Selaa lähdekoodia

fix: 组卷去重与智能补题逻辑

- 去重:从 paper_questions 取 question_bank_id 排除已做题目
- 智能补题:只从学过的内容补充
  - 有教材:同教材前章节
  - 无教材:学生已学知识点(getStudentLearnedKpCodes)
- totalNeeded 传参修复,使题目不足时能触发智能补充
- grade 缺失时从教材推断
- 新增 getStudentLearnedKpCodes、getEarlierChapterNodeIds

Made-with: Cursor
yemeishu 1 viikko sitten
vanhempi
commit
269fdddd35

+ 41 - 13
app/Services/ExamTypeStrategy.php

@@ -913,6 +913,15 @@ class ExamTypeStrategy
             'kp_codes' => array_slice($kpCodes, 0, 5)
         ]);
 
+        // 【重要】grade 缺失时从教材推断,保证智能补充可触发
+        if ($textbookId && ($grade === null || $grade === '')) {
+            $grade = DB::table('textbooks')->where('id', $textbookId)->value('grade');
+            Log::info('ExamTypeStrategy: 智能组卷从教材推断 grade', [
+                'textbook_id' => $textbookId,
+                'inferred_grade' => $grade
+            ]);
+        }
+
         // 组装增强参数
         $enhanced = array_merge($params, [
             'kp_code_list' => $kpCodes,
@@ -1172,10 +1181,23 @@ class ExamTypeStrategy
         // 在LearningAnalyticsService中会使用这些参数进行题目筛选
         $textbookCatalogNodeIds = $chapterIdList; // 直接使用章节ID作为textbook_catalog_node_id筛选条件
 
+        // 【重要】确保 grade 和 textbook_id 传给智能补充;grade 缺失时从教材推断
+        $textbookId = (int) ($params['textbook_id'] ?? 0);
+        $grade = $params['grade'] ?? null;
+        if ($textbookId && $grade === null) {
+            $grade = DB::table('textbooks')->where('id', $textbookId)->value('grade');
+            Log::info('ExamTypeStrategy: 教材组卷从教材推断 grade', [
+                'textbook_id' => $textbookId,
+                'inferred_grade' => $grade
+            ]);
+        }
+
         // 组装增强参数
         $enhanced = array_merge($params, [
             'kp_codes' => $kpCodes, // 可能为空,但仍保留
             'chapter_id_list' => $chapterIdList,
+            'textbook_id' => $textbookId ?: ($params['textbook_id'] ?? null), // 保证智能补充有教材范围
+            'grade' => $grade,
             'textbook_catalog_node_ids' => $textbookCatalogNodeIds, // 【重要】即使kpCodes为空也设置此参数
             'exclude_question_ids' => $answeredQuestionIds,
             'paper_name' => $params['paper_name'] ?? ('教材组题_' . now()->format('Ymd_His')),
@@ -1422,29 +1444,35 @@ class ExamTypeStrategy
     }
 
     /**
-     * 获取学生已答题目ID列表(用于排除)
+     * 获取学生已做题目ID列表(用于排除)
+     *
+     * 唯一来源:从 paper_questions 中取该学生名下所有试卷的题目 ID(questions.id)
+     * 确保新卷子不会出现学生做过的重复题目
      */
     private function getStudentAnsweredQuestionIds(string $studentId, array $kpCodes): array
     {
         try {
-            // 【修复】查询 student_answer_questions 表,不使用 kp_code 过滤(该字段可能不存在)
-            $query = DB::table('student_answer_questions')
-                ->where('student_id', $studentId)
-                ->distinct();
+            $questionIds = PaperQuestion::query()
+                ->whereHas('paper', fn ($q) => $q->where('student_id', $studentId))
+                ->whereNotNull('question_bank_id')
+                ->where('question_bank_id', '>', 0)
+                ->distinct()
+                ->pluck('question_bank_id')
+                ->map(fn ($v) => (int) $v)
+                ->values()
+                ->toArray();
 
-            // 如果有 question_id 字段,直接查询
-            $questionIds = $query->pluck('question_id')->toArray();
+            $paperCount = Paper::where('student_id', $studentId)->count();
 
-            Log::debug('ExamTypeStrategy: 查询学生已答题目', [
+            Log::info('ExamTypeStrategy: 从 paper_questions 获取学生已做题目', [
                 'student_id' => $studentId,
-                'kp_count' => count($kpCodes),
-                'answered_count' => count($questionIds),
-                'note' => '只按学生ID过滤,不按kp_code过滤'
+                'paper_count' => $paperCount,
+                'exclude_count' => count($questionIds),
             ]);
 
-            return array_filter($questionIds); // 移除空值
+            return $questionIds;
         } catch (\Exception $e) {
-            Log::error('ExamTypeStrategy: 查询学生已答题目失败', [
+            Log::error('ExamTypeStrategy: 获取学生已做题目失败', [
                 'student_id' => $studentId,
                 'error' => $e->getMessage()
             ]);

+ 170 - 22
app/Services/LearningAnalyticsService.php

@@ -1447,13 +1447,15 @@ class LearningAnalyticsService
                     ]);
 
                     // 【修复超纲问题】传入 grade 和 textbook_id,用于智能补充时限制范围
+                    // 【修复】传入实际需要数量,否则 totalNeeded=0 会导致智能补充不触发
                     $excludeQuestionIds = $params['exclude_question_ids'] ?? [];
+                    $needCount = $totalQuestions - count($priorityQuestions);
                     $additionalQuestions = $this->getQuestionsFromBank(
                         $kpCodes,
                         $skills,
                         $studentId,
                         $questionTypeRatio,
-                        $poolLimit,
+                        $needCount,
                         [],
                         $excludeQuestionIds,
                         $questionCategory,
@@ -1805,17 +1807,22 @@ class LearningAnalyticsService
                     'available_count' => count($selectedQuestions),
                     'grade' => $grade,
                     'textbook_id' => $textbookId,
-                    'strategy' => $textbookId ? '从指定教材的其他知识点补充(避免超纲)' : '从同年级其他知识点补充'
+                    'student_id' => $studentId ? '(有)' : '(无)',
+                    'strategy' => $textbookId ? '从同教材前章节补充' : ($studentId ? '从学生已学知识点补充' : '无教材且无学生ID,不补充')
                 ]);
 
-                // 【修复超纲问题】补充策略:从同年级同教材的其他知识点补充
-                // 传入 textbook_id 参数,避免七年级上册学生拿到下册题目
+                // 【修复超纲问题】补充策略:只从「学过的」内容补充
+                // - 有 textbook_id:从同教材的前章节补充
+                // - 无 textbook_id:从学生做过的题目对应的知识点补充,避免未学内容
                 $supplementaryQuestions = $this->getSupplementaryQuestionsForGrade(
                     $grade,
                     array_column($selectedQuestions, 'kp_code'),
                     $deficit,
                     $difficultyCategory,
-                    $textbookId  // 【关键】传入教材ID,限制补充范围
+                    $textbookId,
+                    $excludeQuestionIds,
+                    $textbookCatalogNodeIds ?? null,
+                    $studentId
                 );
 
                 if (!empty($supplementaryQuestions)) {
@@ -3024,6 +3031,9 @@ class LearningAnalyticsService
      * @param int $needCount 需要补充的题目数量
      * @param int $difficultyCategory 难度类别
      * @param int|null $textbookId 教材ID(用于限制补充范围,避免超纲)
+     * @param array $excludeQuestionIds 排除的题目ID(学生已做过,避免重复)
+     * @param array|null $textbookCatalogNodeIds 当前选中的章节节点ID;与 textbookId 同时传入时,仅从同教材的前章节补充(未学章节不补充)
+     * @param string|null $studentId 学生ID(无教材时用于获取「已学知识点」,仅从已学内容补充,避免未学章节)
      * @return array 补充的题目列表
      */
     private function getSupplementaryQuestionsForGrade(
@@ -3031,7 +3041,10 @@ class LearningAnalyticsService
         array $existingKpCodes,
         int $needCount,
         int $difficultyCategory,
-        ?int $textbookId = null
+        ?int $textbookId = null,
+        array $excludeQuestionIds = [],
+        ?array $textbookCatalogNodeIds = null,
+        ?string $studentId = null
     ): array {
         try {
             Log::info('getSupplementaryQuestionsForGrade: 开始智能补充', [
@@ -3040,15 +3053,54 @@ class LearningAnalyticsService
                 'need_count' => $needCount,
                 'difficulty_category' => $difficultyCategory,
                 'textbook_id' => $textbookId,
-                'note' => $textbookId ? '限制在指定教材范围内,避免超纲' : '使用整个年级范围'
+                'student_id' => $studentId ? '(有)' : '(无)',
+                'exclude_count' => count($excludeQuestionIds),
+                'has_chapter_scope' => !empty($textbookCatalogNodeIds),
             ]);
 
+            // 【核心】补充范围:只从「学过的」内容补充,不能从未学知识点或年级对应章节中选
+            // - 有 textbookId:从 getGradeKnowledgePoints(grade, textbookId) + 前章节(若有 textbookCatalogNodeIds)
+            // - 无 textbookId 且有 studentId:从学生做过的题目对应知识点(getStudentLearnedKpCodes)补充
+            // - 无 textbookId 且无 studentId:无法确定学过的内容,不补充
+            $gradeKpCodes = [];
+            if ($textbookId) {
+                $gradeKpCodes = $this->getGradeKnowledgePoints($grade, $textbookId);
+                Log::info('getSupplementaryQuestionsForGrade: 教材模式,使用教材知识点', [
+                    'kp_count' => count($gradeKpCodes),
+                    'textbook_id' => $textbookId,
+                ]);
+            } elseif ($studentId) {
+                $learnedKps = $this->getStudentLearnedKpCodes($studentId);
+                $gradeKpCodes = array_values(array_diff($learnedKps, $existingKpCodes));
+                Log::info('getSupplementaryQuestionsForGrade: 无教材模式,使用学生已学知识点', [
+                    'learned_count' => count($learnedKps),
+                    'after_exclude_existing' => count($gradeKpCodes),
+                ]);
+            } else {
+                Log::warning('getSupplementaryQuestionsForGrade: 无教材且无学生ID,无法确定学过的内容,不补充');
+                return [];
+            }
+
+            if (empty($gradeKpCodes)) {
+                Log::warning('getSupplementaryQuestionsForGrade: 无可用知识点,跳过补充', [
+                    'grade' => $grade,
+                    'textbook_id' => $textbookId,
+                    'note' => $textbookId ? '教材知识点不足' : '学生已学知识点不足或与当前选题重复',
+                ]);
+                return [];
+            }
+
             // 查询同年级其他知识点的题目
             $query = \App\Models\Question::query();
 
             // 只获取审核通过的题目
             $query->where('audit_status', 0);
 
+            // 【新增】排除学生已做过的题目
+            if (!empty($excludeQuestionIds)) {
+                $query->whereNotIn('id', $excludeQuestionIds);
+            }
+
             $stageGrade = $this->normalizeQuestionStageGrade($grade);
             if ($stageGrade !== null) {
                 $query->where('grade', $stageGrade);
@@ -3063,21 +3115,21 @@ class LearningAnalyticsService
                 $query->whereNotIn('kp_code', $existingKpCodes);
             }
 
-            // 【修复超纲问题】限制在指定教材范围内(通过教材关联)
-            // 如果有 textbook_id,只从该教材的知识点中补充,避免七年级上册学生拿到下册题目
-            $gradeKpCodes = $this->getGradeKnowledgePoints($grade, $textbookId);
-            if (!empty($gradeKpCodes)) {
-                $query->whereIn('kp_code', $gradeKpCodes);
-                Log::info('getSupplementaryQuestionsForGrade: 应用知识点范围限制', [
-                    'kp_codes_count' => count($gradeKpCodes),
-                    'textbook_id' => $textbookId
-                ]);
-            } else {
-                Log::warning('getSupplementaryQuestionsForGrade: 未找到年级知识点,跳过补充', [
-                    'grade' => $grade,
-                    'textbook_id' => $textbookId
-                ]);
-                return [];
+            $query->whereIn('kp_code', $gradeKpCodes);
+
+            // 【新增】仅从同教材前章节补充:部分章节尚未学过,不补充未学章节的题目
+            if ($textbookId && !empty($textbookCatalogNodeIds)) {
+                $allowedNodeIds = $this->getEarlierChapterNodeIds((int) $textbookId, $textbookCatalogNodeIds);
+                if (!empty($allowedNodeIds)) {
+                    $query->whereIn('textbook_catalog_nodes_id', $allowedNodeIds);
+                    Log::info('getSupplementaryQuestionsForGrade: 限制为前章节', [
+                        'allowed_node_count' => count($allowedNodeIds),
+                        'max_sort_order' => '同选中章节及之前'
+                    ]);
+                } else {
+                    Log::warning('getSupplementaryQuestionsForGrade: 未找到前章节节点,跳过补充');
+                    return [];
+                }
             }
 
             // 筛选有解题思路的题目
@@ -3158,6 +3210,53 @@ class LearningAnalyticsService
         }
     }
 
+    /**
+     * 获取同教材中「当前选中章节及之前」的节点 ID 列表(用于前章节补充,避免未学章节)
+     *
+     * @param int $textbookId 教材ID
+     * @param array $chapterNodeIds 当前选中的章节节点ID
+     * @return array 允许的节点ID列表(sort_order <= 选中章节的最大 sort_order)
+     */
+    private function getEarlierChapterNodeIds(int $textbookId, array $chapterNodeIds): array
+    {
+        if (empty($chapterNodeIds)) {
+            return [];
+        }
+        try {
+            $maxSortOrder = DB::table('textbook_catalog_nodes')
+                ->where('textbook_id', $textbookId)
+                ->whereIn('id', $chapterNodeIds)
+                ->max('sort_order');
+
+            if ($maxSortOrder === null) {
+                Log::warning('getEarlierChapterNodeIds: 未找到选中章节的 sort_order', [
+                    'textbook_id' => $textbookId,
+                    'chapter_node_ids' => $chapterNodeIds
+                ]);
+                return [];
+            }
+
+            $allowedNodeIds = DB::table('textbook_catalog_nodes')
+                ->where('textbook_id', $textbookId)
+                ->where('sort_order', '<=', $maxSortOrder)
+                ->pluck('id')
+                ->toArray();
+
+            Log::info('getEarlierChapterNodeIds: 前章节节点', [
+                'textbook_id' => $textbookId,
+                'max_sort_order' => $maxSortOrder,
+                'allowed_node_count' => count($allowedNodeIds)
+            ]);
+            return $allowedNodeIds;
+        } catch (\Exception $e) {
+            Log::error('getEarlierChapterNodeIds: 查询失败', [
+                'textbook_id' => $textbookId,
+                'error' => $e->getMessage()
+            ]);
+            return [];
+        }
+    }
+
     /**
      * 获取指定年级的知识点列表
      *
@@ -3165,6 +3264,55 @@ class LearningAnalyticsService
      * @param int|null $textbookId 教材ID(可选,用于限制在特定教材范围内,避免超纲)
      * @return array 知识点代码列表
      */
+    /**
+     * 获取学生「已学知识点」:通过该学生做过的题目(paper_questions)对应的 kp_code 推断
+     * 用于无教材信息时,智能补充仅从已学知识点中选,避免出现未学内容
+     *
+     * @param string|null $studentId 学生ID
+     * @return array 知识点代码列表(去重、非空)
+     */
+    private function getStudentLearnedKpCodes(?string $studentId): array
+    {
+        if (empty($studentId)) {
+            return [];
+        }
+        try {
+            $questionBankIds = \App\Models\PaperQuestion::query()
+                ->whereHas('paper', fn($q) => $q->where('student_id', $studentId))
+                ->pluck('question_bank_id')
+                ->unique()
+                ->filter()
+                ->values()
+                ->toArray();
+
+            if (empty($questionBankIds)) {
+                Log::info('getStudentLearnedKpCodes: 学生暂无做题记录', ['student_id' => $studentId]);
+                return [];
+            }
+
+            $kpCodes = \App\Models\Question::query()
+                ->whereIn('id', $questionBankIds)
+                ->whereNotNull('kp_code')
+                ->where('kp_code', '!=', '')
+                ->pluck('kp_code')
+                ->unique()
+                ->values()
+                ->toArray();
+
+            Log::info('getStudentLearnedKpCodes: 已学知识点', [
+                'student_id' => $studentId,
+                'kp_count' => count($kpCodes),
+            ]);
+            return $kpCodes;
+        } catch (\Exception $e) {
+            Log::error('getStudentLearnedKpCodes: 查询失败', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage(),
+            ]);
+            return [];
+        }
+    }
+
     private function getGradeKnowledgePoints(int $grade, ?int $textbookId = null): array
     {
         try {

+ 143 - 0
docs/组卷逻辑修复总结_本分支.md

@@ -0,0 +1,143 @@
+# 组卷逻辑修复总结(本分支)
+
+## 一、修改概览
+
+| 文件 | 修改类型 | 说明 |
+|------|----------|------|
+| `app/Services/ExamTypeStrategy.php` | 组卷去重、参数传递 | 排除已做题、grade 推断 |
+| `app/Services/LearningAnalyticsService.php` | 智能补充、已学知识点 | 补充范围、totalNeeded、新方法 |
+
+---
+
+## 二、具体改动清单
+
+### 2.1 ExamTypeStrategy.php
+
+#### ① 组卷去重:`getStudentAnsweredQuestionIds`
+
+**原逻辑**:从 `student_answer_questions` 表按 `student_id` 查 `question_id`  
+**新逻辑**:从 `paper_questions` 表,按该学生名下试卷的 `question_bank_id` 去重
+
+```php
+// 唯一来源:paper_questions → question_bank_id(对应 questions.id)
+// 确保新卷子不出现学生做过的重复题目
+PaperQuestion::query()
+    ->whereHas('paper', fn ($q) => $q->where('student_id', $studentId))
+    ->whereNotNull('question_bank_id')
+    ->where('question_bank_id', '>', 0)
+    ->distinct()
+    ->pluck('question_bank_id')
+```
+
+**原因**:与组卷实际数据源(`paper_questions`)保持一致,去重更准确。
+
+---
+
+#### ② grade 推断:教材组卷 `buildTextbookAssembleParams`
+
+**新增**:当传入 `textbook_id` 但 `grade` 为空时,从 `textbooks` 表按 `textbook_id` 查询 `grade`
+
+```php
+if ($textbookId && $grade === null) {
+    $grade = DB::table('textbooks')->where('id', $textbookId)->value('grade');
+}
+```
+
+**保证**:`params` 中同时传递 `textbook_id` 和 `grade`,供智能补充使用。
+
+---
+
+#### ③ grade 推断:智能组卷 `buildIntelligentAssembleParams`
+
+**新增**:同上,从教材推断 `grade`,保证智能补充可触发。
+
+---
+
+### 2.2 LearningAnalyticsService.php
+
+#### ① totalNeeded 传参修复(智能补充触发)
+
+**原逻辑**:传入 `$poolLimit`(固定为 0),`totalNeeded` 恒为 0,智能补充条件 `totalNeeded > 0 && count < totalNeeded` 恒不成立  
+**新逻辑**:传入 `$needCount = $totalQuestions - count($priorityQuestions)`,题目不足时可正确触发智能补充
+
+---
+
+#### ② 智能补充范围:`getSupplementaryQuestionsForGrade`
+
+**补充范围策略**:
+
+| 场景 | 补充来源 | 说明 |
+|------|----------|------|
+| 有 `textbookId` | `getGradeKnowledgePoints(grade, textbookId)` | 同教材知识点 |
+| 有 `textbookId` + `textbookCatalogNodeIds` | 上述 + `getEarlierChapterNodeIds` | 仅同教材前章节 |
+| 无 `textbookId` + 有 `studentId` | `getStudentLearnedKpCodes(studentId)` | 仅学生已学知识点 |
+| 无 `textbookId` + 无 `studentId` | 不补充 | 无法确定学过的内容 |
+
+---
+
+#### ③ 新增方法
+
+- **`getStudentLearnedKpCodes(?string $studentId)`**  
+  从 `paper_questions` + `Paper` + `Question` 获取该学生做过的题目的 `kp_code`,作为「已学知识点」。
+
+- **`getEarlierChapterNodeIds(int $textbookId, array $chapterNodeIds)`**  
+  按 `sort_order <= max(选中章节)` 筛选同教材节点,用于「前章节补充」。
+
+---
+
+#### ④ 新增参数
+
+`getSupplementaryQuestionsForGrade` 新增参数:
+
+- `excludeQuestionIds`:排除学生已做题目,避免重复
+- `textbookCatalogNodeIds`:教材组卷时限制为前章节
+- `studentId`:无教材时获取已学知识点
+
+---
+
+## 三、潜在风险与应对
+
+| 风险点 | 分析 | 结论 |
+|--------|------|------|
+| **新学生无做题记录** | `getStudentLearnedKpCodes` 返回 [],`gradeKpCodes` 为空,不补充 | ✅ 正确:宁可少题,不补未学内容 |
+| **无教材且无 studentId** | 如教师预览卷子,不补充 | ✅ 正确:无法确定学过的内容时不补充 |
+| **textbooks 表无 grade** | `value('grade')` 返回 null,`grade` 为 null,智能补充不触发 | ⚠️ 依赖数据:需保证教材配置完整 |
+| **paper_questions.question_bank_id 为空** | `getStudentAnsweredQuestionIds` 已过滤 `whereNotNull` 且 `> 0` | ✅ 已处理 |
+| **poolLimit 改为 needCount** | 原 `poolLimit=0` 导致智能补充从不触发;现传入实际需要数量 | ✅ 修复正确 |
+| **getStudentAnsweredQuestionIds 的 $kpCodes 参数** | 仍保留参数,内部已不再使用 | ✅ 兼容调用方,无影响 |
+
+---
+
+## 四、数据流校验
+
+```
+组卷请求
+  → ExamTypeStrategy::buildParams
+    → getStudentAnsweredQuestionIds(studentId) → exclude_question_ids
+    → 教材组卷:grade 推断、textbook_id、textbook_catalog_node_ids
+    → 智能组卷:grade 推断
+  → LearningAnalyticsService::generateIntelligentExam
+    → getQuestionsFromBank(kpCodes, ..., excludeQuestionIds, textbookCatalogNodeIds, grade, textbookId)
+      → 主查询:whereNotIn('id', excludeQuestionIds)
+      → 不足时:getSupplementaryQuestionsForGrade(grade, existingKps, deficit, ..., textbookId, excludeQuestionIds, textbookCatalogNodeIds, studentId)
+        → 有教材:getGradeKnowledgePoints + getEarlierChapterNodeIds(若有章节)
+        → 无教材:getStudentLearnedKpCodes(studentId)
+      → whereNotIn('id', excludeQuestionIds) 再次排除
+```
+
+---
+
+## 五、验证结论
+
+1. **去重**:paper_132736368400673、paper_132736388400529、paper_132736538400759、paper_132736648500556 均无重复出题。
+2. **智能补充**:paper_132736648500556(修复后)仅从学生已学 31 个知识点补充,无 10/12 年级或未学章节内容。
+3. **教材组卷**:`textbook_id` 和 `textbookCatalogNodeIds` 传递正确,前章节补充逻辑生效。
+4. **totalNeeded**:题目不足时可正常触发智能补充。
+
+---
+
+## 六、建议后续关注
+
+1. **教材配置**:确认 `textbooks.grade` 有值,避免 grade 推断失败。
+2. **新学生首卷**:无做题记录时可能题量不足,可在产品侧提示或放宽策略(若业务允许)。
+3. **题型分布**:智能补充后填空可能偏少(取决于已学 KP 下填空题目数量),可视情况调整题型池或策略。