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

fix: 增加 0 基础的难度逻辑

yemeishu преди 1 седмица
родител
ревизия
0800a96529

+ 51 - 5
app/Http/Controllers/Api/IntelligentExamController.php

@@ -68,7 +68,7 @@ class IntelligentExamController extends Controller
             'student_name' => 'required|string|max:50',
             'teacher_name' => 'required|string|max:50',
             'total_questions' => 'nullable|integer|min:1|max:100',
-            'difficulty_category' => 'nullable|integer|in:1,2,3,4',
+            'difficulty_category' => 'nullable|integer|in:0,1,2,3,4',
             'kp_codes' => 'nullable|array',
             'kp_codes.*' => 'string',
             'skills' => 'nullable|array',
@@ -284,6 +284,55 @@ class IntelligentExamController extends Controller
                 ? $this->paperPayloadService->buildExamContent($paperModel)
                 : [];
 
+            $finalStats = $result['stats'] ?? [
+                'total_selected' => count($questions),
+                'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds),
+            ];
+            if (! isset($finalStats['difficulty_category'])) {
+                $finalStats['difficulty_category'] = $difficultyCategory;
+            }
+            if (! isset($finalStats['final_distribution'])) {
+                $distributionService = app(\App\Services\DifficultyDistributionService::class);
+                $finalBuckets = $distributionService->groupQuestionsByDifficultyRange($questions, (int) $difficultyCategory);
+                $finalTotal = max(1, count($questions));
+                $finalStats['final_distribution'] = array_map(static function ($bucket) use ($finalTotal) {
+                    $count = count($bucket);
+                    return [
+                        'count' => $count,
+                        'ratio' => round(($count / $finalTotal) * 100, 2),
+                    ];
+                }, $finalBuckets);
+            }
+            if (! isset($finalStats['final_distribution_shortage'])) {
+                $distributionService = app(\App\Services\DifficultyDistributionService::class);
+                $distribution = $distributionService->calculateDistribution((int) $difficultyCategory, (int) ($data['total_questions'] ?? count($questions)));
+                $buckets = $distributionService->groupQuestionsByDifficultyRange($questions, (int) $difficultyCategory);
+                $expected = [
+                    'primary_low' => 0,
+                    'primary_medium' => 0,
+                    'primary_high' => 0,
+                    'secondary' => 0,
+                    'other' => 0,
+                ];
+                foreach ($distribution as $level => $config) {
+                    $bucketKey = $distributionService->mapDifficultyLevelToRangeKey($level, (int) $difficultyCategory);
+                    $expected[$bucketKey] += (int) ($config['count'] ?? 0);
+                }
+                $actual = array_map(static fn($bucket) => count($bucket), $buckets);
+                $finalStats['final_distribution_shortage'] = array_map(static function ($count, $bucketKey) use ($actual) {
+                    $actualCount = $actual[$bucketKey] ?? 0;
+                    return [
+                        'expected' => $count,
+                        'actual' => $actualCount,
+                        'short' => max(0, $count - $actualCount),
+                    ];
+                }, $expected, array_keys($expected));
+            }
+
+            $this->taskManager->updateTaskStatus($taskId, [
+                'stats' => $finalStats,
+            ]);
+
             // 触发后台PDF生成
             $this->triggerPdfGeneration($taskId, $paperId);
 
@@ -308,10 +357,7 @@ class IntelligentExamController extends Controller
                         'exam_paper_pdf' => null,
                         'grading_pdf' => null,
                     ],
-                    'stats' => $result['stats'] ?? [
-                        'total_selected' => count($questions),
-                        'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds),
-                    ],
+                    'stats' => $finalStats,
                     'created_at' => now()->toISOString(),
                 ],
             ];

+ 219 - 0
app/Services/DifficultyDistributionService.php

