|
@@ -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```";
|
|
|
|
|
+ }
|
|
|
|
|
+}
|