translation-prompt.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. /**
  2. * Executable renderer and strict response parser for the committed
  3. * documentation-translation prompt contract.
  4. */
  5. import { basename } from 'node:path'
  6. import { SaxesParser } from 'saxes'
  7. /** Placeholder names supported by the committed translation prompt. */
  8. export const TRANSLATION_PROMPT_PLACEHOLDERS = [
  9. 'source_lang',
  10. 'target_lang',
  11. 'translation_rules',
  12. 'terminology',
  13. 'source_filename',
  14. 'source_filename_zh',
  15. ] as const
  16. type TranslationPromptPlaceholder = (typeof TRANSLATION_PROMPT_PLACEHOLDERS)[number]
  17. /** Languages accepted by the bidirectional prompt. */
  18. type TranslationLanguage = 'English' | 'Chinese'
  19. /** Inputs that vary for one rendered translation request. */
  20. export interface TranslationPromptInput {
  21. sourceLanguage: TranslationLanguage
  22. /** Source basename, including `.md` or `.zh.md`. */
  23. sourceFilename: string
  24. /** Complete current `translation-rules.md` contents. */
  25. translationRules: string
  26. /** Complete current `terminology.md` contents. */
  27. terminology: string
  28. }
  29. /** Parsed contents of the three-element XML response. */
  30. export interface TranslationResponse {
  31. translation: string
  32. review: string
  33. final: string
  34. }
  35. const PLACEHOLDER = /{{([a-z_]+)}}/g
  36. const TEMPLATE_OPEN = '## 模板正文\n\n````text\n'
  37. const TEMPLATE_CLOSE = '\n````'
  38. const RESPONSE_CHILDREN = ['translation', 'review', 'final'] as const
  39. /** Extract the machine-consumed text fence from `translation-prompt.md`. */
  40. function extractTranslationPrompt(document: string): string {
  41. const start = document.indexOf(TEMPLATE_OPEN)
  42. if (start === -1) throw new Error('translation prompt: missing `## 模板正文` text fence')
  43. const contentStart = start + TEMPLATE_OPEN.length
  44. const end = document.indexOf(TEMPLATE_CLOSE, contentStart)
  45. if (end === -1) throw new Error('translation prompt: missing closing four-backtick fence')
  46. return document.slice(contentStart, end)
  47. }
  48. /** Read the placeholder names documented in the prompt's contract table. */
  49. export function documentedTranslationPromptPlaceholders(document: string): string[] {
  50. const preambleEnd = document.indexOf(TEMPLATE_OPEN)
  51. if (preambleEnd === -1) throw new Error('translation prompt: missing template body')
  52. return [...document.slice(0, preambleEnd).matchAll(/^\| `{{([a-z_]+)}}` \|/gm)].map(match => match[1] ?? '')
  53. }
  54. /** Render one system prompt from the checked-in template and canonical rules. */
  55. export function renderTranslationPrompt(document: string, input: TranslationPromptInput): string {
  56. if (basename(input.sourceFilename) !== input.sourceFilename) {
  57. throw new Error(`translation prompt: sourceFilename must be a basename; got ${JSON.stringify(input.sourceFilename)}`)
  58. }
  59. const sourceIsChinese = input.sourceFilename.endsWith('.zh.md')
  60. if (input.sourceLanguage === 'Chinese' ? !sourceIsChinese : sourceIsChinese || !input.sourceFilename.endsWith('.md')) {
  61. throw new Error(`translation prompt: ${input.sourceFilename} does not match source language ${input.sourceLanguage}`)
  62. }
  63. const targetLanguage: TranslationLanguage = input.sourceLanguage === 'English' ? 'Chinese' : 'English'
  64. const sourceFilenameZh = sourceIsChinese ? input.sourceFilename : input.sourceFilename.replace(/\.md$/, '.zh.md')
  65. const values: Record<TranslationPromptPlaceholder, string> = {
  66. source_lang: input.sourceLanguage,
  67. target_lang: targetLanguage,
  68. translation_rules: input.translationRules,
  69. terminology: input.terminology,
  70. source_filename: input.sourceFilename,
  71. source_filename_zh: sourceFilenameZh,
  72. }
  73. const template = extractTranslationPrompt(document)
  74. const placeholderFreeTemplate = template.replace(PLACEHOLDER, '')
  75. if (placeholderFreeTemplate.includes('{{') || placeholderFreeTemplate.includes('}}')) {
  76. throw new Error('translation prompt: template contains malformed placeholder syntax')
  77. }
  78. const names = [...template.matchAll(PLACEHOLDER)].map(match => match[1] ?? '')
  79. const unknown = names.filter(name => !TRANSLATION_PROMPT_PLACEHOLDERS.includes(name as TranslationPromptPlaceholder))
  80. if (unknown.length > 0) throw new Error(`translation prompt: unsupported placeholder(s): ${[...new Set(unknown)].join(', ')}`)
  81. const missing = TRANSLATION_PROMPT_PLACEHOLDERS.filter(name => !names.includes(name))
  82. if (missing.length > 0) throw new Error(`translation prompt: template does not use required placeholder(s): ${missing.join(', ')}`)
  83. return template.replace(PLACEHOLDER, (_token, name: string) => values[name as TranslationPromptPlaceholder])
  84. }
  85. /** Escape one value so it remains byte-identical inside an XML CDATA field. */
  86. function escapeTranslationCdata(value: string): string {
  87. return value.replaceAll(']]>', ']]]]><![CDATA[>')
  88. }
  89. /** Serialize a response using the exact XML wire contract in the prompt. */
  90. export function renderTranslationResponse(response: TranslationResponse): string {
  91. return [
  92. '<dsh-translation-response version="1">',
  93. `<translation><![CDATA[${escapeTranslationCdata(response.translation)}]]></translation>`,
  94. `<review><![CDATA[${escapeTranslationCdata(response.review)}]]></review>`,
  95. `<final><![CDATA[${escapeTranslationCdata(response.final)}]]></final>`,
  96. '</dsh-translation-response>',
  97. ].join('\n')
  98. }
  99. /** Parse and validate the exact XML response shape emitted by the model. */
  100. export function parseTranslationResponse(xml: string): TranslationResponse {
  101. const values: TranslationResponse = { translation: '', review: '', final: '' }
  102. const stack: string[] = []
  103. const cdataFields = new Set<string>()
  104. let rootSeen = false
  105. let childIndex = 0
  106. const fail = (message: string): never => {
  107. throw new Error(`translation response: ${message}`)
  108. }
  109. const parser = new SaxesParser({ xmlns: false })
  110. parser.on('opentag', (tag) => {
  111. if (stack.length === 0) {
  112. if (rootSeen) fail('contains more than one root element')
  113. if (tag.name !== 'dsh-translation-response') fail(`expected dsh-translation-response root, got ${tag.name}`)
  114. const attributes = Object.keys(tag.attributes)
  115. if (attributes.length !== 1 || tag.attributes.version !== '1') fail('root must have only version="1"')
  116. rootSeen = true
  117. } else if (stack.length === 1) {
  118. const expected = RESPONSE_CHILDREN[childIndex]
  119. if (tag.name !== expected) fail(`expected ${expected ?? 'no more children'}, got ${tag.name}`)
  120. if (Object.keys(tag.attributes).length !== 0) fail(`${tag.name} must not have attributes`)
  121. childIndex++
  122. } else {
  123. fail(`nested element ${tag.name} is not allowed`)
  124. }
  125. stack.push(tag.name)
  126. })
  127. parser.on('text', (value) => {
  128. if (stack.length <= 1 && value.trim() === '') return
  129. fail('all response field content must be inside CDATA')
  130. })
  131. parser.on('cdata', (value) => {
  132. const field = stack.at(-1)
  133. if (field === undefined || !RESPONSE_CHILDREN.includes(field as (typeof RESPONSE_CHILDREN)[number])) {
  134. fail('CDATA is allowed only inside translation, review, or final')
  135. }
  136. const key = field as (typeof RESPONSE_CHILDREN)[number]
  137. values[key] += value
  138. cdataFields.add(key)
  139. })
  140. parser.on('closetag', (tag) => {
  141. const expected = stack.pop()
  142. if (expected !== tag.name) fail(`closing ${tag.name} does not match ${expected ?? 'nothing'}`)
  143. })
  144. parser.on('comment', () => fail('comments are not allowed'))
  145. parser.on('doctype', () => fail('doctypes are not allowed'))
  146. parser.on('processinginstruction', () => fail('processing instructions are not allowed'))
  147. parser.on('error', error => fail(`invalid XML: ${error.message}`))
  148. parser.write(xml).close()
  149. if (childIndex !== RESPONSE_CHILDREN.length) fail('translation, review, and final must each appear exactly once and in order')
  150. for (const field of RESPONSE_CHILDREN) {
  151. if (!cdataFields.has(field)) fail(`${field} must contain a CDATA section`)
  152. }
  153. return values
  154. }