mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(i18n): complete prompt v4 pipeline path
This commit is contained in:
@@ -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
|
||||
2026-07-23-translation-prompt-v4-contract.md: be7a1c5c78ff770d554dd694b1e4760d2b5c0415
|
||||
2026-07-23-translation-prompt-v4-contract.zh.md: a637361cc853ba652fe25adcba8ef15dde0df75d
|
||||
2026-07-23-translation-prompt-v4-contract.md: b288f14e784687ab194392cd94a872aa5324a812
|
||||
2026-07-23-translation-prompt-v4-contract.zh.md: 3b1bcba90dc6d4b2eea1e9b2c0af65db3ca0969b
|
||||
|
||||
@@ -10,15 +10,15 @@ Automated counterpart generation needs a stable prompt that reproduces the regis
|
||||
|
||||
## 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 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). 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 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.
|
||||
The response has three ordered top-level sections: `translation`, `review`, and `final`. The response consumer derives the target basename from the retained source context and mechanically inserts or corrects the language switcher 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 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.
|
||||
The executable contract lives in [the renderer, request assembler, parser, and response consumer](../../../../scripts/translation-prompt.ts). Unit tests cover both directions, request order, target-path validation, strict section order and cardinality, fenced responses, inline tag mentions, delimiter lines inside Markdown bodies, and new-pair switcher correction. A keyless subprocess snapshot pins the assembled prompt and five reviewed example turns together with a recorded response consumed through the target-path correction.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -30,4 +30,4 @@ The executable contract lives in [the renderer and parser](../../../../scripts/t
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
@@ -10,15 +10,15 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
提交入库的[翻译提示词](../../../../docs/i18n/translation-prompt.md)是经过校准的流水线资源。其渲染器仅注入源语言、目标语言和当前[术语表](../../../../docs/i18n/terminology.md);few-shot 示例由经过评审的整篇文档对提供。模板可以包含针对特定模型的校准规则,但这些规则必须服从仓库中具约束力的配对、术语、结构与强调格式契约。
|
||||
提交入库的[翻译提示词](../../../../docs/i18n/translation-prompt.md)是经过校准的流水线资源。其渲染器仅注入源语言、目标语言和当前[术语表](../../../../docs/i18n/terminology.md)。请求组装器在模型可见的提示词之外保留源文件基本名,并在真正的源文档之前,将每组经评审的整篇文档对编排为一个纯文本 user/assistant 示例轮次。模板可以包含针对特定模型的校准规则,但这些规则必须服从仓库中具约束力的配对、术语、结构与强调格式契约。
|
||||
|
||||
响应包含三个有序的顶层分段:`translation`、`review` 和 `final`。流水线在解析后读取 `final`,随后依据目标路径以机械方式插入或校正语言切换行。解析器要求每个分段恰好出现一次,拒绝封套之外的内容,并允许响应最外层有一层 `xml` Markdown 围栏,因为模型有时会照抄提示词中的示例围栏。
|
||||
响应包含三个有序的顶层分段:`translation`、`review` 和 `final`。响应消费方根据保留的源文件上下文推导目标文件基本名,并以机械方式在 `final` 中插入或校正语言切换行。解析器要求每个分段恰好出现一次,拒绝封套之外的内容,并允许响应最外层有一层 `xml` Markdown 围栏,因为模型有时会照抄提示词中的示例围栏。
|
||||
|
||||
## 响应封装格式
|
||||
|
||||
分段定界行由协议格式(wire format)保留。当 Markdown 正文中的某一行仅包含定界标签(前面可以带反斜杠)时,序列化器和模型会在行首再添加一个反斜杠;解析器则只移除一个。这种保留计数的转义方式让字面量定界标签与已转义的定界标签都能无损往返,同时不会改动行内提及的标签。
|
||||
|
||||
可执行契约由[渲染器和解析器](../../../../scripts/translation-prompt.ts)实现。其测试覆盖双向渲染、严格的分段顺序与数量约束、带围栏的响应、行内提及标签,以及 Markdown 正文中的定界行。
|
||||
可执行契约由[渲染器、请求组装器、解析器和响应消费方](../../../../scripts/translation-prompt.ts)实现。单元测试覆盖两个翻译方向、请求顺序、目标路径校验、严格的分段顺序与数量约束、带围栏的响应、行内提及标签、Markdown 正文中的定界行,以及新配对的语言切换行校正。一个无密钥子进程快照锁定组装后的提示词、五个经评审的示例轮次,以及录制响应经目标路径校正后的消费结果。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -30,4 +30,4 @@ Status: implemented
|
||||
|
||||
## 影响
|
||||
|
||||
提示词措辞属于可执行行为,因此既接受代码评审,也由翻译提示词校验器校验。经校准的资源与通用翻译规则可以针对各自的受众分别演进,但评审必须拒绝任何与仓库约束性契约冲突的改动。只有当源文档中的封装标签独占一行时,行转义才会显现;解析器测试锁定这一无损行为。
|
||||
提示词措辞属于可执行行为,因此需要经过代码评审、翻译提示词校验器校验及可运行的请求/响应快照验证。经校准的资源与通用翻译规则可以针对各自的受众分别演进,但评审必须拒绝任何与仓库约束性契约冲突的改动。只有当源文档中的封装标签独占一行时,行转义才会显现;解析器测试锁定这一无损行为。
|
||||
|
||||
@@ -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
|
||||
|
||||
15
scripts/fixtures/translation-prompt/response.txt
Normal file
15
scripts/fixtures/translation-prompt/response.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
<translation>
|
||||
# 快照说明
|
||||
|
||||
agent(智能体)执行一个步骤。
|
||||
</translation>
|
||||
|
||||
<review>
|
||||
- 无修正
|
||||
</review>
|
||||
|
||||
<final>
|
||||
# 快照说明
|
||||
|
||||
agent(智能体)执行一个步骤。
|
||||
</final>
|
||||
3
scripts/fixtures/translation-prompt/snapshot-note.md
Normal file
3
scripts/fixtures/translation-prompt/snapshot-note.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 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)
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,10 @@ 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'
|
||||
|
||||
@@ -15,7 +17,7 @@ const terminology = '| English | 中文 |\n|---|---|\n| agent | agent |'
|
||||
|
||||
describe('translation prompt rendering', () => {
|
||||
it('renders both directions with every placeholder resolved', () => {
|
||||
const en = renderTranslationPrompt(document, { sourceLanguage: 'English', terminology })
|
||||
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('{{')
|
||||
@@ -25,15 +27,46 @@ describe('translation prompt 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', terminology })
|
||||
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', terminology })).toThrow(/unsupported placeholder/)
|
||||
expect(() => renderTranslationPrompt(alien, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })).toThrow(/unsupported placeholder/)
|
||||
const missing = document.replaceAll('{{terminology}}', '')
|
||||
expect(() => renderTranslationPrompt(missing, { sourceLanguage: 'English', terminology })).toThrow(/required placeholder/)
|
||||
expect(() => renderTranslationPrompt(missing, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })).toThrow(/required placeholder/)
|
||||
})
|
||||
|
||||
it('assembles bare few-shot turns before the real source document', () => {
|
||||
const request = renderTranslationRequest(document, {
|
||||
sourceLanguage: 'English',
|
||||
sourceFilename: 'guide.md',
|
||||
sourceDocument: '# Guide\n\nNew source.',
|
||||
terminology,
|
||||
examples: [{ english: '# Example\n\nEnglish.', chinese: '# 示例\n\n中文。' }],
|
||||
})
|
||||
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.',
|
||||
])
|
||||
|
||||
const reverse = renderTranslationRequest(document, {
|
||||
sourceLanguage: 'Chinese',
|
||||
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新源文。',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,4 +110,40 @@ describe('translation response sections', () => {
|
||||
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('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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
* 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 switcher filename is spelled out by the model from
|
||||
* the document itself; the pipeline injects no other repository file.
|
||||
* Markdown bodies). The pipeline retains filename context outside the model
|
||||
* request and corrects the final language switcher after parsing.
|
||||
*/
|
||||
|
||||
import { basename } from 'node:path'
|
||||
|
||||
/** Placeholder names supported by the committed translation prompt. */
|
||||
export const TRANSLATION_PROMPT_PLACEHOLDERS = ['source_lang', 'target_lang', 'terminology'] as const
|
||||
|
||||
@@ -20,10 +22,36 @@ type TranslationLanguage = 'English' | 'Chinese'
|
||||
/** Inputs that vary for one rendered translation request. */
|
||||
export interface TranslationPromptInput {
|
||||
sourceLanguage: TranslationLanguage
|
||||
/** Source basename, including `.md` or `.zh.md`. */
|
||||
sourceFilename: string
|
||||
/** Complete current `terminology.md` contents. */
|
||||
terminology: string
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -36,6 +64,33 @@ 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}>`]))
|
||||
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 {
|
||||
@@ -56,6 +111,7 @@ export function documentedTranslationPromptPlaceholders(document: string): strin
|
||||
|
||||
/** Render one system prompt from the checked-in template. */
|
||||
export function renderTranslationPrompt(document: string, input: TranslationPromptInput): string {
|
||||
translationFiles(input)
|
||||
const targetLanguage: TranslationLanguage = input.sourceLanguage === 'English' ? 'Chinese' : 'English'
|
||||
const values: Record<TranslationPromptPlaceholder, string> = {
|
||||
source_lang: input.sourceLanguage,
|
||||
@@ -72,6 +128,28 @@ export function renderTranslationPrompt(document: string, input: TranslationProm
|
||||
return template.replace(PLACEHOLDER, (_token, name: string) => values[name as TranslationPromptPlaceholder])
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
|
||||
function escapeResponseBody(value: string): string {
|
||||
return value.split('\n').map((line) => {
|
||||
const delimiter = line.replace(/^\\+/, '')
|
||||
@@ -133,3 +211,37 @@ export function parseTranslationResponse(text: string): TranslationResponse {
|
||||
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()
|
||||
if (!/^#\s+\S/.test(lines[0] ?? '')) {
|
||||
throw new Error('translation response: final document must start with an H1 heading')
|
||||
}
|
||||
|
||||
let contentStart = 1
|
||||
while (lines[contentStart] === '') contentStart++
|
||||
if (LANGUAGE_SWITCHER.test(lines[contentStart] ?? '')) contentStart++
|
||||
while (lines[contentStart] === '') contentStart++
|
||||
|
||||
const output = [lines[0] 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,15 +20,38 @@ 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 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', terminology })
|
||||
const chineseSource = renderTranslationPrompt(document, { sourceLanguage: 'Chinese', terminology })
|
||||
const englishInput = { sourceLanguage: 'English' as const, sourceFilename: 'snapshot-note.md', terminology }
|
||||
const englishSource = renderTranslationPrompt(document, englishInput)
|
||||
const chineseSource = renderTranslationPrompt(document, {
|
||||
sourceLanguage: 'Chinese',
|
||||
sourceFilename: 'snapshot-note.zh.md',
|
||||
terminology,
|
||||
})
|
||||
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')
|
||||
@@ -38,7 +64,22 @@ try {
|
||||
const parsed = parseTranslationResponse(renderTranslationResponse(roundTrip))
|
||||
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 three-section 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)
|
||||
if (consumed.final.split('\n')[2] !== '[English](snapshot-note.md) | 中文') {
|
||||
throw new Error('recorded new-pair response does not 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',
|
||||
'examples/*/tests/**/*.snapshot.ts',
|
||||
'packages/sdk/*/tests/**/*.snapshot.ts',
|
||||
'packages/ui/tui/tests/**/*.snapshot.ts',
|
||||
|
||||
Reference in New Issue
Block a user