@kontourai/survey 7.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/example-data/public-directory-review-resource.d.ts +3 -3
  2. package/dist/src/calibration.d.ts +15 -0
  3. package/dist/src/calibration.js +11 -0
  4. package/dist/src/canonical-reviewed-trust-input.d.ts +10 -0
  5. package/dist/src/canonical-reviewed-trust-input.js +15 -0
  6. package/dist/src/decision-carry-forward.d.ts +157 -0
  7. package/dist/src/decision-carry-forward.js +295 -0
  8. package/dist/src/extraction-envelope.d.ts +4 -3
  9. package/dist/src/extraction-envelope.js +3 -3
  10. package/dist/src/field-states.d.ts +100 -0
  11. package/dist/src/field-states.js +169 -0
  12. package/dist/src/index.d.ts +3 -1
  13. package/dist/src/index.js +2 -0
  14. package/dist/src/mcp/review-mcp.js +35 -10
  15. package/dist/src/review-resource.d.ts +29 -0
  16. package/dist/src/review-workbench/audit-sample.d.ts +32 -0
  17. package/dist/src/review-workbench/audit-sample.js +53 -0
  18. package/dist/src/review-workbench/extraction-inspector.d.ts +1 -1
  19. package/dist/src/review-workbench/extraction-inspector.js +1 -1
  20. package/dist/src/review-workbench/field-state-view.d.ts +15 -0
  21. package/dist/src/review-workbench/field-state-view.js +84 -0
  22. package/dist/src/review-workbench/review-queue-session.d.ts +46 -1
  23. package/dist/src/review-workbench/review-queue-session.js +74 -3
  24. package/dist/src/review-workbench/review-session-replay.d.ts +1 -1
  25. package/dist/src/review-workbench/review-session-replay.js +15 -1
  26. package/dist/src/review-workbench/review-workbench-css.generated.js +32 -0
  27. package/dist/src/review-workbench/review-workbench.css +32 -0
  28. package/dist/src/review-workbench/review-workbench.d.ts +13 -1
  29. package/dist/src/review-workbench/review-workbench.js +41 -15
  30. package/dist/src/review-workbench/review-workbench.standalone.css +32 -0
  31. package/package.json +1 -1
@@ -0,0 +1,100 @@
1
+ import { type CandidateVerification } from "./candidate-verification.js";
2
+ import { type DecisionBasis, type DecisionCarryForward, type DecisionSupersession } from "./decision-carry-forward.js";
3
+ import { type ExtractionEnvelopeImport } from "./extraction-envelope.js";
4
+ import type { ReviewDecision } from "./review-resource.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 declare const FIELD_STATE_METADATA_KEY = "survey.kontourai.io/field-state";
17
+ /**
18
+ * What the extraction says about a field.
19
+ *
20
+ * - `value`: at least one candidate with evidence.
21
+ * - `conflicting`: two or more distinct candidate values for the one claim.
22
+ * - `unsupported`: every candidate has a verifier record saying contradicted
23
+ * or not-addressed for its current value, and none saying supported.
24
+ * - `excluded`: the extraction proposed values for the field, but the import
25
+ * left every one out because its excerpt was not at its cited span. The
26
+ * field was read; its proposals are unverifiable, not disproven.
27
+ * - `not_covered`: no proposal at all, and the extraction reported that part
28
+ * of the content was not read (a partial or failed run).
29
+ */
30
+ export type FieldContentState = "value" | "conflicting" | "unsupported" | "excluded" | "not_covered";
31
+ /**
32
+ * Where review stands for a field: `pending` (no decision), `accepted`,
33
+ * `rejected`, `could_not_confirm`, or `superseded` (a supersession record
34
+ * names the decision as replaced).
35
+ */
36
+ export type FieldLifecycleState = "pending" | "accepted" | "rejected" | "could_not_confirm" | "superseded";
37
+ /** A claim slot: the claim a proposal would project to, at its path indices. */
38
+ export interface FieldSlot {
39
+ readonly subjectType: string;
40
+ readonly subjectId: string;
41
+ readonly facet: string;
42
+ readonly claimType: string;
43
+ readonly fieldOrBehavior: string;
44
+ readonly claimId?: string;
45
+ readonly pathIndices?: readonly number[];
46
+ }
47
+ /** Facts about a field that are not states. */
48
+ export interface FieldStateSignals {
49
+ /** The run that produced this import stopped short or failed: other text may hold values. */
50
+ readonly incompleteRun?: true;
51
+ /** Proposals for this slot the import left out because their excerpt was not at their span. */
52
+ readonly excludedProposals?: number;
53
+ /** The import could not ground any candidate (its prepared artifact did not resolve or verify). */
54
+ readonly unresolvedImport?: true;
55
+ }
56
+ export interface FieldState {
57
+ readonly importName: string;
58
+ readonly slot: FieldSlot;
59
+ /** The ReviewItem for this slot, when the import produced one. */
60
+ readonly reviewItemName?: string;
61
+ /** Absent when no producer can state one (see the module note on `not_found`). */
62
+ readonly content?: FieldContentState;
63
+ /** Absent when the slot has no review item, so there is nothing to decide. */
64
+ readonly lifecycle?: FieldLifecycleState;
65
+ /** The decision the lifecycle comes from, when there is one. */
66
+ readonly decisionName?: string;
67
+ /** Whether that decision was made on this round's item or carried forward from an earlier one. */
68
+ readonly decisionBasis?: DecisionBasis;
69
+ readonly signals: FieldStateSignals;
70
+ }
71
+ export interface FieldStateImport {
72
+ readonly record: ExtractionEnvelopeImport;
73
+ /**
74
+ * The fields the extraction was asked for, so a field with no proposal is
75
+ * reported too. Without it only slots that have a proposal appear.
76
+ */
77
+ readonly expectedFields?: readonly FieldSlot[];
78
+ }
79
+ export interface DeriveFieldStatesInput {
80
+ readonly imports: readonly FieldStateImport[];
81
+ /** Decisions on this round's items. At most one per item. */
82
+ readonly decisions?: readonly ReviewDecision[];
83
+ /** Verifier records for this round's candidates (see `CandidateVerification`). */
84
+ readonly verifications?: readonly CandidateVerification[];
85
+ /** Records that the decisions they name as prior were replaced. */
86
+ readonly supersessions?: readonly DecisionSupersession[];
87
+ /** Earlier decisions carried forward onto this round's items. */
88
+ readonly carryForwards?: readonly DecisionCarryForward[];
89
+ }
90
+ /**
91
+ * Derives the content and lifecycle state of every field in the given
92
+ * imports. Pure: reads only its input. Each state appears only when the input
93
+ * that produces it is present: no verifications, no `unsupported`; no
94
+ * supersessions, no `superseded`.
95
+ */
96
+ export declare function deriveFieldStates(input: DeriveFieldStatesInput): FieldState[];
97
+ /** The lifecycle a workbench decision gives its item, as `deriveFieldStates` reads it. */
98
+ export declare function lifecycleForDecision(decision: ReviewDecision | undefined): Exclude<FieldLifecycleState, "superseded">;
99
+ /** The claim metadata the Surface projection carries for one field state. */
100
+ export declare function fieldStateClaimMetadata(state: FieldState): Record<string, unknown>;
@@ -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>`;
@@ -276,6 +297,7 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
276
297
  </head>
277
298
  <body>
278
299
  ${attestation.state === "unverified" ? `<p class="notice" id="unverified-queue-note">${escapeHtml(attestation.message)}</p>` : ""}
300
+ ${blind ? `<p class="notice" id="score-blind-note">${escapeHtml(scoreBlindLines(current)[0])}</p>` : ""}
279
301
  <p class="eyebrow">Survey Review</p>
280
302
  <h1>${escapeHtml(item.spec.target)}</h1>
281
303
  <div class="meta">
@@ -289,7 +311,7 @@ ${attestation.state === "unverified" ? `<p class="notice" id="unverified-queue-n
289
311
  <div class="card">
