Преглед изворни кода

feat: add wrong question practice assembly

yemeishu пре 1 недеља
родитељ
комит
06325c407c

+ 11 - 50
app/Http/Controllers/Api/IntelligentExamController.php

@@ -12,6 +12,7 @@ use App\Services\ExternalIdService;
 use App\Services\LearningAnalyticsService;
 use App\Services\PaperPayloadService;
 use App\Services\QuestionBankService;
+use App\Services\QuestionPayloadMapper;
 use App\Services\TaskManager;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
@@ -86,7 +87,7 @@ class IntelligentExamController extends Controller
             'mistake_question_ids.*' => 'string',
             'callback_url' => 'nullable|url',  // 异步完成后推送通知的URL
             // 新增:组卷类型
-            'assemble_type' => 'nullable|integer|in:0,1,2,3,4,5,8,9,15',
+            'assemble_type' => 'nullable|integer|in:0,1,2,3,4,5,8,9,15,16',
             'exam_type' => 'nullable|string|in:general,diagnostic,practice,mistake,textbook,knowledge,knowledge_points',
             // 错题本类型专用参数
             'paper_ids' => 'nullable|array',
@@ -137,15 +138,19 @@ class IntelligentExamController extends Controller
         $requestPayloadSnapshotRaw = $payload;
 
         $assembleType = (int) ($data['assemble_type'] ?? 4);
