diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index ab1c9024ad..a1ff3a701e 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -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: 17bb1eeb67b4f5119a698fca23f12490c9378a7f -README.zh.md: c957a82bf420a942e2249942a2d9afc54ad950cf +README.md: 3980aef52545aeb7c8ec44856cb95c7c0f7c7f22 +README.zh.md: 39e03cb6d33c008d2f310915db6b6bebd638cded diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 17bb1eeb67..3980aef525 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -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`. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index c957a82bf4..39e03cb6d3 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -49,4 +49,4 @@ ## 分工 -对侧译文由运行 [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、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。prompt 契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把进仓模板(注入术语表;模板自带经人工校准的规则)渲染到英译中或中译英的 prompt 中,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。 diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index d59170b00a..91b52bbb51 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -14,6 +14,8 @@ 流水线只识别上表中的占位符,并且一次翻译整篇文档。它不支持 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}`、`{{translation_rules}}` 或 `%%` 分段协议;输出采用模板正文规定的三段 XML,流水线解析取 `` 段。 +语言切换行:已有配对的源文件自带切换行,模型按模板规则翻转即可。全新配对的源文件没有切换行,模型也无从得知文件名——此时由流水线在解析 `` 后按目标文件名插入或校正切换行(机械后处理,配对门禁兜底校验)。 + ## Few-shot 金标 流水线使用**整篇文档**的中英对照作为 few-shot,不是模板内嵌的句子级正误例。以下 5 组配对文档均经过人工评审,以仓库当前版本为准、随仓库更新: @@ -41,7 +43,7 @@ You are a senior technical translator specializing in LLM and agent development - 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 `**`, always insert a space before the next character. +- 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). ### 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. @@ -73,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), render the corresponding Chinese term in italics: *必须*、*禁止*、*应当*、*可以*. +- 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 (**必须**). #### When translating into English (To be added.) diff --git a/scripts/translation-prompt.spec.ts b/scripts/translation-prompt.spec.ts index db3f8b31a0..45ba97b8e7 100644 --- a/scripts/translation-prompt.spec.ts +++ b/scripts/translation-prompt.spec.ts @@ -42,9 +42,19 @@ describe('translation response sections', () => { 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 as its close tag', review: '- 无修正', final: 'F' } + expect(parseTranslationResponse(renderTranslationResponse(doc))).toEqual(doc) + }) + + 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 /) + }) + it('rejects missing, unterminated, or duplicated sections', () => { - expect(() => parseTranslationResponse('\nA\n')).toThrow(/missing /) - expect(() => parseTranslationResponse('\nA')).toThrow(/unterminated /) + expect(() => parseTranslationResponse('\nA\n')).toThrow(/missing or unterminated /) + 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 /) }) diff --git a/scripts/translation-prompt.ts b/scripts/translation-prompt.ts index 0556ff39fa..25f1d9ae09 100644 --- a/scripts/translation-prompt.ts +++ b/scripts/translation-prompt.ts @@ -81,6 +81,10 @@ export function renderTranslationResponse(response: TranslationResponse): string * and in order; bodies are raw Markdown taken verbatim between the tags. * 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() @@ -88,20 +92,16 @@ export function parseTranslationResponse(text: string): TranslationResponse { if (fenced?.[1] !== undefined) body = fenced[1].trim() const values: Partial> = {} - let cursor = 0 for (const section of RESPONSE_SECTIONS) { - const open = `<${section}>` - const close = `` - const start = body.indexOf(open, cursor) - if (start === -1) throw new Error(`translation response: missing <${section}> section`) - const end = body.indexOf(close, start + open.length) - if (end === -1) throw new Error(`translation response: unterminated <${section}> section`) - values[section] = body.slice(start + open.length, end).replace(/^\n/, '').replace(/\n$/, '') - cursor = end + close.length + const pattern = new RegExp(`^<${section}>\\n?([\\s\\S]*?)\\n?^$`, '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`) + values[section] = first[1] } - for (const section of RESPONSE_SECTIONS) { - const again = body.indexOf(`<${section}>`, cursor) - if (again !== -1) throw new Error(`translation response: duplicate <${section}> section`) + const order = RESPONSE_SECTIONS.map(section => body.search(new RegExp(`^<${section}>`, 'm'))) + if (!(order[0]! < order[1]! && order[1]! < order[2]!)) { + throw new Error('translation response: sections must appear in translation, review, final order') } return values as TranslationResponse }