@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,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE MECHANICAL GATES (bd startsim-ob4d, RED items 8–12, 14, 15).
|
|
3
|
+
*
|
|
4
|
+
* `startsim-r9q8`'s central move: every measure here compares SOURCE TO OUTPUT
|
|
5
|
+
* and none of them judges the output's language. That is what makes the product
|
|
6
|
+
* assessable by the people selling it, neither of whom reads Arabic.
|
|
7
|
+
*
|
|
8
|
+
* Their blind spot is stated so nobody mistakes a green run for a good
|
|
9
|
+
* translation: A TRANSLATION CAN BE 100% TERM-CORRECT AND STILL BE WRONG.
|
|
10
|
+
* Adherence says the right words appeared. It says nothing about whether the
|
|
11
|
+
* sentence means what the source meant. That question belongs to the named
|
|
12
|
+
* reviewer and to gate 4 of the ship floor, and no assertion in this file
|
|
13
|
+
* substitutes for it.
|
|
14
|
+
*
|
|
15
|
+
* Numeric fidelity, do-not-translate and structure are the 100%-or-fail gates;
|
|
16
|
+
* glossary adherence ships at ≥98% overall because a matcher may legitimately
|
|
17
|
+
* miss an inflected term. None of that tolerance lives HERE — these are unit
|
|
18
|
+
* tests over crafted input where the answer is exactly known.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, expect, it } from 'vitest';
|
|
22
|
+
|
|
23
|
+
import { stripIsolates } from '@startsimpli/i18n';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
createTranslationService,
|
|
27
|
+
extractNumerics,
|
|
28
|
+
reassembleDocument,
|
|
29
|
+
renderTranslatePrompt,
|
|
30
|
+
runMechanicalChecks,
|
|
31
|
+
segmentDocument,
|
|
32
|
+
type TranslationRoute,
|
|
33
|
+
} from '../index';
|
|
34
|
+
import type { GlossaryTerm, MemoryUnit, StructuredDocument } from '../types';
|
|
35
|
+
import {
|
|
36
|
+
DNT_BRAND,
|
|
37
|
+
GOLDEN_SETS,
|
|
38
|
+
NUMERICS_IN_FIXTURE,
|
|
39
|
+
envelopeFor,
|
|
40
|
+
pairIn,
|
|
41
|
+
segmentsOf,
|
|
42
|
+
type GoldenSet,
|
|
43
|
+
} from '../__fixtures__/golden-pairs';
|
|
44
|
+
import { stubProvider } from './stub-provider';
|
|
45
|
+
|
|
46
|
+
function walledRoute(): TranslationRoute {
|
|
47
|
+
return {
|
|
48
|
+
posture: 'walled',
|
|
49
|
+
provider: 'stub',
|
|
50
|
+
model: 'stub-model',
|
|
51
|
+
allowedModels: ['stub-model'],
|
|
52
|
+
fallback: false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe.each(GOLDEN_SETS)(
|
|
57
|
+
'mechanical gates [$sourceLocale→$targetLocale]',
|
|
58
|
+
(set: GoldenSet) => {
|
|
59
|
+
const binding = set.glossary.find((t) => !t.doNotTranslate)!;
|
|
60
|
+
|
|
61
|
+
it('injects only the glossary terms that OCCUR in this batch', () => {
|
|
62
|
+
// The prompt scales with the TEXT, not with the glossary. A client with a
|
|
63
|
+
// 4,000-term glossary must not pay for all of it on a two-line page.
|
|
64
|
+
const absent: GlossaryTerm = {
|
|
65
|
+
source: 'force majeure',
|
|
66
|
+
target: 'NOT-IN-THIS-BATCH',
|
|
67
|
+
binding: true,
|
|
68
|
+
};
|
|
69
|
+
const batch = [{ id: 'glossary-term', text: pairIn(set, 'glossary-term').sourceText }];
|
|
70
|
+
|
|
71
|
+
const prompt = renderTranslatePrompt(batch, {
|
|
72
|
+
sourceLocale: set.sourceLocale,
|
|
73
|
+
targetLocale: set.targetLocale,
|
|
74
|
+
glossary: [...set.glossary, absent],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(prompt).toContain(binding.source);
|
|
78
|
+
expect(prompt).toContain(binding.target);
|
|
79
|
+
expect(prompt).not.toContain(absent.source);
|
|
80
|
+
expect(prompt).not.toContain(absent.target);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('labels memory examples so the model cannot mistake one for input', () => {
|
|
84
|
+
// `startsim-yfot` names this the single most likely prompt-level failure:
|
|
85
|
+
// the model translates its own examples.
|
|
86
|
+
const example: MemoryUnit = {
|
|
87
|
+
sourceText: 'A previously approved sentence.',
|
|
88
|
+
targetText: pairIn(set, 'plain').targetText,
|
|
89
|
+
sourceLocale: set.sourceLocale,
|
|
90
|
+
targetLocale: set.targetLocale,
|
|
91
|
+
};
|
|
92
|
+
const batch = [{ id: 'plain', text: pairIn(set, 'plain').sourceText }];
|
|
93
|
+
|
|
94
|
+
const prompt = renderTranslatePrompt(batch, {
|
|
95
|
+
sourceLocale: set.sourceLocale,
|
|
96
|
+
targetLocale: set.targetLocale,
|
|
97
|
+
memoryExamples: [example],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// The example and the thing to translate must sit in DIFFERENT blocks.
|
|
101
|
+
const exampleAt = prompt.indexOf(example.sourceText);
|
|
102
|
+
const inputAt = prompt.indexOf(batch[0]!.text);
|
|
103
|
+
expect(exampleAt).toBeGreaterThanOrEqual(0);
|
|
104
|
+
expect(inputAt).toBeGreaterThanOrEqual(0);
|
|
105
|
+
expect(prompt.slice(0, inputAt)).not.toBe(prompt.slice(0, exampleAt));
|
|
106
|
+
expect(prompt).toMatch(/<(memory|example)[\s>]/i);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('scores a binding term as adhered when its approved form is present', () => {
|
|
110
|
+
const pair = pairIn(set, 'glossary-term');
|
|
111
|
+
|
|
112
|
+
const checks = runMechanicalChecks(pair.sourceText, pair.targetText, set.glossary);
|
|
113
|
+
|
|
114
|
+
expect(checks.glossaryAdherence).toContainEqual({
|
|
115
|
+
term: binding.source,
|
|
116
|
+
present: true,
|
|
117
|
+
});
|
|
118
|
+
expect(checks.ok).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('scores a binding term as MISSED when the approved form is absent', () => {
|
|
122
|
+
// The gate has to be able to fail, or it is decoration.
|
|
123
|
+
const pair = pairIn(set, 'glossary-term');
|
|
124
|
+
const stripped = pair.targetText.replace(binding.target, 'XXX');
|
|
125
|
+
|
|
126
|
+
const checks = runMechanicalChecks(pair.sourceText, stripped, set.glossary);
|
|
127
|
+
|
|
128
|
+
expect(checks.glossaryAdherence).toContainEqual({
|
|
129
|
+
term: binding.source,
|
|
130
|
+
present: false,
|
|
131
|
+
});
|
|
132
|
+
expect(checks.ok).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('requires a do-not-translate brand to survive VERBATIM', () => {
|
|
136
|
+
const pair = pairIn(set, 'do-not-translate');
|
|
137
|
+
|
|
138
|
+
const good = runMechanicalChecks(pair.sourceText, pair.targetText, set.glossary);
|
|
139
|
+
expect(good.doNotTranslate).toContainEqual({ term: DNT_BRAND, present: true });
|
|
140
|
+
|
|
141
|
+
const mangled = pair.targetText.replace(DNT_BRAND, 'نورثويند');
|
|
142
|
+
const bad = runMechanicalChecks(pair.sourceText, mangled, set.glossary);
|
|
143
|
+
expect(bad.doNotTranslate).toContainEqual({ term: DNT_BRAND, present: false });
|
|
144
|
+
expect(bad.ok).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('round-trips numbers, percentages and dates as a multiset', () => {
|
|
148
|
+
// 100%, non-negotiable: a dropped digit in a business document is a
|
|
149
|
+
// commercial error, not a stylistic one.
|
|
150
|
+
const pair = pairIn(set, 'numerics');
|
|
151
|
+
|
|
152
|
+
const checks = runMechanicalChecks(pair.sourceText, pair.targetText, set.glossary);
|
|
153
|
+
|
|
154
|
+
expect(checks.missingNumerics).toEqual([]);
|
|
155
|
+
expect(extractNumerics(pair.sourceText).sort()).toEqual(
|
|
156
|
+
[...NUMERICS_IN_FIXTURE].sort(),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('catches a dropped numeric', () => {
|
|
161
|
+
const pair = pairIn(set, 'numerics');
|
|
162
|
+
const dropped = pair.targetText.replace('$4,200,000', '');
|
|
163
|
+
|
|
164
|
+
const checks = runMechanicalChecks(pair.sourceText, dropped, set.glossary);
|
|
165
|
+
|
|
166
|
+
expect(checks.missingNumerics).toContain('$4,200,000');
|
|
167
|
+
expect(checks.ok).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('stores target text with NO bidi isolate characters', () => {
|
|
171
|
+
// `startsim-jb1z`'s rule: isolation happens at RENDER time. A stored
|
|
172
|
+
// string carrying U+2066–U+2069 is invisible on screen and very visible
|
|
173
|
+
// in a failing diff — and it breaks exact memory matching.
|
|
174
|
+
const provider = stubProvider(envelopeFor(set));
|
|
175
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
176
|
+
|
|
177
|
+
return service
|
|
178
|
+
.translate({
|
|
179
|
+
segments: segmentsOf(set),
|
|
180
|
+
sourceLocale: set.sourceLocale,
|
|
181
|
+
targetLocale: set.targetLocale,
|
|
182
|
+
glossary: set.glossary,
|
|
183
|
+
})
|
|
184
|
+
.then((result) => {
|
|
185
|
+
for (const unit of result.translated) {
|
|
186
|
+
expect(stripIsolates(unit.targetText)).toBe(unit.targetText);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('records the provider, model and prompt version on every unit', () => {
|
|
192
|
+
// Without provenance a quality regression after a route change is
|
|
193
|
+
// unattributable, and "translated under the old prompt" becomes a guess.
|
|
194
|
+
const provider = stubProvider(envelopeFor(set));
|
|
195
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
196
|
+
|
|
197
|
+
return service
|
|
198
|
+
.translate({
|
|
199
|
+
segments: segmentsOf(set),
|
|
200
|
+
sourceLocale: set.sourceLocale,
|
|
201
|
+
targetLocale: set.targetLocale,
|
|
202
|
+
})
|
|
203
|
+
.then((result) => {
|
|
204
|
+
for (const unit of result.translated) {
|
|
205
|
+
expect(unit.provenance.provider).toBe('stub');
|
|
206
|
+
expect(unit.provenance.model).toBe('stub-model');
|
|
207
|
+
expect(unit.provenance.promptVersion).toBeTruthy();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('serves an exact memory hit and makes ZERO provider calls', async () => {
|
|
213
|
+
// The cheapest, sharpest test in the suite. An exact hit costs nothing,
|
|
214
|
+
// which is why the effective rate falls as the memory fills.
|
|
215
|
+
const pair = pairIn(set, 'plain');
|
|
216
|
+
const memory: MemoryUnit[] = [
|
|
217
|
+
{
|
|
218
|
+
sourceText: pair.sourceText,
|
|
219
|
+
targetText: pair.targetText,
|
|
220
|
+
sourceLocale: set.sourceLocale,
|
|
221
|
+
targetLocale: set.targetLocale,
|
|
222
|
+
},
|
|
223
|
+
];
|
|
224
|
+
const provider = stubProvider('{"segments":[]}');
|
|
225
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
226
|
+
|
|
227
|
+
const result = await service.translate({
|
|
228
|
+
segments: [{ id: 'plain', text: pair.sourceText }],
|
|
229
|
+
sourceLocale: set.sourceLocale,
|
|
230
|
+
targetLocale: set.targetLocale,
|
|
231
|
+
memory,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(provider.calls).toHaveLength(0);
|
|
235
|
+
expect(result.providerCalls).toBe(0);
|
|
236
|
+
expect(result.translated).toHaveLength(1);
|
|
237
|
+
expect(result.translated[0]!.fromMemory).toBe(true);
|
|
238
|
+
expect(result.translated[0]!.targetText).toBe(pair.targetText);
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* STRUCTURE IS DETERMINISTIC BY CONSTRUCTION, because `startsim-jb1z` keeps it
|
|
245
|
+
* out of the model entirely: the caller segments, the caller reassembles, and
|
|
246
|
+
* the model only ever sees leaf text. So this is a pure round-trip and there is
|
|
247
|
+
* no excuse for it being anything but exact.
|
|
248
|
+
*/
|
|
249
|
+
describe('structure round-trip', () => {
|
|
250
|
+
const document: StructuredDocument = {
|
|
251
|
+
nodes: [
|
|
252
|
+
{ kind: 'heading', text: 'Quarterly Report' },
|
|
253
|
+
{
|
|
254
|
+
kind: 'list',
|
|
255
|
+
children: [
|
|
256
|
+
{ kind: 'list_item', text: 'Revenue grew 12.5%.' },
|
|
257
|
+
{ kind: 'list_item', text: 'Headcount held flat.' },
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
kind: 'table',
|
|
262
|
+
children: [
|
|
263
|
+
{
|
|
264
|
+
kind: 'table_row',
|
|
265
|
+
children: [
|
|
266
|
+
{ kind: 'table_cell', text: 'Region' },
|
|
267
|
+
{ kind: 'table_cell', text: 'Total' },
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
{ kind: 'paragraph', text: 'See the enclosed contract.' },
|
|
273
|
+
// A product code is not prose. It must survive without being offered to
|
|
274
|
+
// the model at all.
|
|
275
|
+
{ kind: 'code', text: 'NW-LOG-4420-X', translatable: false },
|
|
276
|
+
],
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
it('extracts only translatable leaves, and never the untranslatable ones', () => {
|
|
280
|
+
const { segments } = segmentDocument(document);
|
|
281
|
+
|
|
282
|
+
const texts = segments.map((s) => s.text);
|
|
283
|
+
expect(texts).toContain('Quarterly Report');
|
|
284
|
+
expect(texts).toContain('Revenue grew 12.5%.');
|
|
285
|
+
expect(texts).toContain('Region');
|
|
286
|
+
expect(texts).not.toContain('NW-LOG-4420-X');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('returns the ORIGINAL shape when the leaves are put back unchanged', () => {
|
|
290
|
+
const { segments, skeleton } = segmentDocument(document);
|
|
291
|
+
const identity = new Map(segments.map((s) => [s.id, s.text]));
|
|
292
|
+
|
|
293
|
+
expect(reassembleDocument(skeleton, identity)).toEqual(document);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('changes only the leaf text when the leaves are translated', () => {
|
|
297
|
+
const { segments, skeleton } = segmentDocument(document);
|
|
298
|
+
const translated = new Map(segments.map((s) => [s.id, `⟦${s.text}⟧`]));
|
|
299
|
+
|
|
300
|
+
const out = reassembleDocument(skeleton, translated);
|
|
301
|
+
|
|
302
|
+
// Same node tree, same kinds, same nesting — only text differs.
|
|
303
|
+
const kinds = (doc: StructuredDocument): string[] =>
|
|
304
|
+
doc.nodes.flatMap(function walk(node): string[] {
|
|
305
|
+
return [node.kind, ...(node.children ?? []).flatMap(walk)];
|
|
306
|
+
});
|
|
307
|
+
expect(kinds(out)).toEqual(kinds(document));
|
|
308
|
+
expect(out.nodes[4]!.text).toBe('NW-LOG-4420-X');
|
|
309
|
+
expect(out.nodes[0]!.text).toBe('⟦Quarterly Report⟧');
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* THE NAMING BAN, ENFORCED (bd startsim-jb1z, via bd startsim-bfem).
|
|
315
|
+
*
|
|
316
|
+
* A guard rather than a RED assertion — it passes today because the stubs are
|
|
317
|
+
* empty, and its job is to fail the day GREEN writes `if (target === 'ar')`.
|
|
318
|
+
*/
|
|
319
|
+
describe('the engine is language-agnostic', () => {
|
|
320
|
+
it('contains no locale literal anywhere in the translation engine', async () => {
|
|
321
|
+
const { readdir, readFile } = await import('node:fs/promises');
|
|
322
|
+
const { join } = await import('node:path');
|
|
323
|
+
const root = new URL('..', import.meta.url).pathname;
|
|
324
|
+
|
|
325
|
+
const entries = (await readdir(root, { withFileTypes: true }))
|
|
326
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.ts'));
|
|
327
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
328
|
+
|
|
329
|
+
for (const entry of entries) {
|
|
330
|
+
const source = await readFile(join(root, entry.name), 'utf8');
|
|
331
|
+
const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
332
|
+
// A locale compared as a STRING is the leak. Locales are data.
|
|
333
|
+
expect(code, `${entry.name} branches on a locale code`).not.toMatch(
|
|
334
|
+
/===\s*['"](ar|zh|en|he|fa|ur)(-[A-Za-z0-9]+)?['"]/,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ROUTING AND THE FAILURE PATH (bd startsim-ob4d, RED items 16–18).
|
|
3
|
+
*
|
|
4
|
+
* `startsim-bfem`: "Failure-path assertions matter as much as happy-path ones,
|
|
5
|
+
* because the failure path is where the confidentiality guarantee actually
|
|
6
|
+
* breaks." The named precedent is real and still in the estate — a Django
|
|
7
|
+
* handler that logs the model's raw response content on a JSON-parse failure.
|
|
8
|
+
* If translation reproduces that, the wall `startsim-hopl` sells is a comment.
|
|
9
|
+
*
|
|
10
|
+
* The three properties pinned here:
|
|
11
|
+
*
|
|
12
|
+
* 16. A malformed envelope is retried, then FAILS — and no response text and
|
|
13
|
+
* no client text reaches any log, on any path.
|
|
14
|
+
* 17. A `walled` route whose provider will not answer FAILS. It never crosses
|
|
15
|
+
* to a vendor under different retention terms. This is a deliberate
|
|
16
|
+
* override of `LLMService.getProvider()`'s "fall back to any available",
|
|
17
|
+
* whose `isAvailable()` is `Boolean(apiKey)` — and prod is on record
|
|
18
|
+
* holding an OpenAI key in `ANTHROPIC_API_KEY`, so a deployment that
|
|
19
|
+
* believes it is walled can route client text to another vendor with
|
|
20
|
+
* nothing saying so.
|
|
21
|
+
* 18. The service REFUSES TO BOOT on a model outside the deployment's
|
|
22
|
+
* allow-list. `claude-fable-5` is the named case: a Covered Model that
|
|
23
|
+
* cannot run under zero-retention terms, advertised by our own provider
|
|
24
|
+
* today. A live foot-gun, not a hypothetical.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, expect, it } from 'vitest';
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
EnvelopeParseError,
|
|
31
|
+
TranslationConfigError,
|
|
32
|
+
WalledRouteUnavailableError,
|
|
33
|
+
createTranslationService,
|
|
34
|
+
parseTranslationEnvelope,
|
|
35
|
+
resolveTranslationRoute,
|
|
36
|
+
type TranslationRoute,
|
|
37
|
+
} from '../index';
|
|
38
|
+
import { GOLDEN_SETS, pairIn, segmentsOf } from '../__fixtures__/golden-pairs';
|
|
39
|
+
import { capturingLogger, stubProvider } from './stub-provider';
|
|
40
|
+
|
|
41
|
+
/** One locale is enough here — nothing in this file is language-sensitive. */
|
|
42
|
+
const SET = GOLDEN_SETS[0]!;
|
|
43
|
+
|
|
44
|
+
function walledRoute(overrides: Partial<TranslationRoute> = {}): TranslationRoute {
|
|
45
|
+
return {
|
|
46
|
+
posture: 'walled',
|
|
47
|
+
provider: 'stub',
|
|
48
|
+
model: 'stub-model',
|
|
49
|
+
allowedModels: ['stub-model'],
|
|
50
|
+
fallback: false,
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function request() {
|
|
56
|
+
return {
|
|
57
|
+
segments: segmentsOf(SET),
|
|
58
|
+
sourceLocale: SET.sourceLocale,
|
|
59
|
+
targetLocale: SET.targetLocale,
|
|
60
|
+
glossary: SET.glossary,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('a malformed envelope', () => {
|
|
65
|
+
/**
|
|
66
|
+
* Prose wrapped around the client's own sentence — what a model actually
|
|
67
|
+
* returns when it ignores the JSON instruction. The client text is IN here on
|
|
68
|
+
* purpose: it is the needle the log assertion searches for.
|
|
69
|
+
*/
|
|
70
|
+
const CLIENT_SENTENCE = pairIn(SET, 'glossary-term').sourceText;
|
|
71
|
+
const GARBAGE = `Sure! Here is the translation of "${CLIENT_SENTENCE}" — I hope it helps.`;
|
|
72
|
+
|
|
73
|
+
it('is retried before the batch is failed', async () => {
|
|
74
|
+
const provider = stubProvider(GARBAGE);
|
|
75
|
+
const service = createTranslationService(walledRoute(), {
|
|
76
|
+
provider,
|
|
77
|
+
maxEnvelopeRetries: 2,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await expect(service.translate(request())).rejects.toThrow(EnvelopeParseError);
|
|
81
|
+
// One original attempt plus the permitted retries — a parse failure is
|
|
82
|
+
// worth asking again for, once, with a correction prompt.
|
|
83
|
+
expect(provider.calls.length).toBeGreaterThan(1);
|
|
84
|
+
expect(provider.calls.length).toBeLessThanOrEqual(3);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('puts NO response text and NO client text in any log', async () => {
|
|
88
|
+
const logger = capturingLogger();
|
|
89
|
+
const provider = stubProvider(GARBAGE);
|
|
90
|
+
const service = createTranslationService(walledRoute(), {
|
|
91
|
+
provider,
|
|
92
|
+
logger,
|
|
93
|
+
maxEnvelopeRetries: 1,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await expect(service.translate(request())).rejects.toThrow(EnvelopeParseError);
|
|
97
|
+
|
|
98
|
+
// Asserted against the FIXTURE TEXT rather than by inspecting call sites,
|
|
99
|
+
// which is the only version of this check that cannot rot.
|
|
100
|
+
const logged = logger.all();
|
|
101
|
+
expect(logged).not.toContain(CLIENT_SENTENCE);
|
|
102
|
+
expect(logged).not.toContain(GARBAGE);
|
|
103
|
+
for (const pair of SET.pairs) {
|
|
104
|
+
if (pair.sourceText) expect(logged).not.toContain(pair.sourceText);
|
|
105
|
+
if (pair.targetText) expect(logged).not.toContain(pair.targetText);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('carries no response text in the error it throws, either', async () => {
|
|
110
|
+
// An exception is a log line waiting to happen: it will be caught, printed,
|
|
111
|
+
// and shipped to an aggregator by code that has never read this file.
|
|
112
|
+
const provider = stubProvider(GARBAGE);
|
|
113
|
+
const service = createTranslationService(walledRoute(), {
|
|
114
|
+
provider,
|
|
115
|
+
maxEnvelopeRetries: 1,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await expect(service.translate(request())).rejects.toThrow(EnvelopeParseError);
|
|
119
|
+
await service.translate(request()).catch((error: unknown) => {
|
|
120
|
+
const text = `${(error as Error).message}\n${(error as Error).stack ?? ''}`;
|
|
121
|
+
expect(text).not.toContain(CLIENT_SENTENCE);
|
|
122
|
+
expect(text).not.toContain(GARBAGE);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('rejects unparseable JSON as malformed rather than guessing', () => {
|
|
127
|
+
const parsed = parseTranslationEnvelope(GARBAGE, ['plain']);
|
|
128
|
+
|
|
129
|
+
expect(parsed.ok).toBe(false);
|
|
130
|
+
if (parsed.ok) return;
|
|
131
|
+
expect(parsed.reason).toBe('malformed');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('the walled posture', () => {
|
|
136
|
+
it('FAILS when the configured provider is unavailable, rather than falling back', async () => {
|
|
137
|
+
const provider = stubProvider('{"segments":[]}', { available: false });
|
|
138
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
139
|
+
|
|
140
|
+
await expect(service.translate(request())).rejects.toThrow(
|
|
141
|
+
WalledRouteUnavailableError,
|
|
142
|
+
);
|
|
143
|
+
// It must not even have tried: the refusal is the correct outcome, and the
|
|
144
|
+
// job is retried later against the SAME provider.
|
|
145
|
+
expect(provider.calls).toHaveLength(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('refuses at BOOT to be configured with fallback enabled', () => {
|
|
149
|
+
// Posture is a property of the deployment and fallback is a property of the
|
|
150
|
+
// posture. `walled` + `fallback: true` is not a preference, it is a breach
|
|
151
|
+
// waiting for an outage.
|
|
152
|
+
expect(() =>
|
|
153
|
+
createTranslationService(walledRoute({ fallback: true }), {
|
|
154
|
+
provider: stubProvider('{}'),
|
|
155
|
+
}),
|
|
156
|
+
).toThrow(TranslationConfigError);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('surfaces a provider refusal as a failure, never as an empty document', async () => {
|
|
160
|
+
const provider = stubProvider('{}', {
|
|
161
|
+
throws: new Error('provider refused'),
|
|
162
|
+
});
|
|
163
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
164
|
+
|
|
165
|
+
await expect(service.translate(request())).rejects.toThrow();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('the boot-time model allow-list', () => {
|
|
170
|
+
it('refuses to build a service whose model is outside the allow-list', () => {
|
|
171
|
+
expect(() =>
|
|
172
|
+
createTranslationService(
|
|
173
|
+
walledRoute({ model: 'claude-fable-5', allowedModels: ['stub-model'] }),
|
|
174
|
+
{ provider: stubProvider('{}') },
|
|
175
|
+
),
|
|
176
|
+
).toThrow(TranslationConfigError);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('refuses a Covered Model on a walled deployment, by name', () => {
|
|
180
|
+
// Fail the boot, not the first client document.
|
|
181
|
+
expect(() =>
|
|
182
|
+
resolveTranslationRoute({
|
|
183
|
+
TRANSLATION_POSTURE: 'walled',
|
|
184
|
+
TRANSLATION_PROVIDER: 'anthropic',
|
|
185
|
+
TRANSLATION_MODEL: 'claude-fable-5',
|
|
186
|
+
}),
|
|
187
|
+
).toThrow(TranslationConfigError);
|
|
188
|
+
|
|
189
|
+
expect(() =>
|
|
190
|
+
resolveTranslationRoute({
|
|
191
|
+
TRANSLATION_POSTURE: 'walled',
|
|
192
|
+
TRANSLATION_PROVIDER: 'anthropic',
|
|
193
|
+
TRANSLATION_MODEL: 'claude-mythos-5',
|
|
194
|
+
}),
|
|
195
|
+
).toThrow(TranslationConfigError);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('resolves a walled route with fallback OFF, whatever the environment asks for', () => {
|
|
199
|
+
const route = resolveTranslationRoute({
|
|
200
|
+
TRANSLATION_POSTURE: 'walled',
|
|
201
|
+
TRANSLATION_PROVIDER: 'anthropic',
|
|
202
|
+
TRANSLATION_MODEL: 'claude-sonnet-5',
|
|
203
|
+
TRANSLATION_ALLOWED_MODELS: 'claude-sonnet-5',
|
|
204
|
+
// Asked for, and ignored: the posture decides.
|
|
205
|
+
TRANSLATION_FALLBACK: 'true',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(route.posture).toBe('walled');
|
|
209
|
+
expect(route.fallback).toBe(false);
|
|
210
|
+
expect(route.model).toBe('claude-sonnet-5');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('permits fallback on the open posture', () => {
|
|
214
|
+
// OGMC's marketing copy and MCR's artist metadata are not confidential in
|
|
215
|
+
// the sense the client's material is. Forcing the whole estate through the
|
|
216
|
+
// walled route would be cargo-culting.
|
|
217
|
+
const route = resolveTranslationRoute({
|
|
218
|
+
TRANSLATION_POSTURE: 'open',
|
|
219
|
+
TRANSLATION_PROVIDER: 'anthropic',
|
|
220
|
+
TRANSLATION_MODEL: 'claude-sonnet-5',
|
|
221
|
+
TRANSLATION_ALLOWED_MODELS: 'claude-sonnet-5',
|
|
222
|
+
TRANSLATION_FALLBACK: 'true',
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(route.posture).toBe('open');
|
|
226
|
+
expect(route.fallback).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PURPOSE-BUILT provider stub for the translation suite.
|
|
3
|
+
*
|
|
4
|
+
* `MockLLMProvider` is deliberately not used here, and `startsim-bfem` says why:
|
|
5
|
+
* its behaviour is set through a module-global mutable (`let mockBehavior`, set
|
|
6
|
+
* via `setMockBehavior()`) carrying a single `customResponse` string. That is
|
|
7
|
+
* fine for one-response tests and wrong for these, which need a DIFFERENT
|
|
8
|
+
* envelope per call and must not leak state between parallel test files.
|
|
9
|
+
*
|
|
10
|
+
* The engine takes an `ILLMProvider` by injection, so a local stub costs one
|
|
11
|
+
* file and buys isolation. That is the right trade.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ILLMProvider } from '../../providers/interface';
|
|
15
|
+
import type { GenerateOptions, LLMResponse } from '../../types';
|
|
16
|
+
import type { TranslationLogger } from '../types';
|
|
17
|
+
|
|
18
|
+
export interface RecordedCall {
|
|
19
|
+
userPrompt: string;
|
|
20
|
+
systemPrompt: string;
|
|
21
|
+
options?: GenerateOptions;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StubProvider extends ILLMProvider {
|
|
25
|
+
/** Every call, in order, so a test can assert the BATCH count and the prompt. */
|
|
26
|
+
readonly calls: RecordedCall[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StubProviderOptions {
|
|
30
|
+
name?: string;
|
|
31
|
+
model?: string;
|
|
32
|
+
/** `isAvailable()`. False models a provider that is down or unconfigured. */
|
|
33
|
+
available?: boolean;
|
|
34
|
+
/** Thrown instead of answering — a provider that refuses. */
|
|
35
|
+
throws?: Error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param script What to return, per call. A string array is consumed in order
|
|
40
|
+
* and the last entry repeats; a function receives the 0-based call index.
|
|
41
|
+
*/
|
|
42
|
+
export function stubProvider(
|
|
43
|
+
script: string | string[] | ((callIndex: number, call: RecordedCall) => string),
|
|
44
|
+
options: StubProviderOptions = {},
|
|
45
|
+
): StubProvider {
|
|
46
|
+
const {
|
|
47
|
+
name = 'stub',
|
|
48
|
+
model = 'stub-model',
|
|
49
|
+
available = true,
|
|
50
|
+
throws,
|
|
51
|
+
} = options;
|
|
52
|
+
const calls: RecordedCall[] = [];
|
|
53
|
+
|
|
54
|
+
const pick = (index: number, call: RecordedCall): string => {
|
|
55
|
+
if (typeof script === 'function') return script(index, call);
|
|
56
|
+
if (typeof script === 'string') return script;
|
|
57
|
+
return script[Math.min(index, script.length - 1)] ?? '';
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
name,
|
|
62
|
+
availableModels: [model],
|
|
63
|
+
defaultModel: model,
|
|
64
|
+
calls,
|
|
65
|
+
isAvailable: () => available,
|
|
66
|
+
async generate(
|
|
67
|
+
userPrompt: string,
|
|
68
|
+
systemPrompt: string,
|
|
69
|
+
generateOptions?: GenerateOptions,
|
|
70
|
+
): Promise<LLMResponse> {
|
|
71
|
+
const call: RecordedCall = { userPrompt, systemPrompt, options: generateOptions };
|
|
72
|
+
calls.push(call);
|
|
73
|
+
if (throws) throw throws;
|
|
74
|
+
return {
|
|
75
|
+
content: pick(calls.length - 1, call),
|
|
76
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
77
|
+
model,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CapturingLogger extends TranslationLogger {
|
|
84
|
+
/** Every line and every piece of metadata, flattened for substring search. */
|
|
85
|
+
readonly written: string[];
|
|
86
|
+
/** One string carrying everything logged, for a single `not.toContain`. */
|
|
87
|
+
all(): string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A logger that keeps everything, so "no client text reaches a log" can be
|
|
92
|
+
* ASSERTED against the fixture text rather than checked by reading call sites.
|
|
93
|
+
*
|
|
94
|
+
* The metadata is serialised into the same haystack on purpose: the leak this
|
|
95
|
+
* guards against is a raw provider response passed as a structured field, which
|
|
96
|
+
* is exactly what the Django precedent at `messages/services/llm.py` does.
|
|
97
|
+
*/
|
|
98
|
+
export function capturingLogger(): CapturingLogger {
|
|
99
|
+
const written: string[] = [];
|
|
100
|
+
const record = (level: string) => (message: string, meta?: Record<string, unknown>) => {
|
|
101
|
+
written.push(`${level} ${message}`);
|
|
102
|
+
if (meta !== undefined) {
|
|
103
|
+
try {
|
|
104
|
+
written.push(JSON.stringify(meta));
|
|
105
|
+
} catch {
|
|
106
|
+
written.push(String(meta));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
written,
|
|
112
|
+
all: () => written.join('\n'),
|
|
113
|
+
debug: record('debug'),
|
|
114
|
+
info: record('info'),
|
|
115
|
+
warn: record('warn'),
|
|
116
|
+
error: record('error'),
|
|
117
|
+
};
|
|
118
|
+
}
|