Преглед на файлове

Merge branch 'development' of github.com:visuddhinanda/mint into development

visuddhinanda преди 2 седмици
родител
ревизия
c43ae39010

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

@@ -0,0 +1,436 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Helpers\LlmResponseParser;
+use App\Http\Api\ChannelApi;
+use App\Http\Resources\AiModelResource;
+use App\Models\PaliText;
+use App\Services\AIModelService;
+use App\Services\OpenAIService;
+use App\Services\SentenceService;
+use App\Services\TermService;
+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';
+
+    /**
+     * 用 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,
+        protected TermService $termService
+    ) {
+        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
+    {
+        $terms = $this->termService->getGlossary();
+
+        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)');
+
+        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;
+    }
+
+    /**
+     * 判断某 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;
     }

+ 79 - 22
api-v13/app/Console/Commands/UpgradeAITerm.php

@@ -2,25 +2,34 @@
 
 namespace App\Console\Commands;
 
-use Illuminate\Console\Command;
-
-use App\Services\AIModelService;
-use App\Services\TermService;
+use App\Http\Resources\AiModelResource;
 use App\Services\AIAssistant\AITermService;
+use App\Services\AIModelService;
 use App\Services\AuthService;
-
-use App\Http\Resources\AiModelResource;
-
+use App\Services\TermService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
 
 class UpgradeAITerm extends Command
 {
+    // 断点缓存键:记录 channel 模式下最后一个已完成的术语游标 ['index'=>int,'word'=>string],
+    // 使命令可重入——中断后重跑自动跳过已完成的术语
+    private const CURSOR_KEY = 'upgrade:ai.term:cursor';
+
     /**
      * The name and signature of the console command.
-     * php artisan upgrade:ai.term  --id=e07bf5b8-bd81-4f0a-9d2c-8e0128b954d7
-     * php artisan upgrade:ai.term
+     * php artisan upgrade:ai.term {model} --id=e07bf5b8-bd81-4f0a-9d2c-8e0128b954d7
+     * php artisan upgrade:ai.term {model} --channel={channelId}
+     *
      * @var string
      */
-    protected $signature = 'upgrade:ai.term {--id=} {--resume} {--model=} ';
+    protected $signature = 'upgrade:ai.term
+    {model : LLM 模型 id}
+    {--word= : 保留参数}
+    {--channel= : 遍历术语表,逐词生成/更新到该 channel}
+    {--id= : 仅处理该术语 guid}
+    {--thinking= : 开启/关闭 deepseek thinking,true | false}
+    {--fresh : 清除断点缓存,从头开始}';
 
     /**
      * The console command description.
@@ -30,9 +39,15 @@ class UpgradeAITerm extends Command
     protected $description = 'Command description';
 
     protected AiModelResource $model;
+
     protected $modelToken;
+
     protected $workChannel;
+
     protected $accessToken;
+
+    protected ?bool $thinking = null;
+
     /**
      * Create a new command instance.
      *
@@ -54,28 +69,70 @@ class UpgradeAITerm extends Command
      */
     public function handle()
     {
-        if (!$this->option('model')) {
-            $modelId = $this->ask('请输入 llm model id');
-        } else {
-            $modelId = $this->option('model');
-        }
+        $modelId = $this->argument('model');
+
         $this->aiTermService->setModel($modelId);
         $this->model = $this->modelService->getModelById($modelId);
         $this->info("model:{$this->model['model']}");
         $this->modelToken = AuthService::getUserToken($modelId);
 
+        if ($this->option('thinking') !== null) {
+            $this->thinking = $this->option('thinking') === 'true';
+        }
+        $this->aiTermService->setThinking($this->thinking);
+
+        if ($this->option('fresh')) {
+            Cache::forget(self::CURSOR_KEY);
+            $this->info('cleared checkpoint');
+        }
+
         if ($this->option('id')) {
-            $terms = [['guid' => $this->option('id'), 'word' => 'word']];
+            $this->info($this->option('id').' running');
+            $result = $this->aiTermService->update($this->option('id'));
+            $this->info('done content '.substr($result, 0, 30));
+        } elseif ($this->option('channel')) {
+            $this->runChannel($this->option('channel'));
         } else {
-            $terms = $this->termService->getCommunityGlossary('zh-Hans')['items']->toArray();
-        }
+            $this->error('id or channel is request');
 
-        foreach ($terms as $key => $term) {
-            $this->info("[{$term['word']}] running");
-            $result = $this->aiTermService->update($term['guid']);
-            $this->info("[{$term['word']}]content " . substr($result, 0, 30));
+            return 1;
         }
 
         return 0;
     }
+
+    /**
+     * 遍历术语表,逐词生成/更新到指定 channel;逐词写入断点以支持重入。
+     *
+     * 断点游标记录最后一个已完成术语的 word 与 index;重跑时优先按 word 在当前术语表中
+     * 定位续跑位置(术语表尾部新增也能正确接续),找不到则回退到 index。
+     */
+    private function runChannel(string $channelId): void
+    {
+        $terms = $this->termService->getGlossary();
+        $total = count($terms);
+        $this->info("{$total} terms");
+
+        $startIndex = 0;
+        $cursor = Cache::get(self::CURSOR_KEY, []);
+        if (! empty($cursor)) {
+            $pos = array_search($cursor['word'] ?? null, $terms, true);
+            $startIndex = $pos !== false ? (int) $pos + 1 : (int) ($cursor['index'] ?? -1) + 1;
+            $this->info("resume from index {$startIndex} (after word [{$cursor['word']}])");
+        }
+
+        for ($key = $startIndex; $key < $total; $key++) {
+            $term = $terms[$key];
+            $this->info("[{$key}/{$total}][{$term}] running");
+            $result = $this->aiTermService->updateOrCreate($channelId, $term);
+            $this->info("[{$term}]content ".substr($result, 0, 30));
+
+            // 该术语完成后写入断点(中途中断时不会误标记未完成的术语)
+            Cache::put(self::CURSOR_KEY, ['index' => $key, 'word' => $term], now()->addHours(48));
+        }
+
+        // 全部完成,清空断点
+        Cache::forget(self::CURSOR_KEY);
+        $this->info('all terms completed');
+    }
 }

+ 22 - 19
api-v13/app/Console/Commands/UpgradeAITranslation.php

@@ -2,10 +2,6 @@
 
 namespace App\Console\Commands;
 
-use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
-use Illuminate\Support\Facades\Log;
-
 use App\Helpers\LlmResponseParser;
 use App\Http\Api\ChannelApi;
 use App\Http\Resources\AiModelResource;
@@ -17,8 +13,9 @@ use App\Services\AIModelService;
 use App\Services\AuthService;
 use App\Services\OpenAIService;
 use App\Services\SentenceService;
-
-use function PHPUnit\Framework\isEmpty;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
 
 class UpgradeAITranslation extends Command
 {
@@ -47,7 +44,7 @@ class UpgradeAITranslation extends Command
     {--resume}
     {--model=}
     {--thinking= : 开启和关闭deepseek thinking true | false}
-    {--steps=translate : translation 工作流步骤,逗号分隔,可选 translate,review,revise,evaluate(evaluate 为质量评估,须放最后)}
+    {--steps=translate : translation 工作流步骤,逗号分隔,可选 translate,term,review,revise,evaluate(term 为术语标注,可单独运行;evaluate 为质量评估,须放最后)}
     {--nissaya=translate,review,evaluate : 启用 nissaya 参考资料的步骤,逗号分隔,可选 translate,review,evaluate;传空字符串则全部不注入}
     {--fresh : 清除缓存断点,从头开始}';
 
@@ -98,11 +95,13 @@ class UpgradeAITranslation extends Command
          */
         if (! $this->option('model')) {
             $this->error('model is request');
+
             return 1;
         }
         $this->model = $this->modelService->getModelById($this->option('model'));
         if (empty($this->model)) {
             $this->error('invalid model id ');
+
             return 1;
         }
         $this->info("model:{$this->model['model']}");
@@ -112,21 +111,21 @@ class UpgradeAITranslation extends Command
         $this->workChannel = ChannelApi::getById($this->argument('channel'));
         // 需要判断输入channel 与翻译类型是否一致 nissaya -> nissaya channel
         if ($this->workChannel['type'] !== $this->argument('type')) {
-            $this->error('channel type not match request ' . $this->argument('type') . ' input is ' . $this->workChannel['type']);
+            $this->error('channel type not match request '.$this->argument('type').' input is '.$this->workChannel['type']);
 
             return 1;
         }
 
         if ($this->option('thinking')) {
             $this->thinking = $this->option('thinking') === 'true';
-            $this->line('thinking is ' . $this->option('thinking'));
+            $this->line('thinking is '.$this->option('thinking'));
         }
 
         // translation 工作流步骤校验
         $steps = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('steps')))));
         $invalid = array_diff($steps, PaliTranslateService::STEPS);
         if (! empty($invalid)) {
-            $this->error('invalid steps: ' . implode(',', $invalid) . '. allowed: ' . implode(',', PaliTranslateService::STEPS));
+            $this->error('invalid steps: '.implode(',', $invalid).'. allowed: '.implode(',', PaliTranslateService::STEPS));
 
             return 1;
         }
