AssembleExamTaskJob.php 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818
  1. <?php
  2. namespace App\Jobs;
  3. use App\Models\MistakeRecord;
  4. use App\Services\DifficultyDistributionService;
  5. use App\Services\LearningAnalyticsService;
  6. use App\Services\QuestionDifficultyResolver;
  7. use App\Services\QuestionBankService;
  8. use App\Services\QuestionPayloadMapper;
  9. use App\Services\TaskManager;
  10. use App\Services\WrongQuestionPracticePlanService;
  11. use App\Support\AssembleType;
  12. use Illuminate\Bus\Queueable;
  13. use Illuminate\Contracts\Queue\ShouldQueue;
  14. use Illuminate\Foundation\Bus\Dispatchable;
  15. use Illuminate\Queue\InteractsWithQueue;
  16. use Illuminate\Queue\SerializesModels;
  17. use Illuminate\Support\Facades\Log;
  18. use Throwable;
  19. class AssembleExamTaskJob implements ShouldQueue
  20. {
  21. use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
  22. public string $taskId;
  23. public int $tries = 2;
  24. public int $timeout = 180;
  25. public function __construct(string $taskId)
  26. {
  27. $this->taskId = $taskId;
  28. // 复用现有 pdf 队列,与历史部署/消费者一致
  29. $this->onQueue('pdf');
  30. $this->afterCommit();
  31. }
  32. public function handle(
  33. LearningAnalyticsService $learningAnalyticsService,
  34. QuestionBankService $questionBankService,
  35. WrongQuestionPracticePlanService $wrongQuestionPracticePlanService,
  36. TaskManager $taskManager
  37. ): void {
  38. $task = $taskManager->getTaskStatus($this->taskId);
  39. if (!is_array($task) || empty($task['data']) || !is_array($task['data'])) {
  40. $taskManager->markTaskFailed($this->taskId, '任务数据不存在');
  41. return;
  42. }
  43. $data = $task['data'];
  44. $assembleStartedAt = microtime(true);
  45. $phaseStartedAt = $assembleStartedAt;
  46. try {
  47. $taskManager->updateTaskProgress($this->taskId, 5, '开始异步组卷...');
  48. $requestedAssembleType = (int) ($data['assemble_type'] ?? 4);
  49. $strategyAssembleType = AssembleType::toStrategyType($requestedAssembleType);
  50. $difficultyCategory = $data['difficulty_category'] ?? 1;
  51. $paperName = $data['paper_name'] ?? ('智能试卷_'.now()->format('Ymd_His'));
  52. $mistakeIds = $data['mistake_ids'] ?? [];
  53. $mistakeQuestionIds = $data['mistake_question_ids'] ?? [];
  54. $paperIds = $data['paper_ids'] ?? [];
  55. $questionTypeRatio = $this->normalizeQuestionTypeRatio($data['question_type_ratio'] ?? []);
  56. $questions = [];
  57. $result = null;
  58. $diagnosticChapterId = null;
  59. $explanationKpCodes = null;
  60. $wrongQuestionPracticePlan = null;
  61. if (in_array($requestedAssembleType, [15, 16], true)) {
  62. // assemble_type=15(错题再练):paper_ids 为题库 question_id,直组原错题。
  63. // assemble_type=16(错题追练):paper_ids 仍为题库 question_id,但只用来生成知识点组卷计划。
  64. $questionIdList = $this->normalizeBankQuestionIdsList($paperIds);
  65. if ($questionIdList === []) {
  66. $taskManager->markTaskFailed($this->taskId, ($requestedAssembleType === 16 ? '错题追练' : '错题再练').'组卷需提供 paper_ids(题库题目 id)');
  67. return;
  68. }
  69. if ($requestedAssembleType === 16) {
  70. $wrongQuestionPracticePlan = $wrongQuestionPracticePlanService->build(
  71. (string) $data['student_id'],
  72. $questionIdList,
  73. (int) ($data['total_questions'] ?? config('question_bank.default_total_questions'))
  74. );
  75. if (empty($wrongQuestionPracticePlan['usable'])) {
  76. $taskManager->markTaskFailed($this->taskId, $wrongQuestionPracticePlan['message'] ?? '错题追练没有可用的知识点题目');
  77. return;
  78. }
  79. $paperName = $data['paper_name'] ?? ('错题追练_'.$data['student_id'].'_'.now()->format('Ymd_His'));
  80. $params = [
  81. 'student_id' => $data['student_id'],
  82. 'grade' => $data['grade'] ?? null,
  83. 'total_questions' => (int) ($wrongQuestionPracticePlan['target_questions'] ?? ($data['total_questions'] ?? config('question_bank.default_total_questions'))),
  84. 'kp_codes' => $wrongQuestionPracticePlan['kp_code_list'] ?? [],
  85. 'skills' => $data['skills'] ?? [],
  86. 'question_type_ratio' => $wrongQuestionPracticePlan['question_type_ratio'] ?? $questionTypeRatio,
  87. 'difficulty_category' => $difficultyCategory,
  88. 'assemble_type' => 2,
  89. 'exam_type' => 'knowledge',
  90. 'paper_ids' => [],
  91. 'textbook_id' => $data['textbook_id'] ?? null,
  92. 'end_catalog_id' => $data['end_catalog_id'] ?? null,
  93. 'chapter_id_list' => $data['chapter_id_list'] ?? null,
  94. 'kp_code_list' => $wrongQuestionPracticePlan['kp_code_list'] ?? [],
  95. 'kp_target_counts' => $wrongQuestionPracticePlan['kp_target_counts'] ?? [],
  96. 'target_difficulty_by_kp' => $wrongQuestionPracticePlan['target_difficulty_by_kp'] ?? [],
  97. 'max_difficulty_by_kp' => $wrongQuestionPracticePlan['max_difficulty_by_kp'] ?? [],
  98. 'type_targets_by_kp' => $wrongQuestionPracticePlan['type_targets_by_kp'] ?? [],
  99. 'exclude_question_ids' => $wrongQuestionPracticePlan['exclude_question_ids'] ?? [],
  100. 'wrong_question_practice_plan' => $wrongQuestionPracticePlan,
  101. ];
  102. $result = $learningAnalyticsService->generateIntelligentExam($params);
  103. if (empty($result['success'])) {
  104. $taskManager->markTaskFailed($this->taskId, $result['message'] ?? '错题追练组卷未生成题目');
  105. return;
  106. }
  107. if (isset($result['stats']['difficulty_category'])) {
  108. $difficultyCategory = $result['stats']['difficulty_category'];
  109. }
  110. $diagnosticChapterId = $result['diagnostic_chapter_id'] ?? null;
  111. $explanationKpCodes = $result['explanation_kp_codes'] ?? null;
  112. $result['assemble_type'] = 16;
  113. $questions = $this->hydrateQuestions($result['questions'] ?? [], $wrongQuestionPracticePlan['kp_code_list'] ?? []);
  114. if (empty($questions)) {
  115. $taskManager->markTaskFailed($this->taskId, '错题追练组卷未生成有效题目');
  116. return;
  117. }
  118. } else {
  119. $strict = $this->resolveMistakeQuestionIdsStrictForStudent(
  120. (string) $data['student_id'],
  121. [],
  122. array_map(static fn ($id) => (string) $id, $questionIdList)
  123. );
  124. if (! ($strict['ok'] ?? false)) {
  125. $taskManager->markTaskFailed($this->taskId, $strict['message'] ?? '错题校验失败');
  126. return;
  127. }
  128. $questionIds = $strict['question_ids'];
  129. $bankQuestions = $questionBankService->getQuestionsByIds($questionIds)['data'] ?? [];
  130. if (empty($bankQuestions)) {
  131. $taskManager->markTaskFailed($this->taskId, '错题对应题库题目不可用');
  132. return;
  133. }
  134. $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes'] ?? []);
  135. $questions = app(QuestionDifficultyResolver::class)->applyCalibratedDifficulty($questions);
  136. $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
  137. $paperName = $data['paper_name'] ?? ('错题再练_'.$data['student_id'].'_'.now()->format('Ymd_His'));
  138. }
  139. } elseif (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
  140. // assemble_type=5 时 mistake_ids / mistake_question_ids 须严格归属该学生;其它类型走宽松解析。
  141. if ($requestedAssembleType === 5) {
  142. $strict = $this->resolveMistakeQuestionIdsStrictForStudent(
  143. (string) $data['student_id'],
  144. $mistakeIds,
  145. $mistakeQuestionIds
  146. );
  147. if (! ($strict['ok'] ?? false)) {
  148. $taskManager->markTaskFailed($this->taskId, $strict['message'] ?? '错题校验失败');
  149. return;
  150. }
  151. $questionIds = $strict['question_ids'];
  152. } else {
  153. $questionIds = $this->resolveMistakeQuestionIds((string) $data['student_id'], $mistakeIds, $mistakeQuestionIds);
  154. }
  155. if (empty($questionIds)) {
  156. $taskManager->markTaskFailed($this->taskId, '未找到可用的错题题目');
  157. return;
  158. }
  159. $bankQuestions = $questionBankService->getQuestionsByIds($questionIds)['data'] ?? [];
  160. if (empty($bankQuestions)) {
  161. $taskManager->markTaskFailed($this->taskId, '错题对应题库题目不可用');
  162. return;
  163. }
  164. $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes'] ?? []);
  165. $questions = app(QuestionDifficultyResolver::class)->applyCalibratedDifficulty($questions);
  166. $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
  167. $paperName = $data['paper_name'] ?? ('错题复习_'.$data['student_id'].'_'.now()->format('Ymd_His'));
  168. } else {
  169. $params = [
  170. 'student_id' => $data['student_id'],
  171. 'grade' => $data['grade'] ?? null,
  172. 'total_questions' => $data['total_questions'],
  173. 'kp_codes' => $strategyAssembleType === 3 ? null : ($data['kp_codes'] ?? null),
  174. 'skills' => $data['skills'] ?? [],
  175. 'question_type_ratio' => $questionTypeRatio,
  176. 'difficulty_category' => $difficultyCategory,
  177. 'assemble_type' => $strategyAssembleType,
  178. 'exam_type' => $data['exam_type'] ?? 'general',
  179. 'paper_ids' => $paperIds,
  180. 'textbook_id' => $data['textbook_id'] ?? null,
  181. 'end_catalog_id' => $data['end_catalog_id'] ?? null,
  182. 'chapter_id_list' => $data['chapter_id_list'] ?? null,
  183. 'kp_code_list' => $strategyAssembleType === 3 ? null : ($data['kp_code_list'] ?? $data['kp_codes'] ?? []),
  184. 'practice_options' => $data['practice_options'] ?? null,
  185. 'mistake_options' => $data['mistake_options'] ?? null,
  186. ];
  187. $result = $learningAnalyticsService->generateIntelligentExam($params);
  188. if (empty($result['success'])) {
  189. $taskManager->markTaskFailed($this->taskId, $result['message'] ?? '智能出卷失败');
  190. return;
  191. }
  192. if (isset($result['stats']['difficulty_category'])) {
  193. $difficultyCategory = $result['stats']['difficulty_category'];
  194. }
  195. $diagnosticChapterId = $result['diagnostic_chapter_id'] ?? null;
  196. $explanationKpCodes = $result['explanation_kp_codes'] ?? null;
  197. $questions = $this->hydrateQuestions($result['questions'] ?? [], $data['kp_codes'] ?? []);
  198. }
  199. Log::info('assemble.job.timing', [
  200. 'task_id' => $this->taskId,
  201. 'phase' => 'select_and_prepare_questions',
  202. 'elapsed_ms' => (int) round((microtime(true) - $phaseStartedAt) * 1000),
  203. 'assemble_type' => $requestedAssembleType,
  204. 'strategy_assemble_type' => $strategyAssembleType,
  205. 'question_count' => count($questions),
  206. ]);
  207. if (empty($questions)) {
  208. $taskManager->markTaskFailed($this->taskId, '未能生成有效题目');
  209. return;
  210. }
  211. $totalQuestions = min((int) ($data['total_questions'] ?? 10), count($questions));
  212. $questions = array_slice($questions, 0, $totalQuestions);
  213. $questions = $this->sortQuestionsWithinTypeByDifficulty($questions);
  214. $targetTotalScore = (float) ($data['total_score'] ?? 100.0);
  215. $questions = $this->adjustQuestionScores($questions, $targetTotalScore);
  216. $totalScore = array_sum(array_column($questions, 'score'));
  217. $finalAssembleType = ($result !== null && isset($result['assemble_type']))
  218. ? (int) $result['assemble_type']
  219. : $requestedAssembleType;
  220. if (in_array($requestedAssembleType, [11, 12, 13], true)) {
  221. $finalAssembleType = $requestedAssembleType;
  222. }
  223. if ($finalAssembleType === 16) {
  224. $difficultyCategory = $this->deriveDifficultyCategoryFromSelectedDistribution($questions);
  225. }
  226. $requestPayloadParams = $data['request_payload_snapshot_raw'] ?? null;
  227. $phaseStartedAt = microtime(true);
  228. $paperId = $questionBankService->saveExamToDatabase([
  229. 'paper_id' => $data['paper_id'] ?? null,
  230. 'paper_name' => $paperName,
  231. 'student_id' => $data['student_id'],
  232. 'teacher_id' => $data['teacher_id'] ?? null,
  233. 'params' => $requestPayloadParams,
  234. 'assembleType' => $finalAssembleType,
  235. 'difficulty_category' => $difficultyCategory,
  236. 'total_score' => $totalScore,
  237. 'questions' => $questions,
  238. 'diagnostic_chapter_id' => $diagnosticChapterId,
  239. 'explanation_kp_codes' => $explanationKpCodes,
  240. ]);
  241. if (! $paperId) {
  242. $taskManager->markTaskFailed($this->taskId, '试卷保存失败');
  243. return;
  244. }
  245. Log::info('assemble.job.timing', [
  246. 'task_id' => $this->taskId,
  247. 'phase' => 'save_exam_to_database',
  248. 'elapsed_ms' => (int) round((microtime(true) - $phaseStartedAt) * 1000),
  249. 'paper_id' => $paperId,
  250. ]);
  251. $finalStats = $result['stats'] ?? [
  252. 'total_selected' => count($questions),
  253. 'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds) || in_array($requestedAssembleType, [15, 16], true),
  254. ];
  255. if ($wrongQuestionPracticePlan !== null) {
  256. $finalStats['wrong_question_practice_plan'] = $wrongQuestionPracticePlan;
  257. }
  258. if (! isset($finalStats['difficulty_category'])) {
  259. $finalStats['difficulty_category'] = $difficultyCategory;
  260. }
  261. if ($finalAssembleType === 16) {
  262. $finalStats['difficulty_category'] = $difficultyCategory;
  263. $finalStats['final_avg_difficulty'] = $this->averageQuestionDifficulty($questions);
  264. }
  265. $taskManager->updateTaskStatus($this->taskId, [
  266. 'paper_id' => $paperId,
  267. 'stats' => $finalStats,
  268. 'assemble_elapsed_ms' => (int) round((microtime(true) - $assembleStartedAt) * 1000),
  269. ]);
  270. $taskManager->updateTaskProgress($this->taskId, 40, '组卷完成,开始生成PDF...');
  271. $phaseStartedAt = microtime(true);
  272. dispatch(new GenerateExamPdfJob($this->taskId, $paperId));
  273. Log::info('assemble.job.timing', [
  274. 'task_id' => $this->taskId,
  275. 'phase' => 'dispatch_pdf_job',
  276. 'elapsed_ms' => (int) round((microtime(true) - $phaseStartedAt) * 1000),
  277. 'paper_id' => $paperId,
  278. ]);
  279. Log::info('assemble.success', [
  280. 'task_id' => $this->taskId,
  281. 'paper_id' => $paperId,
  282. 'assemble_type' => $finalAssembleType,
  283. 'question_count' => count($questions),
  284. 'total_score' => $totalScore,
  285. 'elapsed_ms' => (int) round((microtime(true) - $assembleStartedAt) * 1000),
  286. ]);
  287. } catch (\Exception $e) {
  288. Log::error('AssembleExamTaskJob: 异常', [
  289. 'task_id' => $this->taskId,
  290. 'error' => $e->getMessage(),
  291. ]);
  292. $taskManager->markTaskFailed($this->taskId, $e->getMessage());
  293. }
  294. }
  295. public function failed(Throwable $exception): void
  296. {
  297. app(TaskManager::class)->markTaskFailed($this->taskId, $exception->getMessage());
  298. }
  299. private function normalizeQuestionTypeRatio(array $input): array
  300. {
  301. $defaults = ['选择题' => 40, '填空题' => 20, '解答题' => 40];
  302. $normalized = [];
  303. foreach ($input as $key => $value) {
  304. if (! is_numeric($value)) {
  305. continue;
  306. }
  307. $type = $this->normalizeQuestionTypeKey((string) $key);
  308. if ($type) {
  309. $normalized[$type] = (float) $value;
  310. }
  311. }
  312. $merged = array_merge($defaults, $normalized);
  313. $sum = array_sum($merged);
  314. if ($sum > 0) {
  315. foreach ($merged as $k => $v) {
  316. $merged[$k] = round(($v / $sum) * 100, 2);
  317. }
  318. }
  319. return $merged;
  320. }
  321. private function normalizeQuestionTypeKey(string $key): ?string
  322. {
  323. $key = trim($key);
  324. if (in_array($key, ['choice', '选择题', 'single_choice', 'multiple_choice', 'CHOICE', 'SINGLE_CHOICE', 'MULTIPLE_CHOICE'], true)) {
  325. return '选择题';
  326. }
  327. if (in_array($key, ['fill', '填空题', 'blank', 'FILL_IN_THE_BLANK', 'FILL'], true)) {
  328. return '填空题';
  329. }
  330. if (in_array($key, ['answer', '解答题', '计算题', 'CALCULATION', 'WORD_PROBLEM', 'PROOF'], true)) {
  331. return '解答题';
  332. }
  333. return null;
  334. }
  335. private function resolveMistakeQuestionIds(string $studentId, array $mistakeIds, array $mistakeQuestionIds): array
  336. {
  337. $questionIds = [];
  338. if (! empty($mistakeQuestionIds)) {
  339. $questionIds = array_merge($questionIds, $mistakeQuestionIds);
  340. }
  341. if (! empty($mistakeIds)) {
  342. $fromDb = MistakeRecord::query()->where('student_id', $studentId)->whereIn('id', $mistakeIds)->pluck('question_id')->filter()->values()->all();
  343. $questionIds = array_merge($questionIds, $fromDb);
  344. }
  345. return array_values(array_unique(array_filter($questionIds)));
  346. }
  347. /**
  348. * 追练(assemble_type=5)+ 指定错题:mistake_ids 须逐条命中该学生的 mistake_records;
  349. * mistake_question_ids 须在该学生错题本中至少有一条记录。顺序:先按 mistake_ids 请求顺序,再追加题号列表(去重)。
  350. * assemble_type=15(错题再练)将 paper_ids 解析为题库题目 id 后,仅使用本方法的 mistake_question_ids 分支做校验。
  351. *
  352. * @return array{ok: bool, message?: string, question_ids?: array<int, string>}
  353. */
  354. private function resolveMistakeQuestionIdsStrictForStudent(string $studentId, array $mistakeIds, array $mistakeQuestionIds): array
  355. {
  356. $mistakeIds = array_values(array_filter(array_map('strval', $mistakeIds), fn ($v) => $v !== ''));
  357. $mistakeQuestionIds = array_values(array_filter(array_map('strval', $mistakeQuestionIds), fn ($v) => $v !== ''));
  358. $orderedQuestionIds = [];
  359. $seen = [];
  360. if ($mistakeIds !== []) {
  361. $rowIdSet = array_values(array_unique($mistakeIds));
  362. $records = MistakeRecord::query()
  363. ->where('student_id', $studentId)
  364. ->whereIn('id', $rowIdSet)
  365. ->get()
  366. ->keyBy(fn ($r) => (string) $r->id);
  367. foreach ($mistakeIds as $mid) {
  368. $rec = $records[$mid] ?? null;
  369. $qid = $rec && $rec->question_id !== null && $rec->question_id !== ''
  370. ? (string) $rec->question_id
  371. : '';
  372. if ($qid === '') {
  373. return [
  374. 'ok' => false,
  375. 'message' => '部分错题记录不存在或不属于该学生: '.$mid,
  376. ];
  377. }
  378. if (! isset($seen[$qid])) {
  379. $seen[$qid] = true;
  380. $orderedQuestionIds[] = $qid;
  381. }
  382. }
  383. }
  384. foreach ($mistakeQuestionIds as $qid) {
  385. $exists = MistakeRecord::query()
  386. ->where('student_id', $studentId)
  387. ->where('question_id', $qid)
  388. ->exists();
  389. if (! $exists) {
  390. return [
  391. 'ok' => false,
  392. 'message' => '学生错题本中不存在题目: '.$qid,
  393. ];
  394. }
  395. if (! isset($seen[$qid])) {
  396. $seen[$qid] = true;
  397. $orderedQuestionIds[] = $qid;
  398. }
  399. }
  400. return ['ok' => true, 'question_ids' => $orderedQuestionIds];
  401. }
  402. /**
  403. * assemble_type=15/16 时 paper_ids 承载题库题目 id:纯数字字符串转为 int,去重并保持首次出现顺序。
  404. *
  405. * @return array<int, int|string>
  406. */
  407. private function normalizeBankQuestionIdsList(array $raw): array
  408. {
  409. $out = [];
  410. $seen = [];
  411. foreach ($raw as $v) {
  412. if ($v === null) {
  413. continue;
  414. }
  415. if (is_string($v)) {
  416. $v = trim($v);
  417. if ($v === '') {
  418. continue;
  419. }
  420. }
  421. if (is_int($v)) {
  422. $normalized = $v;
  423. } elseif (is_float($v) && floor($v) == $v) {
  424. $normalized = (int) $v;
  425. } else {
  426. $s = trim((string) $v);
  427. if ($s === '') {
  428. continue;
  429. }
  430. $normalized = preg_match('/^-?\d+$/', $s) ? (int) $s : $s;
  431. }
  432. $dedupeKey = is_int($normalized) ? 'i:'.$normalized : 's:'.(string) $normalized;
  433. if (isset($seen[$dedupeKey])) {
  434. continue;
  435. }
  436. $seen[$dedupeKey] = true;
  437. $out[] = $normalized;
  438. }
  439. return $out;
  440. }
  441. private function hydrateQuestions(array $questions, array $kpCodes): array
  442. {
  443. $mapper = app(QuestionPayloadMapper::class);
  444. $normalized = [];
  445. foreach ($questions as $question) {
  446. $normalized[] = $mapper->fromArray($question, $kpCodes);
  447. }
  448. return array_values(array_filter($normalized, fn ($q) => ! empty($q['id'])));
  449. }
  450. private function sortQuestionsByRequestedIds(array $questions, array $requestedIds): array
  451. {
  452. if (empty($requestedIds)) {
  453. return $questions;
  454. }
  455. $order = array_flip($requestedIds);
  456. usort($questions, function ($a, $b) use ($order) {
  457. $aPos = $order[(string) ($a['id'] ?? '')] ?? PHP_INT_MAX;
  458. $bPos = $order[(string) ($b['id'] ?? '')] ?? PHP_INT_MAX;
  459. return $aPos <=> $bPos;
  460. });
  461. return $questions;
  462. }
  463. private function sortQuestionsWithinTypeByDifficulty(array $questions): array
  464. {
  465. $grouped = ['choice' => [], 'fill' => [], 'answer' => []];
  466. foreach ($questions as $question) {
  467. $type = $this->normalizeQuestionType((string) ($question['question_type'] ?? 'answer'));
  468. $grouped[$type][] = $question;
  469. }
  470. $sortFn = function (array $a, array $b): int {
  471. $ad = (float) ($a['difficulty'] ?? 0.5);
  472. $bd = (float) ($b['difficulty'] ?? 0.5);
  473. if ($ad !== $bd) {
  474. return $ad <=> $bd;
  475. }
  476. return ((int) ($a['id'] ?? $a['question_id'] ?? 0)) <=> ((int) ($b['id'] ?? $b['question_id'] ?? 0));
  477. };
  478. usort($grouped['choice'], $sortFn);
  479. usort($grouped['fill'], $sortFn);
  480. usort($grouped['answer'], $sortFn);
  481. $sorted = array_merge($grouped['choice'], $grouped['fill'], $grouped['answer']);
  482. foreach ($sorted as $idx => &$question) {
  483. $question['question_number'] = $idx + 1;
  484. }
  485. unset($question);
  486. return $sorted;
  487. }
  488. private function deriveDifficultyCategoryFromSelectedDistribution(array $questions): int
  489. {
  490. if ($questions === []) {
  491. return 1;
  492. }
  493. $service = app(DifficultyDistributionService::class);
  494. $total = count($questions);
  495. $bestCategory = 1;
  496. $bestScore = null;
  497. foreach ([0, 1, 2, 3, 4] as $category) {
  498. $actualBuckets = $service->groupQuestionsByDifficultyRange($questions, $category);
  499. $expectedBuckets = $this->expectedDifficultyBucketCounts($service, $category, $total);
  500. $score = 0;
  501. foreach (['primary_low', 'primary_medium', 'primary_high', 'secondary', 'other'] as $bucketKey) {
  502. $score += abs(count($actualBuckets[$bucketKey] ?? []) - ($expectedBuckets[$bucketKey] ?? 0));
  503. }
  504. if ($bestScore === null || $score < $bestScore) {
  505. $bestScore = $score;
  506. $bestCategory = $category;
  507. }
  508. }
  509. return $bestCategory;
  510. }
  511. /**
  512. * @return array{primary_low: int, primary_medium: int, primary_high: int, secondary: int, other: int}
  513. */
  514. private function expectedDifficultyBucketCounts(DifficultyDistributionService $service, int $category, int $totalQuestions): array
  515. {
  516. $expected = [
  517. 'primary_low' => 0,
  518. 'primary_medium' => 0,
  519. 'primary_high' => 0,
  520. 'secondary' => 0,
  521. 'other' => 0,
  522. ];
  523. foreach ($service->calculateDistribution($category, $totalQuestions) as $level => $config) {
  524. $bucketKey = $service->mapDifficultyLevelToRangeKey((string) $level, $category);
  525. $expected[$bucketKey] = ($expected[$bucketKey] ?? 0) + (int) ($config['count'] ?? 0);
  526. }
  527. return $expected;
  528. }
  529. private function averageQuestionDifficulty(array $questions): float
  530. {
  531. if ($questions === []) {
  532. return 0.0;
  533. }
  534. $sum = 0.0;
  535. foreach ($questions as $question) {
  536. $difficulty = $question['difficulty'] ?? 0.0;
  537. $value = is_numeric($difficulty) ? (float) $difficulty : 0.0;
  538. if ($value > 1) {
  539. $value = $value / 5;
  540. }
  541. $sum += max(0.0, min(1.0, $value));
  542. }
  543. return round($sum / count($questions), 4);
  544. }
  545. private function normalizeQuestionType(string $type): string
  546. {
  547. $type = strtolower(trim($type));
  548. if (in_array($type, ['choice', 'single_choice', 'multiple_choice', '选择题', '单选', '多选'], true)) {
  549. return 'choice';
  550. }
  551. if (in_array($type, ['fill', 'fill_in_the_blank', 'blank', '填空题', '填空'], true)) {
  552. return 'fill';
  553. }
  554. return 'answer';
  555. }
  556. private function adjustQuestionScores(array $questions, float $targetTotalScore = 100.0): array
  557. {
  558. if (empty($questions)) {
  559. return $questions;
  560. }
  561. // 第一步:按题型排序
  562. $sortedQuestions = [];
  563. $choiceQuestions = [];
  564. $fillQuestions = [];
  565. $answerQuestions = [];
  566. foreach ($questions as $question) {
  567. $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer');
  568. if ($type === 'choice') {
  569. $choiceQuestions[] = $question;
  570. } elseif ($type === 'fill') {
  571. $fillQuestions[] = $question;
  572. } else {
  573. $answerQuestions[] = $question;
  574. }
  575. }
  576. $sortedQuestions = array_merge($choiceQuestions, $fillQuestions, $answerQuestions);
  577. Log::debug('adjustQuestionScores 开始', [
  578. 'choice_count' => count($choiceQuestions),
  579. 'fill_count' => count($fillQuestions),
  580. 'answer_count' => count($answerQuestions),
  581. ]);
  582. foreach ($sortedQuestions as $idx => &$question) {
  583. $question['question_number'] = $idx + 1;
  584. }
  585. unset($question);
  586. $typeCounts = [
  587. 'choice' => count($choiceQuestions),
  588. 'fill' => count($fillQuestions),
  589. 'answer' => count($answerQuestions),
  590. ];
  591. $typeIndexes = ['choice' => [], 'fill' => [], 'answer' => []];
  592. foreach ($sortedQuestions as $index => $question) {
  593. $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer');
  594. $typeIndexes[$type][] = $index;
  595. }
  596. $questionScores = [];
  597. $totalQuestions = $typeCounts['choice'] + $typeCounts['fill'] + $typeCounts['answer'];
  598. $globalBaseScore = floor($targetTotalScore / $totalQuestions);
  599. $globalBaseScore = max(1, $globalBaseScore);
  600. $typeOrder = [];
  601. foreach ($sortedQuestions as $question) {
  602. $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer');
  603. if (! in_array($type, $typeOrder)) {
  604. $typeOrder[] = $type;
  605. }
  606. }
  607. $remainingBudget = $targetTotalScore;
  608. foreach ($typeOrder as $typeIndex => $type) {
  609. $count = $typeCounts[$type];
  610. if ($count === 0) {
  611. continue;
  612. }
  613. if ($typeIndex === 0) {
  614. $thisBase = $globalBaseScore;
  615. foreach ($typeIndexes[$type] as $idx) {
  616. $questionScores[$idx] = $thisBase;
  617. }
  618. foreach ($typeIndexes[$type] as $idx) {
  619. $questionScores[$idx] = max(1, $questionScores[$idx] - 1);
  620. }
  621. $allocated = 0;
  622. foreach ($typeIndexes[$type] as $idx) {
  623. $allocated += $questionScores[$idx];
  624. }
  625. $remainingBudget -= $allocated;
  626. } elseif ($typeIndex === count($typeOrder) - 1) {
  627. $thisBase = floor($remainingBudget / $count);
  628. $thisBase = max(1, $thisBase);
  629. foreach ($typeIndexes[$type] as $idx) {
  630. $questionScores[$idx] = $thisBase;
  631. }
  632. $total = $thisBase * $count;
  633. $remainder = $remainingBudget - $total;
  634. if ($remainder > 0) {
  635. $answerIndexes = array_values($typeIndexes[$type]);
  636. $startIdx = max(0, count($answerIndexes) - $remainder);
  637. for ($i = $startIdx; $i < count($answerIndexes); $i++) {
  638. $questionScores[$answerIndexes[$i]] += 1;
  639. }
  640. }
  641. } else {
  642. $thisBase = $globalBaseScore;
  643. foreach ($typeIndexes[$type] as $idx) {
  644. $questionScores[$idx] = $thisBase;
  645. }
  646. $allocated = 0;
  647. foreach ($typeIndexes[$type] as $idx) {
  648. $allocated += $questionScores[$idx];
  649. }
  650. $remainingBudget -= $allocated;
  651. }
  652. }
  653. if (count($typeOrder) > 1) {
  654. $lastType = end($typeOrder);
  655. $otherTypes = array_slice($typeOrder, 0, -1);
  656. $maxOtherScore = 0;
  657. foreach ($otherTypes as $type) {
  658. foreach ($typeIndexes[$type] as $idx) {
  659. $maxOtherScore = max($maxOtherScore, $questionScores[$idx]);
  660. }
  661. }
  662. $minLastScore = PHP_INT_MAX;
  663. foreach ($typeIndexes[$lastType] as $idx) {
  664. $minLastScore = min($minLastScore, $questionScores[$idx]);
  665. }
  666. if ($minLastScore <= $maxOtherScore) {
  667. $diff = $maxOtherScore - $minLastScore + 1;
  668. $reductionPerQuestion = min($diff, 2);
  669. foreach ($otherTypes as $type) {
  670. foreach ($typeIndexes[$type] as $idx) {
  671. $questionScores[$idx] = max(1, $questionScores[$idx] - $reductionPerQuestion);
  672. }
  673. }
  674. $reallocated = $targetTotalScore;
  675. foreach ($typeIndexes[$lastType] as $idx) {
  676. $reallocated -= $questionScores[$idx];
  677. }
  678. foreach ($otherTypes as $type) {
  679. foreach ($typeIndexes[$type] as $idx) {
  680. $reallocated -= $questionScores[$idx];
  681. }
  682. }
  683. if ($reallocated > 0) {
  684. $newBase = floor($reallocated / $typeCounts[$lastType]);
  685. foreach ($typeIndexes[$lastType] as $idx) {
  686. $questionScores[$idx] = $newBase;
  687. }
  688. $total = $newBase * $typeCounts[$lastType];
  689. $remainder = $reallocated - $total;
  690. if ($remainder > 0) {
  691. $lastIndexes = array_values($typeIndexes[$lastType]);
  692. $startIdx = max(0, count($lastIndexes) - $remainder);
  693. for ($i = $startIdx; $i < count($lastIndexes); $i++) {
  694. $questionScores[$lastIndexes[$i]] += 1;
  695. }
  696. }
  697. }
  698. }
  699. }
  700. $adjustedQuestions = [];
  701. foreach ($sortedQuestions as $index => $question) {
  702. $adjustedQuestions[$index] = $question;
  703. $adjustedQuestions[$index]['score'] = $questionScores[$index] ?? 5;
  704. }
  705. $total = array_sum(array_column($adjustedQuestions, 'score'));
  706. $diff = (int) $targetTotalScore - (int) $total;
  707. if ($diff !== 0 && ! empty($adjustedQuestions)) {
  708. $count = count($adjustedQuestions);
  709. $i = $count - 1;
  710. while ($diff !== 0) {
  711. $score = $adjustedQuestions[$i]['score'];
  712. if ($diff > 0) {
  713. $adjustedQuestions[$i]['score'] = $score + 1;
  714. $diff--;
  715. } else {
  716. if ($score > 1) {
  717. $adjustedQuestions[$i]['score'] = $score - 1;
  718. $diff++;
  719. }
  720. }
  721. $i--;
  722. if ($i < 0) {
  723. $i = $count - 1;
  724. if ($diff < 0) {
  725. $minScore = min(array_column($adjustedQuestions, 'score'));
  726. if ($minScore <= 1) {
  727. break;
  728. }
  729. }
  730. }
  731. }
  732. }
  733. return $adjustedQuestions;
  734. }
  735. }