@kontourai/survey 6.0.0 → 7.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 (32) hide show
  1. package/dist/src/candidate-verification.d.ts +175 -0
  2. package/dist/src/candidate-verification.js +334 -0
  3. package/dist/src/canonical-reviewed-trust-input.js +30 -1
  4. package/dist/src/console/review-console-server.js +22 -4
  5. package/dist/src/index.d.ts +6 -4
  6. package/dist/src/index.js +3 -2
  7. package/dist/src/mcp/review-mcp.js +161 -44
  8. package/dist/src/review-resource.d.ts +16 -0
  9. package/dist/src/review-session-file.d.ts +7 -0
  10. package/dist/src/review-workbench/audit-rows.d.ts +1 -1
  11. package/dist/src/review-workbench/audit-rows.js +1 -0
  12. package/dist/src/review-workbench/queue-binding.d.ts +45 -2
  13. package/dist/src/review-workbench/queue-binding.js +45 -0
  14. package/dist/src/review-workbench/review-presentation.d.ts +60 -0
  15. package/dist/src/review-workbench/review-presentation.js +107 -10
  16. package/dist/src/review-workbench/review-queue-session.d.ts +47 -4
  17. package/dist/src/review-workbench/review-queue-session.js +73 -8
  18. package/dist/src/review-workbench/review-session-replay.d.ts +11 -1
  19. package/dist/src/review-workbench/review-session-replay.js +35 -1
  20. package/dist/src/review-workbench/review-workbench-css.generated.js +96 -1
  21. package/dist/src/review-workbench/review-workbench-element.d.ts +10 -0
  22. package/dist/src/review-workbench/review-workbench-element.js +21 -3
  23. package/dist/src/review-workbench/review-workbench.css +96 -1
  24. package/dist/src/review-workbench/review-workbench.d.ts +28 -8
  25. package/dist/src/review-workbench/review-workbench.js +174 -63
  26. package/dist/src/review-workbench/review-workbench.standalone.css +96 -1
  27. package/dist/src/review-workbench/server-review-session.d.ts +18 -0
  28. package/dist/src/review-workbench/server-review-session.js +20 -2
  29. package/dist/src/surface-reviewed-extraction.d.ts +23 -0
  30. package/dist/src/surface-reviewed-extraction.js +64 -1
  31. package/dist/src/to-surface.js +32 -3
  32. package/package.json +1 -1
@@ -1,24 +1,25 @@
1
1
  import { canonicalJson } from "./canonical.js";
2
2
  import { assertReviewResolutionConsistency } from "../producer-discipline.js";
