|
|
@@ -729,6 +729,16 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
float $totalScore,
|
|
|
array $questionTypeRatio
|
|
|
): array {
|
|
|
+ // 去重:确保输入题目列表没有重复ID
|
|
|
+ $uniqueQuestions = [];
|
|
|
+ foreach ($questions as $q) {
|
|
|
+ $id = $q['id'] ?? $q['question_id'] ?? null;
|
|
|
+ if ($id && !isset($uniqueQuestions[$id])) {
|
|
|
+ $uniqueQuestions[$id] = $q;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ $questions = array_values($uniqueQuestions);
|
|
|
+
|
|
|
if (count($questions) <= $targetCount) {
|
|
|
return $questions;
|
|
|
}
|
|
|
@@ -760,92 +770,106 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
$difficultyFilteredQuestions = $this->filterByDifficulty($categorizedQuestions, $difficultyCategory);
|
|
|
|
|
|
// 3. 根据题型配比计算每种题型应选择的题目数量
|
|
|
- // 先确保每种题型至少有1题(如果题目数量>=3)
|
|
|
$selectedQuestions = [];
|
|
|
- $totalSelected = 0;
|
|
|
-
|
|
|
+ $selectedIds = []; // 用于追踪已选题目ID
|
|
|
+
|
|
|
// 优先保证每种题型至少一题(适用于总题目数>=3的情况)
|
|
|
if ($targetCount >= 3) {
|
|
|
foreach (['choice', 'fill', 'answer'] as $typeKey) {
|
|
|
if (!empty($difficultyFilteredQuestions[$typeKey])) {
|
|
|
// 随机选择1道该题型的题目
|
|
|
$randomIndex = array_rand($difficultyFilteredQuestions[$typeKey]);
|
|
|
- $selectedQuestions[] = $difficultyFilteredQuestions[$typeKey][$randomIndex];
|
|
|
- $totalSelected++;
|
|
|
+ $q = $difficultyFilteredQuestions[$typeKey][$randomIndex];
|
|
|
+ $id = $q['id'] ?? $q['question_id'];
|
|
|
+
|
|
|
+ if (!in_array($id, $selectedIds)) {
|
|
|
+ $selectedQuestions[] = $q;
|
|
|
+ $selectedIds[] = $id;
|
|
|
+ }
|
|
|
|
|
|
\Illuminate\Support\Facades\Log::info("保证题型最少题目: {$typeKey}", [
|
|
|
'selected_index' => $randomIndex
|
|
|
]);
|
|
|
} else {
|
|
|
- // 如果某题型没有题目,标记为缺失
|
|
|
\Illuminate\Support\Facades\Log::warning("题型缺失: {$typeKey},需要从其他题型补充");
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 如果题目数量不足3题,则跳过最少保证
|
|
|
// 根据题型配比计算每种题型应选择的题目数量
|
|
|
foreach ($questionTypeRatio as $type => $ratio) {
|
|
|
$typeKey = $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer');
|
|
|
- $countForType = floor($targetCount * $ratio / 100);
|
|
|
-
|
|
|
- // 如果总题目数>=3,已经为每种题型分配了1题,需要从剩余数量中扣除
|
|
|
- if ($targetCount >= 3 && $totalSelected > 0) {
|
|
|
- // 重新计算:确保至少1题 + 按比例分配的额外题目
|
|
|
- $baseCount = 1; // 最少1题
|
|
|
- $extraCount = floor(($targetCount - 3) * $ratio / 100); // 剩余题目按比例分配
|
|
|
- $countForType = $baseCount + $extraCount;
|
|
|
- }
|
|
|
-
|
|
|
- if ($countForType > 0 && !empty($difficultyFilteredQuestions[$typeKey])) {
|
|
|
- // 按难度排序后选择该题型的一部分题目
|
|
|
- $availableCount = count($difficultyFilteredQuestions[$typeKey]);
|
|
|
- $takeCount = min($countForType, $availableCount, $targetCount - $totalSelected);
|
|
|
-
|
|
|
- // 如果该题型已经在最少保证中分配过,需要排除已分配的题目
|
|
|
- if ($targetCount >= 3 && isset($selectedQuestions)) {
|
|
|
- // 重新获取题目,排除已选择的
|
|
|
- $availableQuestions = $difficultyFilteredQuestions[$typeKey];
|
|
|
- $takeCount = min($takeCount, $availableCount, $targetCount - $totalSelected);
|
|
|
- if ($takeCount > 0) {
|
|
|
- $selectedFromType = array_rand(array_flip(array_keys($availableQuestions)), $takeCount);
|
|
|
- if (!is_array($selectedFromType)) {
|
|
|
- $selectedFromType = [$selectedFromType];
|
|
|
- }
|
|
|
- foreach ($selectedFromType as $index) {
|
|
|
- $selectedQuestions[] = $availableQuestions[$index];
|
|
|
- }
|
|
|
- $totalSelected += $takeCount;
|
|
|
+
|
|
|
+ // 计算该类型目标数量
|
|
|
+ $targetTypeCount = floor($targetCount * $ratio / 100);
|
|
|
+
|
|
|
+ // 调整目标数量:如果总数>=3,需要考虑已经选了的题目
|
|
|
+ // 简单起见,我们计算总共需要的数量,然后减去已经选了的数量
|
|
|
+ // 但这里为了保证比例,我们还是尽量多选
|
|
|
+
|
|
|
+ if ($targetTypeCount <= 0) continue;
|
|
|
+
|
|
|
+ if (!empty($difficultyFilteredQuestions[$typeKey])) {
|
|
|
+ $availableQuestions = $difficultyFilteredQuestions[$typeKey];
|
|
|
+ // 过滤掉已选的
|
|
|
+ $availableQuestions = array_filter($availableQuestions, function($q) use ($selectedIds) {
|
|
|
+ $id = $q['id'] ?? $q['question_id'];
|
|
|
+ return !in_array($id, $selectedIds);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 如果没有可用题目了,跳过
|
|
|
+ if (empty($availableQuestions)) continue;
|
|
|
+
|
|
|
+ $availableCount = count($availableQuestions);
|
|
|
+ // 还需要选多少:目标数量 - 已选该类型的数量
|
|
|
+ $currentTypeCount = 0;
|
|
|
+ foreach ($selectedQuestions as $sq) {
|
|
|
+ if ($this->determineQuestionType($sq) === $typeKey) {
|
|
|
+ $currentTypeCount++;
|
|
|
}
|
|
|
- } else {
|
|
|
- // 正常分配
|
|
|
+ }
|
|
|
+
|
|
|
+ $needToSelect = $targetTypeCount - $currentTypeCount;
|
|
|
+
|
|
|
+ if ($needToSelect > 0) {
|
|
|
+ $takeCount = min($needToSelect, $availableCount, $targetCount - count($selectedQuestions));
|
|
|
+
|
|
|
if ($takeCount > 0) {
|
|
|
- $selectedFromType = array_rand(array_flip(array_keys($difficultyFilteredQuestions[$typeKey])), $takeCount);
|
|
|
- if (!is_array($selectedFromType)) {
|
|
|
- $selectedFromType = [$selectedFromType];
|
|
|
+ $randomKeys = array_rand($availableQuestions, $takeCount);
|
|
|
+ if (!is_array($randomKeys)) {
|
|
|
+ $randomKeys = [$randomKeys];
|
|
|
}
|
|
|
- foreach ($selectedFromType as $index) {
|
|
|
- $selectedQuestions[] = $difficultyFilteredQuestions[$typeKey][$index];
|
|
|
+
|
|
|
+ foreach ($randomKeys as $key) {
|
|
|
+ $q = $availableQuestions[$key];
|
|
|
+ $selectedQuestions[] = $q;
|
|
|
+ $selectedIds[] = $q['id'] ?? $q['question_id'];
|
|
|
}
|
|
|
- $totalSelected += $takeCount;
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- \Illuminate\Support\Facades\Log::info("{$type}题型筛选结果", [
|
|
|
- 'available' => $availableCount,
|
|
|
- 'take' => $takeCount,
|
|
|
- 'ratio' => $ratio,
|
|
|
- 'type' => $typeKey
|
|
|
- ]);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 4. 如果还有空缺,随机补充其他题型
|
|
|
- while ($totalSelected < $targetCount && count($selectedQuestions) < count($questions)) {
|
|
|
- $randomQuestion = $questions[array_rand($questions)];
|
|
|
- if (!in_array($randomQuestion, $selectedQuestions)) {
|
|
|
- $selectedQuestions[] = $randomQuestion;
|
|
|
- $totalSelected++;
|
|
|
+ if (count($selectedQuestions) < $targetCount) {
|
|
|
+ // 从所有题目中过滤掉已选的
|
|
|
+ $remainingQuestions = array_filter($questions, function($q) use ($selectedIds) {
|
|
|
+ $id = $q['id'] ?? $q['question_id'];
|
|
|
+ return !in_array($id, $selectedIds);
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!empty($remainingQuestions)) {
|
|
|
+ $needed = $targetCount - count($selectedQuestions);
|
|
|
+ $take = min($needed, count($remainingQuestions));
|
|
|
+
|
|
|
+ $randomKeys = array_rand($remainingQuestions, $take);
|
|
|
+ if (!is_array($randomKeys)) {
|
|
|
+ $randomKeys = [$randomKeys];
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach ($randomKeys as $key) {
|
|
|
+ $selectedQuestions[] = $remainingQuestions[$key];
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -1011,8 +1035,15 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
*/
|
|
|
protected function determineQuestionType(array $question): string
|
|
|
{
|
|
|
+ // 0. 如果题目已有明确类型,直接返回
|
|
|
+ if (!empty($question['type'])) {
|
|
|
+ if ($question['type'] === 'choice' || $question['type'] === '选择题') return 'choice';
|
|
|
+ if ($question['type'] === 'fill' || $question['type'] === '填空题') return 'fill';
|
|
|
+ if ($question['type'] === 'answer' || $question['type'] === '解答题') return 'answer';
|
|
|
+ }
|
|
|
+
|
|
|
$tags = $question['tags'] ?? '';
|
|
|
- $stem = $question['stem'] ?? '';
|
|
|
+ $stem = $question['stem'] ?? $question['content'] ?? '';
|
|
|
|
|
|
// 1. 根据标签判断
|
|
|
if (is_string($tags)) {
|
|
|
@@ -1027,45 +1058,45 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 2. 根据题干内容判断 - 选择题(有括号的或包含选项A.B.C.D.)
|
|
|
+ // 2. 根据题干内容判断 - 填空题优先(有下划线)
|
|
|
+ // 填空题特征:连续的下划线,或者括号中明显是填空的(通常不会有选项)
|
|
|
if (is_string($stem)) {
|
|
|
- // 检查全角括号
|
|
|
- if (strpos($stem, '()') !== false) {
|
|
|
- return 'choice';
|
|
|
- }
|
|
|
- // 检查半角括号
|
|
|
- if (strpos($stem, '()') !== false) {
|
|
|
- return 'choice';
|
|
|
- }
|
|
|
- // 检查选项格式 A. B. C. D.(支持跨行匹配)
|
|
|
- if (preg_match('/[A-D]\.\s/m', $stem)) {
|
|
|
- return 'choice';
|
|
|
+ // 检查填空题特征:连续下划线
|
|
|
+ if (strpos($stem, '____') !== false || strpos($stem, '______') !== false) {
|
|
|
+ return 'fill';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 3. 根据题干内容判断 - 填空题(有下划线)
|
|
|
- if (is_string($stem) && (strpos($stem, '____') !== false || strpos($stem, '______') !== false)) {
|
|
|
- return 'fill';
|
|
|
- }
|
|
|
-
|
|
|
- // 4. 根据题干长度和内容判断(启发式)
|
|
|
+ // 3. 根据题干内容判断 - 选择题
|
|
|
+ // 选择题特征:必须包含选项 A. B. C. D.
|
|
|
if (is_string($stem)) {
|
|
|
- $shortQuestions = ['下列', '判断', '选择', '计算', '求'];
|
|
|
- $isShort = false;
|
|
|
- foreach ($shortQuestions as $keyword) {
|
|
|
- if (strpos($stem, $keyword) !== false) {
|
|
|
- $isShort = true;
|
|
|
- break;
|
|
|
+ // 检查选项格式 A. B. C. D.(支持跨行匹配)
|
|
|
+ // 更严格的正则:A. 后面跟内容,或者 (A) 后面跟内容
|
|
|
+ if (preg_match('/[A-D]\s*\./', $stem) || preg_match('/\([A-D]\)/', $stem)) {
|
|
|
+ // 再次确认是否包含多个选项,防止误判
|
|
|
+ if (preg_match('/A\./', $stem) && preg_match('/B\./', $stem)) {
|
|
|
+ return 'choice';
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ // 如果只有括号但没有选项,可能是填空题
|
|
|
+ // 比如 "计算:(1) ... (2) ..." 这种是解答题
|
|
|
+ // "若 x > 0,则 x + 1 ( )" 这种可能是填空也可能是选择,取决于是否有选项
|
|
|
+ // 这里我们保守一点,如果没有选项特征,就不认为是选择题
|
|
|
+ }
|
|
|
|
|
|
- // 短题目通常是选择题或填空题
|
|
|
- if ($isShort && mb_strlen($stem) < 100) {
|
|
|
- return 'choice';
|
|
|
+ // 4. 再次检查填空题特征(括号填空)
|
|
|
+ if (is_string($stem)) {
|
|
|
+ // 只有括号且没有选项,通常是填空
|
|
|
+ if ((strpos($stem, '()') !== false || strpos($stem, '()') !== false) && !preg_match('/[A-D]\./', $stem)) {
|
|
|
+ return 'fill';
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- // 有证明、解答等关键词的是解答题
|
|
|
- if (strpos($stem, '证明') !== false || strpos($stem, '分析') !== false || strpos($stem, '求证') !== false) {
|
|
|
+ // 5. 根据题干长度和内容判断(启发式)
|
|
|
+ if (is_string($stem)) {
|
|
|
+ // 有证明、解答、计算、求证等关键词的是解答题
|
|
|
+ if (strpos($stem, '证明') !== false || strpos($stem, '求证') !== false || strpos($stem, '解方程') !== false || strpos($stem, '计算:') !== false) {
|
|
|
return 'answer';
|
|
|
}
|
|
|
}
|