|
|
@@ -0,0 +1,378 @@
|
|
|
+<?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);
|
|
|
+ }
|
|
|
+}
|