@kontourai/survey 7.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +2 -1
  3. package/dist/src/calibration.d.ts +15 -0
  4. package/dist/src/calibration.js +11 -0
  5. package/dist/src/canonical-reviewed-trust-input.d.ts +10 -0
  6. package/dist/src/canonical-reviewed-trust-input.js +15 -0
  7. package/dist/src/console/review-console-server.d.ts +20 -0
  8. package/dist/src/console/review-console-server.js +75 -4
  9. package/dist/src/decision-carry-forward.d.ts +157 -0
  10. package/dist/src/decision-carry-forward.js +295 -0
  11. package/dist/src/extraction-envelope.d.ts +4 -3
  12. package/dist/src/extraction-envelope.js +3 -3
  13. package/dist/src/field-states.d.ts +100 -0
  14. package/dist/src/field-states.js +169 -0
  15. package/dist/src/index.d.ts +3 -1
  16. package/dist/src/index.js +2 -0
  17. package/dist/src/mcp/review-mcp.js +44 -13
  18. package/dist/src/review-resource.d.ts +29 -0
  19. package/dist/src/review-session-file.d.ts +6 -0
  20. package/dist/src/review-session-file.js +2 -0
  21. package/dist/src/review-workbench/audit-sample.d.ts +32 -0
  22. package/dist/src/review-workbench/audit-sample.js +53 -0
  23. package/dist/src/review-workbench/extraction-inspector.d.ts +1 -1
  24. package/dist/src/review-workbench/extraction-inspector.js +1 -1
  25. package/dist/src/review-workbench/field-state-view.d.ts +15 -0
  26. package/dist/src/review-workbench/field-state-view.js +84 -0
  27. package/dist/src/review-workbench/review-queue-session.d.ts +46 -1
  28. package/dist/src/review-workbench/review-queue-session.js +74 -3
  29. package/dist/src/review-workbench/review-session-replay.d.ts +1 -1
  30. package/dist/src/review-workbench/review-session-replay.js +15 -1
  31. package/dist/src/review-workbench/review-workbench-css.generated.d.ts +9 -0
  32. package/dist/src/review-workbench/review-workbench-css.generated.js +453 -43
  33. package/dist/src/review-workbench/review-workbench-element.js +68 -212
  34. package/dist/src/review-workbench/review-workbench.css +386 -42
  35. package/dist/src/review-workbench/review-workbench.d.ts +13 -1
  36. package/dist/src/review-workbench/review-workbench.js +41 -15
  37. package/dist/src/review-workbench/review-workbench.standalone.css +37 -23
  38. package/dist/src/review-workbench/vendor/kontourai-ui/tokens/themes.css +189 -10
  39. package/dist/src/review-workbench/vendor/kontourai-ui/tokens/tokens.css +156 -5
  40. package/package.json +13 -8
