@kontourai/survey 8.1.1 → 8.2.1
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 +6 -4
- package/dist/src/console/review-console-server.d.ts +3 -1
- package/dist/src/console/review-console-server.js +58 -15
- package/dist/src/review-workbench/review-workbench-css.generated.js +11 -0
- package/dist/src/review-workbench/review-workbench.css +11 -0
- package/dist/src/review-workbench/review-workbench.d.ts +15 -0
- package/dist/src/review-workbench/review-workbench.js +39 -29
- package/dist/src/review-workbench/review-workbench.standalone.css +11 -0
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -38,12 +38,14 @@ The Review Workbench rendering a real example queue — current vs proposed valu
|
|
|
38
38
|
## Quickstart
|
|
39
39
|
|
|
40
40
|
```sh
|
|
41
|
-
npm install @kontourai/survey @kontourai/surface
|
|
41
|
+
npm install @kontourai/survey @kontourai/surface@^4
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
Survey supports `@kontourai/surface` 2.13 and later 2.x,
|
|
45
|
-
|
|
46
|
-
Survey share one Surface copy (`npm ls @kontourai/surface`
|
|
44
|
+
Survey supports `@kontourai/surface` 2.13 and later 2.x, 3.x, and 4.x. CI tests
|
|
45
|
+
2.13.0 and the newest 3.x and 4.x. Install a Surface version in that range so
|
|
46
|
+
your project and Survey share one Surface copy (`npm ls @kontourai/surface`
|
|
47
|
+
shows one entry). Without a version, npm installs the latest Surface, which
|
|
48
|
+
can fall outside that range; npm then installs a separate copy for Survey.
|
|
47
49
|
|
|
48
50
|
Requires Node.js >=22. TypeScript >=5.0 is required to compile against
|
|
49
51
|
Survey's published type declarations (`defineProductVocabulary`'s `const`
|
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { type ServerReviewSessionRecord } from "../review-workbench/server-review-session.js";
|
|
16
16
|
import { type ReviewQueueBinding } from "../review-workbench/queue-binding.js";
|
|
17
|
-
import type { ReviewWorkbenchResult } from "../review-workbench/review-workbench.js";
|
|
17
|
+
import type { ReviewWorkbenchResult, ReviewQueuePresentation } from "../review-workbench/review-workbench.js";
|
|
18
18
|
import type { ReviewSessionEvent } from "../review-resource.js";
|
|
19
19
|
export interface ReviewConsoleServerOptions {
|
|
20
|
+
/** Producer-owned display hints; independent of queue binding and application authority. */
|
|
21
|
+
readonly presentation?: ReviewQueuePresentation;
|
|
20
22
|
/** Absolute path to the session JSON file. */
|
|
21
23
|
readonly sessionPath: string;
|
|
22
24
|
/** TCP port. Defaults to 0 (OS-assigned). */
|
|
@@ -286,6 +286,7 @@ body {
|
|
|
286
286
|
color: var(--k-text, #eef3f8);
|
|
287
287
|
font-size: 13px;
|
|
288
288
|
}
|
|
289
|
+
.console-save-status[data-state="success"] { border-color: var(--k-positive); }
|
|
289
290
|
.console-save-status[hidden] { display: none; }
|
|
290
291
|
#review-workbench {
|
|
291
292
|
flex: 1;
|
|
@@ -325,7 +326,7 @@ body {
|
|
|
325
326
|
</button>
|
|
326
327
|
</div>
|
|
327
328
|
</header>
|
|
328
|
-
${canApply ? '<div style="display:flex;justify-content:flex-end;padding:8px 16px"><button type="button" id="apply-review" class="theme-toggle">Send review to workflow</button></div>' : ''}
|
|
329
|
+
${canApply ? '<div style="display:flex;justify-content:flex-end;padding:8px 16px"><button type="button" id="apply-review" class="theme-toggle" disabled>Send review to workflow</button></div>' : ''}
|
|
329
330
|
<div id="console-save-status" class="console-save-status" role="alert" data-testid="console-save-status" hidden></div>
|
|
330
331
|
<main id="review-workbench" class="workbench survey-workbench-embed" data-testid="review-workbench"></main>
|
|
331
332
|
<script type="module">
|
|
@@ -342,11 +343,13 @@ import { mountReviewWorkbench, replayReviewSessionEvents, defaultReviewSessionNa
|
|
|
342
343
|
// stored session, re-mounts from it and tells the reviewer once.
|
|
343
344
|
let saveChain = Promise.resolve();
|
|
344
345
|
|
|
345
|
-
function showSaveStatus(message) {
|
|
346
|
+
function showSaveStatus(message, state = "error") {
|
|
346
347
|
const el = document.getElementById("console-save-status");
|
|
347
348
|
if (!el) return;
|
|
348
349
|
if (message) {
|
|
349
350
|
el.textContent = message;
|
|
351
|
+
el.dataset.state = state;
|
|
352
|
+
el.setAttribute("role", state === "success" ? "status" : "alert");
|
|
350
353
|
el.hidden = false;
|
|
351
354
|
} else {
|
|
352
355
|
el.textContent = "";
|
|
@@ -366,7 +369,9 @@ function createConsoleEventStore(baseRevision) {
|
|
|
366
369
|
|
|
367
370
|
async function refused(message) {
|
|
368
371
|
abandoned = true;
|
|
369
|
-
|
|
372
|
+
saveFailed = true;
|
|
373
|
+
updateApplyButton();
|
|
374
|
+
showSaveStatus(message + " Reload before sending the review, or retry the change.");
|
|
370
375
|
await fetchAndMount();
|
|
371
376
|
}
|
|
372
377
|
|
|
@@ -387,9 +392,11 @@ function createConsoleEventStore(baseRevision) {
|
|
|
387
392
|
return;
|
|
388
393
|
}
|
|
389
394
|
if (res.ok) {
|
|
390
|
-
const body = await res.json()
|
|
391
|
-
if (typeof body.revision
|
|
395
|
+
const body = await res.json();
|
|
396
|
+
if (typeof body.revision !== "string") throw new Error("Missing saved revision");
|
|
397
|
+
revision = body.revision; currentRevision = revision;
|
|
392
398
|
persistedCount = localEvents.length;
|
|
399
|
+
saveFailed = false;
|
|
393
400
|
showSaveStatus("");
|
|
394
401
|
return;
|
|
395
402
|
}
|
|
@@ -404,9 +411,18 @@ function createConsoleEventStore(baseRevision) {
|
|
|
404
411
|
return {
|
|
405
412
|
// The mount state is already replayed from the stored log.
|
|
406
413
|
load: () => undefined,
|
|
407
|
-
save: (
|
|
414
|
+
save: (session, events) => {
|
|
415
|
+
currentState = session;
|
|
416
|
+
stateGeneration++;
|
|
417
|
+
pendingSaveCount++;
|
|
418
|
+
updateApplyButton();
|
|
408
419
|
const localEvents = [...events];
|
|
409
|
-
saveChain = saveChain.then(() => persist(localEvents))
|
|
420
|
+
saveChain = saveChain.then(() => persist(localEvents)).catch(async () => {
|
|
421
|
+
await refused("Your last change was not saved. The console reloaded the stored session.");
|
|
422
|
+
}).finally(() => {
|
|
423
|
+
pendingSaveCount--;
|
|
424
|
+
updateApplyButton();
|
|
425
|
+
});
|
|
410
426
|
pendingSave = saveChain;
|
|
411
427
|
return saveChain;
|
|
412
428
|
},
|
|
@@ -418,9 +434,19 @@ let currentRevision = null;
|
|
|
418
434
|
let applying = false;
|
|
419
435
|
let wasApplied = false;
|
|
420
436
|
let pendingSave = Promise.resolve();
|
|
437
|
+
let pendingSaveCount = 0;
|
|
438
|
+
let saveFailed = false;
|
|
439
|
+
let currentState = null;
|
|
440
|
+
let stateGeneration = 0;
|
|
441
|
+
let fetchGeneration = 0;
|
|
442
|
+
function updateApplyButton() {
|
|
443
|
+
if (!applyButton) return;
|
|
444
|
+
const complete = currentState?.items?.length > 0 && currentState.items.every(item => currentState.decisionsByItemName[item.metadata.name] !== undefined);
|
|
445
|
+
applyButton.disabled = applying || wasApplied || pendingSaveCount > 0 || saveFailed || !currentRevision || !complete;
|
|
446
|
+
}
|
|
421
447
|
const applyButton = document.getElementById("apply-review");
|
|
422
448
|
applyButton?.addEventListener("click", async () => {
|
|
423
|
-
if (applying || !currentRevision) return;
|
|
449
|
+
if (applyButton.disabled || applying || !currentRevision) return;
|
|
424
450
|
applying = true;
|
|
425
451
|
applyButton.disabled = true;
|
|
426
452
|
try {
|
|
@@ -430,31 +456,41 @@ applyButton?.addEventListener("click", async () => {
|
|
|
430
456
|
body: JSON.stringify({ baseRevision: currentRevision }),
|
|
431
457
|
});
|
|
432
458
|
const body = await response.json();
|
|
433
|
-
showSaveStatus(response.ok ? body.application.summary : body.error);
|
|
459
|
+
showSaveStatus(response.ok ? body.application.summary : body.error, response.ok ? "success" : "error");
|
|
434
460
|
await fetchAndMount();
|
|
435
|
-
} catch { showSaveStatus("The review could not reach the workflow. Reload before trying again."); }
|
|
436
|
-
finally { applying = false;
|
|
461
|
+
} catch { saveFailed = true; showSaveStatus("The review could not reach the workflow. Reload before trying again."); }
|
|
462
|
+
finally { applying = false; updateApplyButton(); }
|
|
437
463
|
});
|
|
438
464
|
|
|
439
465
|
async function fetchAndMount() {
|
|
466
|
+
const fetchBasis = ++fetchGeneration;
|
|
467
|
+
const stateBasis = stateGeneration;
|
|
440
468
|
try {
|
|
441
469
|
const res = await fetch("/api/session");
|
|
470
|
+
if (fetchBasis !== fetchGeneration || stateBasis !== stateGeneration) return;
|
|
442
471
|
if (!res.ok) {
|
|
443
472
|
const body = await res.json().catch(() => ({}));
|
|
473
|
+
if (fetchBasis !== fetchGeneration || stateBasis !== stateGeneration) return;
|
|
474
|
+
currentRevision = null;
|
|
475
|
+
updateApplyButton();
|
|
444
476
|
showSaveStatus(body.error || ("The session could not be loaded (HTTP " + res.status + ")."));
|
|
445
477
|
const root = document.getElementById("review-workbench");
|
|
446
478
|
if (root) root.innerHTML = "";
|
|
447
479
|
return;
|
|
448
480
|
}
|
|
449
481
|
const data = await res.json();
|
|
450
|
-
|
|
482
|
+
if (fetchBasis !== fetchGeneration || stateBasis !== stateGeneration) return;
|
|
483
|
+
const { snapshot, events, revision, extractionImport, presentation } = data;
|
|
451
484
|
currentRevision = revision;
|
|
452
485
|
wasApplied = Boolean(data.producerApplication);
|
|
453
|
-
if (
|
|
486
|
+
if (data.producerApplication) showSaveStatus(data.producerApplication.summary, "success");
|
|
454
487
|
const state = events && events.length > 0
|
|
455
488
|
? replayReviewSessionEvents(snapshot, events)
|
|
456
489
|
: snapshot;
|
|
457
490
|
|
|
491
|
+
currentState = state;
|
|
492
|
+
updateApplyButton();
|
|
493
|
+
|
|
458
494
|
// Preserve active item selection across reloads
|
|
459
495
|
if (activeItemName && state.items && state.items.some(i => i.metadata.name === activeItemName)) {
|
|
460
496
|
state.activeItemName = activeItemName;
|
|
@@ -463,8 +499,14 @@ async function fetchAndMount() {
|
|
|
463
499
|
const root = document.getElementById("review-workbench");
|
|
464
500
|
if (!root) return;
|
|
465
501
|
|
|
466
|
-
mountReviewWorkbench(root, state, { eventStore: createConsoleEventStore(revision), ...(extractionImport ? { extractionImport } : {}) });
|
|
502
|
+
mountReviewWorkbench(root, state, { hideApply: Boolean(applyButton), queuePresentation: presentation, eventStore: createConsoleEventStore(revision), ...(extractionImport ? { extractionImport } : {}) });
|
|
503
|
+
if (wasApplied) root.querySelectorAll("button[data-item-name], input[data-item-name], textarea[data-item-name], select[data-item-name]").forEach(control => { control.disabled = true; });
|
|
467
504
|
} catch (err) {
|
|
505
|
+
if (fetchBasis !== fetchGeneration || stateBasis !== stateGeneration) return;
|
|
506
|
+
currentRevision = null;
|
|
507
|
+
currentState = null;
|
|
508
|
+
updateApplyButton();
|
|
509
|
+
showSaveStatus("The session could not be loaded. Reload before sending the review.");
|
|
468
510
|
console.error("[console] Mount error:", err);
|
|
469
511
|
}
|
|
470
512
|
}
|
|
@@ -486,7 +528,7 @@ function connectSse() {
|
|
|
486
528
|
if (label) label.textContent = "live";
|
|
487
529
|
});
|
|
488
530
|
sse.addEventListener("update", () => {
|
|
489
|
-
fetchAndMount();
|
|
531
|
+
if (pendingSaveCount === 0 && !applying) fetchAndMount();
|
|
490
532
|
});
|
|
491
533
|
sse.addEventListener("error", () => {
|
|
492
534
|
if (dot) dot.classList.add("disconnected");
|
|
@@ -664,6 +706,7 @@ export async function startReviewConsoleServer(options) {
|
|
|
664
706
|
events: content.events,
|
|
665
707
|
revision: reviewSessionRevision(content.events),
|
|
666
708
|
state: currentSessionState(content.snapshot, content.events),
|
|
709
|
+
...(options.presentation ? { presentation: options.presentation } : {}),
|
|
667
710
|
queueAttestation: attestation.state,
|
|
668
711
|
...(content.producerApplication ? { producerApplication: content.producerApplication } : {}),
|
|
669
712
|
...(content.extractionImport !== undefined ? { extractionImport: content.extractionImport } : {}),
|
|
@@ -1781,6 +1781,17 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
1781
1781
|
}
|
|
1782
1782
|
}
|
|
1783
1783
|
|
|
1784
|
+
.survey-workbench-embed .review-context{
|
|
1785
|
+
margin: var(--k-space-4);
|
|
1786
|
+
padding: var(--k-space-4);
|
|
1787
|
+
border: var(--k-border-thin) solid var(--k-line-strong);
|
|
1788
|
+
border-radius: var(--k-radius-sm);
|
|
1789
|
+
background: var(--k-panel-raised);
|
|
1790
|
+
color: var(--k-text);
|
|
1791
|
+
white-space: pre-wrap;
|
|
1792
|
+
overflow-wrap: anywhere;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1784
1795
|
|
|
1785
1796
|
.survey-workbench-embed {
|
|
1786
1797
|
overflow: hidden;
|
|
@@ -1778,6 +1778,17 @@
|
|
|
1778
1778
|
}
|
|
1779
1779
|
}
|
|
1780
1780
|
|
|
1781
|
+
.survey-workbench-embed .review-context{
|
|
1782
|
+
margin: var(--k-space-4);
|
|
1783
|
+
padding: var(--k-space-4);
|
|
1784
|
+
border: var(--k-border-thin) solid var(--k-line-strong);
|
|
1785
|
+
border-radius: var(--k-radius-sm);
|
|
1786
|
+
background: var(--k-panel-raised);
|
|
1787
|
+
color: var(--k-text);
|
|
1788
|
+
white-space: pre-wrap;
|
|
1789
|
+
overflow-wrap: anywhere;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1781
1792
|
|
|
1782
1793
|
.survey-workbench-embed {
|
|
1783
1794
|
overflow: hidden;
|
|
@@ -100,7 +100,20 @@ export interface PersistentReviewSessionEventStoreOptions {
|
|
|
100
100
|
readonly persist: (request: ReviewSessionPersistenceRequest) => Promise<ReviewSessionPersistenceResult | void>;
|
|
101
101
|
readonly onStatusChange?: (state: ReviewSessionPersistenceState) => void;
|
|
102
102
|
}
|
|
103
|
+
/** Serializable producer-owned display hints. Never alter canonical items, events or apply authority. */
|
|
104
|
+
export interface ReviewQueuePresentation {
|
|
105
|
+
readonly title?: string;
|
|
106
|
+
readonly context?: string;
|
|
107
|
+
readonly items?: Readonly<Record<string, {
|
|
108
|
+
readonly title?: string;
|
|
109
|
+
readonly kind?: "acknowledgment" | "choice";
|
|
110
|
+
readonly candidateLabels?: Readonly<Record<string, string>>;
|
|
111
|
+
}>>;
|
|
112
|
+
}
|
|
103
113
|
export interface MountReviewWorkbenchOptions {
|
|
114
|
+
readonly queuePresentation?: ReviewQueuePresentation;
|
|
115
|
+
/** A host that owns submission can hide the local apply/export control. */
|
|
116
|
+
readonly hideApply?: boolean;
|
|
104
117
|
readonly eventStore?: ReviewSessionEventStore;
|
|
105
118
|
readonly presentationAdapter?: ReviewPresentationAdapter;
|
|
106
119
|
/**
|
|
@@ -238,6 +251,8 @@ export declare function createPersistentReviewSessionEventStore(options: Persist
|
|
|
238
251
|
export declare function renderReviewWorkbenchHtml(state: ReviewWorkbenchState | ReviewQueueSessionState, _events?: readonly ReviewSessionEvent[], options?: {
|
|
239
252
|
readonly presentationAdapter?: ReviewPresentationAdapter;
|
|
240
253
|
readonly queueView?: Partial<ReviewQueueView>;
|
|
254
|
+
readonly queuePresentation?: ReviewQueuePresentation;
|
|
255
|
+
readonly hideApply?: boolean;
|
|
241
256
|
/** See {@link MountReviewWorkbenchOptions.extractionImport}. */
|
|
242
257
|
readonly extractionImport?: ExtractionEnvelopeImport | ExtractionEnvelopeImportResult;
|
|
243
258
|
/** See {@link MountReviewWorkbenchOptions.fieldStates}. */
|
|
@@ -135,10 +135,12 @@ function buildDecisionCardAuthorizing(state, adapter) {
|
|
|
135
135
|
* Renders the decision card's prompt for a review item and decision.
|
|
136
136
|
*
|
|
137
137
|
* It is rebuilt from the item when the decision is built, not captured from
|
|
138
|
-
* what a reviewer's screen showed: it records
|
|
139
|
-
*
|
|
138
|
+
* what a reviewer's screen showed: it records a deterministic semantic basis
|
|
139
|
+
* from the canonical item, decision and presentation adapter. Queue display
|
|
140
|
+
* hints (title, context, action labels) are not a verbatim prompt capture and
|
|
141
|
+
* do not change this server-reconstructable authorizing basis.
|
|
140
142
|
*
|
|
141
|
-
*
|
|
143
|
+
* The current/proposed semantic form is:
|
|
142
144
|
* "For {target}, decide whether {proposed} should replace {current}."
|
|
143
145
|
* followed by the selected decision label so the block is self-contained.
|
|
144
146
|
*/
|
|
@@ -523,7 +525,7 @@ export function createPersistentReviewSessionEventStore(options) {
|
|
|
523
525
|
}
|
|
524
526
|
export function renderReviewWorkbenchHtml(state, _events, options = {}) {
|
|
525
527
|
const session = queueSessionFromStartState(state);
|
|
526
|
-
return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView), attestReviewQueueExtraction(session.items, options.extractionImport), options.fieldStates);
|
|
528
|
+
return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView), attestReviewQueueExtraction(session.items, options.extractionImport), options.fieldStates, options.queuePresentation, options.hideApply);
|
|
527
529
|
}
|
|
528
530
|
/**
|
|
529
531
|
* The visual/decision state of a field card, derived from the reviewer's local
|
|
@@ -611,7 +613,7 @@ function isEmptyValue(value) {
|
|
|
611
613
|
}
|
|
612
614
|
const ARROW_SVG = "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><path d=\"M5 12h14M13 6l6 6-6 6\"/></svg>";
|
|
613
615
|
const WARNING_SVG = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><path d=\"M12 9v4M12 17h.01\"/><path d=\"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z\"/></svg>";
|
|
614
|
-
function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, attestation, fieldStates) {
|
|
616
|
+
function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, attestation, fieldStates, queuePresentation, hideApply) {
|
|
615
617
|
// A queue that does not match its stored extraction import is not reviewed:
|
|
616
618
|
// its items could hide or alter what the source said.
|
|
617
619
|
if (attestation.state === "diverges") {
|
|
@@ -641,7 +643,7 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, a
|
|
|
641
643
|
<div class="top">
|
|
642
644
|
<div class="subj">
|
|
643
645
|
<p class="eyebrow">Proposed changes</p>
|
|
644
|
-
<h1>${escapeHtml(headerTitle)}</h1>
|
|
646
|
+
<h1>${escapeHtml(queuePresentation?.title ?? headerTitle)}</h1>
|
|
645
647
|
</div>
|
|
646
648
|
</div>
|
|
647
649
|
<div class="meta">
|
|
@@ -650,9 +652,10 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, a
|
|
|
650
652
|
<div class="progress">
|
|
651
653
|
<div class="bar"><i style="width:${progressPct}%"></i></div>
|
|
652
654
|
<span class="ptext"><b data-testid="decided-count">${decidedCount}</b> of <b>${totalCount}</b> decided</span>
|
|
653
|
-
|
|
655
|
+
${hideApply ? "" : `<button class="apply" type="button" data-testid="apply-button"${decidedCount === 0 ? " disabled" : ""}>Apply ${decidedCount} decision${decidedCount === 1 ? "" : "s"}</button>`}
|
|
654
656
|
</div>
|
|
655
657
|
</header>
|
|
658
|
+
${queuePresentation?.context ? `<section class="review-context" data-testid="review-context" aria-label="Review context">${escapeHtml(queuePresentation.context)}</section>` : ""}
|
|
656
659
|
${fieldStates ? renderFieldStatesHtml(fieldStates, session) : ""}
|
|
657
660
|
<nav class="queue-controls" aria-label="Review queue navigation">
|
|
658
661
|
<label>
|
|
@@ -673,7 +676,7 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, a
|
|
|
673
676
|
</div>` : ""}
|
|
674
677
|
</nav>
|
|
675
678
|
<div class="fields" data-testid="review-fields">
|
|
676
|
-
${window.items.map((item) => renderFieldCard(item, session, presentationAdapter)).join("")
|
|
679
|
+
${window.items.map((item) => renderFieldCard(item, session, presentationAdapter, queuePresentation?.items?.[item.metadata.name])).join("")
|
|
677
680
|
|| '<p class="queue-empty">No review fields match the current search and disposition.</p>'}
|
|
678
681
|
</div>
|
|
679
682
|
${renderFooterTally(session)}
|
|
@@ -691,7 +694,7 @@ function queueDispositionOptions(selected) {
|
|
|
691
694
|
];
|
|
692
695
|
return options.map(([value, label]) => `<option value="${value}"${selected === value ? " selected" : ""}>${label}</option>`).join("");
|
|
693
696
|
}
|
|
694
|
-
function renderFieldCard(item, session, presentationAdapter) {
|
|
697
|
+
function renderFieldCard(item, session, presentationAdapter, hints) {
|
|
695
698
|
const decision = session.decisionsByItemName[item.metadata.name];
|
|
696
699
|
const state = fieldCardState(item, decision);
|
|
697
700
|
const decided = decision !== undefined;
|
|
@@ -703,6 +706,10 @@ function renderFieldCard(item, session, presentationAdapter) {
|
|
|
703
706
|
const conflict = proposedCandidates.length > 1;
|
|
704
707
|
const proposed = proposedCandidates.length === 1 ? proposedCandidates[0] : undefined;
|
|
705
708
|
const presentation = buildReviewItemPresentation(item, presentationAdapter);
|
|
709
|
+
const title = hints?.title ?? presentation.targetLabel;
|
|
710
|
+
const acknowledgment = hints?.kind === "acknowledgment" && proposed !== undefined;
|
|
711
|
+
const choice = hints?.kind === "choice";
|
|
712
|
+
const statusLabel = decision === "could-not-confirm" ? "Could not confirm" : choice ? (decided ? "Action recorded" : "Choose an action") : acknowledgment ? (decided ? "Reviewed" : "Needs review") : chipLabel(state, current !== undefined && !isEmptyValue(current.value), conflict ? proposedCandidates.length : undefined);
|
|
706
713
|
const hasCurrentValue = current !== undefined && !isEmptyValue(current.value);
|
|
707
714
|
const kind = hasCurrentValue ? "Update" : "New";
|
|
708
715
|
const keepLabel = hasCurrentValue ? "Keep current" : conflict ? "Reject all values" : "Leave unset";
|
|
@@ -734,26 +741,28 @@ function renderFieldCard(item, session, presentationAdapter) {
|
|
|
734
741
|
<div class="stripe"></div>
|
|
735
742
|
<div class="fbody">
|
|
736
743
|
<div class="frow1">
|
|
737
|
-
<span class="fname">${escapeHtml(
|
|
744
|
+
<span class="fname">${escapeHtml(title)}</span>
|
|
738
745
|
<span class="fkind">${kind}</span>
|
|
739
|
-
<span class="chip ${state} push" data-testid="field-chip">${
|
|
746
|
+
<span class="chip ${state} push" data-testid="field-chip">${statusLabel}</span>
|
|
740
747
|
</div>
|
|
741
748
|
${proposed
|
|
742
749
|
? renderDiffRow(item, current, proposed, presentation.targetLabel, decided, effectiveProposedText, currentPresentationText)
|
|
743
750
|
: proposedCandidates.length > 1
|
|
744
|
-
? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name])
|
|
751
|
+
? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name], choice ? hints : undefined)
|
|
745
752
|
: "<p class=\"field-value\">No proposed value is available for this field.</p>"}
|
|
746
753
|
${proposed ? renderProvenanceRow(item, proposed, presentationAdapter, isScoreBlind(session)) : ""}
|
|
747
754
|
${renderExtractionImportNotes(presentation)}
|
|
748
755
|
${isScoreBlind(session) ? "" : renderVerificationNotes(candidateVerificationNotes(item, decision === "accept-proposed" ? editedValue : undefined))}
|
|
756
|
+
${choice ? renderReviewerNote(item, session) : ""}
|
|
749
757
|
<div class="decide">
|
|
750
|
-
${keepDecision === undefined ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>`}
|
|
751
|
-
${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}"
|
|
752
|
-
|
|
758
|
+
${acknowledgment || keepDecision === undefined || (choice && !current) ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${escapeHtml(choice && current ? hints?.candidateLabels?.[current.id] ?? keepLabel : keepLabel)}</button>`}
|
|
759
|
+
${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">${acknowledgment ? "Mark reviewed" : choice ? escapeHtml(hints?.candidateLabels?.[proposed.id] ?? proposedPresentationText) : "Use proposed"}</button>
|
|
760
|
+
${acknowledgment || choice ? "" : `<label class="wrong">
|
|
753
761
|
<input type="checkbox" class="wrongbox" data-testid="wrong-toggle" data-item-name="${escapeHtml(item.metadata.name)}">
|
|
754
762
|
Suggestion was wrong
|
|
755
763
|
</label>
|
|
756
|
-
<button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>`
|
|
764
|
+
<button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>`}
|
|
765
|
+
${acknowledgment || choice ? `<button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}` : ""}
|
|
757
766
|
${conflict ? `<button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}
|
|
758
767
|
</div>
|
|
759
768
|
<!--
|
|
@@ -765,10 +774,10 @@ function renderFieldCard(item, session, presentationAdapter) {
|
|
|
765
774
|
-->
|
|
766
775
|
<span class="derr" data-testid="decision-error" role="alert" hidden></span>
|
|
767
776
|
<div class="decided">
|
|
768
|
-
<span class="chip ${state}" data-testid="decided-chip">${
|
|
777
|
+
<span class="chip ${state}" data-testid="decided-chip">${statusLabel}</span>
|
|
769
778
|
<button class="undo" type="button" data-testid="undo-decision" data-item-name="${escapeHtml(item.metadata.name)}">Change</button>
|
|
770
779
|
</div>
|
|
771
|
-
${renderAuditDetails(item, current, proposed, session, presentationAdapter)}
|
|
780
|
+
${renderAuditDetails(item, current, proposed, session, presentationAdapter, choice)}
|
|
772
781
|
</div>
|
|
773
782
|
</section>
|
|
774
783
|
`;
|
|
@@ -782,23 +791,24 @@ function renderFieldCard(item, session, presentationAdapter) {
|
|
|
782
791
|
* one value. Proposals excluded at import are not candidates: they are listed
|
|
783
792
|
* by {@link renderExtractionImportNotes} and cannot be chosen.
|
|
784
793
|
*/
|
|
785
|
-
function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel, decision, selectedCandidateId) {
|
|
794
|
+
function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel, decision, selectedCandidateId, hints) {
|
|
786
795
|
const chose = decision === "select-proposed";
|
|
787
796
|
const values = candidates.map((candidate) => {
|
|
788
797
|
const text = buildReviewCandidatePresentation(item, candidate, presentationAdapter, targetLabel).valueText;
|
|
798
|
+
const actionLabel = hints?.candidateLabels?.[candidate.id] ?? text;
|
|
789
799
|
const excerpt = candidate.locator?.excerpt;
|
|
790
800
|
const chosen = chose && candidate.id === selectedCandidateId;
|
|
791
801
|
const mark = chose
|
|
792
802
|
? ` <span class="choice-tag ${chosen ? "chosen" : "not-chosen"}" data-testid="choice-tag">${chosen ? "Chosen" : "Not chosen"}</span>`
|
|
793
803
|
: "";
|
|
794
804
|
const control = decision === undefined
|
|
795
|
-
? `<button class="btn choose" type="button" data-testid="select-value" data-item-name="${escapeHtml(item.metadata.name)}" data-candidate-id="${escapeHtml(candidate.id)}" aria-label="Use ${escapeHtml(text)} for ${escapeHtml(targetLabel)}"
|
|
805
|
+
? `<button class="btn choose" type="button" data-testid="select-value" data-item-name="${escapeHtml(item.metadata.name)}" data-candidate-id="${escapeHtml(candidate.id)}" aria-label="${hints ? escapeHtml(actionLabel) : `Use ${escapeHtml(text)} for ${escapeHtml(targetLabel)}`}">${hints ? escapeHtml(actionLabel) : "Use this value"}</button>`
|
|
796
806
|
: "";
|
|
797
807
|
return `<li data-testid="conflicting-value" data-candidate-id="${escapeHtml(candidate.id)}"${chose ? ` data-chosen="${chosen ? "true" : "false"}"` : ""}>
|
|
798
808
|
<span class="conflict-text"><span class="vtext">${escapeHtml(text)}</span>${excerpt ? ` <q>${escapeHtml(excerpt)}</q>` : ""}${mark}</span>${control}
|
|
799
809
|
</li>`;
|
|
800
810
|
}).join("");
|
|
801
|
-
const intro = decision === undefined
|
|
811
|
+
const intro = hints ? (decision === undefined ? "Choose an action. Add a reviewer note when a correction or explanation is needed." : "Action recorded.") : decision === undefined
|
|
802
812
|
? `${candidates.length} different values were proposed for this field. Use the value the source supports, reject them all, or mark the field Could not confirm with a reason.`
|
|
803
813
|
: chose
|
|
804
814
|
? `${candidates.length} different values were proposed for this field. One was chosen; ${candidates.length === 2 ? "the other was" : "the others were"} seen and not chosen.`
|
|
@@ -1066,7 +1076,10 @@ function renderConfidenceMeter(confidence) {
|
|
|
1066
1076
|
* here is secondary detail tucked behind one `<details>` toggle, kept off the
|
|
1067
1077
|
* primary review surface.
|
|
1068
1078
|
*/
|
|
1069
|
-
function
|
|
1079
|
+
function renderReviewerNote(item, session) {
|
|
1080
|
+
return `<label class="note-field"><span class="field-label">Reviewer note</span><textarea data-testid="reviewer-note" data-item-name="${escapeHtml(item.metadata.name)}">${escapeHtml(session.notesByItemName[item.metadata.name] ?? "")}</textarea></label>`;
|
|
1081
|
+
}
|
|
1082
|
+
function renderAuditDetails(item, current, proposed, session, presentationAdapter, noteOnCard = false) {
|
|
1070
1083
|
const decision = session.decisionsByItemName[item.metadata.name];
|
|
1071
1084
|
const note = session.notesByItemName[item.metadata.name] ?? "";
|
|
1072
1085
|
const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
|
|
@@ -1091,10 +1104,7 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
|
|
|
1091
1104
|
<details class="audit-details" data-testid="audit-details">
|
|
1092
1105
|
<summary>Audit details</summary>
|
|
1093
1106
|
<div class="audit-body">
|
|
1094
|
-
|
|
1095
|
-
<span class="field-label">Reviewer note</span>
|
|
1096
|
-
<textarea data-testid="reviewer-note" data-item-name="${escapeHtml(item.metadata.name)}">${escapeHtml(note)}</textarea>
|
|
1097
|
-
</label>
|
|
1107
|
+
${noteOnCard ? "" : renderReviewerNote(item, session)}
|
|
1098
1108
|
${definition ? `<p class="field-value"><span class="field-label">Decision effect</span> ${escapeHtml(definition.effect)}</p>` : ""}
|
|
1099
1109
|
${renderProducerFeedbackTags(item)}
|
|
1100
1110
|
<dl class="field-stack compact">
|
|
@@ -1459,7 +1469,7 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
1459
1469
|
currentSession: () => session,
|
|
1460
1470
|
currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events, { presentationAdapter: options.presentationAdapter }),
|
|
1461
1471
|
presentationAdapter: options.presentationAdapter,
|
|
1462
|
-
renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter, queueView, attestation, options.fieldStates),
|
|
1472
|
+
renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter, queueView, attestation, options.fieldStates, options.queuePresentation, options.hideApply),
|
|
1463
1473
|
setDecision,
|
|
1464
1474
|
clearDecision,
|
|
1465
1475
|
updateReviewerNote,
|
|
@@ -1479,8 +1489,8 @@ function reviewSessionStorageKey(session, keyPrefix) {
|
|
|
1479
1489
|
const itemNames = buildReviewSessionResource(session).spec.reviewItemNames.join(",");
|
|
1480
1490
|
return `${keyPrefix}:${itemNames}`;
|
|
1481
1491
|
}
|
|
1482
|
-
function renderCurrentState(root, session, controller, presentationAdapter, queueView, attestation, fieldStates) {
|
|
1483
|
-
root.innerHTML = renderReviewQueueSessionHtml(session, presentationAdapter, queueView ?? normalizeReviewQueueView(undefined), attestation ?? attestReviewQueueExtraction(session.items, undefined), fieldStates);
|
|
1492
|
+
function renderCurrentState(root, session, controller, presentationAdapter, queueView, attestation, fieldStates, queuePresentation, hideApply) {
|
|
1493
|
+
root.innerHTML = renderReviewQueueSessionHtml(session, presentationAdapter, queueView ?? normalizeReviewQueueView(undefined), attestation ?? attestReviewQueueExtraction(session.items, undefined), fieldStates, queuePresentation, hideApply);
|
|
1484
1494
|
bindFieldCardInteractions(root, controller);
|
|
1485
1495
|
bindApplyButton(root, controller);
|
|
1486
1496
|
bindClampToggles(root);
|
|
@@ -1242,3 +1242,14 @@ pre[data-testid="decision-payload"] {
|
|
|
1242
1242
|
transition: none !important;
|
|
1243
1243
|
}
|
|
1244
1244
|
}
|
|
1245
|
+
|
|
1246
|
+
.review-context {
|
|
1247
|
+
margin: var(--k-space-4);
|
|
1248
|
+
padding: var(--k-space-4);
|
|
1249
|
+
border: var(--k-border-thin) solid var(--k-line-strong);
|
|
1250
|
+
border-radius: var(--k-radius-sm);
|
|
1251
|
+
background: var(--k-panel-raised);
|
|
1252
|
+
color: var(--k-text);
|
|
1253
|
+
white-space: pre-wrap;
|
|
1254
|
+
overflow-wrap: anywhere;
|
|
1255
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontourai/survey",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.2.1",
|
|
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",
|
|
@@ -59,18 +59,17 @@
|
|
|
59
59
|
"test:browser:concurrent": "npm run build && node scripts/test-playwright-concurrency.mjs",
|
|
60
60
|
"verify": "npm run check:ui-token-drift && npm run check:content-boundary && npm run check:decisions && npm run typecheck && npm test && npm run test:package-smoke && npm run check:review-workbench && npm run test:browser && npm run test:browser:concurrent",
|
|
61
61
|
"check:content-boundary": "node --test tests/content-boundary-script.test.cjs && node scripts/check-content-boundary.cjs",
|
|
62
|
-
"check:decisions": "
|
|
62
|
+
"check:decisions": "npx -y --package=https://codeload.github.com/kontourai/corpus/tar.gz/08c62ccd72880ff4bd4c378ae252ad437b4b4fb5 -- corpus decisions check --base ${DECISIONS_BASE:-origin/main}",
|
|
63
63
|
"check:guards": "node scripts/check-guards.mjs",
|
|
64
|
-
"gen:decisions-index": "
|
|
65
|
-
"freeze:adrs": "node scripts/freeze-adrs.mjs",
|
|
64
|
+
"gen:decisions-index": "npx -y --package=https://codeload.github.com/kontourai/corpus/tar.gz/08c62ccd72880ff4bd4c378ae252ad437b4b4fb5 -- corpus decisions index",
|
|
66
65
|
"sync:review-workbench-assets": "node scripts/sync-review-workbench-assets.cjs",
|
|
67
66
|
"check:review-workbench-assets": "node scripts/sync-review-workbench-assets.cjs --check",
|
|
68
67
|
"check:review-workbench": "npm run check:review-workbench:static && npm run check:generated-css",
|
|
69
68
|
"check:review-workbench:static": "npm run build && node scripts/check-review-workbench.cjs",
|
|
70
69
|
"setup:repo-hooks": "node scripts/setup-repo-hooks.cjs",
|
|
71
70
|
"validate:repo-hooks": "node scripts/validate-repo-hooks.cjs",
|
|
72
|
-
"docs:build": "npx -y github
|
|
73
|
-
"docs:check": "npx -y github
|
|
71
|
+
"docs:build": "npx -y --package=https://codeload.github.com/kontourai/corpus/tar.gz/08c62ccd72880ff4bd4c378ae252ad437b4b4fb5 -- corpus build",
|
|
72
|
+
"docs:check": "npx -y --package=https://codeload.github.com/kontourai/corpus/tar.gz/08c62ccd72880ff4bd4c378ae252ad437b4b4fb5 -- corpus check",
|
|
74
73
|
"docs:demo": "npm run build && node scripts/build-demo.mjs",
|
|
75
74
|
"check:generated-css": "node scripts/copy-review-workbench-package-assets.cjs --check",
|
|
76
75
|
"check:ui-token-drift": "npm run check:generated-css && npm run check:review-workbench-assets",
|
|
@@ -93,7 +92,8 @@
|
|
|
93
92
|
"devDependencies": {
|
|
94
93
|
"@kontourai/flow-agents": "5.10.0",
|
|
95
94
|
"@kontourai/traverse": "^3.0.1",
|
|
96
|
-
"@kontourai/ui": "^1.
|
|
95
|
+
"@kontourai/ui": "^1.20.0",
|
|
96
|
+
"@kontourai/veritas": "1.11.2",
|
|
97
97
|
"@playwright/test": "^1.62.0",
|
|
98
98
|
"@types/node": "^26.1.2",
|
|
99
99
|
"typescript": "^7.0.2"
|