KnowledgeExplanationService.php 24 KB

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