@kontourai/survey 6.1.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 (31) hide show
  1. package/dist/example-data/public-directory-review-resource.d.ts +3 -3
  2. package/dist/src/canonical-reviewed-trust-input.js +30 -1
  3. package/dist/src/console/review-console-server.js +22 -4
  4. package/dist/src/index.d.ts +3 -3
  5. package/dist/src/index.js +1 -1
  6. package/dist/src/mcp/review-mcp.js +139 -36
  7. package/dist/src/review-resource.d.ts +7 -0
  8. package/dist/src/review-session-file.d.ts +7 -0
  9. package/dist/src/review-workbench/audit-rows.d.ts +1 -1
  10. package/dist/src/review-workbench/audit-rows.js +1 -0
  11. package/dist/src/review-workbench/queue-binding.d.ts +45 -2
  12. package/dist/src/review-workbench/queue-binding.js +45 -0
  13. package/dist/src/review-workbench/review-presentation.d.ts +23 -0
  14. package/dist/src/review-workbench/review-presentation.js +51 -10
  15. package/dist/src/review-workbench/review-queue-session.d.ts +47 -4
  16. package/dist/src/review-workbench/review-queue-session.js +73 -8
  17. package/dist/src/review-workbench/review-session-replay.d.ts +11 -1
  18. package/dist/src/review-workbench/review-session-replay.js +35 -1
  19. package/dist/src/review-workbench/review-workbench-css.generated.js +94 -0
  20. package/dist/src/review-workbench/review-workbench-element.d.ts +10 -0
  21. package/dist/src/review-workbench/review-workbench-element.js +21 -3
  22. package/dist/src/review-workbench/review-workbench.css +94 -0
  23. package/dist/src/review-workbench/review-workbench.d.ts +28 -8
  24. package/dist/src/review-workbench/review-workbench.js +157 -62
  25. package/dist/src/review-workbench/review-workbench.standalone.css +94 -0
  26. package/dist/src/review-workbench/server-review-session.d.ts +18 -0
  27. package/dist/src/review-workbench/server-review-session.js +20 -2
  28. package/dist/src/surface-reviewed-extraction.d.ts +23 -0
  29. package/dist/src/surface-reviewed-extraction.js +64 -1
  30. package/dist/src/to-surface.js +32 -3
  31. 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 { candidateVerificationNotes, 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,35 +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
- // Likewise the verifier records the card showed, read against the same value.
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.
136
143
  const verification = candidateVerificationNotes(state.item, state.decision === "accept-proposed" ? state.editedValue : undefined).map((note) => note.sentence);
137
- return [decisionCardBasePrompt(state, targetLabel), ...(excluded ? [excluded] : []), ...verification].join(" ");
144
+ return [decisionCardBasePrompt(state, presentation.targetLabel, adapter), ...(excluded ? [excluded] : []), ...(unreadable ? [unreadable] : []), ...verification].join(" ");
138
145
  }
