Quellcode durchsuchen

Merge branch 'feat/exam-assemble-fixes': 组卷教材章节知识点与卷子 PDF 修复

yemeishu vor 6 Tagen
Ursprung
Commit
d1e88deec9

+ 119 - 0
app/Console/Commands/RunQuestionQualityCheckCommand.php

@@ -0,0 +1,119 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\QuestionQualityCheckService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class RunQuestionQualityCheckCommand extends Command
+{
+    protected $signature = 'question:qc
+        {--table=questions_tem : 待质检题目表名}
+        {--kp= : 指定知识点,不传则按下学期题少 KP 筛选}
+        {--limit=100 : 质检题目数量上限}
+        {--textbook= : 教材 ID}
+        {--semester=2 : 学期 1=上 2=下}
+        {--dry-run : 仅输出筛选结果,不执行质检}';
+
+    protected $description = '题目自动质检:从 questions_tem 按下学期题少 KP 筛选题目并执行校验';
+
+    public function handle(QuestionQualityCheckService $qcService): int
+    {
+        $table = $this->option('table');
+
+        if (! Schema::hasTable($table)) {
+            $this->error("表 {$table} 不存在,请先创建或指定正确表名");
+            return 1;
+        }
+
+        $kp = $this->option('kp');
+        $limit = (int) $this->option('limit');
+        $dryRun = $this->option('dry-run');
+
+        if ($kp) {
+            $questions = DB::table($table)
+                ->where('kp_code', $kp)
+                ->limit($limit)
+                ->get();
+        } else {
+            $kps = $qcService->getKpsWithFewQuestions(
+                $this->option('textbook') ? (int) $this->option('textbook') : null,
+                (int) $this->option('semester'),
+                20
+            );
+
+            if (empty($kps)) {
+                $this->warn('未找到题少的 KP,请检查 textbooks、textbook_chapter_knowledge_relation 数据');
+                return 0;
+            }
+
+            $this->info('按下学期题少 KP 筛选,前 5 个:');
+            foreach (array_slice($kps, 0, 5) as $r) {
+                $this->line("  {$r['kp_code']}: {$r['question_count']} 题");
+            }
+
+            $kpCodes = array_column($kps, 'kp_code');
+            $questions = DB::table($table)
+                ->whereIn('kp_code', $kpCodes)
+                ->limit($limit)
+                ->get();
+        }
+
+        $total = $questions->count();
+        $this->info("待质检题目数: {$total}");
+
+        if ($dryRun) {
+            $this->info('[dry-run] 不执行质检');
+            return 0;
+        }
+
+        $passed = 0;
+        $failed = 0;
+
+        $bar = $this->output->createProgressBar($total);
+        $bar->start();
+
+        foreach ($questions as $q) {
+            $row = (array) $q;
+            $mapped = $this->mapQuestionRow($row);
+            $result = $qcService->runAutoCheck(
+                $mapped,
+                $row['id'] ?? null,
+                null
+            );
+
+            if ($result['passed']) {
+                $passed++;
+            } else {
+                $failed++;
+                $this->newLine();
+                $this->warn("  [{$row['id']}] " . implode(', ', $result['errors']));
+            }
+            $bar->advance();
+        }
+
+        $bar->finish();
+        $this->newLine(2);
+        $this->info("质检完成: 通过 {$passed},未通过 {$failed}");
+
+        return 0;
+    }
+
+    /**
+     * 将 questions_tem 行映射为质检服务所需格式
+     */
+    private function mapQuestionRow(array $row): array
+    {
+        return [
+            'stem' => $row['stem'] ?? $row['content'] ?? '',
+            'answer' => $row['answer'] ?? $row['correct_answer'] ?? '',
+            'solution' => $row['solution'] ?? '',
+            'question_type' => $row['question_type'] ?? $row['tags'] ?? '',
+            'options' => is_string($row['options'] ?? null)
+                ? json_decode($row['options'], true)
+                : ($row['options'] ?? null),
+        ];
+    }
+}

+ 53 - 0
app/Services/ExamPdfExportService.php

@@ -73,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

+ 55 - 2
app/Services/ExamTypeStrategy.php

