0, 'skipped' => 0, 'errors' => 0, ]; foreach ($papers as $paper) { $candidates = $paper->candidates() ->whereIn('status', [ PreQuestionCandidate::STATUS_REVIEWED, PreQuestionCandidate::STATUS_PENDING, PreQuestionCandidate::STATUS_ACCEPTED, ]) ->where('is_question_candidate', true) ->get(); $result = $this->promoteCandidates($candidates); $summary['processed'] += $result['processed']; $summary['skipped'] += $result['skipped']; $summary['errors'] += $result['errors']; } return $summary; } /** * 将候选题集合批量入库到 questions。 */ public function promoteCandidates(Collection $candidates): array { $summary = [ 'processed' => 0, 'skipped' => 0, 'errors' => 0, ]; foreach ($candidates as $candidate) { try { $this->hydrateQuestionDetails($candidate); $this->hydrateKnowledgePoints($candidate); $validationErrors = $this->validateCandidate($candidate); if (!empty($validationErrors)) { $summary['errors']++; Log::warning('Candidate validation failed during promotion', [ 'candidate_id' => $candidate->id, 'errors' => $validationErrors, ]); continue; } $question = $this->promoteCandidate($candidate); if ($question) { $summary['processed']++; } else { $summary['skipped']++; } } catch (\Throwable $e) { $summary['errors']++; Log::error('Failed to promote candidate to question', [ 'candidate_id' => $candidate->id, 'error' => $e->getMessage(), ]); } } return $summary; } private function promoteCandidate(PreQuestionCandidate $candidate): ?Question { $meta = $candidate->meta ?? []; if (!empty($meta['question_id'])) { $existing = Question::query()->find($meta['question_id']); if ($existing) { return null; } Log::warning('Candidate has stale question_id, re-promoting', [ 'candidate_id' => $candidate->id, 'question_id' => $meta['question_id'], ]); unset($meta['question_id']); } return DB::transaction(function () use ($candidate, $meta) { $generated = $meta['generated_question'] ?? []; $uploadedImages = $this->uploadCandidateImages($candidate); $questionType = $this->normalizeQuestionType( is_string($meta['question_type'] ?? null) ? $meta['question_type'] : ($generated['question_type'] ?? null), $candidate ); $kpCodes = $this->normalizeKpCodes($meta['kp_codes'] ?? ($generated['knowledge_points'] ?? [])); $primaryKp = $kpCodes[0] ?? null; $solutionSteps = $this->resolveSolutionSteps($candidate, $questionType, $meta); if (!empty($solutionSteps['steps'])) { $meta['solution_steps'] = $solutionSteps['steps']; } if (!empty($solutionSteps['solution']) && empty($meta['solution'])) { $meta['solution'] = $solutionSteps['solution']; } $questionCode = sprintf('CAND-%d', $candidate->id); $options = $candidate->options ?: ($generated['options'] ?? null); if (is_string($options)) { $decoded = json_decode($options, true); $options = is_array($decoded) ? $decoded : null; } $question = Question::updateOrCreate( ['question_code' => $questionCode], [ 'question_type' => $questionType, 'kp_code' => $primaryKp, 'stem' => $candidate->stem ?: ($generated['stem'] ?? null) ?: $candidate->raw_text ?: $candidate->raw_markdown, 'options' => $options, 'answer' => $meta['answer'] ?? ($generated['answer'] ?? null), 'solution' => $meta['solution'] ?? ($generated['solution'] ?? null), 'difficulty' => $meta['difficulty'] ?? ($generated['difficulty'] ?? null), 'source_file_id' => $candidate->source_file_id, 'source_paper_id' => $candidate->source_paper_id, 'paper_part_id' => $candidate->part_id, 'textbook_id' => $candidate->sourcePaper?->textbook_id, 'source' => 'markdown_import', 'tags' => $this->joinTags($meta['tags'] ?? []), 'meta' => [ 'candidate_id' => $candidate->id, 'import_id' => $candidate->import_id, 'images' => $uploadedImages, 'solution_steps' => $meta['solution_steps'] ?? [], 'generated_question' => $generated, ], ] ); $abilities = $meta['abilities'] ?? ($generated['abilities'] ?? []); QuestionMeta::updateOrCreate( ['question_id' => $question->id], [ 'abilities' => $abilities, 'generation_info' => [ 'source' => 'candidate', 'candidate_id' => $candidate->id, ], 'review_status' => 'reviewed', ] ); $this->syncKpRelations($question->id, $kpCodes); $meta['question_id'] = $question->id; $meta['images_uploaded'] = !empty($uploadedImages); $candidate->update([ 'images' => $uploadedImages, 'status' => PreQuestionCandidate::STATUS_ACCEPTED, 'meta' => $meta, ]); return $question; }); } private function uploadCandidateImages(PreQuestionCandidate $candidate): array { $meta = $candidate->meta ?? []; $images = $candidate->images ?? []; if (is_string($images)) { $decoded = json_decode($images, true); $images = is_array($decoded) ? $decoded : []; } if (empty($images)) { return []; } if (!empty($meta['images_uploaded'])) { return $images; } $uploadedUrls = []; foreach ($images as $idx => $url) { $extension = pathinfo(parse_url((string) $url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION) ?: 'jpg'; $path = "questions/images/{$candidate->id}_{$idx}.{$extension}"; $uploadedUrls[] = $this->uploader->put($path, (string)@file_get_contents($url)) ?: $url; } return $uploadedUrls; } private function normalizeQuestionType(?string $type, PreQuestionCandidate $candidate): string { $type = strtolower(trim((string) $type)); $map = [ 'choice' => 'choice', 'fill' => 'fill', 'answer' => 'answer', '选择题' => 'choice', '填空题' => 'fill', '解答题' => 'answer', '简答题' => 'answer', '计算题' => 'answer', ]; if ($type !== '' && isset($map[$type])) { return $map[$type]; } if (!empty($candidate->options)) { return 'choice'; } $stem = (string) ($candidate->stem ?? $candidate->raw_markdown ?? ''); if (preg_match('/_{2,}|\\(\\s*\\)/u', $stem)) { return 'fill'; } return 'answer'; } private function normalizeKpCodes(array|string $kpCodes): array { if (is_string($kpCodes)) { $kpCodes = preg_split('/[,,\\s]+/u', $kpCodes) ?: []; } return array_values(array_filter(array_map('trim', $kpCodes))); } private function syncKpRelations(int $questionId, array $kpCodes): void { if (empty($kpCodes)) { return; } QuestionKpRelation::query() ->where('question_id', $questionId) ->whereNotIn('kp_code', $kpCodes) ->delete(); foreach ($kpCodes as $code) { QuestionKpRelation::updateOrCreate( [ 'question_id' => $questionId, 'kp_code' => $code, ], [ 'weight' => 1.0, ] ); } } private function joinTags(array|string $tags): ?string { if (is_string($tags)) { $tags = preg_split('/[,,\\s]+/u', $tags) ?: []; } $tags = array_values(array_filter(array_map('trim', $tags))); return empty($tags) ? null : implode(',', $tags); } /** * 验证候选题是否具备入库条件 */ public function validateCandidate(PreQuestionCandidate $candidate): array { $errors = []; $meta = $candidate->meta ?? []; if (empty($candidate->stem) && empty($candidate->raw_markdown)) { $errors[] = '题干内容为空'; } if (empty($candidate->sourcePaper?->textbook_id)) { $errors[] = '未设置教材信息'; } if (empty($meta['question_type']) && empty($candidate->question_type)) { // 虽然 promoteCandidate 会自动兜底题型,但建议在校对阶段明确 } return $errors; } private function hydrateQuestionDetails(PreQuestionCandidate $candidate): void { $meta = $candidate->meta ?? []; $generated = $meta['generated_question'] ?? []; if (!empty($generated)) { return; } $needs = false; $fields = [ $meta['answer'] ?? null, $meta['solution'] ?? null, $meta['difficulty'] ?? null, $meta['question_type'] ?? null, ]; foreach ($fields as $value) { if (empty($value)) { $needs = true; break; } } if (empty($meta['kp_codes']) && empty($candidate->kp_code)) { $needs = true; } $questionType = $this->normalizeQuestionType( is_string($meta['question_type'] ?? null) ? $meta['question_type'] : null, $candidate ); if ($questionType === 'answer' && empty($meta['solution_steps'])) { $needs = true; } if (!$needs) { return; } $questionText = (string) ($candidate->stem ?: $candidate->raw_text ?: $candidate->raw_markdown); if ($questionText === '') { return; } $context = $this->buildGenerationContext($candidate->sourcePaper); $images = $candidate->images ?? []; if (is_string($images)) { $decoded = json_decode($images, true); $images = is_array($decoded) ? $decoded : []; } $imageText = ''; if (!empty($images)) { $imageText = "image_urls:\n" . implode("\n", array_map('strval', $images)); } $parts = []; if ($context) { $parts[] = $context; } if ($imageText !== '') { $parts[] = $imageText; } $parts[] = "题目内容:\n" . $questionText; $sourceText = implode("\n\n", $parts); $result = app(QuestionGenerationService::class)->generateFromSource($sourceText); if (empty($result['success'])) { Log::warning('AI question generation failed', [ 'candidate_id' => $candidate->id, 'message' => $result['message'] ?? 'unknown', ]); return; } $generated = $result['question'] ?? []; if (empty($generated)) { return; } if (empty($meta['answer']) && !empty($generated['answer'])) { $meta['answer'] = $generated['answer']; } if (empty($meta['solution']) && !empty($generated['solution'])) { $meta['solution'] = $generated['solution']; } if (empty($meta['difficulty']) && isset($generated['difficulty'])) { $meta['difficulty'] = $generated['difficulty']; } if (empty($meta['question_type']) && !empty($generated['question_type'])) { $meta['question_type'] = $generated['question_type']; } if (empty($meta['kp_codes']) && !empty($generated['knowledge_points'])) { $meta['kp_codes'] = $generated['knowledge_points']; } if (empty($meta['solution_steps']) && !empty($generated['solution_steps'])) { $meta['solution_steps'] = $generated['solution_steps']; } if (empty($meta['abilities']) && !empty($generated['abilities'])) { $meta['abilities'] = $generated['abilities']; } $meta['generated_question'] = $generated; $candidate->meta = $meta; } private function hydrateKnowledgePoints(PreQuestionCandidate $candidate): void { $meta = $candidate->meta ?? []; $kpCodes = $this->normalizeKpCodes($meta['kp_codes'] ?? ($candidate->kp_code ?? [])); if (!empty($kpCodes)) { return; } $questionText = (string) ($candidate->stem ?: $candidate->raw_text ?: $candidate->raw_markdown); if ($questionText === '') { return; } $paper = $candidate->sourcePaper; $context = $this->buildKnowledgeMatchContext($paper); $candidates = $this->buildKnowledgePointPool($paper); $matches = app(AiKnowledgeService::class)->matchKnowledgePointsByAi($questionText, $candidates, $context); if (empty($matches)) { Log::warning('AI knowledge match returned empty', [ 'candidate_id' => $candidate->id, 'paper_id' => $candidate->source_paper_id, ]); return; } $meta['kp_codes'] = array_values(array_unique(array_filter(array_map( fn ($item) => $item['kp_code'] ?? null, $matches )))); $meta['kp_weights'] = $matches; $candidate->meta = $meta; } private function buildKnowledgeMatchContext(?\App\Models\SourcePaper $paper): ?string { if (!$paper) { return null; } $lines = []; if (!empty($paper->grade)) { $lines[] = '年级: ' . $paper->grade; } if (!empty($paper->term)) { $lines[] = '学期: ' . $paper->term; } if (!empty($paper->textbook_id)) { $textbook = $paper->textbook; if ($textbook) { $lines[] = '教材: ' . ($textbook->official_title ?? $textbook->title ?? $textbook->id); } } $meta = $paper->meta ?? []; $catalogIds = $meta['catalog_node_ids'] ?? ($meta['catalog_node_id'] ?? []); $catalogIds = $this->normalizeCatalogNodeIds($catalogIds); if (!empty($catalogIds)) { $titles = TextbookCatalog::query() ->whereIn('id', $catalogIds) ->pluck('title') ->filter() ->take(6) ->toArray(); if (!empty($titles)) { $lines[] = '目录: ' . implode(' / ', $titles); } } return empty($lines) ? null : implode("\n", $lines); } private function buildGenerationContext(?\App\Models\SourcePaper $paper): ?string { return $this->buildKnowledgeMatchContext($paper); } private function buildKnowledgePointPool(?\App\Models\SourcePaper $paper): array { $query = KnowledgePoint::query()->select(['kp_code', 'name']); if ($paper?->grade) { $query->where('grade', $paper->grade); } if ($paper?->subject) { $query->where('subject', $paper->subject); } return $query->orderBy('kp_code')->limit(300)->get()->toArray(); } private function resolveSolutionSteps(PreQuestionCandidate $candidate, string $questionType, array $meta): array { if ($questionType !== 'answer') { return [ 'solution' => $meta['solution'] ?? '', 'steps' => [], ]; } if (!empty($meta['solution_steps'])) { return [ 'solution' => $meta['solution'] ?? '', 'steps' => $meta['solution_steps'], ]; } $questionText = (string) ($candidate->stem ?: $candidate->raw_text ?: $candidate->raw_markdown); if ($questionText === '') { return [ 'solution' => $meta['solution'] ?? '', 'steps' => [], ]; } $result = app(AiSolutionService::class)->generateSolutionSteps($questionText); return [ 'solution' => $result['solution'] ?? '', 'steps' => $result['steps'] ?? [], ]; } private function normalizeCatalogNodeIds(array|string $value): array { $ids = is_array($value) ? $value : [$value]; $ids = array_values(array_unique(array_map('intval', array_filter($ids)))); return $ids; } }