| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547 |
- <?php
- namespace App\Services;
- use App\Models\PreQuestionCandidate;
- use App\Models\Question;
- use App\Models\QuestionKpRelation;
- use App\Models\QuestionMeta;
- use App\Models\KnowledgePoint;
- use App\Models\TextbookCatalog;
- use App\Services\AiKnowledgeService;
- use App\Services\AiSolutionService;
- use App\Services\QuestionGenerationService;
- use App\Services\PdfStorageService;
- use Illuminate\Support\Collection;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Log;
- class QuestionCandidateToQuestionService
- {
- public function __construct(private readonly PdfStorageService $uploader)
- {
- }
- /**
- * 将源卷子下已校对的候选题入库到 questions。
- */
- public function promoteFromSourcePapers(Collection $papers): array
- {
- $summary = [
- 'processed' => 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;
- }
- }
|