@llm-translate/cli 1.0.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/.dockerignore +51 -0
  2. package/.env.example +33 -0
  3. package/.github/workflows/docs-pages.yml +57 -0
  4. package/.github/workflows/release.yml +49 -0
  5. package/.translaterc.json +44 -0
  6. package/CLAUDE.md +243 -0
  7. package/Dockerfile +55 -0
  8. package/README.md +371 -0
  9. package/RFC.md +1595 -0
  10. package/dist/cli/index.d.ts +2 -0
  11. package/dist/cli/index.js +4494 -0
  12. package/dist/cli/index.js.map +1 -0
  13. package/dist/index.d.ts +1152 -0
  14. package/dist/index.js +3841 -0
  15. package/dist/index.js.map +1 -0
  16. package/docker-compose.yml +56 -0
  17. package/docs/.vitepress/config.ts +161 -0
  18. package/docs/api/agent.md +262 -0
  19. package/docs/api/engine.md +274 -0
  20. package/docs/api/index.md +171 -0
  21. package/docs/api/providers.md +304 -0
  22. package/docs/changelog.md +64 -0
  23. package/docs/cli/dir.md +243 -0
  24. package/docs/cli/file.md +213 -0
  25. package/docs/cli/glossary.md +273 -0
  26. package/docs/cli/index.md +129 -0
  27. package/docs/cli/init.md +158 -0
  28. package/docs/cli/serve.md +211 -0
  29. package/docs/glossary.json +235 -0
  30. package/docs/guide/chunking.md +272 -0
  31. package/docs/guide/configuration.md +139 -0
  32. package/docs/guide/cost-optimization.md +237 -0
  33. package/docs/guide/docker.md +371 -0
  34. package/docs/guide/getting-started.md +150 -0
  35. package/docs/guide/glossary.md +241 -0
  36. package/docs/guide/index.md +86 -0
  37. package/docs/guide/ollama.md +515 -0
  38. package/docs/guide/prompt-caching.md +221 -0
  39. package/docs/guide/providers.md +232 -0
  40. package/docs/guide/quality-control.md +206 -0
  41. package/docs/guide/vitepress-integration.md +265 -0
  42. package/docs/index.md +63 -0
  43. package/docs/ja/api/agent.md +262 -0
  44. package/docs/ja/api/engine.md +274 -0
  45. package/docs/ja/api/index.md +171 -0
  46. package/docs/ja/api/providers.md +304 -0
  47. package/docs/ja/changelog.md +64 -0
  48. package/docs/ja/cli/dir.md +243 -0
  49. package/docs/ja/cli/file.md +213 -0
  50. package/docs/ja/cli/glossary.md +273 -0
  51. package/docs/ja/cli/index.md +111 -0
  52. package/docs/ja/cli/init.md +158 -0
  53. package/docs/ja/guide/chunking.md +271 -0
  54. package/docs/ja/guide/configuration.md +139 -0
  55. package/docs/ja/guide/cost-optimization.md +30 -0
  56. package/docs/ja/guide/getting-started.md +150 -0
  57. package/docs/ja/guide/glossary.md +214 -0
  58. package/docs/ja/guide/index.md +32 -0
  59. package/docs/ja/guide/ollama.md +410 -0
  60. package/docs/ja/guide/prompt-caching.md +221 -0
  61. package/docs/ja/guide/providers.md +232 -0
  62. package/docs/ja/guide/quality-control.md +137 -0
  63. package/docs/ja/guide/vitepress-integration.md +265 -0
  64. package/docs/ja/index.md +58 -0
  65. package/docs/ko/api/agent.md +262 -0
  66. package/docs/ko/api/engine.md +274 -0
  67. package/docs/ko/api/index.md +171 -0
  68. package/docs/ko/api/providers.md +304 -0
  69. package/docs/ko/changelog.md +64 -0
  70. package/docs/ko/cli/dir.md +243 -0
  71. package/docs/ko/cli/file.md +213 -0
  72. package/docs/ko/cli/glossary.md +273 -0
  73. package/docs/ko/cli/index.md +111 -0
  74. package/docs/ko/cli/init.md +158 -0
  75. package/docs/ko/guide/chunking.md +271 -0
  76. package/docs/ko/guide/configuration.md +139 -0
  77. package/docs/ko/guide/cost-optimization.md +30 -0
  78. package/docs/ko/guide/getting-started.md +150 -0
  79. package/docs/ko/guide/glossary.md +214 -0
  80. package/docs/ko/guide/index.md +32 -0
  81. package/docs/ko/guide/ollama.md +410 -0
  82. package/docs/ko/guide/prompt-caching.md +221 -0
  83. package/docs/ko/guide/providers.md +232 -0
  84. package/docs/ko/guide/quality-control.md +137 -0
  85. package/docs/ko/guide/vitepress-integration.md +265 -0
  86. package/docs/ko/index.md +58 -0
  87. package/docs/zh/api/agent.md +262 -0
  88. package/docs/zh/api/engine.md +274 -0
  89. package/docs/zh/api/index.md +171 -0
  90. package/docs/zh/api/providers.md +304 -0
  91. package/docs/zh/changelog.md +64 -0
  92. package/docs/zh/cli/dir.md +243 -0
  93. package/docs/zh/cli/file.md +213 -0
  94. package/docs/zh/cli/glossary.md +273 -0
  95. package/docs/zh/cli/index.md +111 -0
  96. package/docs/zh/cli/init.md +158 -0
  97. package/docs/zh/guide/chunking.md +271 -0
  98. package/docs/zh/guide/configuration.md +139 -0
  99. package/docs/zh/guide/cost-optimization.md +30 -0
  100. package/docs/zh/guide/getting-started.md +150 -0
  101. package/docs/zh/guide/glossary.md +214 -0
  102. package/docs/zh/guide/index.md +32 -0
  103. package/docs/zh/guide/ollama.md +410 -0
  104. package/docs/zh/guide/prompt-caching.md +221 -0
  105. package/docs/zh/guide/providers.md +232 -0
  106. package/docs/zh/guide/quality-control.md +137 -0
  107. package/docs/zh/guide/vitepress-integration.md +265 -0
  108. package/docs/zh/index.md +58 -0
  109. package/package.json +91 -0
  110. package/release.config.mjs +15 -0
  111. package/schemas/glossary.schema.json +110 -0
  112. package/src/cli/commands/dir.ts +469 -0
  113. package/src/cli/commands/file.ts +291 -0
  114. package/src/cli/commands/glossary.ts +221 -0
  115. package/src/cli/commands/init.ts +68 -0
  116. package/src/cli/commands/serve.ts +60 -0
  117. package/src/cli/index.ts +64 -0
  118. package/src/cli/options.ts +59 -0
  119. package/src/core/agent.ts +1119 -0
  120. package/src/core/chunker.ts +391 -0
  121. package/src/core/engine.ts +634 -0
  122. package/src/errors.ts +188 -0
  123. package/src/index.ts +147 -0
  124. package/src/integrations/vitepress.ts +549 -0
  125. package/src/parsers/markdown.ts +383 -0
  126. package/src/providers/claude.ts +259 -0
  127. package/src/providers/interface.ts +109 -0
  128. package/src/providers/ollama.ts +379 -0
  129. package/src/providers/openai.ts +308 -0
  130. package/src/providers/registry.ts +153 -0
  131. package/src/server/index.ts +152 -0
  132. package/src/server/middleware/auth.ts +93 -0
  133. package/src/server/middleware/logger.ts +90 -0
  134. package/src/server/routes/health.ts +84 -0
  135. package/src/server/routes/translate.ts +210 -0
  136. package/src/server/types.ts +138 -0
  137. package/src/services/cache.ts +899 -0
  138. package/src/services/config.ts +217 -0
  139. package/src/services/glossary.ts +247 -0
  140. package/src/types/analysis.ts +164 -0
  141. package/src/types/index.ts +265 -0
  142. package/src/types/modes.ts +121 -0
  143. package/src/types/mqm.ts +157 -0
  144. package/src/utils/logger.ts +141 -0
  145. package/src/utils/tokens.ts +116 -0
  146. package/tests/fixtures/glossaries/ml-glossary.json +53 -0
  147. package/tests/fixtures/input/lynq-installation.ko.md +350 -0
  148. package/tests/fixtures/input/lynq-installation.md +350 -0
  149. package/tests/fixtures/input/simple.ko.md +27 -0
  150. package/tests/fixtures/input/simple.md +27 -0
  151. package/tests/unit/chunker.test.ts +229 -0
  152. package/tests/unit/glossary.test.ts +146 -0
  153. package/tests/unit/markdown.test.ts +205 -0
  154. package/tests/unit/tokens.test.ts +81 -0
  155. package/tsconfig.json +28 -0
  156. package/tsup.config.ts +34 -0
  157. package/vitest.config.ts +16 -0
