PaliTranslateService.php 24 KB

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