@@ -1145,7 +1145,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 +1169,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 +1228,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

+ 60 - 16
app/Services/LearningAnalyticsService.php

@@ -2957,6 +2957,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 +3015,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 +3198,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 {

+ 212 - 0
app/Services/QuestionQualityCheckService.php

@@ -0,0 +1,212 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 题目质检服务
+ *
+ * 校验规则:题干、答案、解析、选项、公式、PDF 呈现
+ * 结果由命令输出,不落库(避免本地库覆盖)
+ */
+class QuestionQualityCheckService
+{
+    public const RULES = [
+        'STEM_EMPTY' => ['name' => '题干为空', 'severity' => 'error'],
+        'ANSWER_EMPTY' => ['name' => '答案为空', 'severity' => 'error'],
+        'SOLUTION_EMPTY' => ['name' => '解析为空', 'severity' => 'warning'],
+        'CHOICE_OPTIONS_MISSING' => ['name' => '选择题缺选项', 'severity' => 'error'],
+        'FORMULA_INVALID' => ['name' => '公式异常', 'severity' => 'error'],
+        'CONTENT_TOO_SHORT' => ['name' => '题干过短', 'severity' => 'warning'],
+    ];
+
+    /**
+     * 对单道题目执行自动质检
+     *
+     * @param array $question 题目数据,需包含 stem, answer, solution, question_type, options
+     * @param int|null $questionTemId questions_tem 表 ID
+     * @param int|null $questionId questions 表 ID
+     * @return array ['passed' => bool, 'results' => array, 'errors' => array]
+     */
+    public function runAutoCheck(array $question, ?int $questionTemId = null, ?int $questionId = null): array
+    {
+        $stem = $question['stem'] ?? $question['content'] ?? '';
+        $answer = $question['answer'] ?? '';
+        $solution = $question['solution'] ?? '';
+        $questionType = $question['question_type'] ?? ($question['tags'] ?? '');
+        $options = $question['options'] ?? null;
+
+        $results = [];
+        $errors = [];
+
+        // STEM_EMPTY / CONTENT_TOO_SHORT
+        $stemLen = mb_strlen(trim((string) $stem));
+        if ($stemLen === 0) {
+            $results[] = $this->recordCheck('STEM_EMPTY', false, '题干为空');
+            $errors[] = 'STEM_EMPTY';
+        } elseif ($stemLen < 5) {
+            $results[] = $this->recordCheck('CONTENT_TOO_SHORT', false, "题干过短({$stemLen}字)");
+            $errors[] = 'CONTENT_TOO_SHORT';
+        } else {
+            $results[] = $this->recordCheck('STEM_EMPTY', true);
+        }
+
+        // ANSWER_EMPTY
+        if (trim((string) $answer) === '') {
+            $results[] = $this->recordCheck('ANSWER_EMPTY', false, '答案为空');
+            $errors[] = 'ANSWER_EMPTY';
+        } else {
+            $results[] = $this->recordCheck('ANSWER_EMPTY', true);
+        }
+
+        // SOLUTION_EMPTY(解答题强校验)
+        $isAnswerType = in_array(strtolower((string) $questionType), ['answer', '解答题', '解答'], true);
+        if ($isAnswerType && trim((string) $solution) === '') {
+            $results[] = $this->recordCheck('SOLUTION_EMPTY', false, '解答题解析为空');
+            $errors[] = 'SOLUTION_EMPTY';
+        } else {
+            $results[] = $this->recordCheck('SOLUTION_EMPTY', true);
+        }
+
+        // CHOICE_OPTIONS_MISSING
+        $isChoice = in_array(strtolower((string) $questionType), ['choice', '选择题', 'select'], true);
+        if ($isChoice) {
+            $optsOk = is_array($options) && count($options) >= 2;
+            if (!$optsOk) {
+                $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', false, '选择题选项为空或不足2个');
+                $errors[] = 'CHOICE_OPTIONS_MISSING';
+            } else {
+                $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true);
+            }
+        } else {
+            $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true, null, 'skip');
+        }
+
+        // FORMULA_INVALID(尝试处理公式,捕获异常)
+        try {
+            $processed = MathFormulaProcessor::processFormulas($stem);
+            $processedAnswer = MathFormulaProcessor::processFormulas($answer);
+            $processedSolution = MathFormulaProcessor::processFormulas($solution);
+
+            $hasError = $this->detectFormulaError($processed)
+                || $this->detectFormulaError($processedAnswer)
+                || $this->detectFormulaError($processedSolution);
+
+            if ($hasError) {
+                $results[] = $this->recordCheck('FORMULA_INVALID', false, '公式定界符不匹配或存在异常');
+                $errors[] = 'FORMULA_INVALID';
+            } else {
+                $results[] = $this->recordCheck('FORMULA_INVALID', true);
+            }
+        } catch (\Throwable $e) {
+            $results[] = $this->recordCheck('FORMULA_INVALID', false, $e->getMessage());
+            $errors[] = 'FORMULA_INVALID';
+        }
+
+        $passed = empty($errors);
+
+        return [
+            'passed' => $passed,
+            'results' => $results,
+            'errors' => $errors,
+        ];
+    }
+
+    /**
+     * 检测公式处理后的内容是否仍有明显错误(如未闭合的 $)
+     */
+    private function detectFormulaError(string $content): bool
+    {
+        $len = strlen($content);
+        $dollarCount = 0;
+        $inEscape = false;
+        for ($i = 0; $i < $len; $i++) {
+            $c = $content[$i];
+            if ($c === '\\' && !$inEscape) {
+                $inEscape = true;
+                continue;
+            }
+            if ($c === '$') {
+                $dollarCount++;
+            }
+            $inEscape = false;
+        }
+        return ($dollarCount % 2) !== 0;
+    }
+
+    private function recordCheck(string $ruleCode, bool $passed, ?string $detail = null, string $result = 'pass'): array
+    {
+        $info = self::RULES[$ruleCode] ?? ['name' => $ruleCode, 'severity' => 'error'];
+        return [
+            'rule_code' => $ruleCode,
+            'rule_name' => $info['name'],
+            'passed' => $passed,
+            'auto_result' => $passed ? 'pass' : ($result === 'skip' ? 'skip' : 'fail'),
+            'detail' => $detail,
+        ];
+    }
+
+    /**
+     * 获取下学期章节关联的、题少的 KP 列表(用于筛选 questions_tem)
+     *
+     * @param int|null $textbookId 教材 ID,null 则取默认教材
+     * @param int $semesterCode 学期 1=上 2=下
+     * @param int $limit 返回前 N 个题少的 KP
+     * @return array [['kp_code' => string, 'question_count' => int], ...]
+     */
+    public function getKpsWithFewQuestions(?int $textbookId = null, int $semesterCode = 2, int $limit = 50): array
+    {
+        $textbooksQuery = DB::table('textbooks');
+        if (Schema::hasColumn('textbooks', 'is_deleted')) {
+            $textbooksQuery->where('is_deleted', 0);
+        }
+        if ($textbookId) {
+            $textbooksQuery->where('id', $textbookId);
+        }
+        if (Schema::hasColumn('textbooks', 'semester_code')) {
+            $textbooksQuery->where('semester_code', $semesterCode);
+        }
+
+        $textbookIds = $textbooksQuery->pluck('id')->toArray();
+        if (empty($textbookIds)) {
+            Log::warning('QuestionQualityCheckService: 未找到下学期教材');
+            return [];
+        }
+
+        $kpCodes = DB::table('textbook_chapter_knowledge_relation as tckr')
+            ->join('textbook_catalog_nodes as tcn', 'tckr.catalog_chapter_id', '=', 'tcn.id')
+            ->whereIn('tcn.textbook_id', $textbookIds)
+            ->where(function ($q) {
+                $q->where('tckr.is_deleted', 0)->orWhereNull('tckr.is_deleted');
+            })
+            ->distinct()
+            ->pluck('tckr.kp_code')
+            ->toArray();
+
+        if (empty($kpCodes)) {
+            return [];
+        }
+
+        $counts = DB::table('questions')
+            ->whereIn('kp_code', $kpCodes)
+            ->where('audit_status', 0)
+            ->selectRaw('kp_code, count(*) as cnt')
+            ->groupBy('kp_code')
+            ->pluck('cnt', 'kp_code')
+            ->toArray();
+
+        $result = [];
+        foreach ($kpCodes as $kp) {
+            $result[] = [
+                'kp_code' => $kp,
+                'question_count' => $counts[$kp] ?? 0,
+            ];
+        }
+
+        usort($result, fn ($a, $b) => $a['question_count'] <=> $b['question_count']);
+        return array_slice($result, 0, $limit);
+    }
+}

