| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259 |
- <?php
- namespace App\Services;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Log;
- use Illuminate\Support\Facades\Schema;
- /**
- * 题目质检服务
- *
- * 校验规则:题干、答案、解析、选项、公式、PDF 呈现
- * 自动质检 + 人工质检结果分别记录到 question_qc_results
- */
- 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 question_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);
- foreach ($results as $r) {
- $this->saveQcResult($r, $questionTemId, $questionId, 'auto');
- }
- 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,
- ];
- }
- private function saveQcResult(array $r, ?int $questionTemId, ?int $questionId, string $source): void
- {
- if (!Schema::hasTable('question_qc_results')) {
- return;
- }
- try {
- DB::table('question_qc_results')->insert([
- 'question_tem_id' => $questionTemId,
- 'question_id' => $questionId,
- 'rule_code' => $r['rule_code'],
- 'rule_name' => $r['rule_name'] ?? null,
- 'passed' => $r['passed'],
- 'auto_result' => $r['auto_result'] ?? ($r['passed'] ? 'pass' : 'fail'),
- 'manual_result' => null,
- 'pdf_render_ok' => null,
- 'detail' => $r['detail'] ?? null,
- 'source' => $source,
- 'created_at' => now(),
- 'updated_at' => now(),
- ]);
- } catch (\Throwable $e) {
- Log::warning('QuestionQualityCheckService: 保存质检结果失败', [
- 'rule_code' => $r['rule_code'],
- 'error' => $e->getMessage(),
- ]);
- }
- }
- /**
- * 记录人工质检结果(更新已有自动质检记录)
- */
- public function recordManualResult(int $questionTemId, string $ruleCode, string $manualResult, ?bool $pdfRenderOk = null): void
- {
- if (!Schema::hasTable('question_qc_results')) {
- return;
- }
- DB::table('question_qc_results')
- ->where('question_tem_id', $questionTemId)
- ->where('rule_code', $ruleCode)
- ->update([
- 'manual_result' => $manualResult,
- 'pdf_render_ok' => $pdfRenderOk,
- 'updated_at' => now(),
- ]);
- }
- /**
- * 获取下学期章节关联的、题少的 KP 列表(用于筛选 question_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')->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);
- }
- }
|