@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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "AI decision catalog",
|
|
4
|
+
"description": "Declarative decision catalog for @gobing-ai/ts-ai-decision (task 0089). Strict: unknown keys are rejected at every level. Cross-field checks (fallback vocabulary, ${params.*} references, defaults, reserved instructions parameter) run at load time.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["version", "decisions"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "Bundled package-specifier ref to this schema; validated when present."
|
|
12
|
+
},
|
|
13
|
+
"version": {
|
|
14
|
+
"const": 1
|
|
15
|
+
},
|
|
16
|
+
"defaults": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"properties": {
|
|
20
|
+
"maker": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"pattern": "^[a-z][a-z0-9-]*$",
|
|
23
|
+
"description": "Registered maker name (built-ins: typesafe | fm-local | laya-local)."
|
|
24
|
+
},
|
|
25
|
+
"model": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Forwarded as the ask-level model; omit for the backend default."
|
|
28
|
+
},
|
|
29
|
+
"minConfidence": {
|
|
30
|
+
"type": "number",
|
|
31
|
+
"minimum": 0,
|
|
32
|
+
"maximum": 1
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"decisions": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"minProperties": 1,
|
|
39
|
+
"propertyNames": {
|
|
40
|
+
"pattern": "^[a-z][a-z0-9_-]*$"
|
|
41
|
+
},
|
|
42
|
+
"additionalProperties": {
|
|
43
|
+
"$ref": "#/$defs/decision"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"$defs": {
|
|
48
|
+
"jsonValue": {
|
|
49
|
+
"anyOf": [
|
|
50
|
+
{
|
|
51
|
+
"type": "string"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"type": "number"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"type": "boolean"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"type": "null"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"type": "array",
|
|
64
|
+
"items": {
|
|
65
|
+
"$ref": "#/$defs/jsonValue"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"type": "object",
|
|
70
|
+
"additionalProperties": {
|
|
71
|
+
"$ref": "#/$defs/jsonValue"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
},
|
|
76
|
+
"jevEntry": {
|
|
77
|
+
"description": "A Jev entry: description string, JSON object, JSON array, or null (left undescribed). Description string leaves support ${params.*} templating.",
|
|
78
|
+
"anyOf": [
|
|
79
|
+
{
|
|
80
|
+
"type": "string"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"type": "null"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"type": "array",
|
|
87
|
+
"items": {
|
|
88
|
+
"$ref": "#/$defs/jsonValue"
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"type": "object",
|
|
93
|
+
"additionalProperties": {
|
|
94
|
+
"$ref": "#/$defs/jsonValue"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
"paramType": {
|
|
100
|
+
"enum": ["string", "number", "boolean", "enum", "json"]
|
|
101
|
+
},
|
|
102
|
+
"param": {
|
|
103
|
+
"anyOf": [
|
|
104
|
+
{
|
|
105
|
+
"$ref": "#/$defs/paramType"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"type": "object",
|
|
109
|
+
"additionalProperties": false,
|
|
110
|
+
"required": ["type"],
|
|
111
|
+
"properties": {
|
|
112
|
+
"type": {
|
|
113
|
+
"$ref": "#/$defs/paramType"
|
|
114
|
+
},
|
|
115
|
+
"default": {},
|
|
116
|
+
"values": {
|
|
117
|
+
"type": "array",
|
|
118
|
+
"items": {
|
|
119
|
+
"type": "string"
|
|
120
|
+
},
|
|
121
|
+
"minItems": 1,
|
|
122
|
+
"description": "Accepted strings for type: enum (at least one)."
|
|
123
|
+
},
|
|
124
|
+
"description": {
|
|
125
|
+
"type": "string"
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
},
|
|
131
|
+
"choiceCriteria": {
|
|
132
|
+
"type": "object",
|
|
133
|
+
"minProperties": 2,
|
|
134
|
+
"additionalProperties": {
|
|
135
|
+
"$ref": "#/$defs/jevEntry"
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
"scoreCriteria": {
|
|
139
|
+
"type": "array",
|
|
140
|
+
"minItems": 2,
|
|
141
|
+
"items": {
|
|
142
|
+
"$ref": "#/$defs/jevEntry"
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
"noulCriteria": {
|
|
146
|
+
"type": "object",
|
|
147
|
+
"additionalProperties": false,
|
|
148
|
+
"properties": {
|
|
149
|
+
"true": {
|
|
150
|
+
"$ref": "#/$defs/jevEntry"
|
|
151
|
+
},
|
|
152
|
+
"false": {
|
|
153
|
+
"$ref": "#/$defs/jevEntry"
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
"decision": {
|
|
158
|
+
"type": "object",
|
|
159
|
+
"additionalProperties": false,
|
|
160
|
+
"required": ["type", "fallback"],
|
|
161
|
+
"properties": {
|
|
162
|
+
"type": {
|
|
163
|
+
"enum": ["choice", "score", "noul"]
|
|
164
|
+
},
|
|
165
|
+
"description": {
|
|
166
|
+
"type": "string",
|
|
167
|
+
"description": "Documentation only; never sent to the model."
|
|
168
|
+
},
|
|
169
|
+
"instructions": {
|
|
170
|
+
"$ref": "#/$defs/jevEntry",
|
|
171
|
+
"description": "Templated Jev entry; defaults to \"${params.instructions}\"."
|
|
172
|
+
},
|
|
173
|
+
"parameters": {
|
|
174
|
+
"type": "object",
|
|
175
|
+
"propertyNames": {
|
|
176
|
+
"pattern": "^[a-z][a-zA-Z0-9_]*$"
|
|
177
|
+
},
|
|
178
|
+
"additionalProperties": {
|
|
179
|
+
"$ref": "#/$defs/param"
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
"criteria": {
|
|
183
|
+
"description": "choice: label map (>= 2 labels); score: ordered rubric (>= 2 levels); noul: optional { true?, false? }.",
|
|
184
|
+
"anyOf": [
|
|
185
|
+
{
|
|
186
|
+
"$ref": "#/$defs/choiceCriteria"
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
"$ref": "#/$defs/scoreCriteria"
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
"$ref": "#/$defs/noulCriteria"
|
|
193
|
+
}
|
|
194
|
+
]
|
|
195
|
+
},
|
|
196
|
+
"fallback": {
|
|
197
|
+
"description": "choice: a declared label; score: integer level in [0, levels-1]; noul: boolean.",
|
|
198
|
+
"anyOf": [
|
|
199
|
+
{
|
|
200
|
+
"type": "string"
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"type": "number"
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
"type": "boolean"
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
},
|
|
210
|
+
"minConfidence": {
|
|
211
|
+
"type": "number",
|
|
212
|
+
"minimum": 0,
|
|
213
|
+
"maximum": 1
|
|
214
|
+
},
|
|
215
|
+
"maker": {
|
|
216
|
+
"type": "string",
|
|
217
|
+
"pattern": "^[a-z][a-z0-9-]*$",
|
|
218
|
+
"description": "Registered maker name; checked by the hub, not the loader."
|
|
219
|
+
},
|
|
220
|
+
"model": {
|
|
221
|
+
"type": "string"
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { Json } from '@gobing-ai/ts-ai-runner';
|
|
2
|
+
import type { FileSystem } from '@gobing-ai/ts-runtime';
|
|
3
|
+
import { createNodeFileSystem, parseStructuredConfig } from '@gobing-ai/ts-runtime';
|
|
4
|
+
import { DecisionCatalogError } from './errors';
|
|
5
|
+
import { DEFAULT_INSTRUCTIONS_TEMPLATE, INSTRUCTIONS_PARAM, implicitInstructionsParam } from './params';
|
|
6
|
+
import { DecisionCatalogSchema, type ParsedCatalog, type ParsedDecision, type ParsedParamUnion } from './schema';
|
|
7
|
+
import type {
|
|
8
|
+
CatalogDefaults,
|
|
9
|
+
DecisionCatalog,
|
|
10
|
+
DecisionCriteria,
|
|
11
|
+
DecisionDefinition,
|
|
12
|
+
DecisionParam,
|
|
13
|
+
DecisionType,
|
|
14
|
+
JevEntry,
|
|
15
|
+
} from './types';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Catalog loading (task 0089 R5/R6): a thin layer over ts-runtime's
|
|
19
|
+
* `parseStructuredConfig` (YAML parse + `$schema` validation), then the zod
|
|
20
|
+
* schema, then the cross-field checks. Validation is all-or-nothing per
|
|
21
|
+
* catalog — a catalog that fails any check produces no definitions.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Same template regex as `ts-dual-workflow-engine`'s variable resolver. */
|
|
25
|
+
const TEMPLATE_REF = /\$\{([^}]+)\}/g;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Options for {@link loadDecisionCatalog} and {@link parseDecisionCatalog}.
|
|
29
|
+
*/
|
|
30
|
+
export interface CatalogLoadOptions {
|
|
31
|
+
/** Honour the top-level `$schema` when present. Default true. */
|
|
32
|
+
validateSchema?: boolean;
|
|
33
|
+
/** ts-runtime FileSystem used to read the catalog; defaults to `createNodeFileSystem()`. */
|
|
34
|
+
fileSystem?: Pick<FileSystem, 'readFile'>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const YAML_SOURCE_PATTERN = /\.(yaml|yml)$/;
|
|
38
|
+
|
|
39
|
+
function assertYamlSource(source: string): void {
|
|
40
|
+
if (!YAML_SOURCE_PATTERN.test(source)) {
|
|
41
|
+
throw new DecisionCatalogError(
|
|
42
|
+
`Decision catalogs are YAML only; source "${source}" does not end in .yaml or .yml`,
|
|
43
|
+
source,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Load and parse a YAML catalog file, returning typed decision definitions. */
|
|
49
|
+
export async function loadDecisionCatalog(path: string, options: CatalogLoadOptions = {}): Promise<DecisionCatalog> {
|
|
50
|
+
assertYamlSource(path);
|
|
51
|
+
const fileSystem = options.fileSystem ?? createNodeFileSystem();
|
|
52
|
+
let content: string;
|
|
53
|
+
try {
|
|
54
|
+
content = await fileSystem.readFile(path);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new DecisionCatalogError(
|
|
57
|
+
`Cannot read decision catalog "${path}": ${error instanceof Error ? error.message : String(error)}`,
|
|
58
|
+
path,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return parseDecisionCatalog(content, path, options);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Parse a catalog string (YAML) into typed decision definitions. The source must end in .yaml/.yml. */
|
|
65
|
+
export async function parseDecisionCatalog(
|
|
66
|
+
content: string,
|
|
67
|
+
source: string,
|
|
68
|
+
options: CatalogLoadOptions = {},
|
|
69
|
+
): Promise<DecisionCatalog> {
|
|
70
|
+
assertYamlSource(source);
|
|
71
|
+
let raw: unknown;
|
|
72
|
+
try {
|
|
73
|
+
raw = await parseStructuredConfig(content, source, {
|
|
74
|
+
validateSchema: options.validateSchema ?? true,
|
|
75
|
+
fileSystem: options.fileSystem,
|
|
76
|
+
});
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// YAML syntax (including duplicate keys) and $schema failures are load failures:
|
|
79
|
+
// wrap so callers always see DecisionCatalogError naming the source.
|
|
80
|
+
throw new DecisionCatalogError(
|
|
81
|
+
`Decision catalog "${source}" failed to parse or validate: ` +
|
|
82
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
83
|
+
source,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parsed = DecisionCatalogSchema.safeParse(raw);
|
|
88
|
+
if (!parsed.success) {
|
|
89
|
+
const issue = parsed.error.issues[0];
|
|
90
|
+
const fieldPath = issue?.path.map(String).join('.') ?? '';
|
|
91
|
+
const decisionId = fieldPath.startsWith('decisions.') ? fieldPath.split('.')[1] : undefined;
|
|
92
|
+
throw new DecisionCatalogError(
|
|
93
|
+
`Invalid decision catalog "${source}"${fieldPath ? ` at ${fieldPath}` : ''}: ${issue?.message ?? 'schema mismatch'}`,
|
|
94
|
+
source,
|
|
95
|
+
decisionId,
|
|
96
|
+
fieldPath || undefined,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return buildCatalog(parsed.data, source);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildCatalog(parsed: ParsedCatalog, source: string): DecisionCatalog {
|
|
103
|
+
const decisions: Record<string, DecisionDefinition> = {};
|
|
104
|
+
for (const [id, raw] of Object.entries(parsed.decisions)) {
|
|
105
|
+
decisions[id] = checkDecision(id, raw, source);
|
|
106
|
+
}
|
|
107
|
+
const defaults: CatalogDefaults = parsed.defaults ?? {};
|
|
108
|
+
return { source, defaults, decisions };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function catalogError(source: string, decisionId: string, field: string, message: string): DecisionCatalogError {
|
|
112
|
+
return new DecisionCatalogError(`Decision "${decisionId}" in "${source}": ${message}`, source, decisionId, field);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeParam(name: string, rawParam: ParsedParamUnion): DecisionParam {
|
|
116
|
+
if (typeof rawParam === 'string') return { name, type: rawParam, required: true };
|
|
117
|
+
const hasDefault = 'default' in rawParam;
|
|
118
|
+
return {
|
|
119
|
+
name,
|
|
120
|
+
type: rawParam.type,
|
|
121
|
+
required: !hasDefault,
|
|
122
|
+
...(hasDefault ? { default: rawParam.default as DecisionParam['default'] } : {}),
|
|
123
|
+
...(rawParam.values !== undefined ? { values: rawParam.values } : {}),
|
|
124
|
+
...(rawParam.description !== undefined ? { description: rawParam.description } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function checkParam(id: string, param: DecisionParam, source: string): void {
|
|
129
|
+
if (param.type === 'enum') {
|
|
130
|
+
if (param.values === undefined || param.values.length < 1) {
|
|
131
|
+
throw catalogError(
|
|
132
|
+
source,
|
|
133
|
+
id,
|
|
134
|
+
`parameters.${param.name}.values`,
|
|
135
|
+
'enum parameter requires at least one value',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
} else if (param.values !== undefined) {
|
|
139
|
+
throw catalogError(
|
|
140
|
+
source,
|
|
141
|
+
id,
|
|
142
|
+
`parameters.${param.name}.values`,
|
|
143
|
+
`only enum parameters may declare values; parameter type is "${param.type}"`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (!param.required && !defaultMatches(param)) {
|
|
147
|
+
throw catalogError(
|
|
148
|
+
source,
|
|
149
|
+
id,
|
|
150
|
+
`parameters.${param.name}.default`,
|
|
151
|
+
`default ${JSON.stringify(param.default ?? null)} does not match declared type "${param.type}"` +
|
|
152
|
+
(param.values ? ` with values [${param.values.join(', ')}]` : ''),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function defaultMatches(param: DecisionParam): boolean {
|
|
158
|
+
const value = param.default;
|
|
159
|
+
switch (param.type) {
|
|
160
|
+
case 'string':
|
|
161
|
+
return typeof value === 'string';
|
|
162
|
+
case 'number':
|
|
163
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
164
|
+
case 'boolean':
|
|
165
|
+
return typeof value === 'boolean';
|
|
166
|
+
case 'enum':
|
|
167
|
+
return typeof value === 'string' && param.values?.includes(value) === true;
|
|
168
|
+
case 'json':
|
|
169
|
+
return true; // any JSON value, including null
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeCriteria(id: string, raw: ParsedDecision, source: string): DecisionCriteria {
|
|
174
|
+
const criteria = raw.criteria;
|
|
175
|
+
if (raw.type === 'choice') {
|
|
176
|
+
if (criteria === undefined || Array.isArray(criteria) || typeof criteria !== 'object' || criteria === null) {
|
|
177
|
+
throw catalogError(source, id, 'criteria', 'choice decision requires a criteria label map');
|
|
178
|
+
}
|
|
179
|
+
const labels = criteria as Record<string, JevEntry>;
|
|
180
|
+
if (Object.keys(labels).length < 2) {
|
|
181
|
+
throw catalogError(source, id, 'criteria', 'choice criteria requires at least two labels');
|
|
182
|
+
}
|
|
183
|
+
return { kind: 'choice', labels };
|
|
184
|
+
}
|
|
185
|
+
if (raw.type === 'score') {
|
|
186
|
+
if (criteria === undefined) {
|
|
187
|
+
throw catalogError(source, id, 'criteria', 'score decision requires a criteria rubric list');
|
|
188
|
+
}
|
|
189
|
+
if (!Array.isArray(criteria)) {
|
|
190
|
+
throw catalogError(source, id, 'criteria', 'score criteria must be a list of level descriptions');
|
|
191
|
+
}
|
|
192
|
+
if (criteria.length < 2) {
|
|
193
|
+
throw catalogError(source, id, 'criteria', 'score criteria requires at least two levels');
|
|
194
|
+
}
|
|
195
|
+
return { kind: 'score', levels: criteria };
|
|
196
|
+
}
|
|
197
|
+
// noul: optional { true?, false? } (R4). The zod union may have matched the open
|
|
198
|
+
// record branch, so unknown outcome keys are rejected here.
|
|
199
|
+
if (criteria === undefined) return { kind: 'noul' };
|
|
200
|
+
if (Array.isArray(criteria) || typeof criteria !== 'object' || criteria === null) {
|
|
201
|
+
throw catalogError(source, id, 'criteria', 'noul criteria must be an object with "true"/"false" keys');
|
|
202
|
+
}
|
|
203
|
+
for (const key of Object.keys(criteria)) {
|
|
204
|
+
if (key !== 'true' && key !== 'false') {
|
|
205
|
+
throw catalogError(
|
|
206
|
+
source,
|
|
207
|
+
id,
|
|
208
|
+
'criteria',
|
|
209
|
+
`noul criteria accepts only the keys "true" and "false"; got "${key}"`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const outcomes = criteria as { true?: JevEntry; false?: JevEntry };
|
|
214
|
+
return {
|
|
215
|
+
kind: 'noul',
|
|
216
|
+
...(outcomes.true !== undefined || outcomes.false !== undefined
|
|
217
|
+
? {
|
|
218
|
+
outcomes: {
|
|
219
|
+
...(outcomes.true !== undefined ? { true: outcomes.true } : {}),
|
|
220
|
+
...(outcomes.false !== undefined ? { false: outcomes.false } : {}),
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
: {}),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function checkFallback(
|
|
228
|
+
id: string,
|
|
229
|
+
type: DecisionType,
|
|
230
|
+
criteria: DecisionCriteria,
|
|
231
|
+
fallback: string | number | boolean,
|
|
232
|
+
source: string,
|
|
233
|
+
): void {
|
|
234
|
+
if (type === 'choice') {
|
|
235
|
+
// Own-property check: prototype keys like "constructor" must not pass as labels.
|
|
236
|
+
if (criteria.kind !== 'choice' || typeof fallback !== 'string' || !Object.hasOwn(criteria.labels, fallback)) {
|
|
237
|
+
const labels = criteria.kind === 'choice' ? Object.keys(criteria.labels).join(', ') : '';
|
|
238
|
+
throw catalogError(
|
|
239
|
+
source,
|
|
240
|
+
id,
|
|
241
|
+
'fallback',
|
|
242
|
+
`fallback ${JSON.stringify(fallback)} is not a declared criteria label (${labels})`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (type === 'score') {
|
|
248
|
+
const levelCount = criteria.kind === 'score' ? criteria.levels.length : 0;
|
|
249
|
+
if (
|
|
250
|
+
criteria.kind !== 'score' ||
|
|
251
|
+
typeof fallback !== 'number' ||
|
|
252
|
+
!Number.isInteger(fallback) ||
|
|
253
|
+
fallback < 0 ||
|
|
254
|
+
fallback >= levelCount
|
|
255
|
+
) {
|
|
256
|
+
throw catalogError(
|
|
257
|
+
source,
|
|
258
|
+
id,
|
|
259
|
+
'fallback',
|
|
260
|
+
`fallback ${JSON.stringify(fallback)} is not an integer level in [0, ${levelCount - 1}]`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (typeof fallback !== 'boolean') {
|
|
266
|
+
throw catalogError(source, id, 'fallback', `noul fallback must be a boolean; got ${JSON.stringify(fallback)}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Collect every `${...}` reference inside string leaves of a JSON value (recursing objects/arrays). */
|
|
271
|
+
function collectTemplateRefs(value: JevEntry | Json, into: Set<string>): void {
|
|
272
|
+
if (typeof value === 'string') {
|
|
273
|
+
for (const match of value.matchAll(TEMPLATE_REF)) into.add(match[1] ?? '');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (Array.isArray(value)) {
|
|
277
|
+
for (const item of value) collectTemplateRefs(item, into);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (value !== null && typeof value === 'object') {
|
|
281
|
+
for (const entry of Object.values(value)) collectTemplateRefs(entry, into);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function checkTemplateRefs(
|
|
286
|
+
id: string,
|
|
287
|
+
field: string,
|
|
288
|
+
entry: JevEntry,
|
|
289
|
+
declared: ReadonlySet<string>,
|
|
290
|
+
source: string,
|
|
291
|
+
): void {
|
|
292
|
+
const refs = new Set<string>();
|
|
293
|
+
collectTemplateRefs(entry, refs);
|
|
294
|
+
for (const ref of refs) {
|
|
295
|
+
if (!ref.startsWith('params.')) {
|
|
296
|
+
throw catalogError(
|
|
297
|
+
source,
|
|
298
|
+
id,
|
|
299
|
+
field,
|
|
300
|
+
`template ref "\${${ref}}" uses namespace "${ref.split('.')[0] ?? ''}"; only "params" is allowed`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const name = ref.slice('params.'.length);
|
|
304
|
+
if (!declared.has(name)) {
|
|
305
|
+
throw catalogError(source, id, field, `template ref "\${${ref}}" names an undeclared parameter`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function checkDecision(id: string, raw: ParsedDecision, source: string): DecisionDefinition {
|
|
311
|
+
const parameters: Record<string, DecisionParam> = {};
|
|
312
|
+
const rawParams = raw.parameters ?? {};
|
|
313
|
+
if (INSTRUCTIONS_PARAM in rawParams) {
|
|
314
|
+
throw catalogError(
|
|
315
|
+
source,
|
|
316
|
+
id,
|
|
317
|
+
'parameters.instructions',
|
|
318
|
+
'the "instructions" parameter is reserved and implicit (string, default ""); it cannot be declared',
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
for (const [name, rawParam] of Object.entries(rawParams)) {
|
|
322
|
+
const param = normalizeParam(name, rawParam);
|
|
323
|
+
checkParam(id, param, source);
|
|
324
|
+
parameters[name] = param;
|
|
325
|
+
}
|
|
326
|
+
parameters[INSTRUCTIONS_PARAM] = implicitInstructionsParam();
|
|
327
|
+
|
|
328
|
+
const criteria = normalizeCriteria(id, raw, source);
|
|
329
|
+
checkFallback(id, raw.type, criteria, raw.fallback, source);
|
|
330
|
+
|
|
331
|
+
const instructions = raw.instructions ?? DEFAULT_INSTRUCTIONS_TEMPLATE;
|
|
332
|
+
const declared = new Set(Object.keys(parameters));
|
|
333
|
+
checkTemplateRefs(id, 'instructions', instructions, declared, source);
|
|
334
|
+
const criteriaEntries: JevEntry[] =
|
|
335
|
+
criteria.kind === 'choice'
|
|
336
|
+
? Object.values(criteria.labels)
|
|
337
|
+
: criteria.kind === 'score'
|
|
338
|
+
? [...criteria.levels]
|
|
339
|
+
: criteria.outcomes
|
|
340
|
+
? [criteria.outcomes.true, criteria.outcomes.false].filter((e): e is JevEntry => e !== undefined)
|
|
341
|
+
: [];
|
|
342
|
+
for (const entry of criteriaEntries) {
|
|
343
|
+
checkTemplateRefs(id, 'criteria', entry, declared, source);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
id,
|
|
348
|
+
source,
|
|
349
|
+
type: raw.type,
|
|
350
|
+
...(raw.description !== undefined ? { description: raw.description } : {}),
|
|
351
|
+
instructions,
|
|
352
|
+
parameters,
|
|
353
|
+
criteria,
|
|
354
|
+
fallback: raw.fallback,
|
|
355
|
+
...(raw.minConfidence !== undefined ? { minConfidence: raw.minConfidence } : {}),
|
|
356
|
+
...(raw.maker !== undefined ? { maker: raw.maker } : {}),
|
|
357
|
+
...(raw.model !== undefined ? { model: raw.model } : {}),
|
|
358
|
+
};
|
|
359
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DecisionCatalogError — task 0089 — load-time failure for a decision catalog.
|
|
3
|
+
* Names the source and, when known, the decision id and field, so an
|
|
4
|
+
* inconsistent catalog points straight at the offending key.
|
|
5
|
+
*/
|
|
6
|
+
export class DecisionCatalogError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
message: string,
|
|
9
|
+
readonly source: string,
|
|
10
|
+
readonly decisionId?: string,
|
|
11
|
+
readonly field?: string,
|
|
12
|
+
) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'DecisionCatalogError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* DecisionRegistryError — task 0091 — invalid DecisionMakerRegistry use: a name
|
|
20
|
+
* that fails `^[a-z][a-z0-9-]*$` or a duplicate registration.
|
|
21
|
+
*/
|
|
22
|
+
export class DecisionRegistryError extends Error {
|
|
23
|
+
constructor(message: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'DecisionRegistryError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* UnknownDecisionMakerError — task 0091 — resolve of a name that is not
|
|
31
|
+
* registered. The `name` property carries the unknown maker name; the class is
|
|
32
|
+
* identified via `instanceof`, because assigning `this.name` would clobber it.
|
|
33
|
+
*/
|
|
34
|
+
export class UnknownDecisionMakerError extends Error {
|
|
35
|
+
constructor(
|
|
36
|
+
message: string,
|
|
37
|
+
override readonly name: string,
|
|
38
|
+
) {
|
|
39
|
+
super(message);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* UnknownDecisionError — task 0090 — decide/describe of a decision id that no
|
|
45
|
+
* loaded catalog declares. A caller mistake: it throws instead of falling
|
|
46
|
+
* back, before any backend call. The `decisionId` property carries the id; the
|
|
47
|
+
* class is identified via `instanceof`, like UnknownDecisionMakerError.
|
|
48
|
+
*/
|
|
49
|
+
export class UnknownDecisionError extends Error {
|
|
50
|
+
constructor(
|
|
51
|
+
message: string,
|
|
52
|
+
readonly decisionId: string,
|
|
53
|
+
) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = 'UnknownDecisionError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* DecisionInputError — task 0089 R7 — violation of one decision's parameter
|
|
61
|
+
* contract: unknown key, missing required parameter, or type mismatch.
|
|
62
|
+
*/
|
|
63
|
+
export class DecisionInputError extends Error {
|
|
64
|
+
constructor(
|
|
65
|
+
message: string,
|
|
66
|
+
readonly decisionId: string,
|
|
67
|
+
readonly param: string,
|
|
68
|
+
) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = 'DecisionInputError';
|
|
71
|
+
}
|
|
72
|
+
}
|