|
|
@@ -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)) {
|