Răsfoiți Sursa

Revert "Merge branch 'ye/fix-served-difficulty-alpha'"

This reverts commit 355b180bdfed04be124c3588b825da46fa0879e0, reversing
changes made to c888e10fd2b28b4e1c40926411bf1e1d3b86ff12.
yemeishu 3 săptămâni în urmă
părinte
comite
49af802437

+ 85 - 710
app/Services/ExamPdfExportService.php

@@ -28,7 +28,6 @@ use Symfony\Component\Process\Process;
 class ExamPdfExportService
 {
     private ?KatexRenderer $katexRenderer = null;
-    private ?array $knowledgePointMetaCache = null;
     private const PDF_IMAGE_WIDTH_WIDE_PX = 250;
     private const PDF_IMAGE_WIDTH_VERY_WIDE_PX = 330;
 
@@ -323,11 +322,8 @@ class ExamPdfExportService
                 }
             }
 
-            // 组装V3报告展示数据(模块化)
-            $templateData['v3'] = $this->buildAnalysisReportV3Data($templateData);
-
-            // 渲染HTML(V3模板)
-            $html = view('exam-analysis.pdf-report-v3', $templateData)->render();
+            // 渲染HTML
+            $html = view('exam-analysis.pdf-report', $templateData)->render();
             if (! $html) {
                 Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]);
 
@@ -374,616 +370,6 @@ class ExamPdfExportService
         }
     }
 
