Procházet zdrojové kódy

Merge pull request #2408 from visuddhinanda/development

Development
visuddhinanda před 3 dny
rodič
revize
4af922bd57

+ 52 - 110
api-v13/app/Console/Commands/UpgradeAITranslation.php

@@ -5,16 +5,14 @@ namespace App\Console\Commands;
 use App\Helpers\LlmResponseParser;
 use App\Helpers\LlmResponseParser;
 use App\Http\Api\ChannelApi;
 use App\Http\Api\ChannelApi;
 use App\Http\Resources\AiModelResource;
 use App\Http\Resources\AiModelResource;
-use App\Models\PaliSentence;
 use App\Models\PaliText;
 use App\Models\PaliText;
 use App\Models\Sentence;
 use App\Models\Sentence;
 use App\Services\AIAssistant\NissayaTranslateService;
 use App\Services\AIAssistant\NissayaTranslateService;
+use App\Services\AIAssistant\PaliTranslateService;
 use App\Services\AIModelService;
 use App\Services\AIModelService;
 use App\Services\AuthService;
 use App\Services\AuthService;
 use App\Services\OpenAIService;
 use App\Services\OpenAIService;
-use App\Services\SearchPaliDataService;
 use App\Services\SentenceService;
 use App\Services\SentenceService;
-use App\Tools\Tools;
 use Illuminate\Console\Command;
 use Illuminate\Console\Command;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Log;
@@ -23,9 +21,19 @@ class UpgradeAITranslation extends Command
 {
 {
     /**
     /**
      * The name and signature of the console command.
      * The name and signature of the console command.
-     * php artisan upgrade:ai.translation translation --book=141 --para=535
+     * php artisan upgrade:ai.translation translation --book=131 --para=27
      * php artisan upgrade:ai.translation nissaya --book=207 --para=1247
      * php artisan upgrade:ai.translation nissaya --book=207 --para=1247
      *
      *
+     * nissaya 参考资料用法示例(--nissaya 指定哪些步骤注入 nissaya 逐词缅文释义):
+     * - 默认(不传)            translate/review/evaluate 全部注入 nissaya
+     *   php artisan upgrade:ai.translation translation {channel} --book=131 --para=27 --steps=translate,review,revise,evaluate
+     * - 仅 review 注入
+     *   php artisan upgrade:ai.translation translation {channel} --book=131 --para=27 --steps=translate,review,evaluate --nissaya=review
+     * - review + evaluate 注入,translate 不注入
+     *   php artisan upgrade:ai.translation translation {channel} --book=131 --para=27 --steps=translate,review,evaluate --nissaya=review,evaluate
+     * - 全部不注入
+     *   php artisan upgrade:ai.translation translation {channel} --book=131 --para=27 --steps=translate,review,evaluate --nissaya=
+     *
      * @var string
      * @var string
      */
      */
     protected $signature = 'upgrade:ai.translation
     protected $signature = 'upgrade:ai.translation
@@ -36,6 +44,8 @@ class UpgradeAITranslation extends Command
     {--resume}
     {--resume}
     {--model=}
     {--model=}
     {--thinking= : 开启和关闭deepseek thinking true | false}
     {--thinking= : 开启和关闭deepseek thinking true | false}
+    {--steps=translate : translation 工作流步骤,逗号分隔,可选 translate,review,revise,evaluate(evaluate 为质量评估,须放最后)}
+    {--nissaya=translate,review,evaluate : 启用 nissaya 参考资料的步骤,逗号分隔,可选 translate,review,evaluate;传空字符串则全部不注入}
     {--fresh : 清除缓存断点,从头开始}';
     {--fresh : 清除缓存断点,从头开始}';
 
 
     // 缓存键前缀:以 type、channel 区分,记录已完成的 "book|para" 集合,中断后重跑自动跳过
     // 缓存键前缀:以 type、channel 区分,记录已完成的 "book|para" 集合,中断后重跑自动跳过
@@ -67,7 +77,8 @@ class UpgradeAITranslation extends Command
         protected AIModelService $modelService,
         protected AIModelService $modelService,
         protected SentenceService $sentenceService,
         protected SentenceService $sentenceService,
         protected OpenAIService $openAIService,
         protected OpenAIService $openAIService,
-        protected NissayaTranslateService $nissayaTranslateService
+        protected NissayaTranslateService $nissayaTranslateService,
+        protected PaliTranslateService $paliTranslateService
     ) {
     ) {
         parent::__construct();
         parent::__construct();
     }
     }
@@ -82,33 +93,52 @@ class UpgradeAITranslation extends Command
         /**
         /**
          * model
          * model
          */
          */
-        if (!$this->option('model')) {
+        if (! $this->option('model')) {
             $this->error('model is request');
             $this->error('model is request');
+
             return 1;
             return 1;
         }
         }
         $this->model = $this->modelService->getModelById($this->option('model'));
         $this->model = $this->modelService->getModelById($this->option('model'));
         $this->info("model:{$this->model['model']}");
         $this->info("model:{$this->model['model']}");
         $this->modelToken = AuthService::getUserToken($this->model['uid']);
         $this->modelToken = AuthService::getUserToken($this->model['uid']);
 
 
-        //channel
+        // channel
         $this->workChannel = ChannelApi::getById($this->argument('channel'));
         $this->workChannel = ChannelApi::getById($this->argument('channel'));
         // 需要判断输入channel 与翻译类型是否一致 nissaya -> nissaya channel
         // 需要判断输入channel 与翻译类型是否一致 nissaya -> nissaya channel
         if ($this->workChannel['type'] !== $this->argument('type')) {
         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;
             return 1;
         }
         }
 
 
         if ($this->option('thinking')) {
         if ($this->option('thinking')) {
             $this->thinking = $this->option('thinking') === 'true';
             $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));
+
+            return 1;
+        }
+
+        // nissaya 参考资料注入步骤校验(哪些步骤启用 nissaya)
+        $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));
+
+            return 1;
         }
         }
 
 
         $type = $this->argument('type');
         $type = $this->argument('type');
         $channelId = $this->workChannel['id'] ?? '';
         $channelId = $this->workChannel['id'] ?? '';
 
 
         // 缓存键:按 type、channel 区分不同任务的断点
         // 缓存键:按 type、channel 区分不同任务的断点
-        $cacheKey = self::CACHE_KEY_PREFIX . ':' . $type . ':' . $channelId;
+        $cacheKey = self::CACHE_KEY_PREFIX.':'.$type.':'.$channelId;
 
 
         if ($this->option('fresh')) {
         if ($this->option('fresh')) {
             Cache::forget($cacheKey);
             Cache::forget($cacheKey);
@@ -128,7 +158,7 @@ class UpgradeAITranslation extends Command
             // 未指定 book 时,若已有断点缓存,从上次处理到的 book 继续,无需从 1 开始
             // 未指定 book 时,若已有断点缓存,从上次处理到的 book 继续,无需从 1 开始
             $startBook = 1;
             $startBook = 1;
             if (! empty($done)) {
             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);
                 $startBook = max($doneBooks);
                 $this->info("resume from book {$startBook}");
                 $this->info("resume from book {$startBook}");
             }
             }
