@siftline/core 0.0.3 → 0.1.1
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 +34 -7
- package/dist/client-B_yz9_JS.d.mts +162 -0
- package/dist/index.d.mts +337 -10
- package/dist/index.mjs +656 -10
- package/dist/jsonl-CzTtnvyn.mjs +139 -0
- package/dist/testing.d.mts +128 -9
- package/dist/testing.mjs +158 -5
- package/package.json +2 -1
package/dist/index.mjs
CHANGED
|
@@ -1,16 +1,662 @@
|
|
|
1
|
+
import { a as entryType, c as parseRecipe, d as recipeSchema, f as score, i as defineRecipe, l as questionName, n as decodeThrown, p as serializeRecipe, r as choice, s as noul, t as parseJsonLines, u as questionSchema } from "./jsonl-CzTtnvyn.mjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region package.json
|
|
4
|
+
var version = "0.1.1";
|
|
1
5
|
//#endregion
|
|
2
|
-
//#region src/
|
|
6
|
+
//#region src/decision.ts
|
|
7
|
+
/** Strict: an unknown key is a bad Record, not a field to ignore. */
|
|
8
|
+
const recordSchema = z.object({
|
|
9
|
+
id: z.string().min(1),
|
|
10
|
+
state: entryType,
|
|
11
|
+
trimmed: z.boolean().optional()
|
|
12
|
+
}).strict();
|
|
13
|
+
const answerValue = z.union([
|
|
14
|
+
z.string(),
|
|
15
|
+
z.boolean(),
|
|
16
|
+
z.number()
|
|
17
|
+
]);
|
|
18
|
+
const label = z.string();
|
|
19
|
+
const level = z.number().int().min(0);
|
|
3
20
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Changesets bumps `package.json`; this constant follows it without a second edit.
|
|
21
|
+
* Why `value` is not an answer to `asked`, or `null` when it is. Rules and Fixtures both
|
|
22
|
+
* validate against the Recipe with it, so the two report the same words.
|
|
7
23
|
*/
|
|
8
|
-
|
|
24
|
+
function answerValueProblem(asked, value) {
|
|
25
|
+
const shown = JSON.stringify(value);
|
|
26
|
+
if (asked.type === "choice") {
|
|
27
|
+
const parsed = label.safeParse(value);
|
|
28
|
+
if (!parsed.success) return `value ${shown} is not a label`;
|
|
29
|
+
return Object.hasOwn(asked.criteria, parsed.data) ? null : `unknown label ${shown}`;
|
|
30
|
+
}
|
|
31
|
+
if (asked.type === "noul") return value === true || value === false ? null : `value ${shown} is not a boolean`;
|
|
32
|
+
const last = asked.criteria.length - 1;
|
|
33
|
+
return level.max(last).safeParse(value).success ? null : `level index ${shown} is out of range (0-${last})`;
|
|
34
|
+
}
|
|
35
|
+
const unit = z.number().min(0).max(1);
|
|
36
|
+
const levelIndex = z.string().regex(/^\d+$/);
|
|
37
|
+
const choiceEvidence = z.object({
|
|
38
|
+
confidence: unit,
|
|
39
|
+
probabilities: z.record(z.string().min(1), unit)
|
|
40
|
+
}).strict();
|
|
41
|
+
const noulEvidence = z.object({
|
|
42
|
+
probability: unit,
|
|
43
|
+
confidence: unit
|
|
44
|
+
}).strict();
|
|
45
|
+
const scoreEvidence = z.object({
|
|
46
|
+
score: z.number(),
|
|
47
|
+
confidence: unit,
|
|
48
|
+
probabilities: z.record(levelIndex, unit)
|
|
49
|
+
}).strict();
|
|
50
|
+
const evidence = z.union([
|
|
51
|
+
noulEvidence,
|
|
52
|
+
scoreEvidence,
|
|
53
|
+
choiceEvidence
|
|
54
|
+
]);
|
|
55
|
+
const decisionSchema = z.object({
|
|
56
|
+
format: z.literal(1),
|
|
57
|
+
id: z.string().min(1),
|
|
58
|
+
recordId: z.string().min(1),
|
|
59
|
+
recipe: z.object({
|
|
60
|
+
name: z.string().min(1),
|
|
61
|
+
version: z.number().int().min(1)
|
|
62
|
+
}).strict(),
|
|
63
|
+
model: z.string().min(1),
|
|
64
|
+
judgedAt: z.iso.datetime(),
|
|
65
|
+
trimmed: z.boolean(),
|
|
66
|
+
answers: z.record(questionName, answerValue),
|
|
67
|
+
questions: z.record(questionName, evidence),
|
|
68
|
+
confidence: unit,
|
|
69
|
+
review: z.boolean(),
|
|
70
|
+
rule: z.string().min(1).nullable(),
|
|
71
|
+
action: z.string().min(1).nullable(),
|
|
72
|
+
usage: z.object({
|
|
73
|
+
inputTokens: z.number().int().min(0),
|
|
74
|
+
outputTokens: z.number().int().min(0)
|
|
75
|
+
}).strict()
|
|
76
|
+
}).strict();
|
|
77
|
+
function parseDecision(line) {
|
|
78
|
+
return decisionSchema.parse(JSON.parse(line));
|
|
79
|
+
}
|
|
80
|
+
function orderEvidence(block) {
|
|
81
|
+
if ("probability" in block) return {
|
|
82
|
+
probability: block.probability,
|
|
83
|
+
confidence: block.confidence
|
|
84
|
+
};
|
|
85
|
+
if ("score" in block) return {
|
|
86
|
+
score: block.score,
|
|
87
|
+
confidence: block.confidence,
|
|
88
|
+
probabilities: block.probabilities
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
confidence: block.confidence,
|
|
92
|
+
probabilities: block.probabilities
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** The only writer. One compact line, no trailing newline, and nothing is rounded. */
|
|
96
|
+
function serializeDecision(decision) {
|
|
97
|
+
const questions = {};
|
|
98
|
+
for (const [name, block] of Object.entries(decision.questions)) questions[name] = orderEvidence(block);
|
|
99
|
+
return JSON.stringify({
|
|
100
|
+
format: decision.format,
|
|
101
|
+
id: decision.id,
|
|
102
|
+
recordId: decision.recordId,
|
|
103
|
+
recipe: {
|
|
104
|
+
name: decision.recipe.name,
|
|
105
|
+
version: decision.recipe.version
|
|
106
|
+
},
|
|
107
|
+
model: decision.model,
|
|
108
|
+
judgedAt: decision.judgedAt,
|
|
109
|
+
trimmed: decision.trimmed,
|
|
110
|
+
answers: decision.answers,
|
|
111
|
+
questions,
|
|
112
|
+
confidence: decision.confidence,
|
|
113
|
+
review: decision.review,
|
|
114
|
+
rule: decision.rule,
|
|
115
|
+
action: decision.action,
|
|
116
|
+
usage: {
|
|
117
|
+
inputTokens: decision.usage.inputTokens,
|
|
118
|
+
outputTokens: decision.usage.outputTokens
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/errors.ts
|
|
124
|
+
/** The base every toolkit error extends, in core and in `@siftline/actions`. */
|
|
125
|
+
var SiftlineError = class extends Error {
|
|
126
|
+
code;
|
|
127
|
+
retryable;
|
|
128
|
+
constructor(message, code, retryable, options) {
|
|
129
|
+
super(message, options);
|
|
130
|
+
this.name = new.target.name;
|
|
131
|
+
this.code = code;
|
|
132
|
+
this.retryable = retryable;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/rules.ts
|
|
137
|
+
const ruleConditionSchema = z.discriminatedUnion("comparator", [
|
|
138
|
+
z.object({
|
|
139
|
+
question: questionName,
|
|
140
|
+
comparator: z.literal("is"),
|
|
141
|
+
value: z.union([
|
|
142
|
+
z.string().min(1),
|
|
143
|
+
z.boolean(),
|
|
144
|
+
z.number().int().min(0)
|
|
145
|
+
])
|
|
146
|
+
}).strict(),
|
|
147
|
+
z.object({
|
|
148
|
+
question: questionName,
|
|
149
|
+
comparator: z.literal("isOneOf"),
|
|
150
|
+
value: z.array(z.string().min(1)).min(1)
|
|
151
|
+
}).strict(),
|
|
152
|
+
z.object({
|
|
153
|
+
question: questionName,
|
|
154
|
+
comparator: z.literal("atLeast"),
|
|
155
|
+
value: z.number().int().min(0)
|
|
156
|
+
}).strict(),
|
|
157
|
+
z.object({
|
|
158
|
+
question: questionName,
|
|
159
|
+
comparator: z.literal("atMost"),
|
|
160
|
+
value: z.number().int().min(0)
|
|
161
|
+
}).strict()
|
|
162
|
+
]);
|
|
163
|
+
const ruleSchema = z.object({
|
|
164
|
+
id: z.string().min(1),
|
|
165
|
+
condition: ruleConditionSchema,
|
|
166
|
+
action: z.string().min(1).nullable()
|
|
167
|
+
}).strict();
|
|
168
|
+
const levelAnswer = z.number();
|
|
169
|
+
function matches(answers, condition) {
|
|
170
|
+
const actual = answers[condition.question];
|
|
171
|
+
if (actual === void 0) return false;
|
|
172
|
+
if (condition.comparator === "is") return actual === condition.value;
|
|
173
|
+
if (condition.comparator === "isOneOf") return condition.value.some((label) => label === actual);
|
|
174
|
+
const level = levelAnswer.safeParse(actual);
|
|
175
|
+
if (!level.success) return false;
|
|
176
|
+
return condition.comparator === "atLeast" ? level.data >= condition.value : level.data <= condition.value;
|
|
177
|
+
}
|
|
178
|
+
/** Pure, first match wins, no review gate. Cloud runs it again on corrected answers. */
|
|
179
|
+
function evaluateRules(answers, rules) {
|
|
180
|
+
for (const rule of rules) if (matches(answers, rule.condition)) return {
|
|
181
|
+
rule: rule.id,
|
|
182
|
+
action: rule.action
|
|
183
|
+
};
|
|
184
|
+
return {
|
|
185
|
+
rule: null,
|
|
186
|
+
action: null
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function routeDecision(decision, rules) {
|
|
190
|
+
if (decision.review) return {
|
|
191
|
+
...decision,
|
|
192
|
+
rule: null,
|
|
193
|
+
action: null
|
|
194
|
+
};
|
|
195
|
+
return {
|
|
196
|
+
...decision,
|
|
197
|
+
...evaluateRules(decision.answers, rules)
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const comparatorsFor = {
|
|
201
|
+
choice: ["is", "isOneOf"],
|
|
202
|
+
noul: ["is"],
|
|
203
|
+
score: [
|
|
204
|
+
"is",
|
|
205
|
+
"atLeast",
|
|
206
|
+
"atMost"
|
|
207
|
+
]
|
|
208
|
+
};
|
|
209
|
+
/** The portal's stale-Rule warning. Never runs inside `evaluateRules`. */
|
|
210
|
+
function validateRules(rules, recipe) {
|
|
211
|
+
const problems = [];
|
|
212
|
+
const seen = /* @__PURE__ */ new Set();
|
|
213
|
+
for (const rule of rules) {
|
|
214
|
+
const report = (problem) => {
|
|
215
|
+
problems.push({
|
|
216
|
+
rule: rule.id,
|
|
217
|
+
problem
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
if (seen.has(rule.id)) report(`duplicate id "${rule.id}"`);
|
|
221
|
+
seen.add(rule.id);
|
|
222
|
+
const { question, comparator, value } = rule.condition;
|
|
223
|
+
const asked = recipe.questions[question];
|
|
224
|
+
if (!asked) {
|
|
225
|
+
report(`unknown question "${question}"`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (!comparatorsFor[asked.type].some((allowed) => allowed === comparator)) {
|
|
229
|
+
report(`comparator "${comparator}" is not valid for a ${asked.type} question`);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
for (const candidate of Array.isArray(value) ? value : [value]) {
|
|
233
|
+
const problem = answerValueProblem(asked, candidate);
|
|
234
|
+
if (problem !== null) report(problem);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return problems;
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/judge.ts
|
|
241
|
+
/** The gate's width when the caller sets none (cloud ADR 0005). */
|
|
242
|
+
const DEFAULT_MAX_IN_FLIGHT = 8;
|
|
9
243
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* the first recipe lands.
|
|
244
|
+
* The client gave up on a 429 or a 529. The fallback delay when `retryAfterMs` is `null`
|
|
245
|
+
* belongs to the caller.
|
|
13
246
|
*/
|
|
14
|
-
|
|
247
|
+
var JudgeExhaustedError = class extends SiftlineError {
|
|
248
|
+
retryAfterMs;
|
|
249
|
+
constructor(message, retryAfterMs, options) {
|
|
250
|
+
super(message, "jev_exhausted", true, options);
|
|
251
|
+
this.retryAfterMs = retryAfterMs;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
/** Everything else a judged call can fail on. The thrown value stays on `cause`. */
|
|
255
|
+
var JudgeError = class extends SiftlineError {
|
|
256
|
+
status;
|
|
257
|
+
reason;
|
|
258
|
+
constructor(message, reason, status, options) {
|
|
259
|
+
super(message, "jev_error", reason === "network" || reason === "timeout", options);
|
|
260
|
+
this.status = status;
|
|
261
|
+
this.reason = reason;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
const RETRY_MODES = {
|
|
265
|
+
prompt: {
|
|
266
|
+
retry: {
|
|
267
|
+
maxRetries: 1,
|
|
268
|
+
backoffMaxMs: 2e3,
|
|
269
|
+
maxRetryAfterMs: 5e3
|
|
270
|
+
},
|
|
271
|
+
timeout: 1e4
|
|
272
|
+
},
|
|
273
|
+
patient: {
|
|
274
|
+
retry: {
|
|
275
|
+
maxRetries: 5,
|
|
276
|
+
backoffMaxMs: 3e4,
|
|
277
|
+
maxRetryAfterMs: 6e4
|
|
278
|
+
},
|
|
279
|
+
timeout: 3e4
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
/**
|
|
283
|
+
* FIFO, unbounded wait. A slot is held for the whole `systemOne` call, the SDK's internal
|
|
284
|
+
* retries included, and an aborted waiter leaves the queue without ever taking one.
|
|
285
|
+
*/
|
|
286
|
+
function createGate(limit) {
|
|
287
|
+
let inFlight = 0;
|
|
288
|
+
const queue = [];
|
|
289
|
+
function release() {
|
|
290
|
+
const next = queue.shift();
|
|
291
|
+
if (next) next.admit();
|
|
292
|
+
else inFlight -= 1;
|
|
293
|
+
}
|
|
294
|
+
return async (signal) => {
|
|
295
|
+
signal?.throwIfAborted();
|
|
296
|
+
if (inFlight < limit) {
|
|
297
|
+
inFlight += 1;
|
|
298
|
+
return release;
|
|
299
|
+
}
|
|
300
|
+
await new Promise((resolve, reject) => {
|
|
301
|
+
let onAbort;
|
|
302
|
+
const waiter = { admit: () => {
|
|
303
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
304
|
+
resolve();
|
|
305
|
+
} };
|
|
306
|
+
if (signal) {
|
|
307
|
+
onAbort = () => {
|
|
308
|
+
const index = queue.indexOf(waiter);
|
|
309
|
+
if (index >= 0) queue.splice(index, 1);
|
|
310
|
+
reject(signal.reason);
|
|
311
|
+
};
|
|
312
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
313
|
+
}
|
|
314
|
+
queue.push(waiter);
|
|
315
|
+
});
|
|
316
|
+
return release;
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const errorBodySchema = z.object({ detail: z.object({ error_type: z.string() }) });
|
|
320
|
+
function reasonOf(thrown, status) {
|
|
321
|
+
if (status === null) return thrown.name !== void 0 && /timeout/i.test(thrown.name) ? "timeout" : "network";
|
|
322
|
+
const body = errorBodySchema.safeParse(thrown.body);
|
|
323
|
+
const type = body.success ? body.data.detail.error_type : void 0;
|
|
324
|
+
if (type === "api_usage_error" || type === "max_tokens_exceeded") return type;
|
|
325
|
+
return "unknown";
|
|
326
|
+
}
|
|
327
|
+
function isAbort(cause, thrown, signal) {
|
|
328
|
+
if (signal?.aborted === true && cause === signal.reason) return true;
|
|
329
|
+
return thrown.name === "AbortError" || thrown.name === "APIUserAbortError";
|
|
330
|
+
}
|
|
331
|
+
/** Core cannot import the SDK's error classes, so it decodes their fields. Never returns. */
|
|
332
|
+
function mapClientError(cause, signal) {
|
|
333
|
+
const thrown = decodeThrown(cause);
|
|
334
|
+
if (isAbort(cause, thrown, signal)) throw cause;
|
|
335
|
+
const status = thrown.status ?? null;
|
|
336
|
+
const message = thrown.message !== void 0 && thrown.message !== "" ? thrown.message : String(cause);
|
|
337
|
+
if (status === 429 || status === 529) throw new JudgeExhaustedError(message, thrown.retryAfterMs ?? null, { cause });
|
|
338
|
+
throw new JudgeError(message, reasonOf(thrown, status), status, { cause });
|
|
339
|
+
}
|
|
340
|
+
function invalidAnswers(message) {
|
|
341
|
+
return new JudgeError(message, "invalid_answers", null);
|
|
342
|
+
}
|
|
343
|
+
/** Lowest key wins a tie, so the caller reads insertion order as the tie-break. */
|
|
344
|
+
function argmax(probabilities) {
|
|
345
|
+
let best = "";
|
|
346
|
+
let top = Number.NEGATIVE_INFINITY;
|
|
347
|
+
for (const [key, probability] of Object.entries(probabilities)) if (probability > top) {
|
|
348
|
+
top = probability;
|
|
349
|
+
best = key;
|
|
350
|
+
}
|
|
351
|
+
return best;
|
|
352
|
+
}
|
|
353
|
+
function rebuild(name, keys, source) {
|
|
354
|
+
const probabilities = {};
|
|
355
|
+
for (const key of keys) {
|
|
356
|
+
const probability = source[key];
|
|
357
|
+
if (probability === void 0) throw invalidAnswers(`question ${name} has no probability for ${key}`);
|
|
358
|
+
probabilities[key] = probability;
|
|
359
|
+
}
|
|
360
|
+
return probabilities;
|
|
361
|
+
}
|
|
362
|
+
function mismatch(name, question, answer) {
|
|
363
|
+
return invalidAnswers(`question ${name} is a ${question.type}, answered as a ${answer.type}`);
|
|
364
|
+
}
|
|
365
|
+
function mapAnswer(name, question, answer) {
|
|
366
|
+
if (answer.type === "choice") {
|
|
367
|
+
if (question.type !== "choice") throw mismatch(name, question, answer);
|
|
368
|
+
const probabilities = rebuild(name, Object.keys(question.criteria), answer.probabilities);
|
|
369
|
+
return {
|
|
370
|
+
answer: argmax(probabilities),
|
|
371
|
+
evidence: {
|
|
372
|
+
confidence: answer.confidence,
|
|
373
|
+
probabilities
|
|
374
|
+
},
|
|
375
|
+
confidence: answer.confidence
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
if (answer.type === "score") {
|
|
379
|
+
if (question.type !== "score") throw mismatch(name, question, answer);
|
|
380
|
+
const probabilities = rebuild(name, question.criteria.map((_criterion, index) => String(index)), answer.probabilities);
|
|
381
|
+
return {
|
|
382
|
+
answer: Number(argmax(probabilities)),
|
|
383
|
+
evidence: {
|
|
384
|
+
score: answer.score,
|
|
385
|
+
confidence: answer.confidence,
|
|
386
|
+
probabilities
|
|
387
|
+
},
|
|
388
|
+
confidence: answer.confidence
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
if (question.type !== "noul") throw mismatch(name, question, answer);
|
|
392
|
+
const probability = answer.noul;
|
|
393
|
+
const confidence = Math.round(Math.abs(2 * probability - 1) * 100) / 100;
|
|
394
|
+
return {
|
|
395
|
+
answer: probability >= .5,
|
|
396
|
+
evidence: {
|
|
397
|
+
probability,
|
|
398
|
+
confidence
|
|
399
|
+
},
|
|
400
|
+
confidence
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/** Rebuilt in Recipe insertion order, which is what `serializeDecision` writes out. */
|
|
404
|
+
function mapAnswers(recipe, result) {
|
|
405
|
+
const names = Object.keys(recipe.questions);
|
|
406
|
+
const answered = Object.keys(result.answers);
|
|
407
|
+
if (answered.length !== names.length || !names.every((name) => name in result.answers)) throw invalidAnswers(`the model answered [${answered.join(", ")}], the Recipe asks [${names.join(", ")}]`);
|
|
408
|
+
const answers = {};
|
|
409
|
+
const questions = {};
|
|
410
|
+
const confidences = [];
|
|
411
|
+
for (const [name, question] of Object.entries(recipe.questions)) {
|
|
412
|
+
const response = result.answers[name];
|
|
413
|
+
if (!response) throw invalidAnswers(`question ${name} went unanswered`);
|
|
414
|
+
const mapped = mapAnswer(name, question, response);
|
|
415
|
+
answers[name] = mapped.answer;
|
|
416
|
+
questions[name] = mapped.evidence;
|
|
417
|
+
confidences.push(mapped.confidence);
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
answers,
|
|
421
|
+
questions,
|
|
422
|
+
confidence: Math.min(...confidences)
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
function createJudge(options) {
|
|
426
|
+
const { client } = options;
|
|
427
|
+
const { retry, timeout } = RETRY_MODES[options.retry];
|
|
428
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
429
|
+
const acquire = createGate(options.maxInFlight ?? 8);
|
|
430
|
+
return async (record, recipe, callOptions = {}) => {
|
|
431
|
+
const { signal } = callOptions;
|
|
432
|
+
const release = await acquire(signal);
|
|
433
|
+
let result;
|
|
434
|
+
try {
|
|
435
|
+
result = await client.systemOne({
|
|
436
|
+
state: record.state,
|
|
437
|
+
questions: recipe.questions,
|
|
438
|
+
model: recipe.model
|
|
439
|
+
}, {
|
|
440
|
+
retry,
|
|
441
|
+
signal,
|
|
442
|
+
timeout
|
|
443
|
+
});
|
|
444
|
+
} catch (cause) {
|
|
445
|
+
mapClientError(cause, signal);
|
|
446
|
+
} finally {
|
|
447
|
+
release();
|
|
448
|
+
}
|
|
449
|
+
const mapped = mapAnswers(recipe, result);
|
|
450
|
+
const { answers, questions } = mapped;
|
|
451
|
+
return {
|
|
452
|
+
format: 1,
|
|
453
|
+
id: callOptions.id ?? crypto.randomUUID(),
|
|
454
|
+
recordId: record.id,
|
|
455
|
+
recipe: {
|
|
456
|
+
name: recipe.name,
|
|
457
|
+
version: recipe.version
|
|
458
|
+
},
|
|
459
|
+
model: result.model,
|
|
460
|
+
judgedAt: now().toISOString(),
|
|
461
|
+
trimmed: record.trimmed ?? false,
|
|
462
|
+
answers,
|
|
463
|
+
questions,
|
|
464
|
+
confidence: mapped.confidence,
|
|
465
|
+
review: mapped.confidence < recipe.reviewThreshold,
|
|
466
|
+
rule: null,
|
|
467
|
+
action: null,
|
|
468
|
+
usage: {
|
|
469
|
+
inputTokens: result.usage.input_tokens,
|
|
470
|
+
outputTokens: result.usage.output_tokens
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/fixtures.ts
|
|
477
|
+
const fixtureSchema = z.object({
|
|
478
|
+
id: z.string().min(1).optional(),
|
|
479
|
+
origin: z.string().min(1).optional(),
|
|
480
|
+
by: z.string().min(1).optional(),
|
|
481
|
+
state: entryType,
|
|
482
|
+
expect: z.record(questionName, answerValue).refine((e) => Object.keys(e).length >= 1, "at least one expectation")
|
|
483
|
+
}).strict();
|
|
484
|
+
/** A bad line, named by its 1-based position. Core never reads files, so there is no path. */
|
|
485
|
+
var FixtureParseError = class extends SiftlineError {
|
|
486
|
+
line;
|
|
487
|
+
constructor(message, line, options) {
|
|
488
|
+
super(message, "fixture_invalid", false, options);
|
|
489
|
+
this.line = line;
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
function parseFixture(line) {
|
|
493
|
+
return fixtureSchema.parse(JSON.parse(line));
|
|
494
|
+
}
|
|
495
|
+
/** The only writer. One compact line, no trailing newline, absent optionals omitted. */
|
|
496
|
+
function serializeFixture(fixture) {
|
|
497
|
+
const { id, origin, by, state, expect } = fixture;
|
|
498
|
+
return JSON.stringify({
|
|
499
|
+
id,
|
|
500
|
+
origin,
|
|
501
|
+
by,
|
|
502
|
+
state,
|
|
503
|
+
expect
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
function parseFixtures(text) {
|
|
507
|
+
return parseJsonLines(text, parseFixture, (line, cause) => new FixtureParseError(`fixture line ${line} is not a valid Fixture`, line, { cause }));
|
|
508
|
+
}
|
|
509
|
+
var FixtureValidationError = class extends SiftlineError {
|
|
510
|
+
problems;
|
|
511
|
+
constructor(problems, options) {
|
|
512
|
+
super(summarize(problems), "fixture_invalid", false, options);
|
|
513
|
+
this.problems = problems;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
/** The first problem, and how many stand behind it. */
|
|
517
|
+
function summarize(problems) {
|
|
518
|
+
const first = problems[0];
|
|
519
|
+
if (!first) return "the Fixtures do not fit the Recipe";
|
|
520
|
+
const head = `${first.fixture}: ${first.problem}`;
|
|
521
|
+
return problems.length === 1 ? head : `${head} (and ${problems.length - 1} more)`;
|
|
522
|
+
}
|
|
523
|
+
/** The portal's stale-Fixture warning, and what the runner refuses to start on. */
|
|
524
|
+
function validateFixtures(fixtures, recipe) {
|
|
525
|
+
const problems = [];
|
|
526
|
+
const seen = /* @__PURE__ */ new Set();
|
|
527
|
+
for (const [offset, fixture] of fixtures.entries()) {
|
|
528
|
+
const named = fixture.id ?? String(offset + 1);
|
|
529
|
+
const report = (problem) => {
|
|
530
|
+
problems.push({
|
|
531
|
+
fixture: named,
|
|
532
|
+
problem
|
|
533
|
+
});
|
|
534
|
+
};
|
|
535
|
+
if (fixture.id !== void 0) {
|
|
536
|
+
if (seen.has(fixture.id)) report(`duplicate id "${fixture.id}"`);
|
|
537
|
+
seen.add(fixture.id);
|
|
538
|
+
}
|
|
539
|
+
for (const [question, expected] of Object.entries(fixture.expect)) {
|
|
540
|
+
const asked = recipe.questions[question];
|
|
541
|
+
if (!asked) {
|
|
542
|
+
report(`unknown question "${question}"`);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const problem = answerValueProblem(asked, expected);
|
|
546
|
+
if (problem !== null) report(`${problem} for question "${question}"`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return problems;
|
|
550
|
+
}
|
|
551
|
+
function defineFixtures(recipe, fixtures) {
|
|
552
|
+
for (const fixture of fixtures) fixtureSchema.parse(fixture);
|
|
553
|
+
const problems = validateFixtures(fixtures, recipe);
|
|
554
|
+
if (problems.length > 0) throw new FixtureValidationError(problems);
|
|
555
|
+
return fixtures.map((fixture) => ({
|
|
556
|
+
...fixture,
|
|
557
|
+
expect: inRecipeOrder(fixture.expect, recipe)
|
|
558
|
+
}));
|
|
559
|
+
}
|
|
560
|
+
function inRecipeOrder(expect, recipe) {
|
|
561
|
+
const ordered = {};
|
|
562
|
+
for (const question of Object.keys(recipe.questions)) {
|
|
563
|
+
const expected = expect[question];
|
|
564
|
+
if (expected !== void 0) ordered[question] = expected;
|
|
565
|
+
}
|
|
566
|
+
return ordered;
|
|
567
|
+
}
|
|
568
|
+
/** Pure. Strict equality per type, no tolerance, in `expect` insertion order. */
|
|
569
|
+
function compareAnswers(expect, answers) {
|
|
570
|
+
const mismatches = [];
|
|
571
|
+
for (const [question, expected] of Object.entries(expect)) {
|
|
572
|
+
if (expected === void 0) continue;
|
|
573
|
+
const actual = answers[question];
|
|
574
|
+
if (actual !== expected) mismatches.push({
|
|
575
|
+
question,
|
|
576
|
+
expected,
|
|
577
|
+
actual
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
return mismatches;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* The pure half. A Question absent from a Fixture's `expect` leaves that Question's
|
|
584
|
+
* denominator; an unsure Decision counts on its answers, because the threshold is a routing
|
|
585
|
+
* knob and must not hide errors.
|
|
586
|
+
*/
|
|
587
|
+
function scoreResults(results, recipe) {
|
|
588
|
+
const questions = {};
|
|
589
|
+
for (const question of Object.keys(recipe.questions)) questions[question] = {
|
|
590
|
+
asserted: 0,
|
|
591
|
+
matched: 0,
|
|
592
|
+
accuracy: null
|
|
593
|
+
};
|
|
594
|
+
for (const result of results) {
|
|
595
|
+
const missed = new Set(result.mismatches.map((mismatch) => mismatch.question));
|
|
596
|
+
for (const [question, expected] of Object.entries(result.expect)) {
|
|
597
|
+
if (expected === void 0) continue;
|
|
598
|
+
const tally = questions[question];
|
|
599
|
+
if (!tally) continue;
|
|
600
|
+
tally.asserted += 1;
|
|
601
|
+
if (!missed.has(question)) tally.matched += 1;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const scored = [];
|
|
605
|
+
for (const tally of Object.values(questions)) {
|
|
606
|
+
if (tally.asserted === 0) continue;
|
|
607
|
+
tally.accuracy = tally.matched / tally.asserted;
|
|
608
|
+
scored.push(tally.accuracy);
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
recipe: {
|
|
612
|
+
name: recipe.name,
|
|
613
|
+
version: recipe.version
|
|
614
|
+
},
|
|
615
|
+
model: results[0]?.decision.model ?? "",
|
|
616
|
+
fixtures: results,
|
|
617
|
+
questions,
|
|
618
|
+
accuracy: scored.length === 0 ? null : Math.min(...scored)
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Fires every Fixture through the Judge at once and lets the Judge's gate meter concurrency.
|
|
623
|
+
* The first `JudgeError` or `JudgeExhaustedError` aborts the rest and is rethrown: a partial
|
|
624
|
+
* accuracy is worse than none.
|
|
625
|
+
*/
|
|
626
|
+
async function testRecipe(judge, recipe, fixtures, options = {}) {
|
|
627
|
+
const problems = validateFixtures(fixtures, recipe);
|
|
628
|
+
if (problems.length > 0) throw new FixtureValidationError(problems);
|
|
629
|
+
const { onResult } = options;
|
|
630
|
+
const controller = new AbortController();
|
|
631
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
632
|
+
return scoreResults(await Promise.all(fixtures.map(async (fixture, offset) => {
|
|
633
|
+
const index = offset + 1;
|
|
634
|
+
const id = fixture.id ?? `fixture:${index}`;
|
|
635
|
+
let decision;
|
|
636
|
+
try {
|
|
637
|
+
decision = await judge({
|
|
638
|
+
id,
|
|
639
|
+
state: fixture.state
|
|
640
|
+
}, recipe, { signal });
|
|
641
|
+
} catch (cause) {
|
|
642
|
+
controller.abort(cause);
|
|
643
|
+
throw cause;
|
|
644
|
+
}
|
|
645
|
+
const result = {
|
|
646
|
+
index,
|
|
647
|
+
id,
|
|
648
|
+
decision,
|
|
649
|
+
expect: fixture.expect,
|
|
650
|
+
mismatches: compareAnswers(fixture.expect, decision.answers),
|
|
651
|
+
review: decision.review
|
|
652
|
+
};
|
|
653
|
+
onResult?.(result);
|
|
654
|
+
return result;
|
|
655
|
+
})), recipe);
|
|
656
|
+
}
|
|
657
|
+
//#endregion
|
|
658
|
+
//#region src/index.ts
|
|
659
|
+
/** The published version of `@siftline/core`, baked in at build time. */
|
|
660
|
+
const VERSION = version;
|
|
15
661
|
//#endregion
|
|
16
|
-
export {
|
|
662
|
+
export { DEFAULT_MAX_IN_FLIGHT, FixtureParseError, FixtureValidationError, JudgeError, JudgeExhaustedError, SiftlineError, VERSION, choice, compareAnswers, createJudge, decisionSchema, defineFixtures, defineRecipe, evaluateRules, fixtureSchema, noul, parseDecision, parseFixture, parseFixtures, parseRecipe, questionSchema, recipeSchema, recordSchema, routeDecision, ruleConditionSchema, ruleSchema, score, scoreResults, serializeDecision, serializeFixture, serializeRecipe, testRecipe, validateFixtures, validateRules };
|