@kontourai/survey 0.4.6 → 0.4.8
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 +23 -0
- package/dist/examples/review-workbench/review-workbench.d.ts +1 -7
- package/dist/examples/review-workbench/review-workbench.js +5 -487
- package/dist/src/index.d.ts +1 -1
- package/dist/src/review-resource.d.ts +33 -2
- package/dist/{examples → src}/review-workbench/review-queue-session.d.ts +7 -1
- package/dist/src/review-workbench/review-queue-session.js +275 -0
- package/dist/{examples → src}/review-workbench/review-surface-preview.d.ts +1 -1
- package/dist/{examples → src}/review-workbench/review-surface-preview.js +18 -1
- package/dist/{examples → src}/review-workbench/review-workbench-data.d.ts +1 -1
- package/dist/{examples → src}/review-workbench/review-workbench-data.js +1 -1
- package/dist/src/review-workbench/review-workbench.css +1499 -0
- package/dist/src/review-workbench/review-workbench.d.ts +71 -0
- package/dist/src/review-workbench/review-workbench.js +892 -0
- package/dist/src/review-workbench/review-workbench.standalone.css +1341 -0
- package/dist/src/review-workbench/vendor/console-kit/tokens/fonts.css +1 -0
- package/dist/src/review-workbench/vendor/console-kit/tokens/index.css +2 -0
- package/dist/src/review-workbench/vendor/console-kit/tokens/themes.css +67 -0
- package/dist/src/review-workbench/vendor/console-kit/tokens/tokens.css +79 -0
- package/package.json +15 -3
- package/dist/examples/review-workbench/review-queue-session.js +0 -122
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
import { candidateForDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
2
|
+
import { buildSurfaceProjectionPreview, formatValue, } from "./review-surface-preview.js";
|
|
3
|
+
import { reviewResourceApiVersion, } from "../review-resource.js";
|
|
4
|
+
export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
5
|
+
export { buildSurfaceProjectionPreview, } from "./review-surface-preview.js";
|
|
6
|
+
export function buildReviewDecision(state) {
|
|
7
|
+
if (!state.decision) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const definition = workbenchDecisionDefinitions[state.decision];
|
|
11
|
+
const candidate = candidateForDecision(state.item, state.decision);
|
|
12
|
+
const projection = {
|
|
13
|
+
...candidate.projection,
|
|
14
|
+
reviewOutcomeId: candidate.projection?.reviewOutcomeId
|
|
15
|
+
?? `${state.item.metadata.name}:${state.decision}:review-outcome`,
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
apiVersion: reviewResourceApiVersion,
|
|
19
|
+
kind: "ReviewDecision",
|
|
20
|
+
metadata: {
|
|
21
|
+
name: `${state.item.metadata.name}-${state.decision}`,
|
|
22
|
+
labels: state.item.metadata.labels,
|
|
23
|
+
producer: state.item.metadata.producer,
|
|
24
|
+
},
|
|
25
|
+
spec: {
|
|
26
|
+
reviewItemName: state.item.metadata.name,
|
|
27
|
+
candidateId: candidate.id,
|
|
28
|
+
status: definition.status,
|
|
29
|
+
actor: {
|
|
30
|
+
id: state.actorId,
|
|
31
|
+
},
|
|
32
|
+
reviewedAt: state.reviewedAt,
|
|
33
|
+
rationale: state.note,
|
|
34
|
+
projection,
|
|
35
|
+
},
|
|
36
|
+
status: {
|
|
37
|
+
appliedToClaimIds: candidate.projection?.claimId ? [candidate.projection.claimId] : undefined,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function buildReviewDecisionsFromSession(session) {
|
|
42
|
+
return session.items.flatMap((item) => {
|
|
43
|
+
const decision = session.decisionsByItemName[item.metadata.name];
|
|
44
|
+
if (!decision) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const reviewDecision = buildReviewDecision(currentReviewWorkbenchState({
|
|
48
|
+
...session,
|
|
49
|
+
activeItemName: item.metadata.name,
|
|
50
|
+
}));
|
|
51
|
+
return reviewDecision ? [reviewDecision] : [];
|
|
52
|
+
});
|
|
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
|
+
}
|
|
84
|
+
export function buildReviewWorkbenchSessionExport(session, events = buildReviewSessionEvents(session)) {
|
|
85
|
+
return {
|
|
86
|
+
session: buildReviewSessionResource(session, events),
|
|
87
|
+
events,
|
|
88
|
+
decisions: buildReviewDecisionsFromSession(session),
|
|
89
|
+
results: buildReviewWorkbenchResultsFromSession(session),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function createInMemoryReviewSessionEventStore(initialEvents = []) {
|
|
93
|
+
let savedEvents = [...initialEvents];
|
|
94
|
+
return {
|
|
95
|
+
events: () => [...savedEvents],
|
|
96
|
+
load: () => savedEvents.length > 0 ? [...savedEvents] : undefined,
|
|
97
|
+
save: (_session, events) => {
|
|
98
|
+
savedEvents = [...events];
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export function createLocalStorageReviewSessionEventStore(storage, keyPrefix = reviewWorkbenchSessionStorageKey) {
|
|
103
|
+
return {
|
|
104
|
+
load: (session) => {
|
|
105
|
+
const value = storage.getItem(reviewSessionStorageKey(session, keyPrefix));
|
|
106
|
+
if (!value) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(value);
|
|
111
|
+
return Array.isArray(parsed) ? parsed : undefined;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
save: (session, events) => {
|
|
118
|
+
storage.setItem(reviewSessionStorageKey(session, keyPrefix), JSON.stringify(events));
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export function createPersistentReviewSessionEventStore(options) {
|
|
123
|
+
let savedEvents = [...(options.initialEvents ?? [])];
|
|
124
|
+
let lastPersistedSerialized = JSON.stringify(savedEvents);
|
|
125
|
+
let lastPersistedEventCount = savedEvents.length;
|
|
126
|
+
let pendingSave = Promise.resolve();
|
|
127
|
+
const emit = (state) => {
|
|
128
|
+
options.onStatusChange?.(state);
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
events: () => [...savedEvents],
|
|
132
|
+
load: () => savedEvents.length > 0 ? [...savedEvents] : undefined,
|
|
133
|
+
save: (session, events) => {
|
|
134
|
+
const normalizedEvents = [...events];
|
|
135
|
+
const serialized = JSON.stringify(normalizedEvents);
|
|
136
|
+
if (serialized === lastPersistedSerialized) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
pendingSave = pendingSave
|
|
140
|
+
.then(async () => {
|
|
141
|
+
if (serialized === lastPersistedSerialized) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
emit({ status: "saving", events: normalizedEvents });
|
|
145
|
+
const result = await options.persist({
|
|
146
|
+
session,
|
|
147
|
+
events: normalizedEvents,
|
|
148
|
+
expectedEventCount: lastPersistedEventCount,
|
|
149
|
+
});
|
|
150
|
+
savedEvents = normalizedEvents;
|
|
151
|
+
lastPersistedSerialized = serialized;
|
|
152
|
+
lastPersistedEventCount = result?.eventCount ?? normalizedEvents.length;
|
|
153
|
+
emit({ status: "saved", events: normalizedEvents });
|
|
154
|
+
})
|
|
155
|
+
.catch((error) => {
|
|
156
|
+
emit({ status: "error", events: normalizedEvents, error });
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
export function renderReviewWorkbenchHtml(state, events) {
|
|
162
|
+
if ("items" in state) {
|
|
163
|
+
return renderReviewQueueSessionHtml(state, events);
|
|
164
|
+
}
|
|
165
|
+
return `
|
|
166
|
+
<section class="workbench-shell" aria-label="Survey review workbench">
|
|
167
|
+
${renderWorkbenchHeader(state)}
|
|
168
|
+
<div class="content-grid">
|
|
169
|
+
${renderReviewFocus(state)}
|
|
170
|
+
${renderCandidateComparison(state)}
|
|
171
|
+
${renderDecisionColumn(state)}
|
|
172
|
+
</div>
|
|
173
|
+
</section>
|
|
174
|
+
`;
|
|
175
|
+
}
|
|
176
|
+
function renderReviewQueueSessionHtml(session, events) {
|
|
177
|
+
const state = currentReviewWorkbenchState(session);
|
|
178
|
+
const sessionExport = buildReviewWorkbenchSessionExport(session, events);
|
|
179
|
+
return `
|
|
180
|
+
<section class="workbench-shell" aria-label="Survey review workbench">
|
|
181
|
+
${renderWorkbenchHeader(state)}
|
|
182
|
+
${renderActiveReviewStrip(session, state)}
|
|
183
|
+
<div class="queue-layout">
|
|
184
|
+
<aside class="queue-panel" aria-label="Review queue">
|
|
185
|
+
${renderQueueRows(session)}
|
|
186
|
+
${renderSessionSummary(session)}
|
|
187
|
+
${renderSessionAudit(session, sessionExport)}
|
|
188
|
+
</aside>
|
|
189
|
+
<div class="content-grid">
|
|
190
|
+
${renderReviewFocus(state)}
|
|
191
|
+
${renderCandidateComparison(state)}
|
|
192
|
+
${renderDecisionColumn(state)}
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</section>
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
function renderSessionAudit(session, sessionExport) {
|
|
199
|
+
const lastEvents = sessionExport.events.slice(-6);
|
|
200
|
+
const replayed = replayReviewSessionEvents({
|
|
201
|
+
...session,
|
|
202
|
+
activeItemName: session.items[0]?.metadata.name ?? session.activeItemName,
|
|
203
|
+
notesByItemName: {},
|
|
204
|
+
decisionsByItemName: {},
|
|
205
|
+
}, sessionExport.events);
|
|
206
|
+
const replayMatchesSession = sameStringRecord(replayed.decisionsByItemName, session.decisionsByItemName)
|
|
207
|
+
&& sameStringRecord(replayed.notesByItemName, session.notesByItemName);
|
|
208
|
+
return `
|
|
209
|
+
<section class="session-audit" data-testid="session-audit" aria-label="Review session audit trail">
|
|
210
|
+
<div class="session-audit-head">
|
|
211
|
+
<div>
|
|
212
|
+
<span class="field-label">ReviewSession</span>
|
|
213
|
+
<h2>${escapeHtml(sessionExport.session.metadata.name)}</h2>
|
|
214
|
+
</div>
|
|
215
|
+
<span class="state-label">${escapeHtml(replayMatchesSession ? "replay ok" : "replay drift")}</span>
|
|
216
|
+
</div>
|
|
217
|
+
<dl class="summary-grid audit-grid">
|
|
218
|
+
${metaItem("Events", String(sessionExport.events.length))}
|
|
219
|
+
${metaItem("Decisions", String(sessionExport.decisions.length))}
|
|
220
|
+
${metaItem("Active item", sessionExport.session.status?.activeItemName ?? "none")}
|
|
221
|
+
${metaItem("Actor", sessionExport.session.spec.actor?.id ?? "unknown")}
|
|
222
|
+
</dl>
|
|
223
|
+
<ol class="session-event-list" data-testid="session-event-list">
|
|
224
|
+
${lastEvents.length === 0
|
|
225
|
+
? "<li><span class=\"field-label\">No events</span><span>No persisted review activity yet.</span></li>"
|
|
226
|
+
: lastEvents.map(renderSessionEventRow).join("")}
|
|
227
|
+
</ol>
|
|
228
|
+
<details class="session-export" data-testid="session-export">
|
|
229
|
+
<summary>Session export</summary>
|
|
230
|
+
<pre>${escapeHtml(JSON.stringify(sessionExport, null, 2))}</pre>
|
|
231
|
+
</details>
|
|
232
|
+
</section>
|
|
233
|
+
`;
|
|
234
|
+
}
|
|
235
|
+
function sameStringRecord(left, right) {
|
|
236
|
+
const leftKeys = Object.keys(left).sort();
|
|
237
|
+
const rightKeys = Object.keys(right).sort();
|
|
238
|
+
return leftKeys.length === rightKeys.length
|
|
239
|
+
&& leftKeys.every((key, index) => key === rightKeys[index] && left[key] === right[key]);
|
|
240
|
+
}
|
|
241
|
+
function renderSessionEventRow(event) {
|
|
242
|
+
const target = event.spec.reviewItemName ?? event.spec.activeItemName ?? event.spec.sessionName;
|
|
243
|
+
const detail = [
|
|
244
|
+
event.spec.status,
|
|
245
|
+
event.spec.candidateId,
|
|
246
|
+
event.spec.rationale,
|
|
247
|
+
].filter(Boolean).join(" - ");
|
|
248
|
+
return `
|
|
249
|
+
<li>
|
|
250
|
+
<span class="event-sequence">${escapeHtml(String(event.spec.sequence).padStart(2, "0"))}</span>
|
|
251
|
+
<span>
|
|
252
|
+
<strong>${escapeHtml(event.spec.eventType)}</strong>
|
|
253
|
+
<small>${escapeHtml(target)}${detail ? ` - ${escapeHtml(detail)}` : ""}</small>
|
|
254
|
+
</span>
|
|
255
|
+
</li>
|
|
256
|
+
`;
|
|
257
|
+
}
|
|
258
|
+
function renderQueueRows(session) {
|
|
259
|
+
return `
|
|
260
|
+
<section class="queue-list" data-testid="review-queue">
|
|
261
|
+
<div class="queue-head">
|
|
262
|
+
<h2>Review queue</h2>
|
|
263
|
+
<button class="next-button" type="button" data-testid="next-unresolved">Next unresolved</button>
|
|
264
|
+
</div>
|
|
265
|
+
${session.items.map((item) => renderQueueRow(item, session)).join("")}
|
|
266
|
+
</section>
|
|
267
|
+
`;
|
|
268
|
+
}
|
|
269
|
+
function renderQueueRow(item, session) {
|
|
270
|
+
const status = deriveQueueRowStatus(item, session);
|
|
271
|
+
const isActive = item.metadata.name === session.activeItemName;
|
|
272
|
+
return `
|
|
273
|
+
<button class="queue-row${isActive ? " is-active" : ""}" type="button" data-item-name="${escapeHtml(item.metadata.name)}" data-testid="queue-row" data-queue-status="${status}">
|
|
274
|
+
<span class="queue-row-main">
|
|
275
|
+
<span class="queue-row-title">${escapeHtml(item.metadata.name)}</span>
|
|
276
|
+
<span class="queue-row-target">${escapeHtml(item.spec.target)}</span>
|
|
277
|
+
</span>
|
|
278
|
+
<span class="state-label">${escapeHtml(status)}</span>
|
|
279
|
+
</button>
|
|
280
|
+
`;
|
|
281
|
+
}
|
|
282
|
+
function renderSessionSummary(session) {
|
|
283
|
+
const summary = reviewSessionSummary(session);
|
|
284
|
+
return `
|
|
285
|
+
<section class="session-summary" data-testid="session-summary">
|
|
286
|
+
<h2>Session summary</h2>
|
|
287
|
+
<dl class="summary-grid">
|
|
288
|
+
${metaItem("Accepted", String(summary.accepted))}
|
|
289
|
+
${metaItem("Kept current", String(summary.keptCurrent))}
|
|
290
|
+
${metaItem("Rejected", String(summary.rejected))}
|
|
291
|
+
${metaItem("Escalated", String(summary.escalated))}
|
|
292
|
+
${metaItem("Unresolved", String(summary.unresolved))}
|
|
293
|
+
</dl>
|
|
294
|
+
</section>
|
|
295
|
+
`;
|
|
296
|
+
}
|
|
297
|
+
function renderActiveReviewStrip(session, state) {
|
|
298
|
+
const activeIndex = session.items.findIndex((item) => item.metadata.name === session.activeItemName);
|
|
299
|
+
const position = activeIndex >= 0 ? activeIndex + 1 : 1;
|
|
300
|
+
const status = deriveQueueRowStatus(state.item, session);
|
|
301
|
+
return `
|
|
302
|
+
<section class="active-review-strip" data-testid="active-review-strip" aria-label="Active review item">
|
|
303
|
+
<div class="active-review-copy">
|
|
304
|
+
<span class="field-label">Review ${position} of ${session.items.length}</span>
|
|
305
|
+
<strong>${escapeHtml(state.item.metadata.name)}</strong>
|
|
306
|
+
<span>${escapeHtml(state.item.spec.target)}</span>
|
|
307
|
+
</div>
|
|
308
|
+
<div class="active-review-actions">
|
|
309
|
+
<span class="state-label">${escapeHtml(status)}</span>
|
|
310
|
+
<button class="next-button" type="button" data-testid="active-next-unresolved">Next unresolved</button>
|
|
311
|
+
</div>
|
|
312
|
+
<div class="active-review-decisions" aria-label="Quick review decision">
|
|
313
|
+
${renderDecisionButtons(state)}
|
|
314
|
+
</div>
|
|
315
|
+
</section>
|
|
316
|
+
`;
|
|
317
|
+
}
|
|
318
|
+
function renderWorkbenchHeader(state) {
|
|
319
|
+
const current = candidateByRole(state.item, "current");
|
|
320
|
+
const proposed = candidateByRole(state.item, "proposed");
|
|
321
|
+
return `
|
|
322
|
+
<header class="topbar">
|
|
323
|
+
<div>
|
|
324
|
+
<p class="eyebrow">${escapeHtml(String(state.item.metadata.producer?.displayName ?? "Survey"))}</p>
|
|
325
|
+
<h1>Review candidate update</h1>
|
|
326
|
+
<p class="review-question">For <strong>${escapeHtml(state.item.spec.target)}</strong>, decide whether <strong>${escapeHtml(formatValue(proposed.value))}</strong> should replace <strong>${escapeHtml(formatValue(current.value))}</strong>.</p>
|
|
327
|
+
</div>
|
|
328
|
+
<dl class="meta-grid">
|
|
329
|
+
${metaItem("Item", state.item.metadata.name)}
|
|
330
|
+
${metaItem("Status", state.item.spec.candidateSetStatus ?? "unresolved")}
|
|
331
|
+
${metaItem("Selected", state.item.spec.selectedCandidateId ?? "none")}
|
|
332
|
+
${metaItem("Candidate count", String(state.item.status?.observedCandidateCount ?? state.item.spec.candidates.length))}
|
|
333
|
+
</dl>
|
|
334
|
+
</header>
|
|
335
|
+
`;
|
|
336
|
+
}
|
|
337
|
+
function renderReviewMain(state) {
|
|
338
|
+
return `
|
|
339
|
+
<div class="review-main">
|
|
340
|
+
${renderReviewFocus(state)}
|
|
341
|
+
${renderCandidateComparison(state)}
|
|
342
|
+
</div>
|
|
343
|
+
`;
|
|
344
|
+
}
|
|
345
|
+
function renderReviewFocus(state) {
|
|
346
|
+
const current = candidateByRole(state.item, "current");
|
|
347
|
+
const proposed = candidateByRole(state.item, "proposed");
|
|
348
|
+
const proposedConfidence = proposed.extraction.confidence ?? proposed.confidence;
|
|
349
|
+
const currentConfidence = current.extraction.confidence ?? current.confidence;
|
|
350
|
+
return `
|
|
351
|
+
<section class="review-focus" data-testid="review-focus" aria-label="Active review focus">
|
|
352
|
+
<div class="focus-head">
|
|
353
|
+
<span class="field-label">Active review</span>
|
|
354
|
+
<span class="state-label">${escapeHtml(state.item.spec.candidateSetStatus ?? "unresolved")}</span>
|
|
355
|
+
</div>
|
|
356
|
+
<div class="focus-values">
|
|
357
|
+
${focusValue("Current", current, currentConfidence, "current")}
|
|
358
|
+
${focusValue("Proposed", proposed, proposedConfidence, "proposed")}
|
|
359
|
+
</div>
|
|
360
|
+
<dl class="focus-evidence">
|
|
361
|
+
${fieldItem("Target", state.item.spec.target)}
|
|
362
|
+
${fieldItem("Proposed source", proposed.source.sourceRef)}
|
|
363
|
+
${fieldItem("Proposed excerpt", proposed.locator?.excerpt ?? "none", "excerpt")}
|
|
364
|
+
</dl>
|
|
365
|
+
</section>
|
|
366
|
+
`;
|
|
367
|
+
}
|
|
368
|
+
function focusValue(label, candidate, confidence, tone) {
|
|
369
|
+
return `
|
|
370
|
+
<div class="focus-value is-${tone}">
|
|
371
|
+
<span class="field-label">${escapeHtml(label)}</span>
|
|
372
|
+
<strong>${escapeHtml(formatValue(candidate.value))}</strong>
|
|
373
|
+
<span>${escapeHtml(confidence === undefined ? "confidence unknown" : formatConfidence(confidence))}</span>
|
|
374
|
+
</div>
|
|
375
|
+
`;
|
|
376
|
+
}
|
|
377
|
+
function renderCandidateComparison(state) {
|
|
378
|
+
const current = candidateByRole(state.item, "current");
|
|
379
|
+
const proposed = candidateByRole(state.item, "proposed");
|
|
380
|
+
return `
|
|
381
|
+
<section class="candidate-grid" aria-label="Candidate comparison">
|
|
382
|
+
${renderCandidateCard(current, state)}
|
|
383
|
+
${renderCandidateCard(proposed, state)}
|
|
384
|
+
</section>
|
|
385
|
+
`;
|
|
386
|
+
}
|
|
387
|
+
function renderDecisionColumn(state) {
|
|
388
|
+
return `
|
|
389
|
+
<aside class="decision-column" aria-label="Review decision">
|
|
390
|
+
${renderDecisionControls(state)}
|
|
391
|
+
${renderSurfacePreview(state)}
|
|
392
|
+
${renderDecisionPayload(state)}
|
|
393
|
+
</aside>
|
|
394
|
+
`;
|
|
395
|
+
}
|
|
396
|
+
function renderDecisionControls(state) {
|
|
397
|
+
const activeDefinition = state.decision ? workbenchDecisionDefinitions[state.decision] : undefined;
|
|
398
|
+
return `
|
|
399
|
+
<section class="decision-row">
|
|
400
|
+
<label class="field">
|
|
401
|
+
<span class="field-label">Reviewer note</span>
|
|
402
|
+
<textarea id="reviewer-note" data-testid="reviewer-note">${escapeHtml(state.note)}</textarea>
|
|
403
|
+
</label>
|
|
404
|
+
<div class="decision-buttons">
|
|
405
|
+
${renderDecisionButtons(state)}
|
|
406
|
+
</div>
|
|
407
|
+
<div class="effect" data-testid="decision-effect">
|
|
408
|
+
<span class="field-label">Decision effect</span>
|
|
409
|
+
<span class="field-value">${escapeHtml(activeDefinition?.effect ?? "No decision selected.")}</span>
|
|
410
|
+
</div>
|
|
411
|
+
${renderFeedbackTags(state)}
|
|
412
|
+
</section>
|
|
413
|
+
`;
|
|
414
|
+
}
|
|
415
|
+
function renderFeedbackTags(state) {
|
|
416
|
+
const tags = producerFeedbackTags(state.item);
|
|
417
|
+
return `
|
|
418
|
+
<div class="feedback-tags" data-testid="producer-feedback-tags">
|
|
419
|
+
<span class="field-label">Producer feedback tags</span>
|
|
420
|
+
<div class="tag-row">
|
|
421
|
+
${tags.length === 0 ? "<span class=\"tag is-empty\">none</span>" : tags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("")}
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
`;
|
|
425
|
+
}
|
|
426
|
+
function renderDecisionButtons(state) {
|
|
427
|
+
return Object.entries(workbenchDecisionDefinitions).map(([key, definition]) => `
|
|
428
|
+
<button class="decision-button${state.decision === key ? " is-active" : ""}" type="button" data-decision="${key}">
|
|
429
|
+
${escapeHtml(definition.label)}
|
|
430
|
+
</button>
|
|
431
|
+
`).join("");
|
|
432
|
+
}
|
|
433
|
+
function renderDecisionPayload(state) {
|
|
434
|
+
const decision = buildReviewDecision(state);
|
|
435
|
+
return `
|
|
436
|
+
<section class="payload-panel">
|
|
437
|
+
<h2 class="field-label">ReviewDecision payload</h2>
|
|
438
|
+
<pre data-testid="decision-payload">${escapeHtml(JSON.stringify(decision ?? null, null, 2))}</pre>
|
|
439
|
+
</section>
|
|
440
|
+
`;
|
|
441
|
+
}
|
|
442
|
+
function renderSurfacePreview(state) {
|
|
443
|
+
const preview = buildSurfaceProjectionPreview(state.item, buildReviewDecision(state));
|
|
444
|
+
return preview ? renderPopulatedSurfacePreview(preview) : renderPendingSurfacePreview();
|
|
445
|
+
}
|
|
446
|
+
function renderPendingSurfacePreview() {
|
|
447
|
+
return `
|
|
448
|
+
<section class="surface-preview" data-testid="surface-preview" aria-label="Surface preview">
|
|
449
|
+
<div class="surface-head">
|
|
450
|
+
<h2>Surface preview</h2>
|
|
451
|
+
<span class="state-label">pending</span>
|
|
452
|
+
</div>
|
|
453
|
+
<p class="preview-disclaimer">Choose a review decision to preview the source and review posture that would project toward Surface. Survey does not validate real-world truth.</p>
|
|
454
|
+
</section>
|
|
455
|
+
`;
|
|
456
|
+
}
|
|
457
|
+
function renderPopulatedSurfacePreview(preview) {
|
|
458
|
+
return `
|
|
459
|
+
<section class="surface-preview" data-testid="surface-preview" aria-label="Surface preview">
|
|
460
|
+
<div class="surface-head">
|
|
461
|
+
<h2>Surface preview</h2>
|
|
462
|
+
<span class="state-label">local preview</span>
|
|
463
|
+
</div>
|
|
464
|
+
<p class="preview-disclaimer" data-testid="surface-preview-disclaimer">${escapeHtml(preview.postureDisclaimer)}</p>
|
|
465
|
+
<div class="preview-section-grid">
|
|
466
|
+
${renderSurfacePreviewSections(preview)}
|
|
467
|
+
</div>
|
|
468
|
+
</section>
|
|
469
|
+
`;
|
|
470
|
+
}
|
|
471
|
+
function renderSurfacePreviewSections(preview) {
|
|
472
|
+
return [
|
|
473
|
+
renderCanonicalClaim(preview),
|
|
474
|
+
renderCandidateHistory(preview),
|
|
475
|
+
renderSourceEvidence(preview),
|
|
476
|
+
renderReviewEvent(preview),
|
|
477
|
+
renderIntegrityPosture(preview),
|
|
478
|
+
renderAuthorityTrace(preview),
|
|
479
|
+
].join("");
|
|
480
|
+
}
|
|
481
|
+
function renderCanonicalClaim(preview) {
|
|
482
|
+
return renderPreviewSection("Selected canonical claim", "surface-canonical-claim", [
|
|
483
|
+
["Candidate", preview.canonicalClaim.candidateId],
|
|
484
|
+
["Claim", preview.canonicalClaim.claimId],
|
|
485
|
+
["Value", preview.canonicalClaim.value],
|
|
486
|
+
["Review status", preview.canonicalClaim.status],
|
|
487
|
+
]);
|
|
488
|
+
}
|
|
489
|
+
function renderReviewEvent(preview) {
|
|
490
|
+
return renderPreviewSection("Review event", "surface-review-event", [
|
|
491
|
+
["Actor", preview.reviewEvent?.actor ?? "unknown"],
|
|
492
|
+
["Reviewed at", preview.reviewEvent?.reviewedAt ?? "not recorded"],
|
|
493
|
+
["Status", preview.reviewEvent?.status ?? "pending"],
|
|
494
|
+
["Rationale", preview.reviewEvent?.rationale ?? "No reviewer rationale provided."],
|
|
495
|
+
["Outcome", preview.reviewEvent?.reviewOutcomeId ?? "not provided"],
|
|
496
|
+
]);
|
|
497
|
+
}
|
|
498
|
+
function renderIntegrityPosture(preview) {
|
|
499
|
+
return renderPreviewSection("Integrity posture", "surface-integrity-posture", [
|
|
500
|
+
["Candidate set", preview.integrityPosture.candidateSetId],
|
|
501
|
+
["Raw source", preview.integrityPosture.rawSourceId],
|
|
502
|
+
["Extraction", preview.integrityPosture.extractionId],
|
|
503
|
+
["Checksum", preview.integrityPosture.checksum],
|
|
504
|
+
]);
|
|
505
|
+
}
|
|
506
|
+
function renderAuthorityTrace(preview) {
|
|
507
|
+
return renderPreviewSection("Authority trace", "surface-authority-trace", [
|
|
508
|
+
["Status", preview.authorityTrace.label],
|
|
509
|
+
["Detail", preview.authorityTrace.detail],
|
|
510
|
+
], preview.authorityTrace.status === "empty" ? " is-neutral" : "");
|
|
511
|
+
}
|
|
512
|
+
function renderCandidateHistory(preview) {
|
|
513
|
+
const rows = preview.candidateHistory.length === 0
|
|
514
|
+
? [["History", "No unselected candidates."]]
|
|
515
|
+
: preview.candidateHistory.flatMap((candidate) => [
|
|
516
|
+
["History", candidate.historyLabel],
|
|
517
|
+
["Candidate", candidate.candidateId],
|
|
518
|
+
["Value", candidate.value],
|
|
519
|
+
]);
|
|
520
|
+
return renderPreviewSection("Unselected candidate history", "surface-candidate-history", rows);
|
|
521
|
+
}
|
|
522
|
+
function renderSourceEvidence(preview) {
|
|
523
|
+
const rows = [
|
|
524
|
+
["Source URL", preview.sourceEvidence.sourceRef],
|
|
525
|
+
["Source ref", preview.sourceEvidence.sourceId],
|
|
526
|
+
["Excerpt", preview.sourceEvidence.excerpt],
|
|
527
|
+
["Extraction", preview.sourceEvidence.extractionId],
|
|
528
|
+
["Extractor", preview.sourceEvidence.extractor],
|
|
529
|
+
["Observed", preview.sourceEvidence.observedAt],
|
|
530
|
+
];
|
|
531
|
+
if (preview.sourceEvidence.sourceAuthority) {
|
|
532
|
+
rows.push(["Source authority class", preview.sourceEvidence.sourceAuthority.authorityClass], ["Declared by", preview.sourceEvidence.sourceAuthority.declaredBy], ["Authority scope", preview.sourceEvidence.sourceAuthority.scope]);
|
|
533
|
+
}
|
|
534
|
+
return renderPreviewSection("Source evidence", "surface-source-evidence", rows);
|
|
535
|
+
}
|
|
536
|
+
function renderPreviewSection(title, testId, rows, extraClass = "") {
|
|
537
|
+
return `
|
|
538
|
+
<section class="preview-section${extraClass}" data-testid="${testId}">
|
|
539
|
+
<h3>${escapeHtml(title)}</h3>
|
|
540
|
+
<dl class="field-stack compact">
|
|
541
|
+
${rows.map(([label, value]) => fieldItem(label, value)).join("")}
|
|
542
|
+
</dl>
|
|
543
|
+
</section>
|
|
544
|
+
`;
|
|
545
|
+
}
|
|
546
|
+
export function mountReviewWorkbench(root, startState = initialReviewQueueSessionState(), options = {}) {
|
|
547
|
+
const controller = createReviewWorkbenchController(root, startState, options);
|
|
548
|
+
controller.renderCurrentState();
|
|
549
|
+
}
|
|
550
|
+
function createReviewWorkbenchController(root, startState, options) {
|
|
551
|
+
const baseSession = queueSessionFromStartState(startState);
|
|
552
|
+
const eventStore = options.eventStore ?? browserReviewSessionEventStore();
|
|
553
|
+
let events = eventStore?.load(baseSession) ?? [];
|
|
554
|
+
let session = events.length > 0 ? replayReviewSessionEvents(baseSession, events) : baseSession;
|
|
555
|
+
const persistEvents = () => {
|
|
556
|
+
eventStore?.save(session, events);
|
|
557
|
+
};
|
|
558
|
+
const appendEvent = (eventType) => {
|
|
559
|
+
const state = currentReviewWorkbenchState(session);
|
|
560
|
+
const decision = state.decision;
|
|
561
|
+
const candidate = decision ? candidateForDecision(state.item, decision) : undefined;
|
|
562
|
+
const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
|
|
563
|
+
events = [
|
|
564
|
+
...events,
|
|
565
|
+
buildReviewSessionEvent(session, {
|
|
566
|
+
sessionName: defaultReviewSessionName,
|
|
567
|
+
sequence: events.length + 1,
|
|
568
|
+
eventType,
|
|
569
|
+
occurredAt: session.reviewedAt,
|
|
570
|
+
reviewItemName: state.item.metadata.name,
|
|
571
|
+
activeItemName: eventType === "item-selected" ? session.activeItemName : undefined,
|
|
572
|
+
reviewDecisionName: decision ? `${state.item.metadata.name}-${decision}` : undefined,
|
|
573
|
+
candidateId: candidate?.id,
|
|
574
|
+
status: definition?.status,
|
|
575
|
+
rationale: state.note,
|
|
576
|
+
data: decision ? { workbenchDecision: decision } : undefined,
|
|
577
|
+
}),
|
|
578
|
+
];
|
|
579
|
+
};
|
|
580
|
+
const applySessionUpdate = (update) => {
|
|
581
|
+
session = update(session);
|
|
582
|
+
};
|
|
583
|
+
const selectDecision = (decision) => {
|
|
584
|
+
applySessionUpdate((current) => ({
|
|
585
|
+
...current,
|
|
586
|
+
decisionsByItemName: {
|
|
587
|
+
...current.decisionsByItemName,
|
|
588
|
+
[currentReviewItem(current).metadata.name]: decision,
|
|
589
|
+
},
|
|
590
|
+
}));
|
|
591
|
+
appendEvent("decision-changed");
|
|
592
|
+
persistEvents();
|
|
593
|
+
};
|
|
594
|
+
const controller = {
|
|
595
|
+
currentState: () => currentReviewWorkbenchState(session),
|
|
596
|
+
currentSession: () => session,
|
|
597
|
+
currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events),
|
|
598
|
+
goToNextUnresolved: () => {
|
|
599
|
+
applyNextUnresolvedSessionUpdate(applySessionUpdate, session);
|
|
600
|
+
appendEvent("item-selected");
|
|
601
|
+
persistEvents();
|
|
602
|
+
},
|
|
603
|
+
renderCurrentState: () => renderCurrentState(root, session, controller),
|
|
604
|
+
selectDecision,
|
|
605
|
+
selectQueueItem: (itemName) => {
|
|
606
|
+
applySessionUpdate((current) => ({ ...current, activeItemName: itemName }));
|
|
607
|
+
appendEvent("item-selected");
|
|
608
|
+
persistEvents();
|
|
609
|
+
},
|
|
610
|
+
updateReviewerNote: (note) => {
|
|
611
|
+
applyReviewerNoteSessionUpdate(applySessionUpdate, session, note);
|
|
612
|
+
appendEvent("note-changed");
|
|
613
|
+
persistEvents();
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
return controller;
|
|
617
|
+
}
|
|
618
|
+
function applyReviewerNoteSessionUpdate(applySessionUpdate, session, note) {
|
|
619
|
+
const itemName = currentReviewItem(session).metadata.name;
|
|
620
|
+
applySessionUpdate((current) => ({
|
|
621
|
+
...current,
|
|
622
|
+
notesByItemName: {
|
|
623
|
+
...current.notesByItemName,
|
|
624
|
+
[itemName]: note,
|
|
625
|
+
},
|
|
626
|
+
}));
|
|
627
|
+
}
|
|
628
|
+
function applyNextUnresolvedSessionUpdate(applySessionUpdate, session) {
|
|
629
|
+
const next = nextUnresolvedItemName(session);
|
|
630
|
+
if (next) {
|
|
631
|
+
applySessionUpdate((current) => ({ ...current, activeItemName: next }));
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function browserReviewSessionEventStore() {
|
|
635
|
+
if (typeof window === "undefined" || !window.localStorage) {
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
return createLocalStorageReviewSessionEventStore(window.localStorage);
|
|
639
|
+
}
|
|
640
|
+
function reviewSessionStorageKey(session, keyPrefix) {
|
|
641
|
+
const itemNames = buildReviewSessionResource(session).spec.reviewItemNames.join(",");
|
|
642
|
+
return `${keyPrefix}:${itemNames}`;
|
|
643
|
+
}
|
|
644
|
+
function renderCurrentState(root, session, controller) {
|
|
645
|
+
root.innerHTML = renderReviewWorkbenchHtml(session, controller.currentSessionExport().events);
|
|
646
|
+
bindReviewerNote(root, controller.updateReviewerNote, controller.currentState, controller.currentSession, controller.currentSessionExport, controller.renderCurrentState);
|
|
647
|
+
bindDecisionButtons(root, (decision) => {
|
|
648
|
+
controller.selectDecision(decision);
|
|
649
|
+
controller.renderCurrentState();
|
|
650
|
+
});
|
|
651
|
+
bindQueueRows(root, (itemName) => {
|
|
652
|
+
controller.selectQueueItem(itemName);
|
|
653
|
+
controller.renderCurrentState();
|
|
654
|
+
});
|
|
655
|
+
bindNextUnresolved(root, () => {
|
|
656
|
+
controller.goToNextUnresolved();
|
|
657
|
+
controller.renderCurrentState();
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
function queueSessionFromStartState(startState) {
|
|
661
|
+
if ("items" in startState) {
|
|
662
|
+
return startState;
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
items: [startState.item],
|
|
666
|
+
activeItemName: startState.item.metadata.name,
|
|
667
|
+
notesByItemName: startState.note ? { [startState.item.metadata.name]: startState.note } : {},
|
|
668
|
+
decisionsByItemName: startState.decision ? { [startState.item.metadata.name]: startState.decision } : {},
|
|
669
|
+
reviewedAt: startState.reviewedAt,
|
|
670
|
+
actorId: startState.actorId,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
function bindReviewerNote(root, updateReviewerNote, currentState, currentSession, currentSessionExport, renderCurrentState) {
|
|
674
|
+
root.querySelector("[data-testid='reviewer-note']")?.addEventListener("input", (event) => {
|
|
675
|
+
updateReviewerNote(event.target.value);
|
|
676
|
+
refreshDecisionOutputs(root, currentState(), renderCurrentState);
|
|
677
|
+
refreshSessionAudit(root, currentSession(), currentSessionExport(), renderCurrentState);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
function bindDecisionButtons(root, selectDecision) {
|
|
681
|
+
root.querySelectorAll("[data-decision]").forEach((button) => {
|
|
682
|
+
button.addEventListener("click", () => {
|
|
683
|
+
selectDecision(button.dataset.decision);
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
function bindQueueRows(root, selectQueueItem) {
|
|
688
|
+
root.querySelectorAll("[data-item-name]").forEach((button) => {
|
|
689
|
+
button.addEventListener("click", () => {
|
|
690
|
+
selectQueueItem(button.dataset.itemName ?? "");
|
|
691
|
+
scrollActiveReviewIntoView(root);
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
function bindNextUnresolved(root, goToNextUnresolved) {
|
|
696
|
+
root
|
|
697
|
+
.querySelectorAll("[data-testid='next-unresolved'], [data-testid='active-next-unresolved']")
|
|
698
|
+
.forEach((button) => {
|
|
699
|
+
button.addEventListener("click", () => {
|
|
700
|
+
goToNextUnresolved();
|
|
701
|
+
scrollActiveReviewIntoView(root);
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
function scrollActiveReviewIntoView(root) {
|
|
706
|
+
if (typeof window === "undefined" || !window.matchMedia("(max-width: 980px)").matches) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
root.querySelector("[data-testid='active-review-strip']")?.scrollIntoView({
|
|
710
|
+
block: "start",
|
|
711
|
+
behavior: "smooth",
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function refreshDecisionOutputs(root, state, renderCurrentState) {
|
|
715
|
+
const payload = root.querySelector("[data-testid='decision-payload']");
|
|
716
|
+
if (payload) {
|
|
717
|
+
payload.textContent = JSON.stringify(buildReviewDecision(state) ?? null, null, 2);
|
|
718
|
+
}
|
|
719
|
+
const preview = root.querySelector("[data-testid='surface-preview']");
|
|
720
|
+
if (!preview) {
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (typeof document === "undefined") {
|
|
724
|
+
renderCurrentState();
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const wrapper = document.createElement("div");
|
|
728
|
+
wrapper.innerHTML = renderSurfacePreview(state);
|
|
729
|
+
preview.replaceWith(wrapper.firstElementChild);
|
|
730
|
+
}
|
|
731
|
+
function refreshSessionAudit(root, session, sessionExport, renderCurrentState) {
|
|
732
|
+
const audit = root.querySelector("[data-testid='session-audit']");
|
|
733
|
+
if (!audit) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (typeof document === "undefined") {
|
|
737
|
+
renderCurrentState();
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const wrapper = document.createElement("div");
|
|
741
|
+
wrapper.innerHTML = renderSessionAudit(session, sessionExport);
|
|
742
|
+
audit.replaceWith(wrapper.firstElementChild);
|
|
743
|
+
}
|
|
744
|
+
function producerFeedbackTags(item) {
|
|
745
|
+
const tags = item.spec.producerPolicy?.feedbackTags;
|
|
746
|
+
return Array.isArray(tags) ? tags.map(formatValue) : [];
|
|
747
|
+
}
|
|
748
|
+
function renderCandidateCard(candidate, state) {
|
|
749
|
+
const selectedRole = selectedCandidateRole(state);
|
|
750
|
+
const candidateState = state.decision
|
|
751
|
+
? candidate.role === selectedRole ? "selected" : "unselected"
|
|
752
|
+
: "pending";
|
|
753
|
+
const cssState = candidateState === "selected" ? " is-selected" : candidateState === "unselected" ? " is-unselected" : "";
|
|
754
|
+
const confidence = candidate.extraction.confidence ?? candidate.confidence;
|
|
755
|
+
return `
|
|
756
|
+
<article class="candidate-card${cssState}" data-testid="candidate-${escapeHtml(candidate.role ?? candidate.id)}" data-outcome="${candidateState}">
|
|
757
|
+
<div class="card-head">
|
|
758
|
+
<div>
|
|
759
|
+
<p class="eyebrow">${escapeHtml(candidate.id)}</p>
|
|
760
|
+
<h2 class="role">${escapeHtml(titleCase(candidate.role ?? "candidate"))}</h2>
|
|
761
|
+
</div>
|
|
762
|
+
<span class="state-label">${escapeHtml(candidateState)}</span>
|
|
763
|
+
</div>
|
|
764
|
+
<div class="candidate-value">
|
|
765
|
+
<span class="field-label">Value</span>
|
|
766
|
+
<p class="field-value">${escapeHtml(formatValue(candidate.value))}</p>
|
|
767
|
+
</div>
|
|
768
|
+
<dl class="field-stack">
|
|
769
|
+
${fieldItem("Source URL", candidate.source.sourceRef)}
|
|
770
|
+
${fieldItem("Source ref", candidate.source.sourceId ?? candidate.source.sourceRef)}
|
|
771
|
+
${fieldItem("Locator", candidate.locator?.locator ?? candidate.locator?.scheme ?? "none")}
|
|
772
|
+
${fieldItem("Excerpt", candidate.locator?.excerpt ?? "none", "excerpt")}
|
|
773
|
+
${fieldItem("Extraction confidence", confidence === undefined ? "unknown" : formatConfidence(confidence))}
|
|
774
|
+
${fieldItem("Extractor", candidate.extraction.extractor ?? "unknown")}
|
|
775
|
+
${fieldItem("Claim", candidate.claimTarget.claimId ?? candidate.claimTarget.fieldOrBehavior)}
|
|
776
|
+
</dl>
|
|
777
|
+
</article>
|
|
778
|
+
`;
|
|
779
|
+
}
|
|
780
|
+
function candidateByRole(item, role) {
|
|
781
|
+
const candidate = item.spec.candidates.find((entry) => entry.role === role);
|
|
782
|
+
if (!candidate) {
|
|
783
|
+
throw new Error(`ReviewItem ${item.metadata.name} has no ${role} candidate.`);
|
|
784
|
+
}
|
|
785
|
+
return candidate;
|
|
786
|
+
}
|
|
787
|
+
function metaItem(label, value) {
|
|
788
|
+
return `
|
|
789
|
+
<div class="meta-item">
|
|
790
|
+
<dt class="field-label">${escapeHtml(label)}</dt>
|
|
791
|
+
<dd class="meta-value">${escapeHtml(value)}</dd>
|
|
792
|
+
</div>
|
|
793
|
+
`;
|
|
794
|
+
}
|
|
795
|
+
function fieldItem(label, value, extraClass = "") {
|
|
796
|
+
return `
|
|
797
|
+
<div class="field ${extraClass}">
|
|
798
|
+
<dt class="field-label">${escapeHtml(label)}</dt>
|
|
799
|
+
<dd class="field-value">${escapeHtml(value)}</dd>
|
|
800
|
+
</div>
|
|
801
|
+
`;
|
|
802
|
+
}
|
|
803
|
+
function formatConfidence(value) {
|
|
804
|
+
return `${Math.round(value * 100)}% (${value.toFixed(2)})`;
|
|
805
|
+
}
|
|
806
|
+
function titleCase(value) {
|
|
807
|
+
const displayValue = formatValue(value);
|
|
808
|
+
return displayValue.slice(0, 1).toUpperCase() + displayValue.slice(1);
|
|
809
|
+
}
|
|
810
|
+
function escapeHtml(value) {
|
|
811
|
+
return formatValue(value)
|
|
812
|
+
.replaceAll("&", "&")
|
|
813
|
+
.replaceAll("<", "<")
|
|
814
|
+
.replaceAll(">", ">")
|
|
815
|
+
.replaceAll("\"", """)
|
|
816
|
+
.replaceAll("'", "'");
|
|
817
|
+
}
|
|
818
|
+
export function browserReviewWorkbenchStartState() {
|
|
819
|
+
if (typeof window === "undefined") {
|
|
820
|
+
return undefined;
|
|
821
|
+
}
|
|
822
|
+
const config = window.kontourSurveyReviewWorkbench;
|
|
823
|
+
if (!config) {
|
|
824
|
+
return undefined;
|
|
825
|
+
}
|
|
826
|
+
if (isReviewQueueSessionState(config) || isReviewWorkbenchState(config)) {
|
|
827
|
+
return config;
|
|
828
|
+
}
|
|
829
|
+
const configuredStartState = config.startState;
|
|
830
|
+
return isReviewQueueSessionState(configuredStartState) || isReviewWorkbenchState(configuredStartState)
|
|
831
|
+
? configuredStartState
|
|
832
|
+
: undefined;
|
|
833
|
+
}
|
|
834
|
+
function isReviewQueueSessionState(value) {
|
|
835
|
+
if (!isRecord(value)
|
|
836
|
+
|| !Array.isArray(value.items)
|
|
837
|
+
|| typeof value.activeItemName !== "string"
|
|
838
|
+
|| !isStringRecord(value.notesByItemName)
|
|
839
|
+
|| !isStringRecord(value.decisionsByItemName)
|
|
840
|
+
|| typeof value.reviewedAt !== "string"
|
|
841
|
+
|| typeof value.actorId !== "string") {
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
return value.items.length > 0
|
|
845
|
+
&& value.items.every(isReviewItem)
|
|
846
|
+
&& value.items.some((item) => item.metadata.name === value.activeItemName);
|
|
847
|
+
}
|
|
848
|
+
function isReviewWorkbenchState(value) {
|
|
849
|
+
return isRecord(value)
|
|
850
|
+
&& isReviewItem(value.item)
|
|
851
|
+
&& typeof value.note === "string"
|
|
852
|
+
&& typeof value.reviewedAt === "string"
|
|
853
|
+
&& typeof value.actorId === "string";
|
|
854
|
+
}
|
|
855
|
+
function isReviewItem(value) {
|
|
856
|
+
return isRecord(value)
|
|
857
|
+
&& value.apiVersion === reviewResourceApiVersion
|
|
858
|
+
&& value.kind === "ReviewItem"
|
|
859
|
+
&& isResourceMetadata(value.metadata)
|
|
860
|
+
&& isReviewItemSpec(value.spec);
|
|
861
|
+
}
|
|
862
|
+
function isResourceMetadata(value) {
|
|
863
|
+
return isRecord(value) && typeof value.name === "string" && value.name.length > 0;
|
|
864
|
+
}
|
|
865
|
+
function isReviewItemSpec(value) {
|
|
866
|
+
return isRecord(value)
|
|
867
|
+
&& typeof value.target === "string"
|
|
868
|
+
&& Array.isArray(value.candidates)
|
|
869
|
+
&& value.candidates.length > 0
|
|
870
|
+
&& value.candidates.every(isReviewCandidate);
|
|
871
|
+
}
|
|
872
|
+
function isReviewCandidate(value) {
|
|
873
|
+
return isRecord(value)
|
|
874
|
+
&& typeof value.id === "string"
|
|
875
|
+
&& isRecord(value.source)
|
|
876
|
+
&& typeof value.source.sourceRef === "string"
|
|
877
|
+
&& isRecord(value.extraction)
|
|
878
|
+
&& typeof value.extraction.target === "string"
|
|
879
|
+
&& isRecord(value.claimTarget)
|
|
880
|
+
&& typeof value.claimTarget.subjectType === "string"
|
|
881
|
+
&& typeof value.claimTarget.subjectId === "string"
|
|
882
|
+
&& typeof value.claimTarget.surface === "string"
|
|
883
|
+
&& typeof value.claimTarget.claimType === "string"
|
|
884
|
+
&& typeof value.claimTarget.fieldOrBehavior === "string"
|
|
885
|
+
&& typeof value.claimTarget.impactLevel === "string";
|
|
886
|
+
}
|
|
887
|
+
function isRecord(value) {
|
|
888
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
889
|
+
}
|
|
890
|
+
function isStringRecord(value) {
|
|
891
|
+
return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
892
|
+
}
|