@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
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/llm",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.18",
|
|
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,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the OpenAI provider actually PUTS ON THE WIRE.
|
|
3
|
+
*
|
|
4
|
+
* The sibling of `anthropic-provider.test.ts`, and it exists for the same class
|
|
5
|
+
* of bug: a 400 caused by a parameter WE send, which no amount of response
|
|
6
|
+
* handling can recover from.
|
|
7
|
+
*
|
|
8
|
+
* The acceptance boundary below is not invented. It was measured against
|
|
9
|
+
* api.openai.com with a real key on 2026-08-19, while debugging why a live
|
|
10
|
+
* translation on marketing-agents failed:
|
|
11
|
+
*
|
|
12
|
+
* gpt-5.2 max_tokens -> 400 Unsupported parameter: 'max_tokens' is not
|
|
13
|
+
* supported with this model. Use
|
|
14
|
+
* 'max_completion_tokens' instead.
|
|
15
|
+
* gpt-5.2 max_completion_tokens -> 200
|
|
16
|
+
* gpt-4o max_tokens -> 200
|
|
17
|
+
*
|
|
18
|
+
* So the provider as written could never call ANY gpt-5 model: it always sent
|
|
19
|
+
* `max_tokens`. That is a total outage of the newest family, not an edge case.
|
|
20
|
+
*
|
|
21
|
+
* The second thing pinned here is the FAMILY TEST. bd startsim-768w.38.26 filed
|
|
22
|
+
* the literal-id deny-list (`model === 'gpt-5' || 'gpt-5.1' || 'gpt-5.2'`) as a
|
|
23
|
+
* bug, because it is "correct only until OpenAI ships gpt-5.3". These tests use
|
|
24
|
+
* an unreleased id on purpose, so the rule has to be about the FAMILY.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
27
|
+
|
|
28
|
+
import { OpenAIProvider } from '../providers/openai';
|
|
29
|
+
|
|
30
|
+
interface Sent {
|
|
31
|
+
model: string;
|
|
32
|
+
temperature?: number;
|
|
33
|
+
max_tokens?: number;
|
|
34
|
+
max_completion_tokens?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const OK_BODY = {
|
|
38
|
+
id: 'chatcmpl-1',
|
|
39
|
+
object: 'chat.completion',
|
|
40
|
+
created: 0,
|
|
41
|
+
model: 'server-reported-model',
|
|
42
|
+
choices: [{ index: 0, message: { role: 'assistant', content: '{"ok":true}' }, finish_reason: 'stop' }],
|
|
43
|
+
usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
let sent: Sent[];
|
|
47
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
48
|
+
|
|
49
|
+
function provider(defaultModel: string) {
|
|
50
|
+
return new OpenAIProvider({ apiKey: 'sk-test', defaultModel, defaultTemperature: 0.2 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
sent = [];
|
|
55
|
+
fetchMock = vi.fn(async (_url: unknown, init: RequestInit) => {
|
|
56
|
+
sent.push(JSON.parse(String(init.body)) as Sent);
|
|
57
|
+
return { ok: true, status: 200, json: async () => OK_BODY } as unknown as Response;
|
|
58
|
+
});
|
|
59
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
vi.unstubAllGlobals();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('the token-limit parameter', () => {
|
|
67
|
+
it.each(['gpt-5', 'gpt-5.1', 'gpt-5.2'])(
|
|
68
|
+
'%s gets max_completion_tokens and never max_tokens',
|
|
69
|
+
async (model) => {
|
|
70
|
+
await provider(model).generate('u', 's');
|
|
71
|
+
|
|
72
|
+
expect(sent[0]!.max_completion_tokens).toBeGreaterThan(0);
|
|
73
|
+
expect(sent[0]!.max_tokens).toBeUndefined();
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
it('an UNRELEASED gpt-5 model is handled by family, not by a hardcoded list', async () => {
|
|
78
|
+
// bd startsim-768w.38.26. The day this id ships, a literal deny-list sends
|
|
79
|
+
// it max_tokens and every call 400s — silently, from the caller's view.
|
|
80
|
+
await provider('gpt-5.3').generate('u', 's');
|
|
81
|
+
|
|
82
|
+
expect(sent[0]!.max_completion_tokens).toBeGreaterThan(0);
|
|
83
|
+
expect(sent[0]!.max_tokens).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('gpt-4o keeps max_tokens, which it accepts', async () => {
|
|
87
|
+
// The fix must not break the models that were working.
|
|
88
|
+
await provider('gpt-4o').generate('u', 's');
|
|
89
|
+
|
|
90
|
+
expect(sent[0]!.max_tokens).toBeGreaterThan(0);
|
|
91
|
+
expect(sent[0]!.max_completion_tokens).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('honours an explicit maxTokens on whichever parameter the model takes', async () => {
|
|
95
|
+
await provider('gpt-5.2').generate('u', 's', { maxTokens: 123 });
|
|
96
|
+
await provider('gpt-4o').generate('u', 's', { maxTokens: 123 });
|
|
97
|
+
|
|
98
|
+
expect(sent[0]!.max_completion_tokens).toBe(123);
|
|
99
|
+
expect(sent[1]!.max_tokens).toBe(123);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('temperature', () => {
|
|
104
|
+
it.each(['gpt-5', 'gpt-5.1', 'gpt-5.2', 'gpt-5.3'])(
|
|
105
|
+
'%s is sent temperature 1.0, whatever the deployment configured',
|
|
106
|
+
async (model) => {
|
|
107
|
+
// Base gpt-5 models reject any other value.
|
|
108
|
+
await provider(model).generate('u', 's', { temperature: 0.2 });
|
|
109
|
+
|
|
110
|
+
expect(sent[0]!.temperature).toBe(1.0);
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
it('a FINE-TUNED gpt-5 keeps the configured temperature', async () => {
|
|
115
|
+
// The existing carve-out, preserved: ft: models do accept it.
|
|
116
|
+
await provider('ft:gpt-5.2:acme::abc123').generate('u', 's', { temperature: 0.2 });
|
|
117
|
+
|
|
118
|
+
expect(sent[0]!.temperature).toBe(0.2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('gpt-4o keeps the configured temperature', async () => {
|
|
122
|
+
await provider('gpt-4o').generate('u', 's', { temperature: 0.2 });
|
|
123
|
+
|
|
124
|
+
expect(sent[0]!.temperature).toBe(0.2);
|
|
125
|
+
});
|
|
126
|
+
});
|
package/src/providers/openai.ts
CHANGED
|
@@ -31,10 +31,35 @@ interface OpenAIRequest {
|
|
|
31
31
|
model: string;
|
|
32
32
|
messages: OpenAIMessage[];
|
|
33
33
|
temperature?: number;
|
|
34
|
+
/** Pre-gpt-5 models. Rejected outright by the gpt-5 family. */
|
|
34
35
|
max_tokens?: number;
|
|
36
|
+
/** The gpt-5 family's replacement for `max_tokens`. */
|
|
37
|
+
max_completion_tokens?: number;
|
|
35
38
|
response_format?: { type: 'json_object' | 'text' };
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Is this the gpt-5 FAMILY? Matched by prefix, deliberately, not by a list of
|
|
43
|
+
* ids (bd startsim-768w.38.26 filed the literal-id deny-list as a bug: it is
|
|
44
|
+
* "correct only until OpenAI ships gpt-5.3", and the failure is a silent 400).
|
|
45
|
+
* `ft:gpt-5…` fine-tunes are still gpt-5 models on the wire.
|
|
46
|
+
*/
|
|
47
|
+
function isGpt5Family(model: string): boolean {
|
|
48
|
+
return /^gpt-5/.test(model.startsWith('ft:') ? model.slice(3) : model);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The token-limit parameter this model accepts. MEASURED against api.openai.com
|
|
53
|
+
* on 2026-08-19: gpt-5.2 rejects `max_tokens` with "Unsupported parameter ...
|
|
54
|
+
* Use 'max_completion_tokens' instead", while gpt-4o accepts `max_tokens`.
|
|
55
|
+
* Sending the wrong one is a total outage of that model, not a degraded call.
|
|
56
|
+
*/
|
|
57
|
+
function tokenLimitParam(model: string, maxTokens: number): Partial<OpenAIRequest> {
|
|
58
|
+
return isGpt5Family(model)
|
|
59
|
+
? { max_completion_tokens: maxTokens }
|
|
60
|
+
: { max_tokens: maxTokens };
|
|
61
|
+
}
|
|
62
|
+
|
|
38
63
|
interface OpenAIResponse {
|
|
39
64
|
id: string;
|
|
40
65
|
object: string;
|
|
@@ -149,9 +174,9 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
149
174
|
|
|
150
175
|
const model = options?.model ?? this.config.defaultModel;
|
|
151
176
|
// Base gpt-5 models require temperature=1.0, but fine-tuned models use configured temperature
|
|
152
|
-
const isBaseGpt5 = model === 'gpt-5' || model === 'gpt-5.1' || model === 'gpt-5.2';
|
|
153
177
|
const isFineTuned = model.startsWith('ft:');
|
|
154
|
-
const
|
|
178
|
+
const isBaseGpt5 = isGpt5Family(model) && !isFineTuned;
|
|
179
|
+
const temperature = isBaseGpt5
|
|
155
180
|
? 1.0
|
|
156
181
|
: (options?.temperature ?? this.config.defaultTemperature);
|
|
157
182
|
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
@@ -164,7 +189,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
164
189
|
{ role: 'user', content: userPrompt },
|
|
165
190
|
],
|
|
166
191
|
temperature,
|
|
167
|
-
|
|
192
|
+
...tokenLimitParam(model, maxTokens),
|
|
168
193
|
response_format: { type: 'json_object' },
|
|
169
194
|
};
|
|
170
195
|
|
|
@@ -264,9 +289,9 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
264
289
|
}
|
|
265
290
|
|
|
266
291
|
const model = options?.model ?? this.config.defaultModel;
|
|
267
|
-
const isBaseGpt5 = model === 'gpt-5' || model === 'gpt-5.1' || model === 'gpt-5.2';
|
|
268
292
|
const isFineTuned = model.startsWith('ft:');
|
|
269
|
-
const
|
|
293
|
+
const isBaseGpt5 = isGpt5Family(model) && !isFineTuned;
|
|
294
|
+
const temperature = isBaseGpt5
|
|
270
295
|
? 1.0
|
|
271
296
|
: (options?.temperature ?? this.config.defaultTemperature);
|
|
272
297
|
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
@@ -297,7 +322,7 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
297
322
|
model,
|
|
298
323
|
messages: oaiMessages,
|
|
299
324
|
temperature,
|
|
300
|
-
|
|
325
|
+
...tokenLimitParam(model, maxTokens),
|
|
301
326
|
tools: tools.map((t) => ({
|
|
302
327
|
type: 'function' as const,
|
|
303
328
|
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
@@ -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
|
+
}
|