@ixo/decision-oracle 0.2.24 → 0.2.25

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 CHANGED
@@ -784,6 +784,6 @@ symlinks are not packed). CI diffs them — change one, copy it to the other.
784
784
 
785
785
  ## Status
786
786
 
787
- Published on npm: `@ixo/decision-oracle` 0.2.24 and `@ixo/decision-oracle-client` 0.1.1.
787
+ Published on npm: `@ixo/decision-oracle` 0.2.25 and `@ixo/decision-oracle-client` 0.1.1.
788
788
 
789
789
  Apache License 2.0: `LICENSE` at the repo root and in each package.
@@ -1 +1 @@
1
- {"version":3,"file":"define-oracle.d.ts","sourceRoot":"","sources":["../src/define-oracle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAMH,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AAItE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAIvC,OAAO,EAAE,KAAK,YAAY,EAAiC,MAAM,UAAU,CAAC;AAC5E,OAAO,EAAE,WAAW,IAAI,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC3E,OAAO,EAAE,aAAa,IAAI,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AACjF,OAAO,EAAE,cAAc,IAAI,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAEpF,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,2BAA2B,CAAC,SAAS,CAAC,CAAC;IAC9C,SAAS,EAAE,+BAA+B,CAAC,SAAS,CAAC,CAAC;IACtD,WAAW,EAAE,OAAO,gBAAgB,CAAC;IACrC,cAAc,EAAE,OAAO,mBAAmB,CAAC;IAC3C,6FAA6F;IAC7F,aAAa,EAAE,OAAO,kBAAkB,CAAC;IACzC,iBAAiB,EAAE,OAAO,iBAAiB,CAAC;CAC7C;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CA4D/D"}
1
+ {"version":3,"file":"define-oracle.d.ts","sourceRoot":"","sources":["../src/define-oracle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAMH,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AAItE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAKvC,OAAO,EAAE,KAAK,YAAY,EAAiC,MAAM,UAAU,CAAC;AAC5E,OAAO,EAAE,WAAW,IAAI,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC3E,OAAO,EAAE,aAAa,IAAI,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AACjF,OAAO,EAAE,cAAc,IAAI,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAEpF,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,2BAA2B,CAAC,SAAS,CAAC,CAAC;IAC9C,SAAS,EAAE,+BAA+B,CAAC,SAAS,CAAC,CAAC;IACtD,WAAW,EAAE,OAAO,gBAAgB,CAAC;IACrC,cAAc,EAAE,OAAO,mBAAmB,CAAC;IAC3C,6FAA6F;IAC7F,aAAa,EAAE,OAAO,kBAAkB,CAAC;IACzC,iBAAiB,EAAE,OAAO,iBAAiB,CAAC;CAC7C;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CA8D/D"}
@@ -26,6 +26,7 @@ import app from "./app.js";
26
26
  import { runCronTick } from "./cron/scan.js";
27
27
  import { producerOf } from "./lib/facts/tools.js";
28
28
  import { handleOpenhandsRoute } from "./lib/openhands/routes.js";
29
+ import { handleWhyRoute } from "./lib/openhands/why.js";
29
30
  import { sentryOptions } from "./lib/sentry.js";
30
31
  import { OracleConfigSchema, setOracle } from "./oracle.js";
31
32
  import { ClaimIngest as ClaimIngestClass } from "./workflows/claim-ingest.js";
@@ -64,9 +65,10 @@ export function defineOracle(config) {
64
65
  // other Worker handlers live.
65
66
  const handler = Sentry.withSentry(sentryOptions, {
66
67
  async fetch(request, env, ctx) {
67
- // The OpenHands webhooks sit in front of the app: they are not `/v1` routes and carry no UCAN.
68
+ // The OpenHands routes sit in front of the app: they are not `/v1` routes — the webhooks carry
69
+ // no UCAN, the claimant's "why" carries one on its claim, not on `ixo:eval-oracle`.
68
70
  if (openhands) {
69
- const answered = await handleOpenhandsRoute(request, env);
71
+ const answered = (await handleOpenhandsRoute(request, env)) ?? (await handleWhyRoute(request, env));
70
72
  if (answered)
71
73
  return answered;
72
74
  }
@@ -1 +1 @@
1
- {"version":3,"file":"define-oracle.js","sourceRoot":"","sources":["../src/define-oracle.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"define-oracle.js","sourceRoot":"","sources":["../src/define-oracle.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The claimant's "why", built from what this oracle already holds for a claim: the decision row
3
+ * (the engine's gate and criterion outcomes, its receipt, the frozen `ext`/`ai` facts, the
4
+ * `erroredFacts`) and the pinned rubric's own words (labels, limits, the duplicate and frequency
5
+ * rules). Pure — `why.ts` reads the row and the rubric and serves what this returns. Ported from
6
+ * openhands-oracles `core/src/openhands/explain.ts` minus its campaign label table: a fact is
7
+ * labelled by the rubric's own sentence (an AI check) or by its name, never by a provider's word.
8
+ *
9
+ * DISPLAY ONLY. The rubric is read for its public sentences after the engine decided; nothing here
10
+ * binds, validates or decides — the engine's `Decision` is the only verdict, `outcomeOf` reads it.
11
+ *
12
+ * One generic mapper, never a case per claim: each gate and criterion is explained through the ONE
13
+ * fact its condition reads (`ext.<name>` / `ai.<name>`), or through the claim fields it tests.
14
+ *
15
+ * WORKER-SAFE by construction. Nothing reaches the body verbatim except the rubric's own sentences
16
+ * (the protocol's public text) and the AI's sentence about the claimant's own submission, sanitised.
17
+ * An `ext` reason is NEVER echoed — a provider's reason may name other claims or places — only
18
+ * numbers read out of the stock geo handlers' measurement grammar (`facts/geo.ts`:
19
+ * `<n> m from centre, limit <m> m; evidence: …`, `<n> m apart, limit <m> m`). The engine's
20
+ * `duplicate` / `frequency` reason lines name the matching claim, so those gates are explained from
21
+ * the rubric alone. No model prompt (`aiChecks[].instructions`) is ever read.
22
+ */
23
+ import type { DecisionRow } from "../../db/schema.js";
24
+ export type WhyRow = Pick<DecisionRow, "claimId" | "collectionId" | "rubricId" | "verdict" | "status" | "receiptCid" | "decision" | "facts" | "erroredFacts">;
25
+ export type Outcome = "approved" | "rejected" | "review" | "pending";
26
+ export type Result = "pass" | "fail" | "error" | "review";
27
+ export type EvidenceGrade = "camera" | "browser-reported" | "none";
28
+ export interface WhyReason {
29
+ code: string;
30
+ label: string;
31
+ result: Result;
32
+ detail: string;
33
+ value?: number | string;
34
+ threshold?: number | string;
35
+ unit?: string;
36
+ }
37
+ export interface WhyBody {
38
+ outcome: Outcome;
39
+ summary: string;
40
+ reasons: WhyReason[];
41
+ evidenceGrade?: EvidenceGrade;
42
+ reviewNote?: string;
43
+ receiptCid?: string;
44
+ }
45
+ type Condition = Record<string, unknown>;
46
+ /** The pinned rubric minus everything a worker must not see (the AI checks' instructions). */
47
+ export interface RubricView {
48
+ gates: {
49
+ code: string;
50
+ reason?: string;
51
+ when?: Condition;
52
+ onFail?: string;
53
+ }[];
54
+ criteria: {
55
+ code: string;
56
+ reason?: string;
57
+ appliesWhen?: Condition;
58
+ score?: Condition;
59
+ }[];
60
+ /** Review task id → the criterion it scores, when it scores one. */
61
+ tasks: Record<string, string | null>;
62
+ unique?: {
63
+ id: string;
64
+ label?: string;
65
+ };
66
+ frequency?: {
67
+ id: string;
68
+ label?: string;
69
+ max?: number;
70
+ per?: string;
71
+ };
72
+ }
73
+ /** A `#rub` envelope (`{ rubric: {...} }`) → the view; null when it is not a rubric at all. */
74
+ export declare function rubricView(doc: unknown): RubricView | null;
75
+ /** What this oracle holds for a claim it has seen but the engine has not yet decided. */
76
+ export declare function pendingBody(): WhyBody;
77
+ export declare function explain(row: WhyRow, rubric: RubricView | null): WhyBody;
78
+ /**
79
+ * What a model or a handler wrote, minus anything that could identify someone: contact details,
80
+ * identifiers, places, long numbers, plate-shaped tokens and long verbatim quotes become
81
+ * `[hidden]`; whitespace is collapsed and the result capped. Empty in, empty out.
82
+ */
83
+ export declare function sanitise(text: string): string;
84
+ export {};
85
+ //# sourceMappingURL=explain.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"explain.d.ts","sourceRoot":"","sources":["../../../src/lib/openhands/explain.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,MAAM,MAAM,MAAM,GAAG,IAAI,CACvB,WAAW,EACT,SAAS,GACT,cAAc,GACd,UAAU,GACV,SAAS,GACT,QAAQ,GACR,YAAY,GACZ,UAAU,GACV,OAAO,GACP,cAAc,CACjB,CAAC;AAEF,MAAM,MAAM,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,QAAQ,GAAG,SAAS,CAAC;AACrE,MAAM,MAAM,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAC1D,MAAM,MAAM,aAAa,GAAG,QAAQ,GAAG,kBAAkB,GAAG,MAAM,CAAC;AAEnE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,OAAO;IACtB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,SAAS,EAAE,CAAC;IACrB,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAID,KAAK,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEzC,8FAA8F;AAC9F,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,SAAS,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9E,QAAQ,EAAE;QACR,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,WAAW,CAAC,EAAE,SAAS,CAAC;QACxB,KAAK,CAAC,EAAE,SAAS,CAAC;KACnB,EAAE,CAAC;IACJ,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;IACrC,MAAM,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,SAAS,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACxE;AAQD,+FAA+F;AAC/F,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,UAAU,GAAG,IAAI,CAuC1D;AAqMD,yFAAyF;AACzF,wBAAgB,WAAW,IAAI,OAAO,CAMrC;AAED,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,IAAI,GAAG,OAAO,CAoLvE;AAsDD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAiB7C"}
@@ -0,0 +1,499 @@
1
+ /**
2
+ * The claimant's "why", built from what this oracle already holds for a claim: the decision row
3
+ * (the engine's gate and criterion outcomes, its receipt, the frozen `ext`/`ai` facts, the
4
+ * `erroredFacts`) and the pinned rubric's own words (labels, limits, the duplicate and frequency
5
+ * rules). Pure — `why.ts` reads the row and the rubric and serves what this returns. Ported from
6
+ * openhands-oracles `core/src/openhands/explain.ts` minus its campaign label table: a fact is
7
+ * labelled by the rubric's own sentence (an AI check) or by its name, never by a provider's word.
8
+ *
9
+ * DISPLAY ONLY. The rubric is read for its public sentences after the engine decided; nothing here
10
+ * binds, validates or decides — the engine's `Decision` is the only verdict, `outcomeOf` reads it.
11
+ *
12
+ * One generic mapper, never a case per claim: each gate and criterion is explained through the ONE
13
+ * fact its condition reads (`ext.<name>` / `ai.<name>`), or through the claim fields it tests.
14
+ *
15
+ * WORKER-SAFE by construction. Nothing reaches the body verbatim except the rubric's own sentences
16
+ * (the protocol's public text) and the AI's sentence about the claimant's own submission, sanitised.
17
+ * An `ext` reason is NEVER echoed — a provider's reason may name other claims or places — only
18
+ * numbers read out of the stock geo handlers' measurement grammar (`facts/geo.ts`:
19
+ * `<n> m from centre, limit <m> m; evidence: …`, `<n> m apart, limit <m> m`). The engine's
20
+ * `duplicate` / `frequency` reason lines name the matching claim, so those gates are explained from
21
+ * the rubric alone. No model prompt (`aiChecks[].instructions`) is ever read.
22
+ */
23
+ const str = (v) => (typeof v === "string" && v ? v : undefined);
24
+ const obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
25
+ const list = (v) => Array.isArray(v) ? v.map(obj).filter((x) => !!x) : [];
26
+ /** A `#rub` envelope (`{ rubric: {...} }`) → the view; null when it is not a rubric at all. */
27
+ export function rubricView(doc) {
28
+ const rubric = obj(obj(doc)?.rubric);
29
+ if (!rubric)
30
+ return null;
31
+ const scoring = obj(rubric.scoring);
32
+ const unique = obj(rubric.unique);
33
+ const frequency = obj(rubric.frequency);
34
+ const tasks = {};
35
+ for (const task of list(obj(rubric.review)?.tasks)) {
36
+ const id = str(task.id);
37
+ if (id)
38
+ tasks[id] = str(task.forCriterion) ?? null;
39
+ }
40
+ return {
41
+ gates: list(rubric.gates).flatMap((g) => {
42
+ const code = str(g.code);
43
+ return code
44
+ ? [{ code, reason: str(g.reason), when: obj(g.when), onFail: str(g.onFail) }]
45
+ : [];
46
+ }),
47
+ criteria: list(scoring?.criteria).flatMap((c) => {
48
+ const code = str(c.code);
49
+ return code
50
+ ? [{ code, reason: str(c.reason), appliesWhen: obj(c.appliesWhen), score: obj(c.score) }]
51
+ : [];
52
+ }),
53
+ tasks,
54
+ ...(str(unique?.id)
55
+ ? { unique: { id: str(unique?.id), label: str(unique?.label) } }
56
+ : {}),
57
+ ...(str(frequency?.id)
58
+ ? {
59
+ frequency: {
60
+ id: str(frequency?.id),
61
+ label: str(frequency?.label),
62
+ max: typeof frequency?.max === "number" ? frequency.max : undefined,
63
+ per: str(frequency?.per),
64
+ },
65
+ }
66
+ : {}),
67
+ };
68
+ }
69
+ /** Every `{ field, op, value }` leaf of a condition tree (`all` / `any` / `not` / a score's `when`). */
70
+ function leaves(node, out = []) {
71
+ if (Array.isArray(node)) {
72
+ for (const n of node)
73
+ leaves(n, out);
74
+ }
75
+ else if (node && typeof node === "object") {
76
+ const o = node;
77
+ if (typeof o.field === "string")
78
+ out.push(o);
79
+ for (const [k, v] of Object.entries(o))
80
+ if (k !== "value")
81
+ leaves(v, out);
82
+ }
83
+ return out;
84
+ }
85
+ /** The facts a condition reads, each once, with a `.score` threshold where it compares one. */
86
+ function factRefs(...nodes) {
87
+ const refs = new Map();
88
+ for (const leaf of nodes.flatMap((n) => leaves(n))) {
89
+ const m = /^(ext|ai)\.([A-Za-z0-9_]+)\.(valid|score)$/.exec(leaf.field);
90
+ if (!m)
91
+ continue;
92
+ const key = `${m[1]}.${m[2]}`;
93
+ const ref = refs.get(key) ?? { lane: m[1], name: m[2] };
94
+ if (m[3] === "score" && typeof leaf.value === "number" && /^>=?$/.test(String(leaf.op))) {
95
+ ref.threshold = Math.max(ref.threshold ?? 0, leaf.value);
96
+ }
97
+ refs.set(key, ref);
98
+ }
99
+ return [...refs.values()];
100
+ }
101
+ // --- labels ---------------------------------------------------------------------------------------
102
+ const humanise = (s) => s
103
+ .replace(/^\$/, "")
104
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
105
+ .replace(/[._-]+/g, " ")
106
+ .trim()
107
+ .replace(/^./, (c) => c.toUpperCase())
108
+ .replace(/(?<=\s)([A-Z])(?=[a-z])/g, (c) => c.toLowerCase());
109
+ const capped = (s, n = 160) => s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
110
+ /** An AI check's gate reason IS the criterion the claimant was shown; any other fact, its name. */
111
+ function factLabel(ref, rubricReason) {
112
+ if (ref.lane === "ai" && rubricReason)
113
+ return capped(rubricReason);
114
+ return humanise(ref.name);
115
+ }
116
+ /** A gate that reads only claim fields, labelled by what it tests. */
117
+ function fieldLabel(when, rubricReason) {
118
+ const ops = leaves(when);
119
+ const op = String(ops[0]?.op ?? "");
120
+ const fields = [...new Set(ops.map((l) => String(l.field)))];
121
+ const what = fields.length === 1 ? humanise(fields[0] ?? "") : "Files";
122
+ if (op === "present")
123
+ return `${what} handed in`;
124
+ if (op === "mediaTypeIn")
125
+ return `${what === "Files" ? "File" : what} type`;
126
+ if (op === "maxSizeMB")
127
+ return `${what === "Files" ? "File" : what} size`;
128
+ return rubricReason ? capped(rubricReason) : what;
129
+ }
130
+ const fmt = (n) => n.toLocaleString("en-US", { maximumFractionDigits: 2 });
131
+ /** The package's measurement grammar: the distance and its limit, never the rest of the reason. */
132
+ function measured(reason) {
133
+ const m = /(\d+(?:\.\d+)?) m (from centre|apart|from the reported point)(?:, limit (\d+(?:\.\d+)?) m)?/.exec(reason);
134
+ if (!m)
135
+ return null;
136
+ return {
137
+ value: Number(m[1]),
138
+ apart: m[2] === "apart",
139
+ ...(m[3] ? { threshold: Number(m[3]) } : {}),
140
+ };
141
+ }
142
+ /** Who wrote a fact's time and place, from the grade its reason carries. */
143
+ function gradeOf(fact, errored) {
144
+ if (errored && NO_TIME_PLACE.test(errored))
145
+ return "none";
146
+ const reason = fact?.status === "ok" ? (fact.reason ?? "") : "";
147
+ if (/evidence: .*\b(?:browser-reported|claim) \(/.test(reason))
148
+ return "browser-reported";
149
+ if (/browser-reported/.test(reason))
150
+ return "browser-reported";
151
+ if (/evidence: .*\bcamera \(/.test(reason))
152
+ return "camera";
153
+ return null;
154
+ }
155
+ const NO_TIME_PLACE = /no (?:EXIF|timestamp|GPS|capture time|coordinates)|no timestamp or GPS/i;
156
+ /**
157
+ * The stock pair check's `evidence: time T, place P` (P may name each photo, `a / b`) → the
158
+ * worker's two words for it. The claim's own lat/lon is the claimant's word, so it reads as
159
+ * browser-reported; the weakest source on either side wins.
160
+ */
161
+ function gradeText(reason) {
162
+ const m = /evidence: time ([^;]+?)(?:, place ([^;]+))?$/.exec(reason);
163
+ if (!m)
164
+ return null;
165
+ const short = (s) => /\b(?:browser-reported|claim) \(/.test(s) ? "browser-reported" : "camera";
166
+ const time = short(m[1] ?? "");
167
+ const place = m[2] ? short(m[2]) : time;
168
+ return time === place ? `Time/place: ${time}` : `Time: ${time}, place: ${place}`;
169
+ }
170
+ const HELD = "could not be checked automatically: held for a person";
171
+ /** One gate or criterion, explained through the one fact it reads. */
172
+ function factReason(code, result, ref, fact, errored, rubricReason) {
173
+ const label = factLabel(ref, rubricReason);
174
+ const base = { code, label, result };
175
+ if (result === "error" || fact?.status === "error") {
176
+ const text = errored ?? fact?.detail ?? "";
177
+ const held = result === "error" ? ": held for a person" : "";
178
+ return {
179
+ ...base,
180
+ detail: NO_TIME_PLACE.test(text)
181
+ ? `No photo time/GPS${held}`
182
+ : `${label}: could not be checked automatically${held}`,
183
+ };
184
+ }
185
+ if (result === "review")
186
+ return { ...base, detail: `${label}: a person is checking this` };
187
+ const reason = fact?.reason ?? "";
188
+ if (typeof fact?.value === "number" &&
189
+ fact.value >= 0 &&
190
+ fact.value <= 10_000 &&
191
+ ref.lane === "ext" &&
192
+ !measured(reason) &&
193
+ ref.threshold !== undefined) {
194
+ const pct = Math.round(fact.value / 100);
195
+ const needs = Math.round(ref.threshold / 100);
196
+ return {
197
+ ...base,
198
+ detail: `${label}: ${pct}% (needs ${needs}%)`,
199
+ value: pct,
200
+ threshold: needs,
201
+ unit: "%",
202
+ };
203
+ }
204
+ if (ref.lane === "ext") {
205
+ const m = measured(reason);
206
+ const grade = gradeText(reason);
207
+ if (m) {
208
+ const limit = m.threshold !== undefined ? ` (limit ${fmt(m.threshold)} m)` : "";
209
+ return {
210
+ ...base,
211
+ detail: `${label}: ${fmt(m.value)} m${m.apart ? " apart" : ""}${limit}${grade ? `; ${grade}` : ""}`,
212
+ value: m.value,
213
+ ...(m.threshold !== undefined ? { threshold: m.threshold } : {}),
214
+ unit: "m",
215
+ };
216
+ }
217
+ if (grade && result === "pass")
218
+ return { ...base, detail: grade };
219
+ }
220
+ if (result === "pass")
221
+ return { ...base, detail: "Passed" };
222
+ if (ref.lane === "ai" && reason) {
223
+ const said = sanitise(reason);
224
+ if (said)
225
+ return { ...base, detail: said };
226
+ }
227
+ return { ...base, detail: rubricReason ?? "Did not pass" };
228
+ }
229
+ // --- the mapper -----------------------------------------------------------------------------------
230
+ const GATE_RESULT = {
231
+ passed: "pass",
232
+ failed: "fail",
233
+ unevaluated: "error",
234
+ not_reached: null,
235
+ skipped: null,
236
+ };
237
+ function outcomeOf(row) {
238
+ if (!row.decision || row.status === "unusable" || row.status === "invalid_facts")
239
+ return "pending";
240
+ if (row.status === "pending_review" || row.verdict === "review")
241
+ return "review";
242
+ if (row.verdict === "approve" || row.verdict === "partial")
243
+ return "approved";
244
+ if (row.verdict === "reject")
245
+ return "rejected";
246
+ return "pending";
247
+ }
248
+ /** What this oracle holds for a claim it has seen but the engine has not yet decided. */
249
+ export function pendingBody() {
250
+ return {
251
+ outcome: "pending",
252
+ summary: "Not decided yet: the checks on your claim are still running.",
253
+ reasons: [],
254
+ };
255
+ }
256
+ export function explain(row, rubric) {
257
+ const outcome = outcomeOf(row);
258
+ const decision = row.decision;
259
+ const receipt = row.receiptCid ? { receiptCid: row.receiptCid } : {};
260
+ if (!decision || outcome === "pending") {
261
+ return {
262
+ ...pendingBody(),
263
+ summary: "Not decided yet: your claim could not be checked automatically. Nothing is needed from you.",
264
+ ...receipt,
265
+ };
266
+ }
267
+ const ext = (row.facts?.ext ?? {});
268
+ const ai = (row.facts?.ai ?? {});
269
+ const factOf = (ref) => (ref.lane === "ext" ? ext : ai)[ref.name];
270
+ // `erroredFacts` (the engine's copy of what errored) when it sent one, else the frozen facts'.
271
+ const errored = new Map();
272
+ for (const e of row.erroredFacts ?? [])
273
+ errored.set(e.name, e.detail);
274
+ for (const [lane, facts] of [
275
+ ["ext", ext],
276
+ ["ai", ai],
277
+ ]) {
278
+ for (const [name, f] of Object.entries(facts)) {
279
+ if (f.status === "error" && f.detail && !errored.has(`${lane}.${name}`)) {
280
+ errored.set(`${lane}.${name}`, f.detail);
281
+ }
282
+ }
283
+ }
284
+ const erroredOf = (ref) => errored.get(`${ref.lane}.${ref.name}`);
285
+ const reasons = [];
286
+ const seen = new Set();
287
+ const push = (r) => {
288
+ // A criterion that restates its gate (same fact, same answer) is said once.
289
+ const key = `${r.result}|${r.label}|${r.detail}`;
290
+ if (seen.has(key))
291
+ return;
292
+ seen.add(key);
293
+ reasons.push(r);
294
+ };
295
+ const engineReason = (code) => decision.reasons.find((r) => r.code === code)?.reason;
296
+ // A review fold never rewrites gates: a gate that held the claim for a person stays `failed`,
297
+ // and the person's answer is a reason line (decide.ts, S11 settle). Say what the person said.
298
+ const settled = decision.reasons.find((r) => / by human review$/.test(r.reason))?.reason;
299
+ for (const gate of decision.gates) {
300
+ const result = GATE_RESULT[gate.outcome] ?? null;
301
+ if (!result)
302
+ continue;
303
+ // Engine-synthetic gates: their reason lines name the matching claim — the rubric speaks instead.
304
+ if (gate.code.startsWith("unique:")) {
305
+ const id = gate.code.slice("unique:".length);
306
+ push({
307
+ code: id,
308
+ label: rubric?.unique?.label ?? "Photo not used before",
309
+ result,
310
+ detail: result === "fail" ? "This photo was already used in another claim" : "Passed",
311
+ });
312
+ continue;
313
+ }
314
+ if (gate.code.startsWith("frequency:")) {
315
+ const id = gate.code.slice("frequency:".length);
316
+ const f = rubric?.frequency;
317
+ push({
318
+ code: id,
319
+ label: f?.label ?? "Claim limit",
320
+ result,
321
+ detail: result !== "fail"
322
+ ? "Passed"
323
+ : f?.max !== undefined && f.per
324
+ ? `Limit reached: at most ${fmt(f.max)} claims a ${f.per}`
325
+ : "Limit reached",
326
+ ...(f?.max !== undefined ? { threshold: f.max } : {}),
327
+ });
328
+ continue;
329
+ }
330
+ const spec = rubric?.gates.find((g) => g.code === gate.code);
331
+ // An approved claim cannot carry a hard failed gate, so without the rubric it was a review gate.
332
+ if (settled &&
333
+ result === "fail" &&
334
+ (spec ? spec.onFail === "review" : outcome === "approved")) {
335
+ push({
336
+ code: gate.code,
337
+ label: "Checked by a person",
338
+ result: outcome === "approved" ? "pass" : "fail",
339
+ detail: `${settled.replace(/ by human review$/, "")} by a person`,
340
+ });
341
+ continue;
342
+ }
343
+ const reasonText = spec?.reason ?? engineReason(gate.code);
344
+ const refs = factRefs(spec?.when);
345
+ const [only] = refs;
346
+ if (only && refs.length === 1) {
347
+ push(factReason(gate.code, result, only, factOf(only), erroredOf(only), reasonText));
348
+ }
349
+ else {
350
+ const label = spec ? fieldLabel(spec.when, reasonText) : humanise(gate.code);
351
+ push({
352
+ code: gate.code,
353
+ label,
354
+ result,
355
+ detail: result === "pass"
356
+ ? "Passed"
357
+ : result === "fail"
358
+ ? (reasonText ?? "Did not pass")
359
+ : `${label}: ${HELD}`,
360
+ });
361
+ }
362
+ }
363
+ const reviewed = new Set();
364
+ for (const c of decision.criteria) {
365
+ const spec = rubric?.criteria.find((s) => s.code === c.code);
366
+ let result;
367
+ if (c.pending)
368
+ result = "review";
369
+ else if (c.scoreBps === null)
370
+ result = null;
371
+ else
372
+ result = c.scoreBps >= 5000 ? "pass" : "fail";
373
+ if (!result)
374
+ continue;
375
+ if (result === "review")
376
+ reviewed.add(c.code);
377
+ const refs = factRefs(spec?.appliesWhen, spec?.score);
378
+ const [only] = refs;
379
+ if (only && refs.length === 1 && result !== "review") {
380
+ push(factReason(c.code, result, only, factOf(only), erroredOf(only), spec?.reason));
381
+ }
382
+ else {
383
+ const label = spec?.reason ? capped(spec.reason) : humanise(c.code);
384
+ push({
385
+ code: c.code,
386
+ label,
387
+ result,
388
+ detail: result === "review"
389
+ ? "A person is checking this"
390
+ : result === "pass"
391
+ ? "Passed"
392
+ : "Did not pass",
393
+ });
394
+ }
395
+ }
396
+ if (outcome === "review" && !reasons.some((r) => r.result === "error" || r.result === "review")) {
397
+ const task = decision.pendingTasks.find((t) => !reviewed.has(rubric?.tasks[t] ?? ""));
398
+ if (task) {
399
+ push({
400
+ code: task,
401
+ label: "Checked by a person",
402
+ result: "review",
403
+ detail: "A person is checking your claim",
404
+ });
405
+ }
406
+ }
407
+ const grades = Object.entries(ext)
408
+ .map(([name, f]) => gradeOf(f, errored.get(`ext.${name}`)))
409
+ .filter((g) => g !== null);
410
+ const evidenceGrade = grades.includes("none")
411
+ ? "none"
412
+ : grades.includes("browser-reported")
413
+ ? "browser-reported"
414
+ : grades.includes("camera")
415
+ ? "camera"
416
+ : undefined;
417
+ const note = outcome === "review"
418
+ ? [...errored.values()]
419
+ .map((d) => sanitise(d.replace(/^[A-Za-z0-9_]+: /, "")))
420
+ .filter(Boolean)
421
+ .join(" · ")
422
+ : "";
423
+ return {
424
+ outcome,
425
+ summary: summaryOf(outcome, row.verdict, reasons),
426
+ reasons,
427
+ ...(evidenceGrade ? { evidenceGrade } : {}),
428
+ ...(note ? { reviewNote: capped(note, REASON_CAP) } : {}),
429
+ ...receipt,
430
+ };
431
+ }
432
+ const sentence = (s) => (/[.!?…]$/.test(s) ? s : `${s}.`);
433
+ function summaryOf(outcome, verdict, reasons) {
434
+ if (outcome === "approved") {
435
+ return verdict === "partial"
436
+ ? "Approved in part: some checks fell short."
437
+ : "Approved: every check passed.";
438
+ }
439
+ if (outcome === "rejected") {
440
+ const first = reasons.find((r) => r.result === "fail");
441
+ return first ? `Rejected. ${sentence(first.detail)}` : "Rejected.";
442
+ }
443
+ const held = reasons.find((r) => r.result === "error" || r.result === "review");
444
+ return held
445
+ ? `Held for a person to check. ${sentence(held.result === "review" ? held.label : held.detail)}`
446
+ : "Held for a person to check.";
447
+ }
448
+ // --- sanitising the AI's sentence and the errored details ------------------------------------------
449
+ const REASON_CAP = 280;
450
+ const HIDDEN = "[hidden]";
451
+ const URL_RE = /\b(?:https?:\/\/|www\.)\S+/gi;
452
+ const EMAIL_RE = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g;
453
+ /** A DID, a CID, a chain address, a hash — any identifier that could name another claim or person. */
454
+ const DID_RE = /\bdid:[a-z0-9]+:[^\s,;)]+/gi;
455
+ const ID_TOKEN_RE = /\b(?:baf[a-z2-7]{20,}|[A-Za-z0-9]{30,}|(?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{12,})\b/g;
456
+ /** A lat/lon pair, or one coordinate precise enough to place someone (4+ decimals). */
457
+ const COORD_PAIR_RE = /-?\d{1,3}\.\d+\s*[,;/ ]\s*-?\d{1,3}\.\d+/g;
458
+ const COORD_ONE_RE = /-?\d{1,3}\.\d{4,}/g;
459
+ /** Phone-shaped runs; only hidden when they carry 7+ digits, so "2 - 3 bags" survives. */
460
+ const PHONE_RE = /\+?\(?\d[\d\s().-]{5,}\d/g;
461
+ const LONG_DIGITS_RE = /\d{6,}/g;
462
+ /** One token of 5–8 letters/digits — a plate when it mixes 2+ letters with 2+ digits. */
463
+ const PLATE_TOKEN_RE = /\b[A-Za-z0-9]{5,8}\b/g;
464
+ /** Two short upper-case groups split by a space or hyphen — "AB12 CDE", "KA-01 MX". */
465
+ const PLATE_SPACED_RE = /\b[A-Z0-9]{2,4}[ -][A-Z0-9]{2,4}\b/g;
466
+ /**
467
+ * Text the model quoted verbatim (a sign, a label, something written) once it runs to 4+ words.
468
+ * A single quote only opens after whitespace, so the apostrophes in "claimant's … isn't" never
469
+ * pair up into a quote.
470
+ */
471
+ const QUOTED_RE = /["“]([^"“”]*)["”]|‘([^‘’]*)’|(?<=^|\s)'([^']*)'(?=[\s.,;:!?)]|$)/g;
472
+ const digits = (s) => (s.match(/\d/g) ?? []).length;
473
+ const letters = (s) => (s.match(/[A-Za-z]/g) ?? []).length;
474
+ const plateLike = (s) => {
475
+ const alnum = s.replace(/[^A-Za-z0-9]/g, "");
476
+ return alnum.length >= 5 && alnum.length <= 8 && digits(alnum) >= 2 && letters(alnum) >= 2;
477
+ };
478
+ /**
479
+ * What a model or a handler wrote, minus anything that could identify someone: contact details,
480
+ * identifiers, places, long numbers, plate-shaped tokens and long verbatim quotes become
481
+ * `[hidden]`; whitespace is collapsed and the result capped. Empty in, empty out.
482
+ */
483
+ export function sanitise(text) {
484
+ let out = text
485
+ .replace(URL_RE, HIDDEN)
486
+ .replace(EMAIL_RE, HIDDEN)
487
+ .replace(DID_RE, HIDDEN)
488
+ .replace(ID_TOKEN_RE, HIDDEN)
489
+ .replace(COORD_PAIR_RE, HIDDEN)
490
+ .replace(COORD_ONE_RE, HIDDEN)
491
+ .replace(PHONE_RE, (m) => (digits(m) >= 7 ? HIDDEN : m))
492
+ .replace(LONG_DIGITS_RE, HIDDEN)
493
+ .replace(PLATE_SPACED_RE, (m) => (plateLike(m) ? HIDDEN : m))
494
+ .replace(PLATE_TOKEN_RE, (m) => (plateLike(m) ? HIDDEN : m))
495
+ .replace(QUOTED_RE, (m, a, b, c) => (a ?? b ?? c ?? "").trim().split(/\s+/).length >= 4 ? `"${HIDDEN}"` : m);
496
+ out = out.replace(/\s+/g, " ").trim();
497
+ return capped(out, REASON_CAP);
498
+ }
499
+ //# sourceMappingURL=explain.js.map