@babelize/engine 0.1.0 → 0.1.2
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/dist/analysis/analyze.js +1 -1
- package/dist/detection/stack.d.ts +42 -0
- package/dist/detection/stack.js +277 -0
- package/dist/django/catalog.d.ts +92 -0
- package/dist/django/catalog.js +677 -0
- package/dist/django/extract.d.ts +20 -0
- package/dist/django/extract.js +79 -0
- package/dist/django/inject-file.d.ts +6 -0
- package/dist/django/inject-file.js +52 -0
- package/dist/django/inject.d.ts +11 -0
- package/dist/django/inject.js +205 -0
- package/dist/django/keys.d.ts +21 -0
- package/dist/django/keys.js +63 -0
- package/dist/django/locale-names.d.ts +13 -0
- package/dist/django/locale-names.js +60 -0
- package/dist/django/mo-file.d.ts +6 -0
- package/dist/django/mo-file.js +60 -0
- package/dist/django/plural-forms.d.ts +34 -0
- package/dist/django/plural-forms.js +216 -0
- package/dist/django/po-file.d.ts +38 -0
- package/dist/django/po-file.js +273 -0
- package/dist/django/python-extract.d.ts +34 -0
- package/dist/django/python-extract.js +619 -0
- package/dist/django/python-lexer.d.ts +65 -0
- package/dist/django/python-lexer.js +398 -0
- package/dist/django/template-extract.d.ts +32 -0
- package/dist/django/template-extract.js +680 -0
- package/dist/django/types.d.ts +49 -0
- package/dist/django/types.js +15 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +16 -2
- package/dist/local/pseudo-provider.js +5 -4
- package/dist/local/translate-core.d.ts +5 -2
- package/dist/local/translate-core.js +43 -5
- package/dist/local/translate-messages.js +7 -9
- package/dist/modules/extractor/extract.js +22 -14
- package/dist/modules/finalization/django-finalize.d.ts +15 -0
- package/dist/modules/finalization/django-finalize.js +156 -19
- package/dist/modules/finalization/finalize.d.ts +2 -0
- package/dist/modules/finalization/finalize.js +58 -30
- package/dist/modules/finalization/nextjs-finalize.d.ts +4 -1
- package/dist/modules/finalization/nextjs-finalize.js +8 -9
- package/dist/modules/finalization/writes.d.ts +29 -0
- package/dist/modules/finalization/writes.js +78 -0
- package/dist/modules/format-injection/index.js +3 -2
- package/dist/modules/glossary/index.d.ts +1 -2
- package/dist/modules/glossary/index.js +14 -6
- package/dist/modules/glossary/rules.d.ts +81 -0
- package/dist/modules/glossary/rules.js +244 -0
- package/dist/modules/injection/inject.js +14 -30
- package/dist/modules/keygen/keygen.js +7 -0
- package/dist/modules/locale-builder/locales.js +23 -0
- package/dist/modules/lockfile/index.d.ts +1 -1
- package/dist/modules/lockfile/index.js +2 -1
- package/dist/modules/lockfile/lockfile-manager.d.ts +9 -0
- package/dist/modules/lockfile/lockfile-manager.js +16 -1
- package/dist/modules/lockfile/schema.d.ts +42 -0
- package/dist/modules/lockfile/schema.js +11 -0
- package/dist/modules/translator/icu-branches.d.ts +138 -0
- package/dist/modules/translator/icu-branches.js +393 -0
- package/dist/modules/translator/translate.d.ts +40 -4
- package/dist/modules/translator/translate.js +285 -170
- package/dist/modules/workspace/validate.js +67 -32
- package/dist/orchestrator.d.ts +5 -0
- package/dist/orchestrator.js +125 -14
- package/dist/providers/types.d.ts +10 -2
- package/dist/qa/check-project.d.ts +2 -1
- package/dist/qa/check-project.js +50 -7
- package/dist/qa/gettext-files.d.ts +15 -0
- package/dist/qa/gettext-files.js +115 -0
- package/dist/qa/locale-qa.js +20 -3
- package/dist/runtime/config.js +1 -0
- package/dist/runtime/types.d.ts +64 -0
- package/dist/utils/extraction-options.d.ts +9 -3
- package/dist/utils/extraction-options.js +13 -3
- package/dist/utils/file-utils.js +7 -2
- package/dist/utils/jsx-message.js +7 -0
- package/dist/utils/metadata.d.ts +4 -2
- package/dist/utils/metadata.js +16 -10
- package/dist/utils/not-injected.js +2 -0
- package/dist/utils/placeholder-protection.d.ts +18 -1
- package/dist/utils/placeholder-protection.js +58 -13
- package/dist/utils/translation-sanitizer.d.ts +1 -0
- package/dist/utils/translation-sanitizer.js +23 -9
- package/dist/utils/translator-load-balancer.js +5 -1
- package/dist/utils/untranslated.d.ts +15 -0
- package/dist/utils/untranslated.js +34 -0
- package/package.json +2 -2
- package/dist/injection/django/django-template-injector.d.ts +0 -29
- package/dist/injection/django/django-template-injector.js +0 -216
- package/dist/modules/glossary/glossary.d.ts +0 -9
- package/dist/modules/glossary/glossary.js +0 -27
- package/dist/modules/glossary/masker.d.ts +0 -23
- package/dist/modules/glossary/masker.js +0 -56
- package/dist/modules/locale-builder/po.d.ts +0 -33
- package/dist/modules/locale-builder/po.js +0 -131
- package/dist/modules/translator/translator.d.ts +0 -5
- package/dist/modules/translator/translator.js +0 -46
- package/dist/utils/dtl-mask.d.ts +0 -25
- package/dist/utils/dtl-mask.js +0 -58
- package/dist/utils/py-extractor.d.ts +0 -14
- package/dist/utils/py-extractor.js +0 -285
|
@@ -1,43 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
3
|
exports.FALLBACK_QA_WARNING = void 0;
|
|
4
|
+
exports.loadProjectTranslationRules = loadProjectTranslationRules;
|
|
37
5
|
exports.translateLocales = translateLocales;
|
|
38
6
|
exports.backfillTranslationMemory = backfillTranslationMemory;
|
|
39
|
-
const path = __importStar(require("path"));
|
|
40
7
|
const cache_keys_1 = require("../../utils/cache-keys");
|
|
8
|
+
const untranslated_1 = require("../../utils/untranslated");
|
|
41
9
|
const logger_1 = require("../../utils/logger");
|
|
42
10
|
const translation_quality_1 = require("../../utils/translation-quality");
|
|
43
11
|
const file_utils_1 = require("../../utils/file-utils");
|
|
@@ -47,18 +15,25 @@ const keygen_1 = require("../keygen");
|
|
|
47
15
|
const concurrent_map_1 = require("../../utils/concurrent-map");
|
|
48
16
|
const language_codes_1 = require("./language-codes");
|
|
49
17
|
const glossary_1 = require("../glossary");
|
|
18
|
+
const check_project_1 = require("../../qa/check-project");
|
|
50
19
|
const gemini_provider_1 = require("./client/gemini.provider");
|
|
51
20
|
const memory_matcher_1 = require("./memory/memory-matcher");
|
|
52
21
|
const default_translation_provider_1 = require("../../providers/default-translation-provider");
|
|
53
22
|
const translation_sanitizer_1 = require("../../utils/translation-sanitizer");
|
|
54
23
|
const glossary_match_1 = require("../../utils/glossary-match");
|
|
55
24
|
const icu_spans_1 = require("../../utils/icu-spans");
|
|
25
|
+
const icu_branches_1 = require("./icu-branches");
|
|
56
26
|
const BATCH_SIZE = 15;
|
|
57
27
|
/** QA note on keys the backup (NLLB) translator produced. */
|
|
58
28
|
exports.FALLBACK_QA_WARNING = 'translated with the backup translator; will be retried with the AI translator next run';
|
|
59
29
|
/** Keys re-sent after failing validation go in small batches with per-key notes. */
|
|
60
30
|
const REPAIR_BATCH_SIZE = 5;
|
|
61
31
|
const TRANSLATE_CONCURRENCY = 3; // Max concurrent locales/batches
|
|
32
|
+
/**
|
|
33
|
+
* ICU branches sent to a plain-text translator go in requests of up to this
|
|
34
|
+
* many sentences (the translator service's batch limit).
|
|
35
|
+
*/
|
|
36
|
+
const BRANCH_BATCH_SIZE = 50;
|
|
62
37
|
/** A plain source whose stored translation is ICU only fits a project whose runtime formats ICU. */
|
|
63
38
|
function reusableTranslation(source, translated, targetPlurals) {
|
|
64
39
|
return targetPlurals || (0, icu_message_1.isIcuMessage)(source) || !(0, icu_message_1.isIcuMessage)(translated);
|
|
@@ -85,12 +60,6 @@ function isUnchangedSentence(sentText, returnedText) {
|
|
|
85
60
|
const words = norm(sentText).split(' ').filter((w) => /\p{L}{2,}/u.test(w));
|
|
86
61
|
return words.length >= 3 && norm(sentText) === norm(returnedText);
|
|
87
62
|
}
|
|
88
|
-
function escapeTerm(term) {
|
|
89
|
-
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
90
|
-
}
|
|
91
|
-
function containsTerm(text, term) {
|
|
92
|
-
return new RegExp(`(?<![\\w])${escapeTerm(term)}(?![\\w])`, 'i').test(text);
|
|
93
|
-
}
|
|
94
63
|
/** Tags, URLs, code and never-translate terms present in the source but absent from the output. */
|
|
95
64
|
function missingSpans(source, translated, neverTranslateTerms) {
|
|
96
65
|
const have = new Set((0, placeholder_protection_1.protectText)(translated, neverTranslateTerms, { icu: true }).tokens.map((t) => t.toLowerCase()));
|
|
@@ -98,11 +67,24 @@ function missingSpans(source, translated, neverTranslateTerms) {
|
|
|
98
67
|
return [...want].filter((t) => !have.has(t.toLowerCase()));
|
|
99
68
|
}
|
|
100
69
|
/** Glossary terms in the source whose required translation is missing from the output. */
|
|
101
|
-
function glossaryWarnings(source, translated, glossary) {
|
|
70
|
+
function glossaryWarnings(source, translated, glossary, neverTranslateTerms) {
|
|
102
71
|
return glossary
|
|
103
|
-
.filter((g) =>
|
|
72
|
+
.filter((g) => (0, glossary_1.sourceHasTerm)(source, g.term, neverTranslateTerms) && !(0, glossary_match_1.containsGlossaryTranslation)(translated, g.translation))
|
|
104
73
|
.map((g) => `Glossary: "${g.term}" should be translated as "${g.translation}"`);
|
|
105
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Why a stored translation (cache or TM) can't be reused under the project's
|
|
77
|
+
* current glossary, or null if it can: a placeholder, tag, URL or
|
|
78
|
+
* never-translate term of the source is missing, or an "always translate as"
|
|
79
|
+
* term's required translation is. Such a hit is treated as a miss.
|
|
80
|
+
*/
|
|
81
|
+
function storedTranslationProblem(source, stored, neverTranslateTerms, glossaryForLocale) {
|
|
82
|
+
const lost = missingSpans(source, stored, neverTranslateTerms);
|
|
83
|
+
if (lost.length > 0)
|
|
84
|
+
return `missing ${lost.join(', ')}`;
|
|
85
|
+
const glossary = glossaryWarnings(source, stored, glossaryForLocale, neverTranslateTerms);
|
|
86
|
+
return glossary.length > 0 ? glossary.join('; ') : null;
|
|
87
|
+
}
|
|
106
88
|
const SCRIPT_RANGES = [
|
|
107
89
|
[0x0000, 0x024F], // Latin (Basic, Latin-1, Extended-A/B) — one script
|
|
108
90
|
[0x0370, 0x03FF], // Greek
|
|
@@ -156,29 +138,22 @@ function hasMixedScripts(text) {
|
|
|
156
138
|
* project-specific wording may be baked in.
|
|
157
139
|
*/
|
|
158
140
|
async function loadProjectTranslationRules(projectId, rules) {
|
|
159
|
-
let
|
|
160
|
-
let alwaysTranslateAsEntries = [];
|
|
141
|
+
let glossary = glossary_1.EMPTY_GLOSSARY;
|
|
161
142
|
let brandVoice = null;
|
|
162
143
|
let complete = true;
|
|
163
144
|
if (!rules)
|
|
164
|
-
return {
|
|
145
|
+
return { glossary, brandVoice, complete };
|
|
165
146
|
try {
|
|
166
147
|
const entries = await rules.getGlossary(projectId);
|
|
167
148
|
if (entries?.length) {
|
|
168
|
-
|
|
169
|
-
.filter((e) => e.type === 'never_translate')
|
|
170
|
-
.map((e) => e.term);
|
|
171
|
-
alwaysTranslateAsEntries = entries
|
|
172
|
-
.filter((e) => e.type === 'always_translate_as')
|
|
173
|
-
.map((e) => ({
|
|
174
|
-
term: e.term,
|
|
175
|
-
type: 'always_translate_as',
|
|
176
|
-
translations: e.translations || {},
|
|
177
|
-
}));
|
|
149
|
+
glossary = (0, glossary_1.normalizeGlossary)(entries);
|
|
178
150
|
logger_1.engineLogger.info('Loaded project glossary', {
|
|
179
|
-
neverTranslate:
|
|
180
|
-
alwaysTranslateAs:
|
|
151
|
+
neverTranslate: glossary.neverTranslate.length,
|
|
152
|
+
alwaysTranslateAs: glossary.alwaysTranslateAs.length,
|
|
153
|
+
conflicts: glossary.conflicts.length,
|
|
181
154
|
});
|
|
155
|
+
for (const c of glossary.conflicts)
|
|
156
|
+
logger_1.engineLogger.warn(c.message, { projectId });
|
|
182
157
|
}
|
|
183
158
|
}
|
|
184
159
|
catch (err) {
|
|
@@ -198,16 +173,32 @@ async function loadProjectTranslationRules(projectId, rules) {
|
|
|
198
173
|
complete = false;
|
|
199
174
|
logger_1.engineLogger.warn('Failed to load brand voice', { error: err });
|
|
200
175
|
}
|
|
201
|
-
return {
|
|
176
|
+
return { glossary, brandVoice, complete };
|
|
202
177
|
}
|
|
203
178
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
179
|
+
* The glossary of a run without a cloud project (CLI/MCP, no projectId):
|
|
180
|
+
* `.babelize/config.json` `glossary`, the same file and schema `babelize
|
|
181
|
+
* check` reads.
|
|
207
182
|
*/
|
|
208
|
-
function
|
|
209
|
-
|
|
210
|
-
|
|
183
|
+
async function loadWorkspaceGlossary(workspacePath) {
|
|
184
|
+
try {
|
|
185
|
+
const { glossary } = await (0, check_project_1.readProjectLocaleConfig)(workspacePath);
|
|
186
|
+
if (!glossary)
|
|
187
|
+
return glossary_1.EMPTY_GLOSSARY;
|
|
188
|
+
const rules = (0, glossary_1.normalizeGlossary)([
|
|
189
|
+
...(glossary.neverTranslate ?? []).map((term) => ({ term, type: 'never_translate' })),
|
|
190
|
+
...(glossary.alwaysTranslateAs ?? []).map((e) => ({ term: e.term, type: 'always_translate_as', translations: e.translations })),
|
|
191
|
+
]);
|
|
192
|
+
logger_1.engineLogger.info('Loaded glossary from workspace config', {
|
|
193
|
+
neverTranslate: rules.neverTranslate.length,
|
|
194
|
+
alwaysTranslateAs: rules.alwaysTranslateAs.length,
|
|
195
|
+
});
|
|
196
|
+
return rules;
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
logger_1.engineLogger.warn('Failed to load glossary from workspace config', { error: err });
|
|
200
|
+
return glossary_1.EMPTY_GLOSSARY;
|
|
201
|
+
}
|
|
211
202
|
}
|
|
212
203
|
async function translateLocales(workspace, sourceLocale, targetLocales, forceTranslation = false, dryRun = false, projectId, onProgress, keysToTranslateByLocale, cacheProvider, byokConfig, contextMap, extractionContextMap, providers = {}, options = {}) {
|
|
213
204
|
const locales = [];
|
|
@@ -231,50 +222,16 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
|
|
|
231
222
|
delete flatSource[key];
|
|
232
223
|
}
|
|
233
224
|
}
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
rulesComplete = rules.complete;
|
|
245
|
-
}
|
|
246
|
-
// Fall back to workspace config for CLI/MCP runs (no projectId)
|
|
247
|
-
if (!projectId) {
|
|
248
|
-
try {
|
|
249
|
-
const babelizeConfigPath = path.join(workspace.path, '.babelize', 'config.json');
|
|
250
|
-
const fileUtils = await Promise.resolve().then(() => __importStar(require('../../utils/file-utils')));
|
|
251
|
-
const { readJsonFile } = fileUtils;
|
|
252
|
-
if (await fileUtils.pathExists(babelizeConfigPath)) {
|
|
253
|
-
const config = await readJsonFile(babelizeConfigPath);
|
|
254
|
-
if (config?.customTranslations) {
|
|
255
|
-
for (const [term, entry] of Object.entries(config.customTranslations)) {
|
|
256
|
-
if (entry.neverTranslate) {
|
|
257
|
-
neverTranslateTerms.push(term);
|
|
258
|
-
}
|
|
259
|
-
if (entry.translations) {
|
|
260
|
-
alwaysTranslateAsEntries.push({
|
|
261
|
-
term,
|
|
262
|
-
type: 'always_translate_as',
|
|
263
|
-
translations: entry.translations,
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
logger_1.engineLogger.info('Loaded glossary from workspace config', {
|
|
268
|
-
neverTranslate: neverTranslateTerms.length,
|
|
269
|
-
alwaysTranslateAs: alwaysTranslateAsEntries.length,
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
catch (err) {
|
|
275
|
-
logger_1.engineLogger.warn('Failed to load glossary from workspace config', { error: err });
|
|
276
|
-
}
|
|
277
|
-
}
|
|
225
|
+
// The project's glossary and brand voice (injected rules store), or for a
|
|
226
|
+
// run without a project the workspace's .babelize/config.json glossary.
|
|
227
|
+
const rules = options.rules
|
|
228
|
+
?? (projectId
|
|
229
|
+
? await loadProjectTranslationRules(projectId, providers.rules)
|
|
230
|
+
: { glossary: await loadWorkspaceGlossary(workspace.path), brandVoice: null, complete: true });
|
|
231
|
+
const glossary = rules.glossary;
|
|
232
|
+
const neverTranslateTerms = glossary.neverTranslate;
|
|
233
|
+
const brandVoice = rules.brandVoice;
|
|
234
|
+
const rulesComplete = rules.complete;
|
|
278
235
|
// Check if translator is configured before processing locales
|
|
279
236
|
if (!byokConfig && !providers.translator && !process.env.TRANSLATOR_URL && targetLocales.some(l => l !== sourceLocale.locale)) {
|
|
280
237
|
logger_1.engineLogger.error('TRANSLATOR_URL environment variable is not set and no BYOK config provided. Translations will fall back to source text.');
|
|
@@ -308,8 +265,8 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
|
|
|
308
265
|
const sourceLangCode = (0, language_codes_1.toFlores)(sourceLocale.locale);
|
|
309
266
|
// LLM translators (BYOK, or an injected one such as Babelize-hosted)
|
|
310
267
|
// take the brand voice in the prompt; NLLB output is polished after.
|
|
311
|
-
const hasBrandVoiceInPrompt = !!((byokConfig || providers.translator) && brandVoice);
|
|
312
|
-
const translationResult = await translateEntries(entriesToTranslate, sourceLangCode, targetLangCode, projectId, onProgress,
|
|
268
|
+
const hasBrandVoiceInPrompt = !!((byokConfig || (providers.translator && !providers.translator.plainText)) && brandVoice);
|
|
269
|
+
const translationResult = await translateEntries(entriesToTranslate, sourceLangCode, targetLangCode, projectId, onProgress, glossary, targetLocale, cacheProvider, byokConfig, contextMap, extractionContextMap, hasBrandVoiceInPrompt ? (brandVoice || undefined) : undefined, dryRun, forceTranslation, providers, rulesComplete, options);
|
|
313
270
|
const entriesResult = translationResult.result;
|
|
314
271
|
const batchStats = translationResult.stats;
|
|
315
272
|
const batchMetadata = translationResult.metadata;
|
|
@@ -325,6 +282,7 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
|
|
|
325
282
|
let translatedEntries = entriesResult;
|
|
326
283
|
// Post-hoc brand voice polish only for the NLLB service; LLM
|
|
327
284
|
// translators already had the brand voice in the prompt.
|
|
285
|
+
const glossaryForLocale = (0, glossary_1.fixedTermsFor)(glossary, targetLocale);
|
|
328
286
|
if (brandVoice && !hasBrandVoiceInPrompt) {
|
|
329
287
|
// TM hits are approved/previously-accepted text: polishing them again
|
|
330
288
|
// would rewrite reviewed translations nondeterministically every run.
|
|
@@ -348,7 +306,10 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
|
|
|
348
306
|
// ICU (in the source, or a plural the translation added) must
|
|
349
307
|
// stay an equivalent message of the validated translation.
|
|
350
308
|
const icu = (0, icu_message_1.isIcuMessage)(entriesToTranslate[k] ?? '') || (0, icu_message_1.isIcuMessage)(before) || (0, icu_message_1.isIcuMessage)(v);
|
|
351
|
-
|
|
309
|
+
// ...and still uses every required glossary translation the validated text used.
|
|
310
|
+
const source = entriesToTranslate[k] ?? '';
|
|
311
|
+
const glossaryOk = glossaryWarnings(source, v, glossaryForLocale, neverTranslateTerms).length <= glossaryWarnings(source, before, glossaryForLocale, neverTranslateTerms).length;
|
|
312
|
+
const safe = (0, placeholder_protection_1.sameProtectedSpans)(before, v, neverTranslateTerms) && glossaryOk &&
|
|
352
313
|
(!icu || !(0, icu_message_1.compareIcuMessages)(before, v, targetLocale).some((i) => i.severity === 'error'));
|
|
353
314
|
if (safe)
|
|
354
315
|
accepted[k] = v;
|
|
@@ -368,7 +329,9 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
|
|
|
368
329
|
...translatedData,
|
|
369
330
|
_meta: { ...existingTranslatedMeta, dir: (0, language_codes_1.isRTL)(targetLocale) ? 'rtl' : 'ltr', language: targetLocale },
|
|
370
331
|
};
|
|
371
|
-
|
|
332
|
+
// gettext catalogues are merged into the existing .po files once the
|
|
333
|
+
// whole run's translations are known (see django/catalog.ts).
|
|
334
|
+
if (!dryRun && workspace.localeFormat !== 'po')
|
|
372
335
|
await (0, file_utils_1.writeJsonFile)(targetPath, translatedData, true);
|
|
373
336
|
return {
|
|
374
337
|
locale: targetLocale,
|
|
@@ -418,7 +381,7 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
|
|
|
418
381
|
metadata: perLocaleMetadata
|
|
419
382
|
};
|
|
420
383
|
}
|
|
421
|
-
async function translateEntries(entries, sourceLang, targetLang, projectId, onProgress,
|
|
384
|
+
async function translateEntries(entries, sourceLang, targetLang, projectId, onProgress, glossary = glossary_1.EMPTY_GLOSSARY, targetLocale = '', cacheProvider, byokConfig, contextMap, extractionContextMap, brandVoice, dryRun = false, forceTranslation = false, providers = {}, rulesComplete = true, options = {}) {
|
|
422
385
|
const result = {};
|
|
423
386
|
const metadata = {};
|
|
424
387
|
const keys = Object.keys(entries);
|
|
@@ -433,6 +396,14 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
433
396
|
for (const k of keys)
|
|
434
397
|
metadata[k] = { ...(metadata[k] ?? {}), inTm: true };
|
|
435
398
|
};
|
|
399
|
+
const neverTranslateTerms = glossary.neverTranslate;
|
|
400
|
+
// "Always translate as" terms with a translation for this locale (exact
|
|
401
|
+
// locale, else its base language).
|
|
402
|
+
const glossaryForLocale = (0, glossary_1.fixedTermsFor)(glossary, targetLocale);
|
|
403
|
+
// Output shaped by such a rule is this project's wording: it must not go
|
|
404
|
+
// into the cache or TM, which are shared beyond the project.
|
|
405
|
+
const projectWording = (source) => (0, glossary_1.hasFixedTerm)(source, glossary, targetLocale);
|
|
406
|
+
const reuseProblem = (source, stored) => storedTranslationProblem(source, stored, neverTranslateTerms, glossaryForLocale);
|
|
436
407
|
for (const key of keys) {
|
|
437
408
|
const text = entries[key];
|
|
438
409
|
const entryContext = contextMap?.[key];
|
|
@@ -440,8 +411,16 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
440
411
|
const textHash = (0, cache_keys_1.hashText)(textForHash);
|
|
441
412
|
const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, textHash);
|
|
442
413
|
const cached = cacheProvider && !forceTranslation ? await cacheProvider.get(cacheKey) : { hit: false, data: null };
|
|
443
|
-
|
|
444
|
-
|
|
414
|
+
// A stored value that is the untranslated source (cached before this was
|
|
415
|
+
// rejected) is a miss, so the string is translated again instead of failing forever.
|
|
416
|
+
// So is one that breaks the project's glossary (a never-translate term
|
|
417
|
+
// translated, a required translation missing): glossary edits reach
|
|
418
|
+
// cached strings by re-translating them.
|
|
419
|
+
const cacheProblem = cached.hit && cached.data !== null ? reuseProblem(text, cached.data) : null;
|
|
420
|
+
if (cacheProblem)
|
|
421
|
+
logger_1.engineLogger.info('Cached translation breaks the glossary; translating again', { key, targetLocale, problem: cacheProblem });
|
|
422
|
+
if (cached.hit && cached.data !== null && !cacheProblem && reusableTranslation(text, cached.data, !!options.targetPlurals) && !(0, untranslated_1.isUntranslatedInto)(text, cached.data, targetLocale)) {
|
|
423
|
+
result[key] = cached.data;
|
|
445
424
|
cacheHits++;
|
|
446
425
|
// Reused, not translated this run (billing counts only fresh output).
|
|
447
426
|
metadata[key] = { ...(metadata[key] ?? {}), fromCache: true };
|
|
@@ -449,8 +428,8 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
449
428
|
// since cache entries written before TM stored pre-glossary text may
|
|
450
429
|
// have them baked in — strings with project glossary rules or an
|
|
451
430
|
// in-prompt brand voice, which must not leak into other projects.
|
|
452
|
-
if (rulesComplete && !entryContext && !brandVoice && !
|
|
453
|
-
cacheBackfill.push({ key, sourceText: text, translatedText:
|
|
431
|
+
if (rulesComplete && !entryContext && !brandVoice && !projectWording(text)) {
|
|
432
|
+
cacheBackfill.push({ key, sourceText: text, translatedText: cached.data });
|
|
454
433
|
}
|
|
455
434
|
}
|
|
456
435
|
else {
|
|
@@ -514,13 +493,17 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
514
493
|
continue;
|
|
515
494
|
}
|
|
516
495
|
const lookup = (0, memory_matcher_1.matchFromMemory)(allMemory, entry.value, storeLang);
|
|
517
|
-
|
|
518
|
-
|
|
496
|
+
// A TM entry that breaks this project's glossary is a miss (TM is shared across the owner's projects).
|
|
497
|
+
const tmProblem = lookup.hit && lookup.translatedText ? reuseProblem(entry.value, lookup.translatedText) : null;
|
|
498
|
+
if (tmProblem)
|
|
499
|
+
logger_1.engineLogger.info('TM translation breaks the glossary; translating again', { key: entry.key, targetLocale, problem: tmProblem });
|
|
500
|
+
if (lookup.hit && lookup.translatedText && !tmProblem && reusableTranslation(entry.value, lookup.translatedText, !!options.targetPlurals) && !(0, untranslated_1.isUntranslatedInto)(entry.value, lookup.translatedText, targetLocale)) {
|
|
501
|
+
result[entry.key] = lookup.translatedText;
|
|
519
502
|
tmHits++;
|
|
520
503
|
metadata[entry.key] = { fromMemory: true, inTm: true };
|
|
521
|
-
// Warm
|
|
504
|
+
// Warm the cache (never with this project's glossary wording)
|
|
522
505
|
const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, entry.hash);
|
|
523
|
-
if (cacheProvider) {
|
|
506
|
+
if (cacheProvider && !projectWording(entry.value)) {
|
|
524
507
|
cacheProvider.set(cacheKey, lookup.translatedText, cache_keys_1.TRANSLATION_CACHE_TTL_SECONDS).catch((err) => {
|
|
525
508
|
logger_1.engineLogger.error('Failed to warm Redis cache from TM', { cacheKey, error: err });
|
|
526
509
|
});
|
|
@@ -553,30 +536,29 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
553
536
|
});
|
|
554
537
|
}
|
|
555
538
|
const failures = {};
|
|
556
|
-
// LLM providers get structure-aware prompts and repair attempts
|
|
557
|
-
// service
|
|
558
|
-
|
|
539
|
+
// LLM providers get structure-aware prompts and repair attempts. The NLLB
|
|
540
|
+
// service translates plain text only: ICU messages go branch by branch
|
|
541
|
+
// (./icu-branches), and mixed-script text fails up front.
|
|
542
|
+
const isLlm = !!byokConfig || (!!providers.translator && !providers.translator.plainText);
|
|
559
543
|
// Scripts FLORES-200 lacks (sr-Latn, uz-Cyrl) only work with an LLM.
|
|
560
544
|
const nllbTarget = !targetLocale || !(0, language_codes_1.isSupported)(targetLocale) || (0, language_codes_1.isNllbSupported)(targetLocale);
|
|
561
545
|
if (uncachedEntries.length > 0) {
|
|
562
|
-
const glossaryForLocale = alwaysTranslateAsEntries
|
|
563
|
-
.filter((e) => e.translations[targetLocale])
|
|
564
|
-
.map((e) => ({ term: e.term, translation: e.translations[targetLocale] }));
|
|
565
546
|
// 1. Protect placeholders, tags and never-translate terms. Strings with
|
|
566
547
|
// nothing translatable left (e.g. "{count}", a brand name) are their own
|
|
567
|
-
// translation and never reach the translator.
|
|
548
|
+
// translation and never reach the translator. "Always translate as"
|
|
549
|
+
// terms are tokens too for a plain-text translator, restored to their
|
|
550
|
+
// fixed translation (it can't take a glossary); an LLM gets them as
|
|
551
|
+
// required terminology in the prompt instead.
|
|
568
552
|
const prepared = [];
|
|
569
553
|
for (const entry of uncachedEntries) {
|
|
570
554
|
const icu = (0, icu_message_1.isIcuMessage)(entry.value);
|
|
571
|
-
if (!isLlm && (
|
|
555
|
+
if (!isLlm && (hasMixedScripts(entry.value) || !nllbTarget)) {
|
|
572
556
|
failures[entry.key] = {
|
|
573
557
|
reason: 'unsupported_by_provider',
|
|
574
558
|
retryable: false,
|
|
575
559
|
message: !nllbTarget
|
|
576
560
|
? `${targetLocale} needs an LLM translation provider`
|
|
577
|
-
:
|
|
578
|
-
? 'ICU plural/select messages need an LLM translation provider'
|
|
579
|
-
: 'Text mixing writing systems needs an LLM translation provider',
|
|
561
|
+
: 'Text mixing writing systems needs an LLM translation provider',
|
|
580
562
|
};
|
|
581
563
|
continue;
|
|
582
564
|
}
|
|
@@ -584,24 +566,44 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
584
566
|
// Japanese keeps only "other"), so ICU text is sent untokenized and its
|
|
585
567
|
// tags/URLs/terms are checked as a set after translation instead. The
|
|
586
568
|
// same goes for a plain message with a count ("{files} files were
|
|
587
|
-
// uploaded"): an LLM may return a plural for a language that needs one
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
569
|
+
// uploaded"): an LLM may return a plural for a language that needs one;
|
|
570
|
+
// for NLLB one is built branch by branch when the language has more
|
|
571
|
+
// than one plural category.
|
|
572
|
+
const countArgs = options.targetPlurals && !icu && (isLlm || (0, icu_branches_1.targetCategories)(targetLocale, 'cardinal').length > 1)
|
|
573
|
+
? (0, icu_message_1.countArguments)(entry.value)
|
|
574
|
+
: [];
|
|
575
|
+
const ownTokens = (0, placeholder_protection_1.protectText)(entry.value, neverTranslateTerms);
|
|
576
|
+
if ((0, placeholder_protection_1.hasNothingToTranslate)(ownTokens.text)) {
|
|
593
577
|
result[entry.key] = entry.value;
|
|
594
578
|
// Copied through (placeholders/codes only); the translator never saw it.
|
|
595
579
|
metadata[entry.key] = { ...(metadata[entry.key] ?? {}), passthrough: true };
|
|
596
580
|
continue;
|
|
597
581
|
}
|
|
582
|
+
// Never-translate terms win over a fixed term spelled the same, and
|
|
583
|
+
// placeholders/URLs/tags over both (see protectText).
|
|
584
|
+
const withFixed = glossaryForLocale.length > 0
|
|
585
|
+
? (0, placeholder_protection_1.protectText)(entry.value, neverTranslateTerms, { fixedTerms: glossaryForLocale })
|
|
586
|
+
: ownTokens;
|
|
587
|
+
if (!icu && (0, placeholder_protection_1.hasNothingToTranslate)(withFixed.text)) {
|
|
588
|
+
// Only glossary terms and placeholders ("Settings" → "Konfiguration"):
|
|
589
|
+
// the rule is the translation; no translator needed (or billed).
|
|
590
|
+
result[entry.key] = (0, placeholder_protection_1.restoreText)(withFixed.text, withFixed.restore);
|
|
591
|
+
metadata[entry.key] = { ...(metadata[entry.key] ?? {}), passthrough: true, glossary: true };
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const tokenized = isLlm ? ownTokens : withFixed;
|
|
595
|
+
const sendRaw = icu || countArgs.length > 0;
|
|
596
|
+
const prot = sendRaw ? { text: entry.value, tokens: [], restore: [] } : tokenized;
|
|
598
597
|
prepared.push({
|
|
599
598
|
...entry,
|
|
600
599
|
sendText: prot.text,
|
|
601
600
|
tokens: prot.tokens,
|
|
601
|
+
restore: prot.restore,
|
|
602
602
|
icu: sendRaw,
|
|
603
603
|
countArgs: countArgs.length ? countArgs : undefined,
|
|
604
|
-
plain: countArgs.length
|
|
604
|
+
plain: countArgs.length || (isLlm && !icu)
|
|
605
|
+
? { sendText: withFixed.text, tokens: withFixed.tokens, restore: withFixed.restore }
|
|
606
|
+
: undefined,
|
|
605
607
|
});
|
|
606
608
|
}
|
|
607
609
|
// 2. Translate in batches; judge every key independently.
|
|
@@ -623,7 +625,11 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
623
625
|
const tokenCheck = (0, placeholder_protection_1.checkTokens)(keyResult.value, entry.tokens.length);
|
|
624
626
|
if (!tokenCheck.ok)
|
|
625
627
|
return fail('placeholder_lost', true, `Placeholder problem: ${(0, placeholder_protection_1.describeTokenProblem)(tokenCheck, entry.tokens)}`);
|
|
626
|
-
const value = (0, placeholder_protection_1.restoreText)((0, translation_sanitizer_1.sanitizeTranslation)(keyResult.value, { nllb }), entry.
|
|
628
|
+
const value = (0, placeholder_protection_1.restoreText)((0, translation_sanitizer_1.sanitizeTranslation)(keyResult.value, { nllb, source: entry.sendText }), entry.restore);
|
|
629
|
+
// NLLB sometimes answers a short string with only its language tag
|
|
630
|
+
// ("eng_Latn"), which the sanitizer strips to nothing: no translation.
|
|
631
|
+
if (!value.trim())
|
|
632
|
+
return fail('empty_value', true, 'The translator returned no usable text');
|
|
627
633
|
const warnings = [];
|
|
628
634
|
// A plural added for a count placeholder is validated as ICU too.
|
|
629
635
|
if (entry.icu || (0, icu_message_1.isIcuMessage)(value)) {
|
|
@@ -642,7 +648,7 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
642
648
|
}
|
|
643
649
|
warnings.push(...issues.map((i) => i.message));
|
|
644
650
|
}
|
|
645
|
-
if (isUnchangedSentence(entry.sendText, keyResult.value)) {
|
|
651
|
+
if (isUnchangedSentence(entry.sendText, keyResult.value) || (0, untranslated_1.isUntranslatedInto)(entry.value, value, targetLocale)) {
|
|
646
652
|
return fail('untranslated', true, `Returned unchanged instead of translated into ${targetLocale}`);
|
|
647
653
|
}
|
|
648
654
|
const quality = (0, translation_quality_1.validateTranslationQuality)(entry.value, value);
|
|
@@ -658,10 +664,10 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
658
664
|
};
|
|
659
665
|
const translateBatch = async (batch, hints) => {
|
|
660
666
|
const batchGlossary = [
|
|
661
|
-
...glossaryForLocale.filter((g) => batch.some((b) =>
|
|
667
|
+
...glossaryForLocale.filter((g) => batch.some((b) => (0, glossary_1.sourceHasTerm)(b.value, g.term, neverTranslateTerms))),
|
|
662
668
|
// Untokenized ICU text: keep never-translate terms as-is via the glossary.
|
|
663
669
|
...neverTranslateTerms
|
|
664
|
-
.filter((t) => batch.some((b) => b.icu &&
|
|
670
|
+
.filter((t) => batch.some((b) => b.icu && (0, glossary_1.sourceHasTerm)(b.value, t)))
|
|
665
671
|
.map((t) => ({ term: t, translation: t })),
|
|
666
672
|
];
|
|
667
673
|
const keyResults = await translator.translateBatch({
|
|
@@ -708,14 +714,104 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
708
714
|
if (byokError)
|
|
709
715
|
throw byokError;
|
|
710
716
|
};
|
|
717
|
+
/**
|
|
718
|
+
* Translate ICU entries with a plain-text translator, branch by branch:
|
|
719
|
+
* the branch sentences of all entries go out in as few requests as
|
|
720
|
+
* possible, then each entry is put back together and validated like any
|
|
721
|
+
* ICU translation. One outcome per entry.
|
|
722
|
+
*/
|
|
723
|
+
const translateByBranches = async (entries, provider) => {
|
|
724
|
+
const out = new Map();
|
|
725
|
+
const plans = new Map();
|
|
726
|
+
const requests = [];
|
|
727
|
+
const unitKey = (key, id) => `${key}#branch${id}`;
|
|
728
|
+
for (const b of entries) {
|
|
729
|
+
const plan = (0, icu_branches_1.planIcuBranches)(b.value, targetLocale, {
|
|
730
|
+
neverTranslateTerms,
|
|
731
|
+
// Always a plain-text translator here: fixed terms go as tokens.
|
|
732
|
+
fixedTerms: glossaryForLocale,
|
|
733
|
+
valuePlaceholder: options.pluralValuePlaceholders?.[b.key],
|
|
734
|
+
pluralizeArgs: b.countArgs,
|
|
735
|
+
});
|
|
736
|
+
if ('error' in plan) {
|
|
737
|
+
out.set(b.key, { ok: false, reason: 'unsupported_by_provider', retryable: false, message: `This ICU message can't be translated branch by branch: ${plan.error}` });
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
plans.set(b.key, { entry: b, plan });
|
|
741
|
+
for (const u of plan.units) {
|
|
742
|
+
if (u.passthrough)
|
|
743
|
+
continue;
|
|
744
|
+
requests.push({ key: unitKey(b.key, u.id), value: u.sendText, parentContext: contextMap?.[b.key], extractionContext: extractionContextMap?.[b.key] });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const results = new Map();
|
|
748
|
+
const sendAll = async (list) => {
|
|
749
|
+
const batches = chunk(list, BRANCH_BATCH_SIZE);
|
|
750
|
+
await (0, concurrent_map_1.concurrentMap)(batches, async (batch, i) => {
|
|
751
|
+
onProgress?.(`Translating plural/select branches ${i + 1}/${batches.length} for ${targetLang}...`);
|
|
752
|
+
try {
|
|
753
|
+
const keyResults = await provider.translateBatch({ entries: batch, sourceLang, targetLang, targetLocale });
|
|
754
|
+
for (const r of keyResults)
|
|
755
|
+
results.set(r.key, r);
|
|
756
|
+
}
|
|
757
|
+
catch (error) {
|
|
758
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
759
|
+
logger_1.engineLogger.error('Branch translation batch failed', { error: message, sourceLang, targetLang });
|
|
760
|
+
for (const e of batch)
|
|
761
|
+
results.set(e.key, { key: e.key, status: 'failed', reason: 'provider_error', retryable: true, message });
|
|
762
|
+
}
|
|
763
|
+
}, TRANSLATE_CONCURRENCY);
|
|
764
|
+
};
|
|
765
|
+
await sendAll(requests);
|
|
766
|
+
// Branches the translator skipped get one more request.
|
|
767
|
+
const skipped = requests.filter((r) => {
|
|
768
|
+
const res = results.get(r.key);
|
|
769
|
+
return !res || (res.status === 'failed' && res.reason === 'missing_from_response');
|
|
770
|
+
});
|
|
771
|
+
if (skipped.length > 0)
|
|
772
|
+
await sendAll(skipped);
|
|
773
|
+
const checkUnit = (unit, translated) => isUnchangedSentence(unit.sendText, translated) || (0, untranslated_1.isUntranslatedInto)(unit.sendText, translated, targetLocale)
|
|
774
|
+
? { ok: false, reason: 'untranslated', retryable: true, message: `returned unchanged instead of translated into ${targetLocale}` }
|
|
775
|
+
: null;
|
|
776
|
+
for (const [key, { entry, plan }] of plans) {
|
|
777
|
+
const composed = plan.compose(new Map(plan.units.map((u) => [u.id, results.get(unitKey(key, u.id))])), checkUnit);
|
|
778
|
+
if (!composed.ok) {
|
|
779
|
+
out.set(key, { ok: false, reason: composed.reason, retryable: composed.retryable, message: composed.message });
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
// Each branch was cleaned up on its own; the message is checked as a whole.
|
|
783
|
+
out.set(key, judge(entry, { key, status: 'ok', value: composed.value }, false));
|
|
784
|
+
}
|
|
785
|
+
return out;
|
|
786
|
+
};
|
|
787
|
+
// With a plain-text translator, ICU entries (and counts that may become
|
|
788
|
+
// plurals) go branch by branch; everything else as before.
|
|
789
|
+
const viaBranches = new Set(isLlm ? [] : prepared.filter((b) => b.icu).map((b) => b.key));
|
|
711
790
|
const firstPassHint = (b) => (b.countArgs ? pluralHint(b.countArgs, targetLocale) : undefined);
|
|
712
|
-
await runBatches(chunk(prepared, BATCH_SIZE), isLlm ? firstPassHint : undefined);
|
|
791
|
+
await runBatches(chunk(prepared.filter((b) => !viaBranches.has(b.key)), BATCH_SIZE), isLlm ? firstPassHint : undefined);
|
|
792
|
+
if (viaBranches.size > 0) {
|
|
793
|
+
const branchEntries = prepared.filter((b) => viaBranches.has(b.key));
|
|
794
|
+
for (const [key, o] of await translateByBranches(branchEntries, translator))
|
|
795
|
+
outcomes.set(key, o);
|
|
796
|
+
// A count whose plural forms couldn't be built is translated as the
|
|
797
|
+
// plain message it is (as before target-side plurals), and says so.
|
|
798
|
+
const noPlural = branchEntries.filter((b) => b.plain && outcomes.get(b.key)?.ok === false);
|
|
799
|
+
if (noPlural.length > 0) {
|
|
800
|
+
const why = new Map(noPlural.map((b) => [b.key, outcomes.get(b.key).message]));
|
|
801
|
+
await runBatches(chunk(noPlural.map((b) => ({ ...b, ...b.plain, icu: false, countArgs: undefined, plain: undefined })), BATCH_SIZE));
|
|
802
|
+
for (const b of noPlural) {
|
|
803
|
+
const o = outcomes.get(b.key);
|
|
804
|
+
if (o?.ok)
|
|
805
|
+
o.warnings.push(`Translated as plain text: ${targetLocale} plural forms couldn't be built (${why.get(b.key)})`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
713
809
|
// 3. One targeted repair attempt for keys that failed validation, telling
|
|
714
810
|
// the model exactly what was wrong. NLLB is deterministic, so for it
|
|
715
811
|
// only keys it skipped are worth asking for again.
|
|
716
812
|
const toRepair = prepared.filter((b) => {
|
|
717
813
|
const o = outcomes.get(b.key);
|
|
718
|
-
return o && !o.ok && o.retryable && o.reason !== 'provider_error' && (isLlm || o.reason === 'missing_from_response');
|
|
814
|
+
return o && !o.ok && o.retryable && o.reason !== 'provider_error' && !viaBranches.has(b.key) && (isLlm || o.reason === 'missing_from_response');
|
|
719
815
|
});
|
|
720
816
|
if (toRepair.length > 0) {
|
|
721
817
|
logger_1.engineLogger.info('Retrying keys that failed validation', { count: toRepair.length, targetLang });
|
|
@@ -732,8 +828,8 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
732
828
|
: undefined);
|
|
733
829
|
}
|
|
734
830
|
// 3b. Keys the LLM couldn't be reached for (rate limit, outage) get one
|
|
735
|
-
// attempt on the fallback translator, if there is one. It's NLLB-class
|
|
736
|
-
//
|
|
831
|
+
// attempt on the fallback translator, if there is one. It's NLLB-class:
|
|
832
|
+
// ICU messages go branch by branch, mixed-script strings stay failed.
|
|
737
833
|
const fellBack = new Set();
|
|
738
834
|
const fallback = providers.fallbackTranslator;
|
|
739
835
|
if (fallback && isLlm && nllbTarget) {
|
|
@@ -766,6 +862,19 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
766
862
|
}
|
|
767
863
|
}
|
|
768
864
|
}
|
|
865
|
+
const icuToFallback = prepared.filter((b) => {
|
|
866
|
+
const o = outcomes.get(b.key);
|
|
867
|
+
return o && !o.ok && o.reason === 'provider_error' && b.icu && !b.plain && !hasMixedScripts(b.value);
|
|
868
|
+
});
|
|
869
|
+
if (icuToFallback.length > 0) {
|
|
870
|
+
logger_1.engineLogger.warn('LLM translator unavailable; trying the fallback translator branch by branch for ICU messages', { count: icuToFallback.length, targetLang });
|
|
871
|
+
for (const [key, o] of await translateByBranches(icuToFallback, fallback)) {
|
|
872
|
+
if (o.ok) {
|
|
873
|
+
outcomes.set(key, o);
|
|
874
|
+
fellBack.add(key);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
769
878
|
}
|
|
770
879
|
// 4. Record results. Failed keys get no value at all: the locale file
|
|
771
880
|
// omits them (the app shows the source string, honestly), the lockfile
|
|
@@ -779,9 +888,13 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
779
888
|
: { reason: 'missing_from_response', retryable: true };
|
|
780
889
|
continue;
|
|
781
890
|
}
|
|
782
|
-
|
|
891
|
+
// No post-hoc glossary rewriting: a plain-text translator's output
|
|
892
|
+
// already has the fixed terms (restored from tokens, then validated);
|
|
893
|
+
// an LLM had them in the prompt, and if it ignored one that is a QA
|
|
894
|
+
// warning, never a blind replace that could hit a placeholder or URL.
|
|
895
|
+
const finalValue = outcome.value;
|
|
783
896
|
result[entry.key] = finalValue;
|
|
784
|
-
const qa = [...outcome.warnings, ...glossaryWarnings(entry.value, finalValue, glossaryForLocale)];
|
|
897
|
+
const qa = [...outcome.warnings, ...glossaryWarnings(entry.value, finalValue, glossaryForLocale, neverTranslateTerms)];
|
|
785
898
|
if (qa.length > 0)
|
|
786
899
|
metadata[entry.key] = { ...(metadata[entry.key] ?? {}), qa };
|
|
787
900
|
// Fallback output is a stopgap: keep it out of the shared cache and TM
|
|
@@ -795,10 +908,15 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
795
908
|
};
|
|
796
909
|
continue;
|
|
797
910
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
//
|
|
801
|
-
//
|
|
911
|
+
if (options.progress)
|
|
912
|
+
options.progress.freshTranslations++;
|
|
913
|
+
// A translation shaped by an "always translate as" rule is this
|
|
914
|
+
// project's wording: the cache and TM are shared beyond the project,
|
|
915
|
+
// so it stays out of both (the lockfile keeps it for this project).
|
|
916
|
+
// Hits are checked against the glossary before reuse, so edits to the
|
|
917
|
+
// glossary re-translate anything stored that no longer complies.
|
|
918
|
+
if (projectWording(entry.value))
|
|
919
|
+
continue;
|
|
802
920
|
const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, entry.hash);
|
|
803
921
|
if (cacheProvider) {
|
|
804
922
|
cacheProvider.set(cacheKey, outcome.value, cache_keys_1.TRANSLATION_CACHE_TTL_SECONDS).catch((err) => {
|
|
@@ -826,25 +944,22 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
826
944
|
// replaces the previous run's — cache hits, fallbacks and mixed-script keys
|
|
827
945
|
// included — instead of keeping a stale fromMemory/fuzzyMatches.
|
|
828
946
|
for (const key of Object.keys(result)) {
|
|
829
|
-
|
|
830
|
-
|
|
947
|
+
const meta = { ...(metadata[key] ?? {}) };
|
|
948
|
+
// The glossary rules this translation was made (or checked) under: the
|
|
949
|
+
// lockfile re-translates the key when they change. Not recorded when the
|
|
950
|
+
// rules failed to load, so the next run that has them redoes the key.
|
|
951
|
+
if (rulesComplete) {
|
|
952
|
+
const fingerprint = (0, glossary_1.glossaryFingerprint)(entries[key], targetLocale, glossary);
|
|
953
|
+
if (fingerprint)
|
|
954
|
+
meta.glossaryHash = fingerprint;
|
|
955
|
+
}
|
|
956
|
+
const notes = (0, glossary_1.conflictNotes)(entries[key], glossary);
|
|
957
|
+
if (notes.length > 0)
|
|
958
|
+
meta.qa = [...(meta.qa ?? []), ...notes];
|
|
959
|
+
metadata[key] = meta;
|
|
831
960
|
}
|
|
832
961
|
return { result, stats: { tmHits }, metadata, failures };
|
|
833
962
|
}
|
|
834
|
-
/** Put back never-translate terms that were masked before translation. */
|
|
835
|
-
function restoreNeverTranslate(originalText, translatedText, neverTranslateTerms) {
|
|
836
|
-
if (neverTranslateTerms.length === 0)
|
|
837
|
-
return translatedText;
|
|
838
|
-
const { terms: usedTerms } = (0, glossary_1.applyGlossaryMask)(originalText, neverTranslateTerms);
|
|
839
|
-
return (0, glossary_1.restoreGlossaryMask)(translatedText, usedTerms);
|
|
840
|
-
}
|
|
841
|
-
function applyGlossaryPostProcessing(originalText, translatedText, neverTranslateTerms, alwaysTranslateAsEntries, targetLocale) {
|
|
842
|
-
let processed = restoreNeverTranslate(originalText, translatedText, neverTranslateTerms);
|
|
843
|
-
if (alwaysTranslateAsEntries.length > 0 && targetLocale) {
|
|
844
|
-
processed = (0, glossary_1.applyAlwaysTranslateAs)(processed, alwaysTranslateAsEntries, targetLocale);
|
|
845
|
-
}
|
|
846
|
-
return processed;
|
|
847
|
-
}
|
|
848
963
|
/**
|
|
849
964
|
* Persist translations to the project owner's TM. Never throws: a TM failure
|
|
850
965
|
* must not fail the run, but it is logged at error level rather than skipped.
|
|
@@ -916,7 +1031,7 @@ async function backfillTranslationMemory(projectId, sourceLocale, flatSource, tr
|
|
|
916
1031
|
for (const [key, translatedText] of Object.entries(translations)) {
|
|
917
1032
|
const sourceText = flatSource[key];
|
|
918
1033
|
if (excludeKeys.has(key) ||
|
|
919
|
-
|
|
1034
|
+
(0, glossary_1.hasFixedTerm)(sourceText ?? '', rules.glossary, locale) ||
|
|
920
1035
|
!sourceText || !translatedText || sourceText === translatedText) {
|
|
921
1036
|
skipped.push(key);
|
|
922
1037
|
continue;
|