@@ -0,0 +1,169 @@
1
+ import { foldCandidateVerifications } from "./candidate-verification.js";
2
+ import { decisionReference, decisionResolution, validateDecisionCarryForward, validateDecisionSupersession, } from "./decision-carry-forward.js";
3
+ import { buildReviewItemsFromExtractionEnvelopeImport, excerptMismatchProposalIndices, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
4
+ import { canonicalJson } from "./review-workbench/canonical.js";
5
+ /**
6
+ * Per-field content and lifecycle states (#294), derived from records Survey
7
+ * holds and never stored as labels.
8
+ *
9
+ * Only states with a producer ship. `not_found` (the source was read in full
10
+ * and holds no value for the field) needs per-field chunk coverage and a
11
+ * per-field dropped-proposal count from the extractor, which no producer
12
+ * reports yet, so a field with no candidate in a complete run has no content
13
+ * state at all. Absence of a state never means verified.
14
+ */
15
+ /** The claim-metadata key under which the Surface projection carries a field's states. */
16
+ export const FIELD_STATE_METADATA_KEY = "survey.kontourai.io/field-state";
17
+ /**
18
+ * Derives the content and lifecycle state of every field in the given
19
+ * imports. Pure: reads only its input. Each state appears only when the input
20
+ * that produces it is present: no verifications, no `unsupported`; no
21
+ * supersessions, no `superseded`.
22
+ */
23
+ export function deriveFieldStates(input) {
24
+ const decisionsByItem = new Map();
25
+ for (const decision of input.decisions ?? []) {
26
+ const name = decision.spec.reviewItemName;
27
+ if (decisionsByItem.has(name))
28
+ throw new Error(`ReviewItem ${name} has more than one decision; field states read one decision per item.`);
29
+ decisionsByItem.set(name, decision);
30
+ }
31
+ const carriedByItem = new Map();
32
+ for (const raw of input.carryForwards ?? []) {
33
+ const record = validateDecisionCarryForward(raw);
34
+ if (carriedByItem.has(record.reviewItemName))
35
+ throw new Error(`ReviewItem ${record.reviewItemName} has more than one carried-forward decision.`);
36
+ carriedByItem.set(record.reviewItemName, record);
37
+ }
38
+ const supersededDigests = new Set((input.supersessions ?? []).map((raw) => validateDecisionSupersession(raw).priorDecision.digest));
39
+ const verifications = input.verifications;
40
+ const states = [];
41
+ for (const entry of input.imports) {
42
+ const record = validateExtractionEnvelopeImport(entry.record);
43
+ const importName = record.metadata.name;
44
+ const outcome = record.spec.envelope.result.outcome.status;
45
+ const incompleteRun = outcome === "partial" || outcome === "failure";
46
+ const unresolvedImport = record.status.state === "unresolved";
47
+ const baseSignals = (excluded) => ({
48
+ ...(incompleteRun ? { incompleteRun: true } : {}),
49
+ ...(excluded > 0 ? { excludedProposals: excluded } : {}),
50
+ ...(unresolvedImport ? { unresolvedImport: true } : {}),
51
+ });
52
+ const excludedBySlot = new Map();
53
+ for (const index of excerptMismatchProposalIndices(record)) {
54
+ const key = slotKey(proposalSlot(record, index));
55
+ excludedBySlot.set(key, (excludedBySlot.get(key) ?? 0) + 1);
56
+ }
57
+ const seen = new Set();
58
+ for (const item of buildReviewItemsFromExtractionEnvelopeImport(record)) {
59
+ const slot = itemSlot(record, item);
60
+ const key = slotKey(slot);
61
+ seen.add(key);
62
+ const lifecycle = lifecycleFor(item, decisionsByItem, carriedByItem, supersededDigests);
63
+ states.push({
64
+ importName,
65
+ slot,
66
+ reviewItemName: item.metadata.name,
67
+ content: contentFor(item, verifications),
68
+ ...lifecycle,
69
+ signals: baseSignals(excludedBySlot.get(key) ?? 0),
70
+ });
71
+ }
72
+ // A slot whose every proposal was excluded has no item; it still appears,
73
+ // so a dropped proposal never vanishes from the field's record.
74
+ for (const [key, count] of excludedBySlot) {
75
+ if (seen.has(key))
76
+ continue;
77
+ seen.add(key);
78
+ const index = [...excerptMismatchProposalIndices(record)].find((proposalIndex) => slotKey(proposalSlot(record, proposalIndex)) === key);
79
+ // Proposals were made, so the field was read: `excluded`, never `not_covered`.
80
+ states.push({ importName, slot: proposalSlot(record, index), content: "excluded", signals: baseSignals(count) });
81
+ }
82
+ for (const expected of entry.expectedFields ?? []) {
83
+ const slot = normalizeSlot(expected);
84
+ const key = slotKey(slot);
85
+ if (seen.has(key))
86
+ continue;
87
+ seen.add(key);
88
+ states.push({ importName, slot, ...(incompleteRun ? { content: "not_covered" } : {}), signals: baseSignals(0) });
89
+ }
90
+ }
91
+ return states;
92
+ }
93
+ /** The lifecycle a workbench decision gives its item, as `deriveFieldStates` reads it. */
94
+ export function lifecycleForDecision(decision) {
95
+ if (!decision)
96
+ return "pending";
97
+ return decisionResolution(decision) ?? "pending";
98
+ }
99
+ /** The claim metadata the Surface projection carries for one field state. */
100
+ export function fieldStateClaimMetadata(state) {
101
+ return {
102
+ schemaVersion: 1,
103
+ ...(state.content !== undefined ? { content: state.content } : {}),
104
+ ...(state.lifecycle !== undefined ? { lifecycle: state.lifecycle } : {}),
105
+ ...(state.decisionBasis !== undefined ? { decisionBasis: state.decisionBasis } : {}),
106
+ ...(Object.keys(state.signals).length ? { signals: { ...state.signals } } : {}),
107
+ };
108
+ }
109
+ function lifecycleFor(item, decisionsByItem, carriedByItem, supersededDigests) {
110
+ const decision = decisionsByItem.get(item.metadata.name);
111
+ if (decision) {
112
+ const superseded = supersededDigests.has(decisionReference(decision).digest);
113
+ return { lifecycle: superseded ? "superseded" : lifecycleForDecision(decision), decisionName: decision.metadata.name, decisionBasis: "affirmed" };
114
+ }
115
+ const carried = carriedByItem.get(item.metadata.name);
116
+ if (carried) {
117
+ if (carried.candidateId !== undefined && !item.spec.candidates.some((candidate) => candidate.id === carried.candidateId)) {
118
+ throw new Error(`Carried-forward decision for ${item.metadata.name} names candidate ${carried.candidateId}, which the item does not hold.`);
119
+ }
120
+ const superseded = supersededDigests.has(carried.priorDecision.digest);
121
+ return { lifecycle: superseded ? "superseded" : carried.resolution, decisionName: carried.priorDecision.name, decisionBasis: "carried-forward" };
122
+ }
123
+ return { lifecycle: "pending" };
124
+ }
125
+ function contentFor(item, verifications) {
126
+ if (verifications !== undefined && item.spec.candidates.every((candidate) => candidateUnsupported(candidate, verifications)))
127
+ return "unsupported";
128
+ return item.spec.candidates.length > 1 ? "conflicting" : "value";
129
+ }
130
+ /** A candidate with a contradicted or not-addressed record for its current value, and no supported one. */
131
+ function candidateUnsupported(candidate, verifications) {
132
+ const { applicable } = foldCandidateVerifications({ candidateId: candidate.id, value: candidate.value }, verifications);
133
+ const verdicts = applicable.filter((record) => record.result !== "abstain");
134
+ return verdicts.length > 0 && verdicts.every((record) => record.result === "contradicted" || record.result === "not-addressed");
135
+ }
136
+ function itemSlot(record, item) {
137
+ const binding = item.metadata.producer?.["survey.kontourai.io/extraction-envelope"];
138
+ const first = binding?.proposalIndices?.[0];
139
+ if (first === undefined)
140
+ throw new Error(`ReviewItem ${item.metadata.name} carries no proposal binding.`);
141
+ return proposalSlot(record, first);
142
+ }
143
+ function proposalSlot(record, index) {
144
+ const target = record.spec.claimTargets[index];
145
+ const proposal = record.spec.envelope.result.proposals[index];
146
+ return normalizeSlot({
147
+ subjectType: target.subjectType, subjectId: target.subjectId, facet: target.facet, claimType: target.claimType,
148
+ fieldOrBehavior: target.fieldOrBehavior, ...(target.claimId !== undefined ? { claimId: target.claimId } : {}),
149
+ ...(proposal.pathIndices !== undefined ? { pathIndices: proposal.pathIndices } : {}),
150
+ });
151
+ }
152
+ function normalizeSlot(slot) {
153
+ for (const key of ["subjectType", "subjectId", "facet", "claimType", "fieldOrBehavior"]) {
154
+ if (typeof slot[key] !== "string" || slot[key].trim() === "")
155
+ throw new Error(`Field slot ${key} must be a non-empty string.`);
156
+ }
157
+ return {
158
+ subjectType: slot.subjectType, subjectId: slot.subjectId, facet: slot.facet, claimType: slot.claimType, fieldOrBehavior: slot.fieldOrBehavior,
159
+ ...(slot.claimId !== undefined ? { claimId: slot.claimId } : {}),
160
+ ...(slot.pathIndices !== undefined ? { pathIndices: [...slot.pathIndices] } : {}),
161
+ };
162
+ }
163
+ /** The same identity the importer groups a claim slot by. */
164
+ function slotKey(slot) {
165
+ return canonicalJson({
166
+ subjectType: slot.subjectType, subjectId: slot.subjectId, facet: slot.facet, claimType: slot.claimType,
167
+ fieldOrBehavior: slot.fieldOrBehavior, claimId: slot.claimId ?? null, pathIndices: slot.pathIndices ?? null,
168
+ });
169
+ }
@@ -9,7 +9,7 @@ export { resolvePortablePdfRegion } from "./pdf-layout.js";
9
9
  export type { PortablePdfBoundingBox, PortablePdfLayout, PortablePdfPageGeometry, PortablePdfRegionContext, PortablePdfTable, PortablePdfTableCell, PortablePdfTextElement, PortablePdfTextRange, } from "./pdf-layout.js";
10
10
  export type { ExtractionAlignmentState, ArtifactUnavailableCode, BuiltExtractionInspectorCandidate, BuiltExtractionInspectorModel, ExtractionInspectorCandidate, ExtractionInspectorEntry, ExtractionInspectorExportOptions, ExtractionInspectorFilters, ExtractionInspectorInput, ExtractionInspectorModel, ExtractionInspectorSource, ResolvedExtractionArtifact, } from "./review-workbench/extraction-inspector.js";
11
11
  export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, ExtractionEnvelopeImportProvenance, ExtractionEnvelopeImportResult, ExtractionEnvelopeResolutionIdentity, PortableExtractionCoverageEntry, PortableExtractionEvidenceMatch, PortableExtractionPartialReason, PortableExtractionOccurrence, PortableExtractionProducedBy, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
12
- export type { CandidateRole, ClaimTargetHint, ProducerPolicy, ExtractionReference, ResourceEnvelope, ResourceMetadata, ReviewActor, ReviewCandidate, ReviewDecision, ReviewDecisionMode, ReviewDecisionSpec, ReviewDecisionStatus, ReviewItem, ReviewItemSpec, ReviewItemStatus, ReviewLocator, ReviewResource, ReviewResourceApiVersion, ReviewResourceKind, ReviewSession, ReviewSessionEvent, ReviewSessionEventSpec, ReviewSessionEventStatus, ReviewSessionEventType, ReviewSessionSpec, ReviewSessionStatus, ReviewValueDescriptor, ReviewValueType, SourceReference, SurveyRecordProjectionHint, } from "./review-resource.js";
12
+ export type { CandidateRole, ClaimTargetHint, ProducerPolicy, ExtractionReference, ResourceEnvelope, ResourceMetadata, ReviewActor, ReviewCandidate, ReviewDecision, ReviewDecisionMode, ReviewDecisionSpec, ReviewSessionPresentation, ReviewSessionSampling, ReviewDecisionStatus, ReviewItem, ReviewItemSpec, ReviewItemStatus, ReviewLocator, ReviewResource, ReviewResourceApiVersion, ReviewResourceKind, ReviewSession, ReviewSessionEvent, ReviewSessionEventSpec, ReviewSessionEventStatus, ReviewSessionEventType, ReviewSessionSpec, ReviewSessionStatus, ReviewValueDescriptor, ReviewValueType, SourceReference, SurveyRecordProjectionHint, } from "./review-resource.js";
13
13
  export { toSurfaceReviewedExtractionDecision, toSurfaceReviewedExtractionImport, toSurfaceReviewedExtractionItem, } from "./surface-reviewed-extraction.js";
14
14
  export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
15
15
  export type { CandidateReviewRecordInput, SurveyClaimRecord, SurveyInputBuilderArgs, SurveyObservationInput, } from "./builder.js";
@@ -58,3 +58,5 @@ export { deriveOversightMetrics, mergeTrustBundleWithOversightMetrics, oversight
58
58
  export type { AggregateOversightMetrics, DeriveOversightMetricsOptions, OversightMetrics, OversightMetricsClaimsSubject, OversightQualityClaim, ReviewerOversightMetrics, } from "./oversight-metrics.js";
59
59
  export { calibrationToClaims, deriveCalibration, mergeTrustBundleWithCalibration, } from "./calibration.js";
60
60
  export type { CalibrationBin, CalibrationClaim, CalibrationClaimsSubject, CalibrationGroup, CalibrationInput, CalibrationMetrics, CalibrationSample, DeriveCalibrationOptions, } from "./calibration.js";
61
+ export { deriveFieldStates, fieldStateClaimMetadata, lifecycleForDecision, FIELD_STATE_METADATA_KEY, type DeriveFieldStatesInput, type FieldContentState, type FieldLifecycleState, type FieldSlot, type FieldState, type FieldStateImport, type FieldStateSignals, } from "./field-states.js";
62
+ export { buildDecisionSupersession, decisionCarryForwardKind, decisionReference, decisionResolution, decisionSupersessionKind, defaultCarryForwardPolicy, splitRoundForCarryForward, validateDecisionCarryForward, validateDecisionSupersession, type BuildDecisionSupersessionInput, type CarriedForwardItem, type CarriedResolution, type CarryForwardPolicy, type DecisionBasis, type DecisionCarryForward, type DecisionReference, type DecisionSupersession, type NeedsReviewItem, type NeedsReviewReason, type PriorRoundDecision, type RoundReviewItem, type SplitRoundForCarryForwardInput, type SplitRoundForCarryForwardResult, type SupersessionReason, } from "./decision-carry-forward.js";
package/dist/src/index.js CHANGED
@@ -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,7 +3,7 @@ import { resolve, dirname } from "node:path";
3
3
  import { McpServer } from "@modelcontextprotocol/server";
4
4
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
5
5
  import { z } from "zod";
6
- import { attestReviewQueueExtraction, buildReviewSessionEvents, conflictSelectionIssue, 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
8
  import { buildReviewItemPresentation, candidateVerificationNotes, excludedProposalsSentence, excludedProposalsUnreadableSentence } from "../review-workbench/review-presentation.js";
9
9
  import { appendReviewSessionEvents, readReviewSessionFile, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
@@ -41,6 +41,14 @@ function attestedQueue(file) {
41
41
  function attestationLines(attestation) {
42
42
  return attestation.state === "unverified" ? [attestation.message, ``] : [];
43
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}`, ``] : [];
51
+ }
44
52
  // ---- Queue helpers -------------------------------------------------------
45
53
  function queueSummaryText(snapshot, events, attestation) {
46
54
  const current = currentSessionState(snapshot, events);
@@ -57,6 +65,7 @@ function queueSummaryText(snapshot, events, attestation) {
57
65
  });
58
66
  return [
59
67
  ...attestationLines(attestation),
68
+ ...scoreBlindLines(current),
60
69
  `Review queue: ${resolved}/${total} resolved`,
61
70
  `Active item: ${activeItem.metadata.name} (${activeItem.spec.target})`,
62
71
  ...(nextItem ? [`Next unresolved: ${nextItem}`] : ["All items resolved."]),
@@ -77,8 +86,11 @@ function itemDetailText(item, snapshot, events, attestation) {
77
86
  const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
78
87
  const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v);
79
88
  const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "unknown";
89
+ const blind = isScoreBlind(current);
90
+ const confidenceLine = (c) => blind ? [] : [` confidence: ${confStr(c)}`];
80
91
  const lines = [
81
92
  ...attestationLines(attestation),
93
+ ...scoreBlindLines(current),
82
94
  `Item: ${item.metadata.name}`,
83
95
  `Target: ${item.spec.target}`,
84
96
  `Status: ${status}`,
@@ -87,7 +99,7 @@ function itemDetailText(item, snapshot, events, attestation) {
87
99
  ...(note ? [`Note: ${note}`] : []),
88
100
  ``,
89
101
  `Current value: ${valueStr(currentCandidate?.value ?? "(none)")}`,
90
- ` confidence: ${confStr(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence)}`,
102
+ ...confidenceLine(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence),
91
103
  ` source: ${currentCandidate?.source?.sourceRef ?? "none"}`,
92
104
  ...(currentCandidate?.locator?.excerpt ? [` excerpt: ${currentCandidate.locator.excerpt}`] : []),
93
105
  ``,
@@ -100,12 +112,12 @@ function itemDetailText(item, snapshot, events, attestation) {
100
112
  ...(proposedCandidates.length === 0 ? [`Proposed value: (none)`] : proposedCandidates.flatMap((candidate) => [
101
113
  `Proposed value: ${valueStr(candidate.value)}${chosenId === undefined ? "" : candidate.id === chosenId ? " [chosen]" : " [not chosen]"}`,
102
114
  ...(proposedCandidates.length > 1 ? [` candidateId: ${candidate.id}`] : []),
103
- ` confidence: ${confStr(candidate.extraction?.confidence ?? candidate.confidence)}`,
115
+ ...confidenceLine(candidate.extraction?.confidence ?? candidate.confidence),
104
116
  ` source: ${candidate.source?.sourceRef ?? "none"}`,
105
117
  ...(candidate.locator?.excerpt ? [` excerpt: ${candidate.locator.excerpt}`] : []),
106
118
  ])),
107
119
  ...extractionImportLines(item),
108
- ...candidateVerificationNotes(item, editedValueFor(item, current)).flatMap((entry) => [``, `Verification: ${entry.sentence}`]),
120
+ ...(blind ? [] : candidateVerificationNotes(item, editedValueFor(item, current)).flatMap((entry) => [``, `Verification: ${entry.sentence}`])),
109
121
  ];
110
122
  if (item.spec.rationale) {
111
123
  lines.push(``, `Rationale: ${item.spec.rationale}`);
@@ -113,6 +125,13 @@ function itemDetailText(item, snapshot, events, attestation) {
113
125
  return lines.join("\n");
114
126
  }
115
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
+ }
116
135
  function editedValueFor(item, state) {
117
136
  return state.decisionsByItemName[item.metadata.name] === "accept-proposed" ? state.editedValuesByItemName?.[item.metadata.name] : undefined;
118
137
  }
@@ -157,8 +176,10 @@ function buildReviewCardHtml(item, snapshot, events, attestation) {
157
176
  const status = deriveQueueRowStatus(item, current);
158
177
  const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v, null, 2);
159
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>`;
160
181
  const currentValue = valueStr(currentCandidate?.value ?? "—");
161
- const currentConf = confStr(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence);
182
+ const currentConf = confHtml(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence);
162
183
  const currentSource = currentCandidate?.source?.sourceRef ?? "—";
163
184
  const currentExcerpt = currentCandidate?.locator?.excerpt ?? "";
164
185
  const proposedCard = (candidate, label) => {
@@ -174,7 +195,7 @@ function buildReviewCardHtml(item, snapshot, events, attestation) {
174
195
  return `<div class="card is-proposed"${conflict && candidate ? ` data-candidate-id="${escapeHtml(candidate.id)}"` : ""}>
175
196
  <div class="card-label">${escapeHtml(label)}</div>
176
197
  <div class="value">${value.includes("\n") ? `<pre>${escapeHtml(value)}</pre>` : escapeHtml(value)}</div>
177
- <div class="conf">confidence ${confStr(candidate?.extraction?.confidence ?? candidate?.confidence)}</div>
198
+ ${confHtml(candidate?.extraction?.confidence ?? candidate?.confidence)}
178
199
  <div class="source-ref">${escapeHtml(candidate?.source?.sourceRef ?? "—")}</div>
179
200
  ${excerpt ? `<div class="excerpt">${escapeHtml(excerpt)}</div>` : ""}
180
201
  ${choice}
@@ -187,7 +208,7 @@ function buildReviewCardHtml(item, snapshot, events, attestation) {
187
208
  const itemPresentation = buildReviewItemPresentation(item);
188
209
  const excludedNote = excludedProposalsSentence(itemPresentation.excludedProposals);
189
210
  const unreadableNote = excludedProposalsUnreadableSentence(itemPresentation.excludedProposalsUnreadable);
190
- const verificationNotes = candidateVerificationNotes(item, editedValueFor(item, current));
211
+ const verificationNotes = blind ? [] : candidateVerificationNotes(item, editedValueFor(item, current));
191
212
  const decisionBadge = decision
192
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>`
193
214
  : `<span class="badge badge-pending">${escapeHtml(status)}</span>`;
@@ -207,7 +228,7 @@ function buildReviewCardHtml(item, snapshot, events, attestation) {
207
228
  --k-line-strong: rgba(150,180,210,0.22);
208
229
  --k-text: #eef3f8;
209
230
  --k-text-muted: #aebccb;
210
- --k-text-faint: #72869b;
231
+ --k-text-faint: #75889d;
211
232
  --k-brand: #5ce0c6;
212
233
  --k-positive: #34d399;
213
234
  --k-caution: #f3b14b;
@@ -215,7 +236,7 @@ function buildReviewCardHtml(item, snapshot, events, attestation) {
215
236
  --k-active: #7aa2ff;
216
237
  --k-radius-sm: 9px;
217
238
  --k-radius-md: 14px;
218
- --k-font-ui: "Hanken Grotesk",ui-sans-serif,system-ui,-apple-system,sans-serif;
239
+ --k-font-ui: "Hanken Grotesk",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
219
240
  --k-font-mono: "IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
220
241
  }
221
242
  @media (prefers-color-scheme: light) {
@@ -228,7 +249,11 @@ function buildReviewCardHtml(item, snapshot, events, attestation) {
228
249
  --k-line-strong: rgba(36,40,46,0.20);
229
250
  --k-text: #202124;
230
251
  --k-text-muted: #5b626b;
231
- --k-text-faint: #707782;
252
+ --k-text-faint: #6a707b;
253
+ /* The dark brand (#5ce0c6) reads at ~1.6:1 on the light canvas; the eyebrow
254
+ and source link use --k-brand as text, so light mode takes @kontourai/ui's
255
+ survey light brand, retinted in 1.18 to clear 4.5:1. */
256
+ --k-brand: #107e6d;
232
257
  --k-positive: #168257;
233
258
  --k-caution: #8a5a00;
234
259
  --k-negative: #c83b3b;
@@ -276,6 +301,7 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
276
301
  </head>
277
302
  <body>
278
303
  ${attestation.state === "unverified" ? `<p class="notice" id="unverified-queue-note">${escapeHtml(attestation.message)}</p>` : ""}
304
+ ${blind ? `<p class="notice" id="score-blind-note">${escapeHtml(scoreBlindLines(current)[0])}</p>` : ""}
279
305
  <p class="eyebrow">Survey Review</p>
280
306
  <h1>${escapeHtml(item.spec.target)}</h1>
281
307
  <div class="meta">
@@ -289,7 +315,7 @@ ${attestation.state === "unverified" ? `<p class="notice" id="unverified-queue-n
289
315
  <div class="card">
290
316
  <div class="card-label">Current</div>
291
317
  <div class="value">${currentValue.includes("\n") ? `<pre>${escapeHtml(currentValue)}</pre>` : escapeHtml(currentValue)}</div>
292
- <div class="conf">confidence ${currentConf}</div>
318
+ ${currentConf}
293
319
  <div class="source-ref">${escapeHtml(currentSource)}</div>
294
320
  ${currentExcerpt ? `<div class="excerpt">${escapeHtml(currentExcerpt)}</div>` : ""}
295
321
  </div>
@@ -376,6 +402,7 @@ async function toolQueue(options) {
376
402
  const text = queueSummaryText(snapshot, events, attestation);
377
403
  const queueData = {
378
404
  queueAttestation: attestation.state,
405
+ ...sessionConditions(snapshot),
379
406
  items: snapshot.items.map((item) => {
380
407
  const current = currentSessionState(snapshot, events);
381
408
  const selectedCandidateId = current.decisionsByItemName[item.metadata.name] === "select-proposed"
@@ -413,8 +440,10 @@ async function toolItem(itemName, options) {
413
440
  const text = itemDetailText(item, snapshot, events, attestation);
414
441
  const decision = current.decisionsByItemName[item.metadata.name];
415
442
  const selectedCandidateId = decision === "select-proposed" ? current.selectedCandidateIdsByItemName?.[item.metadata.name] : undefined;
443
+ const blind = isScoreBlind(current);
416
444
  const itemData = {
417
445
  queueAttestation: attestation.state,
446
+ ...sessionConditions(snapshot),
418
447
  name: item.metadata.name,
419
448
  target: item.spec.target,
420
449
  status: deriveQueueRowStatus(item, current),
@@ -431,14 +460,14 @@ async function toolItem(itemName, options) {
431
460
  };
432
461
  })(),
433
462
  candidates: (() => {
434
- const notes = candidateVerificationNotes(item, editedValueFor(item, current));
463
+ const notes = blind ? [] : candidateVerificationNotes(item, editedValueFor(item, current));
435
464
  return item.spec.candidates.map((c, index) => {
436
465
  const note = notes.find((entry) => entry.candidateIndex === index);
437
466
  return {
438
467
  id: c.id,
439
468
  role: c.role,
440
469
  value: c.value,
441
- confidence: c.extraction?.confidence ?? c.confidence,
470
+ ...(blind ? {} : { confidence: c.extraction?.confidence ?? c.confidence }),
442
471
  sourceRef: c.source?.sourceRef,
443
472
  excerpt: c.locator?.excerpt,
444
473
  ...(selectedCandidateId !== undefined && c.role === "proposed" ? { chosen: c.id === selectedCandidateId } : {}),
@@ -466,6 +495,8 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
466
495
  // Read, validate and write inside the shared session lock so a concurrent
467
496
  // decide or console save cannot interleave and drop this decision (#281).
468
497
  const { snapshot, sessionWithDecision, newEvents, attestation } = await updateReviewSessionFile(options.sessionPath, (file) => {
498
+ if (file.producerApplication !== undefined)
499
+ throw new DomainError("This review was already sent to its workflow; open a new review session");
469
500
  const { snapshot, events } = file;
470
501
  const attestation = attestedQueue(file);
471
502
  const current = currentSessionState(snapshot, events);
@@ -207,7 +207,36 @@ export interface ReviewDecisionSpec {
207
207
  * decisions recorded before choosing between conflicting values existed.
208
208
  */
209
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;
210
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
+ };
211
240
  export interface ReviewDecisionStatus {
212
241
  appliedToClaimIds?: string[];
213
242
  }
@@ -21,6 +21,12 @@ export interface ReviewSessionFileContent {
21
21
  * from an import is presented as an unverified queue.
22
22
  */
23
23
  readonly extractionImport?: unknown;
24
+ /** Presentation receipt after a producer consumed this session; further appends refuse. */
25
+ readonly producerApplication?: {
26
+ readonly reference: string;
27
+ readonly summary: string;
28
+ readonly revision: string;
29
+ };
24
30
  }
25
31
  export interface ReviewSessionFileLockOptions {
26
32
  /** How long to wait for a busy lock before failing. Defaults to 10s. */
@@ -304,6 +304,8 @@ export function storedReviewSessionName(content, fallback = defaultReviewSession
304
304
  * rewritten, so decision reversals and note changes stay on record.
305
305
  */
306
306
  export function appendReviewSessionEvents(content, appended) {
307
+ if (content.producerApplication !== undefined)
308
+ throw new Error("This review was already sent to its workflow; open a new review session");
307
309
  const sessionName = storedReviewSessionName(content);
308
310
  const start = content.events.length;
309
311
  const renumbered = [...appended]
@@ -0,0 +1,32 @@
1
+ import type { ReviewItem } from "../review-resource.js";
2
+ import { type ReviewQueueSessionState } from "./review-queue-session.js";
3
+ export interface RandomAuditSampleOptions {
4
+ /** Inclusion probability for each item, greater than 0 and at most 1. */
5
+ readonly rate: number;
6
+ /** Any non-empty string. The same seed and items always draw the same sample. */
7
+ readonly seed: string;
8
+ }
9
+ /**
10
+ * Draws a seeded random audit sample from `items`, for example the items an
11
+ * auto-accept threshold would have accepted without review.
12
+ *
13
+ * Each item is included on its own when a SHA-256 of `(seed, item name)`,
14
+ * read as a number in [0, 1), is below `rate`. The draw is deterministic, does
15
+ * not depend on the order of `items`, and an item's inclusion does not change
16
+ * when other items are added or removed. Items keep their input order. Item
17
+ * names must be unique.
18
+ */
19
+ export declare function drawRandomAuditSample(items: readonly ReviewItem[], options: RandomAuditSampleOptions): ReviewItem[];
20
+ /**
21
+ * Draws a random audit sample (see {@link drawRandomAuditSample}) and opens it
22
+ * as a score-blind review session: the session hides every confidence and
23
+ * verifier result, and each decision made in it records
24
+ * `presentation: { scoreBlind: true }` and
25
+ * `sampling: { kind: "random-audit", rate, seed }`.
26
+ *
27
+ * Throws when the draw selects no item, since a session needs one.
28
+ */
29
+ export declare function openRandomAuditSession(items: readonly ReviewItem[], options: RandomAuditSampleOptions & {
30
+ readonly reviewedAt?: string;
31
+ readonly actorId?: string;
32
+ }): ReviewQueueSessionState;
@@ -0,0 +1,53 @@
1
+ import { sha256Hex } from "../sha256.js";
2
+ import { canonicalJson } from "./canonical.js";
3
+ import { assertAuditRate, assertAuditSeed, initialReviewQueueSessionState, } from "./review-queue-session.js";
4
+ /**
5
+ * Draws a seeded random audit sample from `items`, for example the items an
6
+ * auto-accept threshold would have accepted without review.
7
+ *
8
+ * Each item is included on its own when a SHA-256 of `(seed, item name)`,
9
+ * read as a number in [0, 1), is below `rate`. The draw is deterministic, does
10
+ * not depend on the order of `items`, and an item's inclusion does not change
11
+ * when other items are added or removed. Items keep their input order. Item
12
+ * names must be unique.
13
+ */
14
+ export function drawRandomAuditSample(items, options) {
15
+ assertAuditRate(options.rate);
16
+ assertAuditSeed(options.seed);
17
+ const names = new Set();
18
+ for (const item of items) {
19
+ if (names.has(item.metadata.name)) {
20
+ throw new Error(`Audit sample items must have unique names; ${item.metadata.name} appears twice.`);
21
+ }
22
+ names.add(item.metadata.name);
23
+ }
24
+ return items.filter((item) => auditDraw(options.seed, item.metadata.name) < options.rate);
25
+ }
26
+ /**
27
+ * Draws a random audit sample (see {@link drawRandomAuditSample}) and opens it
28
+ * as a score-blind review session: the session hides every confidence and
29
+ * verifier result, and each decision made in it records
30
+ * `presentation: { scoreBlind: true }` and
31
+ * `sampling: { kind: "random-audit", rate, seed }`.
32
+ *
33
+ * Throws when the draw selects no item, since a session needs one.
34
+ */
35
+ export function openRandomAuditSession(items, options) {
36
+ const sample = drawRandomAuditSample(items, options);
37
+ if (sample.length === 0) {
38
+ throw new Error(`The random audit sample at rate ${options.rate} drew no items from ${items.length}; use a higher rate or more items.`);
39
+ }
40
+ const base = initialReviewQueueSessionState(sample);
41
+ return {
42
+ ...base,
43
+ ...(options.reviewedAt !== undefined ? { reviewedAt: options.reviewedAt } : {}),
44
+ ...(options.actorId !== undefined ? { actorId: options.actorId } : {}),
45
+ presentation: { scoreBlind: true },
46
+ sampling: { kind: "random-audit", rate: options.rate, seed: options.seed },
47
+ };
48
+ }
49
+ /** A deterministic number in [0, 1) from the first 52 bits of a SHA-256. */
50
+ function auditDraw(seed, itemName) {
51
+ const hex = sha256Hex(canonicalJson(["survey.random-audit.v1", seed, itemName])).slice(0, 13);
52
+ return Number.parseInt(hex, 16) / 2 ** 52;
53
+ }
@@ -88,7 +88,7 @@ export interface ExtractionInspectorSource {
88
88
  }>;
89
89
  /**
90
90
  * Whether the import checked excerpts against the prepared artifact
91
- * (`status.provenance`; a record without it is `unverified`). Always set by
91
+ * (`status.provenance`, required on every import record). Always set by
92
92
  * {@link buildExtractionInspectorModel}; optional for hand-authored models.
93
93
  */
94
94
  importProvenance?: "verified" | "unverified";
@@ -25,7 +25,7 @@ export function buildExtractionInspectorModel(input) {
25
25
  if (sourceKeys.has(sourceKey))
26
26
  throw new Error("Extraction inspector source identity collision.");
27
27
  sourceKeys.add(sourceKey);
28
- const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.diagnostics, entry.artifact, envelope.result.ocrDerived, envelope.result.proposals, record.status.provenance ?? "unverified");
28
+ const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.diagnostics, entry.artifact, envelope.result.ocrDerived, envelope.result.proposals, record.status.provenance);
29
29
  sources.push(source);
30
30
  const candidateStart = candidates.length;
31
31
  const itemByProposalIndex = new Map();
@@ -0,0 +1,15 @@
1
+ import type { FieldState } from "../field-states.js";
2
+ import { type ReviewQueueSessionState } from "./review-queue-session.js";
3
+ /**
4
+ * The field-state panel: one row per field the extraction was asked for or
5
+ * proposed, with its derived content state, its lifecycle, and its signals.
6
+ * Fields with no review card (not read, only excluded proposals, no proposal)
7
+ * are listed too, so a run that lost content never reads as complete.
8
+ *
9
+ * The lifecycle of a field in the queue is read from the live session, so it
10
+ * follows the reviewer's decisions. Only what the session cannot know comes
11
+ * from the derived states: a decision carried forward from an earlier round,
12
+ * and a superseded one. In a score-blind session a content state derived from
13
+ * verifier records is shown as the state its candidates alone give.
14
+ */
15
+ export declare function renderFieldStatesHtml(states: readonly FieldState[], session: ReviewQueueSessionState): string;