소스 검색

feat(kp-assemble): 知识点组卷按直接父节点扩展整棵子树

- assemble_type=2 时将 kp_code_list 扩展为各选中 KP 的直接父节点在 knowledge_points 中的整棵后代(含父),以便同一章节下兄弟 KP 题目一并参与选题
- 新增配置 question_bank.kp_assemble_include_parent_subtree 及环境变量 KP_ASSEMBLE_INCLUDE_PARENT_SUBTREE
- 保留 kp_code_list_original 供排查

Made-with: Cursor
yemeishu 4 일 전
부모
커밋
d99ef10e49
2개의 변경된 파일103개의 추가작업 그리고 3개의 파일을 삭제
  1. 91 3
      app/Services/ExamTypeStrategy.php
  2. 12 0
      config/question_bank.php

+ 91 - 3
app/Services/ExamTypeStrategy.php

@@ -962,20 +962,32 @@ class ExamTypeStrategy
         $kpCodeList = array_values(array_unique(array_filter($kpCodeList)));
         $studentId = $params['student_id'] ?? null;
         $totalQuestions = $params['total_questions'] ?? 20;
+        $assembleType = (int) ($params['assemble_type'] ?? 4);
 
         if (empty($kpCodeList)) {
             Log::warning('ExamTypeStrategy: 知识点组卷需要 kp_code_list 参数');
             return $this->buildGeneralParams($params);
         }
 
+        $kpCodesForPool = $kpCodeList;
+        if ($assembleType === 2 && filter_var(config('question_bank.kp_assemble_include_parent_subtree', true), FILTER_VALIDATE_BOOLEAN)) {
+            $kpCodesForPool = $this->expandKpCodesWithParentChapterSubtrees($kpCodeList);
+            Log::info('ExamTypeStrategy: 知识点组卷已按父节点扩展整棵子树 KP', [
+                'original_kp_codes' => $kpCodeList,
+                'expanded_kp_codes' => $kpCodesForPool,
+                'original_count' => count($kpCodeList),
+                'expanded_count' => count($kpCodesForPool),
+            ]);
+        }
+
         Log::debug('ExamTypeStrategy: 知识点组卷', [
             'kp_count' => count($kpCodeList),
+            'kp_pool_count' => count($kpCodesForPool),
             'total_questions' => $totalQuestions
         ]);
 
         // assemble_type=2 走知识点扩展策略(复用 QuestionExpansionService)。
         // 只在基础题池不足时,才会触发子知识点补充(由扩展策略内部控制)。
-        $assembleType = (int) ($params['assemble_type'] ?? 4);
         $priorityQuestionIds = [];
         $basePoolCount = 0;
         $finalPoolCount = 0;
@@ -985,7 +997,7 @@ class ExamTypeStrategy
             $expansionStrategy = $this->questionExpansionService->expandQuestionsByKnowledgePoints(
                 $params,
                 $studentId ? (string) $studentId : null,
-                $kpCodeList,
+                $kpCodesForPool,
                 [],
                 (int) $totalQuestions
             );