-        if ($assembleType === 15 && empty($data['paper_ids'] ?? [])) {
+        if (in_array($assembleType, [15, 16], true) && empty($data['paper_ids'] ?? [])) {
+            $typeLabel = $assembleType === 16 ? '错题追练' : '错题再练';
+            $paperIdsRule = $assembleType === 16
+                ? "assemble_type 为 {$assembleType}({$typeLabel})时,paper_ids 须为非空数组,元素为题库题目 question_id"
+                : "assemble_type 为 {$assembleType}({$typeLabel})时,paper_ids 须为非空数组,元素为题库题目 question_id,且该学生错题本中须存在对应错题记录";
             return response()->json([
                 'success' => false,
                 'message' => '参数错误',
-                'errors' => ['paper_ids' => ['assemble_type 为 15(错题再练)时,paper_ids 须为非空数组,元素为题库题目 question_id,且该学生错题本中须存在对应错题记录']], 
+                'errors' => ['paper_ids' => [$paperIdsRule]],
             ], 422);
         }
 
-        // API 固定题量:含按卷追练(5)、错题再练(15) 等,一律 default_total_questions,不使用请求题量参数
+        // API 固定题量:含按卷追练(5)、错题再练(15)、错题追练(16) 等,一律 default_total_questions,不使用请求题量参数
         $data['total_questions'] = (int) config('question_bank.default_total_questions');
         // 预分配 paper_id,保证接口语义稳定(后续异步化时也可继续同步返回)
         $reservedPaperId = $this->questionBankService->generatePaperId();
@@ -674,59 +679,15 @@ class IntelligentExamController extends Controller
 
     private function hydrateQuestions(array $questions, array $kpCodes): array
     {
+        $mapper = app(QuestionPayloadMapper::class);
         $normalized = [];
         foreach ($questions as $question) {
-            $type = $this->normalizeQuestionTypeKey($question['question_type'] ?? $question['type'] ?? '') ?? $this->guessType($question);
-            $score = $question['score'] ?? $this->defaultScore($type);
-
-            $normalized[] = [
-                'id' => $question['id'] ?? $question['question_id'] ?? null,
-                'question_id' => $question['question_id'] ?? null,
-                'question_type' => $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer'),
-                'stem' => $question['stem'] ?? $question['content'] ?? ($question['question_text'] ?? ''),
-                'content' => $question['content'] ?? $question['stem'] ?? '',
-                'options' => $question['options'] ?? ($question['choices'] ?? []),
-                'answer' => $question['answer'] ?? $question['correct_answer'] ?? '',
-                'solution' => $question['solution'] ?? '',
-                'difficulty' => isset($question['difficulty']) ? (float) $question['difficulty'] : 0.5,
-                'score' => $score,
-                'estimated_time' => $question['estimated_time'] ?? 300,
-                'kp' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
-                'kp_code' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
-            ];
+            $normalized[] = $mapper->fromArray($question, $kpCodes);
         }
 
         return array_values(array_filter($normalized, fn ($q) => ! empty($q['id'])));
     }
 
-    private function guessType(array $question): string
-    {
-        if (! empty($question['options']) && is_array($question['options'])) {
-            return '选择题';
-        }
-
-        $content = $question['stem'] ?? $question['content'] ?? '';
-        if (is_string($content) && (strpos($content, '____') !== false || strpos($content, '()') !== false)) {
-            return '填空题';
-        }
-
-        return '解答题';
-    }
-
-    /**
-     * 根据题目类型获取默认分值(中国中学卷子标准)
-     * 选择题:5分/题,填空题:5分/题,解答题:10分/题
-     */
-    private function defaultScore(string $type): int
-    {
-        return match ($type) {
-            '选择题' => 5,
-            '填空题' => 5,
-            '解答题' => 10,
-            default => 5,
-        };
-    }
-
     /**
      * 计算试卷总分并调整各题目分值,确保总分接近目标分数
      * 符合中国中学卷子标准:

+ 164 - 62
app/Jobs/AssembleExamTaskJob.php

@@ -3,9 +3,12 @@
 namespace App\Jobs;
 
 use App\Models\MistakeRecord;
+use App\Services\DifficultyDistributionService;
 use App\Services\LearningAnalyticsService;
 use App\Services\QuestionBankService;
+use App\Services\QuestionPayloadMapper;
 use App\Services\TaskManager;
+use App\Services\WrongQuestionPracticePlanService;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
@@ -35,6 +38,7 @@ class AssembleExamTaskJob implements ShouldQueue
     public function handle(
         LearningAnalyticsService $learningAnalyticsService,
         QuestionBankService $questionBankService,
+        WrongQuestionPracticePlanService $wrongQuestionPracticePlanService,
         TaskManager $taskManager
     ): void {
         $task = $taskManager->getTaskStatus($this->taskId);
@@ -62,35 +66,91 @@ class AssembleExamTaskJob implements ShouldQueue
             $result = null;
             $diagnosticChapterId = null;
             $explanationKpCodes = null;
+            $wrongQuestionPracticePlan = null;
 
-            if ($assembleType === 15) {
-                // assemble_type=15(展示类型「错题再练」):paper_ids 为题库 question_id,须在该学生 mistake_records 中存在;与 assemble_type=5(卷 id 追练)分离
+            if (in_array($assembleType, [15, 16], true)) {
+                // assemble_type=15(错题再练):paper_ids 为题库 question_id,直组原错题。
+                // assemble_type=16(错题追练):paper_ids 仍为题库 question_id,但只用来生成知识点组卷计划。
                 $questionIdList = $this->normalizeBankQuestionIdsList($paperIds);
                 if ($questionIdList === []) {
-                    $taskManager->markTaskFailed($this->taskId, '错题再练组卷需提供 paper_ids(题库题目 id)');
+                    $taskManager->markTaskFailed($this->taskId, ($assembleType === 16 ? '错题追练' : '错题再练').'组卷需提供 paper_ids(题库题目 id)');
                     return;
                 }
 
-                $strict = $this->resolveMistakeQuestionIdsStrictForStudent(
-                    (string) $data['student_id'],
-                    [],
-                    array_map(static fn ($id) => (string) $id, $questionIdList)
-                );
-                if (! ($strict['ok'] ?? false)) {
-                    $taskManager->markTaskFailed($this->taskId, $strict['message'] ?? '错题校验失败');
-                    return;
-                }
-                $questionIds = $strict['question_ids'];
+                if ($assembleType === 16) {
+                    $wrongQuestionPracticePlan = $wrongQuestionPracticePlanService->build(
+                        (string) $data['student_id'],
+                        $questionIdList,
+                        (int) ($data['total_questions'] ?? config('question_bank.default_total_questions'))
+                    );
 
-                $bankQuestions = $questionBankService->getQuestionsByIds($questionIds)['data'] ?? [];
-                if (empty($bankQuestions)) {
-                    $taskManager->markTaskFailed($this->taskId, '错题对应题库题目不可用');
-                    return;
-                }
+                    if (empty($wrongQuestionPracticePlan['usable'])) {
+                        $taskManager->markTaskFailed($this->taskId, $wrongQuestionPracticePlan['message'] ?? '错题追练没有可用的知识点题目');
+                        return;
+                    }
 
-                $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes'] ?? []);
-                $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
-                $paperName = $data['paper_name'] ?? ('错题再练_'.$data['student_id'].'_'.now()->format('Ymd_His'));
+                    $paperName = $data['paper_name'] ?? ('错题追练_'.$data['student_id'].'_'.now()->format('Ymd_His'));
+                    $params = [
+                        'student_id' => $data['student_id'],
+                        'grade' => $data['grade'] ?? null,
+                        'total_questions' => (int) ($wrongQuestionPracticePlan['target_questions'] ?? ($data['total_questions'] ?? config('question_bank.default_total_questions'))),
+                        'kp_codes' => $wrongQuestionPracticePlan['kp_code_list'] ?? [],
+                        'skills' => $data['skills'] ?? [],
+                        'question_type_ratio' => $wrongQuestionPracticePlan['question_type_ratio'] ?? $questionTypeRatio,
+                        'difficulty_category' => $difficultyCategory,
+                        'assemble_type' => 2,
+                        'exam_type' => 'knowledge',
+                        'paper_ids' => [],
+                        'textbook_id' => $data['textbook_id'] ?? null,
+                        'end_catalog_id' => $data['end_catalog_id'] ?? null,
+                        'chapter_id_list' => $data['chapter_id_list'] ?? null,
+                        'kp_code_list' => $wrongQuestionPracticePlan['kp_code_list'] ?? [],
+                        'kp_target_counts' => $wrongQuestionPracticePlan['kp_target_counts'] ?? [],
+                        'target_difficulty_by_kp' => $wrongQuestionPracticePlan['target_difficulty_by_kp'] ?? [],
+                        'max_difficulty_by_kp' => $wrongQuestionPracticePlan['max_difficulty_by_kp'] ?? [],
+                        'type_targets_by_kp' => $wrongQuestionPracticePlan['type_targets_by_kp'] ?? [],
+                        'exclude_question_ids' => $wrongQuestionPracticePlan['exclude_question_ids'] ?? [],
+                        'wrong_question_practice_plan' => $wrongQuestionPracticePlan,
+                    ];
+
+                    $result = $learningAnalyticsService->generateIntelligentExam($params);
+                    if (empty($result['success'])) {
+                        $taskManager->markTaskFailed($this->taskId, $result['message'] ?? '错题追练组卷未生成题目');
+                        return;
+                    }
+
+                    if (isset($result['stats']['difficulty_category'])) {
+                        $difficultyCategory = $result['stats']['difficulty_category'];
+                    }
+                    $diagnosticChapterId = $result['diagnostic_chapter_id'] ?? null;
+                    $explanationKpCodes = $result['explanation_kp_codes'] ?? null;
+                    $result['assemble_type'] = 16;
+                    $questions = $this->hydrateQuestions($result['questions'] ?? [], $wrongQuestionPracticePlan['kp_code_list'] ?? []);
+                    if (empty($questions)) {
+                        $taskManager->markTaskFailed($this->taskId, '错题追练组卷未生成有效题目');
+                        return;
+                    }
+                } else {
+                    $strict = $this->resolveMistakeQuestionIdsStrictForStudent(
+                        (string) $data['student_id'],
+                        [],
+                        array_map(static fn ($id) => (string) $id, $questionIdList)
+                    );
+                    if (! ($strict['ok'] ?? false)) {
+                        $taskManager->markTaskFailed($this->taskId, $strict['message'] ?? '错题校验失败');
+                        return;
+                    }
+                    $questionIds = $strict['question_ids'];
+                    $bankQuestions = $questionBankService->getQuestionsByIds($questionIds)['data'] ?? [];
+                    if (empty($bankQuestions)) {
+                        $taskManager->markTaskFailed($this->taskId, '错题对应题库题目不可用');
+                        return;
+                    }
+
+                    $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes'] ?? []);
+                    $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
+                    $paperName = $data['paper_name'] ?? ('错题再练_'.$data['student_id'].'_'.now()->format('Ymd_His'));
+                }
             } elseif (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
                 // assemble_type=5 时 mistake_ids / mistake_question_ids 须严格归属该学生;其它类型走宽松解析。
                 if ($assembleType === 5) {
@@ -176,6 +236,9 @@ class AssembleExamTaskJob implements ShouldQueue
             $totalScore = array_sum(array_column($questions, 'score'));
 
             $finalAssembleType = ($result !== null && isset($result['assemble_type'])) ? $result['assemble_type'] : $assembleType;
+            if ($finalAssembleType === 16) {
+                $difficultyCategory = $this->deriveDifficultyCategoryFromSelectedDistribution($questions);
+            }
             $requestPayloadParams = $data['request_payload_snapshot_raw'] ?? null;
             $phaseStartedAt = microtime(true);
             $paperId = $questionBankService->saveExamToDatabase([
@@ -205,11 +268,18 @@ class AssembleExamTaskJob implements ShouldQueue
 
             $finalStats = $result['stats'] ?? [
                 'total_selected' => count($questions),
-                'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds) || $assembleType === 15,
+                'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds) || in_array($assembleType, [15, 16], true),
             ];
+            if ($wrongQuestionPracticePlan !== null) {
+                $finalStats['wrong_question_practice_plan'] = $wrongQuestionPracticePlan;
+            }
             if (! isset($finalStats['difficulty_category'])) {
                 $finalStats['difficulty_category'] = $difficultyCategory;
             }
+            if ($finalAssembleType === 16) {
+                $finalStats['difficulty_category'] = $difficultyCategory;
+                $finalStats['final_avg_difficulty'] = $this->averageQuestionDifficulty($questions);
+            }
 
             $taskManager->updateTaskStatus($this->taskId, [
                 'paper_id' => $paperId,
@@ -361,7 +431,7 @@ class AssembleExamTaskJob implements ShouldQueue
     }
 
     /**
-     * assemble_type=15 时 paper_ids 承载题库题目 id:纯数字字符串转为 int,去重并保持首次出现顺序。
+     * assemble_type=15/16 时 paper_ids 承载题库题目 id:纯数字字符串转为 int,去重并保持首次出现顺序。
      *
      * @return array<int, int|string>
      */
@@ -403,25 +473,10 @@ class AssembleExamTaskJob implements ShouldQueue
 
     private function hydrateQuestions(array $questions, array $kpCodes): array
     {
+        $mapper = app(QuestionPayloadMapper::class);
         $normalized = [];
         foreach ($questions as $question) {
-            $type = $this->normalizeQuestionTypeKey($question['question_type'] ?? $question['type'] ?? '') ?? $this->guessType($question);
-            $score = $question['score'] ?? $this->defaultScore($type);
-            $normalized[] = [
-                'id' => $question['id'] ?? $question['question_id'] ?? null,
-                'question_id' => $question['question_id'] ?? null,
-                'question_type' => $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer'),
-                'stem' => $question['stem'] ?? $question['content'] ?? ($question['question_text'] ?? ''),
-                'content' => $question['content'] ?? $question['stem'] ?? '',
-                'options' => $question['options'] ?? ($question['choices'] ?? []),
-                'answer' => $question['answer'] ?? $question['correct_answer'] ?? '',
-                'solution' => $question['solution'] ?? '',
-                'difficulty' => isset($question['difficulty']) ? (float) $question['difficulty'] : 0.5,
-                'score' => $score,
-                'estimated_time' => $question['estimated_time'] ?? 300,
-                'kp' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
-                'kp_code' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
-            ];
+            $normalized[] = $mapper->fromArray($question, $kpCodes);
         }
         return array_values(array_filter($normalized, fn ($q) => ! empty($q['id'])));
     }
@@ -440,28 +495,6 @@ class AssembleExamTaskJob implements ShouldQueue
         return $questions;
     }
 
