-
Notifications
You must be signed in to change notification settings - Fork 17.2k
Expand file tree
/
Copy pathtranslation-prompt.ts
More file actions
259 lines (229 loc) · 11.3 KB
/
Copy pathtranslation-prompt.ts
File metadata and controls
259 lines (229 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
/**
* 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'
/** Placeholder names supported by the committed translation prompt. */
export const TRANSLATION_PROMPT_PLACEHOLDERS = ['source_lang', 'target_lang', 'terminology'] as const
type TranslationPromptPlaceholder = (typeof TRANSLATION_PROMPT_PLACEHOLDERS)[number]
/** Languages accepted by the bidirectional prompt. */
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
review: string
final: string
}
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}>`]))
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 {
const start = document.indexOf(TEMPLATE_OPEN)
if (start === -1) throw new Error('translation prompt: missing `## 模板正文` text fence')
const contentStart = start + TEMPLATE_OPEN.length
const end = document.indexOf(TEMPLATE_CLOSE, contentStart)
if (end === -1) throw new Error('translation prompt: missing closing four-backtick fence')
return document.slice(contentStart, end)
}
/** Read the placeholder names documented in the prompt's contract table. */
export function documentedTranslationPromptPlaceholders(document: string): string[] {
const preambleEnd = document.indexOf(TEMPLATE_OPEN)
if (preambleEnd === -1) throw new Error('translation prompt: missing template body')
return [...document.slice(0, preambleEnd).matchAll(/^\| `{{([a-z_]+)}}` \|/gm)].map(match => match[1] ?? '')
}
/** 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,
target_lang: targetLanguage,
terminology: input.terminology,
}
const template = extractTranslationPrompt(document)
const placeholderFreeTemplate = template.replace(PLACEHOLDER, '')
if (placeholderFreeTemplate.includes('{{') || placeholderFreeTemplate.includes('}}')) {
throw new Error('translation prompt: template contains malformed placeholder syntax')
}
const names = [...template.matchAll(PLACEHOLDER)].map(match => match[1] ?? '')
const unknown = names.filter(name => !TRANSLATION_PROMPT_PLACEHOLDERS.includes(name as TranslationPromptPlaceholder))
if (unknown.length > 0) throw new Error(`translation prompt: unsupported placeholder(s): ${[...new Set(unknown)].join(', ')}`)
const missing = TRANSLATION_PROMPT_PLACEHOLDERS.filter(name => !names.includes(name))
if (missing.length > 0) throw new Error(`translation prompt: template does not use required placeholder(s): ${missing.join(', ')}`)
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(/^\\+/, '')
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 format the prompt requests. */
export function renderTranslationResponse(response: TranslationResponse): string {
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; escaped delimiter lines in Markdown bodies are restored.
* A fenced ```xml wrapper around the whole response is tolerated, matching
* the wrapper some models copy 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()
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`)
}
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')
}
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
}
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) }
}