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