Bladeren bron

fix: restore analysis wrong-list flow and align mastery presentation

yemeishu 2 weken geleden
bovenliggende
commit
1652b2a2a2

+ 3 - 0
app/DTO/ExamAnalysisDataDto.php

@@ -17,6 +17,7 @@ class ExamAnalysisDataDto
         public readonly array $parentMasteryLevels, // 新增:父节点掌握度数据
         public readonly array $insights,
         public readonly array $recommendations,
+        public readonly array $rawAnalysisData = [],
         public readonly ?string $analysisId = null
     ) {}
 
@@ -34,6 +35,7 @@ class ExamAnalysisDataDto
             parentMasteryLevels: $data['parent_mastery_levels'] ?? [], // 新增:父节点掌握度数据
             insights: $data['insights'] ?? [],
             recommendations: $data['recommendations'] ?? [],
+            rawAnalysisData: $data['analysis_data'] ?? [],
             analysisId: $data['analysis_id'] ?? $data['analysisId'] ?? null,
         );
     }
@@ -52,6 +54,7 @@ class ExamAnalysisDataDto
             'parent_mastery_levels' => $this->parentMasteryLevels, // 新增:父节点掌握度数据
             'insights' => $this->insights,
             'recommendations' => $this->recommendations,
+            'analysis_data' => $this->rawAnalysisData,
             'analysis_id' => $this->analysisId,
         ];
     }

+ 2 - 1
app/DTO/ReportPayloadDto.php

@@ -34,7 +34,8 @@ class ReportPayloadDto
             parentMasteryLevels: $dto->parentMasteryLevels, // 新增:父节点掌握度数据
             questionInsights: $dto->insights,
             recommendations: $dto->recommendations,
-            analysisData: $dto->toArray()
+            // 必须透传原始 analysis_data,模板依赖 question_analysis/knowledge_point_analysis 原始结构
+            analysisData: $dto->rawAnalysisData
         );
     }
 

+ 59 - 92
app/Services/ExamAnswerAnalysisService.php

@@ -1172,14 +1172,38 @@ class ExamAnswerAnalysisService
         $analysis = [];
 
         foreach ($knowledgeMasteryVector as $kpId => $data) {
+            $totalAttempts = intval($data['total_attempts'] ?? 0);
+            $correctAttempts = intval($data['correct_attempts'] ?? 0);
+            $examAccuracyRatio = $totalAttempts > 0 ? ($correctAttempts / $totalAttempts) : null;
+            $historicalMastery = floatval($data['mastery'] ?? 0);
+
+            // 收敛规则(最小影响):
+            // 仅调整“报告展示口径”,不改 student_knowledge_mastery 的历史累计值。
+            // 当次有错题时,采用“历史掌握度 + 当次正确率”的证据加权融合:
+            //   display = (historical*PRIOR_WEIGHT + examAccuracy*evidenceWeight) / (PRIOR_WEIGHT + evidenceWeight)
+            // 这样避免一次异常把 100% 直接打到 0%,同时保证有错会下调。
+            $displayMastery = $historicalMastery;
+            if ($examAccuracyRatio !== null && $correctAttempts < $totalAttempts) {
+                $priorWeight = 8.0; // 历史先验权重(固定,保证历史连续性)
+                $evidenceWeight = max(1.0, min(10.0, floatval($totalAttempts))); // 当次证据权重(随作答量提升)
+                $displayMastery = (
+                    ($historicalMastery * $priorWeight) + ($examAccuracyRatio * $evidenceWeight)
+                ) / ($priorWeight + $evidenceWeight);
+            }
+
+            $displayData = $data;
+            $displayData['mastery'] = round($displayMastery, 4);
+
             // 【公司要求】移除置信度,只保留核心掌握度分析
             $analysis[] = [
                 'kp_id' => $kpId,
-                'mastery_level' => $data['mastery'],
-                'performance_in_exam' => $this->evaluatePerformanceLevel($data['mastery']),
+                'mastery_level' => round($displayMastery, 4),
+                'historical_mastery_level' => round($historicalMastery, 4),
+                'exam_accuracy_rate' => $examAccuracyRatio !== null ? round($examAccuracyRatio, 4) : null,
+                'performance_in_exam' => $this->evaluatePerformanceLevel($displayMastery),
                 'evidence_count' => count($data['step_details']),
                 'step_evidence' => $data['step_details'],
-                'recommendation' => $this->generateKnowledgePointRecommendation($data),
+                'recommendation' => $this->generateKnowledgePointRecommendation($displayData),
             ];
         }
 
