ExtractPaliTerm.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Helpers\LlmResponseParser;
  4. use App\Http\Api\ChannelApi;
  5. use App\Http\Resources\AiModelResource;
  6. use App\Models\PaliText;
  7. use App\Services\AIModelService;
  8. use App\Services\OpenAIService;
  9. use App\Services\SentenceService;
  10. use App\Services\TermService;
  11. use Illuminate\Console\Command;
  12. use Illuminate\Support\Facades\Cache;
  13. use Illuminate\Support\Facades\Log;
  14. class ExtractPaliTerm extends Command
  15. {
  16. // 断点缓存键:记录最后一个已完成的 chapter 游标 ['book'=>int,'paragraph'=>int],
  17. // 使命令可重入——中断后重跑自动跳过已完成的 chapter
  18. private const CURSOR_KEY = 'extract:pali.term:cursor';
  19. /**
  20. * 用 LLM 从巴利文经文中提取术语。
  21. *
  22. * 以 pali_texts 表为准,扁平遍历每个 book 的 chapter(level 1-7)。从每个 book
  23. * 的第一个 chapter 开始,按 paragraph 顺序,每个 chapter 覆盖「本 chapter 起始段落
  24. * → 下一个 chapter 起始段落之前」的所有段落(含正文 bodytext)。把该 chapter 的
  25. * 巴利文(pali_texts.text 拼接)连同完整社区术语表发给大模型,引导其输出与本章相关
  26. * 的全部术语的巴利语单词(JSON 字符串数组),保存到系统术语 channel。
  27. *
  28. * 测试:
  29. * php artisan extract:pali.term 554cdbc1-e170-4145-a808-3cc2cfa721c7 --book=93 --para=8
  30. *
  31. * @var string
  32. */
  33. protected $signature = 'extract:pali.term
  34. {model : LLM 模型 id}
  35. {--book= : 仅处理该 book;不传则遍历全部 book}
  36. {--para= : 仅处理包含该段落的 chapter(向下取所在 chapter);需配合 --book}
  37. {--thinking= : 开启/关闭 deepseek thinking,true | false}
  38. {--fresh : 清除断点缓存,从头开始}';
  39. /**
  40. * The console command description.
  41. *
  42. * @var string
  43. */
  44. protected $description = '用 LLM 逐 chapter 提取巴利文相关术语并保存到系统术语 channel';
  45. /**
  46. * LLM 模型配置(含 model/url/key/uid,支持数组访问)。
  47. */
  48. protected AiModelResource $model;
  49. /**
  50. * 保存目标 channel(系统术语 channel)。
  51. */
  52. protected array $glossaryChannel;
  53. /**
  54. * 术语表词条原形集合(word => true),用于后置过滤:
  55. * 只保留确实存在于术语表的词,丢弃 LLM 输出的屈折变形或臆造词。
  56. *
  57. * @var array<string, bool>
  58. */
  59. protected array $glossarySet = [];
  60. protected ?bool $thinking = null;
  61. public function __construct(
  62. protected AIModelService $modelService,
  63. protected OpenAIService $openAIService,
  64. protected SentenceService $sentenceService,
  65. protected TermService $termService
  66. ) {
  67. parent::__construct();
  68. }
  69. /**
  70. * Execute the console command.
  71. */
  72. public function handle(): int
  73. {
  74. // ---------- 模型 ----------
  75. $model = $this->modelService->getModelById($this->argument('model'));
  76. if (! $model instanceof AiModelResource || $model->resource === null) {
  77. $this->error('invalid model id');
  78. return 1;
  79. }
  80. $this->model = $model;
  81. $this->info("model: {$this->model['model']}");
  82. if ($this->option('thinking') !== null) {
  83. $this->thinking = $this->option('thinking') === 'true';
  84. }
  85. // ---------- 保存目标 channel ----------
  86. $channelId = ChannelApi::getSysChannel('_system_glossary_');
  87. if (! $channelId) {
  88. $this->error('system glossary channel "_system_glossary_" not found');
  89. return 1;
  90. }
  91. $this->glossaryChannel = ChannelApi::getById($channelId);
  92. Log::info('extract:pali.term glossary channel', ['channel' => $channelId]);
  93. // ---------- 完整术语表(一次性加载,所有 chapter 共用)----------
  94. $glossaryJson = $this->loadGlossaryJson();
  95. if ($glossaryJson === null) {
  96. return 1;
  97. }
  98. if ($this->option('fresh')) {
  99. Cache::forget(self::CURSOR_KEY);
  100. $this->info('cleared checkpoint');
  101. }
  102. // ---------- 模式三:book + paragraph —— 忽略断点,只跑该 chapter ----------
  103. if ($this->option('book') && $this->option('para')) {
  104. $this->runSingleChapter((int) $this->option('book'), (int) $this->option('para'), $glossaryJson);
  105. return 0;
  106. }
  107. // --para 必须配合 --book
  108. if ($this->option('para')) {
  109. $this->error('--para must be used together with --book');
  110. return 1;
  111. }
  112. $cursor = Cache::get(self::CURSOR_KEY, []);
  113. // ---------- 模式二:仅 book —— 同一 book 从断点续,跑完即止,不跳到下一 book ----------
  114. if ($this->option('book')) {
  115. $book = (int) $this->option('book');
  116. // 断点是同一 book 才续跑,否则整本从头
  117. $resumeCursor = (! empty($cursor) && (int) $cursor['book'] === $book) ? $cursor : [];
  118. if (! empty($resumeCursor)) {
  119. $this->info("resume book {$book} from para {$resumeCursor['paragraph']}");
  120. }
  121. $this->runBook($book, $glossaryJson, $resumeCursor);
  122. return 0;
  123. }
  124. // ---------- 模式一:无参数 —— 从断点继续,跨 book 直到末尾 ----------
  125. $startBook = ! empty($cursor) ? (int) $cursor['book'] : 1;
  126. if (! empty($cursor)) {
  127. $this->info("resume from book {$cursor['book']} para {$cursor['paragraph']}");
  128. }
  129. for ($book = $startBook; $book <= 217; $book++) {
  130. // 仅断点所在的首 book 受游标限制,之后的 book 全量处理
  131. $this->runBook($book, $glossaryJson, $book === $startBook ? $cursor : []);
  132. }
  133. // 全部完成,清空断点
  134. Cache::forget(self::CURSOR_KEY);
  135. $this->info('all books completed');
  136. return 0;
  137. }
  138. /**
  139. * 加载完整术语表(dhamma_terms 全表、跨所有 channel 去重)并编码为发送给 LLM 的紧凑 JSON。
  140. *
  141. * @return string|null 编码后的术语表 JSON;术语表为空时返回 null
  142. */
  143. private function loadGlossaryJson(): ?string
  144. {
  145. $terms = $this->termService->getGlossary();
  146. if (empty($terms)) {
  147. $this->error('glossary is empty');
  148. return null;
  149. }
  150. // 词条原形集合,供 askLlm 后置过滤(O(1) 查找)
  151. $this->glossarySet = array_fill_keys($terms, true);
  152. $this->info('glossary loaded: '.count($terms).' terms (dhamma_terms + user_dicts)');
  153. return json_encode($terms, JSON_UNESCAPED_UNICODE);
  154. }
  155. /**
  156. * 处理单个 book 的全部 chapter(扁平遍历),逐 chapter 写入断点以支持重入。
  157. *
  158. * @param array{book?: int, paragraph?: int} $resumeCursor 断点游标;非空时跳过其
  159. * (含)之前已完成的 chapter
  160. */
  161. private function runBook(int $book, string $glossaryJson, array $resumeCursor = []): void
  162. {
  163. // 本书全部 chapter 节点(level 1-7),按 paragraph 顺序——扁平遍历,非树遍历
  164. $chapters = PaliText::where('book', $book)
  165. ->whereBetween('level', [1, 7])
  166. ->orderBy('paragraph')
  167. ->get(['paragraph', 'level', 'toc']);
  168. if ($chapters->isEmpty()) {
  169. Log::info('extract:pali.term no chapter', ['book' => $book]);
  170. return;
  171. }
  172. $maxParagraph = (int) PaliText::where('book', $book)->max('paragraph');
  173. foreach ($chapters as $chapter) {
  174. $start = (int) $chapter->paragraph;
  175. // 断点跳过:游标(含)之前的 chapter 视为已完成
  176. if ($this->isDone($resumeCursor, $book, $start)) {
  177. $this->info("skip {$book}-{$start} (done)");
  178. continue;
  179. }
  180. $end = $this->chapterEnd($book, $start, $maxParagraph);
  181. $this->processChapter($book, $start, $end, $glossaryJson);
  182. // 该 chapter 完成后写入断点(中途中断时不会误标记未完成的 chapter)
  183. Cache::put(self::CURSOR_KEY, ['book' => $book, 'paragraph' => $start], now()->addHours(48));
  184. }
  185. }
  186. /**
  187. * 模式三:处理 --para 所在的单个 chapter(向下取所在 chapter),不读写断点。
  188. */
  189. private function runSingleChapter(int $book, int $para, string $glossaryJson): void
  190. {
  191. // 向下取所在 chapter:≤para 的最近 chapter 节点(level 1-7)
  192. $target = PaliText::where('book', $book)
  193. ->where('paragraph', '<=', $para)
  194. ->whereBetween('level', [1, 7])
  195. ->orderBy('paragraph', 'desc')
  196. ->first(['paragraph']);
  197. if (! $target) {
  198. $this->error("no chapter found for book={$book} para={$para}");
  199. return;
  200. }
  201. $start = (int) $target->paragraph;
  202. $maxParagraph = (int) PaliText::where('book', $book)->max('paragraph');
  203. $end = $this->chapterEnd($book, $start, $maxParagraph);
  204. $this->processChapter($book, $start, $end, $glossaryJson);
  205. }
  206. /**
  207. * chapter 区间终点 = 下一个 chapter 起始段落之前;末章到本书结尾。
  208. */
  209. private function chapterEnd(int $book, int $start, int $maxParagraph): int
  210. {
  211. $next = PaliText::where('book', $book)
  212. ->where('paragraph', '>', $start)
  213. ->whereBetween('level', [1, 7])
  214. ->orderBy('paragraph')
  215. ->value('paragraph');
  216. return $next ? (int) $next - 1 : $maxParagraph;
  217. }
  218. /**
  219. * 判断某 chapter 是否在断点游标(含)之前——即已完成、应跳过。
  220. *
  221. * @param array{book?: int, paragraph?: int} $cursor
  222. */
  223. private function isDone(array $cursor, int $book, int $para): bool
  224. {
  225. if (empty($cursor)) {
  226. return false;
  227. }
  228. return $book < (int) $cursor['book']
  229. || ($book === (int) $cursor['book'] && $para <= (int) $cursor['paragraph']);
  230. }
  231. /**
  232. * 处理单个 chapter:拼接巴利文 → 调 LLM → 解析 → 保存。
  233. */
  234. private function processChapter(int $book, int $start, int $end, string $glossaryJson): void
  235. {
  236. $startAt = time();
  237. // chapter 巴利文 = 区间内全部段落 pali_texts.text 顺序拼接
  238. $paliText = PaliText::where('book', $book)
  239. ->whereBetween('paragraph', [$start, $end])
  240. ->orderBy('paragraph')
  241. ->pluck('text')
  242. ->filter(fn ($t) => filled($t))
  243. ->implode("\n");
  244. Log::info('extract:pali.term chapter', [
  245. 'book' => $book,
  246. 'start' => $start,
  247. 'end' => $end,
  248. 'pali_strlen' => mb_strlen($paliText),
  249. ]);
  250. if ($paliText === '') {
  251. $this->warn("skip empty chapter {$book}-{$start}");
  252. return;
  253. }
  254. $words = $this->askLlm($glossaryJson, $paliText);
  255. $this->saveChapterGlossary($book, $start, $words);
  256. $time = time() - $startAt;
  257. $this->info("chapter {$book}-{$start} (para {$start}-{$end}) => ".count($words)." terms, time={$time}s");
  258. }
  259. /**
  260. * 把术语表与 chapter 巴利文发给 LLM,返回相关术语的巴利语单词数组。
  261. *
  262. * @return array<int, string>
  263. */
  264. private function askLlm(string $glossaryJson, string $paliText): array
  265. {
  266. $sysPrompt = <<<'md'
  267. 你是一位精通巴利语的佛教术语专家。
  268. ## 任务
  269. 给定一段巴利文原文,以及一份术语表(巴利语单词列表),
  270. 请从术语表中找出与这段经文相关的全部术语,并且输出。
  271. ## 要求
  272. - 输出的每个单词必须是给你的术语表中的单词。
  273. - 经文中的术语往往以屈折变化(变格、变位、复合、连音等)形式出现。你必须把这些
  274. 变形还原、找到对应术语表中的词条,输出术语表里的那个原形,而不是经文中出现的变形。
  275. 例如:经文中的 "brahmadattena"(具格)对应词条 "brahmadatta",应输出 "brahmadatta";
  276. "sattānaṃ" 对应 "satta",输出 "satta";"buddhassa" 对应 "buddha",输出 "buddha"。
  277. - 绝不要输出经文里的屈折变形,也不要输出术语表中不存在的单词。
  278. - 复合词中局部包含术语的,也要输出。不要输出整个复合词,只输出其中包含的术语。
  279. - 只输出与本段经文内容确实相关的术语,不要输出无关术语。
  280. - 输出 JSON 数组,元素为术语表中的巴利语单词原形,不要包含释义或其他字段。
  281. ## 只保留有实义的术语,排除非术语词(即使它们出现在术语表中)
  282. 术语指有独立含义的佛教/巴利专有概念、法相名词,或人名、地名、专有名词。
  283. 请排除以下这类“非术语”的功能词与泛用词:
  284. - 语法虚词、不变词、连词、语气词:如 ca、vā、pana、kho、iti、hi、api、eva、ce、tu 等。
  285. - 代词、指示词、疑问词:如 so、ayaṃ、taṃ、idha、kiṃ、ya- 等。
  286. - 极常见的系动词、泛用动词及其变位:如 hoti、atthi、karoti 等纯功能性用法。
  287. - 其他你判断不构成独立术语的高频功能词、助词、量词等。
  288. 判断标准:若去掉该词后不影响“这是一个值得收录为词条的概念/名相”,则视为非术语并排除。
  289. ## 输出格式(直接输出 JSON,无需任何额外说明)
  290. ["dukkha","nibbāna","saṅkhāra"]
  291. md;
  292. $userMessage = "## 术语表\n```json\n{$glossaryJson}\n```\n\n## 巴利文经文\n{$paliText}";
  293. $llm = $this->openAIService->setApiUrl($this->model['url'])
  294. ->setModel($this->model['model'])
  295. ->setApiKey($this->model['key'])
  296. ->setSystemPrompt($sysPrompt)
  297. ->setTemperature(0.3)
  298. ->setStream(false);
  299. if ($this->thinking !== null) {
  300. $llm = $llm->setThinking($this->thinking);
  301. }
  302. $response = $llm->send($userMessage);
  303. $content = is_array($response)
  304. ? ($response['choices'][0]['message']['content'] ?? '[]')
  305. : (string) $response;
  306. Log::debug('extract:pali.term llm response', ['content' => $content]);
  307. $parsed = LlmResponseParser::json($content);
  308. // 规范化:非空字符串、去重、去空白
  309. $candidates = [];
  310. foreach ($parsed as $item) {
  311. if (is_string($item) && trim($item) !== '') {
  312. $candidates[] = trim($item);
  313. }
  314. }
  315. $candidates = array_values(array_unique($candidates));
  316. // 后置过滤:归一为 NFC 后比对,只保留确实存在于术语表的词条原形,丢弃屈折变形/臆造词
  317. $words = [];
  318. $dropped = [];
  319. foreach ($candidates as $word) {
  320. $nfc = $this->toNfc($word);
  321. if (isset($this->glossarySet[$nfc])) {
  322. $words[] = $nfc;
  323. } else {
  324. $dropped[] = $word;
  325. }
  326. }
  327. if (! empty($dropped)) {
  328. Log::warning('extract:pali.term dropped non-glossary words', ['dropped' => $dropped]);
  329. }
  330. return $words;
  331. }
  332. /**
  333. * 将 chapter 术语(巴利语单词数组)以 JSON 保存为一条 Sentence 记录。
  334. *
  335. * paragraph 用 chapter 起始段落,整章一条记录故 word_start/word_end 均为 0。
  336. *
  337. * @param array<int, string> $words
  338. */
  339. private function saveChapterGlossary(int $book, int $startPara, array $words): void
  340. {
  341. try {
  342. $this->sentenceService->saveWithHistory([
  343. 'book_id' => $book,
  344. 'paragraph' => $startPara,
  345. 'word_start' => 0,
  346. 'word_end' => 0,
  347. 'channel_uid' => $this->glossaryChannel['id'],
  348. 'content' => json_encode($words, JSON_UNESCAPED_UNICODE),
  349. 'content_type' => 'json',
  350. 'lang' => $this->glossaryChannel['lang'] ?? 'pli',
  351. 'status' => $this->glossaryChannel['status'] ?? 10,
  352. 'editor_uid' => $this->model['uid'],
  353. ]);
  354. } catch (\Exception $e) {
  355. $this->error("save {$book}-{$startPara} failed: ".$e->getMessage());
  356. Log::error('extract:pali.term save failed', [
  357. 'book' => $book,
  358. 'paragraph' => $startPara,
  359. 'error' => $e->getMessage(),
  360. ]);
  361. throw $e;
  362. }
  363. }
  364. }