139
- function decisionCardBasePrompt(state, targetLabel) {
146
+ function decisionCardBasePrompt(state, targetLabel, adapter) {
147
+ const valueText = (candidate) => buildReviewCandidatePresentation(state.item, candidate, adapter, targetLabel).valueText;
140
148
  const currentCandidate = state.item.spec.candidates.find((c) => c.role === "current");
141
149
  const proposedCandidates = state.item.spec.candidates.filter((c) => c.role === "proposed");
142
- // A conflict card lists every proposed value and offers reject-all or could
143
- // 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.
144
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
+ }
145
160
  const decisionLabel = state.decision === "reject-proposed"
146
161
  ? "Reject all values"
147
162
  : state.decision ? workbenchDecisionDefinitions[state.decision].label : "";
148
- 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}.`;
149
164
  }
150
165
  const proposedCandidate = proposedCandidates[0];
151
- const currentValue = formatValue(currentCandidate?.value ?? "");
152
- const proposedValue = formatValue(proposedCandidate?.value ?? "");
166
+ const currentValue = currentCandidate ? valueText(currentCandidate) : formatValue("");
167
+ const proposedValue = proposedCandidate ? valueText(proposedCandidate) : formatValue("");
153
168
  const decisionLabel = state.decision ? workbenchDecisionDefinitions[state.decision].label : "";
154
169
  return `For ${targetLabel}, decide whether ${proposedValue} should replace ${currentValue}. Selected decision: ${decisionLabel}.`;
155
170
  }
@@ -161,7 +176,7 @@ export class ReviewApplyActionMappingError extends Error {
161
176
  this.issues = issues;
162
177
  }
163
178
  }
164
- export function buildReviewDecisionsFromSession(session) {
179
+ export function buildReviewDecisionsFromSession(session, options = {}) {
165
180
  return session.items.flatMap((item) => {
166
181
  const decision = session.decisionsByItemName[item.metadata.name];
167
182
  if (!decision) {
@@ -170,7 +185,7 @@ export function buildReviewDecisionsFromSession(session) {
170
185
  const reviewDecision = buildReviewDecision(currentReviewWorkbenchState({
171
186
  ...session,
172
187
  activeItemName: item.metadata.name,
173
- }));
188
+ }), options);
174
189
  return reviewDecision ? [reviewDecision] : [];
175
190
  });
176
191
  }
@@ -291,7 +306,7 @@ function matchingSelectedCandidate(item, result) {
291
306
  function structuralEqual(left, right) {
292
307
  return canonicalJson(left) === canonicalJson(right);
293
308
  }
294
- export function buildReviewWorkbenchResultsFromSession(session) {
309
+ export function buildReviewWorkbenchResultsFromSession(session, options = {}) {
295
310
  return session.items.flatMap((item) => {
296
311
  const decision = session.decisionsByItemName[item.metadata.name];
297
312
  if (!decision) {
@@ -301,7 +316,7 @@ export function buildReviewWorkbenchResultsFromSession(session) {
301
316
  ...session,
302
317
  activeItemName: item.metadata.name,
303
318
  });
304
- const reviewDecision = buildReviewDecision(state);
319
+ const reviewDecision = buildReviewDecision(state, options);
305
320
  if (!reviewDecision) {
306
321
  return [];
307
322
  }
@@ -315,11 +330,12 @@ export function buildReviewWorkbenchResultsFromSession(session) {
315
330
  rationale: reviewDecision.spec.rationale,
316
331
  }];
317
332
  }
318
- const selectedCandidate = candidateForDecision(item, decision);
333
+ const selectedCandidateId = session.selectedCandidateIdsByItemName?.[item.metadata.name];
334
+ const selectedCandidate = candidateForDecision(item, decision, selectedCandidateId);
319
335
  const editedValue = decision === "accept-proposed"
320
336
  ? session.editedValuesByItemName?.[item.metadata.name]
321
337
  : undefined;
322
- const effectiveValue = effectiveValueForDecision(item, decision, editedValue);
338
+ const effectiveValue = effectiveValueForDecision(item, decision, editedValue, selectedCandidateId);
323
339
  return [{
324
340
  reviewItemName: item.metadata.name,
325
341
  decision,
@@ -338,12 +354,12 @@ export function buildReviewWorkbenchResultsFromSession(session) {
338
354
  }];
339
355
  });
340
356
  }
341
- export function buildReviewWorkbenchSessionExport(session, events = buildReviewSessionEvents(session)) {
357
+ export function buildReviewWorkbenchSessionExport(session, events = buildReviewSessionEvents(session), options = {}) {
342
358
  return {
343
359
  session: buildReviewSessionResource(session, events),
344
360
  events,
345
- decisions: buildReviewDecisionsFromSession(session),
346
- results: buildReviewWorkbenchResultsFromSession(session),
361
+ decisions: buildReviewDecisionsFromSession(session, options),
362
+ results: buildReviewWorkbenchResultsFromSession(session, options),
347
363
  };
348
364
  }
349
365
  export function replayReviewSessionEventsForSnapshot(snapshot, events) {
@@ -353,9 +369,9 @@ export function replayReviewSessionEventsForSnapshot(snapshot, events) {
353
369
  }
354
370
  return replayReviewSessionEvents(snapshot, events);
355
371
  }
356
- export function buildReviewWorkbenchSessionExportForSnapshot(snapshot, events) {
372
+ export function buildReviewWorkbenchSessionExportForSnapshot(snapshot, events, options = {}) {
357
373
  const replayedSession = replayReviewSessionEventsForSnapshot(snapshot, events);
358
- return buildReviewWorkbenchSessionExport(replayedSession, events);
374
+ return buildReviewWorkbenchSessionExport(replayedSession, events, options);
359
375
  }
360
376
  export function deriveReviewSessionApplyResultForSnapshot(options) {
361
377
  const requiredResolvedItems = options.requiredResolvedItems ?? "none";
@@ -371,7 +387,7 @@ export function deriveReviewSessionApplyResultForSnapshot(options) {
371
387
  }
372
388
  const warnings = reviewSessionReplayWarningsForSnapshot(options.snapshot, options.events);
373
389
  const replayedSession = replayReviewSessionEvents(options.snapshot, options.events);
374
- const sessionExport = buildReviewWorkbenchSessionExport(replayedSession, options.events);
390
+ const sessionExport = buildReviewWorkbenchSessionExport(replayedSession, options.events, { presentationAdapter: options.presentationAdapter });
375
391
  const resolvedItemNames = new Set(sessionExport.results.map((result) => result.reviewItemName));
376
392
  const unresolvedItemNames = options.snapshot.items
377
393
  .map((item) => item.metadata.name)
@@ -498,7 +514,7 @@ export function createPersistentReviewSessionEventStore(options) {
498
514
  }
499
515
  export function renderReviewWorkbenchHtml(state, _events, options = {}) {
500
516
  const session = queueSessionFromStartState(state);
501
- return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView));
517
+ return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView), attestReviewQueueExtraction(session.items, options.extractionImport));
502
518
  }
503
519
  /**
504
520
  * The visual/decision state of a field card, derived from the reviewer's local
@@ -506,7 +522,7 @@ export function renderReviewWorkbenchHtml(state, _events, options = {}) {
506
522
  * candidate set (e.g. a pre-decided item seeded by the host) as already-kept.
507
523
  */
508
524
  function fieldCardState(item, decision) {
509
- if (decision === "accept-proposed")
525
+ if (decision === "accept-proposed" || decision === "select-proposed")
510
526
  return "accepted";
511
527
  if (decision === "keep-current")
512
528
  return "kept";
@@ -528,7 +544,9 @@ function fieldCardState(item, decision) {
528
544
  */
529
545
  function chipLabel(state, hasCurrentValue, conflictingValues) {
530
546
  switch (state) {
531
- 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";
532
550
  case "kept": return hasCurrentValue ? "Kept current" : conflictingValues ? "All values rejected" : "Left unset";
533
551
  case "rejected": return hasCurrentValue ? "Kept — flagged wrong" : conflictingValues ? "All values rejected" : "Left unset";
534
552
  case "could-not-confirm": return "Could not confirm";
@@ -584,7 +602,16 @@ function isEmptyValue(value) {
584
602
  }
585
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>";
586
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>";
587
- 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
+ }
588
615
  const totalCount = session.items.length;
589
616
  const decidedCount = session.items.filter((item) => session.decisionsByItemName[item.metadata.name] !== undefined
590
617
  || item.spec.candidateSetStatus === "resolved").length;
@@ -594,7 +621,10 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView) {
594
621
  const headerTitle = typeof producerName === "string" && producerName.length > 0 ? producerName : "Review queue";
595
622
  const window = buildReviewQueueWindow(session, queueView);
596
623
  return `