@@ -0,0 +1,1119 @@
1
+ import type {
2
+ TranslationRequest,
3
+ TranslationResult,
4
+ ResolvedGlossary,
5
+ QualityEvaluation,
6
+ MQMEvaluation,
7
+ MQMError,
8
+ PreTranslationAnalysis,
9
+ TranslationMode,
10
+ } from "../types/index.js";
11
+ import {
12
+ parseMQMResponse,
13
+ formatMQMErrorsForPrompt,
14
+ } from "../types/mqm.js";
15
+ import {
16
+ parseAnalysisResponse,
17
+ formatAnalysisForPrompt,
18
+ createEmptyAnalysis,
19
+ } from "../types/analysis.js";
20
+ import { getModeConfig } from "../types/modes.js";
21
+ import type {
22
+ LLMProvider,
23
+ ChatMessage,
24
+ CacheableTextPart,
25
+ } from "../providers/interface.js";
26
+ import { createGlossaryLookup } from "../services/glossary.js";
27
+ import { logger, createTimer } from "../utils/logger.js";
28
+ import { TranslationError, ErrorCode } from "../errors.js";
29
+
30
+ // ============================================================================
31
+ // Prompt Templates (from RFC.md Section 7.2)
32
+ // ============================================================================
33
+
34
+ /**
35
+ * Build cacheable system instructions for translation
36
+ * This part is static and can be cached across multiple requests
37
+ */
38
+ function buildSystemInstructions(
39
+ sourceLang: string,
40
+ targetLang: string
41
+ ): string {
42
+ return `You are a professional translator specializing in ${sourceLang} to ${targetLang} translation.
43
+
44
+ ## Rules:
45
+ 1. Apply glossary terms exactly as specified
46
+ 2. Preserve all formatting (markdown, HTML tags, code blocks)
47
+ 3. Maintain the same tone and style
48
+ 4. Do not translate content inside code blocks
49
+ 5. Keep URLs, file paths, and technical identifiers unchanged
50
+ 6. Keep placeholders like __CODE_BLOCK_0__ unchanged`;
51
+ }
52
+
53
+ /**
54
+ * Build cacheable glossary section
55
+ * This can be cached as it's reused across multiple chunks
56
+ */
57
+ function buildGlossarySection(glossaryText: string): string {
58
+ return `## Glossary (MUST use these exact translations):
59
+ ${glossaryText || "No glossary provided."}`;
60
+ }
61
+
62
+ /**
63
+ * Build the dynamic part of the translation prompt (not cached)
64
+ */
65
+ function buildTranslationContent(
66
+ sourceText: string,
67
+ context?: { documentPurpose?: string; styleInstruction?: string; previousContext?: string }
68
+ ): string {
69
+ const styleSection = context?.styleInstruction
70
+ ? `Style: ${context.styleInstruction}\n`
71
+ : "";
72
+
73
+ return `## Document Context:
74
+ Purpose: ${context?.documentPurpose ?? "General translation"}
75
+ ${styleSection}Previous content: ${context?.previousContext ?? "None"}
76
+
77
+ ## Source Text:
78
+ ${sourceText}
79
+
80
+ Provide ONLY the translated text below, with no additional commentary or headers:`;
81
+ }
82
+
83
+ /**
84
+ * Build message with cacheable parts for initial translation
85
+ * Uses prompt caching for system instructions and glossary
86
+ */
87
+ function buildCacheableTranslationMessage(
88
+ sourceText: string,
89
+ sourceLang: string,
90
+ targetLang: string,
91
+ glossaryText: string,
92
+ context?: { documentPurpose?: string; styleInstruction?: string; previousContext?: string }
93
+ ): ChatMessage {
94
+ const systemInstructions = buildSystemInstructions(sourceLang, targetLang);
95
+ const glossarySection = buildGlossarySection(glossaryText);
96
+ const translationContent = buildTranslationContent(sourceText, context);
97
+
98
+ // Structure content parts with cache control
99
+ // System instructions + glossary are cached (static across chunks)
100
+ // Translation content is dynamic (changes per chunk)
101
+ const contentParts: CacheableTextPart[] = [
102
+ {
103
+ type: "text",
104
+ text: systemInstructions,
105
+ cacheControl: { type: "ephemeral" },
106
+ },
107
+ {
108
+ type: "text",
109
+ text: glossarySection,
110
+ cacheControl: { type: "ephemeral" },
111
+ },
112
+ {
113
+ type: "text",
114
+ text: translationContent,
115
+ // No cache control - this is dynamic per request
116
+ },
117
+ ];
118
+
119
+ return {
120
+ role: "user",
121
+ content: contentParts,
122
+ };
123
+ }
124
+
125
+ function buildInitialTranslationPrompt(
126
+ sourceText: string,
127
+ sourceLang: string,
128
+ targetLang: string,
129
+ glossaryText: string,
130
+ context?: { documentPurpose?: string; styleInstruction?: string; previousContext?: string }
131
+ ): string {
132
+ const styleSection = context?.styleInstruction
133
+ ? `Style: ${context.styleInstruction}\n`
134
+ : "";
135
+
136
+ return `You are a professional translator. Translate the following ${sourceLang} text to ${targetLang}.
137
+
138
+ ## Glossary (MUST use these exact translations):
139
+ ${glossaryText || "No glossary provided."}
140
+
141
+ ## Document Context:
142
+ Purpose: ${context?.documentPurpose ?? "General translation"}
143
+ ${styleSection}Previous content: ${context?.previousContext ?? "None"}
144
+
145
+ ## Rules:
146
+ 1. Apply glossary terms exactly as specified
147
+ 2. Preserve all formatting (markdown, HTML tags, code blocks)
148
+ 3. Maintain the same tone and style
149
+ 4. Do not translate content inside code blocks
150
+ 5. Keep URLs, file paths, and technical identifiers unchanged
151
+ 6. Keep placeholders like __CODE_BLOCK_0__ unchanged
152
+
153
+ ## Source Text:
154
+ ${sourceText}
155
+
156
+ Provide ONLY the translated text below, with no additional commentary or headers:`;
157
+ }
158
+
159
+ function buildReflectionPrompt(
160
+ sourceText: string,
161
+ translatedText: string,
162
+ sourceLang: string,
163
+ targetLang: string,
164
+ glossaryText: string
165
+ ): string {
166
+ return `Review this translation and provide specific improvement suggestions.
167
+
168
+ ## Source (${sourceLang}):
169
+ ${sourceText}
170
+
171
+ ## Translation (${targetLang}):
172
+ ${translatedText}
173
+
174
+ ## Glossary Requirements:
175
+ ${glossaryText || "No glossary provided."}
176
+
177
+ ## Evaluate and suggest improvements for:
178
+ 1. **Accuracy**: Does the translation convey the exact meaning?
179
+ 2. **Glossary Compliance**: Are all glossary terms applied correctly?
180
+ 3. **Fluency**: Does it read naturally in ${targetLang}?
181
+ 4. **Formatting**: Is the structure preserved?
182
+ 5. **Consistency**: Are terms translated consistently?
183
+
184
+ Provide a numbered list of specific, actionable suggestions:`;
185
+ }
186
+
187
+ function buildImprovementPrompt(
188
+ sourceText: string,
189
+ currentTranslation: string,
190
+ suggestions: string,
191
+ glossaryText: string
192
+ ): string {
193
+ return `Improve this translation based on the following suggestions.
194
+
195
+ ## Source Text:
196
+ ${sourceText}
197
+
198
+ ## Current Translation:
199
+ ${currentTranslation}
200
+
201
+ ## Improvement Suggestions:
202
+ ${suggestions}
203
+
204
+ ## Glossary (MUST apply):
205
+ ${glossaryText || "No glossary provided."}
206
+
207
+ Provide ONLY the improved translation below, with no additional commentary or headers:`;
208
+ }
209
+
210
+ function buildQualityEvaluationPrompt(
211
+ sourceText: string,
212
+ translatedText: string,
213
+ sourceLang: string,
214
+ targetLang: string
215
+ ): string {
216
+ return `Rate this translation's quality from 0 to 100.
217
+
218
+ ## Source (${sourceLang}):
219
+ ${sourceText}
220
+
221
+ ## Translation (${targetLang}):
222
+ ${translatedText}
223
+
224
+ ## Evaluation Criteria:
225
+ - Semantic accuracy (40 points)
226
+ - Fluency and naturalness (25 points)
227
+ - Glossary compliance (20 points)
228
+ - Format preservation (15 points)
229
+
230
+ Respond with only a JSON object:
231
+ {"score": <number>, "breakdown": {"accuracy": <n>, "fluency": <n>, "glossary": <n>, "format": <n>}, "issues": ["issue1", "issue2"]}`;
232
+ }
233
+
234
+ /**
235
+ * Build MQM evaluation prompt (TEaR-style)
236
+ * Based on https://arxiv.org/abs/2402.16379
237
+ */
238
+ function buildMQMEvaluationPrompt(
239
+ sourceText: string,
240
+ translatedText: string,
241
+ sourceLang: string,
242
+ targetLang: string,
243
+ glossaryText: string
244
+ ): string {
245
+ return `Evaluate this translation using MQM (Multidimensional Quality Metrics) framework.
246
+
247
+ ## Source (${sourceLang}):
248
+ ${sourceText}
249
+
250
+ ## Translation (${targetLang}):
251
+ ${translatedText}
252
+
253
+ ## Glossary Terms (must be applied exactly):
254
+ ${glossaryText || "No glossary provided."}
255
+
256
+ ## MQM Error Categories:
257
+ - accuracy/mistranslation: Incorrect meaning
258
+ - accuracy/omission: Missing content from source
259
+ - accuracy/addition: Extra content not in source
260
+ - accuracy/untranslated: Source text left unchanged
261
+ - fluency/grammar: Grammatical errors
262
+ - fluency/spelling: Spelling/typos
263
+ - fluency/register: Inappropriate formality
264
+ - fluency/inconsistency: Inconsistent terminology
265
+ - style/awkward: Unnatural phrasing
266
+ - style/unidiomatic: Non-native expressions
267
+
268
+ ## Severity Weights:
269
+ - "minor" (1 point): Noticeable but doesn't affect understanding
270
+ - "major" (5 points): Affects understanding or usability
271
+ - "critical" (25 points): Completely wrong or unusable
272
+
273
+ ## Instructions:
274
+ 1. Identify all translation errors
275
+ 2. Classify each by type and severity
276
+ 3. Provide the span and suggested fix
277
+ 4. Calculate score: 100 - sum(weights)
278
+
279
+ Respond with only a JSON object:
280
+ {
281
+ "errors": [
282
+ {"type": "accuracy/mistranslation", "severity": "major", "span": "affected text", "suggestion": "corrected text", "explanation": "reason"}
283
+ ],
284
+ "score": <100 - sum of weights>,
285
+ "summary": "brief overall assessment"
286
+ }`;
287
+ }
288
+
289
+ /**
290
+ * Build MQM-based refinement prompt
291
+ * Uses specific error annotations to guide improvements
292
+ */
293
+ function buildMQMRefinementPrompt(
294
+ sourceText: string,
295
+ currentTranslation: string,
296
+ errors: MQMError[],
297
+ glossaryText: string
298
+ ): string {
299
+ const errorList = formatMQMErrorsForPrompt(errors);
300
+
301
+ return `Fix the following translation errors.
302
+
303
+ ## Source Text:
304
+ ${sourceText}
305
+
306
+ ## Current Translation:
307
+ ${currentTranslation}
308
+
309
+ ## Errors to Fix:
310
+ ${errorList}
311
+
312
+ ## Glossary (MUST apply):
313
+ ${glossaryText || "No glossary provided."}
314
+
315
+ Apply ONLY the fixes listed above. Do not make other changes.
316
+ Provide ONLY the corrected translation, with no additional commentary:`;
317
+ }
318
+
319
+ /**
320
+ * Build pre-translation analysis prompt (MAPS-style)
321
+ * Based on https://github.com/zwhe99/MAPS-mt
322
+ */
323
+ function buildPreAnalysisPrompt(
324
+ sourceText: string,
325
+ sourceLang: string,
326
+ targetLang: string,
327
+ glossaryText: string
328
+ ): string {
329
+ return `Analyze this ${sourceLang} text before translating to ${targetLang}.
330
+
331
+ ## Source Text:
332
+ ${sourceText}
333
+
334
+ ## Available Glossary Terms:
335
+ ${glossaryText || "No glossary provided."}
336
+
337
+ ## Analyze and extract:
338
+ 1. **Key Terms**: Important domain-specific terms needing careful translation
339
+ 2. **Ambiguous Phrases**: Phrases with multiple possible interpretations
340
+ 3. **Preserve Exact**: Code, URLs, names that should NOT be translated
341
+ 4. **Challenges**: Specific difficulties for ${sourceLang}→${targetLang}
342
+
343
+ Respond with only a JSON object:
344
+ {
345
+ "keyTerms": [{"term": "...", "context": "...", "suggestedTranslation": "...", "fromGlossary": true/false}],
346
+ "ambiguousPhrases": [{"phrase": "...", "interpretations": ["..."], "recommendation": "..."}],
347
+ "preserveExact": ["code snippets", "URLs", "names"],
348
+ "challenges": ["challenge 1", "challenge 2"],
349
+ "domain": "technical|marketing|legal|medical|general",
350
+ "registerRecommendation": "formal|informal|neutral"
351
+ }`;
352
+ }
353
+
354
+ // ============================================================================
355
+ // Translation Agent
356
+ // ============================================================================
357
+
358
+ export interface TranslationAgentOptions {
359
+ provider: LLMProvider;
360
+ qualityThreshold?: number;
361
+ maxIterations?: number;
362
+ verbose?: boolean;
363
+ /** If true, throw error when quality threshold is not met after max iterations */
364
+ strictQuality?: boolean;
365
+ /** Enable prompt caching for Claude provider (default: true) */
366
+ enableCaching?: boolean;
367
+ /** Translation mode: fast, balanced, quality (default: balanced) */
368
+ mode?: TranslationMode;
369
+ /** Enable pre-translation analysis (MAPS-style) - overrides mode setting */
370
+ enableAnalysis?: boolean;
371
+ /** Use MQM-based evaluation - overrides mode setting */
372
+ useMQMEvaluation?: boolean;
373
+ }
374
+
375
+ export class TranslationAgent {
376
+ private provider: LLMProvider;
377
+ private qualityThreshold: number;
378
+ private maxIterations: number;
379
+ private verbose: boolean;
380
+ private strictQuality: boolean;
381
+ private enableCaching: boolean;
382
+ private enableAnalysis: boolean;
383
+ private useMQMEvaluation: boolean;
384
+
385
+ constructor(options: TranslationAgentOptions) {
386
+ this.provider = options.provider;
387
+ this.verbose = options.verbose ?? false;
388
+ this.strictQuality = options.strictQuality ?? false;
389
+
390
+ // Get mode configuration
391
+ const modeConfig = getModeConfig(options.mode ?? "balanced");
392
+
393
+ // Apply mode settings, allowing explicit overrides
394
+ this.qualityThreshold = options.qualityThreshold ?? modeConfig.qualityThreshold;
395
+ this.maxIterations = options.maxIterations ?? modeConfig.maxIterations;
396
+ this.enableAnalysis = options.enableAnalysis ?? modeConfig.enableAnalysis;
397
+ this.useMQMEvaluation = options.useMQMEvaluation ?? modeConfig.useMQMEvaluation;
398
+
399
+ // Enable caching by default for Claude provider
400
+ this.enableCaching =
401
+ options.enableCaching ?? options.provider.name === "claude";
402
+
403
+ if (this.verbose) {
404
+ logger.info(`Translation mode: ${options.mode ?? "balanced"}`);
405
+ logger.info(` - Analysis: ${this.enableAnalysis ? "enabled" : "disabled"}`);
406
+ logger.info(` - MQM evaluation: ${this.useMQMEvaluation ? "enabled" : "disabled"}`);
407
+ logger.info(` - Quality threshold: ${this.qualityThreshold}`);
408
+ logger.info(` - Max iterations: ${this.maxIterations}`);
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Translate content using Self-Refine loop with optional MAPS analysis and MQM evaluation
414
+ */
415
+ async translate(request: TranslationRequest): Promise<TranslationResult> {
416
+ const timer = createTimer();
417
+ let totalInputTokens = 0;
418
+ let totalOutputTokens = 0;
419
+ let totalCacheReadTokens = 0;
420
+ let totalCacheWriteTokens = 0;
421
+ let iterations = 0;
422
+
423
+ // Prepare glossary text for prompts
424
+ const glossaryText = request.glossary
425
+ ? createGlossaryLookup(
426
+ request.glossary as ResolvedGlossary
427
+ ).formatForPrompt()
428
+ : "";
429
+
430
+ // Step 1: Pre-Translation Analysis (MAPS-style, optional)
431
+ let analysis: PreTranslationAnalysis | null = null;
432
+ if (this.enableAnalysis) {
433
+ if (this.verbose) {
434
+ logger.info("Analyzing source text (MAPS)...");
435
+ }
436
+ analysis = await this.analyzeSource(
437
+ request.content,
438
+ request.sourceLang,
439
+ request.targetLang,
440
+ glossaryText
441
+ );
442
+ if (this.verbose && analysis) {
443
+ logger.info(` - Domain: ${analysis.domain}`);
444
+ logger.info(` - Key terms: ${analysis.keyTerms.length}`);
445
+ logger.info(` - Challenges: ${analysis.challenges.length}`);
446
+ }
447
+ }
448
+
449
+ // Step 2: Initial Translation
450
+ if (this.verbose) {
451
+ logger.info("Starting initial translation...");
452
+ }
453
+
454
+ const initialResult = await this.generateInitialTranslation(
455
+ request.content,
456
+ request.sourceLang,
457
+ request.targetLang,
458
+ glossaryText,
459
+ request.context,
460
+ analysis
461
+ );
462
+ let currentTranslation = initialResult.content;
463
+ iterations++;
464
+
465
+ totalInputTokens += initialResult.usage.inputTokens;
466
+ totalOutputTokens += initialResult.usage.outputTokens;
467
+ totalCacheReadTokens += initialResult.usage.cacheReadTokens ?? 0;
468
+ totalCacheWriteTokens += initialResult.usage.cacheWriteTokens ?? 0;
469
+
470
+ // Fast mode: Skip evaluation and refinement
471
+ if (this.maxIterations <= 1 && this.qualityThreshold <= 0) {
472
+ if (this.verbose) {
473
+ logger.info("Fast mode: Skipping evaluation and refinement");
474
+ }
475
+ return {
476
+ content: currentTranslation,
477
+ metadata: {
478
+ qualityScore: 0,
479
+ qualityThreshold: 0,
480
+ thresholdMet: true,
481
+ iterations,
482
+ tokensUsed: {
483
+ input: totalInputTokens,
484
+ output: totalOutputTokens,
485
+ cacheRead: totalCacheReadTokens,
486
+ cacheWrite: totalCacheWriteTokens,
487
+ },
488
+ duration: timer.elapsed(),
489
+ provider: this.provider.name,
490
+ model: "default",
491
+ },
492
+ glossaryCompliance: request.glossary
493
+ ? this.checkGlossaryCompliance(
494
+ request.content,
495
+ currentTranslation,
496
+ request.glossary as ResolvedGlossary
497
+ )
498
+ : undefined,
499
+ };
500
+ }
501
+
502
+ // Step 3: Evaluate and Refine Loop
503
+ let qualityScore = 0;
504
+ let lastEvaluation: QualityEvaluation | null = null;
505
+ let lastMQMEvaluation: MQMEvaluation | null = null;
506
+
507
+ while (iterations < this.maxIterations) {
508
+ // Evaluate quality (MQM or simple)
509
+ if (this.verbose) {
510
+ logger.info(
511
+ `Evaluating translation quality (iteration ${iterations})...`
512
+ );
513
+ }
514
+
515
+ if (this.useMQMEvaluation) {
516
+ // MQM-based evaluation
517
+ lastMQMEvaluation = await this.evaluateQualityMQM(
518
+ request.content,
519
+ currentTranslation,
520
+ request.sourceLang,
521
+ request.targetLang,
522
+ glossaryText
523
+ );
524
+ qualityScore = lastMQMEvaluation.score;
525
+
526
+ if (this.verbose) {
527
+ logger.info(`MQM score: ${qualityScore}/${this.qualityThreshold}`);
528
+ if (lastMQMEvaluation.errors.length > 0) {
529
+ logger.info(` - Errors: ${lastMQMEvaluation.errors.length} (${lastMQMEvaluation.breakdown.accuracy} accuracy, ${lastMQMEvaluation.breakdown.fluency} fluency, ${lastMQMEvaluation.breakdown.style} style)`);
530
+ }
531
+ }
532
+ } else {
533
+ // Simple evaluation
534
+ lastEvaluation = await this.evaluateQuality(
535
+ request.content,
536
+ currentTranslation,
537
+ request.sourceLang,
538
+ request.targetLang
539
+ );
540
+ qualityScore = lastEvaluation.score;
541
+
542
+ if (this.verbose) {
543
+ logger.info(`Quality score: ${qualityScore}/${this.qualityThreshold}`);
544
+ }
545
+ }
546
+
547
+ // Check if quality threshold is met
548
+ if (qualityScore >= this.qualityThreshold) {
549
+ if (this.verbose) {
550
+ logger.success(
551
+ `Quality threshold met after ${iterations} iterations`
552
+ );
553
+ }
554
+ break;
555
+ }
556
+
557
+ // Step 4: Refine translation
558
+ if (this.verbose) {
559
+ logger.info("Refining translation...");
560
+ }
561
+
562
+ let improveResult: {
563
+ content: string;
564
+ usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number };
565
+ };
566
+
567
+ if (this.useMQMEvaluation && lastMQMEvaluation && lastMQMEvaluation.errors.length > 0) {
568
+ // MQM-based refinement: Apply specific error fixes
569
+ improveResult = await this.refineWithMQM(
570
+ request.content,
571
+ currentTranslation,
572
+ lastMQMEvaluation.errors,
573
+ glossaryText
574
+ );
575
+ } else {
576
+ // Legacy refinement: Generate suggestions then apply
577
+ const suggestions = await this.generateReflection(
578
+ request.content,
579
+ currentTranslation,
580
+ request.sourceLang,
581
+ request.targetLang,
582
+ glossaryText
583
+ );
584
+
585
+ improveResult = await this.improveTranslation(
586
+ request.content,
587
+ currentTranslation,
588
+ suggestions,
589
+ glossaryText
590
+ );
591
+ }
592
+
593
+ currentTranslation = improveResult.content;
594
+ iterations++;
595
+ totalInputTokens += improveResult.usage.inputTokens;
596
+ totalOutputTokens += improveResult.usage.outputTokens;
597
+ totalCacheReadTokens += improveResult.usage.cacheReadTokens ?? 0;
598
+ totalCacheWriteTokens += improveResult.usage.cacheWriteTokens ?? 0;
599
+ }
600
+
601
+ // Final evaluation if not done
602
+ if (this.useMQMEvaluation) {
603
+ if (!lastMQMEvaluation || iterations === this.maxIterations) {
604
+ lastMQMEvaluation = await this.evaluateQualityMQM(
605
+ request.content,
606
+ currentTranslation,
607
+ request.sourceLang,
608
+ request.targetLang,
609
+ glossaryText
610
+ );
611
+ qualityScore = lastMQMEvaluation.score;
612
+ }
613
+ } else {
614
+ if (!lastEvaluation || iterations === this.maxIterations) {
615
+ lastEvaluation = await this.evaluateQuality(
616
+ request.content,
617
+ currentTranslation,
618
+ request.sourceLang,
619
+ request.targetLang
620
+ );
621
+ qualityScore = lastEvaluation.score;
622
+ }
623
+ }
624
+
625
+ // Check if quality threshold was met
626
+ const thresholdMet = qualityScore >= this.qualityThreshold;
627
+
628
+ if (!thresholdMet && this.strictQuality) {
629
+ throw new TranslationError(ErrorCode.QUALITY_THRESHOLD_NOT_MET, {
630
+ score: qualityScore,
631
+ threshold: this.qualityThreshold,
632
+ iterations,
633
+ maxIterations: this.maxIterations,
634
+ issues: lastEvaluation?.issues ?? lastMQMEvaluation?.errors.map(e => `${e.type}: ${e.span}`) ?? [],
635
+ });
636
+ }
637
+
638
+ if (!thresholdMet && this.verbose) {
639
+ logger.warn(
640
+ `Quality threshold not met: ${qualityScore}/${this.qualityThreshold} after ${iterations} iterations`
641
+ );
642
+ }
643
+
644
+ // Log cache efficiency if verbose and caching was used
645
+ if (this.verbose && (totalCacheReadTokens > 0 || totalCacheWriteTokens > 0)) {
646
+ const cacheHitRate =
647
+ totalCacheReadTokens > 0
648
+ ? ((totalCacheReadTokens / (totalCacheReadTokens + totalInputTokens)) * 100).toFixed(1)
649
+ : "0";
650
+ logger.info(
651
+ `Cache stats: ${totalCacheReadTokens} read, ${totalCacheWriteTokens} written (${cacheHitRate}% hit rate)`
652
+ );
653
+ }
654
+
655
+ return {
656
+ content: currentTranslation,
657
+ metadata: {
658
+ qualityScore,
659
+ qualityThreshold: this.qualityThreshold,
660
+ thresholdMet,
661
+ iterations,
662
+ tokensUsed: {
663
+ input: totalInputTokens,
664
+ output: totalOutputTokens,
665
+ cacheRead: totalCacheReadTokens,
666
+ cacheWrite: totalCacheWriteTokens,
667
+ },
668
+ duration: timer.elapsed(),
669
+ provider: this.provider.name,
670
+ model: "default",
671
+ },
672
+ glossaryCompliance: request.glossary
673
+ ? this.checkGlossaryCompliance(
674
+ request.content,
675
+ currentTranslation,
676
+ request.glossary as ResolvedGlossary
677
+ )
678
+ : undefined,
679
+ };
680
+ }
681
+
682
+ // ============================================================================
683
+ // Private Methods
684
+ // ============================================================================
685
+
686
+ private async generateInitialTranslation(
687
+ sourceText: string,
688
+ sourceLang: string,
689
+ targetLang: string,
690
+ glossaryText: string,
691
+ context?: {
692
+ documentPurpose?: string;
693
+ styleInstruction?: string;
694
+ previousChunks?: string[];
695
+ documentSummary?: string;
696
+ },
697
+ analysis?: PreTranslationAnalysis | null
698
+ ): Promise<{
699
+ content: string;
700
+ usage: {
701
+ inputTokens: number;
702
+ outputTokens: number;
703
+ cacheReadTokens?: number;
704
+ cacheWriteTokens?: number;
705
+ };
706
+ }> {
707
+ let messages: ChatMessage[];
708
+
709
+ // Build analysis context if available
710
+ const analysisContext = analysis ? formatAnalysisForPrompt(analysis) : "";
711
+ const enrichedContext = {
712
+ documentPurpose: context?.documentPurpose,
713
+ styleInstruction: context?.styleInstruction,
714
+ previousContext: context?.previousChunks?.slice(-2).join("\n"),
715
+ };
716
+
717
+ if (this.enableCaching) {
718
+ // Use cacheable message format for Claude
719
+ const baseMessage = buildCacheableTranslationMessage(
720
+ sourceText,
721
+ sourceLang,
722
+ targetLang,
723
+ glossaryText,
724
+ enrichedContext
725
+ );
726
+
727
+ // Add analysis context if available
728
+ if (analysisContext && Array.isArray(baseMessage.content)) {
729
+ const contentParts = baseMessage.content as CacheableTextPart[];
730
+ // Insert analysis after glossary, before translation content
731
+ contentParts.splice(2, 0, {
732
+ type: "text",
733
+ text: `\n## Pre-Translation Analysis:\n${analysisContext}\n`,
734
+ });
735
+ }
736
+
737
+ messages = [baseMessage];
738
+ } else {
739
+ // Fallback to simple string format for other providers
740
+ let prompt = buildInitialTranslationPrompt(
741
+ sourceText,
742
+ sourceLang,
743
+ targetLang,
744
+ glossaryText,
745
+ enrichedContext
746
+ );
747
+
748
+ // Inject analysis into prompt if available
749
+ if (analysisContext) {
750
+ prompt = prompt.replace(
751
+ "## Source Text:",
752
+ `## Pre-Translation Analysis:\n${analysisContext}\n\n## Source Text:`
753
+ );
754
+ }
755
+
756
+ messages = [{ role: "user", content: prompt }];
757
+ }
758
+
759
+ const response = await this.provider.chat({ messages });
760
+ const cleanedContent = this.cleanTranslationOutput(response.content);
761
+
762
+ return {
763
+ content: this.preserveWhitespace(sourceText, cleanedContent),
764
+ usage: {
765
+ inputTokens: response.usage.inputTokens,
766
+ outputTokens: response.usage.outputTokens,
767
+ cacheReadTokens: response.usage.cacheReadTokens,
768
+ cacheWriteTokens: response.usage.cacheWriteTokens,
769
+ },
770
+ };
771
+ }
772
+
773
+ private async generateReflection(
774
+ sourceText: string,
775
+ translatedText: string,
776
+ sourceLang: string,
777
+ targetLang: string,
778
+ glossaryText: string
779
+ ): Promise<string> {
780
+ const prompt = buildReflectionPrompt(
781
+ sourceText,
782
+ translatedText,
783
+ sourceLang,
784
+ targetLang,
785
+ glossaryText
786
+ );
787
+
788
+ const messages: ChatMessage[] = [{ role: "user", content: prompt }];
789
+
790
+ const response = await this.provider.chat({ messages });
791
+ return response.content.trim();
792
+ }
793
+
794
+ private async improveTranslation(
795
+ sourceText: string,
796
+ currentTranslation: string,
797
+ suggestions: string,
798
+ glossaryText: string
799
+ ): Promise<{
800
+ content: string;
801
+ usage: {
802
+ inputTokens: number;
803
+ outputTokens: number;
804
+ cacheReadTokens?: number;
805
+ cacheWriteTokens?: number;
806
+ };
807
+ }> {
808
+ let messages: ChatMessage[];
809
+
810
+ if (this.enableCaching) {
811
+ // Use cacheable format - glossary is cached
812
+ const contentParts: CacheableTextPart[] = [
813
+ {
814
+ type: "text",
815
+ text: `Improve this translation based on the following suggestions.
816
+
817
+ ## Glossary (MUST apply):
818
+ ${glossaryText || "No glossary provided."}`,
819
+ cacheControl: { type: "ephemeral" },
820
+ },
821
+ {
822
+ type: "text",
823
+ text: `## Source Text:
824
+ ${sourceText}
825
+
826
+ ## Current Translation:
827
+ ${currentTranslation}
828
+
829
+ ## Improvement Suggestions:
830
+ ${suggestions}
831
+
832
+ Provide ONLY the improved translation below, with no additional commentary or headers:`,
833
+ },
834
+ ];
835
+ messages = [{ role: "user", content: contentParts }];
836
+ } else {
837
+ const prompt = buildImprovementPrompt(
838
+ sourceText,
839
+ currentTranslation,
840
+ suggestions,
841
+ glossaryText
842
+ );
843
+ messages = [{ role: "user", content: prompt }];
844
+ }
845
+
846
+ const response = await this.provider.chat({ messages });
847
+ const cleanedContent = this.cleanTranslationOutput(response.content);
848
+
849
+ return {
850
+ content: this.preserveWhitespace(sourceText, cleanedContent),
851
+ usage: {
852
+ inputTokens: response.usage.inputTokens,
853
+ outputTokens: response.usage.outputTokens,
854
+ cacheReadTokens: response.usage.cacheReadTokens,
855
+ cacheWriteTokens: response.usage.cacheWriteTokens,
856
+ },
857
+ };
858
+ }
859
+
860
+ private async evaluateQuality(
861
+ sourceText: string,
862
+ translatedText: string,
863
+ sourceLang: string,
864
+ targetLang: string
865
+ ): Promise<QualityEvaluation> {
866
+ const prompt = buildQualityEvaluationPrompt(
867
+ sourceText,
868
+ translatedText,
869
+ sourceLang,
870
+ targetLang
871
+ );
872
+
873
+ const messages: ChatMessage[] = [{ role: "user", content: prompt }];
874
+
875
+ const response = await this.provider.chat({ messages });
876
+
877
+ try {
878
+ // Extract JSON from response
879
+ const jsonMatch = response.content.match(/\{[\s\S]*\}/);
880
+ if (!jsonMatch) {
881
+ throw new Error("No JSON found in response");
882
+ }
883
+
884
+ const evaluation = JSON.parse(jsonMatch[0]) as {
885
+ score: number;
886
+ breakdown: {
887
+ accuracy: number;
888
+ fluency: number;
889
+ glossary: number;
890
+ format: number;
891
+ };
892
+ issues: string[];
893
+ };
894
+
895
+ return {
896
+ score: evaluation.score,
897
+ breakdown: evaluation.breakdown,
898
+ issues: evaluation.issues,
899
+ };
900
+ } catch {
901
+ // Fallback if parsing fails
902
+ return {
903
+ score: 75, // Default score
904
+ breakdown: {
905
+ accuracy: 30,
906
+ fluency: 20,
907
+ glossary: 15,
908
+ format: 10,
909
+ },
910
+ issues: ["Failed to parse quality evaluation response"],
911
+ };
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Pre-translation analysis using MAPS-style approach
917
+ * Identifies key terms, ambiguous phrases, and translation challenges
918
+ */
919
+ private async analyzeSource(
920
+ sourceText: string,
921
+ sourceLang: string,
922
+ targetLang: string,
923
+ glossaryText: string
924
+ ): Promise<PreTranslationAnalysis | null> {
925
+ const prompt = buildPreAnalysisPrompt(
926
+ sourceText,
927
+ sourceLang,
928
+ targetLang,
929
+ glossaryText
930
+ );
931
+
932
+ const messages: ChatMessage[] = [{ role: "user", content: prompt }];
933
+
934
+ try {
935
+ const response = await this.provider.chat({ messages });
936
+ return parseAnalysisResponse(response.content);
937
+ } catch (error) {
938
+ if (this.verbose) {
939
+ logger.warn(`Pre-analysis failed: ${error}`);
940
+ }
941
+ return createEmptyAnalysis();
942
+ }
943
+ }
944
+
945
+ /**
946
+ * Evaluate translation quality using MQM framework
947
+ * Returns structured error annotations for targeted refinement
948
+ */
949
+ private async evaluateQualityMQM(
950
+ sourceText: string,
951
+ translatedText: string,
952
+ sourceLang: string,
953
+ targetLang: string,
954
+ glossaryText: string
955
+ ): Promise<MQMEvaluation> {
956
+ const prompt = buildMQMEvaluationPrompt(
957
+ sourceText,
958
+ translatedText,
959
+ sourceLang,
960
+ targetLang,
961
+ glossaryText
962
+ );
963
+
964
+ const messages: ChatMessage[] = [{ role: "user", content: prompt }];
965
+
966
+ try {
967
+ const response = await this.provider.chat({ messages });
968
+ const evaluation = parseMQMResponse(response.content);
969
+
970
+ if (evaluation) {
971
+ return evaluation;
972
+ }
973
+
974
+ // Fallback if parsing fails
975
+ return {
976
+ errors: [],
977
+ score: 75,
978
+ summary: "Failed to parse MQM evaluation",
979
+ breakdown: { accuracy: 0, fluency: 0, style: 0 },
980
+ };
981
+ } catch {
982
+ return {
983
+ errors: [],
984
+ score: 75,
985
+ summary: "MQM evaluation failed",
986
+ breakdown: { accuracy: 0, fluency: 0, style: 0 },
987
+ };
988
+ }
989
+ }
990
+
991
+ /**
992
+ * Refine translation based on MQM error annotations
993
+ * Applies targeted fixes for identified errors
994
+ */
995
+ private async refineWithMQM(
996
+ sourceText: string,
997
+ currentTranslation: string,
998
+ errors: MQMError[],
999
+ glossaryText: string
1000
+ ): Promise<{
1001
+ content: string;
1002
+ usage: {
1003
+ inputTokens: number;
1004
+ outputTokens: number;
1005
+ cacheReadTokens?: number;
1006
+ cacheWriteTokens?: number;
1007
+ };
1008
+ }> {
1009
+ const prompt = buildMQMRefinementPrompt(
1010
+ sourceText,
1011
+ currentTranslation,
1012
+ errors,
1013
+ glossaryText
1014
+ );
1015
+
1016
+ const messages: ChatMessage[] = [{ role: "user", content: prompt }];
1017
+ const response = await this.provider.chat({ messages });
1018
+ const cleanedContent = this.cleanTranslationOutput(response.content);
1019
+
1020
+ return {
1021
+ content: this.preserveWhitespace(sourceText, cleanedContent),
1022
+ usage: {
1023
+ inputTokens: response.usage.inputTokens,
1024
+ outputTokens: response.usage.outputTokens,
1025
+ cacheReadTokens: response.usage.cacheReadTokens,
1026
+ cacheWriteTokens: response.usage.cacheWriteTokens,
1027
+ },
1028
+ };
1029
+ }
1030
+
1031
+ /**
1032
+ * Clean up translation output by removing prompt artifacts
1033
+ * Uses guardrails to detect and remove any trailing prompt-like content
1034
+ */
1035
+ private cleanTranslationOutput(text: string): string {
1036
+ let cleaned = text.trim();
1037
+
1038
+ // Guardrail 1: Remove trailing markdown headers that look like prompt sections
1039
+ // These are likely prompt artifacts, not actual translation content
1040
+ const trailingHeaderPattern = /\n+##\s+[A-Z][^:\n]*:\s*$/;
1041
+ cleaned = cleaned.replace(trailingHeaderPattern, '');
1042
+
1043
+ // Guardrail 2: If the text ends with a colon followed by optional whitespace,
1044
+ // it's likely an incomplete prompt artifact
1045
+ const incompletePromptPattern = /:\s*$/;
1046
+ if (incompletePromptPattern.test(cleaned)) {
1047
+ // Find the last complete line/paragraph
1048
+ const lines = cleaned.split('\n');
1049
+ while (lines.length > 0 && incompletePromptPattern.test(lines[lines.length - 1]?.trim() ?? '')) {
1050
+ lines.pop();
1051
+ }
1052
+ cleaned = lines.join('\n');
1053
+ }
1054
+
1055
+ // Guardrail 3: Remove any trailing numbered list items that look like evaluation criteria
1056
+ // (typically starts with "1. **" pattern for bold evaluation headers)
1057
+ const evaluationListPattern = /\n+\d+\.\s*\*\*[^*]+\*\*[\s\S]*$/;
1058
+ if (evaluationListPattern.test(cleaned)) {
1059
+ cleaned = cleaned.replace(evaluationListPattern, '');
1060
+ }
1061
+
1062
+ return cleaned.trim();
1063
+ }
1064
+
1065
+ /**
1066
+ * Preserve leading/trailing whitespace from source text in translated text
1067
+ * This ensures document structure (line breaks between sections) is maintained
1068
+ */
1069
+ private preserveWhitespace(
1070
+ sourceText: string,
1071
+ translatedText: string
1072
+ ): string {
1073
+ // Extract leading whitespace from source
1074
+ const leadingMatch = sourceText.match(/^(\s*)/);
1075
+ const leadingWhitespace = leadingMatch ? leadingMatch[1] : "";
1076
+
1077
+ // Extract trailing whitespace from source
1078
+ const trailingMatch = sourceText.match(/(\s*)$/);
1079
+ const trailingWhitespace = trailingMatch ? trailingMatch[1] : "";
1080
+
1081
+ return leadingWhitespace + translatedText + trailingWhitespace;
1082
+ }
1083
+
1084
+ private checkGlossaryCompliance(
1085
+ sourceText: string,
1086
+ translatedText: string,
1087
+ glossary: ResolvedGlossary
1088
+ ): { applied: string[]; missed: string[] } {
1089
+ const lookup = createGlossaryLookup(glossary);
1090
+ const sourceTerms = lookup.findAll(sourceText);
1091
+
1092
+ const applied: string[] = [];
1093
+ const missed: string[] = [];
1094
+
1095
+ for (const term of sourceTerms) {
1096
+ const targetInTranslation = term.caseSensitive
1097
+ ? translatedText.includes(term.target)
1098
+ : translatedText.toLowerCase().includes(term.target.toLowerCase());
1099
+
1100
+ if (targetInTranslation) {
1101
+ applied.push(term.source);
1102
+ } else {
1103
+ missed.push(term.source);
1104
+ }
1105
+ }
1106
+
1107
+ return { applied, missed };
1108
+ }
1109
+ }
1110
+
1111
+ // ============================================================================
1112
+ // Factory Function
1113
+ // ============================================================================
1114
+
1115
+ export function createTranslationAgent(
1116
+ options: TranslationAgentOptions
1117
+ ): TranslationAgent {
1118
+ return new TranslationAgent(options);
1119
+ }