@kontourai/survey 0.4.7 → 0.4.9

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.
package/README.md CHANGED
@@ -87,6 +87,16 @@ application's `body` or `:root` styles. Hosts should mount into an element like:
87
87
  The package also exposes `@kontourai/survey/review-workbench/standalone.css` for
88
88
  the standalone demo page. Use that only when Survey owns the whole page.
89
89
 
90
+ For the full consumer path from `ReviewItem` construction through persisted
91
+ review events, exported results, and optional Surface projection, see
92
+ [`docs/consumer-integration-guide.md`](docs/consumer-integration-guide.md).
93
+ That guide also covers the server-side apply boundary: producers should derive
94
+ write results from reviewed snapshots plus persisted events, not from
95
+ browser-computed decisions or exported result payloads.
96
+ For the current decision on why Survey is not adding a generic review adapter
97
+ builder yet, see
98
+ [`docs/consumer-adapter-abstraction-assessment.md`](docs/consumer-adapter-abstraction-assessment.md).
99
+
90
100
  ## Contributor checks
91
101
 
92
102
  Install the repo-owned Git hooks once per clone:
@@ -15,7 +15,24 @@ export function buildSurfaceProjectionPreview(item, decision) {
15
15
  };
16
16
  }
17
17
  export function formatValue(value) {
18
- return typeof value === "string" ? value : JSON.stringify(value);
18
+ if (typeof value === "string") {
19
+ return value;
20
+ }
21
+ if (value === undefined) {
22
+ return "";
23
+ }
24
+ if (value === null) {
25
+ return "null";
26
+ }
27
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
28
+ return String(value);
29
+ }
30
+ try {
31
+ return JSON.stringify(value, null, 2);
32
+ }
33
+ catch {
34
+ return String(value);
35
+ }
19
36
  }