-    private function guessType(array $question): string
-    {
-        if (! empty($question['options']) && is_array($question['options'])) {
-            return '选择题';
-        }
-        $content = $question['stem'] ?? $question['content'] ?? '';
-        if (is_string($content) && (strpos($content, '____') !== false || strpos($content, '()') !== false)) {
-            return '填空题';
-        }
-        return '解答题';
-    }
-
-    private function defaultScore(string $type): int
-    {
-        return match ($type) {
-            '选择题' => 5,
-            '填空题' => 5,
-            '解答题' => 10,
-            default => 5,
-        };
-    }
-
     private function sortQuestionsWithinTypeByDifficulty(array $questions): array
     {
         $grouped = ['choice' => [], 'fill' => [], 'answer' => []];
@@ -488,6 +521,75 @@ class AssembleExamTaskJob implements ShouldQueue
         return $sorted;
     }
 
+    private function deriveDifficultyCategoryFromSelectedDistribution(array $questions): int
+    {
+        if ($questions === []) {
+            return 1;
+        }
+
+        $service = app(DifficultyDistributionService::class);
+        $total = count($questions);
+        $bestCategory = 1;
+        $bestScore = null;
+
+        foreach ([0, 1, 2, 3, 4] as $category) {
+            $actualBuckets = $service->groupQuestionsByDifficultyRange($questions, $category);
+            $expectedBuckets = $this->expectedDifficultyBucketCounts($service, $category, $total);
+            $score = 0;
+
+            foreach (['primary_low', 'primary_medium', 'primary_high', 'secondary', 'other'] as $bucketKey) {
+                $score += abs(count($actualBuckets[$bucketKey] ?? []) - ($expectedBuckets[$bucketKey] ?? 0));
+            }
+
+            if ($bestScore === null || $score < $bestScore) {
+                $bestScore = $score;
+                $bestCategory = $category;
+            }
+        }
+
+        return $bestCategory;
+    }
+
+    /**
+     * @return array{primary_low: int, primary_medium: int, primary_high: int, secondary: int, other: int}
+     */
+    private function expectedDifficultyBucketCounts(DifficultyDistributionService $service, int $category, int $totalQuestions): array
+    {
+        $expected = [
+            'primary_low' => 0,
+            'primary_medium' => 0,
+            'primary_high' => 0,
+            'secondary' => 0,
+            'other' => 0,
+        ];
+
+        foreach ($service->calculateDistribution($category, $totalQuestions) as $level => $config) {
+            $bucketKey = $service->mapDifficultyLevelToRangeKey((string) $level, $category);
+            $expected[$bucketKey] = ($expected[$bucketKey] ?? 0) + (int) ($config['count'] ?? 0);
+        }
+
+        return $expected;
+    }
+
+    private function averageQuestionDifficulty(array $questions): float
+    {
+        if ($questions === []) {
+            return 0.0;
+        }
+
+        $sum = 0.0;
+        foreach ($questions as $question) {
+            $difficulty = $question['difficulty'] ?? 0.0;
+            $value = is_numeric($difficulty) ? (float) $difficulty : 0.0;
+            if ($value > 1) {
+                $value = $value / 5;
+            }
+            $sum += max(0.0, min(1.0, $value));
+        }
+
+        return round($sum / count($questions), 4);
+    }
+
     private function normalizeQuestionType(string $type): string
     {
         $type = strtolower(trim($type));

+ 2 - 0
app/Services/ApiDocumentation.php

@@ -277,6 +277,8 @@ class ApiDocumentation
                             ['name' => 'grade', 'type' => 'string', 'required' => true, 'description' => '年级(7/8/9)'],
                             ['name' => 'knowledge_points', 'type' => 'array', 'required' => false, 'description' => '指定知识点列表'],
                             ['name' => 'difficulty', 'type' => 'string', 'required' => false, 'description' => '难度偏好:easy/medium/hard/adaptive'],
+                            ['name' => 'assemble_type', 'type' => 'integer', 'required' => false, 'description' => '组卷类型;15=错题再练,16=错题追练'],
+                            ['name' => 'paper_ids', 'type' => 'array', 'required' => false, 'description' => 'assemble_type=15/16 时临时承载题库题目 question_id 列表'],
                         ],
                     ],
                     'response' => [

+ 21 - 13
app/Services/ExamTypeStrategy.php

@@ -35,12 +35,12 @@ class ExamTypeStrategy
 
     /**
      * 根据组卷类型构建参数
-     * assembleType: 0-章节摸底, 1-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-按卷追练(paper_ids=试卷), 8-智能组卷(新), 9-原摸底, 15-错题再练(paper_ids=题库题目id,由 AssembleExamTaskJob 单独处理)
+     * assembleType: 0-章节摸底, 1-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-按卷追练(paper_ids=试卷), 8-智能组卷(新), 9-原摸底, 15-错题再练(paper_ids=题库题目id,由 AssembleExamTaskJob 单独处理), 16-错题追练(paper_ids=题库题目id,由 AssembleExamTaskJob 单独处理)
      *
      * 映射规则(前端不改,后端动态处理):
      * - 0, 9(摸底)→ 章节摸底(新逻辑)
      * - 1, 8(智能组卷)→ 按知识点顺序学习(新逻辑)
-     * - 2, 3, 4, 5 → 保持原有逻辑不变(5 的 paper_ids 为试卷 ID;15 不入此策略,由 AssembleExamTaskJob 直组
+     * - 2, 3, 4, 5 → 保持原有逻辑不变(5 的 paper_ids 为试卷 ID;15/16 不入此策略,由 AssembleExamTaskJob 单独处理
      */
     public function buildParams(array $baseParams, int $assembleType): array
     {
@@ -993,6 +993,20 @@ class ExamTypeStrategy
             'total_questions' => $totalQuestions
         ]);
 
