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, ]; } }