PaliTranslateService.php 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632
  1. <?php
  2. namespace App\Services\AIAssistant;
  3. use App\Helpers\LlmResponseParser;
  4. use App\Http\Api\ChannelApi;
  5. use App\Http\Resources\AiModelResource;
  6. use App\Models\PaliSentence;
  7. use App\Models\PaliText;
  8. use App\Models\Sentence;
  9. use App\Services\OpenAIService;
  10. use App\Services\SearchPaliDataService;
  11. use Illuminate\Support\Facades\Log;
  12. /**
  13. * 巴利原文 -> 简体中文 的多步骤翻译工作流。
  14. *
  15. * 支持四个步骤,可单独运行或按顺序串联:
  16. * - translate:根据巴利原文产出译文
  17. * - review:对已有译文打分并给出问题清单(不修改译文)
  18. * - revise:根据 review 的问题清单产出改进后的译文
  19. * - evaluate:质量评估,对照原文找出译文的真实问题,按级别就地用 HTML span 标注译文(颜色=严重程度,title=问题+建议),作为工作流最后一步
  20. *
  21. * 单独运行 review / revise / evaluate 时,已有译文从输出 channel 读取。
  22. *
  23. * 工作流自动提取同段落的 nissaya(巴利逐词缅文释义)注入 translate / review / evaluate
  24. * 作为参考资料(见 PaliNissayaReferenceService);无 nissaya 数据的段落不受影响。
  25. */
  26. class PaliTranslateService
  27. {
  28. /**
  29. * 可用的工作流步骤
  30. */
  31. public const STEPS = ['translate', 'review', 'revise', 'evaluate'];
  32. /**
  33. * 支持注入 nissaya 参考资料的步骤(revise 基于 review 意见工作,无需 nissaya)
  34. */
  35. public const NISSAYA_STEPS = ['translate', 'review', 'evaluate'];
  36. protected AiModelResource $model;
  37. protected ?bool $thinking = null;
  38. protected bool $stream = false;
  39. /**
  40. * 输出 channel(用于单独运行 review / revise 时读取已有译文)
  41. *
  42. * @var array<string, mixed>
  43. */
  44. protected array $workChannel = [];
  45. /**
  46. * 启用 nissaya 参考资料的步骤;默认全部支持的步骤都注入
  47. *
  48. * @var string[]
  49. */
  50. protected array $nissayaSteps = self::NISSAYA_STEPS;
  51. /**
  52. * translate 步骤的提示词
  53. */
  54. protected string $translatePrompt = <<<'md'
  55. 你是一个巴利语翻译助手。
  56. pali 是巴利原文的一个段落,json格式, 每条记录是一个句子。包括id 和 content 两个字段
  57. 请翻译这个段落为简体中文。
  58. 若用户额外提供 nissaya(巴利原文的逐词缅文释义,与 pali 通过 id 一一对应,按词列出每个巴利词的语法解析与缅文释义,形如「巴利词= 缅文释义」):它是判断词义、修饰关系、指代关系和句子结构最权威的依据,翻译时应优先参照 nissaya 确定原意,遇到歧义时以 nissaya 为准。
  59. 若用户额外提供 glossary(巴利语术语表,JSON 字符串数组,每个元素是一个巴利语术语原形):翻译时请在巴利原文中查找这些术语。凡巴利原文中出现了术语表中的词(包括其屈折变化、复合等形式),该词在译文相应位置**不要译为中文**,而是原样输出为 `[[术语原形]]`——即用术语表中给出的那个原形,外加双方括号包裹;其余内容照常翻译为中文。例如原文出现 rājagahe 且术语表含 rājagaha,则译文写作「住在[[rājagaha]]」而非「住在王舍城」。术语表中没有、或原文中并未出现的词,一律按正常翻译处理,不要添加双方括号。
  60. 翻译要求
  61. 1. 语言风格为现代汉语,**绝对不要**使用古汉语或者半文半白。**不要参考**阿含经和元亨寺语言风格。
  62. 2. 译文严谨,完全贴合巴利原文,不要加入自己的理解
  63. 3. 经名、人名、地名等专有名词:有约定俗成的标准译名时优先使用标准译名;没有标准译名的,尽量按词义意译;意译确有困难的再使用音译。同一专有名词在全文中译名须前后一致
  64. 4. 巴利原文中的黑体字在译文中也使用黑体。其他标点符号跟随巴利原文,但应该替换为相应的汉字全角符号
  65. 输出格式jsonl
  66. 输出id 和 content 两个字段,
  67. id 使用巴利原文句子的id ,
  68. content 为中文译文
  69. 直接输出jsonl数据,无需解释
  70. **输出范例**
  71. {"id":"1-2-3-4","content":"译文"}
  72. {"id":"2-3-4-5","content":"译文"}
  73. md;
  74. /**
  75. * review 步骤的提示词:对已有译文打分并指出问题,不修改译文。
  76. */
  77. protected string $reviewPrompt = <<<'md'
  78. 你是一个资深的巴利语翻译审校专家。
  79. 用户会提供巴利原文(pali)以及一份待审校的简体中文译文(translation),两者均为 json,通过 id 一一对应。
  80. 用户还可能提供 nissaya(巴利原文的逐词缅文释义,与 pali 通过 id 对应,按词列出每个巴利词的语法解析与缅文释义,形如「巴利词= 缅文释义」):它是判断词义、修饰关系、指代关系和句子结构最权威的依据,审校时应以 nissaya 为准核对译文是否贴合原意,发现译文与 nissaya 冲突的,须在 issues 中指出。
  81. 译文中可能出现 `[[巴利术语]]`(双方括号包裹巴利语原词)形式的标记:这是**有意保留**的术语标记,表示该词按术语表以巴利原形呈现、刻意不译为中文,属于正确处理。审校时**完全忽略**这些 `[[...]]` 标记,**不要**把它当作漏译、未翻译、误译或格式问题,不要因此扣分,也不要在 issues 中提及;只需正常审校其余中文译文。
  82. 请逐句审校译文,但**不要修改译文**,只输出审校意见。
  83. 审校维度:
  84. 1. 准确性:译文是否完全贴合巴利原文,有无漏译、增译、误译
  85. 2. 专有名词:人名、地名、经名等专有名词的译名是否正确、是否使用约定俗成的标准译名,有无与读音相近的其他专名混淆(如把 Aṭṭhakanāgara“八城”误作 Āṭānāṭiya“阿吒曩胝”),同一专名在段落内译名是否前后一致
  86. 3. 语言:是否为规范的现代汉语书面语,有无古汉语或半文半白
  87. 4. 格式:黑体、全角标点是否符合要求
  88. 输出格式jsonl,每条记录对应一个句子,包含三个字段:
  89. id:与原文相同的句子id
  90. score:译文质量评分,整数 0-100
  91. issues:问题清单,简明中文描述;若没有问题则输出空字符串
  92. 直接输出jsonl数据,无需解释
  93. **输出范例**
  94. {"id":"1-2-3-4","score":85,"issues":"漏译了 bhagavā;标点未使用全角"}
  95. {"id":"2-3-4-5","score":100,"issues":""}
  96. md;
  97. /**
  98. * revise 步骤的提示词:根据审校意见产出改进后的译文。
  99. */
  100. protected string $revisePrompt = <<<'md'
  101. 你是一个巴利语翻译助手。
  102. 用户会提供巴利原文(pali)、当前译文(translation)以及审校意见(review),均为 json,通过 id 一一对应。
  103. 请根据审校意见(review)修订当前译文(translation),产出改进后的译文。
  104. 修订要求:
  105. 1. 针对 review 中 issues 指出的问题进行修正
  106. 2. issues 为空、且 score 较高的句子可保持原译文
  107. 3. 语言风格为现代汉语书面语,不要使用古汉语或者半文半白
  108. 4. 译文严谨,完全贴合巴利原文,不要加入自己的理解
  109. 5. 经名、人名、地名等专有名词:有约定俗成的标准译名时优先使用标准译名;没有标准译名的,尽量按词义意译;意译确有困难的再使用音译。同一专有名词在全文中译名须前后一致
  110. 6. 巴利原文中的黑体字在译文中也使用黑体。其他标点符号跟随巴利原文,但应替换为相应的汉字全角符号
  111. 输出格式jsonl
  112. 输出id 和 content 两个字段,
  113. id 使用巴利原文句子的id ,
  114. content 为修订后的中文译文
  115. 直接输出jsonl数据,无需解释
  116. **输出范例**
  117. {"id":"1-2-3-4","content":"译文"}
  118. {"id":"2-3-4-5","content":"译文"}
  119. md;
  120. /**
  121. * evaluate 步骤的提示词:对照原文找出译文真实问题,按级别就地标注译文。
  122. */
  123. protected string $evaluatePrompt = <<<'md'
  124. 你是一位资深的巴利语译文质量检查员,精通巴利原典与注释书传统。
  125. 用户会提供巴利原文(pali)与对应的简体中文译文(translation),两者均为 json,通过 id 一一对应。
  126. 用户还可能提供 nissaya(巴利原文的逐词缅文释义,与 pali 通过 id 对应,按词列出每个巴利词的语法解析与缅文释义,形如「巴利词= 缅文释义」):它是判断词义、修饰关系、指代关系和句子结构最权威的依据,审查时应以 nissaya 为准核对译文,凡译文与 nissaya 冲突处即为真实问题,须按级别标注。
  127. 你的任务:逐句对照原文审查译文,找出其中**确实存在**的翻译问题,按严重程度分级,并把问题**就地标注在译文上**,最后输出标注后的译文。
  128. # 问题分级与类型
  129. 问题分为四个**级别**(severity),每个级别下又分若干**类型**(type)。请先判断片段属于哪个级别,再从该级别下选出最贴切的类型名。**级别从高到低,标注时只取最严重的一级。**
  130. - fatal(严重错误):会让读者对经文意思有严重误解。类型:
  131. - 严重失真:主、谓(含非谓语动词)、宾中有一项判断错误,导致句子意思严重失真
  132. - 教理违背:句子意思违背基本教理原则
  133. - error(错误,有举必究,必须修改):类型:
  134. - 漏译:原文中有的内容在译文中缺失
  135. - 多译:译文中增加了原文没有的内容
  136. - 词义误译:词语的意思翻译错误
  137. - 修饰错误:修饰关系判断错误
  138. - 误解表达:表达方式会导致读者误解
  139. - 义理不符:义理与注释书不符
  140. - 用词不符:用词与注释书不符
  141. - 指代错误:代词指代的对象错误
  142. - warning(待提升):类型:
  143. - 语意不明:关键词语意不明确
  144. - 缺少注释:二意场合没有添加注释
  145. - 指代不明:代词指代不够明确
  146. - 汉语语病:不导致误解的汉语语病
  147. - 标点错误:标点符号使用错误
  148. - 误用标记:不该使用术语标记时使用了术语标记
  149. - 逻辑不规范:整句逻辑表达不规范
  150. - suggestion(可提升,仅影响阅读体验):类型:
  151. - 表达晦涩:语言表达不够流畅
  152. - 代词指代:代词指代不够明确
  153. - 风格统一:语言风格不统一
  154. - 术语标记:该使用而没有使用术语标记
  155. - 缺少注释:不常用术语需要编写注释或者百科
  156. - 句式复杂:复杂的嵌套句整句语言逻辑理解困难(对读者不友好)
  157. 若同一句子存在多重问题,只按其中最严重的一级标注,并选用该级别下最贴切的类型名。
  158. # 标注方法
  159. 只对译文中**有问题的最小片段**,用如下 span 原地包裹(不改动译文本身的文字与黑体等格式,仅在外层套标签):
  160. <span class='evaluate evaluate-级别' title='类型·级别:问题简述|建议:修改建议'>有问题的译文片段</span>
  161. 其中 class 里的「级别」与 title 里的「级别」都必须是 fatal / error / warning / suggestion 之一;title 里的「类型」必须是上面对应级别下列出的类型名(例如 语意不明、漏译、严重失真)。**级别在前判定,类型在该级别内挑选**,不要张冠李戴(例如 语意不明 只能配 warning)。
  162. **span 的属性一律用单引号**(class='...' title='...'),不要用双引号——因为 content 整体是 JSON 字符串、本身由双引号包裹,属性再用双引号极易因转义出错导致整行 JSON 解析失败、整句被丢弃。title 等属性值内若要引用文字,请使用中文全角引号「」或‘’,**严禁**出现 ASCII 双引号(")或单引号(')。
  163. title 写法:先写「类型·级别」,再用一句话说清问题是什么,最后用「|建议:」给出具体可操作的修改建议。
  164. # 必须遵守的原则
  165. 1. 只标注你**确有把握**的真实问题;拿不准就不标。
  166. 2. 宁缺毋滥:不要为凑数而标注,不要把正确译文误标为问题,严禁过度标注。没有问题的句子,content 与原译文**一字不差**地原样返回,不加任何标签。
  167. 3. 标注片段尽量短,精准定位到出问题的词或短语,不要整句包裹。
  168. 4. 完整保留译文原有的文字与格式(黑体 ** **、全角标点等)。
  169. # 输出格式 jsonl
  170. 每行对应一个句子,包含两个字段:
  171. id:与原文相同的句子 id
  172. content:标注后的译文(无问题则与原译文完全一致)
  173. 直接输出 jsonl 数据,无需解释。
  174. **输出范例**(注意 span 属性用单引号,整行是合法 JSON)
  175. {"id":"1-2-3-4","content":"他于<span class='evaluate evaluate-error' title='漏译·error:原文 bhagavā 未译出|建议:补译为‘世尊’'>那时</span>住在王舍城。"}
  176. {"id":"2-3-4-5","content":"完全正确的译文原样返回。"}
  177. md;
  178. public function __construct(
  179. protected OpenAIService $openAIService,
  180. protected SearchPaliDataService $searchPaliDataService,
  181. protected PaliNissayaReferenceService $nissayaReference,
  182. ) {}
  183. /**
  184. * 设置模型配置
  185. */
  186. public function setModel(AiModelResource $model): self
  187. {
  188. $this->model = $model;
  189. return $this;
  190. }
  191. /**
  192. * 设置 deepseek thinking 开关;传入 null 时保持默认(不改动)
  193. */
  194. public function setThinking(?bool $thinking): self
  195. {
  196. if ($thinking === null) {
  197. return $this;
  198. }
  199. $this->thinking = $thinking;
  200. return $this;
  201. }
  202. /**
  203. * 设置是否流式输出
  204. */
  205. public function setStream(bool $stream): self
  206. {
  207. $this->stream = $stream;
  208. return $this;
  209. }
  210. /**
  211. * 设置输出 channel(用于单独运行 review / revise 时读取已有译文)
  212. *
  213. * @param array<string, mixed> $channel
  214. */
  215. public function setChannel(array $channel): self
  216. {
  217. $this->workChannel = $channel;
  218. return $this;
  219. }
  220. /**
  221. * 设置启用 nissaya 参考资料的步骤(可单独开关 translate / review / evaluate);
  222. * 仅保留 NISSAYA_STEPS 支持的步骤,非法值自动忽略。
  223. *
  224. * @param string[] $steps
  225. */
  226. public function setNissayaSteps(array $steps): self
  227. {
  228. $this->nissayaSteps = array_values(array_intersect($steps, self::NISSAYA_STEPS));
  229. return $this;
  230. }
  231. /**
  232. * 设置 translate 步骤的提示词
  233. */
  234. public function setTranslatePrompt(string $prompt): self
  235. {
  236. $this->translatePrompt = $prompt;
  237. return $this;
  238. }
  239. /**
  240. * 设置 review 步骤的提示词
  241. */
  242. public function setReviewPrompt(string $prompt): self
  243. {
  244. $this->reviewPrompt = $prompt;
  245. return $this;
  246. }
  247. /**
  248. * 设置 revise 步骤的提示词
  249. */
  250. public function setRevisePrompt(string $prompt): self
  251. {
  252. $this->revisePrompt = $prompt;
  253. return $this;
  254. }
  255. /**
  256. * 设置 evaluate 步骤的提示词
  257. */
  258. public function setEvaluatePrompt(string $prompt): self
  259. {
  260. $this->evaluatePrompt = $prompt;
  261. return $this;
  262. }
  263. /**
  264. * 执行多步骤工作流,返回最终译文(list of ['id' => ..., 'content' => ...])。
  265. *
  266. * @param string[] $steps translate / review / revise 的有序子集
  267. * @return array<int, array{id: string, content: string}>
  268. */
  269. public function run(array $steps, int $book, int $para): array
  270. {
  271. if (! isset($this->model)) {
  272. Log::error('PaliTranslate: model is invalid');
  273. return [];
  274. }
  275. $pali = $this->getPaliContent($book, $para);
  276. // 提取同段落的 nissaya(巴利逐词缅文释义)作为参考资料,按 nissayaSteps 注入对应步骤
  277. $nissaya = $this->nissayaReference->forParagraph($book, $para);
  278. Log::debug('PaliTranslate: nissaya 参考', ['count' => count($nissaya), 'steps' => $this->nissayaSteps]);
  279. // 工作流不以 translate 开头时,从输出 channel 读取已有译文作为输入
  280. $translation = in_array('translate', $steps, true)
  281. ? []
  282. : $this->existingTranslation($book, $para);
  283. $review = [];
  284. foreach ($steps as $step) {
  285. switch ($step) {
  286. case 'translate':
  287. // 加载本段所属 chapter 的巴利术语表;命中术语时译文输出 [[术语]] 而非中文
  288. $glossary = $this->loadGlossary($book, $para);
  289. Log::debug('PaliTranslate: glossary 术语表', ['count' => count($glossary)]);
  290. $translation = $this->translate($pali, $this->nissayaFor('translate', $nissaya), $glossary);
  291. break;
  292. case 'review':
  293. $review = $this->review($pali, $translation, $this->nissayaFor('review', $nissaya));
  294. Log::debug('PaliTranslate: review 完成', ['review' => $review]);
  295. break;
  296. case 'revise':
  297. $translation = $this->revise($pali, $translation, $review);
  298. break;
  299. case 'evaluate':
  300. $translation = $this->evaluate($pali, $translation, $this->nissayaFor('evaluate', $nissaya));
  301. break;
  302. }
  303. }
  304. // 只有产出译文的步骤(translate / revise / evaluate)才返回可写库的数据;
  305. // evaluate 写库内容为带 HTML 标注的译文;仅 review 时报告已写入日志,无需重新保存原译文
  306. $producesTranslation = (bool) array_intersect($steps, ['translate', 'revise', 'evaluate']);
  307. return $producesTranslation ? $translation : [];
  308. }
  309. /**
  310. * 提取段落的巴利原文,按句子返回 ['id' => ..., 'content' => ...]
  311. *
  312. * @return array<int, array{id: string, content: string}>
  313. */
  314. public function getPaliContent(int $book, int $para): array
  315. {
  316. $sentences = PaliSentence::where('book', $book)
  317. ->where('paragraph', $para)
  318. ->orderBy('word_begin')
  319. ->get();
  320. $json = [];
  321. foreach ($sentences as $sentence) {
  322. $content = $this->searchPaliDataService->getSentenceContent($book, $para, $sentence->word_begin, $sentence->word_end);
  323. $id = "{$book}-{$para}-{$sentence->word_begin}-{$sentence->word_end}";
  324. $json[] = ['id' => $id, 'content' => $content['markdown']];
  325. }
  326. return $json;
  327. }
  328. /**
  329. * translate 步骤:根据巴利原文产出译文
  330. *
  331. * @param array<int, array{id: string, content: string}> $pali
  332. * @param array<int, array{id: string, content: string}> $nissaya 巴利逐词缅文释义参考资料,可空
  333. * @param string[] $glossary 巴利术语表(术语原形列表),命中时译文输出 [[术语]],可空
  334. * @return array<int, array{id: string, content: string}>
  335. */
  336. public function translate(array $pali, array $nissaya = [], array $glossary = []): array
  337. {
  338. $userText = "# pali\n\n".$this->jsonBlock($pali)."\n\n"
  339. .$this->nissayaSection($nissaya)
  340. .$this->glossarySection($glossary);
  341. Log::debug('PaliTranslate: translate', ['input' => $userText]);
  342. $content = $this->send($this->translatePrompt, $userText);
  343. return LlmResponseParser::jsonl($content);
  344. }
  345. /**
  346. * review 步骤:对已有译文打分并给出问题清单(不修改译文)
  347. *
  348. * @param array<int, array{id: string, content: string}> $pali
  349. * @param array<int, array{id: string, content: string}> $translation
  350. * @param array<int, array{id: string, content: string}> $nissaya 巴利逐词缅文释义参考资料,可空
  351. * @return array<int, array{id: string, score: int, issues: string}>
  352. */
  353. public function review(array $pali, array $translation, array $nissaya = []): array
  354. {
  355. $userText = "# pali\n\n".$this->jsonBlock($pali)."\n\n"
  356. ."# translation\n\n".$this->jsonBlock($translation)."\n\n"
  357. .$this->nissayaSection($nissaya);
  358. Log::debug('PaliTranslate: review', ['input' => $userText]);
  359. $content = $this->send($this->reviewPrompt, $userText);
  360. Log::debug('PaliTranslate: review', ['output' => $content]);
  361. return LlmResponseParser::jsonl($content);
  362. }
  363. /**
  364. * revise 步骤:根据审校意见产出改进后的译文
  365. *
  366. * @param array<int, array{id: string, content: string}> $pali
  367. * @param array<int, array{id: string, content: string}> $translation
  368. * @param array<int, array{id: string, score: int, issues: string}> $review
  369. * @return array<int, array{id: string, content: string}>
  370. */
  371. public function revise(array $pali, array $translation, array $review): array
  372. {
  373. $userText = "# pali\n\n".$this->jsonBlock($pali)."\n\n"
  374. ."# translation\n\n".$this->jsonBlock($translation)."\n\n"
  375. ."# review\n\n".$this->jsonBlock($review)."\n\n";
  376. Log::debug('PaliTranslate: revise', ['input' => $userText]);
  377. $content = $this->send($this->revisePrompt, $userText);
  378. Log::debug('PaliTranslate: revise', ['output' => $content]);
  379. return LlmResponseParser::jsonl($content);
  380. }
  381. /**
  382. * evaluate 步骤:对照原文找出译文真实问题,按级别就地用 HTML span 标注译文。
  383. * 返回标注后的译文(无问题的句子原样返回),作为工作流最后一步写库。
  384. *
  385. * @param array<int, array{id: string, content: string}> $pali
  386. * @param array<int, array{id: string, content: string}> $translation
  387. * @param array<int, array{id: string, content: string}> $nissaya 巴利逐词缅文释义参考资料,可空
  388. * @return array<int, array{id: string, content: string}>
  389. */
  390. public function evaluate(array $pali, array $translation, array $nissaya = []): array
  391. {
  392. $userText = "# pali\n\n".$this->jsonBlock($pali)."\n\n"
  393. ."# translation\n\n".$this->jsonBlock($translation)."\n\n"
  394. .$this->nissayaSection($nissaya);
  395. Log::debug('PaliTranslate: evaluate', ['input' => $userText]);
  396. $content = $this->send($this->evaluatePrompt, $userText);
  397. Log::debug('PaliTranslate: evaluate', ['output' => $content]);
  398. return LlmResponseParser::jsonl($content);
  399. }
  400. /**
  401. * 从输出 channel 读取已有译文,按句子返回 ['id' => ..., 'content' => ...]
  402. *
  403. * @return array<int, array{id: string, content: string}>
  404. */
  405. protected function existingTranslation(int $book, int $para): array
  406. {
  407. $channelId = $this->workChannel['id'] ?? null;
  408. if (! $channelId) {
  409. Log::warning('PaliTranslate: 未设置输出 channel,无法读取已有译文');
  410. return [];
  411. }
  412. $sentences = Sentence::where('channel_uid', $channelId)
  413. ->where('book_id', $book)
  414. ->where('paragraph', $para)
  415. ->orderBy('word_start')
  416. ->get();
  417. $result = [];
  418. foreach ($sentences as $sentence) {
  419. $id = "{$sentence->book_id}-{$sentence->paragraph}-{$sentence->word_start}-{$sentence->word_end}";
  420. $result[] = ['id' => $id, 'content' => $sentence->content];
  421. }
  422. return $result;
  423. }
  424. /**
  425. * 调用 LLM,返回响应文本
  426. */
  427. protected function send(string $systemPrompt, string $userText): string
  428. {
  429. $startAt = time();
  430. $response = $this->openAIService
  431. ->setApiUrl($this->model['url'])
  432. ->setModel($this->model['model'])
  433. ->setApiKey($this->model['key'])
  434. ->setSystemPrompt($systemPrompt)
  435. ->setTemperature(0.0)
  436. ->setThinking($this->thinking)
  437. ->setStream($this->stream)
  438. ->send($userText);
  439. $complete = time() - $startAt;
  440. $content = $response['choices'][0]['message']['content'] ?? '[]';
  441. Log::debug("PaliTranslate: complete in {$complete}s", ['content' => $content]);
  442. return is_string($content) ? $content : '[]';
  443. }
  444. /**
  445. * 按开关返回某步骤应使用的 nissaya;未启用该步骤时返回空数组。
  446. *
  447. * @param array<int, array{id: string, content: string}> $nissaya
  448. * @return array<int, array{id: string, content: string}>
  449. */
  450. protected function nissayaFor(string $step, array $nissaya): array
  451. {
  452. return in_array($step, $this->nissayaSteps, true) ? $nissaya : [];
  453. }
  454. /**
  455. * 构造 nissaya 参考资料区块;无数据时返回空串(不污染提示词)。
  456. * 与 pali / translation 一致使用 [{id, content}] json,便于模型按 id 对应句子。
  457. *
  458. * @param array<int, array{id: string, content: string}> $nissaya
  459. */
  460. protected function nissayaSection(array $nissaya): string
  461. {
  462. if (empty($nissaya)) {
  463. return '';
  464. }
  465. return "# nissaya\n\n".$this->jsonBlock($nissaya)."\n\n";
  466. }
  467. /**
  468. * 加载本段所属 chapter 的巴利术语表。
  469. *
  470. * 术语表由 extract:pali.term 命令按 chapter 生成,保存在 _system_glossary_ channel,
  471. * 以 chapter 起始段落(level 1-7 节点)为 paragraph、word_start/word_end 均为 0、
  472. * content 为巴利术语原形的 JSON 字符串数组。这里先把当前段落向下归到所属 chapter,
  473. * 再读取该 chapter 的术语表。加载失败或无数据时返回空数组(不影响翻译)。
  474. *
  475. * @return string[] 巴利术语原形列表
  476. */
  477. protected function loadGlossary(int $book, int $para): array
  478. {
  479. $channelId = ChannelApi::getSysChannel('_system_glossary_');
  480. if (! $channelId) {
  481. return [];
  482. }
  483. // 术语表按 chapter 起始段落存储:向下取 ≤para 的最近 chapter 节点(level 1-7)
  484. $chapterStart = PaliText::where('book', $book)
  485. ->where('paragraph', '<=', $para)
  486. ->whereBetween('level', [1, 7])
  487. ->orderBy('paragraph', 'desc')
  488. ->value('paragraph');
  489. if ($chapterStart === null) {
  490. return [];
  491. }
  492. $content = Sentence::where('channel_uid', $channelId)
  493. ->where('book_id', $book)
  494. ->where('paragraph', $chapterStart)
  495. ->where('word_start', 0)
  496. ->where('word_end', 0)
  497. ->value('content');
  498. if (empty($content)) {
  499. return [];
  500. }
  501. $words = json_decode($content, true);
  502. if (! is_array($words)) {
  503. return [];
  504. }
  505. return array_values(array_filter($words, fn ($w) => is_string($w) && $w !== ''));
  506. }
  507. /**
  508. * 构造 glossary 术语表区块;无数据时返回空串(不污染提示词)。
  509. *
  510. * @param string[] $glossary
  511. */
  512. protected function glossarySection(array $glossary): string
  513. {
  514. if (empty($glossary)) {
  515. return '';
  516. }
  517. return "# glossary\n\n```json\n".json_encode(array_values($glossary), JSON_UNESCAPED_UNICODE)."\n```\n\n";
  518. }
  519. /**
  520. * 将数组包裹为 ```json ... ``` 代码块
  521. *
  522. * @param array<int, mixed> $data
  523. */
  524. protected function jsonBlock(array $data): string
  525. {
  526. return "```json\n".json_encode($data, JSON_UNESCAPED_UNICODE)."\n```";
  527. }
  528. }