RunQuestionQualityCheckCommand.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Services\ExamPdfExportService;
  4. use App\Services\QuestionQualityCheckService;
  5. use Illuminate\Console\Command;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Schema;
  8. class RunQuestionQualityCheckCommand extends Command
  9. {
  10. protected $signature = 'question:qc
  11. {--table=questions_tem : 待质检题目表名}
  12. {--kp= : 指定知识点,不传则按下学期题少 KP 筛选}
  13. {--limit=100 : 质检题目数量上限}
  14. {--textbook= : 教材 ID}
  15. {--semester=2 : 学期 1=上 2=下}
  16. {--dry-run : 仅输出筛选结果,不执行质检}
  17. {--list-kps : 第一步:仅罗列前10个题少的 KP(按下学期章节),不质检}
  18. {--export-pdf : 按知识点分组生成 PDF(使用组卷规范,含试卷+判卷)}
  19. {--ai-check : 启用 AI 校验(答案正确性、答案与题目匹配,会请求 AI 接口)}';
  20. protected $description = '题目自动质检:从 questions_tem 按下学期题少 KP 筛选题目并执行校验';
  21. public function handle(QuestionQualityCheckService $qcService, ExamPdfExportService $pdfService): int
  22. {
  23. if ($this->option('list-kps')) {
  24. return $this->listKpsWithFewQuestions($qcService, 10);
  25. }
  26. if ($this->option('export-pdf')) {
  27. return $this->exportPdfByKp($qcService, $pdfService);
  28. }
  29. $table = $this->option('table');
  30. if (! Schema::hasTable($table)) {
  31. $this->error("表 {$table} 不存在,请先创建或指定正确表名");
  32. return 1;
  33. }
  34. $kp = $this->option('kp');
  35. $limit = (int) $this->option('limit');
  36. $dryRun = $this->option('dry-run');
  37. $excludeFromQuestions = $table === 'questions_tem' && Schema::hasTable('questions')
  38. && Schema::hasColumn($table, 'question_code')
  39. && Schema::hasColumn('questions', 'question_code');
  40. if ($kp) {
  41. $query = DB::table($table)->where('kp_code', $kp);
  42. } else {
  43. $kps = $qcService->getKpsWithFewQuestions(
  44. $this->option('textbook') ? (int) $this->option('textbook') : null,
  45. (int) $this->option('semester'),
  46. 10
  47. );
  48. if (empty($kps)) {
  49. $this->warn('未找到题少的 KP,请检查 textbooks、textbook_chapter_knowledge_relation 数据');
  50. return 0;
  51. }
  52. $this->info('按下学期题少 KP 筛选(前 10 个):');
  53. foreach ($kps as $i => $r) {
  54. $this->line(" " . ($i + 1) . ". {$r['kp_code']} — {$r['question_count']} 题");
  55. }
  56. $kpCodes = array_column($kps, 'kp_code');
  57. $query = DB::table($table)->whereIn('kp_code', $kpCodes);
  58. }
  59. if ($excludeFromQuestions) {
  60. $query->whereNotIn('question_code', DB::table('questions')->select('question_code'));
  61. $this->info('已排除 questions 中已有的题目(按 question_code 去重)');
  62. }
  63. $questions = $query->limit($limit)->get();
  64. $aiCheck = $this->option('ai-check');
  65. $total = $questions->count();
  66. $this->info("待质检题目数: {$total}");
  67. if ($aiCheck) {
  68. $this->info('已启用 AI 校验(答案正确性、与题目匹配)');
  69. }
  70. if ($dryRun) {
  71. $this->info('[dry-run] 不执行质检');
  72. return 0;
  73. }
  74. $passed = 0;
  75. $failed = 0;
  76. $bar = $this->output->createProgressBar($total);
  77. $bar->start();
  78. foreach ($questions as $q) {
  79. $row = (array) $q;
  80. $mapped = $this->mapQuestionRow($row);
  81. $result = $qcService->runAutoCheck(
  82. $mapped,
  83. $row['id'] ?? null,
  84. null,
  85. ['ai_check' => $aiCheck]
  86. );
  87. if ($result['passed']) {
  88. $passed++;
  89. } else {
  90. $failed++;
  91. $this->newLine();
  92. $labels = array_map(
  93. fn ($code) => QuestionQualityCheckService::RULES[$code]['name'] ?? $code,
  94. $result['errors']
  95. );
  96. $this->warn(" [{$row['id']}] " . implode('、', $labels));
  97. }
  98. $bar->advance();
  99. }
  100. $bar->finish();
  101. $this->newLine(2);
  102. $this->info("质检完成: 通过 {$passed},未通过 {$failed}");
  103. return 0;
  104. }
  105. /**
  106. * 第一步:罗列按顺序题数最少的 10 个知识点(下学期章节、题少优先)
  107. */
  108. private function listKpsWithFewQuestions(QuestionQualityCheckService $qcService, int $top): int
  109. {
  110. $kps = $qcService->getKpsWithFewQuestions(
  111. $this->option('textbook') ? (int) $this->option('textbook') : null,
  112. (int) $this->option('semester'),
  113. $top
  114. );
  115. if (empty($kps)) {
  116. $this->warn('未找到题少的 KP,请检查 textbooks、textbook_chapter_knowledge_relation 数据');
  117. return 0;
  118. }
  119. $this->info("按下学期章节、题少优先,前 {$top} 个知识点:");
  120. $this->newLine();
  121. foreach ($kps as $i => $r) {
  122. $rank = $i + 1;
  123. $this->line(" {$rank}. {$r['kp_code']} — {$r['question_count']} 题");
  124. }
  125. return 0;
  126. }
  127. /**
  128. * 按知识点分组生成 PDF,使用组卷和 PDF 生成规范(ExamPdfExportService::generateByQuestions)
  129. */
  130. private function exportPdfByKp(QuestionQualityCheckService $qcService, ExamPdfExportService $pdfService): int
  131. {
  132. $table = $this->option('table');
  133. $kp = $this->option('kp');
  134. $grouped = $qcService->getQcQuestionsGroupedByKp(
  135. $table,
  136. $this->option('textbook') ? (int) $this->option('textbook') : null,
  137. (int) $this->option('semester'),
  138. 10,
  139. (int) $this->option('limit') ?: null,
  140. $kp ?: null
  141. );
  142. if (empty($grouped)) {
  143. $this->warn('未找到待导出的题目,请检查数据');
  144. return 0;
  145. }
  146. $this->info('按知识点分组生成 PDF(试卷 + 判卷):');
  147. $this->newLine();
  148. $student = ['name' => '________', 'grade' => '________'];
  149. $teacher = ['name' => '________'];
  150. $totalPdf = 0;
  151. $errors = [];
  152. foreach ($grouped as $kpCode => $questions) {
  153. $count = $questions->count();
  154. $this->line(" [{$kpCode}] {$count} 题");
  155. $groupedQuestions = $this->buildGroupedQuestionsForPdf($questions);
  156. $totalQ = count($groupedQuestions['choice'] ?? []) + count($groupedQuestions['fill'] ?? []) + count($groupedQuestions['answer'] ?? []);
  157. if ($totalQ === 0) {
  158. $this->warn(" 跳过:无可导出题目");
  159. continue;
  160. }
  161. $paperName = "质检题目_{$kpCode}";
  162. $paperId = 'qc_kp_' . $kpCode . '_' . time() . '_' . uniqid();
  163. $paper = $this->buildVirtualPaperForPdf($paperId, $paperName, $groupedQuestions);
  164. try {
  165. $result = $pdfService->generateByQuestions($paper, $groupedQuestions, $student, $teacher, true);
  166. if (! empty($result['pdf_url'])) {
  167. $this->line('');
  168. $this->line($result['pdf_url']);
  169. $this->line('');
  170. $totalPdf++;
  171. }
  172. } catch (\Throwable $e) {
  173. $errors[] = "{$kpCode}: {$e->getMessage()}";
  174. $this->error(" 失败: {$e->getMessage()}");
  175. }
  176. }
  177. $this->newLine();
  178. $this->info("完成: 共 {$totalPdf} 个知识点生成 PDF" . (empty($errors) ? '' : ',' . count($errors) . ' 个失败'));
  179. return empty($errors) ? 0 : 1;
  180. }
  181. /**
  182. * 将题目集合转为 PDF 规范格式:choice / fill / answer
  183. */
  184. private function buildGroupedQuestionsForPdf($questions): array
  185. {
  186. $grouped = ['choice' => [], 'fill' => [], 'answer' => []];
  187. $typeMap = [
  188. 'choice' => 'choice', '选择题' => 'choice', 'single_choice' => 'choice', 'multiple_choice' => 'choice',
  189. 'fill' => 'fill', '填空题' => 'fill', 'blank' => 'fill',
  190. 'answer' => 'answer', '解答题' => 'answer', 'subjective' => 'answer', 'calculation' => 'answer', 'proof' => 'answer',
  191. ];
  192. $scoreMap = ['choice' => 5, 'fill' => 5, 'answer' => 10];
  193. $num = 1;
  194. foreach ($questions as $q) {
  195. $rawType = strtolower(trim((string) ($q->question_type ?? $q->tags ?? 'answer')));
  196. $type = $typeMap[$rawType] ?? 'answer';
  197. $score = $scoreMap[$type] ?? 5;
  198. $opts = $q->options ?? null;
  199. if (is_string($opts)) {
  200. $opts = json_decode($opts, true);
  201. }
  202. $grouped[$type][] = (object) [
  203. 'id' => $q->id ?? 0,
  204. 'question_number' => $num++,
  205. 'content' => $q->stem ?? $q->content ?? '',
  206. 'options' => is_array($opts) ? $opts : [],
  207. 'answer' => $q->answer ?? $q->correct_answer ?? '',
  208. 'solution' => $q->solution ?? '',
  209. 'score' => $score,
  210. 'difficulty' => $q->difficulty ?? 0.5,
  211. 'kp_code' => $q->kp_code ?? '',
  212. ];
  213. }
  214. return $grouped;
  215. }
  216. /**
  217. * 构建虚拟试卷对象(符合 ExamPdfExportService 规范)
  218. */
  219. private function buildVirtualPaperForPdf(string $paperId, string $paperName, array $groupedQuestions): object
  220. {
  221. $totalScore = 0;
  222. $totalQuestions = 0;
  223. foreach ($groupedQuestions as $qs) {
  224. foreach ($qs as $q) {
  225. $totalScore += $q->score ?? 5;
  226. $totalQuestions++;
  227. }
  228. }
  229. return (object) [
  230. 'paper_id' => $paperId,
  231. 'paper_name' => $paperName,
  232. 'total_score' => $totalScore,
  233. 'total_questions' => $totalQuestions,
  234. 'created_at' => now()->toDateTimeString(),
  235. ];
  236. }
  237. /**
  238. * 将 questions_tem 行映射为质检服务所需格式
  239. */
  240. private function mapQuestionRow(array $row): array
  241. {
  242. $optionsRaw = $row['options'] ?? null;
  243. $options = null;
  244. $optionsJsonInvalid = false;
  245. if (is_string($optionsRaw) && trim($optionsRaw) !== '') {
  246. $decoded = json_decode($optionsRaw, true);
  247. if (json_last_error() !== JSON_ERROR_NONE) {
  248. $optionsJsonInvalid = true;
  249. } else {
  250. $options = $decoded;
  251. }
  252. } elseif (is_array($optionsRaw)) {
  253. $options = $optionsRaw;
  254. }
  255. return [
  256. 'stem' => $row['stem'] ?? $row['content'] ?? '',
  257. 'answer' => $row['answer'] ?? $row['correct_answer'] ?? '',
  258. 'solution' => $row['solution'] ?? '',
  259. 'question_type' => $row['question_type'] ?? $row['tags'] ?? '',
  260. 'options' => $options,
  261. 'options_json_invalid' => $optionsJsonInvalid,
  262. ];
  263. }
  264. }