3
- import { candidateForDecision, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
3
+ import { candidateForDecision, conflictSelectionIssue, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, unchosenProposedCandidates, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
4
4
  import { reviewSessionReplayWarningsForSnapshot, validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
5
5
  import { buildSurfaceProjectionPreview, formatValue, } from "./review-surface-preview.js";
6
6
  import { buildReviewCandidatePresentation, buildReviewItemPresentation, } from "./review-presentation.js";
7
7
  import { findSoleCandidateById, reviewResourceApiVersion, } from "../review-resource.js";
8
8
  import { validateAuthorizing, buildAuthorizedActionAuthorizing } from "../review-authorizing.js";
9
- import { excludedProposalsSentence, humanizeIdentifier } from "./review-presentation.js";
9
+ import { attestReviewQueueExtraction } from "./queue-binding.js";
10
+ import { candidateVerificationNotes, excludedProposalsSentence, excludedProposalsUnreadableSentence } from "./review-presentation.js";
10
11
  import { editedValueFromEditorText, isIsoCalendarDate, parsePlainDecimal } from "./edited-value.js";
11
12
  import { createAuditFactTrace, } from "./audit-rows.js";
12
13
  export { reviewAuditRowKeys } from "./audit-rows.js";
13
- export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./queue-binding.js";
14
+ export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, attestReviewQueueExtraction, bindReviewQueue, reviewItemCarriesExtractionBinding, unverifiedExtractionQueueSentence, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./queue-binding.js";
14
15
  export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, } from "./extraction-inspector.js";
15
- export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
16
+ export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, conflictSelectionIssue, unchosenProposedCandidates, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
16
17
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-presentation.js";
17
18
  export { buildSurfaceProjectionPreview, } from "./review-surface-preview.js";
18
19
  export { reviewSessionReplayWarningsForSnapshot, validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
19
20
  export { checkEditedValueForItem, editedValueFromEditorText, } from "./edited-value.js";
20
21
  export { assertReviewDecisionModeAllows, DecisionModeViolationError, validateReviewDecisionMode, } from "./producer-decision-mode.js";
21
- export function buildReviewDecision(state) {
22
+ export function buildReviewDecision(state, options = {}) {
22
23
  if (!state.decision) {
23
24
  return undefined;
24
25
  }
@@ -26,7 +27,8 @@ export function buildReviewDecision(state) {
26
27
  if (state.decision === "could-not-confirm" && !state.note.trim()) {
27
28
  throw new Error("Could not confirm requires a non-empty reason.");
28
29
  }
29
- const candidate = candidateForDecision(state.item, state.decision);
30
+ const candidate = candidateForDecision(state.item, state.decision, state.selectedCandidateId);
31
+ const unchosen = unchosenProposedCandidates(state.item, state.decision, state.selectedCandidateId);
30
32
  // A decision that selects no candidate (a value-neutral decision on a
31
33
  // conflict) records no candidate id and no candidate's projection hints.
32
34
  const selectsNone = decisionSelectsNoCandidate(state.item, state.decision);
@@ -36,7 +38,7 @@ export function buildReviewDecision(state) {
36
38
  ?? `${state.item.metadata.name}:${state.decision}:review-outcome`,
37
39
  };
38
40
  const candidateProjection = selectsNone ? undefined : candidate.projection;
39
- const authorizing = buildDecisionCardAuthorizing(state);
41
+ const authorizing = buildDecisionCardAuthorizing(state, options.presentationAdapter);
40
42
  const reviewDecision = {
41
43
  apiVersion: reviewResourceApiVersion,
42
44
  kind: "ReviewDecision",
@@ -66,6 +68,8 @@ export function buildReviewDecision(state) {
66
68
  ...(state.decision === "accept-proposed" && state.editedValue !== undefined
67
69
  ? { editedValue: state.editedValue }
68
70
  : {}),
71
+ // A choice between conflicting values records the rivals it passed over.
72
+ ...(unchosen.length ? { unselectedCandidateIds: unchosen.map((entry) => entry.id) } : {}),
69
73
  },
70
74
  status: {
71
75
  ...(candidateProjection?.claimId ? { appliedToClaimIds: [candidateProjection.claimId] } : {}),
@@ -91,12 +95,11 @@ const DECISION_CARD_PROMPT_REF = "review-workbench/decision-card@v1";
91
95
  * On validation failure, emits a console warning and returns undefined so the
92
96
  * outcome is recorded without authorizing (transparency-gap-not-blocker per ADR 0004).
93
97
  */
94
- function buildDecisionCardAuthorizing(state) {
98
+ function buildDecisionCardAuthorizing(state, adapter) {
95
99
  if (!state.decision) {
96
100
  return undefined;
97
101
  }
98
- const targetLabel = humanizeIdentifier(state.item.spec.target);
99
- const renderedPrompt = decisionCardRenderedPrompt(state, targetLabel);
102
+ const renderedPrompt = decisionCardRenderedPrompt(state, buildReviewItemPresentation(state.item, adapter), adapter);
100
103
  // Reviewer note present means the reviewer also typed a rationale — "typed".
101
104
  // Control-only affirmation (no note) is "affirmed-control".
102
105
  const action = state.note?.trim() ? "typed" : "affirmed-control";
@@ -121,34 +124,47 @@ function buildDecisionCardAuthorizing(state) {
121
124
  }
122
125
  }
123
126
  /**
124
- * Derives the exact decision prompt rendered on the workbench decision card
125
- * for a given review item and decision.
127
+ * Renders the decision card's prompt for a review item and decision.
128
+ *
129
+ * It is rebuilt from the item when the decision is built, not captured from
130
+ * what a reviewer's screen showed: it records what the card states for this
131
+ * item, with the same presentation adapter, target label and value text.
126
132
  *
127
133
  * Format mirrors the review question shown in the workbench header:
128
134
  * "For {target}, decide whether {proposed} should replace {current}."
129
135
  * followed by the selected decision label so the block is self-contained.
130
136
  */
131
- function decisionCardRenderedPrompt(state, targetLabel) {
132
- // The card shows proposals excluded at import; the recorded prompt must say
133
- // the reviewer was told about them.
134
- const excluded = excludedProposalsSentence(buildReviewItemPresentation(state.item).excludedProposals);
135
- const base = decisionCardBasePrompt(state, targetLabel);
136
- return excluded ? `${base} ${excluded}` : base;
137
- }
138
- function decisionCardBasePrompt(state, targetLabel) {
137
+ function decisionCardRenderedPrompt(state, presentation, adapter) {
138
+ // The card states proposals excluded at import, and stored ones it cannot
139
+ // show; so does the prompt.
140
+ const excluded = excludedProposalsSentence(presentation.excludedProposals);
141
+ const unreadable = excludedProposalsUnreadableSentence(presentation.excludedProposalsUnreadable);
142
+ // Likewise the verifier records the card shows, read against the same value.
143
+ const verification = candidateVerificationNotes(state.item, state.decision === "accept-proposed" ? state.editedValue : undefined).map((note) => note.sentence);
144
+ return [decisionCardBasePrompt(state, presentation.targetLabel, adapter), ...(excluded ? [excluded] : []), ...(unreadable ? [unreadable] : []), ...verification].join(" ");
145
+ }
146
+ function decisionCardBasePrompt(state, targetLabel, adapter) {
147
+ const valueText = (candidate) => buildReviewCandidatePresentation(state.item, candidate, adapter, targetLabel).valueText;
139
148
  const currentCandidate = state.item.spec.candidates.find((c) => c.role === "current");
140
149
  const proposedCandidates = state.item.spec.candidates.filter((c) => c.role === "proposed");
141
- // A conflict card lists every proposed value and offers reject-all or could
142
- // not confirm; the prompt states the same, naming no single value.
150
+ // A conflict card lists every proposed value and offers a choice of one,
151
+ // reject-all or could not confirm; the prompt states the same: every value,
152
+ // and for a choice, which value was chosen and which were not.
143
153
  if (proposedCandidates.length > 1) {
154
+ const values = `For ${targetLabel}, ${proposedCandidates.length} different values were proposed: ${proposedCandidates.map(valueText).join(", ")}.`;
155
+ if (state.decision === "select-proposed") {
156
+ const chosen = proposedCandidates.find((c) => c.id === state.selectedCandidateId);
157
+ const unchosen = proposedCandidates.filter((c) => c.id !== state.selectedCandidateId);
158
+ return `${values} Selected decision: ${workbenchDecisionDefinitions[state.decision].label}: ${chosen ? valueText(chosen) : formatValue("")} (not chosen: ${unchosen.map(valueText).join(", ")}).`;
159
+ }
144
160
  const decisionLabel = state.decision === "reject-proposed"
145
161
  ? "Reject all values"
146
162
  : state.decision ? workbenchDecisionDefinitions[state.decision].label : "";
147
- return `For ${targetLabel}, ${proposedCandidates.length} different values were proposed: ${proposedCandidates.map((c) => formatValue(c.value)).join(", ")}. Selected decision: ${decisionLabel}.`;
163
+ return `${values} Selected decision: ${decisionLabel}.`;
148
164
  }
149
165
  const proposedCandidate = proposedCandidates[0];
150
- const currentValue = formatValue(currentCandidate?.value ?? "");
151
- const proposedValue = formatValue(proposedCandidate?.value ?? "");
166
+ const currentValue = currentCandidate ? valueText(currentCandidate) : formatValue("");
167
+ const proposedValue = proposedCandidate ? valueText(proposedCandidate) : formatValue("");
152
168
  const decisionLabel = state.decision ? workbenchDecisionDefinitions[state.decision].label : "";
153
169
  return `For ${targetLabel}, decide whether ${proposedValue} should replace ${currentValue}. Selected decision: ${decisionLabel}.`;
154
170
  }
@@ -160,7 +176,7 @@ export class ReviewApplyActionMappingError extends Error {
160
176
  this.issues = issues;
161
177
  }
162
178
  }
163
- export function buildReviewDecisionsFromSession(session) {
179
+ export function buildReviewDecisionsFromSession(session, options = {}) {
164
180
  return session.items.flatMap((item) => {
165
181
  const decision = session.decisionsByItemName[item.metadata.name];
166
182
  if (!decision) {
@@ -169,7 +185,7 @@ export function buildReviewDecisionsFromSession(session) {
169
185
  const reviewDecision = buildReviewDecision(currentReviewWorkbenchState({
170
186
  ...session,
171
187
  activeItemName: item.metadata.name,
172
- }));
188
+ }), options);
173
189
  return reviewDecision ? [reviewDecision] : [];
174
190
  });
175
191
  }
@@ -290,7 +306,7 @@ function matchingSelectedCandidate(item, result) {
290
306
  function structuralEqual(left, right) {
291
307
  return canonicalJson(left) === canonicalJson(right);
292
308
  }
293
- export function buildReviewWorkbenchResultsFromSession(session) {
309
+ export function buildReviewWorkbenchResultsFromSession(session, options = {}) {
294
310
  return session.items.flatMap((item) => {
295
311
  const decision = session.decisionsByItemName[item.metadata.name];
296
312
  if (!decision) {
@@ -300,7 +316,7 @@ export function buildReviewWorkbenchResultsFromSession(session) {
300
316
  ...session,
301
317
  activeItemName: item.metadata.name,
302
318
  });
303
- const reviewDecision = buildReviewDecision(state);
319
+ const reviewDecision = buildReviewDecision(state, options);
304
320
  if (!reviewDecision) {
305
321
  return [];
306
322
  }
@@ -314,11 +330,12 @@ export function buildReviewWorkbenchResultsFromSession(session) {
314
330
  rationale: reviewDecision.spec.rationale,
315
331
  }];
316
332
  }
317
- const selectedCandidate = candidateForDecision(item, decision);
333
+ const selectedCandidateId = session.selectedCandidateIdsByItemName?.[item.metadata.name];
334
+ const selectedCandidate = candidateForDecision(item, decision, selectedCandidateId);
318
335
  const editedValue = decision === "accept-proposed"
319
336
  ? session.editedValuesByItemName?.[item.metadata.name]
320
337
  : undefined;
321
- const effectiveValue = effectiveValueForDecision(item, decision, editedValue);
338
+ const effectiveValue = effectiveValueForDecision(item, decision, editedValue, selectedCandidateId);
322
339
  return [{
323
340
  reviewItemName: item.metadata.name,
324
341
  decision,
@@ -337,12 +354,12 @@ export function buildReviewWorkbenchResultsFromSession(session) {
337
354
  }];
338
355
  });
339
356
  }
340
- export function buildReviewWorkbenchSessionExport(session, events = buildReviewSessionEvents(session)) {
357
+ export function buildReviewWorkbenchSessionExport(session, events = buildReviewSessionEvents(session), options = {}) {
341
358
  return {
342
359
  session: buildReviewSessionResource(session, events),
343
360
  events,
344
- decisions: buildReviewDecisionsFromSession(session),
345
- results: buildReviewWorkbenchResultsFromSession(session),
361
+ decisions: buildReviewDecisionsFromSession(session, options),
362
+ results: buildReviewWorkbenchResultsFromSession(session, options),
346
363
  };
347
364
  }
348
365
  export function replayReviewSessionEventsForSnapshot(snapshot, events) {
@@ -352,9 +369,9 @@ export function replayReviewSessionEventsForSnapshot(snapshot, events) {
352
369
  }
353
370
  return replayReviewSessionEvents(snapshot, events);
354
371
  }
355
- export function buildReviewWorkbenchSessionExportForSnapshot(snapshot, events) {
372
+ export function buildReviewWorkbenchSessionExportForSnapshot(snapshot, events, options = {}) {
356
373
  const replayedSession = replayReviewSessionEventsForSnapshot(snapshot, events);
357
- return buildReviewWorkbenchSessionExport(replayedSession, events);
374
+ return buildReviewWorkbenchSessionExport(replayedSession, events, options);
358
375
  }
359
376
  export function deriveReviewSessionApplyResultForSnapshot(options) {
360
377
  const requiredResolvedItems = options.requiredResolvedItems ?? "none";
@@ -370,7 +387,7 @@ export function deriveReviewSessionApplyResultForSnapshot(options) {
370
387
  }
371
388
  const warnings = reviewSessionReplayWarningsForSnapshot(options.snapshot, options.events);
372
389
  const replayedSession = replayReviewSessionEvents(options.snapshot, options.events);
373
- const sessionExport = buildReviewWorkbenchSessionExport(replayedSession, options.events);
390
+ const sessionExport = buildReviewWorkbenchSessionExport(replayedSession, options.events, { presentationAdapter: options.presentationAdapter });
374
391
  const resolvedItemNames = new Set(sessionExport.results.map((result) => result.reviewItemName));
375
392
  const unresolvedItemNames = options.snapshot.items
376
393
  .map((item) => item.metadata.name)
@@ -497,7 +514,7 @@ export function createPersistentReviewSessionEventStore(options) {
497
514
  }
498
515
  export function renderReviewWorkbenchHtml(state, _events, options = {}) {
499
516
  const session = queueSessionFromStartState(state);
500
- return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView));
517
+ return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView), attestReviewQueueExtraction(session.items, options.extractionImport));
501
518
  }
502
519
  /**
503
520
  * The visual/decision state of a field card, derived from the reviewer's local
@@ -505,7 +522,7 @@ export function renderReviewWorkbenchHtml(state, _events, options = {}) {
505
522
  * candidate set (e.g. a pre-decided item seeded by the host) as already-kept.
506
523
  */
507
524
  function fieldCardState(item, decision) {
508
- if (decision === "accept-proposed")
525
+ if (decision === "accept-proposed" || decision === "select-proposed")
509
526
  return "accepted";
510
527
  if (decision === "keep-current")
511
528
  return "kept";
@@ -527,7 +544,9 @@ function fieldCardState(item, decision) {
527
544
  */
528
545
  function chipLabel(state, hasCurrentValue, conflictingValues) {
529
546
  switch (state) {
530
- case "accepted": return "Accepted";
547
+ // On a conflict the only accepting decision is a choice of one value, and
548
+ // the chip says it was a choice, never a plain accept.
549
+ case "accepted": return conflictingValues ? `Chose 1 of ${conflictingValues} values` : "Accepted";
531
550
  case "kept": return hasCurrentValue ? "Kept current" : conflictingValues ? "All values rejected" : "Left unset";
532
551
  case "rejected": return hasCurrentValue ? "Kept — flagged wrong" : conflictingValues ? "All values rejected" : "Left unset";
533
552
  case "could-not-confirm": return "Could not confirm";
@@ -583,7 +602,16 @@ function isEmptyValue(value) {
583
602
  }
584
603
  const ARROW_SVG = "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><path d=\"M5 12h14M13 6l6 6-6 6\"/></svg>";
585
604
  const WARNING_SVG = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><path d=\"M12 9v4M12 17h.01\"/><path d=\"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z\"/></svg>";
586
- function renderReviewQueueSessionHtml(session, presentationAdapter, queueView) {
605
+ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, attestation) {
606
+ // A queue that does not match its stored extraction import is not reviewed:
607
+ // its items could hide or alter what the source said.
608
+ if (attestation.state === "diverges") {
609
+ return `
610
+ <section class="workbench-shell review" data-testid="review-workbench-shell" data-queue-attestation="diverges" aria-label="Survey review workbench">
611
+ <div class="queue-attestation diverges" data-testid="queue-attestation" data-state="diverges" role="alert">${WARNING_SVG}<span>${escapeHtml(attestation.message)}</span></div>
612
+ </section>
613
+ `;
614
+ }
587
615
  const totalCount = session.items.length;
588
616
  const decidedCount = session.items.filter((item) => session.decisionsByItemName[item.metadata.name] !== undefined
589
617
  || item.spec.candidateSetStatus === "resolved").length;
@@ -593,7 +621,10 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView) {
593
621
  const headerTitle = typeof producerName === "string" && producerName.length > 0 ? producerName : "Review queue";
594
622
  const window = buildReviewQueueWindow(session, queueView);
595
623
  return `
596
- <section class="workbench-shell review" data-testid="review-workbench-shell" aria-label="Survey review workbench">
624
+ <section class="workbench-shell review" data-testid="review-workbench-shell" data-queue-attestation="${attestation.state}" aria-label="Survey review workbench">
625
+ ${attestation.state === "unverified"
626
+ ? `<div class="queue-attestation unverified" data-testid="queue-attestation" data-state="unverified" role="note">${WARNING_SVG}<span>${escapeHtml(attestation.message)}</span></div>`
627
+ : ""}
597
628
  <header class="rhead">
598
629
  <div class="top">
599
630
  <div class="subj">
@@ -653,9 +684,9 @@ function renderFieldCard(item, session, presentationAdapter) {
653
684
  const decided = decision !== undefined;
654
685
  const current = item.spec.candidates.find((candidate) => candidate.role === "current");
655
686
  const proposedCandidates = item.spec.candidates.filter((candidate) => candidate.role === "proposed");
656
- // Several proposed values for one claim are a conflict. A control names a
657
- // role, not a value, so none may make one of them the trusted value; the
658
- // reviewer can still reject them all or end the round as could-not-confirm.
687
+ // Several proposed values for one claim are a conflict. The reviewer can
688
+ // choose one of them by candidate id (select-proposed), reject them all, or
689
+ // end the round as could-not-confirm; no role-based control may pick one.
659
690
  const conflict = proposedCandidates.length > 1;
660
691
  const proposed = proposedCandidates.length === 1 ? proposedCandidates[0] : undefined;
661
692
  const presentation = buildReviewItemPresentation(item, presentationAdapter);
@@ -697,10 +728,11 @@ function renderFieldCard(item, session, presentationAdapter) {
697
728
  ${proposed
698
729
  ? renderDiffRow(item, current, proposed, presentation.targetLabel, decided, effectiveProposedText, currentPresentationText)
699
730
  : proposedCandidates.length > 1
700
- ? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel)
731
+ ? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name])
701
732
  : "<p class=\"field-value\">No proposed value is available for this field.</p>"}
702
733
  ${proposed ? renderProvenanceRow(item, proposed, presentationAdapter) : ""}
703
734
  ${renderExtractionImportNotes(presentation)}
735
+ ${renderVerificationNotes(candidateVerificationNotes(item, decision === "accept-proposed" ? editedValue : undefined))}
704
736
  <div class="decide">
705
737
  ${keepDecision === undefined ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>`}
706
738
  ${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">Use proposed</button>
@@ -730,18 +762,37 @@ function renderFieldCard(item, session, presentationAdapter) {
730
762
  }
731
763
  /**
732
764
  * The card body for an item whose candidate set holds several proposed values.
733
- * Lists every value with its excerpt; the workbench records a decision against
734
- * a role, so it cannot pick one of them, and says what the reviewer can do.
765
+ * Lists every value with its excerpt. Until the field is decided, each value
766
+ * has its own "Use this value" control, which records `select-proposed` with
767
+ * that candidate's id. Once a value is chosen, every value stays listed and is
768
+ * marked chosen or not chosen, so the card never reads as if there had been
769
+ * one value. Proposals excluded at import are not candidates: they are listed
770
+ * by {@link renderExtractionImportNotes} and cannot be chosen.
735
771
  */
736
- function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel) {
772
+ function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel, decision, selectedCandidateId) {
773
+ const chose = decision === "select-proposed";
737
774
  const values = candidates.map((candidate) => {
738
775
  const text = buildReviewCandidatePresentation(item, candidate, presentationAdapter, targetLabel).valueText;
739
776
  const excerpt = candidate.locator?.excerpt;
740
- return `<li data-testid="conflicting-value"><span class="vtext">${escapeHtml(text)}</span>${excerpt ? ` <q>${escapeHtml(excerpt)}</q>` : ""}</li>`;
777
+ const chosen = chose && candidate.id === selectedCandidateId;
778
+ const mark = chose
779
+ ? ` <span class="choice-tag ${chosen ? "chosen" : "not-chosen"}" data-testid="choice-tag">${chosen ? "Chosen" : "Not chosen"}</span>`
780
+ : "";
781
+ const control = decision === undefined
782
+ ? `<button class="btn choose" type="button" data-testid="select-value" data-item-name="${escapeHtml(item.metadata.name)}" data-candidate-id="${escapeHtml(candidate.id)}" aria-label="Use ${escapeHtml(text)} for ${escapeHtml(targetLabel)}">Use this value</button>`
783
+ : "";
784
+ return `<li data-testid="conflicting-value" data-candidate-id="${escapeHtml(candidate.id)}"${chose ? ` data-chosen="${chosen ? "true" : "false"}"` : ""}>
785
+ <span class="conflict-text"><span class="vtext">${escapeHtml(text)}</span>${excerpt ? ` <q>${escapeHtml(excerpt)}</q>` : ""}${mark}</span>${control}
786
+ </li>`;
741
787
  }).join("");
788
+ const intro = decision === undefined
789
+ ? `${candidates.length} different values were proposed for this field. Use the value the source supports, reject them all, or mark the field Could not confirm with a reason.`
790
+ : chose
791
+ ? `${candidates.length} different values were proposed for this field. One was chosen; ${candidates.length === 2 ? "the other was" : "the others were"} seen and not chosen.`
792
+ : `${candidates.length} different values were proposed for this field.`;
742
793
  return `
743
- <div class="field-value" data-testid="conflicting-proposals">
744
- <p>${candidates.length} different values were proposed for this field. This queue cannot choose one of them yet: reject them all, or mark the field Could not confirm with a reason.</p>
794
+ <div class="field-value conflict-values" data-testid="conflicting-proposals">
795
+ <p>${escapeHtml(intro)}</p>
745
796
  <ul>${values}</ul>
746
797
  </div>
747
798
  `;
@@ -942,7 +993,8 @@ function renderProvenanceRow(item, proposed, presentationAdapter) {
942
993
  */
943
994
  function renderExtractionImportNotes(presentation) {
944
995
  const { excerptVerification: verification, excludedProposals: excluded } = presentation;
945
- if (verification === undefined && excluded.length === 0)
996
+ const unreadable = excludedProposalsUnreadableSentence(presentation.excludedProposalsUnreadable);
997
+ if (verification === undefined && excluded.length === 0 && unreadable === undefined)
946
998
  return "";
947
999
  const excludedHtml = excluded.length === 0 ? "" : `
948
1000
  <div class="noprov" data-testid="excluded-proposals" role="note">
@@ -952,12 +1004,32 @@ function renderExtractionImportNotes(presentation) {
952
1004
  ${excluded.map((entry) => `<span data-testid="excluded-proposal" data-proposal-index="${escapeHtml(String(entry.proposalIndex))}"><q>${escapeHtml(entry.valueText)}</q> (proposal ${escapeHtml(String(entry.proposalIndex))}, ${escapeHtml(entry.locator)})</span>`).join(", ")}.
953
1005
  Unverifiable is not disproven: check the source before deciding.</span>
954
1006
  </div>`;
1007
+ const unreadableHtml = unreadable === undefined ? "" : `
1008
+ <div class="noprov" data-testid="excluded-proposals-unreadable" data-reason="${escapeHtml(presentation.excludedProposalsUnreadable.reason)}" role="note">
1009
+ ${WARNING_SVG}
1010
+ <span class="tag">${presentation.excludedProposalsUnreadable.reason === "binding-broken" ? "Binding broken" : "Unreadable"}</span>
1011
+ <span>${escapeHtml(unreadable)} Check the source before deciding.</span>
1012
+ </div>`;
955
1013
  const verificationHtml = verification === undefined ? "" : `
956
1014
  <p class="excerpt-verification" data-testid="excerpt-verification" data-verified="${verification === "verified" ? "true" : "false"}">${verification === "verified"
957
1015
  ? "Excerpts checked against the prepared source text at import."
958
1016
  : "Excerpts not checked against the prepared source text at import."}</p>`;
959
1017
  return `
960
- <div class="prov import-notes">${excludedHtml}${verificationHtml}
1018
+ <div class="prov import-notes">${excludedHtml}${unreadableHtml}${verificationHtml}
1019
+ </div>
1020
+ `;
1021
+ }
1022
+ /**
1023
+ * What verifier records say about the value on this card. A record is what a
1024
+ * verifier said, not a review decision; records bound to another value and
1025
+ * records that failed validation are counted, never shown as verdicts.
1026
+ */
1027
+ function renderVerificationNotes(notes) {
1028
+ if (notes.length === 0)
1029
+ return "";
1030
+ return `
1031
+ <div class="prov import-notes">${notes.map((note) => `
1032
+ <p class="support-verification" data-testid="support-verification" data-candidate-id="${escapeHtml(note.candidateId)}" data-status="${note.status}">${escapeHtml(note.sentence)}</p>`).join("")}
961
1033
  </div>
962
1034
  `;
963
1035
  }
@@ -989,10 +1061,11 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
989
1061
  note,
990
1062
  decision,
991
1063
  editedValue: session.editedValuesByItemName?.[item.metadata.name],
1064
+ selectedCandidateId: session.selectedCandidateIdsByItemName?.[item.metadata.name],
992
1065
  reviewedAt: session.reviewedAt,
993
1066
  actorId: session.actorId,
994
1067
  };
995
- const reviewDecisionPayload = buildReviewDecision(state);
1068
+ const reviewDecisionPayload = buildReviewDecision(state, { presentationAdapter });
996
1069
  const preview = buildSurfaceProjectionPreview(item, reviewDecisionPayload, presentationAdapter);
997
1070
  // Seeded with what the card face already shows, so the audit surface never
998
1071
  // reprints it. Passed down through every section: the sections render in DOM
@@ -1017,7 +1090,12 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
1017
1090
  ${proposed?.extraction.model ? fieldItem("model", "Model", proposed.extraction.model) : ""}
1018
1091
  ${proposed ? placementItem(trace, { of: proposed.id, property: "extraction.extractor" }, "extractor", "Extractor", proposed.extraction.extractor ?? "unknown") : ""}
1019
1092
  ${proposed ? fieldItem("extracted-at", "Extracted at", proposed.extraction.extractedAt ?? "unknown") : ""}
1020
- ${buildReviewItemPresentation(item, presentationAdapter).excludedProposals.map((entry) => fieldItem("excluded-proposal", `Excluded proposal ${entry.proposalIndex}`, `${entry.valueText} at ${entry.locator}: excerpt not at the cited span`)).join("")}
1093
+ ${(() => {
1094
+ const itemPresentation = buildReviewItemPresentation(item, presentationAdapter);
1095
+ const unreadable = excludedProposalsUnreadableSentence(itemPresentation.excludedProposalsUnreadable);
1096
+ return itemPresentation.excludedProposals.map((entry) => fieldItem("excluded-proposal", `Excluded proposal ${entry.proposalIndex}`, `${entry.valueText} at ${entry.locator}: excerpt not at the cited span`)).join("")
1097
+ + (unreadable ? fieldItem("excluded-proposals-unreadable", "Excluded proposals not shown", unreadable) : "");
1098
+ })()}
1021
1099
  </dl>
1022
1100
  ${preview
1023
1101
  ? `<div class="preview-section-grid">${renderSurfacePreviewSections(preview, trace)}</div><p class="preview-disclaimer preview-disclaimer-footer">${escapeHtml(preview.postureDisclaimer)}</p>`
@@ -1241,6 +1319,9 @@ function createReviewWorkbenchController(root, startState, options) {
1241
1319
  let events = eventStore?.load(baseSession) ?? [];
1242
1320
  let session = events.length > 0 ? replayReviewSessionEvents(baseSession, events) : baseSession;
1243
1321
  let queueView = normalizeReviewQueueView({ pageSize: options.pageSize });
1322
+ // The queue's items never change after mount (decisions live in events), so
1323
+ // the check against its extraction import runs once.
1324
+ const attestation = attestReviewQueueExtraction(baseSession.items, options.extractionImport);
1244
1325
  const persistEvents = () => {
1245
1326
  eventStore?.save(session, events);
1246
1327
  };
@@ -1256,7 +1337,7 @@ function createReviewWorkbenchController(root, startState, options) {
1256
1337
  const session = sessionForEvent;
1257
1338
  const item = itemName ? session.items.find((entry) => entry.metadata.name === itemName) : undefined;
1258
1339
  const decision = itemName ? session.decisionsByItemName[itemName] : undefined;
1259
- const candidateId = item && decision ? decisionCandidateId(item, decision) : undefined;
1340
+ const candidateId = item && decision ? decisionCandidateId(item, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name]) : undefined;
1260
1341
  const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
1261
1342
  const note = itemName ? session.notesByItemName[itemName] : undefined;
1262
1343
  // Carry the reviewer's inline edit in the event (accept-proposed only), so
@@ -1299,7 +1380,7 @@ function createReviewWorkbenchController(root, startState, options) {
1299
1380
  events = [...events, event];
1300
1381
  persistEvents();
1301
1382
  };
1302
- const setDecision = (itemName, decision, rawEditedValue) => {
1383
+ const setDecision = (itemName, decision, rawEditedValue, selectedCandidateId) => {
1303
1384
  if (decision === "could-not-confirm" && !session.notesByItemName[itemName]?.trim()) {
1304
1385
  throw new Error("Could not confirm requires a non-empty reason.");
1305
1386
  }
@@ -1318,11 +1399,19 @@ function createReviewWorkbenchController(root, startState, options) {
1318
1399
  else {
1319
1400
  delete nextEditedValuesByItemName[itemName];
1320
1401
  }
1402
+ const nextSelections = { ...session.selectedCandidateIdsByItemName };
1403
+ if (decision === "select-proposed" && selectedCandidateId !== undefined) {
1404
+ nextSelections[itemName] = selectedCandidateId;
1405
+ }
1406
+ else {
1407
+ delete nextSelections[itemName];
1408
+ }
1321
1409
  commitSessionUpdate({
1322
1410
  ...session,
1323
1411
  activeItemName: itemName,
1324
1412
  decisionsByItemName: { ...session.decisionsByItemName, [itemName]: decision },
1325
1413
  editedValuesByItemName: nextEditedValuesByItemName,
1414
+ selectedCandidateIdsByItemName: nextSelections,
1326
1415
  }, "decision-changed", itemName);
1327
1416
  };
1328
1417
  const clearDecision = (itemName) => {
@@ -1332,12 +1421,15 @@ function createReviewWorkbenchController(root, startState, options) {
1332
1421
  delete remainingEdits[itemName];
1333
1422
  const remainingAttemptEvidenceIds = { ...session.attemptEvidenceIdsByItemName };
1334
1423
  delete remainingAttemptEvidenceIds[itemName];
1424
+ const remainingSelections = { ...session.selectedCandidateIdsByItemName };
1425
+ delete remainingSelections[itemName];
1335
1426
  commitSessionUpdate({
1336
1427
  ...session,
1337
1428
  activeItemName: itemName,
1338
1429
  decisionsByItemName: remainingDecisions,
1339
1430
  editedValuesByItemName: remainingEdits,
1340
1431
  attemptEvidenceIdsByItemName: remainingAttemptEvidenceIds,
1432
+ selectedCandidateIdsByItemName: remainingSelections,
1341
1433
  }, "decision-changed", itemName, { workbenchDecision: null });
1342
1434
  };
1343
1435
  const updateReviewerNote = (itemName, note) => {
@@ -1345,8 +1437,9 @@ function createReviewWorkbenchController(root, startState, options) {
1345
1437
  };
1346
1438
  const controller = {
1347
1439
  currentSession: () => session,
1348
- currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events),
1349
- renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter, queueView),
1440
+ currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events, { presentationAdapter: options.presentationAdapter }),
1441
+ presentationAdapter: options.presentationAdapter,
1442
+ renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter, queueView, attestation),
1350
1443
  setDecision,
1351
1444
  clearDecision,
1352
1445
  updateReviewerNote,
@@ -1366,8 +1459,8 @@ function reviewSessionStorageKey(session, keyPrefix) {
1366
1459
  const itemNames = buildReviewSessionResource(session).spec.reviewItemNames.join(",");
1367
1460
  return `${keyPrefix}:${itemNames}`;
1368
1461
  }
1369
- function renderCurrentState(root, session, controller, presentationAdapter, queueView) {
1370
- root.innerHTML = renderReviewWorkbenchHtml(session, undefined, { presentationAdapter, queueView });
1462
+ function renderCurrentState(root, session, controller, presentationAdapter, queueView, attestation) {
1463
+ root.innerHTML = renderReviewQueueSessionHtml(session, presentationAdapter, queueView ?? normalizeReviewQueueView(undefined), attestation ?? attestReviewQueueExtraction(session.items, undefined));
1371
1464
  bindFieldCardInteractions(root, controller);
1372
1465
  bindApplyButton(root, controller);
1373
1466
  bindClampToggles(root);
@@ -1415,6 +1508,9 @@ function queueSessionFromStartState(startState) {
1415
1508
  ...(startState.attemptEvidenceIds?.length
1416
1509
  ? { attemptEvidenceIdsByItemName: { [startState.item.metadata.name]: [...startState.attemptEvidenceIds] } }
1417
1510
  : {}),
1511
+ ...(startState.decision === "select-proposed" && startState.selectedCandidateId !== undefined
1512
+ ? { selectedCandidateIdsByItemName: { [startState.item.metadata.name]: startState.selectedCandidateId } }
1513
+ : {}),
1418
1514
  reviewedAt: startState.reviewedAt,
1419
1515
  actorId: startState.actorId,
1420
1516
  };
@@ -1472,6 +1568,21 @@ function bindFieldCardInteractions(root, controller) {
1472
1568
  controller.renderCurrentState();
1473
1569
  });
1474
1570
  });
1571
+ // One value of a conflict: the button names its candidate, and the decision
1572
+ // records that id. The builder refuses an id that is not one of the item's
1573
+ // proposed candidates, so a stale or forged button changes nothing.
1574
+ root.querySelectorAll("[data-testid='select-value']").forEach((button) => {
1575
+ button.addEventListener("click", () => {
1576
+ const itemName = button.dataset.itemName ?? "";
1577
+ const candidateId = button.dataset.candidateId ?? "";
1578
+ const item = controller.currentSession().items.find((entry) => entry.metadata.name === itemName);
1579
+ if (!item || conflictSelectionIssue(item, candidateId)) {
1580
+ return;
1581
+ }
1582
+ controller.setDecision(itemName, "select-proposed", undefined, candidateId);
1583
+ controller.renderCurrentState();
1584
+ });
1585
+ });
1475
1586
  root.querySelectorAll("[data-testid='keep-current']").forEach((button) => {
1476
1587
  button.addEventListener("click", () => {
1477
1588
  const itemName = button.dataset.itemName ?? "";
@@ -1582,7 +1693,7 @@ function refreshAuditPayloadForItem(textarea, controller, itemName) {
1582
1693
  reviewedAt: session.reviewedAt,
1583
1694
  actorId: session.actorId,
1584
1695
  };
1585
- payload.textContent = JSON.stringify(buildReviewDecision(state) ?? null, null, 2);
1696
+ payload.textContent = JSON.stringify(buildReviewDecision(state, { presentationAdapter: controller.presentationAdapter }) ?? null, null, 2);
1586
1697
  }
1587
1698
  function bindClampToggles(root) {
1588
1699
  root.querySelectorAll("[data-clamp-toggle]").forEach((button) => {