QuestionQualityCheckService.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Support\Collection;
  4. use Illuminate\Support\Facades\DB;
  5. use Illuminate\Support\Facades\Log;
  6. use Illuminate\Support\Facades\Schema;
  7. /**
  8. * 题目质检服务
  9. *
  10. * 校验规则:题干、答案、解析、选项、公式、PDF 呈现、AI 答案校验
  11. * 结果由命令输出,不落库(避免本地库覆盖)
  12. */
  13. class QuestionQualityCheckService
  14. {
  15. private const DEFAULT_ANSWER_VALIDATION_PROMPT = <<<'PROMPT'
  16. 你是数学题目质检专家。请校验以下题目的答案是否正确、是否与题目匹配。
  17. 校验要求:
  18. 1. 答案正确性:答案是否在数学/逻辑上正确?(选择题则答案选项内容应对应正确选项)
  19. 2. 答案与题目匹配:答案是否针对题干所问、是否解答了题目要求?
  20. 题目类型:{question_type}
  21. 题干:
  22. {stem}
  23. 选项(选择题):
  24. {options}
  25. 参考答案:{answer}
  26. 解析(若有):
  27. {solution}
  28. 请只输出 JSON,格式:
  29. {
  30. "answer_correct": true|false,
  31. "answer_matches_question": true|false,
  32. "confidence": 0.0~1.0,
  33. "reason": "简要说明(若有问题)"
  34. }
  35. PROMPT;
  36. private ?AiClientService $aiClient = null;
  37. public function __construct(?AiClientService $aiClient = null)
  38. {
  39. $this->aiClient = $aiClient ?? (app()->bound(AiClientService::class) ? app(AiClientService::class) : null);
  40. }
  41. public const RULES = [
  42. 'STEM_EMPTY' => ['name' => '题干为空', 'severity' => 'error'],
  43. 'ANSWER_EMPTY' => ['name' => '答案为空', 'severity' => 'error'],
  44. 'SOLUTION_EMPTY' => ['name' => '解析为空', 'severity' => 'warning'],
  45. 'CHOICE_OPTIONS_MISSING' => ['name' => '选择题缺选项', 'severity' => 'error'],
  46. 'CHOICE_OPTIONS_JSON_INVALID' => ['name' => '选择题选项JSON无效', 'severity' => 'error'],
  47. 'CHOICE_OPTION_TEXT_EMPTY' => ['name' => '选择题存在空白选项', 'severity' => 'warning'],
  48. 'ANSWER_OPTION_MISMATCH' => ['name' => '选择题答案不在选项中', 'severity' => 'error'],
  49. 'FORMULA_INVALID' => ['name' => '公式异常', 'severity' => 'error'],
  50. 'CONTENT_TOO_SHORT' => ['name' => '题干过短', 'severity' => 'warning'],
  51. 'AI_ANSWER_INVALID' => ['name' => 'AI 判定答案错误', 'severity' => 'error'],
  52. 'AI_ANSWER_MISMATCH' => ['name' => 'AI 判定答案与题目不匹配', 'severity' => 'error'],
  53. ];
  54. /**
  55. * 对单道题目执行自动质检
  56. *
  57. * @param array $question 题目数据,需包含 stem, answer, solution, question_type, options;
  58. * 可选 options_json_invalid=true(options 字段为字符串但 JSON 解析失败)
  59. * @param int|null $questionTemId questions_tem 表 ID
  60. * @param int|null $questionId questions 表 ID
  61. * @param array $options ['ai_check' => bool] 是否启用 AI 校验(答案正确性、与题目匹配)
  62. * @return array ['passed' => bool, 'results' => array, 'errors' => array]
  63. */
  64. public function runAutoCheck(array $question, ?int $questionTemId = null, ?int $questionId = null, array $options = []): array
  65. {
  66. $stem = $question['stem'] ?? $question['content'] ?? '';
  67. $answer = $question['answer'] ?? '';
  68. $solution = $question['solution'] ?? '';
  69. $questionType = $this->normalizeQuestionType(
  70. (string) ($question['question_type'] ?? $question['tags'] ?? '')
  71. );
  72. $options = $question['options'] ?? null;
  73. $optionsJsonInvalid = ! empty($question['options_json_invalid']);
  74. $results = [];
  75. $errors = [];
  76. // STEM_EMPTY / CONTENT_TOO_SHORT
  77. $stemLen = mb_strlen(trim((string) $stem));
  78. if ($stemLen === 0) {
  79. $results[] = $this->recordCheck('STEM_EMPTY', false, '题干为空');
  80. $errors[] = 'STEM_EMPTY';
  81. } elseif ($stemLen < 5) {
  82. $results[] = $this->recordCheck('CONTENT_TOO_SHORT', false, "题干过短({$stemLen}字)");
  83. $errors[] = 'CONTENT_TOO_SHORT';
  84. } else {
  85. $results[] = $this->recordCheck('STEM_EMPTY', true);
  86. }
  87. // ANSWER_EMPTY
  88. if (trim((string) $answer) === '') {
  89. $results[] = $this->recordCheck('ANSWER_EMPTY', false, '答案为空');
  90. $errors[] = 'ANSWER_EMPTY';
  91. } else {
  92. $results[] = $this->recordCheck('ANSWER_EMPTY', true);
  93. }
  94. // SOLUTION_EMPTY(解答题强校验)
  95. $isAnswerType = $questionType === 'answer';
  96. if ($isAnswerType && trim((string) $solution) === '') {
  97. $results[] = $this->recordCheck('SOLUTION_EMPTY', false, '解答题解析为空');
  98. $errors[] = 'SOLUTION_EMPTY';
  99. } else {
  100. $results[] = $this->recordCheck('SOLUTION_EMPTY', true);
  101. }
  102. // CHOICE_OPTIONS_*(与 PDF 导出口径:选项 JSON、非空文案数量)
  103. $isChoice = $questionType === 'choice';
  104. if ($isChoice) {
  105. if ($optionsJsonInvalid) {
  106. $results[] = $this->recordCheck('CHOICE_OPTIONS_JSON_INVALID', false, 'options 字段不是合法 JSON');
  107. $errors[] = 'CHOICE_OPTIONS_JSON_INVALID';
  108. $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true, null, 'skip');
  109. $results[] = $this->recordCheck('CHOICE_OPTION_TEXT_EMPTY', true, null, 'skip');
  110. } else {
  111. $optionTexts = $this->extractOptionTexts(is_array($options) ? $options : null);
  112. $nonEmpty = array_values(array_filter($optionTexts, fn ($t) => mb_strlen(trim((string) $t)) > 0));
  113. if (count($nonEmpty) < 2) {
  114. $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', false, '选择题有效选项不足2个');
  115. $errors[] = 'CHOICE_OPTIONS_MISSING';
  116. } else {
  117. $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true);
  118. }
  119. $hasEmptySlot = count($optionTexts) > 0
  120. && count($nonEmpty) < count($optionTexts);
  121. if ($hasEmptySlot) {
  122. $results[] = $this->recordCheck('CHOICE_OPTION_TEXT_EMPTY', false, '存在空白选项项');
  123. $errors[] = 'CHOICE_OPTION_TEXT_EMPTY';
  124. } else {
  125. $results[] = $this->recordCheck('CHOICE_OPTION_TEXT_EMPTY', true);
  126. }
  127. }
  128. } else {
  129. $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true, null, 'skip');
  130. $results[] = $this->recordCheck('CHOICE_OPTIONS_JSON_INVALID', true, null, 'skip');
  131. $results[] = $this->recordCheck('CHOICE_OPTION_TEXT_EMPTY', true, null, 'skip');
  132. }
  133. // ANSWER_OPTION_MISMATCH:选择题答案必须在选项中
  134. if ($isChoice && ! $optionsJsonInvalid && is_array($options)) {
  135. $answerLetter = $this->extractChoiceAnswerLetter((string) $answer);
  136. $optionKeysRaw = array_keys($options);
  137. $optionLetters = array_map(fn ($k) => strtoupper(substr((string) $k, 0, 1)), $optionKeysRaw);
  138. if ($answerLetter !== null && ! in_array($answerLetter, $optionLetters, true)) {
  139. $results[] = $this->recordCheck('ANSWER_OPTION_MISMATCH', false, "答案 {$answerLetter} 不在选项 " . implode(',', $optionLetters) . " 中");
  140. $errors[] = 'ANSWER_OPTION_MISMATCH';
  141. } elseif ($answerLetter === null && trim((string) $answer) !== '') {
  142. $results[] = $this->recordCheck('ANSWER_OPTION_MISMATCH', false, '答案格式无法识别为选项(应为 A/B/C/D)');
  143. $errors[] = 'ANSWER_OPTION_MISMATCH';
  144. } else {
  145. $results[] = $this->recordCheck('ANSWER_OPTION_MISMATCH', true);
  146. }
  147. } else {
  148. $results[] = $this->recordCheck('ANSWER_OPTION_MISMATCH', true, null, 'skip');
  149. }
  150. // FORMULA_INVALID(题干、答案、解析、选择题各选项;捕获异常)
  151. try {
  152. $processed = MathFormulaProcessor::processFormulas($stem);
  153. $processedAnswer = MathFormulaProcessor::processFormulas($answer);
  154. $processedSolution = MathFormulaProcessor::processFormulas($solution);
  155. $hasError = $this->detectFormulaError($processed)
  156. || $this->detectFormulaError($processedAnswer)
  157. || $this->detectFormulaError($processedSolution);
  158. if ($isChoice && ! $optionsJsonInvalid && is_array($options)) {
  159. foreach ($this->extractOptionTexts($options) as $optText) {
  160. if (trim((string) $optText) === '') {
  161. continue;
  162. }
  163. $po = MathFormulaProcessor::processFormulas((string) $optText);
  164. if ($this->detectFormulaError($po)) {
  165. $hasError = true;
  166. break;
  167. }
  168. }
  169. }
  170. if ($hasError) {
  171. $results[] = $this->recordCheck('FORMULA_INVALID', false, '公式定界符不匹配或存在异常');
  172. $errors[] = 'FORMULA_INVALID';
  173. } else {
  174. $results[] = $this->recordCheck('FORMULA_INVALID', true);
  175. }
  176. } catch (\Throwable $e) {
  177. $results[] = $this->recordCheck('FORMULA_INVALID', false, $e->getMessage());
  178. $errors[] = 'FORMULA_INVALID';
  179. }
  180. // AI 校验:答案正确性、答案与题目匹配(需开启 ai_check,且基础校验通过时再调 AI)
  181. $aiCheck = $options['ai_check'] ?? false;
  182. if ($aiCheck && $this->aiClient && empty($errors) && trim((string) $answer) !== '') {
  183. try {
  184. $aiResult = $this->runAiAnswerCheck($question);
  185. if (is_array($aiResult)) {
  186. if (! ($aiResult['answer_correct'] ?? true)) {
  187. $results[] = $this->recordCheck('AI_ANSWER_INVALID', false, $aiResult['reason'] ?? 'AI 判定答案错误');
  188. $errors[] = 'AI_ANSWER_INVALID';
  189. } else {
  190. $results[] = $this->recordCheck('AI_ANSWER_INVALID', true);
  191. }
  192. if (! ($aiResult['answer_matches_question'] ?? true)) {
  193. $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', false, $aiResult['reason'] ?? 'AI 判定答案与题目不匹配');
  194. $errors[] = 'AI_ANSWER_MISMATCH';
  195. } else {
  196. $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', true);
  197. }
  198. } else {
  199. $results[] = $this->recordCheck('AI_ANSWER_INVALID', true, null, 'skip');
  200. $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', true, null, 'skip');
  201. }
  202. } catch (\Throwable $e) {
  203. Log::warning('QuestionQualityCheckService: AI 校验异常', ['error' => $e->getMessage()]);
  204. $results[] = $this->recordCheck('AI_ANSWER_INVALID', true, null, 'skip');
  205. $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', true, null, 'skip');
  206. }
  207. } else {
  208. $results[] = $this->recordCheck('AI_ANSWER_INVALID', true, null, 'skip');
  209. $results[] = $this->recordCheck('AI_ANSWER_MISMATCH', true, null, 'skip');
  210. }
  211. $passed = empty($errors);
  212. return [
  213. 'passed' => $passed,
  214. 'results' => $results,
  215. 'errors' => $errors,
  216. ];
  217. }
  218. /**
  219. * 与组卷/PDF 口径一致:归一化为 choice | fill | answer
  220. */
  221. private function normalizeQuestionType(string $raw): string
  222. {
  223. $t = strtolower(trim($raw));
  224. if ($t === '') {
  225. return 'answer';
  226. }
  227. return match (true) {
  228. str_contains($t, 'choice') || str_contains($t, '选择') => 'choice',
  229. str_contains($t, 'fill') || str_contains($t, 'blank') || str_contains($t, '填空') => 'fill',
  230. in_array($t, ['single_choice', 'multiple_choice', 'select'], true) => 'choice',
  231. in_array($t, ['fill_in_the_blank'], true) => 'fill',
  232. in_array($t, ['answer', 'calculation', 'word_problem', 'proof', '解答', '简答'], true) => 'answer',
  233. default => 'answer',
  234. };
  235. }
  236. /**
  237. * 将 options 转为选项文案列表(与 ExamPdfController::normalizeOptions 语义对齐)
  238. *
  239. * @param array|null $options
  240. * @return list<string>
  241. */
  242. private function extractOptionTexts(?array $options): array
  243. {
  244. if ($options === null || $options === []) {
  245. return [];
  246. }
  247. if (! isset($options[0]) && $options !== []) {
  248. return array_values(array_map(static fn ($v) => (string) $v, $options));
  249. }
  250. if (isset($options[0]) && is_array($options[0])) {
  251. $normalized = [];
  252. foreach ($options as $opt) {
  253. if (! is_array($opt)) {
  254. $normalized[] = (string) $opt;
  255. continue;
  256. }
  257. if (isset($opt['text'])) {
  258. $normalized[] = (string) $opt['text'];
  259. } elseif (isset($opt['value'])) {
  260. $normalized[] = (string) $opt['value'];
  261. } else {
  262. $normalized[] = (string) reset($opt);
  263. }
  264. }
  265. return $normalized;
  266. }
  267. return array_values(array_map(static fn ($v) => (string) $v, $options));
  268. }
  269. /**
  270. * 检测公式处理后的内容是否仍有明显错误(如未闭合的 $)
  271. */
  272. private function detectFormulaError(string $content): bool
  273. {
  274. $len = strlen($content);
  275. $dollarCount = 0;
  276. $inEscape = false;
  277. for ($i = 0; $i < $len; $i++) {
  278. $c = $content[$i];
  279. if ($c === '\\' && !$inEscape) {
  280. $inEscape = true;
  281. continue;
  282. }
  283. if ($c === '$') {
  284. $dollarCount++;
  285. }
  286. $inEscape = false;
  287. }
  288. return ($dollarCount % 2) !== 0;
  289. }
  290. /**
  291. * 提取选择题答案字母(A/B/C/D)
  292. */
  293. private function extractChoiceAnswerLetter(string $answer): ?string
  294. {
  295. $answer = trim($answer);
  296. if (preg_match('/^([A-D])$/i', $answer, $m)) {
  297. return strtoupper($m[1]);
  298. }
  299. if (preg_match('/答案[::]\s*([A-D])/iu', $answer, $m)) {
  300. return strtoupper($m[1]);
  301. }
  302. if (preg_match('/([A-D])[\.、.:]/u', $answer, $m)) {
  303. return strtoupper($m[1]);
  304. }
  305. if (preg_match('/([A-D])/i', $answer, $m)) {
  306. return strtoupper($m[1]);
  307. }
  308. return null;
  309. }
  310. /**
  311. * AI 校验:答案是否正确、是否与题目匹配
  312. */
  313. private function runAiAnswerCheck(array $question): ?array
  314. {
  315. $stem = $question['stem'] ?? $question['content'] ?? '';
  316. $answer = $question['answer'] ?? '';
  317. $solution = $question['solution'] ?? '';
  318. $questionType = $question['question_type'] ?? ($question['tags'] ?? 'answer');
  319. $options = $question['options'] ?? null;
  320. $optionsStr = '无';
  321. if (is_array($options)) {
  322. $parts = [];
  323. foreach ($options as $k => $v) {
  324. $v = is_string($v) ? $v : json_encode($v);
  325. $parts[] = "{$k}: " . mb_substr($v, 0, 200);
  326. }
  327. $optionsStr = implode("\n", $parts);
  328. } elseif (is_string($options)) {
  329. $optionsStr = mb_substr($options, 0, 500);
  330. }
  331. $promptTemplate = (string) config('ai.answer_validation_prompt', '');
  332. if (trim($promptTemplate) === '') {
  333. $promptTemplate = self::DEFAULT_ANSWER_VALIDATION_PROMPT;
  334. }
  335. $prompt = str_replace(
  336. ['{question_type}', '{stem}', '{options}', '{answer}', '{solution}'],
  337. [$questionType, mb_substr((string) $stem, 0, 1500), $optionsStr, (string) $answer, mb_substr((string) $solution, 0, 800)],
  338. $promptTemplate
  339. );
  340. if ($prompt === '') {
  341. return null;
  342. }
  343. $data = $this->aiClient->callJson($prompt);
  344. return is_array($data) && isset($data['answer_correct']) ? $data : null;
  345. }
  346. private function recordCheck(string $ruleCode, bool $passed, ?string $detail = null, string $result = 'pass'): array
  347. {
  348. $info = self::RULES[$ruleCode] ?? ['name' => $ruleCode, 'severity' => 'error'];
  349. return [
  350. 'rule_code' => $ruleCode,
  351. 'rule_name' => $info['name'],
  352. 'passed' => $passed,
  353. 'auto_result' => $passed ? 'pass' : ($result === 'skip' ? 'skip' : 'fail'),
  354. 'detail' => $detail,
  355. ];
  356. }
  357. /**
  358. * 获取下学期章节关联的、题少的 KP 列表(用于筛选 questions_tem)
  359. *
  360. * @param int|null $textbookId 教材 ID,null 则取默认教材
  361. * @param int $semesterCode 学期 1=上 2=下
  362. * @param int $limit 返回前 N 个题少的 KP
  363. * @return array [['kp_code' => string, 'question_count' => int], ...]
  364. */
  365. public function getKpsWithFewQuestions(?int $textbookId = null, int $semesterCode = 2, int $limit = 50): array
  366. {
  367. $textbooksQuery = DB::table('textbooks');
  368. if (Schema::hasColumn('textbooks', 'is_deleted')) {
  369. $textbooksQuery->where('is_deleted', 0);
  370. }
  371. if ($textbookId) {
  372. $textbooksQuery->where('id', $textbookId);
  373. }
  374. if (Schema::hasColumn('textbooks', 'semester_code')) {
  375. $textbooksQuery->where('semester_code', $semesterCode);
  376. }
  377. $textbookIds = $textbooksQuery->pluck('id')->toArray();
  378. if (empty($textbookIds)) {
  379. Log::warning('QuestionQualityCheckService: 未找到下学期教材');
  380. return [];
  381. }
  382. $kpCodes = DB::table('textbook_chapter_knowledge_relation as tckr')
  383. ->join('textbook_catalog_nodes as tcn', 'tckr.catalog_chapter_id', '=', 'tcn.id')
  384. ->whereIn('tcn.textbook_id', $textbookIds)
  385. ->where(function ($q) {
  386. $q->where('tckr.is_deleted', 0)->orWhereNull('tckr.is_deleted');
  387. })
  388. ->distinct()
  389. ->pluck('tckr.kp_code')
  390. ->toArray();
  391. if (empty($kpCodes)) {
  392. return [];
  393. }
  394. $counts = DB::table('questions')
  395. ->whereIn('kp_code', $kpCodes)
  396. ->where('audit_status', 0)
  397. ->selectRaw('kp_code, count(*) as cnt')
  398. ->groupBy('kp_code')
  399. ->pluck('cnt', 'kp_code')
  400. ->toArray();
  401. $result = [];
  402. foreach ($kpCodes as $kp) {
  403. $result[] = [
  404. 'kp_code' => $kp,
  405. 'question_count' => $counts[$kp] ?? 0,
  406. ];
  407. }
  408. usort($result, fn ($a, $b) => $a['question_count'] <=> $b['question_count']);
  409. return array_slice($result, 0, $limit);
  410. }
  411. /**
  412. * 获取待质检题目(与 questions 不重复),按知识点分组
  413. * 用于按 KP 生成 PDF
  414. *
  415. * @param string $table 待质检表名,默认 questions_tem
  416. * @param int|null $textbookId 教材 ID
  417. * @param int $semesterCode 学期 1=上 2=下
  418. * @param int $kpLimit 取前 N 个题少的 KP(当 $singleKp 为 null 时生效)
  419. * @param int|null $perKpLimit 每个 KP 最多取题数,null 不限制
  420. * @param string|null $singleKp 指定单个 KP,则只返回该 KP
  421. * @return array<string, Collection> [kp_code => Collection of question rows]
  422. */
  423. public function getQcQuestionsGroupedByKp(
  424. string $table = 'questions_tem',
  425. ?int $textbookId = null,
  426. int $semesterCode = 2,
  427. int $kpLimit = 10,
  428. ?int $perKpLimit = null,
  429. ?string $singleKp = null
  430. ): array {
  431. if (! Schema::hasTable($table)) {
  432. return [];
  433. }
  434. $kpCodes = $singleKp
  435. ? [$singleKp]
  436. : array_column($this->getKpsWithFewQuestions($textbookId, $semesterCode, $kpLimit), 'kp_code');
  437. if (empty($kpCodes)) {
  438. return [];
  439. }
  440. $query = DB::table($table)->whereIn('kp_code', $kpCodes);
  441. if ($table === 'questions_tem'
  442. && Schema::hasTable('questions')
  443. && Schema::hasColumn($table, 'question_code')
  444. && Schema::hasColumn('questions', 'question_code')
  445. ) {
  446. $query->whereNotIn('question_code', DB::table('questions')->select('question_code'));
  447. }
  448. $all = $query->orderBy('kp_code')->orderBy('id')->get();
  449. $grouped = [];
  450. foreach ($all as $row) {
  451. $kp = $row->kp_code ?? '';
  452. if ($kp === '') {
  453. continue;
  454. }
  455. if ($perKpLimit !== null && isset($grouped[$kp]) && $grouped[$kp]->count() >= $perKpLimit) {
  456. continue;
  457. }
  458. $grouped[$kp] ??= new Collection;
  459. $grouped[$kp]->push($row);
  460. }
  461. return $grouped;
  462. }
  463. }