| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378 |
- <?php
- namespace App\Services;
- use App\Models\Question;
- use Illuminate\Support\Facades\Log;
- class WrongQuestionPracticePlanService
- {
- private const SLOW_STAGE_MS = 300;
- public function __construct(
- private ?QuestionDifficultyResolver $questionDifficultyResolver = null,
- private ?QuestionPayloadMapper $questionPayloadMapper = null
- ) {
- $this->questionDifficultyResolver ??= app(QuestionDifficultyResolver::class);
- $this->questionPayloadMapper ??= app(QuestionPayloadMapper::class);
- }
- /**
- * 错题追练只负责把错题列表归纳成知识点组卷计划,找题仍交给 assemble_type=2。
- *
- * @param array<int, int|string> $sourceQuestionIds
- * @return array<string, mixed>
- */
- 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<int, int|string> $ids
- * @return array<int, int|string>
- */
- 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<int, int|string> $ids
- * @return array<int, array<string, mixed>>
- */
- 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<int, array<string, mixed>> $questions
- * @return array<string, array{questions: array<int, array<string, mixed>>, 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<string, array<string, mixed>> $groups
- * @return array<string, int>
- */
- 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<int, array<string, mixed>> $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<string, int> $sourceCounts
- * @return array<string, int>
- */
- 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<int, array<string, mixed>> $questions
- * @return array<string, float>
- */
- 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<int, array<string, mixed>> $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<string, mixed> $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);
- }
- }
|