@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 CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@startsimpli/llm",
3
- "version": "0.4.16",
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
+ });