@kontourai/survey 6.1.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/src/calibration.d.ts +15 -0
  2. package/dist/src/calibration.js +11 -0
  3. package/dist/src/canonical-reviewed-trust-input.d.ts +10 -0
  4. package/dist/src/canonical-reviewed-trust-input.js +45 -1
  5. package/dist/src/console/review-console-server.js +22 -4
  6. package/dist/src/decision-carry-forward.d.ts +157 -0
  7. package/dist/src/decision-carry-forward.js +295 -0
  8. package/dist/src/extraction-envelope.d.ts +4 -3
  9. package/dist/src/extraction-envelope.js +3 -3
  10. package/dist/src/field-states.d.ts +100 -0
  11. package/dist/src/field-states.js +169 -0
  12. package/dist/src/index.d.ts +6 -4
  13. package/dist/src/index.js +3 -1
  14. package/dist/src/mcp/review-mcp.js +173 -45
  15. package/dist/src/review-resource.d.ts +36 -0
  16. package/dist/src/review-session-file.d.ts +7 -0
  17. package/dist/src/review-workbench/audit-rows.d.ts +1 -1
  18. package/dist/src/review-workbench/audit-rows.js +1 -0
  19. package/dist/src/review-workbench/audit-sample.d.ts +32 -0
  20. package/dist/src/review-workbench/audit-sample.js +53 -0
  21. package/dist/src/review-workbench/extraction-inspector.d.ts +1 -1
  22. package/dist/src/review-workbench/extraction-inspector.js +1 -1
  23. package/dist/src/review-workbench/field-state-view.d.ts +15 -0
  24. package/dist/src/review-workbench/field-state-view.js +84 -0
  25. package/dist/src/review-workbench/queue-binding.d.ts +45 -2
  26. package/dist/src/review-workbench/queue-binding.js +45 -0
  27. package/dist/src/review-workbench/review-presentation.d.ts +23 -0
  28. package/dist/src/review-workbench/review-presentation.js +51 -10
  29. package/dist/src/review-workbench/review-queue-session.d.ts +93 -5
  30. package/dist/src/review-workbench/review-queue-session.js +147 -11
  31. package/dist/src/review-workbench/review-session-replay.d.ts +11 -1
  32. package/dist/src/review-workbench/review-session-replay.js +49 -1
  33. package/dist/src/review-workbench/review-workbench-css.generated.js +126 -0
  34. package/dist/src/review-workbench/review-workbench-element.d.ts +10 -0
  35. package/dist/src/review-workbench/review-workbench-element.js +21 -3
  36. package/dist/src/review-workbench/review-workbench.css +126 -0
  37. package/dist/src/review-workbench/review-workbench.d.ts +40 -8
  38. package/dist/src/review-workbench/review-workbench.js +192 -71
  39. package/dist/src/review-workbench/review-workbench.standalone.css +126 -0
  40. package/dist/src/review-workbench/server-review-session.d.ts +18 -0
  41. package/dist/src/review-workbench/server-review-session.js +20 -2
  42. package/dist/src/surface-reviewed-extraction.d.ts +23 -0
  43. package/dist/src/surface-reviewed-extraction.js +64 -1
  44. package/dist/src/to-surface.js +32 -3
  45. package/package.json +1 -1
