From 6046a13a291b7b430d9659ba37a6b0f0634bd64d Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Thu, 23 Jul 2026 21:44:41 +0800
Subject: [PATCH] fix(i18n): harden prompt v4 contract
---
...3-translation-prompt-v4-contract.i18n.yaml | 6 ++
...26-07-23-translation-prompt-v4-contract.md | 33 +++++++++++
...07-23-translation-prompt-v4-contract.zh.md | 33 +++++++++++
docs/i18n/translation-prompt.md | 16 +++--
scripts/translation-prompt.spec.ts | 16 +++++
scripts/translation-prompt.ts | 59 ++++++++++++++-----
6 files changed, 141 insertions(+), 22 deletions(-)
create mode 100644 .agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml
create mode 100644 .agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md
create mode 100644 .agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md
diff --git a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml
new file mode 100644
index 0000000000..f682c1760c
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml
@@ -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: be7a1c5c78ff770d554dd694b1e4760d2b5c0415
+2026-07-23-translation-prompt-v4-contract.zh.md: a637361cc853ba652fe25adcba8ef15dde0df75d
diff --git a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md
new file mode 100644
index 0000000000..be7a1c5c78
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md
@@ -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); whole reviewed document pairs supply the few-shot examples. 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 pipeline consumes `final` after parsing, then mechanically inserts or corrects the language switcher for the target path. 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 and parser](../../../../scripts/translation-prompt.ts). Its tests cover both render directions, strict section order and cardinality, fenced responses, inline tag mentions, and delimiter lines inside Markdown bodies.
+
+## 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 plus the translation-prompt verifier. 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.
diff --git a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md
new file mode 100644
index 0000000000..a637361cc8
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md
@@ -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);few-shot 示例由经过评审的整篇文档对提供。模板可以包含针对特定模型的校准规则,但这些规则必须服从仓库中具约束力的配对、术语、结构与强调格式契约。
+
+响应包含三个有序的顶层分段:`translation`、`review` 和 `final`。流水线在解析后读取 `final`,随后依据目标路径以机械方式插入或校正语言切换行。解析器要求每个分段恰好出现一次,拒绝封套之外的内容,并允许响应最外层有一层 `xml` Markdown 围栏,因为模型有时会照抄提示词中的示例围栏。
+
+## 响应封装格式
+
+分段定界行由协议格式(wire format)保留。当 Markdown 正文中的某一行仅包含定界标签(前面可以带反斜杠)时,序列化器和模型会在行首再添加一个反斜杠;解析器则只移除一个。这种保留计数的转义方式让字面量定界标签与已转义的定界标签都能无损往返,同时不会改动行内提及的标签。
+
+可执行契约由[渲染器和解析器](../../../../scripts/translation-prompt.ts)实现。其测试覆盖双向渲染、严格的分段顺序与数量约束、带围栏的响应、行内提及标签,以及 Markdown 正文中的定界行。
+
+## 考虑过的替代方案
+
+**在每个请求中注入 `translation-rules.md`。** 该文档既约束人类与 agent,也约束自动翻译流水线。注入它会让编辑规范的每次澄清都与模型行为耦合,并挤占经过人工校准的提示词约束;因此流水线仅注入具约束力的术语表,并直接校验自身资源。
+
+**使用严格的 CDATA XML 文档。** CDATA 提供通用的 XML 封装,但会引入一层嵌套协议、额外的 `]]>` 转义规则,以及三段式契约原本不需要的 XML 解析器行为。预留并转义六种定界行,既能维持经校准的响应形态,也能保留任意 Markdown 内容不变。
+
+**只返回最终译文。** 单一正文更易解析,却会丢弃显式修正步骤;这个步骤用于在发布前发现语气、结构、术语和标点缺陷。
+
+## 影响
+
+提示词措辞属于可执行行为,因此既接受代码评审,也由翻译提示词校验器校验。经校准的资源与通用翻译规则可以针对各自的受众分别演进,但评审必须拒绝任何与仓库约束性契约冲突的改动。只有当源文档中的封装标签独占一行时,行转义才会显现;解析器测试锁定这一无损行为。
diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md
index 570cbdc452..54ce4e0abd 100644
--- a/docs/i18n/translation-prompt.md
+++ b/docs/i18n/translation-prompt.md
@@ -1,6 +1,6 @@
# Translation prompt (pipeline asset)
-本文件是自动翻译流水线的 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 只用于说明典型问题,两者冲突时以文体样例为准。修改本文件会改变翻译行为,需正常经过 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 评审。
## 占位符契约
@@ -75,7 +75,7 @@ You are a senior technical translator specializing in LLM and agent development
- 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 in italics (*必须*), bold source stays bold (**必须**).
+- 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.)
@@ -84,7 +84,8 @@ You are a senior technical translator specializing in LLM and agent development
A terminology table is provided below. Follow it strictly:
- Render every listed term exactly as specified.
-- First occurrence: write as shown in the "首次出现" column (with parenthetical gloss). Subsequent occurrences: write only the part before the parentheses.
+- 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: keep them in the source language. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.
@@ -95,6 +96,8 @@ A terminology table is provided below. Follow it strictly:
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 ``, ``, ``, ``, ``, or ``, 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
(Complete translation of the source document)
@@ -121,7 +124,8 @@ After writing ``, re-read it in the target language only, without l
- 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 bold markers followed by a space?
+- 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?
@@ -137,14 +141,14 @@ After writing ``, re-read it in the target language only, without l
- Is any slang or internal jargon present?
**Terminology**
-- Are first-occurrence glosses correctly applied (not missing, not repeated)?
+- 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?
- Are unlisted terms correctly kept in the source language?
**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?
-- Are RFC 2119 keywords rendered in italics?
+- Do RFC 2119 keywords preserve the source emphasis exactly?
Record corrections in `` with category tags. Then output the corrected version in ``. If no corrections are needed, write "无修正" in `` and copy the translation unchanged into ``.
diff --git a/scripts/translation-prompt.spec.ts b/scripts/translation-prompt.spec.ts
index 45ba97b8e7..6869fdf09b 100644
--- a/scripts/translation-prompt.spec.ts
+++ b/scripts/translation-prompt.spec.ts
@@ -19,6 +19,9 @@ describe('translation prompt rendering', () => {
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('The parser removes exactly one framing escape')
const zh = renderTranslationPrompt(document, { sourceLanguage: 'Chinese', terminology })
expect(zh).toContain('from Chinese to English')
})
@@ -47,6 +50,17 @@ describe('translation response sections', () => {
expect(parseTranslationResponse(renderTranslationResponse(doc))).toEqual(doc)
})
+ it('round-trips wrapper-tag lines inside Markdown bodies', () => {
+ const doc = {
+ translation: '```xml\n\n```',
+ review: '- [Structure] Preserved `` on its own line.',
+ final: 'literal delimiters\n\n\\',
+ }
+ const rendered = renderTranslationResponse(doc)
+ expect(parseTranslationResponse(rendered)).toEqual(doc)
+ expect(() => parseTranslationResponse(rendered.replace('\\', ''))).toThrow(/duplicate /)
+ })
+
it('rejects a duplicate section appearing before final', () => {
const early = '\nA\n\n\nB\n\n\nR\n\n\nF\n'
expect(() => parseTranslationResponse(early)).toThrow(/duplicate /)
@@ -57,5 +71,7 @@ describe('translation response sections', () => {
expect(() => parseTranslationResponse('\nA')).toThrow(/missing or unterminated /)
const dup = '\nA\n\n\nR\n\n\nF\n\n\nG\n'
expect(() => parseTranslationResponse(dup)).toThrow(/duplicate /)
+ expect(() => parseTranslationResponse(`${renderTranslationResponse({ translation: 'A', review: 'R', final: 'F' })}\nstray`))
+ .toThrow(/content is not allowed outside/)
})
})
diff --git a/scripts/translation-prompt.ts b/scripts/translation-prompt.ts
index 9f093d3e47..039d4a1dbe 100644
--- a/scripts/translation-prompt.ts
+++ b/scripts/translation-prompt.ts
@@ -35,6 +35,7 @@ const PLACEHOLDER = /{{([a-z_]+)}}/g
const TEMPLATE_OPEN = '## 模板正文\n\n````text\n'
const TEMPLATE_CLOSE = '\n````'
const RESPONSE_SECTIONS = ['translation', 'review', 'final'] as const
+const RESPONSE_DELIMITERS = new Set(RESPONSE_SECTIONS.flatMap(section => [`<${section}>`, `${section}>`]))
/** Extract the machine-consumed text fence from `translation-prompt.md`. */
function extractTranslationPrompt(document: string): string {
@@ -71,20 +72,31 @@ export function renderTranslationPrompt(document: string, input: TranslationProm
return template.replace(PLACEHOLDER, (_token, name: string) => values[name as TranslationPromptPlaceholder])
}
-/** Serialize a response in the exact three-section shape the prompt requests. */
+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 RESPONSE_SECTIONS.map(section => `<${section}>\n${response[section]}\n${section}>`).join('\n\n')
+ return RESPONSE_SECTIONS.map(section => `<${section}>\n${escapeResponseBody(response[section])}\n${section}>`).join('\n\n')
}
/**
* Parse the three-section response. Sections must each appear exactly once
- * and in order; bodies are raw Markdown taken verbatim between the tags.
+ * 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.
- *
- * Section close tags are matched at line starts (the wire shape the prompt
- * example establishes), so a tag mentioned inline in translated prose does
- * not terminate its section early.
*/
export function parseTranslationResponse(text: string): TranslationResponse {
let body = text.trim()
@@ -92,17 +104,32 @@ export function parseTranslationResponse(text: string): TranslationResponse {
if (fenced?.[1] !== undefined) body = fenced[1].trim()
const values: Partial> = {}
- let previousSectionStart = -1
- for (const section of RESPONSE_SECTIONS) {
- const pattern = new RegExp(`^<${section}>\\n?([\\s\\S]*?)\\n?^${section}>$`, 'gm')
- const first = pattern.exec(body)
- if (first?.[1] === undefined) throw new Error(`translation response: missing or unterminated <${section}> section`)
- if (pattern.exec(body) !== null) throw new Error(`translation response: duplicate <${section}> section`)
- if (first.index <= previousSectionStart) {
+ 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`)
+ }
+ if (openCount > 1 || closeCount > 1) throw new Error(`translation response: duplicate <${section}> 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')
}
- previousSectionStart = first.index
- values[section] = first[1]
+
+ 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
}
+ if (previousCloseEnd !== body.length) throw new Error('translation response: content is not allowed outside response sections')
return values as TranslationResponse
}