translation-prompt.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. /** Unit tests for the prompt-v7 content and unchanged three-section protocol. */
  2. import { readFileSync } from 'node:fs'
  3. import { join, resolve } from 'node:path'
  4. import { describe, expect, it } from 'vitest'
  5. import {
  6. consumeTranslationResponse,
  7. parseTranslationResponse,
  8. renderTranslationPrompt,
  9. renderTranslationRequest,
  10. renderTranslationResponse,
  11. } from './translation-prompt.ts'
  12. const root = resolve(import.meta.dirname, '..')
  13. const document = readFileSync(join(root, 'docs/i18n/translation-prompt.md'), 'utf8')
  14. const terminology = '| English | 中文 |\n|---|---|\n| agent | agent |'
  15. const retainedExamples = [
  16. ['### Colloquial verb → Professional verb', 'The repo pins pnpm@11.7.0 in package.json', '该仓库在 package.json 中固定使用 pnpm@11.7.0'],
  17. ['### Run-on sentence → Natural phrasing with pause', 'Read docs/architecture.md before changing anything under packages/.', '在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。'],
  18. ['### Stiff passive voice → Active and natural', 'a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.', '门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。'],
  19. ['### Invented word → Natural expression', 'A sidecar record of both blob hashes makes consistency checkable', '伴随记录保存两侧 blob hash,使一致性可检查'],
  20. ['### Em-dash → Colon/period', 'FIXME — an issue that should block a new release.', 'FIXME:应当阻塞新版本发布的问题。'],
  21. ['### Overly literal → Meaningful rendering', 'awkward phrasing is easier to notice when you read the translation without comparing it with the source', '不对照原文阅读译文时,更容易察觉别扭的表达'],
  22. ['### Terminology — do not translate what should be kept in English', 'typed service seams, and explicit extension points', '类型化的服务 seam 与显式扩展点'],
  23. ['### Slang/jargon → Professional phrasing', 'The committed agent workflow lives in .agents/skills/dsh-translate-docs', '仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs'],
  24. ['### "For humans" — translate the intent, not the word', 'For humans, start with the development guide', '面向开发者:请先阅读开发指南'],
  25. ['### Code block comments — NEVER translate', '# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)', 'keep exactly as-is, byte-for-byte'],
  26. ['### Language switcher — flip direction', 'English | [中文](README.zh.md)', '[English](README.md) | 中文'],
  27. ]
  28. describe('translation prompt rendering', () => {
  29. it('renders both directions with every placeholder resolved', () => {
  30. const en = renderTranslationPrompt(document, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })
  31. expect(en).toContain('from English to Chinese')
  32. expect(en).toContain(terminology)
  33. expect(en).not.toContain('{{')
  34. expect(en).toContain('plain source stays plain (必须)')
  35. expect(en).toContain('For an English target, use the established English technical term')
  36. expect(en).toContain('does a Chinese target use an established Chinese rendering')
  37. expect(en).toContain('does an English target use the established English technical term')
  38. expect(en).toContain('The parser removes exactly one framing escape')
  39. const zh = renderTranslationPrompt(document, { sourceLanguage: 'Chinese', sourceFilename: 'guide.zh.md', terminology })
  40. expect(zh).toContain('from Chinese to English')
  41. })
  42. it('contains every embedded example', () => {
  43. for (const example of retainedExamples) {
  44. for (const fragment of example) expect(document).toContain(fragment)
  45. }
  46. })
  47. it('states the selected v7 safeguards', () => {
  48. const rendered = renderTranslationPrompt(document, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })
  49. expect(rendered).toContain('## Priority')
  50. expect(rendered).toContain('### Faithfulness')
  51. expect(rendered).toContain('do not invent a filename or switcher')
  52. expect(rendered).toContain('Markdown emphasis markers do not create a word boundary')
  53. expect(rendered).toContain('Never invent responsibility merely to avoid a passive construction')
  54. expect(rendered).toContain('Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety')
  55. expect(rendered).toContain('Chinese output uses its `.zh.md` path')
  56. expect(rendered).toContain('belongs to the active bilingual corpus')
  57. expect(rendered).toContain('a missing counterpart in that corpus is an error')
  58. expect(rendered).toContain('exact query/fragment suffix')
  59. expect(rendered).toContain('Return exactly three raw XML sections')
  60. })
  61. it('rejects a template with unknown or missing placeholders', () => {
  62. const alien = document.replaceAll('{{terminology}}', '{{terms_prompt}}')
  63. expect(() => renderTranslationPrompt(alien, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })).toThrow(/unsupported placeholder/)
  64. const missing = document.replaceAll('{{terminology}}', '')
  65. expect(() => renderTranslationPrompt(missing, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })).toThrow(/required placeholder/)
  66. })
  67. it('rejects unmatched placeholder delimiters', () => {
  68. for (const delimiter of ['{{', '}}']) {
  69. const malformed = document.replace('Your task is to translate', `Your task ${delimiter} is to translate`)
  70. expect(() => renderTranslationPrompt(malformed, {
  71. sourceLanguage: 'English',
  72. sourceFilename: 'guide.md',
  73. terminology,
  74. })).toThrow(/malformed placeholder syntax/)
  75. }
  76. })
  77. it('assembles bare few-shot turns before the real source document', () => {
  78. const request = renderTranslationRequest(document, {
  79. sourceLanguage: 'English',
  80. sourceFilename: 'guide.md',
  81. sourceDocument: '# Guide\n\nNew source.',
  82. terminology,
  83. examples: [{ english: '# Example\n\nEnglish.', chinese: '# 示例\n\n中文。' }],
  84. })
  85. expect(request.targetFilename).toBe('guide.zh.md')
  86. expect(request.messages.map(message => message.role)).toEqual(['system', 'user', 'assistant', 'user'])
  87. expect(request.messages.slice(1).map(message => message.content)).toEqual([
  88. '# Example\n\nEnglish.',
  89. '# 示例\n\n中文。',
  90. '# Guide\n\nNew source.',
  91. ])
  92. const reverse = renderTranslationRequest(document, {
  93. sourceLanguage: 'Chinese',
  94. sourceFilename: 'guide.zh.md',
  95. sourceDocument: '# 指南\n\n新源文。',
  96. terminology,
  97. examples: [{ english: '# Example\n\nEnglish.', chinese: '# 示例\n\n中文。' }],
  98. })
  99. expect(reverse.targetFilename).toBe('guide.md')
  100. expect(reverse.messages.slice(1).map(message => message.content)).toEqual([
  101. '# 示例\n\n中文。',
  102. '# Example\n\nEnglish.',
  103. '# 指南\n\n新源文。',
  104. ])
  105. })
  106. })
  107. describe('translation response sections', () => {
  108. it('round-trips Markdown bodies', () => {
  109. const response = { translation: '# 标题\n\n正文 **加粗**。', review: '- [Tone] 修正一处。\n- 无修正', final: '# 标题\n\n定稿。' }
  110. expect(parseTranslationResponse(renderTranslationResponse(response))).toEqual(response)
  111. })
  112. it('tolerates a fenced xml wrapper around the whole response', () => {
  113. const fenced = '```xml\n<translation>\nA\n</translation>\n\n<review>\n- 无修正\n</review>\n\n<final>\nA\n</final>\n```'
  114. expect(parseTranslationResponse(fenced).final).toBe('A')
  115. })
  116. it('keeps an inline close tag inside prose from terminating the section', () => {
  117. const doc = { translation: 'the wire format uses </translation> as its close tag', review: '- 无修正', final: 'F' }
  118. expect(parseTranslationResponse(renderTranslationResponse(doc))).toEqual(doc)
  119. })
  120. it('round-trips wrapper-tag lines inside Markdown bodies', () => {
  121. const doc = {
  122. translation: '```xml\n</translation>\n```',
  123. review: '- [Structure] Preserved `<final>` on its own line.',
  124. final: 'literal delimiters\n</final>\n\\</final>',
  125. }
  126. const rendered = renderTranslationResponse(doc)
  127. expect(parseTranslationResponse(rendered)).toEqual(doc)
  128. expect(() => parseTranslationResponse(rendered.replace('\\</translation>', '</translation>'))).toThrow(/duplicate <translation>/)
  129. })
  130. it('rejects a duplicate section appearing before final', () => {
  131. const early = '<translation>\nA\n</translation>\n<translation>\nB\n</translation>\n<review>\nR\n</review>\n<final>\nF\n</final>'
  132. expect(() => parseTranslationResponse(early)).toThrow(/duplicate <translation>/)
  133. })
  134. it('rejects missing, unterminated, or duplicated sections', () => {
  135. expect(() => parseTranslationResponse('<translation>\nA\n</translation>')).toThrow(/missing or unterminated <review>/)
  136. expect(() => parseTranslationResponse('<translation>\nA')).toThrow(/missing or unterminated <translation>/)
  137. const dup = '<translation>\nA\n</translation>\n<review>\nR\n</review>\n<final>\nF\n</final>\n<final>\nG\n</final>'
  138. expect(() => parseTranslationResponse(dup)).toThrow(/duplicate <final>/)
  139. expect(() => parseTranslationResponse(`${renderTranslationResponse({ translation: 'A', review: 'R', final: 'F' })}\nstray`))
  140. .toThrow(/content is not allowed outside/)
  141. })
  142. it('inserts or corrects the target switcher after parsing a new-pair response', () => {
  143. const response = renderTranslationResponse({
  144. translation: '# 指南\n\n初稿。',
  145. review: '- 无修正',
  146. final: '# 指南\n\nEnglish | [中文](guide.zh.md)\n\n定稿。',
  147. })
  148. expect(consumeTranslationResponse(response, { sourceLanguage: 'English', sourceFilename: 'guide.md' }).final).toBe([
  149. '# 指南',
  150. '',
  151. '[English](guide.md) | 中文',
  152. '',
  153. '定稿。',
  154. '',
  155. ].join('\n'))
  156. })
  157. it('preserves YAML frontmatter before inserting the target switcher', () => {
  158. const response = renderTranslationResponse({
  159. translation: '# 指南\n\n初稿。',
  160. review: '- 无修正',
  161. final: [
  162. '---',
  163. 'layout: home',
  164. '---',
  165. '',
  166. '# 指南',
  167. '',
  168. '定稿。',
  169. ].join('\n'),
  170. })
  171. expect(consumeTranslationResponse(response, { sourceLanguage: 'English', sourceFilename: 'guide.md' }).final).toBe([
  172. '---',
  173. 'layout: home',
  174. '---',
  175. '',
  176. '# 指南',
  177. '',
  178. '[English](guide.md) | 中文',
  179. '',
  180. '定稿。',
  181. '',
  182. ].join('\n'))
  183. })
  184. it('rejects unterminated YAML frontmatter before the target H1', () => {
  185. const response = renderTranslationResponse({
  186. translation: '# 指南\n\n初稿。',
  187. review: '- 无修正',
  188. final: '---\nlayout: home\n\n# 指南\n\n定稿。',
  189. })
  190. expect(() => consumeTranslationResponse(response, {
  191. sourceLanguage: 'English',
  192. sourceFilename: 'guide.md',
  193. })).toThrow(/unterminated YAML frontmatter/)
  194. })
  195. it('rejects a source filename that contradicts the translation direction', () => {
  196. expect(() => renderTranslationPrompt(document, {
  197. sourceLanguage: 'Chinese',
  198. sourceFilename: 'guide.md',
  199. terminology,
  200. })).toThrow(/does not match source language Chinese/)
  201. })
  202. it('inserts the English target switcher for a Chinese source', () => {
  203. const response = renderTranslationResponse({
  204. translation: '# Guide\n\nDraft.',
  205. review: '- [None] No corrections.',
  206. final: '# Guide\n\nFinal.',
  207. })
  208. expect(consumeTranslationResponse(response, {
  209. sourceLanguage: 'Chinese',
  210. sourceFilename: 'guide.zh.md',
  211. }).final).toContain('\n\nEnglish | [中文](guide.zh.md)\n\n')
  212. })
  213. })