QuestionCandidateToQuestionService.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. <?php
  2. namespace App\Services;
  3. use App\Models\PreQuestionCandidate;
  4. use App\Models\Question;
  5. use App\Models\QuestionKpRelation;
  6. use App\Models\QuestionMeta;
  7. use App\Models\KnowledgePoint;
  8. use App\Models\TextbookCatalog;
  9. use App\Services\AiKnowledgeService;
  10. use App\Services\AiSolutionService;
  11. use App\Services\QuestionGenerationService;
  12. use App\Services\PdfStorageService;
  13. use Illuminate\Support\Collection;
  14. use Illuminate\Support\Facades\DB;
  15. use Illuminate\Support\Facades\Log;
  16. class QuestionCandidateToQuestionService
  17. {
  18. public function __construct(private readonly PdfStorageService $uploader)
  19. {
  20. }
  21. /**
  22. * 将源卷子下已校对的候选题入库到 questions。
  23. */
  24. public function promoteFromSourcePapers(Collection $papers): array
  25. {
  26. $summary = [
  27. 'processed' => 0,
  28. 'skipped' => 0,
  29. 'errors' => 0,
  30. ];
  31. foreach ($papers as $paper) {
  32. $candidates = $paper->candidates()
  33. ->whereIn('status', [
  34. PreQuestionCandidate::STATUS_REVIEWED,
  35. PreQuestionCandidate::STATUS_PENDING,
  36. PreQuestionCandidate::STATUS_ACCEPTED,
  37. ])
  38. ->where('is_question_candidate', true)
  39. ->get();
  40. $result = $this->promoteCandidates($candidates);
  41. $summary['processed'] += $result['processed'];
  42. $summary['skipped'] += $result['skipped'];
  43. $summary['errors'] += $result['errors'];
  44. }
  45. return $summary;
  46. }
  47. /**
  48. * 将候选题集合批量入库到 questions。
  49. */
  50. public function promoteCandidates(Collection $candidates): array
  51. {
  52. $summary = [
  53. 'processed' => 0,
  54. 'skipped' => 0,
  55. 'errors' => 0,
  56. ];
  57. foreach ($candidates as $candidate) {
  58. try {
  59. $this->hydrateQuestionDetails($candidate);
  60. $this->hydrateKnowledgePoints($candidate);
  61. $validationErrors = $this->validateCandidate($candidate);
  62. if (!empty($validationErrors)) {
  63. $summary['errors']++;
  64. Log::warning('Candidate validation failed during promotion', [
  65. 'candidate_id' => $candidate->id,
  66. 'errors' => $validationErrors,
  67. ]);
  68. continue;
  69. }
  70. $question = $this->promoteCandidate($candidate);
  71. if ($question) {
  72. $summary['processed']++;
  73. } else {
  74. $summary['skipped']++;
  75. }
  76. } catch (\Throwable $e) {
  77. $summary['errors']++;
  78. Log::error('Failed to promote candidate to question', [
  79. 'candidate_id' => $candidate->id,
  80. 'error' => $e->getMessage(),
  81. ]);
  82. }
  83. }
  84. return $summary;
  85. }
  86. private function promoteCandidate(PreQuestionCandidate $candidate): ?Question
  87. {
  88. $meta = $candidate->meta ?? [];
  89. if (!empty($meta['question_id'])) {
  90. $existing = Question::query()->find($meta['question_id']);
  91. if ($existing) {
  92. return null;
  93. }
  94. Log::warning('Candidate has stale question_id, re-promoting', [
  95. 'candidate_id' => $candidate->id,
  96. 'question_id' => $meta['question_id'],
  97. ]);
  98. unset($meta['question_id']);
  99. }
  100. return DB::transaction(function () use ($candidate, $meta) {
  101. $generated = $meta['generated_question'] ?? [];
  102. $uploadedImages = $this->uploadCandidateImages($candidate);
  103. $questionType = $this->normalizeQuestionType(
  104. is_string($meta['question_type'] ?? null) ? $meta['question_type'] : ($generated['question_type'] ?? null),
  105. $candidate
  106. );
  107. $kpCodes = $this->normalizeKpCodes($meta['kp_codes'] ?? ($generated['knowledge_points'] ?? []));
  108. $primaryKp = $kpCodes[0] ?? null;
  109. $solutionSteps = $this->resolveSolutionSteps($candidate, $questionType, $meta);
  110. if (!empty($solutionSteps['steps'])) {
  111. $meta['solution_steps'] = $solutionSteps['steps'];
  112. }
  113. if (!empty($solutionSteps['solution']) && empty($meta['solution'])) {
  114. $meta['solution'] = $solutionSteps['solution'];
  115. }
  116. $questionCode = sprintf('CAND-%d', $candidate->id);
  117. $options = $candidate->options ?: ($generated['options'] ?? null);
  118. if (is_string($options)) {
  119. $decoded = json_decode($options, true);
  120. $options = is_array($decoded) ? $decoded : null;
  121. }
  122. $question = Question::updateOrCreate(
  123. ['question_code' => $questionCode],
  124. [
  125. 'question_type' => $questionType,
  126. 'kp_code' => $primaryKp,
  127. 'stem' => $candidate->stem ?: ($generated['stem'] ?? null) ?: $candidate->raw_text ?: $candidate->raw_markdown,
  128. 'options' => $options,
  129. 'answer' => $meta['answer'] ?? ($generated['answer'] ?? null),
  130. 'solution' => $meta['solution'] ?? ($generated['solution'] ?? null),
  131. 'difficulty' => $meta['difficulty'] ?? ($generated['difficulty'] ?? null),
  132. 'source_file_id' => $candidate->source_file_id,
  133. 'source_paper_id' => $candidate->source_paper_id,
  134. 'paper_part_id' => $candidate->part_id,
  135. 'textbook_id' => $candidate->sourcePaper?->textbook_id,
  136. 'source' => 'markdown_import',
  137. 'tags' => $this->joinTags($meta['tags'] ?? []),
  138. 'meta' => [
  139. 'candidate_id' => $candidate->id,
  140. 'import_id' => $candidate->import_id,
  141. 'images' => $uploadedImages,
  142. 'solution_steps' => $meta['solution_steps'] ?? [],
  143. 'generated_question' => $generated,
  144. ],
  145. ]
  146. );
  147. $abilities = $meta['abilities'] ?? ($generated['abilities'] ?? []);
  148. QuestionMeta::updateOrCreate(
  149. ['question_id' => $question->id],
  150. [
  151. 'abilities' => $abilities,
  152. 'generation_info' => [
  153. 'source' => 'candidate',
  154. 'candidate_id' => $candidate->id,
  155. ],
  156. 'review_status' => 'reviewed',
  157. ]
  158. );
  159. $this->syncKpRelations($question->id, $kpCodes);
  160. $meta['question_id'] = $question->id;
  161. $meta['images_uploaded'] = !empty($uploadedImages);
  162. $candidate->update([
  163. 'images' => $uploadedImages,
  164. 'status' => PreQuestionCandidate::STATUS_ACCEPTED,
  165. 'meta' => $meta,
  166. ]);
  167. return $question;
  168. });
  169. }
  170. private function uploadCandidateImages(PreQuestionCandidate $candidate): array
  171. {
  172. $meta = $candidate->meta ?? [];
  173. $images = $candidate->images ?? [];
  174. if (is_string($images)) {
  175. $decoded = json_decode($images, true);
  176. $images = is_array($decoded) ? $decoded : [];
  177. }
  178. if (empty($images)) {
  179. return [];
  180. }
  181. if (!empty($meta['images_uploaded'])) {
  182. return $images;
  183. }
  184. $uploadedUrls = [];
  185. foreach ($images as $idx => $url) {
  186. $extension = pathinfo(parse_url((string) $url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION) ?: 'jpg';
  187. $path = "questions/images/{$candidate->id}_{$idx}.{$extension}";
  188. $uploadedUrls[] = $this->uploader->put($path, (string)@file_get_contents($url)) ?: $url;
  189. }
  190. return $uploadedUrls;
  191. }
  192. private function normalizeQuestionType(?string $type, PreQuestionCandidate $candidate): string
  193. {
  194. $type = strtolower(trim((string) $type));
  195. $map = [
  196. 'choice' => 'choice',
  197. 'fill' => 'fill',
  198. 'answer' => 'answer',
  199. '选择题' => 'choice',
  200. '填空题' => 'fill',
  201. '解答题' => 'answer',
  202. '简答题' => 'answer',
  203. '计算题' => 'answer',
  204. ];
  205. if ($type !== '' && isset($map[$type])) {
  206. return $map[$type];
  207. }
  208. if (!empty($candidate->options)) {
  209. return 'choice';
  210. }
  211. $stem = (string) ($candidate->stem ?? $candidate->raw_markdown ?? '');
  212. if (preg_match('/_{2,}|\\(\\s*\\)/u', $stem)) {
  213. return 'fill';
  214. }
  215. return 'answer';
  216. }
  217. private function normalizeKpCodes(array|string $kpCodes): array
  218. {
  219. if (is_string($kpCodes)) {
  220. $kpCodes = preg_split('/[,,\\s]+/u', $kpCodes) ?: [];
  221. }
  222. return array_values(array_filter(array_map('trim', $kpCodes)));
  223. }
  224. private function syncKpRelations(int $questionId, array $kpCodes): void
  225. {
  226. if (empty($kpCodes)) {
  227. return;
  228. }
  229. QuestionKpRelation::query()
  230. ->where('question_id', $questionId)
  231. ->whereNotIn('kp_code', $kpCodes)
  232. ->delete();
  233. foreach ($kpCodes as $code) {
  234. QuestionKpRelation::updateOrCreate(
  235. [
  236. 'question_id' => $questionId,
  237. 'kp_code' => $code,
  238. ],
  239. [
  240. 'weight' => 1.0,
  241. ]
  242. );
  243. }
  244. }
  245. private function joinTags(array|string $tags): ?string
  246. {
  247. if (is_string($tags)) {
  248. $tags = preg_split('/[,,\\s]+/u', $tags) ?: [];
  249. }
  250. $tags = array_values(array_filter(array_map('trim', $tags)));
  251. return empty($tags) ? null : implode(',', $tags);
  252. }
  253. /**
  254. * 验证候选题是否具备入库条件
  255. */
  256. public function validateCandidate(PreQuestionCandidate $candidate): array
  257. {
  258. $errors = [];
  259. $meta = $candidate->meta ?? [];
  260. if (empty($candidate->stem) && empty($candidate->raw_markdown)) {
  261. $errors[] = '题干内容为空';
  262. }
  263. if (empty($candidate->sourcePaper?->textbook_id)) {
  264. $errors[] = '未设置教材信息';
  265. }
  266. if (empty($meta['question_type']) && empty($candidate->question_type)) {
  267. // 虽然 promoteCandidate 会自动兜底题型,但建议在校对阶段明确
  268. }
  269. return $errors;
  270. }
  271. private function hydrateQuestionDetails(PreQuestionCandidate $candidate): void
  272. {
  273. $meta = $candidate->meta ?? [];
  274. $generated = $meta['generated_question'] ?? [];
  275. if (!empty($generated)) {
  276. return;
  277. }
  278. $needs = false;
  279. $fields = [
  280. $meta['answer'] ?? null,
  281. $meta['solution'] ?? null,
  282. $meta['difficulty'] ?? null,
  283. $meta['question_type'] ?? null,
  284. ];
  285. foreach ($fields as $value) {
  286. if (empty($value)) {
  287. $needs = true;
  288. break;
  289. }
  290. }
  291. if (empty($meta['kp_codes']) && empty($candidate->kp_code)) {
  292. $needs = true;
  293. }
  294. $questionType = $this->normalizeQuestionType(
  295. is_string($meta['question_type'] ?? null) ? $meta['question_type'] : null,
  296. $candidate
  297. );
  298. if ($questionType === 'answer' && empty($meta['solution_steps'])) {
  299. $needs = true;
  300. }
  301. if (!$needs) {
  302. return;
  303. }
  304. $questionText = (string) ($candidate->stem ?: $candidate->raw_text ?: $candidate->raw_markdown);
  305. if ($questionText === '') {
  306. return;
  307. }
  308. $context = $this->buildGenerationContext($candidate->sourcePaper);
  309. $images = $candidate->images ?? [];
  310. if (is_string($images)) {
  311. $decoded = json_decode($images, true);
  312. $images = is_array($decoded) ? $decoded : [];
  313. }
  314. $imageText = '';
  315. if (!empty($images)) {
  316. $imageText = "image_urls:\n" . implode("\n", array_map('strval', $images));
  317. }
  318. $parts = [];
  319. if ($context) {
  320. $parts[] = $context;
  321. }
  322. if ($imageText !== '') {
  323. $parts[] = $imageText;
  324. }
  325. $parts[] = "题目内容:\n" . $questionText;
  326. $sourceText = implode("\n\n", $parts);
  327. $result = app(QuestionGenerationService::class)->generateFromSource($sourceText);
  328. if (empty($result['success'])) {
  329. Log::warning('AI question generation failed', [
  330. 'candidate_id' => $candidate->id,
  331. 'message' => $result['message'] ?? 'unknown',
  332. ]);
  333. return;
  334. }
  335. $generated = $result['question'] ?? [];
  336. if (empty($generated)) {
  337. return;
  338. }
  339. if (empty($meta['answer']) && !empty($generated['answer'])) {
  340. $meta['answer'] = $generated['answer'];
  341. }
  342. if (empty($meta['solution']) && !empty($generated['solution'])) {
  343. $meta['solution'] = $generated['solution'];
  344. }
  345. if (empty($meta['difficulty']) && isset($generated['difficulty'])) {
  346. $meta['difficulty'] = $generated['difficulty'];
  347. }
  348. if (empty($meta['question_type']) && !empty($generated['question_type'])) {
  349. $meta['question_type'] = $generated['question_type'];
  350. }
  351. if (empty($meta['kp_codes']) && !empty($generated['knowledge_points'])) {
  352. $meta['kp_codes'] = $generated['knowledge_points'];
  353. }
  354. if (empty($meta['solution_steps']) && !empty($generated['solution_steps'])) {
  355. $meta['solution_steps'] = $generated['solution_steps'];
  356. }
  357. if (empty($meta['abilities']) && !empty($generated['abilities'])) {
  358. $meta['abilities'] = $generated['abilities'];
  359. }
  360. $meta['generated_question'] = $generated;
  361. $candidate->meta = $meta;
  362. }
  363. private function hydrateKnowledgePoints(PreQuestionCandidate $candidate): void
  364. {
  365. $meta = $candidate->meta ?? [];
  366. $kpCodes = $this->normalizeKpCodes($meta['kp_codes'] ?? ($candidate->kp_code ?? []));
  367. if (!empty($kpCodes)) {
  368. return;
  369. }
  370. $questionText = (string) ($candidate->stem ?: $candidate->raw_text ?: $candidate->raw_markdown);
  371. if ($questionText === '') {
  372. return;
  373. }
  374. $paper = $candidate->sourcePaper;
  375. $context = $this->buildKnowledgeMatchContext($paper);
  376. $candidates = $this->buildKnowledgePointPool($paper);
  377. $matches = app(AiKnowledgeService::class)->matchKnowledgePointsByAi($questionText, $candidates, $context);
  378. if (empty($matches)) {
  379. Log::warning('AI knowledge match returned empty', [
  380. 'candidate_id' => $candidate->id,
  381. 'paper_id' => $candidate->source_paper_id,
  382. ]);
  383. return;
  384. }
  385. $meta['kp_codes'] = array_values(array_unique(array_filter(array_map(
  386. fn ($item) => $item['kp_code'] ?? null,
  387. $matches
  388. ))));
  389. $meta['kp_weights'] = $matches;
  390. $candidate->meta = $meta;
  391. }
  392. private function buildKnowledgeMatchContext(?\App\Models\SourcePaper $paper): ?string
  393. {
  394. if (!$paper) {
  395. return null;
  396. }
  397. $lines = [];
  398. if (!empty($paper->grade)) {
  399. $lines[] = '年级: ' . $paper->grade;
  400. }
  401. if (!empty($paper->term)) {
  402. $lines[] = '学期: ' . $paper->term;
  403. }
  404. if (!empty($paper->textbook_id)) {
  405. $textbook = $paper->textbook;
  406. if ($textbook) {
  407. $lines[] = '教材: ' . ($textbook->official_title ?? $textbook->title ?? $textbook->id);
  408. }
  409. }
  410. $meta = $paper->meta ?? [];
  411. $catalogIds = $meta['catalog_node_ids'] ?? ($meta['catalog_node_id'] ?? []);
  412. $catalogIds = $this->normalizeCatalogNodeIds($catalogIds);
  413. if (!empty($catalogIds)) {
  414. $titles = TextbookCatalog::query()
  415. ->whereIn('id', $catalogIds)
  416. ->pluck('title')
  417. ->filter()
  418. ->take(6)
  419. ->toArray();
  420. if (!empty($titles)) {
  421. $lines[] = '目录: ' . implode(' / ', $titles);
  422. }
  423. }
  424. return empty($lines) ? null : implode("\n", $lines);
  425. }
  426. private function buildGenerationContext(?\App\Models\SourcePaper $paper): ?string
  427. {
  428. return $this->buildKnowledgeMatchContext($paper);
  429. }
  430. private function buildKnowledgePointPool(?\App\Models\SourcePaper $paper): array
  431. {
  432. $query = KnowledgePoint::query()->select(['kp_code', 'name']);
  433. if ($paper?->grade) {
  434. $query->where('grade', $paper->grade);
  435. }
  436. if ($paper?->subject) {
  437. $query->where('subject', $paper->subject);
  438. }
  439. return $query->orderBy('kp_code')->limit(300)->get()->toArray();
  440. }
  441. private function resolveSolutionSteps(PreQuestionCandidate $candidate, string $questionType, array $meta): array
  442. {
  443. if ($questionType !== 'answer') {
  444. return [
  445. 'solution' => $meta['solution'] ?? '',
  446. 'steps' => [],
  447. ];
  448. }
  449. if (!empty($meta['solution_steps'])) {
  450. return [
  451. 'solution' => $meta['solution'] ?? '',
  452. 'steps' => $meta['solution_steps'],
  453. ];
  454. }
  455. $questionText = (string) ($candidate->stem ?: $candidate->raw_text ?: $candidate->raw_markdown);
  456. if ($questionText === '') {
  457. return [
  458. 'solution' => $meta['solution'] ?? '',
  459. 'steps' => [],
  460. ];
  461. }
  462. $result = app(AiSolutionService::class)->generateSolutionSteps($questionText);
  463. return [
  464. 'solution' => $result['solution'] ?? '',
  465. 'steps' => $result['steps'] ?? [],
  466. ];
  467. }
  468. private function normalizeCatalogNodeIds(array|string $value): array
  469. {
  470. $ids = is_array($value) ? $value : [$value];
  471. $ids = array_values(array_unique(array_map('intval', array_filter($ids))));
  472. return $ids;
  473. }
  474. }