Merge remote-tracking branch 'origin/master' into worktree/pr343-retarget-latest-master
# Conflicts: # docs/i18n/README.i18n.yaml # docs/i18n/README.zh.md
This commit is contained in:
@@ -7,7 +7,7 @@ import { spawn } from 'node:child_process'
|
||||
|
||||
// Each UI's node invocation matches its base demo script plus the overlay config.
|
||||
const UIS = new Map([
|
||||
['tui', ['--expose-internals', '--import', 'tsx', 'packages/examples/tui-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']],
|
||||
['tui', ['--import', 'tsx', 'packages/examples/tui-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']],
|
||||
['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
|
||||
])
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<translation>
|
||||
---
|
||||
layout: doc
|
||||
---
|
||||
|
||||
# 快照说明
|
||||
|
||||
agent(智能体)执行一个步骤。
|
||||
</translation>
|
||||
|
||||
<review>
|
||||
- 无修正
|
||||
</review>
|
||||
|
||||
<final>
|
||||
---
|
||||
layout: doc
|
||||
---
|
||||
|
||||
# 快照说明
|
||||
|
||||
agent(智能体)执行一个步骤。
|
||||
</final>
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
layout: doc
|
||||
---
|
||||
|
||||
# Snapshot note
|
||||
|
||||
The agent performs one step.
|
||||
@@ -94,6 +94,7 @@ export const LINK_MAP: Record<string, string> = {
|
||||
CreateSessionOptions: 'persistence.md',
|
||||
SessionHeader: 'persistence.md',
|
||||
SessionLocation: 'persistence.md',
|
||||
SessionPersistenceSnapshot: 'persistence.md',
|
||||
ConfinedArgv: 'sandbox.md',
|
||||
SandboxExecutionPolicy: 'sandbox.md',
|
||||
SandboxMode: 'sandbox.md',
|
||||
@@ -120,11 +121,20 @@ export const LINK_MAP: Record<string, string> = {
|
||||
TurnTrigger: 'session.md',
|
||||
SessionEventReadRequest: 'session-query.md',
|
||||
SessionEventRecord: 'session-query.md',
|
||||
SessionEventResultFilter: 'session-query.md',
|
||||
SessionEventSearchDocument: 'session-query.md',
|
||||
SessionEventSearchHit: 'session-query.md',
|
||||
SessionEventSearchRequest: 'session-query.md',
|
||||
SessionEventTrace: 'session-query.md',
|
||||
SessionEventTraceRequest: 'session-query.md',
|
||||
SessionEventWindow: 'session-query.md',
|
||||
SessionLineageTrace: 'session-query.md',
|
||||
SessionRecord: 'session-query.md',
|
||||
SessionResultFilter: 'session-query.md',
|
||||
SessionSearchExecContext: 'session-query.md',
|
||||
SessionSearchHit: 'session-query.md',
|
||||
SessionSearchPage: 'session-query.md',
|
||||
SessionSearchRequest: 'session-query.md',
|
||||
SessionTitleProvider: 'session-title.md',
|
||||
SessionTitleSnapshot: 'session-title.md',
|
||||
SkillDefinition: 'skills.md',
|
||||
|
||||
@@ -112,7 +112,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'session',
|
||||
title: 'In-memory session store',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'subagent-inprocess'],
|
||||
consumers: ['agent-loop', 'agent', 'cli-demo', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'],
|
||||
note: 'Owns append-only Session instances and emits the durable session event feed.',
|
||||
},
|
||||
{
|
||||
@@ -129,16 +129,17 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Durable session persistence seam',
|
||||
mode: 'seam',
|
||||
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
|
||||
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query'],
|
||||
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query', 'session-query-sqlite'],
|
||||
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
|
||||
},
|
||||
{
|
||||
key: 'sessionQuery',
|
||||
pkg: 'session-query',
|
||||
title: 'Exact session-history reads and traces',
|
||||
title: 'Session reads, traces, filters, and search',
|
||||
mode: 'seam',
|
||||
implementations: ['session-query-sqlite'],
|
||||
consumers: ['session-reference'],
|
||||
note: 'Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces.',
|
||||
note: 'The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service.',
|
||||
},
|
||||
{
|
||||
key: 'sessionReferences',
|
||||
@@ -978,7 +979,7 @@ function renderToolPipeline(): string {
|
||||
const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
|
||||
return [
|
||||
...generatedHeader('Tool Execution Pipeline'),
|
||||
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards and `tools/result` are the owner-enforced boundaries around them.',
|
||||
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards, definition-owned `finalizeContent`, and `tools/result` are the owner-enforced boundaries around them.',
|
||||
'',
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
@@ -994,6 +995,8 @@ function renderToolPipeline(): string {
|
||||
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
|
||||
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
|
||||
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
|
||||
' normalized["Registry outer normalization<br/>pipeline/result snapshot throws become isError"]',
|
||||
' finalize["ToolDefinition.finalizeContent<br/>last content-only invariant"]',
|
||||
` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`,
|
||||
' context["Active-batch additionalContexts FIFO<br/>context/message after recorded tool results"]',
|
||||
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
|
||||
@@ -1005,25 +1008,32 @@ function renderToolPipeline(): string {
|
||||
' pre -->|allow| guards',
|
||||
' guards -->|allow| around',
|
||||
' guards -->|deny| denied',
|
||||
' guards -.->|throw| normalized',
|
||||
' around --> toolBody',
|
||||
' pre -->|deny| denied',
|
||||
' pre -->|ask| approval',
|
||||
' approval -->|allowed-once| guards',
|
||||
' approval -->|rejected, cancelled, unavailable| denied',
|
||||
' approval -.->|throw| normalized',
|
||||
' denied --> post',
|
||||
' pre -.->|throw| normalized',
|
||||
' toolBody --> fsGate',
|
||||
' fsGate --> toolBody',
|
||||
' toolBody --> owned',
|
||||
' toolBody --> around',
|
||||
' around --> post',
|
||||
' post --> final',
|
||||
' around -.->|wrapper throws| normalized',
|
||||
' post -.->|throw| normalized',
|
||||
' post --> finalize',
|
||||
' normalized --> finalize',
|
||||
' finalize --> final',
|
||||
' final --> toolResult',
|
||||
' toolResult --> presentResult',
|
||||
' toolResult --> allResults',
|
||||
' allResults --> context',
|
||||
'```',
|
||||
'',
|
||||
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
|
||||
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
|
||||
'',
|
||||
...maintenanceFooter(maintenance),
|
||||
].join('\n')
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
+180
-92
@@ -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) }
|
||||
}
|
||||
@@ -1148,6 +1148,61 @@
|
||||
"doc": "docs/core-data-structures/lsp.md",
|
||||
"symbol": "LspService",
|
||||
"source": "packages/lsp/lsp/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.md",
|
||||
"symbol": "SessionPersistenceRevision",
|
||||
"source": "packages/session-persistence/session-persistence/src/revision.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.md",
|
||||
"symbol": "SessionPersistenceSnapshot",
|
||||
"source": "packages/session-persistence/session-persistence/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.md",
|
||||
"symbol": "SessionResultFilter",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.md",
|
||||
"symbol": "SessionEventResultFilter",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.md",
|
||||
"symbol": "SessionEventSearchDocument",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.md",
|
||||
"symbol": "SessionSearchCursor",
|
||||
"source": "packages/session-query/session-query/src/cursor.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.md",
|
||||
"symbol": "SessionSearchRequest",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.md",
|
||||
"symbol": "SessionEventSearchRequest",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.md",
|
||||
"symbol": "SessionSearchPage",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.md",
|
||||
"symbol": "SessionEventSearchHit",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.md",
|
||||
"symbol": "SessionSearchHit",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -53,6 +53,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' },
|
||||
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
@@ -76,6 +77,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
|
||||
'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
|
||||
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
|
||||
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' },
|
||||
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user