@kontourai/survey 5.0.0 → 6.1.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.
@@ -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";
@@ -35,8 +35,10 @@ 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 { 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";
38
+ export { buildCandidateVerification, candidateVerificationKind, DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS, MAX_SUPPORT_VERIFIER_TIMEOUT_MS, foldCandidateVerifications, runSupportVerifier, supportVerificationInputDigest, validateCandidateVerification, valueDigest, } from "./candidate-verification.js";
39
+ export type { BuildCandidateVerificationInput, CandidateVerification, CandidateVerificationInapplicableReason, CandidateVerificationStatus, CandidateVerificationSubject, FoldCandidateVerificationsResult, RunSupportVerifierOptions, SupportAbstainReason, SupportEvidence, SupportVerdict, SupportVerificationInput, SupportVerificationMethod, SupportVerificationResult, SupportVerifier, } from "./candidate-verification.js";
40
+ export { buildInterpretationReadingPresentation, buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, candidateVerificationNotes, humanizeIdentifier, } from "./review-workbench/review-presentation.js";
41
+ export type { CandidateVerificationNote, CandidateVerificationRecordPresentation, InterpretationReadingPresentation, InterpretationReadingSource, ReviewCandidatePresentation, ReviewCandidatePresentationContext, ReviewItemPresentation, ReviewItemPresentationContext, ReviewPresentationAdapter, ReviewPresentationLink, ReviewResultPresentation, ReviewTracePresentationContext, ReviewTraceRef, ReviewValuePresentationContext, } from "./review-workbench/review-presentation.js";
40
42
  export { apiRecordSource, manualEntrySource, policyStandardSource, uploadedDocumentSource, webPageSource, } from "./raw-source.js";
41
43
  export type { ApiRecordSourceInput, ChecksumInput, ManualEntrySourceInput, PolicyStandardMetadata, PolicyStandardSourceInput, RawSourceInput, UploadedDocumentSourceInput, WebPageSourceInput, } from "./raw-source.js";
42
44
  export { applyAutoAcceptPolicy, applyMappingReview, buildMappingReviewItems, lookupMapping, lookupRejectedMapping, normalizeQuestion, proposalsToCandidateSet, referenceMappingProposer, resolveQuestion, } from "./inquiry-mapping.js";
package/dist/src/index.js CHANGED
@@ -17,7 +17,8 @@ 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 { buildInterpretationReadingPresentation, buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-workbench/review-presentation.js";
20
+ export { buildCandidateVerification, candidateVerificationKind, DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS, MAX_SUPPORT_VERIFIER_TIMEOUT_MS, foldCandidateVerifications, runSupportVerifier, supportVerificationInputDigest, validateCandidateVerification, valueDigest, } from "./candidate-verification.js";
21
+ export { buildInterpretationReadingPresentation, buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, candidateVerificationNotes, humanizeIdentifier, } from "./review-workbench/review-presentation.js";
21
22
  export { apiRecordSource, manualEntrySource, policyStandardSource, uploadedDocumentSource, webPageSource, } from "./raw-source.js";
22
23
  export { applyAutoAcceptPolicy, applyMappingReview, buildMappingReviewItems, lookupMapping, lookupRejectedMapping, normalizeQuestion, proposalsToCandidateSet, referenceMappingProposer, resolveQuestion, } from "./inquiry-mapping.js";
23
24
  export { referenceUtteranceExtractor, surveyAgentUtterance, utteranceToSurveyInput, } from "./agent-utterance.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, candidateVerificationNotes, 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,27 @@ 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),
83
+ ...candidateVerificationNotes(item, editedValueFor(item, current)).flatMap((entry) => [``, `Verification: ${entry.sentence}`]),
81
84
  ];
82
85
  if (item.spec.rationale) {
83
86
  lines.push(``, `Rationale: ${item.spec.rationale}`);
84
87
  }
85
88
  return lines.join("\n");
86
89
  }
90
+ /** The reviewer's edit when the item's decision accepts one; verifier records are read against it. */
91
+ function editedValueFor(item, state) {
92
+ return state.decisionsByItemName[item.metadata.name] === "accept-proposed" ? state.editedValuesByItemName?.[item.metadata.name] : undefined;
93
+ }
94
+ /** What an envelope import says about the item's evidence, including rival values it excluded. */
95
+ function extractionImportLines(item) {
96
+ const presentation = buildReviewItemPresentation(item);
97
+ const excluded = excludedProposalsSentence(presentation.excludedProposals);
98
+ return [
99
+ ...(excluded ? [``, `Excluded: ${excluded} Check the source before accepting.`] : []),
100
+ ...(presentation.excerptVerification ? [``, `Excerpts ${presentation.excerptVerification === "verified" ? "were" : "were not"} checked against the prepared source text at import.`] : []),
101
+ ];
102
+ }
87
103
  // ---- UI card -------------------------------------------------------------
