Quellcode durchsuchen

merge: bring main (kp-assemble) into feat/question-bank-qc

yemeishu vor 4 Tagen
Ursprung
Commit
b32d5e8be6

+ 179 - 1
app/Services/ExamPdfExportService.php

@@ -5,7 +5,9 @@ namespace App\Services;
 use App\DTO\ExamAnalysisDataDto;
 use App\DTO\ReportPayloadDto;
 use App\Models\Paper;
+use App\Models\Question;
 use App\Models\Student;
+use App\Support\GradingStyleQuestionStem;
 use App\Support\PaperNaming;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\File;
@@ -71,6 +73,59 @@ class ExamPdfExportService
         return $url;
     }
 
+    /**
+     * 渲染试卷 HTML → 生成 PDF → 上传存储(generateExamPdf / generateGradingPdf 共用)
+     */
+    private function renderAndStoreExamPdf(string $paperId, bool $includeAnswer, string $suffix, bool $useGradingView = false): ?string
+    {
+        $html = $this->renderExamHtml($paperId, $includeAnswer, $useGradingView);
+        if ($html === null || trim($html) === '') {
+            Log::error('renderAndStoreExamPdf: HTML 为空', [
+                'paper_id' => $paperId,
+                'suffix' => $suffix,
+            ]);
+
+            return null;
+        }
+
+        $pdfBinary = $this->buildPdf($html);
+        if ($pdfBinary === null || $pdfBinary === '') {
+            Log::error('renderAndStoreExamPdf: buildPdf 失败', [
+                'paper_id' => $paperId,
+                'suffix' => $suffix,
+            ]);
+
+            return null;
+        }
+
+        $paper = Paper::query()->where('paper_id', $paperId)->first();
+        if (! $paper) {
+            Log::error('renderAndStoreExamPdf: 试卷不存在', ['paper_id' => $paperId]);
+
+            return null;
+        }
+
+        $stamp = now()->format('YmdHis').strtoupper(Str::random(4));
+        $base = $this->buildPaperNamePrefix($paper).'_'.$suffix.'_'.$stamp;
+        $safe = PaperNaming::toSafeFilename($base).'.pdf';
+        $path = 'exams/'.$safe;
+
+        $url = $this->pdfStorageService->put($path, $pdfBinary);
+        if (! $url) {
+            Log::error('renderAndStoreExamPdf: 上传失败', ['paper_id' => $paperId, 'path' => $path]);
+
+            return null;
+        }
+
+        Log::info('renderAndStoreExamPdf: 完成', [
+            'paper_id' => $paperId,
+            'suffix' => $suffix,
+            'url' => $url,
+        ]);
+
+        return $url;
+    }
+
     /**
      * 【优化方案】生成统一PDF(卷子 + 判卷一页完成)
      * 效率提升40-50%,只需生成一次PDF
@@ -1122,6 +1177,102 @@ class ExamPdfExportService
         return $details;
     }
 
+    /**
+     * 将题库 options 转为 [A=>文本, B=>文本, ...],供学情报告展示
+     *
+     * @param  mixed  $raw  questions.options(JSON/数组)
+     * @return array<string, string>
+     */
+    private function normalizeChoiceOptionsMap($raw): array
+    {
+        if ($raw === null || $raw === '') {
+            return [];
+        }
+        if (is_string($raw)) {
+            $decoded = json_decode($raw, true);
+            $raw = is_array($decoded) ? $decoded : [];
+        }
+        if (! is_array($raw)) {
+            return [];
+        }
+
+        $out = [];
+        foreach ($raw as $k => $v) {
+            if (is_string($k) && preg_match('/([A-H])/i', $k, $m)) {
+                $letter = strtoupper($m[1]);
+                $text = is_array($v)
+                    ? (string) ($v['content'] ?? $v['text'] ?? $v['value'] ?? '')
+                    : (string) $v;
+                $text = trim($text);
+                if ($text !== '') {
+                    $out[$letter] = $text;
+                }
+            }
+        }
+        if (! empty($out)) {
+            return $out;
+        }
+
+        $letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
+        $i = 0;
+        foreach ($raw as $v) {
+            if ($i >= count($letters)) {
+                break;
+            }
+            $text = is_array($v)
+                ? (string) ($v['content'] ?? $v['text'] ?? $v['value'] ?? '')
+                : (string) $v;
+            $text = trim($text);
+            if ($text !== '') {
+                $out[$letters[$i]] = $text;
+            }
+            $i++;
+        }
+
+        return $out;
+    }
+
+    /**
+     * 从题干 HTML 中解析选项(与 ExamPdfController::extractOptions 口径一致,输出为字母=>文本)
+     *
+     * @return array<string, string>
+     */
+    private function extractChoiceOptionsFromStem(string $content): array
+    {
+        $out = [];
+        $contentWithoutSvg = preg_replace('/<svg[^>]*>.*?<\/svg>/is', '[SVG_PLACEHOLDER]', $content);
+        $pattern = '/(?:^|\s)([A-H])[\.、:.:]\s*(.+?)(?=(?:^|\s)[A-H][\.、:.:]|$)/su';
+
+        if (preg_match_all($pattern, $contentWithoutSvg, $matches, PREG_SET_ORDER)) {
+            foreach ($matches as $match) {
+                $letter = strtoupper($match[1]);
+                $optionText = trim($match[2]);
+                $optionText = preg_replace('/\s+$/', '', $optionText);
+                $optionText = preg_replace('/^\$\$\s*/', '', $optionText);
+                $optionText = preg_replace('/\s*\$\$$/', '', $optionText);
+                if ($optionText !== '') {
+                    $out[$letter] = $optionText;
+                }
+            }
+        }
+
+        if (empty($out)) {
+            $lines = preg_split('/[\r\n]+/', $contentWithoutSvg);
+            foreach ($lines as $line) {
+                $line = trim($line);
+                if (preg_match('/^([A-H])[\.、:.:]\s*(.+)$/u', $line, $match)) {
+                    $letter = strtoupper($match[1]);
+                    $optionText = trim($match[2]);
+                    if ($optionText !== '') {
+                        $out[$letter] = $optionText;
+                    }
+                }
+            }
+        }
+
+        return $out;
+    }
+
     /**
      * 处理题目数据(用于报告)
      */
