Преглед на файлове

feat: 难度分布步骤改为题型感知,同时满足题型比例和难度分布

原难度分布步骤从整个题池按难度分桶随机选题,完全忽略题型比例,
导致解答题(数量占主导)被大量选入。新增 applyTypeAwareDifficultyDistribution
方法,在每种题型内部分别应用难度分布,确保最终结果同时满足
question_type_ratio 和 difficulty_category 两个约束。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
gwd преди 1 месец
родител
ревизия
c2f36208eb
променени са 1 файла, в които са добавени 176 реда и са изтрити 21 реда
  1. 176 21
      app/Services/LearningAnalyticsService.php

+ 176 - 21
app/Services/LearningAnalyticsService.php

@@ -1571,40 +1571,29 @@ class LearningAnalyticsService
             ]);
 
             if ($enableDistribution && !$isExcludedType) {
-                Log::info('LearningAnalyticsService: 应用难度系数分布', [
-                    'difficulty_category_before' => $difficultyCategory,
+                Log::info('LearningAnalyticsService: 应用题型感知的难度分布', [
+                    'difficulty_category' => $difficultyCategory,
                     'assemble_type' => $assembleType,
-                    'before_count' => count($selectedQuestions)
+                    'before_count' => count($selectedQuestions),
+                    'question_type_ratio' => $questionTypeRatio,
                 ]);
 
                 try {
-                    // 使用 ExamTypeStrategy 的独立方法应用难度分布
-                    $questionExpansionService = $this->questionExpansionService ?? app(QuestionExpansionService::class);
-                    $examStrategy = new ExamTypeStrategy($questionExpansionService);
-
-                    $distributionCandidates = $this->buildDistributionCandidates(
+                    $selectedQuestions = $this->applyTypeAwareDifficultyDistribution(
                         $allQuestions,
                         $selectedQuestions,
-                        (int) $difficultyCategory
-                    );
-
-                    $selectedQuestions = $examStrategy->applyDifficultyDistributionToQuestions(
-                        $distributionCandidates,
                         $totalQuestions,
-                        $difficultyCategory,
-                        $params
+                        (int) $difficultyCategory,
+                        $questionTypeRatio
                     );
 
-                    Log::info('LearningAnalyticsService: 难度分布应用完成', [
-                        'difficulty_category_after' => $difficultyCategory,
-                        'before_count' => count($distributionCandidates),
+                    Log::info('LearningAnalyticsService: 题型感知难度分布完成', [
                         'after_count' => count($selectedQuestions),
-                        'success' => count($selectedQuestions) >= $totalQuestions
+                        'type_breakdown' => $this->countByType($selectedQuestions),
                     ]);
                 } catch (\Exception $e) {
-                    Log::warning('LearningAnalyticsService: 难度分布应用失败,继续使用原结果', [
+                    Log::warning('LearningAnalyticsService: 题型感知难度分布失败,继续使用原结果', [
                         'error' => $e->getMessage(),
-                        'difficulty_category_when_error' => $difficultyCategory
                     ]);
                 }
             }
@@ -3317,6 +3306,172 @@ class LearningAnalyticsService
         return $shortage;
     }
 
+    /**
+     * 题型感知的难度分布选题
+     * 在每种题型内部应用难度分布,确保题型比例和难度分布同时被满足
+     */
+    private function applyTypeAwareDifficultyDistribution(
+        array $allQuestions,
+        array $selectedQuestions,
+        int $totalQuestions,
+        int $difficultyCategory,
+        array $questionTypeRatio
+    ): array {
+        $diffService = app(DifficultyDistributionService::class);
+        $distribution = $diffService->calculateDistribution($difficultyCategory, $totalQuestions);
+
+        // 1. 计算每种题型的目标数量
+        $ratioMap = ['choice' => '选择题', 'fill' => '填空题', 'answer' => '解答题'];
+        $totalRatio = array_sum($questionTypeRatio) ?: 100;
+        $typeTargets = [];
+        $fractions = [];
+        $allocated = 0;
+
+        foreach ($ratioMap as $type => $ratioKey) {
+            $ratio = $questionTypeRatio[$ratioKey] ?? 0;
+            $exact = $totalQuestions * $ratio / $totalRatio;
+            $typeTargets[$type] = (int) floor($exact);
+            $fractions[$type] = $exact - floor($exact);
+            $allocated += $typeTargets[$type];
+        }
+
+        $remainder = $totalQuestions - $allocated;
+        arsort($fractions);
+        foreach ($fractions as $type => $frac) {
+            if ($remainder <= 0) break;
+            $typeTargets[$type]++;
+            $remainder--;
+        }
+
+        // 2. 构建候选题池(合并 allQuestions + selectedQuestions 去重)
+        $candidatePool = [];
+        $seen = [];
+        foreach (array_merge($allQuestions, $selectedQuestions) as $q) {
+            $id = $q['id'] ?? null;
+            if ($id && !isset($seen[$id])) {
+                $seen[$id] = true;
+                $candidatePool[] = $q;
+            }
+        }
+
+        // 3. 按题型分组候选题
+        $candidatesByType = ['choice' => [], 'fill' => [], 'answer' => []];
+        foreach ($candidatePool as $q) {
+            $qType = $q['question_type'] ?? '';
+            if (isset($candidatesByType[$qType])) {
+                $candidatesByType[$qType][] = $q;
+            }
+        }
+
+        // 4. 在每种题型内按难度分桶选题
+        $result = [];
+        $usedIds = [];
+        $typeActual = [];
+
+        foreach ($ratioMap as $type => $ratioKey) {
+            $target = $typeTargets[$type];
+            if ($target <= 0) {
+                $typeActual[$type] = 0;
+                continue;
+            }
+
+            $typeCandidates = $candidatesByType[$type];
+            $buckets = $diffService->groupQuestionsByDifficultyRange($typeCandidates, $difficultyCategory);
+
+            // 按 distribution 比例在该题型内选题
+            $typeSelected = [];
+            foreach ($distribution as $level => $config) {
+                $levelRatio = $config['percentage'];
+                $levelTarget = max(0, (int) round($target * $levelRatio / 100));
+                if ($levelTarget <= 0) continue;
+
+                $rangeKey = $diffService->mapDifficultyLevelToRangeKey($level, $difficultyCategory);
+                $bucket = $buckets[$rangeKey] ?? [];
+                shuffle($bucket);
+
+                $taken = 0;
+                foreach ($bucket as $q) {
+                    if ($taken >= $levelTarget) break;
+                    $qid = $q['id'] ?? null;
+                    if ($qid && !isset($usedIds[$qid])) {
+                        $typeSelected[] = $q;
+                        $usedIds[$qid] = true;
+                        $taken++;
+                    }
+                }
+            }
+
+            // 如果难度分布不满足目标数,从该题型剩余题目补充
+            if (count($typeSelected) < $target) {
+                $allTypeBuckets = array_merge(
+                    $buckets['primary_medium'] ?? [],
+                    $buckets['primary_low'] ?? [],
+                    $buckets['primary_high'] ?? [],
+                    $buckets['secondary'] ?? [],
+                    $buckets['other'] ?? []
+                );
+                shuffle($allTypeBuckets);
+                foreach ($allTypeBuckets as $q) {
+                    if (count($typeSelected) >= $target) break;
+                    $qid = $q['id'] ?? null;
+                    if ($qid && !isset($usedIds[$qid])) {
+                        $typeSelected[] = $q;
+                        $usedIds[$qid] = true;
+                    }
+                }
+            }
+
+            $typeActual[$type] = count($typeSelected);
+            $result = array_merge($result, $typeSelected);
+        }
+
+        // 5. 如果总数不足(某题型候选不够),跨题型补充
+        if (count($result) < $totalQuestions) {
+            $deficit = $totalQuestions - count($result);
+            // 按 fill > choice > answer 优先级补充
+            $supplementOrder = ['fill', 'choice', 'answer'];
+            foreach ($supplementOrder as $type) {
+                if ($deficit <= 0) break;
+                $bucket = $candidatesByType[$type];
+                shuffle($bucket);
+                foreach ($bucket as $q) {
+                    if ($deficit <= 0) break;
+                    $qid = $q['id'] ?? null;
+                    if ($qid && !isset($usedIds[$qid])) {
+                        $result[] = $q;
+                        $usedIds[$qid] = true;
+                        $deficit--;
+                    }
+                }
+            }
+        }
+
+        Log::info('LearningAnalyticsService: 题型感知难度分布详情', [
+            'type_targets' => $typeTargets,
+            'type_actual' => $typeActual,
+            'total_result' => count($result),
+            'difficulty_category' => $difficultyCategory,
+            'candidates_by_type' => array_map('count', $candidatesByType),
+        ]);
+
+        return array_slice($result, 0, $totalQuestions);
+    }
+
+    /**
+     * 统计题目列表中各题型的数量
+     */
+    private function countByType(array $questions): array
+    {
+        $counts = ['choice' => 0, 'fill' => 0, 'answer' => 0];
+        foreach ($questions as $q) {
+            $type = $q['question_type'] ?? '';
+            if (isset($counts[$type])) {
+                $counts[$type]++;
+            }
+        }
+        return $counts;
+    }
+
     private function buildDistributionCandidates(array $allQuestions, array $selectedQuestions, int $difficultyCategory): array
     {
         if (empty($allQuestions)) {