@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.
@@ -0,0 +1,99 @@
1
+ import { createDecisionMaker, type DecisionMaker, type DecisionMakerOptions } from '@gobing-ai/ts-ai-runner';
2
+ import { createFmDriver, type FmDriverOptions } from '@gobing-ai/ts-decision-fm';
3
+ import { createLayaDriver, type LayaDriverOptions } from '@gobing-ai/ts-laya-mlx';
4
+ import { DecisionRegistryError, UnknownDecisionMakerError } from './errors';
5
+
6
+ /** A maker, or a lazy factory for one (sync or async). Factories run on first `resolve` and are memoised on success. */
7
+ export type MakerSource = DecisionMaker | (() => DecisionMaker | Promise<DecisionMaker>);
8
+
9
+ /** Options for the three built-in makers pre-registered by {@link DecisionMakerRegistry} (task 0091 R2). */
10
+ export interface BuiltinMakerOptions {
11
+ /** Forwarded to `createDecisionMaker` for every built-in (env, apiKey, baseURL, timeoutMs, ...). */
12
+ readonly makerOptions?: Omit<DecisionMakerOptions, 'driver' | 'backend' | 'model'>;
13
+ /** Driver options for the bundled local drivers; the fm platform check runs at first resolve, not registration. */
14
+ readonly driverOptions?: { readonly 'fm-local'?: FmDriverOptions; readonly 'laya-local'?: LayaDriverOptions };
15
+ }
16
+
17
+ const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
18
+
19
+ /**
20
+ * Registry mapping plain names to {@link DecisionMaker}s (task 0091, ADR-033
21
+ * § Maker registry). Built-ins are registered as lazy factories, so
22
+ * `register`, `has` and `names` never construct a maker or driver; the first
23
+ * `resolve` runs the factory and memoises the maker, while a failed factory
24
+ * stays un-memoised so a later resolve retries (e.g. a model not yet
25
+ * downloaded). No catalog dependency — catalog code depends on this, never
26
+ * the reverse.
27
+ */
28
+ export class DecisionMakerRegistry {
29
+ private readonly entries = new Map<
30
+ string,
31
+ { source: MakerSource; maker?: DecisionMaker; pending?: Promise<DecisionMaker> }
32
+ >();
33
+
34
+ /** Pre-registers `typesafe`, `fm-local` and `laya-local` unless `builtins: false`. */
35
+ constructor(options?: { builtins?: BuiltinMakerOptions | false }) {
36
+ if (options?.builtins === false) return;
37
+ const { makerOptions = {}, driverOptions = {} } = options?.builtins ?? {};
38
+ this.register('typesafe', () => createDecisionMaker({ backend: 'typesafe', ...makerOptions }));
39
+ this.register('fm-local', () =>
40
+ createDecisionMaker({ driver: createFmDriver(driverOptions['fm-local'] ?? {}), ...makerOptions }),
41
+ );
42
+ this.register('laya-local', () =>
43
+ createDecisionMaker({ driver: createLayaDriver(driverOptions['laya-local'] ?? {}), ...makerOptions }),
44
+ );
45
+ }
46
+
47
+ /** Register a maker or factory under `name`; chains. Throws on a bad or already-registered name. */
48
+ register(name: string, source: MakerSource): this {
49
+ if (!NAME_PATTERN.test(name)) {
50
+ throw new DecisionRegistryError(`Invalid decision maker name '${name}' — must match ${NAME_PATTERN}.`);
51
+ }
52
+ if (this.entries.has(name)) {
53
+ throw new DecisionRegistryError(`Decision maker '${name}' is already registered.`);
54
+ }
55
+ this.entries.set(name, { source });
56
+ return this;
57
+ }
58
+
59
+ has(name: string): boolean {
60
+ return this.entries.has(name);
61
+ }
62
+
63
+ names(): string[] {
64
+ return [...this.entries.keys()];
65
+ }
66
+
67
+ /**
68
+ * Resolve `name` to a maker, running and memoising a factory on first use. Factory errors
69
+ * propagate unchanged. Concurrent resolves of one name share a single in-flight factory run
70
+ * so every caller gets the same instance; a rejected run is cleared so a later resolve retries.
71
+ */
72
+ async resolve(name: string): Promise<DecisionMaker> {
73
+ const entry = this.entries.get(name);
74
+ if (entry === undefined) {
75
+ throw new UnknownDecisionMakerError(
76
+ `Unknown decision maker '${name}' — register it or use a built-in.`,
77
+ name,
78
+ );
79
+ }
80
+ if (entry.maker) return entry.maker;
81
+ // A check-then-set across `await` would let two concurrent callers run the factory and
82
+ // receive different instances, so the run itself is memoised while in flight. The
83
+ // rejection teardown attaches via .catch instead of a try/finally inside the run: with a
84
+ // finally-based clear, Bun's test runner reports the settled rejection as an unhandled
85
+ // error when a later resolve retries (false positive).
86
+ if (!entry.pending) {
87
+ const run = (async () => {
88
+ const maker = typeof entry.source === 'function' ? await entry.source() : entry.source;
89
+ entry.maker = maker;
90
+ return maker;
91
+ })();
92
+ run.catch(() => {
93
+ if (entry.pending === run) entry.pending = undefined; // allow a later resolve to retry
94
+ });
95
+ entry.pending = run;
96
+ }
97
+ return await entry.pending;
98
+ }
99
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { Desc, Json } from '@gobing-ai/ts-ai-runner';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * Zod schema for the decision catalog — the runtime source of truth (task 0089 R4).
6
+ * `schemas/decision-catalog.schema.json` describes the same shape for editors and
7
+ * standard validators; strictness (`additionalProperties: false` / `.strict()`) applies
8
+ * at every level. Cross-field semantics (fallback vocabulary, template refs, defaults,
9
+ * reserved `instructions`) are enforced by the loader in `catalog.ts`.
10
+ */
11
+
12
+ export const DECISION_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
13
+ /** Declared parameter names: lowercase-start identifier, no hyphens. */
14
+ export const PARAM_NAME_PATTERN = /^[a-z][a-zA-Z0-9_]*$/;
15
+ /** Maker registry names: lowercase kebab-case. */
16
+ export const MAKER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
17
+
18
+ const JsonValueSchema: z.ZodType<Json> = z.lazy(() =>
19
+ z.union([
20
+ z.string(),
21
+ z.number(),
22
+ z.boolean(),
23
+ z.null(),
24
+ z.array(JsonValueSchema),
25
+ z.record(z.string(), JsonValueSchema),
26
+ ]),
27
+ );
28
+
29
+ /** A Jev entry: string, JSON object, JSON array, or null ("left undescribed"). */
30
+ const JevEntrySchema: z.ZodType<Desc> = z.lazy(() =>
31
+ z.union([z.string(), z.null(), z.array(JsonValueSchema), z.record(z.string(), JsonValueSchema)]),
32
+ );
33
+
34
+ const ParamTypeSchema = z.enum(['string', 'number', 'boolean', 'enum', 'json']);
35
+
36
+ /** Full parameter form; the shorthand is the bare {@link ParamTypeSchema} string. */
37
+ const FullParamSchema = z
38
+ .object({
39
+ type: ParamTypeSchema,
40
+ default: z.unknown().optional(),
41
+ values: z.array(z.string()).min(1).optional(),
42
+ description: z.string().optional(),
43
+ })
44
+ .strict();
45
+
46
+ /** Authored parameter form: the bare type shorthand or the full strict object. */
47
+ export const ParamSchema = z.union([ParamTypeSchema, FullParamSchema]);
48
+
49
+ const NoulCriteriaSchema = z.object({ true: JevEntrySchema.optional(), false: JevEntrySchema.optional() }).strict();
50
+ const ChoiceCriteriaSchema = z.record(z.string().min(1), JevEntrySchema);
51
+ const ScoreCriteriaSchema = z.array(JevEntrySchema);
52
+
53
+ /** One authored decision (strict; cross-field semantics are enforced by the loader in `catalog.ts`). */
54
+ export const DecisionSchema = z
55
+ .object({
56
+ type: z.enum(['choice', 'score', 'noul']),
57
+ description: z.string().optional(),
58
+ instructions: JevEntrySchema.optional(),
59
+ parameters: z.record(z.string().regex(PARAM_NAME_PATTERN), ParamSchema).optional(),
60
+ criteria: z.union([NoulCriteriaSchema, ChoiceCriteriaSchema, ScoreCriteriaSchema]).optional(),
61
+ fallback: z.union([z.string(), z.number(), z.boolean()]),
62
+ minConfidence: z.number().min(0).max(1).optional(),
63
+ maker: z.string().regex(MAKER_NAME_PATTERN).optional(),
64
+ model: z.string().optional(),
65
+ })
66
+ .strict();
67
+
68
+ /** Whole-catalog schema (strict; `version` must be 1 and at least one decision is required). */
69
+ export const DecisionCatalogSchema = z
70
+ .object({
71
+ $schema: z.string().optional(),
72
+ version: z.literal(1),
73
+ defaults: z
74
+ .object({
75
+ maker: z.string().regex(MAKER_NAME_PATTERN).optional(),
76
+ model: z.string().optional(),
77
+ minConfidence: z.number().min(0).max(1).optional(),
78
+ })
79
+ .strict()
80
+ .optional(),
81
+ decisions: z.record(z.string().regex(DECISION_ID_PATTERN), DecisionSchema),
82
+ })
83
+ .strict()
84
+ .refine((catalog) => Object.keys(catalog.decisions).length >= 1, {
85
+ message: 'decisions must declare at least one decision',
86
+ });
87
+
88
+ /** Inferred shape of a validated catalog body. */
89
+ export type ParsedCatalog = z.infer<typeof DecisionCatalogSchema>;
90
+ /** Inferred shape of one validated decision body. */
91
+ export type ParsedDecision = z.infer<typeof DecisionSchema>;
92
+ /** Inferred shape of the full (non-shorthand) parameter form. */
93
+ export type ParsedParam = z.infer<typeof FullParamSchema>;
94
+ /** The whole parameter union: the shorthand type string or the full form. */
95
+ export type ParsedParamUnion = z.infer<typeof ParamSchema>;
package/src/types.ts ADDED
@@ -0,0 +1,80 @@
1
+ import type { Desc, Json } from '@gobing-ai/ts-ai-runner';
2
+
3
+ /** Jev question types a decision can declare (task 0089 R4). */
4
+ export type DecisionType = 'choice' | 'score' | 'noul';
5
+
6
+ /** Declared parameter types; `enum` requires `values`, `json` accepts any JSON value. */
7
+ export type ParamType = 'string' | 'number' | 'boolean' | 'enum' | 'json';
8
+
9
+ /** One declared parameter, normalized from the shorthand (`name: type`) or full form. */
10
+ export interface DecisionParam {
11
+ readonly name: string;
12
+ readonly type: ParamType;
13
+ /** True when authored without a `default` key: every call must supply it. */
14
+ readonly required: boolean;
15
+ /** Authored `default` value (may be null); defined exactly when not required. */
16
+ readonly default?: Json;
17
+ /** Accepted strings for `type: enum` (at least one). */
18
+ readonly values?: readonly string[];
19
+ readonly description?: string;
20
+ }
21
+
22
+ /** A criteria/instructions entry: description string, JSON object, JSON array, or null ("undescribed"). */
23
+ export type JevEntry = Desc;
24
+
25
+ /** Normalized criteria of a decision, shaped by its `type`. */
26
+ export type DecisionCriteria =
27
+ | { readonly kind: 'choice'; readonly labels: Readonly<Record<string, JevEntry>> }
28
+ | { readonly kind: 'score'; readonly levels: readonly JevEntry[] }
29
+ | { readonly kind: 'noul'; readonly outcomes?: { readonly true?: JevEntry; readonly false?: JevEntry } };
30
+
31
+ /** One typed decision definition as produced by the loader. */
32
+ export interface DecisionDefinition {
33
+ readonly id: string;
34
+ readonly source: string;
35
+ readonly type: DecisionType;
36
+ /** Documentation only; never sent to the model. */
37
+ readonly description?: string;
38
+ /** Authored instructions template; {@link DEFAULT_INSTRUCTIONS_TEMPLATE} when absent. */
39
+ readonly instructions: JevEntry;
40
+ /** Declared parameters plus the implicit reserved `instructions` parameter. */
41
+ readonly parameters: Readonly<Record<string, DecisionParam>>;
42
+ readonly criteria: DecisionCriteria;
43
+ readonly fallback: string | number | boolean;
44
+ /** Overrides `defaults.minConfidence` (0..1). */
45
+ readonly minConfidence?: number;
46
+ /** Registry name, checked at hub load, not by the catalog loader. */
47
+ readonly maker?: string;
48
+ readonly model?: string;
49
+ }
50
+
51
+ /** Catalog-wide maker/model/minConfidence defaults; every key optional. */
52
+ export interface CatalogDefaults {
53
+ readonly maker?: string;
54
+ readonly model?: string;
55
+ readonly minConfidence?: number;
56
+ }
57
+
58
+ /** A loaded catalog: typed definitions, all-or-nothing (task 0089 invariant). */
59
+ export interface DecisionCatalog {
60
+ readonly source: string;
61
+ readonly defaults: CatalogDefaults;
62
+ readonly decisions: Readonly<Record<string, DecisionDefinition>>;
63
+ }
64
+
65
+ /** Fully resolved parameter values for one `decide` call, including `instructions`. */
66
+ export type ResolvedParams = Record<string, Json>;
67
+
68
+ /** The rendered Jev question for one decision, ready for the neutral surface. */
69
+ export type JevQuestion =
70
+ | {
71
+ readonly type: 'choice';
72
+ readonly instructions: JevEntry;
73
+ readonly criteria: Readonly<Record<string, JevEntry>>;
74
+ }
75
+ | { readonly type: 'score'; readonly instructions: JevEntry; readonly criteria: readonly JevEntry[] }
76
+ | {
77
+ readonly type: 'noul';
78
+ readonly instructions: JevEntry;
79
+ readonly criteria?: { readonly true?: JevEntry; readonly false?: JevEntry };
80
+ };