Explorar o código

Merge pull request #2421 from visuddhinanda/development

Development
visuddhinanda hai 1 mes
pai
achega
a045700c6a

+ 484 - 0
api-v13/app/Console/Commands/ExtractPaliTerm.php

@@ -0,0 +1,484 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Helpers\LlmResponseParser;
+use App\Http\Api\ChannelApi;
+use App\Http\Resources\AiModelResource;
+use App\Models\DhammaTerm;
+use App\Models\PaliText;
+use App\Models\UserDict;
+use App\Services\AIModelService;
+use App\Services\OpenAIService;
+use App\Services\SentenceService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
+
+class ExtractPaliTerm extends Command
+{
+    // 断点缓存键:记录最后一个已完成的 chapter 游标 ['book'=>int,'paragraph'=>int],
+    // 使命令可重入——中断后重跑自动跳过已完成的 chapter
+    private const CURSOR_KEY = 'extract:pali.term:cursor';
+
+    // 术语表额外纳入的 user_dicts 词典 id(其 word 去除连字符与空格后加入)
+    private const USER_DICT_IDS = [
+        'b089de57-f146-4095-b886-057863728c43',
+        '0bfd87ec-f3ac-49a2-985e-28388779078d',
+        '7c7ee287-35ba-4cf3-b87b-30f1fa6e57c9',
+        '4ac8a0d5-9c6f-4b9f-983d-84288d47f993',
+    ];
+
+    /**
+     * 用 LLM 从巴利文经文中提取术语。
+     *
+     * 以 pali_texts 表为准,扁平遍历每个 book 的 chapter(level 1-7)。从每个 book
+     * 的第一个 chapter 开始,按 paragraph 顺序,每个 chapter 覆盖「本 chapter 起始段落
+     * → 下一个 chapter 起始段落之前」的所有段落(含正文 bodytext)。把该 chapter 的
+     * 巴利文(pali_texts.text 拼接)连同完整社区术语表发给大模型,引导其输出与本章相关
+     * 的全部术语的巴利语单词(JSON 字符串数组),保存到系统术语 channel。
+     *
+     * 测试:
+     *   php artisan extract:pali.term 554cdbc1-e170-4145-a808-3cc2cfa721c7 --book=93 --para=8
+     *
+     * @var string
+     */
+    protected $signature = 'extract:pali.term
+    {model : LLM 模型 id}
+    {--book= : 仅处理该 book;不传则遍历全部 book}
+    {--para= : 仅处理包含该段落的 chapter(向下取所在 chapter);需配合 --book}
+    {--thinking= : 开启/关闭 deepseek thinking,true | false}
+    {--fresh : 清除断点缓存,从头开始}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '用 LLM 逐 chapter 提取巴利文相关术语并保存到系统术语 channel';
+
+    /**
+     * LLM 模型配置(含 model/url/key/uid,支持数组访问)。
+     */
+    protected AiModelResource $model;
+
+    /**
+     * 保存目标 channel(系统术语 channel)。
+     */
+    protected array $glossaryChannel;
+
+    /**
+     * 术语表词条原形集合(word => true),用于后置过滤:
+     * 只保留确实存在于术语表的词,丢弃 LLM 输出的屈折变形或臆造词。
+     *
+     * @var array<string, bool>
+     */
+    protected array $glossarySet = [];
+
+    protected ?bool $thinking = null;
+
+    public function __construct(
+        protected AIModelService $modelService,
+        protected OpenAIService $openAIService,
+        protected SentenceService $sentenceService
+    ) {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     */
+    public function handle(): int
+    {
+        // ---------- 模型 ----------
+        $model = $this->modelService->getModelById($this->argument('model'));
+        if (! $model instanceof AiModelResource || $model->resource === null) {
+            $this->error('invalid model id');
+
+            return 1;
+        }
+        $this->model = $model;
+        $this->info("model: {$this->model['model']}");
+
+        if ($this->option('thinking') !== null) {
+            $this->thinking = $this->option('thinking') === 'true';
+        }
+
+        // ---------- 保存目标 channel ----------
+        $channelId = ChannelApi::getSysChannel('_system_glossary_');
+        if (! $channelId) {
+            $this->error('system glossary channel "_system_glossary_" not found');
+
+            return 1;
+        }
+        $this->glossaryChannel = ChannelApi::getById($channelId);
+        Log::info('extract:pali.term glossary channel', ['channel' => $channelId]);
+
+        // ---------- 完整术语表(一次性加载,所有 chapter 共用)----------
+        $glossaryJson = $this->loadGlossaryJson();
+        if ($glossaryJson === null) {
+            return 1;
+        }
+
+        if ($this->option('fresh')) {
+            Cache::forget(self::CURSOR_KEY);
+            $this->info('cleared checkpoint');
+        }
+
+        // ---------- 模式三:book + paragraph —— 忽略断点,只跑该 chapter ----------
+        if ($this->option('book') && $this->option('para')) {
+            $this->runSingleChapter((int) $this->option('book'), (int) $this->option('para'), $glossaryJson);
+
+            return 0;
+        }
+
+        // --para 必须配合 --book
+        if ($this->option('para')) {
+            $this->error('--para must be used together with --book');
+
+            return 1;
+        }
+
+        $cursor = Cache::get(self::CURSOR_KEY, []);
+
+        // ---------- 模式二:仅 book —— 同一 book 从断点续,跑完即止,不跳到下一 book ----------
+        if ($this->option('book')) {
+            $book = (int) $this->option('book');
+            // 断点是同一 book 才续跑,否则整本从头
+            $resumeCursor = (! empty($cursor) && (int) $cursor['book'] === $book) ? $cursor : [];
+            if (! empty($resumeCursor)) {
+                $this->info("resume book {$book} from para {$resumeCursor['paragraph']}");
+            }
+            $this->runBook($book, $glossaryJson, $resumeCursor);
+
+            return 0;
+        }
+
+        // ---------- 模式一:无参数 —— 从断点继续,跨 book 直到末尾 ----------
+        $startBook = ! empty($cursor) ? (int) $cursor['book'] : 1;
+        if (! empty($cursor)) {
+            $this->info("resume from book {$cursor['book']} para {$cursor['paragraph']}");
+        }
+        for ($book = $startBook; $book <= 217; $book++) {
+            // 仅断点所在的首 book 受游标限制,之后的 book 全量处理
+            $this->runBook($book, $glossaryJson, $book === $startBook ? $cursor : []);
+        }
+        // 全部完成,清空断点
+        Cache::forget(self::CURSOR_KEY);
+        $this->info('all books completed');
+
+        return 0;
+    }
+
+    /**
+     * 加载完整术语表(dhamma_terms 全表、跨所有 channel 去重)并编码为发送给 LLM 的紧凑 JSON。
+     *
+     * @return string|null 编码后的术语表 JSON;术语表为空时返回 null
+     */
+    private function loadGlossaryJson(): ?string
+    {
+        // 1) dhamma_terms 全表 distinct 词条原形(不限 channel),排除 word 含空格的短语条目
+        $dhammaWords = DhammaTerm::query()
+            ->whereNotNull('word')
+            ->where('word', '!=', '')
+            ->where('word', 'not like', '% %')
+            ->distinct()
+            ->pluck('word')
+            ->all();
+
+        // 2) user_dicts 指定词典的词,去除连字符与空格后加入
+        $userWords = UserDict::query()
+            ->whereIn('dict_id', self::USER_DICT_IDS)
+            ->whereNotNull('word')
+            ->where('word', '!=', '')
+            ->pluck('word')
+            ->map(fn ($w) => str_replace(['-', ' '], '', (string) $w))
+            ->all();
+
+        // 合并 + NFC 归一(避免巴利文变音符 NFC/NFD 不一致)+ 去空 + 去重
+        $terms = array_map(fn ($w) => $this->toNfc(trim((string) $w)), array_merge($dhammaWords, $userWords));
+        $terms = array_values(array_unique(array_filter($terms, fn ($w) => $w !== '')));
+
+        if (empty($terms)) {
+            $this->error('glossary is empty');
+
+            return null;
+        }
+
+        // 词条原形集合,供 askLlm 后置过滤(O(1) 查找)
+        $this->glossarySet = array_fill_keys($terms, true);
+
+        $this->info('glossary loaded: '.count($terms).' terms (dhamma_terms + user_dicts)');
+        Log::info('extract:pali.term glossary loaded', [
+            'total' => count($terms),
+            'dhamma_terms' => count($dhammaWords),
+            'user_dicts' => count($userWords),
+        ]);
+
+        return json_encode($terms, JSON_UNESCAPED_UNICODE);
+    }
+
+    /**
+     * 处理单个 book 的全部 chapter(扁平遍历),逐 chapter 写入断点以支持重入。
+     *
+     * @param  array{book?: int, paragraph?: int}  $resumeCursor  断点游标;非空时跳过其
+     *                                                            (含)之前已完成的 chapter
+     */
+    private function runBook(int $book, string $glossaryJson, array $resumeCursor = []): void
+    {
+        // 本书全部 chapter 节点(level 1-7),按 paragraph 顺序——扁平遍历,非树遍历
+        $chapters = PaliText::where('book', $book)
+            ->whereBetween('level', [1, 7])
+            ->orderBy('paragraph')
+            ->get(['paragraph', 'level', 'toc']);
+
+        if ($chapters->isEmpty()) {
+            Log::info('extract:pali.term no chapter', ['book' => $book]);
+
+            return;
+        }
+
+        $maxParagraph = (int) PaliText::where('book', $book)->max('paragraph');
+
+        foreach ($chapters as $chapter) {
+            $start = (int) $chapter->paragraph;
+
+            // 断点跳过:游标(含)之前的 chapter 视为已完成
+            if ($this->isDone($resumeCursor, $book, $start)) {
+                $this->info("skip {$book}-{$start} (done)");
+
+                continue;
+            }
+
+            $end = $this->chapterEnd($book, $start, $maxParagraph);
+            $this->processChapter($book, $start, $end, $glossaryJson);
+
+            // 该 chapter 完成后写入断点(中途中断时不会误标记未完成的 chapter)
+            Cache::put(self::CURSOR_KEY, ['book' => $book, 'paragraph' => $start], now()->addHours(48));
+        }
+    }
+
+    /**
+     * 模式三:处理 --para 所在的单个 chapter(向下取所在 chapter),不读写断点。
+     */
+    private function runSingleChapter(int $book, int $para, string $glossaryJson): void
+    {
+        // 向下取所在 chapter:≤para 的最近 chapter 节点(level 1-7)
+        $target = PaliText::where('book', $book)
+            ->where('paragraph', '<=', $para)
+            ->whereBetween('level', [1, 7])
+            ->orderBy('paragraph', 'desc')
+            ->first(['paragraph']);
+
+        if (! $target) {
+            $this->error("no chapter found for book={$book} para={$para}");
+
+            return;
+        }
+
+        $start = (int) $target->paragraph;
+        $maxParagraph = (int) PaliText::where('book', $book)->max('paragraph');
+        $end = $this->chapterEnd($book, $start, $maxParagraph);
+
+        $this->processChapter($book, $start, $end, $glossaryJson);
+    }
+
+    /**
+     * chapter 区间终点 = 下一个 chapter 起始段落之前;末章到本书结尾。
+     */
+    private function chapterEnd(int $book, int $start, int $maxParagraph): int
+    {
+        $next = PaliText::where('book', $book)
+            ->where('paragraph', '>', $start)
+            ->whereBetween('level', [1, 7])
+            ->orderBy('paragraph')
+            ->value('paragraph');
+
+        return $next ? (int) $next - 1 : $maxParagraph;
+    }
+
+    /**
+     * Unicode 规范化为 NFC(巴利文变音符匹配需统一 NFC/NFD)。intl 不可用时原样返回。
+     */
+    private function toNfc(string $s): string
+    {
+        if (class_exists(\Normalizer::class)) {
+            $normalized = \Normalizer::normalize($s, \Normalizer::FORM_C);
+            if ($normalized !== false) {
+                return $normalized;
+            }
+        }
+
+        return $s;
+    }
+
+    /**
+     * 判断某 chapter 是否在断点游标(含)之前——即已完成、应跳过。
+     *
+     * @param  array{book?: int, paragraph?: int}  $cursor
+     */
+    private function isDone(array $cursor, int $book, int $para): bool
+    {
+        if (empty($cursor)) {
+            return false;
+        }
+
+        return $book < (int) $cursor['book']
+            || ($book === (int) $cursor['book'] && $para <= (int) $cursor['paragraph']);
+    }
+
+    /**
+     * 处理单个 chapter:拼接巴利文 → 调 LLM → 解析 → 保存。
+     */
+    private function processChapter(int $book, int $start, int $end, string $glossaryJson): void
+    {
+        $startAt = time();
+
+        // chapter 巴利文 = 区间内全部段落 pali_texts.text 顺序拼接
+        $paliText = PaliText::where('book', $book)
+            ->whereBetween('paragraph', [$start, $end])
+            ->orderBy('paragraph')
+            ->pluck('text')
+            ->filter(fn ($t) => filled($t))
+            ->implode("\n");
+
+        Log::info('extract:pali.term chapter', [
+            'book' => $book,
+            'start' => $start,
+            'end' => $end,
+            'pali_strlen' => mb_strlen($paliText),
+        ]);
+
+        if ($paliText === '') {
+            $this->warn("skip empty chapter {$book}-{$start}");
+
+            return;
+        }
+
+        $words = $this->askLlm($glossaryJson, $paliText);
+
+        $this->saveChapterGlossary($book, $start, $words);
+
+        $time = time() - $startAt;
+        $this->info("chapter {$book}-{$start} (para {$start}-{$end}) => ".count($words)." terms, time={$time}s");
+    }
+
+    /**
+     * 把术语表与 chapter 巴利文发给 LLM,返回相关术语的巴利语单词数组。
+     *
+     * @return array<int, string>
+     */
+    private function askLlm(string $glossaryJson, string $paliText): array
+    {
+        $sysPrompt = <<<'md'
+        你是一位精通巴利语的佛教术语专家。
+
+        ## 任务
+        给定一段巴利文原文,以及一份术语表(巴利语单词列表),
+        请从术语表中找出与这段经文相关的全部术语,并且输出。
+
+        ## 要求
+        - 输出的每个单词必须是给你的术语表中的单词。
+        - 经文中的术语往往以屈折变化(变格、变位、复合、连音等)形式出现。你必须把这些
+          变形还原、找到对应术语表中的词条,输出术语表里的那个原形,而不是经文中出现的变形。
+          例如:经文中的 "brahmadattena"(具格)对应词条 "brahmadatta",应输出 "brahmadatta";
+          "sattānaṃ" 对应 "satta",输出 "satta";"buddhassa" 对应 "buddha",输出 "buddha"。
+        - 绝不要输出经文里的屈折变形,也不要输出术语表中不存在的单词。
+        - 复合词中局部包含术语的,也要输出。不要输出整个复合词,只输出其中包含的术语。
+        - 只输出与本段经文内容确实相关的术语,不要输出无关术语。
+        - 输出 JSON 数组,元素为术语表中的巴利语单词原形,不要包含释义或其他字段。
+
+        ## 只保留有实义的术语,排除非术语词(即使它们出现在术语表中)
+        术语指有独立含义的佛教/巴利专有概念、法相名词,或人名、地名、专有名词。
+        请排除以下这类“非术语”的功能词与泛用词:
+        - 语法虚词、不变词、连词、语气词:如 ca、vā、pana、kho、iti、hi、api、eva、ce、tu 等。
+        - 代词、指示词、疑问词:如 so、ayaṃ、taṃ、idha、kiṃ、ya- 等。
+        - 极常见的系动词、泛用动词及其变位:如 hoti、atthi、karoti 等纯功能性用法。
+        - 其他你判断不构成独立术语的高频功能词、助词、量词等。
+        判断标准:若去掉该词后不影响“这是一个值得收录为词条的概念/名相”,则视为非术语并排除。
+
+        ## 输出格式(直接输出 JSON,无需任何额外说明)
+        ["dukkha","nibbāna","saṅkhāra"]
+        md;
+
+        $userMessage = "## 术语表\n```json\n{$glossaryJson}\n```\n\n## 巴利文经文\n{$paliText}";
+
+        $llm = $this->openAIService->setApiUrl($this->model['url'])
+            ->setModel($this->model['model'])
+            ->setApiKey($this->model['key'])
+            ->setSystemPrompt($sysPrompt)
+            ->setTemperature(0.3)
+            ->setStream(false);
+        if ($this->thinking !== null) {
+            $llm = $llm->setThinking($this->thinking);
+        }
+
+        $response = $llm->send($userMessage);
+        $content = is_array($response)
+            ? ($response['choices'][0]['message']['content'] ?? '[]')
+            : (string) $response;
+        Log::debug('extract:pali.term llm response', ['content' => $content]);
+
+        $parsed = LlmResponseParser::json($content);
+
+        // 规范化:非空字符串、去重、去空白
+        $candidates = [];
+        foreach ($parsed as $item) {
+            if (is_string($item) && trim($item) !== '') {
+                $candidates[] = trim($item);
+            }
+        }
+        $candidates = array_values(array_unique($candidates));
+
+        // 后置过滤:归一为 NFC 后比对,只保留确实存在于术语表的词条原形,丢弃屈折变形/臆造词
+        $words = [];
+        $dropped = [];
+        foreach ($candidates as $word) {
+            $nfc = $this->toNfc($word);
+            if (isset($this->glossarySet[$nfc])) {
+                $words[] = $nfc;
+            } else {
+                $dropped[] = $word;
+            }
+        }
+
+        if (! empty($dropped)) {
+            Log::warning('extract:pali.term dropped non-glossary words', ['dropped' => $dropped]);
+        }
+
+        return $words;
+    }
+
+    /**
+     * 将 chapter 术语(巴利语单词数组)以 JSON 保存为一条 Sentence 记录。
+     *
+     * paragraph 用 chapter 起始段落,整章一条记录故 word_start/word_end 均为 0。
+     *
+     * @param  array<int, string>  $words
+     */
+    private function saveChapterGlossary(int $book, int $startPara, array $words): void
+    {
+        try {
+            $this->sentenceService->saveWithHistory([
+                'book_id' => $book,
+                'paragraph' => $startPara,
+                'word_start' => 0,
+                'word_end' => 0,
+                'channel_uid' => $this->glossaryChannel['id'],
+                'content' => json_encode($words, JSON_UNESCAPED_UNICODE),
+                'content_type' => 'json',
+                'lang' => $this->glossaryChannel['lang'] ?? 'pli',
+                'status' => $this->glossaryChannel['status'] ?? 10,
+                'editor_uid' => $this->model['uid'],
+            ]);
+        } catch (\Exception $e) {
+            $this->error("save {$book}-{$startPara} failed: ".$e->getMessage());
+            Log::error('extract:pali.term save failed', [
+                'book' => $book,
+                'paragraph' => $startPara,
+                'error' => $e->getMessage(),
+            ]);
+            throw $e;
+        }
+    }
+}

+ 28 - 22
api-v13/app/Console/Commands/InitSystemChannel.php

@@ -3,6 +3,7 @@
 namespace App\Console\Commands;
 
 use App\Models\Channel;
+use App\Tools\Tools;
 use Illuminate\Console\Command;
 
 class InitSystemChannel extends Command
@@ -23,81 +24,86 @@ class InitSystemChannel extends Command
 
     protected $channels = [
         [
-            "name" => '_System_Pali_VRI_',
+            'name' => '_System_Pali_VRI_',
             'type' => 'original',
             'lang' => 'pali',
         ],
         [
-            "name" => '_System_Wbw_VRI_',
+            'name' => '_System_Wbw_VRI_',
             'type' => 'wbw',
             'lang' => 'pali',
         ],
         [
-            "name" => '_System_Grammar_Term_zh-hans_',
+            'name' => '_System_Grammar_Term_zh-hans_',
             'type' => 'translation',
             'lang' => 'zh-Hans',
         ],
         [
-            "name" => '_System_Grammar_Term_zh-hant_',
+            'name' => '_System_Grammar_Term_zh-hant_',
             'type' => 'translation',
             'lang' => 'zh-Hant',
         ],
         [
-            "name" => '_System_Grammar_Term_en_',
+            'name' => '_System_Grammar_Term_en_',
             'type' => 'translation',
             'lang' => 'en',
         ],
         [
-            "name" => '_System_Grammar_Term_my_',
+            'name' => '_System_Grammar_Term_my_',
             'type' => 'translation',
             'lang' => 'my',
         ],
         [
-            "name" => '_community_term_zh-hans_',
+            'name' => '_community_term_zh-hans_',
             'type' => 'translation',
             'lang' => 'zh-Hans',
         ],
         [
-            "name" => '_community_term_zh-hant_',
+            'name' => '_community_term_zh-hant_',
             'type' => 'translation',
             'lang' => 'zh-Hant',
         ],
         [
-            "name" => '_community_term_en_',
+            'name' => '_community_term_en_',
             'type' => 'translation',
             'lang' => 'en',
         ],
         [
-            "name" => '_community_translation_zh-hans_',
+            'name' => '_community_translation_zh-hans_',
             'type' => 'translation',
             'lang' => 'zh-Hans',
         ],
         [
-            "name" => '_community_translation_zh-hant_',
+            'name' => '_community_translation_zh-hant_',
             'type' => 'translation',
             'lang' => 'zh-Hant',
         ],
         [
-            "name" => '_community_translation_en_',
+            'name' => '_community_translation_en_',
             'type' => 'translation',
             'lang' => 'en',
         ],
         [
-            "name" => '_System_Quote_',
+            'name' => '_System_Quote_',
             'type' => 'original',
             'lang' => 'en',
         ],
         [
-            "name" => '_community_summary_zh-hans_',
+            'name' => '_community_summary_zh-hans_',
             'type' => 'translation',
             'lang' => 'zh-Hans',
         ],
         [
-            "name" => '_System_commentary_',
+            'name' => '_System_commentary_',
             'type' => 'commentary',
             'lang' => 'en',
             'status' => 30,
         ],
+        [
+            'name' => '_system_glossary_',
+            'type' => 'original',
+            'lang' => 'pi',
+        ],
     ];
 
     /**
@@ -117,15 +123,15 @@ class InitSystemChannel extends Command
      */
     public function handle()
     {
-        if (\App\Tools\Tools::isStop()) {
+        if (Tools::isStop()) {
             return 0;
         }
-        $this->info("init:system.channel start");
+        $this->info('init:system.channel start');
         foreach ($this->channels as $key => $value) {
-            # code...
+            // code...
             $channel = Channel::firstOrNew([
                 'name' => $value['name'],
-                'owner_uid' => config("mint.admin.root_uuid"),
+                'owner_uid' => config('mint.admin.root_uuid'),
             ]);
             if (empty($channel->id)) {
                 $channel->id = app('snowflake')->id();
@@ -133,7 +139,7 @@ class InitSystemChannel extends Command
             $channel->type = $value['type'];
             $channel->lang = $value['lang'];
             $channel->editor_id = 0;
-            $channel->owner_uid = config("mint.admin.root_uuid");
+            $channel->owner_uid = config('mint.admin.root_uuid');
             $channel->create_time = time() * 1000;
             $channel->modify_time = time() * 1000;
             $channel->is_system = true;
@@ -141,9 +147,9 @@ class InitSystemChannel extends Command
                 $channel->status = $value['status'];
             }
             $channel->save();
-            $this->line("init " . $value['name']);
+            $this->line('init '.$value['name']);
         }
-        $this->info("init:system.channel successful");
+        $this->info('init:system.channel successful');
 
         return 0;
     }

+ 85 - 14
api-v13/app/Services/AIAssistant/PaliTranslateService.php

@@ -3,8 +3,10 @@
 namespace App\Services\AIAssistant;
 
 use App\Helpers\LlmResponseParser;
+use App\Http\Api\ChannelApi;
 use App\Http\Resources\AiModelResource;
 use App\Models\PaliSentence;
+use App\Models\PaliText;
 use App\Models\Sentence;
 use App\Services\OpenAIService;
 use App\Services\SearchPaliDataService;
@@ -66,6 +68,8 @@ class PaliTranslateService
 
         若用户额外提供 nissaya(巴利原文的逐词缅文释义,与 pali 通过 id 一一对应,按词列出每个巴利词的语法解析与缅文释义,形如「巴利词= 缅文释义」):它是判断词义、修饰关系、指代关系和句子结构最权威的依据,翻译时应优先参照 nissaya 确定原意,遇到歧义时以 nissaya 为准。
 
+        若用户额外提供 glossary(巴利语术语表,JSON 字符串数组,每个元素是一个巴利语术语原形):翻译时请在巴利原文中查找这些术语。凡巴利原文中出现了术语表中的词(包括其屈折变化、复合等形式),该词在译文相应位置**不要译为中文**,而是原样输出为 `[[术语原形]]`——即用术语表中给出的那个原形,外加双方括号包裹;其余内容照常翻译为中文。例如原文出现 rājagahe 且术语表含 rājagaha,则译文写作「住在[[rājagaha]]」而非「住在王舍城」。术语表中没有、或原文中并未出现的词,一律按正常翻译处理,不要添加双方括号。
+
         翻译要求
         1. 语言风格为现代汉语,**绝对不要**使用古汉语或者半文半白。**不要参考**阿含经和元亨寺语言风格。
         2. 译文严谨,完全贴合巴利原文,不要加入自己的理解
@@ -93,6 +97,8 @@ class PaliTranslateService
         用户会提供巴利原文(pali)以及一份待审校的简体中文译文(translation),两者均为 json,通过 id 一一对应。
         用户还可能提供 nissaya(巴利原文的逐词缅文释义,与 pali 通过 id 对应,按词列出每个巴利词的语法解析与缅文释义,形如「巴利词= 缅文释义」):它是判断词义、修饰关系、指代关系和句子结构最权威的依据,审校时应以 nissaya 为准核对译文是否贴合原意,发现译文与 nissaya 冲突的,须在 issues 中指出。
 
+        译文中可能出现 `[[巴利术语]]`(双方括号包裹巴利语原词)形式的标记:这是**有意保留**的术语标记,表示该词按术语表以巴利原形呈现、刻意不译为中文,属于正确处理。审校时**完全忽略**这些 `[[...]]` 标记,**不要**把它当作漏译、未翻译、误译或格式问题,不要因此扣分,也不要在 issues 中提及;只需正常审校其余中文译文。
+
         请逐句审校译文,但**不要修改译文**,只输出审校意见。
         审校维度:
         1. 准确性:译文是否完全贴合巴利原文,有无漏译、增译、误译
@@ -346,7 +352,10 @@ class PaliTranslateService
         foreach ($steps as $step) {
             switch ($step) {
                 case 'translate':
-                    $translation = $this->translate($pali, $this->nissayaFor('translate', $nissaya));
+                    // 加载本段所属 chapter 的巴利术语表;命中术语时译文输出 [[术语]] 而非中文
+                    $glossary = $this->loadGlossary($book, $para);
+                    Log::debug('PaliTranslate: glossary 术语表', ['count' => count($glossary)]);
+                    $translation = $this->translate($pali, $this->nissayaFor('translate', $nissaya), $glossary);
                     break;
                 case 'review':
                     $review = $this->review($pali, $translation, $this->nissayaFor('review', $nissaya));
@@ -395,11 +404,14 @@ class PaliTranslateService
      *
      * @param  array<int, array{id: string, content: string}>  $pali
      * @param  array<int, array{id: string, content: string}>  $nissaya  巴利逐词缅文释义参考资料,可空
+     * @param  string[]  $glossary  巴利术语表(术语原形列表),命中时译文输出 [[术语]],可空
      * @return array<int, array{id: string, content: string}>
      */
-    public function translate(array $pali, array $nissaya = []): array
+    public function translate(array $pali, array $nissaya = [], array $glossary = []): array
     {
-        $userText = "# pali\n\n" . $this->jsonBlock($pali) . "\n\n" . $this->nissayaSection($nissaya);
+        $userText = "# pali\n\n".$this->jsonBlock($pali)."\n\n"
+            .$this->nissayaSection($nissaya)
+            .$this->glossarySection($glossary);
         Log::debug('PaliTranslate: translate', ['input' => $userText]);
 
         $content = $this->send($this->translatePrompt, $userText);
@@ -417,9 +429,9 @@ class PaliTranslateService
      */
     public function review(array $pali, array $translation, array $nissaya = []): array
     {
-        $userText = "# pali\n\n" . $this->jsonBlock($pali) . "\n\n"
-            . "# translation\n\n" . $this->jsonBlock($translation) . "\n\n"
-            . $this->nissayaSection($nissaya);
+        $userText = "# pali\n\n".$this->jsonBlock($pali)."\n\n"
+            ."# translation\n\n".$this->jsonBlock($translation)."\n\n"
+            .$this->nissayaSection($nissaya);
         Log::debug('PaliTranslate: review', ['input' => $userText]);
 
         $content = $this->send($this->reviewPrompt, $userText);
@@ -438,9 +450,9 @@ class PaliTranslateService
      */
     public function revise(array $pali, array $translation, array $review): array
     {
-        $userText = "# pali\n\n" . $this->jsonBlock($pali) . "\n\n"
-            . "# translation\n\n" . $this->jsonBlock($translation) . "\n\n"
-            . "# review\n\n" . $this->jsonBlock($review) . "\n\n";
+        $userText = "# pali\n\n".$this->jsonBlock($pali)."\n\n"
+            ."# translation\n\n".$this->jsonBlock($translation)."\n\n"
+            ."# review\n\n".$this->jsonBlock($review)."\n\n";
         Log::debug('PaliTranslate: revise', ['input' => $userText]);
 
         $content = $this->send($this->revisePrompt, $userText);
@@ -460,9 +472,9 @@ class PaliTranslateService
      */
     public function evaluate(array $pali, array $translation, array $nissaya = []): array
     {
-        $userText = "# pali\n\n" . $this->jsonBlock($pali) . "\n\n"
-            . "# translation\n\n" . $this->jsonBlock($translation) . "\n\n"
-            . $this->nissayaSection($nissaya);
+        $userText = "# pali\n\n".$this->jsonBlock($pali)."\n\n"
+            ."# translation\n\n".$this->jsonBlock($translation)."\n\n"
+            .$this->nissayaSection($nissaya);
         Log::debug('PaliTranslate: evaluate', ['input' => $userText]);
 
         $content = $this->send($this->evaluatePrompt, $userText);
@@ -546,7 +558,66 @@ class PaliTranslateService
             return '';
         }
 
-        return "# nissaya\n\n" . $this->jsonBlock($nissaya) . "\n\n";
+        return "# nissaya\n\n".$this->jsonBlock($nissaya)."\n\n";
+    }
+
+    /**
+     * 加载本段所属 chapter 的巴利术语表。
+     *
+     * 术语表由 extract:pali.term 命令按 chapter 生成,保存在 _system_glossary_ channel,
+     * 以 chapter 起始段落(level 1-7 节点)为 paragraph、word_start/word_end 均为 0、
+     * content 为巴利术语原形的 JSON 字符串数组。这里先把当前段落向下归到所属 chapter,
+     * 再读取该 chapter 的术语表。加载失败或无数据时返回空数组(不影响翻译)。
+     *
+     * @return string[] 巴利术语原形列表
+     */
+    protected function loadGlossary(int $book, int $para): array
+    {
+        $channelId = ChannelApi::getSysChannel('_system_glossary_');
+        if (! $channelId) {
+            return [];
+        }
+
+        // 术语表按 chapter 起始段落存储:向下取 ≤para 的最近 chapter 节点(level 1-7)
+        $chapterStart = PaliText::where('book', $book)
+            ->where('paragraph', '<=', $para)
+            ->whereBetween('level', [1, 7])
+            ->orderBy('paragraph', 'desc')
+            ->value('paragraph');
+        if ($chapterStart === null) {
+            return [];
+        }
+
+        $content = Sentence::where('channel_uid', $channelId)
+            ->where('book_id', $book)
+            ->where('paragraph', $chapterStart)
+            ->where('word_start', 0)
+            ->where('word_end', 0)
+            ->value('content');
+        if (empty($content)) {
+            return [];
+        }
+
+        $words = json_decode($content, true);
+        if (! is_array($words)) {
+            return [];
+        }
+
+        return array_values(array_filter($words, fn ($w) => is_string($w) && $w !== ''));
+    }
+
+    /**
+     * 构造 glossary 术语表区块;无数据时返回空串(不污染提示词)。
+     *
+     * @param  string[]  $glossary
+     */
+    protected function glossarySection(array $glossary): string
+    {
+        if (empty($glossary)) {
+            return '';
+        }
+
+        return "# glossary\n\n```json\n".json_encode(array_values($glossary), JSON_UNESCAPED_UNICODE)."\n```\n\n";
     }
 
     /**
@@ -556,6 +627,6 @@ class PaliTranslateService
      */
     protected function jsonBlock(array $data): string
     {
-        return "```json\n" . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n```";
+        return "```json\n".json_encode($data, JSON_UNESCAPED_UNICODE)."\n```";
     }
 }