@@ -0,0 +1,219 @@
+<?php
+
+namespace App\Services;
+
+class DifficultyDistributionService
+{
+    public function calculateDistribution(int $category, int $totalQuestions): array
+    {
+        switch ($category) {
+            case 0:
+                $mediumPercentage = 90; // 0-0.1
+                $lowPercentage = 0;
+                $highPercentage = 10;   // 0.1-0.25
+                break;
+            case 1:
+                $mediumPercentage = 90; // 0-0.25
+                $lowPercentage = 0;
+                $highPercentage = 10;
+                break;
+            case 2:
+                $mediumPercentage = 50; // 0.25-0.5
+                $lowPercentage = 25;    // <0.25
+                $highPercentage = 25;   // >0.5
+                break;
+            case 3:
+                $mediumPercentage = 50; // 0.5-0.75
+                $lowPercentage = 25;    // <0.5
+                $highPercentage = 25;   // >0.75
+                break;
+            case 4:
+                $mediumPercentage = 50; // 0.75-1
+                $lowPercentage = 25;    // <0.75
+                $highPercentage = 25;   // >0.75
+                break;
+            default:
+                $lowPercentage = 25;
+                $mediumPercentage = 50;
+                $highPercentage = 25;
+        }
+
+        $lowCount = (int) round($totalQuestions * $lowPercentage / 100);
+        $mediumCount = (int) round($totalQuestions * $mediumPercentage / 100);
+        $highCount = $totalQuestions - $lowCount - $mediumCount;
+
+        return [
+            'low' => [
+                'percentage' => $lowPercentage,
+                'count' => $lowCount,
+                'label' => '低级难度'
+            ],
+            'medium' => [
+                'percentage' => $mediumPercentage,
+                'count' => $mediumCount,
+                'label' => '基准难度'
+            ],
+            'high' => [
+                'percentage' => $highPercentage,
+                'count' => $highCount,
+                'label' => '拔高难度'
+            ]
+        ];
+    }
+
+    public function getRanges(int $category): array
+    {
+        switch ($category) {
+            case 0:
+                return [
+                    'primary' => ['min' => 0.0, 'max' => 0.1, 'percentage' => 90],
+                    'secondary' => ['min' => 0.1, 'max' => 0.25, 'percentage' => 10],
+                    'description' => '0基础型:0-0.1占比90%,0.1-0.25占比10%'
+                ];
+            case 1:
+                return [
+                    'primary' => ['min' => 0.0, 'max' => 0.25, 'percentage' => 90],
+                    'secondary' => ['min' => 0.25, 'max' => 1.0, 'percentage' => 10],
+                    'description' => '基础型:0-0.25占比90%,0.25-1占比10%'
+                ];
+            case 2:
+                return [
+                    'primary' => ['min' => 0.25, 'max' => 0.5, 'percentage' => 50],
+                    'low' => ['min' => 0.0, 'max' => 0.25, 'percentage' => 25],
+                    'high' => ['min' => 0.5, 'max' => 1.0, 'percentage' => 25],
+                    'description' => '进阶型:0.25-0.5占比50%,<0.25占比25%,>0.5占比25%'
+                ];
+            case 3:
+                return [
+                    'primary' => ['min' => 0.5, 'max' => 0.75, 'percentage' => 50],
+                    'low' => ['min' => 0.0, 'max' => 0.5, 'percentage' => 25],
+                    'high' => ['min' => 0.75, 'max' => 1.0, 'percentage' => 25],
+                    'description' => '中等型:0.5-0.75占比50%,<0.5占比25%,>0.75占比25%'
+                ];
+            case 4:
+                return [
+                    'primary' => ['min' => 0.75, 'max' => 1.0, 'percentage' => 50],
+                    'secondary' => ['min' => 0.0, 'max' => 0.75, 'percentage' => 50],
+                    'description' => '拔高型:0.75-1占比50%,其他占比50%'
+                ];
+            default:
+                return [
+                    'primary' => ['min' => 0.0, 'max' => 1.0, 'percentage' => 100],
+                    'description' => '默认:全难度范围'
+                ];
+        }
+    }
+
+    public function groupQuestionsByDifficultyRange(array $questions, int $category): array
+    {
+        $buckets = [
+            'primary_low' => [],
+            'primary_medium' => [],
+            'primary_high' => [],
+            'secondary' => [],
+            'other' => []
+        ];
+
+        foreach ($questions as $question) {
+            $difficulty = (float) ($question['difficulty'] ?? 0);
+            $rangeKey = $this->classifyQuestionByDifficulty($difficulty, $category);
+            $buckets[$rangeKey][] = $question;
+        }
+
+        return $buckets;
+    }
+
+    public function classifyQuestionByDifficulty(float $difficulty, int $category): string
+    {
+        switch ($category) {
+            case 0:
+                if ($difficulty >= 0 && $difficulty <= 0.1) {
+                    return 'primary_medium';
+                }
+                if ($difficulty > 0.1 && $difficulty <= 0.25) {
+                    return 'secondary';
+                }
+                return 'other';
+            case 1:
+                if ($difficulty >= 0 && $difficulty <= 0.25) {
+                    return 'primary_medium';
+                }
+                return 'secondary';
+            case 2:
+                if ($difficulty >= 0.25 && $difficulty <= 0.5) {
+                    return 'primary_medium';
+                }
+                if ($difficulty < 0.25) {
+                    return 'primary_low';
+                }
+                return 'primary_high';
+            case 3:
+                if ($difficulty >= 0.5 && $difficulty <= 0.75) {
+                    return 'primary_medium';
+                }
+                if ($difficulty < 0.5) {
+                    return 'primary_low';
+                }
+                return 'primary_high';
+            case 4:
+                if ($difficulty >= 0.75 && $difficulty <= 1.0) {
+                    return 'primary_medium';
+                }
+                return 'secondary';
+            default:
+                return 'other';
+        }
+    }
+
+    public function mapDifficultyLevelToRangeKey(string $level, int $category): string
+    {
+        switch ($category) {
+            case 0:
+                return match($level) {
+                    'low' => 'secondary',
+                    'medium' => 'primary_medium',
+                    'high' => 'secondary',
+                    default => 'secondary'
+                };
+            case 1:
+                return match($level) {
+                    'low' => 'secondary',
+                    'medium' => 'primary_medium',
+                    'high' => 'secondary',
+                    default => 'secondary'
+                };
+            case 2:
+                return match($level) {
+                    'low' => 'primary_low',
+                    'medium' => 'primary_medium',
+                    'high' => 'primary_high',
+                    default => 'other'
+                };
+            case 3:
+                return match($level) {
+                    'low' => 'primary_low',
+                    'medium' => 'primary_medium',
+                    'high' => 'primary_high',
+                    default => 'other'
+                };
+            case 4:
+                return match($level) {
+                    'low' => 'secondary',
+                    'medium' => 'primary_medium',
+                    'high' => 'secondary',
+                    default => 'secondary'
+                };
+            default:
+                return 'other';
+        }
+    }
+
+    public function getSupplementOrder(int $category): array
+    {
+        return match ($category) {
+            0, 1, 4 => ['secondary', 'other'],
+            2, 3 => ['primary_medium', 'primary_low', 'primary_high', 'other'],
+            default => ['other']
+        };
+    }
+}

