|
|
@@ -0,0 +1,212 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace App\Services;
|
|
|
+
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
+use Illuminate\Support\Facades\Log;
|
|
|
+use Illuminate\Support\Facades\Schema;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 题目质检服务
|
|
|
+ *
|
|
|
+ * 校验规则:题干、答案、解析、选项、公式、PDF 呈现
|
|
|
+ * 结果由命令输出,不落库(避免本地库覆盖)
|
|
|
+ */
|
|
|
+class QuestionQualityCheckService
|
|
|
+{
|
|
|
+ public const RULES = [
|
|
|
+ 'STEM_EMPTY' => ['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 questions_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);
|
|
|
+
|
|
|
+ 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,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取下学期章节关联的、题少的 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);
|
|
|
+ }
|
|
|
+}
|