@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
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/llm",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.17",
|
|
4
4
|
"description": "Generic LLM service infrastructure — provider abstraction, retry logic, JSON extraction",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./src/index.ts"
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./translation": "./src/translation/index.ts"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"src",
|
|
@@ -15,7 +16,8 @@
|
|
|
15
16
|
"access": "public"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
18
|
-
"zod": "^4.3.6"
|
|
19
|
+
"zod": "^4.3.6",
|
|
20
|
+
"@startsimpli/i18n": "^0.1.3"
|
|
19
21
|
},
|
|
20
22
|
"devDependencies": {
|
|
21
23
|
"@types/node": "^20.19.39",
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SYNTHETIC GOLDEN SET (bd startsim-bfem, decision 1).
|
|
3
|
+
*
|
|
4
|
+
* ────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* READ THIS BEFORE TRUSTING A TARGET STRING BELOW.
|
|
6
|
+
*
|
|
7
|
+
* Every `targetText` here is CONSTRUCTED, not translated. Nobody on our side
|
|
8
|
+
* reads Arabic or Chinese, so authoring convincing prose in either was never on
|
|
9
|
+
* the table — and pretending otherwise would put unreviewable text in the repo
|
|
10
|
+
* that a future reader mistakes for a reference.
|
|
11
|
+
*
|
|
12
|
+
* What these strings ARE: scaffolds built so the mechanical checks have
|
|
13
|
+
* something TRUE to assert. Each one carries, verbatim, the things a check
|
|
14
|
+
* looks for — the approved glossary term, the do-not-translate brand, every
|
|
15
|
+
* number and date — wrapped in filler that is honestly marked as filler
|
|
16
|
+
* (`نص تجريبي` / `示例文本`, both meaning roughly "sample text"). They are not
|
|
17
|
+
* fluent, they are not idiomatic, and no conclusion about translation QUALITY
|
|
18
|
+
* may be drawn from them. They pin down INVARIANTS, which is all the design
|
|
19
|
+
* permits a test to assert anyway (`startsim-bfem`, decision 2: assert
|
|
20
|
+
* relations between input and output, never the output itself).
|
|
21
|
+
*
|
|
22
|
+
* `provenance: 'reviewed'` is reserved for the two honest pairs — one per
|
|
23
|
+
* locale — that a named target-language reviewer contributes once. There are
|
|
24
|
+
* none yet: that is the `startsim-r9q8` blocker, and it is a people problem.
|
|
25
|
+
* Until then every pair here reads `constructed`, and a reader who cannot read
|
|
26
|
+
* the language can tell the difference by looking at one field.
|
|
27
|
+
* ────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* THE REAL SET LIVES OUTSIDE THIS REPOSITORY — 200 stratified segments in the
|
|
30
|
+
* client's own tenant, per `startsim-r9q8`. It never enters git. This set is
|
|
31
|
+
* its proxy and SHARES ITS SHAPE on purpose: the same assertions must run over
|
|
32
|
+
* either, or the synthetic set stops being a proxy and becomes a second thing
|
|
33
|
+
* to maintain.
|
|
34
|
+
*
|
|
35
|
+
* TWO LOCALES, ONE RTL AND ONE LTR, AND THAT IS THE ENFORCEMENT MECHANISM.
|
|
36
|
+
* `startsim-jb1z` bans naming a language in the engine. A test that needs to
|
|
37
|
+
* know which locale it is running has caught a leak — so every suite here is
|
|
38
|
+
* driven by `describe.each(GOLDEN_SETS)` over one shared body. EN→ZH is not
|
|
39
|
+
* decoration either: OGMC needs it, and it is the pair whose reviewer already
|
|
40
|
+
* exists.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { GlossaryTerm, Locale, TranslationSegment } from '../types';
|
|
44
|
+
|
|
45
|
+
/** How a target string came to exist. See the header. */
|
|
46
|
+
export type PairProvenance = 'constructed' | 'reviewed';
|
|
47
|
+
|
|
48
|
+
export interface GoldenPair {
|
|
49
|
+
/** Stable across locales, so one test body can ask both sets for the same case. */
|
|
50
|
+
id: string;
|
|
51
|
+
/** What this pair exists to exercise. */
|
|
52
|
+
covers: string;
|
|
53
|
+
sourceText: string;
|
|
54
|
+
targetText: string;
|
|
55
|
+
provenance: PairProvenance;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GoldenSet {
|
|
59
|
+
sourceLocale: Locale;
|
|
60
|
+
targetLocale: Locale;
|
|
61
|
+
/**
|
|
62
|
+
* Present so a TEST can label its own output, never so the ENGINE can branch.
|
|
63
|
+
* Nothing in `packages/llm/src/translation/` may read this field — the engine
|
|
64
|
+
* takes direction from `dirFor()` at render time or not at all.
|
|
65
|
+
*/
|
|
66
|
+
rtl: boolean;
|
|
67
|
+
glossary: GlossaryTerm[];
|
|
68
|
+
pairs: GoldenPair[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The source side is shared: the same English drives both target locales. */
|
|
72
|
+
const SOURCE_LOCALE = 'en';
|
|
73
|
+
|
|
74
|
+
const SOURCES = {
|
|
75
|
+
plain: 'The quarterly report is ready for your review.',
|
|
76
|
+
glossaryTerm: 'Please review the settlement agreement before Friday.',
|
|
77
|
+
doNotTranslate: 'Contact Northwind Logistics about the delay.',
|
|
78
|
+
numerics: 'Revenue grew 12.5% to $4,200,000 on 2026-03-31.',
|
|
79
|
+
numberOnly: '2,750',
|
|
80
|
+
empty: '',
|
|
81
|
+
quoteAndNewline: 'She said "we accept the terms".\nThe contract is enclosed.',
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
/** The one brand string that must survive verbatim into every target. */
|
|
85
|
+
export const DNT_BRAND = 'Northwind Logistics';
|
|
86
|
+
|
|
87
|
+
/** The numerics the `numerics` pair must round-trip, as a multiset. */
|
|
88
|
+
export const NUMERICS_IN_FIXTURE = ['12.5%', '$4,200,000', '2026-03-31'] as const;
|
|
89
|
+
|
|
90
|
+
const AR: GoldenSet = {
|
|
91
|
+
sourceLocale: SOURCE_LOCALE,
|
|
92
|
+
targetLocale: 'ar',
|
|
93
|
+
rtl: true,
|
|
94
|
+
glossary: [
|
|
95
|
+
{ source: 'settlement agreement', target: 'اتفاقية التسوية', binding: true },
|
|
96
|
+
{ source: DNT_BRAND, target: DNT_BRAND, binding: true, doNotTranslate: true },
|
|
97
|
+
],
|
|
98
|
+
pairs: [
|
|
99
|
+
{
|
|
100
|
+
id: 'plain',
|
|
101
|
+
covers: 'a plain sentence with nothing special in it',
|
|
102
|
+
sourceText: SOURCES.plain,
|
|
103
|
+
targetText: 'نص تجريبي: التقرير ربع السنوي جاهز للمراجعة.',
|
|
104
|
+
provenance: 'constructed',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'glossary-term',
|
|
108
|
+
covers: 'a binding glossary term that must appear in its approved form',
|
|
109
|
+
sourceText: SOURCES.glossaryTerm,
|
|
110
|
+
targetText: 'نص تجريبي: يرجى مراجعة اتفاقية التسوية قبل يوم الجمعة.',
|
|
111
|
+
provenance: 'constructed',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'do-not-translate',
|
|
115
|
+
covers: 'a brand name that must survive verbatim',
|
|
116
|
+
sourceText: SOURCES.doNotTranslate,
|
|
117
|
+
targetText: `نص تجريبي: يرجى الاتصال بـ ${DNT_BRAND} بشأن التأخير.`,
|
|
118
|
+
provenance: 'constructed',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'numerics',
|
|
122
|
+
covers: 'a percentage, a currency amount and an ISO date',
|
|
123
|
+
sourceText: SOURCES.numerics,
|
|
124
|
+
targetText: 'نص تجريبي: نمت الإيرادات 12.5% إلى $4,200,000 في 2026-03-31.',
|
|
125
|
+
provenance: 'constructed',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'number-only',
|
|
129
|
+
covers: 'a segment that is nothing but a number',
|
|
130
|
+
sourceText: SOURCES.numberOnly,
|
|
131
|
+
targetText: '2,750',
|
|
132
|
+
provenance: 'constructed',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 'empty',
|
|
136
|
+
covers: 'an empty segment — the caller handed us nothing',
|
|
137
|
+
sourceText: SOURCES.empty,
|
|
138
|
+
targetText: '',
|
|
139
|
+
provenance: 'constructed',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'quote-and-newline',
|
|
143
|
+
covers: 'an embedded quotation mark and a newline — the XML-block escaping case',
|
|
144
|
+
sourceText: SOURCES.quoteAndNewline,
|
|
145
|
+
targetText: 'نص تجريبي: قالت «نقبل الشروط».\nالعقد مرفق.',
|
|
146
|
+
provenance: 'constructed',
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const ZH: GoldenSet = {
|
|
152
|
+
sourceLocale: SOURCE_LOCALE,
|
|
153
|
+
targetLocale: 'zh',
|
|
154
|
+
rtl: false,
|
|
155
|
+
glossary: [
|
|
156
|
+
{ source: 'settlement agreement', target: '和解协议', binding: true },
|
|
157
|
+
{ source: DNT_BRAND, target: DNT_BRAND, binding: true, doNotTranslate: true },
|
|
158
|
+
],
|
|
159
|
+
pairs: [
|
|
160
|
+
{
|
|
161
|
+
id: 'plain',
|
|
162
|
+
covers: 'a plain sentence with nothing special in it',
|
|
163
|
+
sourceText: SOURCES.plain,
|
|
164
|
+
targetText: '示例文本:季度报告已可供审阅。',
|
|
165
|
+
provenance: 'constructed',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: 'glossary-term',
|
|
169
|
+
covers: 'a binding glossary term that must appear in its approved form',
|
|
170
|
+
sourceText: SOURCES.glossaryTerm,
|
|
171
|
+
targetText: '示例文本:请在星期五之前审阅和解协议。',
|
|
172
|
+
provenance: 'constructed',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'do-not-translate',
|
|
176
|
+
covers: 'a brand name that must survive verbatim',
|
|
177
|
+
sourceText: SOURCES.doNotTranslate,
|
|
178
|
+
targetText: `示例文本:请联系 ${DNT_BRAND} 了解延误情况。`,
|
|
179
|
+
provenance: 'constructed',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: 'numerics',
|
|
183
|
+
covers: 'a percentage, a currency amount and an ISO date',
|
|
184
|
+
sourceText: SOURCES.numerics,
|
|
185
|
+
targetText: '示例文本:收入增长 12.5% 至 $4,200,000,日期为 2026-03-31。',
|
|
186
|
+
provenance: 'constructed',
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: 'number-only',
|
|
190
|
+
covers: 'a segment that is nothing but a number',
|
|
191
|
+
sourceText: SOURCES.numberOnly,
|
|
192
|
+
targetText: '2,750',
|
|
193
|
+
provenance: 'constructed',
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: 'empty',
|
|
197
|
+
covers: 'an empty segment — the caller handed us nothing',
|
|
198
|
+
sourceText: SOURCES.empty,
|
|
199
|
+
targetText: '',
|
|
200
|
+
provenance: 'constructed',
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: 'quote-and-newline',
|
|
204
|
+
covers: 'an embedded quotation mark and a newline — the XML-block escaping case',
|
|
205
|
+
sourceText: SOURCES.quoteAndNewline,
|
|
206
|
+
targetText: '示例文本:她说“我们接受这些条款”。\n合同已附上。',
|
|
207
|
+
provenance: 'constructed',
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* THE LOADER. One entry point, shared with the real set — the point of the
|
|
214
|
+
* split is that the same assertions run over either.
|
|
215
|
+
*/
|
|
216
|
+
export const GOLDEN_SETS: readonly GoldenSet[] = [AR, ZH];
|
|
217
|
+
|
|
218
|
+
export function goldenSet(targetLocale: Locale): GoldenSet {
|
|
219
|
+
const found = GOLDEN_SETS.find((set) => set.targetLocale === targetLocale);
|
|
220
|
+
if (!found) throw new Error(`no golden set for target locale ${targetLocale}`);
|
|
221
|
+
return found;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function pairIn(set: GoldenSet, id: string): GoldenPair {
|
|
225
|
+
const found = set.pairs.find((pair) => pair.id === id);
|
|
226
|
+
if (!found) throw new Error(`no golden pair '${id}' in ${set.targetLocale}`);
|
|
227
|
+
return found;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The set as translation input — ids are the pair ids, which are opaque here. */
|
|
231
|
+
export function segmentsOf(set: GoldenSet): TranslationSegment[] {
|
|
232
|
+
return set.pairs.map((pair) => ({ id: pair.id, text: pair.sourceText }));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A well-formed provider response for `set`, as the engine should receive it. */
|
|
236
|
+
export function envelopeFor(
|
|
237
|
+
set: GoldenSet,
|
|
238
|
+
ids: readonly string[] = set.pairs.map((pair) => pair.id),
|
|
239
|
+
): string {
|
|
240
|
+
return JSON.stringify({
|
|
241
|
+
segments: ids.map((id) => ({ id, text: pairIn(set, id).targetText })),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
@@ -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
|
+
});
|