+        // 先合并显式排除题和学生已做题,避免知识点扩展 priority 池把这些题再塞回来。
+        $answeredQuestionIds = [];
+        if ($studentId) {
+            $answeredQuestionIds = $this->getStudentAnsweredQuestionIds($studentId, $kpCodeList);
+        }
+        $providedExcludeQuestionIds = $params['exclude_question_ids'] ?? [];
+        if (! is_array($providedExcludeQuestionIds)) {
+            $providedExcludeQuestionIds = [];
+        }
+        $excludeQuestionIds = array_values(array_unique(array_filter(array_merge(
+            $providedExcludeQuestionIds,
+            $answeredQuestionIds
+        ))));
+
         // assemble_type=2 走知识点扩展策略(复用 QuestionExpansionService)。
         // 只在基础题池不足时,才会触发子知识点补充(由扩展策略内部控制)。
         $priorityQuestionIds = [];
@@ -1002,7 +1016,7 @@ class ExamTypeStrategy
 
         if ($assembleType === 2) {
             $expansionStrategy = $this->questionExpansionService->expandQuestionsByKnowledgePoints(
-                $params,
+                array_merge($params, ['exclude_question_ids' => $excludeQuestionIds]),
                 $studentId ? (string) $studentId : null,
                 $kpCodeList,
                 [],
@@ -1018,17 +1032,11 @@ class ExamTypeStrategy
             $childAddedCount = max(0, $finalPoolCount - $basePoolCount);
         }
 
-        // 如果有学生ID,获取已做过的题目ID列表(用于排除)
-        $answeredQuestionIds = [];
-        if ($studentId) {
-            $answeredQuestionIds = $this->getStudentAnsweredQuestionIds($studentId, $kpCodeList);
-        }
-
         Log::debug('ExamTypeStrategy: 知识点组卷题池评估', [
             'kp_count' => count($kpCodeList),
             'supplement_kp_count' => count($kpSupplementSubtreeCodes),
             'pool_count' => $finalPoolCount,
-            'exclude_count' => count($answeredQuestionIds),
+            'exclude_count' => count($excludeQuestionIds),
         ]);
 
         // 组装增强参数
@@ -1037,10 +1045,10 @@ class ExamTypeStrategy
             'kp_supplement_subtree_codes' => $kpSupplementSubtreeCodes,
             'kp_code_list_original' => $kpCodeList,
             'mistake_question_ids' => $basePoolCount < (int) $totalQuestions ? $priorityQuestionIds : [],
-            'exclude_question_ids' => $answeredQuestionIds,
+            'exclude_question_ids' => $excludeQuestionIds,
             'paper_name' => $params['paper_name'] ?? ('知识点组题_' . now()->format('Ymd_His')),
             // 知识点组卷:442配比(配合固定分值5/5/10,各题型贡献均等)
-            'question_type_ratio' => [
+            'question_type_ratio' => $params['question_type_ratio'] ?? [
                 '选择题' => 40,
                 '填空题' => 40,
                 '解答题' => 20,
@@ -1057,7 +1065,7 @@ class ExamTypeStrategy
         Log::debug('ExamTypeStrategy: 知识点组卷参数构建完成', [
             'kp_count' => count($kpCodeList),
             'supplement_kp_count' => count($kpSupplementSubtreeCodes),
-            'exclude_count' => count($answeredQuestionIds),
+            'exclude_count' => count($excludeQuestionIds),
         ]);
 
         return $enhanced;

+ 266 - 2
app/Services/LearningAnalyticsService.php

@@ -1508,6 +1508,10 @@ class LearningAnalyticsService
             // 3. 根据掌握度对题目进行筛选和排序(含追练:题量与 total_questions / default_total_questions 一致,不再按 50 拉满)
             $targetQuestionCount = min(count($allQuestions), $totalQuestions);
 
+            if (! empty($params['kp_target_counts']) || ! empty($params['max_difficulty_by_kp']) || ! empty($params['type_targets_by_kp'])) {
+                $allQuestions = app(QuestionDifficultyResolver::class)->applyCalibratedDifficulty($allQuestions);
+            }
+
             $startTime = microtime(true);
             $selectedQuestions = $this->selectQuestionsByMastery(
                 $allQuestions,
@@ -1517,7 +1521,10 @@ class LearningAnalyticsService
                 $difficultyLevels,
                 $weaknessFilter,
                 $assembleType,  // 新增assembleType参数
-                $params['kp_code_list_original'] ?? $params['kp_codes'] ?? []
+                $params['kp_code_list_original'] ?? $params['kp_codes'] ?? [],
+                $params['kp_target_counts'] ?? [],
+                $params['max_difficulty_by_kp'] ?? [],
+                $params['type_targets_by_kp'] ?? []
             );
             if (empty($selectedQuestions)) {
                 return [
@@ -2073,7 +2080,10 @@ class LearningAnalyticsService
         array $difficultyLevels,
         array $weaknessFilter,
         int $assembleType,  // 新增assembleType参数
-        array $requestedKpCodes = []
+        array $requestedKpCodes = [],
+        array $kpTargetCounts = [],
+        array $maxDifficultyByKp = [],
+        array $typeTargetsByKp = []
     ): array {
         // 【修复】题目数量处理逻辑:无论题目数量多少,都要进行权重分配和筛选
         // 如果题目数量超过目标,则截取到目标数量
@@ -2099,6 +2109,17 @@ class LearningAnalyticsService
         $requestedKpSet = array_fill_keys($requestedKpCodes, true);
         $shouldRestrictToRequestedKps = ($assembleType === 2 && ! empty($requestedKpSet));
 
+        if ($assembleType === 2 && ! empty($kpTargetCounts)) {
+            return $this->selectQuestionsByKpTargets(
+                $questions,
+                $totalQuestions,
+                $requestedKpCodes,
+                $kpTargetCounts,
+                $maxDifficultyByKp,
+                $typeTargetsByKp
+            );
+        }
+
         if ($shouldRestrictToRequestedKps) {
             $requestedQuestions = array_values(array_filter($questions, function ($question) use ($requestedKpSet) {
                 $kpCode = $question['kp_code'] ?? '';
@@ -2720,6 +2741,249 @@ class LearningAnalyticsService
         return $finalQuestions;
     }
 
+    /**
+     * 错题追练复用知识点组卷时使用:按计划里的 KP 配额、题型配额和难度上限选题。
+     *
+     * @param  array<int, array<string, mixed>>  $questions
+     * @param  array<int, string>  $requestedKpCodes
+     * @param  array<string, int>  $kpTargetCounts
+     * @param  array<string, float|int|string>  $maxDifficultyByKp
+     * @param  array<string, array<string, int>>  $typeTargetsByKp
+     * @return array<int, array<string, mixed>>
+     */
+    private function selectQuestionsByKpTargets(
+        array $questions,
+        int $totalQuestions,
+        array $requestedKpCodes,
+        array $kpTargetCounts,
+        array $maxDifficultyByKp,
+        array $typeTargetsByKp
+    ): array {
+        $normalizedKpTargetCounts = [];
+        foreach ($kpTargetCounts as $kpCode => $count) {
+            $count = (int) $count;
+            if ($count > 0) {
+                $normalizedKpTargetCounts[(string) $kpCode] = $count;
+            }
+        }
+        $kpTargetCounts = $normalizedKpTargetCounts;
+        if ($kpTargetCounts === [] || $totalQuestions <= 0) {
+            return [];
+        }
+
+        $targetKpOrder = array_values(array_filter($requestedKpCodes, static fn ($kp) => isset($kpTargetCounts[$kp])));
+        foreach (array_keys($kpTargetCounts) as $kpCode) {
+            if (! in_array($kpCode, $targetKpOrder, true)) {
+                $targetKpOrder[] = $kpCode;
+            }
+        }
+
+        $targetKpSet = array_fill_keys(array_keys($kpTargetCounts), true);
+        $questionsByKp = [];
+        $supplementQuestions = [];
+        foreach ($questions as $question) {
+            $kpCode = (string) ($question['kp_code'] ?? '');
+            if ($kpCode === '') {
+                continue;
+            }
+            $questionsByKp[$kpCode][] = $question;
+            if (! isset($targetKpSet[$kpCode])) {
+                $supplementQuestions[] = $question;
+            }
+        }
+
+        $selected = [];
+        $usedIds = [];
+        $shortageByKp = [];
+
+        foreach ($targetKpOrder as $kpCode) {
+            $need = min((int) ($kpTargetCounts[$kpCode] ?? 0), max(0, $totalQuestions - count($selected)));
+            if ($need <= 0) {
+                continue;
+            }
+
+            $maxDifficulty = $this->normalizeAssemblyDifficulty($maxDifficultyByKp[$kpCode] ?? null);
+            $typeTargets = $typeTargetsByKp[$kpCode] ?? [];
+            $directSelected = $this->takeQuestionsForPracticeTarget(
+                $questionsByKp[$kpCode] ?? [],
+                $need,
+                $maxDifficulty,
+                $typeTargets,
+                $usedIds
+            );
+            foreach ($directSelected as $question) {
+                $question['practice_target_kp_code'] = $kpCode;
+                $question['practice_target_difficulty'] = $maxDifficulty;
+                $selected[] = $question;
+            }
+
+            $remainingNeed = $need - count($directSelected);
+            if ($remainingNeed > 0 && $supplementQuestions !== []) {
+                $supplementSelected = $this->takeQuestionsForPracticeTarget(
+                    $supplementQuestions,
+                    $remainingNeed,
+                    $maxDifficulty,
+                    [],
+                    $usedIds
+                );
+                foreach ($supplementSelected as $question) {
+                    $question['practice_target_kp_code'] = $kpCode;
+                    $question['practice_target_difficulty'] = $maxDifficulty;
+                    $question['practice_supplement_kp_code'] = $question['kp_code'] ?? null;
+                    $selected[] = $question;
+                }
+                $remainingNeed -= count($supplementSelected);
+            }
+
+            if ($remainingNeed > 0) {
+                $shortageByKp[$kpCode] = $remainingNeed;
+            }
+        }
+
+        if (count($selected) < $totalQuestions) {
+            Log::warning('selectQuestionsByKpTargets: planned KP selection produced fewer questions', [
+                'target_count' => $totalQuestions,
+                'selected_count' => count($selected),
+                'shortage_by_kp' => $shortageByKp,
+                'kp_target_counts' => $kpTargetCounts,
+                'available_kp_distribution' => array_map('count', $questionsByKp),
+            ]);
+        }
+
+        Log::info('selectQuestionsByKpTargets 完成', [
+            'target_count' => $totalQuestions,
+            'selected_count' => count($selected),
+            'kp_target_counts' => $kpTargetCounts,
+            'selected_kp_distribution' => array_count_values(array_column($selected, 'kp_code')),
+            'practice_target_distribution' => array_count_values(array_column($selected, 'practice_target_kp_code')),
+        ]);
+
+        return array_slice($selected, 0, $totalQuestions);
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $pool
+     * @param  array<string, int>  $typeTargets
+     * @param  array<string, bool>  $usedIds
+     * @return array<int, array<string, mixed>>
+     */
+    private function takeQuestionsForPracticeTarget(
+        array $pool,
+        int $need,
+        ?float $maxDifficulty,
+        array $typeTargets,
+        array &$usedIds
+    ): array {
+        if ($need <= 0 || $pool === []) {
+            return [];
+        }
+
+        $candidateBuckets = ['choice' => [], 'fill' => [], 'answer' => []];
+        $questionPayloadMapper = app(QuestionPayloadMapper::class);
+        foreach ($pool as $question) {
+            $id = (string) ($question['id'] ?? $question['question_id'] ?? '');
+            if ($id === '' || isset($usedIds[$id])) {
+                continue;
+            }
+
+            $difficulty = $this->normalizeAssemblyDifficulty($question['difficulty'] ?? null) ?? 0.5;
+
+            $type = $questionPayloadMapper->normalizeQuestionType((string) ($question['question_type'] ?? $question['type'] ?? ''))
+                ?? $this->determineQuestionType($question);
+            if (! isset($candidateBuckets[$type])) {
+                $type = 'answer';
+            }
+            $question['practice_match_delta'] = $maxDifficulty !== null ? round($maxDifficulty - $difficulty, 4) : 0.0;
+            $question['practice_candidate_difficulty'] = $difficulty;
+            $candidateBuckets[$type][] = $question;
+        }
+
+        foreach ($candidateBuckets as &$bucket) {
+            usort($bucket, function (array $a, array $b) use ($maxDifficulty): int {
+                return $this->comparePracticeDifficulty($a, $b, $maxDifficulty);
+            });
+        }
+        unset($bucket);
+
+        $selected = [];
+        foreach (['choice', 'fill', 'answer'] as $type) {
+            $target = min((int) ($typeTargets[$type] ?? 0), $need - count($selected));
+            if ($target <= 0) {
+                continue;
+            }
+            while ($target > 0 && ! empty($candidateBuckets[$type])) {
+                $question = array_shift($candidateBuckets[$type]);
+                $id = (string) ($question['id'] ?? $question['question_id'] ?? '');
+                if ($id === '' || isset($usedIds[$id])) {
+                    continue;
+                }
+                $selected[] = $question;
+                $usedIds[$id] = true;
+                $target--;
+            }
+        }
+
+        $remaining = array_merge($candidateBuckets['choice'], $candidateBuckets['fill'], $candidateBuckets['answer']);
+        usort($remaining, function (array $a, array $b) use ($maxDifficulty): int {
+            return $this->comparePracticeDifficulty($a, $b, $maxDifficulty);
+        });
+
+        foreach ($remaining as $question) {
+            if (count($selected) >= $need) {
+                break;
+            }
+            $id = (string) ($question['id'] ?? $question['question_id'] ?? '');
+            if ($id === '' || isset($usedIds[$id])) {
+                continue;
+            }
+            $selected[] = $question;
+            $usedIds[$id] = true;
+        }
+
+        return $selected;
+    }
+
+    private function comparePracticeDifficulty(array $a, array $b, ?float $targetDifficulty): int
+    {
+        $da = $this->normalizeAssemblyDifficulty($a['difficulty'] ?? null) ?? 0.5;
+        $db = $this->normalizeAssemblyDifficulty($b['difficulty'] ?? null) ?? 0.5;
+
+        if ($targetDifficulty === null) {
+            $scoreA = abs(0.5 - $da);
+            $scoreB = abs(0.5 - $db);
+        } else {
+            $scoreA = $da <= $targetDifficulty
+                ? $targetDifficulty - $da
+                : 1 + ($da - $targetDifficulty);
+            $scoreB = $db <= $targetDifficulty
+                ? $targetDifficulty - $db
+                : 1 + ($db - $targetDifficulty);
+        }
+
+        if ($scoreA !== $scoreB) {
+            return $scoreA <=> $scoreB;
+        }
+
+        return ((int) ($a['id'] ?? $a['question_id'] ?? 0)) <=> ((int) ($b['id'] ?? $b['question_id'] ?? 0));
+    }
+
+    private function normalizeAssemblyDifficulty(mixed $difficulty): ?float
+    {
+        if ($difficulty === null || $difficulty === '') {
+            return null;
+        }
+        if (! is_numeric($difficulty)) {
+            return null;
+        }
+
+        $value = (float) $difficulty;
+        if ($value > 1) {
+            $value = $value / 5;
+        }
+
+        return max(0.0, min(1.0, $value));
+    }
+
     /**
      * @param  array<int, array<string, mixed>>  $questions
      * @param  array<int, string>  $requestedKpCodes

+ 28 - 5
app/Services/QuestionExpansionService.php

@@ -337,11 +337,18 @@ class QuestionExpansionService
             'step5_count' => 0,
             'expansion_details' => []
         ];
+        $initialExcludeQuestionIds = array_values(array_unique(array_filter(
+            $baseParams['exclude_question_ids'] ?? []
+        )));
 
         // Step 1: 获取直接关联知识点题目(优先级:最高)
         // 直接使用用户指定的知识点数组中的题目
         if (!empty($targetKnowledgePoints)) {
-            $step1Questions = $this->getQuestionsByKnowledgePoints($targetKnowledgePoints, $strategy['mistake_question_ids'], $totalQuestions);
+            $step1Questions = $this->getQuestionsByKnowledgePoints(
+                $targetKnowledgePoints,
+                array_merge($initialExcludeQuestionIds, $strategy['mistake_question_ids']),
+                $totalQuestions
+            );
             $strategy['mistake_question_ids'] = array_merge($strategy['mistake_question_ids'], $step1Questions);
             $strategy['expansion_details']['step1_direct_kp'] = [
                 'target_knowledge_points' => $targetKnowledgePoints,
@@ -367,7 +374,11 @@ class QuestionExpansionService
         // 强化特定知识点的掌握,题目类型多样化
         $remaining = $totalQuestions - count($strategy['mistake_question_ids']);
         if (!empty($targetKnowledgePoints) && $remaining > 0) {
-            $step2Questions = $this->getQuestionsByKnowledgePoints($targetKnowledgePoints, $strategy['mistake_question_ids'], $remaining);
+            $step2Questions = $this->getQuestionsByKnowledgePoints(
+                $targetKnowledgePoints,
+                array_merge($initialExcludeQuestionIds, $strategy['mistake_question_ids']),
+                $remaining
+            );
             $strategy['mistake_question_ids'] = array_merge($strategy['mistake_question_ids'], $step2Questions);
             $strategy['step2_count'] = count($step2Questions);
             $strategy['expansion_details']['step2_same_kp'] = [
@@ -392,7 +403,11 @@ class QuestionExpansionService
         if (!empty($targetKnowledgePoints) && $remaining > 0) {
             $childKps = $this->getChildKnowledgePoints($targetKnowledgePoints);
             if (!empty($childKps)) {
-                $step3Questions = $this->getQuestionsByKnowledgePoints($childKps, $strategy['mistake_question_ids'], $remaining);
+                $step3Questions = $this->getQuestionsByKnowledgePoints(
+                    $childKps,
+                    array_merge($initialExcludeQuestionIds, $strategy['mistake_question_ids']),
+                    $remaining
+                );
                 $strategy['mistake_question_ids'] = array_merge($strategy['mistake_question_ids'], $step3Questions);
                 $strategy['step3_count'] = count($step3Questions);
                 $strategy['expansion_details']['step3_child_kp_level1'] = [
@@ -420,7 +435,11 @@ class QuestionExpansionService
             // 排除已使用的目标知识点
             $weaknessKps = array_diff($weaknessKps, $targetKnowledgePoints);
             if (!empty($weaknessKps)) {
-                $step4Questions = $this->getQuestionsByKnowledgePoints($weaknessKps, $strategy['mistake_question_ids'], $remaining);
+                $step4Questions = $this->getQuestionsByKnowledgePoints(
+                    $weaknessKps,
+                    array_merge($initialExcludeQuestionIds, $strategy['mistake_question_ids']),
+                    $remaining
+                );
                 $strategy['mistake_question_ids'] = array_merge($strategy['mistake_question_ids'], $step4Questions);
                 $strategy['step4_count'] = count($step4Questions);
                 $strategy['expansion_details']['step4_weakness'] = [
@@ -450,7 +469,11 @@ class QuestionExpansionService
             if (!empty($weaknessKps)) {
                 $childWeaknessKps = $this->getChildKnowledgePoints($weaknessKps);
                 if (!empty($childWeaknessKps)) {
-                    $step5Questions = $this->getQuestionsByKnowledgePoints($childWeaknessKps, $strategy['mistake_question_ids'], $remaining);
+                    $step5Questions = $this->getQuestionsByKnowledgePoints(
+                        $childWeaknessKps,
+                        array_merge($initialExcludeQuestionIds, $strategy['mistake_question_ids']),
+                        $remaining
+                    );
                     $strategy['mistake_question_ids'] = array_merge($strategy['mistake_question_ids'], $step5Questions);
                     $strategy['step5_count'] = count($step5Questions);
                     $strategy['expansion_details']['step5_child_weakness_level2'] = [

+ 114 - 0
app/Services/QuestionPayloadMapper.php

@@ -0,0 +1,114 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Question;
+
+class QuestionPayloadMapper
+{
+    /**
+     * @param array<int, string> $fallbackKpCodes
+     * @return array<string, mixed>
+     */
+    public function fromArray(array $question, array $fallbackKpCodes = []): array
+    {
+        $type = $this->normalizeQuestionType(
+            (string) ($question['question_type'] ?? $question['type'] ?? '')
+        ) ?? $this->guessType($question);
+
+        $kpCode = (string) ($question['kp_code']
+            ?? $question['kp']
+            ?? $question['knowledge_point']
+            ?? $question['knowledge_point_code']
+            ?? ($fallbackKpCodes[0] ?? ''));
+
+        $id = $question['id'] ?? $question['question_id'] ?? $question['question_bank_id'] ?? null;
+
+        return [
+            'id' => $id,
+            'question_id' => $question['question_id'] ?? $id,
+            'question_type' => $type,
+            'stem' => $question['stem'] ?? $question['content'] ?? ($question['question_text'] ?? ''),
+            'content' => $question['content'] ?? $question['stem'] ?? '',
+            'options' => $question['options'] ?? ($question['choices'] ?? []),
+            'answer' => $question['answer'] ?? $question['correct_answer'] ?? '',
+            'solution' => $question['solution'] ?? '',
+            'difficulty' => $this->normalizeDifficulty($question['difficulty'] ?? null),
+            'score' => $question['score'] ?? $this->defaultScore($type),
+            'estimated_time' => $question['estimated_time'] ?? 300,
+            'kp' => $kpCode,
+            'kp_code' => $kpCode,
+        ];
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function fromModel(Question $question): array
+    {
+        return $this->fromArray([
+            'id' => $question->id,
+            'question_id' => $question->id,
+            'question_type' => $question->question_type,
+            'stem' => $question->stem,
+            'content' => $question->stem,
+            'options' => $question->options ?? [],
+            'answer' => $question->answer,
+            'solution' => $question->solution,
+            'difficulty' => $question->difficulty,
+            'kp_code' => $question->kp_code,
+        ]);
+    }
+
+    public function normalizeQuestionType(?string $type): ?string
+    {
+        $type = strtolower(trim((string) $type));
+        if (in_array($type, ['choice', 'single_choice', 'multiple_choice', '选择题', '单选', '多选'], true)) {
+            return 'choice';
+        }
+        if (in_array($type, ['fill', 'fill_in_the_blank', 'blank', '填空题', '填空'], true)) {
+            return 'fill';
+        }
+        if (in_array($type, ['answer', 'calculation', 'word_problem', 'proof', '解答题', '计算题'], true)) {
+            return 'answer';
+        }
+
+        return null;
+    }
+
+    public function normalizeDifficulty(mixed $difficulty): float
+    {
+        if ($difficulty === null || $difficulty === '') {
+            return 0.5;
+        }
+
+        $value = (float) $difficulty;
+        if ($value > 1) {
+            $value = $value / 5;
+        }
+
+        return max(0.0, min(1.0, $value));
+    }
+
+    private function guessType(array $question): string
+    {
+        if (! empty($question['options']) && is_array($question['options'])) {
+            return 'choice';
+        }
+
+        $content = $question['stem'] ?? $question['content'] ?? '';
+        if (is_string($content) && (str_contains($content, '____') || str_contains($content, '()'))) {
+            return 'fill';
+        }
+
+        return 'answer';
+    }
+
+    private function defaultScore(string $type): int
+    {
+        return match ($type) {
+            'choice', 'fill' => 5,
+            default => 10,
+        };
+    }
+}

+ 378 - 0
app/Services/WrongQuestionPracticePlanService.php

@@ -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);
+    }
+}

+ 1 - 0
app/Support/PaperNaming.php

@@ -16,6 +16,7 @@ class PaperNaming
             3 => '教材组题',
             5 => '智能追练',
             15 => '错题再练',
+            16 => '错题追练',
             default => throw new InvalidArgumentException("不支持的 assemble_type: {$assembleType}"),
         };
     }