-    /**
-     * 构建学情报告 V3 展示数据(模块化视图)
-     */
-    private function buildAnalysisReportV3Data(array $templateData): array
-    {
-        $rawAnalysis = $templateData['analysis_data'] ?? [];
-        $kpRows = $rawAnalysis['knowledge_point_analysis'] ?? [];
-        $questionRows = $rawAnalysis['question_analysis'] ?? [];
-        $overallSummary = $rawAnalysis['overall_summary'] ?? [];
-
-        $kpMeta = $this->getKnowledgePointMetaMap();
-        $grade = (string) ($templateData['student']['grade'] ?? '');
-        $stage = $this->resolveRadarStage($grade, $templateData['full_parent_mastery_levels'] ?? []);
-        $profile = $this->getRadarProfileByStage($stage);
-        $rootCode = (string) ($profile['root_code'] ?? 'M00');
-        $moduleCodes = $profile['module_codes'] ?? [];
-        $moduleNames = $profile['module_names'] ?? [];
-        $dimensionDefs = $profile['dimension_defs'] ?? [];
-
-        $moduleAgg = [];
-        foreach ($moduleCodes as $moduleCode) {
-            $moduleAgg[$moduleCode] = [
-                'mastery_sum' => 0.0,
-                'mastery_count' => 0,
-                'question_max' => 0.0,
-                'question_obtained' => 0.0,
-            ];
-        }
-
-        $fullParentMap = $templateData['full_parent_mastery_levels'] ?? [];
-        if (! is_array($fullParentMap)) {
-            $fullParentMap = [];
-        }
-
-        foreach ($kpRows as $row) {
-            $kpId = trim((string) ($row['kp_id'] ?? ''));
-            if ($kpId === '') {
-                continue;
-            }
-            $moduleCode = $this->mapKpToStageModule($kpId, $kpMeta, $rootCode);
-            if (! $moduleCode || ! isset($moduleAgg[$moduleCode])) {
-                continue;
-            }
-
-            $mastery = (float) ($row['mastery_level'] ?? 0);
-            $moduleAgg[$moduleCode]['mastery_sum'] += $mastery;
-            $moduleAgg[$moduleCode]['mastery_count']++;
-        }
-
-        foreach ($questionRows as $questionRow) {
-            $maxScore = (float) ($questionRow['max_score'] ?? 0);
-            $obtainedScore = (float) ($questionRow['score_obtained'] ?? 0);
-            if ($maxScore <= 0) {
-                continue;
-            }
-
-            $kpId = '';
-            $questionKps = $questionRow['knowledge_points'] ?? [];
-            if (is_array($questionKps) && ! empty($questionKps)) {
-                $first = $questionKps[0] ?? [];
-                if (is_array($first)) {
-                    $kpId = trim((string) ($first['kp_id'] ?? ''));
-                }
-            }
-            if ($kpId === '') {
-                continue;
-            }
-
-            $moduleCode = $this->mapKpToStageModule($kpId, $kpMeta, $rootCode);
-            if (! $moduleCode || ! isset($moduleAgg[$moduleCode])) {
-                continue;
-            }
-
-            $moduleAgg[$moduleCode]['question_max'] += $maxScore;
-            $moduleAgg[$moduleCode]['question_obtained'] += max(0.0, min($obtainedScore, $maxScore));
-        }
-
-        $moduleRows = [];
-        foreach ($moduleCodes as $moduleCode) {
-            $agg = $moduleAgg[$moduleCode] ?? null;
-            $masteryLevel = null;
-            $kpCount = 0;
-
-            if (isset($fullParentMap[$moduleCode])) {
-                $parentRow = $fullParentMap[$moduleCode];
-                $masteryLevel = isset($parentRow['mastery_level']) ? (float) $parentRow['mastery_level'] : null;
-                $kpCount = (int) ($parentRow['children_total_count'] ?? 0);
-            }
-
-            if ($masteryLevel === null && $agg && $agg['mastery_count'] > 0) {
-                $masteryLevel = $agg['mastery_sum'] / $agg['mastery_count'];
-            }
-            $masteryScore5 = $masteryLevel !== null ? round($masteryLevel * 5, 2) : null;
-            $examMax = (float) ($agg['question_max'] ?? 0.0);
-            $examObtained = (float) ($agg['question_obtained'] ?? 0.0);
-            $examRate = $examMax > 0 ? round($examObtained / $examMax, 4) : null;
-            if ($kpCount <= 0) {
-                $kpCount = (int) ($agg['mastery_count'] ?? 0);
-            }
-
-            $moduleRows[] = [
-                'module_code' => $moduleCode,
-                'module_name' => $moduleNames[$moduleCode] ?? $moduleCode,
-                'mastery_level' => $masteryLevel !== null ? round($masteryLevel, 4) : null,
-                'mastery_score_5' => $masteryScore5,
-                'status' => $this->resolveMasteryStatus($masteryLevel),
-                'kp_count' => $kpCount,
-                'exam_max_score' => round($examMax, 2),
-                'exam_obtained_score' => round($examObtained, 2),
-                'exam_score_rate' => $examRate,
-                'ability_text' => $this->defaultModuleAbilityText($moduleCode, $moduleNames[$moduleCode] ?? $moduleCode),
-            ];
-        }
-
-        $moduleMap = [];
-        foreach ($moduleRows as $moduleRow) {
-            $moduleMap[$moduleRow['module_code']] = $moduleRow;
-        }
-
-        $dimensions = [];
-        foreach ($dimensionDefs as $def) {
-            $masteryWeightedSum = 0.0;
-            $weightSum = 0.0;
-            $scoreMax = 0.0;
-            $scoreObtained = 0.0;
-            $kpCount = 0;
-
-            foreach ($def['modules'] as $moduleCode) {
-                $row = $moduleMap[$moduleCode] ?? null;
-                if (! $row) {
-                    continue;
-                }
-                $weight = max(1, (int) ($row['kp_count'] ?? 0));
-                if ($row['mastery_level'] !== null) {
-                    $masteryWeightedSum += ((float) $row['mastery_level']) * $weight;
-                    $weightSum += $weight;
-                }
-                $scoreMax += (float) ($row['exam_max_score'] ?? 0);
-                $scoreObtained += (float) ($row['exam_obtained_score'] ?? 0);
-                $kpCount += (int) ($row['kp_count'] ?? 0);
-            }
-
-            $mastery = $weightSum > 0 ? ($masteryWeightedSum / $weightSum) : null;
-            $score5 = $mastery !== null ? round($mastery * 5, 2) : null;
-            $scoreRate = $scoreMax > 0 ? round($scoreObtained / $scoreMax, 4) : null;
-
-            $dimensions[] = [
-                'id' => $def['id'],
-                'name' => $def['name'],
-                'module_codes' => $def['modules'],
-                'mastery_level' => $mastery !== null ? round($mastery, 4) : null,
-                'score_5' => $score5,
-                'status' => $this->resolveMasteryStatus($mastery),
-                'kp_count' => $kpCount,
-                'exam_score_rate' => $scoreRate,
-            ];
-        }
-
-        $allMax = 0.0;
-        $allObtained = 0.0;
-        foreach ($moduleRows as $moduleRow) {
-            $allMax += (float) ($moduleRow['exam_max_score'] ?? 0);
-            $allObtained += (float) ($moduleRow['exam_obtained_score'] ?? 0);
-        }
-        $scoreRate = $allMax > 0 ? round($allObtained / $allMax, 4) : null;
-        $avgMastery = isset($overallSummary['average_mastery'])
-            ? (float) $overallSummary['average_mastery']
-            : $this->averageByKey(array_filter($dimensions, fn ($d) => $d['mastery_level'] !== null), 'mastery_level');
-
-        $overallLabel = $overallSummary['overall_performance'] ?? $this->resolveOverallPerformanceLabel($avgMastery);
-        $paths = $this->buildV3LearningPaths($dimensions);
-
-        // 雷达图:轴固定为学段根节点的“第一层父知识点”(根节点的直接子节点)
-        // 掌握度计算口径:严格沿用上一版父节点掌握度口径(full_parent_mastery_levels / parent_mastery_levels)。
-        $radar = [];
-        $hitParentMap = $templateData['parent_mastery_levels'] ?? [];
-        if (! is_array($hitParentMap)) {
-            $hitParentMap = [];
-        }
-
-        // 第二块雷达图补充:父轴下子知识点扩散(突出本卷掌握度变化)
-        $radarChildrenByModule = [];
-        foreach ($kpRows as $row) {
-            $kpId = trim((string) ($row['kp_id'] ?? ''));
-            if ($kpId === '') {
-                continue;
-            }
-            $moduleCode = $this->mapKpToStageModule($kpId, $kpMeta, $rootCode);
-            if (! $moduleCode || ! in_array($moduleCode, $moduleCodes, true)) {
-                continue;
-            }
-            $parentCode = (string) ($kpMeta[$kpId]['parent_kp_code'] ?? '');
-            $depth = $this->resolveDepthToModule($kpId, $moduleCode, $kpMeta);
-            $change = $row['change'] ?? null;
-            $radarChildrenByModule[$moduleCode][] = [
-                'code' => $kpId,
-                'name' => $kpMeta[$kpId]['name'] ?? $kpId,
-                'parent_code' => $parentCode,
-                'depth' => $depth,
-                'change' => $change !== null ? round((float) $change, 4) : null,
-                'mastery_level' => isset($row['mastery_level']) ? round((float) $row['mastery_level'], 4) : null,
-                'changed' => $change !== null && abs((float) $change) > 0.0001,
-            ];
-        }
-        foreach ($radarChildrenByModule as $moduleCode => $items) {
-            usort($items, static function ($a, $b) {
-                $da = (int) ($a['depth'] ?? 1);
-                $db = (int) ($b['depth'] ?? 1);
-                if ($da !== $db) {
-                    return $da <=> $db;
-                }
-                $ca = abs((float) ($a['change'] ?? 0));
-                $cb = abs((float) ($b['change'] ?? 0));
-                if ($ca === $cb) {
-                    return strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? ''));
-                }
-                return $cb <=> $ca;
-            });
-            $radarChildrenByModule[$moduleCode] = $items;
-        }
-
-        foreach ($moduleCodes as $moduleCode) {
-            $hitParentRow = $hitParentMap[$moduleCode] ?? null;
-            $hitAvg = null;
-            if (is_array($hitParentRow) && isset($hitParentRow['children_hit_avg_mastery'])) {
-                $hitAvg = (float) $hitParentRow['children_hit_avg_mastery'];
-            }
-
-            $fullParentRow = $fullParentMap[$moduleCode] ?? null;
-            $fullMastery = null;
-            if (is_array($fullParentRow) && isset($fullParentRow['mastery_level'])) {
-                $fullMastery = (float) $fullParentRow['mastery_level'];
-            }
-
-            $mastery = $hitAvg ?? $fullMastery;
-            $hasMastery = $mastery !== null;
-            $score5 = $hasMastery ? round($mastery * 5, 2) : 0.0;
-            $status = $hasMastery ? $this->resolveMasteryStatus($mastery) : '未涉及';
-
-            $radar[] = [
-                'code' => $moduleCode,
-                'name' => $moduleNames[$moduleCode] ?? $moduleCode,
-                'value' => $score5,
-                'status' => $status,
-                'has_mastery' => $hasMastery,
-                'children' => $radarChildrenByModule[$moduleCode] ?? [],
-            ];
-        }
-
-        $foundation = $paths['keep'][0]['name'] ?? ($paths['boost'][0]['name'] ?? '核心模块');
-        $breakthrough = $paths['boost'][0]['name'] ?? ($paths['key'][0]['name'] ?? '重点模块');
-        $minimum = $paths['key'][0]['name'] ?? ($paths['boost'][0]['name'] ?? '薄弱模块');
-
-        return [
-            'summary' => [
-                'score_obtained' => round($allObtained, 1),
-                'score_total' => round($allMax, 1),
-                'score_rate' => $scoreRate,
-                'average_mastery' => $avgMastery !== null ? round($avgMastery, 4) : null,
-                'overall_label' => $overallLabel,
-                'stage' => $stage,
-            ],
-            'radar' => $radar,
-            'modules' => $moduleRows,
-            'dimensions' => $dimensions,
-            'paths' => $paths,
-            'overall_plan' => [
-                "保基础(60%):稳住{$foundation}",
-                "拉中档(30%):突破{$breakthrough}",
-                "冲压轴(10%):优先补齐{$minimum}",
-            ],
-        ];
-    }
-
-    private function buildV3LearningPaths(array $dimensions): array
-    {
-        $items = [];
-        foreach ($dimensions as $dimension) {
-            if (! isset($dimension['mastery_level']) || $dimension['mastery_level'] === null) {
-                continue;
-            }
-            $items[] = [
-                'name' => (string) ($dimension['name'] ?? ''),
-                'mastery_level' => (float) $dimension['mastery_level'],
-                'score_5' => (float) ($dimension['score_5'] ?? 0),
-                'status' => $dimension['status'] ?? '一般',
-            ];
-        }
-
-        usort($items, fn ($a, $b) => $a['mastery_level'] <=> $b['mastery_level']);
-        $lowToHigh = $items;
-        $highToLow = array_reverse($items);
-
-        $keep = array_values(array_filter($highToLow, fn ($i) => $i['mastery_level'] >= 0.75));
-        $boost = array_values(array_filter($lowToHigh, fn ($i) => $i['mastery_level'] >= 0.5 && $i['mastery_level'] < 0.75));
-        $key = array_values(array_filter($lowToHigh, fn ($i) => $i['mastery_level'] < 0.5));
-
-        if (empty($keep) && ! empty($highToLow)) {
-            $keep[] = $highToLow[0];
-        }
-        if (empty($boost) && count($lowToHigh) > 1) {
-            $boost[] = $lowToHigh[(int) floor((count($lowToHigh) - 1) / 2)];
-        }
-        if (empty($key) && ! empty($lowToHigh)) {
-            $key[] = $lowToHigh[0];
-        }
-
-        return [
-            'keep' => array_slice($keep, 0, 2),
-            'boost' => array_slice($boost, 0, 2),
-            'key' => array_slice($key, 0, 2),
-        ];
-    }
-
-    private function resolveMasteryStatus(?float $mastery): string
-    {
-        if ($mastery === null) {
-            return '暂无';
-        }
-        if ($mastery >= 0.8) {
-            return '良好';
-        }
-        if ($mastery >= 0.6) {
-            return '一般';
-        }
-
-        return '薄弱';
-    }
-
-    private function resolveOverallPerformanceLabel(?float $avgMastery): string
-    {
-        if ($avgMastery === null) {
-            return '待评估';
-        }
-        if ($avgMastery >= 0.85) {
-            return '优秀';
-        }
-        if ($avgMastery >= 0.7) {
-            return '良好';
-        }
-        if ($avgMastery >= 0.55) {
-            return '一般';
-        }
-
-        return '需提升';
-    }
-
-    private function averageByKey(array $rows, string $key): ?float
-    {
-        if (empty($rows)) {
-            return null;
-        }
-        $sum = 0.0;
-        $count = 0;
-        foreach ($rows as $row) {
-            if (! isset($row[$key])) {
-                continue;
-            }
-            $sum += (float) $row[$key];
-            $count++;
-        }
-
-        return $count > 0 ? ($sum / $count) : null;
-    }
-
-    /**
-     * 收集某学段根节点下的全量后代知识点(不含根节点本身)
-     */
-    private function collectStageKnowledgePoints(string $rootCode): array
-    {
-        if ($rootCode === '') {
-            return [];
-        }
-
-        try {
-            $rows = DB::connection('mysql')
-                ->table('knowledge_points')
-                ->select('kp_code', 'parent_kp_code')
-                ->get();
-
-            $children = [];
-            foreach ($rows as $row) {
-                $code = trim((string) ($row->kp_code ?? ''));
-                $parent = trim((string) ($row->parent_kp_code ?? ''));
-                if ($code === '' || $parent === '') {
-                    continue;
-                }
-                $children[$parent][] = $code;
-            }
-
-            $result = [];
-            $queue = [$rootCode];
-            $visited = [];
-            while (! empty($queue)) {
-                $parent = array_shift($queue);
-                if (isset($visited[$parent])) {
-                    continue;
-                }
-                $visited[$parent] = true;
-                foreach (($children[$parent] ?? []) as $childCode) {
-                    if ($childCode === $rootCode) {
-                        continue;
-                    }
-                    $result[] = $childCode;
-                    $queue[] = $childCode;
-                }
-            }
-
-            return array_values(array_unique($result));
-        } catch (\Throwable $e) {
-            Log::warning('ExamPdfExportService: collectStageKnowledgePoints failed', [
-                'root_code' => $rootCode,
-                'error' => $e->getMessage(),
-            ]);
-
-            return [];
-        }
-    }
-
-    private function defaultModuleAbilityText(string $moduleCode, ?string $moduleName = null): string
-    {
-        return match ($moduleCode) {
-            'M01' => '数感与运算、代数式处理、基础建模',
-            'M02' => '方程化归、分类讨论、不等式推理',
-            'M03' => '图形性质识别、辅助线与逻辑证明',
-            'M04' => '图形变化、度量计算、坐标与变换',
-            'M05' => '相似迁移、比例推导、勾股应用',
-            'M06' => '数据分析、统计图表、概率判断',
-            'M07' => '函数建模、图像理解、函数性质迁移',
-            'S01_000' => '集合表示、集合运算、集合关系判断',
-            'S02_000' => '命题结构、逻辑推理、充分必要条件辨析',
-            'S03_000' => '不等式变形、比较法、恒成立问题处理',
-            'S04_000' => '函数模型、图像性质、函数迁移应用',
-            'S05_000' => '导数概念、单调最值、切线与优化问题',
-            'S06_000' => '三角函数图像、恒等变换、向量运算',
-            'S07_000' => '空间想象、立体几何证明与计算',
-            'S08_000' => '坐标法、轨迹方程、圆锥曲线综合',
-            'S09_000' => '递推与通项、求和技巧、数列建模',
-            'S10_000' => '概率模型、统计推断、随机变量分析',
-            'S11_000' => '复数运算、代数几何意义转换',
-            default => '综合能力',
-        };
-    }
-
-    private function resolveRadarStage(string $grade, array $fullParentLevels = []): string
-    {
-        $g = trim($grade);
-        if ($g !== '') {
-            if (preg_match('/高一|高二|高三/u', $g)) {
-                return 'high';
-            }
-            if (preg_match('/\d+/', $g, $m)) {
-                $n = (int) $m[0];
-                if ($n >= 10) {
-                    return 'high';
-                }
-                if ($n > 0 && $n <= 9) {
-                    return 'junior';
-                }
-            }
-        }
-
-        foreach (array_keys($fullParentLevels) as $kpCode) {
-            if (str_starts_with((string) $kpCode, 'S')) {
-                return 'high';
-            }
-        }
-
-        return 'junior';
-    }
-
-    private function getRadarProfileByStage(string $stage): array
-    {
-        if ($stage === 'high') {
-            $moduleNames = [
-                'S01_000' => '集合',
-                'S02_000' => '逻辑用语',
-                'S03_000' => '不等式',
-                'S04_000' => '函数',
-                'S05_000' => '导数',
-                'S06_000' => '三角函数与向量',
-                'S07_000' => '立体几何',
-                'S08_000' => '解析几何',
-                'S09_000' => '数列',
-                'S10_000' => '概率统计',
-                'S11_000' => '复数',
-            ];
-            $dimensionDefs = [];
-            foreach ($moduleNames as $code => $name) {
-                $dimensionDefs[] = [
-                    'id' => strtolower(str_replace('_', '', $code)),
-                    'name' => $name,
-                    'modules' => [$code],
-                ];
-            }
-
-            return [
-                'root_code' => 'S000_000',
-                'module_codes' => array_keys($moduleNames),
-                'module_names' => $moduleNames,
-                'dimension_defs' => $dimensionDefs,
-            ];
-        }
-
-        return [
-            'root_code' => 'M00',
-            'module_codes' => ['M01', 'M02', 'M03', 'M04', 'M05', 'M06', 'M07'],
-            'module_names' => [
-                'M01' => '数与代数',
-                'M02' => '方程与不等式',
-                'M03' => '图形性质',
-                'M04' => '图形变化与度量',
-                'M05' => '相似与勾股',
-                'M06' => '统计与概率',
-                'M07' => '函数',
-            ],
-            'dimension_defs' => [
-                ['id' => 'number_expr', 'name' => '数与式', 'modules' => ['M01']],
-                ['id' => 'equation', 'name' => '方程与不等式', 'modules' => ['M02']],
-                ['id' => 'function', 'name' => '函数', 'modules' => ['M07']],
-                ['id' => 'shape_change', 'name' => '图形的变化', 'modules' => ['M04']],
-                ['id' => 'shape_property', 'name' => '图形的性质', 'modules' => ['M03', 'M05']],
-                ['id' => 'stat_prob', 'name' => '统计与概率', 'modules' => ['M06']],
-            ],
-        ];
-    }
-
-    private function getKnowledgePointMetaMap(): array
-    {
-        if ($this->knowledgePointMetaCache !== null) {
-            return $this->knowledgePointMetaCache;
-        }
-
-        $rows = DB::connection('mysql')
-            ->table('knowledge_points')
-            ->select(['kp_code', 'name', 'parent_kp_code'])
-            ->get();
-
-        $map = [];
-        foreach ($rows as $row) {
-            $code = trim((string) ($row->kp_code ?? ''));
-            if ($code === '') {
-                continue;
-            }
-            $map[$code] = [
-                'name' => (string) ($row->name ?? $code),
-                'parent_kp_code' => trim((string) ($row->parent_kp_code ?? '')),
-            ];
-        }
-
-        $this->knowledgePointMetaCache = $map;
-
-        return $map;
-    }
-
-    private function mapKpToJuniorModule(string $kpCode, array $kpMeta): ?string
-    {
-        return $this->mapKpToStageModule($kpCode, $kpMeta, 'M00');
-    }
-
-    private function mapKpToStageModule(string $kpCode, array $kpMeta, string $rootCode): ?string
-    {
-        $code = trim($kpCode);
-        if ($code === '' || empty($kpMeta[$code])) {
-            return null;
-        }
-
-        $cursor = $code;
-        for ($depth = 0; $depth < 16; $depth++) {
-            $node = $kpMeta[$cursor] ?? null;
-            if (! $node) {
-                return null;
-            }
-
-            $parent = (string) ($node['parent_kp_code'] ?? '');
-            if ($parent === $rootCode) {
-                return $cursor;
-            }
-            if ($parent === '' || $parent === $cursor) {
-                return null;
-            }
-
-            $cursor = $parent;
-        }
-
-        return null;
-    }
-
-    private function resolveDepthToModule(string $kpCode, string $moduleCode, array $kpMeta): int
-    {
-        $depth = 1;
-        $cursor = trim($kpCode);
-        for ($i = 0; $i < 24; $i++) {
-            if ($cursor === '' || ! isset($kpMeta[$cursor])) {
-                break;
-            }
-            if ($cursor === $moduleCode) {
-                return max(1, $depth - 1);
-            }
-            $parent = trim((string) ($kpMeta[$cursor]['parent_kp_code'] ?? ''));
-            if ($parent === '' || $parent === $cursor) {
-                break;
-            }
-            $cursor = $parent;
-            $depth++;
-        }
-
-        return max(1, $depth);
-    }
-
     /**
      * 生成合并PDF(试卷 + 判卷)
      * 先分别生成两个PDF,然后合并
@@ -1292,7 +678,7 @@ class ExamPdfExportService
                 'header_title' => $examCode,
                 'exam_pdf_title' => '试卷_'.$examCode,
                 'grading_pdf_title' => '判卷_'.$examCode,
-                'knowledge_pdf_title' => '知识点讲解_'.$examCode,
+                'knowledge_pdf_title' => '知识点梳理_'.$examCode,
             ];
 
             $html = view($viewName, [
@@ -1437,7 +823,6 @@ class ExamPdfExportService
         // 【修复】优先使用analysisData中的knowledge_point_analysis数据
         $masteryData = [];
         $parentMasteryLevels = []; // 新增:父节点掌握度数据
-        $allParentMasteryLevelsRaw = []; // 全量父节点掌握度(非本卷过滤)
         Log::info('ExamPdfExportService: 开始处理掌握度数据', [
             'student_id' => $studentId,
             'analysisData_keys' => array_keys($analysisData),
@@ -1510,7 +895,6 @@ class ExamPdfExportService
                 // 获取所有父节点掌握度
                 $masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
                 $allParentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? [];
-                $allParentMasteryLevelsRaw = $allParentMasteryLevels;
                 $overviewDetails = $masteryOverview['details'] ?? [];
                 foreach ($overviewDetails as $detail) {
                     if (is_object($detail)) {
@@ -1605,7 +989,6 @@ class ExamPdfExportService
                 $masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
                 $masteryData = $masteryOverview['details'] ?? [];
                 $parentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? []; // 获取父节点掌握度
-                $allParentMasteryLevelsRaw = $parentMasteryLevels;
 
                 // 【修复】将对象数组转换为关联数组(避免 stdClass 对象访问错误)
                 if (! empty($masteryData) && is_array($masteryData)) {
@@ -1731,12 +1114,6 @@ class ExamPdfExportService
             $masteryMap,
             $snapshotMasteryData
         );
-        $processedFullParentMastery = $this->buildParentMasteryFromAllParents(
-            $allParentMasteryLevelsRaw,
-            $kpNameMap,
-            $masteryMap,
-            $snapshotMasteryData
-        );
 
         Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
             'raw_count' => count($parentMasteryLevels),
@@ -1760,7 +1137,6 @@ class ExamPdfExportService
             'mastery' => $masterySummary,
             'exam_hit_kp_codes' => $examQuestionKpCodes,
             'parent_mastery_levels' => $processedParentMastery, // 【修复】使用处理后的父节点数据
-            'full_parent_mastery_levels' => $processedFullParentMastery,
             'insights' => $analysisData['question_analysis'] ?? [], // 使用question_analysis替代question_results
             'recommendations' => $recommendations,
             'analysis_data' => $analysisData,
@@ -2548,11 +1924,11 @@ class ExamPdfExportService
             'has_katex_local' => $hasKatexLocal,
         ]);
 
-        // 如果既没有 CDN 也没有本地链接,跳过
+        // 如果既没有 CDN 也没有本地链接,仍尝试注入 KaTeX 关系符通用修复
         if (! $hasKatexCdn && ! $hasKatexLocal) {
             Log::warning('ExamPdfExportService: HTML 中没有 KaTeX 资源链接,跳过内联');
 
-            return $html;
+            return $this->applyKatexRelationGlyphFixes($html);
         }
 
         try {
@@ -2652,6 +2028,10 @@ class ExamPdfExportService
                     'after_length' => $afterLength,
                     'size_change' => $afterLength - $beforeLength,
                 ]);
+
+                // 通用修复:统一优化 KaTeX 关系运算符(mrel)字形
+                // 覆盖平行、垂直、等于、不等于等关系符,避免出现“平行符像 ||”的问题
+                $html = $this->applyKatexRelationGlyphFixes($html);
             } else {
                 Log::warning('ExamPdfExportService: KatexRenderer 未初始化,跳过预渲染');
             }
@@ -2667,7 +2047,82 @@ class ExamPdfExportService
             ]);
         }
 
-        return $html;
+        return $this->applyKatexRelationGlyphFixes($html);
+    }
+
+    /**
+     * 应用 KaTeX 关系符通用修复(先标记,再注入样式)
+     */
+    private function applyKatexRelationGlyphFixes(string $html): string
+    {
+        $html = $this->tagKatexParallelRelationSpans($html);
+
+        return $this->injectKatexRelationGlyphStyle($html);
+    }
+
+    /**
+     * 标记 KaTeX 中的平行关系符(∥),便于使用字体无关样式兜底
+     */
+    private function tagKatexParallelRelationSpans(string $html): string
+    {
+        if (strpos($html, '∥') === false || strpos($html, 'mrel') === false) {
+            return $html;
+        }
+
+        $pattern = '/<span(?P<attrs>[^>]*)class=(["\'])(?P<class>[^"\']*\bmrel\b[^"\']*)\2(?P<tail>[^>]*)>\s*∥\s*<\/span>/u';
+        $replaced = preg_replace_callback($pattern, static function (array $matches): string {
+            $attrs = $matches['attrs'] ?? '';
+            $quote = $matches[2] ?? '"';
+            $class = trim($matches['class'] ?? '');
+            $tail = $matches['tail'] ?? '';
+
+            if ($class === '') {
+                $class = 'mrel katex-rel-parallel';
+            } elseif (! preg_match('/\bkatex-rel-parallel\b/', $class)) {
+                $class .= ' katex-rel-parallel';
+            }
+
+            return '<span'.$attrs.'class='.$quote.$class.$quote.$tail.'>∥</span>';
+        }, $html);
+
+        return $replaced ?? $html;
+    }
+
+    /**
+     * 注入 KaTeX 关系运算符统一字形样式(全局、通用)
+     */
+    private function injectKatexRelationGlyphStyle(string $html): string
+    {
+        if (strpos($html, 'class="katex"') === false && strpos($html, 'class="katex-html"') === false) {
+            return $html;
+        }
+
+        if (strpos($html, 'id="katex-relation-glyph-style"') !== false) {
+            return $html;
+        }
+
+        $style = '<style id="katex-relation-glyph-style">'
+            .'.katex .mrel{'
+            .'font-family:"KaTeX_AMS","KaTeX_Main","NotoSerifCJKsc-Regular","NotoSerifCJKsc-Bold","NotoSans-Regular","NotoSans-Bold","NotoSansMonoCJKjp-Regular",serif !important;'
+            .'letter-spacing:.04em;'
+            .'}'
+            .'.katex .mrel .mord{font-family:inherit !important;}'
+            .'.katex .mrel.katex-rel-parallel{'
+            .'display:inline-block;'
+            .'font-size:1.22em;'
+            .'line-height:1.28;'
+            .'letter-spacing:.04em;'
+            .'vertical-align:0;'
+            .'transform:skewX(-14deg) scaleY(1.42);'
+            .'transform-origin:center;'
+            .'}'
+            .'</style>';
+
+        if (stripos($html, '</head>') !== false) {
+            return preg_replace('/<\/head>/i', $style.'</head>', $html, 1) ?? ($html.$style);
+        }
+
+        return $html.$style;
     }
 
     /**
@@ -3545,86 +3000,6 @@ class ExamPdfExportService
         return $processed;
     }
 
-    /**
-     * 基于学生全量掌握度构建父节点列表(不按本卷命中过滤)
-     */
-    private function buildParentMasteryFromAllParents(
-        array $allParentMasteryLevels,
-        array $kpNameMap,
-        array $masteryMap = [],
-        array $snapshotMasteryData = []
-    ): array {
-        if (empty($allParentMasteryLevels)) {
-            return [];
-        }
-
-        $parentCodes = [];
-        foreach ($allParentMasteryLevels as $code => $value) {
-            $parentCode = trim((string) $code);
-            if ($parentCode === '') {
-                continue;
-            }
-            $parentCodes[] = $parentCode;
-        }
-        $parentCodes = array_values(array_unique($parentCodes));
-        if (empty($parentCodes)) {
-            return [];
-        }
-
-        $parents = DB::connection('mysql')
-            ->table('knowledge_points')
-            ->whereIn('kp_code', $parentCodes)
-            ->pluck('name', 'kp_code')
-            ->toArray();
-
-        $processed = [];
-        foreach ($parentCodes as $parentCode) {
-            $raw = $allParentMasteryLevels[$parentCode] ?? null;
-            $rawMastery = is_array($raw)
-                ? floatval($raw['mastery_level'] ?? 0)
-                : (is_object($raw) ? floatval($raw->mastery_level ?? 0) : floatval($raw ?? 0));
-
-            $childrenData = $this->getChildKnowledgePoints($parentCode, $kpNameMap, [], $masteryMap);
-            $allChildren = $childrenData['all_children'] ?? [];
-
-            $allLevels = array_map(fn ($c) => floatval($c['mastery_level'] ?? 0), $allChildren);
-            $masteryLevel = ! empty($allLevels) ? (array_sum($allLevels) / count($allLevels)) : $rawMastery;
-
-            $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);
-            }
-            $masteryChange = ! empty($prevLevels) ? ($masteryLevel - (array_sum($prevLevels) / count($prevLevels))) : null;
-
-            $processed[$parentCode] = [
-                'kp_code' => $parentCode,
-                'kp_name' => $parents[$parentCode] ?? ($kpNameMap[$parentCode] ?? $parentCode),
-                'mastery_level' => round(floatval($masteryLevel), 4),
-                'mastery_percentage' => round(floatval($masteryLevel) * 100, 2),
-                'mastery_change' => $masteryChange !== null ? round(floatval($masteryChange), 4) : null,
-                'change_source' => 'full_mastery_overview',
-                'children' => [],
-                'children_all' => $allChildren,
-                'children_hit_count' => 0,
-                'children_total_count' => count($allChildren),
-                'children_hit_avg_mastery' => null,
-                'level' => $this->calculateKnowledgePointLevel($parentCode),
-            ];
-        }
-
-        uasort($processed, fn ($a, $b) => $b['mastery_level'] <=> $a['mastery_level']);
-
-        return $processed;
-    }
-
     /**
      * 计算知识点层级深度
      */