20
37
  function selectedPreviewCandidate(item, decision) {
21
38
  if (!decision?.spec.candidateId) {
@@ -1,5 +1,5 @@
1
- import { type ReviewQueueSessionState, type ReviewWorkbenchState } from "./review-queue-session.js";
2
- import { type ReviewDecision, type ReviewSession, type ReviewSessionEvent } from "../review-resource.js";
1
+ import { type ReviewQueueSessionState, type ReviewWorkbenchDecision, type ReviewWorkbenchState } from "./review-queue-session.js";
2
+ import { type ReviewCandidate, type ReviewDecision, type ReviewSession, type ReviewSessionEvent } from "../review-resource.js";
3
3
  export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, type ReviewQueueRowStatus, type ReviewQueueSessionState, type ReviewSessionSummary, type ReviewWorkbenchDecision, type ReviewWorkbenchState, } from "./review-queue-session.js";
4
4
  export { buildSurfaceProjectionPreview, type PreviewAuthorityTrace, type PreviewCandidateHistory, type PreviewClaim, type PreviewIntegrityPosture, type PreviewReviewEvent, type PreviewSourceAuthority, type PreviewSourceEvidence, type SurfaceProjectionPreview, } from "./review-surface-preview.js";
5
5
  export declare function buildReviewDecision(state: ReviewWorkbenchState): ReviewDecision | undefined;
@@ -7,23 +7,72 @@ export interface ReviewWorkbenchSessionExport {
7
7
  readonly session: ReviewSession;
8
8
  readonly events: readonly ReviewSessionEvent[];
9
9
  readonly decisions: readonly ReviewDecision[];
10
+ readonly results: readonly ReviewWorkbenchResult[];
11
+ }
12
+ export type ReviewSessionReplayIssueCode = "unknown-active-item" | "unknown-review-item" | "unknown-candidate";
13
+ export interface ReviewSessionReplayIssue {
14
+ readonly code: ReviewSessionReplayIssueCode;
15
+ readonly eventName: string;
16
+ readonly sequence: number;
17
+ readonly reviewItemName?: string;
18
+ readonly candidateId?: string;
19
+ readonly message: string;
10
20
  }
11
21
  export interface ReviewSessionEventStore {
12
22
  load(session: ReviewQueueSessionState): readonly ReviewSessionEvent[] | undefined;
13
23
  save(session: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): void;
14
24
  }
25
+ export type ReviewSessionPersistenceStatus = "idle" | "saving" | "saved" | "error";
26
+ export interface ReviewSessionPersistenceState {
27
+ readonly status: ReviewSessionPersistenceStatus;
28
+ readonly events: readonly ReviewSessionEvent[];
29
+ readonly error?: unknown;
30
+ }
31
+ export interface ReviewSessionPersistenceRequest {
32
+ readonly session: ReviewQueueSessionState;
33
+ readonly events: readonly ReviewSessionEvent[];
34
+ readonly expectedEventCount: number;
35
+ }
36
+ export interface ReviewSessionPersistenceResult {
37
+ readonly eventCount?: number;
38
+ }
39
+ export interface PersistentReviewSessionEventStoreOptions {
40
+ readonly initialEvents?: readonly ReviewSessionEvent[];
41
+ readonly persist: (request: ReviewSessionPersistenceRequest) => Promise<ReviewSessionPersistenceResult | void>;
42
+ readonly onStatusChange?: (state: ReviewSessionPersistenceState) => void;
43
+ }
15
44
  export interface MountReviewWorkbenchOptions {
16
45
  readonly eventStore?: ReviewSessionEventStore;
17
46
  }
18
47
  export interface BrowserReviewWorkbenchConfig {
19
48
  readonly startState?: ReviewQueueSessionState | ReviewWorkbenchState;
20
49
  }
50
+ export interface ReviewWorkbenchResult {
51
+ readonly reviewItemName: string;
52
+ readonly decision: ReviewWorkbenchDecision;
53
+ readonly selectedCandidate: ReviewCandidate;
54
+ readonly selectedCandidateId: string;
55
+ readonly selectedCandidateRole?: ReviewCandidate["role"];
56
+ readonly selectedValue: unknown;
57
+ readonly selectedDisplayValue: string;
58
+ readonly unselectedCandidates: readonly ReviewCandidate[];
59
+ readonly reviewDecision: ReviewDecision;
60
+ readonly status: ReviewDecision["spec"]["status"];
61
+ readonly rationale?: string;
62
+ }
21
63
  export declare function buildReviewDecisionsFromSession(session: ReviewQueueSessionState): ReviewDecision[];
64
+ export declare function buildReviewWorkbenchResultsFromSession(session: ReviewQueueSessionState): ReviewWorkbenchResult[];
22
65
  export declare function buildReviewWorkbenchSessionExport(session: ReviewQueueSessionState, events?: readonly ReviewSessionEvent[]): ReviewWorkbenchSessionExport;
66
+ export declare function validateReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayIssue[];
67
+ export declare function replayReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewQueueSessionState;
68
+ export declare function buildReviewWorkbenchSessionExportForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewWorkbenchSessionExport;
23
69
  export declare function createInMemoryReviewSessionEventStore(initialEvents?: readonly ReviewSessionEvent[]): ReviewSessionEventStore & {
24
70
  events(): readonly ReviewSessionEvent[];
25
71
  };
26
72
  export declare function createLocalStorageReviewSessionEventStore(storage: Pick<Storage, "getItem" | "setItem">, keyPrefix?: string): ReviewSessionEventStore;
73
+ export declare function createPersistentReviewSessionEventStore(options: PersistentReviewSessionEventStoreOptions): ReviewSessionEventStore & {
74
+ events(): readonly ReviewSessionEvent[];
75
+ };
27
76
  export declare function renderReviewWorkbenchHtml(state: ReviewWorkbenchState | ReviewQueueSessionState, events?: readonly ReviewSessionEvent[]): string;
28
77
  export declare function mountReviewWorkbench(root: HTMLElement, startState?: ReviewQueueSessionState | ReviewWorkbenchState, options?: MountReviewWorkbenchOptions): void;
29
78
  export declare function browserReviewWorkbenchStartState(): ReviewQueueSessionState | ReviewWorkbenchState | undefined;
@@ -51,13 +51,98 @@ export function buildReviewDecisionsFromSession(session) {
51
51
  return reviewDecision ? [reviewDecision] : [];
52
52
  });
