KnowledgeExplanationService.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. <?php
  2. namespace App\Services;
  3. use App\Models\KnowledgeExplanation;
  4. use App\Models\KnowledgePoint;
  5. use App\Models\MistakeRecord;
  6. use App\Models\PaperQuestion;
  7. use App\Models\Question;
  8. use Illuminate\Support\Collection;
  9. class KnowledgeExplanationService
  10. {
  11. public function __construct(
  12. private readonly ExamPdfExportService $examPdfExportService
  13. ) {
  14. }
  15. public function generateKnowledgeId(): string
  16. {
  17. // 对齐 paper_id 的数字段生成规则(PaperIdGenerator),并增加唯一性兜底
  18. for ($i = 0; $i < 5; $i++) {
  19. $numericId = PaperIdGenerator::generate();
  20. $knowledgeId = 'knowledge_' . $numericId;
  21. if (! $this->validateKnowledgeId($knowledgeId)) {
  22. continue;
  23. }
  24. $exists = KnowledgeExplanation::query()
  25. ->where('knowledge_id', $knowledgeId)
  26. ->exists();
  27. if (! $exists) {
  28. return $knowledgeId;
  29. }
  30. }
  31. throw new \RuntimeException('无法生成唯一的 knowledge_id');
  32. }
  33. public function prepareKnowledgeExplanation(array $payload): array
  34. {
  35. $knowledgeId = (string) ($payload['knowledge_id'] ?? $this->generateKnowledgeId());
  36. if (! $this->validateKnowledgeId($knowledgeId)) {
  37. throw new \InvalidArgumentException('knowledge_id 格式非法,必须为 knowledge_ + 15位数字');
  38. }
  39. $studentId = (string) ($payload['student_id'] ?? '');
  40. $teacherId = (string) ($payload['teacher_id'] ?? '');
  41. $difficultyCategory = isset($payload['difficulty_category']) ? (int) $payload['difficulty_category'] : null;
  42. $kpCodes = $this->resolveKpCodes($payload);
  43. $knowledgePoints = $this->examPdfExportService->buildExplanations($kpCodes);
  44. $history = $this->loadStudentQuestionHistory($studentId);
  45. $casePayload = [];
  46. foreach ($knowledgePoints as &$point) {
  47. $kpCode = (string) ($point['kp_code'] ?? '');
  48. if ($kpCode === '') {
  49. $point['cases'] = [];
  50. continue;
  51. }
  52. $cases = $this->pickCasesForKnowledgePoint($kpCode, $history['done'], $history['wrong'], 5, $difficultyCategory);
  53. $point['cases'] = $cases;
  54. $casePayload[$kpCode] = array_map(static function (array $item): array {
  55. return [
  56. 'question_id' => $item['question_id'],
  57. 'source_type' => $item['source_type'],
  58. 'is_wrong_case' => $item['is_wrong_case'],
  59. 'child_kp_code' => $item['child_kp_code'] ?? null,
  60. 'child_kp_name' => $item['child_kp_name'] ?? null,
  61. 'source_label' => $item['source_label'] ?? null,
  62. ];
  63. }, $cases);
  64. }
  65. unset($point);
  66. $contentHash = hash('sha256', json_encode([
  67. 'student_id' => $studentId,
  68. 'kp_codes' => $kpCodes,
  69. 'case_payload' => $casePayload,
  70. ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
  71. $record = KnowledgeExplanation::updateOrCreate([
  72. 'knowledge_id' => $knowledgeId,
  73. ], [
  74. 'teacher_id' => $teacherId,
  75. 'student_id' => $studentId,
  76. 'assemble_type' => 22,
  77. 'status' => 'processing',
  78. 'kp_codes' => $kpCodes,
  79. 'case_payload' => $casePayload,
  80. 'content_hash' => $contentHash,
  81. 'pdf_url' => null,
  82. 'generated_at' => null,
  83. ]);
  84. return [
  85. 'knowledge_id' => $knowledgeId,
  86. 'record' => $record,
  87. 'knowledge_points' => $knowledgePoints,
  88. ];
  89. }
  90. /**
  91. * 仅用于本地模板调试预览:不落库,直接返回渲染数据。
  92. */
  93. public function previewKnowledgeExplanation(array $payload): array
  94. {
  95. $knowledgeId = (string) ($payload['knowledge_id'] ?? $this->generateKnowledgeId());
  96. if (! $this->validateKnowledgeId($knowledgeId)) {
  97. throw new \InvalidArgumentException('knowledge_id 格式非法,必须为 knowledge_ + 15位数字');
  98. }
  99. $studentId = (string) ($payload['student_id'] ?? '');
  100. $teacherId = (string) ($payload['teacher_id'] ?? '');
  101. $difficultyCategory = isset($payload['difficulty_category']) ? (int) $payload['difficulty_category'] : null;
  102. $kpCodes = $this->resolveKpCodes($payload);
  103. $knowledgePoints = $this->examPdfExportService->buildExplanations($kpCodes);
  104. $history = $this->loadStudentQuestionHistory($studentId);
  105. foreach ($knowledgePoints as &$point) {
  106. $kpCode = (string) ($point['kp_code'] ?? '');
  107. if ($kpCode === '') {
  108. $point['cases'] = [];
  109. continue;
  110. }
  111. $point['cases'] = $this->pickCasesForKnowledgePoint($kpCode, $history['done'], $history['wrong'], 5, $difficultyCategory);
  112. }
  113. unset($point);
  114. return [
  115. 'knowledge_id' => $knowledgeId,
  116. 'student_id' => $studentId,
  117. 'teacher_id' => $teacherId,
  118. 'knowledge_points' => $knowledgePoints,
  119. ];
  120. }
  121. /**
  122. * 使用已保存的 knowledge_id/kp_codes/case_payload 重建 PDF 渲染数据。
  123. * 知识点正文会读取当前库中最新内容,案例题目按 case_payload 中的 question_id 复原。
  124. */
  125. public function rebuildKnowledgePointsForRecord(KnowledgeExplanation $record): array
  126. {
  127. $kpCodes = is_array($record->kp_codes) ? $record->kp_codes : [];
  128. $knowledgePoints = $this->examPdfExportService->buildExplanations($kpCodes);
  129. $casePayload = is_array($record->case_payload) ? $record->case_payload : [];
  130. if (! empty($casePayload)) {
  131. $questionIds = [];
  132. foreach ($casePayload as $items) {
  133. if (! is_array($items)) {
  134. continue;
  135. }
  136. foreach ($items as $item) {
  137. $questionId = (int) ($item['question_id'] ?? 0);
  138. if ($questionId > 0) {
  139. $questionIds[$questionId] = true;
  140. }
  141. }
  142. }
  143. $questionsById = empty($questionIds)
  144. ? collect()
  145. : Question::query()
  146. ->whereIn('id', array_keys($questionIds))
  147. ->get(['id', 'kp_code', 'stem', 'options', 'meta', 'answer', 'solution', 'question_type', 'difficulty'])
  148. ->keyBy('id');
  149. foreach ($knowledgePoints as &$point) {
  150. $kpCode = (string) ($point['kp_code'] ?? '');
  151. $point['cases'] = [];
  152. $items = $casePayload[$kpCode] ?? [];
  153. if (! is_array($items)) {
  154. continue;
  155. }
  156. foreach ($items as $item) {
  157. if (! is_array($item)) {
  158. continue;
  159. }
  160. $questionId = (int) ($item['question_id'] ?? 0);
  161. $question = $questionsById->get($questionId);
  162. if (! $question instanceof Question) {
  163. continue;
  164. }
  165. $sourceType = (string) ($item['source_type'] ?? 'fallback');
  166. $case = $this->formatCaseQuestion($question, $sourceType, (bool) ($item['is_wrong_case'] ?? false));
  167. $case['child_kp_code'] = $item['child_kp_code'] ?? null;
  168. $case['child_kp_name'] = $item['child_kp_name'] ?? null;
  169. $case['source_label'] = $item['source_label'] ?? ($case['source_label'] ?? null);
  170. $point['cases'][] = $case;
  171. }
  172. }
  173. unset($point);
  174. return $knowledgePoints;
  175. }
  176. $payload = [
  177. 'knowledge_id' => $record->knowledge_id,
  178. 'student_id' => $record->student_id,
  179. 'teacher_id' => $record->teacher_id,
  180. 'kp_codes' => $kpCodes,
  181. ];
  182. return (array) ($this->previewKnowledgeExplanation($payload)['knowledge_points'] ?? []);
  183. }
  184. private function validateKnowledgeId(string $knowledgeId): bool
  185. {
  186. if (! preg_match('/^knowledge_([1-9]\d{14})$/', $knowledgeId, $matches)) {
  187. return false;
  188. }
  189. return PaperIdGenerator::validate($matches[1]);
  190. }
  191. private function resolveKpCodes(array $payload): array
  192. {
  193. $raw = $payload['kp_codes'] ?? $payload['kp_code_list'] ?? [];
  194. if (! is_array($raw)) {
  195. return [];
  196. }
  197. $codes = [];
  198. foreach ($raw as $code) {
  199. $value = trim((string) $code);
  200. if ($value === '') {
  201. continue;
  202. }
  203. $codes[$value] = true;
  204. }
  205. return array_keys($codes);
  206. }
  207. private function loadStudentQuestionHistory(string $studentId): array
  208. {
  209. $done = PaperQuestion::query()
  210. ->select('paper_questions.question_bank_id')
  211. ->join('papers', 'papers.paper_id', '=', 'paper_questions.paper_id')
  212. ->where('papers.student_id', $studentId)
  213. ->whereNotNull('paper_questions.question_bank_id')
  214. ->pluck('paper_questions.question_bank_id')
  215. ->map(static fn ($id) => (int) $id)
  216. ->filter(static fn ($id) => $id > 0)
  217. ->unique()
  218. ->values()
  219. ->all();
  220. $wrong = MistakeRecord::query()
  221. ->where('student_id', $studentId)
  222. ->whereNotNull('question_id')
  223. ->pluck('question_id')
  224. ->map(static fn ($id) => (int) $id)
  225. ->filter(static fn ($id) => $id > 0)
  226. ->unique()
  227. ->values()
  228. ->all();
  229. return [
  230. 'done' => $done,
  231. 'wrong' => $wrong,
  232. ];
  233. }
  234. private function pickCasesForKnowledgePoint(string $kpCode, array $doneIds, array $wrongIds, int $limit, ?int $difficultyCategory = null): array
  235. {
  236. $children = KnowledgePoint::query()
  237. ->where('parent_kp_code', $kpCode)
  238. ->whereNotNull('kp_code')
  239. ->where('kp_code', '!=', '')
  240. ->orderBy('id')
  241. ->limit($limit)
  242. ->get(['kp_code', 'name']);
  243. if ($children->isNotEmpty()) {
  244. $selected = collect();
  245. $usedQuestionIds = [];
  246. foreach ($children as $child) {
  247. if ($selected->count() >= $limit) {
  248. break;
  249. }
  250. $case = $this->pickSingleCaseForKnowledgePoint((string) $child->kp_code, $doneIds, $wrongIds, $usedQuestionIds, $difficultyCategory);
  251. if ($case === null) {
  252. continue;
  253. }
  254. $case['child_kp_code'] = (string) $child->kp_code;
  255. $case['child_kp_name'] = (string) ($child->name ?: $child->kp_code);
  256. $case['source_label'] = (string) ($child->name ?: $child->kp_code);
  257. $selected->push($case);
  258. $usedQuestionIds[] = (int) $case['question_id'];
  259. }
  260. return $selected->values()->all();
  261. }
  262. $selected = collect();
  263. $pick = function (Collection $bucket, string $sourceType, bool $isWrong) use ($selected, $limit): void {
  264. foreach ($bucket as $question) {
  265. if ($selected->count() >= $limit) {
  266. break;
  267. }
  268. if ($selected->contains('question_id', (int) $question->id)) {
  269. continue;
  270. }
  271. $selected->push($this->formatCaseQuestion($question, $sourceType, $isWrong));
  272. }
  273. };
  274. $pick($this->queryBucket($kpCode, static function ($query) use ($doneIds) {
  275. if (! empty($doneIds)) {
  276. $query->whereNotIn('id', $doneIds);
  277. }
  278. }, $difficultyCategory), 'new', false);
  279. if ($selected->count() < $limit) {
  280. $pick($this->queryBucket($kpCode, static function ($query) use ($wrongIds) {
  281. if (empty($wrongIds)) {
  282. $query->whereRaw('1=0');
  283. return;
  284. }
  285. $query->whereIn('id', $wrongIds);
  286. }, $difficultyCategory), 'wrong', true);
  287. }
  288. if ($selected->count() < $limit) {
  289. $pick($this->queryBucket($kpCode, static function ($query) use ($doneIds) {
  290. if (empty($doneIds)) {
  291. $query->whereRaw('1=0');
  292. return;
  293. }
  294. $query->whereIn('id', $doneIds);
  295. }, $difficultyCategory), 'reviewed', false);
  296. }
  297. if ($selected->count() < $limit) {
  298. $excluded = $selected->pluck('question_id')->all();
  299. $pick($this->queryBucket($kpCode, static function ($query) use ($excluded) {
  300. if (! empty($excluded)) {
  301. $query->whereNotIn('id', $excluded);
  302. }
  303. }, $difficultyCategory), 'fallback', false);
  304. }
  305. return $selected->values()->all();
  306. }
  307. private function pickSingleCaseForKnowledgePoint(string $kpCode, array $doneIds, array $wrongIds, array $excludedIds = [], ?int $difficultyCategory = null): ?array
  308. {
  309. $pickOne = function (callable $mutator, string $sourceType, bool $isWrong) use ($kpCode, $difficultyCategory): ?array {
  310. $bucket = $this->queryBucket($kpCode, $mutator, $difficultyCategory);
  311. $question = $bucket->first();
  312. if (! $question) {
  313. return null;
  314. }
  315. return $this->formatCaseQuestion($question, $sourceType, $isWrong);
  316. };
  317. $baseExclude = $excludedIds;
  318. $case = $pickOne(static function ($query) use ($doneIds, $baseExclude) {
  319. if (! empty($doneIds)) {
  320. $query->whereNotIn('id', $doneIds);
  321. }
  322. if (! empty($baseExclude)) {
  323. $query->whereNotIn('id', $baseExclude);
  324. }
  325. }, 'new', false);
  326. if ($case) {
  327. return $case;
  328. }
  329. $case = $pickOne(static function ($query) use ($wrongIds, $baseExclude) {
  330. if (empty($wrongIds)) {
  331. $query->whereRaw('1=0');
  332. return;
  333. }
  334. $query->whereIn('id', $wrongIds);
  335. if (! empty($baseExclude)) {
  336. $query->whereNotIn('id', $baseExclude);
  337. }
  338. }, 'wrong', true);
  339. if ($case) {
  340. return $case;
  341. }
  342. $case = $pickOne(static function ($query) use ($doneIds, $baseExclude) {
  343. if (empty($doneIds)) {
  344. $query->whereRaw('1=0');
  345. return;
  346. }
  347. $query->whereIn('id', $doneIds);
  348. if (! empty($baseExclude)) {
  349. $query->whereNotIn('id', $baseExclude);
  350. }
  351. }, 'reviewed', false);
  352. if ($case) {
  353. return $case;
  354. }
  355. return $pickOne(static function ($query) use ($baseExclude) {
  356. if (! empty($baseExclude)) {
  357. $query->whereNotIn('id', $baseExclude);
  358. }
  359. }, 'fallback', false);
  360. }
  361. private function queryBucket(string $kpCode, callable $mutator, ?int $difficultyCategory = null): Collection
  362. {
  363. $query = Question::query()
  364. ->where('kp_code', $kpCode)
  365. ->whereNotNull('stem')
  366. ->where('stem', '!=', '')
  367. ->whereNotNull('answer')
  368. ->whereNotNull('solution')
  369. ->inRandomOrder()
  370. ->limit(80);
  371. $mutator($query);
  372. $candidates = $query->get(['id', 'kp_code', 'stem', 'options', 'meta', 'answer', 'solution', 'question_type', 'difficulty']);
  373. return $this->rankCandidates($candidates, $difficultyCategory, 30);
  374. }
  375. private function rankCandidates(Collection $candidates, ?int $difficultyCategory, int $limit): Collection
  376. {
  377. if ($candidates->isEmpty()) {
  378. return collect();
  379. }
  380. // 无难度偏好时:随机抽样
  381. if ($difficultyCategory === null || $difficultyCategory < 0 || $difficultyCategory > 4) {
  382. return $candidates->shuffle()->take($limit)->values();
  383. }
  384. $target = $this->targetDifficultyByCategory($difficultyCategory);
  385. // 先按难度贴合度排序,再加随机扰动,避免每次都返回同题
  386. $ranked = $candidates
  387. ->shuffle()
  388. ->map(function (Question $q) use ($target): array {
  389. $difficulty = $this->normalizeDifficultyValue($q->difficulty);
  390. $distance = abs($difficulty - $target);
  391. $jitter = mt_rand(0, 1000) / 10000;
  392. return [
  393. 'question' => $q,
  394. 'rank_score' => $distance + $jitter,
  395. ];
  396. })
  397. ->sortBy('rank_score')
  398. ->pluck('question')
  399. ->take($limit)
  400. ->values();
  401. return $ranked;
  402. }
  403. private function targetDifficultyByCategory(int $difficultyCategory): float
  404. {
  405. return match ($difficultyCategory) {
  406. 0 => 0.25,
  407. 1 => 0.40,
  408. 2 => 0.55,
  409. 3 => 0.70,
  410. 4 => 0.85,
  411. default => 0.55,
  412. };
  413. }
  414. private function normalizeDifficultyValue(mixed $difficulty): float
  415. {
  416. if (! is_numeric($difficulty)) {
  417. return 0.55;
  418. }
  419. $value = (float) $difficulty;
  420. if ($value > 1) {
  421. $value = $value / 5;
  422. }
  423. return max(0.0, min(1.0, $value));
  424. }
  425. private function formatCaseQuestion(Question $question, string $sourceType, bool $isWrongCase): array
  426. {
  427. $sourceLabel = match ($sourceType) {
  428. 'wrong' => '错题讲解',
  429. 'reviewed' => '已做题',
  430. 'fallback' => '补充题',
  431. default => '新题',
  432. };
  433. $stemRaw = (string) ($question->stem ?? '');
  434. $options = $this->normalizeQuestionOptions($question->options);
  435. if (empty($options) && is_array($question->meta ?? null)) {
  436. $meta = (array) $question->meta;
  437. $options = $this->normalizeQuestionOptions($meta['options'] ?? $meta['question_options'] ?? null);
  438. }
  439. if (empty($options)) {
  440. [$stemWithoutExtractedOptions, $extractedOptions] = $this->extractChoiceOptionsFromStem((string) ($question->stem ?? ''));
  441. if (! empty($extractedOptions)) {
  442. $stemRaw = $stemWithoutExtractedOptions;
  443. $options = $extractedOptions;
  444. }
  445. }
  446. return [
  447. 'question_id' => (int) $question->id,
  448. 'source_type' => $sourceType,
  449. 'is_wrong_case' => $isWrongCase,
  450. 'source_label' => $sourceLabel,
  451. 'stem' => $stemRaw,
  452. 'options' => $options,
  453. 'answer' => (string) ($question->answer ?? ''),
  454. 'solution' => (string) ($question->solution ?? ''),
  455. 'question_type' => (string) ($question->question_type ?? ''),
  456. 'difficulty' => is_numeric($question->difficulty) ? (float) $question->difficulty : null,
  457. ];
  458. }
  459. /**
  460. * 标准化选择题选项,输出为 ['A' => '...', 'B' => '...']。
  461. */
  462. private function normalizeQuestionOptions(mixed $rawOptions): array
  463. {
  464. if (is_string($rawOptions)) {
  465. $decoded = json_decode($rawOptions, true);
  466. if (is_array($decoded)) {
  467. $rawOptions = $decoded;
  468. }
  469. }
  470. if (! is_array($rawOptions) || empty($rawOptions)) {
  471. return [];
  472. }
  473. $normalized = [];
  474. foreach ($rawOptions as $key => $value) {
  475. $label = strtoupper(trim((string) $key));
  476. $content = '';
  477. if (is_array($value)) {
  478. // 兼容多种选项结构:['A' => '...'] / [['label'=>'A','content'=>'...']]
  479. $candidateLabel = (string) ($value['label'] ?? $value['key'] ?? '');
  480. if ($candidateLabel !== '') {
  481. $label = strtoupper(trim($candidateLabel));
  482. }
  483. $content = (string) ($value['content'] ?? $value['value'] ?? $value['text'] ?? '');
  484. } else {
  485. $content = (string) $value;
  486. }
  487. if (trim($content) === '') {
  488. continue;
  489. }
  490. if (! preg_match('/^[A-Z]$/', $label)) {
  491. $idx = count($normalized);
  492. $label = chr(ord('A') + $idx);
  493. }
  494. // 选项文本保持原样,公式与 HTML 转义由卷子共用 partial(exam-choice-options)处理
  495. $normalized[$label] = $content;
  496. }
  497. return $normalized;
  498. }
  499. /**
  500. * 兜底:从题干中提取 A/B/C/D 选项文本(兼容旧库数据)。
  501. */
  502. private function extractChoiceOptionsFromStem(string $stem): array
  503. {
  504. if (trim($stem) === '') {
  505. return [$stem, []];
  506. }
  507. $pattern = '/(?:^|<br\s*\/?>|\r?\n)\s*([A-H])\s*[\..、::]\s*(.+?)(?=(?:<br\s*\/?>|\r?\n)\s*[A-H]\s*[\..、::]\s*|$)/isu';
  508. preg_match_all($pattern, $stem, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
  509. if (empty($matches)) {
  510. return [$stem, []];
  511. }
  512. $options = [];
  513. foreach ($matches as $m) {
  514. $label = strtoupper(trim((string) ($m[1][0] ?? '')));
  515. $content = trim((string) ($m[2][0] ?? ''));
  516. if ($label === '' || $content === '') {
  517. continue;
  518. }
  519. $options[$label] = $content;
  520. }
  521. if (empty($options)) {
  522. return [$stem, []];
  523. }
  524. $firstOptionOffset = (int) ($matches[0][0][1] ?? 0);
  525. $stemWithoutOptions = trim(substr($stem, 0, $firstOptionOffset));
  526. return [$stemWithoutOptions !== '' ? $stemWithoutOptions : $stem, $options];
  527. }
  528. }