@@ -142,7 +172,7 @@ class UpgradeAITranslation extends Command
             }
             }
             foreach ($paragraphs as $key => $paragraph) {
             foreach ($paragraphs as $key => $paragraph) {
                 // 稳定游标:缓存键已含 type、channel,此处仅以 book|para 标识处理单元
                 // 稳定游标:缓存键已含 type、channel,此处仅以 book|para 标识处理单元
-                $cursor = $book . '|' . $paragraph;
+                $cursor = $book.'|'.$paragraph;
                 if (isset($done[$cursor])) {
                 if (isset($done[$cursor])) {
                     $this->info("skip {$cursor}");
                     $this->info("skip {$cursor}");
 
 
@@ -152,7 +182,12 @@ class UpgradeAITranslation extends Command
                 $data = [];
                 $data = [];
                 switch ($this->argument('type')) {
                 switch ($this->argument('type')) {
                     case 'translation':
                     case 'translation':
-                        $data = $this->aiPaliTranslate($book, $paragraph);
+                        $data = $this->paliTranslateService
+                            ->setModel($this->model)
+                            ->setChannel($this->workChannel)
+                            ->setThinking($this->thinking ?? null)
+                            ->setNissayaSteps($nissayaSteps)
+                            ->run($steps, (int) $book, (int) $paragraph);
                         break;
                         break;
                     case 'nissaya':
                     case 'nissaya':
                         $data = $this->aiNissayaTranslate($book, $paragraph);
                         $data = $this->aiNissayaTranslate($book, $paragraph);
@@ -166,7 +201,7 @@ class UpgradeAITranslation extends Command
                 }
                 }
                 $this->save($data);
                 $this->save($data);
                 $time = time() - $start;
                 $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;
                 $done[$cursor] = true;
                 Cache::put($cacheKey, $done, now()->addHours(24));
                 Cache::put($cacheKey, $done, now()->addHours(24));
@@ -181,82 +216,6 @@ class UpgradeAITranslation extends Command
         return 0;
         return 0;
     }
     }
 
 
-    private function getPaliContent($book, $para)
-    {
-        $sentenceService = app(SearchPaliDataService::class);
-        $sentences = PaliSentence::where('book', $book)
-            ->where('paragraph', $para)
-            ->orderBy('word_begin')
-            ->get();
-        if (! $sentences) {
-            return null;
-        }
-        $json = [];
-        foreach ($sentences as $key => $sentence) {
-            $content = $sentenceService->getSentenceContent($book, $para, $sentence->word_begin, $sentence->word_end);
-            $id = "{$book}-{$para}-{$sentence->word_begin}-{$sentence->word_end}";
-            $json[] = ['id' => $id, 'content' => $content['markdown']];
-        }
-
-        return $json;
-    }
-
-    private function aiPaliTranslate($book, $para)
-    {
-        $prompt = <<<'md'
-        你是一个巴利语翻译助手。
-        pali 是巴利原文的一个段落,json格式, 每条记录是一个句子。包括id 和 content 两个字段
-        请翻译这个段落为简体中文。
-
-        翻译要求
-        1. 语言风格为现代汉语书面语,不要使用古汉语或者半文半白。
-        2. 译文严谨,完全贴合巴利原文,不要加入自己的理解
-        3. 巴利原文中的黑体字在译文中也使用黑体。其他标点符号跟随巴利原文,但应该替换为相应的汉字全角符号
-
-        输出格式jsonl
-        输出id 和 content 两个字段,
-        id 使用巴利原文句子的id ,
-        content 为中文译文
-
-        直接输出jsonl数据,无需解释
-
-
-    **输出范例**
-    {"id":"1-2-3-4","content":"译文"}
-    {"id":"2-3-4-5","content":"译文"}
-    md;
-
-        $pali = $this->getPaliContent($book, $para);
-        $originalText = "```json\n" . json_encode($pali, JSON_UNESCAPED_UNICODE) . "\n```";
-        Log::debug($originalText);
-        if (! $this->model) {
-            Log::error('model is invalid');
-
-            return [];
-        }
-        $startAt = time();
-        $llm = $this->openAIService->setApiUrl($this->model['url'])
-            ->setModel($this->model['model'])
-            ->setApiKey($this->model['key'])
-            ->setSystemPrompt($prompt)
-            ->setTemperature(0.0)
-            ->setStream(false);
-        if (isset($this->thinking)) {
-            $llm = $llm->setThinking($this->thinking);
-        }
-
-        $response =    $llm->send("# pali\n\n{$originalText}\n\n");
-        $complete = time() - $startAt;
-        $translationText = $response['choices'][0]['message']['content'] ?? '[]';
-        Log::debug("complete in {$complete}s", ['content' => $translationText]);
-        $json = [];
-        if (is_string($translationText)) {
-            $json = LlmResponseParser::jsonl($translationText);
-        }
-
-        return $json;
-    }
-
     private function aiWBW($book, $para)
     private function aiWBW($book, $para)
     {
     {
         $sysPrompt = <<<'md'
         $sysPrompt = <<<'md'
@@ -316,7 +275,7 @@ class UpgradeAITranslation extends Command
             $response = $llm->send("```json\n{$tplText}\n```");
             $response = $llm->send("```json\n{$tplText}\n```");
             $complete = time() - $startAt;
             $complete = time() - $startAt;
             $content = $response['choices'][0]['message']['content'] ?? '[]';
             $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);
             $json = LlmResponseParser::jsonl($content);
 
 
@@ -332,32 +291,15 @@ class UpgradeAITranslation extends Command
 
 
     private function aiNissayaTranslate($book, $para)
     private function aiNissayaTranslate($book, $para)
     {
     {
-        $sysPrompt = <<<'md'
-        你是一个佛教翻译专家,精通巴利文和缅文
-        ## 翻译要求:
-        - 请将nissaya单词表中的巴利文和缅文分别翻译为中文
-        - 输入格式为 巴利文:缅文
-        - 一行是一条记录,翻译的时候,请不要拆分一行中的巴利文单词或缅文单词,一行中出现多个单词的,一起翻译
-        - 输出csv格式内容,分隔符为"$",
-        - 字段如下:巴利文$巴利文的中文译文$缅文$缅文的中文译文 #两个译文的语义相似度(%)
-
-        **范例**:
-
-        pana$然而$ဝါဒန္တရကား$教义之说 #60%
-
-        直接输出csv, 无需其他内容
-        用```包裹的行为注释内容,也需要翻译和解释。放在最后面。如果没有```,无需处理
-        md;
-
         $sentences = Sentence::nissaya()
         $sentences = Sentence::nissaya()
             ->language('my') // 过滤缅文
             ->language('my') // 过滤缅文
             ->where('book_id', $book)
             ->where('book_id', $book)
             ->where('paragraph', $para)
             ->where('paragraph', $para)
-            ->orderBy('strlen')
+            ->orderBy('word_start')
             ->get();
             ->get();
         $result = [];
         $result = [];
         foreach ($sentences as $key => $sentence) {
         foreach ($sentences as $key => $sentence) {
-            if (!empty($sentence->content)) {
+            if (! empty($sentence->content)) {
                 $id = "{$sentence->book_id}-{$sentence->paragraph}-{$sentence->word_start}-{$sentence->word_end}";
                 $id = "{$sentence->book_id}-{$sentence->paragraph}-{$sentence->word_start}-{$sentence->word_end}";
 
 
                 $aiNissaya = $this->nissayaTranslateService
                 $aiNissaya = $this->nissayaTranslateService

+ 42 - 46
api-v13/app/Services/AIAssistant/ArticleTranslateService.php

@@ -2,35 +2,29 @@
 
 
 namespace App\Services\AIAssistant;
 namespace App\Services\AIAssistant;
 
 
+use App\Http\Api\ChannelApi;
+use App\Models\CustomBook;
 use App\Services\ArticleService;
 use App\Services\ArticleService;
+use App\Services\AuthService;
 use App\Services\PaliContentService;
 use App\Services\PaliContentService;
 use App\Services\SentenceService;
 use App\Services\SentenceService;
-use App\Services\AuthService;
-
-use App\Models\CustomBook;
-
-
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Log;
-use App\Http\Api\ChannelApi;
-
 
 
 class ArticleTranslateService
 class ArticleTranslateService
 {
 {
-    protected ArticleService $articleService;
-    protected PaliContentService $paliContentService;
-    protected TranslateService $translateService;
-    protected SentenceService $sentenceService;
-
-
     protected string $modelId;
     protected string $modelId;
+
     protected string $modelToken;
     protected string $modelToken;
+
     protected array $translation = [];
     protected array $translation = [];
+
     protected string $outputChannelId;
     protected string $outputChannelId;
+
     protected string $currArticleId;
     protected string $currArticleId;
 
 
     protected bool $thinking;
     protected bool $thinking;
 
 
-    protected string $systemPrompt = <<<PROMPT
+    protected string $systemPrompt = <<<'PROMPT'
     请根据提供的原文,翻译为简体中文。
     请根据提供的原文,翻译为简体中文。
 
 
     原文为逐句数据,翻译时请依照句子的上下文翻译。
     原文为逐句数据,翻译时请依照句子的上下文翻译。
@@ -61,49 +55,42 @@ class ArticleTranslateService
     PROMPT;
     PROMPT;
 
 
     public function __construct(
     public function __construct(
-        ArticleService $article,
-        PaliContentService $paliContent,
-        TranslateService $translateService,
-        SentenceService $sentenceService
-    ) {
-        $this->articleService = $article;
-        $this->paliContentService = $paliContent;
-        $this->translateService = $translateService;
-        $this->sentenceService = $sentenceService;
-    }
+        protected ArticleService $articleService,
+        protected PaliContentService $paliContentService,
+        protected TranslateService $translateService,
+        protected SentenceService $sentenceService
+    ) {}
 
 
     /**
     /**
      * 设置模型配置
      * 设置模型配置
-     *
-     * @param string $model
-     * @return self
      */
      */
     public function setModel(string $model): self
     public function setModel(string $model): self
     {
     {
         $this->modelId = $model;
         $this->modelId = $model;
         $this->modelToken = app(AuthService::class)->getUserToken($model);
         $this->modelToken = app(AuthService::class)->getUserToken($model);
+
         return $this;
         return $this;
     }
     }
+
     /**
     /**
      * 设置模型配置
      * 设置模型配置
-     *
-     * @param bool $thinking
-     * @return self
      */
      */
     public function setThinking(bool $thinking): self
     public function setThinking(bool $thinking): self
     {
     {
         $this->thinking = $thinking;
         $this->thinking = $thinking;
+
         return $this;
         return $this;
     }
     }
+
     /**
     /**
      * 设置模型配置
      * 设置模型配置
      *
      *
-     * @param string $model
-     * @return self
+     * @param  string  $model
      */
      */
     public function setChannel(string $id): self
     public function setChannel(string $id): self
     {
     {
         $this->outputChannelId = $id;
         $this->outputChannelId = $id;
+
         return $this;
         return $this;
     }
     }
 
 
@@ -111,6 +98,7 @@ class ArticleTranslateService
     {
     {
         return $this->currArticleId;
         return $this->currArticleId;
     }
     }
+
     public function translateAnthology(string $anthologyId, ?callable $onEach = null): int
     public function translateAnthology(string $anthologyId, ?callable $onEach = null): int
     {
     {
         $articleIds = $this->articleService->articlesInAnthology($anthologyId);
         $articleIds = $this->articleService->articlesInAnthology($anthologyId);
@@ -124,17 +112,19 @@ class ArticleTranslateService
 
 
         return count($articleIds);
         return count($articleIds);
     }
     }
+
     public function translateArticle(string $articleId)
     public function translateArticle(string $articleId)
     {
     {
         $this->currArticleId = $articleId;
         $this->currArticleId = $articleId;
-        //获取文章中的句子id
+        // 获取文章中的句子id
         $sentenceIds = $this->articleService->sentenceIds($articleId);
         $sentenceIds = $this->articleService->sentenceIds($articleId);
-        if (!$sentenceIds || count($sentenceIds) === 0) {
+        if (! $sentenceIds || count($sentenceIds) === 0) {
             $this->translation = [];
             $this->translation = [];
+
             return $this;
             return $this;
         }
         }
-        $bookId = (int)explode('-', $sentenceIds[0])[0];
-        //提取原文
+        $bookId = (int) explode('-', $sentenceIds[0])[0];
+        // 提取原文
         $originalChannelId = CustomBook::where('book_id', $bookId)->value('channel_id');
         $originalChannelId = CustomBook::where('book_id', $bookId)->value('channel_id');
 
 
         $original = $this->paliContentService->sentences($sentenceIds, [$originalChannelId], 'read');
         $original = $this->paliContentService->sentences($sentenceIds, [$originalChannelId], 'read');
@@ -144,27 +134,29 @@ class ArticleTranslateService
                 $org = $sent['origin'][0];
                 $org = $sent['origin'][0];
                 $orgData[] = [
                 $orgData[] = [
                     'id' => "{$org['book']}-{$org['para']}-{$org['wordStart']}-{$org['wordEnd']}",
                     'id' => "{$org['book']}-{$org['para']}-{$org['wordStart']}-{$org['wordEnd']}",
-                    'content' => !empty($org['content']) ? $org['content'] : $org['html'],
+                    'content' => ! empty($org['content']) ? $org['content'] : $org['html'],
                 ];
                 ];
             }
             }
         }
         }
-        //翻译
+        // 翻译
         $result = $this->translateService->setModel($this->modelId)
         $result = $this->translateService->setModel($this->modelId)
             ->setSystemPrompt($this->systemPrompt)
             ->setSystemPrompt($this->systemPrompt)
-            ->setTranslatePrompt("# 原文\n\n" .
-                "```json\n" .
-                json_encode($orgData, JSON_UNESCAPED_UNICODE) .
+            ->setTranslatePrompt("# 原文\n\n".
+                "```json\n".
+                json_encode($orgData, JSON_UNESCAPED_UNICODE).
                 "\n```")
                 "\n```")
             ->translate();
             ->translate();
         Log::debug('ai translation', ['data' => $result->toArray()['data']]);
         Log::debug('ai translation', ['data' => $result->toArray()['data']]);
         $this->translation = $result->toArray()['data'];
         $this->translation = $result->toArray()['data'];
+
         return $this;
         return $this;
     }
     }
-    //写入结果channel
+
+    // 写入结果channel
     public function save()
     public function save()
     {
     {
         if (
         if (
-            !is_array($this->translation) ||
+            ! is_array($this->translation) ||
             count($this->translation) === 0
             count($this->translation) === 0
         ) {
         ) {
             return 0;
             return 0;
@@ -173,6 +165,7 @@ class ArticleTranslateService
         $sentData = [];
         $sentData = [];
         $sentData = array_map(function ($n) use ($channelInfo) {
         $sentData = array_map(function ($n) use ($channelInfo) {
             $sId = explode('-', $n['id']);
             $sId = explode('-', $n['id']);
+
             return [
             return [
                 'book_id' => $sId[0],
                 'book_id' => $sId[0],
                 'paragraph' => $sId[1],
                 'paragraph' => $sId[1],
@@ -186,16 +179,17 @@ class ArticleTranslateService
                 'editor_uid' => $this->modelId,
                 'editor_uid' => $this->modelId,
             ];
             ];
         }, $this->translation);
         }, $this->translation);
-        foreach ($sentData as  $value) {
+        foreach ($sentData as $value) {
             $this->sentenceService->save($value);
             $this->sentenceService->save($value);
         }
         }
+
         return count($sentData);
         return count($sentData);
     }
     }
 
 
     public function saveRpc(string $endpoint, string $accessToken)
     public function saveRpc(string $endpoint, string $accessToken)
     {
     {
         if (
         if (
-            !is_array($this->translation) ||
+            ! is_array($this->translation) ||
             count($this->translation) === 0
             count($this->translation) === 0
         ) {
         ) {
             return 0;
             return 0;
@@ -204,6 +198,7 @@ class ArticleTranslateService
         $sentData = [];
         $sentData = [];
         $sentData = array_map(function ($n) use ($channelInfo, $accessToken) {
         $sentData = array_map(function ($n) use ($channelInfo, $accessToken) {
             $sId = explode('-', $n['id']);
             $sId = explode('-', $n['id']);
+
             return [
             return [
                 'book_id' => $sId[0],
                 'book_id' => $sId[0],
                 'paragraph' => $sId[1],
                 'paragraph' => $sId[1],
@@ -215,9 +210,10 @@ class ArticleTranslateService
                 'access_token' => $accessToken,
                 'access_token' => $accessToken,
             ];
             ];
         }, $this->translation);
         }, $this->translation);
-        foreach ($sentData as  $value) {
+        foreach ($sentData as $value) {
             $this->sentenceService->saveRpc($endpoint, $value, $this->modelToken);
             $this->sentenceService->saveRpc($endpoint, $value, $this->modelToken);
         }
         }
+
         return count($sentData);
         return count($sentData);
     }
     }
 
 

+ 34 - 33
api-v13/app/Services/AIAssistant/NissayaTranslateService.php

@@ -2,25 +2,24 @@
 
 
 namespace App\Services\AIAssistant;
 namespace App\Services\AIAssistant;
 
 
+use App\Http\Resources\AiModelResource;
 use App\Services\NissayaParser;
 use App\Services\NissayaParser;
 use App\Services\OpenAIService;
 use App\Services\OpenAIService;
 use App\Services\RomanizeService;
 use App\Services\RomanizeService;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Log;
-use App\Http\Resources\AiModelResource;
-
 
 
 class NissayaTranslateService
 class NissayaTranslateService
 {
 {
-    protected OpenAIService $openAIService;
-    protected NissayaParser $nissayaParser;
-    protected RomanizeService $romanizeService;
     protected AiModelResource $model;
     protected AiModelResource $model;
+
     protected bool $romanize;
     protected bool $romanize;
 
 
+    protected bool $thinking;
+
     /**
     /**
      * 翻译提示词模板
      * 翻译提示词模板
      */
      */
-    protected string $translatePrompt = <<<PROMPT
+    protected string $translatePrompt = <<<'PROMPT'
 你是一个专业的缅甸语翻译专家。你的任务是将缅文逐词解析(Nissaya)翻译成中文。
 你是一个专业的缅甸语翻译专家。你的任务是将缅文逐词解析(Nissaya)翻译成中文。
 
 
 输入格式:
 输入格式:
@@ -51,58 +50,64 @@ class NissayaTranslateService
 PROMPT;
 PROMPT;
 
 
     public function __construct(
     public function __construct(
-        OpenAIService $openAIService,
-        NissayaParser $nissayaParser,
-        RomanizeService $romanizeService
+        protected OpenAIService $openAIService,
+        protected NissayaParser $nissayaParser,
+        protected RomanizeService $romanizeService
     ) {
     ) {
-        $this->openAIService = $openAIService;
-        $this->nissayaParser = $nissayaParser;
-        $this->romanizeService = $romanizeService;
         $this->romanize = true;
         $this->romanize = true;
     }
     }
 
 
     /**
     /**
      * 设置模型配置
      * 设置模型配置
-     *
-     * @param \App\Http\Resources\AiModelResource $model
-     * @return self
      */
      */
     public function setModel(AiModelResource $model): self
     public function setModel(AiModelResource $model): self
     {
     {
         $this->model = $model;
         $this->model = $model;
+
+        return $this;
+    }
+
+    /**
+     * 设置模型配置
+     */
+    public function setThinking(?bool $thinking): self
+    {
+        if ($thinking === null) {
+            return $this;
+        }
+        $this->thinking = $thinking;
+
         return $this;
         return $this;
     }
     }
 
 
     /**
     /**
      * 设置翻译提示词
      * 设置翻译提示词
-     *
-     * @param string $prompt
-     * @return self
      */
      */
     public function setTranslatePrompt(string $prompt): self
     public function setTranslatePrompt(string $prompt): self
     {
     {
         $this->translatePrompt = $prompt;
         $this->translatePrompt = $prompt;
+
         return $this;
         return $this;
     }
     }
 
 
     /**
     /**
      * 设置翻译提示词
      * 设置翻译提示词
      *
      *
-     * @param string $prompt
-     * @return self
+     * @param  string  $prompt
      */
      */
     public function setRomanize(bool $romanize): self
     public function setRomanize(bool $romanize): self
     {
     {
         $this->romanize = $romanize;
         $this->romanize = $romanize;
+
         return $this;
         return $this;
     }
     }
 
 
     /**
     /**
      * 翻译缅文版逐词解析
      * 翻译缅文版逐词解析
      *
      *
-     * @param string $text 格式: 巴利文=缅文
-     * @param bool $stream 是否流式输出
-     * @return array
+     * @param  string  $text  格式: 巴利文=缅文
+     * @param  bool  $stream  是否流式输出
+     *
      * @throws \Exception
      * @throws \Exception
      */
      */
     public function translate(string $text, bool $stream = false): array
     public function translate(string $text, bool $stream = false): array
@@ -143,6 +148,7 @@ PROMPT;
                 ->setSystemPrompt($this->translatePrompt)
                 ->setSystemPrompt($this->translatePrompt)
                 ->setTemperature(0.3)
                 ->setTemperature(0.3)
                 ->setStream($stream)
                 ->setStream($stream)
+                ->setThinking($this->thinking)
                 ->send($jsonlInput);
                 ->send($jsonlInput);
 
 
             $complete = time() - $startAt;
             $complete = time() - $startAt;
@@ -192,14 +198,12 @@ PROMPT;
                 $data[$key]['original'] = $this->romanizeService->myanmarToRoman($value['original']);
                 $data[$key]['original'] = $this->romanizeService->myanmarToRoman($value['original']);
             }
             }
         }
         }
+
         return $data;
         return $data;
     }
     }
 
 
     /**
     /**
      * 将数组转换为JSONL格式
      * 将数组转换为JSONL格式
-     *
-     * @param array $data
-     * @return string
      */
      */
     protected function arrayToJsonl(array $data): string
     protected function arrayToJsonl(array $data): string
     {
     {
@@ -207,14 +211,12 @@ PROMPT;
         foreach ($data as $item) {
         foreach ($data as $item) {
             $lines[] = json_encode($item, JSON_UNESCAPED_UNICODE);
             $lines[] = json_encode($item, JSON_UNESCAPED_UNICODE);
         }
         }
+
         return implode("\n", $lines);
         return implode("\n", $lines);
     }
     }
 
 
     /**
     /**
      * 将JSONL格式转换为数组
      * 将JSONL格式转换为数组
-     *
-     * @param string $jsonl
-     * @return array
      */
      */
     protected function jsonlToArray(string $jsonl): array
     protected function jsonlToArray(string $jsonl): array
     {
     {
@@ -248,9 +250,7 @@ PROMPT;
     /**
     /**
      * 批量翻译(将大文本分批处理)
      * 批量翻译(将大文本分批处理)
      *
      *
-     * @param string $text
-     * @param int $batchSize 每批处理的条目数
-     * @return array
+     * @param  int  $batchSize  每批处理的条目数
      */
      */
     public function translateInBatches(string $text, int $batchSize = 50): array
     public function translateInBatches(string $text, int $batchSize = 50): array
     {
     {
@@ -266,7 +266,7 @@ PROMPT;
             ];
             ];
 
 
             foreach ($batches as $index => $batch) {
             foreach ($batches as $index => $batch) {
-                Log::debug("NissayaTranslate: 处理批次 " . ($index + 1) . "/" . count($batches));
+                Log::debug('NissayaTranslate: 处理批次 '.($index + 1).'/'.count($batches));
 
 
                 $jsonlInput = $this->arrayToJsonl($batch);
                 $jsonlInput = $this->arrayToJsonl($batch);
                 $response = $this->openAIService
                 $response = $this->openAIService
@@ -276,6 +276,7 @@ PROMPT;
                     ->setSystemPrompt($this->translatePrompt)
                     ->setSystemPrompt($this->translatePrompt)
                     ->setTemperature(0.7)
                     ->setTemperature(0.7)
                     ->setStream(false)
                     ->setStream(false)
+                    ->setThinking($this->thinking)
                     ->send($jsonlInput);
                     ->send($jsonlInput);
 
 
                 $content = $response['choices'][0]['message']['content'] ?? '';
                 $content = $response['choices'][0]['message']['content'] ?? '';

+ 43 - 0
api-v13/app/Services/AIAssistant/PaliNissayaReferenceService.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Services\AIAssistant;
+
+use App\Models\Sentence;
+
+/**
+ * 提取指定段落的 nissaya(巴利原文逐词缅文释义),作为翻译 / 审校 / 评估的参考资料。
+ *
+ * nissaya 是缅甸传统的逐词释义:每个巴利词后给出其语法解析与缅文释义
+ * (格式形如「巴利词= 缅文释义。」),是判断词义、修饰关系、指代关系与
+ * 句子结构的权威依据。该模块只负责按段落取数,可复用于任意工作流步骤。
+ */
+class PaliNissayaReferenceService
+{
+    /**
+     * 按句子提取段落的 nissaya 原文,返回 ['id' => ..., 'content' => ...]。
+     * id 与巴利原文 / 译文一致(book-para-word_start-word_end),便于按句对应。
+     * 无 nissaya 数据时返回空数组。
+     *
+     * @return array<int, array{id: string, content: string}>
+     */
+    public function forParagraph(int $book, int $para): array
+    {
+        $sentences = Sentence::nissaya()
+            ->language('my') // 缅文 nissaya
+            ->where('book_id', $book)
+            ->where('paragraph', $para)
+            ->orderBy('word_start')
+            ->get();
+
+        $result = [];
+        foreach ($sentences as $sentence) {
+            if (empty($sentence->content)) {
+                continue;
+            }
+            $id = "{$sentence->book_id}-{$sentence->paragraph}-{$sentence->word_start}-{$sentence->word_end}";
+            $result[] = ['id' => $id, 'content' => $sentence->content];
+        }
+
+        return $result;
+    }
+}

+ 538 - 0
api-v13/app/Services/AIAssistant/PaliTranslateService.php

@@ -0,0 +1,538 @@
+<?php
+
+namespace App\Services\AIAssistant;
+
+use App\Helpers\LlmResponseParser;
+use App\Http\Resources\AiModelResource;
+use App\Models\PaliSentence;
+use App\Models\Sentence;
+use App\Services\OpenAIService;
+use App\Services\SearchPaliDataService;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 巴利原文 -> 简体中文 的多步骤翻译工作流。
+ *
+ * 支持四个步骤,可单独运行或按顺序串联:
+ * - translate:根据巴利原文产出译文
+ * - review:对已有译文打分并给出问题清单(不修改译文)
+ * - revise:根据 review 的问题清单产出改进后的译文
+ * - evaluate:质量评估,对照原文找出译文的真实问题,按级别就地用 HTML span 标注译文(颜色=严重程度,title=问题+建议),作为工作流最后一步
+ *
+ * 单独运行 review / revise / evaluate 时,已有译文从输出 channel 读取。
+ *
+ * 工作流自动提取同段落的 nissaya(巴利逐词缅文释义)注入 translate / review / evaluate
+ * 作为参考资料(见 PaliNissayaReferenceService);无 nissaya 数据的段落不受影响。
+ */
+class PaliTranslateService
+{
+    /**
+     * 可用的工作流步骤
+     */
+    public const STEPS = ['translate', 'review', 'revise', 'evaluate'];
+
+    /**
+     * 支持注入 nissaya 参考资料的步骤(revise 基于 review 意见工作,无需 nissaya)
+     */
+    public const NISSAYA_STEPS = ['translate', 'review', 'evaluate'];
+
+    protected AiModelResource $model;
+
+    protected ?bool $thinking = null;
+
+    protected bool $stream = false;
+
+    /**
+     * 输出 channel(用于单独运行 review / revise 时读取已有译文)
+     *
+     * @var array<string, mixed>
+     */
+    protected array $workChannel = [];
+
+    /**
+     * 启用 nissaya 参考资料的步骤;默认全部支持的步骤都注入
+     *
+     * @var string[]
+     */
+    protected array $nissayaSteps = self::NISSAYA_STEPS;
+
+    /**
+     * translate 步骤的提示词
+     */
+    protected string $translatePrompt = <<<'md'
+        你是一个巴利语翻译助手。
+        pali 是巴利原文的一个段落,json格式, 每条记录是一个句子。包括id 和 content 两个字段
+        请翻译这个段落为简体中文。
+
+        若用户额外提供 nissaya(巴利原文的逐词缅文释义,与 pali 通过 id 一一对应,按词列出每个巴利词的语法解析与缅文释义,形如「巴利词= 缅文释义」):它是判断词义、修饰关系、指代关系和句子结构最权威的依据,翻译时应优先参照 nissaya 确定原意,遇到歧义时以 nissaya 为准。
+
+        翻译要求
+        1. 语言风格为现代汉语书面语,不要使用古汉语或者半文半白。
+        2. 译文严谨,完全贴合巴利原文,不要加入自己的理解
+        3. 经名、人名、地名等专有名词:有约定俗成的标准译名时优先使用标准译名;没有标准译名的,尽量按词义意译;意译确有困难的再使用音译。同一专有名词在全文中译名须前后一致
+        4. 巴利原文中的黑体字在译文中也使用黑体。其他标点符号跟随巴利原文,但应该替换为相应的汉字全角符号
+
+        输出格式jsonl
+        输出id 和 content 两个字段,
+        id 使用巴利原文句子的id ,
+        content 为中文译文
+
+        直接输出jsonl数据,无需解释
+
+
+        **输出范例**
+        {"id":"1-2-3-4","content":"译文"}
+        {"id":"2-3-4-5","content":"译文"}
+        md;
+
+    /**
+     * review 步骤的提示词:对已有译文打分并指出问题,不修改译文。
+     */
+    protected string $reviewPrompt = <<<'md'
+        你是一个资深的巴利语翻译审校专家。
+        用户会提供巴利原文(pali)以及一份待审校的简体中文译文(translation),两者均为 json,通过 id 一一对应。
+        用户还可能提供 nissaya(巴利原文的逐词缅文释义,与 pali 通过 id 对应,按词列出每个巴利词的语法解析与缅文释义,形如「巴利词= 缅文释义」):它是判断词义、修饰关系、指代关系和句子结构最权威的依据,审校时应以 nissaya 为准核对译文是否贴合原意,发现译文与 nissaya 冲突的,须在 issues 中指出。
+
+        请逐句审校译文,但**不要修改译文**,只输出审校意见。
+        审校维度:
+        1. 准确性:译文是否完全贴合巴利原文,有无漏译、增译、误译
+        2. 专有名词:人名、地名、经名等专有名词的译名是否正确、是否使用约定俗成的标准译名,有无与读音相近的其他专名混淆(如把 Aṭṭhakanāgara“八城”误作 Āṭānāṭiya“阿吒曩胝”),同一专名在段落内译名是否前后一致
+        3. 语言:是否为规范的现代汉语书面语,有无古汉语或半文半白
+        4. 格式:黑体、全角标点是否符合要求
+
+        输出格式jsonl,每条记录对应一个句子,包含三个字段:
+        id:与原文相同的句子id
+        score:译文质量评分,整数 0-100
+        issues:问题清单,简明中文描述;若没有问题则输出空字符串
+
+        直接输出jsonl数据,无需解释
+
+        **输出范例**
+        {"id":"1-2-3-4","score":85,"issues":"漏译了 bhagavā;标点未使用全角"}
+        {"id":"2-3-4-5","score":100,"issues":""}
+        md;
+
+    /**
+     * revise 步骤的提示词:根据审校意见产出改进后的译文。
+     */
+    protected string $revisePrompt = <<<'md'
+        你是一个巴利语翻译助手。
+        用户会提供巴利原文(pali)、当前译文(translation)以及审校意见(review),均为 json,通过 id 一一对应。
+
+        请根据审校意见(review)修订当前译文(translation),产出改进后的译文。
+        修订要求:
+        1. 针对 review 中 issues 指出的问题进行修正
+        2. issues 为空、且 score 较高的句子可保持原译文
+        3. 语言风格为现代汉语书面语,不要使用古汉语或者半文半白
+        4. 译文严谨,完全贴合巴利原文,不要加入自己的理解
+        5. 经名、人名、地名等专有名词:有约定俗成的标准译名时优先使用标准译名;没有标准译名的,尽量按词义意译;意译确有困难的再使用音译。同一专有名词在全文中译名须前后一致
+        6. 巴利原文中的黑体字在译文中也使用黑体。其他标点符号跟随巴利原文,但应替换为相应的汉字全角符号
+
+        输出格式jsonl
+        输出id 和 content 两个字段,
+        id 使用巴利原文句子的id ,
+        content 为修订后的中文译文
+
+        直接输出jsonl数据,无需解释
+
+        **输出范例**
+        {"id":"1-2-3-4","content":"译文"}
+        {"id":"2-3-4-5","content":"译文"}
+        md;
+
+    /**
+     * evaluate 步骤的提示词:对照原文找出译文真实问题,按级别就地标注译文。
+     */
+    protected string $evaluatePrompt = <<<'md'
+        你是一位资深的巴利语译文质量检查员,精通巴利原典与注释书传统。
+        用户会提供巴利原文(pali)与对应的简体中文译文(translation),两者均为 json,通过 id 一一对应。
+        用户还可能提供 nissaya(巴利原文的逐词缅文释义,与 pali 通过 id 对应,按词列出每个巴利词的语法解析与缅文释义,形如「巴利词= 缅文释义」):它是判断词义、修饰关系、指代关系和句子结构最权威的依据,审查时应以 nissaya 为准核对译文,凡译文与 nissaya 冲突处即为真实问题,须按级别标注。
+
+        你的任务:逐句对照原文审查译文,找出其中**确实存在**的翻译问题,按严重程度分级,并把问题**就地标注在译文上**,最后输出标注后的译文。
+
+        # 问题分级(严重程度由高到低)
+        - fatal(严重错误):会让读者产生邪见或重大误解。例如主语、谓语、宾语等句子核心成分判断错误,或句意违背基本教理。
+        - error(错误,必须修改):漏译;衍译(多出原文没有的内容);词义或修饰关系译错;造成误解的表达;义理或用词与注释书不符;代词指代错误。
+        - warning(待提升):关键词有歧义却未加注释;代词指代不清;不致误解的汉语语病;标点误用;术语标记使用不当;整句逻辑表达不规范。
+        - suggestion(可提升):语言不够流畅;风格不统一;该用术语标记而未用;嵌套长句对读者不友好等仅影响阅读体验的问题。
+
+        若同一片段存在多重问题,只按其中最严重的一级标注。
+
+        # 标注方法
+        只对译文中**有问题的最小片段**,用如下 span 原地包裹(不改动译文本身的文字与黑体等格式,仅在外层套标签):
+
+        <span class="evaluate-级别" style="background:颜色" title="类别·级别:问题简述|建议:修改建议">有问题的译文片段</span>
+
+        级别与背景颜色对应(越暖代表越严重):
+        - fatal      颜色 #ffcdd2
+        - error      颜色 #ffe0b2
+        - warning    颜色 #fff9c4
+        - suggestion 颜色 #c8e6c9
+
+        title 写法:先写问题类别与级别,再用一句话说清问题是什么,最后给出具体可操作的修改建议,用「|建议:」分隔。
+
+        # 必须遵守的原则
+        1. 只标注你**确有把握**的真实问题;拿不准就不标。
+        2. 宁缺毋滥:不要为凑数而标注,不要把正确译文误标为问题,严禁过度标注。没有问题的句子,content 与原译文**一字不差**地原样返回,不加任何标签。
+        3. 标注片段尽量短,精准定位到出问题的词或短语,不要整句包裹。
+        4. 完整保留译文原有的文字与格式(黑体 ** **、全角标点等)。
+
+        # 输出格式 jsonl
+        每行对应一个句子,包含两个字段:
+        id:与原文相同的句子 id
+        content:标注后的译文(无问题则与原译文完全一致)
+
+        直接输出 jsonl 数据,无需解释。
+
+        **输出范例**
+        {"id":"1-2-3-4","content":"他于<span class=\"evaluate-error\" style=\"background:#ffe0b2\" title=\"漏译·error:原文 bhagavā 未译出|建议:补译为‘世尊’\">那时</span>住在王舍城。"}
+        {"id":"2-3-4-5","content":"完全正确的译文原样返回。"}
+        md;
+
+    public function __construct(
+        protected OpenAIService $openAIService,
+        protected SearchPaliDataService $searchPaliDataService,
+        protected PaliNissayaReferenceService $nissayaReference,
+    ) {}
+
+    /**
+     * 设置模型配置
+     */
+    public function setModel(AiModelResource $model): self
+    {
+        $this->model = $model;
+
+        return $this;
+    }
+
+    /**
+     * 设置 deepseek thinking 开关;传入 null 时保持默认(不改动)
+     */
+    public function setThinking(?bool $thinking): self
+    {
+        if ($thinking === null) {
+            return $this;
+        }
+        $this->thinking = $thinking;
+
+        return $this;
+    }
+
+    /**
+     * 设置是否流式输出
+     */
+    public function setStream(bool $stream): self
+    {
+        $this->stream = $stream;
+
+        return $this;
+    }
+
+    /**
+     * 设置输出 channel(用于单独运行 review / revise 时读取已有译文)
+     *
+     * @param  array<string, mixed>  $channel
+     */
+    public function setChannel(array $channel): self
+    {
+        $this->workChannel = $channel;
+
+        return $this;
+    }
+
+    /**
+     * 设置启用 nissaya 参考资料的步骤(可单独开关 translate / review / evaluate);
+     * 仅保留 NISSAYA_STEPS 支持的步骤,非法值自动忽略。
+     *
+     * @param  string[]  $steps
+     */
+    public function setNissayaSteps(array $steps): self
+    {
+        $this->nissayaSteps = array_values(array_intersect($steps, self::NISSAYA_STEPS));
+
+        return $this;
+    }
+
+    /**
+     * 设置 translate 步骤的提示词
+     */
+    public function setTranslatePrompt(string $prompt): self
+    {
+        $this->translatePrompt = $prompt;
+
+        return $this;
+    }
+
+    /**
+     * 设置 review 步骤的提示词
+     */
+    public function setReviewPrompt(string $prompt): self
+    {
+        $this->reviewPrompt = $prompt;
+
+        return $this;
+    }
+
+    /**
+     * 设置 revise 步骤的提示词
+     */
+    public function setRevisePrompt(string $prompt): self
+    {
+        $this->revisePrompt = $prompt;
+
+        return $this;
+    }
+
+    /**
+     * 设置 evaluate 步骤的提示词
+     */
+    public function setEvaluatePrompt(string $prompt): self
+    {
+        $this->evaluatePrompt = $prompt;
+
+        return $this;
+    }
+
+    /**
+     * 执行多步骤工作流,返回最终译文(list of ['id' => ..., 'content' => ...])。
+     *
+     * @param  string[]  $steps  translate / review / revise 的有序子集
+     * @return array<int, array{id: string, content: string}>
+     */
+    public function run(array $steps, int $book, int $para): array
+    {
+        if (! isset($this->model)) {
+            Log::error('PaliTranslate: model is invalid');
+
+            return [];
+        }
+
+        $pali = $this->getPaliContent($book, $para);
+
+        // 提取同段落的 nissaya(巴利逐词缅文释义)作为参考资料,按 nissayaSteps 注入对应步骤
+        $nissaya = $this->nissayaReference->forParagraph($book, $para);
+        Log::debug('PaliTranslate: nissaya 参考', ['count' => count($nissaya), 'steps' => $this->nissayaSteps]);
+
+        // 工作流不以 translate 开头时,从输出 channel 读取已有译文作为输入
+        $translation = in_array('translate', $steps, true)
+            ? []
+            : $this->existingTranslation($book, $para);
+
+        $review = [];
+
+        foreach ($steps as $step) {
+            switch ($step) {
+                case 'translate':
+                    $translation = $this->translate($pali, $this->nissayaFor('translate', $nissaya));
+                    break;
+                case 'review':
+                    $review = $this->review($pali, $translation, $this->nissayaFor('review', $nissaya));
+                    Log::debug('PaliTranslate: review 完成', ['review' => $review]);
+                    break;
+                case 'revise':
+                    $translation = $this->revise($pali, $translation, $review);
+                    break;
+                case 'evaluate':
+                    $translation = $this->evaluate($pali, $translation, $this->nissayaFor('evaluate', $nissaya));
+                    break;
+            }
+        }
+
+        // 只有产出译文的步骤(translate / revise / evaluate)才返回可写库的数据;
+        // evaluate 写库内容为带 HTML 标注的译文;仅 review 时报告已写入日志,无需重新保存原译文
+        $producesTranslation = (bool) array_intersect($steps, ['translate', 'revise', 'evaluate']);
+
+        return $producesTranslation ? $translation : [];
+    }
+
+    /**
+     * 提取段落的巴利原文,按句子返回 ['id' => ..., 'content' => ...]
+     *
+     * @return array<int, array{id: string, content: string}>
+     */
+    public function getPaliContent(int $book, int $para): array
+    {
+        $sentences = PaliSentence::where('book', $book)
+            ->where('paragraph', $para)
+            ->orderBy('word_begin')
+            ->get();
+
+        $json = [];
+        foreach ($sentences as $sentence) {
+            $content = $this->searchPaliDataService->getSentenceContent($book, $para, $sentence->word_begin, $sentence->word_end);
+            $id = "{$book}-{$para}-{$sentence->word_begin}-{$sentence->word_end}";
+            $json[] = ['id' => $id, 'content' => $content['markdown']];
+        }
+
+        return $json;
+    }
+
+    /**
+     * translate 步骤:根据巴利原文产出译文
+     *
+     * @param  array<int, array{id: string, content: string}>  $pali
+     * @param  array<int, array{id: string, content: string}>  $nissaya  巴利逐词缅文释义参考资料,可空
+     * @return array<int, array{id: string, content: string}>
+     */
+    public function translate(array $pali, array $nissaya = []): array
+    {
+        $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 LlmResponseParser::jsonl($content);
+    }
+
+    /**
+     * review 步骤:对已有译文打分并给出问题清单(不修改译文)
+     *
+     * @param  array<int, array{id: string, content: string}>  $pali
+     * @param  array<int, array{id: string, content: string}>  $translation
+     * @param  array<int, array{id: string, content: string}>  $nissaya  巴利逐词缅文释义参考资料,可空
+     * @return array<int, array{id: string, score: int, issues: string}>
+     */
+    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);
+        Log::debug('PaliTranslate: review', ['input' => $userText]);
+
+        $content = $this->send($this->reviewPrompt, $userText);
+        Log::debug('PaliTranslate: review', ['output' => $content]);
+
+        return LlmResponseParser::jsonl($content);
+    }
+
+    /**
+     * revise 步骤:根据审校意见产出改进后的译文
+     *
+     * @param  array<int, array{id: string, content: string}>  $pali
+     * @param  array<int, array{id: string, content: string}>  $translation
+     * @param  array<int, array{id: string, score: int, issues: string}>  $review
+     * @return array<int, array{id: string, content: string}>
+     */
+    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";
+        Log::debug('PaliTranslate: revise', ['input' => $userText]);
+
+        $content = $this->send($this->revisePrompt, $userText);
+        Log::debug('PaliTranslate: revise', ['output' => $content]);
+
+        return LlmResponseParser::jsonl($content);
+    }
+
+    /**
+     * evaluate 步骤:对照原文找出译文真实问题,按级别就地用 HTML span 标注译文。
+     * 返回标注后的译文(无问题的句子原样返回),作为工作流最后一步写库。
+     *
+     * @param  array<int, array{id: string, content: string}>  $pali
+     * @param  array<int, array{id: string, content: string}>  $translation
+     * @param  array<int, array{id: string, content: string}>  $nissaya  巴利逐词缅文释义参考资料,可空
+     * @return array<int, array{id: string, content: string}>
+     */
+    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);
+        Log::debug('PaliTranslate: evaluate', ['input' => $userText]);
+
+        $content = $this->send($this->evaluatePrompt, $userText);
+        Log::debug('PaliTranslate: evaluate', ['output' => $content]);
+
+        return LlmResponseParser::jsonl($content);
+    }
+
+    /**
+     * 从输出 channel 读取已有译文,按句子返回 ['id' => ..., 'content' => ...]
+     *
+     * @return array<int, array{id: string, content: string}>
+     */
+    protected function existingTranslation(int $book, int $para): array
+    {
+        $channelId = $this->workChannel['id'] ?? null;
+        if (! $channelId) {
+            Log::warning('PaliTranslate: 未设置输出 channel,无法读取已有译文');
+
+            return [];
+        }
+
+        $sentences = Sentence::where('channel_uid', $channelId)
+            ->where('book_id', $book)
+            ->where('paragraph', $para)
+            ->orderBy('word_start')
+            ->get();
+
+        $result = [];
+        foreach ($sentences as $sentence) {
+            $id = "{$sentence->book_id}-{$sentence->paragraph}-{$sentence->word_start}-{$sentence->word_end}";
+            $result[] = ['id' => $id, 'content' => $sentence->content];
+        }
+
+        return $result;
+    }
+
+    /**
+     * 调用 LLM,返回响应文本
+     */
+    protected function send(string $systemPrompt, string $userText): string
+    {
+        $startAt = time();
+        $response = $this->openAIService
+            ->setApiUrl($this->model['url'])
+            ->setModel($this->model['model'])
+            ->setApiKey($this->model['key'])
+            ->setSystemPrompt($systemPrompt)
+            ->setTemperature(0.0)
+            ->setThinking($this->thinking)
+            ->setStream($this->stream)
+            ->send($userText);
+        $complete = time() - $startAt;
+
+        $content = $response['choices'][0]['message']['content'] ?? '[]';
+        Log::debug("PaliTranslate: complete in {$complete}s", ['content' => $content]);
+
+        return is_string($content) ? $content : '[]';
+    }
+
+    /**
+     * 按开关返回某步骤应使用的 nissaya;未启用该步骤时返回空数组。
+     *
+     * @param  array<int, array{id: string, content: string}>  $nissaya
+     * @return array<int, array{id: string, content: string}>
+     */
+    protected function nissayaFor(string $step, array $nissaya): array
+    {
+        return in_array($step, $this->nissayaSteps, true) ? $nissaya : [];
+    }
+
+    /**
+     * 构造 nissaya 参考资料区块;无数据时返回空串(不污染提示词)。
+     * 与 pali / translation 一致使用 [{id, content}] json,便于模型按 id 对应句子。
+     *
+     * @param  array<int, array{id: string, content: string}>  $nissaya
+     */
+    protected function nissayaSection(array $nissaya): string
+    {
+        if (empty($nissaya)) {
+            return '';
+        }
+
+        return "# nissaya\n\n".$this->jsonBlock($nissaya)."\n\n";
+    }
+
+    /**
+     * 将数组包裹为 ```json ... ``` 代码块
+     *
+     * @param  array<int, mixed>  $data
+     */
+    protected function jsonBlock(array $data): string
+    {
+        return "```json\n".json_encode($data, JSON_UNESCAPED_UNICODE)."\n```";
+    }
+}

+ 36 - 14
api-v13/app/Services/OpenAIService.php

@@ -2,76 +2,92 @@
 
 
 namespace App\Services;
 namespace App\Services;
 
 
+use Illuminate\Http\Client\ConnectionException;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Log;
-use Illuminate\Http\Client\ConnectionException;
-use Illuminate\Http\Client\RequestException;
 
 
 class OpenAIService
 class OpenAIService
 {
 {
     protected int $retries = 3;
     protected int $retries = 3;
+
     protected int $delayMs = 2000;
     protected int $delayMs = 2000;
+
     protected string $model = 'gpt-4-1106-preview';
     protected string $model = 'gpt-4-1106-preview';
+
     protected string $apiUrl = 'https://api.openai.com/v1/chat/completions';
     protected string $apiUrl = 'https://api.openai.com/v1/chat/completions';
+
     protected string $apiKey;
     protected string $apiKey;
+
     protected string $systemPrompt = '你是一个有帮助的助手。';
     protected string $systemPrompt = '你是一个有帮助的助手。';
+
     protected float $temperature = 0.7;
     protected float $temperature = 0.7;
+
     protected bool $stream = false;
     protected bool $stream = false;
+
     protected int $timeout = 600;
     protected int $timeout = 600;
+
     protected int $maxTokens = 0;
     protected int $maxTokens = 0;
+
     protected bool $thinking;
     protected bool $thinking;
 
 
     public static function withRetry(int $retries = 3, int $delayMs = 2000): static
     public static function withRetry(int $retries = 3, int $delayMs = 2000): static
     {
     {
-        return (new static())->setRetry($retries, $delayMs);
+        return (new static)->setRetry($retries, $delayMs);
     }
     }
 
 
     public function setRetry(int $retries, int $delayMs): static
     public function setRetry(int $retries, int $delayMs): static
     {
     {
         $this->retries = $retries;
         $this->retries = $retries;
         $this->delayMs = $delayMs;
         $this->delayMs = $delayMs;
+
         return $this;
         return $this;
     }
     }
 
 
     public function setModel(string $model): static
     public function setModel(string $model): static
     {
     {
         $this->model = $model;
         $this->model = $model;
+
         return $this;
         return $this;
     }
     }
 
 
     /**
     /**
      * 设置模型配置
      * 设置模型配置
-     *
-     * @param bool $thinking
-     * @return self
      */
      */
-    public function setThinking(bool $thinking): self
+    public function setThinking(?bool $thinking): self
     {
     {
+        if ($thinking === null) {
+            return $this;
+        }
         $this->thinking = $thinking;
         $this->thinking = $thinking;
+
         return $this;
         return $this;
     }
     }
 
 
     public function setApiUrl(string $url): static
     public function setApiUrl(string $url): static
     {
     {
         $this->apiUrl = $url;
         $this->apiUrl = $url;
+
         return $this;
         return $this;
     }
     }
 
 
     public function setApiKey(string $key): static
     public function setApiKey(string $key): static
     {
     {
         $this->apiKey = $key;
         $this->apiKey = $key;
+
         return $this;
         return $this;
     }
     }
 
 
     public function setSystemPrompt(string $prompt): static
     public function setSystemPrompt(string $prompt): static
     {
     {
         $this->systemPrompt = $prompt;
         $this->systemPrompt = $prompt;
+
         return $this;
         return $this;
     }
     }
 
 
     public function setTemperature(float $temperature): static
     public function setTemperature(float $temperature): static
     {
     {
         $this->temperature = $temperature;
         $this->temperature = $temperature;
+
         return $this;
         return $this;
     }
     }
 
 
@@ -90,6 +106,7 @@ class OpenAIService
     public function setMaxToken(int $maxTokens): static
     public function setMaxToken(int $maxTokens): static
     {
     {
         $this->maxTokens = $maxTokens;
         $this->maxTokens = $maxTokens;
+
         return $this;
         return $this;
     }
     }
 
 
@@ -113,6 +130,7 @@ class OpenAIService
                 Log::warning("请求被限流(429),等待 {$retryAfter} 秒后重试...(第 {$attempt} 次)");
                 Log::warning("请求被限流(429),等待 {$retryAfter} 秒后重试...(第 {$attempt} 次)");
                 sleep($retryAfter);
                 sleep($retryAfter);
                 $lastException = $e;
                 $lastException = $e;
+
                 continue;
                 continue;
             } catch (ServerErrorException $e) {
             } catch (ServerErrorException $e) {
                 // 5xx 服务器错误,使用指数退避重试
                 // 5xx 服务器错误,使用指数退避重试
@@ -121,6 +139,7 @@ class OpenAIService
                     usleep($this->delayMs * 1000 * pow(2, $attempt - 1));
                     usleep($this->delayMs * 1000 * pow(2, $attempt - 1));
                 }
                 }
                 $lastException = $e;
                 $lastException = $e;
+
                 continue;
                 continue;
             } catch (ConnectionException $e) {
             } catch (ConnectionException $e) {
                 // 网络连接错误,使用指数退避重试
                 // 网络连接错误,使用指数退避重试
@@ -129,6 +148,7 @@ class OpenAIService
                     usleep($this->delayMs * 1000 * pow(2, $attempt - 1));
                     usleep($this->delayMs * 1000 * pow(2, $attempt - 1));
                 }
                 }
                 $lastException = $e;
                 $lastException = $e;
+
                 continue;
                 continue;
             } catch (NetworkException $e) {
             } catch (NetworkException $e) {
                 // 其他网络错误,使用指数退避重试
                 // 其他网络错误,使用指数退避重试
@@ -137,6 +157,7 @@ class OpenAIService
                     usleep($this->delayMs * 1000 * pow(2, $attempt - 1));
                     usleep($this->delayMs * 1000 * pow(2, $attempt - 1));
                 }
                 }
                 $lastException = $e;
                 $lastException = $e;
+
                 continue;
                 continue;
             } catch (ClientErrorException $e) {
             } catch (ClientErrorException $e) {
                 // 4xx 客户端错误(除429外)不重试,直接抛出
                 // 4xx 客户端错误(除429外)不重试,直接抛出
@@ -144,7 +165,7 @@ class OpenAIService
                 throw $e;
                 throw $e;
             } catch (\Exception $e) {
             } catch (\Exception $e) {
                 // 其他未知异常,不重试,直接抛出
                 // 其他未知异常,不重试,直接抛出
-                Log::error("GPT 请求异常:" . $e->getMessage());
+                Log::error('GPT 请求异常:'.$e->getMessage());
                 throw $e;
                 throw $e;
             }
             }
         }
         }
