yemeishu 1 час назад
Родитель
Сommit
064e8be3c4
2 измененных файлов с 345 добавлено и 69 удалено
  1. 17 1
      app/Services/ExamTypeStrategy.php
  2. 328 68
      app/Services/LearningAnalyticsService.php

+ 17 - 1
app/Services/ExamTypeStrategy.php

@@ -403,11 +403,26 @@ class ExamTypeStrategy
         $mistakeKnowledgePoints = $this->getKnowledgePointsFromQuestions($mistakeQuestionIds);
 
         // 组装增强参数
+        $mistakeCount = count($mistakeQuestionIds);
+        $maxQuestions = 50; // 错题本最大题目数限制
+
+        // 如果错题超过最大值,截取到最大值
+        if ($mistakeCount > $maxQuestions) {
+            Log::warning('ExamTypeStrategy: 错题数量超过最大值限制,已截取', [
+                'mistake_count' => $mistakeCount,
+                'max_limit' => $maxQuestions,
+                'truncated_count' => $maxQuestions
+            ]);
+            $mistakeQuestionIds = array_slice($mistakeQuestionIds, 0, $maxQuestions);
+            $mistakeCount = $maxQuestions;
+        }
+
         $enhanced = array_merge($params, [
             'mistake_question_ids' => $mistakeQuestionIds,
             'paper_ids' => $paperIds,
             'priority_knowledge_points' => array_values($mistakeKnowledgePoints),
             'paper_name' => $params['paper_name'] ?? ('错题本_' . now()->format('Ymd_His')),
+            'total_questions' => $mistakeCount, // 错题本题目数量由实际错题数量决定
             // 错题本:保持原有题型配比
             'question_type_ratio' => [
                 '选择题' => 35,
@@ -417,8 +432,9 @@ class ExamTypeStrategy
             // 错题本不应用难度分布
             'is_mistake_exam' => true,
             'is_paper_based_mistake' => true, // 标记是基于卷子的错题本
-            'mistake_count' => count($mistakeQuestionIds),
+            'mistake_count' => $mistakeCount,
             'knowledge_points_count' => count($mistakeKnowledgePoints),
+            'max_questions_limit' => $maxQuestions,
         ]);
 
         Log::info('ExamTypeStrategy: 错题本参数构建完成', [

+ 328 - 68
app/Services/LearningAnalyticsService.php

@@ -1322,34 +1322,113 @@ class LearningAnalyticsService
             // 2. 优先使用学生错题(如果存在)
             $mistakeQuestionIds = $params['mistake_question_ids'] ?? [];
             $priorityQuestions = [];
+            $maxQuestions = 50; // 全局最大题目数限制
 
             if (!empty($mistakeQuestionIds)) {
                 Log::info('LearningAnalyticsService: 优先获取学生错题', [
                     'mistake_question_ids' => $mistakeQuestionIds,
-                    'count' => count($mistakeQuestionIds)
+                    'count' => count($mistakeQuestionIds),
+                    'max_limit' => $maxQuestions
                 ]);
 
+                // 如果错题超过最大值,截取到最大值
+                $truncatedMistakeIds = $mistakeQuestionIds;
+                if (count($mistakeQuestionIds) > $maxQuestions) {
+                    Log::warning('LearningAnalyticsService: 错题数量超过最大值限制,已截取', [
+                        'mistake_count' => count($mistakeQuestionIds),
+                        'max_limit' => $maxQuestions,
+                        'truncated_count' => $maxQuestions
+                    ]);
+                    $truncatedMistakeIds = array_slice($mistakeQuestionIds, 0, $maxQuestions);
+                }
+
                 // 获取学生错题的详细信息
-                $priorityQuestions = $this->getQuestionsFromBank([], [], $studentId, $questionTypeRatio, 200, $mistakeQuestionIds, [], null);
+                $priorityQuestions = $this->getQuestionsFromBank([], [], $studentId, $questionTypeRatio, $maxQuestions, $truncatedMistakeIds, [], null);
 
                 Log::info('LearningAnalyticsService: 错题获取完成', [
                     'priority_questions_count' => count($priorityQuestions),
-                    'expected_count' => count($mistakeQuestionIds)
+                    'expected_count' => count($truncatedMistakeIds)
                 ]);
 
                 // 如果获取的错题数量少于预期,记录警告
-                if (count($priorityQuestions) < count($mistakeQuestionIds)) {
+                if (count($priorityQuestions) < count($truncatedMistakeIds)) {
                     Log::warning('LearningAnalyticsService: 错题获取不完整', [
-                        'expected' => count($mistakeQuestionIds),
+                        'expected' => count($truncatedMistakeIds),
                         'actual' => count($priorityQuestions),
-                        'missing_ids' => array_diff($mistakeQuestionIds, array_column($priorityQuestions, 'id'))
+                        'missing_ids' => array_diff($truncatedMistakeIds, array_column($priorityQuestions, 'id'))
                     ]);
                 }
             }
 
-            // 3. 如果错题数量不足,补充其他题目(错题本类型不补充)
+            // 3. 处理错题本逻辑
             $allQuestions = $priorityQuestions;
-            $isMistakeBook = ($assembleType === 5); // 错题本类型不补充题目
+            $isMistakeBook = ($assembleType === 5); // 错题本类型
+
+            // 如果是错题本类型,且有错题但数量不足,获取原卷子的所有题目
+            if ($isMistakeBook && !empty($priorityQuestions) && count($priorityQuestions) < 5 && !empty($params['paper_ids'])) {
+                Log::info('LearningAnalyticsService: 错题本错题不足,获取原卷子题目补充', [
+                    'mistake_count' => count($priorityQuestions),
+                    'paper_ids' => $params['paper_ids']
+                ]);
+
+                // 获取原卷子的所有题目
+                $paperQuestionIds = $this->getPaperAllQuestions($params['paper_ids']);
+                if (!empty($paperQuestionIds)) {
+                    Log::info('LearningAnalyticsService: 获取原卷子题目', [
+                        'paper_question_count' => count($paperQuestionIds)
+                    ]);
+
+                    // 使用原卷子题目补充到目标数量
+                    $additionalNeeded = max(5, count($priorityQuestions)) - count($priorityQuestions);
+                    $paperQuestionIds = array_diff($paperQuestionIds, array_column($priorityQuestions, 'id'));
+                    $additionalPaperQuestions = $this->getQuestionsFromBank([], [], $studentId, $questionTypeRatio, $additionalNeeded, $paperQuestionIds, [], null);
+
+                    $allQuestions = array_merge($priorityQuestions, $additionalPaperQuestions);
+                    Log::info('LearningAnalyticsService: 错题本题目补充完成', [
+                        'final_count' => count($allQuestions)
+                    ]);
+                }
+            }
+
+            // 如果是错题本类型,但完全没有错题,使用原卷子的所有题目
+            if ($isMistakeBook && empty($priorityQuestions) && !empty($params['paper_ids'])) {
+                Log::info('LearningAnalyticsService: 错题本无错题,使用原卷子题目', [
+                    'paper_ids' => $params['paper_ids']
+                ]);
+
+                // 获取原卷子的所有题目
+                $paperQuestionIds = $this->getPaperAllQuestions($params['paper_ids']);
+                if (!empty($paperQuestionIds)) {
+                    $allQuestions = $this->getQuestionsFromBank([], [], $studentId, $questionTypeRatio, $maxQuestions, $paperQuestionIds, [], null);
+
+                    // 检查是否获取到题目
+                    if (empty($allQuestions)) {
+                        Log::warning('LearningAnalyticsService: 错题本无法获取任何题目', [
+                            'paper_ids' => $params['paper_ids'],
+                            'paper_question_ids' => $paperQuestionIds,
+                            'reason' => '原卷子题目在题库中不存在或已删除'
+                        ]);
+
+                        throw new \Exception('抱歉,您选择的卷子中的题目在题库中不存在或已被删除,无法生成错题本。请选择其他卷子或联系管理员更新题库。');
+                    }
+
+                    Log::info('LearningAnalyticsService: 错题本使用原卷子题目', [
+                        'question_count' => count($allQuestions)
+                    ]);
+                } else {
+                    throw new \Exception('抱歉,指定的卷子中没有找到题目,无法生成错题本。');
+                }
+            }
+
+            // 错题本类型但既无错题又无卷子题目
+            if ($isMistakeBook && empty($priorityQuestions) && empty($allQuestions)) {
+                Log::warning('LearningAnalyticsService: 错题本既无错题又无卷子题目', [
+                    'student_id' => $studentId,
+                    'paper_ids' => $params['paper_ids'] ?? []
+                ]);
+
+                throw new \Exception('抱歉,您在这个卷子中没有错题记录,且卷子题目无法获取,无法生成错题本。请确认卷子ID是否正确或联系管理员。');
+            }
 
             if (!$isMistakeBook && count($priorityQuestions) < $totalQuestions) {
                 try {
@@ -1361,7 +1440,7 @@ class LearningAnalyticsService
                         'assemble_type' => $assembleType
                     ]);
 
-                    $additionalQuestions = $this->getQuestionsFromBank($kpCodes, $skills, $studentId, $questionTypeRatio, 200, [], [], $questionCategory);
+                    $additionalQuestions = $this->getQuestionsFromBank($kpCodes, $skills, $studentId, $questionTypeRatio, $maxQuestions, [], [], $questionCategory);
                     $allQuestions = array_merge($priorityQuestions, $additionalQuestions);
 
                     Log::info('getQuestionsFromBank 调用完成', [
@@ -1418,8 +1497,8 @@ class LearningAnalyticsService
             }
 
             // 3. 根据掌握度对题目进行筛选和排序
-            // 错题本类型:使用所有错题,不限制数量
-            $targetQuestionCount = $isMistakeBook ? count($allQuestions) : $totalQuestions;
+            // 错题本类型:使用所有错题,超过最大值限制
+            $targetQuestionCount = $isMistakeBook ? min(count($allQuestions), $maxQuestions) : $totalQuestions;
 
             Log::info('开始调用 selectQuestionsByMastery', [
                 'input_count' => count($allQuestions),
@@ -1543,7 +1622,9 @@ class LearningAnalyticsService
 
                     return $priorityQuestions;
                 } else {
-                    Log::warning('getQuestionsFromBank: 优先错题获取失败');
+                    Log::warning('getQuestionsFromBank: 优先错题获取失败,返回空数组让上层处理');
+                    // 错题本类型获取不到错题时,返回空数组,不回退到题库随机选题
+                    return [];
                 }
             }
 
@@ -1648,34 +1729,48 @@ class LearningAnalyticsService
     }
 
     /**
-     * 从本地数据库获取指定ID的题目
+     * 获取指定ID的题目详情
      */
     private function getLocalQuestionsByIds(array $questionIds): array
     {
         try {
-            $questions = \App\Models\Question::whereIn('id', $questionIds)
-                ->select(['id', 'question_code', 'kp_code', 'question_type', 'difficulty', 'stem'])
-                ->get();
+            // 通过QuestionBankService获取题目详情
+            $questionBankService = $this->questionBankService ?? app(\App\Services\QuestionBankService::class);
+
+            // 批量获取题目详情
+            $response = $questionBankService->getQuestionsByIds($questionIds);
+
+            if (empty($response['data'])) {
+                Log::warning('getLocalQuestionsByIds: 未获取到任何题目', [
+                    'requested_ids' => $questionIds
+                ]);
+                return [];
+            }
 
-            // 转换为数组格式
-            $result = $questions->map(function ($q) {
+            $questions = $response['data'];
+
+            // 转换为标准格式
+            $result = array_map(function ($q) {
+                $difficulty = $q['difficulty'] ?? 0.5;
                 return [
-                    'id' => $q->id,
-                    'question_code' => $q->question_code,
-                    'kp_code' => $q->kp_code,
-                    'question_type' => $q->question_type,
-                    'difficulty' => $q->difficulty,
-                    'stem' => $q->stem,
+                    'id' => $q['id'],
+                    'question_code' => $q['question_code'] ?? '',
+                    'kp_code' => $q['kp_code'] ?? '',
+                    'question_type' => $q['question_type'] ?? 'choice',
+                    'difficulty' => (float) $difficulty,
+                    'stem' => $q['stem'] ?? '',
+                    'solution' => $q['solution'] ?? '',
+                    'answer' => $q['answer'] ?? '',
                     'metadata' => [
-                        'has_solution' => true,
-                        'is_choice' => $q->question_type === 'choice',
-                        'is_fill' => $q->question_type === 'fill',
-                        'is_answer' => $q->question_type === 'answer',
-                        'difficulty_label' => $this->getDifficultyLabel($q->difficulty),
-                        'question_type_label' => $this->getQuestionTypeLabel($q->question_type)
+                        'has_solution' => !empty($q['solution']),
+                        'is_choice' => ($q['question_type'] ?? 'choice') === 'choice',
+                        'is_fill' => ($q['question_type'] ?? 'choice') === 'fill',
+                        'is_answer' => ($q['question_type'] ?? 'choice') === 'answer',
+                        'difficulty_label' => $this->getDifficultyLabel((float) $difficulty),
+                        'question_type_label' => $this->getQuestionTypeLabel($q['question_type'] ?? 'choice')
                     ]
                 ];
-            })->toArray();
+            }, $questions);
 
             Log::info('getLocalQuestionsByIds 获取成功', [
                 'requested_count' => count($questionIds),
@@ -1690,13 +1785,44 @@ class LearningAnalyticsService
         } catch (\Exception $e) {
             Log::error('getLocalQuestionsByIds 获取失败', [
                 'question_ids' => $questionIds,
-                'error' => $e->getMessage()
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
             ]);
 
             return [];
         }
     }
 
+    /**
+     * 获取卷子的所有题目ID(不区分对错)
+     */
+    private function getPaperAllQuestions(array $paperIds): array
+    {
+        try {
+            // 查询 paper_questions 表获取所有题目ID
+            $questionIds = DB::table('paper_questions')
+                ->whereIn('paper_id', $paperIds)
+                ->pluck('question_bank_id')
+                ->unique()
+                ->filter()
+                ->values()
+                ->toArray();
+
+            Log::debug('LearningAnalyticsService: 获取卷子所有题目', [
+                'paper_ids' => $paperIds,
+                'question_count' => count($questionIds)
+            ]);
+
+            return $questionIds;
+        } catch (\Exception $e) {
+            Log::error('LearningAnalyticsService: 获取卷子题目失败', [
+                'paper_ids' => $paperIds,
+                'error' => $e->getMessage()
+            ]);
+            return [];
+        }
+    }
+
     /**
      * 获取难度标签
      */
@@ -1853,10 +1979,10 @@ class LearningAnalyticsService
             'group_time_ms' => round($groupTime, 2)
         ]);
 
-        // 2. 为每个知识点计算权重
+        // 2. 为每个知识点计算权重(用于题型内的题目排序)
         $kpWeights = [];
         $kpCodes = array_keys($questionsByKp);
-        Log::info('开始计算知识点权重', [
+        Log::info('开始计算知识点权重(用于题型内排序)', [
             'kp_count' => count($kpCodes),
             'student_id' => $studentId
         ]);
@@ -1919,68 +2045,202 @@ class LearningAnalyticsService
             'avg_time_per_kp_ms' => count($kpCodes) > 0 ? round($totalWeightTime / count($kpCodes), 2) : 0
         ]);
 
-        // 3. 按权重分配题目数量(修复:按权重排序取前N道题)
-        $totalWeight = array_sum($kpWeights);
+        // 3. 按题型分配题目数量(权重用于题型内排序)
         $selectedQuestions = [];
 
-        // 将所有题目合并并添加权重信息
+        // 将所有题目合并
         $weightedQuestions = [];
         foreach ($questionsByKp as $kpCode => $kpQuestions) {
-            $weight = $kpWeights[$kpCode];
             foreach ($kpQuestions as $q) {
-                $q['kp_weight'] = $weight;
                 $q['kp_code'] = $kpCode;
                 $weightedQuestions[] = $q;
             }
         }
 
-        // 打乱题目顺序(避免固定模式)
-        shuffle($weightedQuestions);
+        // ========== 知识点优先选择机制 ==========
+        // 摸底测试的核心目标是最大化知识点覆盖
+        // 题型只是约束条件(每种至少1题)
+
+        // 首先按题型分组
+        $questionsByType = [
+            'choice' => [],
+            'fill' => [],
+            'answer' => [],
+        ];
+
+        foreach ($weightedQuestions as $q) {
+            $qid = $q['id'] ?? $q['question_id'] ?? null;
+            if ($qid && isset($questionTypeCache[$qid])) {
+                $type = $questionTypeCache[$qid];
+            } else {
+                $type = $this->determineQuestionType($q);
+                if ($qid) {
+                    $questionTypeCache[$qid] = $type;
+                }
+            }
+
+            if (!isset($questionsByType[$type])) {
+                $type = 'answer';  // 默认归类为answer
+            }
+            $questionsByType[$type][] = $q;
+        }
+
+        // ========== 步骤1:按知识点分组,优先选不同知识点 ==========
+        $selectedQuestions = [];
+        $kpSelected = []; // 已选知识点记录
+
+        // 先确保每种题型至少选1题(来自不同知识点)
+        foreach (['choice', 'fill', 'answer'] as $type) {
+            if (empty($questionsByType[$type])) {
+                Log::warning('题型分配:题型无题目', ['type' => $type]);
+                continue;
+            }
+
+            // 按权重排序该题型的题目
+            usort($questionsByType[$type], function ($a, $b) use ($kpWeights) {
+                $kpA = $a['kp_code'] ?? '';
+                $kpB = $b['kp_code'] ?? '';
+                $weightA = $kpWeights[$kpA] ?? 1.0;
+                $weightB = $kpWeights[$kpB] ?? 1.0;
+                return $weightB <=> $weightA;
+            });
+
+            // 选择第一个未选过知识点的题目
+            foreach ($questionsByType[$type] as $q) {
+                $kpCode = $q['kp_code'] ?? '';
+                if (!isset($kpSelected[$kpCode])) {
+                    $selectedQuestions[] = $q;
+                    $kpSelected[$kpCode] = true;
+                    Log::debug('题型基础分配', ['type' => $type, 'kp' => $kpCode]);
+                    break;
+                }
+            }
+        }
 
-        // 按权重排序(权重高的在前)
-        usort($weightedQuestions, function ($a, $b) {
-            return ($b['kp_weight'] ?? 1.0) <=> ($a['kp_weight'] ?? 1.0);
+        // ========== 步骤2:继续选不同知识点,直到达到目标数量 ==========
+        $allQuestions = array_merge($questionsByType['choice'], $questionsByType['fill'], $questionsByType['answer']);
+        usort($allQuestions, function ($a, $b) use ($kpWeights) {
+            $kpA = $a['kp_code'] ?? '';
+            $kpB = $b['kp_code'] ?? '';
+            $weightA = $kpWeights[$kpA] ?? 1.0;
+            $weightB = $kpWeights[$kpB] ?? 1.0;
+            if ($weightA == $weightB) {
+                $idA = $a['id'] ?? $a['question_id'] ?? 0;
+                $idB = $b['id'] ?? $b['question_id'] ?? 0;
+                return $idA <=> $idB;
+            }
+            return $weightB <=> $weightA;
         });
 
-        // 选择前 N 道题
-        $selectedQuestions = array_slice($weightedQuestions, 0, $totalQuestions);
+        // 选择未选过知识点的题目(优先)
+        foreach ($allQuestions as $q) {
+            if (count($selectedQuestions) >= $totalQuestions) break;
+
+            $kpCode = $q['kp_code'] ?? '';
+            if (!isset($kpSelected[$kpCode])) {
+                $selectedQuestions[] = $q;
+                $kpSelected[$kpCode] = true;
+            }
+        }
+
+        // ========== 步骤3:如果还有空缺,从已选知识点中补充 ==========
+        if (count($selectedQuestions) < $totalQuestions) {
+            Log::info('知识点分配后题目不足,开始补充', [
+                'selected_count' => count($selectedQuestions),
+                'need_more' => $totalQuestions - count($selectedQuestions),
+                'kp_covered' => count($kpSelected)
+            ]);
+
+            // 统计每个知识点的题目数量
+            $kpCount = [];
+            foreach ($selectedQuestions as $q) {
+                $kpCode = $q['kp_code'] ?? '';
+                $kpCount[$kpCode] = ($kpCount[$kpCode] ?? 0) + 1;
+            }
+
+            // 优先补充知识点数量少的题目
+            foreach ($allQuestions as $q) {
+                if (count($selectedQuestions) >= $totalQuestions) break;
+
+                $kpCode = $q['kp_code'] ?? '';
+                $count = $kpCount[$kpCode] ?? 0;
+
+                // 如果该知识点题目数量少于2题,且题目不在已选列表中
+                if ($count < 2 && !in_array($q, $selectedQuestions)) {
+                    $selectedQuestions[] = $q;
+                    $kpCount[$kpCode] = $count + 1;
+                }
+            }
+        }
 
-        Log::info('知识点题目分配完成', [
+        Log::info('知识点优先选择完成', [
             'total_questions' => $totalQuestions,
             'selected_count' => count($selectedQuestions),
+            'kp_covered' => count($kpSelected),
+            'type_distribution' => array_count_values(array_map(function($q) {
+                $qid = $q['id'] ?? $q['question_id'] ?? null;
+                if ($qid && isset($questionTypeCache[$qid])) {
+                    return $questionTypeCache[$qid];
+                }
+                return $this->determineQuestionType($q);
+            }, $selectedQuestions)),
             'top_kp_distribution' => array_count_values(array_column($selectedQuestions, 'kp_code'))
         ]);
 
-        // 4. 如果题目过多,按权重排序后截取
+        // 最终截取到目标数量(如果超过)
         if (count($selectedQuestions) > $totalQuestions) {
-            Log::info('开始按权重排序题目', [
-                'before_sort_count' => count($selectedQuestions),
-                'target_count' => $totalQuestions
+            Log::info('题目数量超过目标,进行最终截取', [
+                'before' => count($selectedQuestions),
+                'after' => $totalQuestions
             ]);
+            $selectedQuestions = array_slice($selectedQuestions, 0, $totalQuestions);
+        }
 
-            $startTime = microtime(true);
-            usort($selectedQuestions, function ($a, $b) use ($kpWeights) {
-                $weightA = $kpWeights[$a['kp_code']] ?? 1.0;
-                $weightB = $kpWeights[$b['kp_code']] ?? 1.0;
-                return $weightB <=> $weightA;
-            });
-            $sortTime = (microtime(true) - $startTime) * 1000;
+        // ========== 最终排查:确保无重复题目且题型分布合理 ==========
+        $finalQuestions = [];
+        $seenQuestionIds = [];
+        $duplicateCount = 0;
+        $typeDistribution = ['choice' => 0, 'fill' => 0, 'answer' => 0];
 
-            Log::info('权重排序完成', [
-                'sort_time_ms' => round($sortTime, 2),
-                'after_sort_count' => count($selectedQuestions)
-            ]);
+        foreach ($selectedQuestions as $question) {
+            $qbId = $question['question_bank_id'] ?? $question['id'];
+            if (!in_array($qbId, $seenQuestionIds)) {
+                $seenQuestionIds[] = $qbId;
+                $finalQuestions[] = $question;
 
-            $selectedQuestions = array_slice($selectedQuestions, 0, $totalQuestions);
+                // 统计题型分布
+                $qid = $question['id'] ?? $question['question_id'] ?? null;
+                $type = null;
+                if ($qid && isset($questionTypeCache[$qid])) {
+                    $type = $questionTypeCache[$qid];
+                } else {
+                    $type = $this->determineQuestionType($question);
+                    if ($qid) {
+                        $questionTypeCache[$qid] = $type;
+                    }
+                }
+                if (isset($typeDistribution[$type])) {
+                    $typeDistribution[$type]++;
+                }
+            } else {
+                $duplicateCount++;
+                Log::warning('发现重复题目(已自动移除)', [
+                    'question_id' => $qbId,
+                    'duplicate_count' => $duplicateCount
+                ]);
+            }
         }
 
-        Log::info('开始题型配比调整', [
-            'input_count' => count($selectedQuestions),
-            'target_count' => $totalQuestions
+        Log::info('最终排查完成', [
+            'original_count' => count($selectedQuestions),
+            'final_count' => count($finalQuestions),
+            'duplicate_removed' => $duplicateCount,
+            'final_type_distribution' => $typeDistribution
         ]);
 
-        // 5. 按题型进行微调(难度分布由 QuestionLocalService 处理)
-        return $this->adjustQuestionsByRatio($selectedQuestions, $questionTypeRatio, $totalQuestions);
+        // 注意:题型平衡已在上面完成,不需要再调用adjustQuestionsByRatio
+        // adjustQuestionsByRatio主要处理题型配比,但我们的题型平衡机制已经确保了题型分布符合要求
+        return $finalQuestions;
     }
 
     /**