mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge pull request #348 from deepseek-harness/docs/i18n-prompt-v4
docs(i18n): restore prompt-v4 as the pipeline baseline
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-23-translation-prompt-v4-contract.md: 3e1e51797aa3463c8db24d8657120434e6822789
|
||||
2026-07-23-translation-prompt-v4-contract.zh.md: 161d2b6cf3bd3499e3c505a178da40ce577ca797
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Calibrated translation prompt v4 contract
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-23-translation-prompt-v4-contract.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Automated counterpart generation needs a stable prompt that reproduces the register and corrections established by human-reviewed translations. Injecting a general-purpose instruction document changes that calibrated model input whenever human or agent guidance changes, while an unframed response cannot carry a draft, its self-review, and the corrected document separately. Plain XML-like section tags also collide with valid Markdown that documents those same tags.
|
||||
|
||||
## Decision
|
||||
|
||||
The committed [translation prompt](../../../../docs/i18n/translation-prompt.md) is the calibrated pipeline asset. Its renderer injects only the source language, target language, and current [terminology table](../../../../docs/i18n/terminology.md), and rejects unknown, missing, or malformed placeholder syntax before assembling a request. The request assembler retains the source basename outside the model-visible prompt and places each reviewed whole-document pair into one bare-text user/assistant example turn before the real source document. The template may carry model-specific calibration rules, but those rules remain subordinate to the repository's binding pairing, terminology, structure, and emphasis contracts.
|
||||
|
||||
The response has three ordered top-level sections: `translation`, `review`, and `final`. The response consumer derives the target basename from the retained source context, preserves optional leading YAML frontmatter, and mechanically inserts or corrects the language switcher after the first H1 in `final`. The parser requires each section exactly once, rejects content outside the envelope, and tolerates one outer `xml` Markdown fence because models sometimes echo the prompt's example fence.
|
||||
|
||||
## Response framing
|
||||
|
||||
Section delimiter lines are reserved by the wire format. When a Markdown body line consists of a delimiter tag, possibly preceded by backslashes, the serializer and model add one leading backslash; the parser removes exactly one. This count-preserving escape round-trips both a literal delimiter and an already escaped delimiter without changing inline tag mentions.
|
||||
|
||||
The executable contract lives in [the renderer, request assembler, parser, and response consumer](../../../../scripts/translation-prompt.ts). Unit tests cover both directions, request order, placeholder validation, target-path validation, strict section order and cardinality, fenced responses, inline tag mentions, delimiter lines inside Markdown bodies, and frontmatter-preserving new-pair switcher correction. A keyless subprocess snapshot pins the assembled prompt and five reviewed example turns together with a frontmatter-bearing recorded response consumed through the target-path correction.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Inject `translation-rules.md` into every request.** That document governs humans and agents as well as the automated pipeline. Injecting it couples each editorial clarification to model behavior and displaces the manually calibrated prompt constraints; the pipeline instead injects the binding terminology table and verifies its own asset directly.
|
||||
|
||||
**Use a strict CDATA XML document.** CDATA provides general XML framing but adds a nested protocol, an additional `]]>` escape, and XML-parser behavior that the three-section contract does not otherwise need. Reserving and escaping six delimiter lines keeps the calibrated response shape while preserving arbitrary Markdown.
|
||||
|
||||
**Return only the final translation.** A single body is simpler to parse but discards the explicit correction pass used to catch tone, structure, terminology, and punctuation defects before publication.
|
||||
|
||||
## Consequences
|
||||
|
||||
Prompt wording is executable behavior and receives code review, a translation-prompt verifier, and a runnable request/response snapshot. The calibrated asset and the general translation rules can evolve for their different audiences, but review must reject contradictions with binding repository contracts. The line escape is visible only when source documentation contains a wrapper tag on its own line, and parser tests pin its lossless behavior.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 经校准的翻译提示词 v4 契约
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-23-translation-prompt-v4-contract.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
自动生成对侧文件需要一份稳定的提示词,能够复现经人工评审的译文所确立的语体和修正方式。注入通用说明文档,会让这份经校准的模型输入随着面向人类或 agent(智能体)的指导发生变化,而未经封装的响应无法分别承载草稿、自检内容和修正后的文档。普通的类 XML 分段标签还会与用于说明这些标签的合法 Markdown 内容发生冲突。
|
||||
|
||||
## 决策
|
||||
|
||||
提交入库的[翻译提示词](../../../../docs/i18n/translation-prompt.md)是经过校准的流水线资源。其渲染器仅注入源语言、目标语言和当前[术语表](../../../../docs/i18n/terminology.md),并在组装请求前拒绝未知、缺失或语法格式错误的占位符。请求组装器在模型可见的提示词之外保留源文件基本名,并在真正的源文档之前,将每组经评审的整篇文档对编排为一个纯文本 user/assistant 示例轮次。模板可以包含针对特定模型的校准规则,但这些规则必须服从仓库中具约束力的配对、术语、结构与强调格式契约。
|
||||
|
||||
响应包含三个有序的顶层分段:`translation`、`review` 和 `final`。响应消费方根据保留的源文件上下文推导目标文件基本名,保留文件开头可选的 YAML frontmatter,并以机械方式在 `final` 中第一个 H1 之后插入或校正语言切换行。解析器要求每个分段恰好出现一次,拒绝封套之外的内容,并允许响应最外层有一层 `xml` Markdown 围栏,因为模型有时会照抄提示词中的示例围栏。
|
||||
|
||||
## 响应封装格式
|
||||
|
||||
分段定界行由协议格式(wire format)保留。当 Markdown 正文中的某一行仅包含定界标签(前面可以带反斜杠)时,序列化器和模型会在行首再添加一个反斜杠;解析器则只移除一个。这种保留计数的转义方式让字面量定界标签与已转义的定界标签都能无损往返,同时不会改动行内提及的标签。
|
||||
|
||||
可执行契约由[渲染器、请求组装器、解析器和响应消费方](../../../../scripts/translation-prompt.ts)实现。单元测试覆盖两个翻译方向、请求顺序、占位符校验、目标路径校验、严格的分段顺序与数量约束、带围栏的响应、行内提及标签、Markdown 正文中的定界行,以及保留 YAML frontmatter 的新配对语言切换行校正。一个无密钥子进程快照锁定组装后的提示词、五个经评审的示例轮次,以及带 YAML frontmatter 的录制响应经目标路径校正后的消费结果。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在每个请求中注入 `translation-rules.md`。** 该文档既约束人类与 agent,也约束自动翻译流水线。注入它会让编辑规范的每次澄清都与模型行为耦合,并挤占经过人工校准的提示词约束;因此流水线仅注入具约束力的术语表,并直接校验自身资源。
|
||||
|
||||
**使用严格的 CDATA XML 文档。** CDATA 提供通用的 XML 封装,但会引入一层嵌套协议、额外的 `]]>` 转义规则,以及三段式契约原本不需要的 XML 解析器行为。预留并转义六种定界行,既能维持经校准的响应形态,也能保留任意 Markdown 内容不变。
|
||||
|
||||
**只返回最终译文。** 单一正文更易解析,却会丢弃显式修正步骤;这个步骤用于在发布前发现语气、结构、术语和标点缺陷。
|
||||
|
||||
## 影响
|
||||
|
||||
提示词措辞属于可执行行为,因此需要经过代码评审、翻译提示词校验器校验及可运行的请求/响应快照验证。经校准的资源与通用翻译规则可以针对各自的受众分别演进,但评审必须拒绝任何与仓库约束性契约冲突的改动。只有当源文档中的封装标签独占一行时,行转义才会显现;解析器测试锁定这一无损行为。
|
||||
@@ -20,7 +20,7 @@ These are authoritative; read them at the source so this skill never drifts out
|
||||
- **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: the three-file pair (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`), the consistency record's both-side blob hashes, the language-switcher lines, scope/exclusions, and the rollout manifest.
|
||||
- **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md)** — how to translate: faithfulness, structure preservation, terminology discipline, typography (MUST/SHOULD levels).
|
||||
- **[docs/i18n/terminology.md](../../../docs/i18n/terminology.md)** — the terminology table, binding in both directions. Load it BEFORE translating, not when a term feels uncertain; the terms you don't notice are the ones that drift.
|
||||
- **[docs/i18n/translation-prompt.md](../../../docs/i18n/translation-prompt.md)** — the automated pipeline's machine-consumed template. Agents using this skill do not render it; the renderer injects `translation-rules.md` so rules have only one home.
|
||||
- **[docs/i18n/translation-prompt.md](../../../docs/i18n/translation-prompt.md)** — the automated pipeline's calibrated machine-consumed template. Agents using this skill do not render it; the terminology table is the only repository file the automated renderer injects, while this skill and `translation-rules.md` remain binding for agent-authored translations.
|
||||
- **[dsh-prose-standard](../dsh-prose-standard/SKILL.md)** — required prose coverage and editorial judgment. Apply it to both sides without adding or dropping source propositions.
|
||||
|
||||
## Find the work
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: c4ddf44ad2497b4ff371918356ab1ec0698c7049
|
||||
README.zh.md: 4a31af4fdee4db2d0362cf9117a6eef4fea32393
|
||||
README.md: 430c499afbbfb786928276f6348cc0cedf14f94d
|
||||
README.zh.md: 7ac7f4a2a8983c753def61df6f6d86a26405a3a0
|
||||
|
||||
@@ -49,4 +49,4 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co
|
||||
|
||||
## Division of labor
|
||||
|
||||
Counterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the canonical rules into either direction and strictly parses the three-field XML response, while `verify-translation-prompt` exercises both render directions, the checked-in example, and the CDATA split rule in `doc-sync`.
|
||||
Counterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此 README、Agent Note 与 docs 目录树以英文和简体中文双语维护。本页定义配对契约、强制门禁与推进策略;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。
|
||||
本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此 README、Agent Note(agent 决策记录)与 docs 目录树以英文和简体中文双语维护。本页定义配对契约、强制门禁与推进策略;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。
|
||||
|
||||
## 配对契约
|
||||
|
||||
- **两种语言同权。**一篇文档可以先用任一语言撰写和评审——先写中文的 Agent Note 与先写英文的一样正当——另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。
|
||||
- **一对文档是三个同目录文件。**英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对整体合入:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。
|
||||
- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。
|
||||
- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。
|
||||
- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:
|
||||
|
||||
```yaml
|
||||
@@ -15,38 +15,38 @@
|
||||
foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b
|
||||
```
|
||||
|
||||
用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本(`git cat-file -p <hash>`),所以失去同步的配对是「把被改的一侧与其上次确认状态做 diff、再最小化地修补另一侧」——从不整篇重译。两侧对齐后,`pnpm run verify-translation-pairing --write` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审。
|
||||
- **语言切换行。**两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。
|
||||
- **结构与另一侧一一对应。**标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。
|
||||
用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本(`git cat-file -p <hash>`),所以失去同步的配对是「把被改的一侧与其上次确认状态做 diff、再最小化地修补另一侧」,从不整篇重译。两侧对齐后,`pnpm run verify-translation-pairing --write` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审。
|
||||
- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。
|
||||
- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。
|
||||
|
||||
## 门禁:verify-translation-pairing
|
||||
|
||||
`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:
|
||||
|
||||
1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。
|
||||
2. 任何已存在的配对——无论是否 required——都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。
|
||||
2. 任何已存在的配对(无论是否 required)都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。
|
||||
3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。
|
||||
4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对——新建的日期命名 Agent Note 从创建起便须配齐中英文。
|
||||
4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对;新建的日期命名 Agent Note 从创建起便须配齐中英文。
|
||||
|
||||
`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态——missing、out-of-sync 或 ok——是翻译批次的工作清单。它从不失败;它只报告。
|
||||
`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok),是翻译批次的工作清单。它从不失败;它只报告。
|
||||
|
||||
这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write`),与本仓库既有的代码/README doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。
|
||||
这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。
|
||||
|
||||
把门禁的边界说白:**门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。**它检查 hash 和形状;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然——那是契约中评审者的那一半,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。
|
||||
把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。
|
||||
|
||||
## 范围、排除与推进
|
||||
|
||||
**范围**:根 `README.md`,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。
|
||||
**范围**:根 `README.md`,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部内容。包(package)README(`packages/**`)在后续批次加入范围。
|
||||
|
||||
**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):
|
||||
|
||||
- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md` 与 `docs/module-graph.md`——生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。
|
||||
- `docs/AGENTS.md` 与 `.agents/notes/**/AGENTS.md`——agent 指令,与根 `AGENTS.md` 一样只以英文维护。
|
||||
- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md)——二者本身即为中英对照文档。
|
||||
- [translation-prompt.md](translation-prompt.md)——自动翻译流水线的 prompt 模板;正文逐字进入模型请求,配对翻译会改变流水线行为。
|
||||
- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md` 与 `docs/module-graph.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。
|
||||
- `docs/AGENTS.md` 与 `.agents/notes/**/AGENTS.md`:agent 指令,与根 `AGENTS.md` 一样只以英文维护。
|
||||
- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。
|
||||
- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。
|
||||
|
||||
**推进**:以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note),只要标注日期等于或晚于 manifest 的 `requiredSince` 分界日期,合入时就必须配齐双语文件。更早日期的文件属于 backlog(待翻清单),包括分界前夜创建的文件。Agent Note 文件名记录首次提出日期,因此倒填日期绕过分界属于评审可见的违规。manifest 中的 `required` 列表是当前执行红线,并非全量覆盖这一最终目标。翻译批次将路径加入 `required`,使门禁只向前收紧。未列入的文档仍可通过 `--list` 查看,而任何已存在的配对都受完整契约约束。后续修改必须同步更新两侧,因此 `required` 的扩展速度不能超过翻译评审的承载能力。
|
||||
**推进**:以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note),只要标注日期等于或晚于 manifest 的 `requiredSince` 分界日期,合并时就必须配齐双语文件。更早日期的文件属于 backlog(待翻清单),包括分界前夜创建的文件。Agent Note 文件名记录首次提出日期,因此倒填日期绕过分界属于评审可见的违规。manifest 中的 `required` 列表是当前执行红线,并非全量覆盖这一最终目标。翻译批次将路径加入 `required`,使门禁只向前收紧。未列入的文档仍可通过 `--list` 查看,而任何已存在的配对都受完整契约约束。后续修改必须同步更新两侧,因此 `required` 的扩展速度不能超过翻译评审的承载能力。
|
||||
|
||||
## 分工
|
||||
|
||||
对侧译文由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。prompt 契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把权威规则渲染到英译中或中译英的 prompt 中,并严格解析包含三个字段的 XML 响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向、仓库内示例与 CDATA 拆分规则。
|
||||
这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Translation prompt (pipeline asset)
|
||||
|
||||
本文件是自动翻译流水线使用的 prompt 模板;从 `# Translation Prompt` 开始的正文会逐字进入模型请求,因此本文件不参与双语配对(见 [README.md](README.md) 排除清单)。渲染时会把 [translation-rules.md](translation-rules.md) 全文填入 `{{translation_rules}}`,把 [terminology.md](terminology.md) 整表填入 `{{terminology}}`,以免模板另存一份规则而日后失去同步。[style-samples.md](style-samples.md) 定义文体,模板中的 Examples 只用于说明典型问题;术语表、忠实性和结构规则优先于样例,样例只在这些硬性约束内决定文体。修改本文件会改变翻译行为,需正常经过 PR 评审。
|
||||
本文件是自动翻译流水线的 prompt 模板;从 `# Translation Prompt` 开始的正文会逐字进入模型请求,因此本文件不参与双语配对(见 [README.md](README.md) 排除清单)。模板正文与内嵌 few-shot 正误例由 jingtingxiang 基于对存量译文的质量评审撰写,是流水线行为的拍板基线。渲染时把 [terminology.md](terminology.md) 整表填入 `{{terminology}}`;除此之外不注入任何其他仓库文件(translation-rules.md 约束人和 agent 的翻译工作,不注入本模板)。[style-samples.md](style-samples.md) 定义文体,模板中的 Examples 只用于说明典型问题,两者冲突时以文体样例为准。[提示词 v4 契约 Agent Note](../../.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md) 记录该协议的决策与取舍;修改本文件会改变翻译行为,需正常经过 PR 评审。
|
||||
|
||||
## 占位符契约
|
||||
|
||||
@@ -10,18 +10,15 @@
|
||||
|---|---|---|
|
||||
| `{{source_lang}}` | 源语言名(`English` / `Chinese`) | 由改动侧文件推断:`.zh.md` 被改则为 `Chinese` |
|
||||
| `{{target_lang}}` | 目标语言名(`Chinese` / `English`) | 与 `{{source_lang}}` 相对 |
|
||||
| `{{translation_rules}}` | [translation-rules.md](translation-rules.md) 全文(Markdown 原文) | 渲染时读取仓库当前版本,不缓存 |
|
||||
| `{{terminology}}` | [terminology.md](terminology.md) 的完整表格(Markdown 原文) | 渲染时读取仓库当前版本,不缓存 |
|
||||
| `{{source_filename}}` | 源文档的 basename(如 `foo.md` 或 `foo.zh.md`) | 由流水线从待译文件路径取得 |
|
||||
| `{{source_filename_zh}}` | 中文侧 basename(如 `foo.zh.md`) | 英文源追加 `.zh`;中文源使用自身 basename |
|
||||
|
||||
例如,英译中时若源文件是 `foo.md`,`{{source_filename}}` 填 `foo.md`,`{{source_filename_zh}}` 填 `foo.zh.md`;中译英时若源文件是 `foo.zh.md`,两个占位符都填 `foo.zh.md`。
|
||||
流水线只识别上表中的占位符,并且一次翻译整篇文档。它不支持 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}`、`{{translation_rules}}` 或 `%%` 分段协议;输出采用模板正文规定的三段 XML,流水线解析取 `<final>` 段。
|
||||
|
||||
流水线只识别上表中的占位符,并且一次翻译整篇文档。它不支持 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}` 或 `%%` 分段协议。输出必须是一个以 `<dsh-translation-response>` 为根元素的 XML 文档;三个子元素中的 Markdown 内容都放在 CDATA 中。内容出现 `]]>` 时写成 `]]]]><![CDATA[>`,XML 解析后仍会还原为原文。
|
||||
语言切换行:已有配对的源文件自带切换行,模型按模板规则翻转即可。全新配对的源文件没有切换行,模型也无从得知文件名——此时由流水线在解析 `<final>` 后按目标文件名插入或校正切换行(机械后处理,配对门禁兜底校验)。
|
||||
|
||||
## Few-shot 金标
|
||||
|
||||
流水线使用**整篇文档**的中英对照作为 few-shot,不是模板内嵌的句子级正误例。以下 5 组配对文档均经过人工评审,并以仓库当前版本为准,随仓库一同更新:
|
||||
流水线使用**整篇文档**的中英对照作为 few-shot,不是模板内嵌的句子级正误例。以下 5 组配对文档均经过人工评审,以仓库当前版本为准、随仓库更新:
|
||||
|
||||
- `README.md` ↔ `README.zh.md`
|
||||
- `docs/development.md` ↔ `docs/development.zh.md`
|
||||
@@ -29,58 +26,135 @@
|
||||
- `docs/i18n/translation-rules.md` ↔ `docs/i18n/translation-rules.zh.md`
|
||||
- `.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md` ↔ 对应 `.zh.md`
|
||||
|
||||
注入时按当前翻译方向选择每组的源侧与目标侧:user 消息包含源文档全文,assistant 消息采用模板正文规定的 XML 协议;`translation` 与 `final` 都放入目标文档全文,`review` 填 `- [None] No corrections.`。CDATA 遵循上文的 `]]>` 拆分规则。上下文不足时,按上列顺序从后往前删减示例组数。这 5 组也是评审校准锚点;改动任何一组都会改变流水线行为。
|
||||
注入方式:在系统消息(本模板)之后、待译文档之前,每组作为一轮示例对话——user 消息为源文档全文,assistant 消息为定稿译文全文(裸文本,不带三段 XML 包装;只有真实请求要求三段输出)。上下文不足时按上列顺序从后往前删减组数。这 5 组也是评审校准锚点(见 [style-samples.md](style-samples.md)),改动任何一组即改变流水线行为。
|
||||
|
||||
## 模板正文
|
||||
|
||||
````text
|
||||
# Translation Prompt
|
||||
|
||||
You are a senior technical translator specializing in LLM and agent development documentation. Translate the complete source document from {{source_lang}} to {{target_lang}} as natural, professional technical prose.
|
||||
You are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from {{source_lang}} to {{target_lang}}, producing natural, professional technical prose.
|
||||
|
||||
## Binding Translation Rules
|
||||
## Quality Requirements
|
||||
|
||||
The canonical repository rules below are injected verbatim. Apply every direction-appropriate requirement. In those rules, the authored document is the source for this request and the generated document is its counterpart.
|
||||
### Structure and Format Preservation
|
||||
- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks.
|
||||
- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions.
|
||||
- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them.
|
||||
- Every relative link must point to the same target as in the source. Link text is translated; link targets are not.
|
||||
- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction.
|
||||
- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width).
|
||||
|
||||
{{translation_rules}}
|
||||
### Tone and Style
|
||||
- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it.
|
||||
- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.
|
||||
- Use polite imperative forms where the text instructs the reader to do something.
|
||||
- Keep the author's register: concise stays concise, detailed stays detailed.
|
||||
|
||||
## Request-Specific Structure
|
||||
### Sentence Structure
|
||||
- Break long sentences with commas or semicolons. Avoid run-on sentences.
|
||||
- Prefer active voice. Convert passive constructions to active if it reads more naturally.
|
||||
- Translate meaning, not words. Restructure sentences where the target language grammar requires it.
|
||||
- Do not invent words or expressions that do not exist in natural technical writing of the target language.
|
||||
|
||||
- The source basename is `{{source_filename}}`. When translating into Chinese, write `[English]({{source_filename}}) | 中文` immediately after the H1. When translating into English, write `English | [中文]({{source_filename_zh}})` immediately after the H1.
|
||||
- Emit the switcher for a new pair and flip an existing switcher; never copy it unchanged.
|
||||
### Word Choice
|
||||
- Prefer precise, formal vocabulary over casual or colloquial alternatives.
|
||||
- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.
|
||||
- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.
|
||||
- Do not use the same word to translate two different source-language terms that carry distinct meanings.
|
||||
- Avoid repeating the same verb in close proximity; vary word choice for readability.
|
||||
|
||||
## Binding Terminology
|
||||
#### When translating into Chinese
|
||||
- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: "three-package seam" → "由三个包构成的 seam", not "三包 seam".
|
||||
|
||||
Apply the current table below exactly as required by the injected translation rules.
|
||||
### Punctuation
|
||||
|
||||
#### When translating into Chinese
|
||||
- Use full-width Chinese punctuation in prose: `,。:;?!()「」`.
|
||||
- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all.
|
||||
- Use enumeration commas (、) between parallel items, not regular commas.
|
||||
- List item endings: use semicolons or no punctuation. Do not end list items with commas.
|
||||
- Put one half-width space between Chinese text and Latin words/numbers.
|
||||
- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).
|
||||
|
||||
#### When translating into English
|
||||
(To be added.)
|
||||
|
||||
## Terminology
|
||||
|
||||
A terminology table is provided below. Follow it strictly:
|
||||
- Render every listed term exactly as specified.
|
||||
- When the target language is Chinese, use the "中文" column. On first occurrence, write the "首次出现" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses.
|
||||
- When the target language is English, use the "English" column without a Chinese gloss; do not copy the "中文" or "首次出现" value into English prose.
|
||||
- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.
|
||||
- NEVER use translations listed in the "不要译作" column.
|
||||
- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the established English technical term, or preserve an ambiguous source term with a short English gloss and flag it as pending. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.
|
||||
|
||||
{{terminology}}
|
||||
|
||||
## Output Format
|
||||
|
||||
Return exactly one well-formed XML document with this root and these three child elements. Do not wrap it in a Markdown code fence. Put all Markdown and review text inside CDATA. If any content contains the CDATA terminator, split it as `]]]]><![CDATA[>` so XML parsing reconstructs the original `]]>` sequence.
|
||||
Produce your output in three XML sections:
|
||||
|
||||
The outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.
|
||||
|
||||
```xml
|
||||
<dsh-translation-response version="1">
|
||||
<translation><![CDATA[
|
||||
(Complete first-pass translation)
|
||||
]]></translation>
|
||||
<review><![CDATA[
|
||||
- [Tone] Replaced a literal rendering with the established target-language phrasing.
|
||||
- [Terminology] Applied the binding sidecar record term.
|
||||
]]></review>
|
||||
<final><![CDATA[
|
||||
(Complete corrected translation)
|
||||
]]></final>
|
||||
</dsh-translation-response>
|
||||
<translation>
|
||||
(Complete translation of the source document)
|
||||
</translation>
|
||||
|
||||
<review>
|
||||
(Self-review notes, one correction per line with category tag, e.g.)
|
||||
- [Tone] "旁挂记录" → "伴随记录"(生造词)
|
||||
- [Sentence] 第 3 段补充逗号断句
|
||||
- [Punctuation] 两处破折号替换为冒号
|
||||
- 无修正
|
||||
</review>
|
||||
|
||||
<final>
|
||||
(Final translation after corrections)
|
||||
</final>
|
||||
```
|
||||
|
||||
## Self-Review Instructions
|
||||
|
||||
After writing `<translation>`, re-read it in the target language without looking at the source. Then apply the injected translation rules as a clause-by-clause comparison against the source and record actual corrections in English inside `<review>`. Apply every recorded correction in `<final>`. If no correction is needed, write only `- [None] No corrections.` in `<review>` and copy `<translation>` unchanged into `<final>`.
|
||||
After writing `<translation>`, re-read it in the target language only, without looking at the source. Check by category:
|
||||
|
||||
**Structure**
|
||||
- Is the heading hierarchy, list shape, and code block content identical to the source?
|
||||
- Are ALL comments inside code blocks left untranslated (byte-identical to source)?
|
||||
- Is the language switcher line correctly flipped (not copied from source)?
|
||||
- Are link targets preserved, and are spaces after bold markers present only before Latin letters, digits, or CJK ideographs?
|
||||
- Are wrapper-tag lines inside section bodies escaped with one additional backslash?
|
||||
|
||||
**Tone & Style**
|
||||
- Does every sentence read as if originally written by a native speaker?
|
||||
- Is there any colloquial, casual, or overly informal phrasing?
|
||||
|
||||
**Sentence Structure**
|
||||
- Are there run-on sentences that need breaking?
|
||||
- Are there stiff passive constructions that should be converted to active voice?
|
||||
|
||||
**Word Choice**
|
||||
- Are there overly literal translations that sound unnatural?
|
||||
- Is the same target-language word used to translate two distinct source concepts?
|
||||
- Is any slang or internal jargon present?
|
||||
|
||||
**Terminology**
|
||||
- For a Chinese target, are first-occurrence glosses correctly applied (not missing, not repeated)? For an English target, are Chinese glosses absent?
|
||||
- Are any "不要译作" forbidden translations present?
|
||||
- For unlisted terms, does a Chinese target use established Chinese precedent or retain the source term as pending, and does an English target use established English terminology or preserve only an ambiguous source term with a short English gloss?
|
||||
|
||||
**Punctuation** (when target is Chinese)
|
||||
- Are there em-dashes that should be replaced with colons, periods, or commas?
|
||||
- Are list items ending with commas instead of semicolons?
|
||||
- Do RFC 2119 keywords preserve the source emphasis exactly?
|
||||
|
||||
Record corrections in `<review>` with category tags. Then output the corrected version in `<final>`. If no corrections are needed, write "无修正" in `<review>` and copy the translation unchanged into `<final>`.
|
||||
|
||||
## Examples
|
||||
|
||||
Follow the Good versions; these sentence-level examples illustrate error categories, not the assistant-message wire format.
|
||||
Below are representative examples of common problems and their corrections. Follow the "Good" versions.
|
||||
|
||||
### Colloquial verb → Professional verb
|
||||
- Source: `The repo pins pnpm@11.7.0 in package.json`
|
||||
@@ -102,40 +176,40 @@ Follow the Good versions; these sentence-level examples illustrate error categor
|
||||
- Bad: `旁挂记录两侧 blob hash,使一致性可检查`
|
||||
- Good: `伴随记录保存两侧 blob hash,使一致性可检查`
|
||||
|
||||
### Em-dash → Colon/period
|
||||
- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`
|
||||
- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`
|
||||
- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`
|
||||
|
||||
### Overly literal → Meaningful rendering
|
||||
- Source: `awkward phrasing is easier to hear without the source anchoring you`
|
||||
- Bad: `没有源文锚着,别扭的表述更容易被听出来`
|
||||
- Good: `不对照原文时,更容易察觉别扭的表达`
|
||||
|
||||
### Terminology — keep the binding English form
|
||||
### Terminology — do not translate what should be kept in English
|
||||
- Source: `typed service seams, and explicit extension points`
|
||||
- Bad: `类型化的服务 seam(扩展点)与显式扩展点`
|
||||
- Good: `类型化的服务 seam 与显式扩展点`
|
||||
|
||||
### Slang → Professional phrasing
|
||||
### Slang/jargon → Professional phrasing
|
||||
- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`
|
||||
- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`
|
||||
- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`
|
||||
|
||||
### Chinese → English — idiomatic subject and predicate
|
||||
- Source: `门禁绿并不代表译文内容正确。`
|
||||
- Bad: `The gate green does not represent that the translation content is correct.`
|
||||
- Good: `A green gate does not mean the translation is correct.`
|
||||
### "For humans" — translate the intent, not the word
|
||||
- Source: `For humans, start with the development guide`
|
||||
- Bad: `对于人工读者,请先从开发指南开始`("人工读者"生硬)
|
||||
- Good: `面向开发者:请先阅读开发指南`("开发者"自然,且中文里冒号在此处更自然)
|
||||
|
||||
### Code block comments — never translate
|
||||
### Code block comments — NEVER translate
|
||||
- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`
|
||||
- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`
|
||||
- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (byte-identical)
|
||||
- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)
|
||||
|
||||
### Language switcher — English to Chinese
|
||||
- Source: `English | [中文](README.zh.md)`
|
||||
- Bad: `English | [中文](README.zh.md)`
|
||||
- Good: `[English](README.md) | 中文`
|
||||
|
||||
### Language switcher — Chinese to English
|
||||
- Source: `[English](README.md) | 中文`
|
||||
- Bad: `[English](README.md) | 中文`
|
||||
- Good: `English | [中文](README.zh.md)`
|
||||
### Language switcher — flip direction
|
||||
- Source file (English) has: `English | [中文](README.zh.md)`
|
||||
- Bad (copying source unchanged): `English | [中文](README.zh.md)`
|
||||
- Good (flipped for Chinese file): `[English](README.md) | 中文`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -121,7 +121,6 @@
|
||||
"mermaid": "11.16.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"publint": "^0.3.21",
|
||||
"saxes": "^6.0.0",
|
||||
"tsdown": "^0.22.2",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -77,9 +77,6 @@ importers:
|
||||
publint:
|
||||
specifier: ^0.3.21
|
||||
version: 0.3.21
|
||||
saxes:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
tsdown:
|
||||
specifier: ^0.22.2
|
||||
version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)
|
||||
|
||||
23
scripts/fixtures/translation-prompt/response.txt
Normal file
23
scripts/fixtures/translation-prompt/response.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
<translation>
|
||||
---
|
||||
layout: doc
|
||||
---
|
||||
|
||||
# 快照说明
|
||||
|
||||
agent(智能体)执行一个步骤。
|
||||
</translation>
|
||||
|
||||
<review>
|
||||
- 无修正
|
||||
</review>
|
||||
|
||||
<final>
|
||||
---
|
||||
layout: doc
|
||||
---
|
||||
|
||||
# 快照说明
|
||||
|
||||
agent(智能体)执行一个步骤。
|
||||
</final>
|
||||
7
scripts/fixtures/translation-prompt/snapshot-note.md
Normal file
7
scripts/fixtures/translation-prompt/snapshot-note.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
layout: doc
|
||||
---
|
||||
|
||||
# Snapshot note
|
||||
|
||||
The agent performs one step.
|
||||
@@ -379,9 +379,9 @@ function coverageGate(): Gate {
|
||||
})
|
||||
}
|
||||
|
||||
// The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node,
|
||||
// plugins via real exports) — CI and check-all already build, so they exercise what ships rather
|
||||
// than the tsx/source path dev uses. It therefore waits on `build`.
|
||||
// Example and package snapshots boot their bins in `lib` mode (built artifacts under plain Node,
|
||||
// plugins via real exports); repository-script snapshots execute their real source entry path.
|
||||
// CI and check-all already build before either class runs, so the suite waits on `build`.
|
||||
function snapshotGate(): Gate {
|
||||
return pnpmScript('snapshot', 'test:snapshot', {
|
||||
env: { DSH_EXAMPLE_MODE: 'lib' },
|
||||
|
||||
File diff suppressed because one or more lines are too long
32
scripts/translation-prompt.snapshot.ts
Normal file
32
scripts/translation-prompt.snapshot.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Runnable keyless snapshot for the assembled translation request and consumed response. */
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { access, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const expected = join(root, 'scripts/snapshots/translation-prompt-v4/request-response.expected.json')
|
||||
const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh'
|
||||
|
||||
describe('translation prompt runnable snapshot', () => {
|
||||
it('assembles the reviewed examples and consumes a recorded new-pair response', async () => {
|
||||
const { stdout, stderr } = await execFileAsync(process.execPath, [
|
||||
join(root, 'scripts/verify-translation-prompt.ts'),
|
||||
'--snapshot',
|
||||
], { cwd: root, maxBuffer: 4 * 1024 * 1024 })
|
||||
expect(stderr).toBe('')
|
||||
expect(() => {
|
||||
JSON.parse(stdout)
|
||||
}).not.toThrow()
|
||||
if (refreshing) {
|
||||
await mkdir(dirname(expected), { recursive: true })
|
||||
await writeFile(expected, stdout)
|
||||
} else {
|
||||
await access(expected)
|
||||
}
|
||||
await expect(stdout).toMatchFileSnapshot(expected)
|
||||
})
|
||||
})
|
||||
@@ -1,76 +1,200 @@
|
||||
/** Regression tests for the executable translation prompt contract. */
|
||||
/** Unit tests for the prompt-v4 renderer and three-section response parser. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
consumeTranslationResponse,
|
||||
parseTranslationResponse,
|
||||
renderTranslationPrompt,
|
||||
renderTranslationRequest,
|
||||
renderTranslationResponse,
|
||||
} from './translation-prompt.ts'
|
||||
|
||||
const document = `# Wrapper
|
||||
|
||||
## 模板正文
|
||||
|
||||
\`\`\`\`text
|
||||
{{source_lang}} to {{target_lang}}
|
||||
{{translation_rules}}
|
||||
{{terminology}}
|
||||
[English]({{source_filename}}) | [中文]({{source_filename_zh}})
|
||||
\`\`\`\`
|
||||
`
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const document = readFileSync(join(root, 'docs/i18n/translation-prompt.md'), 'utf8')
|
||||
const terminology = '| English | 中文 |\n|---|---|\n| agent | agent |'
|
||||
|
||||
describe('translation prompt rendering', () => {
|
||||
it('renders every supported placeholder without recursively rewriting injected rules', () => {
|
||||
const rendered = renderTranslationPrompt(document, {
|
||||
it('renders both directions with every placeholder resolved', () => {
|
||||
const en = renderTranslationPrompt(document, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })
|
||||
expect(en).toContain('from English to Chinese')
|
||||
expect(en).toContain(terminology)
|
||||
expect(en).not.toContain('{{')
|
||||
expect(en).toContain('plain source stays plain (必须)')
|
||||
expect(en).toContain('When the target language is English, use the "English" column without a Chinese gloss')
|
||||
expect(en).toContain('for a Chinese target, use an established Chinese rendering')
|
||||
expect(en).toContain('for an English target, use the established English technical term')
|
||||
expect(en).toContain('does an English target use established English terminology')
|
||||
expect(en).toContain('The parser removes exactly one framing escape')
|
||||
const zh = renderTranslationPrompt(document, { sourceLanguage: 'Chinese', sourceFilename: 'guide.zh.md', terminology })
|
||||
expect(zh).toContain('from Chinese to English')
|
||||
})
|
||||
|
||||
it('rejects a template with unknown or missing placeholders', () => {
|
||||
const alien = document.replaceAll('{{terminology}}', '{{terms_prompt}}')
|
||||
expect(() => renderTranslationPrompt(alien, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })).toThrow(/unsupported placeholder/)
|
||||
const missing = document.replaceAll('{{terminology}}', '')
|
||||
expect(() => renderTranslationPrompt(missing, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })).toThrow(/required placeholder/)
|
||||
})
|
||||
|
||||
it('rejects unmatched placeholder delimiters', () => {
|
||||
for (const delimiter of ['{{', '}}']) {
|
||||
const malformed = document.replace('Your task is to translate', `Your task ${delimiter} is to translate`)
|
||||
expect(() => renderTranslationPrompt(malformed, {
|
||||
sourceLanguage: 'English',
|
||||
sourceFilename: 'guide.md',
|
||||
terminology,
|
||||
})).toThrow(/malformed placeholder syntax/)
|
||||
}
|
||||
})
|
||||
|
||||
it('assembles bare few-shot turns before the real source document', () => {
|
||||
const request = renderTranslationRequest(document, {
|
||||
sourceLanguage: 'English',
|
||||
sourceFilename: 'guide.md',
|
||||
translationRules: 'A literal {{source_lang}} in injected rules.',
|
||||
terminology: '| English | 中文 |',
|
||||
sourceDocument: '# Guide\n\nNew source.',
|
||||
terminology,
|
||||
examples: [{ english: '# Example\n\nEnglish.', chinese: '# 示例\n\n中文。' }],
|
||||
})
|
||||
expect(rendered).toContain('English to Chinese')
|
||||
expect(rendered).toContain('A literal {{source_lang}} in injected rules.')
|
||||
expect(rendered).toContain('[English](guide.md) | [中文](guide.zh.md)')
|
||||
})
|
||||
expect(request.targetFilename).toBe('guide.zh.md')
|
||||
expect(request.messages.map(message => message.role)).toEqual(['system', 'user', 'assistant', 'user'])
|
||||
expect(request.messages.slice(1).map(message => message.content)).toEqual([
|
||||
'# Example\n\nEnglish.',
|
||||
'# 示例\n\n中文。',
|
||||
'# Guide\n\nNew source.',
|
||||
])
|
||||
|
||||
it('rejects a filename whose suffix contradicts the source language', () => {
|
||||
expect(() => renderTranslationPrompt(document, {
|
||||
const reverse = renderTranslationRequest(document, {
|
||||
sourceLanguage: 'Chinese',
|
||||
sourceFilename: 'guide.md',
|
||||
translationRules: 'rules',
|
||||
terminology: 'terms',
|
||||
})).toThrow('does not match source language Chinese')
|
||||
})
|
||||
|
||||
it('rejects malformed template placeholders before injecting rule contents', () => {
|
||||
expect(() => renderTranslationPrompt(document.replace('{{source_lang}}', '{{source-lang}}'), {
|
||||
sourceLanguage: 'English',
|
||||
sourceFilename: 'guide.md',
|
||||
translationRules: 'A literal {{source_lang}} in injected rules.',
|
||||
terminology: '| English | 中文 |',
|
||||
})).toThrow('template contains malformed placeholder syntax')
|
||||
sourceFilename: 'guide.zh.md',
|
||||
sourceDocument: '# 指南\n\n新源文。',
|
||||
terminology,
|
||||
examples: [{ english: '# Example\n\nEnglish.', chinese: '# 示例\n\n中文。' }],
|
||||
})
|
||||
expect(reverse.targetFilename).toBe('guide.md')
|
||||
expect(reverse.messages.slice(1).map(message => message.content)).toEqual([
|
||||
'# 示例\n\n中文。',
|
||||
'# Example\n\nEnglish.',
|
||||
'# 指南\n\n新源文。',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('translation response XML', () => {
|
||||
it('round-trips Markdown and the CDATA terminator', () => {
|
||||
const response = {
|
||||
translation: '# Draft\n\nA ]]> marker.',
|
||||
review: '- [Tone] Fixed.',
|
||||
final: '# Final\n\nA ]]> marker.',
|
||||
}
|
||||
describe('translation response sections', () => {
|
||||
it('round-trips Markdown bodies', () => {
|
||||
const response = { translation: '# 标题\n\n正文 **加粗**。', review: '- [Tone] 修正一处。\n- 无修正', final: '# 标题\n\n定稿。' }
|
||||
expect(parseTranslationResponse(renderTranslationResponse(response))).toEqual(response)
|
||||
})
|
||||
|
||||
it('rejects missing, reordered, nested, attributed, or non-CDATA children', () => {
|
||||
expect(() => parseTranslationResponse('<dsh-translation-response version="1"/>')).toThrow('translation, review, and final')
|
||||
expect(() => parseTranslationResponse('<dsh-translation-response version="1"><review><![CDATA[x]]></review></dsh-translation-response>'))
|
||||
.toThrow('expected translation, got review')
|
||||
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' })
|
||||
.replace('<translation><![CDATA[x]]></translation>', '<translation><b><![CDATA[x]]></b></translation>')))
|
||||
.toThrow('nested element b is not allowed')
|
||||
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('<review>', '<review lang="en">')))
|
||||
.toThrow('review must not have attributes')
|
||||
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('<![CDATA[x]]>', 'x')))
|
||||
.toThrow('all response field content must be inside CDATA')
|
||||
it('tolerates a fenced xml wrapper around the whole response', () => {
|
||||
const fenced = '```xml\n<translation>\nA\n</translation>\n\n<review>\n- 无修正\n</review>\n\n<final>\nA\n</final>\n```'
|
||||
expect(parseTranslationResponse(fenced).final).toBe('A')
|
||||
})
|
||||
|
||||
it('keeps an inline close tag inside prose from terminating the section', () => {
|
||||
const doc = { translation: 'the wire format uses </translation> as its close tag', review: '- 无修正', final: 'F' }
|
||||
expect(parseTranslationResponse(renderTranslationResponse(doc))).toEqual(doc)
|
||||
})
|
||||
|
||||
it('round-trips wrapper-tag lines inside Markdown bodies', () => {
|
||||
const doc = {
|
||||
translation: '```xml\n</translation>\n```',
|
||||
review: '- [Structure] Preserved `<final>` on its own line.',
|
||||
final: 'literal delimiters\n</final>\n\\</final>',
|
||||
}
|
||||
const rendered = renderTranslationResponse(doc)
|
||||
expect(parseTranslationResponse(rendered)).toEqual(doc)
|
||||
expect(() => parseTranslationResponse(rendered.replace('\\</translation>', '</translation>'))).toThrow(/duplicate <translation>/)
|
||||
})
|
||||
|
||||
it('rejects a duplicate section appearing before final', () => {
|
||||
const early = '<translation>\nA\n</translation>\n<translation>\nB\n</translation>\n<review>\nR\n</review>\n<final>\nF\n</final>'
|
||||
expect(() => parseTranslationResponse(early)).toThrow(/duplicate <translation>/)
|
||||
})
|
||||
|
||||
it('rejects missing, unterminated, or duplicated sections', () => {
|
||||
expect(() => parseTranslationResponse('<translation>\nA\n</translation>')).toThrow(/missing or unterminated <review>/)
|
||||
expect(() => parseTranslationResponse('<translation>\nA')).toThrow(/missing or unterminated <translation>/)
|
||||
const dup = '<translation>\nA\n</translation>\n<review>\nR\n</review>\n<final>\nF\n</final>\n<final>\nG\n</final>'
|
||||
expect(() => parseTranslationResponse(dup)).toThrow(/duplicate <final>/)
|
||||
expect(() => parseTranslationResponse(`${renderTranslationResponse({ translation: 'A', review: 'R', final: 'F' })}\nstray`))
|
||||
.toThrow(/content is not allowed outside/)
|
||||
})
|
||||
|
||||
it('inserts or corrects the target switcher after parsing a new-pair response', () => {
|
||||
const response = renderTranslationResponse({
|
||||
translation: '# 指南\n\n初稿。',
|
||||
review: '- 无修正',
|
||||
final: '# 指南\n\nEnglish | [中文](guide.zh.md)\n\n定稿。',
|
||||
})
|
||||
expect(consumeTranslationResponse(response, { sourceLanguage: 'English', sourceFilename: 'guide.md' }).final).toBe([
|
||||
'# 指南',
|
||||
'',
|
||||
'[English](guide.md) | 中文',
|
||||
'',
|
||||
'定稿。',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('preserves YAML frontmatter before inserting the target switcher', () => {
|
||||
const response = renderTranslationResponse({
|
||||
translation: '# 指南\n\n初稿。',
|
||||
review: '- 无修正',
|
||||
final: [
|
||||
'---',
|
||||
'layout: home',
|
||||
'---',
|
||||
'',
|
||||
'# 指南',
|
||||
'',
|
||||
'定稿。',
|
||||
].join('\n'),
|
||||
})
|
||||
expect(consumeTranslationResponse(response, { sourceLanguage: 'English', sourceFilename: 'guide.md' }).final).toBe([
|
||||
'---',
|
||||
'layout: home',
|
||||
'---',
|
||||
'',
|
||||
'# 指南',
|
||||
'',
|
||||
'[English](guide.md) | 中文',
|
||||
'',
|
||||
'定稿。',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('rejects unterminated YAML frontmatter before the target H1', () => {
|
||||
const response = renderTranslationResponse({
|
||||
translation: '# 指南\n\n初稿。',
|
||||
review: '- 无修正',
|
||||
final: '---\nlayout: home\n\n# 指南\n\n定稿。',
|
||||
})
|
||||
expect(() => consumeTranslationResponse(response, {
|
||||
sourceLanguage: 'English',
|
||||
sourceFilename: 'guide.md',
|
||||
})).toThrow(/unterminated YAML frontmatter/)
|
||||
})
|
||||
|
||||
it('rejects a source filename that contradicts the translation direction', () => {
|
||||
expect(() => renderTranslationPrompt(document, {
|
||||
sourceLanguage: 'Chinese',
|
||||
sourceFilename: 'guide.md',
|
||||
terminology,
|
||||
})).toThrow(/does not match source language Chinese/)
|
||||
})
|
||||
|
||||
it('inserts the English target switcher for a Chinese source', () => {
|
||||
const response = renderTranslationResponse({
|
||||
translation: '# Guide\n\nDraft.',
|
||||
review: '- [None] No corrections.',
|
||||
final: '# Guide\n\nFinal.',
|
||||
})
|
||||
expect(consumeTranslationResponse(response, {
|
||||
sourceLanguage: 'Chinese',
|
||||
sourceFilename: 'guide.zh.md',
|
||||
}).final).toContain('\n\nEnglish | [中文](guide.zh.md)\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
/**
|
||||
* Executable renderer and strict response parser for the committed
|
||||
* documentation-translation prompt contract.
|
||||
* Executable renderer and response parser for the committed
|
||||
* documentation-translation prompt contract (prompt-v4).
|
||||
*
|
||||
* The v4 contract: three placeholders (`source_lang`, `target_lang`,
|
||||
* `terminology`), whole-document translation, and a three-section response
|
||||
* (`<translation>`, `<review>`, `<final>` in order, bare XML tags with raw
|
||||
* Markdown bodies). The pipeline retains filename context outside the model
|
||||
* request and corrects the final language switcher after parsing.
|
||||
*/
|
||||
|
||||
import { basename } from 'node:path'
|
||||
import { SaxesParser } from 'saxes'
|
||||
|
||||
/** Placeholder names supported by the committed translation prompt. */
|
||||
export const TRANSLATION_PROMPT_PLACEHOLDERS = [
|
||||
'source_lang',
|
||||
'target_lang',
|
||||
'translation_rules',
|
||||
'terminology',
|
||||
'source_filename',
|
||||
'source_filename_zh',
|
||||
] as const
|
||||
export const TRANSLATION_PROMPT_PLACEHOLDERS = ['source_lang', 'target_lang', 'terminology'] as const
|
||||
|
||||
type TranslationPromptPlaceholder = (typeof TRANSLATION_PROMPT_PLACEHOLDERS)[number]
|
||||
|
||||
@@ -26,13 +24,35 @@ export interface TranslationPromptInput {
|
||||
sourceLanguage: TranslationLanguage
|
||||
/** Source basename, including `.md` or `.zh.md`. */
|
||||
sourceFilename: string
|
||||
/** Complete current `translation-rules.md` contents. */
|
||||
translationRules: string
|
||||
/** Complete current `terminology.md` contents. */
|
||||
terminology: string
|
||||
}
|
||||
|
||||
/** Parsed contents of the three-element XML response. */
|
||||
/** One reviewed whole-document example available in both directions. */
|
||||
export interface TranslationExample {
|
||||
english: string
|
||||
chinese: string
|
||||
}
|
||||
|
||||
/** Inputs for one complete model request. */
|
||||
export interface TranslationRequestInput extends TranslationPromptInput {
|
||||
sourceDocument: string
|
||||
examples: TranslationExample[]
|
||||
}
|
||||
|
||||
/** One model message in the provider-neutral translation request. */
|
||||
interface TranslationMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
/** Fully assembled request plus the filename that receives the final body. */
|
||||
export interface TranslationRequest {
|
||||
targetFilename: string
|
||||
messages: TranslationMessage[]
|
||||
}
|
||||
|
||||
/** Parsed contents of the three-section response. */
|
||||
export interface TranslationResponse {
|
||||
translation: string
|
||||
review: string
|
||||
@@ -42,7 +62,35 @@ export interface TranslationResponse {
|
||||
const PLACEHOLDER = /{{([a-z_]+)}}/g
|
||||
const TEMPLATE_OPEN = '## 模板正文\n\n````text\n'
|
||||
const TEMPLATE_CLOSE = '\n````'
|
||||
const RESPONSE_CHILDREN = ['translation', 'review', 'final'] as const
|
||||
const RESPONSE_SECTIONS = ['translation', 'review', 'final'] as const
|
||||
const RESPONSE_DELIMITERS = new Set(RESPONSE_SECTIONS.flatMap(section => [`<${section}>`, `</${section}>`]))
|
||||
const LANGUAGE_SWITCHER = /^(?:English \| \[中文\]\(.+\)|\[English\]\(.+\) \| 中文)$/
|
||||
|
||||
interface TranslationFiles {
|
||||
targetFilename: string
|
||||
targetSwitcher: string
|
||||
}
|
||||
|
||||
function translationFiles(input: Pick<TranslationPromptInput, 'sourceFilename' | 'sourceLanguage'>): TranslationFiles {
|
||||
if (basename(input.sourceFilename) !== input.sourceFilename) {
|
||||
throw new Error(`translation prompt: sourceFilename must be a basename; got ${JSON.stringify(input.sourceFilename)}`)
|
||||
}
|
||||
const sourceIsChinese = input.sourceFilename.endsWith('.zh.md')
|
||||
const sourceIsEnglish = input.sourceFilename.endsWith('.md') && !sourceIsChinese
|
||||
if (input.sourceLanguage === 'Chinese' ? !sourceIsChinese : !sourceIsEnglish) {
|
||||
throw new Error(`translation prompt: ${input.sourceFilename} does not match source language ${input.sourceLanguage}`)
|
||||
}
|
||||
if (sourceIsChinese) {
|
||||
return {
|
||||
targetFilename: input.sourceFilename.replace(/\.zh\.md$/, '.md'),
|
||||
targetSwitcher: `English | [中文](${input.sourceFilename})`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
targetFilename: input.sourceFilename.replace(/\.md$/, '.zh.md'),
|
||||
targetSwitcher: `[English](${input.sourceFilename}) | 中文`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract the machine-consumed text fence from `translation-prompt.md`. */
|
||||
function extractTranslationPrompt(document: string): string {
|
||||
@@ -61,25 +109,14 @@ export function documentedTranslationPromptPlaceholders(document: string): strin
|
||||
return [...document.slice(0, preambleEnd).matchAll(/^\| `{{([a-z_]+)}}` \|/gm)].map(match => match[1] ?? '')
|
||||
}
|
||||
|
||||
/** Render one system prompt from the checked-in template and canonical rules. */
|
||||
/** Render one system prompt from the checked-in template. */
|
||||
export function renderTranslationPrompt(document: string, input: TranslationPromptInput): string {
|
||||
if (basename(input.sourceFilename) !== input.sourceFilename) {
|
||||
throw new Error(`translation prompt: sourceFilename must be a basename; got ${JSON.stringify(input.sourceFilename)}`)
|
||||
}
|
||||
const sourceIsChinese = input.sourceFilename.endsWith('.zh.md')
|
||||
if (input.sourceLanguage === 'Chinese' ? !sourceIsChinese : sourceIsChinese || !input.sourceFilename.endsWith('.md')) {
|
||||
throw new Error(`translation prompt: ${input.sourceFilename} does not match source language ${input.sourceLanguage}`)
|
||||
}
|
||||
|
||||
translationFiles(input)
|
||||
const targetLanguage: TranslationLanguage = input.sourceLanguage === 'English' ? 'Chinese' : 'English'
|
||||
const sourceFilenameZh = sourceIsChinese ? input.sourceFilename : input.sourceFilename.replace(/\.md$/, '.zh.md')
|
||||
const values: Record<TranslationPromptPlaceholder, string> = {
|
||||
source_lang: input.sourceLanguage,
|
||||
target_lang: targetLanguage,
|
||||
translation_rules: input.translationRules,
|
||||
terminology: input.terminology,
|
||||
source_filename: input.sourceFilename,
|
||||
source_filename_zh: sourceFilenameZh,
|
||||
}
|
||||
const template = extractTranslationPrompt(document)
|
||||
const placeholderFreeTemplate = template.replace(PLACEHOLDER, '')
|
||||
@@ -95,77 +132,128 @@ export function renderTranslationPrompt(document: string, input: TranslationProm
|
||||
return template.replace(PLACEHOLDER, (_token, name: string) => values[name as TranslationPromptPlaceholder])
|
||||
}
|
||||
|
||||
/** Escape one value so it remains byte-identical inside an XML CDATA field. */
|
||||
function escapeTranslationCdata(value: string): string {
|
||||
return value.replaceAll(']]>', ']]]]><![CDATA[>')
|
||||
/**
|
||||
* Assemble the calibrated system prompt, reviewed bare-text examples, and source document.
|
||||
*
|
||||
* @param document - Checked-in translation prompt asset.
|
||||
* @param input - Direction, filename, terminology, examples, and source document.
|
||||
* @returns Provider-neutral messages and the target basename.
|
||||
*/
|
||||
export function renderTranslationRequest(document: string, input: TranslationRequestInput): TranslationRequest {
|
||||
const files = translationFiles(input)
|
||||
const sourceKey = input.sourceLanguage === 'English' ? 'english' : 'chinese'
|
||||
const targetKey = input.sourceLanguage === 'English' ? 'chinese' : 'english'
|
||||
const messages: TranslationMessage[] = [{ role: 'system', content: renderTranslationPrompt(document, input) }]
|
||||
for (const example of input.examples) {
|
||||
messages.push(
|
||||
{ role: 'user', content: example[sourceKey] },
|
||||
{ role: 'assistant', content: example[targetKey] },
|
||||
)
|
||||
}
|
||||
messages.push({ role: 'user', content: input.sourceDocument })
|
||||
return { targetFilename: files.targetFilename, messages }
|
||||
}
|
||||
|
||||
/** Serialize a response using the exact XML wire contract in the prompt. */
|
||||
function escapeResponseBody(value: string): string {
|
||||
return value.split('\n').map((line) => {
|
||||
const delimiter = line.replace(/^\\+/, '')
|
||||
return RESPONSE_DELIMITERS.has(delimiter) ? `\\${line}` : line
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
function unescapeResponseBody(value: string): string {
|
||||
return value.split('\n').map((line) => {
|
||||
if (!line.startsWith('\\')) return line
|
||||
const candidate = line.slice(1)
|
||||
return RESPONSE_DELIMITERS.has(candidate.replace(/^\\+/, '')) ? candidate : line
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
/** Serialize a response in the exact escaped three-section shape the prompt requests. */
|
||||
export function renderTranslationResponse(response: TranslationResponse): string {
|
||||
return [
|
||||
'<dsh-translation-response version="1">',
|
||||
`<translation><![CDATA[${escapeTranslationCdata(response.translation)}]]></translation>`,
|
||||
`<review><![CDATA[${escapeTranslationCdata(response.review)}]]></review>`,
|
||||
`<final><![CDATA[${escapeTranslationCdata(response.final)}]]></final>`,
|
||||
'</dsh-translation-response>',
|
||||
].join('\n')
|
||||
return RESPONSE_SECTIONS.map(section => `<${section}>\n${escapeResponseBody(response[section])}\n</${section}>`).join('\n\n')
|
||||
}
|
||||
|
||||
/** Parse and validate the exact XML response shape emitted by the model. */
|
||||
export function parseTranslationResponse(xml: string): TranslationResponse {
|
||||
const values: TranslationResponse = { translation: '', review: '', final: '' }
|
||||
const stack: string[] = []
|
||||
const cdataFields = new Set<string>()
|
||||
let rootSeen = false
|
||||
let childIndex = 0
|
||||
const fail = (message: string): never => {
|
||||
throw new Error(`translation response: ${message}`)
|
||||
}
|
||||
const parser = new SaxesParser({ xmlns: false })
|
||||
/**
|
||||
* Parse the three-section response. Sections must each appear exactly once
|
||||
* and in order; escaped delimiter lines in Markdown bodies are restored.
|
||||
* A fenced ```xml wrapper around the whole response is tolerated, matching
|
||||
* the shape some models echo back from the prompt's own example.
|
||||
*/
|
||||
export function parseTranslationResponse(text: string): TranslationResponse {
|
||||
let body = text.trim()
|
||||
const fenced = /^```(?:xml)?\n([\s\S]*?)\n```$/.exec(body)
|
||||
if (fenced?.[1] !== undefined) body = fenced[1].trim()
|
||||
|
||||
parser.on('opentag', (tag) => {
|
||||
if (stack.length === 0) {
|
||||
if (rootSeen) fail('contains more than one root element')
|
||||
if (tag.name !== 'dsh-translation-response') fail(`expected dsh-translation-response root, got ${tag.name}`)
|
||||
const attributes = Object.keys(tag.attributes)
|
||||
if (attributes.length !== 1 || tag.attributes.version !== '1') fail('root must have only version="1"')
|
||||
rootSeen = true
|
||||
} else if (stack.length === 1) {
|
||||
const expected = RESPONSE_CHILDREN[childIndex]
|
||||
if (tag.name !== expected) fail(`expected ${expected ?? 'no more children'}, got ${tag.name}`)
|
||||
if (Object.keys(tag.attributes).length !== 0) fail(`${tag.name} must not have attributes`)
|
||||
childIndex++
|
||||
} else {
|
||||
fail(`nested element ${tag.name} is not allowed`)
|
||||
const values: Partial<Record<(typeof RESPONSE_SECTIONS)[number], string>> = {}
|
||||
const lines = body.split('\n')
|
||||
let previousCloseEnd = 0
|
||||
for (const [index, section] of RESPONSE_SECTIONS.entries()) {
|
||||
const open = `<${section}>`
|
||||
const close = `</${section}>`
|
||||
const openCount = lines.filter(line => line === open).length
|
||||
const closeCount = lines.filter(line => line === close).length
|
||||
if (openCount === 0 || closeCount === 0) {
|
||||
throw new Error(`translation response: missing or unterminated <${section}> section`)
|
||||
}
|
||||
stack.push(tag.name)
|
||||
})
|
||||
parser.on('text', (value) => {
|
||||
if (stack.length <= 1 && value.trim() === '') return
|
||||
fail('all response field content must be inside CDATA')
|
||||
})
|
||||
parser.on('cdata', (value) => {
|
||||
const field = stack.at(-1)
|
||||
if (field === undefined || !RESPONSE_CHILDREN.includes(field as (typeof RESPONSE_CHILDREN)[number])) {
|
||||
fail('CDATA is allowed only inside translation, review, or final')
|
||||
}
|
||||
const key = field as (typeof RESPONSE_CHILDREN)[number]
|
||||
values[key] += value
|
||||
cdataFields.add(key)
|
||||
})
|
||||
parser.on('closetag', (tag) => {
|
||||
const expected = stack.pop()
|
||||
if (expected !== tag.name) fail(`closing ${tag.name} does not match ${expected ?? 'nothing'}`)
|
||||
})
|
||||
parser.on('comment', () => fail('comments are not allowed'))
|
||||
parser.on('doctype', () => fail('doctypes are not allowed'))
|
||||
parser.on('processinginstruction', () => fail('processing instructions are not allowed'))
|
||||
parser.on('error', error => fail(`invalid XML: ${error.message}`))
|
||||
parser.write(xml).close()
|
||||
if (openCount > 1 || closeCount > 1) throw new Error(`translation response: duplicate <${section}> section`)
|
||||
|
||||
if (childIndex !== RESPONSE_CHILDREN.length) fail('translation, review, and final must each appear exactly once and in order')
|
||||
for (const field of RESPONSE_CHILDREN) {
|
||||
if (!cdataFields.has(field)) fail(`${field} must contain a CDATA section`)
|
||||
const openStart = body.search(new RegExp(`^<${section}>$`, 'm'))
|
||||
const closeStart = body.search(new RegExp(`^</${section}>$`, 'm'))
|
||||
const separator = body.slice(previousCloseEnd, openStart)
|
||||
if (closeStart < openStart || (index === 0 ? separator !== '' : !/^\n+$/.test(separator))) {
|
||||
throw new Error('translation response: sections must appear in translation, review, final order')
|
||||
}
|
||||
|
||||
let contentStart = openStart + open.length
|
||||
if (body[contentStart] === '\n') contentStart++
|
||||
let contentEnd = closeStart
|
||||
if (body[contentEnd - 1] === '\n') contentEnd--
|
||||
values[section] = unescapeResponseBody(body.slice(contentStart, contentEnd))
|
||||
previousCloseEnd = closeStart + close.length
|
||||
}
|
||||
return values
|
||||
if (previousCloseEnd !== body.length) throw new Error('translation response: content is not allowed outside response sections')
|
||||
return values as TranslationResponse
|
||||
}
|
||||
|
||||
function correctLanguageSwitcher(markdown: string, switcher: string): string {
|
||||
const lines = markdown.replaceAll('\r\n', '\n').split('\n')
|
||||
while (lines.at(-1) === '') lines.pop()
|
||||
|
||||
let headingIndex = 0
|
||||
if (lines[0] === '---') {
|
||||
const frontmatterEnd = lines.indexOf('---', 1)
|
||||
if (frontmatterEnd === -1) throw new Error('translation response: final document has unterminated YAML frontmatter')
|
||||
headingIndex = frontmatterEnd + 1
|
||||
while (lines[headingIndex] === '') headingIndex++
|
||||
}
|
||||
if (!/^#\s+\S/.test(lines[headingIndex] ?? '')) {
|
||||
throw new Error('translation response: final document must start with an H1 heading')
|
||||
}
|
||||
|
||||
let contentStart = headingIndex + 1
|
||||
while (lines[contentStart] === '') contentStart++
|
||||
if (LANGUAGE_SWITCHER.test(lines[contentStart] ?? '')) contentStart++
|
||||
while (lines[contentStart] === '') contentStart++
|
||||
|
||||
const output = [...lines.slice(0, headingIndex), lines[headingIndex] as string, '', switcher]
|
||||
const content = lines.slice(contentStart)
|
||||
if (content.length > 0) output.push('', ...content)
|
||||
return `${output.join('\n')}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a model response and make its consumed final document target-path correct.
|
||||
*
|
||||
* @param text - Raw three-section model response.
|
||||
* @param input - Source direction and basename retained by the pipeline.
|
||||
* @returns Parsed response whose `final` body has the canonical target switcher.
|
||||
*/
|
||||
export function consumeTranslationResponse(
|
||||
text: string,
|
||||
input: Pick<TranslationPromptInput, 'sourceFilename' | 'sourceLanguage'>,
|
||||
): TranslationResponse {
|
||||
const parsed = parseTranslationResponse(text)
|
||||
const files = translationFiles(input)
|
||||
return { ...parsed, final: correctLanguageSwitcher(parsed.final, files.targetSwitcher) }
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import {
|
||||
consumeTranslationResponse,
|
||||
documentedTranslationPromptPlaceholders,
|
||||
parseTranslationResponse,
|
||||
renderTranslationPrompt,
|
||||
renderTranslationRequest,
|
||||
renderTranslationResponse,
|
||||
TRANSLATION_PROMPT_PLACEHOLDERS,
|
||||
type TranslationExample,
|
||||
} from './translation-prompt.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -17,38 +20,76 @@ function read(path: string): string {
|
||||
}
|
||||
|
||||
try {
|
||||
const mode = process.argv[2]
|
||||
if (mode !== undefined && mode !== '--snapshot') throw new Error(`unsupported argument ${JSON.stringify(mode)}`)
|
||||
const document = read('docs/i18n/translation-prompt.md')
|
||||
const translationRules = read('docs/i18n/translation-rules.md')
|
||||
const terminology = read('docs/i18n/terminology.md')
|
||||
const examplePaths = [
|
||||
['README.md', 'README.zh.md'],
|
||||
['docs/development.md', 'docs/development.zh.md'],
|
||||
['docs/i18n/README.md', 'docs/i18n/README.zh.md'],
|
||||
['docs/i18n/translation-rules.md', 'docs/i18n/translation-rules.zh.md'],
|
||||
[
|
||||
'.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md',
|
||||
'.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md',
|
||||
],
|
||||
] as const
|
||||
const examples: TranslationExample[] = examplePaths.map(([english, chinese]) => ({
|
||||
english: read(english),
|
||||
chinese: read(chinese),
|
||||
}))
|
||||
const sourceDocument = read('scripts/fixtures/translation-prompt/snapshot-note.md')
|
||||
const recordedResponse = read('scripts/fixtures/translation-prompt/response.txt')
|
||||
const documented = documentedTranslationPromptPlaceholders(document)
|
||||
if (documented.join('\n') !== TRANSLATION_PROMPT_PLACEHOLDERS.join('\n')) {
|
||||
throw new Error(`placeholder table must list exactly: ${TRANSLATION_PROMPT_PLACEHOLDERS.join(', ')}`)
|
||||
}
|
||||
|
||||
const englishSource = renderTranslationPrompt(document, {
|
||||
sourceLanguage: 'English',
|
||||
sourceFilename: 'example.md',
|
||||
translationRules,
|
||||
terminology,
|
||||
})
|
||||
const englishInput = { sourceLanguage: 'English' as const, sourceFilename: 'snapshot-note.md', terminology }
|
||||
const englishSource = renderTranslationPrompt(document, englishInput)
|
||||
const chineseSource = renderTranslationPrompt(document, {
|
||||
sourceLanguage: 'Chinese',
|
||||
sourceFilename: 'example.zh.md',
|
||||
translationRules,
|
||||
sourceFilename: 'snapshot-note.zh.md',
|
||||
terminology,
|
||||
})
|
||||
if (!englishSource.includes('[English](example.md) | 中文')) throw new Error('English-source render does not carry the Chinese switcher instruction')
|
||||
if (!chineseSource.includes('English | [中文](example.zh.md)')) throw new Error('Chinese-source render does not carry the English switcher instruction')
|
||||
if (englishSource.includes('{{') || chineseSource.includes('{{')) throw new Error('rendered prompt contains an unresolved placeholder')
|
||||
if (!englishSource.includes('from English to Chinese')) throw new Error('English-source render does not translate into Chinese')
|
||||
if (!chineseSource.includes('from Chinese to English')) throw new Error('Chinese-source render does not translate into English')
|
||||
|
||||
const example = /```xml\n([\s\S]*?)\n```/.exec(englishSource)?.[1]
|
||||
if (example === undefined) throw new Error('rendered prompt has no XML response example')
|
||||
if (example === undefined) throw new Error('rendered prompt has no three-section response example')
|
||||
parseTranslationResponse(example)
|
||||
|
||||
const roundTrip = { translation: 'first ]]> pass', review: '- [None] No corrections.', final: 'final ]]> text' }
|
||||
const roundTrip = { translation: 'first pass\n\nwith **markdown**', review: '- 无修正', final: 'final text' }
|
||||
const parsed = parseTranslationResponse(renderTranslationResponse(roundTrip))
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(roundTrip)) throw new Error('CDATA split rule does not round-trip response content')
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(roundTrip)) throw new Error('three-section response does not round-trip')
|
||||
|
||||
console.log('verify-translation-prompt: both directions render and the XML response contract parses.')
|
||||
const request = renderTranslationRequest(document, { ...englishInput, sourceDocument, examples })
|
||||
if (request.targetFilename !== 'snapshot-note.zh.md') throw new Error('English request resolves the wrong target filename')
|
||||
const expectedRoles = ['system', ...examples.flatMap(() => ['user', 'assistant']), 'user']
|
||||
if (request.messages.map(message => message.role).join('\n') !== expectedRoles.join('\n')) {
|
||||
throw new Error('reviewed examples are not assembled as system, example pairs, then source')
|
||||
}
|
||||
const consumed = consumeTranslationResponse(recordedResponse, englishInput)
|
||||
const expectedFinalPrefix = [
|
||||
'---',
|
||||
'layout: doc',
|
||||
'---',
|
||||
'',
|
||||
'# 快照说明',
|
||||
'',
|
||||
'[English](snapshot-note.md) | 中文',
|
||||
'',
|
||||
].join('\n')
|
||||
if (!consumed.final.startsWith(expectedFinalPrefix)) {
|
||||
throw new Error('recorded frontmatter response does not preserve metadata and receive the canonical target switcher')
|
||||
}
|
||||
|
||||
if (mode === '--snapshot') {
|
||||
process.stdout.write(`${JSON.stringify({ request, response: consumed }, null, 2)}\n`)
|
||||
} else {
|
||||
console.log('verify-translation-prompt: both directions render, reviewed examples assemble, and the consumed response is target-path correct.')
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`verify-translation-prompt: ${message}`)
|
||||
|
||||
@@ -20,10 +20,11 @@ const snapshotMaxConcurrency = positiveIntFromEnv(
|
||||
Math.min(DEFAULT_SNAPSHOT_MAX_CONCURRENCY, availableParallelism()),
|
||||
)
|
||||
|
||||
// Replay is the keyless default: boot real example subprocesses from recorded model scripts and diff
|
||||
// normalized protocol or transcript output plus persisted-log expected outputs. `record` calls the real API
|
||||
// and updates fixtures and expected outputs; `refresh` replays committed scripts and updates current expected outputs.
|
||||
// Replay/refresh never load `.env`; only record reads a key from the environment or root `.env`.
|
||||
// Replay is the keyless default: boot real subprocess paths from recorded model responses and diff
|
||||
// assembled requests, normalized protocol or transcript output, and persisted-log expected outputs.
|
||||
// `record` calls the real API and updates fixtures and expected outputs; `refresh` replays committed scripts
|
||||
// and updates current expected outputs. Replay/refresh never load `.env`; only record reads a key from the
|
||||
// environment or root `.env`.
|
||||
if (process.env.DSH_SNAPSHOT === 'record') {
|
||||
try {
|
||||
process.loadEnvFile(new URL('.env', import.meta.url).pathname)
|
||||
@@ -42,6 +43,7 @@ export default defineConfig({
|
||||
test: {
|
||||
setupFiles: ['./scripts/test-invariants.ts'],
|
||||
include: [
|
||||
'scripts/**/*.snapshot.ts',
|
||||
// The assembled Web snapshot executes generated client bundles; source
|
||||
// mode remains the zero-build path, while lib mode requires a prior build.
|
||||
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? ['apps/web/tests/**/*.snapshot.ts'] : []),
|
||||
|
||||
Reference in New Issue
Block a user