AssembleExamTaskJob.php 35 KB

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