فهرست منبع

feat(kp-assemble): 本源优先、父树 KP 二次补题与合并去重

assemble_type=2 时扩展参数仍用本源 kp_codes;本源与主库补充合并后仍不足且存在 kp_supplement_subtree_codes 时从父章子树拉题。合并结果按 question_bank_id 去重。移除 question_bank 中 KP 组装 env 开关(固定启用父树补题逻辑)。

Made-with: Cursor
yemeishu 4 روز پیش
والد
کامیت
44b86073f7
3فایلهای تغییر یافته به همراه143 افزوده شده و 21 حذف شده
  1. 14 11
      app/Services/ExamTypeStrategy.php
  2. 129 1
      app/Services/LearningAnalyticsService.php
  3. 0 9
      config/question_bank.php

+ 14 - 11
app/Services/ExamTypeStrategy.php

@@ -969,20 +969,22 @@ class ExamTypeStrategy
             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', [
+        // 本源选题用原始 kp;父节点整树仅作为「本源不足时」的补题 KP(见 kp_supplement_subtree_codes → LearningAnalyticsService)
+        $kpSupplementSubtreeCodes = [];
+        if ($assembleType === 2) {
+            $expandedKpCodes = $this->expandKpCodesWithParentChapterSubtrees($kpCodeList);
+            $kpSupplementSubtreeCodes = array_values(array_diff($expandedKpCodes, $kpCodeList));
+            Log::info('ExamTypeStrategy: 知识点组卷父树仅用于补题 KP(本源优先)', [
                 'original_kp_codes' => $kpCodeList,
-                'expanded_kp_codes' => $kpCodesForPool,
+                'supplement_kp_codes' => $kpSupplementSubtreeCodes,
                 'original_count' => count($kpCodeList),
-                'expanded_count' => count($kpCodesForPool),
+                'supplement_count' => count($kpSupplementSubtreeCodes),
             ]);
         }
 
         Log::debug('ExamTypeStrategy: 知识点组卷', [
             'kp_count' => count($kpCodeList),
-            'kp_pool_count' => count($kpCodesForPool),
+            'supplement_kp_count' => count($kpSupplementSubtreeCodes),
             'total_questions' => $totalQuestions
         ]);
 
@@ -997,7 +999,7 @@ class ExamTypeStrategy
             $expansionStrategy = $this->questionExpansionService->expandQuestionsByKnowledgePoints(
                 $params,
                 $studentId ? (string) $studentId : null,
-                $kpCodesForPool,
+                $kpCodeList,
                 [],
                 (int) $totalQuestions
             );
