|
@@ -1555,6 +1555,13 @@ class LearningAnalyticsService
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ $selectedQuestions = $this->enforceAnswerDifficultyFloor(
|
|
|
|
|
+ $allQuestions,
|
|
|
|
|
+ $selectedQuestions,
|
|
|
|
|
+ (int) $difficultyCategory,
|
|
|
|
|
+ (int) $totalQuestions
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
$requestedKpSelectionStats = $this->buildRequestedKpSelectionStats(
|
|
$requestedKpSelectionStats = $this->buildRequestedKpSelectionStats(
|
|
|
$selectedQuestions,
|
|
$selectedQuestions,
|
|
|
$params['kp_code_list_original'] ?? $params['kp_codes'] ?? []
|
|
$params['kp_code_list_original'] ?? $params['kp_codes'] ?? []
|
|
@@ -1788,12 +1795,30 @@ class LearningAnalyticsService
|
|
|
// 注意: 难度筛选由 QuestionLocalService 的难度分布系统处理
|
|
// 注意: 难度筛选由 QuestionLocalService 的难度分布系统处理
|
|
|
// 不在这里进行难度筛选,让 QuestionLocalService 做精确的难度分布
|
|
// 不在这里进行难度筛选,让 QuestionLocalService 做精确的难度分布
|
|
|
|
|
|
|
|
- // 【重要】移除数量限制,获取所有符合条件的题目
|
|
|
|
|
- // 不使用limit()限制查询结果,让后续处理逻辑决定最终数量
|
|
|
|
|
- $query->inRandomOrder();
|
|
|
|
|
|
|
+ $hasSpecificScope = !empty($kpCodes) || !empty($textbookCatalogNodeIds);
|
|
|
|
|
+ $queryPoolLimit = 0;
|
|
|
|
|
+
|
|
|
|
|
+ if ($hasSpecificScope) {
|
|
|
|
|
+ // 有明确知识点/章节范围时,保留随机抽样能力。
|
|
|
|
|
+ $query->inRandomOrder();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 新学生常见场景:没有掌握度、没有知识点范围。
|
|
|
|
|
+ // 避免全表随机+全量加载导致队列任务超时。
|
|
|
|
|
+ $queryPoolLimit = max(500, $totalNeeded * 50);
|
|
|
|
|
+ $query->orderByDesc('id')->limit($queryPoolLimit);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
$questions = $query->get();
|
|
$questions = $query->get();
|
|
|
|
|
|
|
|
|
|
+ Log::info('getQuestionsFromBank: query_pool', [
|
|
|
|
|
+ 'has_specific_scope' => $hasSpecificScope,
|
|
|
|
|
+ 'kp_codes_count' => count($kpCodes),
|
|
|
|
|
+ 'textbook_node_count' => is_array($textbookCatalogNodeIds) ? count($textbookCatalogNodeIds) : 0,
|
|
|
|
|
+ 'total_needed' => $totalNeeded,
|
|
|
|
|
+ 'query_pool_limit' => $queryPoolLimit,
|
|
|
|
|
+ 'result_count' => $questions->count(),
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
// 转换为标准格式
|
|
// 转换为标准格式
|
|
|
$formattedQuestions = $questions->map(function ($q) {
|
|
$formattedQuestions = $questions->map(function ($q) {
|
|
|
return [
|
|
return [
|
|
@@ -3239,7 +3264,22 @@ class LearningAnalyticsService
|
|
|
|
|
|
|
|
private function determineQuestionType(array $q): string
|
|
private function determineQuestionType(array $q): string
|
|
|
{
|
|
{
|
|
|
- // 优先根据题目内容判断(而不是数据库字段)
|
|
|
|
|
|
|
+ // 优先信任结构化题型字段,避免解答题被题干模式误判。
|
|
|
|
|
+ $typeField = $q['question_type'] ?? $q['type'] ?? '';
|
|
|
|
|
+ if (is_string($typeField)) {
|
|
|
|
|
+ $t = strtolower($typeField);
|
|
|
|
|
+ if (in_array($t, ['choice', 'single_choice', 'multiple_choice', '选择题', 'choice', 'single_choice', 'multiple_choice'])) {
|
|
|
|
|
+ return 'choice';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (in_array($t, ['fill', 'blank', 'fill_blank', 'fill_in_the_blank', '填空题'])) {
|
|
|
|
|
+ return 'fill';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (in_array($t, ['answer', 'calculation', 'word_problem', 'proof', '解答题'])) {
|
|
|
|
|
+ return 'answer';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 结构化字段不可用时,再根据题目内容启发式判断。
|
|
|
$stem = $q['stem'] ?? $q['content'] ?? '';
|
|
$stem = $q['stem'] ?? $q['content'] ?? '';
|
|
|
// 处理 stem 可能是数组的情况
|
|
// 处理 stem 可能是数组的情况
|
|
|
if (is_array($stem)) {
|
|
if (is_array($stem)) {
|
|
@@ -3285,20 +3325,7 @@ class LearningAnalyticsService
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 3. 根据题目已有类型字段判断(作为后备)
|
|
|
|
|
- $typeField = $q['question_type'] ?? $q['type'] ?? '';
|
|
|
|
|
- if (is_string($typeField)) {
|
|
|
|
|
- $t = strtolower($typeField);
|
|
|
|
|
- if (in_array($t, ['choice', 'single_choice', 'multiple_choice', '选择题', 'choice', 'single_choice', 'multiple_choice'])) {
|
|
|
|
|
- return 'choice';
|
|
|
|
|
- }
|
|
|
|
|
- if (in_array($t, ['fill', 'blank', 'fill_blank', 'fill_in_the_blank', '填空题'])) {
|
|
|
|
|
- return 'fill';
|
|
|
|
|
- }
|
|
|
|
|
- if (in_array($t, ['answer', 'calculation', 'word_problem', 'proof', '解答题'])) {
|
|
|
|
|
- return 'answer';
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // 3. 无法识别时回落为解答题
|
|
|
|
|
|
|
|
// 4. 根据标签判断
|
|
// 4. 根据标签判断
|
|
|
if (is_string($tags)) {
|
|
if (is_string($tags)) {
|
|
@@ -3852,27 +3879,31 @@ class LearningAnalyticsService
|
|
|
*/
|
|
*/
|
|
|
private function getDifficultyRangesForCategory(int $difficultyCategory): array
|
|
private function getDifficultyRangesForCategory(int $difficultyCategory): array
|
|
|
{
|
|
{
|
|
|
- return match($difficultyCategory) {
|
|
|
|
|
- 1 => [
|
|
|
|
|
- ['min' => 0.0, 'max' => 0.5], // 基础型:偏向低难度
|
|
|
|
|
- ['min' => 0.5, 'max' => 1.0],
|
|
|
|
|
- ],
|
|
|
|
|
- 2 => [
|
|
|
|
|
- ['min' => 0.0, 'max' => 0.5], // 进阶型:均衡分布
|
|
|
|
|
- ['min' => 0.5, 'max' => 1.0],
|
|
|
|
|
- ],
|
|
|
|
|
- 3 => [
|
|
|
|
|
- ['min' => 0.25, 'max' => 0.75], // 中等型:偏向中等难度
|
|
|
|
|
- ['min' => 0.0, 'max' => 1.0],
|
|
|
|
|
- ],
|
|
|
|
|
- 4 => [
|
|
|
|
|
- ['min' => 0.5, 'max' => 1.0], // 拔高型:偏向高难度
|
|
|
|
|
- ['min' => 0.0, 'max' => 0.5],
|
|
|
|
|
- ],
|
|
|
|
|
- default => [
|
|
|
|
|
- ['min' => 0.0, 'max' => 1.0],
|
|
|
|
|
- ]
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ $ranges = app(DifficultyDistributionService::class)->getRanges($difficultyCategory);
|
|
|
|
|
+ $segments = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach (['primary', 'high', 'low', 'secondary', 'fallback_low'] as $key) {
|
|
|
|
|
+ if (!isset($ranges[$key]) || !is_array($ranges[$key])) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $min = $ranges[$key]['min'] ?? null;
|
|
|
|
|
+ $max = $ranges[$key]['max'] ?? null;
|
|
|
|
|
+ $percentage = $ranges[$key]['percentage'] ?? null;
|
|
|
|
|
+ if (!is_numeric($min) || !is_numeric($max)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 仅将“目标占比 > 0”的区间用于补题查询,避免 0% 兜底区间误入候选池。
|
|
|
|
|
+ if (!is_numeric($percentage) || (float) $percentage <= 0.0) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $segments[] = ['min' => (float) $min, 'max' => (float) $max];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if ($segments === []) {
|
|
|
|
|
+ return [['min' => 0.0, 'max' => 1.0]];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $segments;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private function normalizeQuestionStageGrade(int $grade): ?int
|
|
private function normalizeQuestionStageGrade(int $grade): ?int
|
|
@@ -4046,13 +4077,14 @@ class LearningAnalyticsService
|
|
|
|
|
|
|
|
// 如果难度分布不满足目标数,从该题型剩余题目补充
|
|
// 如果难度分布不满足目标数,从该题型剩余题目补充
|
|
|
if (count($typeSelected) < $target) {
|
|
if (count($typeSelected) < $target) {
|
|
|
- $allTypeBuckets = array_merge(
|
|
|
|
|
- $buckets['primary_medium'] ?? [],
|
|
|
|
|
- $buckets['primary_low'] ?? [],
|
|
|
|
|
- $buckets['primary_high'] ?? [],
|
|
|
|
|
- $buckets['secondary'] ?? [],
|
|
|
|
|
- $buckets['other'] ?? []
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ $supplementBucketOrder = array_values(array_unique(array_merge(
|
|
|
|
|
+ $diffService->getSupplementOrder($difficultyCategory),
|
|
|
|
|
+ ['primary_medium', 'primary_high', 'primary_low', 'secondary', 'other']
|
|
|
|
|
+ )));
|
|
|
|
|
+ $allTypeBuckets = [];
|
|
|
|
|
+ foreach ($supplementBucketOrder as $bucketKey) {
|
|
|
|
|
+ $allTypeBuckets = array_merge($allTypeBuckets, $buckets[$bucketKey] ?? []);
|
|
|
|
|
+ }
|
|
|
shuffle($allTypeBuckets);
|
|
shuffle($allTypeBuckets);
|
|
|
foreach ($allTypeBuckets as $q) {
|
|
foreach ($allTypeBuckets as $q) {
|
|
|
if (count($typeSelected) >= $target) break;
|
|
if (count($typeSelected) >= $target) break;
|
|
@@ -4100,6 +4132,186 @@ class LearningAnalyticsService
|
|
|
return array_slice($result, 0, $totalQuestions);
|
|
return array_slice($result, 0, $totalQuestions);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 培优/竞赛卷的解答题更接近真实区分度:不允许用极低难度解答题兜底。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 总题量优先级仍然很高:低难解答题先尝试替换为同题型合格题;没有同题型时,
|
|
|
|
|
+ * 再用任意非违规候选题补齐总数,避免因为题库供给不足直接少题。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param array<int, array<string, mixed>> $candidatePool
|
|
|
|
|
+ * @param array<int, array<string, mixed>> $selectedQuestions
|
|
|
|
|
+ * @return array<int, array<string, mixed>>
|
|
|
|
|
+ */
|
|
|
|
|
+ private function enforceAnswerDifficultyFloor(
|
|
|
|
|
+ array $candidatePool,
|
|
|
|
|
+ array $selectedQuestions,
|
|
|
|
|
+ int $difficultyCategory,
|
|
|
|
|
+ int $totalQuestions
|
|
|
|
|
+ ): array {
|
|
|
|
|
+ $floor = $this->answerDifficultyFloor($difficultyCategory);
|
|
|
|
|
+ if ($floor === null || empty($selectedQuestions)) {
|
|
|
|
|
+ return $selectedQuestions;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $selectedIds = [];
|
|
|
|
|
+ foreach ($selectedQuestions as $question) {
|
|
|
|
|
+ $id = $this->questionIdentity($question);
|
|
|
|
|
+ if ($id !== '') {
|
|
|
|
|
+ $selectedIds[$id] = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $candidates = [];
|
|
|
|
|
+ $seen = [];
|
|
|
|
|
+ foreach (array_merge($candidatePool, $selectedQuestions) as $question) {
|
|
|
|
|
+ $id = $this->questionIdentity($question);
|
|
|
|
|
+ if ($id === '' || isset($seen[$id])) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $seen[$id] = true;
|
|
|
|
|
+ $candidates[] = $question;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ usort($candidates, function (array $a, array $b) use ($difficultyCategory): int {
|
|
|
|
|
+ return $this->compareCandidateDifficultyFit($a, $b, $difficultyCategory);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ $replacementUsed = [];
|
|
|
|
|
+ $removed = 0;
|
|
|
|
|
+ foreach ($selectedQuestions as $idx => $question) {
|
|
|
|
|
+ if (! $this->isLowAnswerQuestion($question, $floor)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $replacement = $this->takeReplacementQuestion(
|
|
|
|
|
+ $candidates,
|
|
|
|
|
+ $selectedIds,
|
|
|
|
|
+ $replacementUsed,
|
|
|
|
|
+ $floor,
|
|
|
|
|
+ true
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if ($replacement === null) {
|
|
|
|
|
+ unset($selectedQuestions[$idx]);
|
|
|
|
|
+ $removed++;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $oldId = $this->questionIdentity($question);
|
|
|
|
|
+ if ($oldId !== '') {
|
|
|
|
|
+ unset($selectedIds[$oldId]);
|
|
|
|
|
+ }
|
|
|
|
|
+ $newId = $this->questionIdentity($replacement);
|
|
|
|
|
+ if ($newId !== '') {
|
|
|
|
|
+ $selectedIds[$newId] = true;
|
|
|
|
|
+ $replacementUsed[$newId] = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ $selectedQuestions[$idx] = $replacement;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $selectedQuestions = array_values($selectedQuestions);
|
|
|
|
|
+ if (count($selectedQuestions) < $totalQuestions) {
|
|
|
|
|
+ foreach ($candidates as $candidate) {
|
|
|
|
|
+ if (count($selectedQuestions) >= $totalQuestions) {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $id = $this->questionIdentity($candidate);
|
|
|
|
|
+ if ($id === '' || isset($selectedIds[$id]) || isset($replacementUsed[$id])) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($this->isLowAnswerQuestion($candidate, $floor)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $selectedQuestions[] = $candidate;
|
|
|
|
|
+ $selectedIds[$id] = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if ($removed > 0 || count($selectedQuestions) < $totalQuestions) {
|
|
|
|
|
+ Log::warning('LearningAnalyticsService: 培优解答题难度护栏触发', [
|
|
|
|
|
+ 'difficulty_category' => $difficultyCategory,
|
|
|
|
|
+ 'answer_floor' => $floor,
|
|
|
|
|
+ 'removed_low_answer_count' => $removed,
|
|
|
|
|
+ 'final_count' => count($selectedQuestions),
|
|
|
|
|
+ 'target_count' => $totalQuestions,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return array_slice($selectedQuestions, 0, $totalQuestions);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function answerDifficultyFloor(int $difficultyCategory): ?float
|
|
|
|
|
+ {
|
|
|
|
|
+ return match ($difficultyCategory) {
|
|
|
|
|
+ 3 => 0.4,
|
|
|
|
|
+ 4 => 0.5,
|
|
|
|
|
+ default => null,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function isLowAnswerQuestion(array $question, float $floor): bool
|
|
|
|
|
+ {
|
|
|
|
|
+ return $this->determineQuestionType($question) === 'answer'
|
|
|
|
|
+ && (float) ($question['difficulty'] ?? 0.0) < $floor;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * @param array<int, array<string, mixed>> $candidates
|
|
|
|
|
+ * @param array<string, bool> $selectedIds
|
|
|
|
|
+ * @param array<string, bool> $replacementUsed
|
|
|
|
|
+ */
|
|
|
|
|
+ private function takeReplacementQuestion(
|
|
|
|
|
+ array $candidates,
|
|
|
|
|
+ array $selectedIds,
|
|
|
|
|
+ array $replacementUsed,
|
|
|
|
|
+ float $floor,
|
|
|
|
|
+ bool $sameTypeOnly
|
|
|
|
|
+ ): ?array {
|
|
|
|
|
+ foreach ($candidates as $candidate) {
|
|
|
|
|
+ $id = $this->questionIdentity($candidate);
|
|
|
|
|
+ if ($id === '' || isset($selectedIds[$id]) || isset($replacementUsed[$id])) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($sameTypeOnly && $this->determineQuestionType($candidate) !== 'answer') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($this->isLowAnswerQuestion($candidate, $floor)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $candidate;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function compareCandidateDifficultyFit(array $a, array $b, int $difficultyCategory): int
|
|
|
|
|
+ {
|
|
|
|
|
+ $target = match ($difficultyCategory) {
|
|
|
|
|
+ 3 => 0.62,
|
|
|
|
|
+ 4 => 0.82,
|
|
|
|
|
+ default => 0.5,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ $da = (float) ($a['difficulty'] ?? 0.5);
|
|
|
|
|
+ $db = (float) ($b['difficulty'] ?? 0.5);
|
|
|
|
|
+ $scoreA = abs($target - $da);
|
|
|
|
|
+ $scoreB = abs($target - $db);
|
|
|
|
|
+
|
|
|
|
|
+ if ($scoreA !== $scoreB) {
|
|
|
|
|
+ return $scoreA <=> $scoreB;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $this->questionIdentity($a) <=> $this->questionIdentity($b);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function questionIdentity(array $question): string
|
|
|
|
|
+ {
|
|
|
|
|
+ return (string) ($question['id'] ?? $question['question_id'] ?? $question['question_bank_id'] ?? '');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 统计题目列表中各题型的数量
|
|
* 统计题目列表中各题型的数量
|
|
|
*/
|
|
*/
|