@kontourai/survey 5.0.0 → 6.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.
@@ -35,12 +35,12 @@ export declare const publicDirectoryReviewItemExample: {
35
35
  excerpt: string;
36
36
  };
37
37
  extraction: {
38
- model?: undefined;
39
38
  extractionId: string;
40
39
  target: string;
41
40
  confidence: number;
42
41
  extractor: string;
43
42
  extractedAt: string;
43
+ model?: undefined;
44
44
  };
45
45
  claimTarget: {
46
46
  claimId: string;
@@ -61,13 +61,13 @@ export declare const publicDirectoryReviewItemExample: {
61
61
  claimId: string;
62
62
  };
63
63
  producer: {
64
- proposalId?: undefined;
65
- oldValue?: undefined;
66
64
  sourceAuthority: {
67
65
  authorityClass: string;
68
66
  declaredBy: string;
69
67
  scope: string;
70
68
  };
69
+ proposalId?: undefined;
70
+ oldValue?: undefined;
71
71
  };
72
72
  } | {
73
73
  id: string;
@@ -168,7 +168,39 @@ export interface ExtractionEnvelopeImportOptions {
168
168
  sourceKind: RawSource["kind"];
169
169
  /** Survey meaning is supplied at the boundary; it is not added to the upstream wire contract. */
170
170
  claimTarget: (proposal: PortableExtractionProposal, index: number) => ClaimTargetHint;
171
+ /**
172
+ * The prepared artifact as the caller resolved it (the same shape the source
173
+ * inspector takes). When supplied, the import checks the text against the
174
+ * envelope's prepared-artifact digest and length, and every proposal's span
175
+ * against its excerpt: a proposal whose span does not match gets no
176
+ * ReviewItem and an `excerpt-mismatch` diagnostic, and an artifact that does
177
+ * not verify makes the import `unresolved`. The text is not stored in the
178
+ * record. Without it the import records `status.provenance: "unverified"`.
179
+ */
180
+ artifact?: ResolvedExtractionArtifact;
171
181
  }
182
+ export type ArtifactUnavailableCode = "not-found" | "storage-error" | "access-denied" | "invalid-artifact" | "unknown";
183
+ /** A prepared artifact as a caller resolved it: its text and digest, or why it could not be read. */
184
+ export type ResolvedExtractionArtifact = {
185
+ status: "available";
186
+ text: string;
187
+ actualDigest: string;
188
+ } | {
189
+ status: "unavailable";
190
+ code: ArtifactUnavailableCode;
191
+ } | {
192
+ status: "digest-mismatch";
193
+ actualDigest: string;
194
+ };
195
+ /**
196
+ * Whether the import checked its proposals' excerpts against the prepared
197
+ * artifact text. `verified` means the supplied text matched the envelope's
198
+ * digest and length and every proposal that produced a ReviewItem matched its
199
+ * span; `unverified` means no text was supplied, or the text did not verify
200
+ * (a diagnostic then says why). Records written before this field existed have
201
+ * no `provenance` and are unverified.
202
+ */
203
+ export type ExtractionEnvelopeImportProvenance = "verified" | "unverified";
172
204
  /**
173
205
  * Why an import is `unresolved`: its prepared artifact did not resolve, or the
174
206
  * extraction itself produced nothing reviewable. `extraction-failed` is a
@@ -180,6 +212,8 @@ export type ExtractionEnvelopeImportDiagnostic = {
180
212
  kind: "artifact-unavailable";
181
213
  status: "unavailable" | "storage-error" | "identity-mismatch" | "invalid-artifact";
182
214
  artifactRef?: string;
215
+ /** The caller's resolver code, present when the artifact supplied at import could not be read. */
216
+ code?: ArtifactUnavailableCode;
183
217
  message: string;
184
218
  } | {
185
219
  kind: "digest-mismatch";
@@ -196,6 +230,13 @@ export type ExtractionEnvelopeImportDiagnostic = {
196
230
  kind: "extraction-incomplete";
197
231
  reason: PortableExtractionPartialReason;
198
232
  message: string;
233
+ }
234
+ /** The prepared text at this proposal's span is not its excerpt; the proposal produced no ReviewItem. */
235
+ | {
236
+ kind: "excerpt-mismatch";
237
+ proposalIndex: number;
238
+ locator: string;
239
+ message: string;
199
240
  };
200
241
  export interface ExtractionEnvelopeImport {
201
242
  apiVersion: typeof extractionEnvelopeImportApiVersion;
@@ -212,6 +253,7 @@ export interface ExtractionEnvelopeImport {
212
253
  status: {
213
254
  state: "grounded" | "unresolved";
214
255
  diagnostics: ExtractionEnvelopeImportDiagnostic[];
256
+ provenance?: ExtractionEnvelopeImportProvenance;
215
257
  };
216
258
  }
217
259
  export interface ExtractionEnvelopeImportResult {
@@ -242,5 +284,29 @@ export declare function buildReviewItemsFromExtractionEnvelopeImport(record: Ext
242
284
  export declare function exportExtractionEnvelopeImport(record: ExtractionEnvelopeImport): string;
243
285
  export declare function reimportExtractionEnvelope(serialized: string): ExtractionEnvelopeImport;
244
286
  export declare function createExtractionEnvelopeResolutionIdentity(record: ExtractionEnvelopeImport, proposalIndex: number): ExtractionEnvelopeResolutionIdentity;
287
+ /** Proposals the import excluded because their span did not match their excerpt. */
288
+ export declare function excerptMismatchProposalIndices(record: ExtractionEnvelopeImport): Set<number>;
289
+ /** Validate the caller-resolved artifact shape; resolver detail beyond the code is refused. */
290
+ export declare function assertResolvedExtractionArtifact(artifact: unknown): asserts artifact is ResolvedExtractionArtifact;
291
+ export type PreparedArtifactCheck = {
292
+ status: "aligned";
293
+ text: string;
294
+ } | {
295
+ status: "unavailable";
296
+ code: ArtifactUnavailableCode;
297
+ } | {
298
+ status: "digest-mismatch";
299
+ actualDigest: string;
300
+ } | {
301
+ status: "wrong-length";
302
+ };
303
+ /**
304
+ * Checks resolved text against the envelope's prepared-artifact identity: the
305
+ * caller's reported digest, the digest of the text itself, and the declared
306
+ * length. Shared by import-time verification and the source inspector.
307
+ */
308
+ export declare function checkPreparedArtifact(prepared: PortableExtractionResultEnvelope["result"]["preparedArtifact"], artifact: ResolvedExtractionArtifact): PreparedArtifactCheck;
309
+ /** Whether the verified prepared text at a proposal's `chars:` span is exactly its excerpt. */
310
+ export declare function proposalSpanMatches(text: string, proposal: PortableExtractionProposal): boolean;
245
311
  /** Validate and defensively clone a persisted or browser-delivered import. */
246
312
  export declare function validateExtractionEnvelopeImport(value: unknown): ExtractionEnvelopeImport;
@@ -26,13 +26,16 @@ export function importExtractionEnvelope(serialized, options) {
26
26
  const producerNamespace = options.producerNamespace ?? envelope.result.provider;
27
27
  nonEmpty(name, "Extraction envelope import name");
28
28
  nonEmpty(producerNamespace, "Extraction envelope producer namespace");
29
- const diagnostics = diagnosticsFor(envelope);
29
+ const verification = options.artifact === undefined
30
+ ? { provenance: "unverified", diagnostics: [] }
31
+ : verifyAgainstArtifact(envelope, options.artifact);
32
+ const diagnostics = [...diagnosticsFor(envelope), ...verification.diagnostics];
30
33
  const record = {
31
34
  apiVersion: extractionEnvelopeImportApiVersion,
32
35
  kind: "ExtractionEnvelopeImport",
33
36
  metadata: { name, producerNamespace },
34
37
  spec: { envelope, sourceKind: options.sourceKind, claimTargets: cloneJson(claimTargets) },
35
- status: { state: diagnostics.length ? "unresolved" : "grounded", diagnostics },
38
+ status: { state: stateFor(diagnostics, envelope.result.proposals.length), diagnostics, provenance: verification.provenance },
36
39
  };
37
40
  return { record, reviewItems: buildReviewItemsFromExtractionEnvelopeImport(record) };
38
41
  }
@@ -58,16 +61,23 @@ export function buildReviewItemsFromExtractionEnvelopeImport(record) {
58
61
  }
59
62
  function claimSlotGroups(record) {
60
63
  const groups = new Map();
61
- record.spec.envelope.result.proposals.forEach((proposal, index) => {
64
+ const excluded = excerptMismatchProposalIndices(record);
65
+ const slotOf = (proposal, index) => {
62
66
  const target = record.spec.claimTargets[index];
63
- const slot = {
67
+ return {
64
68
  subjectType: target.subjectType, subjectId: target.subjectId, facet: target.facet, claimType: target.claimType,
65
69
  fieldOrBehavior: target.fieldOrBehavior, claimId: target.claimId ?? null, pathIndices: proposal.pathIndices ?? null,
66
70
  };
71
+ };
72
+ record.spec.envelope.result.proposals.forEach((proposal, index) => {
73
+ if (excluded.has(index))
74
+ return;
75
+ const target = record.spec.claimTargets[index];
76
+ const slot = slotOf(proposal, index);
67
77
  const key = canonicalJson(slot);
68
78
  const group = groups.get(key);
69
79
  if (!group) {
70
- groups.set(key, { slot, members: [{ proposal, index }] });
80
+ groups.set(key, { slot, members: [{ proposal, index }], excluded: [] });
71
81
  return;
72
82
  }
73
83
  const first = group.members[0].index;
@@ -77,6 +87,14 @@ function claimSlotGroups(record) {
77
87
  }
78
88
  group.members.push({ proposal, index });
79
89
  });
90
+ // An excluded proposal is unverifiable, not disproven: the slot it would have
91
+ // joined keeps a record of it, so a rival value cannot vanish without trace.
92
+ // A slot whose every proposal was excluded has no item; the import
93
+ // diagnostics are then the only record, as for any excluded proposal.
94
+ record.spec.envelope.result.proposals.forEach((proposal, index) => {
95
+ if (excluded.has(index))
96
+ groups.get(canonicalJson(slotOf(proposal, index)))?.excluded.push({ proposal, index });
97
+ });
80
98
  return [...groups.values()];
81
99
  }
82
100
  export function exportExtractionEnvelopeImport(record) {
@@ -123,6 +141,13 @@ function buildReviewItem(record, group) {
123
141
  proposalIndices: group.members.map((member) => member.index),
124
142
  source: envelope.source,
125
143
  ...(envelope.result.preparedArtifact ? { preparedArtifact: envelope.result.preparedArtifact } : {}),
144
+ // Present only on verified imports, so an unverified import's items stay
145
+ // byte-identical to the ones earlier releases produced.
146
+ ...(record.status.provenance === "verified" ? { excerptVerification: "verified" } : {}),
147
+ ...(group.excluded.length ? { excludedProposals: group.excluded.map(({ proposal, index }) => ({
148
+ proposalIndex: index, value: proposal.candidateValue, locator: proposal.provenance.locator,
149
+ excerpt: proposal.provenance.excerpt, reason: "excerpt-mismatch",
150
+ })) } : {}),
126
151
  } } },
127
152
  spec: {
128
153
  target: lead.fieldPath, candidates,
@@ -207,6 +232,146 @@ function evidenceInputs(record, proposal) {
207
232
  extractedAt: record.spec.envelope.result.extractedAt,
208
233
  provenance: proposal.provenance };
209
234
  }
235
+ /** Proposals the import excluded because their span did not match their excerpt. */
236
+ export function excerptMismatchProposalIndices(record) {
237
+ return new Set(record.status.diagnostics.flatMap((diagnostic) => diagnostic.kind === "excerpt-mismatch" ? [diagnostic.proposalIndex] : []));
238
+ }
239
+ /**
240
+ * An import is grounded when nothing but excerpt mismatches was diagnosed and
241
+ * at least one proposal survived them. Every proposal excluded must not read as
242
+ * a complete run that found nothing.
243
+ */
244
+ function stateFor(diagnostics, proposalCount) {
245
+ const mismatches = diagnostics.filter((diagnostic) => diagnostic.kind === "excerpt-mismatch").length;
246
+ if (mismatches !== diagnostics.length)
247
+ return "unresolved";
248
+ return mismatches > 0 && mismatches === proposalCount ? "unresolved" : "grounded";
249
+ }
250
+ /** Validate the caller-resolved artifact shape; resolver detail beyond the code is refused. */
251
+ export function assertResolvedExtractionArtifact(artifact) {
252
+ if (!artifact || typeof artifact !== "object" || Array.isArray(artifact))
253
+ throw new Error("Invalid resolved extraction artifact.");
254
+ const a = artifact;
255
+ const keys = Object.keys(a).sort();
256
+ if (a.status === "available") {
257
+ if (keys.join(",") !== "actualDigest,status,text" || typeof a.text !== "string" || typeof a.actualDigest !== "string" || !/^[a-f0-9]{64}$/.test(a.actualDigest))
258
+ throw new Error("Invalid available extraction artifact.");
259
+ }
260
+ else if (a.status === "digest-mismatch") {
261
+ if (keys.join(",") !== "actualDigest,status" || typeof a.actualDigest !== "string" || !/^[a-f0-9]{64}$/.test(a.actualDigest))
262
+ throw new Error("Invalid digest-mismatch extraction artifact.");
263
+ }
264
+ else if (a.status === "unavailable") {
265
+ if (keys.join(",") !== "code,status" || !ARTIFACT_UNAVAILABLE_CODES.has(a.code))
266
+ throw new Error("Invalid unavailable extraction artifact.");
267
+ }
268
+ else
269
+ throw new Error("Invalid resolved extraction artifact status.");
270
+ }
271
+ /**
272
+ * Checks resolved text against the envelope's prepared-artifact identity: the
273
+ * caller's reported digest, the digest of the text itself, and the declared
274
+ * length. Shared by import-time verification and the source inspector.
275
+ */
276
+ export function checkPreparedArtifact(prepared, artifact) {
277
+ if (artifact.status === "unavailable")
278
+ return { status: "unavailable", code: artifact.code };
279
+ if (artifact.status === "digest-mismatch")
280
+ return { status: "digest-mismatch", actualDigest: artifact.actualDigest };
281
+ const textDigest = sha256Hex(artifact.text);
282
+ if (!prepared || artifact.actualDigest !== prepared.digest || textDigest !== artifact.actualDigest)
283
+ return { status: "digest-mismatch", actualDigest: textDigest };
284
+ if (artifact.text.length !== prepared.contentLength)
285
+ return { status: "wrong-length" };
286
+ return { status: "aligned", text: artifact.text };
287
+ }
288
+ /** Whether the verified prepared text at a proposal's `chars:` span is exactly its excerpt. */
289
+ export function proposalSpanMatches(text, proposal) {
290
+ const match = /^chars:(\d+)-(\d+)$/.exec(proposal.provenance.locator);
291
+ if (!match)
292
+ return false;
293
+ return text.slice(Number(match[1]), Number(match[2])) === proposal.provenance.excerpt;
294
+ }
295
+ function verifyAgainstArtifact(envelope, artifact) {
296
+ assertResolvedExtractionArtifact(artifact);
297
+ const prepared = envelope.result.preparedArtifact;
298
+ if (!prepared)
299
+ throw new Error("The artifact option requires result.preparedArtifact to verify against.");
300
+ const check = checkPreparedArtifact(prepared, artifact);
301
+ if (check.status === "unavailable")
302
+ return { provenance: "unverified", diagnostics: [unreadableArtifactDiagnostic(prepared.ref, check.code)] };
303
+ if (check.status === "wrong-length")
304
+ return { provenance: "unverified", diagnostics: [wrongLengthDiagnostic(prepared.ref)] };
305
+ if (check.status === "digest-mismatch")
306
+ return { provenance: "unverified", diagnostics: [suppliedDigestMismatchDiagnostic(prepared, check.actualDigest)] };
307
+ const diagnostics = [];
308
+ envelope.result.proposals.forEach((proposal, index) => {
309
+ if (!proposalSpanMatches(check.text, proposal))
310
+ diagnostics.push(excerptMismatchDiagnostic(index, proposal));
311
+ });
312
+ return { provenance: "verified", diagnostics };
313
+ }
314
+ function unreadableArtifactDiagnostic(artifactRef, code) {
315
+ const status = code === "storage-error" ? "storage-error" : code === "invalid-artifact" ? "invalid-artifact" : "unavailable";
316
+ return { kind: "artifact-unavailable", status, artifactRef, code, message: `Prepared artifact could not be read to verify excerpts (${code}); no candidate is grounded.` };
317
+ }
318
+ function wrongLengthDiagnostic(artifactRef) {
319
+ return { kind: "artifact-unavailable", status: "invalid-artifact", artifactRef, message: "Prepared artifact text does not have the declared content length; no candidate is grounded." };
320
+ }
321
+ function suppliedDigestMismatchDiagnostic(prepared, actualDigest) {
322
+ return { kind: "digest-mismatch", artifactRef: prepared.ref, expectedDigest: prepared.digest, actualDigest,
323
+ message: "Prepared artifact text supplied at import does not match the extraction artifact digest; no candidate is grounded." };
324
+ }
325
+ function excerptMismatchDiagnostic(index, proposal) {
326
+ return { kind: "excerpt-mismatch", proposalIndex: index, locator: proposal.provenance.locator,
327
+ message: `Proposal ${index} (${proposal.fieldPath}): the prepared text at ${proposal.provenance.locator} is not its excerpt, so it produced no review item.` };
328
+ }
329
+ /**
330
+ * The import-time verification diagnostics a record may carry after the
331
+ * envelope-derived ones. They cannot be recomputed without the artifact text,
332
+ * so a reloaded record is checked for being one the import could have
333
+ * written: each diagnostic is rebuilt from its own fields and must match.
334
+ */
335
+ function verificationDiagnosticsAreCoherent(envelope, provenance, diagnostics) {
336
+ if (provenance === undefined)
337
+ return diagnostics.length === 0;
338
+ const prepared = envelope.result.preparedArtifact;
339
+ const rebuild = (value) => {
340
+ if (!value || typeof value !== "object" || Array.isArray(value) || !prepared)
341
+ return undefined;
342
+ const d = value;
343
+ if (provenance === "verified") {
344
+ const proposal = Number.isSafeInteger(d.proposalIndex) ? envelope.result.proposals[d.proposalIndex] : undefined;
345
+ return d.kind === "excerpt-mismatch" && proposal ? excerptMismatchDiagnostic(d.proposalIndex, proposal) : undefined;
346
+ }
347
+ if (d.kind === "artifact-unavailable") {
348
+ if (d.code === undefined)
349
+ return wrongLengthDiagnostic(prepared.ref);
350
+ return ARTIFACT_UNAVAILABLE_CODES.has(d.code) ? unreadableArtifactDiagnostic(prepared.ref, d.code) : undefined;
351
+ }
352
+ if (d.kind === "digest-mismatch" && typeof d.actualDigest === "string" && /^[a-f0-9]{64}$/.test(d.actualDigest))
353
+ return suppliedDigestMismatchDiagnostic(prepared, d.actualDigest);
354
+ return undefined;
355
+ };
356
+ if (provenance !== "verified" && provenance !== "unverified")
357
+ return false;
358
+ if (provenance === "verified" && !prepared)
359
+ return false;
360
+ if (provenance === "unverified" && diagnostics.length > 1)
361
+ return false;
362
+ let previous = -1;
363
+ for (const diagnostic of diagnostics) {
364
+ const expected = rebuild(diagnostic);
365
+ if (!expected || canonicalJson(expected) !== canonicalJson(diagnostic))
366
+ return false;
367
+ if (expected.kind === "excerpt-mismatch") {
368
+ if (expected.proposalIndex <= previous)
369
+ return false;
370
+ previous = expected.proposalIndex;
371
+ }
372
+ }
373
+ return true;
374
+ }
210
375
  function diagnosticsFor(envelope) {
211
376
  return [...artifactDiagnostics(envelope), ...outcomeDiagnostics(envelope)];
212
377
  }
@@ -257,9 +422,12 @@ function validateImport(value) {
257
422
  if (targets.length !== envelope.result.proposals.length)
258
423
  throw new Error("spec.claimTargets must align with proposals.");
259
424
  const status = obj(record.status, "status");
260
- exact(status, ["state", "diagnostics"], "status");
425
+ exact(status, ["state", "diagnostics"], "status", ["provenance"]);
426
+ const diagnostics = array(status.diagnostics, "status.diagnostics");
261
427
  const expected = diagnosticsFor(envelope);
262
- if (canonicalJson(status.diagnostics) !== canonicalJson(expected) || status.state !== (expected.length ? "unresolved" : "grounded"))
428
+ if (canonicalJson(diagnostics.slice(0, expected.length)) !== canonicalJson(expected)
429
+ || !verificationDiagnosticsAreCoherent(envelope, status.provenance, diagnostics.slice(expected.length))
430
+ || status.state !== stateFor(diagnostics, envelope.result.proposals.length))
263
431
  throw new Error("Import status does not match envelope state.");
264
432
  }
265
433
  /** Validate and defensively clone a persisted or browser-delivered import. */
@@ -592,6 +760,7 @@ const FAILURE_KINDS = new Set(["authentication", "rate-limit", "timeout", "inval
592
760
  const MODEL_SOURCES = new Set(["provider-reported", "configured"]);
593
761
  const SCHEMA_MATCHES = new Set(["ok", "type-mismatch", "enum-mismatch", "format-invalid"]);
594
762
  const VALUE_IN_EXCERPT_MATCHES = new Set(["match", "mismatch", "not-evaluated", "not-applicable"]);
763
+ const ARTIFACT_UNAVAILABLE_CODES = new Set(["not-found", "storage-error", "access-denied", "invalid-artifact", "unknown"]);
595
764
  const PREPARATION_MODES = new Set(["text", "markdown", "transcript", "pdf-text", "image-ocr"]);
596
765
  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"]);
597
766
  const STABLE_IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:@/+~-]{0,255}$/;
@@ -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, PortableExtractionCoverageEntry, PortableExtractionEvidenceMatch, PortableExtractionPartialReason, PortableExtractionOccurrence, PortableExtractionProducedBy, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
11
+ export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, ExtractionEnvelopeImportProvenance, ExtractionEnvelopeImportResult, ExtractionEnvelopeResolutionIdentity, PortableExtractionCoverageEntry, PortableExtractionEvidenceMatch, PortableExtractionPartialReason, 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";
@@ -5,6 +5,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
5
5
  import { z } from "zod";
6
6
  import { buildReviewSessionEvents, currentReviewItem, decisionSelectsNoCandidate, deriveQueueRowStatus, nextUnresolvedItemName, reviewSessionSummary, workbenchDecisionDefinitions, } from "../review-workbench/review-workbench.js";
7
7
  import { createServerReviewSessionRecord, currentSessionState, deriveServerReviewSessionApplyResult, } from "../review-workbench/server-review-session.js";
8
+ import { buildReviewItemPresentation, excludedProposalsSentence } from "../review-workbench/review-presentation.js";
8
9
  import { appendReviewSessionEvents, readReviewSessionFile, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
9
10
  const SESSION_NAME = "mcp-review-session";
10
11
  const UI_RESOURCE_URI_META_KEY = "ui/resourceUri";
@@ -78,12 +79,22 @@ function itemDetailText(item, snapshot, events) {
78
79
  ` source: ${candidate.source?.sourceRef ?? "none"}`,
79
80
  ...(candidate.locator?.excerpt ? [` excerpt: ${candidate.locator.excerpt}`] : []),
80
81
  ])),
82
+ ...extractionImportLines(item),
81
83
  ];
82
84
  if (item.spec.rationale) {
83
85
  lines.push(``, `Rationale: ${item.spec.rationale}`);
84
86
  }
85
87
  return lines.join("\n");
86
88
  }
89
+ /** What an envelope import says about the item's evidence, including rival values it excluded. */
90
+ function extractionImportLines(item) {
91
+ const presentation = buildReviewItemPresentation(item);
92
+ const excluded = excludedProposalsSentence(presentation.excludedProposals);
93
+ return [
94
+ ...(excluded ? [``, `Excluded: ${excluded} Check the source before accepting.`] : []),
95
+ ...(presentation.excerptVerification ? [``, `Excerpts ${presentation.excerptVerification === "verified" ? "were" : "were not"} checked against the prepared source text at import.`] : []),
96
+ ];
97
+ }
87
98
  // ---- UI card -------------------------------------------------------------
88
99
  function escapeJsonInHtml(value) {
89
100
  return JSON.stringify(value)
@@ -131,6 +142,7 @@ function buildReviewCardHtml(item, snapshot, events) {
131
142
  ? proposedCandidates.map((candidate, index) => proposedCard(candidate, `Proposed ${index + 1} of ${proposedCandidates.length}`)).join("\n ")
132
143
  : proposedCard(proposedCandidates[0], "Proposed");
133
144
  const itemNameJson = escapeJsonInHtml(item.metadata.name);
145
+ const excludedNote = excludedProposalsSentence(buildReviewItemPresentation(item).excludedProposals);
134
146
  const decisionBadge = decision
135
147
  ? `<span class="badge badge-${decision === "accept-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(workbenchDecisionDefinitions[decision].label)}</span>`
136
148
  : `<span class="badge badge-pending">${escapeHtml(status)}</span>`;
@@ -232,6 +244,7 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
232
244
  </div>
233
245
  ${proposedCards}
234
246
  </div>
247
+ ${excludedNote ? `<p class="feedback" id="excluded-note">${escapeHtml(excludedNote)}</p>` : ""}
235
248
  ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length} different values were proposed. This card cannot choose one of them yet: reject them all, or use Could not confirm with a reason.</p>` : ""}
236
249
 
237
250
  <div class="divider"></div>
@@ -339,6 +352,13 @@ async function toolItem(itemName, options) {
339
352
  decision: current.decisionsByItemName[item.metadata.name],
340
353
  note: current.notesByItemName[item.metadata.name],
341
354
  candidateSetStatus: item.spec.candidateSetStatus,
355
+ ...(() => {
356
+ const presentation = buildReviewItemPresentation(item);
357
+ return {
358
+ ...(presentation.excerptVerification ? { excerptVerification: presentation.excerptVerification } : {}),
359
+ ...(presentation.excludedProposals.length ? { excludedProposals: presentation.excludedProposals.map(({ proposalIndex, value, locator, excerpt }) => ({ proposalIndex, value, locator, excerpt })) } : {}),
360
+ };
361
+ })(),
342
362
  candidates: item.spec.candidates.map((c) => ({
343
363
  id: c.id,
344
364
  role: c.role,
@@ -25,7 +25,7 @@
25
25
  * it is a duplicate placement or a constant, but a key is never renamed or
26
26
  * repointed at a different fact. Hosts may hold selectors against these values.
27
27
  */
28
- export declare const reviewAuditRowKeys: readonly ["current-candidate-id", "proposed-candidate-id", "claim-id", "raw-source-id", "locator", "model", "extractor", "extracted-at", "history-value", "candidate-id", "source-reference", "excerpt", "observed", "source-authority-class", "declared-by", "authority-scope", "extraction-id", "actor", "reviewed-at", "status", "rationale", "outcome", "checksum", "candidate-set-id", "authority-trace-status", "authority-trace-detail"];
28
+ export declare const reviewAuditRowKeys: readonly ["current-candidate-id", "proposed-candidate-id", "claim-id", "raw-source-id", "locator", "model", "extractor", "extracted-at", "excluded-proposal", "history-value", "candidate-id", "source-reference", "excerpt", "observed", "source-authority-class", "declared-by", "authority-scope", "extraction-id", "actor", "reviewed-at", "status", "rationale", "outcome", "checksum", "candidate-set-id", "authority-trace-status", "authority-trace-detail"];
29
29
  /** One of {@link reviewAuditRowKeys}. */
30
30
  export type ReviewAuditRowKey = (typeof reviewAuditRowKeys)[number];
31
31
  /**
@@ -35,6 +35,7 @@ export const reviewAuditRowKeys = [
35
35
  "model",
36
36
  "extractor",
37
37
  "extracted-at",
38
+ "excluded-proposal",
38
39
  // Unselected candidate history
39
40
  "history-value",
40
41
  "candidate-id",
@@ -1,18 +1,7 @@
1
- import { type ExtractionEnvelopeImportDiagnostic, type ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
1
+ import { type ExtractionEnvelopeImportDiagnostic, type ExtractionEnvelopeImportResult, type ResolvedExtractionArtifact } from "../extraction-envelope.js";
2
2
  import { type PortablePdfRegionContext } from "../pdf-layout.js";
3
3
  export type ExtractionAlignmentState = "aligned" | "excerpt-mismatch" | "artifact-unavailable" | "digest-mismatch";
4
- export type ArtifactUnavailableCode = "not-found" | "storage-error" | "access-denied" | "invalid-artifact" | "unknown";
5
- export type ResolvedExtractionArtifact = {
6
- status: "available";
7
- text: string;
8
- actualDigest: string;
9
- } | {
10
- status: "unavailable";
11
- code: ArtifactUnavailableCode;
12
- } | {
13
- status: "digest-mismatch";
14
- actualDigest: string;
15
- };
4
+ export type { ArtifactUnavailableCode, ResolvedExtractionArtifact } from "../extraction-envelope.js";
16
5
  export interface ExtractionInspectorEntry {
17
6
  /** Result returned by importExtractionEnvelope; includes authoritative ReviewItem identities. */
18
7
  importResult: ExtractionEnvelopeImportResult;
@@ -97,6 +86,21 @@ export interface ExtractionInspectorSource {
97
86
  extractionDiagnostic?: Extract<ExtractionEnvelopeImportDiagnostic, {
98
87
  kind: "extraction-failed" | "extraction-incomplete";
99
88
  }>;
89
+ /**
90
+ * Whether the import checked excerpts against the prepared artifact
91
+ * (`status.provenance`; a record without it is `unverified`). Always set by
92
+ * {@link buildExtractionInspectorModel}; optional for hand-authored models.
93
+ */
94
+ importProvenance?: "verified" | "unverified";
95
+ /**
96
+ * Proposals the import left out because the prepared text at their span is
97
+ * not their excerpt. They have no candidate row; this is where they are shown.
98
+ */
99
+ excludedProposals?: Array<{
100
+ proposalIndex: number;
101
+ field: string;
102
+ locator: string;
103
+ }>;
100
104
  message: string;
101
105
  }
102
106
  export interface ExtractionInspectorModel {
@@ -159,7 +163,7 @@ export declare function buildExtractionInspectorModel(input: ExtractionInspector
159
163
  * stop when there is one, otherwise the artifact alignment. A failed
160
164
  * extraction over an aligned artifact must not show the aligned posture.
161
165
  */
162
- export declare function inspectorSourcePosture(source: Pick<ExtractionInspectorSource, "alignment" | "extractionDiagnostic">): ExtractionAlignmentState | "extraction-failed" | "extraction-incomplete";
166
+ export declare function inspectorSourcePosture(source: Pick<ExtractionInspectorSource, "alignment" | "extractionDiagnostic" | "excludedProposals">): ExtractionAlignmentState | "extraction-failed" | "extraction-incomplete" | "proposals-excluded";
163
167
  export declare function filterExtractionInspectorCandidates(model: ExtractionInspectorModel, filters: ExtractionInspectorFilters): ExtractionInspectorCandidate[];
164
168
  export declare function exportExtractionInspector(model: ExtractionInspectorModel, options?: ExtractionInspectorExportOptions): string;
165
169
  export declare function mountExtractionInspector(container: HTMLElement, model: ExtractionInspectorModel, options?: ExtractionInspectorMountOptions): () => void;
@@ -1,6 +1,5 @@
1
- import { buildReviewItemsFromExtractionEnvelopeImport, validateExtractionEnvelopeImport } from "../extraction-envelope.js";
1
+ import { assertResolvedExtractionArtifact, buildReviewItemsFromExtractionEnvelopeImport, checkPreparedArtifact, excerptMismatchProposalIndices, proposalSpanMatches, validateExtractionEnvelopeImport } from "../extraction-envelope.js";
2
2
  import { canonicalJson } from "./canonical.js";
3
- import { sha256Hex } from "../sha256.js";
4
3
  import { resolvePortablePdfRegion, } from "../pdf-layout.js";
5
4
  /**
6
5
  * Build a read-only view from one or more results already produced by Survey's
@@ -19,14 +18,14 @@ export function buildExtractionInspectorModel(input) {
19
18
  throw new Error("Invalid extraction import result.");
20
19
  const record = validateExtractionEnvelopeImport(entry.importResult.record);
21
20
  assertImportedResult(entry.importResult, record);
22
- assertResolvedArtifact(entry.artifact);
21
+ assertResolvedExtractionArtifact(entry.artifact);
23
22
  const envelope = record.spec.envelope;
24
23
  const prepared = envelope.result.preparedArtifact;
25
24
  const sourceKey = `${record.metadata.producerNamespace}:${record.metadata.name}:${entryIndex}`;
26
25
  if (sourceKeys.has(sourceKey))
27
26
  throw new Error("Extraction inspector source identity collision.");
28
27
  sourceKeys.add(sourceKey);
29
- const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.diagnostics, entry.artifact, envelope.result.ocrDerived);
28
+ const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.diagnostics, entry.artifact, envelope.result.ocrDerived, envelope.result.proposals, record.status.provenance ?? "unverified");
30
29
  sources.push(source);
31
30
  const candidateStart = candidates.length;
32
31
  const itemByProposalIndex = new Map();
@@ -118,25 +117,6 @@ function resolveHighlightElementIds(model) {
118
117
  }
119
118
  return resolved;
120
119
  }
121
- function assertResolvedArtifact(artifact) {
122
- if (!artifact || typeof artifact !== "object" || Array.isArray(artifact))
123
- throw new Error("Invalid resolved extraction artifact.");
124
- const keys = Object.keys(artifact).sort();
125
- if (artifact.status === "available") {
126
- if (keys.join(",") !== "actualDigest,status,text" || typeof artifact.text !== "string" || !/^[a-f0-9]{64}$/.test(artifact.actualDigest))
127
- throw new Error("Invalid available extraction artifact.");
128
- }
129
- else if (artifact.status === "digest-mismatch") {
130
- if (keys.join(",") !== "actualDigest,status" || !/^[a-f0-9]{64}$/.test(artifact.actualDigest))
131
- throw new Error("Invalid digest-mismatch extraction artifact.");
132
- }
133
- else if (artifact.status === "unavailable") {
134
- if (keys.join(",") !== "code,status" || !["not-found", "storage-error", "access-denied", "invalid-artifact", "unknown"].includes(artifact.code))
135
- throw new Error("Invalid unavailable extraction artifact.");
136
- }
137
- else
138
- throw new Error("Invalid resolved extraction artifact status.");
139
- }
140
120
  function assertImportedResult(result, record) {
141
121
  if (!result || typeof result !== "object" || !result.record || !Array.isArray(result.reviewItems))
142
122
  throw new Error("Invalid extraction import result.");
@@ -148,7 +128,9 @@ function assertImportedResult(result, record) {
148
128
  const grounded = record.status?.state === "grounded";
149
129
  const proposals = record.spec.envelope.result.proposals;
150
130
  const covered = reviewItems.flatMap(envelopeItemProposalIndices).sort((left, right) => left - right);
151
- if ((!grounded && reviewItems.length !== 0) || (grounded && canonicalJson(covered) !== canonicalJson(proposals.map((_proposal, index) => index))))
131
+ const excluded = excerptMismatchProposalIndices(record);
132
+ const expectedCovered = proposals.map((_proposal, index) => index).filter((index) => !excluded.has(index));
133
+ if ((!grounded && reviewItems.length !== 0) || (grounded && canonicalJson(covered) !== canonicalJson(expectedCovered)))
152
134
  throw new Error("Extraction import ReviewItems do not match its grounding state.");
153
135
  const canonicalItems = buildReviewItemsFromExtractionEnvelopeImport(record);
154
136
  if (canonicalJson(reviewItems) !== canonicalJson(canonicalItems))
@@ -183,39 +165,51 @@ function envelopeItemProposalIndices(item) {
183
165
  * extraction over an aligned artifact must not show the aligned posture.
184
166
  */
185
167
  export function inspectorSourcePosture(source) {
168
+ // Dropped proposals are a loss of evidence: they lead over an aligned
169
+ // artifact and over an extraction that merely stopped short.
170
+ if (source.alignment === "aligned" && source.excludedProposals?.length && source.extractionDiagnostic?.kind !== "extraction-failed")
171
+ return "proposals-excluded";
186
172
  if (source.extractionDiagnostic)
187
173
  return source.extractionDiagnostic.kind;
188
174
  return source.alignment;
189
175
  }
190
- function sourceModel(key, importName, prepared, diagnostics, artifact, ocrDerived) {
176
+ function sourceModel(key, importName, prepared, diagnostics, artifact, ocrDerived, proposals, importProvenance) {
191
177
  let alignment;
192
178
  let message;
193
179
  const artifactUnresolved = diagnostics.some((diagnostic) => diagnostic.kind === "artifact-unavailable" || diagnostic.kind === "digest-mismatch");
180
+ const excludedProposals = diagnostics.flatMap((diagnostic) => diagnostic.kind === "excerpt-mismatch"
181
+ ? [{ proposalIndex: diagnostic.proposalIndex, field: proposals[diagnostic.proposalIndex]?.fieldPath ?? "", locator: diagnostic.locator }]
182
+ : []);
194
183
  const extractionDiagnostic = diagnostics.find((diagnostic) => diagnostic.kind === "extraction-failed" || diagnostic.kind === "extraction-incomplete");
195
- if (artifactUnresolved || artifact.status === "unavailable") {
184
+ const check = checkPreparedArtifact(prepared, artifact);
185
+ if (artifactUnresolved || check.status === "unavailable") {
196
186
  alignment = "artifact-unavailable";
197
- message = `Prepared artifact unavailable (${artifact.status === "unavailable" ? artifact.code : "invalid-artifact"}). Candidates are not grounded.`;
187
+ message = `Prepared artifact unavailable (${check.status === "unavailable" ? check.code : "invalid-artifact"}). Candidates are not grounded.`;
198
188
  }
199
- else if (artifact.status === "digest-mismatch" || !prepared || artifact.actualDigest !== prepared.digest
200
- || sha256Hex(artifact.text) !== artifact.actualDigest) {
189
+ else if (check.status === "digest-mismatch") {
201
190
  alignment = "digest-mismatch";
202
191
  message = "Prepared artifact digest does not match the extraction artifact. Candidates are not grounded.";
203
192
  }
204
- else if (artifact.text.length !== prepared.contentLength) {
193
+ else if (check.status === "wrong-length") {
205
194
  alignment = "artifact-unavailable";
206
195
  message = "Prepared artifact content has the wrong length. Candidates are not grounded.";
207
196
  }
208
197
  else {
209
198
  alignment = "aligned";
210
- message = extractionDiagnostic
199
+ message = extractionDiagnostic || excludedProposals.length
211
200
  ? "Prepared artifact identity verified."
212
201
  : `Prepared artifact identity verified. Exact source spans are available.${ocrDerived ? " Prepared text is OCR-derived." : ""}`;
213
202
  }
203
+ if (excludedProposals.length) {
204
+ const named = excludedProposals.map((p) => `#${p.proposalIndex} ${p.field} (${p.locator})`).join(", ");
205
+ message = `${excludedProposals.length} proposal${excludedProposals.length === 1 ? " was" : "s were"} excluded at import because the prepared text at the span is not the excerpt: ${named}. ${message}`;
206
+ }
207
+ message = `${message} ${importProvenance === "verified" ? "Excerpts were checked against the prepared artifact at import." : "Excerpts were not checked against the prepared artifact at import."}`;
214
208
  // The extraction's own failure leads: an aligned artifact with no candidates
215
209
  // must not read as a complete run that found nothing.
216
210
  if (extractionDiagnostic)
217
211
  message = `${extractionDiagnostic.message} ${message}`;
218
- return { key, importName, ...(extractionDiagnostic ? { extractionDiagnostic } : {}), ...(prepared?.ref ? { artifactRef: prepared.ref } : {}), ...(prepared?.digest ? { expectedDigest: prepared.digest } : {}), ...("actualDigest" in artifact ? { actualDigest: artifact.actualDigest } : {}), ...(alignment === "aligned" && artifact.status === "available" ? { artifactText: artifact.text } : {}), ...(ocrDerived ? { ocrDerived: true } : {}), alignment, message };
212
+ return { key, importName, importProvenance, ...(excludedProposals.length ? { excludedProposals } : {}), ...(extractionDiagnostic ? { extractionDiagnostic } : {}), ...(prepared?.ref ? { artifactRef: prepared.ref } : {}), ...(prepared?.digest ? { expectedDigest: prepared.digest } : {}), ...("actualDigest" in artifact ? { actualDigest: artifact.actualDigest } : {}), ...(alignment === "aligned" && artifact.status === "available" ? { artifactText: artifact.text } : {}), ...(ocrDerived ? { ocrDerived: true } : {}), alignment, message };
219
213
  }
220
214
  function candidateModel(source, reviewItemName, proposal, index, provider, model, attempt, pass, pdfPageOffsets, pdfLayout, ocrDerived) {
221
215
  const match = /^chars:(\d+)-(\d+)$/.exec(proposal.provenance.locator);
@@ -224,10 +218,10 @@ function candidateModel(source, reviewItemName, proposal, index, provider, model
224
218
  const start = Number(match[1]), end = Number(match[2]);
225
219
  if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start)
226
220
  throw new Error(`Extraction proposal ${index} has an invalid text span.`);
227
- const alignment = source.alignment === "aligned" && source.artifactText.slice(start, end) !== proposal.provenance.excerpt ? "excerpt-mismatch" : source.alignment;
221
+ const alignment = source.alignment === "aligned" && !proposalSpanMatches(source.artifactText, proposal) ? "excerpt-mismatch" : source.alignment;
228
222
  if (alignment === "excerpt-mismatch") {
229
223
  source.alignment = alignment;
230
- source.message = "One or more source spans do not match their recorded excerpts. Affected candidates are not grounded.";
224
+ source.message = `One or more source spans do not match their recorded excerpts. Affected candidates are not grounded.${source.importProvenance === "unverified" ? " Excerpts were not checked against the prepared artifact at import." : ""}`;
231
225
  }
232
226
  let pdfRegion = pdfLayout ? resolvePortablePdfRegion(pdfLayout, proposal.provenance.locator) : undefined;
233
227
  const page = resolvePdfPage(pdfPageOffsets, start);
@@ -306,7 +300,7 @@ export function mountExtractionInspector(container, model, options = {}) {
306
300
  next.hidden = pageCount === 1;
307
301
  previous.disabled = page === 0;
308
302
  next.disabled = page >= pageCount - 1;
309
- postures.innerHTML = model.sources.map(s => { const posture = inspectorSourcePosture(s); return `<div class="inspector-posture ${s.alignment}${posture !== s.alignment ? ` ${posture}` : ""}" role="status" data-posture="${escapeHtml(posture)}"><strong>${escapeHtml(s.importName)}: ${escapeHtml(posture)}</strong><span>${escapeHtml(s.message)}</span></div>`; }).join("");
303
+ postures.innerHTML = model.sources.map(s => { const posture = inspectorSourcePosture(s); const classes = [...new Set([s.alignment, s.extractionDiagnostic?.kind, s.excludedProposals?.length ? "proposals-excluded" : undefined, posture].filter(Boolean))].join(" "); return `<div class="inspector-posture ${classes}" role="status" data-posture="${escapeHtml(posture)}"><strong>${escapeHtml(s.importName)}: ${escapeHtml(posture)}</strong><span>${escapeHtml(s.message)}</span></div>`; }).join("");
310
304
  sourcesRoot.innerHTML = model.sources.map(s => { const anchored = model.candidates.filter(c => c.sourceKey === s.key); const marked = visible.filter(c => c.sourceKey === s.key); return `<div class="inspector-source" aria-label="Prepared source for ${escapeHtml(s.importName)}"><h3>${escapeHtml(s.importName)}</h3><pre tabindex="0">${s.artifactText === undefined ? `${anchored.map(c => anchorHtml(c, highlightIdFor(c))).join("")}<span class="source-unavailable">${escapeHtml(s.message)}</span>` : renderSource(s.artifactText, anchored, marked, highlightIdFor)}</pre></div>`; }).join("");
311
305
  };
312
306
  root.querySelectorAll("select").forEach(select => select.addEventListener("change", event => { event.stopPropagation(); const key = select.dataset.filter; if (select.value)
@@ -50,6 +50,25 @@ export interface ReviewItemPresentation {
50
50
  readonly reviewItemLink?: ReviewPresentationLink;
51
51
  readonly traceRefs: readonly ReviewTraceRef[];
52
52
  readonly candidates: readonly ReviewCandidatePresentation[];
53
+ /**
54
+ * Proposals for this claim that an envelope import left out because the
55
+ * source text at their cited span is not their excerpt. Unverifiable, not
56
+ * disproven: every decision surface shows them. Empty for other items.
57
+ */
58
+ readonly excludedProposals: readonly ExcludedProposalPresentation[];
59
+ /**
60
+ * For an envelope-imported item, whether its import checked excerpts
61
+ * against the prepared artifact. Absent for other items, and for an item
62
+ * whose envelope binding is missing, which cannot claim either.
63
+ */
64
+ readonly excerptVerification?: "verified" | "unverified";
65
+ }
66
+ export interface ExcludedProposalPresentation {
67
+ readonly proposalIndex: number;
68
+ readonly value: unknown;
69
+ readonly valueText: string;
70
+ readonly locator: string;
71
+ readonly excerpt: string;
53
72
  }
54
73
  export interface ReviewResultPresentation {
55
74
  readonly result: ReviewWorkbenchResult;
@@ -106,6 +125,11 @@ export interface InterpretationReadingPresentation {
106
125
  * authored record under a label nothing derived.
107
126
  */
108
127
  export declare function buildInterpretationReadingPresentation(source: InterpretationReadingSource): InterpretationReadingPresentation;
128
+ /**
129
+ * One sentence naming every excluded proposal, for surfaces that speak in
130
+ * text (the MCP item, the recorded decision prompt). Undefined when none.
131
+ */
132
+ export declare function excludedProposalsSentence(excluded: readonly ExcludedProposalPresentation[]): string | undefined;
109
133
  export declare function buildReviewItemPresentation(item: ReviewItem, adapter?: ReviewPresentationAdapter): ReviewItemPresentation;
110
134
  export declare function buildReviewCandidatePresentation(item: ReviewItem, candidate: ReviewCandidate, adapter?: ReviewPresentationAdapter, targetLabel?: string): ReviewCandidatePresentation;
111
135
  export declare function buildReviewResultPresentation(result: ReviewWorkbenchResult, item: ReviewItem | undefined, adapter?: ReviewPresentationAdapter): ReviewResultPresentation;
@@ -49,6 +49,50 @@ export function buildInterpretationReadingPresentation(source) {
49
49
  provenanceLabel: "Authored judgment",
50
50
  };
51
51
  }
52
+ const EXTRACTION_ENVELOPE_PRODUCER = "survey.kontourai.io/extraction-envelope";
53
+ /**
54
+ * The envelope-import binding an item carries, or undefined when it has none.
55
+ * A binding names its import and proposals, and every candidate points back
56
+ * at the same import; anything less cannot vouch for what the import checked.
57
+ */
58
+ function extractionEnvelopeBinding(item) {
59
+ const metadata = item.metadata?.producer?.[EXTRACTION_ENVELOPE_PRODUCER];
60
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
61
+ return undefined;
62
+ const binding = metadata;
63
+ if (typeof binding.importName !== "string" || !binding.importName || !Array.isArray(binding.proposalIndices) || binding.proposalIndices.length === 0)
64
+ return undefined;
65
+ const bound = item.spec.candidates.length > 0 && item.spec.candidates.every((candidate) => {
66
+ const own = candidate.producer?.[EXTRACTION_ENVELOPE_PRODUCER];
67
+ return own?.importName === binding.importName;
68
+ });
69
+ return bound ? binding : undefined;
70
+ }
71
+ /** The well-formed `excludedProposals` entries of a bound envelope item; malformed entries are ignored, never rendered. */
72
+ function excludedProposalsOf(item, binding, adapter) {
73
+ if (!binding || !Array.isArray(binding.excludedProposals))
74
+ return [];
75
+ return binding.excludedProposals.flatMap((entry) => {
76
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
77
+ return [];
78
+ const e = entry;
79
+ if (!Number.isSafeInteger(e.proposalIndex) || typeof e.locator !== "string" || typeof e.excerpt !== "string" || !("value" in e))
80
+ return [];
81
+ const proposed = item.spec.candidates.find((candidate) => candidate.role === "proposed");
82
+ const valueText = (proposed ? adapter.summarizeValue?.(e.value, { item, candidate: proposed, value: e.value }) : undefined) ?? formatValue(e.value);
83
+ return [{ proposalIndex: e.proposalIndex, value: e.value, valueText, locator: e.locator, excerpt: e.excerpt }];
84
+ });
85
+ }
86
+ /**
87
+ * One sentence naming every excluded proposal, for surfaces that speak in
88
+ * text (the MCP item, the recorded decision prompt). Undefined when none.
89
+ */
90
+ export function excludedProposalsSentence(excluded) {
91
+ if (excluded.length === 0)
92
+ return undefined;
93
+ const named = excluded.map((entry) => `${entry.valueText} (proposal ${entry.proposalIndex}, ${entry.locator})`).join(", ");
94
+ return `${excluded.length === 1 ? "Another proposed value was" : `${excluded.length} other proposed values were`} excluded at import because the source text at the cited span is not the excerpt: ${named}. Unverifiable is not disproven.`;
95
+ }
52
96
  export function buildReviewItemPresentation(item, adapter = {}) {
53
97
  const context = { item };
54
98
  const targetLabel = adapter.labelForTarget?.(item.spec.target, context) ?? humanizeIdentifier(item.spec.target);
@@ -61,6 +105,14 @@ export function buildReviewItemPresentation(item, adapter = {}) {
61
105
  reviewItemLink: adapter.linkForReviewItem?.(item, context),
62
106
  traceRefs: traceRefsForReviewItem(item, adapter),
63
107
  candidates: item.spec.candidates.map((candidate) => buildReviewCandidatePresentation(item, candidate, adapter, targetLabel)),
108
+ ...extractionPresentation(item, adapter),
109
+ };
110
+ }
111
+ function extractionPresentation(item, adapter) {
112
+ const binding = extractionEnvelopeBinding(item);
113
+ return {
114
+ excludedProposals: excludedProposalsOf(item, binding, adapter),
115
+ ...(binding ? { excerptVerification: binding.excerptVerification === "verified" ? "verified" : "unverified" } : {}),
64
116
  };
65
117
  }
66
118
  export function buildReviewCandidatePresentation(item, candidate, adapter = {}, targetLabel = adapter.labelForTarget?.(item.spec.target, { item }) ?? humanizeIdentifier(item.spec.target)) {
@@ -246,9 +246,10 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
246
246
  .survey-workbench-embed .inspector-heading, .survey-workbench-embed .inspector-layout{ display: grid; grid-template-columns: minmax(15rem, .7fr) minmax(0, 1.3fr); gap: 1rem; }
247
247
  .survey-workbench-embed .inspector-heading h2{ margin: 0; }
248
248
  .survey-workbench-embed .inspector-posture{ display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
249
- .survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
250
- .survey-workbench-embed .inspector-posture.extraction-failed{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
249
+ /* Caution first: an artifact failure on the same source must win the tie and stay negative. */
251
250
  .survey-workbench-embed .inspector-posture.extraction-incomplete{ background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
251
+ .survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch, .survey-workbench-embed .inspector-posture.proposals-excluded{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
252
+ .survey-workbench-embed .inspector-posture.extraction-failed{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
252
253
  .survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
253
254
  .survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
254
255
  .survey-workbench-embed .inspector-filters select, .survey-workbench-embed .inspector-filters input{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
@@ -834,6 +835,12 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
834
835
  font-size: 12px;
835
836
  }
836
837
 
838
+ .survey-workbench-embed .excerpt-verification{
839
+ margin: 0;
840
+ font-size: 12px;
841
+ color: var(--k-muted);
842
+ }
843
+
837
844
  /* decisions */
838
845
 
839
846
  /* The decision row is the point of the card, so it sits on its own line,
@@ -243,9 +243,10 @@
243
243
  .survey-workbench-embed .inspector-heading, .survey-workbench-embed .inspector-layout{ display: grid; grid-template-columns: minmax(15rem, .7fr) minmax(0, 1.3fr); gap: 1rem; }
244
244
  .survey-workbench-embed .inspector-heading h2{ margin: 0; }
245
245
  .survey-workbench-embed .inspector-posture{ display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
246
- .survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
247
- .survey-workbench-embed .inspector-posture.extraction-failed{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
246
+ /* Caution first: an artifact failure on the same source must win the tie and stay negative. */
248
247
  .survey-workbench-embed .inspector-posture.extraction-incomplete{ background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
248
+ .survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch, .survey-workbench-embed .inspector-posture.proposals-excluded{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
249
+ .survey-workbench-embed .inspector-posture.extraction-failed{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
249
250
  .survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
250
251
  .survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
251
252
  .survey-workbench-embed .inspector-filters select, .survey-workbench-embed .inspector-filters input{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
@@ -831,6 +832,12 @@
831
832
  font-size: 12px;
832
833
  }
833
834
 
835
+ .survey-workbench-embed .excerpt-verification{
836
+ margin: 0;
837
+ font-size: 12px;
838
+ color: var(--k-muted);
839
+ }
840
+
834
841
  /* decisions */
835
842
 
836
843
  /* The decision row is the point of the card, so it sits on its own line,
@@ -6,7 +6,7 @@ import { buildSurfaceProjectionPreview, formatValue, } from "./review-surface-pr
6
6
  import { buildReviewCandidatePresentation, buildReviewItemPresentation, } from "./review-presentation.js";
7
7
  import { findSoleCandidateById, reviewResourceApiVersion, } from "../review-resource.js";
8
8
  import { validateAuthorizing, buildAuthorizedActionAuthorizing } from "../review-authorizing.js";
9
- import { humanizeIdentifier } from "./review-presentation.js";
9
+ import { excludedProposalsSentence, humanizeIdentifier } from "./review-presentation.js";
10
10
  import { editedValueFromEditorText, isIsoCalendarDate, parsePlainDecimal } from "./edited-value.js";
11
11
  import { createAuditFactTrace, } from "./audit-rows.js";
12
12
  export { reviewAuditRowKeys } from "./audit-rows.js";
@@ -129,6 +129,13 @@ function buildDecisionCardAuthorizing(state) {
129
129
  * followed by the selected decision label so the block is self-contained.
130
130
  */
131
131
  function decisionCardRenderedPrompt(state, targetLabel) {
132
+ // The card shows proposals excluded at import; the recorded prompt must say
133
+ // the reviewer was told about them.
134
+ const excluded = excludedProposalsSentence(buildReviewItemPresentation(state.item).excludedProposals);
135
+ const base = decisionCardBasePrompt(state, targetLabel);
136
+ return excluded ? `${base} ${excluded}` : base;
137
+ }
138
+ function decisionCardBasePrompt(state, targetLabel) {
132
139
  const currentCandidate = state.item.spec.candidates.find((c) => c.role === "current");
133
140
  const proposedCandidates = state.item.spec.candidates.filter((c) => c.role === "proposed");
134
141
  // A conflict card lists every proposed value and offers reject-all or could
@@ -693,6 +700,7 @@ function renderFieldCard(item, session, presentationAdapter) {
693
700
  ? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel)
694
701
  : "<p class=\"field-value\">No proposed value is available for this field.</p>"}
695
702
  ${proposed ? renderProvenanceRow(item, proposed, presentationAdapter) : ""}
703
+ ${renderExtractionImportNotes(presentation)}
696
704
  <div class="decide">
697
705
  ${keepDecision === undefined ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>`}
698
706
  ${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">Use proposed</button>
@@ -924,6 +932,35 @@ function renderProvenanceRow(item, proposed, presentationAdapter) {
924
932
  </div>
925
933
  `;
926
934
  }
935
+ /**
936
+ * What an envelope import says about this field's evidence: whether its
937
+ * excerpts were checked against the prepared source text, and any rival
938
+ * proposals the import left out because their excerpt was not at their span.
939
+ * Such a proposal is unverifiable, not disproven, so the reviewer sees it
940
+ * before accepting the value that remained. Empty for items from other
941
+ * producers.
942
+ */
943
+ function renderExtractionImportNotes(presentation) {
944
+ const { excerptVerification: verification, excludedProposals: excluded } = presentation;
945
+ if (verification === undefined && excluded.length === 0)
946
+ return "";
947
+ const excludedHtml = excluded.length === 0 ? "" : `
948
+ <div class="noprov" data-testid="excluded-proposals" role="note">
949
+ ${WARNING_SVG}
950
+ <span class="tag">${excluded.length} excluded</span>
951
+ <span>${excluded.length === 1 ? "Another proposal for this field was" : `${excluded.length} other proposals for this field were`} left out because the source text at the cited span is not the excerpt:
952
+ ${excluded.map((entry) => `<span data-testid="excluded-proposal" data-proposal-index="${escapeHtml(String(entry.proposalIndex))}"><q>${escapeHtml(entry.valueText)}</q> (proposal ${escapeHtml(String(entry.proposalIndex))}, ${escapeHtml(entry.locator)})</span>`).join(", ")}.
953
+ Unverifiable is not disproven: check the source before deciding.</span>
954
+ </div>`;
955
+ const verificationHtml = verification === undefined ? "" : `
956
+ <p class="excerpt-verification" data-testid="excerpt-verification" data-verified="${verification === "verified" ? "true" : "false"}">${verification === "verified"
957
+ ? "Excerpts checked against the prepared source text at import."
958
+ : "Excerpts not checked against the prepared source text at import."}</p>`;
959
+ return `
960
+ <div class="prov import-notes">${excludedHtml}${verificationHtml}
961
+ </div>
962
+ `;
963
+ }
927
964
  function renderConfidenceMeter(confidence) {
928
965
  const clamped = Math.max(0, Math.min(1, confidence));
929
966
  const pct = Math.round(clamped * 100);
@@ -980,6 +1017,7 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
980
1017
  ${proposed?.extraction.model ? fieldItem("model", "Model", proposed.extraction.model) : ""}
981
1018
  ${proposed ? placementItem(trace, { of: proposed.id, property: "extraction.extractor" }, "extractor", "Extractor", proposed.extraction.extractor ?? "unknown") : ""}
982
1019
  ${proposed ? fieldItem("extracted-at", "Extracted at", proposed.extraction.extractedAt ?? "unknown") : ""}
1020
+ ${buildReviewItemPresentation(item, presentationAdapter).excludedProposals.map((entry) => fieldItem("excluded-proposal", `Excluded proposal ${entry.proposalIndex}`, `${entry.valueText} at ${entry.locator}: excerpt not at the cited span`)).join("")}
983
1021
  </dl>
984
1022
  ${preview
985
1023
  ? `<div class="preview-section-grid">${renderSurfacePreviewSections(preview, trace)}</div><p class="preview-disclaimer preview-disclaimer-footer">${escapeHtml(preview.postureDisclaimer)}</p>`
@@ -38,9 +38,10 @@
38
38
  .inspector-heading, .inspector-layout { display: grid; grid-template-columns: minmax(15rem, .7fr) minmax(0, 1.3fr); gap: 1rem; }
39
39
  .inspector-heading h2 { margin: 0; }
40
40
  .inspector-posture { display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
41
- .inspector-posture.digest-mismatch, .inspector-posture.artifact-unavailable, .inspector-posture.excerpt-mismatch { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
42
- .inspector-posture.extraction-failed { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
41
+ /* Caution first: an artifact failure on the same source must win the tie and stay negative. */
43
42
  .inspector-posture.extraction-incomplete { background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
43
+ .inspector-posture.digest-mismatch, .inspector-posture.artifact-unavailable, .inspector-posture.excerpt-mismatch, .inspector-posture.proposals-excluded { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
44
+ .inspector-posture.extraction-failed { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
44
45
  .inspector-filters { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
45
46
  .inspector-filters label { display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
46
47
  .inspector-filters select, .inspector-filters input { color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
@@ -626,6 +627,12 @@ h1, h2, h3, p {
626
627
  font-size: 12px;
627
628
  }
628
629
 
630
+ .excerpt-verification {
631
+ margin: 0;
632
+ font-size: 12px;
633
+ color: var(--k-muted);
634
+ }
635
+
629
636
  /* decisions */
630
637
 
631
638
  /* The decision row is the point of the card, so it sits on its own line,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "5.0.0",
3
+ "version": "6.0.0",
4
4
  "description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",