aiClient = $aiClient ?? (app()->bound(AiClientService::class) ? app(AiClientService::class) : null); } public const RULES = [ 'STEM_EMPTY' => ['name' => '题干为空', 'severity' => 'error'], 'ANSWER_EMPTY' => ['name' => '答案为空', 'severity' => 'error'], 'SOLUTION_EMPTY' => ['name' => '解析为空', 'severity' => 'warning'], 'CHOICE_OPTIONS_MISSING' => ['name' => '选择题缺选项', 'severity' => 'error'], 'CHOICE_OPTIONS_JSON_INVALID' => ['name' => '选择题选项JSON无效', 'severity' => 'error'], 'CHOICE_OPTION_TEXT_EMPTY' => ['name' => '选择题存在空白选项', 'severity' => 'warning'], 'ANSWER_OPTION_MISMATCH' => ['name' => '选择题答案不在选项中', 'severity' => 'error'], 'FORMULA_INVALID' => ['name' => '公式异常', 'severity' => 'error'], 'CONTENT_TOO_SHORT' => ['name' => '题干过短', 'severity' => 'warning'], 'AI_ANSWER_INVALID' => ['name' => 'AI 判定答案错误', 'severity' => 'error'], 'AI_ANSWER_MISMATCH' => ['name' => 'AI 判定答案与题目不匹配', 'severity' => 'error'], ]; /** * 对单道题目执行自动质检 * * @param array $question 题目数据,需包含 stem, answer, solution, question_type, options; * 可选 options_json_invalid=true(options 字段为字符串但 JSON 解析失败) * @param int|null $questionTemId questions_tem 表 ID * @param int|null $questionId questions 表 ID * @param array $options ['ai_check' => bool] 是否启用 AI 校验(答案正确性、与题目匹配) * @return array ['passed' => bool, 'results' => array, 'errors' => array] */ public function runAutoCheck(array $question, ?int $questionTemId = null, ?int $questionId = null, array $options = []): array { $stem = $question['stem'] ?? $question['content'] ?? ''; $answer = $question['answer'] ?? ''; $solution = $question['solution'] ?? ''; $questionType = $this->normalizeQuestionType( (string) ($question['question_type'] ?? $question['tags'] ?? '') ); $options = $question['options'] ?? null; $optionsJsonInvalid = ! empty($question['options_json_invalid']); $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 = $questionType === 'answer'; if ($isAnswerType && trim((string) $solution) === '') { $results[] = $this->recordCheck('SOLUTION_EMPTY', false, '解答题解析为空'); $errors[] = 'SOLUTION_EMPTY'; } else { $results[] = $this->recordCheck('SOLUTION_EMPTY', true); } // CHOICE_OPTIONS_*(与 PDF 导出口径:选项 JSON、非空文案数量) $isChoice = $questionType === 'choice'; if ($isChoice) { if ($optionsJsonInvalid) { $results[] = $this->recordCheck('CHOICE_OPTIONS_JSON_INVALID', false, 'options 字段不是合法 JSON'); $errors[] = 'CHOICE_OPTIONS_JSON_INVALID'; $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true, null, 'skip'); $results[] = $this->recordCheck('CHOICE_OPTION_TEXT_EMPTY', true, null, 'skip'); } else { $optionTexts = $this->extractOptionTexts(is_array($options) ? $options : null); $nonEmpty = array_values(array_filter($optionTexts, fn ($t) => mb_strlen(trim((string) $t)) > 0)); if (count($nonEmpty) < 2) { $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', false, '选择题有效选项不足2个'); $errors[] = 'CHOICE_OPTIONS_MISSING'; } else { $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true); } $hasEmptySlot = count($optionTexts) > 0 && count($nonEmpty) < count($optionTexts); if ($hasEmptySlot) { $results[] = $this->recordCheck('CHOICE_OPTION_TEXT_EMPTY', false, '存在空白选项项'); $errors[] = 'CHOICE_OPTION_TEXT_EMPTY'; } else { $results[] = $this->recordCheck('CHOICE_OPTION_TEXT_EMPTY', true); } } } else { $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true, null, 'skip'); $results[] = $this->recordCheck('CHOICE_OPTIONS_JSON_INVALID', true, null, 'skip'); $results[] = $this->recordCheck('CHOICE_OPTION_TEXT_EMPTY', true, null, 'skip'); } // ANSWER_OPTION_MISMATCH:选择题答案必须在选项中 if ($isChoice && ! $optionsJsonInvalid && is_array($options)) { $answerLetter = $this->extractChoiceAnswerLetter((string) $answer); $optionKeysRaw = array_keys($options); $optionLetters = array_map(fn ($k) => strtoupper(substr((string) $k, 0, 1)), $optionKeysRaw); if ($answerLetter !== null && ! in_array($answerLetter, $optionLetters, true)) { $results[] = $this->recordCheck('ANSWER_OPTION_MISMATCH', false, "答案 {$answerLetter} 不在选项 " . implode(',', $optionLetters) . " 中"); $errors[] = 'ANSWER_OPTION_MISMATCH'; } elseif ($answerLetter === null && trim((string) $answer) !== '') { $results[] = $this->recordCheck('ANSWER_OPTION_MISMATCH', false, '答案格式无法识别为选项(应为 A/B/C/D)'); $errors[] = 'ANSWER_OPTION_MISMATCH'; } else { $results[] = $this->recordCheck('ANSWER_OPTION_MISMATCH', true); } } else { $results[] = $this->recordCheck('ANSWER_OPTION_MISMATCH', 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 ($isChoice && ! $optionsJsonInvalid && is_array($options)) { foreach ($this->extractOptionTexts($options) as $optText) { if (trim((string) $optText) === '') { continue; } $po = MathFormulaProcessor::processFormulas((string) $optText); if ($this->detectFormulaError($po)) { $hasError = true; break; } } } 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'; } // AI 校验:答案正确性、答案与题目匹配(需开启 ai_check,且基础校验通过时再调 AI) $aiCheck = $options['ai_check'] ?? false; if ($aiCheck && $this->aiClient && empty($errors) && trim((string) $answer) !== '') { try { $aiResult = $this->runAiAnswerCheck($question); if (is_array($aiResult)) { if (! ($aiResult['answer_correct'] ?? true)) { $results[] = $this->recordCheck('AI_ANSWER_INVALID', false, $aiResult['reason'] ?? 'AI 判定答案错误'); $errors[] = 'AI_ANSWER_INVALID'; } else { $results[] = $this->recordCheck('AI_ANSWER_INVALID', true); } if (! ($aiResult['answer_matches_question'] ?? true)) { $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', false, $aiResult['reason'] ?? 'AI 判定答案与题目不匹配'); $errors[] = 'AI_ANSWER_MISMATCH'; } else { $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', true); } } else { $results[] = $this->recordCheck('AI_ANSWER_INVALID', true, null, 'skip'); $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', true, null, 'skip'); } } catch (\Throwable $e) { Log::warning('QuestionQualityCheckService: AI 校验异常', ['error' => $e->getMessage()]); $results[] = $this->recordCheck('AI_ANSWER_INVALID', true, null, 'skip'); $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', true, null, 'skip'); } } else { $results[] = $this->recordCheck('AI_ANSWER_INVALID', true, null, 'skip'); $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', true, null, 'skip'); } $passed = empty($errors); return [ 'passed' => $passed, 'results' => $results, 'errors' => $errors, ]; } /** * 与组卷/PDF 口径一致:归一化为 choice | fill | answer */ private function normalizeQuestionType(string $raw): string { $t = strtolower(trim($raw)); if ($t === '') { return 'answer'; } return match (true) { str_contains($t, 'choice') || str_contains($t, '选择') => 'choice', str_contains($t, 'fill') || str_contains($t, 'blank') || str_contains($t, '填空') => 'fill', in_array($t, ['single_choice', 'multiple_choice', 'select'], true) => 'choice', in_array($t, ['fill_in_the_blank'], true) => 'fill', in_array($t, ['answer', 'calculation', 'word_problem', 'proof', '解答', '简答'], true) => 'answer', default => 'answer', }; } /** * 将 options 转为选项文案列表(与 ExamPdfController::normalizeOptions 语义对齐) * * @param array|null $options * @return list */ private function extractOptionTexts(?array $options): array { if ($options === null || $options === []) { return []; } if (! isset($options[0]) && $options !== []) { return array_values(array_map(static fn ($v) => (string) $v, $options)); } if (isset($options[0]) && is_array($options[0])) { $normalized = []; foreach ($options as $opt) { if (! is_array($opt)) { $normalized[] = (string) $opt; continue; } if (isset($opt['text'])) { $normalized[] = (string) $opt['text']; } elseif (isset($opt['value'])) { $normalized[] = (string) $opt['value']; } else { $normalized[] = (string) reset($opt); } } return $normalized; } return array_values(array_map(static fn ($v) => (string) $v, $options)); } /** * 检测公式处理后的内容是否仍有明显错误(如未闭合的 $) */ 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; } /** * 提取选择题答案字母(A/B/C/D) */ private function extractChoiceAnswerLetter(string $answer): ?string { $answer = trim($answer); if (preg_match('/^([A-D])$/i', $answer, $m)) { return strtoupper($m[1]); } if (preg_match('/答案[::]\s*([A-D])/iu', $answer, $m)) { return strtoupper($m[1]); } if (preg_match('/([A-D])[\.、.:]/u', $answer, $m)) { return strtoupper($m[1]); } if (preg_match('/([A-D])/i', $answer, $m)) { return strtoupper($m[1]); } return null; } /** * AI 校验:答案是否正确、是否与题目匹配 */ private function runAiAnswerCheck(array $question): ?array { $stem = $question['stem'] ?? $question['content'] ?? ''; $answer = $question['answer'] ?? ''; $solution = $question['solution'] ?? ''; $questionType = $question['question_type'] ?? ($question['tags'] ?? 'answer'); $options = $question['options'] ?? null; $optionsStr = '无'; if (is_array($options)) { $parts = []; foreach ($options as $k => $v) { $v = is_string($v) ? $v : json_encode($v); $parts[] = "{$k}: " . mb_substr($v, 0, 200); } $optionsStr = implode("\n", $parts); } elseif (is_string($options)) { $optionsStr = mb_substr($options, 0, 500); } $prompt = str_replace( ['{question_type}', '{stem}', '{options}', '{answer}', '{solution}'], [$questionType, mb_substr((string) $stem, 0, 1500), $optionsStr, (string) $answer, mb_substr((string) $solution, 0, 800)], config('ai.answer_validation_prompt', '') ); if ($prompt === '') { return null; } $data = $this->aiClient->callJson($prompt); return is_array($data) && isset($data['answer_correct']) ? $data : null; } 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); } /** * 获取待质检题目(与 questions 不重复),按知识点分组 * 用于按 KP 生成 PDF * * @param string $table 待质检表名,默认 questions_tem * @param int|null $textbookId 教材 ID * @param int $semesterCode 学期 1=上 2=下 * @param int $kpLimit 取前 N 个题少的 KP(当 $singleKp 为 null 时生效) * @param int|null $perKpLimit 每个 KP 最多取题数,null 不限制 * @param string|null $singleKp 指定单个 KP,则只返回该 KP * @return array [kp_code => Collection of question rows] */ public function getQcQuestionsGroupedByKp( string $table = 'questions_tem', ?int $textbookId = null, int $semesterCode = 2, int $kpLimit = 10, ?int $perKpLimit = null, ?string $singleKp = null ): array { if (! Schema::hasTable($table)) { return []; } $kpCodes = $singleKp ? [$singleKp] : array_column($this->getKpsWithFewQuestions($textbookId, $semesterCode, $kpLimit), 'kp_code'); if (empty($kpCodes)) { return []; } $query = DB::table($table)->whereIn('kp_code', $kpCodes); if ($table === 'questions_tem' && Schema::hasTable('questions') && Schema::hasColumn($table, 'question_code') && Schema::hasColumn('questions', 'question_code') ) { $query->whereNotIn('question_code', DB::table('questions')->select('question_code')); } $all = $query->orderBy('kp_code')->orderBy('id')->get(); $grouped = []; foreach ($all as $row) { $kp = $row->kp_code ?? ''; if ($kp === '') { continue; } if ($perKpLimit !== null && isset($grouped[$kp]) && $grouped[$kp]->count() >= $perKpLimit) { continue; } $grouped[$kp] ??= new Collection; $grouped[$kp]->push($row); } return $grouped; } }