fix(i18n): harden prompt v4 contract

This commit is contained in:
Tianyi Cui
2026-07-23 21:44:41 +08:00
parent a66ed5474c
commit 6046a13a29
6 changed files with 141 additions and 22 deletions

View File

@@ -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</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>/)
@@ -57,5 +71,7 @@ describe('translation response sections', () => {
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/)
})
})

View File

@@ -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<Record<(typeof RESPONSE_SECTIONS)[number], string>> = {}
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
}