WrongQuestionPracticePlanService.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. <?php
  2. namespace App\Services;
  3. use App\Models\Question;
  4. use Illuminate\Support\Facades\Log;
  5. class WrongQuestionPracticePlanService
  6. {
  7. private const SLOW_STAGE_MS = 300;
  8. public function __construct(
  9. private ?QuestionDifficultyResolver $questionDifficultyResolver = null,
  10. private ?QuestionPayloadMapper $questionPayloadMapper = null
  11. ) {
  12. $this->questionDifficultyResolver ??= app(QuestionDifficultyResolver::class);
  13. $this->questionPayloadMapper ??= app(QuestionPayloadMapper::class);
  14. }
  15. /**
  16. * 错题追练只负责把错题列表归纳成知识点组卷计划,找题仍交给 assemble_type=2。
  17. *
  18. * @param array<int, int|string> $sourceQuestionIds
  19. * @return array<string, mixed>
  20. */
  21. public function build(string $studentId, array $sourceQuestionIds, int $totalQuestions): array
  22. {
  23. $startedAt = microtime(true);
  24. $sourceQuestionIds = $this->normalizeIds($sourceQuestionIds);
  25. $totalQuestions = max(0, $totalQuestions);
  26. if ($sourceQuestionIds === [] || $totalQuestions <= 0) {
  27. return [
  28. 'usable' => false,
  29. 'message' => '错题追练没有可用的题目输入',
  30. 'source_question_ids' => [],
  31. 'ignored_question_ids' => $sourceQuestionIds,
  32. ];
  33. }
  34. $loadStartedAt = microtime(true);
  35. $sourceQuestions = $this->loadSourceQuestions($sourceQuestionIds);
  36. $this->logStageTiming('load_source_questions', $loadStartedAt, [
  37. 'student_id' => $studentId,
  38. 'requested_source_count' => count($sourceQuestionIds),
  39. 'loaded_source_count' => count($sourceQuestions),
  40. ]);
  41. $loadedIds = array_fill_keys(array_map(static fn (array $q) => (string) ($q['id'] ?? ''), $sourceQuestions), true);
  42. $missingIds = array_values(array_filter($sourceQuestionIds, static fn ($id) => ! isset($loadedIds[(string) $id])));
  43. $usableQuestions = array_values(array_filter($sourceQuestions, static function (array $question) {
  44. return (string) ($question['kp_code'] ?? '') !== '';
  45. }));
  46. $noKpIds = array_values(array_map(
  47. static fn (array $q) => $q['id'] ?? $q['question_id'] ?? null,
  48. array_filter($sourceQuestions, static fn (array $q) => (string) ($q['kp_code'] ?? '') === '')
  49. ));
  50. $ignoredIds = array_values(array_filter(array_merge($missingIds, $noKpIds), static fn ($id) => $id !== null && $id !== ''));
  51. if ($usableQuestions === []) {
  52. Log::warning('WrongQuestionPracticePlanService: no usable source questions', [
  53. 'student_id' => $studentId,
  54. 'requested_source_count' => count($sourceQuestionIds),
  55. 'missing_count' => count($missingIds),
  56. 'no_kp_count' => count($noKpIds),
  57. ]);
  58. return [
  59. 'usable' => false,
  60. 'message' => '错题追练没有可用的知识点题目',
  61. 'source_question_ids' => array_values(array_map(static fn (array $q) => $q['id'], $sourceQuestions)),
  62. 'ignored_question_ids' => $ignoredIds,
  63. ];
  64. }
  65. $groups = $this->groupSourceQuestions($usableQuestions);
  66. $kpTargetCounts = $this->allocateKpTargets($groups, $totalQuestions);
  67. $kpTargetCounts = array_filter($kpTargetCounts, static fn (int $count) => $count > 0);
  68. $targetDifficultyByKp = [];
  69. $typeTargetsByKp = [];
  70. foreach ($kpTargetCounts as $kpCode => $count) {
  71. $items = $groups[$kpCode]['questions'] ?? [];
  72. $targetDifficultyByKp[$kpCode] = $this->averageDifficulty($items);
  73. $typeTargetsByKp[$kpCode] = $this->allocateTypeTargets($items, $count);
  74. }
  75. $questionTypeRatio = $this->buildGlobalTypeRatio($usableQuestions);
  76. $sourceIds = array_values(array_map(static fn (array $q) => $q['id'], $usableQuestions));
  77. $plan = [
  78. 'usable' => true,
  79. 'source_question_ids' => $sourceIds,
  80. 'ignored_question_ids' => $ignoredIds,
  81. 'exclude_question_ids' => $sourceIds,
  82. 'kp_code_list' => array_keys($kpTargetCounts),
  83. 'kp_target_counts' => $kpTargetCounts,
  84. 'target_difficulty_by_kp' => $targetDifficultyByKp,
  85. 'max_difficulty_by_kp' => $targetDifficultyByKp,
  86. 'type_targets_by_kp' => $typeTargetsByKp,
  87. 'question_type_ratio' => $questionTypeRatio,
  88. 'avg_difficulty' => $this->averageDifficulty($usableQuestions),
  89. 'target_questions' => array_sum($kpTargetCounts),
  90. 'source_count' => count($usableQuestions),
  91. 'elapsed_ms' => $this->elapsedMs($startedAt),
  92. ];
  93. Log::info('WrongQuestionPracticePlanService: built wrong-question practice plan', [
  94. 'student_id' => $studentId,
  95. 'requested_source_count' => count($sourceQuestionIds),
  96. 'usable_source_count' => count($usableQuestions),
  97. 'ignored_count' => count($ignoredIds),
  98. 'kp_target_counts' => $kpTargetCounts,
  99. 'target_difficulty_by_kp' => $targetDifficultyByKp,
  100. 'elapsed_ms' => $plan['elapsed_ms'],
  101. ]);
  102. return $plan;
  103. }
  104. /**
  105. * @param array<int, int|string> $ids
  106. * @return array<int, int|string>
  107. */
  108. private function normalizeIds(array $ids): array
  109. {
  110. $out = [];
  111. $seen = [];
  112. foreach ($ids as $id) {
  113. if ($id === null || $id === '') {
  114. continue;
  115. }
  116. $normalized = is_numeric($id) && (string) (int) $id === (string) $id
  117. ? (int) $id
  118. : (string) $id;
  119. $key = is_int($normalized) ? 'i:'.$normalized : 's:'.$normalized;
  120. if (isset($seen[$key])) {
  121. continue;
  122. }
  123. $seen[$key] = true;
  124. $out[] = $normalized;
  125. }
  126. return $out;
  127. }
  128. /**
  129. * @param array<int, int|string> $ids
  130. * @return array<int, array<string, mixed>>
  131. */
  132. private function loadSourceQuestions(array $ids): array
  133. {
  134. $order = array_flip(array_map('strval', $ids));
  135. $questions = Question::query()
  136. ->whereIn('id', $ids)
  137. ->get()
  138. ->map(fn (Question $question) => $this->questionPayloadMapper->fromModel($question))
  139. ->sortBy(fn (array $question) => $order[(string) ($question['id'] ?? '')] ?? PHP_INT_MAX)
  140. ->values()
  141. ->all();
  142. return $this->questionDifficultyResolver->applyCalibratedDifficulty($questions);
  143. }
  144. /**
  145. * @param array<int, array<string, mixed>> $questions
  146. * @return array<string, array{questions: array<int, array<string, mixed>>, count: int, avg_difficulty: float}>
  147. */
  148. private function groupSourceQuestions(array $questions): array
  149. {
  150. $groups = [];
  151. foreach ($questions as $question) {
  152. $kpCode = (string) ($question['kp_code'] ?? '');
  153. if ($kpCode === '') {
  154. continue;
  155. }
  156. $groups[$kpCode]['questions'][] = $question;
  157. }
  158. foreach ($groups as $kpCode => &$group) {
  159. $group['questions'] = $group['questions'] ?? [];
  160. $group['count'] = count($group['questions']);
  161. $group['avg_difficulty'] = $this->averageDifficulty($group['questions']);
  162. }
  163. unset($group);
  164. uasort($groups, static function (array $a, array $b): int {
  165. if (($a['count'] ?? 0) !== ($b['count'] ?? 0)) {
  166. return ($b['count'] ?? 0) <=> ($a['count'] ?? 0);
  167. }
  168. return ($b['avg_difficulty'] ?? 0.0) <=> ($a['avg_difficulty'] ?? 0.0);
  169. });
  170. return $groups;
  171. }
  172. /**
  173. * @param array<string, array<string, mixed>> $groups
  174. * @return array<string, int>
  175. */
  176. private function allocateKpTargets(array $groups, int $totalQuestions): array
  177. {
  178. if ($groups === [] || $totalQuestions <= 0) {
  179. return [];
  180. }
  181. if (count($groups) > $totalQuestions) {
  182. $groups = array_slice($groups, 0, $totalQuestions, true);
  183. }
  184. $totalSourceCount = array_sum(array_map(static fn (array $group) => (int) ($group['count'] ?? 0), $groups));
  185. if ($totalSourceCount <= 0) {
  186. return [];
  187. }
  188. $targets = [];
  189. $fractions = [];
  190. $allocated = 0;
  191. foreach ($groups as $kpCode => $group) {
  192. $exact = $totalQuestions * ((int) ($group['count'] ?? 0)) / $totalSourceCount;
  193. $targets[$kpCode] = (int) floor($exact);
  194. $fractions[$kpCode] = $exact - floor($exact);
  195. $allocated += $targets[$kpCode];
  196. }
  197. foreach ($targets as $kpCode => $count) {
  198. if ($allocated >= $totalQuestions) {
  199. break;
  200. }
  201. if ($count === 0) {
  202. $targets[$kpCode] = 1;
  203. $allocated++;
  204. }
  205. }
  206. arsort($fractions);
  207. foreach ($fractions as $kpCode => $fraction) {
  208. if ($allocated >= $totalQuestions) {
  209. break;
  210. }
  211. $targets[$kpCode]++;
  212. $allocated++;
  213. }
  214. if ($allocated > $totalQuestions) {
  215. $overflow = $allocated - $totalQuestions;
  216. uasort($targets, static fn (int $a, int $b) => $b <=> $a);
  217. foreach ($targets as $kpCode => $count) {
  218. if ($overflow <= 0) {
  219. break;
  220. }
  221. if ($count <= 1) {
  222. continue;
  223. }
  224. $targets[$kpCode]--;
  225. $overflow--;
  226. }
  227. $targets = array_filter($targets, static fn (int $count) => $count > 0);
  228. }
  229. return $targets;
  230. }
  231. /**
  232. * @param array<int, array<string, mixed>> $questions
  233. * @return array{choice: int, fill: int, answer: int}
  234. */
  235. private function allocateTypeTargets(array $questions, int $targetCount): array
  236. {
  237. $counts = ['choice' => 0, 'fill' => 0, 'answer' => 0];
  238. foreach ($questions as $question) {
  239. $type = $this->questionPayloadMapper->normalizeQuestionType((string) ($question['question_type'] ?? 'answer')) ?? 'answer';
  240. $counts[$type]++;
  241. }
  242. return $this->allocateByCounts($counts, $targetCount);
  243. }
  244. /**
  245. * @param array<string, int> $sourceCounts
  246. * @return array<string, int>
  247. */
  248. private function allocateByCounts(array $sourceCounts, int $targetCount): array
  249. {
  250. $total = array_sum($sourceCounts);
  251. $targets = array_fill_keys(array_keys($sourceCounts), 0);
  252. if ($total <= 0 || $targetCount <= 0) {
  253. return $targets;
  254. }
  255. $allocated = 0;
  256. $fractions = [];
  257. foreach ($sourceCounts as $key => $count) {
  258. $exact = $targetCount * $count / $total;
  259. $targets[$key] = (int) floor($exact);
  260. $fractions[$key] = $exact - floor($exact);
  261. $allocated += $targets[$key];
  262. }
  263. arsort($fractions);
  264. foreach ($fractions as $key => $fraction) {
  265. if ($allocated >= $targetCount) {
  266. break;
  267. }
  268. $targets[$key]++;
  269. $allocated++;
  270. }
  271. return $targets;
  272. }
  273. /**
  274. * @param array<int, array<string, mixed>> $questions
  275. * @return array<string, float>
  276. */
  277. private function buildGlobalTypeRatio(array $questions): array
  278. {
  279. $counts = ['choice' => 0, 'fill' => 0, 'answer' => 0];
  280. foreach ($questions as $question) {
  281. $type = $this->questionPayloadMapper->normalizeQuestionType((string) ($question['question_type'] ?? 'answer')) ?? 'answer';
  282. $counts[$type]++;
  283. }
  284. $total = array_sum($counts);
  285. if ($total <= 0) {
  286. return ['选择题' => 40, '填空题' => 40, '解答题' => 20];
  287. }
  288. return [
  289. '选择题' => round($counts['choice'] / $total * 100, 2),
  290. '填空题' => round($counts['fill'] / $total * 100, 2),
  291. '解答题' => round($counts['answer'] / $total * 100, 2),
  292. ];
  293. }
  294. /**
  295. * @param array<int, array<string, mixed>> $questions
  296. */
  297. private function averageDifficulty(array $questions): float
  298. {
  299. if ($questions === []) {
  300. return 0.5;
  301. }
  302. $sum = 0.0;
  303. foreach ($questions as $question) {
  304. $sum += $this->questionPayloadMapper->normalizeDifficulty($question['difficulty'] ?? null);
  305. }
  306. return round($sum / count($questions), 4);
  307. }
  308. /**
  309. * @param array<string, mixed> $context
  310. */
  311. private function logStageTiming(string $stage, float $startedAt, array $context = []): void
  312. {
  313. $elapsedMs = $this->elapsedMs($startedAt);
  314. $payload = array_merge($context, [
  315. 'stage' => $stage,
  316. 'elapsed_ms' => $elapsedMs,
  317. ]);
  318. if ($elapsedMs >= self::SLOW_STAGE_MS) {
  319. Log::warning('WrongQuestionPracticePlanService: slow stage', $payload);
  320. return;
  321. }
  322. Log::debug('WrongQuestionPracticePlanService: stage timing', $payload);
  323. }
  324. private function elapsedMs(float $startedAt): int
  325. {
  326. return (int) round((microtime(true) - $startedAt) * 1000);
  327. }
  328. }