@kontourai/survey 2.5.0 → 4.0.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.
Files changed (38) hide show
  1. package/README.md +6 -1
  2. package/dist/examples/calibrated-auto-accept.d.ts +22 -15
  3. package/dist/examples/calibrated-auto-accept.js +40 -36
  4. package/dist/src/agent-utterance.d.ts +87 -11
  5. package/dist/src/agent-utterance.js +135 -44
  6. package/dist/src/calibration.d.ts +48 -21
  7. package/dist/src/calibration.js +72 -33
  8. package/dist/src/console/review-console-server.d.ts +3 -1
  9. package/dist/src/console/review-console-server.js +203 -50
  10. package/dist/src/extraction-envelope.d.ts +22 -0
  11. package/dist/src/extraction-envelope.js +25 -4
  12. package/dist/src/index.d.ts +9 -8
  13. package/dist/src/index.js +2 -2
  14. package/dist/src/inquiry-mapping.d.ts +15 -1
  15. package/dist/src/inquiry-mapping.js +10 -2
  16. package/dist/src/mcp/review-mcp.js +219 -279
  17. package/dist/src/producer-profile.d.ts +41 -2
  18. package/dist/src/producer-profile.js +29 -2
  19. package/dist/src/review-session-file.d.ts +64 -0
  20. package/dist/src/review-session-file.js +320 -0
  21. package/dist/src/review-workbench/edited-value.d.ts +70 -0
  22. package/dist/src/review-workbench/edited-value.js +147 -0
  23. package/dist/src/review-workbench/review-presentation.d.ts +44 -0
  24. package/dist/src/review-workbench/review-presentation.js +49 -0
  25. package/dist/src/review-workbench/review-queue-session.js +8 -1
  26. package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
  27. package/dist/src/review-workbench/review-session-replay.js +77 -0
  28. package/dist/src/review-workbench/review-workbench.d.ts +8 -4
  29. package/dist/src/review-workbench/review-workbench.js +19 -7
  30. package/dist/src/review-workbench/server-review-session.d.ts +3 -1
  31. package/dist/src/review-workbench/server-review-session.js +1 -0
  32. package/dist/src/reviewed-candidate-resolution.js +13 -7
  33. package/dist/src/schema-mapping.d.ts +23 -0
  34. package/dist/src/schema-mapping.js +30 -20
  35. package/dist/src/to-surface.d.ts +30 -6
  36. package/dist/src/to-surface.js +196 -18
  37. package/dist/src/types.d.ts +39 -0
  38. package/package.json +8 -4
@@ -5,18 +5,20 @@
5
5
  * Routes:
6
6
  * GET / HTML shell that mounts the workbench
7
7
  * GET /api/session Current session state (snapshot + replayed events)
8
- * POST /api/events Append review session events (same contract as MCP server)
8
+ * POST /api/events Append review session events to the stored log (same
9
+ * validation as MCP server), compare-and-swap on the
10
+ * revision the client last read
9
11
  * GET /api/stream SSE stream: emits "update" events when the session file changes
10
12
  * GET /api/health Health check
11
13
  * GET /dist/* Compiled assets served from the dist tree (traversal-safe)
12
14
  */
13
15
  import { createServer } from "node:http";
14
16
  import { watch } from "node:fs";
15
- import { readFile as readFileAsync, writeFile as writeFileAsync, rename as renameAsync } from "node:fs/promises";
17
+ import { readFile as readFileAsync } from "node:fs/promises";
16
18
  import { resolve, join, dirname, extname, normalize, sep } from "node:path";
17
19
  import { fileURLToPath } from "node:url";
18
- import { defaultReviewSessionName, } from "../review-workbench/review-workbench.js";
19
20
  import { createServerReviewSessionRecord, currentSessionState, deriveServerReviewSessionApplyResult, } from "../review-workbench/server-review-session.js";
