QuestionQualityCheckService.php 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  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. * 结果由命令输出,不落库(避免本地库覆盖)
  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 questions_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. return [
  98. 'passed' => $passed,
  99. 'results' => $results,
  100. 'errors' => $errors,
  101. ];
  102. }
  103. /**
  104. * 检测公式处理后的内容是否仍有明显错误(如未闭合的 $)
  105. */
  106. private function detectFormulaError(string $content): bool
  107. {
  108. $len = strlen($content);
  109. $dollarCount = 0;
  110. $inEscape = false;
  111. for ($i = 0; $i < $len; $i++) {
  112. $c = $content[$i];
  113. if ($c === '\\' && !$inEscape) {
  114. $inEscape = true;
  115. continue;
  116. }
  117. if ($c === '$') {
  118. $dollarCount++;
  119. }
  120. $inEscape = false;
  121. }
  122. return ($dollarCount % 2) !== 0;
  123. }
  124. private function recordCheck(string $ruleCode, bool $passed, ?string $detail = null, string $result = 'pass'): array
  125. {
  126. $info = self::RULES[$ruleCode] ?? ['name' => $ruleCode, 'severity' => 'error'];
  127. return [
  128. 'rule_code' => $ruleCode,
  129. 'rule_name' => $info['name'],
  130. 'passed' => $passed,
  131. 'auto_result' => $passed ? 'pass' : ($result === 'skip' ? 'skip' : 'fail'),
  132. 'detail' => $detail,
  133. ];
  134. }
  135. /**
  136. * 获取下学期章节关联的、题少的 KP 列表(用于筛选 questions_tem)
  137. *
  138. * @param int|null $textbookId 教材 ID,null 则取默认教材
  139. * @param int $semesterCode 学期 1=上 2=下
  140. * @param int $limit 返回前 N 个题少的 KP
  141. * @return array [['kp_code' => string, 'question_count' => int], ...]
  142. */
  143. public function getKpsWithFewQuestions(?int $textbookId = null, int $semesterCode = 2, int $limit = 50): array
  144. {
  145. $textbooksQuery = DB::table('textbooks');
  146. if (Schema::hasColumn('textbooks', 'is_deleted')) {
  147. $textbooksQuery->where('is_deleted', 0);
  148. }
  149. if ($textbookId) {
  150. $textbooksQuery->where('id', $textbookId);
  151. }
  152. if (Schema::hasColumn('textbooks', 'semester_code')) {
  153. $textbooksQuery->where('semester_code', $semesterCode);
  154. }
  155. $textbookIds = $textbooksQuery->pluck('id')->toArray();
  156. if (empty($textbookIds)) {
  157. Log::warning('QuestionQualityCheckService: 未找到下学期教材');
  158. return [];
  159. }
  160. $kpCodes = DB::table('textbook_chapter_knowledge_relation as tckr')
  161. ->join('textbook_catalog_nodes as tcn', 'tckr.catalog_chapter_id', '=', 'tcn.id')
  162. ->whereIn('tcn.textbook_id', $textbookIds)
  163. ->where(function ($q) {
  164. $q->where('tckr.is_deleted', 0)->orWhereNull('tckr.is_deleted');
  165. })
  166. ->distinct()
  167. ->pluck('tckr.kp_code')
  168. ->toArray();
  169. if (empty($kpCodes)) {
  170. return [];
  171. }
  172. $counts = DB::table('questions')
  173. ->whereIn('kp_code', $kpCodes)
  174. ->where('audit_status', 0)
  175. ->selectRaw('kp_code, count(*) as cnt')
  176. ->groupBy('kp_code')
  177. ->pluck('cnt', 'kp_code')
  178. ->toArray();
  179. $result = [];
  180. foreach ($kpCodes as $kp) {
  181. $result[] = [
  182. 'kp_code' => $kp,
  183. 'question_count' => $counts[$kp] ?? 0,
  184. ];
  185. }
  186. usort($result, fn ($a, $b) => $a['question_count'] <=> $b['question_count']);
  187. return array_slice($result, 0, $limit);
  188. }
  189. }