@bitbaum/ai-kit 0.6.2
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/LICENSE +21 -0
- package/README.md +216 -0
- package/dist/attempt.d.ts +48 -0
- package/dist/attempt.js +59 -0
- package/dist/catalog.d.ts +65 -0
- package/dist/catalog.js +115 -0
- package/dist/chain.d.ts +204 -0
- package/dist/chain.js +261 -0
- package/dist/fair-share.d.ts +120 -0
- package/dist/fair-share.js +127 -0
- package/dist/forms.d.ts +15 -0
- package/dist/forms.js +15 -0
- package/dist/grounding/contract.d.ts +101 -0
- package/dist/grounding/contract.js +138 -0
- package/dist/grounding/facts.d.ts +107 -0
- package/dist/grounding/facts.js +134 -0
- package/dist/grounding/index.d.ts +24 -0
- package/dist/grounding/index.js +24 -0
- package/dist/grounding/verify.d.ts +91 -0
- package/dist/grounding/verify.js +372 -0
- package/dist/health.d.ts +52 -0
- package/dist/health.js +64 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +70 -0
- package/dist/limits.d.ts +102 -0
- package/dist/limits.js +136 -0
- package/dist/react.d.ts +8 -0
- package/dist/react.js +8 -0
- package/dist/registry.d.ts +133 -0
- package/dist/registry.js +126 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +10 -0
- package/dist-cjs/grounding/contract.js +146 -0
- package/dist-cjs/grounding/facts.js +143 -0
- package/dist-cjs/grounding/index.js +43 -0
- package/dist-cjs/grounding/verify.js +376 -0
- package/dist-cjs/package.json +1 -0
- package/dist-cjs/registry.js +131 -0
- package/package.json +102 -0
- package/src/attempt.ts +82 -0
- package/src/catalog.ts +155 -0
- package/src/chain.ts +318 -0
- package/src/fair-share.ts +183 -0
- package/src/forms.ts +15 -0
- package/src/grounding/contract.ts +176 -0
- package/src/grounding/facts.ts +170 -0
- package/src/grounding/index.ts +50 -0
- package/src/grounding/verify.ts +429 -0
- package/src/health.ts +92 -0
- package/src/index.ts +124 -0
- package/src/limits.ts +137 -0
- package/src/react.ts +8 -0
- package/src/registry.ts +207 -0
- package/src/server.ts +10 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Groundedness verifier — MIRRORED MODULE (see core/README.md).
|
|
3
|
+
*
|
|
4
|
+
* Runs on the generated answer and reports claims the fact set does not support.
|
|
5
|
+
* Deliberately deterministic: no second model call, no embedding round-trip, no
|
|
6
|
+
* added cost or latency. That is a requirement, not a shortcut — this must run
|
|
7
|
+
* on every turn including the free-tier ones, and a verifier that costs a
|
|
8
|
+
* frontier call is one that gets disabled exactly where it is needed most.
|
|
9
|
+
*
|
|
10
|
+
* The insight that makes a cheap check work: fabrication is overwhelmingly
|
|
11
|
+
* NOMINAL. Models invent organisations, titles, people, file paths, phone
|
|
12
|
+
* numbers and dates — tokens that are mechanically recognisable and that must,
|
|
13
|
+
* if genuine, have appeared in the retrieved records or in what the user said.
|
|
14
|
+
* Grammar and hedging are hard to check; proper nouns and digits are easy.
|
|
15
|
+
*
|
|
16
|
+
* Scored against the real failure this was built from, every fabricated claim
|
|
17
|
+
* is caught by the proper-noun or numeric rule:
|
|
18
|
+
*
|
|
19
|
+
* "Ilya Druzhnikov (UZH)" → UZH: novel acronym
|
|
20
|
+
* "Accelerator & Bridge Program Manager" → novel proper-noun run
|
|
21
|
+
* "University of Liechtenstein", "START Summit" → novel proper-noun runs
|
|
22
|
+
* "/opt/fleetcrown/runner/.env" → novel path
|
|
23
|
+
*
|
|
24
|
+
* while the true parts ("Elena Weber SINGA Switzerland", "+41774730093") appear
|
|
25
|
+
* verbatim in the records and pass clean.
|
|
26
|
+
*/
|
|
27
|
+
import { NOT_RECORDED, type Fact } from "./facts.js";
|
|
28
|
+
|
|
29
|
+
export type Violation = {
|
|
30
|
+
kind: "unknown-citation" | "novel-proper-noun" | "novel-number" | "novel-path" | "uncited-claim";
|
|
31
|
+
/** The offending text. */
|
|
32
|
+
text: string;
|
|
33
|
+
/** Why it is a problem, phrased for a repair prompt the model will read. */
|
|
34
|
+
detail: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type VerifyResult = {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
violations: Violation[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Words that are capitalised for reasons other than being a proper noun, or
|
|
44
|
+
* that are part of this system's own vocabulary. Kept deliberately small —
|
|
45
|
+
* every entry is a hole in the check, so add only what demonstrably causes
|
|
46
|
+
* false positives, never to silence a true one.
|
|
47
|
+
*/
|
|
48
|
+
const COMMON = new Set(
|
|
49
|
+
[
|
|
50
|
+
// Sentence/structural
|
|
51
|
+
"the",
|
|
52
|
+
"a",
|
|
53
|
+
"an",
|
|
54
|
+
"and",
|
|
55
|
+
"or",
|
|
56
|
+
"but",
|
|
57
|
+
"if",
|
|
58
|
+
"then",
|
|
59
|
+
"so",
|
|
60
|
+
"because",
|
|
61
|
+
"not",
|
|
62
|
+
"this",
|
|
63
|
+
"that",
|
|
64
|
+
"these",
|
|
65
|
+
"those",
|
|
66
|
+
"it",
|
|
67
|
+
"its",
|
|
68
|
+
"your",
|
|
69
|
+
"you",
|
|
70
|
+
"i",
|
|
71
|
+
"we",
|
|
72
|
+
"there",
|
|
73
|
+
"here",
|
|
74
|
+
"what",
|
|
75
|
+
"which",
|
|
76
|
+
"who",
|
|
77
|
+
"when",
|
|
78
|
+
"where",
|
|
79
|
+
"why",
|
|
80
|
+
"how",
|
|
81
|
+
"no",
|
|
82
|
+
"yes",
|
|
83
|
+
"none",
|
|
84
|
+
"nothing",
|
|
85
|
+
"today",
|
|
86
|
+
"tomorrow",
|
|
87
|
+
"yesterday",
|
|
88
|
+
"now",
|
|
89
|
+
"next",
|
|
90
|
+
"last",
|
|
91
|
+
"first",
|
|
92
|
+
"one",
|
|
93
|
+
"two",
|
|
94
|
+
"three",
|
|
95
|
+
"primary",
|
|
96
|
+
"focus",
|
|
97
|
+
"task",
|
|
98
|
+
"tasks",
|
|
99
|
+
"outreach",
|
|
100
|
+
"note",
|
|
101
|
+
"notes",
|
|
102
|
+
"summary",
|
|
103
|
+
"status",
|
|
104
|
+
"update",
|
|
105
|
+
// Days / months — real words, never evidence of a fabricated entity
|
|
106
|
+
"monday",
|
|
107
|
+
"tuesday",
|
|
108
|
+
"wednesday",
|
|
109
|
+
"thursday",
|
|
110
|
+
"friday",
|
|
111
|
+
"saturday",
|
|
112
|
+
"sunday",
|
|
113
|
+
"january",
|
|
114
|
+
"february",
|
|
115
|
+
"march",
|
|
116
|
+
"april",
|
|
117
|
+
"may",
|
|
118
|
+
"june",
|
|
119
|
+
"july",
|
|
120
|
+
"august",
|
|
121
|
+
"september",
|
|
122
|
+
"october",
|
|
123
|
+
"november",
|
|
124
|
+
"december",
|
|
125
|
+
// This system's own nouns
|
|
126
|
+
"loki",
|
|
127
|
+
"cat",
|
|
128
|
+
"fleetcrown",
|
|
129
|
+
"orangecat",
|
|
130
|
+
"not",
|
|
131
|
+
"recorded",
|
|
132
|
+
].map((w) => w.toLowerCase()),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
/** Normalise for containment tests: casefold, collapse punctuation and space. */
|
|
136
|
+
function norm(s: string): string {
|
|
137
|
+
return s
|
|
138
|
+
.toLowerCase()
|
|
139
|
+
.replace(/[^a-z0-9+]+/g, " ")
|
|
140
|
+
.replace(/\s+/g, " ")
|
|
141
|
+
.trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Everything the model was legitimately given this turn: record values, record
|
|
146
|
+
* subjects, and the user's own message (a name the user typed is fair to
|
|
147
|
+
* repeat). This is the corpus a claim must be traceable to.
|
|
148
|
+
*/
|
|
149
|
+
function buildEvidence(facts: Fact[], userMessage: string, extra: string[]): string {
|
|
150
|
+
const parts: string[] = [userMessage, ...extra];
|
|
151
|
+
for (const f of facts) {
|
|
152
|
+
parts.push(f.subject, f.kind, f.source);
|
|
153
|
+
for (const v of Object.values(f.fields)) if (v) parts.push(v);
|
|
154
|
+
}
|
|
155
|
+
return norm(parts.join(" "));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Lowercase words that legitimately sit INSIDE a proper name and must not break
|
|
160
|
+
* it up: "University of Zurich", "Bank für Handel", "Institute for the Study of
|
|
161
|
+
* Complexity". Without these, the run splits at the connector and the check
|
|
162
|
+
* only ever sees the harmless halves ("University", "Zurich") while the actual
|
|
163
|
+
* fabricated entity slips through unnamed.
|
|
164
|
+
*/
|
|
165
|
+
const NAME_CONNECTORS = new Set([
|
|
166
|
+
"of",
|
|
167
|
+
"the",
|
|
168
|
+
"for",
|
|
169
|
+
"and",
|
|
170
|
+
"de",
|
|
171
|
+
"der",
|
|
172
|
+
"des",
|
|
173
|
+
"van",
|
|
174
|
+
"von",
|
|
175
|
+
"du",
|
|
176
|
+
"da",
|
|
177
|
+
"di",
|
|
178
|
+
"für",
|
|
179
|
+
"el",
|
|
180
|
+
"al",
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Named-entity candidates: ALL-CAPS acronyms, capitalised words, and the
|
|
185
|
+
* multi-word runs they form (connectors allowed strictly between two
|
|
186
|
+
* capitalised tokens, never at an edge).
|
|
187
|
+
*
|
|
188
|
+
* Both the run AND its individual tokens are emitted, deliberately. The run
|
|
189
|
+
* catches composite inventions ("University of Zurich") that no single token
|
|
190
|
+
* reveals; the individual tokens catch an invented acronym sitting next to a
|
|
191
|
+
* real name ("Druzhnikov UZH"), where reporting only the run would name the
|
|
192
|
+
* real person in the violation and produce a repair prompt that deletes the
|
|
193
|
+
* true claim along with the false one.
|
|
194
|
+
*
|
|
195
|
+
* Sentence-initial single words are skipped — otherwise "Rotate the key" flags
|
|
196
|
+
* "Rotate". That costs a little recall at sentence starts and removes the
|
|
197
|
+
* dominant source of false positives; a fabricated name at a sentence start is
|
|
198
|
+
* still caught by its remaining tokens.
|
|
199
|
+
*/
|
|
200
|
+
function properNounRuns(text: string): string[] {
|
|
201
|
+
const out: string[] = [];
|
|
202
|
+
// Strip fenced and inline code — quoted identifiers are usually the user's
|
|
203
|
+
// own or a literal under discussion, not a claim about the world.
|
|
204
|
+
const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ");
|
|
205
|
+
|
|
206
|
+
for (const sentence of prose.split(/(?<=[.!?:\n])\s+/)) {
|
|
207
|
+
const tokens = sentence.match(/[A-Za-z][A-Za-z0-9&.'’-]*/g) ?? [];
|
|
208
|
+
let run: string[] = [];
|
|
209
|
+
|
|
210
|
+
const flush = () => {
|
|
211
|
+
// Trim trailing connectors so "University of" never stands as a run.
|
|
212
|
+
while (run.length > 0 && NAME_CONNECTORS.has((run[run.length - 1] ?? "").toLowerCase()))
|
|
213
|
+
run.pop();
|
|
214
|
+
if (run.length > 1) out.push(run.join(" "));
|
|
215
|
+
run = [];
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
tokens.forEach((tok, i) => {
|
|
219
|
+
const bare = tok.replace(/[.'’-]+$/, "");
|
|
220
|
+
const isAcronym = /^[A-Z]{2,}$/.test(bare);
|
|
221
|
+
const isCapitalised = /^[A-Z][a-z]/.test(bare);
|
|
222
|
+
const isConnector = NAME_CONNECTORS.has(bare.toLowerCase());
|
|
223
|
+
|
|
224
|
+
if (isAcronym || (isCapitalised && i > 0)) {
|
|
225
|
+
run.push(bare);
|
|
226
|
+
out.push(bare); // individually checkable
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// A connector only continues a run that has already started.
|
|
230
|
+
if (isConnector && run.length > 0) {
|
|
231
|
+
run.push(bare);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
flush();
|
|
235
|
+
});
|
|
236
|
+
flush();
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Digit groups worth checking: phone numbers, years, percentages, counts ≥ 2 digits. */
|
|
242
|
+
function numericClaims(text: string): string[] {
|
|
243
|
+
const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ");
|
|
244
|
+
return (prose.match(/\+?\d[\d\s().-]{3,}\d|\b\d{2,}%?\b/g) ?? []).map((s) => s.trim());
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* File and path references — a favourite fabrication, and an unusually
|
|
249
|
+
* damaging one because naming a file implies the model READ it.
|
|
250
|
+
*
|
|
251
|
+
* Covers absolute paths (`/opt/fleetcrown/runner/.env`), relative paths
|
|
252
|
+
* (`data/contact-resolver.json`), and bare filenames with a data/config
|
|
253
|
+
* extension. The relative form matters: when challenged on the UZH claim, the
|
|
254
|
+
* model "corrected" itself by asserting what `data/contact-resolver.json`
|
|
255
|
+
* contained — a file it was never given. That reads as citing a source, which
|
|
256
|
+
* is precisely why an unverified correction is more corrosive than the
|
|
257
|
+
* original error: it spends the credibility the user was trying to restore.
|
|
258
|
+
*/
|
|
259
|
+
function pathClaims(text: string): string[] {
|
|
260
|
+
const patterns = [
|
|
261
|
+
/(?:^|[\s("'`])(\/[A-Za-z0-9_.\-/]{4,})/g, // absolute
|
|
262
|
+
/(?:^|[\s("'`])([A-Za-z0-9_.-]+\/[A-Za-z0-9_.\-/]*[A-Za-z0-9_-]\.[a-z]{2,5})/g, // relative w/ extension
|
|
263
|
+
/(?:^|[\s("'`])([A-Za-z0-9_-]+\.(?:json|env|ya?ml|sql|toml|ini|conf|log))\b/g, // bare config filename
|
|
264
|
+
];
|
|
265
|
+
const out = new Set<string>();
|
|
266
|
+
for (const re of patterns) {
|
|
267
|
+
for (const m of text.matchAll(re)) if (m[1]) out.add(m[1]);
|
|
268
|
+
}
|
|
269
|
+
return [...out];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* How strictly to treat unattested names — the one real difference between the
|
|
274
|
+
* two assistants that use this harness.
|
|
275
|
+
*
|
|
276
|
+
* `closed-world` (Loki): the assistant's entire job is reporting the operator's
|
|
277
|
+
* records, so ANY unattested proper noun is a fabrication. One operator, one
|
|
278
|
+
* data set, no legitimate outside knowledge in scope.
|
|
279
|
+
*
|
|
280
|
+
* `entity-attribution` (Cat): the assistant also answers general questions —
|
|
281
|
+
* how Lightning works, which payment rails exist in Switzerland — where naming
|
|
282
|
+
* Twint or Bitcoin is correct and required. Flagging those would make Cat
|
|
283
|
+
* useless. So the check narrows to what actually goes wrong: attributes
|
|
284
|
+
* attached to one of the USER'S OWN records. A sentence naming a known subject
|
|
285
|
+
* is checked; a sentence of general explanation is not.
|
|
286
|
+
*
|
|
287
|
+
* The narrower mode is genuinely weaker, and that is a real trade, not a
|
|
288
|
+
* loophole: Cat can still invent a fact about the wider world. It can no longer
|
|
289
|
+
* invent an employer for someone in your contacts, which is the failure that
|
|
290
|
+
* actually destroys trust in a personal assistant.
|
|
291
|
+
*/
|
|
292
|
+
export type VerifyMode = "closed-world" | "entity-attribution";
|
|
293
|
+
|
|
294
|
+
/** Does this sentence talk about one of the user's own records? */
|
|
295
|
+
function mentionsSubject(sentence: string, subjects: string[]): boolean {
|
|
296
|
+
const s = norm(sentence);
|
|
297
|
+
return subjects.some((sub) => {
|
|
298
|
+
const n = norm(sub);
|
|
299
|
+
return n.length > 2 && s.includes(n);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Verify an answer against the facts it was supposed to come from.
|
|
305
|
+
*
|
|
306
|
+
* `extraEvidence` lets a caller admit sources outside the fact set — computed
|
|
307
|
+
* directive output, a tool result the model legitimately saw this turn.
|
|
308
|
+
* Anything not in facts, the user's message, or extraEvidence is unsupported
|
|
309
|
+
* by construction.
|
|
310
|
+
*
|
|
311
|
+
* `subjects` (entity-attribution mode) names the user's own records, so the
|
|
312
|
+
* check can tell "your contact Elena works at X" from "Lightning is instant".
|
|
313
|
+
*/
|
|
314
|
+
export function verifyAnswer(input: {
|
|
315
|
+
answer: string;
|
|
316
|
+
facts: Fact[];
|
|
317
|
+
userMessage: string;
|
|
318
|
+
extraEvidence?: string[];
|
|
319
|
+
mode?: VerifyMode;
|
|
320
|
+
subjects?: string[];
|
|
321
|
+
/**
|
|
322
|
+
* Extra legal citation handles beyond the fact ids — the [D1] series for
|
|
323
|
+
* computed answers. Without these a model that correctly cites a computed
|
|
324
|
+
* result gets flagged for citing something "that does not exist", which
|
|
325
|
+
* would train the repair pass to delete true statements.
|
|
326
|
+
*/
|
|
327
|
+
extraCitationIds?: string[];
|
|
328
|
+
}): VerifyResult {
|
|
329
|
+
const { answer, facts, userMessage } = input;
|
|
330
|
+
const mode: VerifyMode = input.mode ?? "closed-world";
|
|
331
|
+
const subjects = input.subjects ?? facts.map((f) => f.subject);
|
|
332
|
+
const evidence = buildEvidence(facts, userMessage, input.extraEvidence ?? []);
|
|
333
|
+
const legalIds = new Set([
|
|
334
|
+
...facts.map((f) => f.id.toUpperCase()),
|
|
335
|
+
...(input.extraCitationIds ?? []).map((id) => id.toUpperCase()),
|
|
336
|
+
]);
|
|
337
|
+
const violations: Violation[] = [];
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* In entity-attribution mode, only sentences about the user's own records are
|
|
341
|
+
* subject to the name check. Built once so the per-token loop stays cheap.
|
|
342
|
+
*/
|
|
343
|
+
const attributionScope =
|
|
344
|
+
mode === "entity-attribution"
|
|
345
|
+
? answer
|
|
346
|
+
.split(/(?<=[.!?:\n])\s+/)
|
|
347
|
+
.filter((s) => mentionsSubject(s, subjects))
|
|
348
|
+
.join(" ")
|
|
349
|
+
: answer;
|
|
350
|
+
|
|
351
|
+
// 1. Citations must resolve. A citation to a record that does not exist is
|
|
352
|
+
// the strongest possible signal of fabrication — it invents its own proof.
|
|
353
|
+
for (const cite of answer.match(/\[[FD]\d+\]/g) ?? []) {
|
|
354
|
+
const id = cite.slice(1, -1).toUpperCase();
|
|
355
|
+
if (!legalIds.has(id)) {
|
|
356
|
+
violations.push({
|
|
357
|
+
kind: "unknown-citation",
|
|
358
|
+
text: cite,
|
|
359
|
+
detail: `${cite} is not a record in this turn's context. Cite only ids that were provided, or say there is no record.`,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 2. Named entities must be traceable. This is the anti-"UZH" rule.
|
|
365
|
+
const seen = new Set<string>();
|
|
366
|
+
for (const run of properNounRuns(attributionScope)) {
|
|
367
|
+
const n = norm(run);
|
|
368
|
+
if (!n || seen.has(n)) continue;
|
|
369
|
+
seen.add(n);
|
|
370
|
+
// Single common words are noise; multi-word runs always checked.
|
|
371
|
+
const words = n.split(" ");
|
|
372
|
+
if (words.length === 1 && (COMMON.has(words[0] ?? "") || (words[0] ?? "").length < 2)) continue;
|
|
373
|
+
if (words.every((w) => COMMON.has(w))) continue;
|
|
374
|
+
if (evidence.includes(n)) continue;
|
|
375
|
+
// A multi-word run whose every word is individually attested is fine —
|
|
376
|
+
// it is a rephrasing, not a new entity.
|
|
377
|
+
if (words.length > 1 && words.every((w) => COMMON.has(w) || evidence.includes(w))) continue;
|
|
378
|
+
violations.push({
|
|
379
|
+
kind: "novel-proper-noun",
|
|
380
|
+
text: run,
|
|
381
|
+
detail: `"${run}" does not appear in any record or in the operator's message. If it is an organisation, role, or place you associated with someone, the relevant field is ${NOT_RECORDED} — remove the claim.`,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// 3. Numbers must be traceable — invented phone numbers and dates read as
|
|
386
|
+
// authoritative precisely because they are specific.
|
|
387
|
+
for (const num of numericClaims(answer)) {
|
|
388
|
+
const n = norm(num);
|
|
389
|
+
if (!n || n.length < 2) continue;
|
|
390
|
+
if (evidence.includes(n)) continue;
|
|
391
|
+
// Compare digits-only too: "+41 77 473 00 93" vs stored "+41774730093".
|
|
392
|
+
const digits = num.replace(/\D/g, "");
|
|
393
|
+
if (digits.length >= 4 && evidence.replace(/\D/g, "").includes(digits)) continue;
|
|
394
|
+
if (digits.length < 4) continue; // small counts ("3 tasks") are rhetorical
|
|
395
|
+
violations.push({
|
|
396
|
+
kind: "novel-number",
|
|
397
|
+
text: num,
|
|
398
|
+
detail: `The number "${num}" is not in any record. Do not state contact details, dates, or metrics that were not provided.`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 4. Paths — "update the key in /opt/fleetcrown/runner/.env" was invented
|
|
403
|
+
// wholesale, and its specificity is what made it convincing.
|
|
404
|
+
for (const p of pathClaims(answer)) {
|
|
405
|
+
if (evidence.includes(norm(p))) continue;
|
|
406
|
+
violations.push({
|
|
407
|
+
kind: "novel-path",
|
|
408
|
+
text: p,
|
|
409
|
+
detail: `The path "${p}" is not in any record. Do not state file locations you were not given.`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return { ok: violations.length === 0, violations };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Turn violations into a repair instruction. One cheap retry with this appended
|
|
418
|
+
* fixes most turns, because the model is not being asked to know more — only to
|
|
419
|
+
* delete claims it cannot support.
|
|
420
|
+
*/
|
|
421
|
+
export function buildRepairPrompt(violations: Violation[], noBasisPhrase: string): string {
|
|
422
|
+
return [
|
|
423
|
+
"Your previous answer contained claims not supported by the records. Rewrite it.",
|
|
424
|
+
"",
|
|
425
|
+
...violations.map((v) => `- ${v.detail}`),
|
|
426
|
+
"",
|
|
427
|
+
`Remove every unsupported claim. Where removing one empties a requested item, write "${noBasisPhrase}" for that item instead of substituting something else. Keep everything that was supported, unchanged.`,
|
|
428
|
+
].join("\n");
|
|
429
|
+
}
|
package/src/health.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observed health of an AI feature — was the last generation actually usable?
|
|
3
|
+
*
|
|
4
|
+
* This exists because of a failure that "the chain never fails" hides just as
|
|
5
|
+
* well as a single pin does. On 2026-08-28 an app's only configured key
|
|
6
|
+
* started returning 401 and the chain (correctly) had nowhere else to go — the
|
|
7
|
+
* routes caught the error and answered HTTP 200 with an apology, and the
|
|
8
|
+
* app's own `/health` reported "healthy" because it only ever checked the
|
|
9
|
+
* database. A total outage of the product's core feature was invisible to
|
|
10
|
+
* every automated check, for as long as nobody happened to try it by hand.
|
|
11
|
+
*
|
|
12
|
+
* A tracker records what actually happened, so a health route can report it
|
|
13
|
+
* and a strict check can refuse to call the app "up" while its AI is down.
|
|
14
|
+
*
|
|
15
|
+
* FACTORY, NOT A SINGLETON. A module-level global would force a shared state
|
|
16
|
+
* shape on every consumer and make the transitions untestable without mutating
|
|
17
|
+
* process-wide state between tests. `createHealthTracker()` returns an
|
|
18
|
+
* isolated instance — a single-process app gets the old singleton behaviour
|
|
19
|
+
* for free by creating exactly one and exporting it from its own module:
|
|
20
|
+
*
|
|
21
|
+
* // lib/llm-health.ts
|
|
22
|
+
* export const llmHealth = createHealthTracker();
|
|
23
|
+
*
|
|
24
|
+
* If the app scales horizontally, this state is per-instance and wants a
|
|
25
|
+
* shared store — that migration is app-specific and out of scope here.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type HealthStatus = "unknown" | "ok" | "degraded" | "down";
|
|
29
|
+
|
|
30
|
+
export interface Health {
|
|
31
|
+
status: HealthStatus;
|
|
32
|
+
consecutiveFailures: number;
|
|
33
|
+
lastError: string | null;
|
|
34
|
+
/** Epoch milliseconds. Format at the API boundary, not here. */
|
|
35
|
+
lastSuccessAt: number | null;
|
|
36
|
+
/** Epoch milliseconds. Format at the API boundary, not here. */
|
|
37
|
+
lastFailureAt: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HealthTrackerOptions {
|
|
41
|
+
/** Consecutive failures before status flips from "degraded" to "down". Default 3. */
|
|
42
|
+
downAfter?: number;
|
|
43
|
+
/** Clock injection point for tests. Default `Date.now`. */
|
|
44
|
+
now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface HealthTracker {
|
|
48
|
+
/** Call after a generation that produced usable content. */
|
|
49
|
+
recordSuccess(): void;
|
|
50
|
+
/** Call when generation threw, or returned nothing usable. */
|
|
51
|
+
recordFailure(error: unknown): void;
|
|
52
|
+
getHealth(): Health;
|
|
53
|
+
/** Test seam — also useful for an app-triggered "recheck now". */
|
|
54
|
+
reset(): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createHealthTracker(options: HealthTrackerOptions = {}): HealthTracker {
|
|
58
|
+
const downAfter = options.downAfter ?? 3;
|
|
59
|
+
const now = options.now ?? Date.now;
|
|
60
|
+
|
|
61
|
+
let consecutiveFailures = 0;
|
|
62
|
+
let lastError: string | null = null;
|
|
63
|
+
let lastSuccessAt: number | null = null;
|
|
64
|
+
let lastFailureAt: number | null = null;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
recordSuccess() {
|
|
68
|
+
consecutiveFailures = 0;
|
|
69
|
+
lastError = null;
|
|
70
|
+
lastSuccessAt = now();
|
|
71
|
+
},
|
|
72
|
+
recordFailure(error: unknown) {
|
|
73
|
+
consecutiveFailures += 1;
|
|
74
|
+
lastFailureAt = now();
|
|
75
|
+
lastError = error instanceof Error ? error.message : String(error ?? "unknown error");
|
|
76
|
+
},
|
|
77
|
+
getHealth(): Health {
|
|
78
|
+
let status: HealthStatus;
|
|
79
|
+
if (consecutiveFailures >= downAfter) status = "down";
|
|
80
|
+
else if (consecutiveFailures > 0) status = "degraded";
|
|
81
|
+
else if (lastSuccessAt !== null) status = "ok";
|
|
82
|
+
else status = "unknown";
|
|
83
|
+
return { status, consecutiveFailures, lastError, lastSuccessAt, lastFailureAt };
|
|
84
|
+
},
|
|
85
|
+
reset() {
|
|
86
|
+
consecutiveFailures = 0;
|
|
87
|
+
lastError = null;
|
|
88
|
+
lastSuccessAt = null;
|
|
89
|
+
lastFailureAt = null;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ai-kit — one install for the AI layer of an app.
|
|
3
|
+
*
|
|
4
|
+
* WHAT IT IS FOR
|
|
5
|
+
* --------------
|
|
6
|
+
* An app that wants an AI feature needs several unrelated-looking decisions to
|
|
7
|
+
* go right, and getting any one wrong looks identical from the outside: the
|
|
8
|
+
* assistant is broken. This package holds all of them, so adding AI is one
|
|
9
|
+
* decision instead of many.
|
|
10
|
+
*
|
|
11
|
+
* which model — a fallback list ACROSS VENDORS, because a single pinned free
|
|
12
|
+
* model is a scheduled outage, and a smaller model at the same
|
|
13
|
+
* vendor draws on the same exhausted daily budget.
|
|
14
|
+
* still there? — has the vendor retired an id we still ask for? The list is
|
|
15
|
+
* itself a list of pins, so it rots too. Zero tokens, so it can
|
|
16
|
+
* run on a schedule instead of being remembered.
|
|
17
|
+
* walk it — a chain nobody walks is a list, not a fallback. `tryChain`
|
|
18
|
+
* tries each link and stops at the first success; a
|
|
19
|
+
* `HealthTracker` records whether the WHOLE chain came back
|
|
20
|
+
* empty, so a health route can say so before a user does.
|
|
21
|
+
* too fast? — tell the three kinds of 429 apart. They share a status code
|
|
22
|
+
* and need opposite responses; only the body distinguishes them.
|
|
23
|
+
* who gets it — divide a fixed daily pool across active users, so the person
|
|
24
|
+
* who arrives at 4pm still gets a turn.
|
|
25
|
+
* filling forms— fill a form from prose and keep talking to it, re-exported
|
|
26
|
+
* from `ai-forms` (see ./forms.ts for why it stays separate).
|
|
27
|
+
*
|
|
28
|
+
* WHY IT WAS RENAMED FROM ai-ration
|
|
29
|
+
* ---------------------------------
|
|
30
|
+
* Because the owner of this fleet read the name and could not tell what it did.
|
|
31
|
+
* That is not a cosmetic complaint: an unreadable name is an adoption cost paid
|
|
32
|
+
* on every single install decision, and this package had ONE adopter while the
|
|
33
|
+
* five repos that skipped it were all taken down together on 2026-08-26 by a
|
|
34
|
+
* retired model id — the exact failure the `chain` and `catalog` modules exist
|
|
35
|
+
* to prevent. "Ration" described one of five modules and buried the other four.
|
|
36
|
+
*
|
|
37
|
+
* STILL NOT INCLUDED: an HTTP client. Every app has its own calling conventions,
|
|
38
|
+
* retries and logging, and replacing those is a rewrite rather than an adoption.
|
|
39
|
+
* This supplies the decisions; the caller keeps the fetch — `tryChain` is an
|
|
40
|
+
* orchestrator, not a client: the caller's own `attempt` function makes the
|
|
41
|
+
* actual request. That rule is under review — `ai-forms`, the most-adopted
|
|
42
|
+
* package in this fleet, is the one that broke it by shipping a route factory
|
|
43
|
+
* and a hook.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
type Provider,
|
|
48
|
+
type Env,
|
|
49
|
+
type Link,
|
|
50
|
+
type CostVerdict,
|
|
51
|
+
providerModels,
|
|
52
|
+
withEnvPrefix,
|
|
53
|
+
freeChain,
|
|
54
|
+
modelCost,
|
|
55
|
+
modelCostAt,
|
|
56
|
+
paidModelsIn,
|
|
57
|
+
dayCapacityTokens,
|
|
58
|
+
usableChain,
|
|
59
|
+
chainFrom,
|
|
60
|
+
} from "./chain.js";
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
type CatalogVerdict,
|
|
64
|
+
type CheckCatalogOptions,
|
|
65
|
+
checkCatalog,
|
|
66
|
+
hasRot,
|
|
67
|
+
deadProviders,
|
|
68
|
+
catalogReport,
|
|
69
|
+
} from "./catalog.js";
|
|
70
|
+
|
|
71
|
+
export {
|
|
72
|
+
type ChainAttemptFailure,
|
|
73
|
+
type TryChainOptions,
|
|
74
|
+
ChainExhaustedError,
|
|
75
|
+
tryChain,
|
|
76
|
+
} from "./attempt.js";
|
|
77
|
+
|
|
78
|
+
export {
|
|
79
|
+
type HealthStatus,
|
|
80
|
+
type Health,
|
|
81
|
+
type HealthTrackerOptions,
|
|
82
|
+
type HealthTracker,
|
|
83
|
+
createHealthTracker,
|
|
84
|
+
} from "./health.js";
|
|
85
|
+
|
|
86
|
+
export {
|
|
87
|
+
type RateLimitKind,
|
|
88
|
+
classifyRateLimit,
|
|
89
|
+
retryAfterSeconds,
|
|
90
|
+
humanizeWait,
|
|
91
|
+
rateLimitMessage,
|
|
92
|
+
} from "./limits.js";
|
|
93
|
+
|
|
94
|
+
export {
|
|
95
|
+
DAY_SECONDS,
|
|
96
|
+
DEFAULT_BURST,
|
|
97
|
+
type ShareInput,
|
|
98
|
+
type ShareReason,
|
|
99
|
+
type ShareDecision,
|
|
100
|
+
fairShare,
|
|
101
|
+
utcDayElapsed,
|
|
102
|
+
utcDayKey,
|
|
103
|
+
} from "./fair-share.js";
|
|
104
|
+
|
|
105
|
+
// Form filling lives at `ai-kit/forms`, NOT here.
|
|
106
|
+
//
|
|
107
|
+
// It was re-exported from this root for one release, so that "adding AI" was a
|
|
108
|
+
// single import as well as a single install. The first app to adopt the merged
|
|
109
|
+
// package showed what that costs: `ai-forms` is ESM-only, so pulling it in from
|
|
110
|
+
// this root made every consumer of the CHAIN load the forms package too — and
|
|
111
|
+
// the app's Jest run, which executes CJS, died on `Unexpected token 'export'`
|
|
112
|
+
// inside a module it never asked for. The fix would have been a
|
|
113
|
+
// `transformIgnorePatterns` entry in that app, and in the next one, and in
|
|
114
|
+
// every app thereafter: one class of breakage, paid per repo, forever.
|
|
115
|
+
//
|
|
116
|
+
// One install is still the promise, and the exports map already keeps it:
|
|
117
|
+
//
|
|
118
|
+
// import { freeChain } from "@bitbaum/ai-kit"; // the chain
|
|
119
|
+
// import { defineFields } from "@bitbaum/ai-kit/forms"; // form filling
|
|
120
|
+
// import { useAssist } from "@bitbaum/ai-kit/react"; // the React hook
|
|
121
|
+
//
|
|
122
|
+
// Same dependency, same version, nothing extra to install — a consumer just
|
|
123
|
+
// stops paying for the half it does not use. That is what subpath exports are
|
|
124
|
+
// for, and collapsing them into the root threw the benefit away.
|