@gobing-ai/ts-ai-runner 0.5.30 → 0.5.33

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.
@@ -0,0 +1,454 @@
1
+ /**
2
+ * The engine-agnostic sampled-driver core (task 0114 R1, ADR-038): prompt
3
+ * rendering, per-question enum options, answer parsing and frequency
4
+ * estimation, shared by every backend that answers by drawing k samples from a
5
+ * model. A backend supplies only the per-ask session hook.
6
+ *
7
+ * Pure by contract (R2): imports only the sibling `types` and `errors` modules —
8
+ * no SDK, no workspace package, no platform API. That is what lets a driver
9
+ * package outside `ts-ai-runner` build on it without a cycle.
10
+ */
11
+
12
+ import { DecisionBackendError, DecisionRequestError } from './errors';
13
+ import type {
14
+ Answer,
15
+ ChoiceAnswer,
16
+ DecisionDriver,
17
+ DecisionState,
18
+ Desc,
19
+ NoulAnswer,
20
+ Question,
21
+ ScoreAnswer,
22
+ } from './types';
23
+
24
+ /**
25
+ * Fixed instructions text. It never asks for confidence, certainty or a
26
+ * probability — self-reported confidence would be fabricated calibration, which
27
+ * the decision contract forbids (ADR-030).
28
+ */
29
+ export const DECISION_INSTRUCTIONS =
30
+ 'You are a decision engine. Answer every question below using only the supplied state. ' +
31
+ 'For each question choose exactly one of the listed options. ' +
32
+ 'Reply with the JSON object the schema requires.';
33
+
34
+ /** The constrained-output schema over the whole question map (fm passes it to `fm respond --schema`). */
35
+ export interface EnumSchema {
36
+ type: 'object';
37
+ title: string;
38
+ 'x-order': string[];
39
+ required: string[];
40
+ additionalProperties: false;
41
+ properties: Record<string, { type: 'string'; enum: string[] }>;
42
+ }
43
+
44
+ /** One ask's sampling session; opened once per ask and closed in `finally`. */
45
+ export interface SampleSession {
46
+ /** Return one sample as a key → option-string record (parsed, not yet enum-checked). */
47
+ sample(): Promise<Record<string, string>>;
48
+ /** Release per-ask resources (temp schema file, scratch dir). Called on success and failure. */
49
+ close?(): void | Promise<void>;
50
+ }
51
+
52
+ /** Configuration for {@link createSampledDriver}. */
53
+ export interface SampledDriverOptions {
54
+ /** Driver name reported through `DecisionDriver.name`. */
55
+ readonly name: string;
56
+ /** Samples per ask. Default: 5. Ignored when `deterministic`. Positive integer. */
57
+ readonly samples?: number;
58
+ /** One sample; the sampler is expected to decode greedily. Default: false. */
59
+ readonly deterministic?: boolean;
60
+ /** Max samples in flight. Default: 1 (sequential). Positive integer. */
61
+ readonly concurrency?: number;
62
+ /** Prefix used in enum-violation messages. Default: `name` (fm passes `'fm'`). */
63
+ readonly sampleLabel?: string;
64
+ /** Open the per-ask session. Receives the rendered texts and the validated request. */
65
+ open(ctx: {
66
+ instructions: string;
67
+ prompt: string;
68
+ questions: Record<string, Question>;
69
+ state: DecisionState;
70
+ model?: string;
71
+ }): Promise<SampleSession>;
72
+ /** Override the default {@link DECISION_INSTRUCTIONS} text. */
73
+ readonly instructions?: string;
74
+ }
75
+
76
+ /** A {@link DecisionDriver} built on sample frequencies, with the estimator it declares (ADR-030). */
77
+ export interface SampledDecisionDriver extends DecisionDriver {
78
+ readonly estimator: { readonly kind: 'sample-frequency'; readonly samples: number; readonly greedy: boolean };
79
+ }
80
+
81
+ /** Build the enum schema over the whole question map: one call answers every key. */
82
+ export function buildEnumSchema(questions: Record<string, Question>): EnumSchema {
83
+ const properties: Record<string, { type: 'string'; enum: string[] }> = {};
84
+ for (const [key, question] of Object.entries(questions)) {
85
+ switch (question.kind) {
86
+ case 'choice':
87
+ properties[key] = { type: 'string', enum: Object.keys(question.labels) };
88
+ break;
89
+ case 'score':
90
+ properties[key] = { type: 'string', enum: question.rubric.map((_, level) => String(level)) };
91
+ break;
92
+ case 'noul':
93
+ properties[key] = { type: 'string', enum: ['yes', 'no'] };
94
+ break;
95
+ }
96
+ }
97
+ const keys = Object.keys(questions);
98
+ return {
99
+ type: 'object',
100
+ title: 'Decision',
101
+ 'x-order': keys,
102
+ required: keys,
103
+ additionalProperties: false,
104
+ properties,
105
+ };
106
+ }
107
+
108
+ /** Format a {@link Desc} into prompt text: strings verbatim, structured values JSON, absence empty. */
109
+ export function formatDesc(desc: Desc | undefined): string {
110
+ if (typeof desc === 'string') return desc;
111
+ if (desc === null || desc === undefined) return '';
112
+ return JSON.stringify(desc);
113
+ }
114
+
115
+ /** The declared option values of a question, in declaration order (estimation + validation share this). */
116
+ export function declaredOptions(question: Question): string[] {
117
+ switch (question.kind) {
118
+ case 'choice':
119
+ return Object.keys(question.labels);
120
+ case 'score':
121
+ return question.rubric.map((_, level) => String(level));
122
+ case 'noul':
123
+ return ['yes', 'no'];
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Render the positional prompt: the state as text (JSON when structured), then
129
+ * one block per question key with the prompt `Desc` and its options with
130
+ * descriptions — choice label keys, score level indices with the rubric text,
131
+ * noul `yes`/`no` with any outcome descriptions.
132
+ */
133
+ export function buildPrompt(state: DecisionState, questions: Record<string, Question>): string {
134
+ const parts: string[] = [];
135
+ const stateText =
136
+ typeof state === 'string' ? state : state === null || state === undefined ? '' : JSON.stringify(state);
137
+ if (stateText.length > 0) parts.push(stateText);
138
+ for (const [key, question] of Object.entries(questions)) {
139
+ const lines: string[] = [];
140
+ const prompt = formatDesc(question.prompt);
141
+ lines.push(`Question "${key}":${prompt.length > 0 ? ` ${prompt}` : ''}`);
142
+ lines.push('Options:');
143
+ switch (question.kind) {
144
+ case 'choice':
145
+ for (const [label, desc] of Object.entries(question.labels)) {
146
+ const d = formatDesc(desc);
147
+ lines.push(d.length > 0 ? `- ${label}: ${d}` : `- ${label}`);
148
+ }
149
+ break;
150
+ case 'score':
151
+ question.rubric.forEach((desc, level) => {
152
+ const d = formatDesc(desc);
153
+ lines.push(d.length > 0 ? `- ${level}: ${d}` : `- ${level}`);
154
+ });
155
+ break;
156
+ case 'noul': {
157
+ for (const outcome of ['yes', 'no'] as const) {
158
+ const d = formatDesc(question[outcome]);
159
+ lines.push(d.length > 0 ? `- ${outcome}: ${d}` : `- ${outcome}`);
160
+ }
161
+ break;
162
+ }
163
+ }
164
+ parts.push(lines.join('\n'));
165
+ }
166
+ return parts.join('\n\n');
167
+ }
168
+
169
+ /**
170
+ * Laya reference `confidence_from_probs` on the empirical distribution:
171
+ * `1 − H(p) / ln(n)` over the n declared options. One-hot ⇒ 1, uniform ⇒ 0.
172
+ */
173
+ export function entropyConfidence(probabilities: number[]): number {
174
+ const n = probabilities.length;
175
+ if (n <= 1) return 1;
176
+ let entropy = 0;
177
+ for (const p of probabilities) {
178
+ if (p > 0) entropy -= p * Math.log(p);
179
+ }
180
+ return 1 - entropy / Math.log(n);
181
+ }
182
+
183
+ /**
184
+ * Choice frequencies over every supplied label (unsampled ⇒ 0). The answer
185
+ * label is the most frequent with ties broken by label declaration order.
186
+ */
187
+ export function estimateChoice(labels: readonly string[], samples: readonly string[]): ChoiceAnswer<string> {
188
+ const counts = new Map<string, number>(labels.map((label) => [label, 0]));
189
+ for (const sample of samples) counts.set(sample, (counts.get(sample) ?? 0) + 1);
190
+ const k = samples.length;
191
+ const probabilities: Record<string, number> = {};
192
+ const distribution: number[] = [];
193
+ let bestLabel = labels[0] ?? '';
194
+ let bestCount = -1;
195
+ for (const label of labels) {
196
+ const count = counts.get(label) ?? 0;
197
+ const probability = count / k;
198
+ distribution.push(probability);
199
+ probabilities[label] = probability;
200
+ // Declaration order wins ties: strict `>` keeps the earlier label.
201
+ if (count > bestCount) {
202
+ bestCount = count;
203
+ bestLabel = label;
204
+ }
205
+ }
206
+ return {
207
+ kind: 'choice',
208
+ label: bestLabel,
209
+ confidence: entropyConfidence(distribution),
210
+ probabilities,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Score frequencies over the level indices `0…n-1`. The answer score is the
216
+ * most frequent level with ties going to the lower level; the legend maps each
217
+ * index back to its rubric description.
218
+ */
219
+ export function estimateScore(rubric: readonly Desc[], samples: readonly string[]): ScoreAnswer {
220
+ const k = samples.length;
221
+ const counts: number[] = [];
222
+ for (const sample of samples) {
223
+ const level = Number.parseInt(sample, 10);
224
+ counts[level] = (counts[level] ?? 0) + 1;
225
+ }
226
+ const probabilities: Record<number, number> = {};
227
+ const distribution: number[] = [];
228
+ let bestLevel = 0;
229
+ let bestCount = -1;
230
+ for (let level = 0; level < rubric.length; level++) {
231
+ const count = counts[level] ?? 0;
232
+ const probability = count / k;
233
+ distribution.push(probability);
234
+ probabilities[level] = probability;
235
+ // Lower level wins ties: strict `>` keeps the smaller index.
236
+ if (count > bestCount) {
237
+ bestCount = count;
238
+ bestLevel = level;
239
+ }
240
+ }
241
+ const legend: Record<number, Desc> = {};
242
+ rubric.forEach((desc, level) => {
243
+ legend[level] = desc;
244
+ });
245
+ return {
246
+ kind: 'score',
247
+ score: bestLevel,
248
+ confidence: entropyConfidence(distribution),
249
+ legend,
250
+ probabilities,
251
+ };
252
+ }
253
+
254
+ /** Noul is a bare yes-probability — no `confidence` key, by contract. */
255
+ export function estimateNoul(samples: readonly string[]): NoulAnswer {
256
+ const yes = samples.filter((sample) => sample === 'yes').length;
257
+ return { kind: 'noul', probability: yes / samples.length };
258
+ }
259
+
260
+ /** A parsed value outside the declared enum is a schema violation, never an answer. */
261
+ export function requireEnumValue(
262
+ parsed: Record<string, string>,
263
+ key: string,
264
+ allowed: readonly string[],
265
+ label = 'sample',
266
+ ): string {
267
+ const value = parsed[key];
268
+ if (value === undefined || !allowed.includes(value)) {
269
+ throw new DecisionBackendError(
270
+ `${label} sample for '${key}' violates the schema enum: ${value === undefined ? '(missing)' : `'${value}'`}`,
271
+ undefined,
272
+ );
273
+ }
274
+ return value;
275
+ }
276
+
277
+ /** Parse text as JSON, reporting failure instead of throwing. */
278
+ function tryParse(text: string): { ok: true; value: unknown } | { ok: false } {
279
+ try {
280
+ return { ok: true, value: JSON.parse(text) };
281
+ } catch {
282
+ return { ok: false };
283
+ }
284
+ }
285
+
286
+ /**
287
+ * The first balanced `{…}` substring, scanned with a string/escape-aware brace
288
+ * counter so braces inside string values do not close the object early.
289
+ */
290
+ function firstBalancedObject(text: string): unknown {
291
+ for (let start = text.indexOf('{'); start !== -1; start = text.indexOf('{', start + 1)) {
292
+ let depth = 0;
293
+ let inString = false;
294
+ let escaped = false;
295
+ for (let i = start; i < text.length; i++) {
296
+ const ch = text[i];
297
+ if (inString) {
298
+ if (escaped) escaped = false;
299
+ else if (ch === '\\') escaped = true;
300
+ else if (ch === '"') inString = false;
301
+ continue;
302
+ }
303
+ if (ch === '"') {
304
+ inString = true;
305
+ } else if (ch === '{') {
306
+ depth += 1;
307
+ } else if (ch === '}') {
308
+ depth -= 1;
309
+ if (depth === 0) {
310
+ const parsed = tryParse(text.slice(start, i + 1));
311
+ if (parsed.ok) return parsed.value;
312
+ break;
313
+ }
314
+ }
315
+ }
316
+ }
317
+ return undefined;
318
+ }
319
+
320
+ /**
321
+ * Lenient answer parser for models without constrained decoding (task 0114 R6).
322
+ * Tries the whole text, then the first fenced block, then the first balanced
323
+ * object in prose. The result must be a flat object of string values.
324
+ */
325
+ export function extractJsonAnswer(text: string): Record<string, string> {
326
+ const direct = tryParse(text.trim());
327
+ let value: unknown = direct.ok ? direct.value : undefined;
328
+
329
+ if (value === undefined) {
330
+ const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
331
+ if (fence !== null) {
332
+ const fenced = tryParse((fence[1] ?? '').trim());
333
+ if (fenced.ok) value = fenced.value;
334
+ }
335
+ }
336
+ if (value === undefined) value = firstBalancedObject(text);
337
+ if (value === undefined) {
338
+ throw new DecisionBackendError('no JSON object in sample output', undefined);
339
+ }
340
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
341
+ throw new DecisionBackendError(`sample output is not a JSON object: ${JSON.stringify(value)}`, undefined);
342
+ }
343
+ const record: Record<string, string> = {};
344
+ for (const [key, entry] of Object.entries(value)) {
345
+ if (typeof entry !== 'string') {
346
+ throw new DecisionBackendError(`sample output has a non-string value for '${key}'`, undefined);
347
+ }
348
+ record[key] = entry;
349
+ }
350
+ return record;
351
+ }
352
+
353
+ /**
354
+ * Build a sampled driver: k draws through one per-ask {@link SampleSession},
355
+ * enum-checked against the declared options and reduced to sample frequencies.
356
+ *
357
+ * The order inside `ask` is the contract (R5): validate → render → open →
358
+ * sample → enum-check → estimate, with `close` in `finally` either way.
359
+ */
360
+ export function createSampledDriver(options: SampledDriverOptions): SampledDecisionDriver {
361
+ const { name, deterministic = false, sampleLabel } = options;
362
+ const samples = options.samples ?? 5;
363
+ const concurrency = options.concurrency ?? 1;
364
+
365
+ if (!Number.isInteger(samples) || samples < 1) {
366
+ throw new DecisionRequestError(`samples must be a positive integer; got ${samples}`, undefined, undefined);
367
+ }
368
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
369
+ throw new DecisionRequestError(
370
+ `concurrency must be a positive integer; got ${concurrency}`,
371
+ undefined,
372
+ undefined,
373
+ );
374
+ }
375
+ const k = deterministic ? 1 : samples;
376
+ const label = sampleLabel ?? name;
377
+
378
+ return {
379
+ name,
380
+ estimator: { kind: 'sample-frequency', samples: k, greedy: deterministic },
381
+
382
+ async ask({
383
+ state,
384
+ questions,
385
+ model,
386
+ }: {
387
+ state: DecisionState;
388
+ questions: Record<string, Question>;
389
+ model?: string;
390
+ }): Promise<Record<string, Answer>> {
391
+ const entries = Object.entries(questions);
392
+ if (entries.length === 0) {
393
+ throw new DecisionRequestError('expected a nonempty question map', undefined, undefined);
394
+ }
395
+ for (const [key, question] of entries) {
396
+ const optionCount = declaredOptions(question).length;
397
+ if (optionCount < 2) {
398
+ throw new DecisionRequestError(
399
+ `question '${key}' has fewer than two options (${optionCount})`,
400
+ undefined,
401
+ undefined,
402
+ );
403
+ }
404
+ }
405
+
406
+ const instructions = options.instructions ?? DECISION_INSTRUCTIONS;
407
+ const prompt = buildPrompt(state, questions);
408
+
409
+ const session = await options.open({ instructions, prompt, questions, state, model });
410
+ try {
411
+ const draws: Array<Record<string, string>> = [];
412
+ for (let start = 0; start < k; start += concurrency) {
413
+ const size = Math.min(concurrency, k - start);
414
+ const batch = Array.from({ length: size }, () => session.sample());
415
+ // allSettled awaits the whole batch before close; the first
416
+ // rejection in draw order wins, so no partial estimate escapes.
417
+ const settled = await Promise.allSettled(batch);
418
+ const failure = settled.find((result) => result.status === 'rejected');
419
+ if (failure !== undefined) throw (failure as PromiseRejectedResult).reason;
420
+ for (const result of settled) {
421
+ draws.push((result as PromiseFulfilledResult<Record<string, string>>).value);
422
+ }
423
+ }
424
+
425
+ const allowed = new Map(entries.map(([key, question]) => [key, declaredOptions(question)]));
426
+ const samplesByKey = new Map(entries.map(([key]) => [key, [] as string[]]));
427
+ for (const draw of draws) {
428
+ for (const [key] of entries) {
429
+ samplesByKey.get(key)?.push(requireEnumValue(draw, key, allowed.get(key) ?? [], label));
430
+ }
431
+ }
432
+
433
+ const answers: Record<string, Answer> = {};
434
+ for (const [key, question] of entries) {
435
+ const values = samplesByKey.get(key) ?? [];
436
+ switch (question.kind) {
437
+ case 'choice':
438
+ answers[key] = estimateChoice(Object.keys(question.labels), values);
439
+ break;
440
+ case 'score':
441
+ answers[key] = estimateScore(question.rubric, values);
442
+ break;
443
+ case 'noul':
444
+ answers[key] = estimateNoul(values);
445
+ break;
446
+ }
447
+ }
448
+ return answers;
449
+ } finally {
450
+ await session.close?.();
451
+ }
452
+ },
453
+ };
454
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from './agents/shims';
5
5
  export * from './ai-runner';
6
6
  export * from './decision/decision-maker';
7
7
  export * from './decision/errors';
8
+ export * from './decision/sampled-driver';
8
9
  export * from './decision/types';
9
10
  export * from './decision/validation';
10
11
  export * from './doctor-runner';
@@ -1,24 +0,0 @@
1
- /**
2
- * The TypeSafe backend driver — the only decision file that imports
3
- * `@typesafe-ai/sdk` (0069 pin). Client wiring (R1/R2), neutral⇄SDK question
4
- * and answer mapping (R4/R5), and the error translation table (R7) live here;
5
- * everything above this file stays vendor-neutral.
6
- */
7
- import type { DecisionDriver } from './types';
8
- /** Configuration the facade resolves (key per R7) and forwards to the TypeSafe driver. */
9
- export interface TypesafeDriverConfig {
10
- apiKey: string;
11
- model?: string;
12
- baseURL?: string;
13
- timeoutMs?: number;
14
- maxRetries?: number;
15
- fetch?: typeof fetch;
16
- }
17
- /**
18
- * Build the TypeSafe driver: exactly one `TypeSafeClient` per driver instance
19
- * (R2), the API key always passed explicitly so the SDK's `TYPESAFE_API_KEY`
20
- * self-resolution never runs (R1). All transport failures — at construction
21
- * or at ask time — come back as `DecisionError`s, never SDK classes (R7).
22
- */
23
- export declare function createTypesafeDriver(config: TypesafeDriverConfig): DecisionDriver;
24
- //# sourceMappingURL=typesafe-driver.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"typesafe-driver.d.ts","sourceRoot":"","sources":["../../src/decision/typesafe-driver.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAwBH,OAAO,KAAK,EAAU,cAAc,EAAY,MAAM,SAAS,CAAC;AAGhE,0FAA0F;AAC1F,MAAM,WAAW,oBAAoB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACxB;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,oBAAoB,GAAG,cAAc,CAoDjF"}
@@ -1,139 +0,0 @@
1
- /**
2
- * The TypeSafe backend driver — the only decision file that imports
3
- * `@typesafe-ai/sdk` (0069 pin). Client wiring (R1/R2), neutral⇄SDK question
4
- * and answer mapping (R4/R5), and the error translation table (R7) live here;
5
- * everything above this file stays vendor-neutral.
6
- */
7
- import { APIConnectionError, APIError, APITimeoutError, AuthenticationError, choice, noul, PermissionDeniedError, RateLimitError, score, TypeSafeClient, TypeSafeError, } from '@typesafe-ai/sdk';
8
- import { DecisionAuthError, DecisionBackendError, DecisionConnectionError, DecisionRateLimitError, DecisionRequestError, DecisionTimeoutError, } from './errors.js';
9
- import { validateAnswers, validateQuestions } from './validation.js';
10
- /**
11
- * Build the TypeSafe driver: exactly one `TypeSafeClient` per driver instance
12
- * (R2), the API key always passed explicitly so the SDK's `TYPESAFE_API_KEY`
13
- * self-resolution never runs (R1). All transport failures — at construction
14
- * or at ask time — come back as `DecisionError`s, never SDK classes (R7).
15
- */
16
- export function createTypesafeDriver(config) {
17
- let client;
18
- try {
19
- client = new TypeSafeClient({
20
- apiKey: config.apiKey,
21
- // baseURL is forwarded as-is. SDK 0.6.0 exports no default
22
- // base-URL constant (`ENV` holds env-var names only), so an
23
- // omitted baseURL still lets the SDK self-resolve
24
- // `TYPESAFE_BASE_URL` — documented residual for 0073.
25
- baseURL: config.baseURL,
26
- defaultModel: config.model,
27
- timeout: config.timeoutMs,
28
- retry: config.maxRetries === undefined ? undefined : { maxRetries: config.maxRetries },
29
- fetch: config.fetch,
30
- });
31
- }
32
- catch (err) {
33
- translateError(err);
34
- }
35
- return {
36
- name: 'typesafe',
37
- async ask({ state, questions, model }) {
38
- validateQuestions(questions);
39
- // The neutral contract allows `null` state ("no caller context") and the SDK type
40
- // accepts it, but the wire drops a null field and the API answers 422 "state: Field
41
- // required" — so every fully parameterless decision used to fall back on this backend.
42
- // Same null→'' mapping the laya and fm drivers already apply; structured state passes
43
- // through untouched (the wire accepts an object or array).
44
- let result;
45
- try {
46
- const sdkQuestions = Object.fromEntries(Object.entries(questions).map(([name, question]) => [name, toSdkQuestion(question)]));
47
- result = await client.systemOne({ state: state ?? '', questions: sdkQuestions, model });
48
- }
49
- catch (err) {
50
- translateError(err);
51
- }
52
- // R6: the SDK echoes the request's question names, so the mapped
53
- // record keeps the caller's keys in correspondence. R8: `model`
54
- // and `usage` on the result are deliberately not surfaced.
55
- if (!result?.answers || typeof result.answers !== 'object' || Array.isArray(result.answers)) {
56
- throw new DecisionBackendError('Invalid decision response: expected answers map', undefined);
57
- }
58
- const answers = Object.fromEntries(Object.entries(result.answers).map(([name, answer]) => [name, fromSdkAnswer(answer)]));
59
- validateAnswers(questions, answers);
60
- return answers;
61
- },
62
- };
63
- }
64
- /** Neutral question → SDK wire question (R4). `null` is "undescribed" on the wire. */
65
- function toSdkQuestion(question) {
66
- switch (question.kind) {
67
- case 'choice':
68
- return choice(question.prompt ?? null, question.labels);
69
- case 'score':
70
- return score(question.prompt ?? null, question.rubric);
71
- case 'noul': {
72
- const outcomes = question.yes === undefined && question.no === undefined
73
- ? undefined
74
- : { true: question.yes, false: question.no };
75
- return noul(question.prompt ?? null, outcomes);
76
- }
77
- }
78
- }
79
- /** SDK wire response → neutral answer (R5). Noul gets no confidence — the wire has none and none is invented. */
80
- function fromSdkAnswer(answer) {
81
- if (!answer || typeof answer !== 'object')
82
- throw new DecisionBackendError('Invalid decision response: expected answer object', undefined);
83
- switch (answer.type) {
84
- case 'choice':
85
- return {
86
- kind: 'choice',
87
- label: answer.choice,
88
- confidence: answer.confidence,
89
- probabilities: answer.probabilities,
90
- };
91
- case 'score':
92
- return {
93
- kind: 'score',
94
- score: answer.score,
95
- confidence: answer.confidence,
96
- legend: answer.legend,
97
- probabilities: answer.probabilities,
98
- };
99
- case 'noul':
100
- return { kind: 'noul', probability: answer.noul };
101
- default:
102
- throw new DecisionBackendError('Invalid decision response: unknown answer kind', undefined);
103
- }
104
- }
105
- /**
106
- * R7, per the design doc's error table. Ordered subclass-before-base:
107
- * `APITimeoutError` extends `APIConnectionError`, and the HTTP subclasses
108
- * extend `APIError`. Rows the table does not name fall to the nearest home:
109
- * 4xx (`NotFoundError` included) is a `DecisionRequestError`, and any local
110
- * `TypeSafeError` (empty questions, invalid client config) lands there too
111
- * with `status: undefined` — so no SDK class ever escapes. Foreign errors are
112
- * rethrown untouched; they are not this package's to translate.
113
- */
114
- function translateError(err) {
115
- const message = err instanceof Error ? err.message : String(err);
116
- if (err instanceof RateLimitError) {
117
- throw new DecisionRateLimitError(message, err.status, err.retryAfterMs, { cause: err });
118
- }
119
- if (err instanceof AuthenticationError || err instanceof PermissionDeniedError) {
120
- throw new DecisionAuthError(message, err.status, { cause: err });
121
- }
122
- if (err instanceof APITimeoutError) {
123
- throw new DecisionTimeoutError(message, err.timeoutMs, { cause: err });
124
- }
125
- if (err instanceof APIConnectionError) {
126
- throw new DecisionConnectionError(message, { cause: err });
127
- }
128
- if (err instanceof APIError) {
129
- if (err.status < 500) {
130
- const raw = typeof err.body === 'string' ? err.body : (JSON.stringify(err.body) ?? '');
131
- throw new DecisionRequestError(message, err.status, raw.slice(0, 200) || undefined, { cause: err });
132
- }
133
- throw new DecisionBackendError(message, err.status, { cause: err });
134
- }
135
- if (err instanceof TypeSafeError) {
136
- throw new DecisionRequestError(message, undefined, message, { cause: err });
137
- }
138
- throw err;
139
- }