5 Commity d1e88deec9 ... 95eceb8937

Autor SHA1 Wiadomość Data
  yemeishu 95eceb8937 fix(组卷): 教材章节走关联表选知识点、补 renderAndStoreExamPdf 6 dni temu
  yemeishu d1e88deec9 Merge branch 'feat/exam-assemble-fixes': 组卷教材章节知识点与卷子 PDF 修复 6 dni temu
  yemeishu 9daa4cb8c3 fix(组卷): 教材章节走关联表选知识点、补 renderAndStoreExamPdf 6 dni temu
  yemeishu ca16b6ca02 fix: questions_tem 表名 + 移除 question_qc_results 迁移 1 tydzień temu
  yemeishu 1f1fd2d9a6 feat: 题库质检体系 - 校验规则与下学期题少 KP 筛选 1 tydzień temu

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

@@ -1,119 +0,0 @@
-<?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),
-        ];
-    }
-}

+ 0 - 212
app/Services/QuestionQualityCheckService.php

@@ -1,212 +0,0 @@
-<?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);
-    }
-}

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

@@ -1,88 +0,0 @@
-# 题库质检方案
-
-## 一、背景
-
-### 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
-```