Quellcode durchsuchen

feat(report): optimize analysis PDF presentation and recommendation capping

yemeishu vor 2 Wochen
Ursprung
Commit
d6b339ebc2

+ 3 - 0
app/DTO/ExamAnalysisDataDto.php

@@ -11,6 +11,7 @@ class ExamAnalysisDataDto
     public function __construct(
         public readonly array $paper,
         public readonly array $student,
+        public readonly array $teacher,
         public readonly array $questions,
         public readonly array $mastery,
         public readonly array $parentMasteryLevels, // 新增:父节点掌握度数据
@@ -27,6 +28,7 @@ class ExamAnalysisDataDto
         return new self(
             paper: $data['paper'] ?? [],
             student: $data['student'] ?? [],
+            teacher: $data['teacher'] ?? [],
             questions: $data['questions'] ?? [],
             mastery: $data['mastery'] ?? [],
             parentMasteryLevels: $data['parent_mastery_levels'] ?? [], // 新增:父节点掌握度数据
@@ -44,6 +46,7 @@ class ExamAnalysisDataDto
         return [
             'paper' => $this->paper,
             'student' => $this->student,
+            'teacher' => $this->teacher,
             'questions' => $this->questions,
             'mastery' => $this->mastery,
             'parent_mastery_levels' => $this->parentMasteryLevels, // 新增:父节点掌握度数据

+ 3 - 0
app/DTO/ReportPayloadDto.php

@@ -11,6 +11,7 @@ class ReportPayloadDto
     public function __construct(
         public readonly array $paper,
         public readonly array $student,
+        public readonly array $teacher,
         public readonly array $questions,
         public readonly array $mastery,
         public readonly array $parentMasteryLevels, // 新增:父节点掌握度数据
@@ -27,6 +28,7 @@ class ReportPayloadDto
         return new self(
             paper: $dto->paper,
             student: $dto->student,
+            teacher: $dto->teacher,
             questions: $dto->questions,
             mastery: $dto->mastery,
             parentMasteryLevels: $dto->parentMasteryLevels, // 新增:父节点掌握度数据
@@ -44,6 +46,7 @@ class ReportPayloadDto
         return [
             'paper' => $this->paper,
             'student' => $this->student,
+            'teacher' => $this->teacher,
             'questions' => $this->questions,
             'mastery' => $this->mastery,
             'parent_mastery_levels' => $this->parentMasteryLevels, // 新增:父节点掌握度数据

+ 117 - 2
app/Services/ExamAnswerAnalysisService.php

@@ -62,7 +62,39 @@ class ExamAnswerAnalysisService
         $questionMappings = $this->getQuestionKnowledgeMappings($questions);
 
         // 3. 保存答题记录到数据库(复用已查询的知识点映射)
-        $this->saveExamAnswerRecords($examData, $questionMappings);
+        $recordChangeState = $this->saveExamAnswerRecords($examData, $questionMappings);
+        // 同步回写 paper_questions 判分结果,保证 PDF 与分析链路一致
+        $this->syncPaperQuestionGrading($examData);
+
+        // 同卷同答案重复提交:直接复用最近一次分析结果,避免掌握度被重复累计
+        $forceRecalculate = boolval($examData['force_recalculate'] ?? false);
+        if (
+            !$forceRecalculate
+            &&
+            !($recordChangeState['steps_changed'] ?? false)
+            && !($recordChangeState['questions_changed'] ?? false)
+        ) {
+            $existing = DB::connection('mysql')
+                ->table('exam_analysis_results')
+                ->where('student_id', $studentId)
+                ->where('paper_id', $examData['paper_id'])
+                ->orderByDesc('created_at')
+                ->first();
+
+            if ($existing && !empty($existing->analysis_data)) {
+                $existingData = json_decode($existing->analysis_data, true) ?: [];
+                $existingData['reused_existing_analysis'] = true;
+                $existingData['reused_analysis_id'] = $existing->id;
+
+                Log::info('同卷同答案重复提交,复用已有分析结果', [
+                    'student_id' => $studentId,
+                    'paper_id' => $examData['paper_id'],
+                    'analysis_id' => $existing->id,
+                ]);
+
+                return $existingData;
+            }
+        }
 
         // 【公司要求】4. 计算每个知识点的加权掌握度(传入学案基准难度)
         // 核心算法:难度映射 → 权重计算 → 数值更新(newMastery = oldMastery + change)
@@ -1360,7 +1392,7 @@ class ExamAnswerAnalysisService
      * @param  array  $examData  考试数据
      * @param  array  $questionMappings  已批量查询的知识点映射,避免N+1
      */
-    private function saveExamAnswerRecords(array $examData, array $questionMappings = []): void
+    private function saveExamAnswerRecords(array $examData, array $questionMappings = []): array
     {
         // 【修复】确保类型正确,避免 SQL 类型转换问题
         $studentId = (string) $examData['student_id'];
@@ -1561,6 +1593,89 @@ class ExamAnswerAnalysisService
             'total_questions' => count($examData['questions']),
             'steps_saved' => count($stepsToInsert),
             'questions_saved' => count($questionsToInsert),
+            'steps_changed' => $stepsChanged,
+            'questions_changed' => $questionsChanged,
+        ]);
+
+        return [
+            'steps_changed' => $stepsChanged,
+            'questions_changed' => $questionsChanged,
+        ];
+    }
+
+    /**
+     * 同步更新 paper_questions 的判分字段
+     * 适配 /api/exam-answer-analysis:保持题目判分与分析数据一致
+     */
+    private function syncPaperQuestionGrading(array $examData): void
+    {
+        $paperId = (string) ($examData['paper_id'] ?? '');
+        if ($paperId === '' || empty($examData['questions']) || !is_array($examData['questions'])) {
+            return;
+        }
+
+        $now = now();
+        $updated = 0;
+
+        foreach ($examData['questions'] as $question) {
+            $questionId = $question['question_id'] ?? $question['question_bank_id'] ?? null;
+            if (empty($questionId)) {
+                continue;
+            }
+
+            $isCorrectArray = $question['is_correct'] ?? [];
+            if (!is_array($isCorrectArray)) {
+                $isCorrectArray = [$isCorrectArray ? 1 : 0];
+            }
+            $totalSteps = count($isCorrectArray);
+            $correctSteps = array_sum(array_map(fn ($v) => (int) $v === 1 ? 1 : 0, $isCorrectArray));
+            $scoreRatio = $totalSteps > 0 ? ($correctSteps / $totalSteps) : null;
+            $isFullyCorrect = $totalSteps > 0 ? ($correctSteps === $totalSteps) : null;
+            $scoreObtained = $question['score_obtained'] ?? null;
+
+            $query = 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);
+                });
+
+            $payload = [
+                'graded_at' => $now,
+            ];
+            if ($isFullyCorrect !== null) {
+                $payload['is_correct'] = $isFullyCorrect ? 1 : 0;
+            }
+            if ($scoreRatio !== null) {
+                $payload['score_ratio'] = round($scoreRatio, 4);
+            }
+            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'];
+            }
+
+            $updated += $query->update($payload);
+        }
+
+        if ($updated > 0) {
+            DB::connection('mysql')->table('papers')
+                ->where('paper_id', $paperId)
+                ->update([
+                    'status' => 'completed',
+                    'completed_at' => $now,
+                    'updated_at' => $now,
+                ]);
+        }
+
+        Log::info('paper_questions 判分同步完成', [
+            'paper_id' => $paperId,
+            'input_question_count' => count($examData['questions']),
+            'updated_rows' => $updated,
         ]);
     }
 

+ 131 - 29
app/Services/ExamPdfExportService.php

@@ -273,9 +273,14 @@ class ExamPdfExportService
                 return null;
             }
 
-            // 保存PDF
-            $version = time();
-            $path = "analysis_reports/{$paperId}_{$studentId}_{$version}.pdf";
+            // 保存PDF(命名统一:姓名_分析报告_卷子id_卷子类型_时间戳)
+            $studentName = (string) ($templateData['student']['name'] ?? $studentId);
+            $paperCode = PaperNaming::extractExamCode((string) ($templateData['paper']['id'] ?? $paperId));
+            $assembleTypeLabel = (string) ($templateData['paper']['assemble_type_label'] ?? '未知类型');
+            $stamp = now()->format('YmdHis') . strtoupper(Str::random(4));
+            $analysisBase = "{$studentName}_分析报告_{$paperCode}_{$assembleTypeLabel}_{$stamp}";
+            $safeAnalysisFile = PaperNaming::toSafeFilename($analysisBase) . '.pdf';
+            $path = "analysis_reports/{$safeAnalysisFile}";
             $url = $this->pdfStorageService->put($path, $pdfBinary);
             if (! $url) {
                 Log::error('ExamPdfExportService: 保存学情PDF失败', ['path' => $path]);
@@ -585,6 +590,11 @@ class ExamPdfExportService
 
             $studentModel = \App\Models\Student::find($paper->student_id);
             $teacherModel = \App\Models\Teacher::find($paper->teacher_id);
+            if (! $teacherModel && ! empty($paper->teacher_id)) {
+                $teacherModel = \App\Models\Teacher::query()
+                    ->where('teacher_id', $paper->teacher_id)
+                    ->first();
+            }
             $student = ['name' => $studentModel->name ?? ($paper->student_id ?? '________'), 'grade' => $studentModel->grade ?? '________'];
             $teacher = ['name' => $teacherModel->name ?? ($paper->teacher_id ?? '________')];
             $examCode = PaperNaming::extractExamCode((string) $paper->paper_id);
@@ -675,6 +685,15 @@ class ExamPdfExportService
             'grade' => $student?->grade ?? '未知年级',
             'class' => $student?->class_name ?? '未知班级',
         ];