@@ -135,7 +134,7 @@ class UpgradeAITranslation extends Command
         $nissayaSteps = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('nissaya')))));
         $invalidNissaya = array_diff($nissayaSteps, PaliTranslateService::NISSAYA_STEPS);
         if (! empty($invalidNissaya)) {
-            $this->error('invalid nissaya steps: ' . implode(',', $invalidNissaya) . '. allowed: ' . implode(',', PaliTranslateService::NISSAYA_STEPS));
+            $this->error('invalid nissaya steps: '.implode(',', $invalidNissaya).'. allowed: '.implode(',', PaliTranslateService::NISSAYA_STEPS));
 
             return 1;
         }
@@ -144,7 +143,7 @@ class UpgradeAITranslation extends Command
         $channelId = $this->workChannel['id'] ?? '';
 
         // 缓存键:按 type、channel 区分不同任务的断点
-        $cacheKey = self::CACHE_KEY_PREFIX . ':' . $type . ':' . $channelId;
+        $cacheKey = self::CACHE_KEY_PREFIX.':'.$type.':'.$channelId;
 
         if ($this->option('fresh')) {
             //Cache::forget($cacheKey);
@@ -164,7 +163,7 @@ class UpgradeAITranslation extends Command
             // 未指定 book 时,若已有断点缓存,从上次处理到的 book 继续,无需从 1 开始
             $startBook = 1;
             if (! empty($done)) {
-                $doneBooks = array_map(fn($cursor) => (int) explode('|', $cursor)[0], array_keys($done));
+                $doneBooks = array_map(fn ($cursor) => (int) explode('|', $cursor)[0], array_keys($done));
                 $startBook = max($doneBooks);
                 $this->info("resume from book {$startBook}");
             }
@@ -176,9 +175,9 @@ class UpgradeAITranslation extends Command
             if ($this->option('para')) {
                 $paragraphs = [$this->option('para')];
             }
-            foreach ($paragraphs as  $paragraph) {
+            foreach ($paragraphs as $paragraph) {
                 // 稳定游标:缓存键已含 type、channel,此处仅以 book|para 标识处理单元
-                $cursor = $book . '|' . $paragraph;
+                $cursor = $book.'|'.$paragraph;
                 if (isset($done[$cursor])) {
                     $this->info("skip {$cursor}");
 
@@ -207,7 +206,7 @@ class UpgradeAITranslation extends Command
                 }
                 $this->save($data);
                 $time = time() - $start;
-                $this->info($this->argument('type') . " {$book}-{$paragraph} " . count($data) . ' sentences time=' . $time);
+                $this->info($this->argument('type')." {$book}-{$paragraph} ".count($data).' sentences time='.$time);
                 // 该处理单元全部写库完成后再标记游标,确保中途中断不会误跳过
                 $done[$cursor] = true;
                 //Cache::put($cacheKey, $done, now()->addHours(24));
@@ -227,7 +226,7 @@ class UpgradeAITranslation extends Command
                 'book' => $book,
                 '--channel' => $this->workChannel['id'],
                 '--summary' => 'off',
-                '--granularity' => 'chapter'
+                '--granularity' => 'chapter',
             ];
             if ($this->option('para')) {
                 $param['--para'] = $this->option('para');
@@ -302,7 +301,7 @@ class UpgradeAITranslation extends Command
             $response = $llm->send("```json\n{$tplText}\n```");
             $complete = time() - $startAt;
             $content = $response['choices'][0]['message']['content'] ?? '[]';
-            Log::debug("ai response in {$complete}s content=" . $content);
+            Log::debug("ai response in {$complete}s content=".$content);
 
             $json = LlmResponseParser::jsonl($content);
 
@@ -347,8 +346,12 @@ class UpgradeAITranslation extends Command
 
     private function save(array $data)
     {
+
         // 写入句子库
         try {
+            // 过滤掉 id 不存在的 item(LLM 输出可能缺失或畸形),避免后续 explode 报错
+            $data = array_filter($data, fn ($n) => ! empty($n['id']));
+
             $sentData = [];
             $sentData = array_map(function ($n) {
                 $sId = explode('-', $n['id']);
@@ -359,7 +362,7 @@ class UpgradeAITranslation extends Command
                     'word_start' => $sId[2],
                     'word_end' => $sId[3],
                     'channel_uid' => $this->workChannel['id'],
-                    'content' => $n['content'],
+                    'content' => $n['content'] ?? '',
                     'content_type' => $n['content_type'] ?? 'markdown',
                     'lang' => $this->workChannel['lang'],
                     'status' => $this->workChannel['status'],

+ 198 - 76
api-v13/app/Http/Controllers/Library/BookController.php

@@ -13,10 +13,20 @@ use App\Services\ChapterService;
 use App\Services\OpenSearchService;
 use App\Services\PaliTextService;
 use Illuminate\Http\Request;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Log;
 
 class BookController extends Controller
 {
-    protected $maxChapterLen = 50000;
+    /**
+     * chapter 聚合显示的字符数阈值。
+     *
+     * ES 中每个 chapter 节点只保存「本节点 → 下一节点」之间的正文,选中含子
+     * chapter 的父节点时其自身文档往往只有标题。显示时按本阈值决定聚合粒度:
+     * chapter_strlen 小于该值的节点,连同其覆盖区间内的所有子 chapter 一并展示;
+     * 超过该值则向下钻取第一个不超过阈值的子 chapter。
+     */
+    protected $maxChapterStrlen = 30000;
 
     protected $minChapterLen = 100;
 
@@ -41,7 +51,7 @@ class BookController extends Controller
 
         $book = $this->getBookInfo($bookRaw);
         $book['contents'] = $this->getBookToc($bookRaw->book, $bookRaw->para, $channelId);
-
+        $book['book_title'] = $this->getBookTitle($bookRaw->book, $bookRaw->para, $channelId);
         // 获取其他版本
         $others = ProgressChapter::with('channel.owner')
             ->where('book', $bookRaw->book)
@@ -95,23 +105,19 @@ class BookController extends Controller
     {
 
         $channelId = $request->input('channel');
-        $openSearchId = "tipitaka_chapter_{$id}_{$channelId}";
 
-        try {
-            $chapter = HitItemDTO::fromArray($this->searchService->get($openSearchId))->toArray();
-        } catch (\Throwable $th) {
-            $chapter = [
-                'category' => [],
-                'title' => '',
-                'display' => '',
+        [$bookId, $paraId] = explode('-', $id);
+        $bookId = (int) $bookId;
+        $paraId = (int) $paraId;
 
-            ];
-        }
+        // 解析选中 chapter 的实际显示区间,并聚合区间内全部 chapter 节点的 ES 内容,
+        // 避免选中父 chapter 时仅显示一个标题。
+        $range = $this->resolveDisplayRange($bookId, $paraId);
+        $chapter = $this->fetchRangeContent($bookId, $channelId, $range['chapters'], $id);
 
-        [$bookId, $paraId] = explode('-', $id);
         if ($request->has('comm')) {
-            $currChapter = $this->paliTextService->getCurrChapter($bookId, $paraId);
-            $commentaries = $this->fetchCommentary($bookId, $paraId, $paraId + $currChapter->chapter_len - 1, $request->input('comm'));
+            // 注释范围与显示区间保持一致(聚合后可能跨多个段落)
+            $commentaries = $this->fetchCommentary($bookId, $range['start'], $range['end'], $request->input('comm'));
             // sid 格式:{book_id}-{paragraph}-{word_start}-{word_end}
             $notesMap = collect($commentaries)->keyBy(function ($note) {
                 return "{$note['book_id']}-{$note['paragraph']}-{$note['word_start']}-{$note['word_end']}";
@@ -122,7 +128,7 @@ class BookController extends Controller
 
         $book = [];
 
-        $book['toc'] = $this->getBookToc((int) $bookId, (int) $paraId, $channelId, 2, 7);
+        $book['toc'] = $this->getBookToc($bookId, $paraId, $channelId, 2, 7, [$range['start'], $range['end']]);
         $channel = ChannelApi::getById($channelId);
         $studio = StudioApi::getById($channel['studio_id']);
         $book['categories'] = $chapter['category'];
@@ -130,8 +136,9 @@ class BookController extends Controller
         $book['author'] = $channel['name'];
         $book['studio'] = $studio;
         $book['tags'] = [];
+        $book['book_title'] = $this->getBookTitle($bookId, $paraId, $channelId);
 
-        $book['pagination'] = $this->pagination((int) $bookId, (int) $paraId, $channelId);
+        $book['pagination'] = $this->pagination($bookId, $paraId, $channelId);
 
         if (isset($notesMap)) {
             $book['content'] = $this->injectNoteMarkers($chapter['display'], $notesMap);
@@ -154,6 +161,103 @@ class BookController extends Controller
         return $view;
     }
 
+    /**
+     * 解析选中 chapter 的实际显示区间。
+     *
+     * ES 中每个 chapter 节点仅保存「本节点 → 下一节点」之间的正文,因此选中含
+     * 子 chapter 的父节点时其自身文档往往只有标题。为完整呈现内容,这里把选中
+     * chapter 覆盖区间内的所有 chapter 节点聚合为一个显示单元:
+     *  - 选中节点 chapter_strlen 不超过阈值:直接使用其覆盖区间
+     *    [paragraph, paragraph + chapter_len - 1];
+     *  - 否则向后下钻,找到第一个不超过阈值的子 chapter,以该子 chapter 的结束
+     *    段落作为区间终点(起点仍为选中段落,保留上层标题作为阅读上下文)。
+     *
+     * @return array{current: PaliText, start: int, end: int, chapters: Collection<int, PaliText>}
+     */
+    private function resolveDisplayRange(int $book, int $para): array
+    {
+        $currBook = $this->bookStart($book, $para);
+        $bookStart = $currBook->paragraph;
+        $bookEnd = $currBook->paragraph + $currBook->chapter_len - 1;
+
+        // 本书内全部 chapter 节点(level < 8,即 book/chapter/subhead 等可导航节点)
+        $paragraphs = PaliText::where('book', $book)
+            ->whereBetween('paragraph', [$bookStart, $bookEnd])
+            ->where('level', '<', 8)
+            ->orderBy('paragraph')
+            ->get();
+
+        $curr = $paragraphs->firstWhere('paragraph', $para);
+        $current = $curr;
+        $endParagraph = $curr->paragraph + $curr->chapter_len - 1;
+
+        if ($curr->chapter_strlen > $this->maxChapterStrlen) {
+            // 选中节点过大:向后下钻,找到第一个不超过阈值的子 chapter
+            foreach ($paragraphs as $key => $paragraph) {
+                if ($paragraph->paragraph <= $curr->paragraph) {
+                    continue;
+                }
+                if ($paragraph->chapter_strlen <= $this->maxChapterStrlen) {
+                    $endParagraph = $paragraph->paragraph + $paragraph->chapter_len - 1;
+                    $current = $paragraph;
+                    break;
+                }
+                if ($paragraph->level <= $curr->level) {
+                    // 已离开选中节点的子树,无法继续下钻,止步于上一个节点
+                    $endParagraph = $paragraphs[$key - 1]->paragraph + $paragraphs[$key - 1]->chapter_len - 1;
+                    $current = $paragraph;
+                    break;
+                }
+            }
+        }
+
+        $start = $curr->paragraph;
+        $end = $endParagraph;
+
+        // 区间内的全部 chapter 节点,用于聚合 ES 内容
+        $chapters = $paragraphs->filter(function ($paragraph) use ($start, $end) {
+            return $paragraph->paragraph >= $start && $paragraph->paragraph <= $end;
+        })->values();
+
+        return compact('current', 'start', 'end', 'chapters');
+    }
+
+    /**
+     * 聚合区间内全部 chapter 节点的 ES 内容。
+     *
+     * 逐个按 ES 文档 id(tipitaka_chapter_{book}-{paragraph}_{channel})获取并按段落
+     * 顺序拼接 display;缺失或获取失败的节点跳过。选中(即传入 $selectedId 的)
+     * 节点同时提供页面标题与分类。
+     *
+     * @param  Collection<int, PaliText>  $chapters
+     * @return array{display: string, title: string, category: array}
+     */
+    private function fetchRangeContent(int $book, string $channelId, $chapters, string $selectedId): array
+    {
+        $display = '';
+        $title = '';
+        $category = [];
+
+        foreach ($chapters as $chapter) {
+            $openSearchId = "tipitaka_chapter_{$book}-{$chapter->paragraph}_{$channelId}";
+
+            try {
+                $doc = HitItemDTO::fromArray($this->searchService->get($openSearchId))->toArray();
+            } catch (\Throwable $th) {
+                continue;
+            }
+
+            $display .= $doc['display'] ?? '';
+
+            if ("{$book}-{$chapter->paragraph}" === $selectedId) {
+                $title = $doc['title'] ?? '';
+                $category = $doc['category'] ?? [];
+            }
+        }
+
+        return compact('display', 'title', 'category');
+    }
+
     private function loadBook(string $id)
     {
         $book = ProgressChapter::with('channel.owner')->find($id);
@@ -190,7 +294,28 @@ class BookController extends Controller
         ];
     }
 
-    private function getBookToc(int $book, int $paragraph, string $channelId, $minLevel = 2, $maxLevel = 2): array
+    private function getBookTitle(int $book, int $paragraph, string $channelId)
+    {
+        $bookTopPara = $this->paliTextService->getBookPara($book, $paragraph)->paragraph;
+        $title = ProgressChapter::where('book', $book)
+            ->where('para', $bookTopPara)
+            ->where('channel_id', $channelId)
+            ->value('title');
+        if (empty($title)) {
+            $title = PaliText::where('book', $book)
+                ->where('paragraph', $bookTopPara)
+                ->value('toc');
+        }
+
+        return $title;
+    }
+
+    /**
+     * @param  array{0: int, 1: int}|null  $activeRange  实际显示的段落区间 [start, end];
+     *                                                   传入时区间内的所有 chapter 均高亮(active),
+     *                                                   用于聚合显示多个 chapter 的场景。不传则仅高亮选中段落。
+     */
+    private function getBookToc(int $book, int $paragraph, string $channelId, $minLevel = 2, $maxLevel = 2, ?array $activeRange = null): array
     {
         $currBook = $this->bookStart($book, $paragraph);
 
@@ -202,7 +327,7 @@ class BookController extends Controller
             ->whereBetween('level', [$minLevel, $maxLevel])
             ->orderBy('paragraph')
             ->get();
-
+        // Log::debug('toc', ['toc' => $paliTexts->toArray()]);
         if ($paliTexts->isEmpty()) {
             return [];
         }
@@ -233,22 +358,35 @@ class BookController extends Controller
             $stack[] = ['id' => $id, 'level' => $level];
         }
 
-        // 当前节点的祖先链
-        $ancestorSet = [];
-        $cursor = $currentId;
-        while (! empty($parents[$cursor])) {
-            $cursor = $parents[$cursor];
-            $ancestorSet[$cursor] = true;
+        // 高亮(active)集合:聚合显示时为区间内所有 chapter,否则仅选中段落
+        $activeSet = [];
+        foreach ($paliTexts as $paliText) {
+            $id = "{$paliText->book}-{$paliText->paragraph}";
+            $active = $activeRange !== null
+                ? ($paliText->paragraph >= $activeRange[0] && $paliText->paragraph <= $activeRange[1])
+                : ($id === $currentId);
+            if ($active) {
+                $activeSet[$id] = true;
+            }
         }
 
-        // 需要展开子节点的集合 = 祖先链 + 当前节点
-        $expandParentSet = $ancestorSet;
-        $expandParentSet[$currentId] = true;
+        // 需要展开子节点的集合 = 每个 active 节点及其祖先链。
+        // 聚合显示跨多级时,区间内各级 chapter 都需展开,其子级才能完整显示,
+        // 否则仅围绕单个当前节点展开,最多只能显示两级。
+        $expandParentSet = [];
+        foreach (array_keys($activeSet) as $activeId) {
+            $expandParentSet[$activeId] = true;
+            $cursor = $activeId;
+            while (! empty($parents[$cursor])) {
+                $cursor = $parents[$cursor];
+                $expandParentSet[$cursor] = true;
+            }
+        }
 
         // 顶层 level(列表中最浅的一层,折叠模式下始终可见)
         $topLevel = (int) $paliTexts->min('level');
 
-        return $paliTexts->map(function ($paliText) use ($chaptersIndexed, $channelId, $currentId, $parents, $expandParentSet, $topLevel) {
+        $output = $paliTexts->map(function ($paliText) use ($chaptersIndexed, $channelId, $parents, $expandParentSet, $activeSet, $topLevel) {
             $id = "{$paliText->book}-{$paliText->paragraph}";
             $level = (int) $paliText->level;
             $title = $paliText->toc;
@@ -274,6 +412,8 @@ class BookController extends Controller
             $visible = $level === $topLevel
                 || ($parentId !== null && isset($expandParentSet[$parentId]));
 
+            $active = isset($activeSet[$id]);
+
             return [
                 'id' => $id,
                 'channel' => $channelId,
@@ -282,10 +422,13 @@ class BookController extends Controller
                 'progress' => $progress,
                 'level' => $level,
                 'disabled' => $disabled,
-                'active' => $id === $currentId,
+                'active' => $active,
                 'hide' => ! $visible,
             ];
         })->all();
+
+        // Log::debug('toc output', ['data' => $output]);
+        return $output;
     }
 
     public function getBookCategory($book, $paragraph)
@@ -313,40 +456,17 @@ class BookController extends Controller
 
     public function pagination(int $book, int $para, string $channelId)
     {
-        $currBook = $this->bookStart($book, $para);
-        $start = $currBook->paragraph;
-        $end = $currBook->paragraph + $currBook->chapter_len - 1;
-        // 查询起始段落
-        $paragraphs = PaliText::where('book', $book)
-            ->whereBetween('paragraph', [$start, $end])
-            ->where('level', '<', 8)
-            ->orderBy('paragraph')
-            ->get();
-        $curr = $paragraphs->firstWhere('paragraph', $para);
-        $current = $curr; // 实际显示的段落
-        $endParagraph = $curr->paragraph + $curr->chapter_len - 1;
-        if ($curr->chapter_strlen > $this->maxChapterLen) {
-            // 太大了,修改结束位置 找到下一级
-            foreach ($paragraphs as $key => $paragraph) {
-                if ($paragraph->paragraph > $curr->paragraph) {
-                    if ($paragraph->chapter_strlen <= $this->maxChapterLen) {
-                        $endParagraph = $paragraph->paragraph + $paragraph->chapter_len - 1;
-                        $current = $paragraph;
-                        break;
-                    }
-                    if ($paragraph->level <= $curr->level) {
-                        // 不能往下走了,就是它了
-                        $endParagraph = $paragraphs[$key - 1]->paragraph + $paragraphs[$key - 1]->chapter_len - 1;
-                        $current = $paragraph;
-                        break;
-                    }
-                }
-            }
-        }
-        $start = $curr->paragraph;
-        $end = $endParagraph;
-        $nextPali = $this->next($current->book, $current->paragraph, $current->level);
-        $prevPali = $this->prev($current->book, $current->paragraph, $current->level);
+        // 与正文显示共用同一区间解析,保证分页边界与实际展示内容一致
+        $range = $this->resolveDisplayRange($book, $para);
+        $start = $range['start'];
+        $end = $range['end'];
+
+        // next/prev 以显示区间为边界,而非某个节点的层级:
+        // 聚合显示子 chapter 时,下一页应是区间 end 之后的第一个可导航段落,
+        // 上一页应是区间 start 之前最近的可导航段落。若按 current 的 level 取同级节点,
+        // 会在章节边界处跳过父级标题页或落入子节点。
+        $nextPali = $this->nextChapter($book, $end);
+        $prevPali = $this->prevChapter($book, $start);
 
         $next = null;
         if ($nextPali) {
@@ -385,26 +505,28 @@ class BookController extends Controller
         return compact('start', 'end', 'next', 'prev');
     }
 
-    public function next($book, $paragraph, $level)
+    /**
+     * 显示区间之后的第一个可导航 chapter 节点(level < 8)。
+     */
+    public function nextChapter(int $book, int $endParagraph): ?PaliText
     {
-        $next = PaliText::where('book', $book)
-            ->where('paragraph', '>', $paragraph)
-            ->where('level', $level)
+        return PaliText::where('book', $book)
+            ->where('paragraph', '>', $endParagraph)
+            ->where('level', '<', 8)
             ->orderBy('paragraph')
             ->first();
-
-        return $next ?? null;
     }
 
-    public function prev($book, $paragraph, $level)
+    /**
+     * 显示区间之前最近的一个可导航 chapter 节点(level < 8)。
+     */
+    public function prevChapter(int $book, int $startParagraph): ?PaliText
     {
-        $prev = PaliText::where('book', $book)
-            ->where('paragraph', '<', $paragraph)
-            ->where('level', $level)
+        return PaliText::where('book', $book)
+            ->where('paragraph', '<', $startParagraph)
+            ->where('level', '<', 8)
             ->orderBy('paragraph', 'desc')
             ->first();
-
-        return $prev ?? null;
     }
 
     public function show2($id)

+ 110 - 28
api-v13/app/Services/AIAssistant/AITermService.php

@@ -2,27 +2,26 @@
 
 namespace App\Services\AIAssistant;
 
-use Illuminate\Support\Facades\Log;
-
-use App\Services\OpenSearchService;
-use App\Services\TermService;
-use App\Services\OpenAIService;
+use App\DTO\Search\SearchDataDTO;
+use App\Http\Resources\AiModelResource;
 use App\Services\AIModelService;
 use App\Services\AuthService;
-
-use App\Http\Resources\AiModelResource;
-use App\DTO\Search\SearchDataDTO;
+use App\Services\OpenAIService;
+use App\Services\OpenSearchService;
+use App\Services\TermService;
+use Illuminate\Support\Facades\Log;
 
 class AITermService
 {
     protected $pageSize = 50;
-    protected AiModelResource $model;
 
+    protected AiModelResource $model;
 
     protected $modelToken;
 
+    protected ?bool $thinking = null;
 
-    private $sysPrompt = <<<md
+    private $sysPrompt = <<<'md'
     请根据提供的文献搜素结果,撰写一个巴利术语的简体中文百科词条。
 
     搜素结果是json数组
@@ -41,7 +40,7 @@ class AITermService
     4. 提供完整的参考文献列表
     5. 保持学术中立性和客观性
     6. 请引用我提供的全部内容,不要有任何遗漏
-    7. 请在文档的开头输出一个模板 {{quality|pending}}
+    7. 请在文档的开头输出一个模板 {{quality|pending}}。 这个模板后面不要有空行,直接跟后面的内容。
 
     **观点引用标准格式:**
     《文献中文名》在《章节中文名》中指出/解释/说明:"巴利文原文"(中文翻译及必要说明)。[link]
@@ -142,7 +141,7 @@ class AITermService
     {{category|经典与文献}} {{category|经藏}} {{category|中部}} {{category|义注}}
     md;
 
-    private $sysPromptTags = <<<md
+    private $sysPromptTags = <<<'md'
     你是一个巴利语佛教百科词条分类专家。请根据以下分类体系,为给定的词条打上分类标签。
 
     在分类标签的前面添加一个**词条概述**
@@ -193,6 +192,18 @@ class AITermService
     {{category|经典与文献}} {{category|经藏}} {{category|中部}} {{category|义注}}
     md;
 
+    private $sysPromptMeaning = <<<'md'
+    你是一个巴利语佛教术语翻译专家。我会提供一个巴利术语,以及它的百科词条正文。
+
+    请你阅读百科词条正文,为这个巴利术语提炼出一个最恰当、简洁的简体中文释义。
+
+    **输出规则:**
+    - 只输出释义本身,不要任何解释、标点修饰、引号或模板
+    - 释义应为简短的中文词语或短语,通常不超过 15 个字
+    - 优先采用学界通用的标准译法
+    - 不要重复输出巴利原词
+    md;
+
     /**
      * Create a new command instance.
      *
@@ -203,10 +214,22 @@ class AITermService
         protected OpenAIService $openAIService,
         protected TermService $termService
     ) {}
+
     public function setModel($id)
     {
         $this->model = $this->modelService->getModelById($id);
         $this->modelToken = AuthService::getUserToken($id);
+
+        return $this;
+    }
+
+    /**
+     * 设置 deepseek thinking 开关;null 表示不显式设置,沿用模型默认。
+     */
+    public function setThinking(?bool $thinking): static
+    {
+        $this->thinking = $thinking;
+
         return $this;
     }
 
@@ -215,15 +238,15 @@ class AITermService
         $search = app(OpenSearchService::class);
         // 组装搜索参数
         $params = [
-            'query'        => $word,
+            'query' => $word,
             'resourceType' => 'tipitaka',
             'granularity' => 'paragraph',
-            'pageSize'     => $this->pageSize,
+            'pageSize' => $this->pageSize,
         ];
         $result = $search->search($params);
 
         $dto = SearchDataDTO::fromArray($result);
-        $res = array();
+        $res = [];
         foreach ($dto->hits->items as $key => $item) {
 
             $res[] = [
@@ -231,46 +254,105 @@ class AITermService
                 'content' => $item->content,
                 'path' => $item->path,
                 'pid' => $item->getParaId(),
-                'link' => $item->getParaLink()
+                'link' => $item->getParaLink(),
             ];
         }
-        Log::debug('query ' . count($res));
+        Log::debug('query '.count($res));
+
         return $res;
     }
 
+    /**
+     * 按 word 查找/新建社区词条(_community_translation_zh-hans_),并用 LLM 生成百科正文写入 note。
+     *
+     * @return string LLM 生成的百科词条正文
+     */
+    public function updateOrCreate(string $channelId, string $word): string
+    {
+
+        $term = $this->termService->findOrCreate($channelId, $word);
+
+        return $this->update($term->guid);
+    }
+
     public function update(string $id)
     {
         // 获取术语
         $term = $this->termService->getRaw($id);
+
+        $content = $this->generateNote($term->word);
+        $meaning = $this->generateMeaning($term->word, $content);
+        $this->termService->update($id, ['note' => $content, 'meaning' => $meaning]);
+
+        return $content;
+    }
+
+    /**
+     * 全文搜索 word 命中段落,连同搜索结果发给 LLM 生成巴利术语百科正文,并输出引用自检日志。
+     *
+     * @return string LLM 生成的百科词条正文
+     */
+    private function generateNote(string $word): string
+    {
         // 全文搜索
-        $query = $this->query($term->word);
+        $query = $this->query($word);
 
         $res = json_encode($query, JSON_UNESCAPED_UNICODE);
         $resText = "# 搜索结果\n```json\n{$res}\n```\n";
-        $termText = "# 巴利术语\n\n{$term->word}\n\n";
-        //LLM 生成
-        $response = $this->openAIService->setApiUrl($this->model['url'])
+        $termText = "# 巴利术语\n\n{$word}\n\n";
+        // LLM 生成
+        $llm = $this->openAIService->setApiUrl($this->model['url'])
             ->setModel($this->model['model'])
             ->setApiKey($this->model['key'])
             ->setSystemPrompt($this->sysPrompt)
             ->setTemperature(0.5)
-            ->setStream(false)
-            ->send($resText . $termText);
+            ->setStream(false);
+        if ($this->thinking !== null) {
+            $llm = $llm->setThinking($this->thinking);
+        }
+        $response = $llm->send($resText.$termText);
 
         $content = $response['choices'][0]['message']['content'] ?? '';
-
-        //输出自检报告
+        $content = str_replace("{{quality|pending}}\n", '{{quality|pending}}', $content);
+        // 输出自检报告
         Log::debug('llm response', ['strlen' => $content]);
         $paraIds = $this->extractAllParaIds($content);
         Log::debug('has paragraph ref ', ['total' => count($paraIds), 'id' => $paraIds]);
-        $searchPid = array_map(fn($item) => $item['pid'], $query);
+        $searchPid = array_map(fn ($item) => $item['pid'], $query);
         $diff = array_values(array_diff($paraIds, $searchPid));
         Log::debug('diff', ['total' => count($diff), 'data' => $diff]);
 
-        $this->termService->update($id, ['note' => $content]);
         return $content;
     }
 
+    /**
+     * 根据已生成的百科词条正文,让 LLM 为巴利术语提炼一个简洁的简体中文释义。
+     *
+     * @return string LLM 提炼的简体中文释义;失败时回退为原词
+     */
+    private function generateMeaning(string $word, string $content): string
+    {
+        $termText = "# 巴利术语\n\n{$word}\n\n";
+        $noteText = "# 百科词条正文\n\n{$content}\n";
+
+        $llm = $this->openAIService->setApiUrl($this->model['url'])
+            ->setModel($this->model['model'])
+            ->setApiKey($this->model['key'])
+            ->setSystemPrompt($this->sysPromptMeaning)
+            ->setTemperature(0.3)
+            ->setStream(false);
+        if ($this->thinking !== null) {
+            $llm = $llm->setThinking($this->thinking);
+        }
+        $response = $llm->send($termText.$noteText);
+
+        $meaning = trim($response['choices'][0]['message']['content'] ?? '');
+
+        Log::debug('llm meaning', ['word' => $word, 'meaning' => $meaning]);
+
+        return $meaning !== '' ? $meaning : $word;
+    }
+
     public function create(string $word) {}
 
     /**
@@ -279,7 +361,7 @@ class AITermService
      * Parses a string that may contain multiple "{{para|...}}" templates
      * and returns an array of unique 'id' parameter values found.
      *
-     * @param string $str The input string containing zero or more {{para|...}} templates
+     * @param  string  $str  The input string containing zero or more {{para|...}} templates
      * @return array<int, string> Array of unique extracted ID values (e.g., ['16-1376', 'ABC-123'])
      *                            Returns empty array if no IDs are found
      *

+ 283 - 31
api-v13/app/Services/AIAssistant/PaliTranslateService.php

@@ -3,23 +3,27 @@
 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;
+use App\Services\TermService;
 use Illuminate\Support\Facades\Log;
 
 /**
  * 巴利原文 -> 简体中文 的多步骤翻译工作流。
  *
- * 支持个步骤,可单独运行或按顺序串联:
+ * 支持个步骤,可单独运行或按顺序串联:
  * - translate:根据巴利原文产出译文
+ * - term:术语标注,根据 channel/社区术语表把译文中的中文术语替换为 [[巴利术语]] 标记
  * - review:对已有译文打分并给出问题清单(不修改译文)
  * - revise:根据 review 的问题清单产出改进后的译文
  * - evaluate:质量评估,对照原文找出译文的真实问题,按级别就地用 HTML span 标注译文(颜色=严重程度,title=问题+建议),作为工作流最后一步
  *
- * 单独运行 review / revise / evaluate 时,已有译文从输出 channel 读取。
+ * 单独运行 term / review / revise / evaluate 时,已有译文从输出 channel 读取。
  *
  * 工作流自动提取同段落的 nissaya(巴利逐词缅文释义)注入 translate / review / evaluate
  * 作为参考资料(见 PaliNissayaReferenceService);无 nissaya 数据的段落不受影响。
@@ -29,7 +33,7 @@ class PaliTranslateService
     /**
      * 可用的工作流步骤
      */
-    public const STEPS = ['translate', 'review', 'revise', 'evaluate'];
+    public const STEPS = ['translate', 'term', 'review', 'revise', 'evaluate'];
 
     /**
      * 支持注入 nissaya 参考资料的步骤(revise 基于 review 意见工作,无需 nissaya)
@@ -85,6 +89,36 @@ class PaliTranslateService
         {"id":"2-3-4-5","content":"译文"}
         md;
 
+    /**
+     * term 步骤的提示词:根据术语表把译文中的中文术语替换为 [[巴利术语]] 标记。
+     */
+    protected string $termPrompt = <<<'md'
+        你是一个巴利语佛教术语标注助手。
+        用户会提供:
+        - pali:巴利原文段落,json,每条记录是一个句子,含 id 和 content 两个字段。
+        - translation:已有的简体中文译文,json,与 pali 通过 id 一一对应。
+        - glossary:巴利语术语表,json 数组,每个元素含 word(巴利术语原形)、meaning(该术语的中文释义)、tag(分类标签)三个字段。
+
+        你的任务:在**不改变译文其他内容**的前提下,把 translation 中对应这些术语的中文词语替换为术语标记 `[[巴利术语原形]]`(双方括号包裹 glossary 中给出的 word 原形),其余内容、措辞、标点、黑体等格式**一字不动**地保留。
+
+        判断与替换规则:
+        1. 只有当某个 glossary 术语在该句的巴利原文(pali)中确实出现(包括其屈折变化、变格、复合、连音等形式),且译文中存在与之对应的中文译词时,才进行替换。原文中并未出现的术语,一律不替换。
+        2. **语境判断(最重要)**:必须结合 glossary 中该术语的 meaning,判断它在当前句子语境下是否确实用作该术语义。同一个巴利词在论藏(阿毗达摩)中可能是专门术语,在经藏中却只是普通词汇。若根据 meaning 与上下文判断该词在此处并非用作术语义,则**不要**替换,保持原中文译文。
+        3. 宁缺毋滥:没有十足把握时不要标记。不确定的一律保持原译文,不加双方括号。
+        4. 不要新增术语表中没有的标记,也不要把已经正确翻译的普通词误标为术语。
+        5. 译文中可能已存在 `[[...]]` 术语标记,原样保留即可,不要重复包裹。
+
+        输出格式jsonl,每行对应一个句子,包含两个字段:
+        id:与原文相同的句子id
+        content:标注后的中文译文(无需替换的句子与原译文完全一致)
+
+        直接输出jsonl数据,无需解释
+
+        **输出范例**
+        {"id":"1-2-3-4","content":"住在[[rājagaha]]"}
+        {"id":"2-3-4-5","content":"原样保留的译文"}
+        md;
+
     /**
      * review 步骤的提示词:对已有译文打分并指出问题,不修改译文。
      */
@@ -93,6 +127,8 @@ class PaliTranslateService
         用户会提供巴利原文(pali)以及一份待审校的简体中文译文(translation),两者均为 json,通过 id 一一对应。
         用户还可能提供 nissaya(巴利原文的逐词缅文释义,与 pali 通过 id 对应,按词列出每个巴利词的语法解析与缅文释义,形如「巴利词= 缅文释义」):它是判断词义、修饰关系、指代关系和句子结构最权威的依据,审校时应以 nissaya 为准核对译文是否贴合原意,发现译文与 nissaya 冲突的,须在 issues 中指出。
 
+        译文中可能出现 `[[巴利术语]]`(双方括号包裹巴利语原词)形式的标记:这是**有意保留**的术语标记,表示该词按术语表以巴利原形呈现、刻意不译为中文,属于正确处理。审校时**完全忽略**这些 `[[...]]` 标记,**不要**把它当作漏译、未翻译、误译或格式问题,不要因此扣分,也不要在 issues 中提及;只需正常审校其余中文译文。
+
         请逐句审校译文,但**不要修改译文**,只输出审校意见。
         审校维度:
         1. 准确性:译文是否完全贴合巴利原文,有无漏译、增译、误译
@@ -216,6 +252,7 @@ class PaliTranslateService
         protected OpenAIService $openAIService,
         protected SearchPaliDataService $searchPaliDataService,
         protected PaliNissayaReferenceService $nissayaReference,
+        protected TermService $termService,
     ) {}
 
     /**
@@ -348,6 +385,12 @@ class PaliTranslateService
                 case 'translate':
                     $translation = $this->translate($pali, $this->nissayaFor('translate', $nissaya));
                     break;
+                case 'term':
+                    // 构造 channel + 社区术语表(经系统术语表过滤、channel 优先),按语境把译文中的中文术语替换为 [[巴利术语]]
+                    $glossary = $this->buildTermGlossary($book, $para);
+                    Log::debug('PaliTranslate: term 术语表', ['count' => count($glossary)]);
+                    $translation = $this->markTerms($pali, $translation, $glossary);
+                    break;
                 case 'review':
                     $review = $this->review($pali, $translation, $this->nissayaFor('review', $nissaya));
                     Log::debug('PaliTranslate: review 完成', ['review' => $review]);
@@ -361,9 +404,9 @@ class PaliTranslateService
             }
         }
 
-        // 只有产出译文的步骤(translate / revise / evaluate)才返回可写库的数据;
+        // 只有产出译文的步骤(translate / term / revise / evaluate)才返回可写库的数据;
         // evaluate 写库内容为带 HTML 标注的译文;仅 review 时报告已写入日志,无需重新保存原译文
-        $producesTranslation = (bool) array_intersect($steps, ['translate', 'revise', 'evaluate']);
+        $producesTranslation = (bool) array_intersect($steps, ['translate', 'term', 'revise', 'evaluate']);
 
         return $producesTranslation ? $translation : [];
     }
@@ -399,12 +442,34 @@ class PaliTranslateService
      */
     public function translate(array $pali, array $nissaya = []): 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);
         Log::debug('PaliTranslate: translate', ['input' => $userText]);
 
-        $content = $this->send($this->translatePrompt, $userText);
+        return $this->sendForIds('translate', $this->translatePrompt, $userText, $this->idsOf($pali));
+    }
+
+    /**
+     * term 步骤:根据术语表,把已有译文中对应的中文术语替换为 [[巴利术语]] 标记。
+     * 由大模型结合巴利原文与术语 meaning 做语境判断;译文或术语表为空时原样返回。
+     *
+     * @param  array<int, array{id: string, content: string}>  $pali
+     * @param  array<int, array{id: string, content: string}>  $translation
+     * @param  array<int, array{word: string, meaning: string, tag: string}>  $glossary
+     * @return array<int, array{id: string, content: string}>
+     */
+    public function markTerms(array $pali, array $translation, array $glossary): array
+    {
+        if (empty($translation) || empty($glossary)) {
+            return $translation;
+        }
+
+        $userText = "# pali\n\n".$this->jsonBlock($pali)."\n\n"
+            ."# translation\n\n".$this->jsonBlock($translation)."\n\n"
+            ."# glossary\n\n".$this->jsonBlock($glossary)."\n\n";
+        Log::debug('PaliTranslate: term', ['input' => $userText]);
 
-        return LlmResponseParser::jsonl($content);
+        return $this->sendForIds('term', $this->termPrompt, $userText, $this->idsOf($translation));
     }
 
     /**
@@ -417,15 +482,12 @@ 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);
-        Log::debug('PaliTranslate: review', ['output' => $content]);
-
-        return LlmResponseParser::jsonl($content);
+        return $this->sendForIds('review', $this->reviewPrompt, $userText, $this->idsOf($translation));
     }
 
     /**
@@ -438,15 +500,12 @@ 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);
-        Log::debug('PaliTranslate: revise', ['output' => $content]);
-
-        return LlmResponseParser::jsonl($content);
+        return $this->sendForIds('revise', $this->revisePrompt, $userText, $this->idsOf($translation));
     }
 
     /**
@@ -460,15 +519,12 @@ 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);
-        Log::debug('PaliTranslate: evaluate', ['output' => $content]);
-
-        return LlmResponseParser::jsonl($content);
+        return $this->sendForIds('evaluate', $this->evaluatePrompt, $userText, $this->idsOf($translation));
     }
 
     /**
@@ -523,6 +579,92 @@ class PaliTranslateService
         return is_string($content) ? $content : '[]';
     }
 
+    /**
+     * 调用 LLM 并解析 jsonl;若返回结果缺失部分期望 id(大模型漏句),则整体重试,
+     * 直到全部 id 补齐或达到最大尝试次数;最终仍缺失时返回最后一次(覆盖最全)的结果。
+     *
+     * @param  string  $step  步骤名,仅用于日志
+     * @param  string[]  $expectedIds  期望覆盖的句子 id 列表
+     * @return array<int, array<string, mixed>>
+     */
+    protected function sendForIds(string $step, string $systemPrompt, string $userText, array $expectedIds, int $maxAttempts = 2): array
+    {
+        $best = [];
+        $bestMissing = $expectedIds;
+
+        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
+            $result = LlmResponseParser::jsonl($this->send($systemPrompt, $userText));
+            $missing = $this->missingIds($expectedIds, $result);
+
+            // 保留缺失最少的一次结果,避免重试反而更差
+            if (count($missing) < count($bestMissing)) {
+                $best = $result;
+                $bestMissing = $missing;
+            }
+
+            if (empty($missing)) {
+                return $result;
+            }
+
+            Log::warning('PaliTranslate: LLM 返回缺失 id,重试', [
+                'step' => $step,
+                'attempt' => $attempt,
+                'missing' => $missing,
+            ]);
+        }
+
+        Log::warning('PaliTranslate: 重试后仍缺失 id', [
+            'step' => $step,
+            'missing' => $bestMissing,
+        ]);
+
+        return $best;
+    }
+
+    /**
+     * 提取记录列表中的 id(非空),保持顺序。
+     *
+     * @param  array<int, array<string, mixed>>  $items
+     * @return string[]
+     */
+    protected function idsOf(array $items): array
+    {
+        $ids = [];
+        foreach ($items as $item) {
+            if (isset($item['id']) && $item['id'] !== '') {
+                $ids[] = (string) $item['id'];
+            }
+        }
+
+        return $ids;
+    }
+
+    /**
+     * 返回 expectedIds 中、在 LLM 结果里缺失的 id。
+     *
+     * @param  string[]  $expectedIds
+     * @param  array<int, array<string, mixed>>  $result
+     * @return string[]
+     */
+    protected function missingIds(array $expectedIds, array $result): array
+    {
+        $returned = [];
+        foreach ($result as $item) {
+            if (isset($item['id'])) {
+                $returned[(string) $item['id']] = true;
+            }
+        }
+
+        $missing = [];
+        foreach ($expectedIds as $id) {
+            if (! isset($returned[(string) $id])) {
+                $missing[] = (string) $id;
+            }
+        }
+
+        return $missing;
+    }
+
     /**
      * 按开关返回某步骤应使用的 nissaya;未启用该步骤时返回空数组。
      *
@@ -546,7 +688,117 @@ 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 !== ''));
+    }
+
+    /**
+     * 构造 term 步骤的术语表:channel 术语表 + 同语言社区术语表,
+     * 经本段所属 chapter 的系统术语表(loadGlossary)过滤——只保留系统术语表里存在的术语,
+     * 再合并去重(channel 优先),每项含 word / meaning / tag 三个字段。
+     *
+     * @return array<int, array{word: string, meaning: string, tag: string}>
+     */
+    protected function buildTermGlossary(int $book, int $para): array
+    {
+        $channelId = $this->workChannel['id'] ?? null;
+        if (! $channelId) {
+            Log::warning('PaliTranslate: term 步骤未设置 channel,跳过术语注入');
+
+            return [];
+        }
+        $lang = $this->workChannel['lang'] ?? 'zh-Hans';
+
+        // 系统术语表(本段所属 chapter 命中的巴利术语原形)作为过滤白名单,算法同 loadGlossary
+        $allowed = $this->loadGlossary($book, $para);
+        if (empty($allowed)) {
+            return [];
+        }
+        $allowedSet = [];
+        foreach ($allowed as $word) {
+            $allowedSet[$this->toNfc($word)] = true;
+        }
+
+        // channel 术语表优先,社区术语表(同语言)补充;只保留系统术语表里有的术语
+        $channelTerms = $this->termService->getChannelGlossary($channelId);
+        $communityTerms = $this->termService->getCommunityGlossary($lang)['items'] ?? [];
+
+        $merged = [];
+        foreach ([$channelTerms, $communityTerms] as $source) {
+            foreach ($source as $term) {
+                $word = $this->toNfc(trim((string) (is_array($term) ? ($term['word'] ?? '') : ($term->word ?? ''))));
+                // 系统术语表里没有、word 为空、或 channel 已提供(优先)则跳过
+                if ($word === '' || ! isset($allowedSet[$word]) || isset($merged[$word])) {
+                    continue;
+                }
+                $merged[$word] = [
+                    'word' => $word,
+                    'meaning' => (string) (is_array($term) ? ($term['meaning'] ?? '') : ($term->meaning ?? '')),
+                    'tag' => (string) (is_array($term) ? ($term['tag'] ?? '') : ($term->tag ?? '')),
+                ];
+            }
+        }
+
+        return array_values($merged);
+    }
+
+    /**
+     * Unicode 规范化为 NFC(巴利文变音符匹配需统一 NFC/NFD)。intl 不可用时原样返回。
+     */
+    protected function toNfc(string $s): string
+    {
+        if (class_exists(\Normalizer::class)) {
+            $normalized = \Normalizer::normalize($s, \Normalizer::FORM_C);
+            if ($normalized !== false) {
+                return $normalized;
+            }
+        }
+
+        return $s;
     }
 
     /**
@@ -556,6 +808,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```";
     }
 }

Файловите разлики са ограничени, защото са твърде много
+ 234 - 228
api-v13/app/Services/OpenSearchService.php


+ 15 - 6
api-v13/app/Services/PaliTextService.php

@@ -11,11 +11,12 @@ class PaliTextService
     public function getParent(int $book, int $para)
     {
         $parent = PaliText::where('book', $book)
-            ->where('paragraph',  $para)->value('parent');
+            ->where('paragraph', $para)->value('parent');
 
         return $parent ? PaliText::where('book', $book)
-            ->where('paragraph',  $parent)->first() : null;
+            ->where('paragraph', $parent)->first() : null;
     }
+
     public function getCurrChapter(int $book, int $para)
     {
         $paragraph = PaliText::where('book', $book)
@@ -28,22 +29,26 @@ class PaliTextService
             return null;
         }
     }
+
     public function getBookPara(int $book, int $para)
     {
         $paragraph = PaliText::where('book', $book)
             ->where('paragraph', '<=', $para)
             ->where('level', 1)
             ->orderBy('paragraph', 'asc')->first();
-        if (!$paragraph) {
+        if (! $paragraph) {
             Log::warning('not found book ', ['book' => $book, 'para' => $para]);
+
             return null;
         }
+
         return $paragraph;
     }
+
     public function getParaCategoryTags(int $book, int $para): array
     {
         $bookPara = self::getBookPara($book, $para);
-        if (!$bookPara) {
+        if (! $bookPara) {
             return [];
         }
         if (Str::isUuid($bookPara->uid)) {
@@ -52,20 +57,24 @@ class PaliTextService
             Log::error('book uid not uuid', [
                 'book' => $book,
                 'para' => $para,
-                'uid' => $bookPara->uid
+                'uid' => $bookPara->uid,
             ]);
+
             return [];
         }
     }
+
     public function getParaInfo(int $book, int $para)
     {
         return PaliText::where('book', $book)
-            ->where('paragraph',  $para)
+            ->where('paragraph', $para)
             ->first();
     }
+
     public function getParaPathTitle(int $book, int $para)
     {
         $para = self::getParaInfo($book, $para);
+
         return array_map(function ($item) {
             return $item->title;
         }, json_decode($para->path));

+ 155 - 45
api-v13/app/Services/TermService.php

@@ -2,17 +2,24 @@
 
 namespace App\Services;
 
-use App\Models\DhammaTerm;
 use App\Http\Api\ChannelApi;
 use App\Http\Resources\TermResource;
-use Illuminate\Http\Request;
-
+use App\Models\DhammaTerm;
+use App\Models\UserDict;
 use App\Tools\Tools;
-
-
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
 
 class TermService
 {
+    // 术语表额外纳入的 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',
+    ];
+
     public function attachLocalName(array $categoryData, string $lang): array
     {
         $allNames = [];
@@ -20,12 +27,12 @@ class TermService
         // 收集所有 name
         foreach ($categoryData as $item) {
 
-            if (!empty($item['category']['name'])) {
+            if (! empty($item['category']['name'])) {
                 $allNames[] = $item['category']['name'];
             }
 
             foreach ($item['children'] as $child) {
-                if (!empty($child['name'])) {
+                if (! empty($child['name'])) {
                     $allNames[] = $child['name'];
                 }
             }
@@ -61,53 +68,119 @@ class TermService
 
         return $categoryData;
     }
+
     public function glossaryByLemma(array $words, string $lang)
     {
         $localTermChannel = ChannelApi::getSysChannel(
-            "_community_term_" . strtolower($lang) . "_"
+            '_community_term_' . strtolower($lang) . '_'
         );
-        if (!$localTermChannel) {
+        if (! $localTermChannel) {
             return null;
         }
         $result = DhammaTerm::select(['guid', 'word', 'tag', 'meaning', 'other_meaning'])
             ->whereIn('word', $words)
             ->where('channal', $localTermChannel)
             ->get();
+
         return $result;
     }
-    public function getCommunityGlossary($lang)
+
+    public function getCommunityGlossary(string $lang)
     {
         $localTermChannel = ChannelApi::getSysChannel(
-            "_community_term_" . strtolower($lang) . "_",
-            "_community_term_en_"
+            '_community_term_' . strtolower($lang) . '_',
+            '_community_term_en_'
         );
-        $result = DhammaTerm::select(['guid', 'word', 'tag', 'meaning', 'other_meaning'])
+        $result = DhammaTerm::select([
+            'guid',
+            'word',
+            'tag',
+            'meaning',
+            'other_meaning'
+        ])
             ->where('channal', $localTermChannel)
             ->get();
+
         return ['items' => $result, 'total' => count($result)];
     }
-    public function getGrammarGlossary($lang)
+
+    public function getChannelGlossary(string $channelId)
+    {
+        $terms = DhammaTerm::whereNotNull('word')
+            ->where('word', '!=', '')
+            ->whereNotNull('word')
+            ->where('channal', $channelId)
+            ->select(['word', 'meaning', 'tag'])
+            ->get();
+        return $terms->toArray();
+    }
+
+    public function getGlossary()
+    {
+        // 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 !== '')));
+
+        return $terms;
+    }
+
+    /**
+     * 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;
+    }
+
+    public function getGrammarGlossary(string $lang)
     {
         $localTermChannel = ChannelApi::getSysChannel(
-            "_System_Grammar_Term_" . strtolower($lang) . "_",
-            "_System_Grammar_Term_en_"
+            '_System_Grammar_Term_' . strtolower($lang) . '_',
+            '_System_Grammar_Term_en_'
         );
         $result = DhammaTerm::select(['guid', 'word', 'tag', 'meaning', 'other_meaning'])
             ->where('channal', $localTermChannel)
             ->get();
+
         return ['items' => $result, 'total' => count($result)];
     }
 
     public function getRaw(string $id)
     {
         $result = DhammaTerm::find($id);
+
         return $result;
     }
 
     public function isCommunity(?string $channelId)
     {
         $channel = ChannelApi::getById($channelId);
-        if (!$channel) {
+        if (! $channel) {
             return false;
         }
         if (strpos($channel['name'], '_community_term_') === false) {
@@ -116,10 +189,11 @@ class TermService
             return true;
         }
     }
+
     public function communityTerm(string $word, string $lang, string $format)
     {
         $localTermChannel = ChannelApi::getSysChannel(
-            "_community_term_" . strtolower($lang) . "_"
+            '_community_term_' . strtolower($lang) . '_'
         );
         $result = DhammaTerm::where('word', $word)
             ->where('channal', $localTermChannel)
@@ -128,7 +202,7 @@ class TermService
             $resource = new TermResource($result);
             $urlParam = ['format' => $format];
             $fakeRequest = Request::create('', 'GET', $urlParam);
-            $termArray    = $resource->toArray($fakeRequest);
+            $termArray = $resource->toArray($fakeRequest);
             if ($result) {
                 return $termArray;
             } else {
@@ -142,7 +216,7 @@ class TermService
     public function communityWiki(string $word, string $lang, string $format)
     {
         $localTermChannel = ChannelApi::getSysChannel(
-            "_community_translation_" . strtolower($lang) . "_"
+            '_community_translation_' . strtolower($lang) . '_'
         );
         $result = DhammaTerm::where('word', $word)
             ->where('channal', $localTermChannel)
@@ -151,7 +225,7 @@ class TermService
             $resource = new TermResource($result);
             $urlParam = ['format' => $format];
             $fakeRequest = Request::create('', 'GET', $urlParam);
-            $termArray    = $resource->toArray($fakeRequest);
+            $termArray = $resource->toArray($fakeRequest);
             if ($result) {
                 return $termArray;
             } else {
@@ -165,7 +239,7 @@ class TermService
     public function communityTerms(string $lang)
     {
         $localTermChannel = ChannelApi::getSysChannel(
-            "_community_term_" . strtolower($lang) . "_"
+            '_community_term_' . strtolower($lang) . '_'
         );
         $result = DhammaTerm::where('channal', $localTermChannel)
             ->whereNotNull('note')
@@ -173,9 +247,10 @@ class TermService
             ->take(10)
             ->orderBy('updated_at', 'desc')
             ->get();
+
         return [
-            "data" => TermResource::collection($result),
-            "count" => 10
+            'data' => TermResource::collection($result),
+            'count' => 10,
         ];
     }
 
@@ -185,7 +260,7 @@ class TermService
         $resource = new TermResource($result);
         $urlParam = ['format' => $format];
         $fakeRequest = Request::create('', 'GET', $urlParam);
-        $termArray    = $resource->toArray($fakeRequest);
+        $termArray = $resource->toArray($fakeRequest);
         if ($result) {
             return $termArray;
         } else {
@@ -225,34 +300,69 @@ class TermService
         if ($term) {
             // 已存在,直接更新
             $term->update([
-                'meaning'       => $data['meaning'],
+                'meaning' => $data['meaning'],
                 'other_meaning' => $data['other_meaning'] ?? null,
-                'note'          => $data['note'] ?? null,
-                'redirect'      => $data['redirect'] ?? null,
-                'editor_id'     => $data['editor_id'],
-                'modify_time'   => $now,
+                'note' => $data['note'] ?? null,
+                'redirect' => $data['redirect'] ?? null,
+                'editor_id' => $data['editor_id'],
+                'modify_time' => $now,
             ]);
         } else {
             // 不存在,新建(一次性写入所有字段)
-            $term = new DhammaTerm();
-            $term->id           = app('snowflake')->id();
-            $term->guid         = (string) \Illuminate\Support\Str::uuid();
-            $term->word         = $data['word'];
-            $term->tag          = $data['tag'] ?? null;
-            $term->channal      = $data['channel_id'];
-            $term->meaning      = $data['meaning'];
+            $term = new DhammaTerm;
+            $term->id = app('snowflake')->id();
+            $term->guid = (string) Str::uuid();
+            $term->word = $data['word'];
+            $term->tag = $data['tag'] ?? null;
+            $term->channal = $data['channel_id'];
+            $term->meaning = $data['meaning'];
             $term->other_meaning = $data['other_meaning'] ?? null;
-            $term->note         = $data['note'] ?? null;
-            $term->redirect     = $data['redirect'] ?? null;
-            $term->editor_id    = $data['editor_id'];  // 注意:需传入 int 类型的 editor id
-            $term->owner        = $channelInfo['studio_id'];
-            $term->word_en      = Tools::getWordEn($data['word']);
-            $term->language     = $channelInfo['lang'] ?? 'zh-Hans';
-            $term->create_time  = $now;
-            $term->modify_time  = $now;
+            $term->note = $data['note'] ?? null;
+            $term->redirect = $data['redirect'] ?? null;
+            $term->editor_id = $data['editor_id'];  // 注意:需传入 int 类型的 editor id
+            $term->owner = $channelInfo['studio_id'];
+            $term->word_en = Tools::getWordEn($data['word']);
+            $term->language = $channelInfo['lang'] ?? 'zh-Hans';
+            $term->create_time = $now;
+            $term->modify_time = $now;
             $term->save();
         }
 
         return $term->guid;
     }
+
+    /**
+     * 按 channel + word 查找术语;不存在则新建一条空白词条(释义留待后续填充)。
+     *
+     * @return DhammaTerm 既有或新建的术语记录
+     */
+    public function findOrCreate(string $channelId, string $word): DhammaTerm
+    {
+        $term = DhammaTerm::where('word', $word)
+            ->where('channal', $channelId)
+            ->first();
+
+        if ($term) {
+            return $term;
+        }
+
+        $channelInfo = ChannelApi::getById($channelId);
+        $now = time();
+
+        $term = new DhammaTerm;
+        $term->id = app('snowflake')->id();
+        $term->guid = (string) Str::uuid();
+        $term->word = $word;
+        $term->meaning = $word;
+        $term->channal = $channelId;
+        $term->word_en = Tools::getWordEn($word);
+        $term->owner = $channelInfo['studio_id'] ?? null;
+        $term->language = $channelInfo['lang'] ?? 'en';
+        $term->editor_id = 0;
+        $term->create_time = $now;
+        $term->modify_time = $now;
+        $term->save();
+
+        return $term;
+    }
 }

+ 48 - 0
api-v13/resources/js/modules/reader.js

@@ -2,6 +2,7 @@
 
 export function initReader() {
     injectCommentaryMarkers();
+    injectEvaluateMarkers();
     initTocToggle();
 }
 
@@ -72,6 +73,53 @@ function injectCommentaryMarkers() {
         });
 }
 
+// 译文质量评估标注:把 <span class="evaluate evaluate-级别" title="…"> 就地
+// 升级为 Tufte 旁注。保留原 span(含背景色高亮)不动,在其后注入
+//   span.evaluate → label.margin-toggle → input.margin-toggle → span.marginnote
+// 复用既有旁注样式:桌面端 marginnote 自动浮到右栏,手机端点击 label 展开。
+// 用 marginnote(而非 sidenote)以避免 ::before 计数器的 [N] 编号。
+function injectEvaluateMarkers() {
+    let counter = 0;
+
+    document
+        .querySelectorAll('article.reader-body span.evaluate[title]')
+        .forEach((evalEl) => {
+            const info = evalEl.getAttribute('title').trim();
+            if (info === '') {
+                return;
+            }
+
+            counter++;
+            const id = `evaluate-${counter}`;
+
+            // label:手机端点击触发(桌面隐藏,marginnote 自动浮动)
+            const label = document.createElement('label');
+            label.htmlFor = id;
+            label.className = 'margin-toggle';
+            label.innerHTML = '<i class="ti ti-alert-triangle"></i>';
+
+            // checkbox:控制手机端展开
+            const checkbox = document.createElement('input');
+            checkbox.type = 'checkbox';
+            checkbox.className = 'margin-toggle';
+            checkbox.id = id;
+
+            // 旁注内容:取自 title,| 分隔的「问题简述/建议」分行显示
+            const note = document.createElement('span');
+            note.className = 'marginnote evaluate-note';
+            info.split('|').forEach((part, index) => {
+                if (index > 0) {
+                    note.appendChild(document.createElement('br'));
+                }
+                note.appendChild(document.createTextNode(part.trim()));
+            });
+
+            // 顺序:span.evaluate → label → input → span.marginnote
+            // (input 与 marginnote 相邻,CSS :checked + .marginnote 依赖此顺序)
+            evalEl.after(label, checkbox, note);
+        });
+}
+
 async function fetchCommentary(noteEl) {
     const uuid = noteEl.dataset.uuid;
 

+ 2 - 2
api-v13/resources/views/library/book/read.blade.php

@@ -135,7 +135,7 @@
 {{-- TOC Offcanvas(mobile) --}}
 <div class="offcanvas offcanvas-start" tabindex="-1" id="tocDrawer">
     <div class="offcanvas-header">
-        <h5 class="offcanvas-title">{{ __('library.toc') }}</h5>
+        <h5 class="offcanvas-title">{{ $book['book_title'] }}</h5>
         <button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
     </div>
     <div class="offcanvas-body">
@@ -150,7 +150,7 @@
         {{-- TOC 侧边栏(tablet+) --}}
         <div class="toc-sidebar card">
             <div class="card-body">
-                <h5>{{ __('library.toc') }}</h5>
+                <h5>{{ $book['book_title'] }}</h5>
                 @include('library.book.toc', ['toc' => $book['toc'] ?? []])
             </div>
         </div>

Някои файлове не бяха показани, защото твърде много файлове са промени