|
@@ -3,9 +3,12 @@
|
|
|
namespace App\Jobs;
|
|
namespace App\Jobs;
|
|
|
|
|
|
|
|
use App\Models\MistakeRecord;
|
|
use App\Models\MistakeRecord;
|
|
|
|
|
+use App\Services\DifficultyDistributionService;
|
|
|
use App\Services\LearningAnalyticsService;
|
|
use App\Services\LearningAnalyticsService;
|
|
|
use App\Services\QuestionBankService;
|
|
use App\Services\QuestionBankService;
|
|
|
|
|
+use App\Services\QuestionPayloadMapper;
|
|
|
use App\Services\TaskManager;
|
|
use App\Services\TaskManager;
|
|
|
|
|
+use App\Services\WrongQuestionPracticePlanService;
|
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Bus\Queueable;
|
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
@@ -35,6 +38,7 @@ class AssembleExamTaskJob implements ShouldQueue
|
|
|
public function handle(
|
|
public function handle(
|
|
|
LearningAnalyticsService $learningAnalyticsService,
|
|
LearningAnalyticsService $learningAnalyticsService,
|
|
|
QuestionBankService $questionBankService,
|
|
QuestionBankService $questionBankService,
|
|
|
|
|
+ WrongQuestionPracticePlanService $wrongQuestionPracticePlanService,
|
|
|
TaskManager $taskManager
|
|
TaskManager $taskManager
|
|
|
): void {
|
|
): void {
|
|
|
$task = $taskManager->getTaskStatus($this->taskId);
|
|
$task = $taskManager->getTaskStatus($this->taskId);
|
|
@@ -62,35 +66,91 @@ class AssembleExamTaskJob implements ShouldQueue
|
|
|
$result = null;
|
|
$result = null;
|
|
|
$diagnosticChapterId = null;
|
|
$diagnosticChapterId = null;
|
|
|
$explanationKpCodes = null;
|
|
$explanationKpCodes = null;
|
|
|
|
|
+ $wrongQuestionPracticePlan = null;
|
|
|
|
|
|
|
|
- if ($assembleType === 15) {
|
|
|
|
|
- // assemble_type=15(展示类型「错题再练」):paper_ids 为题库 question_id,须在该学生 mistake_records 中存在;与 assemble_type=5(卷 id 追练)分离
|
|
|
|
|
|
|
+ if (in_array($assembleType, [15, 16], true)) {
|
|
|
|
|
+ // assemble_type=15(错题再练):paper_ids 为题库 question_id,直组原错题。
|
|
|
|
|
+ // assemble_type=16(错题追练):paper_ids 仍为题库 question_id,但只用来生成知识点组卷计划。
|
|
|
$questionIdList = $this->normalizeBankQuestionIdsList($paperIds);
|
|
$questionIdList = $this->normalizeBankQuestionIdsList($paperIds);
|
|
|
if ($questionIdList === []) {
|
|
if ($questionIdList === []) {
|
|
|
- $taskManager->markTaskFailed($this->taskId, '错题再练组卷需提供 paper_ids(题库题目 id)');
|
|
|
|
|
|
|
+ $taskManager->markTaskFailed($this->taskId, ($assembleType === 16 ? '错题追练' : '错题再练').'组卷需提供 paper_ids(题库题目 id)');
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- $strict = $this->resolveMistakeQuestionIdsStrictForStudent(
|
|
|
|
|
- (string) $data['student_id'],
|
|
|
|
|
- [],
|
|
|
|
|
- array_map(static fn ($id) => (string) $id, $questionIdList)
|
|
|
|
|
- );
|
|
|
|
|
- if (! ($strict['ok'] ?? false)) {
|
|
|
|
|
- $taskManager->markTaskFailed($this->taskId, $strict['message'] ?? '错题校验失败');
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- $questionIds = $strict['question_ids'];
|
|
|
|
|
|
|
+ if ($assembleType === 16) {
|
|
|
|
|
+ $wrongQuestionPracticePlan = $wrongQuestionPracticePlanService->build(
|
|
|
|
|
+ (string) $data['student_id'],
|
|
|
|
|
+ $questionIdList,
|
|
|
|
|
+ (int) ($data['total_questions'] ?? config('question_bank.default_total_questions'))
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- $bankQuestions = $questionBankService->getQuestionsByIds($questionIds)['data'] ?? [];
|
|
|
|
|
- if (empty($bankQuestions)) {
|
|
|
|
|
- $taskManager->markTaskFailed($this->taskId, '错题对应题库题目不可用');
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (empty($wrongQuestionPracticePlan['usable'])) {
|
|
|
|
|
+ $taskManager->markTaskFailed($this->taskId, $wrongQuestionPracticePlan['message'] ?? '错题追练没有可用的知识点题目');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes'] ?? []);
|
|
|
|
|
- $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
|
|
|
|
|
- $paperName = $data['paper_name'] ?? ('错题再练_'.$data['student_id'].'_'.now()->format('Ymd_His'));
|
|
|
|
|
|
|
+ $paperName = $data['paper_name'] ?? ('错题追练_'.$data['student_id'].'_'.now()->format('Ymd_His'));
|
|
|
|
|
+ $params = [
|
|
|
|
|
+ 'student_id' => $data['student_id'],
|
|
|
|
|
+ 'grade' => $data['grade'] ?? null,
|
|
|
|
|
+ 'total_questions' => (int) ($wrongQuestionPracticePlan['target_questions'] ?? ($data['total_questions'] ?? config('question_bank.default_total_questions'))),
|
|
|
|
|
+ 'kp_codes' => $wrongQuestionPracticePlan['kp_code_list'] ?? [],
|
|
|
|
|
+ 'skills' => $data['skills'] ?? [],
|
|
|
|
|
+ 'question_type_ratio' => $wrongQuestionPracticePlan['question_type_ratio'] ?? $questionTypeRatio,
|
|
|
|
|
+ 'difficulty_category' => $difficultyCategory,
|
|
|
|
|
+ 'assemble_type' => 2,
|
|
|
|
|
+ 'exam_type' => 'knowledge',
|
|
|
|
|
+ 'paper_ids' => [],
|
|
|
|
|
+ 'textbook_id' => $data['textbook_id'] ?? null,
|
|
|
|
|
+ 'end_catalog_id' => $data['end_catalog_id'] ?? null,
|
|
|
|
|
+ 'chapter_id_list' => $data['chapter_id_list'] ?? null,
|
|
|
|
|
+ 'kp_code_list' => $wrongQuestionPracticePlan['kp_code_list'] ?? [],
|
|
|
|
|
+ 'kp_target_counts' => $wrongQuestionPracticePlan['kp_target_counts'] ?? [],
|
|
|
|
|
+ 'target_difficulty_by_kp' => $wrongQuestionPracticePlan['target_difficulty_by_kp'] ?? [],
|
|
|
|
|
+ 'max_difficulty_by_kp' => $wrongQuestionPracticePlan['max_difficulty_by_kp'] ?? [],
|
|
|
|
|
+ 'type_targets_by_kp' => $wrongQuestionPracticePlan['type_targets_by_kp'] ?? [],
|
|
|
|
|
+ 'exclude_question_ids' => $wrongQuestionPracticePlan['exclude_question_ids'] ?? [],
|
|
|
|
|
+ 'wrong_question_practice_plan' => $wrongQuestionPracticePlan,
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ $result = $learningAnalyticsService->generateIntelligentExam($params);
|
|
|
|
|
+ if (empty($result['success'])) {
|
|
|
|
|
+ $taskManager->markTaskFailed($this->taskId, $result['message'] ?? '错题追练组卷未生成题目');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (isset($result['stats']['difficulty_category'])) {
|
|
|
|
|
+ $difficultyCategory = $result['stats']['difficulty_category'];
|
|
|
|
|
+ }
|
|
|
|
|
+ $diagnosticChapterId = $result['diagnostic_chapter_id'] ?? null;
|
|
|
|
|
+ $explanationKpCodes = $result['explanation_kp_codes'] ?? null;
|
|
|
|
|
+ $result['assemble_type'] = 16;
|
|
|
|
|
+ $questions = $this->hydrateQuestions($result['questions'] ?? [], $wrongQuestionPracticePlan['kp_code_list'] ?? []);
|
|
|
|
|
+ if (empty($questions)) {
|
|
|
|
|
+ $taskManager->markTaskFailed($this->taskId, '错题追练组卷未生成有效题目');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $strict = $this->resolveMistakeQuestionIdsStrictForStudent(
|
|
|
|
|
+ (string) $data['student_id'],
|
|
|
|
|
+ [],
|
|
|
|
|
+ array_map(static fn ($id) => (string) $id, $questionIdList)
|
|
|
|
|
+ );
|
|
|
|
|
+ if (! ($strict['ok'] ?? false)) {
|
|
|
|
|
+ $taskManager->markTaskFailed($this->taskId, $strict['message'] ?? '错题校验失败');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ $questionIds = $strict['question_ids'];
|
|
|
|
|
+ $bankQuestions = $questionBankService->getQuestionsByIds($questionIds)['data'] ?? [];
|
|
|
|
|
+ if (empty($bankQuestions)) {
|
|
|
|
|
+ $taskManager->markTaskFailed($this->taskId, '错题对应题库题目不可用');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes'] ?? []);
|
|
|
|
|
+ $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
|
|
|
|
|
+ $paperName = $data['paper_name'] ?? ('错题再练_'.$data['student_id'].'_'.now()->format('Ymd_His'));
|
|
|
|
|
+ }
|
|
|
} elseif (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
|
|
} elseif (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
|
|
|
// assemble_type=5 时 mistake_ids / mistake_question_ids 须严格归属该学生;其它类型走宽松解析。
|
|
// assemble_type=5 时 mistake_ids / mistake_question_ids 须严格归属该学生;其它类型走宽松解析。
|
|
|
if ($assembleType === 5) {
|
|
if ($assembleType === 5) {
|
|
@@ -176,6 +236,9 @@ class AssembleExamTaskJob implements ShouldQueue
|
|
|
$totalScore = array_sum(array_column($questions, 'score'));
|
|
$totalScore = array_sum(array_column($questions, 'score'));
|
|
|
|
|
|
|
|
$finalAssembleType = ($result !== null && isset($result['assemble_type'])) ? $result['assemble_type'] : $assembleType;
|
|
$finalAssembleType = ($result !== null && isset($result['assemble_type'])) ? $result['assemble_type'] : $assembleType;
|
|
|
|
|
+ if ($finalAssembleType === 16) {
|
|
|
|
|
+ $difficultyCategory = $this->deriveDifficultyCategoryFromSelectedDistribution($questions);
|
|
|
|
|
+ }
|
|
|
$requestPayloadParams = $data['request_payload_snapshot_raw'] ?? null;
|
|
$requestPayloadParams = $data['request_payload_snapshot_raw'] ?? null;
|
|
|
$phaseStartedAt = microtime(true);
|
|
$phaseStartedAt = microtime(true);
|
|
|
$paperId = $questionBankService->saveExamToDatabase([
|
|
$paperId = $questionBankService->saveExamToDatabase([
|
|
@@ -205,11 +268,18 @@ class AssembleExamTaskJob implements ShouldQueue
|
|
|
|
|
|
|
|
$finalStats = $result['stats'] ?? [
|
|
$finalStats = $result['stats'] ?? [
|
|
|
'total_selected' => count($questions),
|
|
'total_selected' => count($questions),
|
|
|
- 'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds) || $assembleType === 15,
|
|
|
|
|
|
|
+ 'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds) || in_array($assembleType, [15, 16], true),
|
|
|
];
|
|
];
|
|
|
|
|
+ if ($wrongQuestionPracticePlan !== null) {
|
|
|
|
|
+ $finalStats['wrong_question_practice_plan'] = $wrongQuestionPracticePlan;
|
|
|
|
|
+ }
|
|
|
if (! isset($finalStats['difficulty_category'])) {
|
|
if (! isset($finalStats['difficulty_category'])) {
|
|
|
$finalStats['difficulty_category'] = $difficultyCategory;
|
|
$finalStats['difficulty_category'] = $difficultyCategory;
|
|
|
}
|
|
}
|
|
|
|
|
+ if ($finalAssembleType === 16) {
|
|
|
|
|
+ $finalStats['difficulty_category'] = $difficultyCategory;
|
|
|
|
|
+ $finalStats['final_avg_difficulty'] = $this->averageQuestionDifficulty($questions);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
$taskManager->updateTaskStatus($this->taskId, [
|
|
$taskManager->updateTaskStatus($this->taskId, [
|
|
|
'paper_id' => $paperId,
|
|
'paper_id' => $paperId,
|
|
@@ -361,7 +431,7 @@ class AssembleExamTaskJob implements ShouldQueue
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * assemble_type=15 时 paper_ids 承载题库题目 id:纯数字字符串转为 int,去重并保持首次出现顺序。
|
|
|
|
|
|
|
+ * assemble_type=15/16 时 paper_ids 承载题库题目 id:纯数字字符串转为 int,去重并保持首次出现顺序。
|
|
|
*
|
|
*
|
|
|
* @return array<int, int|string>
|
|
* @return array<int, int|string>
|
|
|
*/
|
|
*/
|
|
@@ -403,25 +473,10 @@ class AssembleExamTaskJob implements ShouldQueue
|
|
|
|
|
|
|
|
private function hydrateQuestions(array $questions, array $kpCodes): array
|
|
private function hydrateQuestions(array $questions, array $kpCodes): array
|
|
|
{
|
|
{
|
|
|
|
|
+ $mapper = app(QuestionPayloadMapper::class);
|
|
|
$normalized = [];
|
|
$normalized = [];
|
|
|
foreach ($questions as $question) {
|
|
foreach ($questions as $question) {
|
|
|
- $type = $this->normalizeQuestionTypeKey($question['question_type'] ?? $question['type'] ?? '') ?? $this->guessType($question);
|
|
|
|
|
- $score = $question['score'] ?? $this->defaultScore($type);
|
|
|
|
|
- $normalized[] = [
|
|
|
|
|
- 'id' => $question['id'] ?? $question['question_id'] ?? null,
|
|
|
|
|
- 'question_id' => $question['question_id'] ?? null,
|
|
|
|
|
- 'question_type' => $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer'),
|
|
|
|
|
- 'stem' => $question['stem'] ?? $question['content'] ?? ($question['question_text'] ?? ''),
|
|
|
|
|
- 'content' => $question['content'] ?? $question['stem'] ?? '',
|
|
|
|
|
- 'options' => $question['options'] ?? ($question['choices'] ?? []),
|
|
|
|
|
- 'answer' => $question['answer'] ?? $question['correct_answer'] ?? '',
|
|
|
|
|
- 'solution' => $question['solution'] ?? '',
|
|
|
|
|
- 'difficulty' => isset($question['difficulty']) ? (float) $question['difficulty'] : 0.5,
|
|
|
|
|
- 'score' => $score,
|
|
|
|
|
- 'estimated_time' => $question['estimated_time'] ?? 300,
|
|
|
|
|
- 'kp' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
|
|
|
|
|
- 'kp_code' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
|
|
|
|
|
- ];
|
|
|
|
|
|
|
+ $normalized[] = $mapper->fromArray($question, $kpCodes);
|
|
|
}
|
|
}
|
|
|
return array_values(array_filter($normalized, fn ($q) => ! empty($q['id'])));
|
|
return array_values(array_filter($normalized, fn ($q) => ! empty($q['id'])));
|
|
|
}
|
|
}
|
|
@@ -440,28 +495,6 @@ class AssembleExamTaskJob implements ShouldQueue
|
|
|
return $questions;
|
|
return $questions;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private function guessType(array $question): string
|
|
|
|
|
- {
|
|
|
|
|
- if (! empty($question['options']) && is_array($question['options'])) {
|
|
|
|
|
- return '选择题';
|
|
|
|
|
- }
|
|
|
|
|
- $content = $question['stem'] ?? $question['content'] ?? '';
|
|
|
|
|
- if (is_string($content) && (strpos($content, '____') !== false || strpos($content, '()') !== false)) {
|
|
|
|
|
- return '填空题';
|
|
|
|
|
- }
|
|
|
|
|
- return '解答题';
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private function defaultScore(string $type): int
|
|
|
|
|
- {
|
|
|
|
|
- return match ($type) {
|
|
|
|
|
- '选择题' => 5,
|
|
|
|
|
- '填空题' => 5,
|
|
|
|
|
- '解答题' => 10,
|
|
|
|
|
- default => 5,
|
|
|
|
|
- };
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
private function sortQuestionsWithinTypeByDifficulty(array $questions): array
|
|
private function sortQuestionsWithinTypeByDifficulty(array $questions): array
|
|
|
{
|
|
{
|
|
|
$grouped = ['choice' => [], 'fill' => [], 'answer' => []];
|
|
$grouped = ['choice' => [], 'fill' => [], 'answer' => []];
|
|
@@ -488,6 +521,75 @@ class AssembleExamTaskJob implements ShouldQueue
|
|
|
return $sorted;
|
|
return $sorted;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private function deriveDifficultyCategoryFromSelectedDistribution(array $questions): int
|
|
|
|
|
+ {
|
|
|
|
|
+ if ($questions === []) {
|
|
|
|
|
+ return 1;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $service = app(DifficultyDistributionService::class);
|
|
|
|
|
+ $total = count($questions);
|
|
|
|
|
+ $bestCategory = 1;
|
|
|
|
|
+ $bestScore = null;
|
|
|
|
|
+
|
|
|
|
|
+ foreach ([0, 1, 2, 3, 4] as $category) {
|
|
|
|
|
+ $actualBuckets = $service->groupQuestionsByDifficultyRange($questions, $category);
|
|
|
|
|
+ $expectedBuckets = $this->expectedDifficultyBucketCounts($service, $category, $total);
|
|
|
|
|
+ $score = 0;
|
|
|
|
|
+
|
|
|
|
|
+ foreach (['primary_low', 'primary_medium', 'primary_high', 'secondary', 'other'] as $bucketKey) {
|
|
|
|
|
+ $score += abs(count($actualBuckets[$bucketKey] ?? []) - ($expectedBuckets[$bucketKey] ?? 0));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if ($bestScore === null || $score < $bestScore) {
|
|
|
|
|
+ $bestScore = $score;
|
|
|
|
|
+ $bestCategory = $category;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $bestCategory;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * @return array{primary_low: int, primary_medium: int, primary_high: int, secondary: int, other: int}
|
|
|
|
|
+ */
|
|
|
|
|
+ private function expectedDifficultyBucketCounts(DifficultyDistributionService $service, int $category, int $totalQuestions): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $expected = [
|
|
|
|
|
+ 'primary_low' => 0,
|
|
|
|
|
+ 'primary_medium' => 0,
|
|
|
|
|
+ 'primary_high' => 0,
|
|
|
|
|
+ 'secondary' => 0,
|
|
|
|
|
+ 'other' => 0,
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($service->calculateDistribution($category, $totalQuestions) as $level => $config) {
|
|
|
|
|
+ $bucketKey = $service->mapDifficultyLevelToRangeKey((string) $level, $category);
|
|
|
|
|
+ $expected[$bucketKey] = ($expected[$bucketKey] ?? 0) + (int) ($config['count'] ?? 0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $expected;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function averageQuestionDifficulty(array $questions): float
|
|
|
|
|
+ {
|
|
|
|
|
+ if ($questions === []) {
|
|
|
|
|
+ return 0.0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $sum = 0.0;
|
|
|
|
|
+ foreach ($questions as $question) {
|
|
|
|
|
+ $difficulty = $question['difficulty'] ?? 0.0;
|
|
|
|
|
+ $value = is_numeric($difficulty) ? (float) $difficulty : 0.0;
|
|
|
|
|
+ if ($value > 1) {
|
|
|
|
|
+ $value = $value / 5;
|
|
|
|
|
+ }
|
|
|
|
|
+ $sum += max(0.0, min(1.0, $value));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return round($sum / count($questions), 4);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private function normalizeQuestionType(string $type): string
|
|
private function normalizeQuestionType(string $type): string
|
|
|
{
|
|
{
|
|
|
$type = strtolower(trim($type));
|
|
$type = strtolower(trim($type));
|