@@ -1007,13 +1019,15 @@ class ExamTypeStrategy
 
         Log::debug('ExamTypeStrategy: 知识点组卷题池评估', [
             'kp_count' => count($kpCodeList),
+            'kp_pool_count' => count($kpCodesForPool),
             'pool_count' => $finalPoolCount,
             'exclude_count' => count($answeredQuestionIds),
         ]);
 
         // 组装增强参数
         $enhanced = array_merge($params, [
-            'kp_codes' => $kpCodeList,
+            'kp_codes' => $kpCodesForPool,
+            'kp_code_list_original' => $kpCodeList,
             'mistake_question_ids' => $basePoolCount < (int) $totalQuestions ? $priorityQuestionIds : [],
             'exclude_question_ids' => $answeredQuestionIds,
             'paper_name' => $params['paper_name'] ?? ('知识点组题_' . now()->format('Ymd_His')),
@@ -1034,12 +1048,86 @@ class ExamTypeStrategy
 
         Log::debug('ExamTypeStrategy: 知识点组卷参数构建完成', [
             'kp_count' => count($kpCodeList),
+            'kp_pool_count' => count($kpCodesForPool),
             'exclude_count' => count($answeredQuestionIds),
         ]);
 
         return $enhanced;
     }
 
+    /**
+     * 将用户选中的 kp 扩展为:各选中知识点在 knowledge_points 中「直接父节点」下的整棵子树(含父节点自身及所有后代)之并集。
+     * 多选且同属同一父章节(如 SIM01 下的 SIM01B/C/D)时,distinct parent 仅为 SIM01,等价于纳入 SIM01 整棵树(SIM01A–E 等)的全部 kp_code。
+     * 不在此再上溯祖父:祖父在库中往往挂在更高层(如整册根),会造成超范围并题。
+     *
+     * @return array<string>
+     */
+    private function expandKpCodesWithParentChapterSubtrees(array $kpCodes): array
+    {
+        $kpCodes = array_values(array_unique(array_filter($kpCodes)));
+        if ($kpCodes === []) {
+            return [];
+        }
+
+        $expanded = [];
+        foreach ($kpCodes as $kp) {
+            $expanded[] = $kp;
+        }
+
+        $distinctParents = [];
+        foreach ($kpCodes as $kp) {
+            $p = KnowledgePoint::query()->where('kp_code', $kp)->value('parent_kp_code');
+            if ($p !== null && $p !== '') {
+                $distinctParents[(string) $p] = true;
+            }
+        }
+
+        foreach (array_keys($distinctParents) as $parentKp) {
+            $expanded = array_merge($expanded, $this->getDescendantKpCodesIncludingRoot($parentKp));
+        }
+
+        return array_values(array_unique(array_filter($expanded)));
+    }
+
+    /**
+     * BFS 收集某 kp 节点在 knowledge_points 中的整棵后代子树(含根节点 kp_code)。
+     *
+     * @return array<string>
+     */
+    private function getDescendantKpCodesIncludingRoot(string $rootKpCode): array
+    {
+        $rootKpCode = trim($rootKpCode);
+        if ($rootKpCode === '') {
+            return [];
+        }
+
+        $seen = [$rootKpCode => true];
+        $queue = [$rootKpCode];
+        $guard = 0;
+        $maxIter = 5000;
+
+        while ($queue !== [] && $guard++ < $maxIter) {
+            $cur = array_shift($queue);
+            $children = KnowledgePoint::query()
+                ->where('parent_kp_code', $cur)
+                ->pluck('kp_code')
+                ->all();
+
+            foreach ($children as $child) {
+                $c = (string) $child;
+                if ($c === '') {
+                    continue;
+                }
+                if (! isset($seen[$c])) {
+                    $seen[$c] = true;
+                    $queue[] = $c;
+                }
+            }
+        }
+
+        return array_keys($seen);
+    }
+
     /**
      * 教材组卷 (assembleType=3)
      * 根据 chapter_id_list 查询课本章节,获取知识点,然后组卷

+ 12 - 0
config/question_bank.php

@@ -10,4 +10,16 @@ return [
     'retry_delay' => (int) env('QUESTION_BANK_RETRY_DELAY', 200),
 
     'mode' => env('QUESTION_BANK_MODE', 'local'),
+
+    /** 知识点题量统计:教材章节顺序用哪一册(textbooks.semester,默认 2=下学期) */
+    'kp_stats_semester' => (int) env('KP_STATS_SEMESTER', 2),
+
+    /**
+     * 知识点组卷 assemble_type=2:将 kp_code_list 扩展为「各选中知识点的直接父节点」在 knowledge_points 中的整棵子树并集,
+     * 以便同一父章节(如 SIM01)下兄弟知识点(SIM01A–E)的题目一并参与选题。
+     */
+    'kp_assemble_include_parent_subtree' => filter_var(
+        env('KP_ASSEMBLE_INCLUDE_PARENT_SUBTREE', true),
+        FILTER_VALIDATE_BOOLEAN
+    ),
 ];