+ 6 - 21
app/Services/KatexRenderer.php

@@ -56,20 +56,11 @@ class KatexRenderer
         // 在渲染前修复公式中的实体与 cases 换行问题
         $html = $this->sanitizeLatexInHtml($html);
 
-        // 尝试从缓存获取(缓存异常不应阻断公式渲染主流程)
+        // 尝试从缓存获取
         $cacheKey = $this->getCacheKey($html);
-        if ($this->cacheEnabled) {
-            try {
-                $cached = cache()->get($cacheKey);
-                if (!empty($cached) && is_string($cached)) {
-                    Log::debug('KatexRenderer: 从缓存获取渲染结果');
-                    return $cached;
-                }
-            } catch (\Throwable $e) {
-                Log::warning('KatexRenderer: 读取缓存失败,继续执行实时渲染', [
-                    'error' => $e->getMessage(),
-                ]);
-            }
+        if ($this->cacheEnabled && $cached = cache()->get($cacheKey)) {
+            Log::debug('KatexRenderer: 从缓存获取渲染结果');
+            return $cached;
         }
 
         // 调用 Node.js 脚本渲染
@@ -81,15 +72,9 @@ class KatexRenderer
             ]);
         }
 
-        // 缓存结果(缓存异常不影响主流程)
+        // 缓存结果
         if ($this->cacheEnabled && $rendered !== $html) {
-            try {
-                cache()->put($cacheKey, $rendered, self::CACHE_TTL);
-            } catch (\Throwable $e) {
-                Log::warning('KatexRenderer: 写入缓存失败,已忽略', [
-                    'error' => $e->getMessage(),
-                ]);
-            }
+            cache()->put($cacheKey, $rendered, self::CACHE_TTL);
         }
 
         return $rendered;