package/dist/src/index.js CHANGED
@@ -2,7 +2,7 @@ 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
4
  export { buildExtractionInspectorModel, exportExtractionInspector, inspectorSourcePosture, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
5
- export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
5
+ export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, attestReviewQueueExtraction, bindReviewQueue, reviewItemCarriesExtractionBinding, unverifiedExtractionQueueSentence, 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";
8
8
  export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
@@ -28,3 +28,5 @@ export { confidenceBasisForReview, defineProductVocabulary, stableId } from "./v
28
28
  export { currentProposedReviewItem } from "./current-proposed-review-item.js";
29
29
  export { deriveOversightMetrics, mergeTrustBundleWithOversightMetrics, oversightMetricsToClaims, } from "./oversight-metrics.js";
30
30
  export { calibrationToClaims, deriveCalibration, mergeTrustBundleWithCalibration, } from "./calibration.js";
31
+ export { deriveFieldStates, fieldStateClaimMetadata, lifecycleForDecision, FIELD_STATE_METADATA_KEY, } from "./field-states.js";
32
+ export { buildDecisionSupersession, decisionCarryForwardKind, decisionReference, decisionResolution, decisionSupersessionKind, defaultCarryForwardPolicy, splitRoundForCarryForward, validateDecisionCarryForward, validateDecisionSupersession, } from "./decision-carry-forward.js";
@@ -3,9 +3,9 @@ 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, decisionSelectsNoCandidate, deriveQueueRowStatus, nextUnresolvedItemName, reviewSessionSummary, workbenchDecisionDefinitions, } from "../review-workbench/review-workbench.js";
6
+ import { attestReviewQueueExtraction, buildReviewSessionEvents, conflictSelectionIssue, currentReviewItem, decisionSelectsNoCandidate, deriveQueueRowStatus, isScoreBlind, 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
+ import { buildReviewItemPresentation, candidateVerificationNotes, excludedProposalsSentence, excludedProposalsUnreadableSentence } from "../review-workbench/review-presentation.js";
9
9
  import { appendReviewSessionEvents, readReviewSessionFile, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
10
10
  const SESSION_NAME = "mcp-review-session";
11
11
  const UI_RESOURCE_URI_META_KEY = "ui/resourceUri";
@@ -16,15 +16,41 @@ const SERVER_INSTRUCTIONS = "Use survey_review_queue to inspect the queue, surve
16
16
  // MCP tool decision strings → ReviewWorkbenchDecision
17
17
  const MCP_DECISION_MAP = {
18
18
  accept: "accept-proposed",
19
+ select: "select-proposed",
19
20
  hold: "keep-current",
20
21
  reject: "reject-proposed",
21
22
  "could-not-confirm": "could-not-confirm",
22
23
  };
24
+ /**
25
+ * Reads the session and checks its queue against the extraction import stored
26
+ * beside it. A queue that does not match its import is refused outright; one
27
+ * whose items came from an import but has no record stored is presented with
28
+ * an "Unverified queue" notice on every surface.
29
+ */
23
30
  async function readSessionFile(path) {
24
- return readReviewSessionFile(path);
31
+ const file = await readReviewSessionFile(path);
32
+ return { ...file, attestation: attestedQueue(file) };
33
+ }
34
+ function attestedQueue(file) {
35
+ const attestation = attestReviewQueueExtraction(file.snapshot.items, file.extractionImport);
36
+ if (attestation.state === "diverges") {
37
+ throw new DomainError(attestation.message);
38
+ }
39
+ return attestation;
40
+ }
41
+ function attestationLines(attestation) {
42
+ return attestation.state === "unverified" ? [attestation.message, ``] : [];
43
+ }
44
+ /**
45
+ * A score-blind session shows no confidence and no verifier result on any
46
+ * surface (text, data or card), so the decision is made without them.
47
+ */
48
+ const SCORE_BLIND_NOTICE = "Score-blind review: confidence and verifier results are hidden for this whole session. Decide from the source and its excerpt.";
49
+ function scoreBlindLines(state) {
50
+ return isScoreBlind(state) ? [`${state.sampling?.kind === "random-audit" ? "Random audit sample. " : ""}${SCORE_BLIND_NOTICE}`, ``] : [];
25
51
  }
26
52
  // ---- Queue helpers -------------------------------------------------------
27
- function queueSummaryText(snapshot, events) {
53
+ function queueSummaryText(snapshot, events, attestation) {
28
54
  const current = currentSessionState(snapshot, events);
29
55
  const summary = reviewSessionSummary(current);
30
56
  const total = current.items.length;
@@ -38,6 +64,8 @@ function queueSummaryText(snapshot, events) {
38
64
  return ` ${item.metadata.name} — ${item.spec.target} — ${status}${marker}`;
39
65
  });
40
66
  return [
67
+ ...attestationLines(attestation),
68
+ ...scoreBlindLines(current),
41
69
  `Review queue: ${resolved}/${total} resolved`,
42
70
  `Active item: ${activeItem.metadata.name} (${activeItem.spec.target})`,
43
71
  ...(nextItem ? [`Next unresolved: ${nextItem}`] : ["All items resolved."]),
@@ -48,16 +76,21 @@ function queueSummaryText(snapshot, events) {
48
76
  ...rows,
49
77
  ].join("\n");
50
78
  }
51
- function itemDetailText(item, snapshot, events) {
79
+ function itemDetailText(item, snapshot, events, attestation) {
52
80
  const current = currentSessionState(snapshot, events);
53
81
  const status = deriveQueueRowStatus(item, current);
54
82
  const decision = current.decisionsByItemName[item.metadata.name];
55
83
  const note = current.notesByItemName[item.metadata.name];
84
+ const chosenId = decision === "select-proposed" ? current.selectedCandidateIdsByItemName?.[item.metadata.name] : undefined;
56
85
  const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
57
86
  const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
58
87
  const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v);
59
88
  const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "unknown";
89
+ const blind = isScoreBlind(current);
90
+ const confidenceLine = (c) => blind ? [] : [` confidence: ${confStr(c)}`];
60
91
  const lines = [
92
+ ...attestationLines(attestation),
93
+ ...scoreBlindLines(current),
61
94
  `Item: ${item.metadata.name}`,
62
95
  `Target: ${item.spec.target}`,
63
96
  `Status: ${status}`,
@@ -66,21 +99,25 @@ function itemDetailText(item, snapshot, events) {
66
99
  ...(note ? [`Note: ${note}`] : []),
67
100
  ``,
68
101
  `Current value: ${valueStr(currentCandidate?.value ?? "(none)")}`,
69
- ` confidence: ${confStr(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence)}`,
102
+ ...confidenceLine(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence),
70
103
  ` source: ${currentCandidate?.source?.sourceRef ?? "none"}`,
71
104
  ...(currentCandidate?.locator?.excerpt ? [` excerpt: ${currentCandidate.locator.excerpt}`] : []),
72
105
  ``,
73
106
  ...(proposedCandidates.length > 1
74
- ? [`Conflict: ${proposedCandidates.length} proposed values. Accept is refused; reject them all or use could-not-confirm with a reason.`]
107
+ ? [`Conflict: ${proposedCandidates.length} proposed values. Accept is refused; choose one with decision "select" and its candidateId, reject them all, or use could-not-confirm with a reason.`]
108
+ : []),
109
+ ...(chosenId !== undefined
110
+ ? [`Chosen value: ${valueStr(proposedCandidates.find((c) => c.id === chosenId)?.value)} (candidate ${chosenId}); not chosen: ${proposedCandidates.filter((c) => c.id !== chosenId).map((c) => `${valueStr(c.value)} (candidate ${c.id})`).join(", ")}`]
75
111
  : []),
76
112
  ...(proposedCandidates.length === 0 ? [`Proposed value: (none)`] : proposedCandidates.flatMap((candidate) => [
77
- `Proposed value: ${valueStr(candidate.value)}`,
78
- ` confidence: ${confStr(candidate.extraction?.confidence ?? candidate.confidence)}`,
113
+ `Proposed value: ${valueStr(candidate.value)}${chosenId === undefined ? "" : candidate.id === chosenId ? " [chosen]" : " [not chosen]"}`,
114
+ ...(proposedCandidates.length > 1 ? [` candidateId: ${candidate.id}`] : []),
115
+ ...confidenceLine(candidate.extraction?.confidence ?? candidate.confidence),
79
116
  ` source: ${candidate.source?.sourceRef ?? "none"}`,
80
117
  ...(candidate.locator?.excerpt ? [` excerpt: ${candidate.locator.excerpt}`] : []),
81
118
  ])),
82
119
  ...extractionImportLines(item),
83
- ...candidateVerificationNotes(item, editedValueFor(item, current)).flatMap((entry) => [``, `Verification: ${entry.sentence}`]),
120
+ ...(blind ? [] : candidateVerificationNotes(item, editedValueFor(item, current)).flatMap((entry) => [``, `Verification: ${entry.sentence}`])),
84
121
  ];
85
122
  if (item.spec.rationale) {
86
123
  lines.push(``, `Rationale: ${item.spec.rationale}`);
@@ -88,6 +125,13 @@ function itemDetailText(item, snapshot, events) {
88
125
  return lines.join("\n");
89
126
  }
90
127
  /** The reviewer's edit when the item's decision accepts one; verifier records are read against it. */
128
+ /** The session's presentation and sampling, as a client should read them. */
129
+ function sessionConditions(snapshot) {
130
+ return {
131
+ ...(snapshot.presentation !== undefined ? { presentation: snapshot.presentation } : {}),
132
+ ...(snapshot.sampling !== undefined ? { sampling: snapshot.sampling } : {}),
133
+ };
134
+ }
91
135
  function editedValueFor(item, state) {
92
136
  return state.decisionsByItemName[item.metadata.name] === "accept-proposed" ? state.editedValuesByItemName?.[item.metadata.name] : undefined;
93
137
  }
@@ -95,8 +139,10 @@ function editedValueFor(item, state) {
95
139
  function extractionImportLines(item) {
96
140
  const presentation = buildReviewItemPresentation(item);
97
141
  const excluded = excludedProposalsSentence(presentation.excludedProposals);
142
+ const unreadable = excludedProposalsUnreadableSentence(presentation.excludedProposalsUnreadable);
98
143
  return [
99
144
  ...(excluded ? [``, `Excluded: ${excluded} Check the source before accepting.`] : []),
145
+ ...(unreadable ? [``, `Excluded: ${unreadable} Check the source before accepting.`] : []),
100
146
  ...(presentation.excerptVerification ? [``, `Excerpts ${presentation.excerptVerification === "verified" ? "were" : "were not"} checked against the prepared source text at import.`] : []),
101
147
  ];
102
148
  }
@@ -114,43 +160,57 @@ function escapeHtml(text) {
114
160
  .replace(/>/g, ">")
115
161
  .replace(/"/g, """);
116
162
  }
117
- function buildReviewCardHtml(item, snapshot, events) {
163
+ function buildReviewCardHtml(item, snapshot, events, attestation) {
118
164
  const current = currentSessionState(snapshot, events);
119
165
  const summary = reviewSessionSummary(current);
120
166
  const total = current.items.length;
121
167
  const resolved = total - summary.unresolved;
122
168
  const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
123
169
  const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
124
- // Several proposed values are a conflict: every value is shown, and accept
125
- // (which names a role, not a value) is not offered.
170
+ // Several proposed values are a conflict: every value is shown, accept
171
+ // (which names a role, not a value) is not offered, and each value has its
172
+ // own "Use this value" control that names its candidate id.
126
173
  const conflict = proposedCandidates.length > 1;
127
174
  const decision = current.decisionsByItemName[item.metadata.name];
175
+ const chosenId = decision === "select-proposed" ? current.selectedCandidateIdsByItemName?.[item.metadata.name] : undefined;
128
176
  const status = deriveQueueRowStatus(item, current);
129
177
  const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v, null, 2);
130
178
  const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "—";
179
+ const blind = isScoreBlind(current);
180
+ const confHtml = (c) => blind ? "" : `<div class="conf">confidence ${confStr(c)}</div>`;
131
181
  const currentValue = valueStr(currentCandidate?.value ?? "—");
132
- const currentConf = confStr(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence);
182
+ const currentConf = confHtml(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence);
133
183
  const currentSource = currentCandidate?.source?.sourceRef ?? "—";
134
184
  const currentExcerpt = currentCandidate?.locator?.excerpt ?? "";
135
185
  const proposedCard = (candidate, label) => {
136
186
  const value = valueStr(candidate?.value ?? "—");
137
187
  const excerpt = candidate?.locator?.excerpt ?? "";
138
- return `<div class="card is-proposed">
188
+ const choice = conflict && candidate
189
+ ? chosenId !== undefined
190
+ ? `<div class="choice ${candidate.id === chosenId ? "chosen" : "not-chosen"}" data-candidate-id="${escapeHtml(candidate.id)}">${candidate.id === chosenId ? "Chosen" : "Not chosen"}</div>`
191
+ : decision === undefined
192
+ ? `<button class="btn btn-accept btn-select" data-candidate-id="${escapeHtml(candidate.id)}">Use this value</button>`
193
+ : ""
194
+ : "";
195
+ return `<div class="card is-proposed"${conflict && candidate ? ` data-candidate-id="${escapeHtml(candidate.id)}"` : ""}>
139
196
  <div class="card-label">${escapeHtml(label)}</div>
140
197
  <div class="value">${value.includes("\n") ? `<pre>${escapeHtml(value)}</pre>` : escapeHtml(value)}</div>
141
- <div class="conf">confidence ${confStr(candidate?.extraction?.confidence ?? candidate?.confidence)}</div>
198
+ ${confHtml(candidate?.extraction?.confidence ?? candidate?.confidence)}
142
199
  <div class="source-ref">${escapeHtml(candidate?.source?.sourceRef ?? "—")}</div>
143
200
  ${excerpt ? `<div class="excerpt">${escapeHtml(excerpt)}</div>` : ""}
201
+ ${choice}
144
202
  </div>`;
145
203
  };
146
204
  const proposedCards = conflict
147
205
  ? proposedCandidates.map((candidate, index) => proposedCard(candidate, `Proposed ${index + 1} of ${proposedCandidates.length}`)).join("\n ")
148
206
  : proposedCard(proposedCandidates[0], "Proposed");
149
207
  const itemNameJson = escapeJsonInHtml(item.metadata.name);
150
- const excludedNote = excludedProposalsSentence(buildReviewItemPresentation(item).excludedProposals);
151
- const verificationNotes = candidateVerificationNotes(item, editedValueFor(item, current));
208
+ const itemPresentation = buildReviewItemPresentation(item);
209
+ const excludedNote = excludedProposalsSentence(itemPresentation.excludedProposals);
210
+ const unreadableNote = excludedProposalsUnreadableSentence(itemPresentation.excludedProposalsUnreadable);
211
+ const verificationNotes = blind ? [] : candidateVerificationNotes(item, editedValueFor(item, current));
152
212
  const decisionBadge = decision
153
- ? `<span class="badge badge-${decision === "accept-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(workbenchDecisionDefinitions[decision].label)}</span>`
213
+ ? `<span class="badge badge-${decision === "accept-proposed" || decision === "select-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(decision === "select-proposed" ? `Chose 1 of ${proposedCandidates.length} values` : workbenchDecisionDefinitions[decision].label)}</span>`
154
214
  : `<span class="badge badge-pending">${escapeHtml(status)}</span>`;
155
215
  return `<!doctype html>
156
216
  <html lang="en">
@@ -228,9 +288,16 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
228
288
  .btn-reject:hover,.btn-reject.active{background:color-mix(in srgb,var(--k-negative) 16%,transparent);border-color:var(--k-negative);color:var(--k-negative)}
229
289
  .btn-unconfirmed:hover,.btn-unconfirmed.active{background:color-mix(in srgb,var(--k-caution) 16%,transparent);border-color:var(--k-caution);color:var(--k-caution)}
230
290
  .feedback{font-size:11px;color:var(--k-text-faint);margin-top:8px;min-height:16px}
291
+ .btn-select{width:100%;margin-top:8px}
292
+ .choice{font-family:var(--k-font-mono);font-size:10px;font-weight:600;margin-top:8px;text-transform:uppercase;letter-spacing:.06em}
293
+ .choice.chosen{color:var(--k-positive)}
294
+ .choice.not-chosen{color:var(--k-text-faint)}
295
+ .notice{font-size:11px;color:var(--k-caution);background:color-mix(in srgb,var(--k-caution) 12%,transparent);border-radius:var(--k-radius-sm);padding:7px 10px;margin:0 0 12px}
231
296
  </style>
232
297
  </head>
233
298
  <body>
299
+ ${attestation.state === "unverified" ? `<p class="notice" id="unverified-queue-note">${escapeHtml(attestation.message)}</p>` : ""}
300
+ ${blind ? `<p class="notice" id="score-blind-note">${escapeHtml(scoreBlindLines(current)[0])}</p>` : ""}
234
301
  <p class="eyebrow">Survey Review</p>
235
302
  <h1>${escapeHtml(item.spec.target)}</h1>
236
303
  <div class="meta">
@@ -244,15 +311,18 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
244
311
  <div class="card">
245
312
  <div class="card-label">Current</div>
246
313
  <div class="value">${currentValue.includes("\n") ? `<pre>${escapeHtml(currentValue)}</pre>` : escapeHtml(currentValue)}</div>
247
- <div class="conf">confidence ${currentConf}</div>
314
+ ${currentConf}
248
315
  <div class="source-ref">${escapeHtml(currentSource)}</div>
249
316
  ${currentExcerpt ? `<div class="excerpt">${escapeHtml(currentExcerpt)}</div>` : ""}
250
317
  </div>
251
318
  ${proposedCards}
252
319
  </div>
253
320
  ${excludedNote ? `<p class="feedback" id="excluded-note">${escapeHtml(excludedNote)}</p>` : ""}
321
+ ${unreadableNote ? `<p class="feedback" id="excluded-unreadable-note">${escapeHtml(unreadableNote)}</p>` : ""}
254
322
  ${verificationNotes.map((entry) => `<p class="feedback verification-note" data-candidate-id="${escapeHtml(entry.candidateId)}">${escapeHtml(entry.sentence)}</p>`).join("\n")}
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>` : ""}
323
+ ${conflict ? `<p class="feedback" id="conflict-note">${escapeHtml(chosenId !== undefined
324
+ ? `${proposedCandidates.length} different values were proposed. One was chosen; ${proposedCandidates.length === 2 ? "the other was" : "the others were"} seen and not chosen.`
325
+ : `${proposedCandidates.length} different values were proposed. Use the value the source supports, reject them all, or use Could not confirm with a reason.`)}</p>` : ""}
256
326
 
257
327
  <div class="divider"></div>
258
328
 
@@ -272,7 +342,7 @@ ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length
272
342
  var itemName = ${itemNameJson};
273
343
  var msgId = 1;
274
344
 
275
- function postDecision(decision) {
345
+ function postDecision(decision, candidateId) {
276
346
  var note = document.getElementById('note').value;
277
347
  if (decision === 'could-not-confirm' && !note.trim()) {
278
348
  document.getElementById('feedback').textContent = 'A reason is required when you could not confirm.';
@@ -287,12 +357,18 @@ ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length
287
357
  name: "survey_review_decide",
288
358
  arguments: decision === 'could-not-confirm'
289
359
  ? { itemName: itemName, decision: decision, reason: note }
290
- : { itemName: itemName, decision: decision, note: note || undefined }
360
+ : decision === 'select'
361
+ ? { itemName: itemName, decision: decision, candidateId: candidateId, note: note || undefined }
362
+ : { itemName: itemName, decision: decision, note: note || undefined }
291
363
  }
292
364
  }, "*");
293
365
  return true;
294
366
  }
295
367
 
368
+ Array.prototype.forEach.call(document.querySelectorAll('.btn-select'), function (button) {
369
+ button.addEventListener('click', function () { postDecision('select', button.getAttribute('data-candidate-id')); document.getElementById('feedback').textContent = 'Submitting choice…'; });
370
+ });
371
+
296
372
  var acceptButton = document.getElementById('btn-accept');
297
373
  if (acceptButton) acceptButton.addEventListener('click', function () { postDecision('accept'); document.getElementById('feedback').textContent = 'Submitting accept…'; });
298
374
  var holdButton = document.getElementById('btn-hold');
@@ -318,16 +394,22 @@ ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length
318
394
  // ---- Tool implementations ------------------------------------------------
319
395
  async function toolQueue(options) {
320
396
  const file = await readSessionFile(options.sessionPath);
321
- const { snapshot, events } = file;
322
- const text = queueSummaryText(snapshot, events);
397
+ const { snapshot, events, attestation } = file;
398
+ const text = queueSummaryText(snapshot, events, attestation);
323
399
  const queueData = {
400
+ queueAttestation: attestation.state,
401
+ ...sessionConditions(snapshot),
324
402
  items: snapshot.items.map((item) => {
325
403
  const current = currentSessionState(snapshot, events);
404
+ const selectedCandidateId = current.decisionsByItemName[item.metadata.name] === "select-proposed"
405
+ ? current.selectedCandidateIdsByItemName?.[item.metadata.name]
406
+ : undefined;
326
407
  return {
327
408
  name: item.metadata.name,
328
409
  target: item.spec.target,
329
410
  status: deriveQueueRowStatus(item, current),
330
411
  decision: current.decisionsByItemName[item.metadata.name],
412
+ ...(selectedCandidateId !== undefined ? { selectedCandidateId } : {}),
331
413
  candidateSetStatus: item.spec.candidateSetStatus,
332
414
  };
333
415
  }),
@@ -339,24 +421,30 @@ async function toolQueue(options) {
339
421
  ];
340
422
  if (!options.noUi) {
341
423
  const activeItem = currentReviewItem(currentSessionState(snapshot, events));
342
- content.push(buildUiResource(activeItem, snapshot, events, "queue"));
424
+ content.push(buildUiResource(activeItem, snapshot, events, "queue", attestation));
343
425
  }
344
426
  return content;
345
427
  }
346
428
  async function toolItem(itemName, options) {
347
429
  const file = await readSessionFile(options.sessionPath);
348
- const { snapshot, events } = file;
430
+ const { snapshot, events, attestation } = file;
349
431
  const current = currentSessionState(snapshot, events);
350
432
  const item = current.items.find((i) => i.metadata.name === itemName);
351
433
  if (!item) {
352
434
  throw new DomainError(`Unknown review item: ${itemName}`);
353
435
  }
354
- const text = itemDetailText(item, snapshot, events);
436
+ const text = itemDetailText(item, snapshot, events, attestation);
437
+ const decision = current.decisionsByItemName[item.metadata.name];
438
+ const selectedCandidateId = decision === "select-proposed" ? current.selectedCandidateIdsByItemName?.[item.metadata.name] : undefined;
439
+ const blind = isScoreBlind(current);
355
440
  const itemData = {
441
+ queueAttestation: attestation.state,
442
+ ...sessionConditions(snapshot),
356
443
  name: item.metadata.name,
357
444
  target: item.spec.target,
358
445
  status: deriveQueueRowStatus(item, current),
359
- decision: current.decisionsByItemName[item.metadata.name],
446
+ decision,
447
+ ...(selectedCandidateId !== undefined ? { selectedCandidateId } : {}),
360
448
  note: current.notesByItemName[item.metadata.name],
361
449
  candidateSetStatus: item.spec.candidateSetStatus,
362
450
  ...(() => {
@@ -364,19 +452,21 @@ async function toolItem(itemName, options) {
364
452
  return {
365
453
  ...(presentation.excerptVerification ? { excerptVerification: presentation.excerptVerification } : {}),
366
454
  ...(presentation.excludedProposals.length ? { excludedProposals: presentation.excludedProposals.map(({ proposalIndex, value, locator, excerpt }) => ({ proposalIndex, value, locator, excerpt })) } : {}),
455
+ ...(presentation.excludedProposalsUnreadable ? { excludedProposalsUnreadable: presentation.excludedProposalsUnreadable } : {}),
367
456
  };
368
457
  })(),
369
458
  candidates: (() => {
370
- const notes = candidateVerificationNotes(item, editedValueFor(item, current));
459
+ const notes = blind ? [] : candidateVerificationNotes(item, editedValueFor(item, current));
371
460
  return item.spec.candidates.map((c, index) => {
372
461
  const note = notes.find((entry) => entry.candidateIndex === index);
373
462
  return {
374
463
  id: c.id,
375
464
  role: c.role,
376
465
  value: c.value,
377
- confidence: c.extraction?.confidence ?? c.confidence,
466
+ ...(blind ? {} : { confidence: c.extraction?.confidence ?? c.confidence }),
378
467
  sourceRef: c.source?.sourceRef,
379
468
  excerpt: c.locator?.excerpt,
469
+ ...(selectedCandidateId !== undefined && c.role === "proposed" ? { chosen: c.id === selectedCandidateId } : {}),
380
470
  ...(note ? { verification: { subject: note.subjectLabel, status: note.status, records: note.records, inapplicableCount: note.inapplicableCount, rejectedCount: note.rejectedCount } } : {}),
381
471
  };
382
472
  });
@@ -386,22 +476,23 @@ async function toolItem(itemName, options) {
386
476
  { type: "text", text: `${text}\n\n${JSON.stringify(itemData, null, 2)}` },
387
477
  ];
388
478
  if (!options.noUi) {
389
- content.push(buildUiResource(item, snapshot, events, itemName));
479
+ content.push(buildUiResource(item, snapshot, events, itemName, attestation));
390
480
  }
391
481
  return content;
392
482
  }
393
- async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, options) {
483
+ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, options, selectedCandidateId) {
394
484
  const wbDecision = MCP_DECISION_MAP[mcpDecision];
395
485
  if (!wbDecision) {
396
- throw new DomainError(`Invalid decision: ${mcpDecision}. Must be accept, hold, reject, or could-not-confirm.`);
486
+ throw new DomainError(`Invalid decision: ${mcpDecision}. Must be accept, select, hold, reject, or could-not-confirm.`);
397
487
  }
398
488
  if (wbDecision === "could-not-confirm" && !note?.trim()) {
399
489
  throw new DomainError("survey_review_decide requires a non-empty reason for could-not-confirm");
400
490
  }
401
491
  // Read, validate and write inside the shared session lock so a concurrent
402
492
  // decide or console save cannot interleave and drop this decision (#281).
403
- const { snapshot, sessionWithDecision, newEvents } = await updateReviewSessionFile(options.sessionPath, (file) => {
493
+ const { snapshot, sessionWithDecision, newEvents, attestation } = await updateReviewSessionFile(options.sessionPath, (file) => {
404
494
  const { snapshot, events } = file;
495
+ const attestation = attestedQueue(file);
405
496
  const current = currentSessionState(snapshot, events);
406
497
  const item = current.items.find((i) => i.metadata.name === itemName);
407
498
  if (!item) {
@@ -411,6 +502,12 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
411
502
  if (existingDecision) {
412
503
  throw new DomainError(`Item ${itemName} already has a decision: ${existingDecision}. Use a new session to re-decide.`);
413
504
  }
505
+ if (wbDecision === "select-proposed") {
506
+ const selectionIssue = conflictSelectionIssue(item, selectedCandidateId);
507
+ if (selectionIssue) {
508
+ throw new DomainError(selectionIssue);
509
+ }
510
+ }
414
511
  // Build the updated session state with the decision
415
512
  const sessionWithDecision = {
416
513
  ...current,
@@ -434,6 +531,14 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
434
531
  },
435
532
  }
436
533
  : {}),
534
+ ...(wbDecision === "select-proposed" && selectedCandidateId !== undefined
535
+ ? {
536
+ selectedCandidateIdsByItemName: {
537
+ ...current.selectedCandidateIdsByItemName,
538
+ [itemName]: selectedCandidateId,
539
+ },
540
+ }
541
+ : {}),
437
542
  };
438
543
  // Append only this decision's events (its note, then the decision) to the
439
544
  // stored log. Regenerating the whole log from state would erase earlier
@@ -455,26 +560,36 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
455
560
  record,
456
561
  events: newEvents,
457
562
  requiredResolvedItems: "none",
563
+ extractionImport: file.extractionImport,
458
564
  });
459
565
  if (!applyResult.ok) {
460
566
  throw new DomainError(`Decision validation failed: ${applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue)).join("; ")}`);
461
567
  }
462
568
  const updatedFile = {
569
+ ...file,
463
570
  session: file.session,
464
571
  snapshot,
465
572
  events: newEvents,
466
573
  };
467
- return { next: updatedFile, result: { snapshot, sessionWithDecision, newEvents } };
574
+ return { next: updatedFile, result: { snapshot, sessionWithDecision, newEvents, attestation } };
468
575
  });
469
576
  // Summarize the result
470
577
  const updatedItem = sessionWithDecision.items.find((i) => i.metadata.name === itemName);
471
- const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents) : `Item: ${itemName}`;
472
- const remainingText = queueSummaryText(snapshot, newEvents);
578
+ const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents, attestation) : `Item: ${itemName}`;
579
+ const remainingText = queueSummaryText(snapshot, newEvents, attestation);
473
580
  const definition = workbenchDecisionDefinitions[wbDecision];
474
581
  const conflictRejected = wbDecision === "reject-proposed" && updatedItem !== undefined && decisionSelectsNoCandidate(updatedItem, wbDecision);
582
+ const valueStr = (v) => (typeof v === "string" ? v : JSON.stringify(v));
583
+ const proposed = updatedItem?.spec.candidates.filter((c) => c.role === "proposed") ?? [];
584
+ const chosen = proposed.find((c) => c.id === selectedCandidateId);
585
+ const effect = conflictRejected
586
+ ? "Every proposed value is rejected; none becomes the claim's value."
587
+ : wbDecision === "select-proposed"
588
+ ? `${valueStr(chosen?.value)} becomes the verified value; not chosen: ${proposed.filter((c) => c.id !== selectedCandidateId).map((c) => valueStr(c.value)).join(", ")}.`
589
+ : definition.effect;
475
590
  const text = [
476
591
  `Decision recorded: ${conflictRejected ? "Reject all values" : definition.label}`,
477
- `Effect: ${conflictRejected ? "Every proposed value is rejected; none becomes the claim's value." : definition.effect}`,
592
+ `Effect: ${effect}`,
478
593
  "",
479
594
  itemText,
480
595
  "",
@@ -483,13 +598,13 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
483
598
  ].join("\n");
484
599
  return [{ type: "text", text }];
485
600
  }
486
- function buildUiResource(item, snapshot, events, instance) {
601
+ function buildUiResource(item, snapshot, events, instance, attestation) {
487
602
  return {
488
603
  type: "resource",
489
604
  resource: {
490
605
  uri: `ui://survey/review-card/${encodeURIComponent(instance)}`,
491
606
  mimeType: "text/html;profile=mcp-app",
492
- text: buildReviewCardHtml(item, snapshot, events),
607
+ text: buildReviewCardHtml(item, snapshot, events, attestation),
493
608
  _meta: {
494
609
  ui: {
495
610
  csp: {
@@ -505,10 +620,10 @@ function buildUiResource(item, snapshot, events, instance) {
505
620
  // Render the declared review card from the same function used by embedded tool
506
621
  // results, so Apps and text-first hosts cannot drift.
507
622
  async function readQueuePanelResource(options) {
508
- const { snapshot, events } = await readSessionFile(options.sessionPath);
623
+ const { snapshot, events, attestation } = await readSessionFile(options.sessionPath);
509
624
  const current = currentSessionState(snapshot, events);
510
625
  const activeItem = currentReviewItem(current);
511
- return buildUiResource(activeItem, snapshot, events, "queue").resource;
626
+ return buildUiResource(activeItem, snapshot, events, "queue", attestation).resource;
512
627
  }
513
628
  // ---- Domain error (maps to isError:true, not a JSON-RPC error) -----------
514
629
  class DomainError extends Error {
@@ -557,7 +672,7 @@ function createReviewMcpServer(options, serverVersion) {
557
672
  }, async ({ itemName }) => runReviewTool(() => toolItem(itemName, options)));
558
673
  server.registerTool("survey_review_decide", {
559
674
  title: "Record a review decision",
560
- description: "Apply a decision to a review item and persist it through Survey's server-owned validation boundary. Decision must be accept, hold, reject, or could-not-confirm. Could-not-confirm requires a reason. Domain failures return isError:true.",
675
+ description: "Apply a decision to a review item and persist it through Survey's server-owned validation boundary. Decision must be accept, select, hold, reject, or could-not-confirm. Select chooses one value of a conflict (several proposed values) by candidateId. Could-not-confirm requires a reason. Domain failures return isError:true.",
561
676
  inputSchema: z.discriminatedUnion("decision", [
562
677
  z.object({
563
678
  itemName: z.string().min(1).describe("The ReviewItem name to decide."),
@@ -581,10 +696,23 @@ function createReviewMcpServer(options, serverVersion) {
581
696
  .optional()
582
697
  .describe("Evidence ids attempted before a could-not-confirm decision."),
583
698
  }),
699
+ z.object({
700
+ itemName: z.string().min(1).describe("The ReviewItem name to decide."),
701
+ decision: z
702
+ .literal("select")
703
+ .describe("select = select-proposed: choose one value of a conflict; the other proposed values are recorded as seen and not chosen."),
704
+ candidateId: z
705
+ .string()
706
+ .min(1)
707
+ .describe("The id of the proposed candidate to use, from survey_review_item."),
708
+ note: z.string().optional().describe("Optional reviewer note or rationale."),
709
+ }),
584
710
  ]),
585
711
  }, async (input) => runReviewTool(() => input.decision === "could-not-confirm"
586
712
  ? toolDecide(input.itemName, input.decision, input.reason, input.attemptEvidenceIds, options)
587
- : toolDecide(input.itemName, input.decision, input.note, undefined, options)));
713
+ : input.decision === "select"
714
+ ? toolDecide(input.itemName, input.decision, input.note, undefined, options, input.candidateId)
715
+ : toolDecide(input.itemName, input.decision, input.note, undefined, options)));
588
716
  if (!options.noUi) {
589
717
  server.registerResource("survey-review-workbench", QUEUE_PANEL_URI, {
590
718
  title: "Survey review workbench",
@@ -200,7 +200,43 @@ export interface ReviewDecisionSpec {
200
200
  * Additive/optional: absent means the candidate's original value was used unchanged.
201
201
  */
202
202
  editedValue?: unknown;
203
+ /**
204
+ * The proposed candidates a `select-proposed` decision saw and did not
205
+ * choose, when the reviewer chose one value of a conflict (`candidateId` is
206
+ * the chosen one). Additive/optional: absent on every other decision, and on
207
+ * decisions recorded before choosing between conflicting values existed.
208
+ */
209
+ unselectedCandidateIds?: string[];
210
+ /**
211
+ * The presentation of the session the decision was made in, copied from the
212
+ * session (never supplied by the reviewer). `{ scoreBlind: true }` means the
213
+ * reviewer saw no confidence and no verifier result before deciding.
214
+ * Absent on decisions from sessions that declare none.
215
+ */
216
+ presentation?: ReviewSessionPresentation;
217
+ /** How the session's items were chosen, copied from the session. */
218
+ sampling?: ReviewSessionSampling;
203
219
  }
220
+ /**
221
+ * How a session presents items to its reviewer, fixed by the session (never
222
+ * by the reviewer). `scoreBlind` hides every proposer confidence and every
223
+ * verifier result on every decision surface for the whole session, so the
224
+ * decision is made without them.
225
+ */
226
+ export interface ReviewSessionPresentation {
227
+ readonly scoreBlind: boolean;
228
+ }
229
+ /**
230
+ * How the session's items were chosen: the ordinary review `queue`, or a
231
+ * seeded `random-audit` draw at `rate` (see `drawRandomAuditSample`).
232
+ */
233
+ export type ReviewSessionSampling = {
234
+ readonly kind: "queue";
235
+ } | {
236
+ readonly kind: "random-audit";
237
+ readonly rate: number;
238
+ readonly seed: string;
239
+ };
204
240
  export interface ReviewDecisionStatus {
205
241
  appliedToClaimIds?: string[];
206
242
  }
@@ -14,6 +14,13 @@ export interface ReviewSessionFileContent {
14
14
  readonly session: unknown;
15
15
  readonly snapshot: ReviewQueueSessionState;
16
16
  readonly events: readonly ReviewSessionEvent[];
17
+ /**
18
+ * The extraction import record (`ExtractionEnvelopeImport`) the snapshot's
19
+ * items were built from, stored beside them so every reload path can check
20
+ * the queue against it. Optional: a session without one whose items came
21
+ * from an import is presented as an unverified queue.
22
+ */
23
+ readonly extractionImport?: unknown;
17
24
  }
18
25
  export interface ReviewSessionFileLockOptions {
19
26
  /** How long to wait for a busy lock before failing. Defaults to 10s. */
@@ -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", "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"];
28
+ export declare const reviewAuditRowKeys: readonly ["current-candidate-id", "proposed-candidate-id", "claim-id", "raw-source-id", "locator", "model", "extractor", "extracted-at", "excluded-proposal", "excluded-proposals-unreadable", "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
  /**
@@ -36,6 +36,7 @@ export const reviewAuditRowKeys = [
36
36
  "extractor",
37
37
  "extracted-at",
38
38
  "excluded-proposal",
39
+ "excluded-proposals-unreadable",
39
40
  // Unselected candidate history
40
41
  "history-value",
41
42
  "candidate-id",