2
0

2 Commits 95eceb8937 ... 44b86073f7

Autor SHA1 Mensagem Data
  yemeishu 44b86073f7 feat(kp-assemble): 本源优先、父树 KP 二次补题与合并去重 há 4 dias atrás
  yemeishu d99ef10e49 feat(kp-assemble): 知识点组卷按直接父节点扩展整棵子树 há 4 dias atrás

+ 92 - 1
app/Services/ExamTypeStrategy.php

@@ -962,20 +962,34 @@ 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);
         }
 
+        // 本源选题用原始 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,
+                'supplement_kp_codes' => $kpSupplementSubtreeCodes,
+                'original_count' => count($kpCodeList),
+                'supplement_count' => count($kpSupplementSubtreeCodes),
+            ]);
+        }
+
         Log::debug('ExamTypeStrategy: 知识点组卷', [
             'kp_count' => count($kpCodeList),
+            'supplement_kp_count' => count($kpSupplementSubtreeCodes),
             'total_questions' => $totalQuestions
         ]);
 
         // assemble_type=2 走知识点扩展策略(复用 QuestionExpansionService)。
         // 只在基础题池不足时,才会触发子知识点补充(由扩展策略内部控制)。
-        $assembleType = (int) ($params['assemble_type'] ?? 4);
         $priorityQuestionIds = [];
         $basePoolCount = 0;
         $finalPoolCount = 0;
@@ -1007,6 +1021,7 @@ class ExamTypeStrategy
 
         Log::debug('ExamTypeStrategy: 知识点组卷题池评估', [
             'kp_count' => count($kpCodeList),
+            'supplement_kp_count' => count($kpSupplementSubtreeCodes),
             'pool_count' => $finalPoolCount,
             'exclude_count' => count($answeredQuestionIds),
         ]);
@@ -1014,6 +1029,8 @@ class ExamTypeStrategy
         // 组装增强参数
         $enhanced = array_merge($params, [
             'kp_codes' => $kpCodeList,
+            'kp_supplement_subtree_codes' => $kpSupplementSubtreeCodes,
+            '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 +1051,86 @@ class ExamTypeStrategy
 
         Log::debug('ExamTypeStrategy: 知识点组卷参数构建完成', [
             'kp_count' => count($kpCodeList),
+            'supplement_kp_count' => count($kpSupplementSubtreeCodes),
             '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 查询课本章节,获取知识点,然后组卷

+ 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();
+    }
+
     /**
      * 从题库获取题目
      *

+ 3 - 0
config/question_bank.php

@@ -10,4 +10,7 @@ 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),
 ];