@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.
- package/.dockerignore +51 -0
- package/.env.example +33 -0
- package/.github/workflows/docs-pages.yml +57 -0
- package/.github/workflows/release.yml +49 -0
- package/.translaterc.json +44 -0
- package/CLAUDE.md +243 -0
- package/Dockerfile +55 -0
- package/README.md +371 -0
- package/RFC.md +1595 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +4494 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +1152 -0
- package/dist/index.js +3841 -0
- package/dist/index.js.map +1 -0
- package/docker-compose.yml +56 -0
- package/docs/.vitepress/config.ts +161 -0
- package/docs/api/agent.md +262 -0
- package/docs/api/engine.md +274 -0
- package/docs/api/index.md +171 -0
- package/docs/api/providers.md +304 -0
- package/docs/changelog.md +64 -0
- package/docs/cli/dir.md +243 -0
- package/docs/cli/file.md +213 -0
- package/docs/cli/glossary.md +273 -0
- package/docs/cli/index.md +129 -0
- package/docs/cli/init.md +158 -0
- package/docs/cli/serve.md +211 -0
- package/docs/glossary.json +235 -0
- package/docs/guide/chunking.md +272 -0
- package/docs/guide/configuration.md +139 -0
- package/docs/guide/cost-optimization.md +237 -0
- package/docs/guide/docker.md +371 -0
- package/docs/guide/getting-started.md +150 -0
- package/docs/guide/glossary.md +241 -0
- package/docs/guide/index.md +86 -0
- package/docs/guide/ollama.md +515 -0
- package/docs/guide/prompt-caching.md +221 -0
- package/docs/guide/providers.md +232 -0
- package/docs/guide/quality-control.md +206 -0
- package/docs/guide/vitepress-integration.md +265 -0
- package/docs/index.md +63 -0
- package/docs/ja/api/agent.md +262 -0
- package/docs/ja/api/engine.md +274 -0
- package/docs/ja/api/index.md +171 -0
- package/docs/ja/api/providers.md +304 -0
- package/docs/ja/changelog.md +64 -0
- package/docs/ja/cli/dir.md +243 -0
- package/docs/ja/cli/file.md +213 -0
- package/docs/ja/cli/glossary.md +273 -0
- package/docs/ja/cli/index.md +111 -0
- package/docs/ja/cli/init.md +158 -0
- package/docs/ja/guide/chunking.md +271 -0
- package/docs/ja/guide/configuration.md +139 -0
- package/docs/ja/guide/cost-optimization.md +30 -0
- package/docs/ja/guide/getting-started.md +150 -0
- package/docs/ja/guide/glossary.md +214 -0
- package/docs/ja/guide/index.md +32 -0
- package/docs/ja/guide/ollama.md +410 -0
- package/docs/ja/guide/prompt-caching.md +221 -0
- package/docs/ja/guide/providers.md +232 -0
- package/docs/ja/guide/quality-control.md +137 -0
- package/docs/ja/guide/vitepress-integration.md +265 -0
- package/docs/ja/index.md +58 -0
- package/docs/ko/api/agent.md +262 -0
- package/docs/ko/api/engine.md +274 -0
- package/docs/ko/api/index.md +171 -0
- package/docs/ko/api/providers.md +304 -0
- package/docs/ko/changelog.md +64 -0
- package/docs/ko/cli/dir.md +243 -0
- package/docs/ko/cli/file.md +213 -0
- package/docs/ko/cli/glossary.md +273 -0
- package/docs/ko/cli/index.md +111 -0
- package/docs/ko/cli/init.md +158 -0
- package/docs/ko/guide/chunking.md +271 -0
- package/docs/ko/guide/configuration.md +139 -0
- package/docs/ko/guide/cost-optimization.md +30 -0
- package/docs/ko/guide/getting-started.md +150 -0
- package/docs/ko/guide/glossary.md +214 -0
- package/docs/ko/guide/index.md +32 -0
- package/docs/ko/guide/ollama.md +410 -0
- package/docs/ko/guide/prompt-caching.md +221 -0
- package/docs/ko/guide/providers.md +232 -0
- package/docs/ko/guide/quality-control.md +137 -0
- package/docs/ko/guide/vitepress-integration.md +265 -0
- package/docs/ko/index.md +58 -0
- package/docs/zh/api/agent.md +262 -0
- package/docs/zh/api/engine.md +274 -0
- package/docs/zh/api/index.md +171 -0
- package/docs/zh/api/providers.md +304 -0
- package/docs/zh/changelog.md +64 -0
- package/docs/zh/cli/dir.md +243 -0
- package/docs/zh/cli/file.md +213 -0
- package/docs/zh/cli/glossary.md +273 -0
- package/docs/zh/cli/index.md +111 -0
- package/docs/zh/cli/init.md +158 -0
- package/docs/zh/guide/chunking.md +271 -0
- package/docs/zh/guide/configuration.md +139 -0
- package/docs/zh/guide/cost-optimization.md +30 -0
- package/docs/zh/guide/getting-started.md +150 -0
- package/docs/zh/guide/glossary.md +214 -0
- package/docs/zh/guide/index.md +32 -0
- package/docs/zh/guide/ollama.md +410 -0
- package/docs/zh/guide/prompt-caching.md +221 -0
- package/docs/zh/guide/providers.md +232 -0
- package/docs/zh/guide/quality-control.md +137 -0
- package/docs/zh/guide/vitepress-integration.md +265 -0
- package/docs/zh/index.md +58 -0
- package/package.json +91 -0
- package/release.config.mjs +15 -0
- package/schemas/glossary.schema.json +110 -0
- package/src/cli/commands/dir.ts +469 -0
- package/src/cli/commands/file.ts +291 -0
- package/src/cli/commands/glossary.ts +221 -0
- package/src/cli/commands/init.ts +68 -0
- package/src/cli/commands/serve.ts +60 -0
- package/src/cli/index.ts +64 -0
- package/src/cli/options.ts +59 -0
- package/src/core/agent.ts +1119 -0
- package/src/core/chunker.ts +391 -0
- package/src/core/engine.ts +634 -0
- package/src/errors.ts +188 -0
- package/src/index.ts +147 -0
- package/src/integrations/vitepress.ts +549 -0
- package/src/parsers/markdown.ts +383 -0
- package/src/providers/claude.ts +259 -0
- package/src/providers/interface.ts +109 -0
- package/src/providers/ollama.ts +379 -0
- package/src/providers/openai.ts +308 -0
- package/src/providers/registry.ts +153 -0
- package/src/server/index.ts +152 -0
- package/src/server/middleware/auth.ts +93 -0
- package/src/server/middleware/logger.ts +90 -0
- package/src/server/routes/health.ts +84 -0
- package/src/server/routes/translate.ts +210 -0
- package/src/server/types.ts +138 -0
- package/src/services/cache.ts +899 -0
- package/src/services/config.ts +217 -0
- package/src/services/glossary.ts +247 -0
- package/src/types/analysis.ts +164 -0
- package/src/types/index.ts +265 -0
- package/src/types/modes.ts +121 -0
- package/src/types/mqm.ts +157 -0
- package/src/utils/logger.ts +141 -0
- package/src/utils/tokens.ts +116 -0
- package/tests/fixtures/glossaries/ml-glossary.json +53 -0
- package/tests/fixtures/input/lynq-installation.ko.md +350 -0
- package/tests/fixtures/input/lynq-installation.md +350 -0
- package/tests/fixtures/input/simple.ko.md +27 -0
- package/tests/fixtures/input/simple.md +27 -0
- package/tests/unit/chunker.test.ts +229 -0
- package/tests/unit/glossary.test.ts +146 -0
- package/tests/unit/markdown.test.ts +205 -0
- package/tests/unit/tokens.test.ts +81 -0
- package/tsconfig.json +28 -0
- package/tsup.config.ts +34 -0
- 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
|
+
}
|