+        $teacherInfo = $this->getTeacherInfo((string) ($paper->teacher_id ?? ''));
+        $assembleType = ($paper->paper_type === null || $paper->paper_type === '')
+            ? null
+            : (int) $paper->paper_type;
+        try {
+            $assembleTypeLabel = $assembleType !== null ? PaperNaming::assembleTypeLabel($assembleType) : '未知类型';
+        } catch (\Throwable $e) {
+            $assembleTypeLabel = '未知类型';
+        }
 
         // 【修改】直接从本地数据库获取分析数据(不再调用API)
         $analysisData = [];
@@ -716,6 +735,7 @@ class ExamPdfExportService
             $analysisRecord = \DB::table('exam_analysis_results')
                 ->where('paper_id', $paperId)
                 ->where('student_id', $studentId)
+                ->orderByDesc('created_at')
                 ->first();
 
             if ($analysisRecord && ! empty($analysisRecord->analysis_data)) {
@@ -741,6 +761,8 @@ class ExamPdfExportService
             'has_knowledge_point_analysis' => ! empty($analysisData['knowledge_point_analysis']),
         ]);
 
+        $fullMasteryMap = [];
+
         if (! empty($analysisData['knowledge_point_analysis'])) {
             // 将knowledge_point_analysis转换为buildMasterySummary期望的格式
             foreach ($analysisData['knowledge_point_analysis'] as $kp) {
@@ -770,9 +792,15 @@ class ExamPdfExportService
                     ->first();
 
                 $previousMasteryData = [];
+                $snapshotMasteryData = [];
                 if ($lastSnapshot) {
                     $previousMasteryJson = json_decode($lastSnapshot->mastery_data, true);
                     foreach ($previousMasteryJson as $kpCode => $data) {
+                        $snapshotMasteryData[$kpCode] = [
+                            'current_mastery' => isset($data['current_mastery']) ? floatval($data['current_mastery']) : null,
+                            'previous_mastery' => isset($data['previous_mastery']) ? floatval($data['previous_mastery']) : null,
+                            'change' => isset($data['change']) ? floatval($data['change']) : null,
+                        ];
                         $previousMasteryData[$kpCode] = [
                             'current_mastery' => $data['current_mastery'] ?? 0,
                             'previous_mastery' => $data['previous_mastery'] ?? null,
@@ -787,8 +815,8 @@ class ExamPdfExportService
                 // 为当前知识点添加变化数据
                 foreach ($masteryData as &$item) {
                     $kpCode = $item['kp_code'];
-                    if (isset($previousMasteryData[$kpCode])) {
-                        $previous = floatval($previousMasteryData[$kpCode]['previous_mastery'] ?? 0);
+                    if (isset($previousMasteryData[$kpCode]) && $previousMasteryData[$kpCode]['previous_mastery'] !== null) {
+                        $previous = floatval($previousMasteryData[$kpCode]['previous_mastery']);
                         $current = floatval($item['mastery_level']);
                         $item['mastery_change'] = $current - $previous;
                     }
@@ -798,6 +826,20 @@ class ExamPdfExportService
                 // 获取所有父节点掌握度
                 $masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
                 $allParentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? [];
+                $overviewDetails = $masteryOverview['details'] ?? [];
+                foreach ($overviewDetails as $detail) {
+                    if (is_object($detail)) {
+                        $code = $detail->kp_code ?? null;
+                        if ($code) {
+                            $fullMasteryMap[$code] = floatval($detail->mastery_level ?? 0);
+                        }
+                    } elseif (is_array($detail)) {
+                        $code = $detail['kp_code'] ?? null;
+                        if ($code) {
+                            $fullMasteryMap[$code] = floatval($detail['mastery_level'] ?? 0);
+                        }
+                    }
+                }
 
                 // 计算与本次考试相关的父节点掌握度(基于所有兄弟节点)
                 $parentMasteryLevels = [];
@@ -815,7 +857,15 @@ 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);
+                        }
+
+                        // 次级兜底:若父节点快照缺失,才退回“命中子节点变化均值”
                         $childChanges = [];
                         foreach ($relevantChildren as $childKpCode) {
                             $previousChild = $previousMasteryData[$childKpCode]['previous_mastery'] ?? null;
@@ -830,7 +880,9 @@ class ExamPdfExportService
                                 $childChanges[] = floatval($currentChild) - floatval($previousChild);
                             }
                         }
-                        $avgChange = ! empty($childChanges) ? array_sum($childChanges) / count($childChanges) : null;
+                        $avgChildChange = ! empty($childChanges) ? array_sum($childChanges) / count($childChanges) : null;
+                        $finalParentChange = $parentDeltaFromSnapshot ?? $avgChildChange;
+                        $finalParentMastery = $parentCurrentFromSnapshot ?? $parentMastery;
 
                         // 获取父节点中文名称
                         $parentKpInfo = DB::connection('mysql')
@@ -841,9 +893,10 @@ class ExamPdfExportService
                         $parentMasteryLevels[$parentKpCode] = [
                             'kp_code' => $parentKpCode,
                             'kp_name' => $parentKpInfo->name ?? $parentKpCode,
-                            'mastery_level' => $parentMastery,
-                            'mastery_percentage' => round($parentMastery * 100, 1),
-                            'mastery_change' => $avgChange,
+                            'mastery_level' => $finalParentMastery,
+                            'mastery_percentage' => round($finalParentMastery * 100, 1),
+                            'mastery_change' => $finalParentChange,
+                            'change_source' => $parentDeltaFromSnapshot !== null ? 'parent_snapshot' : 'children_average',
                             'children' => $relevantChildren,
                         ];
                     }
@@ -878,8 +931,9 @@ class ExamPdfExportService
                 if (! empty($masteryData) && is_array($masteryData)) {
                     $masteryData = array_map(function ($item) {
                         if (is_object($item)) {
+                            $kpCode = $item->kp_code ?? null;
                             return [
-                                'kp_code' => $item->kp_code ?? null,
+                                'kp_code' => $kpCode,
                                 'kp_name' => $item->kp_name ?? null,
                                 'mastery_level' => floatval($item->mastery_level ?? 0),
                                 'mastery_change' => $item->mastery_change !== null ? floatval($item->mastery_change) : null,
@@ -890,6 +944,13 @@ class ExamPdfExportService
                     }, $masteryData);
                 }
 
+                foreach ($masteryData as $m) {
+                    $code = $m['kp_code'] ?? null;
+                    if ($code) {
+                        $fullMasteryMap[$code] = floatval($m['mastery_level'] ?? 0);
+                    }
+                }
+
                 // 【修复】获取快照数据以计算掌握度变化
                 $lastSnapshot = DB::connection('mysql')
                     ->table('knowledge_point_mastery_snapshots')
@@ -966,9 +1027,20 @@ class ExamPdfExportService
             'masterySummary_items_sample' => ! empty($masterySummary['items']) ? array_slice($masterySummary['items'], 0, 2) : [],
         ]);
 
+        // 构建当前学生掌握度映射,供父子影响分析展示使用
+        $masteryMap = !empty($fullMasteryMap) ? $fullMasteryMap : [];
+        if (empty($masteryMap)) {
+            foreach ($masteryData as $m) {
+                $code = $m['kp_code'] ?? null;
+                if ($code) {
+                    $masteryMap[$code] = floatval($m['mastery_level'] ?? 0);
+                }
+            }
+        }
+
         // 【修复】处理父节点掌握度数据:过滤零值、转换名称、构建层级关系
         $examKpCodes = array_column($masteryData, 'kp_code'); // 本次考试涉及的知识点
-        $processedParentMastery = $this->processParentMasteryLevels($parentMasteryLevels, $kpNameMap, $examKpCodes);
+        $processedParentMastery = $this->processParentMasteryLevels($parentMasteryLevels, $kpNameMap, $examKpCodes, $masteryMap);
 
         Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
             'raw_count' => count($parentMasteryLevels),
@@ -980,11 +1052,14 @@ class ExamPdfExportService
             'paper' => [
                 'id' => $paper->paper_id,
                 'name' => $paper->paper_name,
+                'paper_type' => $paper->paper_type,
+                'assemble_type_label' => $assembleTypeLabel,
                 'total_questions' => $paper->question_count,
                 'total_score' => $paper->total_score,
                 'created_at' => $paper->created_at,
             ],
             'student' => $studentInfo,
+            'teacher' => $teacherInfo,
             'questions' => $questions,
             'mastery' => $masterySummary,
             'parent_mastery_levels' => $processedParentMastery, // 【修复】使用处理后的父节点数据
@@ -1081,6 +1156,8 @@ class ExamPdfExportService
                 : ($question->question_text ?? '');
 
             $payload = [
+                'question_id' => $question->question_id ?? null,
+                'question_bank_id' => $question->question_bank_id ?? $question->question_id ?? null,
                 'question_number' => $number,
                 'question_text' => $this->formatNewlines($questionText),  // 格式化换行
                 'question_type' => $normalizedType,
@@ -2152,7 +2229,7 @@ class ExamPdfExportService
      * 2. 将kp_code转换为友好的kp_name
      * 3. 构建父子层级关系(只显示本次考试相关的子节点)
      */
-    private function processParentMasteryLevels(array $parentMasteryLevels, array $kpNameMap, array $examKpCodes = []): array
+    private function processParentMasteryLevels(array $parentMasteryLevels, array $kpNameMap, array $examKpCodes = [], array $masteryMap = []): array
     {
         $processed = [];
 
@@ -2160,6 +2237,7 @@ class ExamPdfExportService
             // 兼容不同数据结构:可能是数组或数字
             $masteryLevel = is_array($masteryData) ? ($masteryData['mastery_level'] ?? 0) : $masteryData;
             $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) {
@@ -2170,14 +2248,27 @@ class ExamPdfExportService
             $kpName = $kpNameMap[$kpCode] ?? $kpCode;
 
             // 构建父节点数据,包含子节点信息(只显示本次考试相关的)
+            $childrenData = $this->getChildKnowledgePoints($kpCode, $kpNameMap, $examKpCodes, $masteryMap);
+            $hitLevels = array_map(
+                fn ($c) => floatval($c['mastery_level'] ?? 0),
+                $childrenData['hit_children'] ?? []
+            );
+            $hitAvg = ! empty($hitLevels) ? array_sum($hitLevels) / count($hitLevels) : null;
+
             $processed[$kpCode] = [
                 'kp_code' => $kpCode,
                 'kp_name' => $kpName,
                 'mastery_level' => round(floatval($masteryLevel), 4),
                 'mastery_percentage' => round(floatval($masteryLevel) * 100, 2),
                 'mastery_change' => $masteryChange !== null ? round(floatval($masteryChange), 4) : null,
-                // 【修复】只获取本次考试涉及的子节点
-                'children' => $this->getChildKnowledgePoints($kpCode, $kpNameMap, $examKpCodes),
+                'change_source' => $changeSource,
+                // 兼容旧模板字段:仅命中子节点
+                'children' => $childrenData['hit_children'] ?? [],
+                // 新增:全部直接子节点(含掌握度、是否命中)
+                'children_all' => $childrenData['all_children'] ?? [],
+                'children_hit_count' => count($childrenData['hit_children'] ?? []),
+                'children_total_count' => count($childrenData['all_children'] ?? []),
+                'children_hit_avg_mastery' => $hitAvg !== null ? round($hitAvg, 4) : null,
                 'level' => $this->calculateKnowledgePointLevel($kpCode),
             ];
         }
@@ -2193,9 +2284,10 @@ class ExamPdfExportService
     /**
      * 【修复】获取子知识点列表(只返回本次考试涉及的)
      */
-    private function getChildKnowledgePoints(string $parentKpCode, array $kpNameMap, array $examKpCodes = []): array
+    private function getChildKnowledgePoints(string $parentKpCode, array $kpNameMap, array $examKpCodes = [], array $masteryMap = []): array
     {
-        $children = [];
+        $allChildren = [];
+        $hitChildren = [];
 
         try {
             $childCodes = DB::connection('mysql')
@@ -2205,12 +2297,16 @@ class ExamPdfExportService
                 ->toArray();
 
             foreach ($childCodes as $childCode) {
-                // 只包含本次考试涉及的知识点
-                if (in_array($childCode, $examKpCodes)) {
-                    $children[] = [
-                        'kp_code' => $childCode,
-                        'kp_name' => $kpNameMap[$childCode] ?? $childCode,
-                    ];
+                $isHit = in_array($childCode, $examKpCodes);
+                $childData = [
+                    'kp_code' => $childCode,
+                    'kp_name' => $kpNameMap[$childCode] ?? $childCode,
+                    'mastery_level' => floatval($masteryMap[$childCode] ?? 0),
+                    'is_hit' => $isHit,
+                ];
+                $allChildren[] = $childData;
+                if ($isHit) {
+                    $hitChildren[] = $childData;
                 }
             }
         } catch (\Exception $e) {
@@ -2220,7 +2316,10 @@ class ExamPdfExportService
             ]);
         }
 
-        return $children;
+        return [
+            'all_children' => $allChildren,
+            'hit_children' => $hitChildren,
+        ];
     }
 
     /**
@@ -2367,13 +2466,16 @@ class ExamPdfExportService
         }
 
         try {
-            $teacher = DB::table('teachers')
-                ->where('teacher_id', $teacherId)
-                ->first();
+            $query = DB::table('teachers')->where('teacher_id', $teacherId);
+            // 仅在列存在时追加,避免 Unknown column 'id'
+            if (Schema::hasColumn('teachers', 'id')) {
+                $query->orWhere('id', $teacherId);
+            }
+            $teacher = $query->first();
 
             if ($teacher) {
                 return [
-                    'name' => $teacher->name ?? $teacherId,
+                    'name' => $teacher->name ?? '________',
                     'subject' => $teacher->subject ?? '数学',
                 ];
             }
@@ -2385,7 +2487,7 @@ class ExamPdfExportService
         }
 
         return [
-            'name' => $teacherId,
+            'name' => '________',
             'subject' => '数学',
         ];
     }

+ 44 - 19
app/Services/MasteryCalculator.php

@@ -29,6 +29,10 @@ class MasteryCalculator
      * 最小正确率要求
      */
     private const MIN_CORRECT_RATE = 0.60;
+    /**
+     * 0基础场景下的正向增益限制阈值(正确率低于该值时触发)
+     */
+    private const BASE_ZERO_GAIN_GUARD_CORRECT_RATE = 0.60;
 
     /**
      * 计算学生对指定知识点的掌握度
@@ -36,7 +40,7 @@ class MasteryCalculator
      * @param string $studentId 学生ID
      * @param string $kpCode 知识点编码
      * @param array|null $attempts 答题记录(可选,默认从数据库查询)
-     * @param int|null $examBaseDifficulty 学案基准难度(1-4级,1=筑基, 2=提分, 3=培优, 4=竞赛)
+     * @param int|null $examBaseDifficulty 学案基准难度(0-4级,0=0基础, 1=筑基, 2=提分, 3=培优, 4=竞赛)
      * @return array 返回['mastery' => 掌握度, 'confidence' => 置信度, 'trend' => 趋势]
      */
     public function calculateMasteryLevel(string $studentId, string $kpCode, ?array $attempts = null, ?int $examBaseDifficulty = null): array
@@ -93,6 +97,7 @@ class MasteryCalculator
     private function getDifficultyName(int $difficultyLevel): string
     {
         return match ($difficultyLevel) {
+            0 => '0基础',
             1 => '筑基',
             2 => '提分',
             3 => '培优',
@@ -118,6 +123,14 @@ class MasteryCalculator
         $totalAttempts = count($attempts);
         $correctAttempts = 0;
         $incorrectAttempts = 0;
+        foreach ($attempts as $attempt) {
+            if (boolval($attempt['is_correct'] ?? false)) {
+                $correctAttempts++;
+            } else {
+                $incorrectAttempts++;
+            }
+        }
+        $accuracyRate = $totalAttempts > 0 ? ($correctAttempts / $totalAttempts) : 0.0;
 
         // 计算每次答题的权重变化
         $totalChange = 0.0;
@@ -132,22 +145,26 @@ class MasteryCalculator
             // 根据难度关系计算权重变化
             $change = $this->calculateWeightByDifficultyRelation($questionLevel, $examBaseDifficulty, $isCorrect);
 
-            $totalChange += $change;
-
-            if ($isCorrect) {
-                $correctAttempts++;
-            } else {
-                $incorrectAttempts++;
+            // 0基础保护:当次正确率偏低时,限制正向增益,避免“错很多但掌握度持续贴近100%”
+            if (
+                $examBaseDifficulty === 0
+                && $accuracyRate < self::BASE_ZERO_GAIN_GUARD_CORRECT_RATE
+                && $change > 0
+            ) {
+                $change = round($change * 0.5, 4);
             }
 
-            Log::debug('掌握度变化计算', [
+            $totalChange += $change;
+
+        Log::debug('掌握度变化计算', [
                 'question_id' => $attempt['question_id'] ?? '',
                 'question_difficulty' => $questionDifficulty,
                 'question_level' => $questionLevel,
                 'exam_base_difficulty' => $examBaseDifficulty,
                 'is_correct' => $isCorrect,
                 'change' => $change,
-                'running_total' => $totalChange
+                'running_total' => $totalChange,
+                'accuracy_rate' => round($accuracyRate, 4),
             ]);
         }
 
@@ -190,20 +207,23 @@ class MasteryCalculator
     }
 
     /**
-     * 【公司要求】难度映射:将题目中0.0-1.0的浮点数难度映射为1-4等级
+     * 【公司要求】难度映射:将题目中0.0-1.0的浮点数难度映射为0-4等级
      *
      * 公司要求:
-     * 0.0 ~ 0.25 -> 1级(筑基)
-     * 0.25 ~ 0.5 -> 2级(提分)
-     * 0.5 ~ 0.75 -> 3级(培优)
-     * 0.75 ~ 1.0 -> 4级(竞赛)
+     * 0.0 ~ 0.10 -> 0级(0基础)
+     * 0.10 ~ 0.25 -> 1级(筑基)
+     * 0.25 ~ 0.50 -> 2级(提分)
+     * 0.50 ~ 0.75 -> 3级(培优)
+     * 0.75 ~ 1.00 -> 4级(竞赛)
      *
      * @param float $difficulty 题目难度(0.0-1.0浮点数)
-     * @return int 难度等级(1-4级)
+     * @return int 难度等级(0-4级)
      */
     private function mapDifficultyToLevel(float $difficulty): int
     {
-        if ($difficulty >= 0.0 && $difficulty < 0.25) {
+        if ($difficulty >= 0.0 && $difficulty < 0.10) {
+            return 0; // 0级(0基础)
+        } elseif ($difficulty >= 0.10 && $difficulty < 0.25) {
             return 1; // 1级(筑基)
         } elseif ($difficulty >= 0.25 && $difficulty < 0.5) {
             return 2; // 2级(提分)
@@ -224,10 +244,10 @@ class MasteryCalculator
      *
      * 学案基准难度获取:
      * - 来源:试卷表(papers.difficulty_category)
-     * - 映射:筑基→1级,提分→2级,培优→3级,竞赛→4级
+     * - 映射:0基础→0级,筑基→1级,提分→2级,培优→3级,竞赛→4级
      *
-     * @param int $questionLevel 题目难度等级(1-4)
-     * @param int $examBaseDifficulty 学案基准难度(1-4)
+     * @param int $questionLevel 题目难度等级(0-4)
+     * @param int $examBaseDifficulty 学案基准难度(0-4)
      * @param bool $isCorrect 答题是否正确
      * @return float 权重变化值
      */
@@ -235,6 +255,11 @@ class MasteryCalculator
     {
         if ($questionLevel > $examBaseDifficulty) {
             // 越级:题目难度 > 学案基准难度
+            if ($examBaseDifficulty === 0) {
+                // 仅0基础收紧:越级答错惩罚加大
+                return $isCorrect ? 0.15 : -0.10;
+            }
+
             return $isCorrect ? 0.15 : -0.05;
         } elseif ($questionLevel == $examBaseDifficulty) {
             // 适应:题目难度 = 学案基准难度

+ 193 - 0
docs/exam-analysis-report-flow-review-20260311.md

@@ -0,0 +1,193 @@
+# 学情分析报告生成逻辑深度分析(2026-03-11)
+
+## 1. 分析目标与结论摘要
+
+### 目标
+- 梳理“学情分析报告(PDF)”完整流程(入口、数据、算法、渲染、存储)。
+- 定位客户反馈问题:`上一份学案 20% -> 本次 65%,变化值却显示 80%`。
+- 给出功能、体验、算法三个维度的可落地优化方案。
+
+### 核心结论
+1. 当前系统的“变化值”在**子知识点**与**父知识点(层级掌握度)**上存在**口径不一致**。
+2. 父节点当前值来自“全量子节点平均”,但父节点变化值来自“本次考试命中的子节点平均变化”,导致可出现 `当前65%,变化80%` 这种反直觉结果。
+3. 报告中的“变化值”实际是**绝对变化(百分点)**,UI 用 `%` 容易被理解成“相对增长率”,语义也不清晰。
+4. 存在多处“默认 previous=0”的兜底,会在数据缺失时放大变化值。
+
+---
+
+## 2. 端到端流程(当前实现)
+
+## 2.1 入口与任务
+- API 入口:`POST /api/exam-analysis/report`
+- 代码:`app/Http/Controllers/Api/ExamAnalysisApiController.php:24`
+- 控制器调用:`ExamAnalysisService::generateReport(...)`
+- 注意:名义上是“异步任务”,但当前实现是**同步执行模拟异步**:
+  - `app/Services/ExamAnalysisService.php:49`
+
+## 2.2 服务编排
+- `ExamAnalysisService::processReportGeneration(...)` 主要步骤:
+  1. 组装分析数据 `getAnalysisData`
+  2. 生成 PDF `ExamPdfExportService::generateAnalysisReportPdf`
+  3. 保存 URL 到 `student_reports` 或 OCR 记录
+- 代码:`app/Services/ExamAnalysisService.php:117`
+
+## 2.3 PDF 数据构建核心
+- 主入口:`ExamPdfExportService::generateAnalysisReportPdf`
+- 代码:`app/Services/ExamPdfExportService.php:201`
+- 内部调用:`buildAnalysisData($paperId, $studentId)`
+
+### buildAnalysisData 数据源优先级
+1. 试卷与学生基础信息(`papers` / `students`)
+2. 分析结果(`exam_analysis_results.analysis_data`)
+3. 掌握度数据(优先 `analysis_data.knowledge_point_analysis`,否则走 `MasteryCalculator` 概览)
+4. 快照表 `knowledge_point_mastery_snapshots` 用于补充 `previous_mastery`
+5. 题目列表(`paper_questions` + 题库详情)
+
+关键代码:
+- `app/Services/ExamPdfExportService.php:735-923`
+- 报告模板:`resources/views/exam-analysis/pdf-report.blade.php`
+
+---
+
+## 3. “20% -> 65%,变化却80%”问题根因
+
+## 3.1 现象复现逻辑
+报告中“层级掌握度分析”显示的是父节点:
+- 当前掌握度:`65%`
+- 变化值:`↑80%`
+
+用户直觉:如果上次是 `20%`,那这次 `65%`,变化应约 `+45`(百分点),而不是 `+80`。
+
+## 3.2 代码级根因(关键)
+
+### 根因A:父节点“当前值”和“变化值”来自不同口径
+- 父节点当前掌握度来自:`MasteryCalculator::getStudentMasteryOverviewWithHierarchy`(全量子节点聚合)
+  - `app/Services/MasteryCalculator.php:555`
+- 父节点变化值来自:只对“本次考试涉及子节点(relevantChildren)”计算平均变化
+  - `app/Services/ExamPdfExportService.php:815-834`
+
+也就是说:
+- `mastery_level(parent)` = 全兄弟子节点平均
+- `mastery_change(parent)` = 本次命中子节点平均变化
+
+这两个分母不同,数值天然不可直接对比,导致“当前值不大、变化却很大”的感知错误。
+
+### 根因B:变化值展示语义不清
+模板展示逻辑:
+- `changeText = number_format(abs($delta) * 100, 1) . '%'`
+- `resources/views/exam-analysis/pdf-report.blade.php:111-113, 151-153`
+
+这里把 `delta(0~1)` 直接乘100并显示 `%`,但未说明是“百分点变化”。
+用户容易理解成“相对增幅%”,语义冲突。
+
+### 根因C:缺失数据时默认 previous=0,放大变化
+- `previous_mastery` 缺失时被当成 0:
+  - `app/Services/ExamPdfExportService.php:791, 905`
+
+这会把本应“无对比基线”的记录显示成“大幅提升”。
+
+---
+
+## 4. 功能、体验、算法层面的改进建议
+
+## 4.1 功能层(高优先)
+
+1. 统一父节点变化值口径(P0)
+- 方案A(推荐):父节点变化值 = `当前父节点掌握度 - 上次父节点掌握度`
+- 即都按“父节点维度”计算,不再用“命中子节点均值变化”替代。
+- 收益:杜绝 `65% +80%` 类错觉。
+
+2. 基线明确化(P0)
+- 在报告中写明:
+  - `对比基线:上一份已完成学案(时间:xxxx-xx-xx xx:xx)`
+  - 如果无基线:显示 `首次分析,无历史对比`。
+- 禁止 silently fallback 到 0。
+
+3. 无基线不显示变化(P1)
+- `previous_mastery` 缺失时显示 `--`,并附 `无可比历史数据`。
+
+4. 报告数据版本化(P1)
+- 在 `exam_analysis_results` 记录 `metric_version`、`baseline_snapshot_id`。
+- 避免后续算法改动后旧报告口径不可追溯。
+
+## 4.2 体验层(高优先)
+
+1. 文案改造(P0)
+- 当前:`↑ 80.0%`
+- 建议:`↑ 45.0 个百分点`(绝对变化)
+- 若要展示相对增幅,另加一行:`较上次提升 225%`。
+
+2. 在卡片中显示“三元组”
+- `上次 20.0% → 本次 65.0%(+45.0pp)`
+- 直接消除认知歧义。
+
+3. 父节点卡片增加口径说明(P1)
+- 标注:`父节点掌握度=全部子知识点加权/平均`
+- 与“本次命中知识点变化”分开展示。
+
+4. 异常提示(P1)
+- 当 `|delta_pp| > 60` 且题量很少时,标记 `样本偏小,变化波动较大`。
+
+## 4.3 算法层(高优先)
+
+1. 父节点变化值重算(P0)
+- 按父节点历史快照直接对比:
+  - `parent_delta = parent_current - parent_previous`
+- 不再用 `relevantChildren` 的平均变化替代。
+
+2. 子节点变化值稳健化(P1)
+- 增加最小样本约束(如 attempts>=N)才展示变化。
+- 否则显示“趋势待观察”。
+
+3. 变化值双口径并存(P2)
+- `delta_pp`(百分点)
+- `delta_ratio`(相对变化率)
+- 前端默认展示 `delta_pp`,tooltip 展示 `delta_ratio`。
+
+---
+
+## 5. 关键风险点清单
+
+1. 伪异步(同步执行)
+- `generateReport` 目前同步执行耗时任务,峰值时可能拖慢接口。
+- 代码:`app/Services/ExamAnalysisService.php:49`
+
+2. fallback 分支基线不稳定
+- 当没有 `knowledge_point_analysis` 时,取“最新快照(不按 paper_id)”作为对比基线。
+- 代码:`app/Services/ExamPdfExportService.php:894-899`
+- 风险:跨学案串基线。
+
+3. previous 缺失默认0
+- 可能制造虚高变化。
+- 代码:`app/Services/ExamPdfExportService.php:791, 905`
+
+4. 父节点层级计算规则与展示说明不一致
+- 代码注释写“所有兄弟节点历史数据”,实现却是 `relevantChildren`。
+- 代码:`app/Services/ExamPdfExportService.php:818-833`
+
+---
+
+## 6. 建议的落地优先级(两周内)
+
+### 第一优先(本周)
+1. 修正父节点变化值口径统一(P0)
+2. 无基线不显示变化,不再默认 previous=0(P0)
+3. 报告文案改为“百分点变化”(P0)
+
+### 第二优先(下周)
+1. 报告增加“对比基线时间/来源”(P1)
+2. 加样本量阈值与波动提示(P1)
+3. 异步化真正落地(队列任务)(P1)
+
+---
+
+## 7. 对你问题的直接回答
+
+> “是对比上一份学案吗?”
+
+当前实现并不稳定地等价于“上一份学案”:
+- 有的路径用“当前分析快照内的 previous_mastery”(接近上一次状态);
+- 有的路径用“学生最新快照”(可能跨学案);
+- 父节点变化还混用了“本次命中子节点变化”。
+
+所以你看到 `20% -> 65%,却↑80%` 是**代码层面的口径混用问题**,不是你的理解问题。

+ 441 - 91
resources/views/exam-analysis/pdf-report.blade.php

@@ -7,6 +7,8 @@
 
     // 【修复】从insights中获取AI分析结果(而不是从analysis_data)
     $questionAnalysis = $insights ?? [];
+    // 生成时间(格式:2026年01月30日 15:04:05)
+    $generateDateTime = now()->format('Y年m月d日 H:i:s');
 @endphp
 <!DOCTYPE html>
 <html lang="zh-CN">
@@ -17,25 +19,35 @@
     <style>
         @page {
             size: A4;
-            margin: 1.5cm 1.5cm 2cm 1.5cm;
+            margin: 2.2cm 2cm 2.3cm 2cm;
             @top-left {
-                content: "知了数学";
-                font-size: 10px;
+                content: "知了数学·{{ $generateDateTime }}";
+                font-size: 13px;
+                color: #666;
+            }
+            @top-center {
+                content: "{{ $student['name'] ?? '-' }}";
+                font-size: 13px;
                 color: #666;
             }
             @top-right {
                 content: "{{ $reportCode }}";
-                font-size: 10px;
-                color: #666;
+                font-size: 19px;
+                font-weight: 600;
+                font-family: "Noto Sans", "Liberation Sans", "Nimbus Sans", sans-serif;
+                letter-spacing: 0;
+                padding-right: 3mm;
+                padding-top: 1.8mm;
+                color: #222;
             }
             @bottom-left {
                 content: "{{ $reportCode }}";
-                font-size: 10px;
+                font-size: 11px;
                 color: #666;
             }
             @bottom-right {
                 content: counter(page) "/" counter(pages);
-                font-size: 10px;
+                font-size: 13px;
                 color: #666;
             }
         }
@@ -51,9 +63,8 @@
         }
         h1, h2, h3 { margin: 0; color: #000; }
         .card { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 16px; margin-bottom: 12px; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); }
-        .header { text-align: center; margin-bottom: 1rem; border-bottom: 2px solid #000; padding-bottom: 0.5rem; }
-        .school-name { font-size: 24px; font-weight: bold; margin-bottom: 10px; }
-        .paper-title { font-size: 20px; font-weight: bold; margin-bottom: 15px; }
+        .header { text-align: center; margin-bottom: 1.5rem; border-bottom: 2px solid #000; padding-bottom: 1rem; }
+        .paper-title { font-size: 22px; font-weight: bold; margin-bottom: 14px; }
         .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
         .tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; color: #374151; background: #e5e7eb; }
         .section-title { font-size: 16px; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
@@ -68,22 +79,143 @@
         .progress-wrap { background: #f3f4f6; border-radius: 999px; overflow: hidden; height: 10px; }
         .progress-bar { height: 100%; background: linear-gradient(90deg, #4f46e5, #10b981); }
         .recommend-card { border: 1px dashed #cbd5e1; border-radius: 10px; padding: 10px 12px; margin-bottom: 8px; background: #f8fafc; }
+        .relation-board { margin: 6px 0 10px; padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; }
+        .relation-block { margin-bottom: 10px; padding: 8px; border: 1px solid #dbeafe; border-radius: 8px; background: #fff; }
+        .tree-cards { width: 100%; border-collapse: collapse; table-layout: fixed; }
+        .tree-cards td { border: none; padding: 0; vertical-align: top; }
+        .tree-left { width: 45%; padding-right: 10px; }
+        .tree-right { width: 55%; padding-left: 10px; }
+        .tree-parent {
+            display: inline-block;
+            margin-bottom: 6px;
+            padding: 4px 8px;
+            border-radius: 999px;
+            border: 1px solid #93c5fd;
+            background: #dbeafe;
+            color: #1e3a8a;
+            font-weight: 700;
+            font-size: 12px;
+        }
+        .tree-lines { margin: 0; padding-left: 14px; border-left: 2px solid #cbd5e1; }
+        .tree-line {
+            font-size: 11px;
+            color: #334155;
+            margin-bottom: 3px;
+            white-space: normal;
+            overflow: visible;
+            text-overflow: clip;
+            word-break: break-all;
+            line-height: 1.45;
+        }
+        .tree-line.hit { font-weight: 700; color: #1d4ed8; }
+        .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: #fef2f2; color: #b91c1c; border-color: #fca5a5; }
+        .tree-line-badge.miss { background: #f3f4f6; color: #6b7280; border-color: #d1d5db; }
+        .detail-card {
+            border: 1px solid #e2e8f0;
+            border-radius: 6px;
+            background: #f8fafc;
+            padding: 6px 8px;
+            margin-bottom: 5px;
+        }
+        .detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
+        .detail-title { font-size: 11px; color: #0f172a; font-weight: 700; margin-bottom: 2px; }
+        .detail-meta { font-size: 11px; color: #475569; margin-top: 3px; }
+        .detail-mastery { font-size: 38px; color: #0f172a; font-weight: 700; line-height: 1; margin: 0; }
+        .detail-mastery.high { color: #16a34a; }
+        .detail-mastery.mid { color: #d97706; }
+        .detail-mastery.low { color: #dc2626; }
+        .node-tags { margin-top: 2px; }
+        .node-tag {
+            display: inline-block;
+            margin: 0 6px 6px 0;
+            padding: 1px 7px;
+            border-radius: 999px;
+            font-size: 10px;
+            line-height: 1.4;
+            color: #475569;
+            background: #f1f5f9;
+            border: 1px solid #cbd5e1;
+        }
+        .suggest-tags { display: inline; }
+        .suggest-tag {
+            display: inline-block;
+            margin: 0 6px 6px 0;
+            padding: 1px 7px;
+            border-radius: 999px;
+            font-size: 10px;
+            line-height: 1.4;
+            color: #334155;
+            background: #f8fafc;
+            border: 1px solid #d1d5db;
+        }
+        .weak-kp-tags { margin-top: 6px; }
+        .weak-kp-tag {
+            display: inline-block;
+            margin: 0 6px 6px 0;
+            padding: 1px 7px;
+            border-radius: 999px;
+            font-size: 10px;
+            line-height: 1.45;
+            color: #854d0e;
+            background: #fef3c7;
+            border: 1px solid #fcd34d;
+        }
+        .error-kp-tag {
+            display: inline-block;
+            margin: 0 6px 6px 0;
+            padding: 1px 7px;
+            border-radius: 999px;
+            font-size: 10px;
+            line-height: 1.45;
+            color: #334155;
+            background: #f8fafc;
+            border: 1px solid #d1d5db;
+        }
+        .error-kp-tag.high-risk {
+            color: #b91c1c;
+            border-color: #fca5a5;
+            background: #fff;
+            font-weight: 600;
+        }
+        .detail-change-up { color: #16a34a; font-weight: 700; }
+        .detail-change-down { color: #dc2626; font-weight: 700; }
+        .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; }
+        .solution-content {
+            display: inline-block;
+            line-height: 1.75;
+            white-space: normal;
+            word-break: break-word;
+        }
     </style>
 </head>
 <body>
     <div class="page">
         <div class="header">
             <h1 class="paper-title">学情分析报告</h1>
-            <div style="margin-top: 10px; font-size: 14px;">
-                试卷:{{ $paper['name'] ?? '-' }} | 学生:{{ $student['name'] ?? '-' }} | 年级:{{ $student['grade'] ?? '-' }}
-            </div>
-            <div style="margin-top: 6px; font-size: 14px;">
-                题目数:{{ is_array($questions ?? null) ? count($questions) : ($paper['total_questions'] ?? '-') }}
+            @php
+                $teacherName = trim((string) ($teacher['name'] ?? ''));
+                $showTeacher = $teacherName !== '' && $teacherName !== '________' && $teacherName !== '未知老师';
+            @endphp
+            <div style="display:flex;justify-content:space-between;font-size:14px;margin-top:8px;">
+                @if($showTeacher)
+                    <span>老师:{{ $teacherName }}</span>
+                @endif
+                <span>年级:@formatGrade($student['grade'] ?? '________')</span>
+                @if(!empty($paper['assemble_type_label']) && $paper['assemble_type_label'] !== '未知类型')
+                    <span>类型:{{ $paper['assemble_type_label'] }}</span>
+                @endif
+                <span>姓名:{{ $student['name'] ?? '________' }}</span>
+                <span>题目数:{{ $paper['total_questions'] ?? (is_array($questions ?? null) ? count($questions) : '-') }}</span>
             </div>
         </div>
 
     <div class="card">
-        <div class="section-title">知识点掌握度</div>
+        <div class="section-title">本次命中子知识点掌握度</div>
         @php
             // 【修复】过滤掉K-GENERAL等通用知识点,只显示有值的知识点
             $filteredMasteryItems = [];
@@ -117,7 +249,7 @@
                         <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;">
                             {{ number_format($pct, 1) }}%
-                            <span style="margin-left: 8px; color: #666; font-size: 12px;">{{ $changeText }}</span>
+                            <span style="margin-left: 8px; color: #666; font-size: 12px;">{{ $changeText ? '子节点变化 ' . $changeText : '' }}</span>
                         </div>
                     </div>
                     <div class="progress-wrap" style="height: 12px;">
@@ -135,67 +267,177 @@
         @php
             $parentMasteryLevels = $parent_mastery_levels ?? [];
             $hasParentMastery = !empty($parentMasteryLevels);
+            $childMasteryMap = [];
+            foreach (($filteredMasteryItems ?? []) as $it) {
+                $childMasteryMap[$it['kp_code']] = [
+                    'level' => floatval($it['mastery_level'] ?? 0),
+                    'delta' => isset($it['mastery_change']) ? floatval($it['mastery_change']) : null,
+                ];
+            }
         @endphp
 
         @if($hasParentMastery)
-            @foreach($parentMasteryLevels as $parentData)
-                @php
-                    $pct = $parentData['mastery_percentage'] ?? 0;
-                    $barColor = $pct >= 80 ? '#10b981' : ($pct >= 60 ? '#f59e0b' : '#ef4444');
-                    $parentName = $parentData['kp_name'] ?? $parentData['kp_code'];
-                    $children = $parentData['children'] ?? [];
-                    $level = $parentData['level'] ?? 1;
-                    $delta = $parentData['mastery_change'] ?? null;
-                    // 只有当有变化值时才显示变化信息
-                    $changeText = '';
-                    if ($delta !== null && $delta !== '' && abs($delta) > 0.001) {
-                        $changeText = ($delta > 0 ? '↑ ' : '↓ ') . number_format(abs($delta) * 100, 1) . '%';
-                    }
-                @endphp
-                <div style="margin-bottom: 14px; padding: 10px; border: 1px solid #e0f2fe; border-radius: 6px; background: #f8fafc;">
-                    <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
-                        <div style="font-weight: 700; font-size: 15px; color: #0f172a;">
-                            【第{{ $level }}级】{{ $parentName }}
-                            <span style="margin-left: 8px; font-size: 11px; color: #64748b; font-weight: 400;">
-                                ({{ $parentData['kp_code'] }})
-                            </span>
-                        </div>
-                        <div style="font-weight: 700; color: {{ $barColor }}; font-size: 15px;">
-                            {{ number_format($pct, 1) }}%
-                            @if(!empty($changeText))
-                                <span style="margin-left: 8px; color: #666; font-size: 12px;">{{ $changeText }}</span>
-                            @endif
-                        </div>
-                    </div>
-                    <div class="progress-wrap" style="height: 12px; margin-bottom: 8px;">
-                        <div class="progress-bar" style="width: {{ $pct }}%; background: {{ $barColor }};"></div>
+            <div class="relation-board">
+                <div style="font-size:12px; color:#334155; margin-bottom:6px; font-weight:600;">父子知识点关系</div>
+                @foreach($parentMasteryLevels as $parentData)
+                    @php
+                        $childrenAll = $parentData['children_all'] ?? [];
+                        $children = $parentData['children'] ?? []; // 命中子节点
+                        $childCount = count($childrenAll);
+                        $hitCount = count($children);
+                        $hitAvg = $parentData['children_hit_avg_mastery'] ?? null;
+                        $parentPct = number_format(floatval($parentData['mastery_percentage'] ?? 0), 1);
+                        $parentLevel = floatval($parentData['mastery_level'] ?? 0);
+                        $parentClass = $parentLevel >= 0.8 ? 'high' : ($parentLevel >= 0.6 ? 'mid' : 'low');
+                        $delta = $parentData['mastery_change'] ?? null;
+                        $hitNames = array_values(array_map(fn($c) => $c['kp_name'] ?? ($c['kp_code'] ?? ''), $children));
+                        $allChildrenPerfect = !empty($childrenAll) && count(array_filter($childrenAll, function ($c) {
+                            return floatval($c['mastery_level'] ?? 0) >= 0.999;
+                        })) === count($childrenAll);
+                    @endphp
+                    <div class="relation-block">
+                        <table class="tree-cards">
+                            <tr>
+                                <td class="tree-left">
+                                    <div class="tree-parent">{{ $parentData['kp_name'] ?? $parentData['kp_code'] }}</div>
+                                    @if(!empty($childrenAll))
+                                        <div class="tree-lines">
+                                            @foreach($childrenAll as $child)
+                                                @php
+                                                    $isHit = !empty($child['is_hit']);
+                                                    $m = floatval($child['mastery_level'] ?? 0);
+                                                    $badgeClass = $m >= 0.8 ? 'high' : ($m >= 0.6 ? 'mid' : ($m > 0 ? 'low' : 'miss'));
+                                                    $badgeText = number_format($m * 100, 1) . '%';
+                                                @endphp
+                                                <div class="tree-line {{ $isHit ? 'hit' : '' }}">
+                                                    └─ {{ $child['kp_name'] }}
+                                                    <span class="tree-line-badge {{ $badgeClass }}">{{ $badgeText }}</span>
+                                                    @if($isHit)
+                                                        <span class="tree-line-badge high">本次命中</span>
+                                                    @endif
+                                                </div>
+                                            @endforeach
+                                        </div>
+                                    @else
+                                        <div class="muted">无命中子知识点</div>
+                                    @endif
+                                </td>
+                                <td class="tree-right">
+                                    <div class="detail-card">
+                                        <div class="detail-head">
+                                            <div class="detail-title">{{ $parentData['kp_name'] ?? $parentData['kp_code'] }}</div>
+                                            <div class="detail-mastery {{ $parentClass }}">{{ $parentPct }}%</div>
+                                        </div>
+                                        <div class="detail-meta">
+                                            父节点变化:
+                                            @if($delta !== null)
+                                                <span class="{{ $delta >= 0 ? 'detail-change-up' : 'detail-change-down' }}">
+                                                    {{ $delta >= 0 ? '↑ ' : '↓ ' }}{{ number_format(abs($delta) * 100, 1) }}%
+                                                </span>
+                                            @else
+                                                -
+                                            @endif
+                                        </div>
+                                        <div class="detail-meta" style="margin-top: 3px;">
+                                            本次重点子节点:
+                                            @if($hitCount > 0)
+                                                <span class="node-tags">
+                                                    @foreach($hitNames as $hitName)
+                                                        @if(!empty(trim($hitName)))
+                                                            <span class="node-tag">{{ $hitName }}</span>
+                                                        @endif
+                                                    @endforeach
+                                                </span>
+                                            @else
+                                                无
+                                            @endif
+                                        </div>
+                                        <div class="detail-meta">
+                                            子节点总数 {{ $childCount }} 个,本次命中 {{ $hitCount }} 个,命中均值 {{ $hitAvg !== null ? number_format(floatval($hitAvg) * 100, 1) . '%' : '-' }}
+                                        </div>
+                                    </div>
+                                </td>
+                            </tr>
+                        </table>
                     </div>
-                    @if(!empty($children))
-                        <div style="font-size: 12px; color: #475569;">
-                            <strong>包含子知识点({{ count($children) }}个):</strong>
-                            @foreach($children as $index => $child)
-                                @if($index < 8)
-                                    <span style="display:inline-block; margin: 2px 4px; padding: 2px 6px; background: #e0f2fe; border-radius: 3px; font-size: 11px;">
-                                        {{ $child['kp_name'] }}
-                                    </span>
-                                @endif
-                            @endforeach
-                            @if(count($children) > 8)
-                                <span style="color: #64748b; font-style: italic;">...等{{ count($children) }}个知识点</span>
-                            @endif
-                        </div>
-                    @else
-                        <div style="font-size: 12px; color: #64748b;">
-                            <em>无直接子知识点或子知识点掌握度为0</em>
-                        </div>
-                    @endif
-                </div>
-            @endforeach
+                @endforeach
+            </div>
+            @php
+                $allParentPerfect = !empty($parentMasteryLevels) && count(array_filter($parentMasteryLevels, function ($p) {
+                    return floatval($p['mastery_level'] ?? 0) >= 0.999;
+                })) === count($parentMasteryLevels);
+                $allHitChildrenPerfect = !empty($filteredMasteryItems) && count(array_filter($filteredMasteryItems, function ($it) {
+                    return floatval($it['mastery_level'] ?? 0) >= 0.999;
+                })) === count($filteredMasteryItems);
+                $isAllPerfect = $allParentPerfect && $allHitChildrenPerfect;
+                // 1) 优先:本次命中子知识点中的低掌握度(<60%)
+                $hitWeakChildren = [];
+                $hitWeakKeys = [];
+                foreach (($filteredMasteryItems ?? []) as $hitItem) {
+                    $level = floatval($hitItem['mastery_level'] ?? 0);
+                    $name = trim((string) ($hitItem['kp_name'] ?? $hitItem['kp_code'] ?? ''));
+                    if ($name === '' || $level >= 0.6) {
+                        continue;
+                    }
+                    $key = (string) ($hitItem['kp_code'] ?? $name);
+                    $hitWeakChildren[$key] = [
+                        'name' => $name,
+                        'level' => $level,
+                    ];
+                    $hitWeakKeys[$key] = true;
+                }
+                $hitWeakChildren = array_values($hitWeakChildren);
+                usort($hitWeakChildren, function ($a, $b) {
+                    return $a['level'] <=> $b['level'];
+                });
 
+                // 2) 兜底:若命中子知识点都 >=60%,再从其他低掌握度子知识点补
+                $otherWeakChildren = [];
+                foreach (($parentMasteryLevels ?? []) as $pData) {
+                    foreach (($pData['children_all'] ?? []) as $child) {
+                        $level = floatval($child['mastery_level'] ?? 0);
+                        $name = trim((string) ($child['kp_name'] ?? $child['kp_code'] ?? ''));
+                        if ($name === '' || $level >= 0.6) {
+                            continue;
+                        }
+                        $key = ($child['kp_code'] ?? $name);
+                        if (isset($hitWeakKeys[$key])) {
+                            continue;
+                        }
+                        $otherWeakChildren[$key] = [
+                            'name' => $name,
+                            'level' => $level,
+                        ];
+                    }
+                }
+                $otherWeakChildren = array_values($otherWeakChildren);
+                usort($otherWeakChildren, function ($a, $b) {
+                    return $a['level'] <=> $b['level'];
+                });
+
+                // 3) 最终展示:总数不超过5
+                $globalWeakChildren = [];
+                if (!empty($hitWeakChildren)) {
+                    $globalWeakChildren = array_slice($hitWeakChildren, 0, 5);
+                } else {
+                    $globalWeakChildren = array_slice($otherWeakChildren, 0, 5);
+                }
+            @endphp
             <div style="margin-top: 12px; padding: 8px; background: #fefce8; border-left: 4px solid #eab308; border-radius: 4px;">
                 <div style="font-size: 12px; color: #854d0e; line-height: 1.6;">
                     <strong>学习建议:</strong>
-                    建议重点关注掌握度较低的知识点,通过专项练习提升整体学习水平。优先练习掌握度低于60%的知识点。
+                    @if($isAllPerfect)
+                        本次学案表现非常出色,相关知识点掌握稳定且完整。建议继续进入新的知识点专题学习,优先选择同层级未覆盖内容或更高难度综合题,保持进阶节奏。
+                    @else
+                        建议重点关注掌握度较低的知识点,通过专项练习提升整体学习水平。优先练习掌握度低于60%的知识点。
+                    @endif
+                    @if(!empty($globalWeakChildren))
+                        <div class="weak-kp-tags">
+                            @foreach($globalWeakChildren as $weakChild)
+                                <span class="weak-kp-tag">{{ $weakChild['name'] }}({{ number_format($weakChild['level'] * 100, 1) }}%)</span>
+                            @endforeach
+                        </div>
+                    @endif
                 </div>
             </div>
         @else
@@ -206,18 +448,111 @@
         @endif
     </div>
 
-    <div class="card">
-        <div class="section-title">题目列表</div>
-        @php
-            $insightMap = [];
-            foreach (($question_insights ?? []) as $insight) {
-                $no = $insight['question_number'] ?? $insight['question_id'] ?? null;
-                if ($no !== null) {
-                    $insightMap[$no] = $insight;
-                }
+    @php
+        $insightMap = [];
+        foreach (($question_insights ?? []) as $insight) {
+            $no = $insight['question_number'] ?? $insight['question_id'] ?? null;
+            if ($no !== null) {
+                $insightMap[$no] = $insight;
             }
-        @endphp
-        @foreach($questions as $q)
+        }
+
+        $analysisWrongMap = [];
+        foreach (($analysis_data['question_analysis'] ?? []) as $qa) {
+            $qid = $qa['question_bank_id'] ?? $qa['question_id'] ?? null;
+            if ($qid === null || $qid === '') {
+                continue;
+            }
+            $rawCorrect = $qa['is_correct'] ?? null;
+            $isWrongFromAnalysis = false;
+            if (is_array($rawCorrect)) {
+                $isWrongFromAnalysis = in_array(0, $rawCorrect, true);
+            } elseif ($rawCorrect !== null) {
+                $isWrongFromAnalysis = !boolval($rawCorrect);
+            }
+            if ($isWrongFromAnalysis) {
+                $analysisWrongMap[(string)$qid] = true;
+            }
+        }
+
+        $wrongQuestions = [];
+        foreach (($questions ?? []) as $qItem) {
+            $studentAnswerProbe = $qItem['student_answer'] ?? null;
+            $correctAnswerProbe = $qItem['answer'] ?? ($qItem['correct_answer'] ?? null);
+            $isCorrectProbe = $qItem['is_correct'] ?? null;
+            if ($isCorrectProbe === null && !empty($studentAnswerProbe) && !empty($correctAnswerProbe)) {
+                $isCorrectProbe = (trim((string)$studentAnswerProbe) === trim((string)$correctAnswerProbe)) ? 1 : 0;
+            }
+            $normalizedCorrect = $isCorrectProbe;
+            if ($isCorrectProbe !== null) {
+                $normalizedCorrect = is_bool($isCorrectProbe) ? ($isCorrectProbe ? 1 : 0) : intval($isCorrectProbe);
+            }
+            $qidProbe = (string)($qItem['question_bank_id'] ?? $qItem['question_id'] ?? '');
+            $isWrongByAnalysis = ($qidProbe !== '' && isset($analysisWrongMap[$qidProbe]));
+            if ($normalizedCorrect === 0 || $isWrongByAnalysis) {
+                $wrongQuestions[] = $qItem;
+            }
+        }
+
+        // 错题知识点聚合统计(同知识点错几题/共几题)
+        $kpStats = [];
+        foreach (($questions ?? []) as $qItem) {
+            $kpName = trim((string)($qItem['knowledge_point_name'] ?? $qItem['knowledge_point'] ?? '未标注知识点'));
+            if ($kpName === '') {
+                $kpName = '未标注知识点';
+            }
+            if (!isset($kpStats[$kpName])) {
+                $kpStats[$kpName] = ['total' => 0, 'wrong' => 0];
+            }
+            $kpStats[$kpName]['total']++;
+        }
+        foreach ($wrongQuestions as $qItem) {
+            $kpName = trim((string)($qItem['knowledge_point_name'] ?? $qItem['knowledge_point'] ?? '未标注知识点'));
+            if ($kpName === '') {
+                $kpName = '未标注知识点';
+            }
+            if (!isset($kpStats[$kpName])) {
+                $kpStats[$kpName] = ['total' => 0, 'wrong' => 0];
+            }
+            $kpStats[$kpName]['wrong']++;
+        }
+
+        $kpWrongStats = [];
+        foreach ($kpStats as $kpName => $stat) {
+            if (($stat['wrong'] ?? 0) <= 0) {
+                continue;
+            }
+            $total = max(1, intval($stat['total'] ?? 0));
+            $wrong = intval($stat['wrong'] ?? 0);
+            $kpWrongStats[] = [
+                'kp_name' => $kpName,
+                'wrong' => $wrong,
+                'total' => $total,
+                'rate' => $wrong / $total,
+            ];
+        }
+        usort($kpWrongStats, function ($a, $b) {
+            if ($a['rate'] === $b['rate']) {
+                return $b['wrong'] <=> $a['wrong'];
+            }
+            return $b['rate'] <=> $a['rate'];
+        });
+    @endphp
+
+    @if(!empty($wrongQuestions))
+    <div class="card">
+        <div class="section-title">错题列表</div>
+        @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;">
+                    @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
+                </div>
+            </div>
+        @endif
+        @foreach($wrongQuestions as $q)
             @php
                 // 【修复】从题目数据中获取学生答案、正确答案和判分结果
                 $studentAnswer = $q['student_answer'] ?? $q['student_answer'] ?? null;
@@ -273,6 +608,19 @@
                 if ($analysis === null || $analysis === '') {
                     $analysis = '暂无解题思路,待补充';
                 }
+                $formatSolutionLikeGrading = function ($text) {
+                    if (!is_string($text) || trim($text) === '') {
+                        return $text;
+                    }
+                    $normalized = preg_replace('/\s*;\s*步骤\s*(\d+)/u', ";\n步骤$1", $text);
+                    $normalized = preg_replace('/\s*。\s*步骤\s*(\d+)/u', "。\n步骤$1", $normalized);
+                    $normalized = preg_replace('/(?<!^)(步骤\s*\d+\s*[::])/u', "\n$1", $normalized);
+                    $normalized = preg_replace('/(?<!^)(第\s*\d+\s*步\s*[::]?)/u', "\n$1", $normalized);
+                    $normalized = preg_replace('/\n{3,}/u', "\n\n", $normalized);
+                    // 去掉每行左侧缩进空白,避免出现“左边空好几个字符”
+                    $normalized = preg_replace('/^[\h\x{3000}]+/mu', '', $normalized);
+                    return trim($normalized);
+                };
                 $stepsRaw = $insight['steps'] ?? $insight['solution_steps'] ?? $insight['analysis_steps'] ?? null;
                 $steps = [];
                 if (is_array($stepsRaw)) {
@@ -284,11 +632,13 @@
                 $typeLabel = $typeMap[$q['question_type'] ?? ''] ?? ($q['question_type'] ?? '题型未标注');
                 $questionText = is_string($q['question_text']) ? $q['question_text'] : json_encode($q['question_text'], JSON_UNESCAPED_UNICODE);
                 $solution = $q['solution'] ?? null;
+                $solution = $formatSolutionLikeGrading($solution);
+                $analysis = $formatSolutionLikeGrading($analysis);
             @endphp
-            <div style="border:1px solid #e5e7eb; border-radius:8px; padding:8px 12px; margin-bottom:8px; background:#fff; page-break-inside: avoid;">
+            <div class="question-card">
                 <div style="display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:4px;">
                     <div style="display:flex; align-items:center; gap:8px; font-weight:600;">
-                        <span class="tag">题号 {{ $q['display_number'] ?? $q['question_number'] }}</span>
+                        <span class="tag">题号 {{ $q['display_number'] ?? $q['question_number'] }} · {{ $typeLabel }}</span>
                         @php
                             $kpName = $q['knowledge_point_name'] ?? $q['knowledge_point'] ?? null;
                             if (!empty($kpName) && $kpName !== '-' && $kpName !== '未标注') {
@@ -306,7 +656,7 @@
 
                 {{-- 【新增】学生答案显示(如果有) --}}
                 @if(!empty($studentAnswer))
-                    <div style="margin-bottom:6px; padding:6px; background:#fef2f2; border-left:3px solid #ef4444; border-radius:4px;">
+                    <div class="question-block" style="background:#fef2f2; border-left:3px solid #ef4444;">
                         <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;">
                             {!! nl2br(e($studentAnswer)) !!}
@@ -315,11 +665,10 @@
                 @endif
 
                 <div class="math-content" style="margin-bottom:6px; font-size:12px;">{!! $questionText !!}</div>
-                <div class="muted" style="margin-bottom:6px; font-size:12px;">题型:{{ $typeLabel }}</div>
 
                 {{-- 【修复】正确答案显示 --}}
                 @if(!empty($correctAnswer))
-                    <div style="margin-bottom:6px; padding:6px; background:#f0fdf4; border-left:3px solid #10b981; border-radius:4px;">
+                    <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;">
                             {!! is_string($correctAnswer) ? $correctAnswer : json_encode($correctAnswer, JSON_UNESCAPED_UNICODE) !!}
@@ -329,16 +678,16 @@
 
                 {{-- 【修改】解题思路显示(优先显示solution,其次显示analysis) --}}
                 @if(!empty($solution))
-                    <div style="margin-top:6px; padding:6px; background:#eff6ff; border-left:3px solid #3b82f6; border-radius:4px;">
+                    <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" style="font-size:12px; line-height:1.5; color:#374151;">
-                            {!! is_array($solution) ? json_encode($solution, JSON_UNESCAPED_UNICODE) : $solution !!}
+                        <div class="math-content solution-content" style="font-size:12px; color:#374151;">
+                            {!! nl2br(e(is_array($solution) ? json_encode($solution, JSON_UNESCAPED_UNICODE) : (string) $solution)) !!}
                         </div>
                     </div>
                 @elseif(!empty($analysis) && $analysis !== '暂无解题思路记录')
-                    <div style="margin-top:6px; padding:6px; background:#eff6ff; border-left:3px solid #3b82f6; border-radius:4px;">
+                    <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" style="font-size:12px; line-height:1.5; color:#374151;">{!! $analysis !!}</div>
+                        <div class="math-content solution-content" style="font-size:12px; color:#374151;">{!! nl2br(e((string) $analysis)) !!}</div>
                     </div>
                 @endif
 
@@ -356,6 +705,7 @@
             </div>
         @endforeach
     </div>
+    @endif
     </div> {{-- 闭合page div --}}
 
     <script src="/js/katex.min.js"></script>