@@ -1148,6 +1299,14 @@ class ExamPdfExportService
                 return is_numeric($number) ? (float) $number : ($q->id ?? $idx);
             });
 
+        $bankIds = $sortedQuestions->pluck('question_bank_id')->filter()->unique()->values()->all();
+        $optionsByBankId = [];
+        if (! empty($bankIds)) {
+            $optionsByBankId = Question::whereIn('id', $bankIds)
+                ->pluck('options', 'id')
+                ->toArray();
+        }
+
         foreach ($sortedQuestions as $idx => $question) {
             $kpCode = $question->knowledge_point ?? '';
             $kpName = $kpNameMap[$kpCode] ?? $kpCode ?: '未标注';
@@ -1166,11 +1325,29 @@ class ExamPdfExportService
                 ? json_encode($question->question_text, JSON_UNESCAPED_UNICODE)
                 : ($question->question_text ?? '');
 
+            $optionsFromBank = $question->question_bank_id
+                ? ($optionsByBankId[$question->question_bank_id] ?? null)
+                : null;
+            $choiceOptionsMap = $this->normalizeChoiceOptionsMap($optionsFromBank);
+            if (empty($choiceOptionsMap) && $normalizedType === 'choice') {
+                $choiceOptionsMap = $this->extractChoiceOptionsFromStem((string) $questionText);
+            }
+
+            $questionTextForPayload = $this->formatNewlines($questionText);
+            $questionTextPreprocessed = false;
+            if ($normalizedType === 'choice') {
+                $questionTextForPayload = GradingStyleQuestionStem::buildChoiceStemForReport(
+                    (string) $questionTextForPayload
+                );
+                $questionTextPreprocessed = true;
+            }
+
             $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_text' => $questionTextForPayload,
+                'question_text_preprocessed' => $questionTextPreprocessed,
                 'question_type' => $normalizedType,
                 'knowledge_point' => $kpCode,
                 'knowledge_point_name' => $kpName,
@@ -1181,6 +1358,7 @@ class ExamPdfExportService
                 'correct_answer' => $this->formatNewlines($answer),  // 格式化换行
                 'is_correct' => $question->is_correct ?? null,
                 'score_obtained' => $question->score_obtained ?? null,
+                'options' => $choiceOptionsMap,
             ];
 
             $grouped[$normalizedType][] = $payload;

+ 147 - 3
app/Services/ExamTypeStrategy.php

@@ -962,20 +962,34 @@ class ExamTypeStrategy
         $kpCodeList = array_values(array_unique(array_filter($kpCodeList)));
         $studentId = $params['student_id'] ?? null;
         $totalQuestions = $params['total_questions'] ?? 20;
+        $assembleType = (int) ($params['assemble_type'] ?? 4);
 
         if (empty($kpCodeList)) {
             Log::warning('ExamTypeStrategy: 知识点组卷需要 kp_code_list 参数');
             return $this->buildGeneralParams($params);
         }
 
+        // 本源选题用原始 kp;父节点整树仅作为「本源不足时」的补题 KP(见 kp_supplement_subtree_codes → LearningAnalyticsService)
+        $kpSupplementSubtreeCodes = [];
+        if ($assembleType === 2) {
+            $expandedKpCodes = $this->expandKpCodesWithParentChapterSubtrees($kpCodeList);
+            $kpSupplementSubtreeCodes = array_values(array_diff($expandedKpCodes, $kpCodeList));
+            Log::info('ExamTypeStrategy: 知识点组卷父树仅用于补题 KP(本源优先)', [
+                'original_kp_codes' => $kpCodeList,
+                'supplement_kp_codes' => $kpSupplementSubtreeCodes,
+                'original_count' => count($kpCodeList),
+                'supplement_count' => count($kpSupplementSubtreeCodes),
+            ]);
+        }
+
         Log::debug('ExamTypeStrategy: 知识点组卷', [
             'kp_count' => count($kpCodeList),
+            'supplement_kp_count' => count($kpSupplementSubtreeCodes),
             'total_questions' => $totalQuestions
         ]);
 
         // assemble_type=2 走知识点扩展策略(复用 QuestionExpansionService)。
         // 只在基础题池不足时,才会触发子知识点补充(由扩展策略内部控制)。
