PaliTranslateService.php 35 KB

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