21
+ import { appendReviewSessionEvents, readReviewSessionFile, reviewSessionRevision, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
20
22
  // ---------------------------------------------------------------------------
21
23
  // Constants
22
24
  // ---------------------------------------------------------------------------
@@ -55,13 +57,7 @@ function distRoot() {
55
57
  // Session file helpers
56
58
  // ---------------------------------------------------------------------------
57
59
  async function readSession(path) {
58
- const raw = await readFileAsync(path, "utf8");
59
- return JSON.parse(raw);
60
- }
61
- async function writeSessionAtomic(path, content) {
62
- const tmp = `${path}.tmp`;
63
- await writeFileAsync(tmp, JSON.stringify(content, null, 2), "utf8");
64
- await renameAsync(tmp, path);
60
+ return readReviewSessionFile(path);
65
61
  }
66
62
  // ---------------------------------------------------------------------------
67
63
  // SSE broadcaster
@@ -271,6 +267,14 @@ body {
271
267
  .connection-dot.disconnected {
272
268
  background: var(--k-negative, #ff6f6f);
273
269
  }
270
+ .console-save-status {
271
+ padding: 8px 16px;
272
+ border-bottom: 1px solid var(--k-negative, #ff6f6f);
273
+ background: var(--k-panel-raised, #16202d);
274
+ color: var(--k-text, #eef3f8);
275
+ font-size: 13px;
276
+ }
277
+ .console-save-status[hidden] { display: none; }
274
278
  #review-workbench {
275
279
  flex: 1;
276
280
  min-height: 0;
@@ -309,31 +313,92 @@ body {
309
313
  </button>
310
314
  </div>
311
315
  </header>
316
+ <div id="console-save-status" class="console-save-status" role="alert" data-testid="console-save-status" hidden></div>
312
317
  <main id="review-workbench" class="workbench survey-workbench-embed" data-testid="review-workbench"></main>
313
318
  <script type="module">
314
319
  import { mountReviewWorkbench, replayReviewSessionEvents, defaultReviewSessionName, buildReviewSessionEvents } from "${workbenchJsPath}";
315
320
 
316
321
  // ---- persistence adapter: POST events to /api/events ----
317
- function createConsoleEventStore() {
318
- let _events = [];
322
+ // One store per mount, bound to the server revision the mount was built from.
323
+ // Each save sends only the workbench events of this mount that are not stored
324
+ // yet; the server appends them to the stored log, so reversals and note
325
+ // changes stay on record and decisions another writer stored (for example the
326
+ // MCP server) are kept. Saves are serialized so each one carries the revision
327
+ // the previous save produced. A save the server refuses (409 conflict or any
328
+ // other failure) is never kept as local truth: the console re-fetches the
329
+ // stored session, re-mounts from it and tells the reviewer once.
330
+ let saveChain = Promise.resolve();
331
+
332
+ function showSaveStatus(message) {
333
+ const el = document.getElementById("console-save-status");
334
+ if (!el) return;
335
+ if (message) {
336
+ el.textContent = message;
337
+ el.hidden = false;
338
+ } else {
339
+ el.textContent = "";
340
+ el.hidden = true;
341
+ }
342
+ }
343
+
344
+ function createConsoleEventStore(baseRevision) {
345
+ let revision = baseRevision;
346
+ // Local events of this mount already appended to the stored log.
347
+ let persistedCount = 0;
348
+ // Set once a save from this mount is refused. Saves still queued from this
349
+ // mount were built on the view the server just refused, and the page has
350
+ // re-mounted from the stored session and told the reviewer: drop them
351
+ // instead of reporting the same conflict again.
352
+ let abandoned = false;
353
+
354
+ async function refused(message) {
355
+ abandoned = true;
356
+ showSaveStatus(message);
357
+ await fetchAndMount();
358
+ }
359
+
360
+ async function persist(localEvents) {
361
+ if (abandoned) return;
362
+ const pending = localEvents.slice(persistedCount);
363
+ if (pending.length === 0) return;
364
+ let res;
365
+ try {
366
+ res = await fetch("/api/events", {
367
+ method: "POST",
368
+ headers: { "Content-Type": "application/json" },
369
+ body: JSON.stringify({ events: pending, baseRevision: revision }),
370
+ });
371
+ } catch (err) {
372
+ console.error("[console] Failed to persist events:", err);
373
+ await refused("Your last change was not saved: the console could not reach the server.");
374
+ return;
375
+ }
376
+ if (res.ok) {
377
+ const body = await res.json().catch(() => ({}));
378
+ if (typeof body.revision === "string") revision = body.revision;
379
+ persistedCount = localEvents.length;
380
+ showSaveStatus("");
381
+ return;
382
+ }
383
+ if (res.status === 409) {
384
+ await refused("Your last change was not saved because the session changed (another tab, reviewer or agent wrote to it). The console reloaded the current session.");
385
+ } else {
386
+ const body = await res.json().catch(() => ({}));
387
+ await refused("Your last change was not saved (HTTP " + res.status + (body.error ? ": " + body.error : "") + "). The console reloaded the stored session.");
388
+ }
389
+ }
390
+
319
391
  return {
320
- load: () => _events.length > 0 ? [..._events] : undefined,
321
- save: async (_session, events) => {
322
- _events = [...events];
323
- try {
324
- await fetch("/api/events", {
325
- method: "POST",
326
- headers: { "Content-Type": "application/json" },
327
- body: JSON.stringify({ events }),
328
- });
329
- } catch (err) {
330
- console.error("[console] Failed to persist events:", err);
331
- }
392
+ // The mount state is already replayed from the stored log.
393
+ load: () => undefined,
394
+ save: (_session, events) => {
395
+ const localEvents = [...events];
396
+ saveChain = saveChain.then(() => persist(localEvents));
397
+ return saveChain;
332
398
  },
333
399
  };
334
400
  }
335
401
 
336
- const eventStore = createConsoleEventStore();
337
402
  let activeItemName = null;
338
403
 
339
404
  async function fetchAndMount() {
@@ -341,7 +406,7 @@ async function fetchAndMount() {
341
406
  const res = await fetch("/api/session");
342
407
  if (!res.ok) throw new Error("Session fetch failed: " + res.status);
343
408
  const data = await res.json();
344
- const { snapshot, events } = data;
409
+ const { snapshot, events, revision } = data;
345
410
  const state = events && events.length > 0
346
411
  ? replayReviewSessionEvents(snapshot, events)
347
412
  : snapshot;
@@ -354,7 +419,7 @@ async function fetchAndMount() {
354
419
  const root = document.getElementById("review-workbench");
355
420
  if (!root) return;
356
421
 
357
- mountReviewWorkbench(root, state, { eventStore });
422
+ mountReviewWorkbench(root, state, { eventStore: createConsoleEventStore(revision) });
358
423
  } catch (err) {
359
424
  console.error("[console] Mount error:", err);
360
425
  }
@@ -451,6 +516,49 @@ async function readJsonBody(req) {
451
516
  return parsed;
452
517
  }
453
518
  // ---------------------------------------------------------------------------
519
+ // Request guards (kontourai/survey#281)
520
+ // ---------------------------------------------------------------------------
521
+ function hostnameOf(hostHeader) {
522
+ try {
523
+ return new URL(`http://${hostHeader}`).hostname.replace(/^\[|\]$/g, "");
524
+ }
525
+ catch {
526
+ return undefined;
527
+ }
528
+ }
529
+ /**
530
+ * The server binds to loopback only, but a browser page on another site can
531
+ * still reach it through DNS rebinding (Host is the attacker's name) or a
532
+ * cross-origin "simple request" (text/plain body, foreign Origin). Each guard
533
+ * returns an error message, or undefined when the request passes.
534
+ */
535
+ function hostGuard(req) {
536
+ const host = req.headers.host;
537
+ const hostname = host ? hostnameOf(host) : undefined;
538
+ return hostname && LOOPBACK_HOSTS.has(hostname) ? undefined : "Host must be a loopback address";
539
+ }
540
+ function originGuard(req, port) {
541
+ const origin = req.headers.origin;
542
+ if (origin === undefined)
543
+ return undefined;
544
+ try {
545
+ const parsed = new URL(origin);
546
+ const originPort = parsed.port === "" ? "80" : parsed.port;
547
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
548
+ if (parsed.protocol === "http:" && LOOPBACK_HOSTS.has(hostname) && originPort === String(port)) {
549
+ return undefined;
550
+ }
551
+ }
552
+ catch {
553
+ // Unparseable (for example "null"): refuse below.
554
+ }
555
+ return "Cross-origin writes are not allowed";
556
+ }
557
+ function contentTypeGuard(req) {
558
+ const mediaType = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
559
+ return mediaType === "application/json" ? undefined : "Content-Type must be application/json";
560
+ }
561
+ // ---------------------------------------------------------------------------
454
562
  // Public API
455
563
  // ---------------------------------------------------------------------------
456
564
  export async function startReviewConsoleServer(options) {
@@ -464,8 +572,14 @@ export async function startReviewConsoleServer(options) {
464
572
  const stopWatcher = watchSessionFile(sessionPath, () => {
465
573
  broadcaster.broadcast("update", JSON.stringify({ ts: Date.now() }));
466
574
  });
575
+ let listenPort = 0;
467
576
  const server = createServer(async (req, res) => {
468
577
  try {
578
+ const hostError = hostGuard(req);
579
+ if (hostError) {
580
+ sendJson(res, 403, { error: hostError });
581
+ return;
582
+ }
469
583
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
470
584
  const pathname = url.pathname;
471
585
  // ---- Static dist assets ----
@@ -492,42 +606,80 @@ export async function startReviewConsoleServer(options) {
492
606
  session: content.session,
493
607
  snapshot: content.snapshot,
494
608
  events: content.events,
609
+ revision: reviewSessionRevision(content.events),
495
610
  state: currentSessionState(content.snapshot, content.events),
496
611
  });
497
612
  return;
498
613
  }
499
614
  // ---- Events write (append) ----
500
615
  if (pathname === "/api/events" && req.method === "POST") {
616
+ const originError = originGuard(req, listenPort);
617
+ if (originError) {
618
+ sendJson(res, 403, { error: originError });
619
+ return;
620
+ }
621
+ const contentTypeError = contentTypeGuard(req);
622
+ if (contentTypeError) {
623
+ sendJson(res, 415, { error: contentTypeError });
624
+ return;
625
+ }
501
626
  const body = await readJsonBody(req);
502
627
  const incomingEvents = body.events;
628
+ const baseRevision = body.baseRevision;
503
629
  if (!Array.isArray(incomingEvents)) {
504
630
  sendJson(res, 400, { error: "events must be an array" });
505
631
  return;
506
632
  }
507
- const content = await readSession(sessionPath);
508
- const { snapshot, events: existingEvents } = content;
509
- const record = createServerReviewSessionRecord({
510
- sessionName: defaultReviewSessionName,
511
- snapshot,
512
- eventCount: existingEvents.length,
513
- updatedAt: new Date(),
514
- });
515
- const applyResult = deriveServerReviewSessionApplyResult({
516
- record,
517
- events: incomingEvents,
518
- requiredResolvedItems: "none",
519
- });
520
- if (!applyResult.ok) {
521
- const issueMessages = applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue));
522
- sendJson(res, 422, { error: `Validation failed: ${issueMessages.join("; ")}` });
523
- return;
524
- }
525
- await writeSessionAtomic(sessionPath, {
526
- session: content.session,
527
- snapshot,
528
- events: incomingEvents,
633
+ const outcome = await updateReviewSessionFile(sessionPath, (content) => {
634
+ const { snapshot, events: existingEvents } = content;
635
+ const revision = reviewSessionRevision(existingEvents);
636
+ // Compare-and-swap: the appended events express decisions made on
637
+ // the reviewer's view of the log, so they are only safe to append
638
+ // when that view is the log that is stored right now.
639
+ if (typeof baseRevision !== "string") {
640
+ return { result: { status: 428, body: { error: "baseRevision is required: send the revision from GET /api/session", revision, eventCount: existingEvents.length } } };
641
+ }
642
+ if (baseRevision !== revision) {
643
+ return { result: { status: 409, body: { error: "Session changed since it was read; reload and retry", revision, eventCount: existingEvents.length } } };
644
+ }
645
+ if (incomingEvents.length === 0) {
646
+ return { result: { status: 422, body: { error: "events must contain at least one event to append", revision, eventCount: existingEvents.length } } };
647
+ }
648
+ // Append, never replace: the stored log stays a complete record of
649
+ // reviewer intent (reversals and note changes included).
650
+ const nextEvents = appendReviewSessionEvents(content, incomingEvents);
651
+ const record = createServerReviewSessionRecord({
652
+ sessionName: storedReviewSessionName(content),
653
+ snapshot,
654
+ eventCount: existingEvents.length,
655
+ updatedAt: new Date(),
656
+ });
657
+ let issueMessages;
658
+ try {
659
+ const applyResult = deriveServerReviewSessionApplyResult({
660
+ record,
661
+ events: nextEvents,
662
+ requiredResolvedItems: "none",
663
+ });
664
+ issueMessages = applyResult.ok
665
+ ? []
666
+ : applyResult.issues.map((issue) => ("message" in issue ? issue.message : String(issue)));
667
+ }
668
+ catch (error) {
669
+ issueMessages = [error instanceof Error ? error.message : String(error)];
670
+ }
671
+ if (issueMessages.length > 0) {
672
+ return { result: { status: 422, body: { error: `Validation failed: ${issueMessages.join("; ")}` } } };
673
+ }
674
+ return {
675
+ next: { session: content.session, snapshot, events: nextEvents },
676
+ result: {
677
+ status: 200,
678
+ body: { ok: true, eventCount: nextEvents.length, revision: reviewSessionRevision(nextEvents) },
679
+ },
680
+ };
529
681
  });
530
- sendJson(res, 200, { ok: true, eventCount: incomingEvents.length });
682
+ sendJson(res, outcome.status, outcome.body);
531
683
  return;
532
684
  }
533
685
  // ---- Health ----
@@ -558,6 +710,7 @@ export async function startReviewConsoleServer(options) {
558
710
  if (!address || typeof address === "string") {
559
711
  throw new Error("Unable to determine server address");
560
712
  }
713
+ listenPort = address.port;
561
714
  const normalizedHost = host === "::1" ? "[::1]" : host;
562
715
  const url = `http://${normalizedHost}:${address.port}/`;
563
716
  return {
@@ -17,6 +17,24 @@ export interface PortableExtractionOccurrence {
17
17
  hintUsed: boolean;
18
18
  ambiguous: boolean;
19
19
  }
20
+ /** Which model produced one proposal, as the extractor recorded it for that proposal's own request. */
21
+ export interface PortableExtractionProducedBy {
22
+ model: string;
23
+ modelSource: "provider-reported" | "configured";
24
+ /** Content-free digest of the provider request (`sha256:<hex>`). */
25
+ requestDigest: string;
26
+ }
27
+ /**
28
+ * Deterministic, versioned facts about how a proposal's value relates to the
29
+ * declared schema and to its own excerpt. Annotations only: Survey carries
30
+ * them to review and does not route or block on them.
31
+ */
32
+ export interface PortableExtractionEvidenceMatch {
33
+ checkerVersion: string;
34
+ schema: "ok" | "type-mismatch" | "enum-mismatch" | "format-invalid";
35
+ valueInExcerpt: "match" | "mismatch" | "not-evaluated" | "not-applicable";
36
+ tokenBoundary?: boolean;
37
+ }
20
38
  export interface PortableExtractionProposal {
21
39
  fieldPath: string;
22
40
  candidateValue: unknown;
@@ -31,6 +49,8 @@ export interface PortableExtractionProposal {
31
49
  inferenceType?: "explicit" | "inferred";
32
50
  valueType?: ReviewValueType;
33
51
  enumValues?: string[];
52
+ producedBy?: PortableExtractionProducedBy;
53
+ evidenceMatch?: PortableExtractionEvidenceMatch;
34
54
  }
35
55
  export type PortablePreparedArtifactState = {
36
56
  status: "available" | "unavailable" | "storage-error";
@@ -89,10 +109,12 @@ export interface PortableExtractionResultEnvelope {
89
109
  remainingChunks: number;
90
110
  tokenOvershoot?: number;
91
111
  };
112
+ /** `code` is the upstream error code, informational only; `kind` stays authoritative. */
92
113
  providerFailures?: Array<{
93
114
  provider: string;
94
115
  kind: "authentication" | "rate-limit" | "timeout" | "invalid-request" | "unavailable" | "unknown";
95
116
  retryable: boolean;
117
+ code?: string;
96
118
  }>;
97
119
  taskDigest?: string;
98
120
  exampleDigests?: string[];
@@ -89,7 +89,9 @@ function buildReviewItem(record, proposal, index) {
89
89
  target: proposal.fieldPath,
90
90
  confidence: proposal.confidence,
91
91
  extractor: proposal.extractor,
92
- ...(envelope.result.model ? { model: envelope.result.model } : {}),
92
+ // The proposal's own served model when recorded; in a multi-chunk run
93
+ // `result.model` names only the last chunk's model.
94
+ ...(proposal.producedBy ? { model: proposal.producedBy.model } : envelope.result.model ? { model: envelope.result.model } : {}),
93
95
  },
94
96
  claimTarget: target,
95
97
  producer: { "survey.kontourai.io/extraction-envelope": {
@@ -100,6 +102,8 @@ function buildReviewItem(record, proposal, index) {
100
102
  ...(envelope.result.taskDigest ? { taskDigest: envelope.result.taskDigest } : {}),
101
103
  ...(envelope.result.exampleDigests ? { exampleDigests: envelope.result.exampleDigests } : {}),
102
104
  valueType: { type: valueType, origin: proposal.inferenceType ?? "inferred" },
105
+ ...(proposal.producedBy ? { producedBy: proposal.producedBy } : {}),
106
+ ...(proposal.evidenceMatch ? { evidenceMatch: proposal.evidenceMatch } : {}),
103
107
  occurrence: proposal.provenance.occurrence,
104
108
  attempt: { id: envelope.result.runId, providerCalls: envelope.result.providerCalls },
105
109
  ...(envelope.result.warningClassifications ? { warnings: envelope.result.warningClassifications } : {}),
@@ -228,7 +232,7 @@ function validateEnvelope(input) {
228
232
  }
229
233
  function validateProposal(input, index, contentLength) {
230
234
  const p = obj(input, `proposal[${index}]`);
231
- exact(p, ["fieldPath", "candidateValue", "confidence", "provenance", "extractor"], `proposal[${index}]`, ["pathIndices", "inferenceType", "valueType", "enumValues"]);
235
+ exact(p, ["fieldPath", "candidateValue", "confidence", "provenance", "extractor"], `proposal[${index}]`, ["pathIndices", "inferenceType", "valueType", "enumValues", "producedBy", "evidenceMatch"]);
232
236
  wireNonEmpty(p.fieldPath, "proposal.fieldPath");
233
237
  stableIdentity(p.extractor, "proposal.extractor");
234
238
  finite(p.confidence, "proposal.confidence", 0, 1);
@@ -252,6 +256,10 @@ function validateProposal(input, index, contentLength) {
252
256
  throw new Error("proposal valueType is invalid.");
253
257
  if (p.enumValues !== undefined)
254
258
  array(p.enumValues, "enumValues").forEach((v) => wellFormedString(v, "enumValue"));
259
+ if (p.producedBy !== undefined)
260
+ validateProducedBy(p.producedBy);
261
+ if (p.evidenceMatch !== undefined)
262
+ validateEvidenceMatch(p.evidenceMatch);
255
263
  return cloneJson(p);
256
264
  }
257
265
  function validateOccurrence(input, start, end) { const o = obj(input, "occurrence"); exact(o, ["resolverVersion", "count", "selected", "selection", "hintUsed", "ambiguous"], "occurrence"); if (o.resolverVersion !== "exact-occurrence-v1")
@@ -317,8 +325,18 @@ else
317
325
  throw new Error("partial requires partial outcome."); }
318
326
  function validateWarning(v) { const w = obj(v, "warning"); exact(w, ["category", "code"], "warning"); if (!WARNING_CATEGORIES.has(w.category))
319
327
  throw new Error("warning category invalid."); stableIdentity(w.code, "warning.code"); }
320
- function validateFailure(v) { const f = obj(v, "providerFailure"); exact(f, ["provider", "kind", "retryable"], "providerFailure"); stableIdentity(f.provider, "failure.provider"); if (!FAILURE_KINDS.has(f.kind) || typeof f.retryable !== "boolean")
321
- throw new Error("provider failure invalid."); }
328
+ function validateFailure(v) { const f = obj(v, "providerFailure"); exact(f, ["provider", "kind", "retryable"], "providerFailure", ["code"]); stableIdentity(f.provider, "failure.provider"); if (!FAILURE_KINDS.has(f.kind) || typeof f.retryable !== "boolean")
329
+ throw new Error("provider failure invalid."); if (f.code !== undefined) {
330
+ stableIdentity(f.code, "failure.code");
331
+ if (f.code.length > 128)
332
+ throw new Error("failure.code must be at most 128 characters.");
333
+ } }
334
+ function validateProducedBy(v) { const b = obj(v, "proposal.producedBy"); exact(b, ["model", "modelSource", "requestDigest"], "proposal.producedBy"); stableIdentity(b.model, "proposal.producedBy.model"); if (!MODEL_SOURCES.has(b.modelSource))
335
+ throw new Error("proposal.producedBy.modelSource is invalid."); digest(b.requestDigest, "proposal.producedBy.requestDigest"); }
336
+ function validateEvidenceMatch(v) { const m = obj(v, "proposal.evidenceMatch"); exact(m, ["checkerVersion", "schema", "valueInExcerpt"], "proposal.evidenceMatch", ["tokenBoundary"]); stableIdentity(m.checkerVersion, "proposal.evidenceMatch.checkerVersion"); if (!SCHEMA_MATCHES.has(m.schema))
337
+ throw new Error("proposal.evidenceMatch.schema is invalid."); if (!VALUE_IN_EXCERPT_MATCHES.has(m.valueInExcerpt))
338
+ throw new Error("proposal.evidenceMatch.valueInExcerpt is invalid."); if (m.tokenBoundary !== undefined && typeof m.tokenBoundary !== "boolean")
339
+ throw new Error("proposal.evidenceMatch.tokenBoundary must be a boolean."); }
322
340
  function validateClaimTarget(v) { const t = obj(v, "claimTarget"); exact(t, ["subjectType", "subjectId", "facet", "claimType", "fieldOrBehavior", "impactLevel"], "claimTarget", ["claimId", "evidenceType", "evidenceMethod", "collectedBy", "derivedFrom"]); for (const key of ["subjectType", "subjectId", "facet", "claimType", "fieldOrBehavior"])
323
341
  nonEmpty(t[key], `claimTarget.${key}`); if (!["low", "medium", "high", "critical"].includes(t.impactLevel))
324
342
  throw new Error("claimTarget.impactLevel invalid."); for (const key of ["claimId", "evidenceType", "evidenceMethod", "collectedBy"])
@@ -433,6 +451,9 @@ const PARTIAL = new Set(["cancelled", "max-provider-calls", "max-total-tokens",
433
451
  const FAILURE_CATEGORIES = new Set(["invalid-config", "invalid-task", "preparation", "provider", "unexpected"]);
434
452
  const WARNING_CATEGORIES = new Set(["provider", "normalization", "preparation", "limit", "storage", "content", "other"]);
435
453
  const FAILURE_KINDS = new Set(["authentication", "rate-limit", "timeout", "invalid-request", "unavailable", "unknown"]);
454
+ const MODEL_SOURCES = new Set(["provider-reported", "configured"]);
455
+ const SCHEMA_MATCHES = new Set(["ok", "type-mismatch", "enum-mismatch", "format-invalid"]);
456
+ const VALUE_IN_EXCERPT_MATCHES = new Set(["match", "mismatch", "not-evaluated", "not-applicable"]);
436
457
  const PREPARATION_MODES = new Set(["text", "markdown", "transcript", "pdf-text", "image-ocr"]);
437
458
  const ARTIFACT_INVALID_REASONS = new Set(["not-an-object", "invalid-format", "invalid-version", "invalid-digest", "invalid-ref", "invalid-preparation-mode", "invalid-preparation-version", "invalid-content-length", "invalid-source-snapshot-ref", "ill-formed-unicode", "invalid-resolved-text"]);
438
459
  const STABLE_IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:@/+~-]{0,255}$/;
@@ -1,4 +1,4 @@
1
- export type { CandidateSetStatus, Candidate, CandidateSet, ClaimTarget, EscalationDimension, EscalationRecord, Extraction, Interpretation, LocatorScheme, ProvenanceResolution, RawSource, RawSourceKind, ReviewAuthorizing, ReviewAuthorizingAuthorizedAction, ReviewAuthorizingExchange, ReviewAuthorizingExplicitStatement, ReviewAuthorizingKind, ReviewOutcome, ReviewResolution, ReviewStatus, SurveyInput, } from "./types.js";
1
+ export type { CandidateSetStatus, Candidate, CandidateSet, ClaimTarget, EscalationDimension, EscalationRecord, Extraction, Interpretation, InterpretationAnswerImpact, InterpretationReadingKind, LocatorScheme, ProvenanceResolution, RawSource, RawSourceKind, ReviewAuthorizing, ReviewAuthorizingAuthorizedAction, ReviewAuthorizingExchange, ReviewAuthorizingExplicitStatement, ReviewAuthorizingKind, ReviewOutcome, ReviewResolution, ReviewStatus, SurveyInput, } from "./types.js";
2
2
  export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
3
3
  export { reviewResourceApiVersion } from "./review-resource.js";
4
4
  export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
@@ -8,7 +8,7 @@ export type { BindReviewQueueOptions, ReviewQueueBinding, ReviewQueueBindingIssu
8
8
  export { resolvePortablePdfRegion } from "./pdf-layout.js";
9
9
  export type { PortablePdfBoundingBox, PortablePdfLayout, PortablePdfPageGeometry, PortablePdfRegionContext, PortablePdfTable, PortablePdfTableCell, PortablePdfTextElement, PortablePdfTextRange, } from "./pdf-layout.js";
10
10
  export type { ExtractionAlignmentState, ArtifactUnavailableCode, BuiltExtractionInspectorCandidate, BuiltExtractionInspectorModel, ExtractionInspectorCandidate, ExtractionInspectorEntry, ExtractionInspectorExportOptions, ExtractionInspectorFilters, ExtractionInspectorInput, ExtractionInspectorModel, ExtractionInspectorSource, ResolvedExtractionArtifact, } from "./review-workbench/extraction-inspector.js";
11
- export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, ExtractionEnvelopeImportResult, ExtractionEnvelopeResolutionIdentity, PortableExtractionOccurrence, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
11
+ export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, ExtractionEnvelopeImportResult, ExtractionEnvelopeResolutionIdentity, PortableExtractionEvidenceMatch, PortableExtractionOccurrence, PortableExtractionProducedBy, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
12
12
  export type { CandidateRole, ClaimTargetHint, ProducerPolicy, ExtractionReference, ResourceEnvelope, ResourceMetadata, ReviewActor, ReviewCandidate, ReviewDecision, ReviewDecisionMode, ReviewDecisionSpec, ReviewDecisionStatus, ReviewItem, ReviewItemSpec, ReviewItemStatus, ReviewLocator, ReviewResource, ReviewResourceApiVersion, ReviewResourceKind, ReviewSession, ReviewSessionEvent, ReviewSessionEventSpec, ReviewSessionEventStatus, ReviewSessionEventType, ReviewSessionSpec, ReviewSessionStatus, ReviewValueDescriptor, ReviewValueType, SourceReference, SurveyRecordProjectionHint, } from "./review-resource.js";
13
13
  export { toSurfaceReviewedExtractionDecision, toSurfaceReviewedExtractionImport, toSurfaceReviewedExtractionItem, } from "./surface-reviewed-extraction.js";
14
14
  export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
@@ -17,7 +17,7 @@ export { reviewedCandidateResolution } from "./reviewed-candidate-resolution.js"
17
17
  export type { ReviewedCandidateResolutionInput } from "./reviewed-candidate-resolution.js";
18
18
  export { reviewedCurrentProposedResolution } from "./reviewed-current-proposed-resolution.js";
19
19
  export type { CurrentProposedCandidateRole, ReviewedCurrentProposedResolutionInput, } from "./reviewed-current-proposed-resolution.js";
20
- export { buildSurveyTrustBundle } from "./to-surface.js";
20
+ export { buildSurveyTrustBundle, ReviewAgreementError } from "./to-surface.js";
21
21
  export type { BuildSurveyTrustBundleOptions } from "./to-surface.js";
22
22
  export { buildCanonicalReviewedTrustInput } from "./canonical-reviewed-trust-input.js";
23
23
  export type { BuildCanonicalReviewedTrustInputOptions, CanonicalReviewedTrustInput, } from "./canonical-reviewed-trust-input.js";
@@ -35,16 +35,17 @@ export { repeatedObservation } from "./repeated-observation.js";
35
35
  export type { RepeatedObservationInput } from "./repeated-observation.js";
36
36
  export { sourceOfAuthorityObservation, sourceOfAuthorityObservationBuilder, SourceOfAuthorityObservationBuilder, } from "./source-of-authority-observation.js";
37
37
  export type { SourceAuthorityClass, SourceAuthorityMetadata, SourceOfAuthorityObservationBuilderArgs, SourceOfAuthorityObservationInput, } from "./source-of-authority-observation.js";
38
- export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-workbench/review-presentation.js";
39
- export type { ReviewCandidatePresentation, ReviewCandidatePresentationContext, ReviewItemPresentation, ReviewItemPresentationContext, ReviewPresentationAdapter, ReviewPresentationLink, ReviewResultPresentation, ReviewTracePresentationContext, ReviewTraceRef, ReviewValuePresentationContext, } from "./review-workbench/review-presentation.js";
38
+ export { buildInterpretationReadingPresentation, buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-workbench/review-presentation.js";
39
+ export type { InterpretationReadingPresentation, InterpretationReadingSource, ReviewCandidatePresentation, ReviewCandidatePresentationContext, ReviewItemPresentation, ReviewItemPresentationContext, ReviewPresentationAdapter, ReviewPresentationLink, ReviewResultPresentation, ReviewTracePresentationContext, ReviewTraceRef, ReviewValuePresentationContext, } from "./review-workbench/review-presentation.js";
40
40
  export { apiRecordSource, manualEntrySource, policyStandardSource, uploadedDocumentSource, webPageSource, } from "./raw-source.js";
41
41
  export type { ApiRecordSourceInput, ChecksumInput, ManualEntrySourceInput, PolicyStandardMetadata, PolicyStandardSourceInput, RawSourceInput, UploadedDocumentSourceInput, WebPageSourceInput, } from "./raw-source.js";
42
42
  export { applyAutoAcceptPolicy, applyMappingReview, buildMappingReviewItems, lookupMapping, lookupRejectedMapping, normalizeQuestion, proposalsToCandidateSet, referenceMappingProposer, resolveQuestion, } from "./inquiry-mapping.js";
43
- export type { AutoAcceptPolicy, InquiryMapping, MappingProposal, MappingProposer, } from "./inquiry-mapping.js";
43
+ export type { AutoAcceptWarning } from "./producer-profile.js";
44
+ export type { AutoAcceptPolicy, AutoAcceptPolicyOptions, InquiryMapping, MappingProposal, MappingProposer, } from "./inquiry-mapping.js";
44
45
  export { referenceUtteranceExtractor, surveyAgentUtterance, utteranceToSurveyInput, } from "./agent-utterance.js";
45
- export type { ExtractedStatement, StatementBadge, UtteranceClaimExtractor, UtteranceStatement, UtteranceStatementRecords, UtteranceTrustReport, } from "./agent-utterance.js";
46
+ export type { ExtractedStatement, LocatorResolution, StatementBadge, StatementValueComparison, UtteranceClaimExtractor, UtteranceStatement, UtteranceStatementRecords, UtteranceTrustReport, } from "./agent-utterance.js";
46
47
  export { mappingReviewToSurface, referenceSchemaExtractor, surveySchemaMapping, } from "./schema-mapping.js";
47
- export type { MappingProposalRecord, ReviewedMapping, SchemaMappingExtractor, SchemaMappingOptions, SystemFieldRef, } from "./schema-mapping.js";
48
+ export type { MappingProposalRecord, ReviewedMapping, SchemaMappingExtractor, SchemaMappingOptions, SchemaMappingValue, SystemFieldRef, } from "./schema-mapping.js";
48
49
  export { buildAuthorizedActionAuthorizing, buildPromptRef, isValidAuthorizing, validateAuthorizing } from "./review-authorizing.js";
49
50
  export type { BuildAuthorizedActionAuthorizingInput, BuildPromptRefInput, ReviewAuthorizingIssue, ReviewAuthorizingIssueCode, } from "./review-authorizing.js";
50
51
  export { confidenceBasisForReview, defineProductVocabulary, stableId } from "./vocabulary.js";
package/dist/src/index.js CHANGED
@@ -8,7 +8,7 @@ export { toSurfaceReviewedExtractionDecision, toSurfaceReviewedExtractionImport,
8
8
  export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
9
9
  export { reviewedCandidateResolution } from "./reviewed-candidate-resolution.js";
10
10
  export { reviewedCurrentProposedResolution } from "./reviewed-current-proposed-resolution.js";
11
- export { buildSurveyTrustBundle } from "./to-surface.js";
11
+ export { buildSurveyTrustBundle, ReviewAgreementError } from "./to-surface.js";
12
12
  export { buildCanonicalReviewedTrustInput } from "./canonical-reviewed-trust-input.js";
13
13
  export { buildSurveyLearningProjections } from "./learning-projections.js";
14
14
  export { buildReviewedLearningUpdateProposal } from "./learning-update-proposal.js";
@@ -17,7 +17,7 @@ export { buildCanonicalReviewProofPayload, buildReviewProofAnchor, canonicalRevi
17
17
  export { fieldObservation } from "./field-observation.js";
18
18
  export { repeatedObservation } from "./repeated-observation.js";
19
19
  export { sourceOfAuthorityObservation, sourceOfAuthorityObservationBuilder, SourceOfAuthorityObservationBuilder, } from "./source-of-authority-observation.js";
20
- export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-workbench/review-presentation.js";
20
+ export { buildInterpretationReadingPresentation, buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-workbench/review-presentation.js";
21
21
  export { apiRecordSource, manualEntrySource, policyStandardSource, uploadedDocumentSource, webPageSource, } from "./raw-source.js";
22
22
  export { applyAutoAcceptPolicy, applyMappingReview, buildMappingReviewItems, lookupMapping, lookupRejectedMapping, normalizeQuestion, proposalsToCandidateSet, referenceMappingProposer, resolveQuestion, } from "./inquiry-mapping.js";
23
23
  export { referenceUtteranceExtractor, surveyAgentUtterance, utteranceToSurveyInput, } from "./agent-utterance.js";
@@ -20,6 +20,7 @@
20
20
  import type { DerivationRule, InquiryRecord, TrustBundle } from "@kontourai/surface";
21
21
  import type { CanonicalClaimTarget } from "@kontourai/surface";
22
22
  import type { Candidate, CandidateSet, ReviewOutcome } from "./types.js";
23
+ import type { AutoAcceptWarning } from "./producer-profile.js";
23
24
  import type { ReviewItem } from "./review-resource.js";
24
25
  /**
25
26
  * A single machine- or human-generated suggestion that a natural-language
@@ -140,18 +141,31 @@ export declare function applyMappingReview(candidateSet: CandidateSet, reviewOut
140
141
  export interface AutoAcceptPolicy {
141
142
  minConfidence: number;
142
143
  }
144
+ /** Optional hooks for {@link applyAutoAcceptPolicy}. */
145
+ export interface AutoAcceptPolicyOptions {
146
+ /**
147
+ * Called once for each proposal the policy refused because its confidence
148
+ * is not a finite number in [0, 1]. The proposal gets no mapping and stays
149
+ * in human review.
150
+ */
151
+ onWarning?: (warning: AutoAcceptWarning) => void;
152
+ }
143
153
  /**
144
154
  * Apply an auto-accept policy to a list of proposals, returning InquiryMappings.
145
155
  *
146
156
  * Proposals at or above minConfidence → status "assumed", withinComfortZone: true
147
157
  * Proposals below minConfidence → return a "needs-review" mapping (not yet durable)
158
+ * Proposals whose confidence is not a finite number in [0, 1] are never
159
+ * auto-accepted. Throws `RangeError` unless `policy.minConfidence` is a finite
160
+ * number in (0, 1]. Refused out-of-range proposals are reported through
161
+ * `options.onWarning`.
148
162
  *
149
163
  * Only non-conflicting proposals are auto-accepted. If proposals disagree, they
150
164
  * need human review regardless of confidence.
151
165
  *
152
166
  * Returns an array of InquiryMappings (only for accepted proposals).
153
167
  */
154
- export declare function applyAutoAcceptPolicy(proposals: MappingProposal[], policy: AutoAcceptPolicy): InquiryMapping[];
168
+ export declare function applyAutoAcceptPolicy(proposals: MappingProposal[], policy: AutoAcceptPolicy, options?: AutoAcceptPolicyOptions): InquiryMapping[];
155
169
  /**
156
170
  * Look up an InquiryMapping for a question by exact normalized-text match.
157
171
  *
@@ -18,7 +18,7 @@
18
18
  * and lives in the flow-agents repo.
19
19
  */
20
20
  import { resolveInquiry } from "@kontourai/surface";
21
- import { evaluateAutoAccept, getProducerProposal, hasCandidateConflict, projectProposalsToCandidateSet, } from "./producer-profile.js";
21
+ import { assertValidAutoAcceptThreshold, evaluateAutoAccept, getProducerProposal, hasCandidateConflict, projectProposalsToCandidateSet, } from "./producer-profile.js";
22
22
  import { reviewResourceApiVersion } from "./review-resource.js";
23
23
  // ---------------------------------------------------------------------------
24
24
  // Question normalization
@@ -140,13 +140,18 @@ export function applyMappingReview(candidateSet, reviewOutcome) {
140
140
  *
141
141
  * Proposals at or above minConfidence → status "assumed", withinComfortZone: true
142
142
  * Proposals below minConfidence → return a "needs-review" mapping (not yet durable)
143
+ * Proposals whose confidence is not a finite number in [0, 1] are never
144
+ * auto-accepted. Throws `RangeError` unless `policy.minConfidence` is a finite
145
+ * number in (0, 1]. Refused out-of-range proposals are reported through
146
+ * `options.onWarning`.
143
147
  *
144
148
  * Only non-conflicting proposals are auto-accepted. If proposals disagree, they
145
149
  * need human review regardless of confidence.
146
150
  *
147
151
  * Returns an array of InquiryMappings (only for accepted proposals).
148
152
  */
149
- export function applyAutoAcceptPolicy(proposals, policy) {
153
+ export function applyAutoAcceptPolicy(proposals, policy, options = {}) {
154
+ assertValidAutoAcceptThreshold(policy.minConfidence);
150
155
  if (proposals.length === 0)
151
156
  return [];
152
157
  // If proposals disagree, none can be auto-accepted
@@ -154,6 +159,9 @@ export function applyAutoAcceptPolicy(proposals, policy) {
154
159
  return [];
155
160
  return proposals.flatMap((proposal) => {
156
161
  const decision = evaluateAutoAccept({ confidence: proposal.confidence, rationale: proposal.rationale, proposedAt: proposal.proposedAt }, false, policy, proposal.proposedAt);
162
+ if (decision.warning) {
163
+ options.onWarning?.({ code: decision.warning, proposalId: proposal.id, confidence: decision.confidence });
164
+ }
157
165
  if (!decision.accepted)
158
166
  return [];
159
167
  return [