|
|
@@ -1281,8 +1281,8 @@ class LearningAnalyticsService
|
|
|
$skills = $params['skills'] ?? [];
|
|
|
$questionTypeRatio = $params['question_type_ratio'] ?? [
|
|
|
'选择题' => 40,
|
|
|
- '填空题' => 30,
|
|
|
- '解答题' => 30,
|
|
|
+ '填空题' => 40,
|
|
|
+ '解答题' => 20,
|
|
|
];
|
|
|
// 新增:题目分类筛选
|
|
|
$questionCategory = $params['question_category'] ?? null;
|
|
|
@@ -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
|
|
|
]);
|
|
|
}
|
|
|
}
|
|
|
@@ -2231,10 +2220,40 @@ class LearningAnalyticsService
|
|
|
$useKnowledgePointPriority = ($assembleType === 0); // 摸底测试需要知识点优先
|
|
|
$kpSelected = []; // 已选知识点记录
|
|
|
|
|
|
+ // 【新增】非摸底类型:按比例计算每种题型的目标数量
|
|
|
+ $typeTargets = ['choice' => 0, 'fill' => 0, 'answer' => 0];
|
|
|
+ if (!$useKnowledgePointPriority) {
|
|
|
+ $ratioMap = ['choice' => '选择题', 'fill' => '填空题', 'answer' => '解答题'];
|
|
|
+ $totalRatio = array_sum($questionTypeRatio) ?: 100;
|
|
|
+ $allocated = 0;
|
|
|
+ $fractions = [];
|
|
|
+ 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--;
|
|
|
+ }
|
|
|
+
|
|
|
+ Log::info('selectQuestionsByMastery: 题型目标分配', [
|
|
|
+ 'total_questions' => $totalQuestions,
|
|
|
+ 'ratio' => $questionTypeRatio,
|
|
|
+ 'targets' => $typeTargets,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
Log::info('selectQuestionsByMastery: 知识点优先策略', [
|
|
|
'assemble_type' => $assembleType,
|
|
|
'use_knowledge_point_priority' => $useKnowledgePointPriority,
|
|
|
- 'note' => $useKnowledgePointPriority ? '摸底测试:需要均衡分配知识点' : '知识点组卷:允许同一知识点选多题'
|
|
|
+ 'note' => $useKnowledgePointPriority ? '摸底测试:需要均衡分配知识点' : '按比例分配题型'
|
|
|
]);
|
|
|
|
|
|
// 确保每种题型至少选1题
|
|
|
@@ -2295,14 +2314,26 @@ class LearningAnalyticsService
|
|
|
'note' => $selectedInThisType > 0 ? '成功选择' : '无可用知识点'
|
|
|
]);
|
|
|
} else {
|
|
|
- // 【修复】知识点组卷:随机选择该题型的一道题,避免固定选择第一个导致知识点分布不均
|
|
|
- $randomIndex = array_rand($questionsByType[$type]);
|
|
|
- $selectedQuestions[] = $questionsByType[$type][$randomIndex];
|
|
|
- Log::debug('题型基础分配(随机选择)', [
|
|
|
+ // 非摸底:按目标数量从该题型池中选题
|
|
|
+ $target = $typeTargets[$type] ?? 0;
|
|
|
+ $selectedIds = array_column($selectedQuestions, 'id');
|
|
|
+ $taken = 0;
|
|
|
+ foreach ($questionsByType[$type] as $q) {
|
|
|
+ if ($taken >= $target) break;
|
|
|
+ $qid = $q['id'] ?? null;
|
|
|
+ if ($qid && !in_array($qid, $selectedIds)) {
|
|
|
+ $selectedQuestions[] = $q;
|
|
|
+ $selectedIds[] = $qid;
|
|
|
+ $taken++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ $typeTargets[$type . '_actual'] = $taken;
|
|
|
+
|
|
|
+ Log::info('题型按比例分配', [
|
|
|
'type' => $type,
|
|
|
- 'kp' => $questionsByType[$type][$randomIndex]['kp_code'] ?? 'unknown',
|
|
|
- 'random_index' => $randomIndex,
|
|
|
- 'total_in_type' => count($questionsByType[$type])
|
|
|
+ 'target' => $target,
|
|
|
+ 'actual' => $taken,
|
|
|
+ 'available' => count($questionsByType[$type]),
|
|
|
]);
|
|
|
}
|
|
|
}
|
|
|
@@ -2440,17 +2471,39 @@ class LearningAnalyticsService
|
|
|
]);
|
|
|
}
|
|
|
} else {
|
|
|
- // 知识点组卷:选择未选过的题目(不要求知识点不重复)
|
|
|
- $selectedIds = array_column($selectedQuestions, 'id');
|
|
|
- foreach ($allQuestions as $q) {
|
|
|
- if (count($selectedQuestions) >= $totalQuestions) break;
|
|
|
+ // 非摸底:题型不足时,缺口按优先级补到其他题型
|
|
|
+ $totalSelected = count($selectedQuestions);
|
|
|
+ if ($totalSelected < $totalQuestions) {
|
|
|
+ $deficit = $totalQuestions - $totalSelected;
|
|
|
+ $selectedIds = array_column($selectedQuestions, 'id');
|
|
|
|
|
|
- $qid = $q['id'] ?? null;
|
|
|
- if ($qid && !in_array($qid, $selectedIds)) {
|
|
|
- $selectedQuestions[] = $q;
|
|
|
- $selectedIds[] = $qid;
|
|
|
- Log::debug('继续选择题目(无知识点限制)', ['kp' => $q['kp_code'] ?? 'unknown', 'id' => $qid]);
|
|
|
+ // 按补充优先级:填空 > 解答 > 选择(避免解答题独占缺口)
|
|
|
+ $supplementOrder = ['fill', 'answer', 'choice'];
|
|
|
+
|
|
|
+ Log::info('题型缺口补充开始', [
|
|
|
+ 'deficit' => $deficit,
|
|
|
+ 'current_count' => $totalSelected,
|
|
|
+ 'target' => $totalQuestions,
|
|
|
+ 'supplement_order' => $supplementOrder,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ foreach ($supplementOrder as $type) {
|
|
|
+ if ($deficit <= 0) break;
|
|
|
+ foreach ($questionsByType[$type] as $q) {
|
|
|
+ if ($deficit <= 0) break;
|
|
|
+ $qid = $q['id'] ?? null;
|
|
|
+ if ($qid && !in_array($qid, $selectedIds)) {
|
|
|
+ $selectedQuestions[] = $q;
|
|
|
+ $selectedIds[] = $qid;
|
|
|
+ $deficit--;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
+
|
|
|
+ Log::info('题型缺口补充完成', [
|
|
|
+ 'final_count' => count($selectedQuestions),
|
|
|
+ 'target' => $totalQuestions,
|
|
|
+ ]);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -3253,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)) {
|