+ 88 - 0
docs/题库质检方案.md

@@ -0,0 +1,88 @@
+# 题库质检方案
+
+## 一、背景
+
+### questions_tem 表预期结构(与 questions 对齐)
+
+| 字段 | 说明 |
+|------|------|
+| id | 主键 |
+| stem / content | 题干 |
+| answer / correct_answer | 答案 |
+| solution | 解析 |
+| question_type / tags | 题型 |
+| options | 选项(JSON) |
+| kp_code | 知识点 |
+
+- 待入库题目在 `questions_tem` 中,约 2 万道
+- 需与 `questions` 正式表比对,确保不重复
+- 入库前**严格校验**,以学案/PDF 导出场景验证
+
+## 二、质检机制
+
+### 2.1 题目质检(自动 + 人工)
+
+| 校验项 | 说明 | 自动 | 人工 |
+|--------|------|------|------|
+| 缺题干 stem | 题干为空或过短 | ✓ | ✓ |
+| 缺答案 answer | 答案为空 | ✓ | ✓ |
+| 缺解析 solution | 解答题为空需标记 | ✓ | ✓ |
+| 选择题缺选项 | choice 题型 options 为空或不足 | ✓ | ✓ |
+| 公式乱码 | LaTeX 解析失败、出现未渲染符 | ✓ | ✓ |
+| PDF 呈现 | 实际导出 PDF 是否乱码/错位 | ✓ | ✓ |
+
+### 2.2 学案质检(可撤销)
+
+- 学案使用场景:PDF 导出验证
+- 连续 **10000 份** 无误 → 可撤销该环节以节省成本
+
+### 2.3 结果记录
+
+- 质检结果由命令输出,不落库(避免本地库覆盖导致表丢失)
+- 后续可接入 question_qc_results 等表做持久化
+
+## 三、校验规则清单(可扩展)
+
+| rule_code | 名称 | 说明 |
+|-----------|------|------|
+| STEM_EMPTY | 题干为空 | stem 为空或 trim 后长度 < 5 |
+| ANSWER_EMPTY | 答案为空 | answer 为空 |
+| SOLUTION_EMPTY | 解析为空 | solution 为空(解答题强校验) |
+| CHOICE_OPTIONS_MISSING | 选择题缺选项 | question_type=choice 时 options 为空或非数组 |
+| FORMULA_INVALID | 公式异常 | LaTeX 定界符不匹配、无法解析 |
+| PDF_RENDER_FAIL | PDF 渲染失败 | 导出 PDF 后检测乱码/空白 |
+
+## 四、筛选逻辑(从下学期章节开始)
+
+1. 获取**下学期**教材章节(`textbooks.semester_code` 或类似)
+2. 通过 `textbook_chapter_knowledge_relation` 得到章节关联的 `kp_code`
+3. 统计 `questions` 中每个 kp_code 的题目数量
+4. **按题数升序**:优先选「题少的 KP」补充
+5. 从 `questions_tem` 中筛选该 KP 的题目 → 质检 → 合格后入库
+
+## 五、命令用法
+
+```bash
+# 按下学期题少 KP 筛选,质检 100 题
+php artisan question:qc
+
+# 指定知识点
+php artisan question:qc --kp=S01
+
+# 仅筛选不质检
+php artisan question:qc --dry-run
+
+# 指定教材、学期
+php artisan question:qc --textbook=1 --semester=2 --limit=50
+```
+
+## 六、流程
+
+```
+questions_tem 选题(按下学期 KP 题少优先)
+    → 自动质检(规则引擎)
+    → 记录 auto_result
+    → (可选)PDF 导出预演,检测渲染
+    → 人工复核,记录 manual_result
+    → 全部通过 → 入库 questions
+```