290
312
  <div class="card-label">Current</div>
291
313
  <div class="value">${currentValue.includes("\n") ? `<pre>${escapeHtml(currentValue)}</pre>` : escapeHtml(currentValue)}</div>
292
- <div class="conf">confidence ${currentConf}</div>
314
+ ${currentConf}
293
315
  <div class="source-ref">${escapeHtml(currentSource)}</div>
294
316
  ${currentExcerpt ? `<div class="excerpt">${escapeHtml(currentExcerpt)}</div>` : ""}
295
317
  </div>
@@ -376,6 +398,7 @@ async function toolQueue(options) {
376
398
  const text = queueSummaryText(snapshot, events, attestation);
377
399
  const queueData = {
378
400
  queueAttestation: attestation.state,
401
+ ...sessionConditions(snapshot),
379
402
  items: snapshot.items.map((item) => {
380
403
  const current = currentSessionState(snapshot, events);
381
404
  const selectedCandidateId = current.decisionsByItemName[item.metadata.name] === "select-proposed"
@@ -413,8 +436,10 @@ async function toolItem(itemName, options) {
413
436
  const text = itemDetailText(item, snapshot, events, attestation);
414
437
  const decision = current.decisionsByItemName[item.metadata.name];
415
438
  const selectedCandidateId = decision === "select-proposed" ? current.selectedCandidateIdsByItemName?.[item.metadata.name] : undefined;
439
+ const blind = isScoreBlind(current);
416
440
  const itemData = {
417
441
  queueAttestation: attestation.state,
442
+ ...sessionConditions(snapshot),
418
443
  name: item.metadata.name,
419
444
  target: item.spec.target,
420
445
  status: deriveQueueRowStatus(item, current),
@@ -431,14 +456,14 @@ async function toolItem(itemName, options) {
431
456
  };
432
457
  })(),
433
458
  candidates: (() => {
434
- const notes = candidateVerificationNotes(item, editedValueFor(item, current));
459
+ const notes = blind ? [] : candidateVerificationNotes(item, editedValueFor(item, current));
435
460
  return item.spec.candidates.map((c, index) => {
436
461
  const note = notes.find((entry) => entry.candidateIndex === index);
437
462
  return {
438
463
  id: c.id,
439
464
  role: c.role,
440
465
  value: c.value,
441
- confidence: c.extraction?.confidence ?? c.confidence,
466
+ ...(blind ? {} : { confidence: c.extraction?.confidence ?? c.confidence }),
442
467
  sourceRef: c.source?.sourceRef,
443
468
  excerpt: c.locator?.excerpt,
444
469
  ...(selectedCandidateId !== undefined && c.role === "proposed" ? { chosen: c.id === selectedCandidateId } : {}),
@@ -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
  }
@@ -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;