@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,84 @@
1
+ import { isScoreBlind } from "./review-queue-session.js";
2
+ const CONTENT_TEXT = {
3
+ value: "Value found",
4
+ conflicting: "Conflicting values",
5
+ unsupported: "Not supported by verifier",
6
+ excluded: "Proposals excluded",
7
+ not_covered: "Not read",
8
+ };
9
+ const LIFECYCLE_TEXT = {
10
+ pending: "Needs review",
11
+ accepted: "Accepted",
12
+ rejected: "Rejected",
13
+ could_not_confirm: "Could not confirm",
14
+ superseded: "Superseded",
15
+ };
16
+ const WORKBENCH_LIFECYCLE = {
17
+ "accept-proposed": "accepted",
18
+ "select-proposed": "accepted",
19
+ "keep-current": "accepted",
20
+ "reject-proposed": "rejected",
21
+ "could-not-confirm": "could_not_confirm",
22
+ };
23
+ /**
24
+ * The field-state panel: one row per field the extraction was asked for or
25
+ * proposed, with its derived content state, its lifecycle, and its signals.
26
+ * Fields with no review card (not read, only excluded proposals, no proposal)
27
+ * are listed too, so a run that lost content never reads as complete.
28
+ *
29
+ * The lifecycle of a field in the queue is read from the live session, so it
30
+ * follows the reviewer's decisions. Only what the session cannot know comes
31
+ * from the derived states: a decision carried forward from an earlier round,
32
+ * and a superseded one. In a score-blind session a content state derived from
33
+ * verifier records is shown as the state its candidates alone give.
34
+ */
35
+ export function renderFieldStatesHtml(states, session) {
36
+ if (states.length === 0)
37
+ return "";
38
+ const itemsByName = new Map(session.items.map((item) => [item.metadata.name, item]));
39
+ const rows = states.map((state) => {
40
+ const item = state.reviewItemName !== undefined ? itemsByName.get(state.reviewItemName) : undefined;
41
+ const content = state.content === "unsupported" && isScoreBlind(session)
42
+ ? (item && item.spec.candidates.filter((candidate) => candidate.role === "proposed").length > 1 ? "conflicting" : "value")
43
+ : state.content;
44
+ const live = item ? session.decisionsByItemName[item.metadata.name] : undefined;
45
+ const lifecycle = live !== undefined
46
+ ? WORKBENCH_LIFECYCLE[live]
47
+ : state.lifecycle === "superseded" || state.decisionBasis === "carried-forward"
48
+ ? state.lifecycle
49
+ : state.reviewItemName !== undefined ? "pending" : undefined;
50
+ const carried = live === undefined && state.decisionBasis === "carried-forward";
51
+ const signals = [
52
+ ...(state.signals.incompleteRun ? ["The extraction stopped before reading all of the source."] : []),
53
+ ...(state.signals.excludedProposals ? [`${state.signals.excludedProposals} proposal${state.signals.excludedProposals === 1 ? " was" : "s were"} excluded: the excerpt is not at the cited span.`] : []),
54
+ ...(state.signals.unresolvedImport ? ["The import could not ground any value."] : []),
55
+ ];
56
+ const label = `${state.slot.fieldOrBehavior}${state.slot.pathIndices?.length ? `[${state.slot.pathIndices.join("][")}]` : ""}`;
57
+ return `
58
+ <li class="field-state-row" data-testid="field-state" data-field="${escapeHtml(label)}" data-content="${content ?? "none"}" data-lifecycle="${lifecycle ?? "none"}"${carried ? ' data-basis="carried-forward"' : ""}>
59
+ <span class="field-state-name">${escapeHtml(label)}</span>
60
+ <span class="field-state-chips">
61
+ <span class="chip state-content ${content ?? "none"}" data-testid="field-state-content">${escapeHtml(content === undefined ? "No proposal" : CONTENT_TEXT[content])}</span>
62
+ ${lifecycle === undefined ? "" : `<span class="chip state-lifecycle ${lifecycle}" data-testid="field-state-lifecycle">${escapeHtml(LIFECYCLE_TEXT[lifecycle])}${carried ? " · carried forward" : ""}</span>`}
63
+ </span>
64
+ ${content === undefined ? `<span class="field-state-note">No value was proposed. That is not the same as the source not stating one.</span>` : ""}
65
+ ${signals.map((signal) => `<span class="field-state-note signal" data-testid="field-state-signal">${escapeHtml(signal)}</span>`).join("")}
66
+ </li>`;
67
+ }).join("");
68
+ const unread = states.filter((state) => state.content === "not_covered").length;
69
+ return `
70
+ <section class="field-states" data-testid="field-states" aria-label="Field states">
71
+ <h2>Field states</h2>
72
+ ${unread > 0 ? `<p class="field-states-lead" data-testid="field-states-incomplete">${unread} field${unread === 1 ? " was" : "s were"} not read: the extraction stopped short. ${unread === 1 ? "It has" : "They have"} no value to review, and that does not mean the source lacks ${unread === 1 ? "it" : "them"}.</p>` : ""}
73
+ <ul>${rows}</ul>
74
+ </section>
75
+ `;
76
+ }
77
+ function escapeHtml(value) {
78
+ return String(value)
79
+ .replaceAll("&", "&amp;")
80
+ .replaceAll("<", "&lt;")
81
+ .replaceAll(">", "&gt;")
82
+ .replaceAll('"', "&quot;")
83
+ .replaceAll("'", "&#39;");
84
+ }
@@ -1,4 +1,5 @@
1
- import { type ReviewCandidate, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewSessionEventSpec } from "../../src/review-resource.js";
1
+ import { type ReviewCandidate, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewSessionEventSpec, type ReviewSessionPresentation, type ReviewSessionSampling } from "../../src/review-resource.js";
2
+ export type { ReviewSessionPresentation, ReviewSessionSampling } from "../../src/review-resource.js";
2
3
  /**
3
4
  * The decisions a reviewer can record on a ReviewItem.
4
5
  *
@@ -31,6 +32,10 @@ export interface ReviewWorkbenchState {
31
32
  * decision and ignored for every other one.
32
33
  */
