difficultyDistributionService = $difficultyDistributionService ?? app(DifficultyDistributionService::class); } public function listQuestions(int $page = 1, int $perPage = 50, array $filters = []): array { $query = $this->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(); // 【新增】只获取审核通过的题目(audit_status = 0 表示合格) $query->where('audit_status', 0); 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); } }); } } if (!empty($filters['grade'])) { $stageGrade = $this->normalizeStageGrade((int) $filters['grade']); if ($stageGrade !== null) { $query->where('grade', $stageGrade); } } $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']); } if (!empty($filters['grade'])) { $stageGrade = $this->normalizeStageGrade((int) $filters['grade']); if ($stageGrade !== null) { $query->where('grade', $stageGrade); } } return $query; } private function normalizeStageGrade(int $grade): ?int { if ($grade <= 0) { return null; } return $grade <= 9 ? 2 : 3; } 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 难度类别 (0-4) * - 0: 0-0.1占90%,0.1-0.25占10% * - 1: 0-0.25占90%,0.25-1占10% * - 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, ]); if (empty($questions)) { Log::warning('QuestionLocalService: 输入题目为空'); return []; } // 【恢复】简化逻辑,避免复杂处理 $distribution = $this->difficultyDistributionService->calculateDistribution($difficultyCategory, $totalQuestions); // 按难度范围分桶 $buckets = $this->difficultyDistributionService->groupQuestionsByDifficultyRange($questions, $difficultyCategory); Log::info('QuestionLocalService: 题目分桶', [ 'buckets' => array_map(fn($bucket) => count($bucket), $buckets), 'total_input' => count($questions), 'distribution' => $distribution ]); // 根据分布选择题目 $selected = []; $usedIds = []; foreach ($distribution as $level => $config) { $targetCount = $config['count']; if ($targetCount <= 0) { Log::debug('QuestionLocalService: 跳过难度层级', [ 'level' => $level, 'target_count' => $targetCount ]); continue; } $rangeKey = $this->difficultyDistributionService->mapDifficultyLevelToRangeKey($level, $difficultyCategory); $bucket = $buckets[$rangeKey] ?? []; // 随机打乱 shuffle($bucket); // 选择题目 $taken = 0; foreach ($bucket as $question) { if ($taken >= $targetCount) break; $questionId = $question['id'] ?? null; if ($questionId && !in_array($questionId, $usedIds)) { $selected[] = $question; $usedIds[] = $questionId; $taken++; } } // 【修复】如果某个难度范围题目不足,记录日志但不截断 if ($taken < $targetCount) { Log::warning('QuestionLocalService: 难度范围题目不足,允许后续补充', [ 'level' => $level, 'range_key' => $rangeKey, 'target' => $targetCount, 'actual' => $taken, 'bucket_size' => count($bucket) ]); } } // 如果数量不足,从剩余题目中补充 if (count($selected) < $totalQuestions) { Log::warning('QuestionLocalService: 开始补充题目(难度分布无法满足要求)', [ 'need_more' => $totalQuestions - count($selected), 'selected_count' => count($selected), 'difficulty_category' => $difficultyCategory, 'note' => '优先从次级桶补充,不足再放宽' ]); $needMore = $totalQuestions - count($selected); $supplemented = 0; $supplementOrder = $this->difficultyDistributionService->getSupplementOrder($difficultyCategory); foreach ($supplementOrder as $bucketKey) { if ($supplemented >= $needMore) { break; } $bucket = $buckets[$bucketKey] ?? []; if (empty($bucket)) { continue; } shuffle($bucket); foreach ($bucket as $q) { if ($supplemented >= $needMore) { break; } $id = $q['id'] ?? null; if ($id && !in_array($id, $usedIds)) { $selected[] = $q; $usedIds[] = $id; $supplemented++; } } } if ($supplemented < $needMore) { $remaining = []; foreach ($questions as $q) { $id = $q['id'] ?? null; if ($id && !in_array($id, $usedIds)) { $remaining[] = $q; } } shuffle($remaining); $supplementCount = min($needMore - $supplemented, count($remaining)); $selected = array_merge($selected, array_slice($remaining, 0, $supplementCount)); $supplemented += $supplementCount; } Log::warning('QuestionLocalService: 补充完成', [ 'supplement_added' => $supplemented, 'final_count_before_truncate' => count($selected), 'remaining_unused' => max(0, count($questions) - count($selected)) ]); } // 截断至目标数量 $selected = array_slice($selected, 0, $totalQuestions); $finalBuckets = $this->difficultyDistributionService->groupQuestionsByDifficultyRange($selected, $difficultyCategory); $finalTotal = max(1, count($selected)); $distributionStats = array_map(static function ($bucket) use ($finalTotal) { $count = count($bucket); return [ 'count' => $count, 'ratio' => round(($count / $finalTotal) * 100, 2), ]; }, $finalBuckets); Log::info('QuestionLocalService: 难度分布选择完成', [ 'final_count' => count($selected), 'target_count' => $totalQuestions, 'success' => count($selected) === $totalQuestions, 'input_count' => count($questions), 'distribution_applied' => true, 'final_distribution' => $distributionStats ]); return $selected; } }