@@ -1019,14 +1021,15 @@ class ExamTypeStrategy
 
         Log::debug('ExamTypeStrategy: 知识点组卷题池评估', [
             'kp_count' => count($kpCodeList),
-            'kp_pool_count' => count($kpCodesForPool),
+            'supplement_kp_count' => count($kpSupplementSubtreeCodes),
             'pool_count' => $finalPoolCount,
             'exclude_count' => count($answeredQuestionIds),
         ]);
 
         // 组装增强参数
         $enhanced = array_merge($params, [
-            'kp_codes' => $kpCodesForPool,
+            'kp_codes' => $kpCodeList,
+            'kp_supplement_subtree_codes' => $kpSupplementSubtreeCodes,
             'kp_code_list_original' => $kpCodeList,
             'mistake_question_ids' => $basePoolCount < (int) $totalQuestions ? $priorityQuestionIds : [],
             'exclude_question_ids' => $answeredQuestionIds,
@@ -1048,7 +1051,7 @@ class ExamTypeStrategy
 
         Log::debug('ExamTypeStrategy: 知识点组卷参数构建完成', [
             'kp_count' => count($kpCodeList),
-            'kp_pool_count' => count($kpCodesForPool),
+            'supplement_kp_count' => count($kpSupplementSubtreeCodes),
             'exclude_count' => count($answeredQuestionIds),
         ]);
 

+ 129 - 1
app/Services/LearningAnalyticsService.php

@@ -1442,7 +1442,37 @@ class LearningAnalyticsService
                         $textbookId,                    // 教材ID(避免超纲)
                         $difficultyCategory             // 难度类别
                     );
-                    $allQuestions = array_merge($priorityQuestions, $additionalQuestions);
+                    $allQuestions = $this->dedupeQuestionsByBankId(array_merge($priorityQuestions, $additionalQuestions));
+
+                    // assemble_type=2:本源 KP 合并后仍不足,用父树补题 KP 拉题(与 getQuestionsFromBank 主条件一致,走专用列表,不混入本源查询)
+                    if (
+                        $assembleType === 2
+                        && count($allQuestions) < $totalQuestions
+                        && ! empty($params['kp_supplement_subtree_codes'] ?? [])
+                        && $grade !== null
+                    ) {
+                        $excludeForSupp = array_values(array_unique(array_filter(array_merge(
+                            $excludeQuestionIds,
+                            array_column($allQuestions, 'id')
+                        ))));
+                        $supp = $this->fetchQuestionsForKpAssembleSupplement(
+                            $params['kp_supplement_subtree_codes'],
+                            $excludeForSupp,
+                            (int) $grade,
+                            $skills,
+                            $questionCategory,
+                            $textbookCatalogNodeIds
+                        );
+                        $before = count($allQuestions);
+                        $allQuestions = $this->dedupeQuestionsByBankId(array_merge($allQuestions, $supp));
+                        Log::info('LearningAnalyticsService: 知识点组卷父树补题(本源池不足后)', [
+                            'source_merged_count' => $before,
+                            'supplement_fetched' => count($supp),
+                            'after_merge_count' => count($allQuestions),
+                            'target_total' => $totalQuestions,
+                            'supplement_kp_sample' => array_slice($params['kp_supplement_subtree_codes'], 0, 12),
+                        ]);
+                    }
 
                     Log::info('getQuestionsFromBank 完成', [
                         'questions_count' => count($allQuestions),
@@ -1588,6 +1618,104 @@ class LearningAnalyticsService
         }
     }
 
+    /**
+     * @param  array<int, array<string, mixed>>  $questions
+     * @return array<int, array<string, mixed>>
+     */
+    private function dedupeQuestionsByBankId(array $questions): array
+    {
+        $seen = [];
+        $out = [];
+        foreach ($questions as $q) {
+            $id = $q['id'] ?? null;
+            if ($id === null || $id === '') {
+                continue;
+            }
+            $k = (string) $id;
+            if (isset($seen[$k])) {
+                continue;
+            }
+            $seen[$k] = true;
+            $out[] = $q;
+        }
+
+        return $out;
+    }
+
+    /**
+     * 知识点组卷补题:仅按补充 KP 列表查询,筛选条件与 getQuestionsFromBank 主查询一致(不调用教材/已学那条智能补题)。
+     *
+     * @param  array<string>  $kpCodes
+     * @param  array<int|string>  $excludeQuestionIds
+     * @return array<int, array<string, mixed>>
+     */
+    private function fetchQuestionsForKpAssembleSupplement(
+        array $kpCodes,
+        array $excludeQuestionIds,
+        int $grade,
+        array $skills = [],
+        ?int $questionCategory = null,
+        ?array $textbookCatalogNodeIds = null
+    ): array {
+        $kpCodes = array_values(array_unique(array_filter($kpCodes)));
+        if ($kpCodes === []) {
+            return [];
+        }
+
+        $query = \App\Models\Question::query()
+            ->where('audit_status', 0)
+            ->whereIn('kp_code', $kpCodes);
+
+        $stageGrade = $this->normalizeQuestionStageGrade($grade);
+        if ($stageGrade !== null) {
+            $query->where('grade', $stageGrade);
+        }
+
+        if (! empty($skills)) {
+            $query->where(function ($q) use ($skills) {
+                foreach ($skills as $skill) {
+                    $q->orWhere('tags', 'like', '%'.$skill.'%');
+                }
+            });
+        }
+
+        if (! empty($excludeQuestionIds)) {
+            $query->whereNotIn('id', $excludeQuestionIds);
+        }
+
+        if ($questionCategory !== null) {
+            $query->where('question_category', $questionCategory);
+        }
+
+        $query->whereNotNull('solution')
+            ->where('solution', '!=', '')
+            ->where('solution', '!=', '[]');
+
+        if (! empty($textbookCatalogNodeIds)) {
+            $query->whereIn('textbook_catalog_nodes_id', $textbookCatalogNodeIds);
+        }
+
+        return $query->inRandomOrder()->get()->map(function ($q) {
+            return [
+                'id' => $q->id,
+                'question_code' => $q->question_code,
+                'kp_code' => $q->kp_code,
+                'question_type' => $q->question_type,
+                'difficulty' => $q->difficulty !== null ? (float) $q->difficulty : 0.5,
+                'stem' => $q->stem,
+                'solution' => $q->solution,
+                '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 ?? 0.5),
+                    'question_type_label' => $this->getQuestionTypeLabel($q->question_type),
+                ],
+            ];
+        })->toArray();
+    }
+
     /**
      * 从题库获取题目
      *

+ 0 - 9
config/question_bank.php

@@ -13,13 +13,4 @@ return [
 
     /** 知识点题量统计:教材章节顺序用哪一册(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
-    ),
 ];