@startsimpli/llm 0.4.16 → 0.4.17
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/package.json +5 -3
- package/src/translation/__fixtures__/golden-pairs.ts +243 -0
- package/src/translation/__tests__/batch-contract.test.ts +247 -0
- package/src/translation/__tests__/quality-gates.test.ts +338 -0
- package/src/translation/__tests__/route-confidentiality.test.ts +228 -0
- package/src/translation/__tests__/stub-provider.ts +118 -0
- package/src/translation/index.ts +784 -0
- package/src/translation/types.ts +300 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The translation engine (bd startsim-h1q8, GREEN over bd startsim-ob4d's RED).
|
|
3
|
+
*
|
|
4
|
+
* The contract lives in `./types` and did not move to implement this: every
|
|
5
|
+
* signature here is the one bd startsim-ob4d pinned. The tests in `./__tests__`
|
|
6
|
+
* are the specification — read them before changing anything in this file.
|
|
7
|
+
*
|
|
8
|
+
* WHAT MAY NEVER APPEAR IN THIS DIRECTORY:
|
|
9
|
+
* - a literal model id used to ROUTE (bd startsim-7oh1 — ids live in
|
|
10
|
+
* configuration). The one deny-list below is vendor *terms* data, not a
|
|
11
|
+
* routing decision, and it is overridable from the environment.
|
|
12
|
+
* - a branch on a locale code (bd startsim-jb1z — locales are data). Enforced
|
|
13
|
+
* by a source scan in `__tests__/quality-gates.test.ts`.
|
|
14
|
+
* - a call to the vendor Batch API or Files API (bd startsim-hopl — neither
|
|
15
|
+
* is zero-retention eligible; "batching" here means N segments in ONE prompt)
|
|
16
|
+
* - client text or a raw provider response in a log line, an error message or
|
|
17
|
+
* a stack. Enforced by `__tests__/route-confidentiality.test.ts`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { stripIsolates } from '@startsimpli/i18n/bidi';
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_BATCH_LIMITS,
|
|
24
|
+
type BatchLimits,
|
|
25
|
+
type EnvelopeResult,
|
|
26
|
+
type GlossaryTerm,
|
|
27
|
+
type MechanicalChecks,
|
|
28
|
+
type MemoryUnit,
|
|
29
|
+
type PromptContext,
|
|
30
|
+
type RetentionPosture,
|
|
31
|
+
type SegmentedDocument,
|
|
32
|
+
type StructuredDocument,
|
|
33
|
+
type DocumentNode,
|
|
34
|
+
type TermCheck,
|
|
35
|
+
type TranslatedSegment,
|
|
36
|
+
type TranslationDeps,
|
|
37
|
+
type TranslationFailure,
|
|
38
|
+
type TranslationRequest,
|
|
39
|
+
type TranslationResult,
|
|
40
|
+
type TranslationRoute,
|
|
41
|
+
type TranslationSegment,
|
|
42
|
+
type TranslationService,
|
|
43
|
+
} from './types';
|
|
44
|
+
|
|
45
|
+
export * from './types';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The prompt's identity, recorded on every unit alongside the provider and
|
|
49
|
+
* model. The regression harness reruns when this tuple changes and at no other
|
|
50
|
+
* time (`startsim-bfem`).
|
|
51
|
+
*/
|
|
52
|
+
export const PROMPT_VERSION = 'translate/v1';
|
|
53
|
+
|
|
54
|
+
/** Thrown by every stub here until GREEN lands. Never caught by product code. */
|
|
55
|
+
export class TranslationNotImplementedError extends Error {
|
|
56
|
+
constructor(what: string) {
|
|
57
|
+
super(
|
|
58
|
+
`${what} is not implemented (bd startsim-ob4d is RED; bd startsim-h1q8 implements it)`,
|
|
59
|
+
);
|
|
60
|
+
this.name = 'TranslationNotImplementedError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A deployment that cannot legally or safely serve. Raised at BOOT, never on
|
|
66
|
+
* the first client document.
|
|
67
|
+
*/
|
|
68
|
+
export class TranslationConfigError extends Error {
|
|
69
|
+
constructor(message: string) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = 'TranslationConfigError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A `walled` route whose configured provider would not answer.
|
|
77
|
+
*
|
|
78
|
+
* This is a SUCCESSFUL refusal, not a bug: crossing to another vendor under
|
|
79
|
+
* different retention terms would breach the guarantee the product is sold on
|
|
80
|
+
* (`startsim-hopl`). The job is retried later against the same provider.
|
|
81
|
+
*/
|
|
82
|
+
export class WalledRouteUnavailableError extends Error {
|
|
83
|
+
constructor(provider: string) {
|
|
84
|
+
super(`walled route: ${provider} is unavailable and this posture forbids fallback`);
|
|
85
|
+
this.name = 'WalledRouteUnavailableError';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A provider response that could not be parsed after every permitted retry. */
|
|
90
|
+
export class EnvelopeParseError extends Error {
|
|
91
|
+
readonly reason: string;
|
|
92
|
+
constructor(reason: string) {
|
|
93
|
+
// The MESSAGE CARRIES NO RESPONSE TEXT. That is the whole discipline: the
|
|
94
|
+
// failure path is where the confidentiality guarantee actually breaks.
|
|
95
|
+
super(`translation envelope rejected: ${reason}`);
|
|
96
|
+
this.name = 'EnvelopeParseError';
|
|
97
|
+
this.reason = reason;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// structure — deterministic by construction, because it never reaches a model
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A node's address in the tree, used as its segment id.
|
|
107
|
+
*
|
|
108
|
+
* Derived from POSITION, not content, and recomputed identically when the
|
|
109
|
+
* skeleton is walked again — which is what makes `segmentDocument` and
|
|
110
|
+
* `reassembleDocument` a round trip rather than two functions that agree by
|
|
111
|
+
* luck. The caller never sees these ids unless it segments a document; the
|
|
112
|
+
* engine's own segment ids stay opaque per `startsim-jb1z`.
|
|
113
|
+
*/
|
|
114
|
+
function nodePath(prefix: string, index: number): string {
|
|
115
|
+
return prefix === '' ? `n${index}` : `${prefix}.${index}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A leaf carries text. `translatable: false` marks a product code, id, or similar. */
|
|
119
|
+
function isTranslatableLeaf(node: DocumentNode): boolean {
|
|
120
|
+
return typeof node.text === 'string' && node.translatable !== false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Pull the translatable leaves out of a document, leaving a reassemblable skeleton. */
|
|
124
|
+
export function segmentDocument(document: StructuredDocument): SegmentedDocument {
|
|
125
|
+
const segments: TranslationSegment[] = [];
|
|
126
|
+
|
|
127
|
+
const walk = (nodes: readonly DocumentNode[], prefix: string): void => {
|
|
128
|
+
nodes.forEach((node, index) => {
|
|
129
|
+
const id = nodePath(prefix, index);
|
|
130
|
+
if (isTranslatableLeaf(node)) segments.push({ id, text: node.text as string });
|
|
131
|
+
if (node.children) walk(node.children, id);
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
walk(document.nodes, '');
|
|
135
|
+
|
|
136
|
+
// The skeleton is the document itself, untouched. Structure never enters the
|
|
137
|
+
// model, so there is nothing to strip out of it — reassembly replaces leaf
|
|
138
|
+
// text by id and returns the same shape.
|
|
139
|
+
return { segments, skeleton: cloneDocument(document) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function cloneDocument(document: StructuredDocument): StructuredDocument {
|
|
143
|
+
return { nodes: document.nodes.map(cloneNode) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cloneNode(node: DocumentNode): DocumentNode {
|
|
147
|
+
const copy: DocumentNode = { kind: node.kind };
|
|
148
|
+
if (node.text !== undefined) copy.text = node.text;
|
|
149
|
+
if (node.translatable !== undefined) copy.translatable = node.translatable;
|
|
150
|
+
if (node.children !== undefined) copy.children = node.children.map(cloneNode);
|
|
151
|
+
return copy;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Put translated leaves back. Returns the ORIGINAL shape, by id, never by order. */
|
|
155
|
+
export function reassembleDocument(
|
|
156
|
+
skeleton: StructuredDocument,
|
|
157
|
+
translations: ReadonlyMap<string, string>,
|
|
158
|
+
): StructuredDocument {
|
|
159
|
+
const walk = (nodes: readonly DocumentNode[], prefix: string): DocumentNode[] =>
|
|
160
|
+
nodes.map((node, index) => {
|
|
161
|
+
const id = nodePath(prefix, index);
|
|
162
|
+
const copy = cloneNode(node);
|
|
163
|
+
if (isTranslatableLeaf(node)) {
|
|
164
|
+
const replacement = translations.get(id);
|
|
165
|
+
// A leaf with no translation keeps its source text: a partial result is
|
|
166
|
+
// a readable document with gaps, never a document with holes in it.
|
|
167
|
+
if (replacement !== undefined) copy.text = replacement;
|
|
168
|
+
}
|
|
169
|
+
if (node.children) copy.children = walk(node.children, id);
|
|
170
|
+
return copy;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return { nodes: walk(skeleton.nodes, '') };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// batching — the contract, not an optimisation
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Cut `segments` into calls by CHARACTER budget, always at least one segment
|
|
182
|
+
* per batch even when that one exceeds the budget alone — otherwise an
|
|
183
|
+
* oversized segment is skipped forever
|
|
184
|
+
* (`packages/integrations/src/corpus/extract.ts`'s `takeWithinBudget` rule).
|
|
185
|
+
*/
|
|
186
|
+
export function planBatches(
|
|
187
|
+
segments: readonly TranslationSegment[],
|
|
188
|
+
limits: BatchLimits = DEFAULT_BATCH_LIMITS,
|
|
189
|
+
): TranslationSegment[][] {
|
|
190
|
+
const batches: TranslationSegment[][] = [];
|
|
191
|
+
let current: TranslationSegment[] = [];
|
|
192
|
+
let chars = 0;
|
|
193
|
+
|
|
194
|
+
for (const segment of segments) {
|
|
195
|
+
const size = segment.text.length;
|
|
196
|
+
const wouldExceedChars = current.length > 0 && chars + size > limits.maxCharsPerBatch;
|
|
197
|
+
const wouldExceedCount = current.length >= limits.maxSegmentsPerBatch;
|
|
198
|
+
|
|
199
|
+
if (wouldExceedChars || wouldExceedCount) {
|
|
200
|
+
batches.push(current);
|
|
201
|
+
current = [];
|
|
202
|
+
chars = 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
current.push(segment);
|
|
206
|
+
chars += size;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (current.length > 0) batches.push(current);
|
|
210
|
+
return batches;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// prompt + envelope
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
const SYSTEM_PROMPT = [
|
|
218
|
+
'You are a professional translator.',
|
|
219
|
+
'Translate ONLY the text inside each <segment> element of <input>.',
|
|
220
|
+
'Return STRICT JSON and nothing else, in this exact shape:',
|
|
221
|
+
'{"segments":[{"id":"<the id you were given>","text":"<the translation>"}]}',
|
|
222
|
+
'Rules:',
|
|
223
|
+
'- Echo every id back EXACTLY as given. Never invent an id, never merge or split segments.',
|
|
224
|
+
'- Return one entry per requested segment, no more and no fewer.',
|
|
225
|
+
'- Preserve every number, date, currency amount and percentage exactly as written.',
|
|
226
|
+
'- Preserve line breaks inside a segment.',
|
|
227
|
+
'- <glossary> gives binding terminology: when a source term appears, its approved target form MUST appear in your translation.',
|
|
228
|
+
'- Terms marked do-not-translate MUST be reproduced verbatim in the source script.',
|
|
229
|
+
'- <memory> holds ALREADY-APPROVED example translations. They are reference only. NEVER translate them and never include them in your output.',
|
|
230
|
+
'- If a segment is empty, return it as an empty string.',
|
|
231
|
+
].join('\n');
|
|
232
|
+
|
|
233
|
+
/** Escape the five XML entities so a quote or angle bracket in client text cannot break a block. */
|
|
234
|
+
function escapeXml(text: string): string {
|
|
235
|
+
return text
|
|
236
|
+
.replace(/&/g, '&')
|
|
237
|
+
.replace(/</g, '<')
|
|
238
|
+
.replace(/>/g, '>')
|
|
239
|
+
.replace(/"/g, '"')
|
|
240
|
+
.replace(/'/g, ''');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The glossary terms that actually OCCUR in this batch.
|
|
245
|
+
*
|
|
246
|
+
* The prompt scales with the TEXT, not with the glossary: a client with a
|
|
247
|
+
* 4,000-term glossary must not pay for all of it on a two-line page.
|
|
248
|
+
*/
|
|
249
|
+
function termsOccurringIn(
|
|
250
|
+
batch: readonly TranslationSegment[],
|
|
251
|
+
glossary: readonly GlossaryTerm[] | undefined,
|
|
252
|
+
): GlossaryTerm[] {
|
|
253
|
+
if (!glossary?.length) return [];
|
|
254
|
+
const haystack = batch.map((segment) => segment.text).join('\n').toLowerCase();
|
|
255
|
+
return glossary.filter((term) => haystack.includes(term.source.toLowerCase()));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* One call's user prompt: XML-ish blocks, mirroring the corpus package's
|
|
260
|
+
* `renderAnswerPrompt`. XML rather than JSON *input* because the payload is
|
|
261
|
+
* long free text full of quotes and newlines.
|
|
262
|
+
*
|
|
263
|
+
* ONLY the glossary terms occurring in THIS batch are injected. See `startsim-yfot`.
|
|
264
|
+
*/
|
|
265
|
+
export function renderTranslatePrompt(
|
|
266
|
+
batch: readonly TranslationSegment[],
|
|
267
|
+
context: PromptContext,
|
|
268
|
+
): string {
|
|
269
|
+
const blocks: string[] = [
|
|
270
|
+
`<task source_locale="${escapeXml(context.sourceLocale)}" target_locale="${escapeXml(context.targetLocale)}">`,
|
|
271
|
+
`Translate from ${escapeXml(context.sourceLocale)} to ${escapeXml(context.targetLocale)}.`,
|
|
272
|
+
'</task>',
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
const terms = termsOccurringIn(batch, context.glossary);
|
|
276
|
+
if (terms.length > 0) {
|
|
277
|
+
blocks.push('<glossary>');
|
|
278
|
+
for (const term of terms) {
|
|
279
|
+
blocks.push(
|
|
280
|
+
term.doNotTranslate
|
|
281
|
+
? ` <term do_not_translate="true">${escapeXml(term.source)}</term>`
|
|
282
|
+
: ` <term source="${escapeXml(term.source)}">${escapeXml(term.target)}</term>`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
blocks.push('</glossary>');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Examples sit in their OWN block, before the input. `startsim-yfot` names the
|
|
289
|
+
// model translating its own examples as the likeliest prompt-level failure.
|
|
290
|
+
const examples = context.memoryExamples ?? [];
|
|
291
|
+
if (examples.length > 0) {
|
|
292
|
+
blocks.push('<memory note="approved examples — reference only, do NOT translate these">');
|
|
293
|
+
for (const example of examples) {
|
|
294
|
+
blocks.push(' <example>');
|
|
295
|
+
blocks.push(` <from>${escapeXml(example.sourceText)}</from>`);
|
|
296
|
+
blocks.push(` <to>${escapeXml(example.targetText)}</to>`);
|
|
297
|
+
blocks.push(' </example>');
|
|
298
|
+
}
|
|
299
|
+
blocks.push('</memory>');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
blocks.push('<input>');
|
|
303
|
+
for (const segment of batch) {
|
|
304
|
+
blocks.push(` <segment id="${escapeXml(segment.id)}">${escapeXml(segment.text)}</segment>`);
|
|
305
|
+
}
|
|
306
|
+
blocks.push('</input>');
|
|
307
|
+
|
|
308
|
+
return blocks.join('\n');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The JSON object a well-formed response carries. */
|
|
312
|
+
interface RawEnvelope {
|
|
313
|
+
segments?: unknown;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Pull the JSON object out of a response that may be fenced or prefaced.
|
|
318
|
+
* Returns null when there is nothing parseable — which is `malformed`.
|
|
319
|
+
*/
|
|
320
|
+
function parseJsonObject(raw: string): RawEnvelope | null {
|
|
321
|
+
const attempt = (text: string): RawEnvelope | null => {
|
|
322
|
+
try {
|
|
323
|
+
const value: unknown = JSON.parse(text);
|
|
324
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
325
|
+
? (value as RawEnvelope)
|
|
326
|
+
: null;
|
|
327
|
+
} catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const direct = attempt(raw.trim());
|
|
333
|
+
if (direct) return direct;
|
|
334
|
+
|
|
335
|
+
// A fenced or prefaced object is a formatting slip, not a correctness alarm —
|
|
336
|
+
// recover it rather than burning a retry. Anything else is malformed.
|
|
337
|
+
const first = raw.indexOf('{');
|
|
338
|
+
const last = raw.lastIndexOf('}');
|
|
339
|
+
if (first >= 0 && last > first) return attempt(raw.slice(first, last + 1));
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Parse and police one provider response against the ids that were requested. */
|
|
344
|
+
export function parseTranslationEnvelope(
|
|
345
|
+
raw: string,
|
|
346
|
+
requestedIds: readonly string[],
|
|
347
|
+
): EnvelopeResult {
|
|
348
|
+
const envelope = parseJsonObject(raw);
|
|
349
|
+
if (!envelope || !Array.isArray(envelope.segments)) {
|
|
350
|
+
return { ok: false, reason: 'malformed', offendingIds: [] };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const requested = new Set(requestedIds);
|
|
354
|
+
const segments: TranslationSegment[] = [];
|
|
355
|
+
const empty: string[] = [];
|
|
356
|
+
const seen = new Set<string>();
|
|
357
|
+
const duplicates: string[] = [];
|
|
358
|
+
const unknown: string[] = [];
|
|
359
|
+
|
|
360
|
+
for (const entry of envelope.segments) {
|
|
361
|
+
if (!entry || typeof entry !== 'object') {
|
|
362
|
+
return { ok: false, reason: 'malformed', offendingIds: [] };
|
|
363
|
+
}
|
|
364
|
+
const { id, text } = entry as { id?: unknown; text?: unknown };
|
|
365
|
+
if (typeof id !== 'string') {
|
|
366
|
+
return { ok: false, reason: 'malformed', offendingIds: [] };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// An UNKNOWN or DUPLICATE id means the model attributed text to a segment
|
|
370
|
+
// that did not ask for it. In a product sold on "one client's material never
|
|
371
|
+
// appears against another's" that is a correctness alarm, not a parse slip.
|
|
372
|
+
if (!requested.has(id)) {
|
|
373
|
+
unknown.push(id);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (seen.has(id)) {
|
|
377
|
+
duplicates.push(id);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
seen.add(id);
|
|
381
|
+
|
|
382
|
+
// A MISSING `text` key is structurally broken. An EMPTY STRING is a value:
|
|
383
|
+
// the caller may legitimately have handed us an empty segment, and only the
|
|
384
|
+
// caller — which holds the source — can tell those apart. The service does
|
|
385
|
+
// that comparison; the parser stays source-blind.
|
|
386
|
+
if (typeof text !== 'string') {
|
|
387
|
+
empty.push(id);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
segments.push({ id, text });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (unknown.length > 0) return { ok: false, reason: 'unknown_id', offendingIds: unknown };
|
|
394
|
+
if (duplicates.length > 0) {
|
|
395
|
+
return { ok: false, reason: 'duplicate_id', offendingIds: duplicates };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const missing = requestedIds.filter((id) => !seen.has(id));
|
|
399
|
+
return { ok: true, segments, missing, empty };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// mechanical checks — source-to-output, no target-language reader required
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Ordered longest-first so a date is one token rather than three numbers, and a
|
|
408
|
+
* currency amount is not shredded into its groups. Order IS the algorithm here.
|
|
409
|
+
*/
|
|
410
|
+
const NUMERIC_PATTERN =
|
|
411
|
+
/\d{4}-\d{2}-\d{2}|[$€£¥]\s?\d[\d,]*(?:\.\d+)?|\d[\d,]*(?:\.\d+)?\s?%|\d[\d,]*(?:\.\d+)?/g;
|
|
412
|
+
|
|
413
|
+
/** Numbers, dates, currency amounts and percentages, as a comparable multiset. */
|
|
414
|
+
export function extractNumerics(text: string): string[] {
|
|
415
|
+
return text.match(NUMERIC_PATTERN) ?? [];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Multiset difference: what is in `source` that `target` does not cover. */
|
|
419
|
+
function missingFrom(source: readonly string[], target: readonly string[]): string[] {
|
|
420
|
+
const remaining = new Map<string, number>();
|
|
421
|
+
for (const value of target) remaining.set(value, (remaining.get(value) ?? 0) + 1);
|
|
422
|
+
|
|
423
|
+
const missing: string[] = [];
|
|
424
|
+
for (const value of source) {
|
|
425
|
+
const left = remaining.get(value) ?? 0;
|
|
426
|
+
if (left > 0) remaining.set(value, left - 1);
|
|
427
|
+
else missing.push(value);
|
|
428
|
+
}
|
|
429
|
+
return missing;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function occursIn(haystack: string, needle: string): boolean {
|
|
433
|
+
return needle.length > 0 && haystack.toLowerCase().includes(needle.toLowerCase());
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function runMechanicalChecks(
|
|
437
|
+
sourceText: string,
|
|
438
|
+
targetText: string,
|
|
439
|
+
glossary: readonly GlossaryTerm[],
|
|
440
|
+
): MechanicalChecks {
|
|
441
|
+
const glossaryAdherence: TermCheck[] = [];
|
|
442
|
+
const doNotTranslate: TermCheck[] = [];
|
|
443
|
+
|
|
444
|
+
for (const term of glossary) {
|
|
445
|
+
// Only terms that actually OCCURRED can be adhered to or missed. Scoring a
|
|
446
|
+
// term that was never in the source would make every document look bad.
|
|
447
|
+
if (!occursIn(sourceText, term.source)) continue;
|
|
448
|
+
|
|
449
|
+
if (term.doNotTranslate) {
|
|
450
|
+
// Verbatim, so case-sensitive: a brand is a brand.
|
|
451
|
+
doNotTranslate.push({ term: term.source, present: targetText.includes(term.source) });
|
|
452
|
+
} else {
|
|
453
|
+
glossaryAdherence.push({ term: term.source, present: occursIn(targetText, term.target) });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const missingNumerics = missingFrom(extractNumerics(sourceText), extractNumerics(targetText));
|
|
458
|
+
|
|
459
|
+
const ok =
|
|
460
|
+
glossaryAdherence.every((check) => check.present) &&
|
|
461
|
+
doNotTranslate.every((check) => check.present) &&
|
|
462
|
+
missingNumerics.length === 0;
|
|
463
|
+
|
|
464
|
+
return { glossaryAdherence, doNotTranslate, missingNumerics, ok };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
// routing
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Models the vendor designates as requiring retention, which therefore CANNOT
|
|
473
|
+
* run under a zero-retention arrangement (`startsim-hopl`).
|
|
474
|
+
*
|
|
475
|
+
* This is the one place a model id is named, and it is deliberate: this is
|
|
476
|
+
* vendor TERMS data — a legal fact about a model — not a routing decision, and
|
|
477
|
+
* `startsim-7oh1`'s ban is on selecting models by hardcoded id. It is
|
|
478
|
+
* overridable from the environment so a terms change is a config change, and it
|
|
479
|
+
* is enforced only on the `walled` posture, where the guarantee is being sold.
|
|
480
|
+
*/
|
|
481
|
+
const DEFAULT_COVERED_MODELS = ['claude-fable-5', 'claude-mythos-5'] as const;
|
|
482
|
+
|
|
483
|
+
function splitList(value: string | undefined): string[] {
|
|
484
|
+
return (value ?? '')
|
|
485
|
+
.split(',')
|
|
486
|
+
.map((item) => item.trim())
|
|
487
|
+
.filter((item) => item.length > 0);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function isPosture(value: string | undefined): value is RetentionPosture {
|
|
491
|
+
return value === 'walled' || value === 'open';
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Resolve the deployment's route from configuration and VALIDATE it.
|
|
496
|
+
*
|
|
497
|
+
* Throws `TranslationConfigError` when the configured model is outside the
|
|
498
|
+
* allow-list, or is a Covered Model on a walled deployment.
|
|
499
|
+
*/
|
|
500
|
+
export function resolveTranslationRoute(
|
|
501
|
+
env: Record<string, string | undefined>,
|
|
502
|
+
): TranslationRoute {
|
|
503
|
+
const posture = env.TRANSLATION_POSTURE;
|
|
504
|
+
if (!isPosture(posture)) {
|
|
505
|
+
throw new TranslationConfigError(
|
|
506
|
+
`TRANSLATION_POSTURE must be 'walled' or 'open' (got ${posture ?? 'nothing'})`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const provider = env.TRANSLATION_PROVIDER?.trim();
|
|
511
|
+
if (!provider) throw new TranslationConfigError('TRANSLATION_PROVIDER is not set');
|
|
512
|
+
|
|
513
|
+
const model = env.TRANSLATION_MODEL?.trim();
|
|
514
|
+
if (!model) throw new TranslationConfigError('TRANSLATION_MODEL is not set');
|
|
515
|
+
|
|
516
|
+
const allowedModels = splitList(env.TRANSLATION_ALLOWED_MODELS);
|
|
517
|
+
const coveredModels = env.TRANSLATION_COVERED_MODELS
|
|
518
|
+
? splitList(env.TRANSLATION_COVERED_MODELS)
|
|
519
|
+
: [...DEFAULT_COVERED_MODELS];
|
|
520
|
+
|
|
521
|
+
// Posture decides fallback. A deployment does not get to ask for it.
|
|
522
|
+
const fallback = posture === 'walled' ? false : env.TRANSLATION_FALLBACK === 'true';
|
|
523
|
+
|
|
524
|
+
const route: TranslationRoute = { posture, provider, model, allowedModels, fallback };
|
|
525
|
+
assertRouteIsServable(route, coveredModels);
|
|
526
|
+
return route;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The boot gate, shared by `resolveTranslationRoute` and
|
|
531
|
+
* `createTranslationService` — a route built by hand must clear the same bar as
|
|
532
|
+
* one read from the environment, or the gate is advisory.
|
|
533
|
+
*/
|
|
534
|
+
function assertRouteIsServable(
|
|
535
|
+
route: TranslationRoute,
|
|
536
|
+
coveredModels: readonly string[] = DEFAULT_COVERED_MODELS,
|
|
537
|
+
): void {
|
|
538
|
+
if (route.posture === 'walled' && route.fallback) {
|
|
539
|
+
throw new TranslationConfigError(
|
|
540
|
+
'walled posture forbids fallback: crossing to another vendor would breach the retention guarantee',
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (route.posture === 'walled' && route.allowedModels.length === 0) {
|
|
545
|
+
throw new TranslationConfigError(
|
|
546
|
+
'walled posture requires an explicit model allow-list (TRANSLATION_ALLOWED_MODELS)',
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (route.allowedModels.length > 0 && !route.allowedModels.includes(route.model)) {
|
|
551
|
+
throw new TranslationConfigError(
|
|
552
|
+
`model ${route.model} is outside this deployment's allow-list`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (route.posture === 'walled' && coveredModels.includes(route.model)) {
|
|
557
|
+
throw new TranslationConfigError(
|
|
558
|
+
`model ${route.model} requires vendor-side retention and cannot serve a walled deployment`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// the service
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
const NO_OP_LOGGER = {
|
|
568
|
+
debug: () => {},
|
|
569
|
+
info: () => {},
|
|
570
|
+
warn: () => {},
|
|
571
|
+
error: () => {},
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
/** Exact-match memory key. Locales are part of it; neither is ever compared to a literal. */
|
|
575
|
+
function memoryKey(sourceText: string, source: string, target: string): string {
|
|
576
|
+
return `${source}${target}${sourceText}`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function buildMemoryIndex(memory: readonly MemoryUnit[] | undefined): Map<string, string> {
|
|
580
|
+
const index = new Map<string, string>();
|
|
581
|
+
for (const unit of memory ?? []) {
|
|
582
|
+
index.set(memoryKey(unit.sourceText, unit.sourceLocale, unit.targetLocale), unit.targetText);
|
|
583
|
+
}
|
|
584
|
+
return index;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Build the service for a route. Single-provider by construction on a walled
|
|
589
|
+
* posture — this deliberately does NOT reuse `LLMService.getProvider()`'s
|
|
590
|
+
* "fall back to any available" behaviour, which would cross a retention
|
|
591
|
+
* boundary silently.
|
|
592
|
+
*/
|
|
593
|
+
export function createTranslationService(
|
|
594
|
+
route: TranslationRoute,
|
|
595
|
+
deps: TranslationDeps,
|
|
596
|
+
): TranslationService {
|
|
597
|
+
// Fail the BOOT, not the first client document.
|
|
598
|
+
assertRouteIsServable(route);
|
|
599
|
+
|
|
600
|
+
const logger = deps.logger ?? NO_OP_LOGGER;
|
|
601
|
+
const limits = deps.limits ?? DEFAULT_BATCH_LIMITS;
|
|
602
|
+
const maxEnvelopeRetries = deps.maxEnvelopeRetries ?? 1;
|
|
603
|
+
|
|
604
|
+
const provenance = {
|
|
605
|
+
provider: route.provider,
|
|
606
|
+
model: route.model,
|
|
607
|
+
promptVersion: PROMPT_VERSION,
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* One provider call for one batch, with the id-echo contract enforced.
|
|
612
|
+
*
|
|
613
|
+
* A malformed envelope is worth asking again for — models drift into prose.
|
|
614
|
+
* An unknown or duplicate id is NOT: it is a correctness alarm and retrying
|
|
615
|
+
* would just roll the dice on client material.
|
|
616
|
+
*/
|
|
617
|
+
async function callBatch(
|
|
618
|
+
batch: readonly TranslationSegment[],
|
|
619
|
+
request: TranslationRequest,
|
|
620
|
+
): Promise<{ parsed: Extract<EnvelopeResult, { ok: true }>; calls: number }> {
|
|
621
|
+
const ids = batch.map((segment) => segment.id);
|
|
622
|
+
const basePrompt = renderTranslatePrompt(batch, {
|
|
623
|
+
sourceLocale: request.sourceLocale,
|
|
624
|
+
targetLocale: request.targetLocale,
|
|
625
|
+
glossary: request.glossary,
|
|
626
|
+
memoryExamples: request.memory,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
let calls = 0;
|
|
630
|
+
for (let attempt = 0; attempt <= maxEnvelopeRetries; attempt += 1) {
|
|
631
|
+
const userPrompt =
|
|
632
|
+
attempt === 0
|
|
633
|
+
? basePrompt
|
|
634
|
+
: `${basePrompt}\n\n<correction>Your previous reply was not valid JSON. Reply with ONLY the JSON object described in the system prompt.</correction>`;
|
|
635
|
+
|
|
636
|
+
const response = await deps.provider.generate(userPrompt, SYSTEM_PROMPT, {
|
|
637
|
+
model: route.model,
|
|
638
|
+
});
|
|
639
|
+
calls += 1;
|
|
640
|
+
|
|
641
|
+
const parsed = parseTranslationEnvelope(response.content, ids);
|
|
642
|
+
if (parsed.ok) return { parsed, calls };
|
|
643
|
+
|
|
644
|
+
if (parsed.reason === 'unknown_id' || parsed.reason === 'duplicate_id') {
|
|
645
|
+
// Ids are the caller's own opaque labels, never client text — safe to log.
|
|
646
|
+
logger.error('translation envelope rejected', {
|
|
647
|
+
reason: parsed.reason,
|
|
648
|
+
offendingIds: parsed.offendingIds,
|
|
649
|
+
provider: route.provider,
|
|
650
|
+
});
|
|
651
|
+
throw new EnvelopeParseError(parsed.reason);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
logger.warn('translation envelope unparseable', {
|
|
655
|
+
reason: parsed.reason,
|
|
656
|
+
attempt: attempt + 1,
|
|
657
|
+
segments: batch.length,
|
|
658
|
+
provider: route.provider,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
throw new EnvelopeParseError('malformed');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function translate(request: TranslationRequest): Promise<TranslationResult> {
|
|
666
|
+
// The refusal comes BEFORE any call. On a walled posture an unavailable
|
|
667
|
+
// provider is a successful refusal: the job retries later against the SAME
|
|
668
|
+
// provider rather than crossing to one under different terms.
|
|
669
|
+
if (!deps.provider.isAvailable()) {
|
|
670
|
+
if (route.posture === 'walled') throw new WalledRouteUnavailableError(route.provider);
|
|
671
|
+
throw new Error(`translation provider ${route.provider} is unavailable`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const glossary = request.glossary ?? [];
|
|
675
|
+
const memoryIndex = buildMemoryIndex(request.memory);
|
|
676
|
+
|
|
677
|
+
const translated: TranslatedSegment[] = [];
|
|
678
|
+
const failed: TranslationFailure[] = [];
|
|
679
|
+
const requeued: string[] = [];
|
|
680
|
+
let providerCalls = 0;
|
|
681
|
+
|
|
682
|
+
const finish = (segment: TranslationSegment, rawTarget: string, fromMemory: boolean): void => {
|
|
683
|
+
// Isolates are applied at RENDER time (`startsim-jb1z`). A stored string
|
|
684
|
+
// carrying U+2066–U+2069 is invisible on screen, very visible in a diff,
|
|
685
|
+
// and breaks exact memory matching forever after.
|
|
686
|
+
const targetText = stripIsolates(rawTarget);
|
|
687
|
+
|
|
688
|
+
// An empty target is a failure ONLY when the source had something in it.
|
|
689
|
+
// An empty source legitimately yields an empty target.
|
|
690
|
+
if (segment.text.trim().length > 0 && targetText.trim().length === 0) {
|
|
691
|
+
failed.push({ id: segment.id, reason: 'empty_text' });
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
translated.push({
|
|
696
|
+
id: segment.id,
|
|
697
|
+
targetText,
|
|
698
|
+
provenance,
|
|
699
|
+
fromMemory,
|
|
700
|
+
checks: runMechanicalChecks(segment.text, targetText, glossary),
|
|
701
|
+
});
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
// An exact memory hit costs nothing, which is exactly why it is checked
|
|
705
|
+
// first and why the effective provider rate falls as the memory fills.
|
|
706
|
+
const pending: TranslationSegment[] = [];
|
|
707
|
+
for (const segment of request.segments) {
|
|
708
|
+
const hit = memoryIndex.get(
|
|
709
|
+
memoryKey(segment.text, request.sourceLocale, request.targetLocale),
|
|
710
|
+
);
|
|
711
|
+
if (hit !== undefined) finish(segment, hit, true);
|
|
712
|
+
else pending.push(segment);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (pending.length === 0) {
|
|
716
|
+
return { translated, failed, requeued, providerCalls };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const byId = new Map(request.segments.map((segment) => [segment.id, segment]));
|
|
720
|
+
|
|
721
|
+
for (const batch of planBatches(pending, limits)) {
|
|
722
|
+
const { parsed, calls } = await callBatch(batch, request);
|
|
723
|
+
providerCalls += calls;
|
|
724
|
+
|
|
725
|
+
for (const returned of parsed.segments) {
|
|
726
|
+
const source = byId.get(returned.id);
|
|
727
|
+
if (source) finish(source, returned.text, false);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// A structurally blank entry is not an empty translation, it is a broken one.
|
|
731
|
+
for (const id of parsed.empty) failed.push({ id, reason: 'empty_text' });
|
|
732
|
+
|
|
733
|
+
// One difficult sentence must not cost the document: a missing id is
|
|
734
|
+
// re-queued ALONE, where it gets the model's whole attention.
|
|
735
|
+
for (const id of parsed.missing) {
|
|
736
|
+
const source = byId.get(id);
|
|
737
|
+
if (!source) continue;
|
|
738
|
+
requeued.push(id);
|
|
739
|
+
|
|
740
|
+
try {
|
|
741
|
+
const retry = await callBatch([source], request);
|
|
742
|
+
providerCalls += retry.calls;
|
|
743
|
+
const answer = retry.parsed.segments.find((segment) => segment.id === id);
|
|
744
|
+
if (answer) finish(source, answer.text, false);
|
|
745
|
+
else failed.push({ id, reason: 'missing_id' });
|
|
746
|
+
} catch (error) {
|
|
747
|
+
// DELIBERATE ASYMMETRY, not an accident of where the try/catch landed:
|
|
748
|
+
// a failure in the MAIN batch propagates out of translate() and fails
|
|
749
|
+
// the call, because it is the whole document. A failure in a re-queue
|
|
750
|
+
// is best effort and fails only ITS segment, because the rest of the
|
|
751
|
+
// document already translated and throwing it away would be worse.
|
|
752
|
+
//
|
|
753
|
+
// The reason is `missing_id`, never `mechanical_check`: no translation
|
|
754
|
+
// was produced on this path, so there is no quality verdict to report,
|
|
755
|
+
// and a reviewer told `mechanical_check` would go looking for a bad
|
|
756
|
+
// translation that does not exist.
|
|
757
|
+
logger.warn('re-queued segment failed', {
|
|
758
|
+
id,
|
|
759
|
+
reason: error instanceof EnvelopeParseError ? error.reason : 'provider_error',
|
|
760
|
+
provider: route.provider,
|
|
761
|
+
});
|
|
762
|
+
failed.push({
|
|
763
|
+
id,
|
|
764
|
+
reason: error instanceof EnvelopeParseError ? 'missing_id' : 'provider_error',
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
logger.info('translation batch complete', {
|
|
771
|
+
requested: request.segments.length,
|
|
772
|
+
translated: translated.length,
|
|
773
|
+
failed: failed.length,
|
|
774
|
+
requeued: requeued.length,
|
|
775
|
+
providerCalls,
|
|
776
|
+
provider: route.provider,
|
|
777
|
+
model: route.model,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
return { translated, failed, requeued, providerCalls };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return { translate, route };
|
|
784
|
+
}
|