88
104
  function escapeJsonInHtml(value) {
89
105
  return JSON.stringify(value)
@@ -131,6 +147,8 @@ function buildReviewCardHtml(item, snapshot, events) {
131
147
  ? proposedCandidates.map((candidate, index) => proposedCard(candidate, `Proposed ${index + 1} of ${proposedCandidates.length}`)).join("\n ")
132
148
  : proposedCard(proposedCandidates[0], "Proposed");
133
149
  const itemNameJson = escapeJsonInHtml(item.metadata.name);
150
+ const excludedNote = excludedProposalsSentence(buildReviewItemPresentation(item).excludedProposals);
151
+ const verificationNotes = candidateVerificationNotes(item, editedValueFor(item, current));
134
152
  const decisionBadge = decision
135
153
  ? `<span class="badge badge-${decision === "accept-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(workbenchDecisionDefinitions[decision].label)}</span>`
136
154
  : `<span class="badge badge-pending">${escapeHtml(status)}</span>`;
@@ -232,6 +250,8 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
232
250
  </div>
233
251
  ${proposedCards}
234
252
  </div>
253
+ ${excludedNote ? `<p class="feedback" id="excluded-note">${escapeHtml(excludedNote)}</p>` : ""}
254
+ ${verificationNotes.map((entry) => `<p class="feedback verification-note" data-candidate-id="${escapeHtml(entry.candidateId)}">${escapeHtml(entry.sentence)}</p>`).join("\n")}
235
255
  ${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
256
 
237
257
  <div class="divider"></div>
@@ -339,14 +359,28 @@ async function toolItem(itemName, options) {
339
359
  decision: current.decisionsByItemName[item.metadata.name],
340
360
  note: current.notesByItemName[item.metadata.name],
341
361
  candidateSetStatus: item.spec.candidateSetStatus,
342
- candidates: item.spec.candidates.map((c) => ({
343
- id: c.id,
344
- role: c.role,
345
- value: c.value,
346
- confidence: c.extraction?.confidence ?? c.confidence,
347
- sourceRef: c.source?.sourceRef,
348
- excerpt: c.locator?.excerpt,
349
- })),
362
+ ...(() => {
363
+ const presentation = buildReviewItemPresentation(item);
364
+ return {
365
+ ...(presentation.excerptVerification ? { excerptVerification: presentation.excerptVerification } : {}),
366
+ ...(presentation.excludedProposals.length ? { excludedProposals: presentation.excludedProposals.map(({ proposalIndex, value, locator, excerpt }) => ({ proposalIndex, value, locator, excerpt })) } : {}),
367
+ };
368
+ })(),
369
+ candidates: (() => {
370
+ const notes = candidateVerificationNotes(item, editedValueFor(item, current));
371
+ return item.spec.candidates.map((c, index) => {
372
+ const note = notes.find((entry) => entry.candidateIndex === index);
373
+ return {
374
+ id: c.id,
375
+ role: c.role,
376
+ value: c.value,
377
+ confidence: c.extraction?.confidence ?? c.confidence,
378
+ sourceRef: c.source?.sourceRef,
379
+ excerpt: c.locator?.excerpt,
380
+ ...(note ? { verification: { subject: note.subjectLabel, status: note.status, records: note.records, inapplicableCount: note.inapplicableCount, rejectedCount: note.rejectedCount } } : {}),
381
+ };
382
+ });
383
+ })(),
350
384
  };
351
385
  const content = [
352
386
  { type: "text", text: `${text}\n\n${JSON.stringify(itemData, null, 2)}` },
@@ -1,3 +1,4 @@
1
+ import type { CandidateVerification } from "./candidate-verification.js";
1
2
  import type { CandidateSetStatus, ClaimTarget, Extraction, RawSource, ReviewAuthorizing, ReviewOutcome } from "./types.js";
2
3
  export declare const reviewResourceApiVersion = "survey.kontourai.io/v1alpha1";
3
4
  export type ReviewResourceApiVersion = typeof reviewResourceApiVersion;
@@ -104,6 +105,14 @@ export interface ReviewCandidate {
104
105
  claimTarget: ClaimTargetHint;
105
106
  projection?: SurveyRecordProjectionHint;
106
107
  producer?: Record<string, unknown>;
108
+ /**
109
+ * Records of what independent support verifiers said about this candidate's
110
+ * value (see `CandidateVerification`). A record of a verifier's output, not a
111
+ * trust decision: nothing routes, accepts, or calibrates on it. Every decision
112
+ * surface validates each record on read, ignores any that fail, and shows only
113
+ * those bound to the value under review.
114
+ */
115
+ verifications?: CandidateVerification[];
107
116
  }
108
117
  /**
109
118
  * Well-known neutral value-type vocabulary for {@link ReviewValueDescriptor}.
@@ -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)