+ 12 - 100
app/Services/ExamTypeStrategy.php

@@ -15,11 +15,17 @@ class ExamTypeStrategy
 {
     protected QuestionExpansionService $questionExpansionService;
     protected QuestionLocalService $questionLocalService;
+    protected DifficultyDistributionService $difficultyDistributionService;
 
-    public function __construct(QuestionExpansionService $questionExpansionService, QuestionLocalService $questionLocalService = null)
+    public function __construct(
+        QuestionExpansionService $questionExpansionService,
+        QuestionLocalService $questionLocalService = null,
+        DifficultyDistributionService $difficultyDistributionService = null
+    )
     {
         $this->questionExpansionService = $questionExpansionService;
         $this->questionLocalService = $questionLocalService ?? app(QuestionLocalService::class);
+        $this->difficultyDistributionService = $difficultyDistributionService ?? app(DifficultyDistributionService::class);
     }
 
     /**
@@ -109,7 +115,7 @@ class ExamTypeStrategy
         ]);
 
         // 根据难度类别计算题目分布
-        $distribution = $this->calculateDifficultyDistribution($difficultyCategory, $totalQuestions);
+        $distribution = $this->difficultyDistributionService->calculateDistribution($difficultyCategory, $totalQuestions);
 
         // 构建难度分布配置
         $difficultyDistributionConfig = [
@@ -117,7 +123,7 @@ class ExamTypeStrategy
             'category' => $difficultyCategory,
             'total_questions' => $totalQuestions,
             'distribution' => $distribution,
-            'ranges' => $this->getDifficultyRanges($difficultyCategory),
+            'ranges' => $this->difficultyDistributionService->getRanges($difficultyCategory),
             'use_question_local_service' => true, // 标记使用新的独立方法
         ];
 
@@ -173,70 +179,13 @@ class ExamTypeStrategy
     /**
      * 根据难度类别计算题目分布
      *
-     * @param int $category 难度类别 (1-4)
+     * @param int $category 难度类别 (0-4)
      * @param int $totalQuestions 总题目数
      * @return array 分布配置
      */
     private function calculateDifficultyDistribution(int $category, int $totalQuestions): array
     {
-        // 标准化:25% 低级,50% 基准,25% 拔高
-        $lowPercentage = 25;
-        $mediumPercentage = 50;
-        $highPercentage = 25;
-
-        // 根据难度类别调整分布
-        switch ($category) {
-            case 1:
-                // 基础型:0-0.25占50%,其他占50%
-                $mediumPercentage = 50; // 0-0.25作为基准
-                $lowPercentage = 25;    // 其他低难度
-                $highPercentage = 25;   // 其他高难度
-                break;
-
-            case 2:
-                // 进阶型:0.25-0.5占50%,<0.25占25%,>0.5占25%
-                $mediumPercentage = 50; // 0.25-0.5作为基准
-                $lowPercentage = 25;    // <0.25
-                $highPercentage = 25;   // >0.5
-                break;
-
-            case 3:
-                // 中等型:0.5-0.75占50%,<0.5占25%,>0.75占25%
-                $mediumPercentage = 50; // 0.5-0.75作为基准
-                $lowPercentage = 25;    // <0.5
-                $highPercentage = 25;   // >0.75
-                break;
-
-            case 4:
-                // 拔高型:0.75-1占50%,其他占50%
-                $mediumPercentage = 50; // 0.75-1作为基准
-                $lowPercentage = 25;    // 其他低难度
-                $highPercentage = 25;   // 其他高难度
-                break;
-        }
-
-        // 计算题目数量
-        $lowCount = (int) round($totalQuestions * $lowPercentage / 100);
-        $mediumCount = (int) round($totalQuestions * $mediumPercentage / 100);
-        $highCount = $totalQuestions - $lowCount - $mediumCount;
-
-        return [
-            'low' => [
-                'percentage' => $lowPercentage,
-                'count' => $lowCount,
-                'label' => '低级难度'
-            ],
-            'medium' => [
-                'percentage' => $mediumPercentage,
-                'count' => $mediumCount,
-                'label' => '基准难度'
-            ],
-            'high' => [
-                'percentage' => $highPercentage,
-                'count' => $highCount,
-                'label' => '拔高难度'
-            ]
-        ];
+        return $this->difficultyDistributionService->calculateDistribution($category, $totalQuestions);
     }
 
     /**
@@ -247,43 +196,7 @@ class ExamTypeStrategy
      */
     private function getDifficultyRanges(int $category): array
     {
-        switch ($category) {
-            case 1:
-                return [
-                    'primary' => ['min' => 0.0, 'max' => 0.25, 'percentage' => 50],
-                    'secondary' => ['min' => 0.25, 'max' => 1.0, 'percentage' => 50],
-                    'description' => '基础型:0-0.25占比50%,其他占比50%'
-                ];
-
-            case 2:
-                return [
-                    'primary' => ['min' => 0.25, 'max' => 0.5, 'percentage' => 50],
-                    'low' => ['min' => 0.0, 'max' => 0.25, 'percentage' => 25],
-                    'high' => ['min' => 0.5, 'max' => 1.0, 'percentage' => 25],
-                    'description' => '进阶型:0.25-0.5占比50%,<0.25占比25%,>0.5占比25%'
-                ];
-
-            case 3:
-                return [
-                    'primary' => ['min' => 0.5, 'max' => 0.75, 'percentage' => 50],
-                    'low' => ['min' => 0.0, 'max' => 0.5, 'percentage' => 25],
-                    'high' => ['min' => 0.75, 'max' => 1.0, 'percentage' => 25],
-                    'description' => '中等型:0.5-0.75占比50%,<0.5占比25%,>0.75占比25%'
-                ];
-
-            case 4:
-                return [
-                    'primary' => ['min' => 0.75, 'max' => 1.0, 'percentage' => 50],
-                    'secondary' => ['min' => 0.0, 'max' => 0.75, 'percentage' => 50],
-                    'description' => '拔高型:0.75-1占比50%,其他占比50%'
-                ];
-
-            default:
-                return [
-                    'primary' => ['min' => 0.0, 'max' => 1.0, 'percentage' => 100],
-                    'description' => '默认:全难度范围'
-                ];
-        }
+        return $this->difficultyDistributionService->getRanges($category);
     }
 
     /**
@@ -346,7 +259,6 @@ class ExamTypeStrategy
                 '填空题' => 25,
                 '解答题' => 25,
             ],
-            'question_category' => 1, // question_category=1 代表摸底题目
             'paper_name' => $params['paper_name'] ?? ('摸底测试_' . now()->format('Ymd_His')),
         ]);
 

+ 115 - 13
app/Services/LearningAnalyticsService.php

@@ -2,6 +2,7 @@
 
 namespace App\Services;
 
+use Exception;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\DB;
@@ -1338,7 +1339,8 @@ class LearningAnalyticsService
             // 2. 优先使用学生错题(如果存在)
             $mistakeQuestionIds = $params['mistake_question_ids'] ?? [];
             $priorityQuestions = [];
-            $maxQuestions = 50; // 全局最大题目数限制
+            $maxQuestions = 50; // 错题最大题目数限制
+            $poolLimit = 0; // 题库池不设上限,0 表示不限制
 
             if (!empty($mistakeQuestionIds)) {
                 Log::info('LearningAnalyticsService: 优先获取学生错题', [
@@ -1436,7 +1438,7 @@ class LearningAnalyticsService
                         $skills,
                         $studentId,
                         $questionTypeRatio,
-                        $maxQuestions,
+                        $poolLimit,
                         [],
                         [],
                         $questionCategory,
@@ -1567,8 +1569,14 @@ class LearningAnalyticsService
                     $questionExpansionService = $this->questionExpansionService ?? app(QuestionExpansionService::class);
                     $examStrategy = new ExamTypeStrategy($questionExpansionService);
 
-                    $selectedQuestions = $examStrategy->applyDifficultyDistributionToQuestions(
+                    $distributionCandidates = $this->buildDistributionCandidates(
+                        $allQuestions,
                         $selectedQuestions,
+                        (int) $difficultyCategory
+                    );
+
+                    $selectedQuestions = $examStrategy->applyDifficultyDistributionToQuestions(
+                        $distributionCandidates,
                         $totalQuestions,
                         $difficultyCategory,
                         $params
@@ -1576,7 +1584,7 @@ class LearningAnalyticsService
 
                     Log::info('LearningAnalyticsService: 难度分布应用完成', [
                         'difficulty_category_after' => $difficultyCategory,
-                        'before_count' => count($selectedQuestions),
+                        'before_count' => count($distributionCandidates),
                         'after_count' => count($selectedQuestions),
                         'success' => count($selectedQuestions) >= $totalQuestions
                     ]);
@@ -1600,6 +1608,12 @@ class LearningAnalyticsService
                     })) : 0,
                     'difficulty_distribution_applied' => $enableDistribution && !$isExcludedType,
                     'difficulty_category' => $difficultyCategory,
+                    'final_distribution' => $this->buildDifficultyDistributionStats($selectedQuestions, (int) $difficultyCategory),
+                    'final_distribution_shortage' => $this->buildDifficultyDistributionShortage(
+                        $selectedQuestions,
+                        (int) $difficultyCategory,
+                        (int) $totalQuestions
+                    ),
                     // 【新增】章节知识点数量统计(教材组卷时)
                     'chapter_knowledge_point_stats' => $params['chapter_knowledge_point_stats'] ?? null,
                     'textbook_catalog_node_ids' => $params['textbook_catalog_node_ids'] ?? null
@@ -1669,7 +1683,7 @@ class LearningAnalyticsService
                 'skills' => $skills,
                 'total_needed' => $totalNeeded,
                 'question_type_ratio' => $questionTypeRatio,
-                'note' => '难度筛选由 QuestionLocalService 处理'
+                'note' => $totalNeeded > 0 ? '难度筛选由 QuestionLocalService 处理' : '不限制题库池大小,难度筛选由 QuestionLocalService 处理'
             ]);
 
             $query = \App\Models\Question::query();
@@ -1705,12 +1719,6 @@ class LearningAnalyticsService
                 Log::info('应用题目分类筛选', ['question_category' => $questionCategory]);
             }
 
-            // 【优化】按教材章节节点筛选(textbook_catalog_nodes_id)
-//            if (!empty($textbookCatalogNodeIds)) {
-//                $query->whereIn('textbook_catalog_nodes_id', $textbookCatalogNodeIds);
-//                Log::info('应用教材章节节点筛选', ['textbook_catalog_nodes_ids' => $textbookCatalogNodeIds]);
-//            }
-
             // 筛选有解题思路的题目
             $query->whereNotNull('solution')
                   ->where('solution', '!=', '')
@@ -1769,7 +1777,7 @@ class LearningAnalyticsService
             $selectedQuestions = $formattedQuestions;
 
             // 【修复】重新启用智能补充功能,增加 textbook_id 限制避免超纲
-            if (count($selectedQuestions) < $totalNeeded && $grade !== null) {
+            if ($totalNeeded > 0 && count($selectedQuestions) < $totalNeeded && $grade !== null) {
                 $deficit = $totalNeeded - count($selectedQuestions);
                 Log::warning('getQuestionsFromBank: 指定知识点题目不足,尝试智能补充', [
                     'deficit' => $deficit,
@@ -1809,7 +1817,7 @@ class LearningAnalyticsService
                 'final_count' => count($selectedQuestions),
                 'raw_database_count' => $questions->count(),
                 'total_needed' => $totalNeeded,
-                'success' => count($selectedQuestions) >= $totalNeeded,
+                'success' => $totalNeeded > 0 ? count($selectedQuestions) >= $totalNeeded : true,
                 'time_ms' => round((microtime(true) - $startTime) * 1000, 2)
             ]);
 
@@ -3144,4 +3152,98 @@ class LearningAnalyticsService
         if ($difficulty < 0.7) return '中等';
         return '拔高';
     }
+
+    private function buildDifficultyDistributionStats(array $questions, int $difficultyCategory): array
+    {
+        if (empty($questions)) {
+            return [];
+        }
+
+        $service = app(DifficultyDistributionService::class);
+        $buckets = $service->groupQuestionsByDifficultyRange($questions, $difficultyCategory);
+        $total = max(1, count($questions));
+
+        return array_map(static function ($bucket) use ($total) {
+            $count = count($bucket);
+            return [
+                'count' => $count,
+                'ratio' => round(($count / $total) * 100, 2),
+            ];
+        }, $buckets);
+    }
+
+    private function buildDifficultyDistributionShortage(array $questions, int $difficultyCategory, int $totalQuestions): array
+    {
+        $service = app(DifficultyDistributionService::class);
+        $distribution = $service->calculateDistribution($difficultyCategory, $totalQuestions);
+        $buckets = $service->groupQuestionsByDifficultyRange($questions, $difficultyCategory);
+
+        $expected = [
+            'primary_low' => 0,
+            'primary_medium' => 0,
+            'primary_high' => 0,
+            'secondary' => 0,
+            'other' => 0,
+        ];
+
+        foreach ($distribution as $level => $config) {
+            $bucketKey = $service->mapDifficultyLevelToRangeKey($level, $difficultyCategory);
+            $expected[$bucketKey] += (int) ($config['count'] ?? 0);
+        }
+
+        $actual = array_map(static fn($bucket) => count($bucket), $buckets);
+        $shortage = [];
+
+        foreach ($expected as $bucketKey => $count) {
+            $actualCount = $actual[$bucketKey] ?? 0;
+            $shortage[$bucketKey] = [
+                'expected' => $count,
+                'actual' => $actualCount,
+                'short' => max(0, $count - $actualCount),
+            ];
+        }
+
+        return $shortage;
+    }
+
+    private function buildDistributionCandidates(array $allQuestions, array $selectedQuestions, int $difficultyCategory): array
+    {
+        if (empty($allQuestions)) {
+            return $selectedQuestions;
+        }
+
+        $service = app(DifficultyDistributionService::class);
+        $distribution = $service->calculateDistribution($difficultyCategory, max(1, count($selectedQuestions)));
+        $buckets = $service->groupQuestionsByDifficultyRange($allQuestions, $difficultyCategory);
+
+        $preferredBuckets = [];
+        foreach ($distribution as $level => $config) {
+            $bucketKey = $service->mapDifficultyLevelToRangeKey($level, $difficultyCategory);
+            $preferredBuckets[$bucketKey] = true;
+        }
+
+        $candidates = [];
+        foreach (array_keys($preferredBuckets) as $bucketKey) {
+            foreach ($buckets[$bucketKey] ?? [] as $q) {
+                $candidates[] = $q;
+            }
+        }
+
+        foreach ($selectedQuestions as $q) {
+            $candidates[] = $q;
+        }
+
+        $seen = [];
+        $unique = [];
+        foreach ($candidates as $q) {
+            $id = $q['id'] ?? null;
+            if (!$id || isset($seen[$id])) {
+                continue;
+            }
+            $seen[$id] = true;
+            $unique[] = $q;
+        }
+
+        return $unique;
+    }
 }

+ 71 - 247
app/Services/QuestionLocalService.php

@@ -13,6 +13,14 @@ use Illuminate\Support\Str;
 
 class QuestionLocalService
 {
+    private DifficultyDistributionService $difficultyDistributionService;
+
+    public function __construct(?DifficultyDistributionService $difficultyDistributionService = null)
+    {
+        $this->difficultyDistributionService = $difficultyDistributionService
+            ?? app(DifficultyDistributionService::class);
+    }
+
     public function listQuestions(int $page = 1, int $perPage = 50, array $filters = []): array
     {
         $query = $this->applyFilters(Question::query(), $filters);
@@ -562,8 +570,9 @@ class QuestionLocalService
      *
      * @param array $questions 候选题目数组
      * @param int $totalQuestions 总题目数
-     * @param int $difficultyCategory 难度类别 (1-4)
-     *   - 1: 0-0.25范围占50%,其他占50%
+     * @param int $difficultyCategory 难度类别 (0-4)
+     *   - 0: 0-0.1占90%,0.1-0.25占10%
+     *   - 1: 0-0.25占90%,0.25-1占10%
      *   - 2: 0.25-0.5范围占50%,<0.25占25%,>0.5占25%
      *   - 3: 0.5-0.75范围占50%,<0.5占25%,>0.75占25%
      *   - 4: 0.75-1范围占50%,其他占50%
@@ -575,7 +584,6 @@ class QuestionLocalService
         Log::info('QuestionLocalService: 根据难度系数分布选择题目', [
             'total_questions' => $totalQuestions,
             'difficulty_category' => $difficultyCategory,
-            'input_questions' => count($questions)
         ]);
 
         if (empty($questions)) {
@@ -584,19 +592,15 @@ class QuestionLocalService
         }
 
         // 【恢复】简化逻辑,避免复杂处理
-        $distribution = $this->calculateDifficultyDistribution($difficultyCategory, $totalQuestions);
-
-        Log::info('QuestionLocalService: 难度分布计算', [
-            'distribution' => $distribution,
-            'target_count' => $totalQuestions
-        ]);
+        $distribution = $this->difficultyDistributionService->calculateDistribution($difficultyCategory, $totalQuestions);
 
         // 按难度范围分桶
-        $buckets = $this->groupQuestionsByDifficultyRange($questions, $difficultyCategory);
+        $buckets = $this->difficultyDistributionService->groupQuestionsByDifficultyRange($questions, $difficultyCategory);
 
         Log::info('QuestionLocalService: 题目分桶', [
             'buckets' => array_map(fn($bucket) => count($bucket), $buckets),
-            'total_input' => count($questions)
+            'total_input' => count($questions),
+            'distribution' => $distribution
         ]);
 
         // 根据分布选择题目
@@ -613,16 +617,9 @@ class QuestionLocalService
                 continue;
             }
 
-            $rangeKey = $this->mapDifficultyLevelToRangeKey($level, $difficultyCategory);
+            $rangeKey = $this->difficultyDistributionService->mapDifficultyLevelToRangeKey($level, $difficultyCategory);
             $bucket = $buckets[$rangeKey] ?? [];
 
-            Log::debug('QuestionLocalService: 处理难度层级', [
-                'level' => $level,
-                'range_key' => $rangeKey,
-                'target_count' => $targetCount,
-                'bucket_size' => count($bucket)
-            ]);
-
             // 随机打乱
             shuffle($bucket);
 
@@ -646,267 +643,94 @@ class QuestionLocalService
                     'range_key' => $rangeKey,
                     'target' => $targetCount,
                     'actual' => $taken,
-                    'bucket_size' => count($bucket),
-                    'note' => '将在后续步骤中从其他范围补充题目'
-                ]);
-            } else {
-                Log::debug('QuestionLocalService: 难度层级选择', [
-                    'level' => $level,
-                    'target' => $targetCount,
-                    'actual' => $taken,
-                    'bucket_size' => count($bucket),
-                    'range_key' => $rangeKey
+                    'bucket_size' => count($bucket)
                 ]);
             }
         }
 
-        Log::info('QuestionLocalService: 分布选择后统计', [
-            'selected_count' => count($selected),
-            'target_count' => $totalQuestions,
-            'need_more' => max(0, $totalQuestions - count($selected))
-        ]);
-
         // 如果数量不足,从剩余题目中补充
         if (count($selected) < $totalQuestions) {
             Log::warning('QuestionLocalService: 开始补充题目(难度分布无法满足要求)', [
                 'need_more' => $totalQuestions - count($selected),
                 'selected_count' => count($selected),
                 'difficulty_category' => $difficultyCategory,
-                'note' => '由于难度分布限制,将从所有剩余题目中补充'
+                'note' => '优先从次级桶补充,不足再放宽'
             ]);
 
-            $remaining = [];
-            foreach ($questions as $q) {
-                $id = $q['id'] ?? null;
-                if ($id && !in_array($id, $usedIds)) {
-                    $remaining[] = $q;
+            $needMore = $totalQuestions - count($selected);
+            $supplemented = 0;
+            $supplementOrder = $this->difficultyDistributionService->getSupplementOrder($difficultyCategory);
+
+            foreach ($supplementOrder as $bucketKey) {
+                if ($supplemented >= $needMore) {
+                    break;
+                }
+
+                $bucket = $buckets[$bucketKey] ?? [];
+                if (empty($bucket)) {
+                    continue;
+                }
+
+                shuffle($bucket);
+                foreach ($bucket as $q) {
+                    if ($supplemented >= $needMore) {
+                        break;
+                    }
+
+                    $id = $q['id'] ?? null;
+                    if ($id && !in_array($id, $usedIds)) {
+                        $selected[] = $q;
+                        $usedIds[] = $id;
+                        $supplemented++;
+                    }
                 }
             }
 
-            Log::info('QuestionLocalService: 剩余题目统计', [
-                'remaining_count' => count($remaining),
-                'need_more' => $totalQuestions - count($selected)
-            ]);
+            if ($supplemented < $needMore) {
+                $remaining = [];
+                foreach ($questions as $q) {
+                    $id = $q['id'] ?? null;
+                    if ($id && !in_array($id, $usedIds)) {
+                        $remaining[] = $q;
+                    }
+                }
 
-            // 【修复】打乱剩余题目顺序,确保随机性
-            shuffle($remaining);
-            $needMore = $totalQuestions - count($selected);
-            $supplementCount = min($needMore, count($remaining));
-            $selected = array_merge($selected, array_slice($remaining, 0, $supplementCount));
+                shuffle($remaining);
+                $supplementCount = min($needMore - $supplemented, count($remaining));
+                $selected = array_merge($selected, array_slice($remaining, 0, $supplementCount));
+                $supplemented += $supplementCount;
+            }
 
             Log::warning('QuestionLocalService: 补充完成', [
-                'supplement_added' => $supplementCount,
+                'supplement_added' => $supplemented,
                 'final_count_before_truncate' => count($selected),
-                'remaining_unused' => count($remaining) - $supplementCount
+                'remaining_unused' => max(0, count($questions) - count($selected))
             ]);
         }
 
         // 截断至目标数量
         $selected = array_slice($selected, 0, $totalQuestions);
 
+        $finalBuckets = $this->difficultyDistributionService->groupQuestionsByDifficultyRange($selected, $difficultyCategory);
+        $finalTotal = max(1, count($selected));
+        $distributionStats = array_map(static function ($bucket) use ($finalTotal) {
+            $count = count($bucket);
+            return [
+                'count' => $count,
+                'ratio' => round(($count / $finalTotal) * 100, 2),
+            ];
+        }, $finalBuckets);
+
         Log::info('QuestionLocalService: 难度分布选择完成', [
             'final_count' => count($selected),
             'target_count' => $totalQuestions,
             'success' => count($selected) === $totalQuestions,
             'input_count' => count($questions),
-            'distribution_applied' => true
+            'distribution_applied' => true,
+            'final_distribution' => $distributionStats
         ]);
 
         return $selected;
     }
 
-    /**
-     * 计算难度分布配置
-     *
-     * @param int $category 难度类别 (1-4)
-     * @param int $totalQuestions 总题目数
-     * @return array 分布配置
-     */
-    private function calculateDifficultyDistribution(int $category, int $totalQuestions): array
-    {
-        // 标准化:25% 低级,50% 基准,25% 拔高
-//        $lowPercentage = 25;
-//        $mediumPercentage = 50;
-//        $highPercentage = 25;
-
-        // 根据难度类别调整分布
-        switch ($category) {
-            case 1:
-                // 基础型:0-0.25占50%,其他占50%
-                $mediumPercentage = 90; // 0-0.25作为基准
-                $lowPercentage = 0;    // 其他低难度
-                $highPercentage = 10;   // 其他高难度
-                break;
-
-            case 2:
-                // 进阶型:0.25-0.5占50%,<0.25占25%,>0.5占25%
-                $mediumPercentage = 50; // 0.25-0.5作为基准
-                $lowPercentage = 25;    // <0.25
-                $highPercentage = 25;   // >0.5
-                break;
-
-            case 3:
-                // 中等型:0.5-0.75占50%,<0.5占25%,>0.75占25%
-                $mediumPercentage = 50; // 0.5-0.75作为基准
-                $lowPercentage = 25;    // <0.5
-                $highPercentage = 25;   // >0.75
-                break;
-
-            case 4:
-                // 拔高型:0.75-1占50%,其他占50%
-                $mediumPercentage = 50; // 0.75-1作为基准
-                $lowPercentage = 25;    // 其他低难度
-                $highPercentage = 25;   // 其他高难度
-                break;
-            default:
-                $lowPercentage = 25;
-                $mediumPercentage = 50;
-                $highPercentage = 25;
-        }
-
-        // 计算题目数量
-        $lowCount = (int) round($totalQuestions * $lowPercentage / 100);
-        $mediumCount = (int) round($totalQuestions * $mediumPercentage / 100);
-        $highCount = $totalQuestions - $lowCount - $mediumCount;
-
-        return [
-            'low' => [
-                'percentage' => $lowPercentage,
-                'count' => $lowCount,
-                'label' => '低级难度'
-            ],
-            'medium' => [
-                'percentage' => $mediumPercentage,
-                'count' => $mediumCount,
-                'label' => '基准难度'
-            ],
-            'high' => [
-                'percentage' => $highPercentage,
-                'count' => $highCount,
-                'label' => '拔高难度'
-            ]
-        ];
-    }
-
-    /**
-     * 将题目按难度范围分桶
-     *
-     * @param array $questions 题目数组
-     * @param int $category 难度类别
-     * @return array 分桶结果
-     */
-    private function groupQuestionsByDifficultyRange(array $questions, int $category): array
-    {
-        $buckets = [
-            'primary_low' => [],    // 主要低难度范围
-            'primary_medium' => [], // 主要中等难度范围
-            'primary_high' => [],   // 主要高难度范围
-            'secondary' => [],      // 次要范围
-            'other' => []           // 其他
-        ];
-
-        foreach ($questions as $question) {
-            $difficulty = (float) ($question['difficulty'] ?? 0);
-            $rangeKey = $this->classifyQuestionByDifficulty($difficulty, $category);
-            $buckets[$rangeKey][] = $question;
-        }
-
-        return $buckets;
-    }
-
-    /**
-     * 根据难度值和类别分类题目
-     *
-     * @param float $difficulty 难度值 (0-1)
-     * @param int $category 难度类别 (1-4)
-     * @return string 范围键
-     */
-    private function classifyQuestionByDifficulty(float $difficulty, int $category): string
-    {
-        switch ($category) {
-            case 1:
-                // 基础型:0-0.25作为主要中等,0.25-1作为其他
-                if ($difficulty >= 0 && $difficulty <= 0.25) {
-                    return 'primary_medium';
-                }
-                return 'other';
-
-            case 2:
-                // 进阶型:0.25-0.5作为主要中等,<0.25作为主要低,>0.5作为主要高
-                if ($difficulty >= 0.25 && $difficulty <= 0.5) {
-                    return 'primary_medium';
-                } elseif ($difficulty < 0.25) {
-                    return 'primary_low';
-                }
-                return 'primary_high';
-
-            case 3:
-                // 中等型:0.5-0.75作为主要中等,<0.5作为主要低,>0.75作为主要高
-                if ($difficulty >= 0.5 && $difficulty <= 0.75) {
-                    return 'primary_medium';
-                } elseif ($difficulty < 0.5) {
-                    return 'primary_low';
-                }
-                return 'primary_high';
-
-            case 4:
-                // 拔高型:0.75-1作为主要中等,0-0.75作为其他
-                if ($difficulty >= 0.75 && $difficulty <= 1.0) {
-                    return 'primary_medium';
-                }
-                return 'other';
-
-            default:
-                return 'other';
-        }
-    }
-
-    /**
-     * 将难度层级映射到范围键
-     *
-     * @param string $level 难度层级 (low/medium/high)
-     * @param int $category 难度类别
-     * @return string 范围键
-     */
-    private function mapDifficultyLevelToRangeKey(string $level, int $category): string
-    {
-        // 【修复】难度分布映射逻辑:确保 'other' 桶中的题目能被正确选择
-        // 对于 difficulty_category=1,'other' 桶中的题目应该映射到 'secondary' 桶
-        switch ($category) {
-            case 1:
-                return match($level) {
-                    'low' => 'secondary',  // 修复:映射到 secondary 桶,而非 other
-                    'medium' => 'primary_medium',
-                    'high' => 'secondary',  // 修复:映射到 secondary 桶,而非 other
-                    default => 'secondary'
-                };
-
-            case 2:
-                return match($level) {
-                    'low' => 'primary_low',
-                    'medium' => 'primary_medium',
-                    'high' => 'primary_high',
-                    default => 'other'
-                };
-
-            case 3:
-                return match($level) {
-                    'low' => 'primary_low',
-                    'medium' => 'primary_medium',
-                    'high' => 'primary_high',
-                    default => 'other'
-                };
-
-            case 4:
-                return match($level) {
-                    'low' => 'secondary',  // 修复:映射到 secondary 桶,而非 other
-                    'medium' => 'primary_medium',
-                    'high' => 'secondary',  // 修复:映射到 secondary 桶,而非 other
-                    default => 'secondary'
-                };
-
-            default:
-                return 'other';
-        }
-    }
 }

+ 1 - 0
app/Services/TaskManager.php

@@ -291,6 +291,7 @@ class TaskManager
             $basePayload['paper_id'] = $task['data']['paper_id'] ?? null;
             $basePayload['pdfs'] = $task['pdfs'] ?? null;
             $basePayload['exam_content'] = $task['exam_content'] ?? null;
+            $basePayload['stats'] = $task['stats'] ?? null;
         } elseif ($task['type'] === self::TASK_TYPE_ANALYSIS) {
             $basePayload['callback_type'] = 'analysis_report_generated';
             $basePayload['paper_id'] = $task['data']['paper_id'] ?? $task['data']['paperId'] ?? null;