@babelize/engine 0.1.0 → 0.1.3
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 +297 -173
- 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/icu-message.d.ts +2 -2
- package/dist/utils/icu-message.js +43 -7
- 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 -6
- 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,21 +15,37 @@ 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
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
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;
|
|
37
|
+
/**
|
|
38
|
+
* A plain source whose stored translation is ICU only fits a project whose
|
|
39
|
+
* runtime formats ICU. A stored ICU translation stored before a check it now
|
|
40
|
+
* fails (e.g. Czech without its `few` branch) is translated again.
|
|
41
|
+
*/
|
|
42
|
+
function reusableTranslation(source, translated, targetPlurals, targetLocale) {
|
|
43
|
+
if (!targetPlurals && !(0, icu_message_1.isIcuMessage)(source) && (0, icu_message_1.isIcuMessage)(translated))
|
|
44
|
+
return false;
|
|
45
|
+
if (!(0, icu_message_1.isIcuMessage)(translated))
|
|
46
|
+
return true;
|
|
47
|
+
const pluralizable = (0, icu_message_1.isIcuMessage)(source) ? [] : (0, icu_message_1.countArguments)(source);
|
|
48
|
+
return !(0, icu_message_1.compareIcuMessages)(source, translated, targetLocale, { pluralizable }).some((i) => i.severity === 'error');
|
|
65
49
|
}
|
|
66
50
|
/** Per-entry instruction: pluralize a count argument if the target language needs it. */
|
|
67
51
|
function pluralHint(args, targetLocale) {
|
|
@@ -85,12 +69,6 @@ function isUnchangedSentence(sentText, returnedText) {
|
|
|
85
69
|
const words = norm(sentText).split(' ').filter((w) => /\p{L}{2,}/u.test(w));
|
|
86
70
|
return words.length >= 3 && norm(sentText) === norm(returnedText);
|
|
87
71
|
}
|
|
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
72
|
/** Tags, URLs, code and never-translate terms present in the source but absent from the output. */
|
|
95
73
|
function missingSpans(source, translated, neverTranslateTerms) {
|
|
96
74
|
const have = new Set((0, placeholder_protection_1.protectText)(translated, neverTranslateTerms, { icu: true }).tokens.map((t) => t.toLowerCase()));
|
|
@@ -98,11 +76,24 @@ function missingSpans(source, translated, neverTranslateTerms) {
|
|
|
98
76
|
return [...want].filter((t) => !have.has(t.toLowerCase()));
|
|
99
77
|
}
|
|
100
78
|
/** Glossary terms in the source whose required translation is missing from the output. */
|
|
101
|
-
function glossaryWarnings(source, translated, glossary) {
|
|
79
|
+
function glossaryWarnings(source, translated, glossary, neverTranslateTerms) {
|
|
102
80
|
return glossary
|
|
103
|
-
.filter((g) =>
|
|
81
|
+
.filter((g) => (0, glossary_1.sourceHasTerm)(source, g.term, neverTranslateTerms) && !(0, glossary_match_1.containsGlossaryTranslation)(translated, g.translation))
|
|
104
82
|
.map((g) => `Glossary: "${g.term}" should be translated as "${g.translation}"`);
|
|
105
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Why a stored translation (cache or TM) can't be reused under the project's
|
|
86
|
+
* current glossary, or null if it can: a placeholder, tag, URL or
|
|
87
|
+
* never-translate term of the source is missing, or an "always translate as"
|
|
88
|
+
* term's required translation is. Such a hit is treated as a miss.
|
|
89
|
+
*/
|
|
90
|
+
function storedTranslationProblem(source, stored, neverTranslateTerms, glossaryForLocale) {
|
|
91
|
+
const lost = missingSpans(source, stored, neverTranslateTerms);
|
|
92
|
+
if (lost.length > 0)
|
|
93
|
+
return `missing ${lost.join(', ')}`;
|
|
94
|
+
const glossary = glossaryWarnings(source, stored, glossaryForLocale, neverTranslateTerms);
|
|
95
|
+
return glossary.length > 0 ? glossary.join('; ') : null;
|
|
96
|
+
}
|
|
106
97
|
const SCRIPT_RANGES = [
|
|
107
98
|
[0x0000, 0x024F], // Latin (Basic, Latin-1, Extended-A/B) — one script
|
|
108
99
|
[0x0370, 0x03FF], // Greek
|
|
@@ -156,29 +147,22 @@ function hasMixedScripts(text) {
|
|
|
156
147
|
* project-specific wording may be baked in.
|
|
157
148
|
*/
|
|
158
149
|
async function loadProjectTranslationRules(projectId, rules) {
|
|
159
|
-
let
|
|
160
|
-
let alwaysTranslateAsEntries = [];
|
|
150
|
+
let glossary = glossary_1.EMPTY_GLOSSARY;
|
|
161
151
|
let brandVoice = null;
|
|
162
152
|
let complete = true;
|
|
163
153
|
if (!rules)
|
|
164
|
-
return {
|
|
154
|
+
return { glossary, brandVoice, complete };
|
|
165
155
|
try {
|
|
166
156
|
const entries = await rules.getGlossary(projectId);
|
|
167
157
|
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
|
-
}));
|
|
158
|
+
glossary = (0, glossary_1.normalizeGlossary)(entries);
|
|
178
159
|
logger_1.engineLogger.info('Loaded project glossary', {
|
|
179
|
-
neverTranslate:
|
|
180
|
-
alwaysTranslateAs:
|
|
160
|
+
neverTranslate: glossary.neverTranslate.length,
|
|
161
|
+
alwaysTranslateAs: glossary.alwaysTranslateAs.length,
|
|
162
|
+
conflicts: glossary.conflicts.length,
|
|
181
163
|
});
|
|
164
|
+
for (const c of glossary.conflicts)
|
|
165
|
+
logger_1.engineLogger.warn(c.message, { projectId });
|
|
182
166
|
}
|
|
183
167
|
}
|
|
184
168
|
catch (err) {
|
|
@@ -198,16 +182,32 @@ async function loadProjectTranslationRules(projectId, rules) {
|
|
|
198
182
|
complete = false;
|
|
199
183
|
logger_1.engineLogger.warn('Failed to load brand voice', { error: err });
|
|
200
184
|
}
|
|
201
|
-
return {
|
|
185
|
+
return { glossary, brandVoice, complete };
|
|
202
186
|
}
|
|
203
187
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
188
|
+
* The glossary of a run without a cloud project (CLI/MCP, no projectId):
|
|
189
|
+
* `.babelize/config.json` `glossary`, the same file and schema `babelize
|
|
190
|
+
* check` reads.
|
|
207
191
|
*/
|
|
208
|
-
function
|
|
209
|
-
|
|
210
|
-
|
|
192
|
+
async function loadWorkspaceGlossary(workspacePath) {
|
|
193
|
+
try {
|
|
194
|
+
const { glossary } = await (0, check_project_1.readProjectLocaleConfig)(workspacePath);
|
|
195
|
+
if (!glossary)
|
|
196
|
+
return glossary_1.EMPTY_GLOSSARY;
|
|
197
|
+
const rules = (0, glossary_1.normalizeGlossary)([
|
|
198
|
+
...(glossary.neverTranslate ?? []).map((term) => ({ term, type: 'never_translate' })),
|
|
199
|
+
...(glossary.alwaysTranslateAs ?? []).map((e) => ({ term: e.term, type: 'always_translate_as', translations: e.translations })),
|
|
200
|
+
]);
|
|
201
|
+
logger_1.engineLogger.info('Loaded glossary from workspace config', {
|
|
202
|
+
neverTranslate: rules.neverTranslate.length,
|
|
203
|
+
alwaysTranslateAs: rules.alwaysTranslateAs.length,
|
|
204
|
+
});
|
|
205
|
+
return rules;
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
logger_1.engineLogger.warn('Failed to load glossary from workspace config', { error: err });
|
|
209
|
+
return glossary_1.EMPTY_GLOSSARY;
|
|
210
|
+
}
|
|
211
211
|
}
|
|
212
212
|
async function translateLocales(workspace, sourceLocale, targetLocales, forceTranslation = false, dryRun = false, projectId, onProgress, keysToTranslateByLocale, cacheProvider, byokConfig, contextMap, extractionContextMap, providers = {}, options = {}) {
|
|
213
213
|
const locales = [];
|
|
@@ -231,50 +231,16 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
|
|
|
231
231
|
delete flatSource[key];
|
|
232
232
|
}
|
|
233
233
|
}
|
|
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
|
-
}
|
|
234
|
+
// The project's glossary and brand voice (injected rules store), or for a
|
|
235
|
+
// run without a project the workspace's .babelize/config.json glossary.
|
|
236
|
+
const rules = options.rules
|
|
237
|
+
?? (projectId
|
|
238
|
+
? await loadProjectTranslationRules(projectId, providers.rules)
|
|
239
|
+
: { glossary: await loadWorkspaceGlossary(workspace.path), brandVoice: null, complete: true });
|
|
240
|
+
const glossary = rules.glossary;
|
|
241
|
+
const neverTranslateTerms = glossary.neverTranslate;
|
|
242
|
+
const brandVoice = rules.brandVoice;
|
|
243
|
+
const rulesComplete = rules.complete;
|
|
278
244
|
// Check if translator is configured before processing locales
|
|
279
245
|
if (!byokConfig && !providers.translator && !process.env.TRANSLATOR_URL && targetLocales.some(l => l !== sourceLocale.locale)) {
|
|
280
246
|
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 +274,8 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
|
|
|
308
274
|
const sourceLangCode = (0, language_codes_1.toFlores)(sourceLocale.locale);
|
|
309
275
|
// LLM translators (BYOK, or an injected one such as Babelize-hosted)
|
|
310
276
|
// 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,
|
|
277
|
+
const hasBrandVoiceInPrompt = !!((byokConfig || (providers.translator && !providers.translator.plainText)) && brandVoice);
|
|
278
|
+
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
279
|
const entriesResult = translationResult.result;
|
|
314
280
|
const batchStats = translationResult.stats;
|
|
315
281
|
const batchMetadata = translationResult.metadata;
|
|
@@ -325,6 +291,7 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
|
|
|
325
291
|
let translatedEntries = entriesResult;
|
|
326
292
|
// Post-hoc brand voice polish only for the NLLB service; LLM
|
|
327
293
|
// translators already had the brand voice in the prompt.
|
|
294
|
+
const glossaryForLocale = (0, glossary_1.fixedTermsFor)(glossary, targetLocale);
|
|
328
295
|
if (brandVoice && !hasBrandVoiceInPrompt) {
|
|
329
296
|
// TM hits are approved/previously-accepted text: polishing them again
|
|
330
297
|
// would rewrite reviewed translations nondeterministically every run.
|
|
@@ -348,7 +315,10 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
|
|
|
348
315
|
// ICU (in the source, or a plural the translation added) must
|
|
349
316
|
// stay an equivalent message of the validated translation.
|
|
350
317
|
const icu = (0, icu_message_1.isIcuMessage)(entriesToTranslate[k] ?? '') || (0, icu_message_1.isIcuMessage)(before) || (0, icu_message_1.isIcuMessage)(v);
|
|
351
|
-
|
|
318
|
+
// ...and still uses every required glossary translation the validated text used.
|
|
319
|
+
const source = entriesToTranslate[k] ?? '';
|
|
320
|
+
const glossaryOk = glossaryWarnings(source, v, glossaryForLocale, neverTranslateTerms).length <= glossaryWarnings(source, before, glossaryForLocale, neverTranslateTerms).length;
|
|
321
|
+
const safe = (0, placeholder_protection_1.sameProtectedSpans)(before, v, neverTranslateTerms) && glossaryOk &&
|
|
352
322
|
(!icu || !(0, icu_message_1.compareIcuMessages)(before, v, targetLocale).some((i) => i.severity === 'error'));
|
|
353
323
|
if (safe)
|
|
354
324
|
accepted[k] = v;
|
|
@@ -368,7 +338,9 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
|
|
|
368
338
|
...translatedData,
|
|
369
339
|
_meta: { ...existingTranslatedMeta, dir: (0, language_codes_1.isRTL)(targetLocale) ? 'rtl' : 'ltr', language: targetLocale },
|
|
370
340
|
};
|
|
371
|
-
|
|
341
|
+
// gettext catalogues are merged into the existing .po files once the
|
|
342
|
+
// whole run's translations are known (see django/catalog.ts).
|
|
343
|
+
if (!dryRun && workspace.localeFormat !== 'po')
|
|
372
344
|
await (0, file_utils_1.writeJsonFile)(targetPath, translatedData, true);
|
|
373
345
|
return {
|
|
374
346
|
locale: targetLocale,
|
|
@@ -418,7 +390,7 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
|
|
|
418
390
|
metadata: perLocaleMetadata
|
|
419
391
|
};
|
|
420
392
|
}
|
|
421
|
-
async function translateEntries(entries, sourceLang, targetLang, projectId, onProgress,
|
|
393
|
+
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
394
|
const result = {};
|
|
423
395
|
const metadata = {};
|
|
424
396
|
const keys = Object.keys(entries);
|
|
@@ -433,6 +405,14 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
433
405
|
for (const k of keys)
|
|
434
406
|
metadata[k] = { ...(metadata[k] ?? {}), inTm: true };
|
|
435
407
|
};
|
|
408
|
+
const neverTranslateTerms = glossary.neverTranslate;
|
|
409
|
+
// "Always translate as" terms with a translation for this locale (exact
|
|
410
|
+
// locale, else its base language).
|
|
411
|
+
const glossaryForLocale = (0, glossary_1.fixedTermsFor)(glossary, targetLocale);
|
|
412
|
+
// Output shaped by such a rule is this project's wording: it must not go
|
|
413
|
+
// into the cache or TM, which are shared beyond the project.
|
|
414
|
+
const projectWording = (source) => (0, glossary_1.hasFixedTerm)(source, glossary, targetLocale);
|
|
415
|
+
const reuseProblem = (source, stored) => storedTranslationProblem(source, stored, neverTranslateTerms, glossaryForLocale);
|
|
436
416
|
for (const key of keys) {
|
|
437
417
|
const text = entries[key];
|
|
438
418
|
const entryContext = contextMap?.[key];
|
|
@@ -440,8 +420,16 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
440
420
|
const textHash = (0, cache_keys_1.hashText)(textForHash);
|
|
441
421
|
const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, textHash);
|
|
442
422
|
const cached = cacheProvider && !forceTranslation ? await cacheProvider.get(cacheKey) : { hit: false, data: null };
|
|
443
|
-
|
|
444
|
-
|
|
423
|
+
// A stored value that is the untranslated source (cached before this was
|
|
424
|
+
// rejected) is a miss, so the string is translated again instead of failing forever.
|
|
425
|
+
// So is one that breaks the project's glossary (a never-translate term
|
|
426
|
+
// translated, a required translation missing): glossary edits reach
|
|
427
|
+
// cached strings by re-translating them.
|
|
428
|
+
const cacheProblem = cached.hit && cached.data !== null ? reuseProblem(text, cached.data) : null;
|
|
429
|
+
if (cacheProblem)
|
|
430
|
+
logger_1.engineLogger.info('Cached translation breaks the glossary; translating again', { key, targetLocale, problem: cacheProblem });
|
|
431
|
+
if (cached.hit && cached.data !== null && !cacheProblem && reusableTranslation(text, cached.data, !!options.targetPlurals, targetLocale) && !(0, untranslated_1.isUntranslatedInto)(text, cached.data, targetLocale)) {
|
|
432
|
+
result[key] = cached.data;
|
|
445
433
|
cacheHits++;
|
|
446
434
|
// Reused, not translated this run (billing counts only fresh output).
|
|
447
435
|
metadata[key] = { ...(metadata[key] ?? {}), fromCache: true };
|
|
@@ -449,8 +437,8 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
449
437
|
// since cache entries written before TM stored pre-glossary text may
|
|
450
438
|
// have them baked in — strings with project glossary rules or an
|
|
451
439
|
// in-prompt brand voice, which must not leak into other projects.
|
|
452
|
-
if (rulesComplete && !entryContext && !brandVoice && !
|
|
453
|
-
cacheBackfill.push({ key, sourceText: text, translatedText:
|
|
440
|
+
if (rulesComplete && !entryContext && !brandVoice && !projectWording(text)) {
|
|
441
|
+
cacheBackfill.push({ key, sourceText: text, translatedText: cached.data });
|
|
454
442
|
}
|
|
455
443
|
}
|
|
456
444
|
else {
|
|
@@ -514,13 +502,17 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
514
502
|
continue;
|
|
515
503
|
}
|
|
516
504
|
const lookup = (0, memory_matcher_1.matchFromMemory)(allMemory, entry.value, storeLang);
|
|
517
|
-
|
|
518
|
-
|
|
505
|
+
// A TM entry that breaks this project's glossary is a miss (TM is shared across the owner's projects).
|
|
506
|
+
const tmProblem = lookup.hit && lookup.translatedText ? reuseProblem(entry.value, lookup.translatedText) : null;
|
|
507
|
+
if (tmProblem)
|
|
508
|
+
logger_1.engineLogger.info('TM translation breaks the glossary; translating again', { key: entry.key, targetLocale, problem: tmProblem });
|
|
509
|
+
if (lookup.hit && lookup.translatedText && !tmProblem && reusableTranslation(entry.value, lookup.translatedText, !!options.targetPlurals, targetLocale) && !(0, untranslated_1.isUntranslatedInto)(entry.value, lookup.translatedText, targetLocale)) {
|
|
510
|
+
result[entry.key] = lookup.translatedText;
|
|
519
511
|
tmHits++;
|
|
520
512
|
metadata[entry.key] = { fromMemory: true, inTm: true };
|
|
521
|
-
// Warm
|
|
513
|
+
// Warm the cache (never with this project's glossary wording)
|
|
522
514
|
const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, entry.hash);
|
|
523
|
-
if (cacheProvider) {
|
|
515
|
+
if (cacheProvider && !projectWording(entry.value)) {
|
|
524
516
|
cacheProvider.set(cacheKey, lookup.translatedText, cache_keys_1.TRANSLATION_CACHE_TTL_SECONDS).catch((err) => {
|
|
525
517
|
logger_1.engineLogger.error('Failed to warm Redis cache from TM', { cacheKey, error: err });
|
|
526
518
|
});
|
|
@@ -553,30 +545,29 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
553
545
|
});
|
|
554
546
|
}
|
|
555
547
|
const failures = {};
|
|
556
|
-
// LLM providers get structure-aware prompts and repair attempts
|
|
557
|
-
// service
|
|
558
|
-
|
|
548
|
+
// LLM providers get structure-aware prompts and repair attempts. The NLLB
|
|
549
|
+
// service translates plain text only: ICU messages go branch by branch
|
|
550
|
+
// (./icu-branches), and mixed-script text fails up front.
|
|
551
|
+
const isLlm = !!byokConfig || (!!providers.translator && !providers.translator.plainText);
|
|
559
552
|
// Scripts FLORES-200 lacks (sr-Latn, uz-Cyrl) only work with an LLM.
|
|
560
553
|
const nllbTarget = !targetLocale || !(0, language_codes_1.isSupported)(targetLocale) || (0, language_codes_1.isNllbSupported)(targetLocale);
|
|
561
554
|
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
555
|
// 1. Protect placeholders, tags and never-translate terms. Strings with
|
|
566
556
|
// nothing translatable left (e.g. "{count}", a brand name) are their own
|
|
567
|
-
// translation and never reach the translator.
|
|
557
|
+
// translation and never reach the translator. "Always translate as"
|
|
558
|
+
// terms are tokens too for a plain-text translator, restored to their
|
|
559
|
+
// fixed translation (it can't take a glossary); an LLM gets them as
|
|
560
|
+
// required terminology in the prompt instead.
|
|
568
561
|
const prepared = [];
|
|
569
562
|
for (const entry of uncachedEntries) {
|
|
570
563
|
const icu = (0, icu_message_1.isIcuMessage)(entry.value);
|
|
571
|
-
if (!isLlm && (
|
|
564
|
+
if (!isLlm && (hasMixedScripts(entry.value) || !nllbTarget)) {
|
|
572
565
|
failures[entry.key] = {
|
|
573
566
|
reason: 'unsupported_by_provider',
|
|
574
567
|
retryable: false,
|
|
575
568
|
message: !nllbTarget
|
|
576
569
|
? `${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',
|
|
570
|
+
: 'Text mixing writing systems needs an LLM translation provider',
|
|
580
571
|
};
|
|
581
572
|
continue;
|
|
582
573
|
}
|
|
@@ -584,24 +575,44 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
584
575
|
// Japanese keeps only "other"), so ICU text is sent untokenized and its
|
|
585
576
|
// tags/URLs/terms are checked as a set after translation instead. The
|
|
586
577
|
// 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
|
-
|
|
578
|
+
// uploaded"): an LLM may return a plural for a language that needs one;
|
|
579
|
+
// for NLLB one is built branch by branch when the language has more
|
|
580
|
+
// than one plural category.
|
|
581
|
+
const countArgs = options.targetPlurals && !icu && (isLlm || (0, icu_branches_1.targetCategories)(targetLocale, 'cardinal').length > 1)
|
|
582
|
+
? (0, icu_message_1.countArguments)(entry.value)
|
|
583
|
+
: [];
|
|
584
|
+
const ownTokens = (0, placeholder_protection_1.protectText)(entry.value, neverTranslateTerms);
|
|
585
|
+
if ((0, placeholder_protection_1.hasNothingToTranslate)(ownTokens.text)) {
|
|
593
586
|
result[entry.key] = entry.value;
|
|
594
587
|
// Copied through (placeholders/codes only); the translator never saw it.
|
|
595
588
|
metadata[entry.key] = { ...(metadata[entry.key] ?? {}), passthrough: true };
|
|
596
589
|
continue;
|
|
597
590
|
}
|
|
591
|
+
// Never-translate terms win over a fixed term spelled the same, and
|
|
592
|
+
// placeholders/URLs/tags over both (see protectText).
|
|
593
|
+
const withFixed = glossaryForLocale.length > 0
|
|
594
|
+
? (0, placeholder_protection_1.protectText)(entry.value, neverTranslateTerms, { fixedTerms: glossaryForLocale })
|
|
595
|
+
: ownTokens;
|
|
596
|
+
if (!icu && (0, placeholder_protection_1.hasNothingToTranslate)(withFixed.text)) {
|
|
597
|
+
// Only glossary terms and placeholders ("Settings" → "Konfiguration"):
|
|
598
|
+
// the rule is the translation; no translator needed (or billed).
|
|
599
|
+
result[entry.key] = (0, placeholder_protection_1.restoreText)(withFixed.text, withFixed.restore);
|
|
600
|
+
metadata[entry.key] = { ...(metadata[entry.key] ?? {}), passthrough: true, glossary: true };
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const tokenized = isLlm ? ownTokens : withFixed;
|
|
604
|
+
const sendRaw = icu || countArgs.length > 0;
|
|
605
|
+
const prot = sendRaw ? { text: entry.value, tokens: [], restore: [] } : tokenized;
|
|
598
606
|
prepared.push({
|
|
599
607
|
...entry,
|
|
600
608
|
sendText: prot.text,
|
|
601
609
|
tokens: prot.tokens,
|
|
610
|
+
restore: prot.restore,
|
|
602
611
|
icu: sendRaw,
|
|
603
612
|
countArgs: countArgs.length ? countArgs : undefined,
|
|
604
|
-
plain: countArgs.length
|
|
613
|
+
plain: countArgs.length || (isLlm && !icu)
|
|
614
|
+
? { sendText: withFixed.text, tokens: withFixed.tokens, restore: withFixed.restore }
|
|
615
|
+
: undefined,
|
|
605
616
|
});
|
|
606
617
|
}
|
|
607
618
|
// 2. Translate in batches; judge every key independently.
|
|
@@ -623,7 +634,11 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
623
634
|
const tokenCheck = (0, placeholder_protection_1.checkTokens)(keyResult.value, entry.tokens.length);
|
|
624
635
|
if (!tokenCheck.ok)
|
|
625
636
|
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.
|
|
637
|
+
const value = (0, placeholder_protection_1.restoreText)((0, translation_sanitizer_1.sanitizeTranslation)(keyResult.value, { nllb, source: entry.sendText }), entry.restore);
|
|
638
|
+
// NLLB sometimes answers a short string with only its language tag
|
|
639
|
+
// ("eng_Latn"), which the sanitizer strips to nothing: no translation.
|
|
640
|
+
if (!value.trim())
|
|
641
|
+
return fail('empty_value', true, 'The translator returned no usable text');
|
|
627
642
|
const warnings = [];
|
|
628
643
|
// A plural added for a count placeholder is validated as ICU too.
|
|
629
644
|
if (entry.icu || (0, icu_message_1.isIcuMessage)(value)) {
|
|
@@ -642,7 +657,7 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
642
657
|
}
|
|
643
658
|
warnings.push(...issues.map((i) => i.message));
|
|
644
659
|
}
|
|
645
|
-
if (isUnchangedSentence(entry.sendText, keyResult.value)) {
|
|
660
|
+
if (isUnchangedSentence(entry.sendText, keyResult.value) || (0, untranslated_1.isUntranslatedInto)(entry.value, value, targetLocale)) {
|
|
646
661
|
return fail('untranslated', true, `Returned unchanged instead of translated into ${targetLocale}`);
|
|
647
662
|
}
|
|
648
663
|
const quality = (0, translation_quality_1.validateTranslationQuality)(entry.value, value);
|
|
@@ -658,10 +673,10 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
658
673
|
};
|
|
659
674
|
const translateBatch = async (batch, hints) => {
|
|
660
675
|
const batchGlossary = [
|
|
661
|
-
...glossaryForLocale.filter((g) => batch.some((b) =>
|
|
676
|
+
...glossaryForLocale.filter((g) => batch.some((b) => (0, glossary_1.sourceHasTerm)(b.value, g.term, neverTranslateTerms))),
|
|
662
677
|
// Untokenized ICU text: keep never-translate terms as-is via the glossary.
|
|
663
678
|
...neverTranslateTerms
|
|
664
|
-
.filter((t) => batch.some((b) => b.icu &&
|
|
679
|
+
.filter((t) => batch.some((b) => b.icu && (0, glossary_1.sourceHasTerm)(b.value, t)))
|
|
665
680
|
.map((t) => ({ term: t, translation: t })),
|
|
666
681
|
];
|
|
667
682
|
const keyResults = await translator.translateBatch({
|
|
@@ -708,14 +723,104 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
708
723
|
if (byokError)
|
|
709
724
|
throw byokError;
|
|
710
725
|
};
|
|
726
|
+
/**
|
|
727
|
+
* Translate ICU entries with a plain-text translator, branch by branch:
|
|
728
|
+
* the branch sentences of all entries go out in as few requests as
|
|
729
|
+
* possible, then each entry is put back together and validated like any
|
|
730
|
+
* ICU translation. One outcome per entry.
|
|
731
|
+
*/
|
|
732
|
+
const translateByBranches = async (entries, provider) => {
|
|
733
|
+
const out = new Map();
|
|
734
|
+
const plans = new Map();
|
|
735
|
+
const requests = [];
|
|
736
|
+
const unitKey = (key, id) => `${key}#branch${id}`;
|
|
737
|
+
for (const b of entries) {
|
|
738
|
+
const plan = (0, icu_branches_1.planIcuBranches)(b.value, targetLocale, {
|
|
739
|
+
neverTranslateTerms,
|
|
740
|
+
// Always a plain-text translator here: fixed terms go as tokens.
|
|
741
|
+
fixedTerms: glossaryForLocale,
|
|
742
|
+
valuePlaceholder: options.pluralValuePlaceholders?.[b.key],
|
|
743
|
+
pluralizeArgs: b.countArgs,
|
|
744
|
+
});
|
|
745
|
+
if ('error' in plan) {
|
|
746
|
+
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}` });
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
plans.set(b.key, { entry: b, plan });
|
|
750
|
+
for (const u of plan.units) {
|
|
751
|
+
if (u.passthrough)
|
|
752
|
+
continue;
|
|
753
|
+
requests.push({ key: unitKey(b.key, u.id), value: u.sendText, parentContext: contextMap?.[b.key], extractionContext: extractionContextMap?.[b.key] });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const results = new Map();
|
|
757
|
+
const sendAll = async (list) => {
|
|
758
|
+
const batches = chunk(list, BRANCH_BATCH_SIZE);
|
|
759
|
+
await (0, concurrent_map_1.concurrentMap)(batches, async (batch, i) => {
|
|
760
|
+
onProgress?.(`Translating plural/select branches ${i + 1}/${batches.length} for ${targetLang}...`);
|
|
761
|
+
try {
|
|
762
|
+
const keyResults = await provider.translateBatch({ entries: batch, sourceLang, targetLang, targetLocale });
|
|
763
|
+
for (const r of keyResults)
|
|
764
|
+
results.set(r.key, r);
|
|
765
|
+
}
|
|
766
|
+
catch (error) {
|
|
767
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
768
|
+
logger_1.engineLogger.error('Branch translation batch failed', { error: message, sourceLang, targetLang });
|
|
769
|
+
for (const e of batch)
|
|
770
|
+
results.set(e.key, { key: e.key, status: 'failed', reason: 'provider_error', retryable: true, message });
|
|
771
|
+
}
|
|
772
|
+
}, TRANSLATE_CONCURRENCY);
|
|
773
|
+
};
|
|
774
|
+
await sendAll(requests);
|
|
775
|
+
// Branches the translator skipped get one more request.
|
|
776
|
+
const skipped = requests.filter((r) => {
|
|
777
|
+
const res = results.get(r.key);
|
|
778
|
+
return !res || (res.status === 'failed' && res.reason === 'missing_from_response');
|
|
779
|
+
});
|
|
780
|
+
if (skipped.length > 0)
|
|
781
|
+
await sendAll(skipped);
|
|
782
|
+
const checkUnit = (unit, translated) => isUnchangedSentence(unit.sendText, translated) || (0, untranslated_1.isUntranslatedInto)(unit.sendText, translated, targetLocale)
|
|
783
|
+
? { ok: false, reason: 'untranslated', retryable: true, message: `returned unchanged instead of translated into ${targetLocale}` }
|
|
784
|
+
: null;
|
|
785
|
+
for (const [key, { entry, plan }] of plans) {
|
|
786
|
+
const composed = plan.compose(new Map(plan.units.map((u) => [u.id, results.get(unitKey(key, u.id))])), checkUnit);
|
|
787
|
+
if (!composed.ok) {
|
|
788
|
+
out.set(key, { ok: false, reason: composed.reason, retryable: composed.retryable, message: composed.message });
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
// Each branch was cleaned up on its own; the message is checked as a whole.
|
|
792
|
+
out.set(key, judge(entry, { key, status: 'ok', value: composed.value }, false));
|
|
793
|
+
}
|
|
794
|
+
return out;
|
|
795
|
+
};
|
|
796
|
+
// With a plain-text translator, ICU entries (and counts that may become
|
|
797
|
+
// plurals) go branch by branch; everything else as before.
|
|
798
|
+
const viaBranches = new Set(isLlm ? [] : prepared.filter((b) => b.icu).map((b) => b.key));
|
|
711
799
|
const firstPassHint = (b) => (b.countArgs ? pluralHint(b.countArgs, targetLocale) : undefined);
|
|
712
|
-
await runBatches(chunk(prepared, BATCH_SIZE), isLlm ? firstPassHint : undefined);
|
|
800
|
+
await runBatches(chunk(prepared.filter((b) => !viaBranches.has(b.key)), BATCH_SIZE), isLlm ? firstPassHint : undefined);
|
|
801
|
+
if (viaBranches.size > 0) {
|
|
802
|
+
const branchEntries = prepared.filter((b) => viaBranches.has(b.key));
|
|
803
|
+
for (const [key, o] of await translateByBranches(branchEntries, translator))
|
|
804
|
+
outcomes.set(key, o);
|
|
805
|
+
// A count whose plural forms couldn't be built is translated as the
|
|
806
|
+
// plain message it is (as before target-side plurals), and says so.
|
|
807
|
+
const noPlural = branchEntries.filter((b) => b.plain && outcomes.get(b.key)?.ok === false);
|
|
808
|
+
if (noPlural.length > 0) {
|
|
809
|
+
const why = new Map(noPlural.map((b) => [b.key, outcomes.get(b.key).message]));
|
|
810
|
+
await runBatches(chunk(noPlural.map((b) => ({ ...b, ...b.plain, icu: false, countArgs: undefined, plain: undefined })), BATCH_SIZE));
|
|
811
|
+
for (const b of noPlural) {
|
|
812
|
+
const o = outcomes.get(b.key);
|
|
813
|
+
if (o?.ok)
|
|
814
|
+
o.warnings.push(`Translated as plain text: ${targetLocale} plural forms couldn't be built (${why.get(b.key)})`);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
713
818
|
// 3. One targeted repair attempt for keys that failed validation, telling
|
|
714
819
|
// the model exactly what was wrong. NLLB is deterministic, so for it
|
|
715
820
|
// only keys it skipped are worth asking for again.
|
|
716
821
|
const toRepair = prepared.filter((b) => {
|
|
717
822
|
const o = outcomes.get(b.key);
|
|
718
|
-
return o && !o.ok && o.retryable && o.reason !== 'provider_error' && (isLlm || o.reason === 'missing_from_response');
|
|
823
|
+
return o && !o.ok && o.retryable && o.reason !== 'provider_error' && !viaBranches.has(b.key) && (isLlm || o.reason === 'missing_from_response');
|
|
719
824
|
});
|
|
720
825
|
if (toRepair.length > 0) {
|
|
721
826
|
logger_1.engineLogger.info('Retrying keys that failed validation', { count: toRepair.length, targetLang });
|
|
@@ -732,8 +837,8 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
732
837
|
: undefined);
|
|
733
838
|
}
|
|
734
839
|
// 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
|
-
//
|
|
840
|
+
// attempt on the fallback translator, if there is one. It's NLLB-class:
|
|
841
|
+
// ICU messages go branch by branch, mixed-script strings stay failed.
|
|
737
842
|
const fellBack = new Set();
|
|
738
843
|
const fallback = providers.fallbackTranslator;
|
|
739
844
|
if (fallback && isLlm && nllbTarget) {
|
|
@@ -766,6 +871,19 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
766
871
|
}
|
|
767
872
|
}
|
|
768
873
|
}
|
|
874
|
+
const icuToFallback = prepared.filter((b) => {
|
|
875
|
+
const o = outcomes.get(b.key);
|
|
876
|
+
return o && !o.ok && o.reason === 'provider_error' && b.icu && !b.plain && !hasMixedScripts(b.value);
|
|
877
|
+
});
|
|
878
|
+
if (icuToFallback.length > 0) {
|
|
879
|
+
logger_1.engineLogger.warn('LLM translator unavailable; trying the fallback translator branch by branch for ICU messages', { count: icuToFallback.length, targetLang });
|
|
880
|
+
for (const [key, o] of await translateByBranches(icuToFallback, fallback)) {
|
|
881
|
+
if (o.ok) {
|
|
882
|
+
outcomes.set(key, o);
|
|
883
|
+
fellBack.add(key);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
769
887
|
}
|
|
770
888
|
// 4. Record results. Failed keys get no value at all: the locale file
|
|
771
889
|
// omits them (the app shows the source string, honestly), the lockfile
|
|
@@ -779,9 +897,13 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
779
897
|
: { reason: 'missing_from_response', retryable: true };
|
|
780
898
|
continue;
|
|
781
899
|
}
|
|
782
|
-
|
|
900
|
+
// No post-hoc glossary rewriting: a plain-text translator's output
|
|
901
|
+
// already has the fixed terms (restored from tokens, then validated);
|
|
902
|
+
// an LLM had them in the prompt, and if it ignored one that is a QA
|
|
903
|
+
// warning, never a blind replace that could hit a placeholder or URL.
|
|
904
|
+
const finalValue = outcome.value;
|
|
783
905
|
result[entry.key] = finalValue;
|
|
784
|
-
const qa = [...outcome.warnings, ...glossaryWarnings(entry.value, finalValue, glossaryForLocale)];
|
|
906
|
+
const qa = [...outcome.warnings, ...glossaryWarnings(entry.value, finalValue, glossaryForLocale, neverTranslateTerms)];
|
|
785
907
|
if (qa.length > 0)
|
|
786
908
|
metadata[entry.key] = { ...(metadata[entry.key] ?? {}), qa };
|
|
787
909
|
// Fallback output is a stopgap: keep it out of the shared cache and TM
|
|
@@ -795,10 +917,15 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
795
917
|
};
|
|
796
918
|
continue;
|
|
797
919
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
//
|
|
801
|
-
//
|
|
920
|
+
if (options.progress)
|
|
921
|
+
options.progress.freshTranslations++;
|
|
922
|
+
// A translation shaped by an "always translate as" rule is this
|
|
923
|
+
// project's wording: the cache and TM are shared beyond the project,
|
|
924
|
+
// so it stays out of both (the lockfile keeps it for this project).
|
|
925
|
+
// Hits are checked against the glossary before reuse, so edits to the
|
|
926
|
+
// glossary re-translate anything stored that no longer complies.
|
|
927
|
+
if (projectWording(entry.value))
|
|
928
|
+
continue;
|
|
802
929
|
const cacheKey = (0, cache_keys_1.translationCacheKey)(sourceLang, storeLang, entry.hash);
|
|
803
930
|
if (cacheProvider) {
|
|
804
931
|
cacheProvider.set(cacheKey, outcome.value, cache_keys_1.TRANSLATION_CACHE_TTL_SECONDS).catch((err) => {
|
|
@@ -826,25 +953,22 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
|
|
|
826
953
|
// replaces the previous run's — cache hits, fallbacks and mixed-script keys
|
|
827
954
|
// included — instead of keeping a stale fromMemory/fuzzyMatches.
|
|
828
955
|
for (const key of Object.keys(result)) {
|
|
829
|
-
|
|
830
|
-
|
|
956
|
+
const meta = { ...(metadata[key] ?? {}) };
|
|
957
|
+
// The glossary rules this translation was made (or checked) under: the
|
|
958
|
+
// lockfile re-translates the key when they change. Not recorded when the
|
|
959
|
+
// rules failed to load, so the next run that has them redoes the key.
|
|
960
|
+
if (rulesComplete) {
|
|
961
|
+
const fingerprint = (0, glossary_1.glossaryFingerprint)(entries[key], targetLocale, glossary);
|
|
962
|
+
if (fingerprint)
|
|
963
|
+
meta.glossaryHash = fingerprint;
|
|
964
|
+
}
|
|
965
|
+
const notes = (0, glossary_1.conflictNotes)(entries[key], glossary);
|
|
966
|
+
if (notes.length > 0)
|
|
967
|
+
meta.qa = [...(meta.qa ?? []), ...notes];
|
|
968
|
+
metadata[key] = meta;
|
|
831
969
|
}
|
|
832
970
|
return { result, stats: { tmHits }, metadata, failures };
|
|
833
971
|
}
|
|
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
972
|
/**
|
|
849
973
|
* Persist translations to the project owner's TM. Never throws: a TM failure
|
|
850
974
|
* must not fail the run, but it is logged at error level rather than skipped.
|
|
@@ -916,7 +1040,7 @@ async function backfillTranslationMemory(projectId, sourceLocale, flatSource, tr
|
|
|
916
1040
|
for (const [key, translatedText] of Object.entries(translations)) {
|
|
917
1041
|
const sourceText = flatSource[key];
|
|
918
1042
|
if (excludeKeys.has(key) ||
|
|
919
|
-
|
|
1043
|
+
(0, glossary_1.hasFixedTerm)(sourceText ?? '', rules.glossary, locale) ||
|
|
920
1044
|
!sourceText || !translatedText || sourceText === translatedText) {
|
|
921
1045
|
skipped.push(key);
|
|
922
1046
|
continue;
|