| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313 |
- <?php
- namespace App\Console\Commands;
- use App\Services\ExamPdfExportService;
- use App\Services\QuestionQualityCheckService;
- use Illuminate\Console\Command;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Schema;
- class RunQuestionQualityCheckCommand extends Command
- {
- protected $signature = 'question:qc
- {--table=questions_tem : 待质检题目表名}
- {--kp= : 指定知识点,不传则按下学期题少 KP 筛选}
- {--limit=100 : 质检题目数量上限}
- {--textbook= : 教材 ID}
- {--semester=2 : 学期 1=上 2=下}
- {--dry-run : 仅输出筛选结果,不执行质检}
- {--list-kps : 第一步:仅罗列前10个题少的 KP(按下学期章节),不质检}
- {--export-pdf : 按知识点分组生成 PDF(使用组卷规范,含试卷+判卷)}
- {--ai-check : 启用 AI 校验(答案正确性、答案与题目匹配,会请求 AI 接口)}';
- protected $description = '题目自动质检:从 questions_tem 按下学期题少 KP 筛选题目并执行校验';
- public function handle(QuestionQualityCheckService $qcService, ExamPdfExportService $pdfService): int
- {
- if ($this->option('list-kps')) {
- return $this->listKpsWithFewQuestions($qcService, 10);
- }
- if ($this->option('export-pdf')) {
- return $this->exportPdfByKp($qcService, $pdfService);
- }
- $table = $this->option('table');
- if (! Schema::hasTable($table)) {
- $this->error("表 {$table} 不存在,请先创建或指定正确表名");
- return 1;
- }
- $kp = $this->option('kp');
- $limit = (int) $this->option('limit');
- $dryRun = $this->option('dry-run');
- $excludeFromQuestions = $table === 'questions_tem' && Schema::hasTable('questions')
- && Schema::hasColumn($table, 'question_code')
- && Schema::hasColumn('questions', 'question_code');
- if ($kp) {
- $query = DB::table($table)->where('kp_code', $kp);
- } else {
- $kps = $qcService->getKpsWithFewQuestions(
- $this->option('textbook') ? (int) $this->option('textbook') : null,
- (int) $this->option('semester'),
- 10
- );
- if (empty($kps)) {
- $this->warn('未找到题少的 KP,请检查 textbooks、textbook_chapter_knowledge_relation 数据');
- return 0;
- }
- $this->info('按下学期题少 KP 筛选(前 10 个):');
- foreach ($kps as $i => $r) {
- $this->line(" " . ($i + 1) . ". {$r['kp_code']} — {$r['question_count']} 题");
- }
- $kpCodes = array_column($kps, 'kp_code');
- $query = DB::table($table)->whereIn('kp_code', $kpCodes);
- }
- if ($excludeFromQuestions) {
- $query->whereNotIn('question_code', DB::table('questions')->select('question_code'));
- $this->info('已排除 questions 中已有的题目(按 question_code 去重)');
- }
- $questions = $query->limit($limit)->get();
- $aiCheck = $this->option('ai-check');
- $total = $questions->count();
- $this->info("待质检题目数: {$total}");
- if ($aiCheck) {
- $this->info('已启用 AI 校验(答案正确性、与题目匹配)');
- }
- if ($dryRun) {
- $this->info('[dry-run] 不执行质检');
- return 0;
- }
- $passed = 0;
- $failed = 0;
- $bar = $this->output->createProgressBar($total);
- $bar->start();
- foreach ($questions as $q) {
- $row = (array) $q;
- $mapped = $this->mapQuestionRow($row);
- $result = $qcService->runAutoCheck(
- $mapped,
- $row['id'] ?? null,
- null,
- ['ai_check' => $aiCheck]
- );
- if ($result['passed']) {
- $passed++;
- } else {
- $failed++;
- $this->newLine();
- $labels = array_map(
- fn ($code) => QuestionQualityCheckService::RULES[$code]['name'] ?? $code,
- $result['errors']
- );
- $this->warn(" [{$row['id']}] " . implode('、', $labels));
- }
- $bar->advance();
- }
- $bar->finish();
- $this->newLine(2);
- $this->info("质检完成: 通过 {$passed},未通过 {$failed}");
- return 0;
- }
- /**
- * 第一步:罗列按顺序题数最少的 10 个知识点(下学期章节、题少优先)
- */
- private function listKpsWithFewQuestions(QuestionQualityCheckService $qcService, int $top): int
- {
- $kps = $qcService->getKpsWithFewQuestions(
- $this->option('textbook') ? (int) $this->option('textbook') : null,
- (int) $this->option('semester'),
- $top
- );
- if (empty($kps)) {
- $this->warn('未找到题少的 KP,请检查 textbooks、textbook_chapter_knowledge_relation 数据');
- return 0;
- }
- $this->info("按下学期章节、题少优先,前 {$top} 个知识点:");
- $this->newLine();
- foreach ($kps as $i => $r) {
- $rank = $i + 1;
- $this->line(" {$rank}. {$r['kp_code']} — {$r['question_count']} 题");
- }
- return 0;
- }
- /**
- * 按知识点分组生成 PDF,使用组卷和 PDF 生成规范(ExamPdfExportService::generateByQuestions)
- */
- private function exportPdfByKp(QuestionQualityCheckService $qcService, ExamPdfExportService $pdfService): int
- {
- $table = $this->option('table');
- $kp = $this->option('kp');
- $grouped = $qcService->getQcQuestionsGroupedByKp(
- $table,
- $this->option('textbook') ? (int) $this->option('textbook') : null,
- (int) $this->option('semester'),
- 10,
- (int) $this->option('limit') ?: null,
- $kp ?: null
- );
- if (empty($grouped)) {
- $this->warn('未找到待导出的题目,请检查数据');
- return 0;
- }
- $this->info('按知识点分组生成 PDF(试卷 + 判卷):');
- $this->newLine();
- $student = ['name' => '________', 'grade' => '________'];
- $teacher = ['name' => '________'];
- $totalPdf = 0;
- $errors = [];
- foreach ($grouped as $kpCode => $questions) {
- $count = $questions->count();
- $this->line(" [{$kpCode}] {$count} 题");
- $groupedQuestions = $this->buildGroupedQuestionsForPdf($questions);
- $totalQ = count($groupedQuestions['choice'] ?? []) + count($groupedQuestions['fill'] ?? []) + count($groupedQuestions['answer'] ?? []);
- if ($totalQ === 0) {
- $this->warn(" 跳过:无可导出题目");
- continue;
- }
- $paperName = "质检题目_{$kpCode}";
- $paperId = 'qc_kp_' . $kpCode . '_' . time() . '_' . uniqid();
- $paper = $this->buildVirtualPaperForPdf($paperId, $paperName, $groupedQuestions);
- try {
- $result = $pdfService->generateByQuestions($paper, $groupedQuestions, $student, $teacher, true);
- if (! empty($result['pdf_url'])) {
- $this->line('');
- $this->line($result['pdf_url']);
- $this->line('');
- $totalPdf++;
- }
- } catch (\Throwable $e) {
- $errors[] = "{$kpCode}: {$e->getMessage()}";
- $this->error(" 失败: {$e->getMessage()}");
- }
- }
- $this->newLine();
- $this->info("完成: 共 {$totalPdf} 个知识点生成 PDF" . (empty($errors) ? '' : ',' . count($errors) . ' 个失败'));
- return empty($errors) ? 0 : 1;
- }
- /**
- * 将题目集合转为 PDF 规范格式:choice / fill / answer
- */
- private function buildGroupedQuestionsForPdf($questions): array
- {
- $grouped = ['choice' => [], 'fill' => [], 'answer' => []];
- $typeMap = [
- 'choice' => 'choice', '选择题' => 'choice', 'single_choice' => 'choice', 'multiple_choice' => 'choice',
- 'fill' => 'fill', '填空题' => 'fill', 'blank' => 'fill',
- 'answer' => 'answer', '解答题' => 'answer', 'subjective' => 'answer', 'calculation' => 'answer', 'proof' => 'answer',
- ];
- $scoreMap = ['choice' => 5, 'fill' => 5, 'answer' => 10];
- $num = 1;
- foreach ($questions as $q) {
- $rawType = strtolower(trim((string) ($q->question_type ?? $q->tags ?? 'answer')));
- $type = $typeMap[$rawType] ?? 'answer';
- $score = $scoreMap[$type] ?? 5;
- $opts = $q->options ?? null;
- if (is_string($opts)) {
- $opts = json_decode($opts, true);
- }
- $grouped[$type][] = (object) [
- 'id' => $q->id ?? 0,
- 'question_number' => $num++,
- 'content' => $q->stem ?? $q->content ?? '',
- 'options' => is_array($opts) ? $opts : [],
- 'answer' => $q->answer ?? $q->correct_answer ?? '',
- 'solution' => $q->solution ?? '',
- 'score' => $score,
- 'difficulty' => $q->difficulty ?? 0.5,
- 'kp_code' => $q->kp_code ?? '',
- ];
- }
- return $grouped;
- }
- /**
- * 构建虚拟试卷对象(符合 ExamPdfExportService 规范)
- */
- private function buildVirtualPaperForPdf(string $paperId, string $paperName, array $groupedQuestions): object
- {
- $totalScore = 0;
- $totalQuestions = 0;
- foreach ($groupedQuestions as $qs) {
- foreach ($qs as $q) {
- $totalScore += $q->score ?? 5;
- $totalQuestions++;
- }
- }
- return (object) [
- 'paper_id' => $paperId,
- 'paper_name' => $paperName,
- 'total_score' => $totalScore,
- 'total_questions' => $totalQuestions,
- 'created_at' => now()->toDateTimeString(),
- ];
- }
- /**
- * 将 questions_tem 行映射为质检服务所需格式
- */
- private function mapQuestionRow(array $row): array
- {
- $optionsRaw = $row['options'] ?? null;
- $options = null;
- $optionsJsonInvalid = false;
- if (is_string($optionsRaw) && trim($optionsRaw) !== '') {
- $decoded = json_decode($optionsRaw, true);
- if (json_last_error() !== JSON_ERROR_NONE) {
- $optionsJsonInvalid = true;
- } else {
- $options = $decoded;
- }
- } elseif (is_array($optionsRaw)) {
- $options = $optionsRaw;
- }
- return [
- 'stem' => $row['stem'] ?? $row['content'] ?? '',
- 'answer' => $row['answer'] ?? $row['correct_answer'] ?? '',
- 'solution' => $row['solution'] ?? '',
- 'question_type' => $row['question_type'] ?? $row['tags'] ?? '',
- 'options' => $options,
- 'options_json_invalid' => $optionsJsonInvalid,
- ];
- }
- }
|