|
@@ -43,9 +43,9 @@ class IntelligentExamGeneration extends Page
|
|
|
|
|
|
|
|
// 题型配比
|
|
// 题型配比
|
|
|
public array $questionTypeRatio = [
|
|
public array $questionTypeRatio = [
|
|
|
- '选择题' => 40, // 百分比
|
|
|
|
|
- '填空题' => 30,
|
|
|
|
|
- '解答题' => 30,
|
|
|
|
|
|
|
+ '选择题' => 40, // 百分比,支持 4:2:4 或 5:2:3
|
|
|
|
|
+ '填空题' => 20,
|
|
|
|
|
+ '解答题' => 40,
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
// 难度配比
|
|
// 难度配比
|
|
@@ -54,6 +54,8 @@ class IntelligentExamGeneration extends Page
|
|
|
'中等' => 35,
|
|
'中等' => 35,
|
|
|
'拔高' => 15,
|
|
'拔高' => 15,
|
|
|
];
|
|
];
|
|
|
|
|
+ // 难度多选(空数组表示随机难度)
|
|
|
|
|
+ public array $selectedDifficultyLevels = [];
|
|
|
|
|
|
|
|
// 教师和学生相关
|
|
// 教师和学生相关
|
|
|
public ?string $selectedTeacherId = null;
|
|
public ?string $selectedTeacherId = null;
|
|
@@ -71,8 +73,8 @@ class IntelligentExamGeneration extends Page
|
|
|
return !$this->isGenerating
|
|
return !$this->isGenerating
|
|
|
&& $this->totalQuestions >= 6
|
|
&& $this->totalQuestions >= 6
|
|
|
&& !empty($this->selectedTeacherId)
|
|
&& !empty($this->selectedTeacherId)
|
|
|
- && !empty($this->selectedStudentId)
|
|
|
|
|
- && count($this->selectedKpCodes) > 0;
|
|
|
|
|
|
|
+ && !empty($this->selectedStudentId);
|
|
|
|
|
+ // 注意:不强制要求选择知识点,没有选择时将按年级或随机生成
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public function mount(): void
|
|
public function mount(): void
|
|
@@ -100,6 +102,9 @@ class IntelligentExamGeneration extends Page
|
|
|
$this->selectedTeacherId = (string) $student->teacher_id;
|
|
$this->selectedTeacherId = (string) $student->teacher_id;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // 注意:不初始化 selectedDifficultyLevels,保持为空数组表示随机
|
|
|
|
|
+ // 如果用户不选择任何难度,系统将随机生成题目
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[Computed(cache: false)]
|
|
#[Computed(cache: false)]
|
|
@@ -550,33 +555,12 @@ class IntelligentExamGeneration extends Page
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (empty($this->selectedKpCodes)) {
|
|
|
|
|
- Notification::make()
|
|
|
|
|
- ->title('请选择知识点')
|
|
|
|
|
- ->body('请至少选择 1 个知识点后再生成试卷。可勾选学生薄弱点或手动选择知识点。')
|
|
|
|
|
- ->danger()
|
|
|
|
|
- ->send();
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // 若未选知识点,允许随机(后台会按年级或薄弱点补全)
|
|
|
|
|
|
|
|
- // 自动生成试卷名称
|
|
|
|
|
|
|
+ // 自动生成试卷名称 - 使用 paper_id 作为唯一标识
|
|
|
if (empty($this->paperName)) {
|
|
if (empty($this->paperName)) {
|
|
|
- $studentName = '学生' . ($this->selectedStudentId ?? '未选择');
|
|
|
|
|
- // 如果有选择学生,尝试从数据库获取真实姓名
|
|
|
|
|
- if ($this->selectedStudentId) {
|
|
|
|
|
- try {
|
|
|
|
|
- $student = \App\Models\Student::where('student_id', $this->selectedStudentId)->first();
|
|
|
|
|
- if ($student && $student->name) {
|
|
|
|
|
- $studentName = $student->name;
|
|
|
|
|
- }
|
|
|
|
|
- } catch (\Exception $e) {
|
|
|
|
|
- \Illuminate\Support\Facades\Log::warning('获取学生姓名失败', [
|
|
|
|
|
- 'student_id' => $this->selectedStudentId,
|
|
|
|
|
- 'error' => $e->getMessage()
|
|
|
|
|
- ]);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- $this->paperName = $studentName . '_' . now()->format('Ymd_His') . '_智能试卷';
|
|
|
|
|
|
|
+ // 先生成一个临时名称,保存后会更新为真实的 paper_id
|
|
|
|
|
+ $this->paperName = 'paper_' . time() . '_' . bin2hex(random_bytes(4));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
$this->isGenerating = true;
|
|
$this->isGenerating = true;
|
|
@@ -586,15 +570,16 @@ class IntelligentExamGeneration extends Page
|
|
|
$learningAnalyticsService = app(LearningAnalyticsService::class);
|
|
$learningAnalyticsService = app(LearningAnalyticsService::class);
|
|
|
|
|
|
|
|
// 准备出卷参数
|
|
// 准备出卷参数
|
|
|
- $examParams = [
|
|
|
|
|
- 'student_id' => $this->selectedStudentId,
|
|
|
|
|
- 'grade' => $this->selectedGrade,
|
|
|
|
|
- 'total_questions' => $this->totalQuestions,
|
|
|
|
|
- 'kp_codes' => $this->selectedKpCodes,
|
|
|
|
|
- 'skills' => $this->selectedSkills,
|
|
|
|
|
- 'question_type_ratio' => $this->questionTypeRatio,
|
|
|
|
|
- 'difficulty_ratio' => $this->difficultyRatio,
|
|
|
|
|
- ];
|
|
|
|
|
|
|
+ $examParams = [
|
|
|
|
|
+ 'student_id' => $this->selectedStudentId,
|
|
|
|
|
+ 'grade' => $this->selectedGrade,
|
|
|
|
|
+ 'total_questions' => $this->totalQuestions,
|
|
|
|
|
+ 'kp_codes' => $this->selectedKpCodes,
|
|
|
|
|
+ 'skills' => $this->selectedSkills,
|
|
|
|
|
+ 'question_type_ratio' => $this->questionTypeRatio,
|
|
|
|
|
+ 'difficulty_ratio' => $this->difficultyRatio,
|
|
|
|
|
+ 'difficulty_levels' => $this->selectedDifficultyLevels,
|
|
|
|
|
+ ];
|
|
|
|
|
|
|
|
// 调用智能出卷API
|
|
// 调用智能出卷API
|
|
|
$result = $learningAnalyticsService->generateIntelligentExam($examParams);
|
|
$result = $learningAnalyticsService->generateIntelligentExam($examParams);
|
|
@@ -637,11 +622,12 @@ class IntelligentExamGeneration extends Page
|
|
|
|
|
|
|
|
$newResponse = $questionBankService->filterQuestions($params);
|
|
$newResponse = $questionBankService->filterQuestions($params);
|
|
|
|
|
|
|
|
- // 合并题目并去重
|
|
|
|
|
|
|
+ // 合并题目并去重,只保留有解题思路的题目
|
|
|
if (!empty($newResponse['data'])) {
|
|
if (!empty($newResponse['data'])) {
|
|
|
$existingIds = array_column($questions, 'id');
|
|
$existingIds = array_column($questions, 'id');
|
|
|
foreach ($newResponse['data'] as $newQ) {
|
|
foreach ($newResponse['data'] as $newQ) {
|
|
|
- if (!in_array($newQ['id'], $existingIds)) {
|
|
|
|
|
|
|
+ // 只添加有解题思路的题目
|
|
|
|
|
+ if (!in_array($newQ['id'], $existingIds) && !empty(trim($newQ['solution'] ?? ''))) {
|
|
|
$questions[] = $newQ;
|
|
$questions[] = $newQ;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -660,14 +646,17 @@ class IntelligentExamGeneration extends Page
|
|
|
// 3. 限制试卷题目数量为用户要求的数量
|
|
// 3. 限制试卷题目数量为用户要求的数量
|
|
|
if (count($questions) > $this->totalQuestions) {
|
|
if (count($questions) > $this->totalQuestions) {
|
|
|
// 根据题型配比和难度配比对题目进行筛选和排序
|
|
// 根据题型配比和难度配比对题目进行筛选和排序
|
|
|
|
|
+ // 注意:如果用户未选择难度(selectedDifficultyLevels为空),则传入null表示随机难度
|
|
|
|
|
+ $effectiveDifficultyCategory = !empty($this->selectedDifficultyLevels) ? $this->difficultyCategory : null;
|
|
|
$questions = $this->selectBestQuestions(
|
|
$questions = $this->selectBestQuestions(
|
|
|
$questions,
|
|
$questions,
|
|
|
$this->totalQuestions,
|
|
$this->totalQuestions,
|
|
|
- $this->difficultyCategory,
|
|
|
|
|
|
|
+ $effectiveDifficultyCategory,
|
|
|
$this->totalScore,
|
|
$this->totalScore,
|
|
|
$this->questionTypeRatio
|
|
$this->questionTypeRatio
|
|
|
);
|
|
);
|
|
|
- \Illuminate\Support\Facades\Log::info("从 " . count($questions) . " 道题中筛选出 " . count($questions) . " 道题,难度分类: {$this->difficultyCategory}, 总分: {$this->totalScore}");
|
|
|
|
|
|
|
+ $difficultyLog = $effectiveDifficultyCategory ?? '随机';
|
|
|
|
|
+ \Illuminate\Support\Facades\Log::info("从 " . count($questions) . " 道题中筛选出 " . count($questions) . " 道题,难度: {$difficultyLog}, 总分: {$this->totalScore}");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 3. 检查题型完整性(至少保证每种题型都有题目)
|
|
// 3. 检查题型完整性(至少保证每种题型都有题目)
|
|
@@ -698,20 +687,36 @@ class IntelligentExamGeneration extends Page
|
|
|
|
|
|
|
|
$newResponse = $questionBankService->filterQuestions($params);
|
|
$newResponse = $questionBankService->filterQuestions($params);
|
|
|
if (!empty($newResponse['data'])) {
|
|
if (!empty($newResponse['data'])) {
|
|
|
- $questions = array_merge($questions, $newResponse['data']);
|
|
|
|
|
|
|
+ // 只保留有解题思路的题目
|
|
|
|
|
+ $questionsWithSolution = array_filter($newResponse['data'], function($q) {
|
|
|
|
|
+ return !empty(trim($q['solution'] ?? ''));
|
|
|
|
|
+ });
|
|
|
|
|
+ $questions = array_merge($questions, array_values($questionsWithSolution));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 再次筛选
|
|
// 再次筛选
|
|
|
|
|
+ $effectiveDifficultyCategory = !empty($this->selectedDifficultyLevels) ? $this->difficultyCategory : null;
|
|
|
$questions = $this->selectBestQuestions(
|
|
$questions = $this->selectBestQuestions(
|
|
|
$questions,
|
|
$questions,
|
|
|
$this->totalQuestions,
|
|
$this->totalQuestions,
|
|
|
- $this->difficultyCategory,
|
|
|
|
|
|
|
+ $effectiveDifficultyCategory,
|
|
|
$this->totalScore,
|
|
$this->totalScore,
|
|
|
$this->questionTypeRatio
|
|
$this->questionTypeRatio
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 2. 为题目添加类型信息(如果缺失)
|
|
|
|
|
|
|
+ // 2. 最终过滤:确保所有题目都有解题思路
|
|
|
|
|
+ $questions = array_filter($questions, function($q) {
|
|
|
|
|
+ return !empty(trim($q['solution'] ?? ''));
|
|
|
|
|
+ });
|
|
|
|
|
+ $questions = array_values($questions); // 重新索引数组
|
|
|
|
|
+
|
|
|
|
|
+ \Illuminate\Support\Facades\Log::info('最终过滤后题目数量', [
|
|
|
|
|
+ 'total_questions' => count($questions),
|
|
|
|
|
+ 'filtered_message' => '已过滤掉没有解题思路的题目'
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 为题目添加类型信息(如果缺失)
|
|
|
foreach ($questions as &$question) {
|
|
foreach ($questions as &$question) {
|
|
|
if (!isset($question['question_type'])) {
|
|
if (!isset($question['question_type'])) {
|
|
|
$question['question_type'] = $this->determineQuestionType($question);
|
|
$question['question_type'] = $this->determineQuestionType($question);
|
|
@@ -741,12 +746,14 @@ class IntelligentExamGeneration extends Page
|
|
|
// 4. 保存到数据库
|
|
// 4. 保存到数据库
|
|
|
$questionBankService = app(QuestionBankService::class);
|
|
$questionBankService = app(QuestionBankService::class);
|
|
|
$paperId = $questionBankService->saveExamToDatabase($examData);
|
|
$paperId = $questionBankService->saveExamToDatabase($examData);
|
|
|
-// 如果保存返回 null,使用默认占位 ID,防止 UI 不显示
|
|
|
|
|
-if (empty($paperId)) {
|
|
|
|
|
- $paperId = 'demo_' . $this->selectedStudentId . '_' . now()->format('YmdHis');
|
|
|
|
|
-}
|
|
|
|
|
-\Illuminate\Support\Facades\Log::info('Generated paper ID: ' . $paperId);
|
|
|
|
|
-$this->generatedPaperId = $paperId;
|
|
|
|
|
|
|
+ // 如果保存返回 null,使用默认占位 ID,防止 UI 不显示
|
|
|
|
|
+ if (empty($paperId)) {
|
|
|
|
|
+ $paperId = 'demo_' . $this->selectedStudentId . '_' . now()->format('YmdHis');
|
|
|
|
|
+ }
|
|
|
|
|
+ // 使用 paper_id 作为试卷名称
|
|
|
|
|
+ $this->paperName = $paperId;
|
|
|
|
|
+ \Illuminate\Support\Facades\Log::info('Generated paper ID: ' . $paperId);
|
|
|
|
|
+ $this->generatedPaperId = $paperId;
|
|
|
// 将生成的试卷数据缓存,以便 PDF 预览时使用(缓存 1 小时)
|
|
// 将生成的试卷数据缓存,以便 PDF 预览时使用(缓存 1 小时)
|
|
|
\Illuminate\Support\Facades\Log::info('缓存试卷数据', [
|
|
\Illuminate\Support\Facades\Log::info('缓存试卷数据', [
|
|
|
'paper_id' => $paperId,
|
|
'paper_id' => $paperId,
|
|
@@ -906,15 +913,16 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
protected function selectBestQuestions(
|
|
protected function selectBestQuestions(
|
|
|
array $questions,
|
|
array $questions,
|
|
|
int $targetCount,
|
|
int $targetCount,
|
|
|
- string $difficultyCategory,
|
|
|
|
|
|
|
+ ?string $difficultyCategory, // null 表示随机难度
|
|
|
float $totalScore,
|
|
float $totalScore,
|
|
|
array $questionTypeRatio
|
|
array $questionTypeRatio
|
|
|
): array {
|
|
): array {
|
|
|
- // 去重:确保输入题目列表没有重复ID
|
|
|
|
|
|
|
+ // 去重并过滤:确保输入题目列表没有重复ID,且都有解题思路
|
|
|
$uniqueQuestions = [];
|
|
$uniqueQuestions = [];
|
|
|
foreach ($questions as $q) {
|
|
foreach ($questions as $q) {
|
|
|
$id = $q['id'] ?? $q['question_id'] ?? null;
|
|
$id = $q['id'] ?? $q['question_id'] ?? null;
|
|
|
- if ($id && !isset($uniqueQuestions[$id])) {
|
|
|
|
|
|
|
+ // 只保留有解题思路且不重复的题目
|
|
|
|
|
+ if ($id && !isset($uniqueQuestions[$id]) && !empty(trim($q['solution'] ?? ''))) {
|
|
|
$uniqueQuestions[$id] = $q;
|
|
$uniqueQuestions[$id] = $q;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -939,20 +947,68 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
'answer' => [], // 解答题
|
|
'answer' => [], // 解答题
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
|
|
+ // 统计所有题型的原始类型分布
|
|
|
|
|
+ $rawTypeStats = ['choice' => 0, 'fill' => 0, 'answer' => 0];
|
|
|
|
|
+
|
|
|
foreach ($questions as $question) {
|
|
foreach ($questions as $question) {
|
|
|
$type = $this->determineQuestionType($question);
|
|
$type = $this->determineQuestionType($question);
|
|
|
if (!isset($categorizedQuestions[$type])) {
|
|
if (!isset($categorizedQuestions[$type])) {
|
|
|
$type = 'answer';
|
|
$type = 'answer';
|
|
|
}
|
|
}
|
|
|
$categorizedQuestions[$type][] = $question;
|
|
$categorizedQuestions[$type][] = $question;
|
|
|
|
|
+
|
|
|
|
|
+ // 统计原始类型
|
|
|
|
|
+ $rawType = $question['question_type'] ?? $question['type'] ?? 'unknown';
|
|
|
|
|
+ if (strtolower($rawType) === 'choice') $rawTypeStats['choice']++;
|
|
|
|
|
+ if (strtolower($rawType) === 'fill') $rawTypeStats['fill']++;
|
|
|
|
|
+ if (strtolower($rawType) === 'answer') $rawTypeStats['answer']++;
|
|
|
|
|
+
|
|
|
|
|
+ // 记录选择题的详细信息(用于调试)
|
|
|
|
|
+ if ($type === 'choice') {
|
|
|
|
|
+ \Illuminate\Support\Facades\Log::debug('题目被分类为选择题', [
|
|
|
|
|
+ 'question_id' => $question['id'] ?? '',
|
|
|
|
|
+ 'question_type' => $question['question_type'] ?? '',
|
|
|
|
|
+ 'type' => $question['type'] ?? '',
|
|
|
|
|
+ 'stem_preview' => mb_substr($question['stem'] ?? '', 0, 50)
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ \Illuminate\Support\Facades\Log::info("题目类型分类统计", [
|
|
|
|
|
+ 'total_questions' => count($questions),
|
|
|
|
|
+ 'raw_type_stats' => $rawTypeStats, // 题库中题目的原始类型分布
|
|
|
|
|
+ 'final_type_stats' => [
|
|
|
|
|
+ 'choice' => count($categorizedQuestions['choice']),
|
|
|
|
|
+ 'fill' => count($categorizedQuestions['fill']),
|
|
|
|
|
+ 'answer' => count($categorizedQuestions['answer'])
|
|
|
|
|
+ ]
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
// 2. 根据难度分类筛选题目
|
|
// 2. 根据难度分类筛选题目
|
|
|
- $difficultyFilteredQuestions = $this->filterByDifficulty($categorizedQuestions, $difficultyCategory);
|
|
|
|
|
|
|
+ // 如果难度分类为null(用户未选择),则不过滤,保留所有题目(随机难度)
|
|
|
|
|
+ if ($difficultyCategory === null) {
|
|
|
|
|
+ \Illuminate\Support\Facades\Log::info("用户未选择难度,将随机生成所有难度的题目");
|
|
|
|
|
+ $difficultyFilteredQuestions = $categorizedQuestions;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $difficultyFilteredQuestions = $this->filterByDifficulty($categorizedQuestions, $difficultyCategory);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// 3. 根据题型配比计算每种题型应选择的题目数量
|
|
// 3. 根据题型配比计算每种题型应选择的题目数量
|
|
|
$selectedQuestions = [];
|
|
$selectedQuestions = [];
|
|
|
$selectedIds = []; // 用于追踪已选题目ID
|
|
$selectedIds = []; // 用于追踪已选题目ID
|
|
|
|
|
+ $typeTargets = $this->computeTypeTargets($targetCount, $questionTypeRatio);
|
|
|
|
|
+
|
|
|
|
|
+ // 检查每种题型的可用数量
|
|
|
|
|
+ $availableCounts = [
|
|
|
|
|
+ 'choice' => count($difficultyFilteredQuestions['choice'] ?? []),
|
|
|
|
|
+ 'fill' => count($difficultyFilteredQuestions['fill'] ?? []),
|
|
|
|
|
+ 'answer' => count($difficultyFilteredQuestions['answer'] ?? []),
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ \Illuminate\Support\Facades\Log::info("各题型可用数量", [
|
|
|
|
|
+ 'available' => $availableCounts,
|
|
|
|
|
+ 'targets' => $typeTargets
|
|
|
|
|
+ ]);
|
|
|
|
|
|
|
|
// 优先保证每种题型至少一题(适用于总题目数>=3的情况)
|
|
// 优先保证每种题型至少一题(适用于总题目数>=3的情况)
|
|
|
if ($targetCount >= 3) {
|
|
if ($targetCount >= 3) {
|
|
@@ -978,41 +1034,85 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 根据题型配比计算每种题型应选择的题目数量
|
|
// 根据题型配比计算每种题型应选择的题目数量
|
|
|
- foreach ($questionTypeRatio as $type => $ratio) {
|
|
|
|
|
- $typeKey = $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer');
|
|
|
|
|
-
|
|
|
|
|
- // 计算该类型目标数量
|
|
|
|
|
- $targetTypeCount = floor($targetCount * $ratio / 100);
|
|
|
|
|
|
|
+ foreach ($typeTargets as $typeKey => $targetTypeCount) {
|
|
|
|
|
+ if ($targetTypeCount <= 0) continue;
|
|
|
|
|
|
|
|
- // 调整目标数量:如果总数>=3,需要考虑已经选了的题目
|
|
|
|
|
- // 简单起见,我们计算总共需要的数量,然后减去已经选了的数量
|
|
|
|
|
- // 但这里为了保证比例,我们还是尽量多选
|
|
|
|
|
|
|
+ $availableQuestions = $difficultyFilteredQuestions[$typeKey] ?? [];
|
|
|
|
|
|
|
|
- if ($targetTypeCount <= 0) continue;
|
|
|
|
|
|
|
+ // 过滤掉已选的
|
|
|
|
|
+ $availableQuestions = array_filter($availableQuestions, function($q) use ($selectedIds) {
|
|
|
|
|
+ $id = $q['id'] ?? $q['question_id'];
|
|
|
|
|
+ return !in_array($id, $selectedIds);
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- 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);
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ // 还需要选多少:目标数量 - 已选该类型的数量
|
|
|
|
|
+ $currentTypeCount = 0;
|
|
|
|
|
+ foreach ($selectedQuestions as $sq) {
|
|
|
|
|
+ if ($this->determineQuestionType($sq) === $typeKey) {
|
|
|
|
|
+ $currentTypeCount++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // 如果没有可用题目了,跳过
|
|
|
|
|
- if (empty($availableQuestions)) continue;
|
|
|
|
|
|
|
+ $needToSelect = $targetTypeCount - $currentTypeCount;
|
|
|
|
|
|
|
|
|
|
+ if ($needToSelect > 0) {
|
|
|
$availableCount = count($availableQuestions);
|
|
$availableCount = count($availableQuestions);
|
|
|
- // 还需要选多少:目标数量 - 已选该类型的数量
|
|
|
|
|
- $currentTypeCount = 0;
|
|
|
|
|
- foreach ($selectedQuestions as $sq) {
|
|
|
|
|
- if ($this->determineQuestionType($sq) === $typeKey) {
|
|
|
|
|
- $currentTypeCount++;
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 如果该题型可用数量不足目标数量,从其他题型补充
|
|
|
|
|
+ if ($availableCount < $needToSelect) {
|
|
|
|
|
+ \Illuminate\Support\Facades\Log::warning("题型 {$typeKey} 数量不足", [
|
|
|
|
|
+ 'available' => $availableCount,
|
|
|
|
|
+ 'target' => $needToSelect,
|
|
|
|
|
+ 'will_supplement' => true
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ // 先选完该题型的所有可用题目
|
|
|
|
|
+ if ($availableCount > 0) {
|
|
|
|
|
+ foreach ($availableQuestions as $q) {
|
|
|
|
|
+ $selectedQuestions[] = $q;
|
|
|
|
|
+ $selectedIds[] = $q['id'] ?? $q['question_id'];
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- $needToSelect = $targetTypeCount - $currentTypeCount;
|
|
|
|
|
|
|
+ // 剩余题目从其他题型补充
|
|
|
|
|
+ $remainingNeed = $needToSelect - $availableCount;
|
|
|
|
|
+ $otherTypes = ['choice', 'fill', 'answer'];
|
|
|
|
|
+ $otherTypes = array_filter($otherTypes, fn($t) => $t !== $typeKey);
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($otherTypes as $otherType) {
|
|
|
|
|
+ if ($remainingNeed <= 0) break;
|
|
|
|
|
+
|
|
|
|
|
+ $otherQuestions = $difficultyFilteredQuestions[$otherType] ?? [];
|
|
|
|
|
+ // 过滤掉已选的
|
|
|
|
|
+ $otherQuestions = array_filter($otherQuestions, function($q) use ($selectedIds) {
|
|
|
|
|
+ $id = $q['id'] ?? $q['question_id'];
|
|
|
|
|
+ return !in_array($id, $selectedIds);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (!empty($otherQuestions)) {
|
|
|
|
|
+ $takeCount = min($remainingNeed, count($otherQuestions));
|
|
|
|
|
+ $randomKeys = array_rand($otherQuestions, $takeCount);
|
|
|
|
|
+ if (!is_array($randomKeys)) {
|
|
|
|
|
+ $randomKeys = [$randomKeys];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($randomKeys as $key) {
|
|
|
|
|
+ $q = $otherQuestions[$key];
|
|
|
|
|
+ $selectedQuestions[] = $q;
|
|
|
|
|
+ $selectedIds[] = $q['id'] ?? $q['question_id'];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $remainingNeed -= $takeCount;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- if ($needToSelect > 0) {
|
|
|
|
|
|
|
+ \Illuminate\Support\Facades\Log::info("题型 {$typeKey} 补充完成", [
|
|
|
|
|
+ 'original_need' => $needToSelect,
|
|
|
|
|
+ 'available' => $availableCount,
|
|
|
|
|
+ 'supplemented' => $needToSelect - $remainingNeed
|
|
|
|
|
+ ]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 该题型数量足够,正常选择
|
|
|
$takeCount = min($needToSelect, $availableCount, $targetCount - count($selectedQuestions));
|
|
$takeCount = min($needToSelect, $availableCount, $targetCount - count($selectedQuestions));
|
|
|
|
|
|
|
|
if ($takeCount > 0) {
|
|
if ($takeCount > 0) {
|
|
@@ -1068,6 +1168,67 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
return $finalQuestions;
|
|
return $finalQuestions;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 根据比例与总题数计算各题型目标数量,四舍五入并校正总数
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function computeTypeTargets(int $targetCount, array $questionTypeRatio): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $map = [
|
|
|
|
|
+ '选择题' => 'choice',
|
|
|
|
|
+ '填空题' => 'fill',
|
|
|
|
|
+ '解答题' => 'answer',
|
|
|
|
|
+ ];
|
|
|
|
|
+ $targets = ['choice' => 0, 'fill' => 0, 'answer' => 0];
|
|
|
|
|
+
|
|
|
|
|
+ // 初步分配
|
|
|
|
|
+ foreach ($questionTypeRatio as $label => $ratio) {
|
|
|
|
|
+ $key = $map[$label] ?? null;
|
|
|
|
|
+ if (!$key) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $count = (int) round($targetCount * ($ratio / 100));
|
|
|
|
|
+ if ($ratio > 0 && $count < 1) {
|
|
|
|
|
+ $count = 1; // 有比例就至少 1 道
|
|
|
|
|
+ }
|
|
|
|
|
+ $targets[$key] = $count;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $currentSum = array_sum($targets);
|
|
|
|
|
+ if ($currentSum === 0) {
|
|
|
|
|
+ $targets['answer'] = $targetCount;
|
|
|
|
|
+ return $targets;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 总数超出则递减最多的类型(>1 时才减)
|
|
|
|
|
+ while ($currentSum > $targetCount) {
|
|
|
|
|
+ arsort($targets);
|
|
|
|
|
+ foreach ($targets as $k => $v) {
|
|
|
|
|
+ if ($v > 1) {
|
|
|
|
|
+ $targets[$k]--;
|
|
|
|
|
+ $currentSum--;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 总数不足则按比例最高的类型补
|
|
|
|
|
+ if ($currentSum < $targetCount) {
|
|
|
|
|
+ $ratioByKey = [
|
|
|
|
|
+ 'choice' => $questionTypeRatio['选择题'] ?? 0,
|
|
|
|
|
+ 'fill' => $questionTypeRatio['填空题'] ?? 0,
|
|
|
|
|
+ 'answer' => $questionTypeRatio['解答题'] ?? 0,
|
|
|
|
|
+ ];
|
|
|
|
|
+ while ($currentSum < $targetCount) {
|
|
|
|
|
+ arsort($ratioByKey);
|
|
|
|
|
+ $key = array_key_first($ratioByKey);
|
|
|
|
|
+ $targets[$key]++;
|
|
|
|
|
+ $currentSum++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $targets;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 检查题型完整性,确保每种题型至少有一题
|
|
* 检查题型完整性,确保每种题型至少有一题
|
|
|
*/
|
|
*/
|
|
@@ -1181,8 +1342,8 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
{
|
|
{
|
|
|
$filtered = [];
|
|
$filtered = [];
|
|
|
$difficultyRanges = [
|
|
$difficultyRanges = [
|
|
|
- '基础' => [0, 0.4],
|
|
|
|
|
- '中等' => [0.3, 0.7],
|
|
|
|
|
|
|
+ '基础' => [0, 0.6], // 扩大基础难度上限,从0.4提升到0.6
|
|
|
|
|
+ '中等' => [0.4, 0.8], // 扩大中等难度范围
|
|
|
'拔高' => [0.6, 1.0]
|
|
'拔高' => [0.6, 1.0]
|
|
|
];
|
|
];
|
|
|
|
|
|
|
@@ -1194,12 +1355,8 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
$difficulty = floatval($question['difficulty'] ?? 0.5);
|
|
$difficulty = floatval($question['difficulty'] ?? 0.5);
|
|
|
if ($difficulty >= $targetRange[0] && $difficulty <= $targetRange[1]) {
|
|
if ($difficulty >= $targetRange[0] && $difficulty <= $targetRange[1]) {
|
|
|
$filtered[$type][] = $question;
|
|
$filtered[$type][] = $question;
|
|
|
- } else {
|
|
|
|
|
- // 保留部分越界题目(如果该难度题目不足)
|
|
|
|
|
- if (count($filtered[$type]) < 2) {
|
|
|
|
|
- $filtered[$type][] = $question;
|
|
|
|
|
- }
|
|
|
|
|
}
|
|
}
|
|
|
|
|
+ // 不再保留越界题目,严格按难度范围筛选
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1219,26 +1376,33 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
*/
|
|
*/
|
|
|
protected function determineQuestionType(array $question): string
|
|
protected function determineQuestionType(array $question): string
|
|
|
{
|
|
{
|
|
|
- // 0. 如果题目已有明确类型,直接返回
|
|
|
|
|
- if (!empty($question['question_type'])) {
|
|
|
|
|
- $type = $question['question_type'];
|
|
|
|
|
- if ($type === 'choice' || $type === '选择题') return 'choice';
|
|
|
|
|
- if ($type === 'fill' || $type === '填空题') return 'fill';
|
|
|
|
|
- if ($type === 'answer' || $type === '解答题') return 'answer';
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (!empty($question['type'])) {
|
|
|
|
|
- $type = $question['type'];
|
|
|
|
|
- if ($type === 'choice' || $type === '选择题') return 'choice';
|
|
|
|
|
- if ($type === 'fill' || $type === '填空题') return 'fill';
|
|
|
|
|
- if ($type === 'answer' || $type === '解答题') return 'answer';
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- $tags = $question['tags'] ?? '';
|
|
|
|
|
|
|
+ // 优先根据题目内容判断(而不是数据库字段)
|
|
|
$stem = $question['stem'] ?? $question['content'] ?? '';
|
|
$stem = $question['stem'] ?? $question['content'] ?? '';
|
|
|
|
|
+ $tags = $question['tags'] ?? '';
|
|
|
$skills = $question['skills'] ?? [];
|
|
$skills = $question['skills'] ?? [];
|
|
|
|
|
|
|
|
- // 1. 根据技能点判断
|
|
|
|
|
|
|
+ // 1. 根据题干内容判断 - 选择题特征:必须包含 A. B. C. D. 选项(至少2个)
|
|
|
|
|
+ if (is_string($stem)) {
|
|
|
|
|
+ // 选择题特征:必须包含 A. B. C. D. 四个选项(至少2个)
|
|
|
|
|
+ $hasOptionA = preg_match('/\bA\s*[\.\、\:]/', $stem) || preg_match('/\(A\)/', $stem) || preg_match('/^A[\.\s]/', $stem);
|
|
|
|
|
+ $hasOptionB = preg_match('/\bB\s*[\.\、\:]/', $stem) || preg_match('/\(B\)/', $stem) || preg_match('/^B[\.\s]/', $stem);
|
|
|
|
|
+ $hasOptionC = preg_match('/\bC\s*[\.\、\:]/', $stem) || preg_match('/\(C\)/', $stem) || preg_match('/^C[\.\s]/', $stem);
|
|
|
|
|
+ $hasOptionD = preg_match('/\bD\s*[\.\、\:]/', $stem) || preg_match('/\(D\)/', $stem) || preg_match('/^D[\.\s]/', $stem);
|
|
|
|
|
+ $hasOptionE = preg_match('/\bE\s*[\.\、\:]/', $stem) || preg_match('/\(E\)/', $stem) || preg_match('/^E[\.\s]/', $stem);
|
|
|
|
|
+
|
|
|
|
|
+ // 至少有2个选项就认为是选择题(降低阈值)
|
|
|
|
|
+ $optionCount = ($hasOptionA ? 1 : 0) + ($hasOptionB ? 1 : 0) + ($hasOptionC ? 1 : 0) + ($hasOptionD ? 1 : 0) + ($hasOptionE ? 1 : 0);
|
|
|
|
|
+ if ($optionCount >= 2) {
|
|
|
|
|
+ return 'choice';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 检查是否有"( )"或"( )"括号,这通常是选择题的标志
|
|
|
|
|
+ if (preg_match('/(\s*)|\(\s*\)/', $stem) && (strpos($stem, 'A.') !== false || strpos($stem, 'B.') !== false || strpos($stem, 'C.') !== false || strpos($stem, 'D.') !== false)) {
|
|
|
|
|
+ return 'choice';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 根据技能点判断
|
|
|
if (is_array($skills)) {
|
|
if (is_array($skills)) {
|
|
|
$skillsStr = implode(',', $skills);
|
|
$skillsStr = implode(',', $skills);
|
|
|
if (strpos($skillsStr, '选择题') !== false) return 'choice';
|
|
if (strpos($skillsStr, '选择题') !== false) return 'choice';
|
|
@@ -1246,7 +1410,22 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
if (strpos($skillsStr, '解答题') !== false) return 'answer';
|
|
if (strpos($skillsStr, '解答题') !== false) return 'answer';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 2. 根据标签判断
|
|
|
|
|
|
|
+ // 3. 根据题目已有类型字段判断(作为后备)
|
|
|
|
|
+ if (!empty($question['question_type'])) {
|
|
|
|
|
+ $type = strtolower(trim($question['question_type']));
|
|
|
|
|
+ if (in_array($type, ['choice', '选择题', 'choice question'])) return 'choice';
|
|
|
|
|
+ if (in_array($type, ['fill', '填空题', 'fill in the blank'])) return 'fill';
|
|
|
|
|
+ if (in_array($type, ['answer', '解答题', 'calculation', '简答题'])) return 'answer';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!empty($question['type'])) {
|
|
|
|
|
+ $type = strtolower(trim($question['type']));
|
|
|
|
|
+ if (in_array($type, ['choice', '选择题', 'choice question'])) return 'choice';
|
|
|
|
|
+ if (in_array($type, ['fill', '填空题', 'fill in the blank'])) return 'fill';
|
|
|
|
|
+ if (in_array($type, ['answer', '解答题', 'calculation', '简答题'])) return 'answer';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 根据标签判断
|
|
|
if (is_string($tags)) {
|
|
if (is_string($tags)) {
|
|
|
if (strpos($tags, '选择') !== false || strpos($tags, '选择题') !== false) {
|
|
if (strpos($tags, '选择') !== false || strpos($tags, '选择题') !== false) {
|
|
|
return 'choice';
|
|
return 'choice';
|
|
@@ -1259,22 +1438,7 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 3. 根据题干内容判断 - 必须有明确的选项格式才是选择题
|
|
|
|
|
- if (is_string($stem)) {
|
|
|
|
|
- // 选择题特征:必须包含 A. B. C. D. 四个选项(至少3个)
|
|
|
|
|
- $hasOptionA = preg_match('/\bA\s*[\.\、\:]/', $stem) || preg_match('/\(A\)/', $stem);
|
|
|
|
|
- $hasOptionB = preg_match('/\bB\s*[\.\、\:]/', $stem) || preg_match('/\(B\)/', $stem);
|
|
|
|
|
- $hasOptionC = preg_match('/\bC\s*[\.\、\:]/', $stem) || preg_match('/\(C\)/', $stem);
|
|
|
|
|
- $hasOptionD = preg_match('/\bD\s*[\.\、\:]/', $stem) || preg_match('/\(D\)/', $stem);
|
|
|
|
|
-
|
|
|
|
|
- // 至少有3个选项才认为是选择题
|
|
|
|
|
- $optionCount = ($hasOptionA ? 1 : 0) + ($hasOptionB ? 1 : 0) + ($hasOptionC ? 1 : 0) + ($hasOptionD ? 1 : 0);
|
|
|
|
|
- if ($optionCount >= 3) {
|
|
|
|
|
- return 'choice';
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 4. 填空题特征:连续下划线或明显的填空括号
|
|
|
|
|
|
|
+ // 5. 填空题特征:连续下划线或明显的填空括号
|
|
|
if (is_string($stem)) {
|
|
if (is_string($stem)) {
|
|
|
// 检查填空题特征:连续下划线
|
|
// 检查填空题特征:连续下划线
|
|
|
if (preg_match('/_{3,}/', $stem) || strpos($stem, '____') !== false) {
|
|
if (preg_match('/_{3,}/', $stem) || strpos($stem, '____') !== false) {
|
|
@@ -1286,7 +1450,7 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 5. 根据题干长度和内容判断
|
|
|
|
|
|
|
+ // 6. 根据题干内容关键词判断
|
|
|
if (is_string($stem)) {
|
|
if (is_string($stem)) {
|
|
|
// 有证明、解答、计算、求证等关键词的是解答题
|
|
// 有证明、解答、计算、求证等关键词的是解答题
|
|
|
if (preg_match('/(证明|求证|解方程|计算:|求解|推导|说明理由)/', $stem)) {
|
|
if (preg_match('/(证明|求证|解方程|计算:|求解|推导|说明理由)/', $stem)) {
|
|
@@ -1408,6 +1572,26 @@ Cache::put('generated_exam_' . $paperId, $examData, now()->addHour());
|
|
|
return $questions;
|
|
return $questions;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 快捷设置题型配比方案
|
|
|
|
|
+ */
|
|
|
|
|
+ public function applyRatioPreset(string $preset): void
|
|
|
|
|
+ {
|
|
|
|
|
+ if ($preset === '4-2-4') {
|
|
|
|
|
+ $this->questionTypeRatio = [
|
|
|
|
|
+ '选择题' => 40,
|
|
|
|
|
+ '填空题' => 20,
|
|
|
|
|
+ '解答题' => 40,
|
|
|
|
|
+ ];
|
|
|
|
|
+ } elseif ($preset === '5-2-3') {
|
|
|
|
|
+ $this->questionTypeRatio = [
|
|
|
|
|
+ '选择题' => 50,
|
|
|
|
|
+ '填空题' => 20,
|
|
|
|
|
+ '解答题' => 30,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 保留旧方法以兼容(但不再使用)
|
|
* 保留旧方法以兼容(但不再使用)
|
|
|
*/
|
|
*/
|