QuestionQualityCheckService.php 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Support\Facades\DB;
  4. use Illuminate\Support\Facades\Log;
  5. use Illuminate\Support\Facades\Schema;
  6. /**
  7. * 题目质检服务
  8. *
  9. * 校验规则:题干、答案、解析、选项、公式、PDF 呈现
  10. * 自动质检 + 人工质检结果分别记录到 question_qc_results
  11. */
  12. class QuestionQualityCheckService
  13. {
  14. public const RULES = [
  15. 'STEM_EMPTY' => ['name' => '题干为空', 'severity' => 'error'],
  16. 'ANSWER_EMPTY' => ['name' => '答案为空', 'severity' => 'error'],
  17. 'SOLUTION_EMPTY' => ['name' => '解析为空', 'severity' => 'warning'],
  18. 'CHOICE_OPTIONS_MISSING' => ['name' => '选择题缺选项', 'severity' => 'error'],
  19. 'FORMULA_INVALID' => ['name' => '公式异常', 'severity' => 'error'],
  20. 'CONTENT_TOO_SHORT' => ['name' => '题干过短', 'severity' => 'warning'],
  21. ];
  22. /**
  23. * 对单道题目执行自动质检
  24. *
  25. * @param array $question 题目数据,需包含 stem, answer, solution, question_type, options
  26. * @param int|null $questionTemId question_tem 表 ID
  27. * @param int|null $questionId questions 表 ID
  28. * @return array ['passed' => bool, 'results' => array, 'errors' => array]
  29. */
  30. public function runAutoCheck(array $question, ?int $questionTemId = null, ?int $questionId = null): array
  31. {
  32. $stem = $question['stem'] ?? $question['content'] ?? '';
  33. $answer = $question['answer'] ?? '';
  34. $solution = $question['solution'] ?? '';
  35. $questionType = $question['question_type'] ?? ($question['tags'] ?? '');
  36. $options = $question['options'] ?? null;
  37. $results = [];
  38. $errors = [];
  39. // STEM_EMPTY / CONTENT_TOO_SHORT
  40. $stemLen = mb_strlen(trim((string) $stem));
  41. if ($stemLen === 0) {
  42. $results[] = $this->recordCheck('STEM_EMPTY', false, '题干为空');
  43. $errors[] = 'STEM_EMPTY';
  44. } elseif ($stemLen < 5) {
  45. $results[] = $this->recordCheck('CONTENT_TOO_SHORT', false, "题干过短({$stemLen}字)");
  46. $errors[] = 'CONTENT_TOO_SHORT';
  47. } else {
  48. $results[] = $this->recordCheck('STEM_EMPTY', true);
  49. }
  50. // ANSWER_EMPTY
  51. if (trim((string) $answer) === '') {
  52. $results[] = $this->recordCheck('ANSWER_EMPTY', false, '答案为空');
  53. $errors[] = 'ANSWER_EMPTY';
  54. } else {
  55. $results[] = $this->recordCheck('ANSWER_EMPTY', true);
  56. }
  57. // SOLUTION_EMPTY(解答题强校验)
  58. $isAnswerType = in_array(strtolower((string) $questionType), ['answer', '解答题', '解答'], true);
  59. if ($isAnswerType && trim((string) $solution) === '') {
  60. $results[] = $this->recordCheck('SOLUTION_EMPTY', false, '解答题解析为空');
  61. $errors[] = 'SOLUTION_EMPTY';
  62. } else {
  63. $results[] = $this->recordCheck('SOLUTION_EMPTY', true);
  64. }
  65. // CHOICE_OPTIONS_MISSING
  66. $isChoice = in_array(strtolower((string) $questionType), ['choice', '选择题', 'select'], true);
  67. if ($isChoice) {
  68. $optsOk = is_array($options) && count($options) >= 2;
  69. if (!$optsOk) {
  70. $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', false, '选择题选项为空或不足2个');
  71. $errors[] = 'CHOICE_OPTIONS_MISSING';
  72. } else {
  73. $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true);
  74. }
  75. } else {
  76. $results[] = $this->recordCheck('CHOICE_OPTIONS_MISSING', true, null, 'skip');
  77. }
  78. // FORMULA_INVALID(尝试处理公式,捕获异常)
  79. try {
  80. $processed = MathFormulaProcessor::processFormulas($stem);
  81. $processedAnswer = MathFormulaProcessor::processFormulas($answer);
  82. $processedSolution = MathFormulaProcessor::processFormulas($solution);
  83. $hasError = $this->detectFormulaError($processed)
  84. || $this->detectFormulaError($processedAnswer)
  85. || $this->detectFormulaError($processedSolution);
  86. if ($hasError) {
  87. $results[] = $this->recordCheck('FORMULA_INVALID', false, '公式定界符不匹配或存在异常');
  88. $errors[] = 'FORMULA_INVALID';
  89. } else {
  90. $results[] = $this->recordCheck('FORMULA_INVALID', true);
  91. }
  92. } catch (\Throwable $e) {
  93. $results[] = $this->recordCheck('FORMULA_INVALID', false, $e->getMessage());
  94. $errors[] = 'FORMULA_INVALID';
  95. }
  96. $passed = empty($errors);
  97. foreach ($results as $r) {
  98. $this->saveQcResult($r, $questionTemId, $questionId, 'auto');
  99. }
  100. return [
  101. 'passed' => $passed,
  102. 'results' => $results,
  103. 'errors' => $errors,
  104. ];
  105. }
  106. /**
  107. * 检测公式处理后的内容是否仍有明显错误(如未闭合的 $)
  108. */
  109. private function detectFormulaError(string $content): bool
  110. {
  111. $len = strlen($content);
  112. $dollarCount = 0;
  113. $inEscape = false;
  114. for ($i = 0; $i < $len; $i++) {
  115. $c = $content[$i];
  116. if ($c === '\\' && !$inEscape) {
  117. $inEscape = true;
  118. continue;
  119. }
  120. if ($c === '$') {
  121. $dollarCount++;
  122. }
  123. $inEscape = false;
  124. }
  125. return ($dollarCount % 2) !== 0;
  126. }
  127. private function recordCheck(string $ruleCode, bool $passed, ?string $detail = null, string $result = 'pass'): array
  128. {
  129. $info = self::RULES[$ruleCode] ?? ['name' => $ruleCode, 'severity' => 'error'];
  130. return [
  131. 'rule_code' => $ruleCode,
  132. 'rule_name' => $info['name'],
  133. 'passed' => $passed,
  134. 'auto_result' => $passed ? 'pass' : ($result === 'skip' ? 'skip' : 'fail'),
  135. 'detail' => $detail,
  136. ];
  137. }
  138. private function saveQcResult(array $r, ?int $questionTemId, ?int $questionId, string $source): void
  139. {
  140. if (!Schema::hasTable('question_qc_results')) {
  141. return;
  142. }
  143. try {
  144. DB::table('question_qc_results')->insert([
  145. 'question_tem_id' => $questionTemId,
  146. 'question_id' => $questionId,
  147. 'rule_code' => $r['rule_code'],
  148. 'rule_name' => $r['rule_name'] ?? null,
  149. 'passed' => $r['passed'],
  150. 'auto_result' => $r['auto_result'] ?? ($r['passed'] ? 'pass' : 'fail'),
  151. 'manual_result' => null,
  152. 'pdf_render_ok' => null,
  153. 'detail' => $r['detail'] ?? null,
  154. 'source' => $source,
  155. 'created_at' => now(),
  156. 'updated_at' => now(),
  157. ]);
  158. } catch (\Throwable $e) {
  159. Log::warning('QuestionQualityCheckService: 保存质检结果失败', [
  160. 'rule_code' => $r['rule_code'],
  161. 'error' => $e->getMessage(),
  162. ]);
  163. }
  164. }
  165. /**
  166. * 记录人工质检结果(更新已有自动质检记录)
  167. */
  168. public function recordManualResult(int $questionTemId, string $ruleCode, string $manualResult, ?bool $pdfRenderOk = null): void
  169. {
  170. if (!Schema::hasTable('question_qc_results')) {
  171. return;
  172. }
  173. DB::table('question_qc_results')
  174. ->where('question_tem_id', $questionTemId)
  175. ->where('rule_code', $ruleCode)
  176. ->update([
  177. 'manual_result' => $manualResult,
  178. 'pdf_render_ok' => $pdfRenderOk,
  179. 'updated_at' => now(),
  180. ]);
  181. }
  182. /**
  183. * 获取下学期章节关联的、题少的 KP 列表(用于筛选 question_tem)
  184. *
  185. * @param int|null $textbookId 教材 ID,null 则取默认教材
  186. * @param int $semesterCode 学期 1=上 2=下
  187. * @param int $limit 返回前 N 个题少的 KP
  188. * @return array [['kp_code' => string, 'question_count' => int], ...]
  189. */
  190. public function getKpsWithFewQuestions(?int $textbookId = null, int $semesterCode = 2, int $limit = 50): array
  191. {
  192. $textbooksQuery = DB::table('textbooks')->where('is_deleted', 0);
  193. if ($textbookId) {
  194. $textbooksQuery->where('id', $textbookId);
  195. }
  196. if (Schema::hasColumn('textbooks', 'semester_code')) {
  197. $textbooksQuery->where('semester_code', $semesterCode);
  198. }
  199. $textbookIds = $textbooksQuery->pluck('id')->toArray();
  200. if (empty($textbookIds)) {
  201. Log::warning('QuestionQualityCheckService: 未找到下学期教材');
  202. return [];
  203. }
  204. $kpCodes = DB::table('textbook_chapter_knowledge_relation as tckr')
  205. ->join('textbook_catalog_nodes as tcn', 'tckr.catalog_chapter_id', '=', 'tcn.id')
  206. ->whereIn('tcn.textbook_id', $textbookIds)
  207. ->where(function ($q) {
  208. $q->where('tckr.is_deleted', 0)->orWhereNull('tckr.is_deleted');
  209. })
  210. ->distinct()
  211. ->pluck('tckr.kp_code')
  212. ->toArray();
  213. if (empty($kpCodes)) {
  214. return [];
  215. }
  216. $counts = DB::table('questions')
  217. ->whereIn('kp_code', $kpCodes)
  218. ->where('audit_status', 0)
  219. ->selectRaw('kp_code, count(*) as cnt')
  220. ->groupBy('kp_code')
  221. ->pluck('cnt', 'kp_code')
  222. ->toArray();
  223. $result = [];
  224. foreach ($kpCodes as $kp) {
  225. $result[] = [
  226. 'kp_code' => $kp,
  227. 'question_count' => $counts[$kp] ?? 0,
  228. ];
  229. }
  230. usort($result, fn ($a, $b) => $a['question_count'] <=> $b['question_count']);
  231. return array_slice($result, 0, $limit);
  232. }
  233. }