53
53
  }
54
+ export function buildReviewWorkbenchResultsFromSession(session) {
55
+ return session.items.flatMap((item) => {
56
+ const decision = session.decisionsByItemName[item.metadata.name];
57
+ if (!decision) {
58
+ return [];
59
+ }
60
+ const state = currentReviewWorkbenchState({
61
+ ...session,
62
+ activeItemName: item.metadata.name,
63
+ });
64
+ const reviewDecision = buildReviewDecision(state);
65
+ if (!reviewDecision) {
66
+ return [];
67
+ }
68
+ const selectedCandidate = candidateForDecision(item, decision);
69
+ return [{
70
+ reviewItemName: item.metadata.name,
71
+ decision,
72
+ selectedCandidate,
73
+ selectedCandidateId: selectedCandidate.id,
74
+ selectedCandidateRole: selectedCandidate.role,
75
+ selectedValue: selectedCandidate.value,
76
+ selectedDisplayValue: formatValue(selectedCandidate.value),
77
+ unselectedCandidates: item.spec.candidates.filter((candidate) => candidate.id !== selectedCandidate.id),
78
+ reviewDecision,
79
+ status: reviewDecision.spec.status,
80
+ rationale: reviewDecision.spec.rationale,
81
+ }];
82
+ });
83
+ }
54
84
  export function buildReviewWorkbenchSessionExport(session, events = buildReviewSessionEvents(session)) {
55
85
  return {
56
86
  session: buildReviewSessionResource(session, events),
57
87
  events,
58
88
  decisions: buildReviewDecisionsFromSession(session),
89
+ results: buildReviewWorkbenchResultsFromSession(session),
59
90
  };
60
91
  }
92
+ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
93
+ const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
94
+ return events.flatMap((event) => {
95
+ const issues = [];
96
+ const activeItemName = event.spec.activeItemName;
97
+ const reviewItemName = event.spec.reviewItemName;
98
+ const itemName = reviewItemName ?? activeItemName;
99
+ const eventRef = {
100
+ eventName: event.metadata.name,
101
+ sequence: event.spec.sequence,
102
+ };
103
+ if (activeItemName && !itemsByName.has(activeItemName)) {
104
+ issues.push({
105
+ ...eventRef,
106
+ code: "unknown-active-item",
107
+ reviewItemName: activeItemName,
108
+ message: `ReviewSessionEvent ${event.metadata.name} references active item ${activeItemName}, but the supplied session snapshot does not contain that ReviewItem.`,
109
+ });
110
+ }
111
+ if (reviewItemName && !itemsByName.has(reviewItemName)) {
112
+ issues.push({
113
+ ...eventRef,
114
+ code: "unknown-review-item",
115
+ reviewItemName,
116
+ message: `ReviewSessionEvent ${event.metadata.name} references review item ${reviewItemName}, but the supplied session snapshot does not contain that ReviewItem.`,
117
+ });
118
+ }
119
+ if (event.spec.candidateId && itemName && itemsByName.has(itemName)) {
120
+ const item = itemsByName.get(itemName);
121
+ const hasCandidate = item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId);
122
+ if (!hasCandidate) {
123
+ issues.push({
124
+ ...eventRef,
125
+ code: "unknown-candidate",
126
+ reviewItemName: itemName,
127
+ candidateId: event.spec.candidateId,
128
+ message: `ReviewSessionEvent ${event.metadata.name} references candidate ${event.spec.candidateId}, but ReviewItem ${itemName} in the supplied session snapshot does not contain that candidate.`,
129
+ });
130
+ }
131
+ }
132
+ return issues;
133
+ });
134
+ }
135
+ export function replayReviewSessionEventsForSnapshot(snapshot, events) {
136
+ const issues = validateReviewSessionEventsForSnapshot(snapshot, events);
137
+ if (issues.length > 0) {
138
+ throw new Error(`Review session events do not match the supplied session snapshot: ${issues.map((issue) => issue.message).join(" ")}`);
139
+ }
140
+ return replayReviewSessionEvents(snapshot, events);
141
+ }
142
+ export function buildReviewWorkbenchSessionExportForSnapshot(snapshot, events) {
143
+ const replayedSession = replayReviewSessionEventsForSnapshot(snapshot, events);
144
+ return buildReviewWorkbenchSessionExport(replayedSession, events);
145
+ }
61
146
  export function createInMemoryReviewSessionEventStore(initialEvents = []) {
62
147
  let savedEvents = [...initialEvents];
63
148
  return {
@@ -88,6 +173,45 @@ export function createLocalStorageReviewSessionEventStore(storage, keyPrefix = r
88
173
  },
89
174
  };
90
175
  }
