questionDifficultyResolver ??= app(QuestionDifficultyResolver::class); $this->questionPayloadMapper ??= app(QuestionPayloadMapper::class); } /** * 错题追练只负责把错题列表归纳成知识点组卷计划,找题仍交给 assemble_type=2。 * * @param array $sourceQuestionIds * @return array */ public function build(string $studentId, array $sourceQuestionIds, int $totalQuestions): array { $startedAt = microtime(true); $sourceQuestionIds = $this->normalizeIds($sourceQuestionIds); $totalQuestions = max(0, $totalQuestions); if ($sourceQuestionIds === [] || $totalQuestions <= 0) { return [ 'usable' => false, 'message' => '错题追练没有可用的题目输入', 'source_question_ids' => [], 'ignored_question_ids' => $sourceQuestionIds, ]; } $loadStartedAt = microtime(true); $sourceQuestions = $this->loadSourceQuestions($sourceQuestionIds); $this->logStageTiming('load_source_questions', $loadStartedAt, [ 'student_id' => $studentId, 'requested_source_count' => count($sourceQuestionIds), 'loaded_source_count' => count($sourceQuestions), ]); $loadedIds = array_fill_keys(array_map(static fn (array $q) => (string) ($q['id'] ?? ''), $sourceQuestions), true); $missingIds = array_values(array_filter($sourceQuestionIds, static fn ($id) => ! isset($loadedIds[(string) $id]))); $usableQuestions = array_values(array_filter($sourceQuestions, static function (array $question) { return (string) ($question['kp_code'] ?? '') !== ''; })); $noKpIds = array_values(array_map( static fn (array $q) => $q['id'] ?? $q['question_id'] ?? null, array_filter($sourceQuestions, static fn (array $q) => (string) ($q['kp_code'] ?? '') === '') )); $ignoredIds = array_values(array_filter(array_merge($missingIds, $noKpIds), static fn ($id) => $id !== null && $id !== '')); if ($usableQuestions === []) { Log::warning('WrongQuestionPracticePlanService: no usable source questions', [ 'student_id' => $studentId, 'requested_source_count' => count($sourceQuestionIds), 'missing_count' => count($missingIds), 'no_kp_count' => count($noKpIds), ]); return [ 'usable' => false, 'message' => '错题追练没有可用的知识点题目', 'source_question_ids' => array_values(array_map(static fn (array $q) => $q['id'], $sourceQuestions)), 'ignored_question_ids' => $ignoredIds, ]; } $groups = $this->groupSourceQuestions($usableQuestions); $kpTargetCounts = $this->allocateKpTargets($groups, $totalQuestions); $kpTargetCounts = array_filter($kpTargetCounts, static fn (int $count) => $count > 0); $targetDifficultyByKp = []; $typeTargetsByKp = []; foreach ($kpTargetCounts as $kpCode => $count) { $items = $groups[$kpCode]['questions'] ?? []; $targetDifficultyByKp[$kpCode] = $this->averageDifficulty($items); $typeTargetsByKp[$kpCode] = $this->allocateTypeTargets($items, $count); } $questionTypeRatio = $this->buildGlobalTypeRatio($usableQuestions); $sourceIds = array_values(array_map(static fn (array $q) => $q['id'], $usableQuestions)); $plan = [ 'usable' => true, 'source_question_ids' => $sourceIds, 'ignored_question_ids' => $ignoredIds, 'exclude_question_ids' => $sourceIds, 'kp_code_list' => array_keys($kpTargetCounts), 'kp_target_counts' => $kpTargetCounts, 'target_difficulty_by_kp' => $targetDifficultyByKp, 'max_difficulty_by_kp' => $targetDifficultyByKp, 'type_targets_by_kp' => $typeTargetsByKp, 'question_type_ratio' => $questionTypeRatio, 'avg_difficulty' => $this->averageDifficulty($usableQuestions), 'target_questions' => array_sum($kpTargetCounts), 'source_count' => count($usableQuestions), 'elapsed_ms' => $this->elapsedMs($startedAt), ]; Log::info('WrongQuestionPracticePlanService: built wrong-question practice plan', [ 'student_id' => $studentId, 'requested_source_count' => count($sourceQuestionIds), 'usable_source_count' => count($usableQuestions), 'ignored_count' => count($ignoredIds), 'kp_target_counts' => $kpTargetCounts, 'target_difficulty_by_kp' => $targetDifficultyByKp, 'elapsed_ms' => $plan['elapsed_ms'], ]); return $plan; } /** * @param array $ids * @return array */ private function normalizeIds(array $ids): array { $out = []; $seen = []; foreach ($ids as $id) { if ($id === null || $id === '') { continue; } $normalized = is_numeric($id) && (string) (int) $id === (string) $id ? (int) $id : (string) $id; $key = is_int($normalized) ? 'i:'.$normalized : 's:'.$normalized; if (isset($seen[$key])) { continue; } $seen[$key] = true; $out[] = $normalized; } return $out; } /** * @param array $ids * @return array> */ private function loadSourceQuestions(array $ids): array { $order = array_flip(array_map('strval', $ids)); $questions = Question::query() ->whereIn('id', $ids) ->get() ->map(fn (Question $question) => $this->questionPayloadMapper->fromModel($question)) ->sortBy(fn (array $question) => $order[(string) ($question['id'] ?? '')] ?? PHP_INT_MAX) ->values() ->all(); return $this->questionDifficultyResolver->applyCalibratedDifficulty($questions); } /** * @param array> $questions * @return array>, count: int, avg_difficulty: float}> */ private function groupSourceQuestions(array $questions): array { $groups = []; foreach ($questions as $question) { $kpCode = (string) ($question['kp_code'] ?? ''); if ($kpCode === '') { continue; } $groups[$kpCode]['questions'][] = $question; } foreach ($groups as $kpCode => &$group) { $group['questions'] = $group['questions'] ?? []; $group['count'] = count($group['questions']); $group['avg_difficulty'] = $this->averageDifficulty($group['questions']); } unset($group); uasort($groups, static function (array $a, array $b): int { if (($a['count'] ?? 0) !== ($b['count'] ?? 0)) { return ($b['count'] ?? 0) <=> ($a['count'] ?? 0); } return ($b['avg_difficulty'] ?? 0.0) <=> ($a['avg_difficulty'] ?? 0.0); }); return $groups; } /** * @param array> $groups * @return array */ private function allocateKpTargets(array $groups, int $totalQuestions): array { if ($groups === [] || $totalQuestions <= 0) { return []; } if (count($groups) > $totalQuestions) { $groups = array_slice($groups, 0, $totalQuestions, true); } $totalSourceCount = array_sum(array_map(static fn (array $group) => (int) ($group['count'] ?? 0), $groups)); if ($totalSourceCount <= 0) { return []; } $targets = []; $fractions = []; $allocated = 0; foreach ($groups as $kpCode => $group) { $exact = $totalQuestions * ((int) ($group['count'] ?? 0)) / $totalSourceCount; $targets[$kpCode] = (int) floor($exact); $fractions[$kpCode] = $exact - floor($exact); $allocated += $targets[$kpCode]; } foreach ($targets as $kpCode => $count) { if ($allocated >= $totalQuestions) { break; } if ($count === 0) { $targets[$kpCode] = 1; $allocated++; } } arsort($fractions); foreach ($fractions as $kpCode => $fraction) { if ($allocated >= $totalQuestions) { break; } $targets[$kpCode]++; $allocated++; } if ($allocated > $totalQuestions) { $overflow = $allocated - $totalQuestions; uasort($targets, static fn (int $a, int $b) => $b <=> $a); foreach ($targets as $kpCode => $count) { if ($overflow <= 0) { break; } if ($count <= 1) { continue; } $targets[$kpCode]--; $overflow--; } $targets = array_filter($targets, static fn (int $count) => $count > 0); } return $targets; } /** * @param array> $questions * @return array{choice: int, fill: int, answer: int} */ private function allocateTypeTargets(array $questions, int $targetCount): array { $counts = ['choice' => 0, 'fill' => 0, 'answer' => 0]; foreach ($questions as $question) { $type = $this->questionPayloadMapper->normalizeQuestionType((string) ($question['question_type'] ?? 'answer')) ?? 'answer'; $counts[$type]++; } return $this->allocateByCounts($counts, $targetCount); } /** * @param array $sourceCounts * @return array */ private function allocateByCounts(array $sourceCounts, int $targetCount): array { $total = array_sum($sourceCounts); $targets = array_fill_keys(array_keys($sourceCounts), 0); if ($total <= 0 || $targetCount <= 0) { return $targets; } $allocated = 0; $fractions = []; foreach ($sourceCounts as $key => $count) { $exact = $targetCount * $count / $total; $targets[$key] = (int) floor($exact); $fractions[$key] = $exact - floor($exact); $allocated += $targets[$key]; } arsort($fractions); foreach ($fractions as $key => $fraction) { if ($allocated >= $targetCount) { break; } $targets[$key]++; $allocated++; } return $targets; } /** * @param array> $questions * @return array */ private function buildGlobalTypeRatio(array $questions): array { $counts = ['choice' => 0, 'fill' => 0, 'answer' => 0]; foreach ($questions as $question) { $type = $this->questionPayloadMapper->normalizeQuestionType((string) ($question['question_type'] ?? 'answer')) ?? 'answer'; $counts[$type]++; } $total = array_sum($counts); if ($total <= 0) { return ['选择题' => 40, '填空题' => 40, '解答题' => 20]; } return [ '选择题' => round($counts['choice'] / $total * 100, 2), '填空题' => round($counts['fill'] / $total * 100, 2), '解答题' => round($counts['answer'] / $total * 100, 2), ]; } /** * @param array> $questions */ private function averageDifficulty(array $questions): float { if ($questions === []) { return 0.5; } $sum = 0.0; foreach ($questions as $question) { $sum += $this->questionPayloadMapper->normalizeDifficulty($question['difficulty'] ?? null); } return round($sum / count($questions), 4); } /** * @param array $context */ private function logStageTiming(string $stage, float $startedAt, array $context = []): void { $elapsedMs = $this->elapsedMs($startedAt); $payload = array_merge($context, [ 'stage' => $stage, 'elapsed_ms' => $elapsedMs, ]); if ($elapsedMs >= self::SLOW_STAGE_MS) { Log::warning('WrongQuestionPracticePlanService: slow stage', $payload); return; } Log::debug('WrongQuestionPracticePlanService: stage timing', $payload); } private function elapsedMs(float $startedAt): int { return (int) round((microtime(true) - $startedAt) * 1000); } }