33
34
  readonly selectedCandidateId?: string;
35
+ /** The session's presentation; recorded on the decision. See {@link ReviewSessionPresentation}. */
36
+ readonly presentation?: ReviewSessionPresentation;
37
+ /** How the session's items were chosen; recorded on the decision. */
38
+ readonly sampling?: ReviewSessionSampling;
34
39
  }
35
40
  export interface ReviewQueueSessionState {
36
41
  readonly items: readonly ReviewItem[];
@@ -53,7 +58,47 @@ export interface ReviewQueueSessionState {
53
58
  * `select-proposed` decision and never read it.
54
59
  */
55
60
  readonly selectedCandidateIdsByItemName?: Readonly<Record<string, string>>;
61
+ /**
62
+ * Session-level presentation, set when the session is opened. Every
63
+ * decision event is stamped with it and replay refuses a mismatch; a
64
+ * `ReviewQueueBinding` also covers it. Without a binding, an edit to both the
65
+ * snapshot and every event stamp is not detectable. Absent: scores are shown
66
+ * and decisions record no presentation.
67
+ */
68
+ readonly presentation?: ReviewSessionPresentation;
69
+ /** How the session's items were chosen. Absent: decisions record no sampling. */
70
+ readonly sampling?: ReviewSessionSampling;
56
71
  }
72
+ /** Whether the session hides confidence and verifier results from its reviewer. */
73
+ export declare function isScoreBlind(session: {
74
+ readonly presentation?: ReviewSessionPresentation;
75
+ }): boolean;
76
+ /**
77
+ * Throws unless the session's presentation and sampling are well formed:
78
+ * `scoreBlind` a boolean, and a `random-audit` sampling with a rate in (0, 1]
79
+ * and a non-empty seed. Unknown keys are refused so a decision never records
80
+ * a condition nothing reads.
81
+ */
82
+ export declare function assertReviewSessionConditions(session: {
83
+ readonly presentation?: unknown;
84
+ readonly sampling?: unknown;
85
+ }): void;
86
+ /**
87
+ * The session conditions a decision event carries, or `undefined` for a
88
+ * session that declares none. Stamped on every decision event so replay can
89
+ * refuse events recorded under other conditions than the snapshot's: sighted
90
+ * events replayed over a blind snapshot, or a snapshot whose presentation was
91
+ * changed after decisions were recorded.
92
+ */
93
+ export declare function sessionConditionsStamp(session: {
94
+ readonly presentation?: ReviewSessionPresentation;
95
+ readonly sampling?: ReviewSessionSampling;
96
+ }): {
97
+ presentation?: ReviewSessionPresentation;
98
+ sampling?: ReviewSessionSampling;
99
+ } | undefined;
100
+ export declare function assertAuditRate(rate: unknown): asserts rate is number;
101
+ export declare function assertAuditSeed(seed: unknown): asserts seed is string;
57
102
  export interface ReviewSessionSummary {
58
103
  readonly accepted: number;
59
104
  readonly keptCurrent: number;
@@ -4,6 +4,71 @@ import { checkEditedValueForItem } from "./edited-value.js";
4
4
  import { assertSoleCandidateId, reviewResourceApiVersion, } from "../../src/review-resource.js";
5
5
  export const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
6
6
  export const defaultReviewSessionName = "review-workbench-session";
7
+ /** Whether the session hides confidence and verifier results from its reviewer. */
8
+ export function isScoreBlind(session) {
9
+ return session.presentation?.scoreBlind === true;
10
+ }
11
+ /**
12
+ * Throws unless the session's presentation and sampling are well formed:
13
+ * `scoreBlind` a boolean, and a `random-audit` sampling with a rate in (0, 1]
14
+ * and a non-empty seed. Unknown keys are refused so a decision never records
15
+ * a condition nothing reads.
16
+ */
17
+ export function assertReviewSessionConditions(session) {
18
+ const { presentation, sampling } = session;
19
+ if (presentation !== undefined) {
20
+ if (!isPlainRecord(presentation) || Object.keys(presentation).join(",") !== "scoreBlind" || typeof presentation.scoreBlind !== "boolean") {
21
+ throw new Error("Review session presentation must be { scoreBlind: boolean }.");
22
+ }
23
+ }
24
+ if (sampling !== undefined) {
25
+ if (!isPlainRecord(sampling))
26
+ throw new Error("Review session sampling must be an object.");
27
+ const keys = Object.keys(sampling).sort().join(",");
28
+ if (sampling.kind === "queue") {
29
+ if (keys !== "kind")
30
+ throw new Error("Review session sampling of kind queue takes no rate or seed.");
31
+ }
32
+ else if (sampling.kind === "random-audit") {
33
+ if (keys !== "kind,rate,seed")
34
+ throw new Error("Review session sampling of kind random-audit requires exactly rate and seed.");
35
+ assertAuditRate(sampling.rate);
36
+ assertAuditSeed(sampling.seed);
37
+ }
38
+ else {
39
+ throw new Error("Review session sampling kind must be queue or random-audit.");
40
+ }
41
+ }
42
+ }
43
+ /**
44
+ * The session conditions a decision event carries, or `undefined` for a
45
+ * session that declares none. Stamped on every decision event so replay can
46
+ * refuse events recorded under other conditions than the snapshot's: sighted
47
+ * events replayed over a blind snapshot, or a snapshot whose presentation was
48
+ * changed after decisions were recorded.
49
+ */
50
+ export function sessionConditionsStamp(session) {
51
+ if (session.presentation === undefined && session.sampling === undefined)
52
+ return undefined;
53
+ return {
54
+ ...(session.presentation !== undefined ? { presentation: { scoreBlind: session.presentation.scoreBlind } } : {}),
55
+ ...(session.sampling !== undefined ? { sampling: { ...session.sampling } } : {}),
56
+ };
57
+ }
58
+ export function assertAuditRate(rate) {
59
+ if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0 || rate > 1) {
60
+ throw new Error("A random-audit rate must be a number greater than 0 and at most 1.");
61
+ }
62
+ }
63
+ export function assertAuditSeed(seed) {
64
+ if (typeof seed !== "string" || !AUDIT_SEED.test(seed)) {
65
+ throw new Error("A random-audit seed must be a stable identity: 1-256 characters from letters, digits and . _ : @ / + ~ -, starting with a letter or digit.");
66
+ }
67
+ }
68
+ const AUDIT_SEED = /^[A-Za-z0-9][A-Za-z0-9._:@/+~-]{0,255}$/;
69
+ function isPlainRecord(value) {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value);
71
+ }
7
72
  export const workbenchDecisionDefinitions = {
8
73
  "accept-proposed": {
9
74
  label: "Accept proposed",
@@ -67,6 +132,8 @@ export function currentReviewWorkbenchState(session) {
67
132
  selectedCandidateId: session.selectedCandidateIdsByItemName?.[item.metadata.name],
68
133
  reviewedAt: session.reviewedAt,
69
134
  actorId: session.actorId,
135
+ ...(session.presentation !== undefined ? { presentation: session.presentation } : {}),
136
+ ...(session.sampling !== undefined ? { sampling: session.sampling } : {}),
70
137
  };
71
138
  }
72
139
  export function currentReviewItem(session) {
@@ -360,9 +427,13 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
360
427
  actor: session.actorId,
361
428
  reviewedAt: session.reviewedAt,
362
429
  });