@@ -152,7 +173,7 @@ class OpenAIService
         // 所有重试都失败了
         // 所有重试都失败了
         Log::error("请求多次失败,已重试 {$this->retries} 次");
         Log::error("请求多次失败,已重试 {$this->retries} 次");
         throw new \RuntimeException(
         throw new \RuntimeException(
-            '请求多次失败或超时,请稍后再试。原因: ' . ($lastException ? $lastException->getMessage() : '未知'),
+            '请求多次失败或超时,请稍后再试。原因: '.($lastException ? $lastException->getMessage() : '未知'),
             504,
             504,
             $lastException
             $lastException
         );
         );
@@ -191,7 +212,7 @@ class OpenAIService
 
 
         // 处理 429 速率限制
         // 处理 429 速率限制
         if ($status === 429) {
         if ($status === 429) {
-            $retryAfter = (int)($response->header('Retry-After') ?? 20);
+            $retryAfter = (int) ($response->header('Retry-After') ?? 20);
             throw new RateLimitException(
             throw new RateLimitException(
                 $body['error']['message'] ?? '请求被限流',
                 $body['error']['message'] ?? '请求被限流',
                 $status,
                 $status,
@@ -255,8 +276,8 @@ class OpenAIService
             CURLOPT_POST => true,
             CURLOPT_POST => true,
             CURLOPT_HTTPHEADER => [
             CURLOPT_HTTPHEADER => [
                 "Authorization: Bearer {$this->apiKey}",
                 "Authorization: Bearer {$this->apiKey}",
-                "Content-Type: application/json",
-                "Accept: text/event-stream",
+                'Content-Type: application/json',
+                'Accept: text/event-stream',
             ],
             ],
             CURLOPT_POSTFIELDS => json_encode($payload),
             CURLOPT_POSTFIELDS => json_encode($payload),
             CURLOPT_RETURNTRANSFER => false,
             CURLOPT_RETURNTRANSFER => false,
@@ -269,7 +290,7 @@ class OpenAIService
                 foreach ($lines as $line) {
                 foreach ($lines as $line) {
                     $line = trim($line);
                     $line = trim($line);
 
 
-                    if (!str_starts_with($line, 'data: ')) {
+                    if (! str_starts_with($line, 'data: ')) {
                         continue;
                         continue;
                     }
                     }
 
 
@@ -280,13 +301,14 @@ class OpenAIService
                     }
                     }
 
 
                     $obj = json_decode($json, true);
                     $obj = json_decode($json, true);
-                    if (!is_array($obj)) {
+                    if (! is_array($obj)) {
                         continue;
                         continue;
                     }
                     }
 
 
                     // 检查是否有错误
                     // 检查是否有错误
                     if (isset($obj['error'])) {
                     if (isset($obj['error'])) {
                         $errorMessage = $obj['error']['message'] ?? 'Stream error';
                         $errorMessage = $obj['error']['message'] ?? 'Stream error';
+
                         return 0; // 停止接收
                         return 0; // 停止接收
                     }
                     }
 
 

+ 2 - 2
api-v13/documents/ai-test.md

@@ -5,11 +5,11 @@
 | 1      | 三藏全文搜索      | ✅       | ✅       | ✅     | ✅     | ✅   |
 | 1      | 三藏全文搜索      | ✅       | ✅       | ✅     | ✅     | ✅   |
 | 2      | 百科全文搜索      | ✅       |          |        |        |      |
 | 2      | 百科全文搜索      | ✅       |          |        |        |      |
 | 3      | 注疏穿插          | ✅       | ✅       | ✅     | ✅     |      |
 | 3      | 注疏穿插          | ✅       | ✅       | ✅     | ✅     |      |
-| 4      | 汉译 nissaya      | ✅       | ✅       | ✅     |        |      |
+| 4      | 汉译 nissaya      | ✅       | ✅       | ✅     |        |      |
 | 5      | ai 译文(deepseek) | ✅       |          |        |        |      |
 | 5      | ai 译文(deepseek) | ✅       |          |        |        |      |
 | 6      | ai 译文(claude)   |          |          |        |        |      |
 | 6      | ai 译文(claude)   |          |          |        |        |      |
 | 7      | 第三方译文导入    | ✅       |          |        |        |      |
 | 7      | 第三方译文导入    | ✅       |          |        |        |      |
-| 8      | 五大册-AI 汉译    | ✅       |          |        |        |      |
+| 8      | 五大册-AI 汉译    | ✅       |          |        |        |      |
 | 9      | AI 百科           | ✅       |          |        |        |      |
 | 9      | AI 百科           | ✅       |          |        |        |      |
 | 10     | AI wbw            |          |          |        |        |      |
 | 10     | AI wbw            |          |          |        |        |      |
 
 

+ 31 - 0
api-v13/documents/translation-evaluate.md

@@ -0,0 +1,31 @@
+# 问题分级
+
+- 第一类 严重错误 fatal(零容忍;增长普通用户邪见、降低普通用户对译文的评价)
+    1. 主谓(含非谓语动词)宾有一项判断错误
+    2. 句子意思违背基本教理原则
+
+- 第二类 错误 error(专家有举必究;只要发现,一定要改、只有巴利专家能发现)
+    1. 漏译(例句:32**两**糖块的体积)
+    2. 错误多译
+    3. 错译(词义,修饰关系)
+    4. 导致误解的表达
+    5. 义理或用词与注释书不符
+    6. 代词指代错误
+
+- 第三类 待提升 warning
+    1. 关键词语意不明确或二意场合没有注释(稣息、转起)
+    2. 代词指代不明确
+    3. 不导致误解的汉语语病
+    4. 标点符号使用错误
+    5. 不该使用术语标记时使用了术语标记
+    6. 整句逻辑表达不规范
+
+- 第四类 可提升 suggestion
+    1. 语言表达不够流畅
+    2. 代词指代可能不够明确
+    3. 语言风格不统一
+    4. 该使用而没有使用术语标记
+    5. 不常用术语编写注释或者百科
+    6. 复杂的嵌套句整句语言逻辑理解困难(对读者不友好)
+
+第三类是出版社编辑提出的,第四类是由有佛教背景的读者提出的

+ 2 - 1
dashboard-v6/src/components/article/TypePali.tsx

@@ -27,6 +27,7 @@ import { TaskBuilderChapterModal } from "../task/TaskBuilderChapterModal";
 import type { TTarget } from "../../types";
 import type { TTarget } from "../../types";
 import TocPath from "../tipitaka/TocPath";
 import TocPath from "../tipitaka/TocPath";
 import ParagraphNode from "../tipitaka/ParagraphNode";
 import ParagraphNode from "../tipitaka/ParagraphNode";
+import "./article.css";
 
 
 export interface ISearchParams {
 export interface ISearchParams {
   key: string;
   key: string;
@@ -140,7 +141,7 @@ const TypePali = ({
   };
   };
 
 
   return (
   return (
-    <div>
+    <div className="pcd_article">
       <TaskBuilderChapterModal
       <TaskBuilderChapterModal
         studioName={user?.realName}
         studioName={user?.realName}
         book={parseInt(mBook ?? "0")}
         book={parseInt(mBook ?? "0")}

+ 13 - 0
dashboard-v6/src/components/article/article.css

@@ -171,3 +171,16 @@
 .video-js video {
 .video-js video {
   max-width: 100%;
   max-width: 100%;
 }
 }
+
+.pcd_article .evaluate-fatal{
+  background-color: #ffcdd2;
+}
+.pcd_article .evaluate-error{
+  background-color: #ffe0b2;
+}
+.pcd_article .evaluate-warning{
+  background-color: #fff9c4;
+}
+.pcd_article .evaluate-suggestion{
+  background-color: #c8e6c9;
+}