['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); } }