363
- const data = editedValue !== undefined
364
- ? { workbenchDecision: decision, workbenchEditedValue: editedValue }
365
- : { workbenchDecision: decision, ...(attemptEvidenceIds?.length ? { attemptEvidenceIds } : {}) };
430
+ const conditions = sessionConditionsStamp(session);
431
+ const data = {
432
+ ...(editedValue !== undefined
433
+ ? { workbenchDecision: decision, workbenchEditedValue: editedValue }
434
+ : { workbenchDecision: decision, ...(attemptEvidenceIds?.length ? { attemptEvidenceIds } : {}) }),
435
+ ...(conditions ? { sessionConditions: conditions } : {}),
436
+ };
366
437
  events.push(buildReviewSessionEvent(session, {
367
438
  sessionName,
368
439
  sequence: events.length + 1,
@@ -1,6 +1,6 @@
1
1
  import type { ReviewSessionEvent } from "../review-resource.js";
2
2
  import { type ReviewQueueSessionState } from "./review-queue-session.js";
3
- export type ReviewSessionReplayIssueCode = "invalid-sequence" | "duplicate-sequence" | "non-contiguous-sequence" | "unknown-active-item" | "unknown-review-item" | "unknown-candidate" | "missing-review-item" | "invalid-workbench-decision" | "decision-candidate-mismatch" | "invalid-conflict-selection" | "decision-status-mismatch" | "decision-resolution-mismatch" | "missing-resolution-reason" | "edited-value-not-editable" | "edited-value-type-mismatch";
3
+ export type ReviewSessionReplayIssueCode = "invalid-sequence" | "duplicate-sequence" | "non-contiguous-sequence" | "unknown-active-item" | "unknown-review-item" | "unknown-candidate" | "missing-review-item" | "invalid-workbench-decision" | "decision-candidate-mismatch" | "invalid-conflict-selection" | "decision-status-mismatch" | "decision-resolution-mismatch" | "missing-resolution-reason" | "edited-value-not-editable" | "edited-value-type-mismatch" | "session-conditions-mismatch";
4
4
  export interface ReviewSessionReplayIssue {
5
5
  readonly code: ReviewSessionReplayIssueCode;
6
6
  readonly eventName: string;
@@ -1,5 +1,6 @@
1
- import { candidateForDecision, conflictSelectionIssue, decisionSelectsNoCandidate, isClearedWorkbenchDecisionEvent, workbenchDecisionDefinitions, } from "./review-queue-session.js";
1
+ import { candidateForDecision, conflictSelectionIssue, decisionSelectsNoCandidate, isClearedWorkbenchDecisionEvent, sessionConditionsStamp, workbenchDecisionDefinitions, } from "./review-queue-session.js";
2
2
  import { checkEditedValueForItem } from "./edited-value.js";
3
+ import { canonicalJson } from "./canonical.js";
3
4
  export function validateReviewSessionEventsForSnapshot(snapshot, events) {
4
5
  const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
5
6
  const sequenceIssues = validateEventSequence(events);
@@ -44,6 +45,19 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
44
45
  }
45
46
  else if (event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted") {
46
47
  const decision = replayableWorkbenchDecision(event.spec.data?.workbenchDecision);
48
+ // A decision recorded under other presentation or sampling than the
49
+ // snapshot's must not be replayed as if made under the snapshot's.
50
+ const expectedConditions = sessionConditionsStamp(snapshot);
51
+ const carried = event.spec.data?.sessionConditions;
52
+ if (decision && canonicalJson(carried ?? null) !== canonicalJson(expectedConditions ?? null)) {
53
+ issues.push({
54
+ ...eventRef,
55
+ code: "session-conditions-mismatch",
56
+ reviewItemName,
57
+ candidateId: event.spec.candidateId,
58
+ message: `ReviewSessionEvent ${event.metadata.name} was recorded under session conditions ${JSON.stringify(carried ?? "none")}, but the snapshot's are ${JSON.stringify(expectedConditions ?? "none")}.`,
59
+ });
60
+ }
47
61
  if (!decision) {
48
62
  issues.push({
49
63
  ...eventRef,
@@ -1,2 +1,11 @@
1
1
  export declare const REVIEW_WORKBENCH_CSS: string;
2
+ /** Literal dark token defaults for the element's :host, from @kontourai/ui tokens.css :root. */
3
+ export declare const REVIEW_WORKBENCH_DARK_TOKEN_DECLARATIONS: string;
4
+ /** Literal light token values for the element's color-scheme="light", from @kontourai/ui tokens.css [data-theme="light"]. */
5
+ export declare const REVIEW_WORKBENCH_LIGHT_TOKEN_DECLARATIONS: string;
6
+ /** Each built-in preset's dark and light values, from @kontourai/ui themes.css. */
7
+ export declare const REVIEW_WORKBENCH_THEME_TOKEN_DECLARATIONS: Readonly<Record<string, {
8
+ readonly dark: string;
9
+ readonly light: string;
10
+ }>>;
2
11
  export default REVIEW_WORKBENCH_CSS;