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