597
- <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
+ : ""}
598
628
  <header class="rhead">
599
629
  <div class="top">
600
630
  <div class="subj">
@@ -654,9 +684,9 @@ function renderFieldCard(item, session, presentationAdapter) {
654
684
  const decided = decision !== undefined;
655
685
  const current = item.spec.candidates.find((candidate) => candidate.role === "current");
656
686
  const proposedCandidates = item.spec.candidates.filter((candidate) => candidate.role === "proposed");
657
- // Several proposed values for one claim are a conflict. A control names a
658
- // role, not a value, so none may make one of them the trusted value; the
659
- // 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.
660
690
  const conflict = proposedCandidates.length > 1;
661
691
  const proposed = proposedCandidates.length === 1 ? proposedCandidates[0] : undefined;
662
692
  const presentation = buildReviewItemPresentation(item, presentationAdapter);
@@ -698,7 +728,7 @@ function renderFieldCard(item, session, presentationAdapter) {
698
728
  ${proposed
699
729
  ? renderDiffRow(item, current, proposed, presentation.targetLabel, decided, effectiveProposedText, currentPresentationText)
700
730
  : proposedCandidates.length > 1
701
- ? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel)
731
+ ? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name])
702
732
  : "<p class=\"field-value\">No proposed value is available for this field.</p>"}
703
733
  ${proposed ? renderProvenanceRow(item, proposed, presentationAdapter) : ""}
704
734
  ${renderExtractionImportNotes(presentation)}
@@ -732,18 +762,37 @@ function renderFieldCard(item, session, presentationAdapter) {
732
762
  }
733
763
  /**
734
764
  * The card body for an item whose candidate set holds several proposed values.
735
- * Lists every value with its excerpt; the workbench records a decision against
736
- * 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.
737
771
  */
