@gobing-ai/ts-ai-decision 0.5.13
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/README.md +142 -0
- package/dist/catalog.d.ts +16 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +253 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +67 -0
- package/dist/hub.d.ts +142 -0
- package/dist/hub.d.ts.map +1 -0
- package/dist/hub.js +262 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/params.d.ts +31 -0
- package/dist/params.d.ts.map +1 -0
- package/dist/params.js +149 -0
- package/dist/registry.d.ts +42 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +74 -0
- package/dist/schema.d.ts +137 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +71 -0
- package/dist/types.d.ts +84 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +0 -0
- package/examples/decisions.yaml +42 -0
- package/examples/support.yaml +54 -0
- package/package.json +66 -0
- package/schemas/decision-catalog.schema.json +226 -0
- package/src/catalog.ts +359 -0
- package/src/errors.ts +72 -0
- package/src/hub.ts +419 -0
- package/src/index.ts +7 -0
- package/src/params.ts +170 -0
- package/src/registry.ts +99 -0
- package/src/schema.ts +95 -0
- package/src/types.ts +80 -0
package/src/hub.ts
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Answer,
|
|
3
|
+
DecisionConfigError,
|
|
4
|
+
type DecisionState,
|
|
5
|
+
DecisionTimeoutError,
|
|
6
|
+
type Json,
|
|
7
|
+
type Question,
|
|
8
|
+
q,
|
|
9
|
+
} from '@gobing-ai/ts-ai-runner';
|
|
10
|
+
import { type CatalogLoadOptions, loadDecisionCatalog } from './catalog';
|
|
11
|
+
import { DecisionCatalogError, UnknownDecisionError, UnknownDecisionMakerError } from './errors';
|
|
12
|
+
import { buildState, renderQuestion, resolveDecisionInput } from './params';
|
|
13
|
+
import { type BuiltinMakerOptions, DecisionMakerRegistry, type MakerSource } from './registry';
|
|
14
|
+
import type {
|
|
15
|
+
CatalogDefaults,
|
|
16
|
+
DecisionCatalog,
|
|
17
|
+
DecisionCriteria,
|
|
18
|
+
DecisionDefinition,
|
|
19
|
+
DecisionParam,
|
|
20
|
+
DecisionType,
|
|
21
|
+
JevEntry,
|
|
22
|
+
JevQuestion,
|
|
23
|
+
} from './types';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* DecisionHub (task 0090, ADR-033 § API / § decide algorithm): loads catalogs
|
|
27
|
+
* over the 0089 loader and serves `decide` through makers resolved by name
|
|
28
|
+
* from the 0091 registry. `decide` never rejects for backend construction,
|
|
29
|
+
* transport, timeout, answer validation or low confidence — it maps every such
|
|
30
|
+
* outcome to the declared fallback — and rejects only for caller mistakes
|
|
31
|
+
* (unknown id, invalid input, unregistered per-call maker), always before any
|
|
32
|
+
* backend call. The hub contains no driver code; every maker comes from
|
|
33
|
+
* `registry.resolve(name)`.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Package confidence floor when neither the decision nor the catalog declares one. */
|
|
37
|
+
export const DEFAULT_MIN_CONFIDENCE = 0.7;
|
|
38
|
+
|
|
39
|
+
/** Hub default when neither the per-call options, the decision nor the catalog names a maker. */
|
|
40
|
+
export const DEFAULT_MAKER = 'typesafe';
|
|
41
|
+
|
|
42
|
+
/** Shared envelope of every {@link DecisionResult} variant. */
|
|
43
|
+
export interface DecisionResultBase {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
/** Observed calibration; null only when no answer was obtained. */
|
|
46
|
+
readonly confidence: number | null;
|
|
47
|
+
/** `model` for an accepted answer, `default` for any fallback. */
|
|
48
|
+
readonly source: 'model' | 'default';
|
|
49
|
+
readonly reason: 'accepted' | 'low-confidence' | 'no-backend' | 'timeout' | 'error';
|
|
50
|
+
/** Registry name of the maker that was selected. */
|
|
51
|
+
readonly maker: string;
|
|
52
|
+
readonly durationMs: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The fallback-guaranteed result of one `decide` call, shaped by the decision type. */
|
|
56
|
+
export type DecisionResult =
|
|
57
|
+
| (DecisionResultBase & { readonly type: 'choice'; readonly value: string })
|
|
58
|
+
| (DecisionResultBase & { readonly type: 'score'; readonly value: number })
|
|
59
|
+
| (DecisionResultBase & { readonly type: 'noul'; readonly value: boolean; readonly probability: number | null });
|
|
60
|
+
|
|
61
|
+
/** One loaded decision, for downstream discovery (`hub.list()`). */
|
|
62
|
+
export interface DecisionSummary {
|
|
63
|
+
readonly id: string;
|
|
64
|
+
readonly type: DecisionType;
|
|
65
|
+
readonly description?: string;
|
|
66
|
+
readonly source: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Full served contract of one decision (`hub.describe(id)`), including the effective defaults. */
|
|
70
|
+
export interface DecisionDescriptor {
|
|
71
|
+
readonly id: string;
|
|
72
|
+
readonly type: DecisionType;
|
|
73
|
+
readonly description?: string;
|
|
74
|
+
readonly source: string;
|
|
75
|
+
/** Declared parameter contract including the reserved `instructions` parameter. */
|
|
76
|
+
readonly parameters: Readonly<Record<string, DecisionParam>>;
|
|
77
|
+
readonly criteria: DecisionCriteria;
|
|
78
|
+
readonly fallback: string | number | boolean;
|
|
79
|
+
/** decision → catalog defaults → {@link DEFAULT_MIN_CONFIDENCE}. */
|
|
80
|
+
readonly minConfidence: number;
|
|
81
|
+
/** decision → catalog defaults → hub defaultMaker. */
|
|
82
|
+
readonly maker: string;
|
|
83
|
+
/** decision → catalog defaults; absent when neither declares one. */
|
|
84
|
+
readonly model?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** {@link DecisionHub} construction options. */
|
|
88
|
+
export interface DecisionHubOptions {
|
|
89
|
+
/** Default: a fresh `DecisionMakerRegistry` with the three built-in makers. */
|
|
90
|
+
readonly registry?: DecisionMakerRegistry;
|
|
91
|
+
/** Used when neither the decision nor the catalog names a maker. Default 'typesafe'. */
|
|
92
|
+
readonly defaultMaker?: string;
|
|
93
|
+
/** Clock for `durationMs`; injectable so tests are deterministic. Default `Date.now`. */
|
|
94
|
+
readonly now?: () => number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Per-call options for {@link DecisionHub.decide}. */
|
|
98
|
+
export interface DecideOptions {
|
|
99
|
+
/** Per-call maker name; wins over the decision's, the catalog's and the hub default. */
|
|
100
|
+
readonly maker?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One registered decision plus the catalog defaults it loaded under. */
|
|
104
|
+
interface RegisteredDecision {
|
|
105
|
+
readonly definition: DecisionDefinition;
|
|
106
|
+
readonly defaults: CatalogDefaults;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Effective confidence floor: decision → catalog defaults → package default. */
|
|
110
|
+
function effectiveMinConfidence(definition: DecisionDefinition, defaults: CatalogDefaults): number {
|
|
111
|
+
return definition.minConfidence ?? defaults.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Confidence observed in one validated answer; noul synthesizes `max(p, 1 - p)`. */
|
|
115
|
+
function observedAnswer(answer: Answer): { confidence: number; probability?: number } {
|
|
116
|
+
switch (answer.kind) {
|
|
117
|
+
case 'choice':
|
|
118
|
+
return { confidence: answer.confidence };
|
|
119
|
+
case 'score':
|
|
120
|
+
return { confidence: answer.confidence };
|
|
121
|
+
case 'noul':
|
|
122
|
+
return {
|
|
123
|
+
confidence: Math.max(answer.probability, 1 - answer.probability),
|
|
124
|
+
probability: answer.probability,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Map the rendered question onto the neutral Jev builders — exactly one
|
|
131
|
+
* question per `decide` call, keyed by the decision id at the `ask` level.
|
|
132
|
+
*/
|
|
133
|
+
function buildJevQuestion(question: JevQuestion): Question {
|
|
134
|
+
switch (question.type) {
|
|
135
|
+
case 'choice':
|
|
136
|
+
return q.choice(question.instructions, { ...question.criteria });
|
|
137
|
+
case 'score':
|
|
138
|
+
// The loader enforces >= 2 levels; the tuple is the builder's shape.
|
|
139
|
+
return q.score(question.instructions, question.criteria as readonly [JevEntry, JevEntry, ...JevEntry[]]);
|
|
140
|
+
case 'noul': {
|
|
141
|
+
const outcomes = question.criteria;
|
|
142
|
+
return q.noul(
|
|
143
|
+
question.instructions,
|
|
144
|
+
outcomes !== undefined ? { yes: outcomes.true, no: outcomes.false } : undefined,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Hub over loaded catalogs and named makers (task 0090, ADR-033 § API):
|
|
152
|
+
* `decide` serves one Jev question per call and resolves every backend outcome
|
|
153
|
+
* to the declared fallback; only caller mistakes throw, before any backend
|
|
154
|
+
* call. Discovery (`list`/`describe`) and maker construction stay decoupled —
|
|
155
|
+
* makers come only from `registry.resolve(name)`.
|
|
156
|
+
*/
|
|
157
|
+
export class DecisionHub {
|
|
158
|
+
readonly registry: DecisionMakerRegistry;
|
|
159
|
+
private readonly decisions = new Map<string, RegisteredDecision>();
|
|
160
|
+
private readonly defaultMaker: string;
|
|
161
|
+
private readonly now: () => number;
|
|
162
|
+
|
|
163
|
+
constructor(options: DecisionHubOptions = {}) {
|
|
164
|
+
this.registry = options.registry ?? new DecisionMakerRegistry();
|
|
165
|
+
this.defaultMaker = options.defaultMaker ?? DEFAULT_MAKER;
|
|
166
|
+
this.now = options.now ?? Date.now;
|
|
167
|
+
if (!this.registry.has(this.defaultMaker)) {
|
|
168
|
+
throw new UnknownDecisionMakerError(
|
|
169
|
+
`Default decision maker '${this.defaultMaker}' is not registered in the hub registry ` +
|
|
170
|
+
`(registered: ${this.registry.names().join(', ') || 'none'})`,
|
|
171
|
+
this.defaultMaker,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Register every decision of an already-loaded catalog. All-or-nothing: a
|
|
178
|
+
* duplicate decision id or an unregistered maker name (decision or
|
|
179
|
+
* `defaults.maker`) throws {@link DecisionCatalogError} and registers
|
|
180
|
+
* nothing from the offending catalog.
|
|
181
|
+
*/
|
|
182
|
+
load(catalog: DecisionCatalog): void {
|
|
183
|
+
const defaultsMaker = catalog.defaults.maker;
|
|
184
|
+
if (defaultsMaker !== undefined && !this.registry.has(defaultsMaker)) {
|
|
185
|
+
throw new DecisionCatalogError(
|
|
186
|
+
`Catalog "${catalog.source}" defaults.maker '${defaultsMaker}' is not registered in the hub registry ` +
|
|
187
|
+
`(registered: ${this.registry.names().join(', ') || 'none'})`,
|
|
188
|
+
catalog.source,
|
|
189
|
+
undefined,
|
|
190
|
+
'maker',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
// Stage first, commit only when every decision passed the cross-catalog checks.
|
|
194
|
+
const staged: RegisteredDecision[] = [];
|
|
195
|
+
for (const [id, definition] of Object.entries(catalog.decisions)) {
|
|
196
|
+
const existing = this.decisions.get(id);
|
|
197
|
+
if (existing !== undefined) {
|
|
198
|
+
throw new DecisionCatalogError(
|
|
199
|
+
`Decision id "${id}" in catalog "${catalog.source}" duplicates the decision loaded from ` +
|
|
200
|
+
`"${existing.definition.source}"`,
|
|
201
|
+
catalog.source,
|
|
202
|
+
id,
|
|
203
|
+
'id',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (definition.maker !== undefined && !this.registry.has(definition.maker)) {
|
|
207
|
+
throw new DecisionCatalogError(
|
|
208
|
+
`Decision "${id}" in catalog "${catalog.source}" names maker '${definition.maker}' that is not ` +
|
|
209
|
+
`registered in the hub registry (registered: ${this.registry.names().join(', ') || 'none'})`,
|
|
210
|
+
catalog.source,
|
|
211
|
+
id,
|
|
212
|
+
'maker',
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
staged.push({ definition, defaults: catalog.defaults });
|
|
216
|
+
}
|
|
217
|
+
for (const entry of staged) this.decisions.set(entry.definition.id, entry);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Load a catalog file through the 0089 loader, then register it via {@link load}. */
|
|
221
|
+
async loadFile(path: string, options: CatalogLoadOptions = {}): Promise<void> {
|
|
222
|
+
this.load(await loadDecisionCatalog(path, options));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Pure read over the loaded decisions; never constructs a maker or driver. */
|
|
226
|
+
list(): DecisionSummary[] {
|
|
227
|
+
return [...this.decisions.values()].map(({ definition }) => ({
|
|
228
|
+
id: definition.id,
|
|
229
|
+
type: definition.type,
|
|
230
|
+
...(definition.description !== undefined ? { description: definition.description } : {}),
|
|
231
|
+
source: definition.source,
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Pure read of one decision's served contract; throws UnknownDecisionError for an unknown id. */
|
|
236
|
+
describe(id: string): DecisionDescriptor {
|
|
237
|
+
const { definition, defaults } = this.mustGet(id);
|
|
238
|
+
const model = definition.model ?? defaults.model;
|
|
239
|
+
return {
|
|
240
|
+
id: definition.id,
|
|
241
|
+
type: definition.type,
|
|
242
|
+
...(definition.description !== undefined ? { description: definition.description } : {}),
|
|
243
|
+
source: definition.source,
|
|
244
|
+
parameters: definition.parameters,
|
|
245
|
+
criteria: definition.criteria,
|
|
246
|
+
fallback: definition.fallback,
|
|
247
|
+
minConfidence: effectiveMinConfidence(definition, defaults),
|
|
248
|
+
maker: this.effectiveMakerName(definition, defaults),
|
|
249
|
+
...(model !== undefined ? { model } : {}),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Serve one decision with a fallback-guaranteed answer. Resolves the input,
|
|
255
|
+
* renders exactly one Jev question keyed by the decision id, selects the
|
|
256
|
+
* maker by name (per call → decision → catalog defaults → hub default) and
|
|
257
|
+
* issues a single `maker.ask`. Backend construction, transport, timeout,
|
|
258
|
+
* invalid answers and low confidence all resolve to the declared fallback;
|
|
259
|
+
* only caller mistakes throw, before any backend call.
|
|
260
|
+
*/
|
|
261
|
+
async decide(id: string, input: Record<string, Json> = {}, options: DecideOptions = {}): Promise<DecisionResult> {
|
|
262
|
+
const start = this.now();
|
|
263
|
+
const { definition, defaults } = this.mustGet(id); // UnknownDecisionError — caller error
|
|
264
|
+
const params = resolveDecisionInput(definition, input); // DecisionInputError — caller error
|
|
265
|
+
|
|
266
|
+
const makerName = options.maker ?? definition.maker ?? defaults.maker ?? this.defaultMaker;
|
|
267
|
+
if (!this.registry.has(makerName)) {
|
|
268
|
+
// Reachable only for a per-call name: catalog and hub default names are checked at
|
|
269
|
+
// load/construct. It is a caller error, thrown before any backend interaction.
|
|
270
|
+
throw new UnknownDecisionMakerError(
|
|
271
|
+
`Decision "${id}" was asked on maker '${makerName}' that is not registered in the hub registry ` +
|
|
272
|
+
`(registered: ${this.registry.names().join(', ') || 'none'})`,
|
|
273
|
+
makerName,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const state: DecisionState = buildState(definition, params);
|
|
278
|
+
const model = definition.model ?? defaults.model;
|
|
279
|
+
const request = {
|
|
280
|
+
state,
|
|
281
|
+
questions: { [definition.id]: buildJevQuestion(renderQuestion(definition, params)) },
|
|
282
|
+
...(model !== undefined ? { model } : {}),
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// The one auditable try/catch for steps 4–5: a resolve failure is a construction
|
|
286
|
+
// failure (no-backend); ask failures map per the error taxonomy. Neither rejects.
|
|
287
|
+
let inAsk = false;
|
|
288
|
+
try {
|
|
289
|
+
const maker = await this.registry.resolve(makerName);
|
|
290
|
+
inAsk = true;
|
|
291
|
+
const answers = await maker.ask(request);
|
|
292
|
+
const answer: Answer | undefined = answers[definition.id];
|
|
293
|
+
if (answer === undefined || answer.kind !== definition.type) {
|
|
294
|
+
return this.fallbackResult(definition, makerName, 'error', null, start);
|
|
295
|
+
}
|
|
296
|
+
const observed = observedAnswer(answer);
|
|
297
|
+
if (observed.confidence < effectiveMinConfidence(definition, defaults)) {
|
|
298
|
+
return this.fallbackResult(
|
|
299
|
+
definition,
|
|
300
|
+
makerName,
|
|
301
|
+
'low-confidence',
|
|
302
|
+
observed.confidence,
|
|
303
|
+
start,
|
|
304
|
+
observed.probability,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const base = {
|
|
308
|
+
id: definition.id,
|
|
309
|
+
confidence: observed.confidence,
|
|
310
|
+
source: 'model' as const,
|
|
311
|
+
reason: 'accepted' as const,
|
|
312
|
+
maker: makerName,
|
|
313
|
+
durationMs: this.now() - start,
|
|
314
|
+
};
|
|
315
|
+
switch (answer.kind) {
|
|
316
|
+
case 'choice':
|
|
317
|
+
return { ...base, type: 'choice', value: answer.label };
|
|
318
|
+
case 'score':
|
|
319
|
+
return { ...base, type: 'score', value: answer.score };
|
|
320
|
+
case 'noul':
|
|
321
|
+
return {
|
|
322
|
+
...base,
|
|
323
|
+
type: 'noul',
|
|
324
|
+
value: answer.probability >= 0.5,
|
|
325
|
+
probability: answer.probability,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const reason =
|
|
330
|
+
!inAsk || error instanceof DecisionConfigError
|
|
331
|
+
? 'no-backend'
|
|
332
|
+
: error instanceof DecisionTimeoutError
|
|
333
|
+
? 'timeout'
|
|
334
|
+
: 'error';
|
|
335
|
+
return this.fallbackResult(definition, makerName, reason, null, start);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Maker-name precedence for discovery results: decision → catalog defaults → hub default. */
|
|
340
|
+
private effectiveMakerName(definition: DecisionDefinition, defaults: CatalogDefaults): string {
|
|
341
|
+
return definition.maker ?? defaults.maker ?? this.defaultMaker;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private mustGet(id: string): RegisteredDecision {
|
|
345
|
+
const registered = this.decisions.get(id);
|
|
346
|
+
if (registered === undefined) {
|
|
347
|
+
const loaded = [...this.decisions.keys()];
|
|
348
|
+
throw new UnknownDecisionError(
|
|
349
|
+
`Unknown decision "${id}" — loaded decisions: ${loaded.length > 0 ? loaded.join(', ') : '(none)'}`,
|
|
350
|
+
id,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
return registered;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Build the declared-fallback envelope: `source: 'default'`, the observed
|
|
358
|
+
* confidence (null when none), always the selected maker name. For noul,
|
|
359
|
+
* `probability` stays null unless the answer itself supplied one.
|
|
360
|
+
*/
|
|
361
|
+
private fallbackResult(
|
|
362
|
+
definition: DecisionDefinition,
|
|
363
|
+
makerName: string,
|
|
364
|
+
reason: DecisionResultBase['reason'],
|
|
365
|
+
confidence: number | null,
|
|
366
|
+
start: number,
|
|
367
|
+
probability?: number,
|
|
368
|
+
): DecisionResult {
|
|
369
|
+
const base = {
|
|
370
|
+
id: definition.id,
|
|
371
|
+
confidence,
|
|
372
|
+
source: 'default' as const,
|
|
373
|
+
reason,
|
|
374
|
+
maker: makerName,
|
|
375
|
+
durationMs: this.now() - start,
|
|
376
|
+
};
|
|
377
|
+
switch (definition.type) {
|
|
378
|
+
case 'choice':
|
|
379
|
+
return { ...base, type: 'choice', value: definition.fallback as string };
|
|
380
|
+
case 'score':
|
|
381
|
+
return { ...base, type: 'score', value: definition.fallback as number };
|
|
382
|
+
case 'noul':
|
|
383
|
+
return {
|
|
384
|
+
...base,
|
|
385
|
+
type: 'noul',
|
|
386
|
+
value: definition.fallback as boolean,
|
|
387
|
+
probability: probability ?? null,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Options for {@link createDecisionHub}: one call from options to a ready hub. */
|
|
394
|
+
export interface CreateDecisionHubOptions extends DecisionHubOptions {
|
|
395
|
+
/** Ignored when `registry` is supplied. Default: the three built-in makers. */
|
|
396
|
+
readonly builtins?: BuiltinMakerOptions | false;
|
|
397
|
+
/** Extra makers registered before any catalog loads (catalog checks run at load). */
|
|
398
|
+
readonly makers?: Readonly<Record<string, MakerSource>>;
|
|
399
|
+
/** Catalog files loaded in order with {@link CreateDecisionHubOptions.catalogOptions}. */
|
|
400
|
+
readonly catalogs?: readonly string[];
|
|
401
|
+
/** Forwarded to every `loadFile` call. */
|
|
402
|
+
readonly catalogOptions?: CatalogLoadOptions;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Build the registry, register `makers`, construct the hub and `loadFile` each
|
|
407
|
+
* catalog in order. Any failure rejects; a partially built hub never escapes.
|
|
408
|
+
*/
|
|
409
|
+
export async function createDecisionHub(options: CreateDecisionHubOptions = {}): Promise<DecisionHub> {
|
|
410
|
+
const registry = options.registry ?? new DecisionMakerRegistry({ builtins: options.builtins });
|
|
411
|
+
for (const [name, source] of Object.entries(options.makers ?? {})) {
|
|
412
|
+
registry.register(name, source);
|
|
413
|
+
}
|
|
414
|
+
const hub = new DecisionHub({ registry, defaultMaker: options.defaultMaker, now: options.now });
|
|
415
|
+
for (const path of options.catalogs ?? []) {
|
|
416
|
+
await hub.loadFile(path, options.catalogOptions);
|
|
417
|
+
}
|
|
418
|
+
return hub;
|
|
419
|
+
}
|
package/src/index.ts
ADDED
package/src/params.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { Json } from '@gobing-ai/ts-ai-runner';
|
|
2
|
+
import { DecisionInputError } from './errors';
|
|
3
|
+
import type { DecisionDefinition, DecisionParam, JevEntry, JevQuestion, ResolvedParams } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parameter resolution, `${params.*}` rendering and state building
|
|
7
|
+
* (task 0089 R7; design § Parameters, § Variable replacement).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The reserved per-decision parameter carrying the caller's text; authors cannot declare it. */
|
|
11
|
+
export const INSTRUCTIONS_PARAM = 'instructions';
|
|
12
|
+
|
|
13
|
+
/** Authored-instructions default: the caller's text passed through unchanged. */
|
|
14
|
+
export const DEFAULT_INSTRUCTIONS_TEMPLATE = `\${params.instructions}`;
|
|
15
|
+
|
|
16
|
+
/** Same regex as `ts-dual-workflow-engine`'s variable resolver (design § Variable replacement). */
|
|
17
|
+
const TEMPLATE_REF = /\$\{([^}]+)\}/g;
|
|
18
|
+
|
|
19
|
+
/** The implicit reserved `instructions` parameter: type string, default "". */
|
|
20
|
+
export function implicitInstructionsParam(): DecisionParam {
|
|
21
|
+
return { name: INSTRUCTIONS_PARAM, type: 'string', required: false, default: '' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function expectTypeMatch(decision: DecisionDefinition, param: DecisionParam, value: Json): void {
|
|
25
|
+
const expects = param.values !== undefined ? `one of [${param.values.join(', ')}]` : param.type;
|
|
26
|
+
let ok: boolean;
|
|
27
|
+
switch (param.type) {
|
|
28
|
+
case 'string':
|
|
29
|
+
ok = typeof value === 'string';
|
|
30
|
+
break;
|
|
31
|
+
case 'number':
|
|
32
|
+
ok = typeof value === 'number' && Number.isFinite(value);
|
|
33
|
+
break;
|
|
34
|
+
case 'boolean':
|
|
35
|
+
ok = typeof value === 'boolean';
|
|
36
|
+
break;
|
|
37
|
+
case 'enum':
|
|
38
|
+
ok = typeof value === 'string' && param.values?.includes(value) === true;
|
|
39
|
+
break;
|
|
40
|
+
case 'json':
|
|
41
|
+
return; // any JSON value is accepted
|
|
42
|
+
}
|
|
43
|
+
if (!ok) {
|
|
44
|
+
throw new DecisionInputError(
|
|
45
|
+
`Parameter "${param.name}" of decision "${decision.id}" expects ${expects}, got ${JSON.stringify(value)}`,
|
|
46
|
+
decision.id,
|
|
47
|
+
param.name,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve one decision's parameters: declared defaults overlaid by input, plus the
|
|
54
|
+
* implicit `instructions` (default ""). Unknown keys, missing required parameters
|
|
55
|
+
* (those without a default) and type mismatches throw {@link DecisionInputError}.
|
|
56
|
+
* An explicitly supplied `null` is a provided value (type-checked like any other).
|
|
57
|
+
*/
|
|
58
|
+
export function resolveDecisionInput(decision: DecisionDefinition, input: Record<string, Json> = {}): ResolvedParams {
|
|
59
|
+
const params: ResolvedParams = {};
|
|
60
|
+
for (const key of Object.keys(input)) {
|
|
61
|
+
// Own-property check: input keys like "constructor" must not resolve via the prototype chain.
|
|
62
|
+
if (!Object.hasOwn(decision.parameters, key)) {
|
|
63
|
+
throw new DecisionInputError(
|
|
64
|
+
`Unknown parameter "${key}" for decision "${decision.id}"; declared parameters: ` +
|
|
65
|
+
`${Object.keys(decision.parameters).join(', ')}`,
|
|
66
|
+
decision.id,
|
|
67
|
+
key,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const param of Object.values(decision.parameters)) {
|
|
72
|
+
const supplied = param.name in input ? input[param.name] : undefined;
|
|
73
|
+
if (supplied === undefined && param.required) {
|
|
74
|
+
throw new DecisionInputError(
|
|
75
|
+
`Missing required parameter "${param.name}" for decision "${decision.id}"`,
|
|
76
|
+
decision.id,
|
|
77
|
+
param.name,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const value: Json = supplied !== undefined ? supplied : (param.default ?? null);
|
|
81
|
+
expectTypeMatch(decision, param, value);
|
|
82
|
+
params[param.name] = value;
|
|
83
|
+
}
|
|
84
|
+
return params;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function substitute(decisionId: string, ref: string, params: ResolvedParams): string {
|
|
88
|
+
if (!ref.startsWith('params.')) {
|
|
89
|
+
throw new DecisionInputError(`Template ref "\${${ref}}" must use the "params" namespace`, decisionId, ref);
|
|
90
|
+
}
|
|
91
|
+
const name = ref.slice('params.'.length);
|
|
92
|
+
const value = params[name];
|
|
93
|
+
// Substitution forms (design § Variable replacement): strings verbatim, numbers and
|
|
94
|
+
// booleans via String, json via JSON.stringify, null as "".
|
|
95
|
+
if (value === undefined || value === null) return '';
|
|
96
|
+
if (typeof value === 'string') return value;
|
|
97
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
98
|
+
return JSON.stringify(value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderEntryValue(decisionId: string, value: Json, params: ResolvedParams): Json {
|
|
102
|
+
if (typeof value === 'string') {
|
|
103
|
+
return value.replace(TEMPLATE_REF, (_match, ref: string) => substitute(decisionId, ref, params));
|
|
104
|
+
}
|
|
105
|
+
if (Array.isArray(value)) return value.map((item) => renderEntryValue(decisionId, item, params));
|
|
106
|
+
if (value !== null && typeof value === 'object') {
|
|
107
|
+
return Object.fromEntries(
|
|
108
|
+
Object.entries(value).map(([key, entry]) => [key, renderEntryValue(decisionId, entry, params)]),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderEntry(decisionId: string, entry: JevEntry, params: ResolvedParams): JevEntry {
|
|
115
|
+
return renderEntryValue(decisionId, entry, params) as JevEntry;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Render the decision's Jev question: `${params.<name>}` substitution applied to
|
|
120
|
+
* `instructions` and to criteria-description string leaves. Labels, `fallback`,
|
|
121
|
+
* `maker` and `model` are never templated, so the answer vocabulary stays static.
|
|
122
|
+
*/
|
|
123
|
+
export function renderQuestion(decision: DecisionDefinition, params: ResolvedParams): JevQuestion {
|
|
124
|
+
const instructions = renderEntry(decision.id, decision.instructions, params);
|
|
125
|
+
const criteria = decision.criteria;
|
|
126
|
+
switch (criteria.kind) {
|
|
127
|
+
case 'choice': {
|
|
128
|
+
const rendered: Record<string, JevEntry> = {};
|
|
129
|
+
for (const [label, entry] of Object.entries(criteria.labels)) {
|
|
130
|
+
rendered[label] = renderEntry(decision.id, entry, params);
|
|
131
|
+
}
|
|
132
|
+
return { type: 'choice', instructions, criteria: rendered };
|
|
133
|
+
}
|
|
134
|
+
case 'score':
|
|
135
|
+
return {
|
|
136
|
+
type: 'score',
|
|
137
|
+
instructions,
|
|
138
|
+
criteria: criteria.levels.map((entry) => renderEntry(decision.id, entry, params)),
|
|
139
|
+
};
|
|
140
|
+
case 'noul': {
|
|
141
|
+
const outcomes = criteria.outcomes;
|
|
142
|
+
if (outcomes === undefined) return { type: 'noul', instructions };
|
|
143
|
+
return {
|
|
144
|
+
type: 'noul',
|
|
145
|
+
instructions,
|
|
146
|
+
criteria: {
|
|
147
|
+
...(outcomes.true !== undefined ? { true: renderEntry(decision.id, outcomes.true, params) } : {}),
|
|
148
|
+
...(outcomes.false !== undefined
|
|
149
|
+
? { false: renderEntry(decision.id, outcomes.false, params) }
|
|
150
|
+
: {}),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build the Jev `state` from the resolved declared parameters, excluding
|
|
159
|
+
* `instructions` and null values; null when nothing remains.
|
|
160
|
+
*/
|
|
161
|
+
export function buildState(decision: DecisionDefinition, params: ResolvedParams): Record<string, Json> | null {
|
|
162
|
+
const state: Record<string, Json> = {};
|
|
163
|
+
for (const param of Object.values(decision.parameters)) {
|
|
164
|
+
if (param.name === INSTRUCTIONS_PARAM) continue;
|
|
165
|
+
const value = params[param.name];
|
|
166
|
+
if (value === null || value === undefined) continue;
|
|
167
|
+
state[param.name] = value;
|
|
168
|
+
}
|
|
169
|
+
return Object.keys(state).length === 0 ? null : state;
|
|
170
|
+
}
|