applyFilters(Question::query(), $filters); $paginator = $query->orderByDesc('id')->paginate($perPage, ['*'], 'page', $page); $data = $this->mapQuestions(collect($paginator->items())); return [ 'data' => $data, 'meta' => [ 'page' => $paginator->currentPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), 'total_pages' => $paginator->lastPage(), ], ]; } public function getQuestionById(int $id): ?array { $question = Question::find($id); if (!$question) { return null; } return $this->mapQuestion($question); } public function getQuestionByCode(string $questionCode): ?array { $question = Question::where('question_code', $questionCode)->first(); if (!$question) { return null; } return $this->mapQuestion($question); } public function updateQuestionByCode(string $questionCode, array $payload): bool { $question = Question::where('question_code', $questionCode)->first(); if (!$question) { return false; } $question->fill($this->normalizePayload($payload)); $question->save(); return true; } public function deleteQuestionByCode(string $questionCode): bool { $question = Question::where('question_code', $questionCode)->first(); if (!$question) { return false; } $question->delete(); return true; } public function deleteQuestionById(int $id): bool { $question = Question::find($id); if (!$question) { return false; } $question->delete(); return true; } public function searchQuestions(string $query, int $limit = 20): array { $questions = Question::query() ->search($query) ->orderByDesc('id') ->limit($limit) ->get(); return [ 'data' => $this->mapQuestions($questions), ]; } public function getQuestionsByIds(array $ids): array { if (empty($ids)) { return ['data' => []]; } $questions = Question::query() ->whereIn('id', $ids) ->orderByDesc('id') ->get(); return [ 'data' => $this->mapQuestions($questions), ]; } public function getQuestionsByKpCode(string $kpCode, int $limit = 100): array { $questions = Question::query() ->where('kp_code', $kpCode) ->orderByDesc('id') ->limit($limit) ->get(); return [ 'data' => $this->mapQuestions($questions), ]; } public function getStatistics(array $filters = []): array { $baseQuery = $this->applyFilters(Question::query(), $filters); $total = (clone $baseQuery)->count(); $byDifficulty = (clone $baseQuery) ->selectRaw('difficulty, COUNT(*) as total') ->groupBy('difficulty') ->pluck('total', 'difficulty') ->toArray(); $byTypeRaw = (clone $baseQuery) ->selectRaw('question_type, COUNT(*) as total') ->groupBy('question_type') ->pluck('total', 'question_type') ->toArray(); $byType = []; foreach ($byTypeRaw as $type => $count) { $label = $this->mapQuestionTypeLabel((string) $type); $byType[$label] = ($byType[$label] ?? 0) + $count; } $byKp = (clone $baseQuery) ->selectRaw('kp_code, COUNT(*) as total') ->groupBy('kp_code') ->pluck('total', 'kp_code') ->toArray(); $bySource = (clone $baseQuery) ->selectRaw('source, COUNT(*) as total') ->groupBy('source') ->pluck('total', 'source') ->toArray(); return [ 'total' => $total, 'by_difficulty' => $byDifficulty, 'by_type' => $byType, 'by_kp' => $byKp, 'by_source' => $bySource, ]; } public function generateQuestions(array $params): array { $kpCode = $params['kp_code'] ?? null; // 允许 kp_code 为空,此时从所有可用题目中选择 if (!$kpCode) { // 从 params 中获取 kp_codes 数组 $kpCodes = $params['kp_codes'] ?? []; if (is_string($kpCodes)) { $kpCodes = array_map('trim', explode(',', $kpCodes)); } if (is_array($kpCodes) && !empty($kpCodes)) { $kpCode = $kpCodes[0]; // 使用第一个知识点 } else { // 如果没有指定知识点,从数据库中随机选择一个可用的知识点 $availableKp = Question::query() ->whereNotNull('kp_code') ->where('kp_code', '!=', '') ->distinct() ->pluck('kp_code') ->first(); if ($availableKp) { $kpCode = $availableKp; } else { return [ 'success' => false, 'message' => '系统中没有可用的题目,请先添加题目数据', ]; } } } $count = max(1, (int) ($params['count'] ?? 1)); $keyword = (string) ($params['keyword'] ?? ''); $type = $params['type'] ?? null; $difficulty = $params['difficulty'] ?? null; $skills = $params['skills'] ?? []; $solutionService = app(AiSolutionService::class); $created = []; DB::transaction(function () use ( $count, $kpCode, $keyword, $type, $difficulty, $skills, $solutionService, &$created, $params ) { for ($i = 1; $i <= $count; $i++) { $questionCode = $this->generateQuestionCode(); $stemSuffix = $keyword ? "({$keyword})" : ''; $stem = "【AI生成】{$kpCode} 题目 {$i}{$stemSuffix}"; $options = null; $answer = null; $questionType = $type ?? 'CALCULATION'; if (in_array($questionType, ['CHOICE', 'MULTIPLE_CHOICE'], true)) { $options = [ ['label' => 'A', 'text' => '选项 A'], ['label' => 'B', 'text' => '选项 B'], ['label' => 'C', 'text' => '选项 C'], ['label' => 'D', 'text' => '选项 D'], ]; $answer = 'A'; } $solution = $solutionService->generateSolution($stem, [ 'kp_code' => $kpCode, 'difficulty' => $difficulty, 'question_type' => $questionType, ]); $question = Question::create([ 'question_code' => $questionCode, 'kp_code' => $kpCode, 'stem' => $stem, 'options' => $options, 'answer' => $answer, 'solution' => $solution['solution'] ?? null, 'difficulty' => $difficulty, 'source' => 'ai::local', 'question_type' => $questionType, 'meta' => [ 'skills' => $skills, 'prompt_template' => $params['prompt_template'] ?? null, 'strategy' => $params['strategy'] ?? null, 'generated_at' => now()->toDateTimeString(), 'solution_steps' => $solution['steps'] ?? [], ], ]); $created[] = $this->mapQuestion($question); } }); return [ 'success' => true, 'message' => '生成完成', 'count' => count($created), 'data' => $created, ]; } public function importQuestions(array $questions): array { if (empty($questions)) { return [ 'success' => false, 'message' => '题目为空', 'count' => 0, ]; } $created = 0; DB::transaction(function () use ($questions, &$created) { foreach ($questions as $payload) { $questionCode = $payload['question_code'] ?? $this->generateQuestionCode(); $question = Question::firstOrNew(['question_code' => $questionCode]); $question->fill($this->normalizePayload($payload)); $question->save(); $created++; } }); return [ 'success' => true, 'message' => '导入完成', 'count' => $created, ]; } public function selectQuestionsForExam(int $totalQuestions, array $filters): array { $query = Question::query(); if (!empty($filters['kp_codes'])) { $query->whereIn('kp_code', $filters['kp_codes']); } if (!empty($filters['skills'])) { $skills = array_values(array_filter($filters['skills'])); if (!empty($skills)) { $query->where(function ($q) use ($skills) { foreach ($skills as $skill) { $q->orWhereJsonContains('meta->skills', $skill); } }); } } $questions = $query->get(); $selected = $this->applyRatioSelection($questions, $totalQuestions, $filters); return $this->mapQuestions(collect($selected)); } public function getKnowledgePointOptions(): array { return KnowledgePoint::query() ->orderBy('kp_code') ->pluck('name', 'kp_code') ->toArray(); } public function getSkillNameMapping(?string $kpCode = null): array { return []; } private function applyFilters($query, array $filters) { if (!empty($filters['kp_code'])) { $query->where('kp_code', $filters['kp_code']); } if (!empty($filters['difficulty'])) { $query->where('difficulty', $filters['difficulty']); } if (!empty($filters['type'])) { $query->where('question_type', $filters['type']); } if (!empty($filters['search'])) { $query->search($filters['search']); } return $query; } private function normalizePayload(array $payload): array { $normalized = [ 'question_code' => $payload['question_code'] ?? null, 'kp_code' => $payload['kp_code'] ?? null, 'stem' => $payload['stem'] ?? ($payload['content'] ?? ''), 'options' => $payload['options'] ?? null, 'answer' => $payload['answer'] ?? null, 'solution' => $payload['solution'] ?? null, 'difficulty' => $payload['difficulty'] ?? null, 'source' => $payload['source'] ?? null, 'tags' => $payload['tags'] ?? null, 'question_type' => $payload['question_type'] ?? ($payload['type'] ?? null), 'meta' => $payload['meta'] ?? null, ]; if (isset($payload['skills'])) { $meta = $normalized['meta'] ?? []; $meta['skills'] = is_array($payload['skills']) ? $payload['skills'] : array_filter(array_map('trim', explode(',', (string) $payload['skills']))); $normalized['meta'] = $meta; } return array_filter($normalized, static fn ($value) => $value !== null); } private function mapQuestions(Collection $questions): array { $kpCodes = $questions->pluck('kp_code')->filter()->unique()->values(); $kpMap = $this->resolveKnowledgePointNames($kpCodes->all()); return $questions->map(function (Question $question) use ($kpMap) { $data = $this->mapQuestion($question); $data['kp_name'] = $kpMap[$question->kp_code] ?? null; return $data; })->values()->all(); } private function mapQuestion(Question $question): array { $meta = $question->meta ?? []; $data = [ 'id' => $question->id, 'question_code' => $question->question_code, 'kp_code' => $question->kp_code, 'stem' => $question->stem, 'options' => $question->options, 'answer' => $question->answer, 'solution' => $question->solution, 'difficulty' => $question->difficulty, 'source' => $question->source, 'tags' => $question->tags, 'type' => $question->question_type, 'question_type' => $question->question_type, 'skills' => $meta['skills'] ?? [], 'meta' => $meta, 'created_at' => $question->created_at?->toDateTimeString(), 'updated_at' => $question->updated_at?->toDateTimeString(), ]; return MathFormulaProcessor::processQuestionData($data); } private function generateQuestionCode(): string { return 'Q' . Str::upper(Str::random(10)); } private function mapQuestionTypeLabel(string $type): string { return match (strtoupper($type)) { 'CHOICE' => '选择题', 'MULTIPLE_CHOICE' => '多选题', 'FILL_IN_THE_BLANK', 'FILL' => '填空题', 'CALCULATION', 'WORD_PROBLEM', 'ANSWER' => '解答题', 'PROOF' => '证明题', default => '其他', }; } private function applyRatioSelection(Collection $questions, int $totalQuestions, array $filters): array { $questionsByType = $questions->groupBy(fn (Question $q) => $q->question_type ?? 'CALCULATION'); $questionsByDifficulty = $questions->groupBy(function (Question $q) { $difficulty = (float) ($q->difficulty ?? 0); if ($difficulty <= 0.4) { return 'easy'; } if ($difficulty <= 0.7) { return 'medium'; } return 'hard'; }); $typeRatio = $filters['question_type_ratio'] ?? []; $difficultyRatio = $filters['difficulty_ratio'] ?? []; $selected = collect(); if (!empty($typeRatio)) { foreach ($typeRatio as $type => $ratio) { $bucket = $questionsByType->get($type, collect()); $count = (int) round($totalQuestions * (float) $ratio); $selected = $selected->merge($bucket->shuffle()->take($count)); } } if (!empty($difficultyRatio)) { foreach ($difficultyRatio as $key => $ratio) { $bucketKey = $this->normalizeDifficultyKey($key); $bucket = $questionsByDifficulty->get($bucketKey, collect()); $count = (int) round($totalQuestions * (float) $ratio); $selected = $selected->merge($bucket->shuffle()->take($count)); } } if ($selected->isEmpty()) { return $questions->shuffle()->take($totalQuestions)->values()->all(); } if ($selected->count() < $totalQuestions) { $missing = $totalQuestions - $selected->count(); $fill = $questions->diff($selected)->shuffle()->take($missing); $selected = $selected->merge($fill); } return $selected->values()->all(); } private function normalizeDifficultyKey(string $key): string { if (in_array($key, ['easy', 'medium', 'hard'], true)) { return $key; } $value = (float) $key; if ($value <= 0.4) { return 'easy'; } if ($value <= 0.7) { return 'medium'; } return 'hard'; } private function resolveKnowledgePointNames(array $kpCodes): array { $kpCodes = array_values(array_filter(array_unique($kpCodes))); if (empty($kpCodes)) { return []; } $cacheKey = 'kp-name-map-' . md5(implode('|', $kpCodes)); return Cache::remember($cacheKey, now()->addMinutes(30), function () use ($kpCodes) { $kpMap = KnowledgePoint::query() ->whereIn('kp_code', $kpCodes) ->pluck('name', 'kp_code') ->toArray(); $missing = array_values(array_filter($kpCodes, fn ($code) => empty($kpMap[$code]))); if (empty($missing)) { return $kpMap; } try { $api = app(KnowledgeServiceApi::class); $all = $api->listKnowledgePoints(); foreach ($all as $kp) { $code = $kp['kp_code'] ?? null; $name = $kp['cn_name'] ?? $kp['name'] ?? null; if ($code && $name && in_array($code, $missing, true)) { $kpMap[$code] = $name; } } } catch (\Throwable $e) { // Fallback: keep existing mapping } return $kpMap; }); } /** * 根据难度系数分布选择题目 * * @param array $questions 候选题目数组 * @param int $totalQuestions 总题目数 * @param int $difficultyCategory 难度类别 (1-4) * - 1: 0-0.25范围占50%,其他占50% * - 2: 0.25-0.5范围占50%,<0.25占25%,>0.5占25% * - 3: 0.5-0.75范围占50%,<0.5占25%,>0.75占25% * - 4: 0.75-1范围占50%,其他占50% * @param array $filters 其他筛选条件 * @return array 分布后的题目 */ public function selectQuestionsByDifficultyDistribution(array $questions, int $totalQuestions, int $difficultyCategory = 1, array $filters = []): array { Log::info('QuestionLocalService: 根据难度系数分布选择题目', [ 'total_questions' => $totalQuestions, 'difficulty_category' => $difficultyCategory, 'input_questions' => count($questions) ]); if (empty($questions)) { Log::warning('QuestionLocalService: 输入题目为空'); return []; } // 计算目标分布 $distribution = $this->calculateDifficultyDistribution($difficultyCategory, $totalQuestions); Log::info('QuestionLocalService: 难度分布计算', [ 'distribution' => $distribution ]); // 按难度范围分桶 $buckets = $this->groupQuestionsByDifficultyRange($questions, $difficultyCategory); Log::info('QuestionLocalService: 题目分桶', [ 'buckets' => array_map(fn($bucket) => count($bucket), $buckets) ]); // 根据分布选择题目 $selected = []; $usedIndices = []; foreach ($distribution as $level => $config) { $targetCount = $config['count']; if ($targetCount <= 0) { continue; } $rangeKey = $this->mapDifficultyLevelToRangeKey($level, $difficultyCategory); $bucket = $buckets[$rangeKey] ?? []; // 随机打乱 shuffle($bucket); // 选择题目 $takeCount = min($targetCount, count($bucket)); for ($i = 0; $i < $takeCount; $i++) { if (isset($bucket[$i])) { $selected[] = $bucket[$i]; $usedIndices[] = $bucket[$i]['id'] ?? $i; } } Log::debug('QuestionLocalService: 难度层级选择', [ 'level' => $level, 'target' => $targetCount, 'actual' => $takeCount, 'bucket_size' => count($bucket) ]); } // 如果数量不足,从剩余题目中补充 if (count($selected) < $totalQuestions) { $remaining = []; foreach ($questions as $q) { $id = $q['id'] ?? null; if ($id && !in_array($id, $usedIndices)) { $remaining[] = $q; } } shuffle($remaining); $needMore = $totalQuestions - count($selected); $selected = array_merge($selected, array_slice($remaining, 0, $needMore)); } // 截断至目标数量 $selected = array_slice($selected, 0, $totalQuestions); Log::info('QuestionLocalService: 难度分布选择完成', [ 'final_count' => count($selected), 'target_count' => $totalQuestions ]); return $selected; } /** * 计算难度分布配置 * * @param int $category 难度类别 (1-4) * @param int $totalQuestions 总题目数 * @return array 分布配置 */ private function calculateDifficultyDistribution(int $category, int $totalQuestions): array { // 标准化:25% 低级,50% 基准,25% 拔高 $lowPercentage = 25; $mediumPercentage = 50; $highPercentage = 25; // 根据难度类别调整分布 switch ($category) { case 1: // 基础型:0-0.25占50%,其他占50% $mediumPercentage = 50; // 0-0.25作为基准 $lowPercentage = 25; // 其他低难度 $highPercentage = 25; // 其他高难度 break; case 2: // 进阶型:0.25-0.5占50%,<0.25占25%,>0.5占25% $mediumPercentage = 50; // 0.25-0.5作为基准 $lowPercentage = 25; // <0.25 $highPercentage = 25; // >0.5 break; case 3: // 中等型:0.5-0.75占50%,<0.5占25%,>0.75占25% $mediumPercentage = 50; // 0.5-0.75作为基准 $lowPercentage = 25; // <0.5 $highPercentage = 25; // >0.75 break; case 4: // 拔高型:0.75-1占50%,其他占50% $mediumPercentage = 50; // 0.75-1作为基准 $lowPercentage = 25; // 其他低难度 $highPercentage = 25; // 其他高难度 break; } // 计算题目数量 $lowCount = (int) round($totalQuestions * $lowPercentage / 100); $mediumCount = (int) round($totalQuestions * $mediumPercentage / 100); $highCount = $totalQuestions - $lowCount - $mediumCount; return [ 'low' => [ 'percentage' => $lowPercentage, 'count' => $lowCount, 'label' => '低级难度' ], 'medium' => [ 'percentage' => $mediumPercentage, 'count' => $mediumCount, 'label' => '基准难度' ], 'high' => [ 'percentage' => $highPercentage, 'count' => $highCount, 'label' => '拔高难度' ] ]; } /** * 将题目按难度范围分桶 * * @param array $questions 题目数组 * @param int $category 难度类别 * @return array 分桶结果 */ private function groupQuestionsByDifficultyRange(array $questions, int $category): array { $buckets = [ 'primary_low' => [], // 主要低难度范围 'primary_medium' => [], // 主要中等难度范围 'primary_high' => [], // 主要高难度范围 'secondary' => [], // 次要范围 'other' => [] // 其他 ]; foreach ($questions as $question) { $difficulty = (float) ($question['difficulty'] ?? 0); $rangeKey = $this->classifyQuestionByDifficulty($difficulty, $category); $buckets[$rangeKey][] = $question; } return $buckets; } /** * 根据难度值和类别分类题目 * * @param float $difficulty 难度值 (0-1) * @param int $category 难度类别 (1-4) * @return string 范围键 */ private function classifyQuestionByDifficulty(float $difficulty, int $category): string { switch ($category) { case 1: // 基础型:0-0.25作为主要中等,0.25-1作为其他 if ($difficulty >= 0 && $difficulty <= 0.25) { return 'primary_medium'; } return 'other'; case 2: // 进阶型:0.25-0.5作为主要中等,<0.25作为主要低,>0.5作为主要高 if ($difficulty >= 0.25 && $difficulty <= 0.5) { return 'primary_medium'; } elseif ($difficulty < 0.25) { return 'primary_low'; } return 'primary_high'; case 3: // 中等型:0.5-0.75作为主要中等,<0.5作为主要低,>0.75作为主要高 if ($difficulty >= 0.5 && $difficulty <= 0.75) { return 'primary_medium'; } elseif ($difficulty < 0.5) { return 'primary_low'; } return 'primary_high'; case 4: // 拔高型:0.75-1作为主要中等,0-0.75作为其他 if ($difficulty >= 0.75 && $difficulty <= 1.0) { return 'primary_medium'; } return 'other'; default: return 'other'; } } /** * 将难度层级映射到范围键 * * @param string $level 难度层级 (low/medium/high) * @param int $category 难度类别 * @return string 范围键 */ private function mapDifficultyLevelToRangeKey(string $level, int $category): string { // 根据类别和层级确定范围键 switch ($category) { case 1: return match($level) { 'low' => 'other', 'medium' => 'primary_medium', 'high' => 'other', default => 'other' }; case 2: return match($level) { 'low' => 'primary_low', 'medium' => 'primary_medium', 'high' => 'primary_high', default => 'other' }; case 3: return match($level) { 'low' => 'primary_low', 'medium' => 'primary_medium', 'high' => 'primary_high', default => 'other' }; case 4: return match($level) { 'low' => 'other', 'medium' => 'primary_medium', 'high' => 'other', default => 'other' }; default: return 'other'; } } }