@zanii/blackbox 0.3.0 → 0.4.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 +26 -1
- package/dist/a2a/index.d.ts +27 -0
- package/dist/a2a/index.js +104 -1
- package/dist/agents/index.d.ts +8 -0
- package/dist/analysis/accuracy.d.ts +24 -0
- package/dist/analysis/accuracy.js +45 -0
- package/dist/analysis/credential.d.ts +101 -0
- package/dist/analysis/credential.js +142 -0
- package/dist/analysis/faults.js +115 -0
- package/dist/analysis/grounding.d.ts +122 -0
- package/dist/analysis/grounding.js +445 -0
- package/dist/analysis/hallucination.d.ts +32 -0
- package/dist/analysis/hallucination.js +357 -0
- package/dist/analysis/index.d.ts +23 -0
- package/dist/analysis/index.js +93 -0
- package/dist/analysis/memory.d.ts +8 -0
- package/dist/analysis/memory.js +35 -8
- package/dist/analysis/reference.d.ts +49 -0
- package/dist/analysis/reference.js +164 -0
- package/dist/analysis/taxonomy.js +1 -0
- package/dist/approvals/index.d.ts +23 -0
- package/dist/approvals/index.js +48 -0
- package/dist/archive/parquet.d.ts +2 -0
- package/dist/archive/parquet.js +185 -0
- package/dist/badge/index.d.ts +16 -0
- package/dist/badge/index.js +48 -0
- package/dist/bom/index.js +20 -0
- package/dist/cli.js +114 -10
- package/dist/compliance/art12.js +36 -9
- package/dist/compliance/index.d.ts +36 -2
- package/dist/compliance/index.js +78 -11
- package/dist/compliance/zanii.d.ts +29 -0
- package/dist/compliance/zanii.js +84 -0
- package/dist/constitution/index.d.ts +57 -0
- package/dist/constitution/index.js +131 -0
- package/dist/cv/index.d.ts +39 -0
- package/dist/cv/index.js +108 -0
- package/dist/disclosure/index.d.ts +31 -0
- package/dist/disclosure/index.js +113 -0
- package/dist/encryption/index.d.ts +9 -0
- package/dist/encryption/index.js +31 -0
- package/dist/evidence/index.d.ts +60 -0
- package/dist/evidence/index.js +151 -0
- package/dist/federation/index.d.ts +35 -0
- package/dist/federation/index.js +102 -0
- package/dist/finance/index.d.ts +126 -0
- package/dist/finance/index.js +320 -0
- package/dist/fleet/index.js +9 -0
- package/dist/gov/index.d.ts +108 -0
- package/dist/gov/index.js +225 -0
- package/dist/health/index.d.ts +120 -0
- package/dist/health/index.js +233 -0
- package/dist/index.d.ts +28 -6
- package/dist/index.js +28 -6
- package/dist/memory/index.d.ts +36 -0
- package/dist/memory/index.js +85 -0
- package/dist/occurrence/index.d.ts +11 -0
- package/dist/occurrence/index.js +18 -0
- package/dist/otlp/index.js +28 -1
- package/dist/packs/index.js +44 -4
- package/dist/policy/delta.js +7 -1
- package/dist/policy/index.d.ts +23 -6
- package/dist/policy/index.js +151 -8
- package/dist/policy/zanii.d.ts +31 -0
- package/dist/policy/zanii.js +87 -0
- package/dist/pq/index.d.ts +23 -0
- package/dist/pq/index.js +104 -0
- package/dist/search/index.d.ts +23 -0
- package/dist/search/index.js +69 -0
- package/dist/session/index.d.ts +89 -1
- package/dist/session/index.js +143 -11
- package/dist/sla/index.d.ts +61 -0
- package/dist/sla/index.js +197 -0
- package/dist/succession/index.d.ts +50 -0
- package/dist/succession/index.js +123 -0
- package/dist/tokens/index.d.ts +6 -0
- package/dist/tokens/index.js +46 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/walls/index.d.ts +31 -0
- package/dist/walls/index.js +119 -0
- package/package.json +1 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { type Bodies, type Call } from "../reconcile/record.ts";
|
|
2
|
+
import { canonicalNumber } from "./hallucination.ts";
|
|
3
|
+
import { type ReferencePack } from "./reference.ts";
|
|
4
|
+
export { canonicalNumber };
|
|
5
|
+
import type { Finding } from "./index.ts";
|
|
6
|
+
export interface Fact {
|
|
7
|
+
kind: "money" | "percent" | "quantity" | "date" | string;
|
|
8
|
+
/** Canonical: a decimal (`1250.5`) for money and percentages, normalised text otherwise. */
|
|
9
|
+
value: string;
|
|
10
|
+
at: number;
|
|
11
|
+
end: number;
|
|
12
|
+
currency?: string;
|
|
13
|
+
}
|
|
14
|
+
/** The facts an answer states: money, percentages, quantities, ISO dates, then identifiers (as H1's). */
|
|
15
|
+
export declare function factsIn(text: string): Fact[];
|
|
16
|
+
/** What the agent was given, to check facts against: the record lines it came from (§11 points at
|
|
17
|
+
* them), each normalised, with its numbers. */
|
|
18
|
+
export declare class Evidence {
|
|
19
|
+
/** As given, for a grounding service (§9.1). */
|
|
20
|
+
readonly raw: string;
|
|
21
|
+
readonly numbers: Set<string>;
|
|
22
|
+
private readonly pieces;
|
|
23
|
+
private sums;
|
|
24
|
+
constructor(raw: string, pieces?: ReadonlyArray<{
|
|
25
|
+
seq: number;
|
|
26
|
+
text: string;
|
|
27
|
+
}>);
|
|
28
|
+
/** Whether the evidence holds the fact (§9). */
|
|
29
|
+
holds(f: Fact): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The seqs of the lines that hold a fact, or null. Money, percentages and quantities: the same
|
|
32
|
+
* number, the same in minor units, a percentage as a fraction, or (money) the sum of two of the
|
|
33
|
+
* evidence's numbers. Dates and identifiers: the normalised text.
|
|
34
|
+
*/
|
|
35
|
+
support(f: Fact): number[] | null;
|
|
36
|
+
private pairSums;
|
|
37
|
+
}
|
|
38
|
+
/** The final answers on a record, each with the evidence it should rest on. */
|
|
39
|
+
export declare function answersOf(lines: readonly string[], bodies: Bodies): Array<{
|
|
40
|
+
seq: number;
|
|
41
|
+
text: string;
|
|
42
|
+
evidence: Evidence;
|
|
43
|
+
call: Call;
|
|
44
|
+
}>;
|
|
45
|
+
/** What a grounding service is sent for one answer: the text, the exact tier's facts, and the
|
|
46
|
+
* context it should rest on (the newest 200,000 characters of it). */
|
|
47
|
+
export declare function groundingRequest(sessionId: string, answer: {
|
|
48
|
+
seq: number;
|
|
49
|
+
text: string;
|
|
50
|
+
evidence: Evidence;
|
|
51
|
+
}, packs?: readonly ReferencePack[]): {
|
|
52
|
+
session_id: string;
|
|
53
|
+
answer_seq: number;
|
|
54
|
+
answer: string;
|
|
55
|
+
facts: {
|
|
56
|
+
kind: string;
|
|
57
|
+
value: string;
|
|
58
|
+
}[];
|
|
59
|
+
context: string;
|
|
60
|
+
reference?: {
|
|
61
|
+
pack: string;
|
|
62
|
+
id: string;
|
|
63
|
+
text: string;
|
|
64
|
+
source?: string;
|
|
65
|
+
}[];
|
|
66
|
+
};
|
|
67
|
+
export interface GroundedClaim {
|
|
68
|
+
text: string;
|
|
69
|
+
status: "grounded" | "ungrounded" | "contradicted" | "no_fact";
|
|
70
|
+
score?: number;
|
|
71
|
+
}
|
|
72
|
+
/** A grounding service's answer, checked: `{claims, model?, version?}`, or null (an outage). */
|
|
73
|
+
export declare function parseGroundingAnswer(v: unknown): {
|
|
74
|
+
claims: GroundedClaim[];
|
|
75
|
+
model?: string;
|
|
76
|
+
version?: string;
|
|
77
|
+
} | null;
|
|
78
|
+
/** §9.1: UNGROUNDED_CLAIM and CONTRADICTED_CLAIM from the verdicts a grounding service gave,
|
|
79
|
+
* recorded as `control {action: "grounding", via: "model"}` with the claims as the body. */
|
|
80
|
+
export declare function modelGroundingFindings(lines: readonly string[], bodies: Bodies): Finding[];
|
|
81
|
+
/** spec/findings.md §9-§10: for each fact in a final answer, CONTRADICTED_CLAIM when a reference
|
|
82
|
+
* pack says otherwise, else UNGROUNDED_CLAIM when nothing the agent was given holds it (at most 10
|
|
83
|
+
* per answer); then the grounding service's verdicts (§9.1). */
|
|
84
|
+
export declare function groundingFindings(lines: readonly string[], bodies: Bodies, packs?: readonly ReferencePack[]): Finding[];
|
|
85
|
+
/** What a judge is sent for one answer: the answer and the trajectory before it (its newest 100
|
|
86
|
+
* steps: tools called, whether they failed, earlier answers, the findings so far). */
|
|
87
|
+
export declare function judgeRequest(sessionId: string, answer: {
|
|
88
|
+
seq: number;
|
|
89
|
+
text: string;
|
|
90
|
+
}, lines: readonly string[]): {
|
|
91
|
+
session_id: string;
|
|
92
|
+
answer_seq: number;
|
|
93
|
+
answer: string;
|
|
94
|
+
steps: {
|
|
95
|
+
[key: string]: string | number | boolean;
|
|
96
|
+
}[];
|
|
97
|
+
};
|
|
98
|
+
/** A judge's answer, checked: `{steps: [{rubric, pass, reason?}], model?, version?}`, or null. */
|
|
99
|
+
export declare function parseJudgeAnswer(v: unknown): {
|
|
100
|
+
steps: Array<{
|
|
101
|
+
rubric: string;
|
|
102
|
+
pass: boolean;
|
|
103
|
+
reason?: string;
|
|
104
|
+
}>;
|
|
105
|
+
model?: string;
|
|
106
|
+
version?: string;
|
|
107
|
+
} | null;
|
|
108
|
+
/** §12.1: JUDGE_FAILED for each rubric step a judge failed, from its recorded verdicts. */
|
|
109
|
+
export declare function judgeFindings(lines: readonly string[], bodies: Bodies): Finding[];
|
|
110
|
+
/** spec/findings.md §13: what can hold an answer (BLACKBOX_HOLD_ANSWERS). */
|
|
111
|
+
export declare const HOLD_TRIGGERS: readonly ["contradicted", "money", "ids", "quantity", "percent", "date"];
|
|
112
|
+
/** §13: why an answer should be held before it's delivered: each fact a pack contradicts (with
|
|
113
|
+
* `contradicted`), and each ungrounded fact whose kind is a trigger (`ids` covers the identifiers).
|
|
114
|
+
* Empty: deliver it. */
|
|
115
|
+
export declare function answerRisk(answer: {
|
|
116
|
+
text: string;
|
|
117
|
+
evidence: Evidence;
|
|
118
|
+
}, packs: readonly ReferencePack[], triggers: readonly string[]): Array<{
|
|
119
|
+
index: number;
|
|
120
|
+
kind: string;
|
|
121
|
+
status: "contradicted" | "ungrounded";
|
|
122
|
+
}>;
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// Facts against the record (spec/findings.md §9, H2): the amounts, percentages, dates and
|
|
2
|
+
// identifiers in an agent's answer, each checked against what the agent was given (its request:
|
|
3
|
+
// the person's words, the system prompt, the tool results it passed back) and the tool results on
|
|
4
|
+
// the record. Exact, no model, Arabic and English. Mirrors
|
|
5
|
+
// sdks/python/src/zanii_blackbox/analysis/grounding.py; pinned by spec/vectors/grounding.json.
|
|
6
|
+
import { callsOf } from "../reconcile/record.js";
|
|
7
|
+
import { canonical } from "../reconcile/shared.js";
|
|
8
|
+
import { toolCallsOf } from "./detectors.js";
|
|
9
|
+
import { answerText, canonicalNumber, identifierSpans, normaliseValue, western, } from "./hallucination.js";
|
|
10
|
+
import { checkAgainstPacks, referenceContext } from "./reference.js";
|
|
11
|
+
export { canonicalNumber };
|
|
12
|
+
const decoder = new TextDecoder();
|
|
13
|
+
/** Arabic decimal and thousands marks, and Arabic-Indic digits, as Western ones. */
|
|
14
|
+
const westernText = (s) => western(s).replaceAll("٫", ".").replaceAll("٬", ",");
|
|
15
|
+
const AMOUNT = "[0-9]{1,3}(?:,[0-9]{3})+(?:\\.[0-9]+)?|[0-9]+(?:\\.[0-9]+)?";
|
|
16
|
+
// any currency: the ISO 4217 codes (in capitals), the usual words and symbols, English and Arabic
|
|
17
|
+
const CURRENCY = "AED|AFN|AMD|ANG|AOA|ARS|AUD|AWG|AZN|BAM|BBD|BDT|BGN|BHD|BIF|BMD|BND|BOB|BRL|BSD|BTN|BWP|BYN|BZD|CAD|CDF|CHF|CLP|CNY|COP|CRC|CUP|CVE|CZK|DJF|DKK|DOP|DZD|EGP|ERN|ETB|EUR|FJD|FKP|GBP|GEL|GHS|GIP|GMD|GNF|GTQ|GYD|HKD|HNL|HTG|HUF|IDR|ILS|INR|IQD|IRR|ISK|JMD|JOD|JPY|KES|KGS|KHR|KMF|KPW|KRW|KWD|KYD|KZT|LAK|LBP|LKR|LRD|LSL|LYD|MAD|MDL|MGA|MKD|MMK|MNT|MOP|MRU|MUR|MVR|MWK|MXN|MYR|MZN|NAD|NGN|NIO|NOK|NPR|NZD|OMR|PAB|PEN|PGK|PHP|PKR|PLN|PYG|QAR|RON|RSD|RUB|RWF|SAR|SBD|SCR|SDG|SEK|SGD|SHP|SLE|SOS|SRD|SSP|STN|SVC|SYP|SZL|THB|TJS|TMT|TND|TRY|TTD|TWD|TZS|UAH|UGX|USD|UYU|UZS|VES|VND|VUV|WST|XAF|XCD|XOF|XPF|YER|ZAR|ZMW|ZWL|dirhams|dirham|Dirhams|Dirham|dollars|dollar|Dollars|Dollar|euros|euro|Euros|Euro|pounds|pound|Pounds|Pound|riyals|riyal|Riyals|Riyal|rials|rial|Rials|Rial|rupees|rupee|Rupees|Rupee|dinars|dinar|Dinars|Dinar|francs|franc|Francs|Franc|pesos|peso|Pesos|Peso|yen|Yen|yuan|Yuan|liras|lira|Liras|Lira|ringgits|ringgit|Ringgits|Ringgit|rand|Rand|nairas|naira|Nairas|Naira|shillings|shilling|Shillings|Shilling|taka|Taka|Dhs|Dh|\\$|€|£|¥|₹|₩|₽|₺|₪|฿|₫|₦|₱|د\\.إ|درهم|دراهم|ريال|دولار|دينار|جنيه|يورو";
|
|
18
|
+
const MONEY = new RegExp(`(?<![A-Za-z])(${CURRENCY})\\s?(${AMOUNT})(?![0-9])|(?<![0-9.,])(${AMOUNT})\\s?(${CURRENCY})(?![A-Za-z])`, "g");
|
|
19
|
+
const PERCENT = new RegExp(`(?<![0-9.,])(${AMOUNT})\\s?(?:%|٪|percent|per cent|بالمائة|في المائة)`, "gi");
|
|
20
|
+
// any measured quantity: a dose, a weight, a distance, a duration ("within 14 days")
|
|
21
|
+
const UNIT = "mg/dL|mmol/L|mcg|µg|mg|kg|g|mL|ml|L|km|cm|mm|m|mi|ft|lbs|lb|oz|°C|°F|kWh|MWh|kW|MW|IU|hours|hour|hrs|hr|h|minutes|minute|mins|min|seconds|second|secs|days|day|weeks|week|months|month|years|year|units|unit|items|pieces|tablets|doses|ملغ|مجم|غرام|كغ|كيلوغرام|مل|لتر|كم|متر|ساعة|ساعات|دقيقة|دقائق|يوم|أيام|أسبوع|أسابيع|شهر|أشهر|سنة|سنوات|وحدة|حبة|جرعة";
|
|
22
|
+
const QUANTITY = new RegExp(`(?<![0-9.,])(${AMOUNT})\\s?(?:${UNIT})(?![A-Za-z\u0600-\u06FF])`, "g");
|
|
23
|
+
const ISO_DATE = /(?<![0-9])[0-9]{4}-[0-9]{2}-[0-9]{2}(?![0-9])/g;
|
|
24
|
+
const NUMBER = new RegExp(`(?<![0-9.,])(?:${AMOUNT})(?![0-9])`, "g");
|
|
25
|
+
/** A canonical decimal as an integer of millionths, exactly (sums without floats). */
|
|
26
|
+
function micros(c) {
|
|
27
|
+
const [w = "0", f = ""] = c.split(".");
|
|
28
|
+
return BigInt(w) * 1000000n + BigInt(f.slice(0, 6).padEnd(6, "0"));
|
|
29
|
+
}
|
|
30
|
+
const shift = (c, places) => {
|
|
31
|
+
// ×10^places for places ≥ 0, ÷10^-places otherwise, on the decimal string
|
|
32
|
+
const m = micros(c) * 10n ** BigInt(Math.max(0, places));
|
|
33
|
+
const v = places < 0 ? m / 10n ** BigInt(-places) : m;
|
|
34
|
+
const w = v / 1000000n;
|
|
35
|
+
const f = (v % 1000000n).toString().padStart(6, "0").replace(/0+$/, "");
|
|
36
|
+
return f ? `${w}.${f}` : `${w}`;
|
|
37
|
+
};
|
|
38
|
+
/** The facts an answer states: money, percentages, quantities, ISO dates, then identifiers (as H1's). */
|
|
39
|
+
export function factsIn(text) {
|
|
40
|
+
const s = westernText(text);
|
|
41
|
+
const out = [];
|
|
42
|
+
const taken = [];
|
|
43
|
+
const add = (f) => {
|
|
44
|
+
if (taken.some(([a, b]) => f.at < b && f.end > a))
|
|
45
|
+
return;
|
|
46
|
+
taken.push([f.at, f.end]);
|
|
47
|
+
out.push(f);
|
|
48
|
+
};
|
|
49
|
+
for (const m of s.matchAll(MONEY)) {
|
|
50
|
+
const amount = (m[2] ?? m[3]);
|
|
51
|
+
const currency = (m[1] ?? m[4]);
|
|
52
|
+
const at = m.index ?? 0;
|
|
53
|
+
add({ kind: "money", value: canonicalNumber(amount), at, end: at + m[0].length, currency });
|
|
54
|
+
}
|
|
55
|
+
for (const m of s.matchAll(PERCENT)) {
|
|
56
|
+
const at = m.index ?? 0;
|
|
57
|
+
add({ kind: "percent", value: canonicalNumber(m[1]), at, end: at + m[0].length });
|
|
58
|
+
}
|
|
59
|
+
for (const m of s.matchAll(QUANTITY)) {
|
|
60
|
+
const at = m.index ?? 0;
|
|
61
|
+
add({ kind: "quantity", value: canonicalNumber(m[1]), at, end: at + m[0].length });
|
|
62
|
+
}
|
|
63
|
+
for (const m of s.matchAll(ISO_DATE)) {
|
|
64
|
+
const at = m.index ?? 0;
|
|
65
|
+
add({ kind: "date", value: normaliseValue(m[0]), at, end: at + m[0].length });
|
|
66
|
+
}
|
|
67
|
+
for (const x of identifierSpans(s, taken))
|
|
68
|
+
out.push({ kind: x.kind, value: x.value, at: x.at, end: x.end });
|
|
69
|
+
return out.sort((a, b) => a.at - b.at);
|
|
70
|
+
}
|
|
71
|
+
/** What the agent was given, to check facts against: the record lines it came from (§11 points at
|
|
72
|
+
* them), each normalised, with its numbers. */
|
|
73
|
+
export class Evidence {
|
|
74
|
+
/** As given, for a grounding service (§9.1). */
|
|
75
|
+
raw;
|
|
76
|
+
numbers;
|
|
77
|
+
pieces;
|
|
78
|
+
sums = null;
|
|
79
|
+
constructor(raw, pieces) {
|
|
80
|
+
this.raw = raw;
|
|
81
|
+
this.pieces = (pieces ?? [{ seq: -1, text: raw }]).map((p) => {
|
|
82
|
+
const s = westernText(p.text);
|
|
83
|
+
return {
|
|
84
|
+
seq: p.seq,
|
|
85
|
+
text: normaliseValue(s),
|
|
86
|
+
numbers: new Set([...s.matchAll(NUMBER)].map((m) => canonicalNumber(m[0]))),
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
this.numbers = new Set(this.pieces.flatMap((p) => [...p.numbers]));
|
|
90
|
+
}
|
|
91
|
+
/** Whether the evidence holds the fact (§9). */
|
|
92
|
+
holds(f) {
|
|
93
|
+
return this.support(f) !== null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The seqs of the lines that hold a fact, or null. Money, percentages and quantities: the same
|
|
97
|
+
* number, the same in minor units, a percentage as a fraction, or (money) the sum of two of the
|
|
98
|
+
* evidence's numbers. Dates and identifiers: the normalised text.
|
|
99
|
+
*/
|
|
100
|
+
support(f) {
|
|
101
|
+
if (f.kind === "money" || f.kind === "percent" || f.kind === "quantity") {
|
|
102
|
+
const wanted = [
|
|
103
|
+
f.value,
|
|
104
|
+
shift(f.value, 2),
|
|
105
|
+
...(f.kind === "percent" ? [shift(f.value, -2)] : []),
|
|
106
|
+
];
|
|
107
|
+
const hit = this.pieces.find((p) => wanted.some((w) => p.numbers.has(w)));
|
|
108
|
+
if (hit)
|
|
109
|
+
return [hit.seq];
|
|
110
|
+
return f.kind === "money" ? (this.pairSums().get(f.value) ?? null) : null;
|
|
111
|
+
}
|
|
112
|
+
const hit = this.pieces.find((p) => p.text.includes(f.value));
|
|
113
|
+
return hit ? [hit.seq] : null;
|
|
114
|
+
}
|
|
115
|
+
// ponytail: pair sums only over the first 300 numbers, so a long record stays O(300²)
|
|
116
|
+
pairSums() {
|
|
117
|
+
if (this.sums)
|
|
118
|
+
return this.sums;
|
|
119
|
+
const first = new Map();
|
|
120
|
+
for (const p of this.pieces)
|
|
121
|
+
for (const n of p.numbers)
|
|
122
|
+
if (!first.has(n))
|
|
123
|
+
first.set(n, p.seq);
|
|
124
|
+
const ns = [...first].slice(0, 300).map(([n, seq]) => [micros(n), seq]);
|
|
125
|
+
const out = new Map();
|
|
126
|
+
for (let i = 0; i < ns.length; i++)
|
|
127
|
+
for (let j = i + 1; j < ns.length; j++) {
|
|
128
|
+
const [a, sa] = ns[i];
|
|
129
|
+
const [b, sb] = ns[j];
|
|
130
|
+
const v = a + b;
|
|
131
|
+
const w = v / 1000000n;
|
|
132
|
+
const fr = (v % 1000000n).toString().padStart(6, "0").replace(/0+$/, "");
|
|
133
|
+
const key = fr ? `${w}.${fr}` : `${w}`;
|
|
134
|
+
if (!out.has(key))
|
|
135
|
+
out.set(key, sa === sb ? [sa] : [sa, sb]);
|
|
136
|
+
}
|
|
137
|
+
this.sums = out;
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** The final answers on a record, each with the evidence it should rest on. */
|
|
142
|
+
export function answersOf(lines, bodies) {
|
|
143
|
+
const calls = callsOf(lines, bodies);
|
|
144
|
+
const ends = new Set(toolCallsOf(calls).map((t) => t.seq));
|
|
145
|
+
// tool results on the record, by seq, to add those before each answer
|
|
146
|
+
const results = [];
|
|
147
|
+
// (each answer's evidence: its request, then the tool results before it, as record lines)
|
|
148
|
+
for (const l of lines) {
|
|
149
|
+
const e = JSON.parse(l);
|
|
150
|
+
if (e.kind === "tool.result" || (e.kind === "sdk.event" && e.meta.type === "tool.result")) {
|
|
151
|
+
const b = bodies(e.body_hash);
|
|
152
|
+
results.push({ seq: e.seq, text: b ? decoder.decode(b) : "" });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const c of [...calls].sort((a, b) => a.endSeq - b.endSeq)) {
|
|
157
|
+
if (!c.complete || !c.request || ends.has(c.endSeq))
|
|
158
|
+
continue;
|
|
159
|
+
const text = answerText(c);
|
|
160
|
+
if (!text)
|
|
161
|
+
continue;
|
|
162
|
+
const pieces = [
|
|
163
|
+
{ seq: c.requestSeq, text: canonical(c.request) },
|
|
164
|
+
...results.filter((r) => r.seq < c.endSeq),
|
|
165
|
+
];
|
|
166
|
+
out.push({
|
|
167
|
+
seq: c.endSeq,
|
|
168
|
+
text,
|
|
169
|
+
evidence: new Evidence(pieces.map((p) => p.text).join("\n"), pieces),
|
|
170
|
+
call: c,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
const MAX_PER_ANSWER = 10;
|
|
176
|
+
const LABELS = {
|
|
177
|
+
money: "an amount",
|
|
178
|
+
percent: "a percentage",
|
|
179
|
+
quantity: "a quantity",
|
|
180
|
+
date: "a date",
|
|
181
|
+
email: "an email address",
|
|
182
|
+
iban: "an IBAN",
|
|
183
|
+
uuid: "an identifier",
|
|
184
|
+
reference: "a reference",
|
|
185
|
+
number: "a number",
|
|
186
|
+
};
|
|
187
|
+
// ---------------------------------------------------------------- the model tier (§9.1)
|
|
188
|
+
const CONTEXT_CHARS = 200_000;
|
|
189
|
+
const STATUSES = new Set(["grounded", "ungrounded", "contradicted", "no_fact"]);
|
|
190
|
+
/** What a grounding service is sent for one answer: the text, the exact tier's facts, and the
|
|
191
|
+
* context it should rest on (the newest 200,000 characters of it). */
|
|
192
|
+
export function groundingRequest(sessionId, answer, packs = []) {
|
|
193
|
+
const raw = answer.evidence.raw;
|
|
194
|
+
const reference = referenceContext(answer.text, packs);
|
|
195
|
+
return {
|
|
196
|
+
session_id: sessionId,
|
|
197
|
+
answer_seq: answer.seq,
|
|
198
|
+
answer: answer.text,
|
|
199
|
+
facts: factsIn(answer.text).map((f) => ({ kind: f.kind, value: f.value })),
|
|
200
|
+
context: raw.length > CONTEXT_CHARS ? raw.slice(raw.length - CONTEXT_CHARS) : raw,
|
|
201
|
+
// §10: what the organisation's own packs say about it, when they say anything
|
|
202
|
+
...(reference.length ? { reference } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** A grounding service's answer, checked: `{claims, model?, version?}`, or null (an outage). */
|
|
206
|
+
export function parseGroundingAnswer(v) {
|
|
207
|
+
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
208
|
+
return null;
|
|
209
|
+
const a = v;
|
|
210
|
+
if (!Array.isArray(a.claims) || a.claims.length > 100)
|
|
211
|
+
return null;
|
|
212
|
+
const claims = [];
|
|
213
|
+
for (const c of a.claims) {
|
|
214
|
+
if (typeof c !== "object" || c === null)
|
|
215
|
+
return null;
|
|
216
|
+
const { text, status, score } = c;
|
|
217
|
+
if (typeof text !== "string" || text.length > 2000 || typeof status !== "string")
|
|
218
|
+
return null;
|
|
219
|
+
if (!STATUSES.has(status))
|
|
220
|
+
return null;
|
|
221
|
+
if (score !== undefined && (typeof score !== "number" || !(score >= 0 && score <= 1)))
|
|
222
|
+
return null;
|
|
223
|
+
claims.push({
|
|
224
|
+
text,
|
|
225
|
+
status: status,
|
|
226
|
+
...(score !== undefined ? { score } : {}),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const short = (x) => typeof x === "string" && x.length > 0 && x.length <= 128;
|
|
230
|
+
return {
|
|
231
|
+
claims,
|
|
232
|
+
...(short(a.model) ? { model: a.model } : {}),
|
|
233
|
+
...(short(a.version) ? { version: a.version } : {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** §9.1: UNGROUNDED_CLAIM and CONTRADICTED_CLAIM from the verdicts a grounding service gave,
|
|
237
|
+
* recorded as `control {action: "grounding", via: "model"}` with the claims as the body. */
|
|
238
|
+
export function modelGroundingFindings(lines, bodies) {
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const l of lines) {
|
|
241
|
+
const e = JSON.parse(l);
|
|
242
|
+
if (e.kind !== "control" || e.meta.action !== "grounding" || e.meta.via !== "model")
|
|
243
|
+
continue;
|
|
244
|
+
const seq = e.meta.answer_seq;
|
|
245
|
+
const b = bodies(e.body_hash);
|
|
246
|
+
if (typeof seq !== "number" || !b)
|
|
247
|
+
continue;
|
|
248
|
+
let parsed = null;
|
|
249
|
+
try {
|
|
250
|
+
parsed = parseGroundingAnswer(JSON.parse(decoder.decode(b)));
|
|
251
|
+
}
|
|
252
|
+
catch { }
|
|
253
|
+
for (const [index, c] of (parsed?.claims ?? []).entries()) {
|
|
254
|
+
if (c.status === "contradicted")
|
|
255
|
+
out.push({
|
|
256
|
+
code: "CONTRADICTED_CLAIM",
|
|
257
|
+
source: "hallucination",
|
|
258
|
+
severity: "warning",
|
|
259
|
+
ref: { seq, index, via: "model" },
|
|
260
|
+
detail: "A claim in the answer is contradicted by what the agent was given, says the grounding service.",
|
|
261
|
+
});
|
|
262
|
+
else if (c.status === "ungrounded")
|
|
263
|
+
out.push({
|
|
264
|
+
code: "UNGROUNDED_CLAIM",
|
|
265
|
+
source: "hallucination",
|
|
266
|
+
severity: "caution",
|
|
267
|
+
ref: { seq, index, via: "model" },
|
|
268
|
+
detail: "A claim in the answer has no support in what the agent was given, says the grounding service.",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
/** spec/findings.md §9-§10: for each fact in a final answer, CONTRADICTED_CLAIM when a reference
|
|
275
|
+
* pack says otherwise, else UNGROUNDED_CLAIM when nothing the agent was given holds it (at most 10
|
|
276
|
+
* per answer); then the grounding service's verdicts (§9.1). */
|
|
277
|
+
export function groundingFindings(lines, bodies, packs = []) {
|
|
278
|
+
return [...exactFindings(lines, bodies, packs), ...modelGroundingFindings(lines, bodies)];
|
|
279
|
+
}
|
|
280
|
+
function exactFindings(lines, bodies, packs) {
|
|
281
|
+
const out = [];
|
|
282
|
+
for (const a of answersOf(lines, bodies)) {
|
|
283
|
+
const facts = factsIn(a.text);
|
|
284
|
+
let n = 0;
|
|
285
|
+
for (const [index, f] of facts.entries()) {
|
|
286
|
+
if (n >= MAX_PER_ANSWER)
|
|
287
|
+
break;
|
|
288
|
+
// a pack is the organisation's own word: it comes before what a tool said
|
|
289
|
+
const said = checkAgainstPacks(a.text, f, packs);
|
|
290
|
+
if (said?.status === "contradicted") {
|
|
291
|
+
n++;
|
|
292
|
+
out.push({
|
|
293
|
+
code: "CONTRADICTED_CLAIM",
|
|
294
|
+
source: "hallucination",
|
|
295
|
+
severity: "warning",
|
|
296
|
+
ref: {
|
|
297
|
+
seq: a.seq,
|
|
298
|
+
kind: f.kind,
|
|
299
|
+
index,
|
|
300
|
+
via: "pack",
|
|
301
|
+
pack: said.pack.id,
|
|
302
|
+
fact: said.fact.id,
|
|
303
|
+
},
|
|
304
|
+
detail: `The answer states ${LABELS[f.kind] ?? "a value"} that the reference pack ${said.pack.id} contradicts (${said.fact.id}${said.fact.source ? `, ${said.fact.source}` : ""}).`,
|
|
305
|
+
});
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (said?.status === "grounded" || a.evidence.holds(f))
|
|
309
|
+
continue;
|
|
310
|
+
n++;
|
|
311
|
+
out.push({
|
|
312
|
+
code: "UNGROUNDED_CLAIM",
|
|
313
|
+
source: "hallucination",
|
|
314
|
+
severity: "caution",
|
|
315
|
+
ref: { seq: a.seq, kind: f.kind, index, via: "exact" },
|
|
316
|
+
detail: `The answer states ${LABELS[f.kind] ?? "a value"} that nothing the agent was given holds: it may be made up.`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
// ---------------------------------------------------------------- the judge (§12.1)
|
|
323
|
+
const STEPS = 100;
|
|
324
|
+
/** What a judge is sent for one answer: the answer and the trajectory before it (its newest 100
|
|
325
|
+
* steps: tools called, whether they failed, earlier answers, the findings so far). */
|
|
326
|
+
export function judgeRequest(sessionId, answer, lines) {
|
|
327
|
+
const steps = [];
|
|
328
|
+
const callTools = new Map();
|
|
329
|
+
for (const l of lines) {
|
|
330
|
+
const e = JSON.parse(l);
|
|
331
|
+
if (e.seq >= answer.seq)
|
|
332
|
+
break;
|
|
333
|
+
const m = e.meta;
|
|
334
|
+
if (e.kind === "tool.call" && typeof m.tool === "string") {
|
|
335
|
+
callTools.set(e.seq, m.tool);
|
|
336
|
+
steps.push({ seq: e.seq, kind: "tool.call", tool: m.tool });
|
|
337
|
+
}
|
|
338
|
+
else if (e.kind === "tool.result" &&
|
|
339
|
+
typeof m.call_seq === "number" &&
|
|
340
|
+
callTools.has(m.call_seq))
|
|
341
|
+
steps.push({
|
|
342
|
+
seq: e.seq,
|
|
343
|
+
kind: "tool.result",
|
|
344
|
+
tool: callTools.get(m.call_seq),
|
|
345
|
+
error: m.is_error === true,
|
|
346
|
+
});
|
|
347
|
+
else if (e.kind === "sdk.event" &&
|
|
348
|
+
(m.type === "tool.call" || m.type === "tool.result") &&
|
|
349
|
+
typeof m.name === "string")
|
|
350
|
+
steps.push({ seq: e.seq, kind: m.type, tool: m.name });
|
|
351
|
+
else if (e.kind === "finding" && typeof m.code === "string")
|
|
352
|
+
steps.push({ seq: e.seq, kind: "finding", code: m.code });
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
session_id: sessionId,
|
|
356
|
+
answer_seq: answer.seq,
|
|
357
|
+
answer: answer.text,
|
|
358
|
+
steps: steps.slice(-STEPS),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/** A judge's answer, checked: `{steps: [{rubric, pass, reason?}], model?, version?}`, or null. */
|
|
362
|
+
export function parseJudgeAnswer(v) {
|
|
363
|
+
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
364
|
+
return null;
|
|
365
|
+
const a = v;
|
|
366
|
+
if (!Array.isArray(a.steps) || a.steps.length > 50)
|
|
367
|
+
return null;
|
|
368
|
+
const steps = [];
|
|
369
|
+
for (const s of a.steps) {
|
|
370
|
+
if (typeof s !== "object" || s === null)
|
|
371
|
+
return null;
|
|
372
|
+
const { rubric, pass, reason } = s;
|
|
373
|
+
if (typeof rubric !== "string" || rubric.length < 1 || rubric.length > 200)
|
|
374
|
+
return null;
|
|
375
|
+
if (typeof pass !== "boolean")
|
|
376
|
+
return null;
|
|
377
|
+
if (reason !== undefined && (typeof reason !== "string" || reason.length > 500))
|
|
378
|
+
return null;
|
|
379
|
+
steps.push({ rubric, pass, ...(reason !== undefined ? { reason } : {}) });
|
|
380
|
+
}
|
|
381
|
+
const short = (x) => typeof x === "string" && x.length > 0 && x.length <= 128;
|
|
382
|
+
return {
|
|
383
|
+
steps,
|
|
384
|
+
...(short(a.model) ? { model: a.model } : {}),
|
|
385
|
+
...(short(a.version) ? { version: a.version } : {}),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/** §12.1: JUDGE_FAILED for each rubric step a judge failed, from its recorded verdicts. */
|
|
389
|
+
export function judgeFindings(lines, bodies) {
|
|
390
|
+
const out = [];
|
|
391
|
+
for (const l of lines) {
|
|
392
|
+
const e = JSON.parse(l);
|
|
393
|
+
if (e.kind !== "control" || e.meta.action !== "judge" || e.meta.via !== "model")
|
|
394
|
+
continue;
|
|
395
|
+
const seq = e.meta.answer_seq;
|
|
396
|
+
const b = bodies(e.body_hash);
|
|
397
|
+
if (typeof seq !== "number" || !b)
|
|
398
|
+
continue;
|
|
399
|
+
let parsed = null;
|
|
400
|
+
try {
|
|
401
|
+
parsed = parseJudgeAnswer(JSON.parse(decoder.decode(b)));
|
|
402
|
+
}
|
|
403
|
+
catch { }
|
|
404
|
+
for (const [index, s] of (parsed?.steps ?? []).entries())
|
|
405
|
+
if (!s.pass)
|
|
406
|
+
out.push({
|
|
407
|
+
code: "JUDGE_FAILED",
|
|
408
|
+
source: "hallucination",
|
|
409
|
+
severity: "caution",
|
|
410
|
+
ref: { seq, index, via: "judge" },
|
|
411
|
+
detail: `The judge failed the answer on: ${s.rubric}`,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
// ---------------------------------------------------------------- holding an answer (§13, H7)
|
|
417
|
+
/** spec/findings.md §13: what can hold an answer (BLACKBOX_HOLD_ANSWERS). */
|
|
418
|
+
export const HOLD_TRIGGERS = [
|
|
419
|
+
"contradicted",
|
|
420
|
+
"money",
|
|
421
|
+
"ids",
|
|
422
|
+
"quantity",
|
|
423
|
+
"percent",
|
|
424
|
+
"date",
|
|
425
|
+
];
|
|
426
|
+
const ID_KINDS = new Set(["email", "iban", "uuid", "reference", "number"]);
|
|
427
|
+
/** §13: why an answer should be held before it's delivered: each fact a pack contradicts (with
|
|
428
|
+
* `contradicted`), and each ungrounded fact whose kind is a trigger (`ids` covers the identifiers).
|
|
429
|
+
* Empty: deliver it. */
|
|
430
|
+
export function answerRisk(answer, packs, triggers) {
|
|
431
|
+
const out = [];
|
|
432
|
+
for (const [index, f] of factsIn(answer.text).entries()) {
|
|
433
|
+
const said = checkAgainstPacks(answer.text, f, packs);
|
|
434
|
+
if (said?.status === "contradicted") {
|
|
435
|
+
if (triggers.includes("contradicted"))
|
|
436
|
+
out.push({ index, kind: f.kind, status: "contradicted" });
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (said?.status === "grounded" || answer.evidence.holds(f))
|
|
440
|
+
continue;
|
|
441
|
+
if (triggers.includes(ID_KINDS.has(f.kind) ? "ids" : f.kind))
|
|
442
|
+
out.push({ index, kind: f.kind, status: "ungrounded" });
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Bodies, type Call } from "../reconcile/record.ts";
|
|
2
|
+
import type { Finding } from "./index.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The first way `value` breaks `schema`, or null. ponytail: the JSON Schema subset tools use
|
|
5
|
+
* (type, required, properties, additionalProperties: false, enum, items), not $ref or anyOf.
|
|
6
|
+
*/
|
|
7
|
+
export declare function schemaProblem(schema: unknown, value: unknown, path?: string): string | null;
|
|
8
|
+
/** Arabic-Indic digits as Western ones. */
|
|
9
|
+
export declare const western: (s: string) => string;
|
|
10
|
+
/** Lower case, Western digits, and no separators, so `784-1990 1234567` matches `78419901234567`. */
|
|
11
|
+
export declare function normaliseValue(s: string): string;
|
|
12
|
+
/** The identifying values in a string (Western digits) and where they are, skipping `taken` spans. */
|
|
13
|
+
export declare function identifierSpans(s: string, taken?: Array<[number, number]>): Array<{
|
|
14
|
+
kind: string;
|
|
15
|
+
value: string;
|
|
16
|
+
at: number;
|
|
17
|
+
end: number;
|
|
18
|
+
}>;
|
|
19
|
+
/** The identifying values in tool arguments' strings: emails, IBANs, UUIDs, references, long
|
|
20
|
+
* numbers. ponytail: JSON numbers are skipped (counts, sizes, epoch times), as are generated fields. */
|
|
21
|
+
export declare function identifiersIn(args: unknown, path?: string): Array<{
|
|
22
|
+
kind: string;
|
|
23
|
+
path: string;
|
|
24
|
+
value: string;
|
|
25
|
+
}>;
|
|
26
|
+
/** `1,250.50` → `1250.5`: no separators, leading or trailing zeros. */
|
|
27
|
+
export declare function canonicalNumber(s: string): string;
|
|
28
|
+
/** The answer's text: Anthropic and Chat Completions text blocks, Responses message items. */
|
|
29
|
+
export declare function answerText(c: Call): string;
|
|
30
|
+
/** spec/findings.md §8: UNKNOWN_TOOL, TOOL_ARGS_INVALID, FABRICATED_VALUE, SUCCESS_AFTER_ERROR,
|
|
31
|
+
* PHANTOM_RESULT, in record order. */
|
|
32
|
+
export declare function toolHallucinations(lines: readonly string[], bodies: Bodies): Finding[];
|