@contentrain/decide 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 +199 -0
- package/dist/index.d.mts +587 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1164 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +55 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1164 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { canonicalStringify } from "@contentrain/types";
|
|
5
|
+
//#region src/audit.ts
|
|
6
|
+
function toAuditRecord(decision, at, tenant) {
|
|
7
|
+
const record = {
|
|
8
|
+
at,
|
|
9
|
+
tenant,
|
|
10
|
+
kind: decision.kind,
|
|
11
|
+
version: decision.version,
|
|
12
|
+
key: decision.key,
|
|
13
|
+
shape: decision.shape,
|
|
14
|
+
confidence: decision.confidence,
|
|
15
|
+
source: decision.source,
|
|
16
|
+
ms: decision.ms
|
|
17
|
+
};
|
|
18
|
+
if (decision.choice !== void 0) record.choice = decision.choice;
|
|
19
|
+
if (decision.score !== void 0) record.score = decision.score;
|
|
20
|
+
if (decision.proposed !== void 0) record.proposed = decision.proposed;
|
|
21
|
+
if (decision.unreviewed) record.unreviewed = true;
|
|
22
|
+
if (decision.fallback) record.fallback = decision.fallback;
|
|
23
|
+
if (decision.model) record.model = decision.model;
|
|
24
|
+
if (decision.cost) {
|
|
25
|
+
record.input_tokens = decision.cost.input_tokens;
|
|
26
|
+
record.output_tokens = decision.cost.output_tokens;
|
|
27
|
+
if (decision.cost.usd !== void 0) record.usd = decision.cost.usd;
|
|
28
|
+
}
|
|
29
|
+
return record;
|
|
30
|
+
}
|
|
31
|
+
var MemoryAuditLog = class {
|
|
32
|
+
records = [];
|
|
33
|
+
async write(records) {
|
|
34
|
+
this.records.push(...records);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/** Appends to a JSONL file (conventionally `decisions.jsonl`), one line per decision. */
|
|
38
|
+
var JsonlAuditLog = class {
|
|
39
|
+
constructor(path) {
|
|
40
|
+
this.path = path;
|
|
41
|
+
}
|
|
42
|
+
async write(records) {
|
|
43
|
+
if (!records.length) return;
|
|
44
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
45
|
+
await appendFile(this.path, records.map((record) => `${JSON.stringify(record)}\n`).join(""));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/batch.ts
|
|
50
|
+
const DEFAULT_BATCH = {
|
|
51
|
+
maxItems: 32,
|
|
52
|
+
maxTokens: 32e3
|
|
53
|
+
};
|
|
54
|
+
const estimateTokens = (text) => Math.ceil(text.length / 3);
|
|
55
|
+
/**
|
|
56
|
+
* Split item costs into consecutive batches. `fixed` is what every request
|
|
57
|
+
* pays once (the preamble); `costs[i]` what item i adds (its line and its
|
|
58
|
+
* questions). An item too large for any batch goes alone.
|
|
59
|
+
*/
|
|
60
|
+
function planBatches(costs, fixed, limits = {}) {
|
|
61
|
+
const maxItems = limits.maxItems ?? DEFAULT_BATCH.maxItems;
|
|
62
|
+
const maxTokens = limits.maxTokens ?? DEFAULT_BATCH.maxTokens;
|
|
63
|
+
if (!Number.isInteger(maxItems) || maxItems < 1) throw new RangeError(`maxItems must be a positive integer, got ${maxItems}`);
|
|
64
|
+
const batches = [];
|
|
65
|
+
let current = [];
|
|
66
|
+
let tokens = fixed;
|
|
67
|
+
costs.forEach((cost, i) => {
|
|
68
|
+
if (current.length && (current.length >= maxItems || tokens + cost > maxTokens)) {
|
|
69
|
+
batches.push(current);
|
|
70
|
+
current = [];
|
|
71
|
+
tokens = fixed;
|
|
72
|
+
}
|
|
73
|
+
current.push(i);
|
|
74
|
+
tokens += cost;
|
|
75
|
+
});
|
|
76
|
+
if (current.length) batches.push(current);
|
|
77
|
+
return batches;
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/budget.ts
|
|
81
|
+
/** A per-tenant cap on provider decisions per UTC day, held in memory. */
|
|
82
|
+
var MemoryDailyBudget = class {
|
|
83
|
+
spent = /* @__PURE__ */ new Map();
|
|
84
|
+
constructor(perDay) {
|
|
85
|
+
this.perDay = perDay;
|
|
86
|
+
if (!Number.isInteger(perDay) || perDay < 0) throw new RangeError(`perDay must be a non-negative integer, got ${perDay}`);
|
|
87
|
+
}
|
|
88
|
+
async take(tenant, count, now) {
|
|
89
|
+
const slot = `${tenant}\n${now.toISOString().slice(0, 10)}`;
|
|
90
|
+
const used = this.spent.get(slot) ?? 0;
|
|
91
|
+
const granted = Math.max(0, Math.min(count, this.perDay - used));
|
|
92
|
+
this.spent.set(slot, used + granted);
|
|
93
|
+
return granted;
|
|
94
|
+
}
|
|
95
|
+
used(tenant, now) {
|
|
96
|
+
return this.spent.get(`${tenant}\n${now.toISOString().slice(0, 10)}`) ?? 0;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Closed → open after `failures` consecutive failed requests; open → half-open
|
|
101
|
+
* after `cooldownMs`, where the next request decides: success closes it, a
|
|
102
|
+
* failure opens it for another cooldown.
|
|
103
|
+
*/
|
|
104
|
+
var CircuitBreaker = class {
|
|
105
|
+
consecutive = 0;
|
|
106
|
+
openedAt;
|
|
107
|
+
limit;
|
|
108
|
+
cooldownMs;
|
|
109
|
+
now;
|
|
110
|
+
constructor(options = {}) {
|
|
111
|
+
this.limit = options.failures ?? 3;
|
|
112
|
+
this.cooldownMs = options.cooldownMs ?? 6e4;
|
|
113
|
+
this.now = options.now ?? Date.now;
|
|
114
|
+
}
|
|
115
|
+
get state() {
|
|
116
|
+
if (this.openedAt === void 0) return "closed";
|
|
117
|
+
return this.now() - this.openedAt >= this.cooldownMs ? "half_open" : "open";
|
|
118
|
+
}
|
|
119
|
+
/** Whether a request may go out now. */
|
|
120
|
+
allows() {
|
|
121
|
+
return this.state !== "open";
|
|
122
|
+
}
|
|
123
|
+
success() {
|
|
124
|
+
this.consecutive = 0;
|
|
125
|
+
this.openedAt = void 0;
|
|
126
|
+
}
|
|
127
|
+
failure() {
|
|
128
|
+
this.consecutive++;
|
|
129
|
+
if (this.openedAt !== void 0 || this.consecutive >= this.limit) this.openedAt = this.now();
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/cache.ts
|
|
134
|
+
function cacheKey(kind, version, shape, shaped) {
|
|
135
|
+
return createHash("sha256").update(`${kind}\n${version}\n${shape}\n${canonicalStringify(shaped)}`).digest("hex");
|
|
136
|
+
}
|
|
137
|
+
var MemoryDecisionCache = class {
|
|
138
|
+
entries = /* @__PURE__ */ new Map();
|
|
139
|
+
get size() {
|
|
140
|
+
return this.entries.size;
|
|
141
|
+
}
|
|
142
|
+
async get(key) {
|
|
143
|
+
return this.entries.get(key);
|
|
144
|
+
}
|
|
145
|
+
async set(key, value) {
|
|
146
|
+
this.entries.set(key, value);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* One `{"key","value"}` line per answer, appended. Read once, on first use;
|
|
151
|
+
* a later line for the same key wins. A torn last line (a crash mid-append) is
|
|
152
|
+
* skipped rather than failing the run — the answer is asked for again.
|
|
153
|
+
*/
|
|
154
|
+
var JsonlDecisionCache = class {
|
|
155
|
+
loaded;
|
|
156
|
+
constructor(path) {
|
|
157
|
+
this.path = path;
|
|
158
|
+
}
|
|
159
|
+
load() {
|
|
160
|
+
this.loaded ??= readFile(this.path, "utf8").then((text) => readCacheLines(text), (error) => {
|
|
161
|
+
if (error.code === "ENOENT") return /* @__PURE__ */ new Map();
|
|
162
|
+
throw error;
|
|
163
|
+
});
|
|
164
|
+
return this.loaded;
|
|
165
|
+
}
|
|
166
|
+
async get(key) {
|
|
167
|
+
return (await this.load()).get(key);
|
|
168
|
+
}
|
|
169
|
+
async set(key, value) {
|
|
170
|
+
(await this.load()).set(key, value);
|
|
171
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
172
|
+
await appendFile(this.path, `${JSON.stringify({
|
|
173
|
+
key,
|
|
174
|
+
value
|
|
175
|
+
})}\n`);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
function readCacheLines(text) {
|
|
179
|
+
const entries = /* @__PURE__ */ new Map();
|
|
180
|
+
for (const line of text.split("\n")) {
|
|
181
|
+
if (!line.trim()) continue;
|
|
182
|
+
try {
|
|
183
|
+
const { key, value } = JSON.parse(line);
|
|
184
|
+
if (typeof key === "string" && value && typeof value === "object") entries.set(key, value);
|
|
185
|
+
} catch {}
|
|
186
|
+
}
|
|
187
|
+
return entries;
|
|
188
|
+
}
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/jev.ts
|
|
191
|
+
const JEV_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
|
|
192
|
+
const JEV_TOKEN_ENV = "CONTENTRAIN_JEW_API_TOKEN";
|
|
193
|
+
const DEFAULT_JEV_MODEL = "jev-latest";
|
|
194
|
+
var JevError = class extends Error {
|
|
195
|
+
constructor(message, status, timeout = false) {
|
|
196
|
+
super(message);
|
|
197
|
+
this.status = status;
|
|
198
|
+
this.timeout = timeout;
|
|
199
|
+
this.name = "JevError";
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* The request one batch sends: the kind's header from the first item, then one
|
|
204
|
+
* `Item <n>: ` line per item; every item's questions keyed `item<n>_<suffix>`.
|
|
205
|
+
*/
|
|
206
|
+
function buildJevRequest(spec, shaped) {
|
|
207
|
+
const jev = spec.jev;
|
|
208
|
+
if (!jev) throw new TypeError(`kind ${spec.kind} has no Jev prompt`);
|
|
209
|
+
const header = shaped.length && jev.header ? jev.header(shaped[0]) : "";
|
|
210
|
+
const lines = shaped.map((item, i) => `Item ${i + 1}: ${jev.render(item)}`);
|
|
211
|
+
const questions = {};
|
|
212
|
+
shaped.forEach((_, i) => {
|
|
213
|
+
const prefix = `item${i + 1}`;
|
|
214
|
+
for (const [suffix, question] of Object.entries(jev.questions(prefix))) questions[`${prefix}_${suffix}`] = question;
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
state: header + lines.join("\n"),
|
|
218
|
+
questions
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Hash of everything that shapes a kind's provider request: the model, the
|
|
223
|
+
* batch limits, whether items are grouped, and the request built from two
|
|
224
|
+
* copies of the kind's probe (header, line format, questions, criteria). A
|
|
225
|
+
* change to any of them changes the hash, and with it every cache key.
|
|
226
|
+
*/
|
|
227
|
+
function requestShapeHash(spec, model = DEFAULT_JEV_MODEL) {
|
|
228
|
+
const jev = spec.jev;
|
|
229
|
+
if (!jev) return "none";
|
|
230
|
+
const shape = {
|
|
231
|
+
model,
|
|
232
|
+
batch: jev.batch ?? null,
|
|
233
|
+
grouped: Boolean(jev.group),
|
|
234
|
+
request: buildJevRequest(spec, [jev.probe, jev.probe])
|
|
235
|
+
};
|
|
236
|
+
return createHash("sha256").update(canonicalStringify(shape)).digest("hex").slice(0, 16);
|
|
237
|
+
}
|
|
238
|
+
function createJevProvider(options = {}) {
|
|
239
|
+
const env = options.env ?? process.env;
|
|
240
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
241
|
+
const timeoutMs = options.timeoutMs ?? 15e3;
|
|
242
|
+
const model = options.model ?? "jev-latest";
|
|
243
|
+
return {
|
|
244
|
+
name: "jev",
|
|
245
|
+
model,
|
|
246
|
+
available: () => Boolean(env[JEV_TOKEN_ENV]?.trim()),
|
|
247
|
+
async ask(spec, shaped) {
|
|
248
|
+
const token = env[JEV_TOKEN_ENV]?.trim();
|
|
249
|
+
if (!token) throw new JevError(`${JEV_TOKEN_ENV} is not set`, void 0);
|
|
250
|
+
const { state, questions } = buildJevRequest(spec, shaped);
|
|
251
|
+
let response;
|
|
252
|
+
try {
|
|
253
|
+
response = await doFetch(JEV_ENDPOINT, {
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers: {
|
|
256
|
+
"Authorization": `Bearer ${token}`,
|
|
257
|
+
"Content-Type": "application/json"
|
|
258
|
+
},
|
|
259
|
+
body: JSON.stringify({
|
|
260
|
+
state,
|
|
261
|
+
model,
|
|
262
|
+
questions
|
|
263
|
+
}),
|
|
264
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
265
|
+
});
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const name = error.name;
|
|
268
|
+
if (name === "TimeoutError" || name === "AbortError") throw new JevError(`Jev did not answer within ${timeoutMs}ms`, void 0, true);
|
|
269
|
+
throw new JevError(`Jev request failed: ${redact$1(String(error.message ?? error), token)}`, void 0);
|
|
270
|
+
}
|
|
271
|
+
const text = await response.text();
|
|
272
|
+
if (!response.ok) throw new JevError(`Jev answered HTTP ${response.status}: ${redact$1(text.slice(0, 200), token)}`, response.status);
|
|
273
|
+
let body;
|
|
274
|
+
try {
|
|
275
|
+
body = JSON.parse(text);
|
|
276
|
+
} catch {
|
|
277
|
+
throw new JevError("Jev answered with a body that is not JSON", response.status);
|
|
278
|
+
}
|
|
279
|
+
return readJevResponse(spec, shaped.length, body);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/** Per-item outcomes from a systemone response. An item whose answers are missing or malformed gets `undefined`. */
|
|
284
|
+
function readJevResponse(spec, count, body) {
|
|
285
|
+
const jev = spec.jev;
|
|
286
|
+
const root = body && typeof body === "object" ? body : {};
|
|
287
|
+
const answers = root.answers && typeof root.answers === "object" ? root.answers : {};
|
|
288
|
+
const outcomes = [];
|
|
289
|
+
for (let i = 1; i <= count; i++) {
|
|
290
|
+
const own = {};
|
|
291
|
+
let complete = true;
|
|
292
|
+
for (const suffix of Object.keys(jev.questions(`item${i}`))) {
|
|
293
|
+
const answer = readAnswer(answers[`item${i}_${suffix}`]);
|
|
294
|
+
if (!answer) complete = false;
|
|
295
|
+
else own[suffix] = answer;
|
|
296
|
+
}
|
|
297
|
+
outcomes.push(complete ? jev.read(own) : void 0);
|
|
298
|
+
}
|
|
299
|
+
const usage = root.usage;
|
|
300
|
+
return {
|
|
301
|
+
outcomes,
|
|
302
|
+
...usage && typeof usage.input_tokens === "number" && typeof usage.output_tokens === "number" ? { usage: {
|
|
303
|
+
input_tokens: usage.input_tokens,
|
|
304
|
+
output_tokens: usage.output_tokens
|
|
305
|
+
} } : {},
|
|
306
|
+
...typeof root.model === "string" ? { model: root.model } : {}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const isUnit = (value) => typeof value === "number" && value >= 0 && value <= 1;
|
|
310
|
+
function readAnswer(value) {
|
|
311
|
+
if (!value || typeof value !== "object") return void 0;
|
|
312
|
+
const answer = value;
|
|
313
|
+
const probabilities = answer.probabilities && typeof answer.probabilities === "object" ? { probabilities: answer.probabilities } : {};
|
|
314
|
+
if (answer.type === "choice" && typeof answer.choice === "string" && isUnit(answer.confidence)) return {
|
|
315
|
+
type: "choice",
|
|
316
|
+
choice: answer.choice,
|
|
317
|
+
confidence: answer.confidence,
|
|
318
|
+
...probabilities
|
|
319
|
+
};
|
|
320
|
+
if (answer.type === "score" && typeof answer.score === "number" && Number.isFinite(answer.score) && isUnit(answer.confidence)) return {
|
|
321
|
+
type: "score",
|
|
322
|
+
score: answer.score,
|
|
323
|
+
confidence: answer.confidence,
|
|
324
|
+
...probabilities
|
|
325
|
+
};
|
|
326
|
+
if (answer.type === "noul" && isUnit(answer.noul)) return {
|
|
327
|
+
type: "noul",
|
|
328
|
+
noul: answer.noul
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function redact$1(text, token) {
|
|
332
|
+
return text.split(token).join("[redacted]");
|
|
333
|
+
}
|
|
334
|
+
//#endregion
|
|
335
|
+
//#region src/kinds/shape.ts
|
|
336
|
+
const URL = /\b(?:https?|ftp):\/\/\S+|\bwww\.\S+/gi;
|
|
337
|
+
const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g;
|
|
338
|
+
const PATH = /(^|[\s("'=])\/[\w.~%-]+(?:\/[\w.~%-]*)*/g;
|
|
339
|
+
function scrubText(text, max) {
|
|
340
|
+
const clean = text.replace(URL, "<url>").replace(EMAIL, "<email>").replace(PATH, "$1<path>").replace(/\s+/g, " ").trim();
|
|
341
|
+
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
|
342
|
+
}
|
|
343
|
+
/** A slug-like identifier (post-type name, family name): lower-case word characters and dashes only. */
|
|
344
|
+
function scrubSlug(value, max = 40) {
|
|
345
|
+
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max);
|
|
346
|
+
}
|
|
347
|
+
/** A finite number, or undefined. */
|
|
348
|
+
function finite(value) {
|
|
349
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
350
|
+
}
|
|
351
|
+
//#endregion
|
|
352
|
+
//#region src/kinds/eligibility-band.ts
|
|
353
|
+
const ELIGIBILITY_CHOICES = [
|
|
354
|
+
"eligible",
|
|
355
|
+
"access_blocked",
|
|
356
|
+
"page_only",
|
|
357
|
+
"custom_type"
|
|
358
|
+
];
|
|
359
|
+
const NEEDS_HUMAN = "needs_human";
|
|
360
|
+
const MIN_POSTS = 2;
|
|
361
|
+
/** Posts and custom entries within this factor of each other are the band. */
|
|
362
|
+
const BAND_FACTOR = 2;
|
|
363
|
+
function shapeEligibility(input) {
|
|
364
|
+
const rest = input.rest;
|
|
365
|
+
const counts = /* @__PURE__ */ new Map();
|
|
366
|
+
for (const type of rest.postTypes ?? []) {
|
|
367
|
+
if (type.slug === "post" || type.slug === "page" || !((type.count ?? 0) > 0)) continue;
|
|
368
|
+
const slug = scrubSlug(type.slug);
|
|
369
|
+
counts.set(slug, (counts.get(slug) ?? 0) + (type.count ?? 0));
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
access: input.access,
|
|
373
|
+
rest_reachable: Boolean(rest.reachable),
|
|
374
|
+
posts: rest.counts?.posts ?? null,
|
|
375
|
+
pages: rest.counts?.pages ?? null,
|
|
376
|
+
post_samples: rest.samples?.posts?.length ?? 0,
|
|
377
|
+
page_samples: rest.samples?.pages?.length ?? 0,
|
|
378
|
+
custom_types: [...counts].map(([slug, count]) => ({
|
|
379
|
+
slug,
|
|
380
|
+
count
|
|
381
|
+
})).toSorted((a, b) => a.slug.localeCompare(b.slug))
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
const certain = (choice) => ({
|
|
385
|
+
choice,
|
|
386
|
+
confidence: 1,
|
|
387
|
+
final: true
|
|
388
|
+
});
|
|
389
|
+
const tentative = (choice) => ({
|
|
390
|
+
choice,
|
|
391
|
+
confidence: .5,
|
|
392
|
+
final: false
|
|
393
|
+
});
|
|
394
|
+
function eligibilityRule(input) {
|
|
395
|
+
const site = shapeEligibility(input);
|
|
396
|
+
if (site.access !== "open" || !site.rest_reachable) return certain("access_blocked");
|
|
397
|
+
const postCount = site.posts ?? site.post_samples;
|
|
398
|
+
const customCount = site.custom_types.reduce((sum, type) => sum + type.count, 0);
|
|
399
|
+
const pages = site.page_samples || (site.pages ?? 0);
|
|
400
|
+
if (site.post_samples >= MIN_POSTS) {
|
|
401
|
+
const verdict = customCount <= postCount ? "eligible" : "custom_type";
|
|
402
|
+
return customCount > 0 && customCount * BAND_FACTOR >= postCount && postCount * BAND_FACTOR >= customCount ? tentative(verdict) : certain(verdict);
|
|
403
|
+
}
|
|
404
|
+
const verdict = site.custom_types.length ? "custom_type" : pages ? "page_only" : "access_blocked";
|
|
405
|
+
return site.post_samples === 1 ? tentative(verdict) : certain(verdict);
|
|
406
|
+
}
|
|
407
|
+
function renderEligibility(site) {
|
|
408
|
+
const custom = site.custom_types.length ? site.custom_types.map((type) => `${type.slug}=${type.count}`).join(", ") : "none";
|
|
409
|
+
return [
|
|
410
|
+
`access=${site.access}`,
|
|
411
|
+
`rest=${site.rest_reachable ? "reachable" : "unreachable"}`,
|
|
412
|
+
`posts=${site.posts ?? "unknown"} (sampled ${site.post_samples})`,
|
|
413
|
+
`pages=${site.pages ?? "unknown"} (sampled ${site.page_samples})`,
|
|
414
|
+
`custom post types: ${custom}`
|
|
415
|
+
].join("; ");
|
|
416
|
+
}
|
|
417
|
+
function readEligibility(answers) {
|
|
418
|
+
const answer = answers.eligibility;
|
|
419
|
+
if (answer?.type !== "choice") return void 0;
|
|
420
|
+
return {
|
|
421
|
+
choice: answer.choice,
|
|
422
|
+
confidence: answer.confidence,
|
|
423
|
+
...answer.probabilities ? { probabilities: answer.probabilities } : {}
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
function eligibilityQuestions(prefix) {
|
|
427
|
+
return { eligibility: {
|
|
428
|
+
type: "choice",
|
|
429
|
+
instructions: `${prefix}: where does this site's content live, and can the engine move it?`,
|
|
430
|
+
criteria: {
|
|
431
|
+
eligible: "Standard blog posts carry the site's content; moving posts and pages moves what matters.",
|
|
432
|
+
custom_type: "The content lives mainly in custom post types (products, portfolio, members); moving posts and pages alone leaves it behind.",
|
|
433
|
+
page_only: "No real blog: a corporate or brochure site whose content is its pages.",
|
|
434
|
+
access_blocked: "What the scan could see is not the site's real content; authorized access is needed to reach it."
|
|
435
|
+
}
|
|
436
|
+
} };
|
|
437
|
+
}
|
|
438
|
+
const eligibilityBand = {
|
|
439
|
+
kind: "eligibility_band",
|
|
440
|
+
version: "1",
|
|
441
|
+
choices: ELIGIBILITY_CHOICES,
|
|
442
|
+
shape: shapeEligibility,
|
|
443
|
+
rule: eligibilityRule,
|
|
444
|
+
jev: {
|
|
445
|
+
header: () => "WordPress sites considered for migration. Each item is what a public discovery scan counted on one site. The migration engine moves standard posts and pages only.\n",
|
|
446
|
+
render: renderEligibility,
|
|
447
|
+
questions: eligibilityQuestions,
|
|
448
|
+
read: readEligibility,
|
|
449
|
+
probe: {
|
|
450
|
+
access: "open",
|
|
451
|
+
rest_reachable: true,
|
|
452
|
+
posts: 20,
|
|
453
|
+
pages: 3,
|
|
454
|
+
post_samples: 5,
|
|
455
|
+
page_samples: 3,
|
|
456
|
+
custom_types: [{
|
|
457
|
+
slug: "product",
|
|
458
|
+
count: 30
|
|
459
|
+
}]
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
lowConfidence: {
|
|
463
|
+
threshold: .5,
|
|
464
|
+
choice: NEEDS_HUMAN
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
//#endregion
|
|
468
|
+
//#region src/kinds/punch-item.ts
|
|
469
|
+
const PUNCH_CLASSES = [
|
|
470
|
+
"product_defect",
|
|
471
|
+
"source_limit",
|
|
472
|
+
"cosmetic",
|
|
473
|
+
"measurement"
|
|
474
|
+
];
|
|
475
|
+
const PUNCH_CLASS_CRITERIA = {
|
|
476
|
+
product_defect: "Migration engine'in ürettiği çıktıda bir kusur/hata — üretilen sayfada bir şey yanlış (ör. yanlış görsel tekrar ediyor, bağlantı kırık, içerik markup'ta eksik).",
|
|
477
|
+
source_limit: "Kaynak sitenin kendi yapısından gelen bir sınır — içerik zaten kaynakta veri olarak ayrıştırılamaz durumda (ör. tema markup'ına gömülü, API'de yok), migration engine bunu değiştiremez.",
|
|
478
|
+
cosmetic: "Teslimi engellemeyen küçük görsel fark — kullanıcı deneyimini gerçekten bozmayan, göz ardı edilebilir bir fark.",
|
|
479
|
+
measurement: "Ölçüm/araç belirsizliği — sorunun kendisi değil, ölçüm sürecinin (skor eşiği, örneklem, zaman aşımı) ne olduğunu net söyleyememesi."
|
|
480
|
+
};
|
|
481
|
+
/** Severity rubric, levels 0–3 (Jev's score scale). Level n here is severity n+1 on a 1–4 scale. */
|
|
482
|
+
const PUNCH_SEVERITY_CRITERIA = [
|
|
483
|
+
"Teslimi engellemez — göz ardı edilebilir.",
|
|
484
|
+
"Küçük, düşük öncelik — teslim edilebilir, sonra bakılabilir.",
|
|
485
|
+
"Teslimden önce ele alınmalı — müşteri fark edebilir ama kesin reddetmez.",
|
|
486
|
+
"Müşteri fark eder ve reddeder — teslim öncesi mutlaka çözülmeli."
|
|
487
|
+
];
|
|
488
|
+
function shapePunchItem(input) {
|
|
489
|
+
const site = input.site ?? {};
|
|
490
|
+
const identity = site.name ?? site.url ?? "";
|
|
491
|
+
return {
|
|
492
|
+
label: scrubText(String(input.label ?? ""), 120),
|
|
493
|
+
reason: scrubText(String(input.reason ?? ""), 500),
|
|
494
|
+
linked: Boolean(input.link),
|
|
495
|
+
site: {
|
|
496
|
+
group: identity ? createHash("sha256").update(identity).digest("hex").slice(0, 16) : "",
|
|
497
|
+
decision: typeof site.decision === "string" ? scrubText(site.decision, 40) : null,
|
|
498
|
+
median: finite(site.median) ?? null,
|
|
499
|
+
mobile_median: finite(site.mobile_median) ?? null
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
const SIGNATURES = [
|
|
504
|
+
[/ölçülemedi|genellik kanıtlanmadı/i, "measurement"],
|
|
505
|
+
[/gövde yuvası yok|tam sayfa klon/i, "source_limit"],
|
|
506
|
+
[/hero sızıntısı|medya kaybı|dinamik liste bağlanmadı/i, "product_defect"]
|
|
507
|
+
];
|
|
508
|
+
function punchItemRule(input) {
|
|
509
|
+
const reason = String(input.reason ?? "");
|
|
510
|
+
const hit = SIGNATURES.find(([pattern]) => pattern.test(reason));
|
|
511
|
+
return hit ? {
|
|
512
|
+
choice: hit[1],
|
|
513
|
+
confidence: 1,
|
|
514
|
+
final: false
|
|
515
|
+
} : void 0;
|
|
516
|
+
}
|
|
517
|
+
function punchHeader(item) {
|
|
518
|
+
return `Site: <site> (<url>)\nGenel karar: ${item.site.decision}\nMedyan kalite skoru: ${item.site.median}, mobil: ${item.site.mobile_median}\n`;
|
|
519
|
+
}
|
|
520
|
+
function renderPunchItem(item) {
|
|
521
|
+
return `etiket="${item.label}", link="${item.linked ? "<url>" : "(yok)"}", neden="${item.reason}"`;
|
|
522
|
+
}
|
|
523
|
+
function punchQuestions(prefix) {
|
|
524
|
+
return {
|
|
525
|
+
class: {
|
|
526
|
+
type: "choice",
|
|
527
|
+
instructions: `${prefix} için: bu kalemin kök nedeni nedir?`,
|
|
528
|
+
criteria: PUNCH_CLASS_CRITERIA
|
|
529
|
+
},
|
|
530
|
+
severity: {
|
|
531
|
+
type: "score",
|
|
532
|
+
instructions: `${prefix} için: bu kalem ne kadar ciddi?`,
|
|
533
|
+
criteria: PUNCH_SEVERITY_CRITERIA
|
|
534
|
+
},
|
|
535
|
+
needs_human: {
|
|
536
|
+
type: "noul",
|
|
537
|
+
instructions: `${prefix} kalemi, teslim öncesi bir insanın karar vermesini gerektiriyor (otomatik/açık bir düzeltme değil, yargı gerektiriyor).`
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function readPunchItem(answers) {
|
|
542
|
+
const cls = answers.class;
|
|
543
|
+
const severity = answers.severity;
|
|
544
|
+
if (cls?.type !== "choice" || severity?.type !== "score") return void 0;
|
|
545
|
+
return {
|
|
546
|
+
choice: cls.choice,
|
|
547
|
+
score: severity.score,
|
|
548
|
+
confidence: Math.min(cls.confidence, severity.confidence),
|
|
549
|
+
...cls.probabilities ? { probabilities: cls.probabilities } : {}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function punchLlmPrompt({ state, questions, count }) {
|
|
553
|
+
const q = questions.class;
|
|
554
|
+
const s = questions.severity;
|
|
555
|
+
const h = questions.needs_human;
|
|
556
|
+
return [
|
|
557
|
+
"Aşağıdaki durum bir WordPress → Astro taşıma koşusunun bitirilecekler listesinden bir sitenin kalemlerini içeriyor.",
|
|
558
|
+
"",
|
|
559
|
+
"<state>",
|
|
560
|
+
state,
|
|
561
|
+
"</state>",
|
|
562
|
+
"",
|
|
563
|
+
"Her kalem için üç soruyu yanıtla (itemN yerine kalem numarası):",
|
|
564
|
+
`1) class — ${q.instructions.replace("item1", "itemN")} Seçenekler:`,
|
|
565
|
+
...Object.entries(q.criteria).map(([k, v]) => ` - ${k}: ${v}`),
|
|
566
|
+
`2) severity — ${s.instructions.replace("item1", "itemN")} 0–3 arası tam sayı:`,
|
|
567
|
+
...s.criteria.map((c, n) => ` ${n}: ${c}`),
|
|
568
|
+
`3) needs_human — önermenin doğru olma olasılığı (0–1): ${h.instructions.replace("item1", "itemN")}`,
|
|
569
|
+
"",
|
|
570
|
+
`Tüm ${count} kalemi record_answers aracıyla, her kalem için bir kayıt olarak ver.`
|
|
571
|
+
].join("\n");
|
|
572
|
+
}
|
|
573
|
+
/** Jev's 0–3 severity score as a 1–4 level (PoC-1's hand-label scale). */
|
|
574
|
+
const severityLevel = (score) => Math.min(4, Math.max(1, Math.round(score) + 1));
|
|
575
|
+
const punchItem = {
|
|
576
|
+
kind: "punch_item",
|
|
577
|
+
version: "2",
|
|
578
|
+
choices: PUNCH_CLASSES,
|
|
579
|
+
scoreRange: [0, 3],
|
|
580
|
+
shape: shapePunchItem,
|
|
581
|
+
rule: punchItemRule,
|
|
582
|
+
jev: {
|
|
583
|
+
header: punchHeader,
|
|
584
|
+
group: (item) => item.site.group,
|
|
585
|
+
render: renderPunchItem,
|
|
586
|
+
questions: punchQuestions,
|
|
587
|
+
read: readPunchItem,
|
|
588
|
+
llmPrompt: punchLlmPrompt,
|
|
589
|
+
batch: { maxItems: 25 },
|
|
590
|
+
probe: {
|
|
591
|
+
label: "(aile: post)",
|
|
592
|
+
reason: "en kötü sayfa 56.4 < 80 (5 sayfa ölçüldü)",
|
|
593
|
+
linked: false,
|
|
594
|
+
site: {
|
|
595
|
+
group: "probe",
|
|
596
|
+
decision: "KABUL + PUNCH LIST",
|
|
597
|
+
median: 94.7,
|
|
598
|
+
mobile_median: 83.9
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
//#endregion
|
|
604
|
+
//#region src/kinds/index.ts
|
|
605
|
+
const BUILTIN_KINDS = [punchItem, eligibilityBand];
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/llm.ts
|
|
608
|
+
const noopLlmProvider = {
|
|
609
|
+
name: "llm",
|
|
610
|
+
available: () => false,
|
|
611
|
+
ask: async () => {
|
|
612
|
+
throw new Error("no LLM provider is configured");
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
//#endregion
|
|
616
|
+
//#region src/decide.ts
|
|
617
|
+
function validOutcome(spec, outcome) {
|
|
618
|
+
if (!outcome) return false;
|
|
619
|
+
if (outcome.choice !== void 0 && spec.choices && !spec.choices.includes(outcome.choice)) return false;
|
|
620
|
+
if (outcome.score !== void 0 && spec.scoreRange && (outcome.score < spec.scoreRange[0] || outcome.score > spec.scoreRange[1])) return false;
|
|
621
|
+
return Number.isFinite(outcome.confidence);
|
|
622
|
+
}
|
|
623
|
+
function fromOutcome(spec, key, shape, outcome, source, ms) {
|
|
624
|
+
const decision = {
|
|
625
|
+
kind: spec.kind,
|
|
626
|
+
version: spec.version,
|
|
627
|
+
key,
|
|
628
|
+
shape,
|
|
629
|
+
confidence: outcome.confidence,
|
|
630
|
+
source,
|
|
631
|
+
ms
|
|
632
|
+
};
|
|
633
|
+
if (outcome.choice !== void 0) decision.choice = outcome.choice;
|
|
634
|
+
if (outcome.score !== void 0) decision.score = outcome.score;
|
|
635
|
+
if (outcome.probabilities) decision.probabilities = outcome.probabilities;
|
|
636
|
+
return decision;
|
|
637
|
+
}
|
|
638
|
+
/** A provider answer under the kind's confidence floor becomes the kind's human-review choice. */
|
|
639
|
+
function applyFloor(spec, decision) {
|
|
640
|
+
const floor = spec.lowConfidence;
|
|
641
|
+
if (!floor || decision.choice === void 0 || decision.confidence >= floor.threshold) return decision;
|
|
642
|
+
return {
|
|
643
|
+
...decision,
|
|
644
|
+
proposed: decision.choice,
|
|
645
|
+
choice: floor.choice
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
function fallback(spec, item, shape, reason, ms) {
|
|
649
|
+
return {
|
|
650
|
+
...item.rule ? fromOutcome(spec, item.key, shape, item.rule, "rule", ms) : {
|
|
651
|
+
kind: spec.kind,
|
|
652
|
+
version: spec.version,
|
|
653
|
+
key: item.key,
|
|
654
|
+
shape,
|
|
655
|
+
confidence: 0,
|
|
656
|
+
source: "rule",
|
|
657
|
+
ms
|
|
658
|
+
},
|
|
659
|
+
unreviewed: true,
|
|
660
|
+
fallback: reason
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
function fromCache(cached, key, ms) {
|
|
664
|
+
const { at: _at, source: _source, ...rest } = cached;
|
|
665
|
+
return {
|
|
666
|
+
...rest,
|
|
667
|
+
key,
|
|
668
|
+
source: "cache",
|
|
669
|
+
ms
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
function toCached(decision, source, at) {
|
|
673
|
+
const cached = {
|
|
674
|
+
kind: decision.kind,
|
|
675
|
+
version: decision.version,
|
|
676
|
+
shape: decision.shape,
|
|
677
|
+
confidence: decision.confidence,
|
|
678
|
+
source,
|
|
679
|
+
at
|
|
680
|
+
};
|
|
681
|
+
for (const field of [
|
|
682
|
+
"choice",
|
|
683
|
+
"score",
|
|
684
|
+
"probabilities",
|
|
685
|
+
"proposed",
|
|
686
|
+
"model"
|
|
687
|
+
]) if (decision[field] !== void 0) Object.assign(cached, { [field]: decision[field] });
|
|
688
|
+
return cached;
|
|
689
|
+
}
|
|
690
|
+
/** Split `total` into `parts` integers that sum to it. */
|
|
691
|
+
function share(total, parts, index) {
|
|
692
|
+
const base = Math.floor(total / parts);
|
|
693
|
+
return base + (index < total - base * parts ? 1 : 0);
|
|
694
|
+
}
|
|
695
|
+
function createDecider(config = {}) {
|
|
696
|
+
const kinds = new Map((config.kinds ?? BUILTIN_KINDS).map((spec) => [spec.kind, spec]));
|
|
697
|
+
const cache = config.cache ?? new MemoryDecisionCache();
|
|
698
|
+
const now = config.now ?? (() => /* @__PURE__ */ new Date());
|
|
699
|
+
const providers = [];
|
|
700
|
+
if (config.jev !== false) providers.push(config.jev ?? createJevProvider());
|
|
701
|
+
providers.push(config.llm ?? noopLlmProvider);
|
|
702
|
+
const breakers = new Map(providers.map((p) => [p.name, config.breakers?.[p.name] ?? new CircuitBreaker()]));
|
|
703
|
+
const jevModel = providers.find((p) => p.name === "jev")?.model ?? "jev-latest";
|
|
704
|
+
const shapes = /* @__PURE__ */ new Map();
|
|
705
|
+
const shapeOf = (spec) => {
|
|
706
|
+
let shape = shapes.get(spec.kind);
|
|
707
|
+
if (shape === void 0) shapes.set(spec.kind, shape = requestShapeHash(spec, jevModel));
|
|
708
|
+
return shape;
|
|
709
|
+
};
|
|
710
|
+
function cost(provider, usage, parts, index) {
|
|
711
|
+
if (!usage) return void 0;
|
|
712
|
+
const input_tokens = share(usage.input_tokens, parts, index);
|
|
713
|
+
const output_tokens = share(usage.output_tokens, parts, index);
|
|
714
|
+
const price = config.pricing?.[provider];
|
|
715
|
+
const usd = price ? (input_tokens * price.inputPerMTok + output_tokens * price.outputPerMTok) / 1e6 : void 0;
|
|
716
|
+
return usd === void 0 ? {
|
|
717
|
+
input_tokens,
|
|
718
|
+
output_tokens
|
|
719
|
+
} : {
|
|
720
|
+
input_tokens,
|
|
721
|
+
output_tokens,
|
|
722
|
+
usd
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
/** Ask one provider about `items`; answered items are returned by key, the rest keep their reason to fall back. */
|
|
726
|
+
async function ask(provider, spec, shape, items, tenant) {
|
|
727
|
+
const answered = /* @__PURE__ */ new Map();
|
|
728
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
729
|
+
const breaker = breakers.get(provider.name);
|
|
730
|
+
if (!provider.available() || provider.name === "jev" && !spec.jev) {
|
|
731
|
+
for (const item of items) reasons.set(item.id, "no_provider");
|
|
732
|
+
return {
|
|
733
|
+
answered,
|
|
734
|
+
reasons
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
const groups = /* @__PURE__ */ new Map();
|
|
738
|
+
for (const item of items) {
|
|
739
|
+
const group = spec.jev?.group?.(item.shaped) ?? "";
|
|
740
|
+
groups.set(group, [...groups.get(group) ?? [], item]);
|
|
741
|
+
}
|
|
742
|
+
const batches = [];
|
|
743
|
+
for (const members of groups.values()) {
|
|
744
|
+
const fixed = spec.jev?.header ? estimateTokens(spec.jev.header(members[0].shaped)) : 0;
|
|
745
|
+
const costs = members.map((item) => Math.max(1, estimateTokens(JSON.stringify(buildJevRequestFor(spec, item.shaped))) - fixed));
|
|
746
|
+
for (const batch of planBatches(costs, fixed, spec.jev?.batch ?? config.batch)) batches.push(batch.map((i) => members[i]));
|
|
747
|
+
}
|
|
748
|
+
for (const batch of batches) {
|
|
749
|
+
if (!breaker.allows()) {
|
|
750
|
+
for (const item of batch) reasons.set(item.id, "circuit_open");
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
let members = batch;
|
|
754
|
+
if (config.budget) {
|
|
755
|
+
const granted = await config.budget.take(tenant, batch.length, now());
|
|
756
|
+
members = batch.slice(0, granted);
|
|
757
|
+
for (const item of batch.slice(granted)) reasons.set(item.id, "budget");
|
|
758
|
+
}
|
|
759
|
+
if (!members.length) continue;
|
|
760
|
+
const started = performance.now();
|
|
761
|
+
try {
|
|
762
|
+
const answer = await provider.ask(spec, members.map((item) => item.shaped));
|
|
763
|
+
breaker.success();
|
|
764
|
+
const ms = Math.round(performance.now() - started);
|
|
765
|
+
members.forEach((item, i) => {
|
|
766
|
+
const outcome = answer.outcomes[i];
|
|
767
|
+
if (!validOutcome(spec, outcome)) {
|
|
768
|
+
reasons.set(item.id, "no_answer");
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const decision = fromOutcome(spec, item.key, shape, outcome, provider.name, ms);
|
|
772
|
+
const spent = cost(provider.name, answer.usage, members.length, i);
|
|
773
|
+
if (spent) decision.cost = spent;
|
|
774
|
+
if (answer.model) decision.model = answer.model;
|
|
775
|
+
answered.set(item.id, applyFloor(spec, decision));
|
|
776
|
+
});
|
|
777
|
+
} catch (error) {
|
|
778
|
+
breaker.failure();
|
|
779
|
+
const reason = error?.timeout === true ? "timeout" : "error";
|
|
780
|
+
for (const item of members) reasons.set(item.id, reason);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return {
|
|
784
|
+
answered,
|
|
785
|
+
reasons
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
async function decideMany(kind, inputs, options = {}) {
|
|
789
|
+
const spec = kinds.get(kind);
|
|
790
|
+
if (!spec) throw new TypeError(`unknown decision kind: ${kind} (known: ${[...kinds.keys()].join(", ")})`);
|
|
791
|
+
const tenant = options.tenant ?? "default";
|
|
792
|
+
const shape = shapeOf(spec);
|
|
793
|
+
const fold = options.fold ?? true;
|
|
794
|
+
const rule = options.rule === null ? void 0 : options.rule ?? spec.rule;
|
|
795
|
+
const results = Array.from({ length: inputs.length });
|
|
796
|
+
const pending = /* @__PURE__ */ new Map();
|
|
797
|
+
inputs.forEach((input, i) => {
|
|
798
|
+
const started = performance.now();
|
|
799
|
+
const shaped = spec.shape(input);
|
|
800
|
+
const key = cacheKey(spec.kind, spec.version, shape, shaped);
|
|
801
|
+
const verdict = rule?.(input);
|
|
802
|
+
const valid = verdict && validOutcome(spec, verdict) ? verdict : void 0;
|
|
803
|
+
if (valid?.final) {
|
|
804
|
+
results[i] = fromOutcome(spec, key, shape, valid, "rule", Math.round(performance.now() - started));
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const id = fold ? key : `${key}#${i}`;
|
|
808
|
+
const existing = pending.get(id);
|
|
809
|
+
if (existing) existing.at.push(i);
|
|
810
|
+
else pending.set(id, {
|
|
811
|
+
at: [i],
|
|
812
|
+
id,
|
|
813
|
+
key,
|
|
814
|
+
shaped,
|
|
815
|
+
rule: valid
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
let open = [...pending.values()];
|
|
819
|
+
if (!options.noCache) {
|
|
820
|
+
const started = performance.now();
|
|
821
|
+
const hits = await Promise.all(open.map((item) => cache.get(item.key)));
|
|
822
|
+
const ms = Math.round(performance.now() - started);
|
|
823
|
+
open = open.filter((item, i) => {
|
|
824
|
+
const hit = hits[i];
|
|
825
|
+
if (hit?.kind !== spec.kind || hit.version !== spec.version || hit.shape !== shape) return true;
|
|
826
|
+
for (const at of item.at) results[at] = fromCache(hit, item.key, ms);
|
|
827
|
+
return false;
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
const lastReason = /* @__PURE__ */ new Map();
|
|
831
|
+
for (const provider of providers) {
|
|
832
|
+
if (!open.length) break;
|
|
833
|
+
const { answered, reasons } = await ask(provider, spec, shape, open, tenant);
|
|
834
|
+
for (const [id, decision] of answered) {
|
|
835
|
+
const item = pending.get(id);
|
|
836
|
+
for (const at of item.at) results[at] = decision;
|
|
837
|
+
if (!options.noCache) await cache.set(item.key, toCached(decision, provider.name, now().toISOString()));
|
|
838
|
+
}
|
|
839
|
+
for (const [id, reason] of reasons) if (reason !== "no_provider" || !lastReason.has(id)) lastReason.set(id, reason);
|
|
840
|
+
open = open.filter((item) => !answered.has(item.id));
|
|
841
|
+
}
|
|
842
|
+
for (const item of open) {
|
|
843
|
+
const decision = fallback(spec, item, shape, lastReason.get(item.id) ?? "no_provider", 0);
|
|
844
|
+
for (const at of item.at) results[at] = decision;
|
|
845
|
+
}
|
|
846
|
+
if (config.audit) {
|
|
847
|
+
const at = now().toISOString();
|
|
848
|
+
await config.audit.write(results.map((decision) => toAuditRecord(decision, at, tenant)));
|
|
849
|
+
}
|
|
850
|
+
return results;
|
|
851
|
+
}
|
|
852
|
+
return {
|
|
853
|
+
decideMany,
|
|
854
|
+
decide: async (kind, input, options) => (await decideMany(kind, [input], options))[0],
|
|
855
|
+
kinds: () => [...kinds.keys()]
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
function buildJevRequestFor(spec, shaped) {
|
|
859
|
+
return spec.jev ? buildJevRequest(spec, [shaped]) : shaped;
|
|
860
|
+
}
|
|
861
|
+
let defaultDecider;
|
|
862
|
+
/**
|
|
863
|
+
* Decide with the default decider: built-in kinds, Jev when
|
|
864
|
+
* `CONTENTRAIN_JEW_API_TOKEN` is set, an in-memory cache, no budget, no audit
|
|
865
|
+
* log. Build your own with `createDecider` for anything else.
|
|
866
|
+
*/
|
|
867
|
+
function decide(kind, input, options) {
|
|
868
|
+
defaultDecider ??= createDecider();
|
|
869
|
+
return defaultDecider.decide(kind, input, options);
|
|
870
|
+
}
|
|
871
|
+
//#endregion
|
|
872
|
+
//#region src/anthropic.ts
|
|
873
|
+
const ANTHROPIC_ENDPOINT = "https://api.anthropic.com/v1/messages";
|
|
874
|
+
const ANTHROPIC_KEY_ENV = "ANTHROPIC_API_KEY";
|
|
875
|
+
const DEFAULT_ANTHROPIC_MODEL = "claude-haiku-4-5-20251001";
|
|
876
|
+
const ANTHROPIC_VERSION = "2023-06-01";
|
|
877
|
+
const MAX_TOKENS = 4096;
|
|
878
|
+
const TOOL_NAME = "record_answers";
|
|
879
|
+
var AnthropicError = class extends Error {
|
|
880
|
+
constructor(message, status, timeout = false) {
|
|
881
|
+
super(message);
|
|
882
|
+
this.status = status;
|
|
883
|
+
this.timeout = timeout;
|
|
884
|
+
this.name = "AnthropicError";
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
const itemN = (text) => text.replace(/\bitem1\b/g, "itemN");
|
|
888
|
+
/** The prompt for a kind without its own `llmPrompt`: the state, then every question with its options or rubric. */
|
|
889
|
+
function genericLlmPrompt({ state, questions, count }) {
|
|
890
|
+
const lines = [
|
|
891
|
+
"<state>",
|
|
892
|
+
state,
|
|
893
|
+
"</state>",
|
|
894
|
+
"",
|
|
895
|
+
"Answer these questions for every item (itemN stands for the item's number):"
|
|
896
|
+
];
|
|
897
|
+
Object.entries(questions).forEach(([suffix, question], n) => {
|
|
898
|
+
if (question.type === "choice") {
|
|
899
|
+
lines.push(`${n + 1}) ${suffix} — ${itemN(question.instructions)} Options:`);
|
|
900
|
+
for (const [choice, meaning] of Object.entries(question.criteria)) lines.push(` - ${choice}: ${meaning}`);
|
|
901
|
+
} else if (question.type === "score") {
|
|
902
|
+
lines.push(`${n + 1}) ${suffix} — ${itemN(question.instructions)} An integer from 0 to ${question.criteria.length - 1}:`);
|
|
903
|
+
question.criteria.forEach((meaning, level) => lines.push(` ${level}: ${meaning}`));
|
|
904
|
+
} else lines.push(`${n + 1}) ${suffix} — the probability (0–1) that this statement is true: ${itemN(question.instructions)}`);
|
|
905
|
+
});
|
|
906
|
+
lines.push("", `Give all ${count} items through the ${TOOL_NAME} tool, one record per item.`);
|
|
907
|
+
return lines.join("\n");
|
|
908
|
+
}
|
|
909
|
+
/** The forced tool the model answers through: one record per item, one field per question. */
|
|
910
|
+
function answerTool(questions) {
|
|
911
|
+
const properties = { item: { type: "integer" } };
|
|
912
|
+
for (const [suffix, question] of Object.entries(questions)) properties[suffix] = question.type === "choice" ? {
|
|
913
|
+
type: "string",
|
|
914
|
+
enum: Object.keys(question.criteria)
|
|
915
|
+
} : question.type === "score" ? {
|
|
916
|
+
type: "integer",
|
|
917
|
+
minimum: 0,
|
|
918
|
+
maximum: question.criteria.length - 1
|
|
919
|
+
} : {
|
|
920
|
+
type: "number",
|
|
921
|
+
minimum: 0,
|
|
922
|
+
maximum: 1
|
|
923
|
+
};
|
|
924
|
+
return {
|
|
925
|
+
name: TOOL_NAME,
|
|
926
|
+
description: "Record the answers for every item.",
|
|
927
|
+
input_schema: {
|
|
928
|
+
type: "object",
|
|
929
|
+
properties: { items: {
|
|
930
|
+
type: "array",
|
|
931
|
+
items: {
|
|
932
|
+
type: "object",
|
|
933
|
+
properties,
|
|
934
|
+
required: Object.keys(properties)
|
|
935
|
+
}
|
|
936
|
+
} },
|
|
937
|
+
required: ["items"]
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
/** The Messages API body one batch sends. */
|
|
942
|
+
function buildAnthropicRequest(spec, shaped, model = DEFAULT_ANTHROPIC_MODEL) {
|
|
943
|
+
const jev = spec.jev;
|
|
944
|
+
if (!jev) throw new TypeError(`kind ${spec.kind} has no prompt`);
|
|
945
|
+
const { state } = buildJevRequest(spec, shaped);
|
|
946
|
+
const questions = jev.questions("item1");
|
|
947
|
+
const prompt = (jev.llmPrompt ?? genericLlmPrompt)({
|
|
948
|
+
state,
|
|
949
|
+
questions,
|
|
950
|
+
count: shaped.length
|
|
951
|
+
});
|
|
952
|
+
return {
|
|
953
|
+
model,
|
|
954
|
+
max_tokens: MAX_TOKENS,
|
|
955
|
+
temperature: 0,
|
|
956
|
+
tools: [answerTool(questions)],
|
|
957
|
+
tool_choice: {
|
|
958
|
+
type: "tool",
|
|
959
|
+
name: TOOL_NAME
|
|
960
|
+
},
|
|
961
|
+
messages: [{
|
|
962
|
+
role: "user",
|
|
963
|
+
content: prompt
|
|
964
|
+
}]
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
/** Hash of the LLM request shape (model, prompt, tool), recorded by a calibration of this provider. */
|
|
968
|
+
function anthropicRequestShapeHash(spec, model = DEFAULT_ANTHROPIC_MODEL) {
|
|
969
|
+
if (!spec.jev) return "none";
|
|
970
|
+
const request = buildAnthropicRequest(spec, [spec.jev.probe, spec.jev.probe], model);
|
|
971
|
+
return createHash("sha256").update(canonicalStringify(request)).digest("hex").slice(0, 16);
|
|
972
|
+
}
|
|
973
|
+
function createAnthropicProvider(options = {}) {
|
|
974
|
+
const env = options.env ?? process.env;
|
|
975
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
976
|
+
const timeoutMs = options.timeoutMs ?? 6e4;
|
|
977
|
+
const model = options.model ?? "claude-haiku-4-5-20251001";
|
|
978
|
+
return {
|
|
979
|
+
name: "llm",
|
|
980
|
+
model,
|
|
981
|
+
available: () => Boolean(env[ANTHROPIC_KEY_ENV]?.trim()),
|
|
982
|
+
async ask(spec, shaped) {
|
|
983
|
+
const key = env[ANTHROPIC_KEY_ENV]?.trim();
|
|
984
|
+
if (!key) throw new AnthropicError(`${ANTHROPIC_KEY_ENV} is not set`, void 0);
|
|
985
|
+
const request = buildAnthropicRequest(spec, shaped, model);
|
|
986
|
+
let response;
|
|
987
|
+
try {
|
|
988
|
+
response = await doFetch(ANTHROPIC_ENDPOINT, {
|
|
989
|
+
method: "POST",
|
|
990
|
+
headers: {
|
|
991
|
+
"x-api-key": key,
|
|
992
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
993
|
+
"content-type": "application/json"
|
|
994
|
+
},
|
|
995
|
+
body: JSON.stringify(request),
|
|
996
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
997
|
+
});
|
|
998
|
+
} catch (error) {
|
|
999
|
+
const name = error.name;
|
|
1000
|
+
if (name === "TimeoutError" || name === "AbortError") throw new AnthropicError(`Anthropic did not answer within ${timeoutMs}ms`, void 0, true);
|
|
1001
|
+
throw new AnthropicError(`Anthropic request failed: ${redact(String(error.message ?? error), key)}`, void 0);
|
|
1002
|
+
}
|
|
1003
|
+
const text = await response.text();
|
|
1004
|
+
if (!response.ok) throw new AnthropicError(`Anthropic answered HTTP ${response.status}: ${redact(text.slice(0, 200), key)}`, response.status);
|
|
1005
|
+
let body;
|
|
1006
|
+
try {
|
|
1007
|
+
body = JSON.parse(text);
|
|
1008
|
+
} catch {
|
|
1009
|
+
throw new AnthropicError("Anthropic answered with a body that is not JSON", response.status);
|
|
1010
|
+
}
|
|
1011
|
+
return readAnthropicResponse(spec, shaped.length, body);
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
/** Per-item outcomes from a Messages API response. An item whose record is missing or malformed gets `undefined`. */
|
|
1016
|
+
function readAnthropicResponse(spec, count, body) {
|
|
1017
|
+
const jev = spec.jev;
|
|
1018
|
+
const questions = jev.questions("item1");
|
|
1019
|
+
const root = body && typeof body === "object" ? body : {};
|
|
1020
|
+
const call = (Array.isArray(root.content) ? root.content : []).find((block) => block?.type === "tool_use" && block.name === TOOL_NAME);
|
|
1021
|
+
const input = call?.input && typeof call.input === "object" ? call.input : {};
|
|
1022
|
+
const records = Array.isArray(input.items) ? input.items : [];
|
|
1023
|
+
const outcomes = [];
|
|
1024
|
+
for (let i = 1; i <= count; i++) {
|
|
1025
|
+
const record = records.find((r) => r && typeof r === "object" && r.item === i);
|
|
1026
|
+
const own = {};
|
|
1027
|
+
let complete = Boolean(record);
|
|
1028
|
+
for (const [suffix, question] of Object.entries(questions)) {
|
|
1029
|
+
const answer = record ? readField(question, record[suffix]) : void 0;
|
|
1030
|
+
if (!answer) complete = false;
|
|
1031
|
+
else own[suffix] = answer;
|
|
1032
|
+
}
|
|
1033
|
+
outcomes.push(complete ? jev.read(own) : void 0);
|
|
1034
|
+
}
|
|
1035
|
+
const usage = root.usage;
|
|
1036
|
+
return {
|
|
1037
|
+
outcomes,
|
|
1038
|
+
...usage && typeof usage.input_tokens === "number" && typeof usage.output_tokens === "number" ? { usage: {
|
|
1039
|
+
input_tokens: usage.input_tokens,
|
|
1040
|
+
output_tokens: usage.output_tokens
|
|
1041
|
+
} } : {},
|
|
1042
|
+
...typeof root.model === "string" ? { model: root.model } : {}
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
function readField(question, value) {
|
|
1046
|
+
if (question.type === "choice") return typeof value === "string" && Object.hasOwn(question.criteria, value) ? {
|
|
1047
|
+
type: "choice",
|
|
1048
|
+
choice: value,
|
|
1049
|
+
confidence: 0
|
|
1050
|
+
} : void 0;
|
|
1051
|
+
if (question.type === "score") return Number.isInteger(value) && value >= 0 && value < question.criteria.length ? {
|
|
1052
|
+
type: "score",
|
|
1053
|
+
score: value,
|
|
1054
|
+
confidence: 0
|
|
1055
|
+
} : void 0;
|
|
1056
|
+
return typeof value === "number" && value >= 0 && value <= 1 ? {
|
|
1057
|
+
type: "noul",
|
|
1058
|
+
noul: value
|
|
1059
|
+
} : void 0;
|
|
1060
|
+
}
|
|
1061
|
+
function redact(text, key) {
|
|
1062
|
+
return text.split(key).join("[redacted]");
|
|
1063
|
+
}
|
|
1064
|
+
//#endregion
|
|
1065
|
+
//#region src/calibration.ts
|
|
1066
|
+
/** A request's header and one item line, as a replay key. */
|
|
1067
|
+
function replayKey(state, line) {
|
|
1068
|
+
const first = state.search(/^Item 1: /m);
|
|
1069
|
+
return `${first > 0 ? state.slice(0, first) : ""}\u0000${line}`;
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* A `fetch` that answers systemone requests for `spec` from recorded answers,
|
|
1073
|
+
* matching each item by its request header and its rendered line. An item
|
|
1074
|
+
* with no recording gets no answers, which the decider treats as `no_answer`.
|
|
1075
|
+
* `calls` counts requests.
|
|
1076
|
+
*
|
|
1077
|
+
* A replay re-reads the answers a model gave once. It checks the code path —
|
|
1078
|
+
* shaping, grouping, batching, reading — and says nothing about how the model
|
|
1079
|
+
* answers today; only a live run does.
|
|
1080
|
+
*/
|
|
1081
|
+
function createReplayFetch(spec, cases, model = "replay") {
|
|
1082
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1083
|
+
for (const recorded of cases) {
|
|
1084
|
+
const { state } = buildJevRequest(spec, [spec.shape(recorded.input)]);
|
|
1085
|
+
const line = /^Item 1: (.*)$/m.exec(state)[1];
|
|
1086
|
+
byKey.set(replayKey(state, line), recorded.answers);
|
|
1087
|
+
}
|
|
1088
|
+
const replay = async (_url, init) => {
|
|
1089
|
+
replay.calls++;
|
|
1090
|
+
const { state } = JSON.parse(String(init?.body));
|
|
1091
|
+
const answers = {};
|
|
1092
|
+
for (const line of state.split("\n")) {
|
|
1093
|
+
const match = /^Item (\d+): (.*)$/.exec(line);
|
|
1094
|
+
const recorded = match ? byKey.get(replayKey(state, match[2])) : void 0;
|
|
1095
|
+
if (!recorded) continue;
|
|
1096
|
+
for (const [suffix, answer] of Object.entries(recorded)) answers[`item${match[1]}_${suffix}`] = answer;
|
|
1097
|
+
}
|
|
1098
|
+
return new Response(JSON.stringify({
|
|
1099
|
+
model,
|
|
1100
|
+
answers
|
|
1101
|
+
}), {
|
|
1102
|
+
status: 200,
|
|
1103
|
+
headers: { "Content-Type": "application/json" }
|
|
1104
|
+
});
|
|
1105
|
+
};
|
|
1106
|
+
replay.calls = 0;
|
|
1107
|
+
return replay;
|
|
1108
|
+
}
|
|
1109
|
+
/** Exact class and severity within one level, over every label (an unanswered item counts as a miss). */
|
|
1110
|
+
function measurePunchCalibration(labels, decisions) {
|
|
1111
|
+
let answered = 0;
|
|
1112
|
+
let classMatch = 0;
|
|
1113
|
+
let severityWithin1 = 0;
|
|
1114
|
+
const misses = [];
|
|
1115
|
+
labels.forEach((expected, index) => {
|
|
1116
|
+
const decision = decisions[index];
|
|
1117
|
+
if (decision?.choice === void 0 || decision.score === void 0 || decision.unreviewed) {
|
|
1118
|
+
misses.push({
|
|
1119
|
+
index,
|
|
1120
|
+
expected
|
|
1121
|
+
});
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
answered++;
|
|
1125
|
+
const level = severityLevel(decision.score);
|
|
1126
|
+
const classOk = decision.choice === expected.class;
|
|
1127
|
+
const severityOk = Math.abs(level - expected.severity) <= 1;
|
|
1128
|
+
if (classOk) classMatch++;
|
|
1129
|
+
if (severityOk) severityWithin1++;
|
|
1130
|
+
if (!classOk || !severityOk) misses.push({
|
|
1131
|
+
index,
|
|
1132
|
+
expected,
|
|
1133
|
+
choice: decision.choice,
|
|
1134
|
+
level
|
|
1135
|
+
});
|
|
1136
|
+
});
|
|
1137
|
+
const total = labels.length;
|
|
1138
|
+
return {
|
|
1139
|
+
total,
|
|
1140
|
+
answered,
|
|
1141
|
+
classMatch,
|
|
1142
|
+
severityWithin1,
|
|
1143
|
+
classRate: total ? classMatch / total : 0,
|
|
1144
|
+
severityRate: total ? severityWithin1 / total : 0,
|
|
1145
|
+
misses
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
/** Whether repeated runs over the same inputs chose the same class for every item. */
|
|
1149
|
+
function stableChoices(runs) {
|
|
1150
|
+
const total = runs[0]?.length ?? 0;
|
|
1151
|
+
let stable = 0;
|
|
1152
|
+
for (let i = 0; i < total; i++) {
|
|
1153
|
+
const choices = new Set(runs.map((run) => run[i]?.choice));
|
|
1154
|
+
if (choices.size === 1 && !choices.has(void 0)) stable++;
|
|
1155
|
+
}
|
|
1156
|
+
return {
|
|
1157
|
+
stable,
|
|
1158
|
+
total
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
//#endregion
|
|
1162
|
+
export { ANTHROPIC_ENDPOINT, ANTHROPIC_KEY_ENV, AnthropicError, BUILTIN_KINDS, CircuitBreaker, DEFAULT_ANTHROPIC_MODEL, DEFAULT_BATCH, DEFAULT_JEV_MODEL, ELIGIBILITY_CHOICES, JEV_ENDPOINT, JEV_TOKEN_ENV, JevError, JsonlAuditLog, JsonlDecisionCache, MemoryAuditLog, MemoryDailyBudget, MemoryDecisionCache, NEEDS_HUMAN, PUNCH_CLASSES, PUNCH_CLASS_CRITERIA, PUNCH_SEVERITY_CRITERIA, anthropicRequestShapeHash, buildAnthropicRequest, buildJevRequest, cacheKey, createAnthropicProvider, createDecider, createJevProvider, createReplayFetch, decide, eligibilityBand, eligibilityRule, estimateTokens, genericLlmPrompt, measurePunchCalibration, noopLlmProvider, planBatches, punchItem, punchItemRule, readAnthropicResponse, readJevResponse, requestShapeHash, scrubSlug, scrubText, severityLevel, shapeEligibility, shapePunchItem, stableChoices, toAuditRecord };
|
|
1163
|
+
|
|
1164
|
+
//# sourceMappingURL=index.mjs.map
|