-        $assembleType = (int) ($params['assemble_type'] ?? 4);
         $priorityQuestionIds = [];
         $basePoolCount = 0;
         $finalPoolCount = 0;
@@ -1007,6 +1021,7 @@ class ExamTypeStrategy
 
         Log::debug('ExamTypeStrategy: 知识点组卷题池评估', [
             'kp_count' => count($kpCodeList),
+            'supplement_kp_count' => count($kpSupplementSubtreeCodes),
             'pool_count' => $finalPoolCount,
             'exclude_count' => count($answeredQuestionIds),
         ]);
@@ -1014,6 +1029,8 @@ class ExamTypeStrategy
         // 组装增强参数
         $enhanced = array_merge($params, [
             'kp_codes' => $kpCodeList,
+            'kp_supplement_subtree_codes' => $kpSupplementSubtreeCodes,
+            'kp_code_list_original' => $kpCodeList,
             'mistake_question_ids' => $basePoolCount < (int) $totalQuestions ? $priorityQuestionIds : [],
             'exclude_question_ids' => $answeredQuestionIds,
             'paper_name' => $params['paper_name'] ?? ('知识点组题_' . now()->format('Ymd_His')),
@@ -1034,12 +1051,86 @@ class ExamTypeStrategy
 
         Log::debug('ExamTypeStrategy: 知识点组卷参数构建完成', [
             'kp_count' => count($kpCodeList),
+            'supplement_kp_count' => count($kpSupplementSubtreeCodes),
             'exclude_count' => count($answeredQuestionIds),
         ]);
 
         return $enhanced;
     }
 
+    /**
+     * 将用户选中的 kp 扩展为:各选中知识点在 knowledge_points 中「直接父节点」下的整棵子树(含父节点自身及所有后代)之并集。
+     * 多选且同属同一父章节(如 SIM01 下的 SIM01B/C/D)时,distinct parent 仅为 SIM01,等价于纳入 SIM01 整棵树(SIM01A–E 等)的全部 kp_code。
+     * 不在此再上溯祖父:祖父在库中往往挂在更高层(如整册根),会造成超范围并题。
+     *
+     * @return array<string>
+     */
+    private function expandKpCodesWithParentChapterSubtrees(array $kpCodes): array
+    {
+        $kpCodes = array_values(array_unique(array_filter($kpCodes)));
+        if ($kpCodes === []) {
+            return [];
+        }
+
+        $expanded = [];
+        foreach ($kpCodes as $kp) {
+            $expanded[] = $kp;
+        }
+
+        $distinctParents = [];
+        foreach ($kpCodes as $kp) {
+            $p = KnowledgePoint::query()->where('kp_code', $kp)->value('parent_kp_code');
+            if ($p !== null && $p !== '') {
+                $distinctParents[(string) $p] = true;
+            }
+        }
+
+        foreach (array_keys($distinctParents) as $parentKp) {
+            $expanded = array_merge($expanded, $this->getDescendantKpCodesIncludingRoot($parentKp));
+        }
+
+        return array_values(array_unique(array_filter($expanded)));
+    }
+
+    /**
+     * BFS 收集某 kp 节点在 knowledge_points 中的整棵后代子树(含根节点 kp_code)。
+     *
+     * @return array<string>
+     */
+    private function getDescendantKpCodesIncludingRoot(string $rootKpCode): array
+    {
+        $rootKpCode = trim($rootKpCode);
+        if ($rootKpCode === '') {
+            return [];
+        }
+
+        $seen = [$rootKpCode => true];
+        $queue = [$rootKpCode];
+        $guard = 0;
+        $maxIter = 5000;
+
+        while ($queue !== [] && $guard++ < $maxIter) {
+            $cur = array_shift($queue);
+            $children = KnowledgePoint::query()
+                ->where('parent_kp_code', $cur)
+                ->pluck('kp_code')
+                ->all();
+
+            foreach ($children as $child) {
+                $c = (string) $child;
+                if ($c === '') {
+                    continue;
+                }
+                if (! isset($seen[$c])) {
+                    $seen[$c] = true;
+                    $queue[] = $c;
+                }
+            }
+        }
+
+        return array_keys($seen);
+    }
+
     /**
      * 教材组卷 (assembleType=3)
      * 根据 chapter_id_list 查询课本章节,获取知识点,然后组卷
@@ -1145,7 +1236,7 @@ class ExamTypeStrategy
         if (empty($kpCodes)) {
             Log::warning('ExamTypeStrategy: 未找到章节知识点关联,但保留章节筛选参数', [
                 'chapter_id_list' => $chapterIdList,
-                'note' => '将在LearningAnalyticsService中按textbook_catalog_nodes_id字段筛选题目'
+                'note' => 'LearningAnalyticsService 将按 textbook_chapter_knowledge_relation 解析知识点后按 kp_code 选题',
             ]);
         } else {
             Log::info('ExamTypeStrategy: 获取章节知识点(教材组卷严格限制)', [
@@ -1169,8 +1260,19 @@ class ExamTypeStrategy
         // 在LearningAnalyticsService中会使用这些参数进行题目筛选
         $textbookCatalogNodeIds = $chapterIdList; // 直接使用章节ID作为textbook_catalog_node_id筛选条件
 
-        // 【重要】确保 grade 和 textbook_id 传给智能补充;grade 缺失时从教材推断
+        // 【重要】确保 grade 和 textbook_id 传给智能补充;章节节点归属教材,可由 chapter_id_list 反查 textbook_id
         $textbookId = (int) ($params['textbook_id'] ?? 0);
+        if ($textbookId <= 0 && ! empty($chapterIdList)) {
+            $resolvedTextbookId = $this->resolveTextbookIdFromChapterNodes($chapterIdList);
+            if ($resolvedTextbookId !== null) {
+                $textbookId = $resolvedTextbookId;
+                Log::info('ExamTypeStrategy: 由 chapter_id_list 推断 textbook_id', [
+                    'textbook_id' => $textbookId,
+                    'chapter_id_list' => $chapterIdList,
+                ]);
+            }
+        }
+
         $grade = $params['grade'] ?? null;
         if ($textbookId && $grade === null) {
             $grade = DB::table('textbooks')->where('id', $textbookId)->value('grade');
@@ -1217,6 +1319,48 @@ class ExamTypeStrategy
         return $enhanced;
     }
 
+    /**
+     * 章节节点 textbook_catalog_nodes 均归属某本教材;未传 textbook_id 时可根据节点 id 反查。
+     *
+     * @param  array<int|string>  $chapterIdList  解析后的章节/小节节点 id 列表
+     * @return int|null  唯一教材 id;无法解析或节点不存在时返回 null
+     */
+    private function resolveTextbookIdFromChapterNodes(array $chapterIdList): ?int
+    {
+        $ids = array_values(array_unique(array_filter(array_map(
+            static fn ($id) => (int) $id,
+            $chapterIdList
+        ), static fn (int $id) => $id > 0)));
+
+        if ($ids === []) {
+            return null;
+        }
+
+        $textbookIds = DB::table('textbook_catalog_nodes')
+            ->whereIn('id', $ids)
+            ->pluck('textbook_id')
+            ->unique()
+            ->values()
+            ->all();
+
+        if ($textbookIds === []) {
+            Log::warning('ExamTypeStrategy: chapter_id_list 在 textbook_catalog_nodes 中无匹配行', [
+                'chapter_ids' => $ids,
+            ]);
+
+            return null;
+        }
+
+        if (count($textbookIds) > 1) {
+            Log::warning('ExamTypeStrategy: chapter_id_list 对应多本教材,取首个 textbook_id', [
+                'textbook_ids' => $textbookIds,
+                'chapter_ids' => $ids,
+            ]);
+        }
+
+        return (int) $textbookIds[0];
+    }
+
     /**
      * 根据课本ID获取章节ID列表
      * @param int $textbookId 教材ID

+ 189 - 17
app/Services/LearningAnalyticsService.php

@@ -1442,7 +1442,37 @@ class LearningAnalyticsService
                         $textbookId,                    // 教材ID(避免超纲)
                         $difficultyCategory             // 难度类别
                     );
-                    $allQuestions = array_merge($priorityQuestions, $additionalQuestions);
+                    $allQuestions = $this->dedupeQuestionsByBankId(array_merge($priorityQuestions, $additionalQuestions));
+
+                    // assemble_type=2:本源 KP 合并后仍不足,用父树补题 KP 拉题(与 getQuestionsFromBank 主条件一致,走专用列表,不混入本源查询)
+                    if (
+                        $assembleType === 2
+                        && count($allQuestions) < $totalQuestions
+                        && ! empty($params['kp_supplement_subtree_codes'] ?? [])
+                        && $grade !== null
+                    ) {
+                        $excludeForSupp = array_values(array_unique(array_filter(array_merge(
+                            $excludeQuestionIds,
+                            array_column($allQuestions, 'id')
+                        ))));
+                        $supp = $this->fetchQuestionsForKpAssembleSupplement(
+                            $params['kp_supplement_subtree_codes'],
+                            $excludeForSupp,
+                            (int) $grade,
+                            $skills,
+                            $questionCategory,
+                            $textbookCatalogNodeIds
+                        );
+                        $before = count($allQuestions);
+                        $allQuestions = $this->dedupeQuestionsByBankId(array_merge($allQuestions, $supp));
+                        Log::info('LearningAnalyticsService: 知识点组卷父树补题(本源池不足后)', [
+                            'source_merged_count' => $before,
+                            'supplement_fetched' => count($supp),
+                            'after_merge_count' => count($allQuestions),
+                            'target_total' => $totalQuestions,
+                            'supplement_kp_sample' => array_slice($params['kp_supplement_subtree_codes'], 0, 12),
+                        ]);
+                    }
 
                     Log::info('getQuestionsFromBank 完成', [
                         'questions_count' => count($allQuestions),
@@ -1588,6 +1618,104 @@ class LearningAnalyticsService
         }
     }
 
+    /**
+     * @param  array<int, array<string, mixed>>  $questions
+     * @return array<int, array<string, mixed>>
+     */
+    private function dedupeQuestionsByBankId(array $questions): array
+    {
+        $seen = [];
+        $out = [];
+        foreach ($questions as $q) {
+            $id = $q['id'] ?? null;
+            if ($id === null || $id === '') {
+                continue;
+            }
+            $k = (string) $id;
+            if (isset($seen[$k])) {
+                continue;
+            }
+            $seen[$k] = true;
+            $out[] = $q;
+        }
+
+        return $out;
+    }
+
+    /**
+     * 知识点组卷补题:仅按补充 KP 列表查询,筛选条件与 getQuestionsFromBank 主查询一致(不调用教材/已学那条智能补题)。
+     *
+     * @param  array<string>  $kpCodes
+     * @param  array<int|string>  $excludeQuestionIds
+     * @return array<int, array<string, mixed>>
+     */
+    private function fetchQuestionsForKpAssembleSupplement(
+        array $kpCodes,
+        array $excludeQuestionIds,
+        int $grade,
+        array $skills = [],
+        ?int $questionCategory = null,
+        ?array $textbookCatalogNodeIds = null
+    ): array {
+        $kpCodes = array_values(array_unique(array_filter($kpCodes)));
+        if ($kpCodes === []) {
+            return [];
+        }
+
+        $query = \App\Models\Question::query()
+            ->where('audit_status', 0)
+            ->whereIn('kp_code', $kpCodes);
+
+        $stageGrade = $this->normalizeQuestionStageGrade($grade);
+        if ($stageGrade !== null) {
+            $query->where('grade', $stageGrade);
+        }
+
+        if (! empty($skills)) {
+            $query->where(function ($q) use ($skills) {
+                foreach ($skills as $skill) {
+                    $q->orWhere('tags', 'like', '%'.$skill.'%');
+                }
+            });
+        }
+
+        if (! empty($excludeQuestionIds)) {
+            $query->whereNotIn('id', $excludeQuestionIds);
+        }
+
+        if ($questionCategory !== null) {
+            $query->where('question_category', $questionCategory);
+        }
+
+        $query->whereNotNull('solution')
+            ->where('solution', '!=', '')
+            ->where('solution', '!=', '[]');
+
+        if (! empty($textbookCatalogNodeIds)) {
+            $query->whereIn('textbook_catalog_nodes_id', $textbookCatalogNodeIds);
+        }
+
+        return $query->inRandomOrder()->get()->map(function ($q) {
+            return [
+                'id' => $q->id,
+                'question_code' => $q->question_code,
+                'kp_code' => $q->kp_code,
+                'question_type' => $q->question_type,
+                'difficulty' => $q->difficulty !== null ? (float) $q->difficulty : 0.5,
+                'stem' => $q->stem,
+                'solution' => $q->solution,
+                'metadata' => [
+                    'has_solution' => true,
+                    'is_choice' => $q->question_type === 'choice',
+                    'is_fill' => $q->question_type === 'fill',
+                    'is_answer' => $q->question_type === 'answer',
+                    'difficulty_label' => $this->getDifficultyLabel($q->difficulty ?? 0.5),
+                    'question_type_label' => $this->getQuestionTypeLabel($q->question_type),
+                ],
+            ];
+        })->toArray();
+    }
+
     /**
      * 从题库获取题目
      *
@@ -2957,6 +3085,39 @@ class LearningAnalyticsService
                 return [];
             }
 
+            // 有教材且带章节:用 textbook_chapter_knowledge_relation 由「前章节」解析知识点,再按 kp_code 选题(不用 questions.textbook_catalog_nodes_id)
+            $effectiveKpCodes = $gradeKpCodes;
+            if ($textbookId && ! empty($textbookCatalogNodeIds)) {
+                $allowedNodeIds = $this->getEarlierChapterNodeIds((int) $textbookId, $textbookCatalogNodeIds);
+                if (empty($allowedNodeIds)) {
+                    Log::warning('getSupplementaryQuestionsForGrade: 未找到前章节节点,跳过补充');
+
+                    return [];
+                }
+                $chapterKpCodes = $this->getKpCodesForCatalogChapterIds($allowedNodeIds);
+                if (empty($chapterKpCodes)) {
+                    Log::warning('getSupplementaryQuestionsForGrade: 前章节在 textbook_chapter_knowledge_relation 中无知识点', [
+                        'allowed_node_ids' => $allowedNodeIds,
+                    ]);
+
+                    return [];
+                }
+                $effectiveKpCodes = array_values(array_intersect($gradeKpCodes, $chapterKpCodes));
+                if (empty($effectiveKpCodes)) {
+                    Log::warning('getSupplementaryQuestionsForGrade: 教材年级知识点与章节关联知识点无交集', [
+                        'grade_kp_count' => count($gradeKpCodes),
+                        'chapter_kp_count' => count($chapterKpCodes),
+                    ]);
+
+                    return [];
+                }
+                Log::info('getSupplementaryQuestionsForGrade: 按章节关联知识点缩小补充范围', [
+                    'allowed_chapter_nodes' => count($allowedNodeIds),
+                    'chapter_kp_count' => count($chapterKpCodes),
+                    'effective_kp_count' => count($effectiveKpCodes),
+                ]);
+            }
+
             // 查询同年级其他知识点的题目
             $query = \App\Models\Question::query();
 
@@ -2982,22 +3143,7 @@ class LearningAnalyticsService
                 $query->whereNotIn('kp_code', $existingKpCodes);
             }
 
-            $query->whereIn('kp_code', $gradeKpCodes);
-
-            // 【新增】仅从同教材前章节补充:部分章节尚未学过,不补充未学章节的题目
-            if ($textbookId && !empty($textbookCatalogNodeIds)) {
-                $allowedNodeIds = $this->getEarlierChapterNodeIds((int) $textbookId, $textbookCatalogNodeIds);
-                if (!empty($allowedNodeIds)) {
-                    $query->whereIn('textbook_catalog_nodes_id', $allowedNodeIds);
-                    Log::info('getSupplementaryQuestionsForGrade: 限制为前章节', [
-                        'allowed_node_count' => count($allowedNodeIds),
-                        'max_sort_order' => '同选中章节及之前'
-                    ]);
-                } else {
-                    Log::warning('getSupplementaryQuestionsForGrade: 未找到前章节节点,跳过补充');
-                    return [];
-                }
-            }
+            $query->whereIn('kp_code', $effectiveKpCodes);
 
             // 筛选有解题思路的题目
             $query->whereNotNull('solution')
@@ -3180,6 +3326,32 @@ class LearningAnalyticsService
         }
     }
 
+    /**
+     * 由 catalog 章节节点 ID 列表,从 textbook_chapter_knowledge_relation 取关联的知识点编码(用于选题,而非题目表上的章节字段)。
+     *
+     * @param  array<int>  $catalogChapterIds  textbook_catalog_nodes.id
+     * @return list<string>
+     */
+    private function getKpCodesForCatalogChapterIds(array $catalogChapterIds): array
+    {
+        $ids = array_values(array_unique(array_filter(array_map(
+            static fn ($id) => (int) $id,
+            $catalogChapterIds
+        ), static fn (int $id) => $id > 0)));
+
+        if ($ids === []) {
+            return [];
+        }
+
+        return array_values(array_filter(array_unique(DB::table('textbook_chapter_knowledge_relation')
+            ->whereIn('catalog_chapter_id', $ids)
+            ->where(function ($q) {
+                $q->where('is_deleted', 0)->orWhereNull('is_deleted');
+            })
+            ->pluck('kp_code')
+            ->toArray())));
+    }
+
     private function getGradeKnowledgePoints(int $grade, ?int $textbookId = null): array
     {
         try {

+ 6 - 0
app/Services/MathFormulaProcessor.php

@@ -476,6 +476,12 @@ class MathFormulaProcessor
             'solution', 'question_content', 'options',
         ];
 
+        // 学情报告等场景:题干已按判卷口径完整处理(含 processFormulas),避免二次处理
+        if (! empty($question['question_text_preprocessed'])) {
+            $fieldsToProcess = array_values(array_diff($fieldsToProcess, ['question_text']));
+        }
+        unset($question['question_text_preprocessed']);
+
         return self::processArray($question, $fieldsToProcess);
     }
 

+ 153 - 0
app/Support/GradingStyleQuestionStem.php

@@ -0,0 +1,153 @@
+<?php
+
+namespace App\Support;
+
+use App\Services\MathFormulaProcessor;
+
+/**
+ * 与判卷页 components/exam/paper-body 选择题题干处理口径一致:
+ * 去题号前缀、separateStemAndOptions 分离选项、填空占位虚线、MathFormulaProcessor::processFormulas
+ */
+class GradingStyleQuestionStem
+{
+    /**
+     * 选项字母映射由下方 separateStemAndOptions / extractOptions 从正文解析;与判卷页一致。
+     */
+    public static function buildChoiceStemForReport(string $rawQuestionText): string
+    {
+        $html = is_string($rawQuestionText) ? $rawQuestionText : '';
+        $cleanContent = preg_replace('/^\d+[\.、]\s*/', '', trim($html));
+
+        [$stemLine] = self::separateStemAndOptions($cleanContent);
+
+        $renderedStem = self::applyBlankPlaceholdersLikeGrading($stemLine);
+
+        return MathFormulaProcessor::processFormulas($renderedStem);
+    }
+
+    /**
+     * 与 ExamPdfController::separateStemAndOptions 一致
+     *
+     * @return array{0: string, 1: array<int, string>}
+     */
+    public static function separateStemAndOptions(string $content): array
+    {
+        $contentWithoutSvg = preg_replace('/<svg[^>]*>.*?<\/svg>/is', '[SVG_PLACEHOLDER]', $content);
+
+        $hasOptions = preg_match('/(?:^|\s)[A-D][\.、:.:]/u', $contentWithoutSvg);
+
+        if (! $hasOptions) {
+            return [$content, []];
+        }
+
+        $options = self::extractOptions($content);
+
+        if (! empty($options)) {
+            if (preg_match('/^(.+?)(?=(?:^|\s)[A-D][\.、:.:])/su', $contentWithoutSvg, $match)) {
+                $stem = trim($match[1]);
+                if (strpos($stem, '[SVG_PLACEHOLDER]') !== false) {
+                    foreach (['A.', 'A、', 'A:', 'A.', 'A:'] as $marker) {
+                        if (preg_match('/\s'.preg_quote($marker, '/').'/', $content, $m, PREG_OFFSET_CAPTURE)) {
+                            $pos = $m[0][1];
+                            if ($pos > 0) {
+                                $stem = trim(mb_substr($content, 0, $pos));
+                                break;
+                            }
+                        }
+                    }
+                }
+            } else {
+                $stem = $content;
+                foreach (['A.', 'A、', 'A:', 'A.', 'A:'] as $marker) {
+                    if (preg_match('/\s'.preg_quote($marker, '/').'/', $content, $m, PREG_OFFSET_CAPTURE)) {
+                        $pos = $m[0][1];
+                        if ($pos > 0) {
+                            $stem = trim(mb_substr($content, 0, $pos));
+                            break;
+                        }
+                    }
+                }
+            }
+
+            $stem = preg_replace('/()\s*$/', '', $stem);
+            $stem = trim($stem);
+
+            return [$stem, $options];
+        }
+
+        return [$content, []];
+    }
+
+    /**
+     * 与 ExamPdfController::extractOptions 一致
+     *
+     * @return array<int, string>
+     */
+    public static function extractOptions(string $content): array
+    {
+        $options = [];
+        $contentWithoutSvg = preg_replace('/<svg[^>]*>.*?<\/svg>/is', '[SVG_PLACEHOLDER]', $content);
+        $pattern = '/(?:^|\s)([A-D])[\.、:.:]\s*(.+?)(?=(?:^|\s)[A-D][\.、:.:]|$)/su';
+
+        if (preg_match_all($pattern, $contentWithoutSvg, $matches, PREG_SET_ORDER)) {
+            foreach ($matches as $match) {
+                $optionText = trim($match[2]);
+                $optionText = preg_replace('/\s+$/', '', $optionText);
+                $optionText = preg_replace('/^\$\$\s*/', '', $optionText);
+                $optionText = preg_replace('/\s*\$\$$/', '', $optionText);
+                if (! empty($optionText)) {
+                    $options[] = $optionText;
+                }
+            }
+        }
+
+        if (empty($options)) {
+            $lines = preg_split('/[\r\n]+/', $contentWithoutSvg);
+            foreach ($lines as $line) {
+                $line = trim($line);
+                if (preg_match('/^([A-D])[\.、:.:]\s*(.+)$/u', $line, $match)) {
+                    $optionText = trim($match[2]);
+                    if (! empty($optionText)) {
+                        $options[] = $optionText;
+                    }
+                }
+            }
+        }
+
+        return $options;
+    }
+
+    /**
+     * 与 paper-body 选择题中 $renderedStem 逻辑一致
+     */
+    private static function applyBlankPlaceholdersLikeGrading(string $stemLine): string
+    {
+        $blankSpan = '<span style="display:inline-block; min-width:80px; border-bottom:1.2px dashed #444; vertical-align:bottom;">&nbsp;</span>';
+        $renderedStem = $stemLine;
+        $renderedStem = preg_replace('/\\\underline\{[^}]*\}/', $blankSpan, $renderedStem);
+        $renderedStem = preg_replace('/\\\qquad+/', $blankSpan, $renderedStem);
+
+        $latexPlaceholders = [];
+        $counter = 0;
+        $renderedStem = preg_replace_callback('/\$[^$]+\$/u', function ($matches) use (&$latexPlaceholders, &$counter) {
+            $placeholder = '<<<LATEX_'.$counter.'>>>';
+            $latexPlaceholders[$placeholder] = $matches[0];
+            $counter++;
+
+            return $placeholder;
+        }, $renderedStem);
+
+        $renderedStem = preg_replace(['/(\s*)/u', '/\(\s*\)/', '/_{2,}/'], $blankSpan, $renderedStem);
+
+        foreach ($latexPlaceholders as $placeholder => $latexContent) {
+            $encodedLatex = htmlspecialchars($latexContent, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+            $renderedStem = str_replace($placeholder, $encodedLatex, $renderedStem);
+        }
+
+        if ($renderedStem === $stemLine) {
+            $renderedStem .= ' '.$blankSpan;
+        }
+
+        return $renderedStem;
+    }
+}

+ 3 - 0
config/question_bank.php

@@ -10,4 +10,7 @@ return [
     'retry_delay' => (int) env('QUESTION_BANK_RETRY_DELAY', 200),
 
     'mode' => env('QUESTION_BANK_MODE', 'local'),
+
+    /** 知识点题量统计:教材章节顺序用哪一册(textbooks.semester,默认 2=下学期) */
+    'kp_stats_semester' => (int) env('KP_STATS_SEMESTER', 2),
 ];

+ 219 - 17
resources/views/exam-analysis/pdf-report.blade.php

@@ -194,14 +194,60 @@
             font-size: 12px;
             line-height: 1.7;
         }
-        .question-card .math-content img,
-        .question-card img {
-            max-width: 100% !important;
-            width: auto !important;
-            height: auto !important;
+        /* 题干区:与判卷 PDF(exam-grading)一致 — <img>、内嵌 SVG、公式 */
+        .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;
@@ -212,6 +258,58 @@
             page-break-inside: auto;
             break-inside: auto;
         }
+        /* 与判卷 PDF(exam-grading / paper-body)一致的选项网格 */
+        .report-options {
+            margin-top: 6px;
+            page-break-inside: avoid;
+            break-inside: avoid;
+        }
+        .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: avoid;
+            break-inside: avoid;
+        }
+        .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;
+        }
+        /* 判卷风格:解题思路(与 answer-meta 一致) */
+        .report-answer-meta {
+            font-size: 12px;
+            color: #2f2f2f;
+            line-height: 1.75;
+            margin-top: 6px;
+            page-break-inside: avoid;
+            break-inside: avoid;
+        }
+        .report-answer-meta .answer-line + .answer-line { margin-top: 4px; }
+        .report-answer-meta .solution-content {
+            display: inline;
+            line-height: 1.75;
+        }
     </style>
 </head>
 <body>
