translation-prompt.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. /**
  2. * Executable renderer and response parser for the committed
  3. * documentation-translation prompt contract (prompt-v4).
  4. *
  5. * The v4 contract: three placeholders (`source_lang`, `target_lang`,
  6. * `terminology`), whole-document translation, and a three-section response
  7. * (`<translation>`, `<review>`, `<final>` in order, bare XML tags with raw
  8. * Markdown bodies). The pipeline retains filename context outside the model
  9. * request and corrects the final language switcher after parsing.
  10. */
  11. import { basename } from 'node:path'
  12. /** Placeholder names supported by the committed translation prompt. */
  13. export const TRANSLATION_PROMPT_PLACEHOLDERS = ['source_lang', 'target_lang', 'terminology'] as const
  14. type TranslationPromptPlaceholder = (typeof TRANSLATION_PROMPT_PLACEHOLDERS)[number]
  15. /** Languages accepted by the bidirectional prompt. */
  16. type TranslationLanguage = 'English' | 'Chinese'
  17. /** Inputs that vary for one rendered translation request. */
  18. export interface TranslationPromptInput {
  19. sourceLanguage: TranslationLanguage
  20. /** Source basename, including `.md` or `.zh.md`. */
  21. sourceFilename: string
  22. /** Complete current `terminology.md` contents. */
  23. terminology: string
  24. }
  25. /** One reviewed whole-document example available in both directions. */
  26. export interface TranslationExample {
  27. english: string
  28. chinese: string
  29. }
  30. /** Inputs for one complete model request. */
  31. export interface TranslationRequestInput extends TranslationPromptInput {
  32. sourceDocument: string
  33. examples: TranslationExample[]
  34. }
  35. /** One model message in the provider-neutral translation request. */
  36. interface TranslationMessage {
  37. role: 'system' | 'user' | 'assistant'
  38. content: string
  39. }
  40. /** Fully assembled request plus the filename that receives the final body. */
  41. export interface TranslationRequest {
  42. targetFilename: string
  43. messages: TranslationMessage[]
  44. }
  45. /** Parsed contents of the three-section response. */
  46. export interface TranslationResponse {
  47. translation: string
  48. review: string
  49. final: string
  50. }
  51. const PLACEHOLDER = /{{([a-z_]+)}}/g
  52. const TEMPLATE_OPEN = '## 模板正文\n\n````text\n'
  53. const TEMPLATE_CLOSE = '\n````'
  54. const RESPONSE_SECTIONS = ['translation', 'review', 'final'] as const
  55. const RESPONSE_DELIMITERS = new Set(RESPONSE_SECTIONS.flatMap(section => [`<${section}>`, `</${section}>`]))
  56. const LANGUAGE_SWITCHER = /^(?:English \| \[中文\]\(.+\)|\[English\]\(.+\) \| 中文)$/
  57. interface TranslationFiles {
  58. targetFilename: string
  59. targetSwitcher: string
  60. }
  61. function translationFiles(input: Pick<TranslationPromptInput, 'sourceFilename' | 'sourceLanguage'>): TranslationFiles {
  62. if (basename(input.sourceFilename) !== input.sourceFilename) {
  63. throw new Error(`translation prompt: sourceFilename must be a basename; got ${JSON.stringify(input.sourceFilename)}`)
  64. }
  65. const sourceIsChinese = input.sourceFilename.endsWith('.zh.md')
  66. const sourceIsEnglish = input.sourceFilename.endsWith('.md') && !sourceIsChinese
  67. if (input.sourceLanguage === 'Chinese' ? !sourceIsChinese : !sourceIsEnglish) {
  68. throw new Error(`translation prompt: ${input.sourceFilename} does not match source language ${input.sourceLanguage}`)
  69. }
  70. if (sourceIsChinese) {
  71. return {
  72. targetFilename: input.sourceFilename.replace(/\.zh\.md$/, '.md'),
  73. targetSwitcher: `English | [中文](${input.sourceFilename})`,
  74. }
  75. }
  76. return {
  77. targetFilename: input.sourceFilename.replace(/\.md$/, '.zh.md'),
  78. targetSwitcher: `[English](${input.sourceFilename}) | 中文`,
  79. }
  80. }
  81. /** Extract the machine-consumed text fence from `translation-prompt.md`. */
  82. function extractTranslationPrompt(document: string): string {
  83. const start = document.indexOf(TEMPLATE_OPEN)
  84. if (start === -1) throw new Error('translation prompt: missing `## 模板正文` text fence')
  85. const contentStart = start + TEMPLATE_OPEN.length
  86. const end = document.indexOf(TEMPLATE_CLOSE, contentStart)
  87. if (end === -1) throw new Error('translation prompt: missing closing four-backtick fence')
  88. return document.slice(contentStart, end)
  89. }
  90. /** Read the placeholder names documented in the prompt's contract table. */
  91. export function documentedTranslationPromptPlaceholders(document: string): string[] {
  92. const preambleEnd = document.indexOf(TEMPLATE_OPEN)
  93. if (preambleEnd === -1) throw new Error('translation prompt: missing template body')
  94. return [...document.slice(0, preambleEnd).matchAll(/^\| `{{([a-z_]+)}}` \|/gm)].map(match => match[1] ?? '')
  95. }
  96. /** Render one system prompt from the checked-in template. */
  97. export function renderTranslationPrompt(document: string, input: TranslationPromptInput): string {
  98. translationFiles(input)
  99. const targetLanguage: TranslationLanguage = input.sourceLanguage === 'English' ? 'Chinese' : 'English'
  100. const values: Record<TranslationPromptPlaceholder, string> = {
  101. source_lang: input.sourceLanguage,
  102. target_lang: targetLanguage,
  103. terminology: input.terminology,
  104. }
  105. const template = extractTranslationPrompt(document)
  106. const placeholderFreeTemplate = template.replace(PLACEHOLDER, '')
  107. if (placeholderFreeTemplate.includes('{{') || placeholderFreeTemplate.includes('}}')) {
  108. throw new Error('translation prompt: template contains malformed placeholder syntax')
  109. }
  110. const names = [...template.matchAll(PLACEHOLDER)].map(match => match[1] ?? '')
  111. const unknown = names.filter(name => !TRANSLATION_PROMPT_PLACEHOLDERS.includes(name as TranslationPromptPlaceholder))
  112. if (unknown.length > 0) throw new Error(`translation prompt: unsupported placeholder(s): ${[...new Set(unknown)].join(', ')}`)
  113. const missing = TRANSLATION_PROMPT_PLACEHOLDERS.filter(name => !names.includes(name))
  114. if (missing.length > 0) throw new Error(`translation prompt: template does not use required placeholder(s): ${missing.join(', ')}`)
  115. return template.replace(PLACEHOLDER, (_token, name: string) => values[name as TranslationPromptPlaceholder])
  116. }
  117. /**
  118. * Assemble the calibrated system prompt, reviewed bare-text examples, and source document.
  119. *
  120. * @param document - Checked-in translation prompt asset.
  121. * @param input - Direction, filename, terminology, examples, and source document.
  122. * @returns Provider-neutral messages and the target basename.
  123. */
  124. export function renderTranslationRequest(document: string, input: TranslationRequestInput): TranslationRequest {
  125. const files = translationFiles(input)
  126. const sourceKey = input.sourceLanguage === 'English' ? 'english' : 'chinese'
  127. const targetKey = input.sourceLanguage === 'English' ? 'chinese' : 'english'
  128. const messages: TranslationMessage[] = [{ role: 'system', content: renderTranslationPrompt(document, input) }]
  129. for (const example of input.examples) {
  130. messages.push(
  131. { role: 'user', content: example[sourceKey] },
  132. { role: 'assistant', content: example[targetKey] },
  133. )
  134. }
  135. messages.push({ role: 'user', content: input.sourceDocument })
  136. return { targetFilename: files.targetFilename, messages }
  137. }
  138. function escapeResponseBody(value: string): string {
  139. return value.split('\n').map((line) => {
  140. const delimiter = line.replace(/^\\+/, '')
  141. return RESPONSE_DELIMITERS.has(delimiter) ? `\\${line}` : line
  142. }).join('\n')
  143. }
  144. function unescapeResponseBody(value: string): string {
  145. return value.split('\n').map((line) => {
  146. if (!line.startsWith('\\')) return line
  147. const candidate = line.slice(1)
  148. return RESPONSE_DELIMITERS.has(candidate.replace(/^\\+/, '')) ? candidate : line
  149. }).join('\n')
  150. }
  151. /** Serialize a response in the exact escaped three-section format the prompt requests. */
  152. export function renderTranslationResponse(response: TranslationResponse): string {
  153. return RESPONSE_SECTIONS.map(section => `<${section}>\n${escapeResponseBody(response[section])}\n</${section}>`).join('\n\n')
  154. }
  155. /**
  156. * Parse the three-section response. Sections must each appear exactly once
  157. * and in order; escaped delimiter lines in Markdown bodies are restored.
  158. * A fenced ```xml wrapper around the whole response is tolerated, matching
  159. * the wrapper some models copy from the prompt's own example.
  160. */
  161. export function parseTranslationResponse(text: string): TranslationResponse {
  162. let body = text.trim()
  163. const fenced = /^```(?:xml)?\n([\s\S]*?)\n```$/.exec(body)
  164. if (fenced?.[1] !== undefined) body = fenced[1].trim()
  165. const values: Partial<Record<(typeof RESPONSE_SECTIONS)[number], string>> = {}
  166. const lines = body.split('\n')
  167. let previousCloseEnd = 0
  168. for (const [index, section] of RESPONSE_SECTIONS.entries()) {
  169. const open = `<${section}>`
  170. const close = `</${section}>`
  171. const openCount = lines.filter(line => line === open).length
  172. const closeCount = lines.filter(line => line === close).length
  173. if (openCount === 0 || closeCount === 0) {
  174. throw new Error(`translation response: missing or unterminated <${section}> section`)
  175. }
  176. if (openCount > 1 || closeCount > 1) throw new Error(`translation response: duplicate <${section}> section`)
  177. const openStart = body.search(new RegExp(`^<${section}>$`, 'm'))
  178. const closeStart = body.search(new RegExp(`^</${section}>$`, 'm'))
  179. const separator = body.slice(previousCloseEnd, openStart)
  180. if (closeStart < openStart || (index === 0 ? separator !== '' : !/^\n+$/.test(separator))) {
  181. throw new Error('translation response: sections must appear in translation, review, final order')
  182. }
  183. let contentStart = openStart + open.length
  184. if (body[contentStart] === '\n') contentStart++
  185. let contentEnd = closeStart
  186. if (body[contentEnd - 1] === '\n') contentEnd--
  187. values[section] = unescapeResponseBody(body.slice(contentStart, contentEnd))
  188. previousCloseEnd = closeStart + close.length
  189. }
  190. if (previousCloseEnd !== body.length) throw new Error('translation response: content is not allowed outside response sections')
  191. return values as TranslationResponse
  192. }
  193. function correctLanguageSwitcher(markdown: string, switcher: string): string {
  194. const lines = markdown.replaceAll('\r\n', '\n').split('\n')
  195. while (lines.at(-1) === '') lines.pop()
  196. let headingIndex = 0
  197. if (lines[0] === '---') {
  198. const frontmatterEnd = lines.indexOf('---', 1)
  199. if (frontmatterEnd === -1) throw new Error('translation response: final document has unterminated YAML frontmatter')
  200. headingIndex = frontmatterEnd + 1
  201. while (lines[headingIndex] === '') headingIndex++
  202. }
  203. if (!/^#\s+\S/.test(lines[headingIndex] ?? '')) {
  204. throw new Error('translation response: final document must start with an H1 heading')
  205. }
  206. let contentStart = headingIndex + 1
  207. while (lines[contentStart] === '') contentStart++
  208. if (LANGUAGE_SWITCHER.test(lines[contentStart] ?? '')) contentStart++
  209. while (lines[contentStart] === '') contentStart++
  210. const output = [...lines.slice(0, headingIndex), lines[headingIndex] as string, '', switcher]
  211. const content = lines.slice(contentStart)
  212. if (content.length > 0) output.push('', ...content)
  213. return `${output.join('\n')}\n`
  214. }
  215. /**
  216. * Parse a model response and make its consumed final document target-path correct.
  217. *
  218. * @param text - Raw three-section model response.
  219. * @param input - Source direction and basename retained by the pipeline.
  220. * @returns Parsed response whose `final` body has the canonical target switcher.
  221. */
  222. export function consumeTranslationResponse(
  223. text: string,
  224. input: Pick<TranslationPromptInput, 'sourceFilename' | 'sourceLanguage'>,
  225. ): TranslationResponse {
  226. const parsed = parseTranslationResponse(text)
  227. const files = translationFiles(input)
  228. return { ...parsed, final: correctLanguageSwitcher(parsed.final, files.targetSwitcher) }
  229. }