176
+ export function createPersistentReviewSessionEventStore(options) {
177
+ let savedEvents = [...(options.initialEvents ?? [])];
178
+ let lastPersistedSerialized = JSON.stringify(savedEvents);
179
+ let lastPersistedEventCount = savedEvents.length;
180
+ let pendingSave = Promise.resolve();
181
+ const emit = (state) => {
182
+ options.onStatusChange?.(state);
183
+ };
184
+ return {
185
+ events: () => [...savedEvents],
186
+ load: () => savedEvents.length > 0 ? [...savedEvents] : undefined,
187
+ save: (session, events) => {
188
+ const normalizedEvents = [...events];
189
+ const serialized = JSON.stringify(normalizedEvents);
190
+ if (serialized === lastPersistedSerialized) {
191
+ return;
192
+ }
193
+ pendingSave = pendingSave
194
+ .then(async () => {
195
+ if (serialized === lastPersistedSerialized) {
196
+ return;
197
+ }
198
+ emit({ status: "saving", events: normalizedEvents });
199
+ const result = await options.persist({
200
+ session,
201
+ events: normalizedEvents,
202
+ expectedEventCount: lastPersistedEventCount,
203
+ });
204
+ savedEvents = normalizedEvents;
205
+ lastPersistedSerialized = serialized;
206
+ lastPersistedEventCount = result?.eventCount ?? normalizedEvents.length;
207
+ emit({ status: "saved", events: normalizedEvents });
208
+ })
209
+ .catch((error) => {
210
+ emit({ status: "error", events: normalizedEvents, error });
211
+ });
212
+ },
213
+ };
214
+ }
91
215
  export function renderReviewWorkbenchHtml(state, events) {
92
216
  if ("items" in state) {
93
217
  return renderReviewQueueSessionHtml(state, events);
@@ -673,7 +797,7 @@ function refreshSessionAudit(root, session, sessionExport, renderCurrentState) {
673
797
  }
674
798
  function producerFeedbackTags(item) {
675
799
  const tags = item.spec.producerPolicy?.feedbackTags;
676
- return Array.isArray(tags) ? tags.map(String) : [];
800
+ return Array.isArray(tags) ? tags.map(formatValue) : [];
677
801
  }
678
802
  function renderCandidateCard(candidate, state) {
679
803
  const selectedRole = selectedCandidateRole(state);
@@ -734,10 +858,11 @@ function formatConfidence(value) {
734
858
  return `${Math.round(value * 100)}% (${value.toFixed(2)})`;
735
859
  }
736
860
  function titleCase(value) {
737
- return value.slice(0, 1).toUpperCase() + value.slice(1);
861
+ const displayValue = formatValue(value);
862
+ return displayValue.slice(0, 1).toUpperCase() + displayValue.slice(1);
738
863
  }
739
864
  function escapeHtml(value) {
740
- return value
865
+ return formatValue(value)
741
866
  .replaceAll("&", "&amp;")
742
867
  .replaceAll("<", "&lt;")
743
868
  .replaceAll(">", "&gt;")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",