AssembleExamTaskJob.php 34 KB

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