瀏覽代碼

feat: 题库质检体系 - 校验规则与下学期题少 KP 筛选

- 新增 QuestionQualityCheckService:题干/答案/解析/选项/公式校验
- question_qc_results 表:自动+人工质检结果分别记录
- question:qc 命令:按下学期章节题少 KP 筛选 question_tem 并执行质检
- docs/题库质检方案.md:完整方案与命令用法

Made-with: Cursor
yemeishu 1 周之前
父節點
當前提交
1f1fd2d9a6

+ 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=question_tem : 待质检题目表名}
+        {--kp= : 指定知识点,不传则按下学期题少 KP 筛选}
+        {--limit=100 : 质检题目数量上限}
+        {--textbook= : 教材 ID}
+        {--semester=2 : 学期 1=上 2=下}
+        {--dry-run : 仅输出筛选结果,不执行质检}';
+
+    protected $description = '题目自动质检:从 question_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;
+    }
+
+    /**
+     * 将 question_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),
+        ];
+    }
+}

+ 259 - 0
app/Services/QuestionQualityCheckService.php

@@ -0,0 +1,259 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 题目质检服务
+ *
+ * 校验规则:题干、答案、解析、选项、公式、PDF 呈现
+ * 自动质检 + 人工质检结果分别记录到 question_qc_results
+ */
+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 question_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);
+
+        foreach ($results as $r) {
+            $this->saveQcResult($r, $questionTemId, $questionId, 'auto');
+        }
+
+        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,
+        ];
+    }
+
+    private function saveQcResult(array $r, ?int $questionTemId, ?int $questionId, string $source): void
+    {
+        if (!Schema::hasTable('question_qc_results')) {
+            return;
+        }
+        try {
+            DB::table('question_qc_results')->insert([
+                'question_tem_id' => $questionTemId,
+                'question_id' => $questionId,
+                'rule_code' => $r['rule_code'],
+                'rule_name' => $r['rule_name'] ?? null,
+                'passed' => $r['passed'],
+                'auto_result' => $r['auto_result'] ?? ($r['passed'] ? 'pass' : 'fail'),
+                'manual_result' => null,
+                'pdf_render_ok' => null,
+                'detail' => $r['detail'] ?? null,
+                'source' => $source,
+                'created_at' => now(),
+                'updated_at' => now(),
+            ]);
+        } catch (\Throwable $e) {
+            Log::warning('QuestionQualityCheckService: 保存质检结果失败', [
+                'rule_code' => $r['rule_code'],
+                'error' => $e->getMessage(),
+            ]);
+        }
+    }
+
+    /**
+     * 记录人工质检结果(更新已有自动质检记录)
+     */
+    public function recordManualResult(int $questionTemId, string $ruleCode, string $manualResult, ?bool $pdfRenderOk = null): void
+    {
+        if (!Schema::hasTable('question_qc_results')) {
+            return;
+        }
+        DB::table('question_qc_results')
+            ->where('question_tem_id', $questionTemId)
+            ->where('rule_code', $ruleCode)
+            ->update([
+                'manual_result' => $manualResult,
+                'pdf_render_ok' => $pdfRenderOk,
+                'updated_at' => now(),
+            ]);
+    }
+
+    /**
+     * 获取下学期章节关联的、题少的 KP 列表(用于筛选 question_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')->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);
+    }
+}

+ 40 - 0
database/migrations/2026_03_23_194507_create_question_qc_results_table.php

@@ -0,0 +1,40 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('question_qc_results', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('question_tem_id')->nullable()->comment('question_tem 表题目ID');
+            $table->unsignedBigInteger('question_id')->nullable()->comment('questions 表题目ID(入库后)');
+            $table->string('rule_code', 64)->comment('规则编码 STEM_EMPTY/ANSWER_EMPTY 等');
+            $table->string('rule_name', 128)->nullable()->comment('规则名称');
+            $table->boolean('passed')->default(false)->comment('是否通过');
+            $table->string('auto_result', 32)->nullable()->comment('自动质检结果 pass/fail/skip');
+            $table->string('manual_result', 32)->nullable()->comment('人工质检结果 pass/fail/skip');
+            $table->boolean('pdf_render_ok')->nullable()->comment('PDF 渲染是否正常');
+            $table->text('detail')->nullable()->comment('详情/错误信息');
+            $table->string('source', 32)->default('auto')->comment('来源 auto/manual');
+            $table->timestamps();
+
+            $table->index(['question_tem_id', 'rule_code']);
+            $table->index(['question_id', 'rule_code']);
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('question_qc_results');
+    }
+};

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

@@ -0,0 +1,88 @@
+# 题库质检方案
+
+## 一、背景
+
+### question_tem 表预期结构(与 questions 对齐)
+
+| 字段 | 说明 |
+|------|------|
+| id | 主键 |
+| stem / content | 题干 |
+| answer / correct_answer | 答案 |
+| solution | 解析 |
+| question_type / tags | 题型 |
+| options | 选项(JSON) |
+| kp_code | 知识点 |
+
+- 待入库题目在 `question_tem` 中,约 2 万道
+- 需与 `questions` 正式表比对,确保不重复
+- 入库前**严格校验**,以学案/PDF 导出场景验证
+
+## 二、质检机制
+
+### 2.1 题目质检(自动 + 人工)
+
+| 校验项 | 说明 | 自动 | 人工 |
+|--------|------|------|------|
+| 缺题干 stem | 题干为空或过短 | ✓ | ✓ |
+| 缺答案 answer | 答案为空 | ✓ | ✓ |
+| 缺解析 solution | 解答题为空需标记 | ✓ | ✓ |
+| 选择题缺选项 | choice 题型 options 为空或不足 | ✓ | ✓ |
+| 公式乱码 | LaTeX 解析失败、出现未渲染符 | ✓ | ✓ |
+| PDF 呈现 | 实际导出 PDF 是否乱码/错位 | ✓ | ✓ |
+
+### 2.2 学案质检(可撤销)
+
+- 学案使用场景:PDF 导出验证
+- 连续 **10000 份** 无误 → 可撤销该环节以节省成本
+
+### 2.3 结果记录
+
+- `question_qc_results`:自动质检 + 人工质检分别记录
+- 字段:question_id / question_tem_id、rule_code、passed、auto_result、manual_result、pdf_render_ok 等
+
+## 三、校验规则清单(可扩展)
+
+| 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. 从 `question_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
+```
+
+## 六、流程
+
+```
+question_tem 选题(按下学期 KP 题少优先)
+    → 自动质检(规则引擎)
+    → 记录 auto_result
+    → (可选)PDF 导出预演,检测渲染
+    → 人工复核,记录 manual_result
+    → 全部通过 → 入库 questions
+```