@startsimpli/llm 0.4.16 → 0.4.18
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/__tests__/openai-provider.test.ts +126 -0
- package/src/providers/openai.ts +31 -6
- 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,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE BATCH AND ID-ECHO CONTRACT (bd startsim-ob4d, RED items 1–7, 13, 15).
|
|
3
|
+
*
|
|
4
|
+
* `startsim-yfot` states the contract and this file is its enforcement. The
|
|
5
|
+
* rules being pinned down, in the design's own terms:
|
|
6
|
+
*
|
|
7
|
+
* 1. Ids are OPAQUE and round-trip byte-for-byte.
|
|
8
|
+
* 2. The returned id set EQUALS the requested id set.
|
|
9
|
+
* 3. A MISSING id fails only its own segment, which is re-queued.
|
|
10
|
+
* 4. An UNKNOWN id fails the WHOLE batch. Never map positionally.
|
|
11
|
+
* 5. DUPLICATE ids fail the whole batch.
|
|
12
|
+
* 6. ORDER is not part of the contract — reassembly is by id.
|
|
13
|
+
* 7. EMPTY output for a present id is a failure, not an empty translation.
|
|
14
|
+
*
|
|
15
|
+
* Rules 4 and 5 are the ones that look like parse pedantry and are not. A model
|
|
16
|
+
* that invents an id has attributed text to a segment that did not ask for it,
|
|
17
|
+
* and in a product sold on "one client's material never appears against
|
|
18
|
+
* another's" that is a correctness alarm.
|
|
19
|
+
*
|
|
20
|
+
* EVERY CASE RUNS TWICE, over an RTL and an LTR target, from ONE body. That is
|
|
21
|
+
* `startsim-bfem`'s enforcement of `startsim-jb1z`'s naming ban: a test that
|
|
22
|
+
* needs to know which locale it is running has caught a leak.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, expect, it } from 'vitest';
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
createTranslationService,
|
|
29
|
+
parseTranslationEnvelope,
|
|
30
|
+
planBatches,
|
|
31
|
+
type TranslationRoute,
|
|
32
|
+
} from '../index';
|
|
33
|
+
import { DEFAULT_BATCH_LIMITS, type TranslationSegment } from '../types';
|
|
34
|
+
import {
|
|
35
|
+
GOLDEN_SETS,
|
|
36
|
+
envelopeFor,
|
|
37
|
+
pairIn,
|
|
38
|
+
segmentsOf,
|
|
39
|
+
type GoldenSet,
|
|
40
|
+
} from '../__fixtures__/golden-pairs';
|
|
41
|
+
import { stubProvider } from './stub-provider';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A route literal rather than `resolveTranslationRoute(...)`, so a failure in
|
|
45
|
+
* THIS file always means the batch contract is unimplemented — never that
|
|
46
|
+
* configuration parsing is. Routing has its own suite.
|
|
47
|
+
*/
|
|
48
|
+
function walledRoute(): TranslationRoute {
|
|
49
|
+
return {
|
|
50
|
+
posture: 'walled',
|
|
51
|
+
provider: 'stub',
|
|
52
|
+
model: 'stub-model',
|
|
53
|
+
allowedModels: ['stub-model'],
|
|
54
|
+
fallback: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requestFor(set: GoldenSet) {
|
|
59
|
+
return {
|
|
60
|
+
segments: segmentsOf(set),
|
|
61
|
+
sourceLocale: set.sourceLocale,
|
|
62
|
+
targetLocale: set.targetLocale,
|
|
63
|
+
glossary: set.glossary,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe.each(GOLDEN_SETS)(
|
|
68
|
+
'batch + id echo [$sourceLocale→$targetLocale]',
|
|
69
|
+
(set: GoldenSet) => {
|
|
70
|
+
const ids = set.pairs.map((pair) => pair.id);
|
|
71
|
+
|
|
72
|
+
it('packs every segment into exactly ONE provider call', async () => {
|
|
73
|
+
// The batching rule made enforceable. `packages/llm/README.md`: "Pack N
|
|
74
|
+
// items into one prompt with echoed ids — never loop".
|
|
75
|
+
const provider = stubProvider(envelopeFor(set));
|
|
76
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
77
|
+
|
|
78
|
+
const result = await service.translate(requestFor(set));
|
|
79
|
+
|
|
80
|
+
expect(provider.calls).toHaveLength(1);
|
|
81
|
+
expect(result.providerCalls).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns every requested id, byte-identical', async () => {
|
|
85
|
+
const provider = stubProvider(envelopeFor(set));
|
|
86
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
87
|
+
|
|
88
|
+
const result = await service.translate(requestFor(set));
|
|
89
|
+
|
|
90
|
+
// Set equality, not containment: no more, no fewer.
|
|
91
|
+
expect([...result.translated.map((s) => s.id)].sort()).toEqual([...ids].sort());
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('fails ONLY the missing segment and re-queues it individually', async () => {
|
|
95
|
+
const kept = ids.filter((id) => id !== 'glossary-term');
|
|
96
|
+
// First call omits one id; the re-queued single-segment call answers it.
|
|
97
|
+
const provider = stubProvider([
|
|
98
|
+
envelopeFor(set, kept),
|
|
99
|
+
envelopeFor(set, ['glossary-term']),
|
|
100
|
+
]);
|
|
101
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
102
|
+
|
|
103
|
+
const result = await service.translate(requestFor(set));
|
|
104
|
+
|
|
105
|
+
expect(result.requeued).toContain('glossary-term');
|
|
106
|
+
// One difficult sentence must not cost the document.
|
|
107
|
+
for (const id of kept) {
|
|
108
|
+
expect(result.translated.map((s) => s.id)).toContain(id);
|
|
109
|
+
}
|
|
110
|
+
expect(provider.calls.length).toBeGreaterThan(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('fails the WHOLE batch on an unknown id, and maps nothing positionally', async () => {
|
|
114
|
+
const forged = JSON.stringify({
|
|
115
|
+
segments: [
|
|
116
|
+
// Same COUNT and same ORDER as the request, so a positional mapper
|
|
117
|
+
// would sail straight through this and produce a plausible document.
|
|
118
|
+
...ids.slice(1).map((id) => ({ id, text: pairIn(set, id).targetText })),
|
|
119
|
+
{ id: 'id-the-caller-never-sent', text: pairIn(set, ids[0]!).targetText },
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
const provider = stubProvider(forged);
|
|
123
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
124
|
+
|
|
125
|
+
await expect(service.translate(requestFor(set))).rejects.toThrow(
|
|
126
|
+
/unknown_id/,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('fails the WHOLE batch on a duplicate id', async () => {
|
|
131
|
+
const duplicated = JSON.stringify({
|
|
132
|
+
segments: [
|
|
133
|
+
...ids.map((id) => ({ id, text: pairIn(set, id).targetText })),
|
|
134
|
+
{ id: ids[0], text: pairIn(set, ids[0]!).targetText },
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
const provider = stubProvider(duplicated);
|
|
138
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
139
|
+
|
|
140
|
+
await expect(service.translate(requestFor(set))).rejects.toThrow(
|
|
141
|
+
/duplicate_id/,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('treats empty text for a present id as a failure, not an empty translation', async () => {
|
|
146
|
+
// A blank string is how a silent truncation looks.
|
|
147
|
+
const blanked = JSON.stringify({
|
|
148
|
+
segments: ids.map((id) => ({
|
|
149
|
+
id,
|
|
150
|
+
text: id === 'plain' ? '' : pairIn(set, id).targetText,
|
|
151
|
+
})),
|
|
152
|
+
});
|
|
153
|
+
const provider = stubProvider(blanked);
|
|
154
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
155
|
+
|
|
156
|
+
const result = await service.translate(requestFor(set));
|
|
157
|
+
|
|
158
|
+
expect(result.translated.map((s) => s.id)).not.toContain('plain');
|
|
159
|
+
expect(result.failed).toContainEqual({ id: 'plain', reason: 'empty_text' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('reassembles correctly when the model returns the segments reordered', async () => {
|
|
163
|
+
// Order is NOT part of the contract — asserting it would be asserting
|
|
164
|
+
// model behaviour we do not control. Reassembly is by id.
|
|
165
|
+
const reversed = JSON.stringify({
|
|
166
|
+
segments: [...ids].reverse().map((id) => ({ id, text: pairIn(set, id).targetText })),
|
|
167
|
+
});
|
|
168
|
+
const provider = stubProvider(reversed);
|
|
169
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
170
|
+
|
|
171
|
+
const result = await service.translate(requestFor(set));
|
|
172
|
+
|
|
173
|
+
for (const pair of set.pairs) {
|
|
174
|
+
const got = result.translated.find((s) => s.id === pair.id);
|
|
175
|
+
expect(got?.targetText).toBe(pair.targetText);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('parses a well-formed envelope without inventing or dropping an id', () => {
|
|
180
|
+
// The parser on its own, so a batch failure can be localised to it.
|
|
181
|
+
const parsed = parseTranslationEnvelope(envelopeFor(set), ids);
|
|
182
|
+
|
|
183
|
+
expect(parsed.ok).toBe(true);
|
|
184
|
+
if (!parsed.ok) return;
|
|
185
|
+
expect(parsed.missing).toEqual([]);
|
|
186
|
+
expect(parsed.empty).toEqual([]);
|
|
187
|
+
expect(parsed.segments.map((s) => s.id).sort()).toEqual([...ids].sort());
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Batching is cut by SIZE, not by count, and the reason is written into the
|
|
194
|
+
* corpus package it is copied from: "a corpus that grew a transcript overnight
|
|
195
|
+
* turns yesterday's working slice into today's gateway timeout, and the only
|
|
196
|
+
* symptom is a 504 with no clue in it."
|
|
197
|
+
*
|
|
198
|
+
* Locale-independent by nature — these are character counts.
|
|
199
|
+
*/
|
|
200
|
+
describe('planBatches — cut by character budget', () => {
|
|
201
|
+
const seg = (id: string, chars: number): TranslationSegment => ({
|
|
202
|
+
id,
|
|
203
|
+
text: 'x'.repeat(chars),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('cuts a batch when the character budget is reached', () => {
|
|
207
|
+
const batches = planBatches(
|
|
208
|
+
[seg('a', 1_500), seg('b', 1_500), seg('c', 1_500)],
|
|
209
|
+
{ maxCharsPerBatch: 3_200, maxSegmentsPerBatch: 25 },
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect(batches.length).toBeGreaterThan(1);
|
|
213
|
+
expect(batches[0]!.map((s) => s.id)).toEqual(['a', 'b']);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('always includes at least one segment, even when it alone exceeds the budget', () => {
|
|
217
|
+
// Otherwise an oversized segment is skipped forever — `takeWithinBudget`'s
|
|
218
|
+
// rule, for exactly the same reason.
|
|
219
|
+
const batches = planBatches([seg('huge', 50_000), seg('small', 10)], {
|
|
220
|
+
maxCharsPerBatch: 4_000,
|
|
221
|
+
maxSegmentsPerBatch: 25,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(batches[0]).toHaveLength(1);
|
|
225
|
+
expect(batches[0]![0]!.id).toBe('huge');
|
|
226
|
+
expect(batches.flat().map((s) => s.id)).toContain('small');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('honours the hard segment ceiling so an id list cannot dominate the call', () => {
|
|
230
|
+
// A page of one-line table cells must not become a 300-item echo list.
|
|
231
|
+
const many = Array.from({ length: 60 }, (_, i) => seg(`cell-${i}`, 5));
|
|
232
|
+
|
|
233
|
+
const batches = planBatches(many, { maxCharsPerBatch: 4_000, maxSegmentsPerBatch: 25 });
|
|
234
|
+
|
|
235
|
+
for (const batch of batches) expect(batch.length).toBeLessThanOrEqual(25);
|
|
236
|
+
// Nothing is lost in the cutting.
|
|
237
|
+
expect(batches.flat()).toHaveLength(60);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('loses no segment and duplicates none, whatever the budget', () => {
|
|
241
|
+
const segments = Array.from({ length: 37 }, (_, i) => seg(`s-${i}`, 200));
|
|
242
|
+
|
|
243
|
+
const flat = planBatches(segments, DEFAULT_BATCH_LIMITS).flat();
|
|
244
|
+
|
|
245
|
+
expect(flat.map((s) => s.id)).toEqual(segments.map((s) => s.id));
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -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
|
+
});
|