738
- function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel) {
772
+ function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel, decision, selectedCandidateId) {
773
+ const chose = decision === "select-proposed";
739
774
  const values = candidates.map((candidate) => {
740
775
  const text = buildReviewCandidatePresentation(item, candidate, presentationAdapter, targetLabel).valueText;
741
776
  const excerpt = candidate.locator?.excerpt;
742
- 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>`;
743
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.`;
744
793
  return `
745
- <div class="field-value" data-testid="conflicting-proposals">
746
- <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>
747
796
  <ul>${values}</ul>
748
797
  </div>
749
798
  `;
@@ -944,7 +993,8 @@ function renderProvenanceRow(item, proposed, presentationAdapter) {
944
993
  */
945
994
  function renderExtractionImportNotes(presentation) {
946
995
  const { excerptVerification: verification, excludedProposals: excluded } = presentation;
947
- if (verification === undefined && excluded.length === 0)
996
+ const unreadable = excludedProposalsUnreadableSentence(presentation.excludedProposalsUnreadable);
997
+ if (verification === undefined && excluded.length === 0 && unreadable === undefined)
948
998
  return "";
949
999
  const excludedHtml = excluded.length === 0 ? "" : `
950
1000
  <div class="noprov" data-testid="excluded-proposals" role="note">
@@ -954,12 +1004,18 @@ function renderExtractionImportNotes(presentation) {
954
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(", ")}.
955
1005
  Unverifiable is not disproven: check the source before deciding.</span>
956
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>`;
957
1013
  const verificationHtml = verification === undefined ? "" : `
958
1014
  <p class="excerpt-verification" data-testid="excerpt-verification" data-verified="${verification === "verified" ? "true" : "false"}">${verification === "verified"
959
1015
  ? "Excerpts checked against the prepared source text at import."
960
1016
  : "Excerpts not checked against the prepared source text at import."}</p>`;
961
1017
  return `
962
- <div class="prov import-notes">${excludedHtml}${verificationHtml}
1018
+ <div class="prov import-notes">${excludedHtml}${unreadableHtml}${verificationHtml}
963
1019
  </div>
964
1020
  `;
965
1021
  }
@@ -1005,10 +1061,11 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
1005
1061
  note,
1006
1062
  decision,
1007
1063
  editedValue: session.editedValuesByItemName?.[item.metadata.name],
1064
+ selectedCandidateId: session.selectedCandidateIdsByItemName?.[item.metadata.name],
1008
1065
  reviewedAt: session.reviewedAt,
1009
1066
  actorId: session.actorId,
1010
1067
  };
1011
- const reviewDecisionPayload = buildReviewDecision(state);
1068
+ const reviewDecisionPayload = buildReviewDecision(state, { presentationAdapter });
1012
1069
  const preview = buildSurfaceProjectionPreview(item, reviewDecisionPayload, presentationAdapter);
1013
1070
  // Seeded with what the card face already shows, so the audit surface never
1014
1071
  // reprints it. Passed down through every section: the sections render in DOM
@@ -1033,7 +1090,12 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
1033
1090
  ${proposed?.extraction.model ? fieldItem("model", "Model", proposed.extraction.model) : ""}
1034
1091
  ${proposed ? placementItem(trace, { of: proposed.id, property: "extraction.extractor" }, "extractor", "Extractor", proposed.extraction.extractor ?? "unknown") : ""}
1035
1092
  ${proposed ? fieldItem("extracted-at", "Extracted at", proposed.extraction.extractedAt ?? "unknown") : ""}
1036
- ${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
+ })()}
1037
1099
  </dl>
1038
1100
  ${preview
1039
1101
  ? `<div class="preview-section-grid">${renderSurfacePreviewSections(preview, trace)}</div><p class="preview-disclaimer preview-disclaimer-footer">${escapeHtml(preview.postureDisclaimer)}</p>`
@@ -1257,6 +1319,9 @@ function createReviewWorkbenchController(root, startState, options) {
1257
1319
  let events = eventStore?.load(baseSession) ?? [];
1258
1320
  let session = events.length > 0 ? replayReviewSessionEvents(baseSession, events) : baseSession;
1259
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);
1260
1325
  const persistEvents = () => {
1261
1326
  eventStore?.save(session, events);
1262
1327
  };
@@ -1272,7 +1337,7 @@ function createReviewWorkbenchController(root, startState, options) {
1272
1337
  const session = sessionForEvent;
1273
1338
  const item = itemName ? session.items.find((entry) => entry.metadata.name === itemName) : undefined;
1274
1339
  const decision = itemName ? session.decisionsByItemName[itemName] : undefined;
1275
- const candidateId = item && decision ? decisionCandidateId(item, decision) : undefined;
1340
+ const candidateId = item && decision ? decisionCandidateId(item, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name]) : undefined;
1276
1341
  const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
1277
1342
  const note = itemName ? session.notesByItemName[itemName] : undefined;
1278
1343
  // Carry the reviewer's inline edit in the event (accept-proposed only), so
@@ -1315,7 +1380,7 @@ function createReviewWorkbenchController(root, startState, options) {
1315
1380
  events = [...events, event];
1316
1381
  persistEvents();
1317
1382
  };
1318
- const setDecision = (itemName, decision, rawEditedValue) => {
1383
+ const setDecision = (itemName, decision, rawEditedValue, selectedCandidateId) => {
1319
1384
  if (decision === "could-not-confirm" && !session.notesByItemName[itemName]?.trim()) {
1320
1385
  throw new Error("Could not confirm requires a non-empty reason.");
1321
1386
  }
@@ -1334,11 +1399,19 @@ function createReviewWorkbenchController(root, startState, options) {
1334
1399
  else {
1335
1400
  delete nextEditedValuesByItemName[itemName];
1336
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
+ }
1337
1409
  commitSessionUpdate({
1338
1410
  ...session,
1339
1411
  activeItemName: itemName,
1340
1412
  decisionsByItemName: { ...session.decisionsByItemName, [itemName]: decision },
1341
1413
  editedValuesByItemName: nextEditedValuesByItemName,
1414
+ selectedCandidateIdsByItemName: nextSelections,
1342
1415
  }, "decision-changed", itemName);
1343
1416
  };
1344
1417
  const clearDecision = (itemName) => {
@@ -1348,12 +1421,15 @@ function createReviewWorkbenchController(root, startState, options) {
1348
1421
  delete remainingEdits[itemName];
1349
1422
  const remainingAttemptEvidenceIds = { ...session.attemptEvidenceIdsByItemName };
1350
1423
  delete remainingAttemptEvidenceIds[itemName];
1424
+ const remainingSelections = { ...session.selectedCandidateIdsByItemName };
1425
+ delete remainingSelections[itemName];
1351
1426
  commitSessionUpdate({
1352
1427
  ...session,
1353
1428
  activeItemName: itemName,
1354
1429
  decisionsByItemName: remainingDecisions,
1355
1430
  editedValuesByItemName: remainingEdits,
1356
1431
  attemptEvidenceIdsByItemName: remainingAttemptEvidenceIds,
1432
+ selectedCandidateIdsByItemName: remainingSelections,
1357
1433
  }, "decision-changed", itemName, { workbenchDecision: null });
1358
1434
  };
1359
1435
  const updateReviewerNote = (itemName, note) => {
@@ -1361,8 +1437,9 @@ function createReviewWorkbenchController(root, startState, options) {
1361
1437
  };
1362
1438
  const controller = {
1363
1439
  currentSession: () => session,
1364
- currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events),
1365
- 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),
1366
1443
  setDecision,
1367
1444
  clearDecision,
1368
1445
  updateReviewerNote,
@@ -1382,8 +1459,8 @@ function reviewSessionStorageKey(session, keyPrefix) {
1382
1459
  const itemNames = buildReviewSessionResource(session).spec.reviewItemNames.join(",");
1383
1460
  return `${keyPrefix}:${itemNames}`;
1384
1461
  }
1385
- function renderCurrentState(root, session, controller, presentationAdapter, queueView) {
1386
- 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));
1387
1464
  bindFieldCardInteractions(root, controller);
1388
1465
  bindApplyButton(root, controller);
1389
1466
  bindClampToggles(root);
@@ -1431,6 +1508,9 @@ function queueSessionFromStartState(startState) {
1431
1508
  ...(startState.attemptEvidenceIds?.length
1432
1509
  ? { attemptEvidenceIdsByItemName: { [startState.item.metadata.name]: [...startState.attemptEvidenceIds] } }
1433
1510
  : {}),
1511
+ ...(startState.decision === "select-proposed" && startState.selectedCandidateId !== undefined
1512
+ ? { selectedCandidateIdsByItemName: { [startState.item.metadata.name]: startState.selectedCandidateId } }
1513
+ : {}),
1434
1514
  reviewedAt: startState.reviewedAt,
1435
1515
  actorId: startState.actorId,
1436
1516
  };
@@ -1488,6 +1568,21 @@ function bindFieldCardInteractions(root, controller) {
1488
1568
  controller.renderCurrentState();
1489
1569
  });
1490
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
+ });
1491
1586
  root.querySelectorAll("[data-testid='keep-current']").forEach((button) => {
1492
1587
  button.addEventListener("click", () => {
1493
1588
  const itemName = button.dataset.itemName ?? "";
@@ -1598,7 +1693,7 @@ function refreshAuditPayloadForItem(textarea, controller, itemName) {
1598
1693
  reviewedAt: session.reviewedAt,
1599
1694
  actorId: session.actorId,
1600
1695
  };
1601
- payload.textContent = JSON.stringify(buildReviewDecision(state) ?? null, null, 2);
1696
+ payload.textContent = JSON.stringify(buildReviewDecision(state, { presentationAdapter: controller.presentationAdapter }) ?? null, null, 2);
1602
1697
  }
1603
1698
  function bindClampToggles(root) {
1604
1699
  root.querySelectorAll("[data-clamp-toggle]").forEach((button) => {
@@ -627,6 +627,100 @@ h1, h2, h3, p {
627
627
  font-size: 12px;
628
628
  }
629
629
 
630
+ /* A queue that came from an extraction import but was not checked against
631
+ it (unverified), or that does not match it (diverges, shown instead of the
632
+ queue). */
633
+ .queue-attestation {
634
+ display: flex;
635
+ align-items: flex-start;
636
+ gap: var(--k-space-2);
637
+ margin: 0 0 var(--k-space-3);
638
+ padding: var(--k-space-2) var(--k-space-3);
639
+ border-radius: 9px;
640
+ font-size: 13px;
641
+ line-height: 1.45;
642
+ color: var(--k-caution);
643
+ background: var(--k-caution-wash);
644
+ }
645
+
646
+ .queue-attestation svg {
647
+ flex: none;
648
+ margin-top: 2px;
649
+ }
650
+
651
+ .queue-attestation.diverges {
652
+ color: var(--k-negative);
653
+ background: var(--k-negative-wash);
654
+ }
655
+
656
+ /* Conflicting proposed values: each value is listed with its own choice
657
+ control until the field is decided, then marked chosen or not chosen. */
658
+ .conflict-values ul {
659
+ list-style: none;
660
+ margin: var(--k-space-2) 0 0;
661
+ padding: 0;
662
+ display: grid;
663
+ gap: var(--k-space-2);
664
+ }
665
+
666
+ .conflict-values li {
667
+ display: flex;
668
+ align-items: center;
669
+ justify-content: space-between;
670
+ gap: var(--k-space-3);
671
+ flex-wrap: wrap;
672
+ padding: var(--k-space-2) var(--k-space-3);
673
+ border: 1px solid var(--k-line);
674
+ border-radius: 9px;
675
+ }
676
+
677
+ .conflict-values li[data-chosen="true"] {
678
+ border-color: var(--k-positive);
679
+ background: var(--k-positive-wash);
680
+ }
681
+
682
+ .conflict-values .conflict-text {
683
+ display: inline-flex;
684
+ align-items: baseline;
685
+ gap: var(--k-space-2);
686
+ flex-wrap: wrap;
687
+ min-width: 0;
688
+ }
689
+
690
+ .conflict-values .vtext {
691
+ font-weight: 600;
692
+ overflow-wrap: anywhere;
693
+ }
694
+
695
+ .conflict-values q {
696
+ color: var(--k-muted);
697
+ font-size: 12.5px;
698
+ overflow-wrap: anywhere;
699
+ }
700
+
701
+ .conflict-values .btn.choose {
702
+ min-height: 36px;
703
+ padding: 0 14px;
704
+ font-size: 13px;
705
+ }
706
+
707
+ .choice-tag {
708
+ font-size: 12px;
709
+ font-weight: 600;
710
+ padding: 2px 8px;
711
+ border-radius: 999px;
712
+ }
713
+
714
+ .choice-tag.chosen {
715
+ color: var(--k-positive);
716
+ background: var(--k-positive-wash);
717
+ }
718
+
719
+ .choice-tag.not-chosen {
720
+ color: var(--k-muted);
721
+ background: var(--k-sunken);
722
+ }
723
+
630
724
  .excerpt-verification,
631
725
  .support-verification {
632
726
  margin: 0;