+ 8 - 43
app/Services/QuestionDifficultyResolver.php

@@ -8,13 +8,12 @@ use Illuminate\Support\Facades\Schema;
 class QuestionDifficultyResolver
 {
     private const TABLE = 'question_difficulty_calibrations';
-    private const SERVED_DIFF_CONFIDENCE_DENOMINATOR = 20.0;
 
     private ?bool $tableReady = null;
 
     /**
      * @param  array<int, int|string>  $questionIds
-     * @return array<int, array<string, float|null>> question_bank_id => calibration snapshot
+     * @return array<int, float> question_bank_id => calibrated_difficulty
      */
     public function mapCalibratedDifficulty(array $questionIds): array
     {
@@ -34,29 +33,13 @@ class QuestionDifficultyResolver
 
         return DB::table(self::TABLE)
             ->whereIn('question_bank_id', $questionIds)
-            ->get(['question_bank_id', 'original_difficulty', 'calibrated_difficulty', 'weighted_attempts'])
-            ->mapWithKeys(function ($row) {
-                $qid = (int) ($row->question_bank_id ?? 0);
-                if ($qid <= 0) {
-                    return [];
-                }
-
-                return [
-                    $qid => [
-                        'original_difficulty' => $row->original_difficulty !== null ? (float) $row->original_difficulty : null,
-                        'calibrated_difficulty' => $row->calibrated_difficulty !== null ? (float) $row->calibrated_difficulty : null,
-                        'weighted_attempts' => $row->weighted_attempts !== null ? (float) $row->weighted_attempts : null,
-                    ],
-                ];
-            })
+            ->pluck('calibrated_difficulty', 'question_bank_id')
+            ->map(fn ($v) => (float) $v)
             ->all();
     }
 
     /**
-     * 批量给题目数组计算组卷使用难度 served_diff:
-     * served_diff = alpha * calibrated + (1 - alpha) * original
-     * alpha = min(1, weighted_attempts / 20)
-     * 前提:仅当 calibrated_difficulty 存在时才融合;否则保持原始 difficulty 不变。
+     * 批量给题目数组覆盖 difficulty(校准值优先,原始值兜底)
      *
      * @param  array<int, array<string, mixed>>  $questions
      * @return array<int, array<string, mixed>>
@@ -81,29 +64,10 @@ class QuestionDifficultyResolver
 
         foreach ($questions as &$q) {
             $id = (int) ($q['id'] ?? $q['question_id'] ?? $q['question_bank_id'] ?? 0);
-            if ($id <= 0 || ! array_key_exists($id, $map)) {
-                continue;
-            }
-
-            $snapshot = $map[$id] ?? [];
-            $calibrated = $snapshot['calibrated_difficulty'] ?? null;
-            if ($calibrated === null) {
-                continue;
+            if ($id > 0 && array_key_exists($id, $map)) {
+                $q['difficulty'] = (float) $map[$id];
+                $q['difficulty_source'] = 'calibrated';
             }
-
-            $original = isset($q['difficulty']) && is_numeric($q['difficulty'])
-                ? (float) $q['difficulty']
-                : (float) ($snapshot['original_difficulty'] ?? $calibrated);
-            $weightedAttempts = max(0.0, (float) ($snapshot['weighted_attempts'] ?? 0.0));
-            $alpha = min(1.0, $weightedAttempts / self::SERVED_DIFF_CONFIDENCE_DENOMINATOR);
-            $servedDifficulty = ($alpha * (float) $calibrated) + ((1.0 - $alpha) * $original);
-
-            $q['difficulty'] = round($servedDifficulty, 4);
-            $q['difficulty_source'] = $alpha >= 0.9999 ? 'calibrated' : 'served_blend';
-            $q['difficulty_original'] = round($original, 4);
-            $q['difficulty_calibrated'] = round((float) $calibrated, 4);
-            $q['difficulty_alpha'] = round($alpha, 4);
-            $q['difficulty_weighted_attempts'] = round($weightedAttempts, 4);
         }
         unset($q);
 
@@ -121,3 +85,4 @@ class QuestionDifficultyResolver
         return $this->tableReady;
     }
 }
+

+ 0 - 2
app/Services/QuestionLocalService.php

@@ -622,11 +622,9 @@ class QuestionLocalService
 
         $questions = $this->questionDifficultyResolver->applyCalibratedDifficulty($questions);
         $calibratedCount = count(array_filter($questions, fn ($q) => ($q['difficulty_source'] ?? null) === 'calibrated'));
-        $servedBlendCount = count(array_filter($questions, fn ($q) => ($q['difficulty_source'] ?? null) === 'served_blend'));
         Log::info('QuestionLocalService: 组卷前应用校准难度', [
             'total_candidates' => count($questions),
             'calibrated_candidates' => $calibratedCount,
-            'served_blend_candidates' => $servedBlendCount,
         ]);
 
         $resolveQuestionId = static function (array $question): string {

+ 0 - 739
resources/views/exam-analysis/pdf-report-v3.blade.php

@@ -1,739 +0,0 @@
-@php
-    $v3 = $v3 ?? [];
-    $summary = $v3['summary'] ?? [];
-    $radar = $v3['radar'] ?? [];
-    $modules = $v3['modules'] ?? [];
-    $paths = $v3['paths'] ?? ['keep' => [], 'boost' => [], 'key' => []];
-    $overallPlan = $v3['overall_plan'] ?? [];
-
-    $rawPaperId = $paper['id'] ?? $paper['paper_id'] ?? 'unknown';
-    preg_match('/paper_(\d{15})/', $rawPaperId, $matches);
-    $reportCode = $matches[1] ?? preg_replace('/[^0-9]/', '', (string) $rawPaperId);
-    $generateDateTime = now()->format('Y年m月d日 H:i:s');
-
-    $scoreObtained = $summary['score_obtained'] ?? null;
-    $scoreTotal = $summary['score_total'] ?? null;
-    $scoreRate = $summary['score_rate'] ?? null;
-    $averageMastery = $summary['average_mastery'] ?? null;
-    $overallLabel = $summary['overall_label'] ?? '待评估';
-
-    $statusColor = function (string $status): string {
-        return match ($status) {
-            '良好' => '#16a34a',
-            '一般' => '#f97316',
-            '薄弱' => '#e11d48',
-            default => '#64748b',
-        };
-    };
-
-    $n = max(1, count($radar));
-    $cx = 210;
-    $cy = 155;
-    $r = 108;
-    $outer = [];
-    $inner = [];
-    for ($i = 0; $i < $n; $i++) {
-        $angle = -M_PI / 2 + (2 * M_PI * $i / $n);
-        $ox = $cx + $r * cos($angle);
-        $oy = $cy + $r * sin($angle);
-        $outer[] = [$ox, $oy];
-
-        $value = isset($radar[$i]['value']) ? (float) $radar[$i]['value'] : 0.0;
-        $ratio = max(0.0, min(1.0, $value / 5));
-        $ix = $cx + $r * $ratio * cos($angle);
-        $iy = $cy + $r * $ratio * sin($angle);
-        $inner[] = [$ix, $iy];
-    }
-    $outerPoints = implode(' ', array_map(fn ($p) => round($p[0], 2).','.round($p[1], 2), $outer));
-    $innerPoints = implode(' ', array_map(fn ($p) => round($p[0], 2).','.round($p[1], 2), $inner));
-
-    $insightMap = [];
-    foreach (($question_insights ?? []) as $insight) {
-        $no = $insight['question_number'] ?? $insight['question_id'] ?? null;
-        if ($no !== null) {
-            $insightMap[$no] = $insight;
-        }
-    }
-
-    $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) {
-        $isCorrectProbe = $qItem['is_correct'] ?? null;
-        $studentAnswerProbe = $qItem['student_answer'] ?? null;
-        $correctAnswerProbe = $qItem['answer'] ?? ($qItem['correct_answer'] ?? 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'] ?? '未标注知识点'));
-        $kpName = $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'] ?? '未标注知识点'));
-        $kpName = $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
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-    <meta charset="UTF-8">
-    <title>学情分析报告</title>
-    <link rel="stylesheet" href="/css/katex/katex.min.css">
-    <style>
-        @page {
-            size: A4;
-            margin: 2.2cm 2cm 2.3cm 2cm;
-            @top-left { content: "知了数学·{{ $generateDateTime }}"; font-size: 13px; color: #666; }
-            @top-center { content: "{{ $student['name'] ?? '-' }}"; font-size: 13px; color: #666; }
-            @top-right {
-                content: "{{ $reportCode }}";
-                font-size: 19px;
-                font-weight: 600;
-                font-family: "Noto Sans", "Liberation Sans", "Nimbus Sans", sans-serif;
-                color: #222;
-            }
-            @bottom-left { content: "{{ $reportCode }}"; font-size: 11px; color: #666; }
-            @bottom-right { content: counter(page) "/" counter(pages); font-size: 13px; color: #666; }
-        }
-        * { box-sizing: border-box; }
-        body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; margin: 0; color: #0f172a; font-size: 13px; line-height: 1.65; }
-        .page { page-break-after: auto; }
-        .header { text-align: left; margin-bottom: 16px; }
-        .paper-title { font-size: 30px; font-weight: 700; margin-bottom: 8px; color: #0b3a75; letter-spacing: 1px; }
-        .meta-row { display: flex; justify-content: flex-start; gap: 16px; font-size: 13px; color: #475569; flex-wrap: wrap; }
-        .section { margin-bottom: 14px; page-break-inside: auto; break-inside: auto; }
-        .section-title { font-size: 20px; margin-bottom: 10px; font-weight: 700; color: #0b3a75; border-left: 5px solid #3b82f6; padding-left: 10px; line-height: 1.3; }
-        .card { border: 1px solid #dbeafe; border-radius: 12px; padding: 14px; background: #f8fbff; }
-        .summary-list { margin: 0; padding-left: 18px; }
-        .summary-list li { margin: 6px 0; font-size: 13px; }
-        .radar-center { text-align: center; }
-        .legend { margin-top: 8px; font-size: 12px; color: #475569; }
-        .legend span { margin: 0 8px; }
-        .dot { display: inline-block; width: 10px; height: 10px; border-radius: 999px; margin-right: 4px; vertical-align: middle; }
-        /* PDF 版优先上下结构,避免左右分栏导致拥挤 */
-        .radar-split { display: block; width: 100%; }
-        .radar-left { width: 100%; text-align: center; }
-        .radar-right { width: 100%; padding-left: 0; margin-top: 10px; }
-        .radar-desc { border: 1px solid #dbeafe; background: #f8fbff; border-radius: 12px; padding: 12px; text-align: left; }
-        .radar-item { display: block; margin: 6px 0; font-size: 12px; }
-        .kp-burst-card { margin-top: 10px; border: 1px solid #dbeafe; border-radius: 12px; padding: 10px; background: #fff; }
-        .kp-burst-title { font-size: 13px; font-weight: 700; margin-bottom: 6px; color: #0b3a75; }
-        .kp-burst-meta { font-size: 12px; color: #334155; margin-top: 6px; line-height: 1.6; }
-        .kp-burst-list { margin-top: 6px; font-size: 11px; color: #334155; line-height: 1.5; }
-        .kp-burst-list span { display: inline-block; margin-right: 10px; margin-bottom: 3px; }
-        table { width: 100%; border-collapse: collapse; font-size: 12px; background: #fff; }
-        th, td { border: 1px solid #d0d7e2; padding: 8px 10px; text-align: left; vertical-align: top; }
-        th { background: #f1f5f9; color: #1e293b; font-weight: 700; }
-        .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; color: #fff; font-size: 11px; font-weight: 600; }
-        .path-stack { display: block; }
-        .path-box { border: 1px solid #e5e7eb; border-radius: 12px; padding: 12px 14px; margin-bottom: 10px; }
-        .path-box.keep { background: #f0fdf4; border-color: #86efac; }
-        .path-box.boost { background: #fff7ed; border-color: #fdba74; }
-        .path-box.key { background: #fff1f2; border-color: #fda4af; }
-        .path-title { font-size: 16px; font-weight: 700; margin-bottom: 6px; color: #111827; }
-        .path-box ul { margin: 0; padding-left: 16px; }
-        .path-box li { font-size: 13px; margin: 2px 0; }
-        .plan { background: #eef4ff; border-left: 4px solid #3b82f6; border-radius: 12px; padding: 12px 14px; }
-        .plan ol { margin: 0; padding-left: 18px; }
-        .tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; color: #334155; background: #e5e7eb; }
-        .error-kp-tag { display: inline-block; margin: 0 6px 6px 0; padding: 1px 7px; border-radius: 999px; font-size: 10px; color: #334155; background: #f8fafc; border: 1px solid #d1d5db; }
-        .error-kp-tag.high-risk { color: #b91c1c; border-color: #fca5a5; background: #fff; font-weight: 600; }
-        .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 .question-stem svg,
-        .question-card .math-content svg { max-width: 100%; height: auto; display: block; shape-rendering: geometricPrecision; text-rendering: geometricPrecision; }
-        .question-card .question-stem svg text {
-            font-family: "Noto Serif", "Noto Serif CJK SC", "Noto Sans CJK SC", "Noto Sans", "STSongti-SC", "PingFang SC", "Songti SC", serif !important;
-            font-size: 13px !important;
-            font-weight: bold;
-            dominant-baseline: middle;
-            text-anchor: middle;
-        }
-        .question-card .question-stem svg circle,
-        .question-card .question-stem svg line,
-        .question-card .question-stem svg polygon,
-        .question-card .question-stem svg polyline { shape-rendering: geometricPrecision; }
-        .question-card .question-stem img,
-        .question-card .question-main img {
-            display: block;
-            max-width: 220px;
-            max-height: 60mm;
-            width: auto;
-            height: auto;
-            margin: 6px auto;
-            box-sizing: border-box;
-            object-fit: contain;
-            -webkit-print-color-adjust: exact;
-            print-color-adjust: exact;
-            image-rendering: -webkit-optimize-contrast;
-        }
-        .question-card .question-stem .katex { font-size: 1em !important; vertical-align: 0; }
-        .question-card .question-stem .katex-display { margin: 0.35em 0 !important; }
-        .question-card .solution-content img,
-        .question-card .report-answer-meta img {
-            display: block;
-            max-width: 220px;
-            max-height: 60mm;
-            width: auto;
-            height: auto;
-            margin: 6px auto;
-            object-fit: contain;
-            -webkit-print-color-adjust: exact;
-            print-color-adjust: exact;
-        }
-        .solution-content {
-            display: block;
-            line-height: 1.75;
-            white-space: normal;
-            word-break: break-word;
-            overflow-wrap: anywhere;
-            page-break-inside: auto;
-            break-inside: auto;
-        }
-        .report-options { margin-top: 6px; page-break-inside: auto; break-inside: auto; }
-        .report-options.options-grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px 12px; }
-        .report-options.options-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 20px; }
-        .report-options.options-grid-1 { display: grid; grid-template-columns: 1fr; gap: 8px; }
-        .report-options .option { display: flex; align-items: baseline; font-size: 12px; line-height: 1.6; page-break-inside: auto; break-inside: auto; }
-        .report-options .option strong { margin-right: 4px; flex: 0 0 auto; }
-        .report-options .option-value.option-short { white-space: nowrap; }
-        .report-options .option-value.option-long { white-space: normal; word-break: break-word; }
-        .report-options .option p, .report-options .option div { margin: 0; display: inline; }
-        .report-options .option img { max-width: 100%; height: auto; vertical-align: middle; }
-        .report-answer-meta { font-size: 12px; color: #2f2f2f; line-height: 1.75; margin-top: 6px; page-break-inside: auto; break-inside: auto; }
-        .report-answer-meta .answer-line + .answer-line { margin-top: 4px; }
-        .report-answer-meta .solution-content { display: inline; line-height: 1.75; }
-        .muted { color: #6b7280; font-size: 12px; }
-    </style>
-</head>
-<body>
-<div class="page">
-    <div class="header">
-        <h1 class="paper-title">学情分析报告</h1>
-        <div class="meta-row">
-            <span>学生姓名:{{ $student['name'] ?? '未知' }}</span>
-            <span>报告日期:{{ now()->format('Y年n月j日') }}</span>
-        </div>
-    </div>
-
-    <div class="section">
-        <div class="section-title">一、总体评估</div>
-        <div class="card">
-            <ul class="summary-list">
-                <li>本次诊断得分:
-                    @if($scoreObtained !== null && $scoreTotal !== null && $scoreTotal > 0)
-                        {{ rtrim(rtrim(number_format((float) $scoreObtained, 1), '0'), '.') }}/{{ rtrim(rtrim(number_format((float) $scoreTotal, 1), '0'), '.') }}
-                    @else
-                        暂无得分数据
-                    @endif
-                </li>
-                <li>得分率:{{ $scoreRate !== null ? number_format((float) $scoreRate * 100, 1) . '%' : '暂无得分率' }}</li>
-                <li>平均掌握度:{{ $averageMastery !== null ? number_format((float) $averageMastery * 100, 1) . '%' : '暂无掌握度' }}</li>
-                <li>整体水平:{{ $overallLabel }}</li>
-            </ul>
-        </div>
-    </div>
-
-    <div class="section">
-        <div class="section-title">二、知识点掌握雷达图</div>
-        <div class="radar-split">
-            <div class="radar-left">
-            <svg width="430" height="320" viewBox="0 0 430 320">
-                <polygon points="{{ $outerPoints }}" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
-                <polygon points="{{ $innerPoints }}" fill="rgba(59,130,246,0.28)" stroke="#3b82f6" stroke-width="2"/>
-                @foreach($outer as $i => $p)
-                    <line x1="{{ $cx }}" y1="{{ $cy }}" x2="{{ round($p[0],2) }}" y2="{{ round($p[1],2) }}" stroke="#e2e8f0" stroke-width="1"/>
-                @endforeach
-                @foreach($outer as $i => $p)
-                    @php
-                        $name = $radar[$i]['name'] ?? '';
-                        $children = $radar[$i]['children'] ?? [];
-                        $labelX = $p[0] + (($p[0] >= $cx) ? 9 : -9);
-                        $labelY = $p[1] + (($p[1] >= $cy) ? 12 : -8);
-                        $anchor = $p[0] >= $cx ? 'start' : 'end';
-                        $dotColor = $statusColor((string) ($radar[$i]['status'] ?? '暂无'));
-                        $value = number_format((float) ($radar[$i]['value'] ?? 0), 2);
-                        $axisAngle = atan2(($p[1] - $cy), ($p[0] - $cx));
-                    @endphp
-                    <text x="{{ round($labelX,2) }}" y="{{ round($labelY,2) }}" font-size="11" fill="#334155" text-anchor="{{ $anchor }}">{{ $name }} {{ $value }}</text>
-                    <circle cx="{{ round($inner[$i][0],2) }}" cy="{{ round($inner[$i][1],2) }}" r="4" fill="{{ $dotColor }}" />
-                    @if(!empty($children))
-                        @foreach($children as $cIdx => $child)
-                            @php
-                                $childN = max(1, count($children));
-                                $depth = max(1, intval($child['depth'] ?? 1));
-                                $offset = ($cIdx - (($childN - 1) / 2)) * 0.04;
-                                $childAngle = $axisAngle + $offset;
-                                // 子知识点必须从父轴外圈向外发散,避免父轴值低时挤在中心
-                                $axisOuterR = sqrt(pow(($outer[$i][0] - $cx), 2) + pow(($outer[$i][1] - $cy), 2));
-                                $startR = max($axisOuterR + 4, 112 + (($depth - 1) * 10));
-                                $endR = $startR + 12 + (($depth - 1) * 4);
-                                $sx = $cx + $startR * cos($childAngle);
-                                $sy = $cy + $startR * sin($childAngle);
-                                $ex = $cx + $endR * cos($childAngle);
-                                $ey = $cy + $endR * sin($childAngle);
-                                $changed = !empty($child['changed']);
-                                $cColor = $changed ? '#e11d48' : '#94a3b8';
-                                $cWidth = $changed ? 1.5 : 0.8;
-                                $masteryPct = isset($child['mastery_level']) ? max(0, min(100, (float) $child['mastery_level'] * 100)) : null;
-                                $label = $masteryPct !== null ? number_format($masteryPct, 1) . '%' : '—';
-                                $tx = $cx + ($endR + 5 + (($depth - 1) * 2)) * cos($childAngle);
-                                $ty = $cy + ($endR + 5 + (($depth - 1) * 2)) * sin($childAngle);
-                                $anchor = cos($childAngle) >= 0 ? 'start' : 'end';
-                            @endphp
-                            <line x1="{{ round($sx,2) }}" y1="{{ round($sy,2) }}" x2="{{ round($ex,2) }}" y2="{{ round($ey,2) }}"
-                                  stroke="{{ $cColor }}" stroke-width="{{ $cWidth }}" opacity="{{ $changed ? 0.95 : 0.85 }}"/>
-                            <circle cx="{{ round($ex,2) }}" cy="{{ round($ey,2) }}" r="{{ $changed ? 1.6 : 1.0 }}" fill="{{ $cColor }}" />
-                            <text x="{{ round($tx,2) }}" y="{{ round($ty,2) }}" font-size="9" fill="{{ $cColor }}" text-anchor="{{ $anchor }}">{{ $label }}</text>
-                        @endforeach
-                    @endif
-                @endforeach
-            </svg>
-            <div class="legend">
-                <span><i class="dot" style="background:#16a34a"></i>良好(4.0-5.0)</span>
-                <span><i class="dot" style="background:#f97316"></i>一般(2.0-3.9)</span>
-                <span><i class="dot" style="background:#e11d48"></i>薄弱(0-1.9)</span>
-                <span><i class="dot" style="background:#64748b"></i>未涉及</span>
-                <span><i class="dot" style="background:#94a3b8"></i>子知识点</span>
-                <span><i class="dot" style="background:#e11d48"></i>子知识点变化</span>
-                <span>外圈越远表示层级越深</span>
-            </div>
-            </div>
-            <div class="radar-right">
-            <div class="radar-desc">
-                <strong>雷达图解读</strong>
-                @foreach($radar as $item)
-                    @php $color = $statusColor((string) ($item['status'] ?? '暂无')); @endphp
-                    <span class="radar-item">
-                        <i class="dot" style="background:{{ $color }}"></i>
-                        {{ $item['name'] }}:{{ $item['status'] }}
-                        ({{ !empty($item['has_mastery']) ? number_format((float) ($item['value'] ?? 0), 2) . '/5' : '—' }})
-                        @if(!empty($item['children']))
-                            ,子知识点 {{ count($item['children']) }} 个
-                        @endif
-                    </span>
-                @endforeach
-            </div>
-            </div>
-        </div>
-    </div>
-
-    <div class="section">
-        <div class="section-title">三、模块能力分析表</div>
-        <table>
-            <thead>
-            <tr>
-                <th style="width: 16%;">模块</th>
-                <th style="width: 13%;">掌握分值</th>
-                <th style="width: 12%;">掌握状态</th>
-                <th style="width: 10%;">样本数</th>
-                <th style="width: 12%;">得分率</th>
-                <th>学生当前能力</th>
-            </tr>
-            </thead>
-            <tbody>
-            @foreach($modules as $m)
-                @php
-                    $status = (string) ($m['status'] ?? '暂无');
-                    $color = $statusColor($status);
-                    $rate = $m['exam_score_rate'] ?? null;
-                @endphp
-                <tr>
-                    <td>{{ $m['module_name'] ?? '-' }}</td>
-                    <td>{{ $m['mastery_score_5'] !== null ? number_format((float) $m['mastery_score_5'], 2) . '/5' : '-' }}</td>
-                    <td><span class="badge" style="background:{{ $color }}">{{ $status }}</span></td>
-                    <td>{{ $m['kp_count'] ?? 0 }}</td>
-                    <td>{{ $rate !== null ? number_format((float) $rate * 100, 1) . '%' : '-' }}</td>
-                    <td>{{ $m['ability_text'] ?? '-' }}</td>
-                </tr>
-            @endforeach
-            </tbody>
-        </table>
-    </div>
-
-    <div class="section">
-        <div class="section-title">四、分模块提分路径</div>
-        <div class="path-stack">
-            <div class="path-box keep">
-                <div class="path-title">保分模块(保持优势)</div>
-                <ul>
-                    @foreach(($paths['keep'] ?? []) as $item)
-                        <li>{{ $item['name'] }}:掌握度 {{ number_format((float) ($item['mastery_level'] ?? 0) * 100, 1) }}%</li>
-                    @endforeach
-                </ul>
-                @if(empty($paths['keep']))
-                    <div class="muted">暂无数据</div>
-                @endif
-            </div>
-            <div class="path-box boost">
-                <div class="path-title">涨分模块(重点突破)</div>
-                <ul>
-                    @foreach(($paths['boost'] ?? []) as $item)
-                        <li>{{ $item['name'] }}:掌握度 {{ number_format((float) ($item['mastery_level'] ?? 0) * 100, 1) }}%</li>
-                    @endforeach
-                </ul>
-                @if(empty($paths['boost']))
-                    <div class="muted">暂无数据</div>
-                @endif
-            </div>
-            <div class="path-box key">
-                <div class="path-title">提分关键(优先补短)</div>
-                <ul>
-                    @foreach(($paths['key'] ?? []) as $item)
-                        <li>{{ $item['name'] }}:掌握度 {{ number_format((float) ($item['mastery_level'] ?? 0) * 100, 1) }}%</li>
-                    @endforeach
-                </ul>
-                @if(empty($paths['key']))
-                    <div class="muted">暂无数据</div>
-                @endif
-            </div>
-        </div>
-    </div>
-
-    <div class="section">
-        <div class="section-title">五、整体提升方案</div>
-        <div class="plan">
-            <ol>
-                @foreach($overallPlan as $line)
-                    <li>{{ $line }}</li>
-                @endforeach
-            </ol>
-        </div>
-    </div>
-
-    @if(!empty($wrongQuestions))
-        <div class="section" style="page-break-inside:auto; break-inside:auto;">
-            <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; margin-bottom:6px;">知识点错误率</div>
-                    <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
-                    </div>
-                </div>
-            @endif
-
-            @foreach($wrongQuestions as $q)
-                @php
-                    $studentAnswer = $q['student_answer'] ?? null;
-                    $correctAnswer = $q['answer'] ?? $q['correct_answer'] ?? null;
-                    $isCorrect = $q['is_correct'] ?? null;
-                    if ($isCorrect === null && !empty($studentAnswer) && !empty($correctAnswer)) {
-                        $isCorrect = (trim($studentAnswer) === trim($correctAnswer)) ? 1 : 0;
-                    }
-
-                    $statusText = '';
-                    $statusColorValue = '';
-                    if ($isCorrect === 1) {
-                        $statusText = '正确';
-                        $statusColorValue = '#10b981';
-                    } elseif ($isCorrect === 0) {
-                        $statusText = '错误';
-                        $statusColorValue = '#ef4444';
-                    }
-                    $showStatus = $statusText !== '';
-
-                    $insight = $insightMap[$q['question_number']] ?? ($insightMap[$q['display_number'] ?? null] ?? []);
-                    $fullScore = $insight['full_score'] ?? ($q['score'] ?? null);
-                    if ($isCorrect === 1) {
-                        $score = $fullScore;
-                    } elseif ($isCorrect === 0) {
-                        $score = $q['score_obtained'] ?? 0;
-                    } else {
-                        $score = null;
-                    }
-                    $analysisRaw = $insight['analysis']
-                        ?? $insight['thinking_process']
-                        ?? $insight['feedback']
-                        ?? $insight['suggestions']
-                        ?? $insight['reason']
-                        ?? ($insight['correct_solution'] ?? null);
-                    if (empty($analysisRaw) && !empty($insight['next_steps'])) {
-                        $analysisRaw = '后续建议:' . (is_array($insight['next_steps']) ? implode(';', $insight['next_steps']) : $insight['next_steps']);
-                    }
-                    $analysis = is_array($analysisRaw) ? json_encode($analysisRaw, JSON_UNESCAPED_UNICODE) : $analysisRaw;
-                    if ($analysis === null || $analysis === '') {
-                        $analysis = '暂无解题思路,待补充';
-                    }
-                    if (is_string($analysis)) {
-                        $analysis = preg_replace('/^【?\s*解题思路\s*】?\s*[::]?\s*/u', '', $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)) {
-                        $steps = $stepsRaw;
-                    } elseif (is_string($stepsRaw) && trim($stepsRaw) !== '') {
-                        $steps = preg_split('/[\r\n]+/', trim($stepsRaw));
-                    }
-                    $typeMap = ['choice' => '选择题', 'fill' => '填空题', 'answer' => '解答题'];
-                    $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;
-                    if (is_string($solution)) {
-                        $solution = preg_replace('/^【?\s*解题思路\s*】?\s*[::]?\s*/u', '', $solution);
-                    }
-                    $solution = $formatSolutionLikeGrading($solution);
-                    $analysis = $formatSolutionLikeGrading($analysis);
-                    $renderLikeGrading = function ($text) {
-                        if (is_array($text)) {
-                            $text = json_encode($text, JSON_UNESCAPED_UNICODE);
-                        }
-                        $text = is_string($text) ? trim($text) : '';
-                        if ($text === '') {
-                            return '';
-                        }
-                        // 兼容题库里常见的转义写法:\$x\$、\$\frac{...}\$
-                        $text = preg_replace('/\\\\\\$/u', '$', $text);
-                        return \App\Services\MathFormulaProcessor::processFormulas($text);
-                    };
-                    $questionTextRendered = $renderLikeGrading($questionText);
-                    $displayCorrectAnswer = is_array($correctAnswer) ? json_encode($correctAnswer, JSON_UNESCAPED_UNICODE) : (string) $correctAnswer;
-                    $questionTypeRaw = strtolower(trim((string) ($q['question_type'] ?? '')));
-                    $isChoiceQuestion = in_array($questionTypeRaw, ['choice', 'multiple_choice', 'single_choice', '选择题', 'select'], true);
-                    $normalizedOptions = [];
-                    $correctAnswerLetters = [];
-                    if ($isChoiceQuestion) {
-                        $rawOptions = $q['options'] ?? [];
-                        if (is_string($rawOptions)) {
-                            $decodedOptions = json_decode($rawOptions, true);
-                            $rawOptions = is_array($decodedOptions) ? $decodedOptions : [];
-                        }
-                        if (is_array($rawOptions)) {
-                            foreach ($rawOptions as $optKey => $optValue) {
-                                $letter = null;
-                                if (is_string($optKey) && preg_match('/([A-H])/i', $optKey, $m)) {
-                                    $letter = strtoupper($m[1]);
-                                } elseif (is_array($optValue)) {
-                                    $candidate = $optValue['label'] ?? $optValue['key'] ?? $optValue['option'] ?? null;
-                                    if (is_string($candidate) && preg_match('/([A-H])/i', $candidate, $m)) {
-                                        $letter = strtoupper($m[1]);
-                                    }
-                                }
-                                if ($letter === null) {
-                                    continue;
-                                }
-                                $content = is_array($optValue) ? ($optValue['content'] ?? $optValue['text'] ?? $optValue['value'] ?? '') : $optValue;
-                                if (!is_string($content)) {
-                                    $content = json_encode($content, JSON_UNESCAPED_UNICODE);
-                                }
-                                $content = trim((string) $content);
-                                if ($content !== '') {
-                                    $normalizedOptions[$letter] = $content;
-                                }
-                            }
-                        }
-                        if (trim((string) $correctAnswer) !== '') {
-                            preg_match_all('/[A-H]/i', strtoupper((string) $correctAnswer), $answerMatches);
-                            $correctAnswerLetters = array_values(array_unique($answerMatches[0] ?? []));
-                        }
-                        if (!empty($normalizedOptions) && !empty($correctAnswerLetters)) {
-                            $mappedAnswers = [];
-                            foreach ($correctAnswerLetters as $letter) {
-                                if (isset($normalizedOptions[$letter])) {
-                                    $mappedAnswers[] = $letter . '. ' . $normalizedOptions[$letter];
-                                }
-                            }
-                            if (!empty($mappedAnswers)) {
-                                $displayCorrectAnswer = implode(';', $mappedAnswers);
-                            }
-                        }
-                    }
-                    $choiceOptionLetters = !empty($normalizedOptions) ? array_keys($normalizedOptions) : [];
-                    sort($choiceOptionLetters);
-                    $choiceLayoutClass = 'options-grid-1';
-                    $layoutDecider = app(\App\Support\OptionLayoutDecider::class);
-                    if (! empty($normalizedOptions) && ! empty($choiceOptionLetters)) {
-                        $optValuesForLayout = [];
-                        foreach ($choiceOptionLetters as $L) {
-                            $optValuesForLayout[] = $normalizedOptions[$L];
-                        }
-                        $layoutMeta = $layoutDecider->decide($optValuesForLayout, 'grading');
-                        $choiceLayoutClass = $layoutMeta['class'] ?? 'options-grid-1';
-                    }
-                @endphp
-                <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'] }} · {{ $typeLabel }}</span>
-                            @php
-                                $kpName = $q['knowledge_point_name'] ?? $q['knowledge_point'] ?? null;
-                                if (!empty($kpName) && $kpName !== '-' && $kpName !== '未标注') {
-                                    echo '<span class="tag" style="background: #eef2ff; color:#4338ca;">' . e($kpName) . '</span>';
-                                }
-                            @endphp
-                            @if($showStatus)
-                                <span class="tag" style="background: {{ $statusColorValue }}; color:#fff;">{{ $statusText }}</span>
-                            @endif
-                        </div>
-                        @if($score !== null && $fullScore !== null)
-                            <div class="muted">得分 {{ $score }} / {{ $fullScore }}</div>
-                        @endif
-                    </div>
-
-                    <div class="question-stem math-content" style="margin-bottom:6px;">{!! $questionTextRendered !!}</div>
-
-                    @if(!empty($isChoiceQuestion) && !empty($normalizedOptions))
-                        <div class="report-options {{ $choiceLayoutClass }}">
-                            @foreach($choiceOptionLetters as $optLetter)
-                                @php
-                                    $isCorrectOpt = in_array($optLetter, $correctAnswerLetters ?? [], true);
-                                    $rawOpt = (string) ($normalizedOptions[$optLetter] ?? '');
-                                    $normalizedOpt = str_replace('\\dfrac', '\\frac', $rawOpt);
-                                    $normalizedOpt = str_replace('\\displaystyle', '', $normalizedOpt);
-                                    $normalizedOpt = $layoutDecider->normalizeCompactMathForDisplay($normalizedOpt);
-                                    $rawOptPlain = html_entity_decode(strip_tags($rawOpt), ENT_QUOTES | ENT_HTML5, 'UTF-8');
-                                    $rawOptPlain = preg_replace('/\s+/u', '', $rawOptPlain ?? '');
-                                    $isShortOption = mb_strlen((string) $rawOptPlain, 'UTF-8') <= 8;
-                                    $valClass = $isShortOption ? 'option-short' : 'option-long';
-                                    $renderedOpt = $renderLikeGrading($normalizedOpt);
-                                @endphp
-                                <div class="option option-compact">
-                                    <strong>{{ $optLetter }}.</strong>
-                                    <span class="option-value {{ $valClass }}">{!! $renderedOpt !!}</span>
-                                    @if($isCorrectOpt)
-                                        <span style="margin-left:4px; font-size:13px; color:#15803d; font-weight:700;">✅</span>
-                                    @endif
-                                </div>
-                            @endforeach
-                        </div>
-                    @endif
-
-                    @if(!empty($correctAnswer) && (!$isChoiceQuestion || empty($normalizedOptions)))
-                        <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="line-height:1.7; color:#374151;">
-                                {!! $renderLikeGrading($displayCorrectAnswer) !!}
-                            </div>
-                        </div>
-                    @endif
-
-                    @if(!empty($solution))
-                        <div class="report-answer-meta">
-                            <div class="answer-line">
-                                <strong>解题思路:</strong>
-                                <span class="solution-content">{!! $renderLikeGrading($solution) !!}</span>
-                            </div>
-                        </div>
-                    @elseif(!empty($analysis) && $analysis !== '暂无解题思路记录')
-                        <div class="report-answer-meta">
-                            <div class="answer-line">
-                                <strong>解题思路:</strong>
-                                <span class="solution-content">{!! $renderLikeGrading($analysis) !!}</span>
-                            </div>
-                        </div>
-                    @endif
-
-                    @if(!empty($steps))
-                        <div style="margin-top:6px; font-size:12px;">
-                            <div style="font-weight:600; margin-bottom:3px;">解题步骤</div>
-                            <ol style="margin:0; padding-left:18px;">
-                                @foreach($steps as $s)
-                                    @php
-                                        $stepText = is_array($s) ? json_encode($s, JSON_UNESCAPED_UNICODE) : (string) $s;
-                                    @endphp
-                                    <li style="margin-bottom:2px;">{!! nl2br($renderLikeGrading($stepText)) !!}</li>
-                                @endforeach
-                            </ol>
-                        </div>
-                    @endif
-                </div>
-            @endforeach
-        </div>
-    @endif
-</div>
-<script src="/js/katex.min.js"></script>
-<script src="/js/auto-render.min.js"></script>
-<script>
-    document.addEventListener('DOMContentLoaded', function() {
-        try {
-            renderMathInElement(document.body, {
-                delimiters: [
-                    {left: "$$", right: "$$", display: true},
-                    {left: "$", right: "$", display: false},
-                    {left: "\\(", right: "\\)", display: false},
-                    {left: "\\[", right: "\\]", display: true}
-                ],
-                throwOnError: false,
-                strict: false,
-                trust: true
-            });
-        } catch (e) {}
-    });
-</script>
-</body>
-</html>