fix(i18n): complete prompt v4 pipeline path

This commit is contained in:
Tianyi Cui
2026-07-23 22:41:53 +08:00
parent db99f6881d
commit caaf4f8d18
13 changed files with 361 additions and 27 deletions

View File

@@ -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) }
}