GenerateOptionLayoutRegressionCommand.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Services\ExamPdfExportService;
  4. use App\Support\OptionLayoutDecider;
  5. use Illuminate\Console\Command;
  6. use Illuminate\Support\Collection;
  7. use Illuminate\Support\Facades\DB;
  8. use Illuminate\Support\Facades\File;
  9. class GenerateOptionLayoutRegressionCommand extends Command
  10. {
  11. protected $signature = 'exam:generate-option-layout-regression
  12. {--connection=remote_mysql : 数据库连接}
  13. {--source=main : 题库来源 main|default|ai}
  14. {--question-type=choice : 题型过滤 choice|fill|answer|all}
  15. {--keep-ids= : 固定保留题目ID,逗号分隔,例如 38848,38772,38869}
  16. {--limit=40 : 抽题数量}
  17. {--grading=1 : 是否同时生成判卷PDF 1|0}
  18. {--student-id=REGRESSION : 学生ID}
  19. {--student-name=排版回归 : 学生姓名}
  20. {--student-grade=初三 : 学生年级}
  21. {--teacher-name=回归老师 : 教师姓名}
  22. {--paper-name=选项排版回归 : 试卷名称}
  23. {--report= : 报告输出路径(JSON)}';
  24. protected $description = '遍历题库复杂公式题,生成真实PDF并输出选项布局回归报告';
  25. public function handle(
  26. ExamPdfExportService $pdfExportService,
  27. OptionLayoutDecider $layoutDecider
  28. ): int {
  29. $connection = (string) $this->option('connection');
  30. $source = (string) $this->option('source');
  31. $limit = max(1, (int) $this->option('limit'));
  32. $questionType = (string) $this->option('question-type');
  33. $keepIds = $this->parseKeepIds((string) $this->option('keep-ids'));
  34. $includeGrading = (string) $this->option('grading') !== '0';
  35. $table = match ($source) {
  36. 'default' => 'questions_tem',
  37. 'ai' => 'questions_ai',
  38. default => 'questions',
  39. };
  40. $this->info("开始回归抽题: {$connection}.{$table}, type={$questionType}, limit={$limit}, keep=".count($keepIds));
  41. $questions = $this->fetchComplexQuestions($connection, $table, $limit, $questionType, $keepIds);
  42. if ($questions->isEmpty()) {
  43. $this->warn('未找到复杂公式题,请调整筛选条件或检查题库数据');
  44. return self::FAILURE;
  45. }
  46. $groupedQuestions = $this->groupQuestionsByType($questions);
  47. $paperId = 'layout_regression_'.date('YmdHis').'_'.substr(uniqid('', true), -6);
  48. $paper = $this->buildVirtualPaper(
  49. $paperId,
  50. (string) $this->option('paper-name'),
  51. $groupedQuestions
  52. );
  53. $student = [
  54. 'id' => (string) $this->option('student-id'),
  55. 'name' => (string) $this->option('student-name'),
  56. 'grade' => (string) $this->option('student-grade'),
  57. ];
  58. $teacher = ['name' => (string) $this->option('teacher-name')];
  59. $result = $pdfExportService->generateByQuestions(
  60. $paper,
  61. $groupedQuestions,
  62. $student,
  63. $teacher,
  64. $includeGrading
  65. );
  66. $reportPath = (string) ($this->option('report') ?: storage_path('app/regression/option-layout-'.$paperId.'.json'));
  67. File::ensureDirectoryExists(dirname($reportPath));
  68. File::put($reportPath, json_encode([
  69. 'generated_at' => now()->toDateTimeString(),
  70. 'paper_id' => $paperId,
  71. 'source' => [
  72. 'connection' => $connection,
  73. 'table' => $table,
  74. 'keep_ids' => $keepIds,
  75. ],
  76. 'summary' => [
  77. 'question_count' => $questions->count(),
  78. 'choice_count' => count($groupedQuestions['choice']),
  79. 'fill_count' => count($groupedQuestions['fill']),
  80. 'answer_count' => count($groupedQuestions['answer']),
  81. ],
  82. 'pdfs' => $result,
  83. 'layout_checks' => $this->buildLayoutChecks($groupedQuestions, $layoutDecider),
  84. ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).PHP_EOL);
  85. $this->info('回归PDF生成完成');
  86. $this->line('Exam PDF: '.($result['pdf_url'] ?? 'N/A'));
  87. $this->line('Grading PDF: '.($result['grading_pdf_url'] ?? 'N/A'));
  88. $this->line('Report JSON: '.$reportPath);
  89. return self::SUCCESS;
  90. }
  91. private function fetchComplexQuestions(string $connection, string $table, int $limit, string $questionType, array $keepIds): Collection
  92. {
  93. $kept = collect();
  94. if (! empty($keepIds)) {
  95. $keptRaw = DB::connection($connection)
  96. ->table($table)
  97. ->select(['id', 'question_type', 'kp_code', 'stem', 'options', 'answer', 'solution', 'difficulty'])
  98. ->whereIn('id', $keepIds)
  99. ->get()
  100. ->keyBy('id');
  101. foreach ($keepIds as $id) {
  102. if ($keptRaw->has($id)) {
  103. $kept->push($keptRaw->get($id));
  104. }
  105. }
  106. }
  107. $remainingLimit = max(0, $limit - $kept->count());
  108. $query = DB::connection($connection)
  109. ->table($table)
  110. ->select(['id', 'question_type', 'kp_code', 'stem', 'options', 'answer', 'solution', 'difficulty'])
  111. ->when($questionType !== 'all', function ($q) use ($questionType) {
  112. $q->where('question_type', $questionType);
  113. })
  114. ->when($questionType === 'choice', function ($q) {
  115. $q->whereNotNull('options')
  116. ->where('options', '!=', '')
  117. ->where('options', '!=', '[]')
  118. ->where('options', '!=', '{}');
  119. })
  120. ->where(function ($query) {
  121. $query->where('stem', 'like', '%\\frac%')
  122. ->orWhere('stem', 'like', '%\\sqrt%')
  123. ->orWhere('stem', 'like', '%\\log%')
  124. ->orWhere('stem', 'like', '%\\sin%')
  125. ->orWhere('stem', 'like', '%\\cos%')
  126. ->orWhere('stem', 'like', '%\\tan%')
  127. ->orWhere('stem', 'like', '%<img%')
  128. ->orWhere('options', 'like', '%\\frac%')
  129. ->orWhere('options', 'like', '%\\sqrt%')
  130. ->orWhere('options', 'like', '%\\log%')
  131. ->orWhere('options', 'like', '%\\sin%')
  132. ->orWhere('options', 'like', '%\\cos%')
  133. ->orWhere('options', 'like', '%\\tan%')
  134. ->orWhere('options', 'like', '%^%')
  135. ->orWhere('options', 'like', '%/%');
  136. })
  137. ->when(! empty($keepIds), function ($q) use ($keepIds) {
  138. $q->whereNotIn('id', $keepIds);
  139. })
  140. ->orderByDesc('id');
  141. $sampled = $remainingLimit > 0 ? $query->limit($remainingLimit)->get() : collect();
  142. return $kept->concat($sampled)->values();
  143. }
  144. private function groupQuestionsByType(Collection $questions): array
  145. {
  146. $grouped = [
  147. 'choice' => [],
  148. 'fill' => [],
  149. 'answer' => [],
  150. ];
  151. $questionNumber = 1;
  152. foreach ($questions as $q) {
  153. $type = $this->normalizeQuestionType($q->question_type);
  154. $grouped[$type][] = (object) [
  155. 'id' => $q->id,
  156. 'question_number' => $questionNumber++,
  157. 'content' => $q->stem,
  158. 'options' => $this->normalizeOptions($q->options),
  159. 'answer' => $q->answer,
  160. 'solution' => $q->solution,
  161. 'score' => $this->defaultScore($type),
  162. 'difficulty' => $q->difficulty,
  163. 'kp_code' => $q->kp_code,
  164. ];
  165. }
  166. return $grouped;
  167. }
  168. private function normalizeQuestionType(?string $type): string
  169. {
  170. $type = strtolower(trim((string) $type));
  171. return match ($type) {
  172. 'choice', '选择题', 'single_choice', 'multiple_choice' => 'choice',
  173. 'fill', '填空题', 'blank' => 'fill',
  174. default => 'answer',
  175. };
  176. }
  177. private function normalizeOptions(mixed $options): array
  178. {
  179. if (is_array($options)) {
  180. return array_values($options);
  181. }
  182. if (is_string($options) && trim($options) !== '') {
  183. $decoded = json_decode($options, true);
  184. if (is_array($decoded)) {
  185. return array_values($decoded);
  186. }
  187. }
  188. return [];
  189. }
  190. private function defaultScore(string $type): int
  191. {
  192. return match ($type) {
  193. 'answer' => 10,
  194. default => 5,
  195. };
  196. }
  197. private function buildVirtualPaper(string $paperId, string $paperName, array $groupedQuestions): object
  198. {
  199. $totalScore = 0;
  200. $totalQuestions = 0;
  201. foreach ($groupedQuestions as $items) {
  202. foreach ($items as $item) {
  203. $totalScore += (int) ($item->score ?? 0);
  204. $totalQuestions++;
  205. }
  206. }
  207. return (object) [
  208. 'paper_id' => $paperId,
  209. 'paper_name' => $paperName,
  210. 'total_score' => $totalScore,
  211. 'total_questions' => $totalQuestions,
  212. 'created_at' => now()->toDateTimeString(),
  213. ];
  214. }
  215. private function buildLayoutChecks(array $groupedQuestions, OptionLayoutDecider $layoutDecider): array
  216. {
  217. $rows = [];
  218. foreach (['choice', 'fill', 'answer'] as $type) {
  219. foreach ($groupedQuestions[$type] as $question) {
  220. $options = is_array($question->options ?? null) ? $question->options : [];
  221. if ($type !== 'choice' || empty($options)) {
  222. continue;
  223. }
  224. $examDecision = $layoutDecider->decide($options, 'exam');
  225. $gradingDecision = $layoutDecider->decide($options, 'grading');
  226. $rows[] = [
  227. 'question_id' => $question->id,
  228. 'question_number' => $question->question_number,
  229. 'kp_code' => $question->kp_code,
  230. 'options_count' => count($options),
  231. 'exam_layout' => $examDecision['class'],
  232. 'grading_layout' => $gradingDecision['class'],
  233. 'max_length_exam' => $examDecision['max_length'],
  234. 'has_complex_formula' => $examDecision['has_complex_formula'],
  235. ];
  236. }
  237. }
  238. return $rows;
  239. }
  240. /**
  241. * @return array<int>
  242. */
  243. private function parseKeepIds(string $keepIds): array
  244. {
  245. if (trim($keepIds) === '') {
  246. return [];
  247. }
  248. return collect(explode(',', $keepIds))
  249. ->map(fn ($id) => (int) trim($id))
  250. ->filter(fn ($id) => $id > 0)
  251. ->unique()
  252. ->values()
  253. ->all();
  254. }
  255. }