@@ -1612,51 +1636,21 @@ class ExamAnswerAnalysisService
 
         $now = now();
         $updated = 0;
-
-        $questionIds = [];
-        foreach ($examData['questions'] as $question) {
-            $questionId = $question['question_id'] ?? $question['question_bank_id'] ?? null;
-            if (!empty($questionId)) {
-                $questionIds[] = (string) $questionId;
-            }
-        }
-        $questionIds = array_values(array_unique($questionIds));
-        if (empty($questionIds)) {
-            return;
-        }
-
-        $paperQuestionRows = DB::connection('mysql')->table('paper_questions')
-            ->where('paper_id', $paperId)
-            ->where(function ($q) use ($questionIds) {
-                $q->whereIn('question_bank_id', $questionIds)
-                    ->orWhereIn('question_id', $questionIds);
-            })
-            ->get(['id', 'question_bank_id', 'question_id']);
-
-        $rowIdsByQuestionId = [];
-        foreach ($paperQuestionRows as $row) {
-            if (!empty($row->question_bank_id)) {
-                $rowIdsByQuestionId[(string) $row->question_bank_id][] = (int) $row->id;
-            }
-            if (!empty($row->question_id)) {
-                $rowIdsByQuestionId[(string) $row->question_id][] = (int) $row->id;
-            }
-        }
-
-        $updateRowsById = [];
+        $processedQuestionIds = [];
         foreach ($examData['questions'] as $question) {
             $questionId = $question['question_id'] ?? $question['question_bank_id'] ?? null;
             if (empty($questionId)) {
                 continue;
             }
             $questionId = (string) $questionId;
-            $targetRowIds = $rowIdsByQuestionId[$questionId] ?? [];
-            if (empty($targetRowIds)) {
+            // 同一题只更新一次,避免双字段命中导致重复更新
+            if (isset($processedQuestionIds[$questionId])) {
                 continue;
             }
+            $processedQuestionIds[$questionId] = true;
 
             $isCorrectArray = $question['is_correct'] ?? [];
-            if (!is_array($isCorrectArray)) {
+            if (! is_array($isCorrectArray)) {
                 $isCorrectArray = [$isCorrectArray ? 1 : 0];
             }
             $totalSteps = count($isCorrectArray);
@@ -1665,63 +1659,35 @@ class ExamAnswerAnalysisService
             $isFullyCorrect = $totalSteps > 0 ? ($correctSteps === $totalSteps ? 1 : 0) : null;
             $scoreObtained = $question['score_obtained'] ?? null;
 
-            foreach ($targetRowIds as $rowId) {
-                $payload = [
-                    'id' => $rowId,
-                    'graded_at' => $now,
-                ];
-                if ($isFullyCorrect !== null) {
-                    $payload['is_correct'] = $isFullyCorrect;
-                }
-                if ($scoreRatio !== null) {
-                    $payload['score_ratio'] = $scoreRatio;
-                }
-                if ($scoreObtained !== null) {
-                    $payload['score_obtained'] = $scoreObtained;
-                }
-                if (array_key_exists('student_answer', $question)) {
-                    $payload['student_answer'] = $question['student_answer'];
-                }
-                if (array_key_exists('teacher_comment', $question)) {
-                    $payload['teacher_comment'] = $question['teacher_comment'];
-                }
-                $updateRowsById[$rowId] = $payload;
+            $payload = [
+                'graded_at' => $now,
+            ];
+            if ($isFullyCorrect !== null) {
+                $payload['is_correct'] = $isFullyCorrect;
             }
-        }
-
-        if (!empty($updateRowsById)) {
-            $updateRows = array_values($updateRowsById);
-            $fields = ['graded_at', 'is_correct', 'score_ratio', 'score_obtained', 'student_answer', 'teacher_comment'];
-            $cases = [];
-            $bindings = [];
-
-            foreach ($fields as $field) {
-                $caseSql = "`{$field}` = CASE `id`";
-                $hasValue = false;
-                foreach ($updateRows as $row) {
-                    if (!array_key_exists($field, $row)) {
-                        continue;
-                    }
-                    $hasValue = true;
-                    $caseSql .= ' WHEN ? THEN ?';
-                    $bindings[] = $row['id'];
-                    $bindings[] = $row[$field];
-                }
-                $caseSql .= " ELSE `{$field}` END";
-                if ($hasValue) {
-                    $cases[] = $caseSql;
-                }
+            if ($scoreRatio !== null) {
+                $payload['score_ratio'] = $scoreRatio;
             }
-
-            if (!empty($cases)) {
-                $ids = array_column($updateRows, 'id');
-                $idPlaceholders = implode(',', array_fill(0, count($ids), '?'));
-                $sql = 'UPDATE `paper_questions` SET '.implode(', ', $cases)." WHERE `id` IN ({$idPlaceholders})";
-                foreach ($ids as $id) {
-                    $bindings[] = $id;
-                }
-                $updated = DB::connection('mysql')->update($sql, $bindings);
+            if ($scoreObtained !== null) {
+                $payload['score_obtained'] = $scoreObtained;
             }
+            if (array_key_exists('student_answer', $question)) {
+                $payload['student_answer'] = $question['student_answer'];
+            }
+            if (array_key_exists('teacher_comment', $question)) {
+                $payload['teacher_comment'] = $question['teacher_comment'];
+            }
+
+            // 关键:不再依赖 paper_questions.id(部分库该字段为空),改为业务键匹配更新
+            $affected = DB::connection('mysql')->table('paper_questions')
+                ->where('paper_id', $paperId)
+                ->where(function ($q) use ($questionId) {
+                    $q->where('question_bank_id', $questionId)
+                        ->orWhere('question_id', $questionId);
+                })
+                ->update($payload);
+
+            $updated += $affected;
         }
 
         if ($updated > 0) {
@@ -1737,6 +1703,7 @@ class ExamAnswerAnalysisService
         Log::info('paper_questions 判分同步完成', [
             'paper_id' => $paperId,
             'input_question_count' => count($examData['questions']),
+            'processed_question_count' => count($processedQuestionIds),
             'updated_rows' => $updated,
         ]);
     }

+ 65 - 32
app/Services/ExamPdfExportService.php

@@ -762,6 +762,7 @@ class ExamPdfExportService
         ]);
 
         $fullMasteryMap = [];
+        $snapshotMasteryData = [];
 
         if (! empty($analysisData['knowledge_point_analysis'])) {
             // 将knowledge_point_analysis转换为buildMasterySummary期望的格式
@@ -857,32 +858,26 @@ class ExamPdfExportService
                     $relevantChildren = array_intersect($examKpCodes, $childNodes);
 
                     if (! empty($relevantChildren)) {
-                        // 父节点变化值优先使用父节点自身快照(与父节点当前值同口径)
-                        $parentCurrentFromSnapshot = $snapshotMasteryData[$parentKpCode]['current_mastery'] ?? null;
-                        $parentPreviousFromSnapshot = $snapshotMasteryData[$parentKpCode]['previous_mastery'] ?? null;
-                        $parentDeltaFromSnapshot = null;
-                        if ($parentCurrentFromSnapshot !== null && $parentPreviousFromSnapshot !== null) {
-                            $parentDeltaFromSnapshot = floatval($parentCurrentFromSnapshot) - floatval($parentPreviousFromSnapshot);
+                        // 口径统一:父节点掌握度 = 全部直接子节点(含未命中,缺失按0)均值
+                        $childCurrentLevels = [];
+                        $childPreviousLevels = [];
+                        foreach ($childNodes as $childKpCode) {
+                            $currentChild = floatval($fullMasteryMap[$childKpCode] ?? 0);
+                            $childCurrentLevels[] = $currentChild;
+
+                            $prevFromSnapshot = $snapshotMasteryData[$childKpCode]['previous_mastery'] ?? null;
+                            $currFromSnapshot = $snapshotMasteryData[$childKpCode]['current_mastery'] ?? null;
+                            $previousChild = $prevFromSnapshot ?? $currFromSnapshot ?? $currentChild;
+                            $childPreviousLevels[] = floatval($previousChild);
                         }
 
-                        // 次级兜底:若父节点快照缺失,才退回“命中子节点变化均值”
-                        $childChanges = [];
-                        foreach ($relevantChildren as $childKpCode) {
-                            $previousChild = $previousMasteryData[$childKpCode]['previous_mastery'] ?? null;
-                            $currentChild = null;
-                            foreach ($masteryData as $item) {
-                                if ($item['kp_code'] === $childKpCode) {
-                                    $currentChild = $item['mastery_level'];
-                                    break;
-                                }
-                            }
-                            if ($previousChild !== null && $currentChild !== null) {
-                                $childChanges[] = floatval($currentChild) - floatval($previousChild);
-                            }
-                        }
-                        $avgChildChange = ! empty($childChanges) ? array_sum($childChanges) / count($childChanges) : null;
-                        $finalParentChange = $parentDeltaFromSnapshot ?? $avgChildChange;
-                        $finalParentMastery = $parentCurrentFromSnapshot ?? $parentMastery;
+                        $finalParentMastery = ! empty($childCurrentLevels)
+                            ? array_sum($childCurrentLevels) / count($childCurrentLevels)
+                            : floatval($parentMastery);
+                        $previousParentMastery = ! empty($childPreviousLevels)
+                            ? array_sum($childPreviousLevels) / count($childPreviousLevels)
+                            : $finalParentMastery;
+                        $finalParentChange = $finalParentMastery - $previousParentMastery;
 
                         // 获取父节点中文名称
                         $parentKpInfo = DB::connection('mysql')
@@ -896,7 +891,7 @@ class ExamPdfExportService
                             'mastery_level' => $finalParentMastery,
                             'mastery_percentage' => round($finalParentMastery * 100, 1),
                             'mastery_change' => $finalParentChange,
-                            'change_source' => $parentDeltaFromSnapshot !== null ? 'parent_snapshot' : 'children_average',
+                            'change_source' => 'children_all_average',
                             'children' => $relevantChildren,
                         ];
                     }
@@ -1040,7 +1035,7 @@ class ExamPdfExportService
 
         // 【修复】处理父节点掌握度数据:过滤零值、转换名称、构建层级关系
         $examKpCodes = array_column($masteryData, 'kp_code'); // 本次考试涉及的知识点
-        $processedParentMastery = $this->processParentMasteryLevels($parentMasteryLevels, $kpNameMap, $examKpCodes, $masteryMap);
+        $processedParentMastery = $this->processParentMasteryLevels($parentMasteryLevels, $kpNameMap, $examKpCodes, $masteryMap, $snapshotMasteryData);
 
         Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
             'raw_count' => count($parentMasteryLevels),
@@ -2229,7 +2224,13 @@ class ExamPdfExportService
      * 2. 将kp_code转换为友好的kp_name
      * 3. 构建父子层级关系(只显示本次考试相关的子节点)
      */
-    private function processParentMasteryLevels(array $parentMasteryLevels, array $kpNameMap, array $examKpCodes = [], array $masteryMap = []): array
+    private function processParentMasteryLevels(
+        array $parentMasteryLevels,
+        array $kpNameMap,
+        array $examKpCodes = [],
+        array $masteryMap = [],
+        array $snapshotMasteryData = []
+    ): array
     {
         $processed = [];
 
@@ -2239,16 +2240,48 @@ class ExamPdfExportService
             $masteryChange = is_array($masteryData) ? ($masteryData['mastery_change'] ?? null) : null;
             $changeSource = is_array($masteryData) ? ($masteryData['change_source'] ?? null) : null;
 
-            // 过滤零值和空值
-            if ($masteryLevel === null || $masteryLevel === 0.0 || $masteryLevel <= 0.001) {
-                continue;
-            }
-
             // 获取友好名称
             $kpName = $kpNameMap[$kpCode] ?? $kpCode;
 
             // 构建父节点数据,包含子节点信息(只显示本次考试相关的)
             $childrenData = $this->getChildKnowledgePoints($kpCode, $kpNameMap, $examKpCodes, $masteryMap);
+            $allChildren = $childrenData['all_children'] ?? [];
+
+            // 口径统一:优先使用全部直接子节点均值作为父节点掌握度
+            if (! empty($allChildren)) {
+                $allLevels = array_map(
+                    fn ($c) => floatval($c['mastery_level'] ?? 0),
+                    $allChildren
+                );
+                $masteryLevel = ! empty($allLevels)
+                    ? array_sum($allLevels) / count($allLevels)
+                    : floatval($masteryLevel);
+
+                if (! empty($snapshotMasteryData)) {
+                    $prevLevels = [];
+                    foreach ($allChildren as $child) {
+                        $childCode = (string) ($child['kp_code'] ?? '');
+                        if ($childCode === '') {
+                            continue;
+                        }
+                        $currentChild = floatval($masteryMap[$childCode] ?? 0);
+                        $prevFromSnapshot = $snapshotMasteryData[$childCode]['previous_mastery'] ?? null;
+                        $currFromSnapshot = $snapshotMasteryData[$childCode]['current_mastery'] ?? null;
+                        $previousChild = $prevFromSnapshot ?? $currFromSnapshot ?? $currentChild;
+                        $prevLevels[] = floatval($previousChild);
+                    }
+                    if (! empty($prevLevels)) {
+                        $masteryChange = $masteryLevel - (array_sum($prevLevels) / count($prevLevels));
+                        $changeSource = 'children_all_average';
+                    }
+                }
+            }
+
+            // 过滤零值和空值(在统一口径重算后再过滤)
+            if ($masteryLevel === null || $masteryLevel === 0.0 || $masteryLevel <= 0.001) {
+                continue;
+            }
+
             $hitLevels = array_map(
                 fn ($c) => floatval($c['mastery_level'] ?? 0),
                 $childrenData['hit_children'] ?? []

+ 28 - 16
resources/views/exam-analysis/pdf-report.blade.php

@@ -107,12 +107,12 @@
             word-break: break-all;
             line-height: 1.45;
         }
-        .tree-line.hit { font-weight: 700; color: #1f2937; }
+        .tree-line.hit { font-weight: 600; color: #1e40af; }
         .tree-line-badge { display: inline-block; margin-left: 6px; padding: 1px 5px; border-radius: 999px; font-size: 10px; border: 1px solid transparent; }
-        .tree-line-badge.high { background: #ecfdf3; color: #15803d; border-color: #86efac; }
-        .tree-line-badge.mid { background: #fffbeb; color: #b45309; border-color: #fcd34d; }
-        .tree-line-badge.low { background: #f8fafc; color: #475569; border-color: #cbd5e1; }
-        .tree-line-badge.miss { background: #f3f4f6; color: #6b7280; border-color: #d1d5db; }
+        .tree-line-badge.high,
+        .tree-line-badge.mid,
+        .tree-line-badge.low,
+        .tree-line-badge.miss { background: #f8fafc; color: #64748b; border-color: #cbd5e1; }
         .detail-card {
             border: 1px solid #e2e8f0;
             border-radius: 6px;
@@ -185,6 +185,21 @@
         .aggregate-tip { font-size: 11px; color: #475569; margin-top: 6px; }
         .question-card { border:1px solid #e5e7eb; border-radius:8px; padding:6px 9px; margin-bottom:5px; background:#fff; page-break-inside: auto; break-inside: auto; }
         .question-block { margin-bottom: 5px; padding: 5px; border-radius: 4px; page-break-inside: auto; break-inside: auto; }
+        .question-card,
+        .question-card .math-content,
+        .question-card .solution-content {
+            font-size: 12px;
+            line-height: 1.7;
+        }
+        .question-card .math-content img,
+        .question-card img {
+            max-width: 100% !important;
+            width: auto !important;
+            height: auto !important;
+            object-fit: contain;
+            display: block;
+            margin: 6px auto;
+        }
         .solution-content {
             display: block;
             line-height: 1.75;
@@ -292,10 +307,10 @@
                 @endphp
                 <div style="margin-bottom:12px; padding: 8px; border: 1px solid #e5e7eb; border-radius: 6px;">
                     <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 6px;">
-                        <div style="font-weight: 600; font-size: 14px;">{{ $item['kp_name'] ?? $item['kp_code'] ?? '未知知识点' }}</div>
-                        <div style="font-weight: 600; color: {{ $barColor }}; font-size: 14px;">
+                        <div style="font-weight: 600; font-size: 13px;">{{ $item['kp_name'] ?? $item['kp_code'] ?? '未知知识点' }}</div>
+                        <div style="font-weight: 600; color: {{ $barColor }}; font-size: 13px;">
                             {{ number_format($pct, 1) }}%
-                            <span style="margin-left: 8px; color: #666; font-size: 12px;">{{ $changeText ? '子节点变化 ' . $changeText : '' }}</span>
+                            <span style="margin-left: 8px; color: #666; font-size: 11px;">{{ $changeText ? '子节点变化 ' . $changeText : '' }}</span>
                         </div>
                     </div>
                     <div class="progress-wrap" style="height: 12px;">
@@ -358,9 +373,6 @@
                                                 <div class="tree-line {{ $isHit ? 'hit' : '' }}">
                                                     └─ {{ $child['kp_name'] }}
                                                     <span class="tree-line-badge {{ $badgeClass }}">{{ $badgeText }}</span>
-                                                    @if($isHit)
-                                                        <span class="tree-line-badge miss">本次命中</span>
-                                                    @endif
                                                 </div>
                                             @endforeach
                                         </div>
@@ -591,7 +603,7 @@
         @if(!empty($kpWrongStats))
             <div style="margin-bottom:8px; padding:8px; border:1px solid #e5e7eb; border-radius:6px; background:#f8fafc;">
                 <div style="font-size:12px; font-weight:600; color:#111827; margin-bottom:6px;">知识点错误率</div>
-                <div style="font-size:11px; color:#475569; line-height:1.7;">
+                <div style="font-size:12px; color:#475569; line-height:1.7;">
                     @foreach($kpWrongStats as $item)
                         <span class="error-kp-tag {{ $item['rate'] > 0.5 ? 'high-risk' : '' }}">{{ $item['kp_name'] }}:{{ $item['wrong'] }}/{{ $item['total'] }}({{ number_format($item['rate'] * 100, 1) }}%)</span>
                     @endforeach
@@ -695,13 +707,13 @@
                     @endif
                 </div>
 
-                <div class="math-content" style="margin-bottom:6px; font-size:12px;">{!! $questionText !!}</div>
+                <div class="math-content" style="margin-bottom:6px;">{!! $questionText !!}</div>
 
                 {{-- 【修复】正确答案显示 --}}
                 @if(!empty($correctAnswer))
                     <div class="question-block" style="background:#f0fdf4; border-left:3px solid #10b981;">
                         <div style="font-weight:600; font-size:12px; color:#111827; margin-bottom:3px;">正确答案</div>
-                        <div class="math-content" style="font-size:12px; line-height:1.5; color:#374151;">
+                        <div class="math-content" style="line-height:1.7; color:#374151;">
                             {!! is_string($correctAnswer) ? $correctAnswer : json_encode($correctAnswer, JSON_UNESCAPED_UNICODE) !!}
                         </div>
                     </div>
@@ -711,14 +723,14 @@
                 @if(!empty($solution))
                     <div class="question-block" style="margin-top:6px; background:#eff6ff; border-left:3px solid #3b82f6;">
                         <div style="font-weight:600; font-size:12px; color:#111827; margin-bottom:4px;">解题思路</div>
-                        <div class="math-content solution-content" style="font-size:12px; color:#374151;">
+                        <div class="math-content solution-content" style="color:#374151;">
                             {!! nl2br(e(is_array($solution) ? json_encode($solution, JSON_UNESCAPED_UNICODE) : (string) $solution)) !!}
                         </div>
                     </div>
                 @elseif(!empty($analysis) && $analysis !== '暂无解题思路记录')
                     <div class="question-block" style="margin-top:6px; background:#eff6ff; border-left:3px solid #3b82f6;">
                         <div style="font-weight:600; font-size:12px; color:#111827; margin-bottom:4px;">解题思路</div>
-                        <div class="math-content solution-content" style="font-size:12px; color:#374151;">{!! nl2br(e((string) $analysis)) !!}</div>
+                        <div class="math-content solution-content" style="color:#374151;">{!! nl2br(e((string) $analysis)) !!}</div>
                     </div>
                 @endif