fix(i18n): complete prompt v4 pipeline path
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user