@kontourai/survey 4.0.0 → 5.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.
@@ -2,13 +2,13 @@ export type { CandidateSetStatus, Candidate, CandidateSet, ClaimTarget, Escalati
2
2
  export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
3
3
  export { reviewResourceApiVersion } from "./review-resource.js";
4
4
  export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
5
- export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
5
+ export { buildExtractionInspectorModel, exportExtractionInspector, inspectorSourcePosture, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
6
6
  export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
7
7
  export type { BindReviewQueueOptions, ReviewQueueBinding, ReviewQueueBindingIssue, ReviewQueueBindingIssueCode, ReviewQueueExtractionIssue, ReviewQueueExtractionIssueCode, ValidateReviewQueueBindingOptions, } from "./review-workbench/queue-binding.js";
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, PortableExtractionEvidenceMatch, PortableExtractionOccurrence, PortableExtractionProducedBy, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
11
+ export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, 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";
package/dist/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
2
2
  export { reviewResourceApiVersion } from "./review-resource.js";
3
3
  export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
4
- export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
4
+ export { buildExtractionInspectorModel, exportExtractionInspector, inspectorSourcePosture, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
5
5
  export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
6
6
  export { resolvePortablePdfRegion } from "./pdf-layout.js";
7
7
  export { toSurfaceReviewedExtractionDecision, toSurfaceReviewedExtractionImport, toSurfaceReviewedExtractionItem, } from "./surface-reviewed-extraction.js";
@@ -3,7 +3,7 @@ import { resolve, dirname } from "node:path";
3
3
  import { McpServer } from "@modelcontextprotocol/server";
4
4
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
5
5
  import { z } from "zod";
6
- import { buildReviewSessionEvents, currentReviewItem, deriveQueueRowStatus, nextUnresolvedItemName, reviewSessionSummary, workbenchDecisionDefinitions, } from "../review-workbench/review-workbench.js";
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
8
  import { appendReviewSessionEvents, readReviewSessionFile, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
9
9
  const SESSION_NAME = "mcp-review-session";
@@ -53,7 +53,7 @@ function itemDetailText(item, snapshot, events) {
53
53
  const decision = current.decisionsByItemName[item.metadata.name];
54
54
  const note = current.notesByItemName[item.metadata.name];
55
55
  const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
56
- const proposedCandidate = item.spec.candidates.find((c) => c.role === "proposed");
56
+ const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
57
57
  const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v);
58
58
  const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "unknown";
59
59
  const lines = [
@@ -69,10 +69,15 @@ function itemDetailText(item, snapshot, events) {
69
69
  ` source: ${currentCandidate?.source?.sourceRef ?? "none"}`,
70
70
  ...(currentCandidate?.locator?.excerpt ? [` excerpt: ${currentCandidate.locator.excerpt}`] : []),
71
71
  ``,
72
- `Proposed value: ${valueStr(proposedCandidate?.value ?? "(none)")}`,
73
- ` confidence: ${confStr(proposedCandidate?.extraction?.confidence ?? proposedCandidate?.confidence)}`,
74
- ` source: ${proposedCandidate?.source?.sourceRef ?? "none"}`,
75
- ...(proposedCandidate?.locator?.excerpt ? [` excerpt: ${proposedCandidate.locator.excerpt}`] : []),
72
+ ...(proposedCandidates.length > 1
73
+ ? [`Conflict: ${proposedCandidates.length} proposed values. Accept is refused; reject them all or use could-not-confirm with a reason.`]
74
+ : []),
75
+ ...(proposedCandidates.length === 0 ? [`Proposed value: (none)`] : proposedCandidates.flatMap((candidate) => [
76
+ `Proposed value: ${valueStr(candidate.value)}`,
77
+ ` confidence: ${confStr(candidate.extraction?.confidence ?? candidate.confidence)}`,
78
+ ` source: ${candidate.source?.sourceRef ?? "none"}`,
79
+ ...(candidate.locator?.excerpt ? [` excerpt: ${candidate.locator.excerpt}`] : []),
80
+ ])),
76
81
  ];
77
82
  if (item.spec.rationale) {
78
83
  lines.push(``, `Rationale: ${item.spec.rationale}`);
@@ -99,19 +104,32 @@ function buildReviewCardHtml(item, snapshot, events) {
99
104
  const total = current.items.length;
100
105
  const resolved = total - summary.unresolved;
101
106
  const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
102
- const proposedCandidate = item.spec.candidates.find((c) => c.role === "proposed");
107
+ const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
108
+ // Several proposed values are a conflict: every value is shown, and accept
109
+ // (which names a role, not a value) is not offered.
110
+ const conflict = proposedCandidates.length > 1;
103
111
  const decision = current.decisionsByItemName[item.metadata.name];
104
112
  const status = deriveQueueRowStatus(item, current);
105
113
  const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v, null, 2);
106
114
  const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "—";
107
115
  const currentValue = valueStr(currentCandidate?.value ?? "—");
108
- const proposedValue = valueStr(proposedCandidate?.value ?? "—");
109
116
  const currentConf = confStr(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence);
110
- const proposedConf = confStr(proposedCandidate?.extraction?.confidence ?? proposedCandidate?.confidence);
111
117
  const currentSource = currentCandidate?.source?.sourceRef ?? "—";
112
- const proposedSource = proposedCandidate?.source?.sourceRef ?? "—";
113
118
  const currentExcerpt = currentCandidate?.locator?.excerpt ?? "";
114
- const proposedExcerpt = proposedCandidate?.locator?.excerpt ?? "";
119
+ const proposedCard = (candidate, label) => {
120
+ const value = valueStr(candidate?.value ?? "—");
121
+ const excerpt = candidate?.locator?.excerpt ?? "";
122
+ return `<div class="card is-proposed">
123
+ <div class="card-label">${escapeHtml(label)}</div>
124
+ <div class="value">${value.includes("\n") ? `<pre>${escapeHtml(value)}</pre>` : escapeHtml(value)}</div>
125
+ <div class="conf">confidence ${confStr(candidate?.extraction?.confidence ?? candidate?.confidence)}</div>
126
+ <div class="source-ref">${escapeHtml(candidate?.source?.sourceRef ?? "—")}</div>
127
+ ${excerpt ? `<div class="excerpt">${escapeHtml(excerpt)}</div>` : ""}
128
+ </div>`;
129
+ };
130
+ const proposedCards = conflict
131
+ ? proposedCandidates.map((candidate, index) => proposedCard(candidate, `Proposed ${index + 1} of ${proposedCandidates.length}`)).join("\n ")
132
+ : proposedCard(proposedCandidates[0], "Proposed");
115
133
  const itemNameJson = escapeJsonInHtml(item.metadata.name);
116
134
  const decisionBadge = decision
117
135
  ? `<span class="badge badge-${decision === "accept-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(workbenchDecisionDefinitions[decision].label)}</span>`
@@ -200,6 +218,7 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
200
218
  <div class="meta">
201
219
  <span>${escapeHtml(item.metadata.name)}</span>
202
220
  ${decisionBadge}
221
+ ${conflict ? `<span class="badge badge-hold" id="conflict-badge">Conflict: ${proposedCandidates.length} values</span>` : ""}
203
222
  <span class="progress">${resolved}/${total} resolved</span>
204
223
  </div>
205
224
 
@@ -211,14 +230,9 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
211
230
  <div class="source-ref">${escapeHtml(currentSource)}</div>
212
231
  ${currentExcerpt ? `<div class="excerpt">${escapeHtml(currentExcerpt)}</div>` : ""}
213
232
  </div>
214
- <div class="card is-proposed">
215
- <div class="card-label">Proposed</div>
216
- <div class="value">${proposedValue.includes("\n") ? `<pre>${escapeHtml(proposedValue)}</pre>` : escapeHtml(proposedValue)}</div>
217
- <div class="conf">confidence ${proposedConf}</div>
218
- <div class="source-ref">${escapeHtml(proposedSource)}</div>
219
- ${proposedExcerpt ? `<div class="excerpt">${escapeHtml(proposedExcerpt)}</div>` : ""}
220
- </div>
233
+ ${proposedCards}
221
234
  </div>
235
+ ${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>` : ""}
222
236
 
223
237
  <div class="divider"></div>
224
238
 
@@ -226,9 +240,9 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
226
240
  <textarea class="note-input" id="note" placeholder="Add a rationale for this decision...">${escapeHtml(current.notesByItemName[item.metadata.name] ?? "")}</textarea>
227
241
 
228
242
  <div class="btn-row">
229
- <button class="btn btn-accept${decision === "accept-proposed" ? " active" : ""}" id="btn-accept">Accept proposed</button>
230
- <button class="btn btn-hold${decision === "keep-current" ? " active" : ""}" id="btn-hold">Hold / Keep current</button>
231
- <button class="btn btn-reject${decision === "reject-proposed" ? " active" : ""}" id="btn-reject">Reject proposed</button>
243
+ ${conflict ? "" : `<button class="btn btn-accept${decision === "accept-proposed" ? " active" : ""}" id="btn-accept">Accept proposed</button>`}
244
+ ${currentCandidate ? `<button class="btn btn-hold${decision === "keep-current" ? " active" : ""}" id="btn-hold">Hold / Keep current</button>` : ""}
245
+ <button class="btn btn-reject${decision === "reject-proposed" ? " active" : ""}" id="btn-reject">${conflict ? "Reject all values" : "Reject proposed"}</button>
232
246
  <button class="btn btn-unconfirmed${decision === "could-not-confirm" ? " active" : ""}" id="btn-unconfirmed">Could not confirm</button>
233
247
  </div>
234
248
  <div class="feedback" id="feedback"></div>
@@ -259,8 +273,10 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
259
273
  return true;
260
274
  }
261
275
 
262
- document.getElementById('btn-accept').addEventListener('click', function () { postDecision('accept'); document.getElementById('feedback').textContent = 'Submitting accept…'; });
263
- document.getElementById('btn-hold').addEventListener('click', function () { postDecision('hold'); document.getElementById('feedback').textContent = 'Submitting hold…'; });
276
+ var acceptButton = document.getElementById('btn-accept');
277
+ if (acceptButton) acceptButton.addEventListener('click', function () { postDecision('accept'); document.getElementById('feedback').textContent = 'Submitting accept…'; });
278
+ var holdButton = document.getElementById('btn-hold');
279
+ if (holdButton) holdButton.addEventListener('click', function () { postDecision('hold'); document.getElementById('feedback').textContent = 'Submitting hold…'; });
264
280
  document.getElementById('btn-reject').addEventListener('click', function () { postDecision('reject'); document.getElementById('feedback').textContent = 'Submitting reject…'; });
265
281
  document.getElementById('btn-unconfirmed').addEventListener('click', function () { if (postDecision('could-not-confirm')) document.getElementById('feedback').textContent = 'Submitting could not confirm…'; });
266
282
 
@@ -421,9 +437,10 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
421
437
  const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents) : `Item: ${itemName}`;
422
438
  const remainingText = queueSummaryText(snapshot, newEvents);
423
439
  const definition = workbenchDecisionDefinitions[wbDecision];
440
+ const conflictRejected = wbDecision === "reject-proposed" && updatedItem !== undefined && decisionSelectsNoCandidate(updatedItem, wbDecision);
424
441
  const text = [
425
- `Decision recorded: ${definition.label}`,
426
- `Effect: ${definition.effect}`,
442
+ `Decision recorded: ${conflictRejected ? "Reject all values" : definition.label}`,
443
+ `Effect: ${conflictRejected ? "Every proposed value is rejected; none becomes the claim's value." : definition.effect}`,
427
444
  "",
428
445
  itemText,
429
446
  "",
@@ -1,4 +1,4 @@
1
- import { type ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
1
+ import { type ExtractionEnvelopeImportDiagnostic, type ExtractionEnvelopeImportResult } 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
4
  export type ArtifactUnavailableCode = "not-found" | "storage-error" | "access-denied" | "invalid-artifact" | "unknown";
@@ -89,6 +89,14 @@ export interface ExtractionInspectorSource {
89
89
  artifactText?: string;
90
90
  ocrDerived?: true;
91
91
  alignment: ExtractionAlignmentState;
92
+ /**
93
+ * Present when the extraction failed, or stopped short without proposing
94
+ * anything: the import's `extraction-failed` / `extraction-incomplete`
95
+ * diagnostic. An empty source with this set is not a run that found nothing.
96
+ */
97
+ extractionDiagnostic?: Extract<ExtractionEnvelopeImportDiagnostic, {
98
+ kind: "extraction-failed" | "extraction-incomplete";
99
+ }>;
92
100
  message: string;
93
101
  }
94
102
  export interface ExtractionInspectorModel {
@@ -146,6 +154,12 @@ export interface ExtractionInspectorMountOptions {
146
154
  * and fails closed if mutable caller data has drifted since import.
147
155
  */
148
156
  export declare function buildExtractionInspectorModel(input: ExtractionInspectorInput): BuiltExtractionInspectorModel;
157
+ /**
158
+ * The posture a source is shown with: the extraction's own failure or early
159
+ * stop when there is one, otherwise the artifact alignment. A failed
160
+ * extraction over an aligned artifact must not show the aligned posture.
161
+ */
162
+ export declare function inspectorSourcePosture(source: Pick<ExtractionInspectorSource, "alignment" | "extractionDiagnostic">): ExtractionAlignmentState | "extraction-failed" | "extraction-incomplete";
149
163
  export declare function filterExtractionInspectorCandidates(model: ExtractionInspectorModel, filters: ExtractionInspectorFilters): ExtractionInspectorCandidate[];
150
164
  export declare function exportExtractionInspector(model: ExtractionInspectorModel, options?: ExtractionInspectorExportOptions): string;
151
165
  export declare function mountExtractionInspector(container: HTMLElement, model: ExtractionInspectorModel, options?: ExtractionInspectorMountOptions): () => void;
@@ -26,14 +26,19 @@ export function buildExtractionInspectorModel(input) {
26
26
  if (sourceKeys.has(sourceKey))
27
27
  throw new Error("Extraction inspector source identity collision.");
28
28
  sourceKeys.add(sourceKey);
29
- const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.state, entry.artifact, envelope.result.ocrDerived);
29
+ const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.diagnostics, entry.artifact, envelope.result.ocrDerived);
30
30
  sources.push(source);
31
31
  const candidateStart = candidates.length;
32
+ const itemByProposalIndex = new Map();
33
+ for (const item of entry.importResult.reviewItems) {
34
+ for (const proposalIndex of envelopeItemProposalIndices(item))
35
+ itemByProposalIndex.set(proposalIndex, item.metadata.name);
36
+ }
32
37
  envelope.result.proposals.forEach((proposal, proposalIndex) => {
33
- const item = entry.importResult.reviewItems[proposalIndex];
34
- if (!item)
38
+ const itemName = itemByProposalIndex.get(proposalIndex);
39
+ if (!itemName)
35
40
  return; // unresolved imports legitimately produce no ReviewItems
36
- candidates.push(candidateModel(source, item.metadata.name, proposal, proposalIndex, envelope.result.provider, envelope.result.model, envelope.result.runId, entry.pass, envelope.result.pdfPageOffsets, envelope.result.pdfLayout, envelope.result.ocrDerived));
41
+ candidates.push(candidateModel(source, itemName, proposal, proposalIndex, envelope.result.provider, envelope.result.model, envelope.result.runId, entry.pass, envelope.result.pdfPageOffsets, envelope.result.pdfLayout, envelope.result.ocrDerived));
37
42
  });
38
43
  if (source.alignment === "excerpt-mismatch") {
39
44
  delete source.artifactText;
@@ -141,28 +146,53 @@ function assertImportedResult(result, record) {
141
146
  if (!record.metadata?.name || !record.metadata.producerNamespace || !record.spec?.envelope?.result || !Array.isArray(record.spec.envelope.result.proposals))
142
147
  throw new Error("Malformed extraction import result.");
143
148
  const grounded = record.status?.state === "grounded";
144
- if ((!grounded && reviewItems.length !== 0) || (grounded && reviewItems.length !== record.spec.envelope.result.proposals.length))
149
+ const proposals = record.spec.envelope.result.proposals;
150
+ 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))))
145
152
  throw new Error("Extraction import ReviewItems do not match its grounding state.");
146
153
  const canonicalItems = buildReviewItemsFromExtractionEnvelopeImport(record);
147
154
  if (canonicalJson(reviewItems) !== canonicalJson(canonicalItems))
148
155
  throw new Error("Extraction import ReviewItems do not match their canonical identities and bindings.");
149
- reviewItems.forEach((item, index) => {
150
- const proposal = record.spec.envelope.result.proposals[index];
156
+ reviewItems.forEach((item, itemIndex) => {
151
157
  const metadata = item.metadata?.producer?.["survey.kontourai.io/extraction-envelope"];
152
- const candidate = item.spec?.candidates?.[0];
153
- const binding = candidate?.producer?.["survey.kontourai.io/extraction-envelope"];
154
- if (item.kind !== "ReviewItem" || !item.metadata.name || item.spec.candidates.length !== 1
155
- || metadata?.importName !== record.metadata.name || binding?.importName !== record.metadata.name
156
- || binding.proposalIndex !== index || binding.runId !== record.spec.envelope.result.runId || binding.provider !== record.spec.envelope.result.provider
157
- || item.spec.target !== proposal.fieldPath || candidate?.locator?.locator !== proposal.provenance.locator || candidate.locator.excerpt !== proposal.provenance.excerpt) {
158
- throw new Error(`Extraction import ReviewItem ${index} is inconsistent with its validated proposal.`);
158
+ if (item.kind !== "ReviewItem" || !item.metadata.name || item.spec.candidates.length === 0 || metadata?.importName !== record.metadata.name) {
159
+ throw new Error(`Extraction import ReviewItem ${itemIndex} is inconsistent with its validated proposals.`);
160
+ }
161
+ for (const candidate of item.spec.candidates) {
162
+ const binding = candidate.producer?.["survey.kontourai.io/extraction-envelope"];
163
+ const proposal = typeof binding?.proposalIndex === "number" ? proposals[binding.proposalIndex] : undefined;
164
+ if (!proposal || binding?.importName !== record.metadata.name || binding.runId !== record.spec.envelope.result.runId || binding.provider !== record.spec.envelope.result.provider
165
+ || candidate.extraction.target !== proposal.fieldPath || candidate.locator?.locator !== proposal.provenance.locator || candidate.locator.excerpt !== proposal.provenance.excerpt) {
166
+ throw new Error(`Extraction import ReviewItem ${itemIndex} is inconsistent with its validated proposals.`);
167
+ }
159
168
  }
160
169
  });
161
170
  }
162
- function sourceModel(key, importName, prepared, state, artifact, ocrDerived) {
171
+ /** The proposal indices one imported ReviewItem stands for, as its producer metadata records them. */
172
+ function envelopeItemProposalIndices(item) {
173
+ const metadata = item.metadata?.producer?.["survey.kontourai.io/extraction-envelope"];
174
+ const indices = metadata?.proposalIndices;
175
+ if (!Array.isArray(indices) || indices.length === 0 || !indices.every((index) => Number.isSafeInteger(index) && index >= 0)) {
176
+ throw new Error(`Extraction import ReviewItem ${item.metadata?.name ?? "(unnamed)"} does not record its proposals.`);
177
+ }
178
+ return indices;
179
+ }
180
+ /**
181
+ * The posture a source is shown with: the extraction's own failure or early
182
+ * stop when there is one, otherwise the artifact alignment. A failed
183
+ * extraction over an aligned artifact must not show the aligned posture.
184
+ */
185
+ export function inspectorSourcePosture(source) {
186
+ if (source.extractionDiagnostic)
187
+ return source.extractionDiagnostic.kind;
188
+ return source.alignment;
189
+ }
190
+ function sourceModel(key, importName, prepared, diagnostics, artifact, ocrDerived) {
163
191
  let alignment;
164
192
  let message;
165
- if (state !== "grounded" || artifact.status === "unavailable") {
193
+ const artifactUnresolved = diagnostics.some((diagnostic) => diagnostic.kind === "artifact-unavailable" || diagnostic.kind === "digest-mismatch");
194
+ const extractionDiagnostic = diagnostics.find((diagnostic) => diagnostic.kind === "extraction-failed" || diagnostic.kind === "extraction-incomplete");
195
+ if (artifactUnresolved || artifact.status === "unavailable") {
166
196
  alignment = "artifact-unavailable";
167
197
  message = `Prepared artifact unavailable (${artifact.status === "unavailable" ? artifact.code : "invalid-artifact"}). Candidates are not grounded.`;
168
198
  }
@@ -177,9 +207,15 @@ function sourceModel(key, importName, prepared, state, artifact, ocrDerived) {
177
207
  }
178
208
  else {
179
209
  alignment = "aligned";
180
- message = `Prepared artifact identity verified. Exact source spans are available.${ocrDerived ? " Prepared text is OCR-derived." : ""}`;
210
+ message = extractionDiagnostic
211
+ ? "Prepared artifact identity verified."
212
+ : `Prepared artifact identity verified. Exact source spans are available.${ocrDerived ? " Prepared text is OCR-derived." : ""}`;
181
213
  }
182
- return { key, importName, ...(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 };
214
+ // The extraction's own failure leads: an aligned artifact with no candidates
215
+ // must not read as a complete run that found nothing.
216
+ if (extractionDiagnostic)
217
+ 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 };
183
219
  }
184
220
  function candidateModel(source, reviewItemName, proposal, index, provider, model, attempt, pass, pdfPageOffsets, pdfLayout, ocrDerived) {
185
221
  const match = /^chars:(\d+)-(\d+)$/.exec(proposal.provenance.locator);
@@ -270,7 +306,7 @@ export function mountExtractionInspector(container, model, options = {}) {
270
306
  next.hidden = pageCount === 1;
271
307
  previous.disabled = page === 0;
272
308
  next.disabled = page >= pageCount - 1;
273
- postures.innerHTML = model.sources.map(s => `<div class="inspector-posture ${s.alignment}" role="status"><strong>${escapeHtml(s.importName)}: ${escapeHtml(s.alignment)}</strong><span>${escapeHtml(s.message)}</span></div>`).join("");
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("");
274
310
  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("");
275
311
  };
276
312
  root.querySelectorAll("select").forEach(select => select.addEventListener("change", event => { event.stopPropagation(); const key = select.dataset.filter; if (select.value)
@@ -229,7 +229,7 @@ export function validateReviewQueueAgainstExtractionImport(items, importResult)
229
229
  if (record.status.state !== "grounded") {
230
230
  return [{
231
231
  code: "import-not-grounded",
232
- message: `Extraction import ${record.metadata.name} is ${record.status.state}, not grounded; it cannot attest a review queue.`,
232
+ message: `Extraction import ${record.metadata.name} is ${record.status.state}, not grounded (${record.status.diagnostics.map((diagnostic) => diagnostic.message).join(" ")}); it cannot attest a review queue.`,
233
233
  }];
234
234
  }
235
235
  if (items.length === 0) {
@@ -57,7 +57,8 @@ export interface ReviewResultPresentation {
57
57
  readonly target: string;
58
58
  readonly targetLabel: string;
59
59
  readonly decisionLabel: string;
60
- readonly selectedValueText: string;
60
+ /** Absent when the decision selects no candidate (reject-all or could-not-confirm on a conflict). */
61
+ readonly selectedValueText?: string;
61
62
  readonly applyMeaning: string;
62
63
  readonly reviewItemLink?: ReviewPresentationLink;
63
64
  readonly traceRefs: readonly ReviewTraceRef[];
@@ -85,6 +85,28 @@ export function buildReviewResultPresentation(result, item, adapter = {}) {
85
85
  ? adapter.labelForTarget?.(target, itemContext) ?? humanizeIdentifier(target)
86
86
  : humanizeIdentifier(target);
87
87
  const selectedCandidate = item ? selectedCandidateForResult(item, result) : undefined;
88
+ // A decision that selects no candidate presents no selected value and no
89
+ // selected trace; it names every candidate instead.
90
+ if (result.selectedCandidateId === undefined) {
91
+ const rejected = result.decision === "reject-proposed";
92
+ return {
93
+ result,
94
+ item,
95
+ target,
96
+ targetLabel,
97
+ decisionLabel: humanizeIdentifier(result.decision),
98
+ applyMeaning: rejected
99
+ ? "Saved decision rejects every proposed value; none is applied"
100
+ : "Saved decision records that no proposed value could be confirmed; none is applied",
101
+ reviewItemLink: item && itemContext ? adapter.linkForReviewItem?.(item, itemContext) : undefined,
102
+ traceRefs: [
103
+ { label: "Survey ReviewItem", value: result.reviewItemName, kind: "review-item", context: undefined },
104
+ ...result.unselectedCandidates.map((candidate) => ({
105
+ label: rejected ? "Rejected candidate" : "Unconfirmed candidate", value: candidate.id, kind: "candidate", context: candidate,
106
+ })),
107
+ ].flatMap(({ context, ...ref }) => (item ? withTraceLinks([ref], { item, candidate: context }, adapter) : [ref])),
108
+ };
109
+ }
88
110
  return {
89
111
  result,
90
112
  item,
@@ -168,7 +190,7 @@ function traceRefsForCandidate(item, candidate, adapter) {
168
190
  function traceRefsForResult(item, result, selectedCandidate, adapter) {
169
191
  return withTraceLinks([
170
192
  { label: "Survey ReviewItem", value: result.reviewItemName, kind: "review-item" },
171
- { label: "Selected candidate", value: result.selectedCandidateId, kind: "candidate" },
193
+ { label: "Selected candidate", value: result.selectedCandidateId ?? "none", kind: "candidate" },
172
194
  {
173
195
  label: "Selected claim",
174
196
  value: selectedCandidate?.claimTarget.claimId ?? "not provided",
@@ -94,15 +94,29 @@ export declare function keepActionDecision(item: ReviewItem, flaggedWrong: boole
94
94
  /**
95
95
  * The candidate a workbench decision applies to.
96
96
  *
97
- * Selection is by role, but the id this returns is what every caller makes
98
- * durable — a ReviewDecision's `candidateId`, a session event's, a result's, a
99
- * replay expectation. So the id has to name exactly one candidate before it
100
- * leaves here. Guarding the render path alone let the workbench emit an
101
- * undecidable decision through the export path and then present a different
97
+ * Selection is by role, so the role has to name exactly one candidate before a
98
+ * decision may trust it. Guarding the render path alone let the workbench emit
99
+ * an undecidable decision through the export path and then present a different
102
100
  * candidate's value against it; this is the shared selector all of those go
103
101
  * through, which is why the check belongs here rather than at each of them.
102
+ *
103
+ * A decision that would make one of several candidates in its role the trusted
104
+ * value is refused: picking the first would settle the conflict for the
105
+ * reviewer without showing it. A value-neutral decision (reject, could not
106
+ * confirm) on such an item selects none of them. It still returns the first
107
+ * candidate so in-process callers that need one candidate (rendering, the
108
+ * in-memory `ReviewWorkbenchResult`) have one, but that anchor is not recorded:
109
+ * {@link decisionCandidateId} is `undefined` for it, so the decision, its
110
+ * session events and the canonical projection name no candidate.
104
111
  */
105
112
  export declare function candidateForDecision(item: ReviewItem, decision: ReviewWorkbenchDecision): ReviewCandidate;
113
+ /** Whether a decision on this item selects no candidate at all (see {@link candidateForDecision}). */
114
+ export declare function decisionSelectsNoCandidate(item: ReviewItem, decision: ReviewWorkbenchDecision): boolean;
115
+ /**
116
+ * The candidate id a decision records: the selected candidate's, or
117
+ * `undefined` when the decision selects no candidate.
118
+ */
119
+ export declare function decisionCandidateId(item: ReviewItem, decision: ReviewWorkbenchDecision): string | undefined;
106
120
  /**
107
121
  * The value that should actually be applied for a decision: the reviewer's inline
108
122
  * edit when one was made for an accept-proposed decision, otherwise the selected
@@ -143,32 +143,65 @@ export function reviewSessionSummary(session) {
143
143
  * would invent a prior value, with provenance, that the source never had.
144
144
  */
145
145
  export function keepActionDecision(item, flaggedWrong) {
146
- const hasRole = (role) => item.spec.candidates.some((candidate) => candidate.role === role);
147
- if (flaggedWrong || !hasRole("current")) {
148
- return hasRole("proposed") ? "reject-proposed" : undefined;
146
+ // Keeping the current value is recordable only when exactly one candidate is
147
+ // current (see candidateForDecision); rejecting the proposed values is
148
+ // recordable however many there are.
149
+ const count = (role) => item.spec.candidates.filter((candidate) => candidate.role === role).length;
150
+ if (flaggedWrong || count("current") === 0) {
151
+ return count("proposed") > 0 ? "reject-proposed" : undefined;
149
152
  }
150
- return "keep-current";
153
+ return count("current") === 1 ? "keep-current" : undefined;
151
154
  }
155
+ /**
156
+ * Decisions that make no candidate the trusted value: rejecting the proposed
157
+ * values and ending the round as could-not-confirm. On an item whose role holds
158
+ * several candidates (conflicting values for one claim) they select none of them.
159
+ */
160
+ const VALUE_NEUTRAL_DECISIONS = new Set(["reject-proposed", "could-not-confirm"]);
152
161
  /**
153
162
  * The candidate a workbench decision applies to.
154
163
  *
155
- * Selection is by role, but the id this returns is what every caller makes
156
- * durable — a ReviewDecision's `candidateId`, a session event's, a result's, a
157
- * replay expectation. So the id has to name exactly one candidate before it
158
- * leaves here. Guarding the render path alone let the workbench emit an
159
- * undecidable decision through the export path and then present a different
164
+ * Selection is by role, so the role has to name exactly one candidate before a
165
+ * decision may trust it. Guarding the render path alone let the workbench emit
166
+ * an undecidable decision through the export path and then present a different
160
167
  * candidate's value against it; this is the shared selector all of those go
161
168
  * through, which is why the check belongs here rather than at each of them.
169
+ *
170
+ * A decision that would make one of several candidates in its role the trusted
171
+ * value is refused: picking the first would settle the conflict for the
172
+ * reviewer without showing it. A value-neutral decision (reject, could not
173
+ * confirm) on such an item selects none of them. It still returns the first
174
+ * candidate so in-process callers that need one candidate (rendering, the
175
+ * in-memory `ReviewWorkbenchResult`) have one, but that anchor is not recorded:
176
+ * {@link decisionCandidateId} is `undefined` for it, so the decision, its
177
+ * session events and the canonical projection name no candidate.
162
178
  */
163
179
  export function candidateForDecision(item, decision) {
164
180
  const definition = workbenchDecisionDefinitions[decision];
165
- const candidate = item.spec.candidates.find((entry) => entry.role === definition.candidateRole);
181
+ const matches = item.spec.candidates.filter((entry) => entry.role === definition.candidateRole);
182
+ const candidate = matches[0];
166
183
  if (!candidate) {
167
184
  throw new Error(`ReviewItem ${item.metadata.name} has no ${definition.candidateRole} candidate.`);
168
185
  }
186
+ if (matches.length > 1 && !VALUE_NEUTRAL_DECISIONS.has(decision)) {
187
+ throw new Error(`ReviewItem ${item.metadata.name} has ${matches.length} ${definition.candidateRole} candidates; the ${decision} decision cannot choose between them.`);
188
+ }
169
189
  assertSoleCandidateId(item, candidate.id);
170
190
  return candidate;
171
191
  }
192
+ /** Whether a decision on this item selects no candidate at all (see {@link candidateForDecision}). */
193
+ export function decisionSelectsNoCandidate(item, decision) {
194
+ const role = workbenchDecisionDefinitions[decision].candidateRole;
195
+ return VALUE_NEUTRAL_DECISIONS.has(decision) && item.spec.candidates.filter((entry) => entry.role === role).length > 1;
196
+ }
197
+ /**
198
+ * The candidate id a decision records: the selected candidate's, or
199
+ * `undefined` when the decision selects no candidate.
200
+ */
201
+ export function decisionCandidateId(item, decision) {
202
+ const candidate = candidateForDecision(item, decision);
203
+ return decisionSelectsNoCandidate(item, decision) ? undefined : candidate.id;
204
+ }
172
205
  /**
173
206
  * The value that should actually be applied for a decision: the reviewer's inline
174
207
  * edit when one was made for an accept-proposed decision, otherwise the selected
@@ -254,7 +287,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
254
287
  if (decision === "could-not-confirm" && !note?.trim()) {
255
288
  throw new Error(`ReviewItem ${item.metadata.name} could not confirm requires a non-empty reason.`);
256
289
  }
257
- const candidate = candidateForDecision(item, decision);
290
+ const candidateId = decisionCandidateId(item, decision);
258
291
  const definition = workbenchDecisionDefinitions[decision];
259
292
  const reviewDecisionName = `${item.metadata.name}-${decision}`;
260
293
  // Carry the reviewer's inline edit in the event itself (accept-proposed
@@ -284,7 +317,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
284
317
  occurredAt: session.reviewedAt,
285
318
  reviewItemName: item.metadata.name,
286
319
  reviewDecisionName,
287
- candidateId: candidate.id,
320
+ ...(candidateId !== undefined ? { candidateId } : {}),
288
321
  status: definition.status,
289
322
  ...(decision === "could-not-confirm"
290
323
  ? {
@@ -302,7 +335,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
302
335
  occurredAt: session.reviewedAt,
303
336
  reviewItemName: item.metadata.name,
304
337
  reviewDecisionName,
305
- candidateId: candidate.id,
338
+ ...(candidateId !== undefined ? { candidateId } : {}),
306
339
  status: definition.status,
307
340
  rationale: note,
308
341
  ...(decision === "could-not-confirm"
@@ -1,4 +1,4 @@
1
- import { candidateForDecision, isClearedWorkbenchDecisionEvent, workbenchDecisionDefinitions, } from "./review-queue-session.js";
1
+ import { candidateForDecision, decisionSelectsNoCandidate, isClearedWorkbenchDecisionEvent, workbenchDecisionDefinitions, } from "./review-queue-session.js";
2
2
  import { checkEditedValueForItem } from "./edited-value.js";
3
3
  export function validateReviewSessionEventsForSnapshot(snapshot, events) {
4
4
  const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
@@ -56,17 +56,19 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
56
56
  else if (itemName && itemsByName.has(itemName)) {
57
57
  const item = itemsByName.get(itemName);
58
58
  const expectedCandidate = item ? candidateForDecision(item, decision) : undefined;
59
+ // A decision that selects no candidate must reference none.
60
+ const expectedCandidateId = item && !decisionSelectsNoCandidate(item, decision) ? expectedCandidate?.id : undefined;
59
61
  const expectedStatus = workbenchDecisionDefinitions[decision].status;
60
62
  const referencedCandidateExists = event.spec.candidateId
61
63
  ? item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId)
62
64
  : false;
63
- if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !== expectedCandidate.id) {
65
+ if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !== expectedCandidateId) {
64
66
  issues.push({
65
67
  ...eventRef,
66
68
  code: "decision-candidate-mismatch",
67
69
  reviewItemName: itemName,
68
70
  candidateId: event.spec.candidateId,
69
- message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects candidate ${expectedCandidate.id}, but references ${event.spec.candidateId ?? "no candidate"}.`,
71
+ message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects ${expectedCandidateId ? `candidate ${expectedCandidateId}` : "no candidate"}, but references ${event.spec.candidateId ?? "no candidate"}.`,
70
72
  });
71
73
  }
72
74
  if (event.spec.status !== expectedStatus) {
@@ -247,6 +247,8 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
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
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; }
251
+ .survey-workbench-embed .inspector-posture.extraction-incomplete{ background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
250
252
  .survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
251
253
  .survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
252
254
  .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; }
@@ -244,6 +244,8 @@
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
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; }
248
+ .survey-workbench-embed .inspector-posture.extraction-incomplete{ background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
247
249
  .survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
248
250
  .survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
249
251
  .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; }