@@ -666,6 +764,9 @@
                 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;
@@ -690,6 +791,9 @@
                 $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);
                 // 对齐判卷渲染口径:直接走 MathFormulaProcessor,保留图片与公式标签
@@ -703,6 +807,74 @@
                     }
                     return \App\Services\MathFormulaProcessor::processFormulas($text);
                 };
+                // 选择题:选项来自服务端 questions.options / 题干解析;正确答案字母用于打 ✅
+                $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;">
@@ -723,30 +895,60 @@
                     @endif
                 </div>
 
-                <div class="math-content" style="margin-bottom:6px;">{!! $questionText !!}</div>
+                {{-- 题干:数据已在 ExamPdfExportService 中经 processQuestionData(含 <image>→<img>、公式);样式对齐判卷 question-stem --}}
+                <div class="question-stem math-content" style="margin-bottom:6px;">{!! $questionText !!}</div>
 
-                {{-- 【修复】正确答案显示 --}}
-                @if(!empty($correctAnswer))
+                {{-- 选择题:与判卷页相同网格布局(OptionLayoutDecider),正确项旁 ✅ --}}
+                @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;">
-                            {!! is_string($correctAnswer) ? $correctAnswer : json_encode($correctAnswer, JSON_UNESCAPED_UNICODE) !!}
+                            {!! $renderLikeGrading($displayCorrectAnswer) !!}
                         </div>
                     </div>
                 @endif
 
-                {{-- 【修改】解题思路显示(优先显示solution,其次显示analysis) --}}
+                {{-- 解题思路:判卷页 answer-meta 口径(单行标题+正文,避免大块色条占行) --}}
                 @if(!empty($solution))
-                    <div class="question-block" style="margin-top:6px; background:#eff6ff; border-left:3px solid #3b82f6;">
-                        <div style="font-weight:600; font-size:12px; color:#111827; margin-bottom:4px;">解题思路</div>
-                        <div class="math-content solution-content" style="color:#374151;">
-                            {!! $renderLikeGrading($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="question-block" style="margin-top:6px; background:#eff6ff; border-left:3px solid #3b82f6;">
-                        <div style="font-weight:600; font-size:12px; color:#111827; margin-bottom:4px;">解题思路</div>
-                        <div class="math-content solution-content" style="color:#374151;">{!! $renderLikeGrading($analysis) !!}</div>
+                    <div class="report-answer-meta">
+                        <div class="answer-line">
+                            <strong>解题思路:</strong>
+                            <span class="solution-content">{!! $renderLikeGrading($analysis) !!}</span>
+                        </div>
                     </div>
                 @endif