@kontourai/survey 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/example-data/public-directory-review-resource.d.ts +2 -0
- package/dist/example-data/public-directory-review-resource.js +1 -0
- package/dist/examples/calibrated-auto-accept.d.ts +29 -0
- package/dist/examples/calibrated-auto-accept.js +135 -0
- package/dist/examples/review-workbench/downstream-public-directory-adapter.js +3 -1
- package/dist/src/review-resource.d.ts +18 -0
- package/dist/src/review-workbench/review-queue-session.d.ts +30 -0
- package/dist/src/review-workbench/review-queue-session.js +30 -0
- package/dist/src/review-workbench/review-session-replay.js +7 -2
- package/dist/src/review-workbench/review-workbench-css.generated.js +608 -1426
- package/dist/src/review-workbench/review-workbench-data.d.ts +4 -0
- package/dist/src/review-workbench/review-workbench-data.js +172 -0
- package/dist/src/review-workbench/review-workbench-element.d.ts +5 -1
- package/dist/src/review-workbench/review-workbench-element.js +86 -3
- package/dist/src/review-workbench/review-workbench.css +608 -1426
- package/dist/src/review-workbench/review-workbench.d.ts +11 -1
- package/dist/src/review-workbench/review-workbench.js +390 -538
- package/dist/src/review-workbench/review-workbench.standalone.css +602 -1421
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { canonicalJson } from "./canonical.js";
|
|
2
|
-
import { candidateForDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource,
|
|
2
|
+
import { candidateForDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
3
3
|
import { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
|
|
4
4
|
import { buildSurfaceProjectionPreview, formatValue, } from "./review-surface-preview.js";
|
|
5
5
|
import { buildReviewCandidatePresentation, buildReviewItemPresentation, } from "./review-presentation.js";
|
|
@@ -41,6 +41,7 @@ export function buildReviewDecision(state) {
|
|
|
41
41
|
rationale: state.note,
|
|
42
42
|
authorizing: buildDecisionCardAuthorizing(state),
|
|
43
43
|
projection,
|
|
44
|
+
editedValue: state.decision === "accept-proposed" ? state.editedValue : undefined,
|
|
44
45
|
},
|
|
45
46
|
status: {
|
|
46
47
|
appliedToClaimIds: candidate.projection?.claimId ? [candidate.projection.claimId] : undefined,
|
|
@@ -248,6 +249,10 @@ export function buildReviewWorkbenchResultsFromSession(session) {
|
|
|
248
249
|
return [];
|
|
249
250
|
}
|
|
250
251
|
const selectedCandidate = candidateForDecision(item, decision);
|
|
252
|
+
const editedValue = decision === "accept-proposed"
|
|
253
|
+
? session.editedValuesByItemName?.[item.metadata.name]
|
|
254
|
+
: undefined;
|
|
255
|
+
const effectiveValue = effectiveValueForDecision(item, decision, editedValue);
|
|
251
256
|
return [{
|
|
252
257
|
reviewItemName: item.metadata.name,
|
|
253
258
|
decision,
|
|
@@ -256,6 +261,9 @@ export function buildReviewWorkbenchResultsFromSession(session) {
|
|
|
256
261
|
selectedCandidateRole: selectedCandidate.role,
|
|
257
262
|
selectedValue: selectedCandidate.value,
|
|
258
263
|
selectedDisplayValue: formatValue(selectedCandidate.value),
|
|
264
|
+
editedValue,
|
|
265
|
+
effectiveValue,
|
|
266
|
+
effectiveDisplayValue: formatValue(effectiveValue),
|
|
259
267
|
unselectedCandidates: item.spec.candidates.filter((candidate) => candidate.id !== selectedCandidate.id),
|
|
260
268
|
reviewDecision,
|
|
261
269
|
status: reviewDecision.spec.status,
|
|
@@ -418,339 +426,301 @@ export function createPersistentReviewSessionEventStore(options) {
|
|
|
418
426
|
},
|
|
419
427
|
};
|
|
420
428
|
}
|
|
421
|
-
export function renderReviewWorkbenchHtml(state,
|
|
422
|
-
|
|
423
|
-
|
|
429
|
+
export function renderReviewWorkbenchHtml(state, _events, options = {}) {
|
|
430
|
+
const session = queueSessionFromStartState(state);
|
|
431
|
+
return renderReviewQueueSessionHtml(session, options.presentationAdapter);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* The visual/decision state of a field card, derived from the reviewer's local
|
|
435
|
+
* decision for that ReviewItem, falling back to a producer-declared "resolved"
|
|
436
|
+
* candidate set (e.g. a pre-decided item seeded by the host) as already-kept.
|
|
437
|
+
*/
|
|
438
|
+
function fieldCardState(item, decision) {
|
|
439
|
+
if (decision === "accept-proposed")
|
|
440
|
+
return "accepted";
|
|
441
|
+
if (decision === "keep-current")
|
|
442
|
+
return "kept";
|
|
443
|
+
if (decision === "reject-proposed")
|
|
444
|
+
return "rejected";
|
|
445
|
+
if (item.spec.candidateSetStatus === "resolved")
|
|
446
|
+
return "kept";
|
|
447
|
+
return "review";
|
|
448
|
+
}
|
|
449
|
+
function chipLabel(state) {
|
|
450
|
+
switch (state) {
|
|
451
|
+
case "accepted": return "Accepted";
|
|
452
|
+
case "kept": return "Kept current";
|
|
453
|
+
case "rejected": return "Kept — flagged wrong";
|
|
454
|
+
default: return "Needs review";
|
|
424
455
|
}
|
|
425
|
-
return `
|
|
426
|
-
<section class="workbench-shell" aria-label="Survey review workbench">
|
|
427
|
-
${renderWorkbenchHeader(state, options.presentationAdapter)}
|
|
428
|
-
<div class="content-grid">
|
|
429
|
-
${renderReviewFocus(state, options.presentationAdapter)}
|
|
430
|
-
${renderDecisionColumn(state, options.presentationAdapter)}
|
|
431
|
-
${renderCandidateComparison(state, options.presentationAdapter)}
|
|
432
|
-
</div>
|
|
433
|
-
</section>
|
|
434
|
-
`;
|
|
435
456
|
}
|
|
436
|
-
function
|
|
437
|
-
|
|
438
|
-
|
|
457
|
+
function isEmptyValue(value) {
|
|
458
|
+
return value === undefined || value === null || value === "";
|
|
459
|
+
}
|
|
460
|
+
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>";
|
|
461
|
+
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>";
|
|
462
|
+
function renderReviewQueueSessionHtml(session, presentationAdapter) {
|
|
463
|
+
const totalCount = session.items.length;
|
|
464
|
+
const decidedCount = session.items.filter((item) => session.decisionsByItemName[item.metadata.name] !== undefined
|
|
465
|
+
|| item.spec.candidateSetStatus === "resolved").length;
|
|
466
|
+
const progressPct = totalCount === 0 ? 0 : Math.round((decidedCount / totalCount) * 100);
|
|
467
|
+
const headerItem = session.items[0];
|
|
468
|
+
const producerName = headerItem?.metadata.producer?.displayName;
|
|
469
|
+
const headerTitle = typeof producerName === "string" && producerName.length > 0 ? producerName : "Review queue";
|
|
439
470
|
return `
|
|
440
|
-
<section class="workbench-shell" aria-label="Survey review workbench">
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
<button class="queue-drawer-close" type="button" data-testid="queue-drawer-close" aria-label="Close queue panel">✕</button>
|
|
448
|
-
${renderQueueRows(session, presentationAdapter)}
|
|
449
|
-
${renderSessionSummary(session)}
|
|
450
|
-
${renderSessionAudit(session, sessionExport)}
|
|
451
|
-
</aside>
|
|
452
|
-
<div class="content-grid">
|
|
453
|
-
${renderReviewFocus(state, presentationAdapter)}
|
|
454
|
-
${renderDecisionColumn(state, presentationAdapter)}
|
|
455
|
-
${renderCandidateComparison(state, presentationAdapter)}
|
|
471
|
+
<section class="workbench-shell review" data-testid="review-workbench-shell" aria-label="Survey review workbench">
|
|
472
|
+
<header class="rhead">
|
|
473
|
+
<div class="top">
|
|
474
|
+
<div class="subj">
|
|
475
|
+
<p class="eyebrow">Proposed changes</p>
|
|
476
|
+
<h1>${escapeHtml(headerTitle)}</h1>
|
|
477
|
+
</div>
|
|
456
478
|
</div>
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
`;
|
|
460
|
-
}
|
|
461
|
-
function renderMobileQueueBar(session, state, presentationAdapter) {
|
|
462
|
-
const activeIndex = session.items.findIndex((item) => item.metadata.name === session.activeItemName);
|
|
463
|
-
const position = activeIndex >= 0 ? activeIndex + 1 : 1;
|
|
464
|
-
const resolved = Object.keys(session.decisionsByItemName).length;
|
|
465
|
-
const presentation = buildReviewItemPresentation(state.item, presentationAdapter);
|
|
466
|
-
return `
|
|
467
|
-
<div class="mobile-queue-bar" data-testid="mobile-queue-bar" aria-label="Queue progress">
|
|
468
|
-
<div class="mobile-queue-progress">
|
|
469
|
-
<span class="field-label">Queue · ${resolved} of ${session.items.length} resolved</span>
|
|
470
|
-
<span class="mobile-queue-item-label">${escapeHtml(presentation.targetLabel)}</span>
|
|
471
|
-
</div>
|
|
472
|
-
<button
|
|
473
|
-
class="mobile-queue-open"
|
|
474
|
-
type="button"
|
|
475
|
-
data-testid="mobile-queue-open"
|
|
476
|
-
aria-controls="queue-panel"
|
|
477
|
-
aria-expanded="false"
|
|
478
|
-
aria-label="Open review queue"
|
|
479
|
-
>Queue</button>
|
|
480
|
-
</div>
|
|
481
|
-
`;
|
|
482
|
-
}
|
|
483
|
-
function renderSessionAudit(session, sessionExport) {
|
|
484
|
-
const lastEvents = sessionExport.events.slice(-6);
|
|
485
|
-
const replayed = replayReviewSessionEvents({
|
|
486
|
-
...session,
|
|
487
|
-
activeItemName: session.items[0]?.metadata.name ?? session.activeItemName,
|
|
488
|
-
notesByItemName: {},
|
|
489
|
-
decisionsByItemName: {},
|
|
490
|
-
}, sessionExport.events);
|
|
491
|
-
const replayMatchesSession = sameStringRecord(replayed.decisionsByItemName, session.decisionsByItemName)
|
|
492
|
-
&& sameStringRecord(replayed.notesByItemName, session.notesByItemName);
|
|
493
|
-
return `
|
|
494
|
-
<section class="session-audit" data-testid="session-audit" aria-label="Review session audit trail">
|
|
495
|
-
<div class="session-audit-head">
|
|
496
|
-
<div>
|
|
497
|
-
<span class="field-label">ReviewSession</span>
|
|
498
|
-
<h2>${escapeHtml(sessionExport.session.metadata.name)}</h2>
|
|
479
|
+
<div class="meta">
|
|
480
|
+
<span><b data-testid="fields-changed-count">${totalCount}</b> field${totalCount === 1 ? "" : "s"} to review</span>
|
|
499
481
|
</div>
|
|
500
|
-
<
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
<ol class="session-event-list" data-testid="session-event-list">
|
|
509
|
-
${lastEvents.length === 0
|
|
510
|
-
? "<li><span class=\"field-label\">No events</span><span>No persisted review activity yet.</span></li>"
|
|
511
|
-
: lastEvents.map(renderSessionEventRow).join("")}
|
|
512
|
-
</ol>
|
|
513
|
-
<details class="session-export" data-testid="session-export">
|
|
514
|
-
<summary>Session export</summary>
|
|
515
|
-
<pre>${escapeHtml(JSON.stringify(sessionExport, null, 2))}</pre>
|
|
516
|
-
</details>
|
|
517
|
-
</section>
|
|
518
|
-
`;
|
|
519
|
-
}
|
|
520
|
-
function sameStringRecord(left, right) {
|
|
521
|
-
const leftKeys = Object.keys(left).sort();
|
|
522
|
-
const rightKeys = Object.keys(right).sort();
|
|
523
|
-
return leftKeys.length === rightKeys.length
|
|
524
|
-
&& leftKeys.every((key, index) => key === rightKeys[index] && left[key] === right[key]);
|
|
525
|
-
}
|
|
526
|
-
function renderSessionEventRow(event) {
|
|
527
|
-
const target = event.spec.reviewItemName ?? event.spec.activeItemName ?? event.spec.sessionName;
|
|
528
|
-
const detail = [
|
|
529
|
-
event.spec.status,
|
|
530
|
-
event.spec.candidateId,
|
|
531
|
-
event.spec.rationale,
|
|
532
|
-
].filter(Boolean).join(" - ");
|
|
533
|
-
return `
|
|
534
|
-
<li>
|
|
535
|
-
<span class="event-sequence">${escapeHtml(String(event.spec.sequence).padStart(2, "0"))}</span>
|
|
536
|
-
<span>
|
|
537
|
-
<strong>${escapeHtml(event.spec.eventType)}</strong>
|
|
538
|
-
<small>${escapeHtml(target)}${detail ? ` - ${escapeHtml(detail)}` : ""}</small>
|
|
539
|
-
</span>
|
|
540
|
-
</li>
|
|
541
|
-
`;
|
|
542
|
-
}
|
|
543
|
-
function renderQueueRows(session, presentationAdapter) {
|
|
544
|
-
return `
|
|
545
|
-
<section class="queue-list" data-testid="review-queue">
|
|
546
|
-
<div class="queue-head">
|
|
547
|
-
<h2>Review queue</h2>
|
|
548
|
-
<button class="next-button" type="button" data-testid="next-unresolved">Next unresolved</button>
|
|
482
|
+
<div class="progress">
|
|
483
|
+
<div class="bar"><i style="width:${progressPct}%"></i></div>
|
|
484
|
+
<span class="ptext"><b data-testid="decided-count">${decidedCount}</b> of <b>${totalCount}</b> decided</span>
|
|
485
|
+
<button class="apply" type="button" data-testid="apply-button"${decidedCount === 0 ? " disabled" : ""}>Apply ${decidedCount} decision${decidedCount === 1 ? "" : "s"}</button>
|
|
486
|
+
</div>
|
|
487
|
+
</header>
|
|
488
|
+
<div class="fields" data-testid="review-fields">
|
|
489
|
+
${session.items.map((item) => renderFieldCard(item, session, presentationAdapter)).join("")}
|
|
549
490
|
</div>
|
|
550
|
-
${
|
|
491
|
+
${renderFooterTally(session)}
|
|
551
492
|
</section>
|
|
552
493
|
`;
|
|
553
494
|
}
|
|
554
|
-
function
|
|
555
|
-
const
|
|
556
|
-
const
|
|
495
|
+
function renderFieldCard(item, session, presentationAdapter) {
|
|
496
|
+
const decision = session.decisionsByItemName[item.metadata.name];
|
|
497
|
+
const state = fieldCardState(item, decision);
|
|
498
|
+
const decided = decision !== undefined;
|
|
499
|
+
const current = item.spec.candidates.find((candidate) => candidate.role === "current");
|
|
500
|
+
const proposed = item.spec.candidates.find((candidate) => candidate.role === "proposed");
|
|
557
501
|
const presentation = buildReviewItemPresentation(item, presentationAdapter);
|
|
502
|
+
const hasCurrentValue = current !== undefined && !isEmptyValue(current.value);
|
|
503
|
+
const kind = hasCurrentValue ? "Update" : "New";
|
|
504
|
+
const keepLabel = hasCurrentValue ? "Keep current" : "Leave unset";
|
|
505
|
+
const editedValue = session.editedValuesByItemName?.[item.metadata.name];
|
|
506
|
+
const proposedPresentationText = proposed
|
|
507
|
+
? buildReviewCandidatePresentation(item, proposed, presentationAdapter, presentation.targetLabel).valueText
|
|
508
|
+
: "";
|
|
509
|
+
const effectiveProposedText = decision === "accept-proposed" && editedValue !== undefined
|
|
510
|
+
? String(editedValue)
|
|
511
|
+
: proposedPresentationText;
|
|
512
|
+
const currentPresentationText = current && !isEmptyValue(current.value)
|
|
513
|
+
? buildReviewCandidatePresentation(item, current, presentationAdapter, presentation.targetLabel).valueText
|
|
514
|
+
: undefined;
|
|
558
515
|
return `
|
|
559
|
-
<
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
${
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
${
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
<strong>${escapeHtml(presentation.targetLabel)}</strong>
|
|
593
|
-
<span>${escapeHtml(state.item.metadata.name)}</span>
|
|
594
|
-
</div>
|
|
595
|
-
<div class="active-review-actions">
|
|
596
|
-
<span class="state-label">${escapeHtml(status)}</span>
|
|
597
|
-
<button class="next-button" type="button" data-testid="active-next-unresolved">Next unresolved</button>
|
|
516
|
+
<section
|
|
517
|
+
class="field"
|
|
518
|
+
data-testid="review-field"
|
|
519
|
+
data-item-name="${escapeHtml(item.metadata.name)}"
|
|
520
|
+
data-field="${escapeHtml(item.spec.target)}"
|
|
521
|
+
data-state="${state}"
|
|
522
|
+
data-decided="${decided ? "1" : "0"}"
|
|
523
|
+
${decision ? `data-decision="${escapeHtml(decision)}"` : ""}
|
|
524
|
+
>
|
|
525
|
+
<div class="stripe"></div>
|
|
526
|
+
<div class="fbody">
|
|
527
|
+
<div class="frow1">
|
|
528
|
+
<span class="fname">${escapeHtml(presentation.targetLabel)}</span>
|
|
529
|
+
<span class="fkind">${kind}</span>
|
|
530
|
+
<span class="chip ${state} push" data-testid="field-chip">${chipLabel(state)}</span>
|
|
531
|
+
</div>
|
|
532
|
+
${proposed
|
|
533
|
+
? renderDiffRow(item, current, proposed, presentation.targetLabel, decided, effectiveProposedText, currentPresentationText)
|
|
534
|
+
: "<p class=\"field-value\">No proposed value is available for this field.</p>"}
|
|
535
|
+
${proposed ? renderProvenanceRow(item, proposed, presentationAdapter) : ""}
|
|
536
|
+
<div class="decide">
|
|
537
|
+
<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>
|
|
538
|
+
<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">Use proposed</button>
|
|
539
|
+
<label class="wrong">
|
|
540
|
+
<input type="checkbox" class="wrongbox" data-testid="wrong-toggle" data-item-name="${escapeHtml(item.metadata.name)}">
|
|
541
|
+
Suggestion was wrong
|
|
542
|
+
</label>
|
|
543
|
+
</div>
|
|
544
|
+
<div class="decided">
|
|
545
|
+
<span class="chip ${state}" data-testid="decided-chip">${chipLabel(state)}</span>
|
|
546
|
+
<button class="undo" type="button" data-testid="undo-decision" data-item-name="${escapeHtml(item.metadata.name)}">Change</button>
|
|
547
|
+
</div>
|
|
548
|
+
${renderAuditDetails(item, current, proposed, session, presentationAdapter)}
|
|
598
549
|
</div>
|
|
599
550
|
</section>
|
|
600
551
|
`;
|
|
601
552
|
}
|
|
602
|
-
|
|
603
|
-
|
|
553
|
+
/**
|
|
554
|
+
* Renders the Current → Proposed diff row. The proposed side is editable
|
|
555
|
+
* (via {@link renderProposedValueEditor}) until the field has a decision.
|
|
556
|
+
*/
|
|
557
|
+
function renderDiffRow(item, current, proposed, targetLabel, decided, effectiveProposedText, currentPresentationText) {
|
|
558
|
+
const isEmpty = currentPresentationText === undefined;
|
|
559
|
+
const currentText = isEmpty ? "Not set" : currentPresentationText;
|
|
604
560
|
return `
|
|
605
|
-
<
|
|
606
|
-
<div>
|
|
607
|
-
<
|
|
608
|
-
<
|
|
609
|
-
<p class="review-target-label" data-testid="review-target-label"><strong>${escapeHtml(presentation.targetLabel)}</strong></p>
|
|
561
|
+
<div class="diff">
|
|
562
|
+
<div class="val current${isEmpty ? " empty" : ""}">
|
|
563
|
+
<div class="vlbl">Current</div>
|
|
564
|
+
<div class="vtext" data-testid="current-value">${escapeHtml(currentText)}</div>
|
|
610
565
|
</div>
|
|
611
|
-
<
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
${
|
|
616
|
-
</
|
|
617
|
-
</
|
|
566
|
+
<div class="arrow">${ARROW_SVG}</div>
|
|
567
|
+
<div class="val proposed">
|
|
568
|
+
<div class="vlbl">Proposed</div>
|
|
569
|
+
<div class="vtext" data-value data-testid="proposed-value">${escapeHtml(effectiveProposedText)}</div>
|
|
570
|
+
${decided ? "" : renderProposedValueEditor(item, proposed, targetLabel)}
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
618
573
|
`;
|
|
619
574
|
}
|
|
620
|
-
|
|
575
|
+
/**
|
|
576
|
+
* Renders the inline editor for a proposed value.
|
|
577
|
+
*
|
|
578
|
+
* This is intentionally the ONLY place that knows how to edit a proposed value —
|
|
579
|
+
* a single seam so a later cross-repo change (spanning the upstream field-schema
|
|
580
|
+
* owner and downstream consumers) can swap in typed editors — a `<select>` built
|
|
581
|
+
* from an enum's allowed values, or date/number/boolean inputs — driven by an
|
|
582
|
+
* optional neutral value-type descriptor, without touching the rest of the
|
|
583
|
+
* field-card renderer. Survey deliberately has no value-type/enum system of its
|
|
584
|
+
* own (that belongs to the upstream field-schema owner), so today this always
|
|
585
|
+
* renders a plain text input, matching the approved mockup.
|
|
586
|
+
*/
|
|
587
|
+
function renderProposedValueEditor(item, proposed, targetLabel) {
|
|
588
|
+
const valueText = formatValue(proposed.value);
|
|
621
589
|
return `
|
|
622
|
-
<div class="
|
|
623
|
-
|
|
624
|
-
|
|
590
|
+
<div class="editrow">
|
|
591
|
+
<input
|
|
592
|
+
type="text"
|
|
593
|
+
class="proposed-value-input"
|
|
594
|
+
data-testid="edit-proposed-value"
|
|
595
|
+
data-item-name="${escapeHtml(item.metadata.name)}"
|
|
596
|
+
value="${escapeHtml(valueText)}"
|
|
597
|
+
aria-label="Edit proposed ${escapeHtml(targetLabel)}"
|
|
598
|
+
>
|
|
599
|
+
<span class="ehint">editable</span>
|
|
625
600
|
</div>
|
|
626
601
|
`;
|
|
627
602
|
}
|
|
628
|
-
function
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
<span class="state-label">${escapeHtml(presentation.statusLabel)}</span>
|
|
639
|
-
</div>
|
|
640
|
-
<div class="focus-values">
|
|
641
|
-
${focusValue(state.item, current, currentConfidence, "current", presentationAdapter, presentation.targetLabel)}
|
|
642
|
-
${focusValue(state.item, proposed, proposedConfidence, "proposed", presentationAdapter, presentation.targetLabel)}
|
|
603
|
+
function renderProvenanceRow(item, proposed, presentationAdapter) {
|
|
604
|
+
const excerpt = proposed.locator?.excerpt;
|
|
605
|
+
if (!excerpt) {
|
|
606
|
+
return `
|
|
607
|
+
<div class="prov">
|
|
608
|
+
<div class="noprov" data-testid="no-source-flag">
|
|
609
|
+
${WARNING_SVG}
|
|
610
|
+
<span class="tag">No source</span>
|
|
611
|
+
<span>This value has no supporting excerpt — verify before accepting.</span>
|
|
612
|
+
</div>
|
|
643
613
|
</div>
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
652
|
-
function focusValue(item, candidate, confidence, tone, presentationAdapter, targetLabel) {
|
|
653
|
-
const presentation = buildReviewCandidatePresentation(item, candidate, presentationAdapter, targetLabel);
|
|
614
|
+
`;
|
|
615
|
+
}
|
|
616
|
+
const confidence = proposed.extraction.confidence ?? proposed.confidence;
|
|
617
|
+
const presentation = buildReviewCandidatePresentation(item, proposed, presentationAdapter);
|
|
618
|
+
const sourceLinkHtml = presentation.sourceLink
|
|
619
|
+
? `<a href="${escapeHtml(presentation.sourceLink.href)}">${escapeHtml(presentation.sourceLink.label ?? presentation.sourceText)}</a>`
|
|
620
|
+
: escapeHtml(presentation.sourceText);
|
|
654
621
|
return `
|
|
655
|
-
<div class="
|
|
656
|
-
|
|
657
|
-
<
|
|
658
|
-
|
|
622
|
+
<div class="prov">
|
|
623
|
+
${confidence === undefined ? "" : renderConfidenceMeter(confidence)}
|
|
624
|
+
<div class="excerpt" data-testid="proposed-excerpt">
|
|
625
|
+
<q>${escapeHtml(excerpt)}</q>
|
|
626
|
+
<span class="from">from ${sourceLinkHtml}</span>
|
|
627
|
+
</div>
|
|
659
628
|
</div>
|
|
660
629
|
`;
|
|
661
630
|
}
|
|
662
|
-
function
|
|
663
|
-
const
|
|
664
|
-
const
|
|
631
|
+
function renderConfidenceMeter(confidence) {
|
|
632
|
+
const clamped = Math.max(0, Math.min(1, confidence));
|
|
633
|
+
const pct = Math.round(clamped * 100);
|
|
634
|
+
const isMid = clamped < 0.85;
|
|
665
635
|
return `
|
|
666
|
-
<
|
|
667
|
-
|
|
668
|
-
${
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
}
|
|
672
|
-
function renderDecisionColumn(state, presentationAdapter) {
|
|
673
|
-
return `
|
|
674
|
-
<aside class="decision-column" aria-label="Review decision">
|
|
675
|
-
${renderDecisionControls(state)}
|
|
676
|
-
${renderSurfacePreview(state, presentationAdapter)}
|
|
677
|
-
${renderDecisionPayload(state)}
|
|
678
|
-
</aside>
|
|
636
|
+
<div class="conf" data-testid="confidence-meter">
|
|
637
|
+
<span>Confidence</span>
|
|
638
|
+
<span class="meter${isMid ? " mid" : ""}"><i style="width:${pct}%"></i></span>
|
|
639
|
+
<span class="pct">${clamped.toFixed(2)}</span>
|
|
640
|
+
</div>
|
|
679
641
|
`;
|
|
680
642
|
}
|
|
681
|
-
|
|
682
|
-
|
|
643
|
+
/**
|
|
644
|
+
* The single collapsed power-user/audit surface per field card: reviewer note,
|
|
645
|
+
* decision effect, candidate/claim/source ids, and (once a decision has been made)
|
|
646
|
+
* the Surface projection preview and the raw ReviewDecision payload. Everything
|
|
647
|
+
* here is secondary detail tucked behind one `<details>` toggle, kept off the
|
|
648
|
+
* primary review surface.
|
|
649
|
+
*/
|
|
650
|
+
function renderAuditDetails(item, current, proposed, session, presentationAdapter) {
|
|
651
|
+
const decision = session.decisionsByItemName[item.metadata.name];
|
|
652
|
+
const note = session.notesByItemName[item.metadata.name] ?? "";
|
|
653
|
+
const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
|
|
654
|
+
const state = {
|
|
655
|
+
item,
|
|
656
|
+
note,
|
|
657
|
+
decision,
|
|
658
|
+
editedValue: session.editedValuesByItemName?.[item.metadata.name],
|
|
659
|
+
reviewedAt: session.reviewedAt,
|
|
660
|
+
actorId: session.actorId,
|
|
661
|
+
};
|
|
662
|
+
const reviewDecisionPayload = buildReviewDecision(state);
|
|
663
|
+
const preview = buildSurfaceProjectionPreview(item, reviewDecisionPayload, presentationAdapter);
|
|
683
664
|
return `
|
|
684
|
-
<
|
|
685
|
-
<
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
665
|
+
<details class="audit-details" data-testid="audit-details">
|
|
666
|
+
<summary>Audit details</summary>
|
|
667
|
+
<div class="audit-body">
|
|
668
|
+
<label class="note-field">
|
|
669
|
+
<span class="field-label">Reviewer note</span>
|
|
670
|
+
<textarea data-testid="reviewer-note" data-item-name="${escapeHtml(item.metadata.name)}">${escapeHtml(note)}</textarea>
|
|
671
|
+
</label>
|
|
672
|
+
${definition ? `<p class="field-value"><span class="field-label">Decision effect</span> ${escapeHtml(definition.effect)}</p>` : ""}
|
|
673
|
+
${renderProducerFeedbackTags(item)}
|
|
674
|
+
<dl class="field-stack compact">
|
|
675
|
+
${current ? fieldItem("Current candidate ID", current.id) : ""}
|
|
676
|
+
${proposed ? fieldItem("Proposed candidate ID", proposed.id) : ""}
|
|
677
|
+
${proposed ? fieldItem("Claim ID", proposed.claimTarget.claimId ?? proposed.claimTarget.fieldOrBehavior) : ""}
|
|
678
|
+
${proposed ? fieldItem("Raw Source ID", proposed.source.sourceId ?? proposed.source.sourceRef) : ""}
|
|
679
|
+
${proposed?.locator ? fieldItem("Locator", proposed.locator.locator ?? proposed.locator.scheme) : ""}
|
|
680
|
+
${proposed?.extraction.model ? fieldItem("Model", proposed.extraction.model) : ""}
|
|
681
|
+
${proposed ? fieldItem("Extractor", proposed.extraction.extractor ?? "unknown") : ""}
|
|
682
|
+
${proposed ? fieldItem("Extracted at", proposed.extraction.extractedAt ?? "unknown") : ""}
|
|
683
|
+
</dl>
|
|
684
|
+
${preview
|
|
685
|
+
? `<div class="preview-section-grid">${renderSurfacePreviewSections(preview)}</div><p class="preview-disclaimer preview-disclaimer-footer">${escapeHtml(preview.postureDisclaimer)}</p>`
|
|
686
|
+
: "<p class=\"preview-disclaimer\">No decision recorded yet — pick an option above to preview the saved record.</p>"}
|
|
687
|
+
${reviewDecisionPayload
|
|
688
|
+
? `<details class="reference-details">
|
|
689
|
+
<summary>Saved record (JSON)</summary>
|
|
690
|
+
<pre data-testid="decision-payload">${escapeHtml(JSON.stringify(reviewDecisionPayload, null, 2))}</pre>
|
|
691
|
+
</details>`
|
|
692
|
+
: ""}
|
|
691
693
|
</div>
|
|
692
|
-
|
|
693
|
-
<span class="field-label">Reviewer note</span>
|
|
694
|
-
<textarea id="reviewer-note" data-testid="reviewer-note">${escapeHtml(state.note)}</textarea>
|
|
695
|
-
</label>
|
|
696
|
-
${renderFeedbackTags(state)}
|
|
697
|
-
</section>
|
|
694
|
+
</details>
|
|
698
695
|
`;
|
|
699
696
|
}
|
|
700
|
-
function
|
|
701
|
-
const tags =
|
|
697
|
+
function renderProducerFeedbackTags(item) {
|
|
698
|
+
const tags = item.spec.producerPolicy?.feedbackTags;
|
|
699
|
+
if (!Array.isArray(tags) || tags.length === 0) {
|
|
700
|
+
return "";
|
|
701
|
+
}
|
|
702
702
|
return `
|
|
703
|
-
<div class="
|
|
703
|
+
<div class="note-field">
|
|
704
704
|
<span class="field-label">Producer feedback tags</span>
|
|
705
|
-
<
|
|
706
|
-
${tags.length === 0 ? "<span class=\"tag is-empty\">none</span>" : tags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("")}
|
|
707
|
-
</div>
|
|
705
|
+
<p class="field-value">${tags.map((tag) => escapeHtml(tag)).join(", ")}</p>
|
|
708
706
|
</div>
|
|
709
707
|
`;
|
|
710
708
|
}
|
|
711
|
-
function
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
}
|
|
718
|
-
function renderDecisionPayload(state) {
|
|
719
|
-
const decision = buildReviewDecision(state);
|
|
720
|
-
return `
|
|
721
|
-
<details class="payload-panel">
|
|
722
|
-
<summary class="field-label">ReviewDecision payload</summary>
|
|
723
|
-
<pre data-testid="decision-payload">${escapeHtml(JSON.stringify(decision ?? null, null, 2))}</pre>
|
|
724
|
-
</details>
|
|
725
|
-
`;
|
|
726
|
-
}
|
|
727
|
-
function renderSurfacePreview(state, presentationAdapter) {
|
|
728
|
-
const preview = buildSurfaceProjectionPreview(state.item, buildReviewDecision(state), presentationAdapter);
|
|
729
|
-
return preview ? renderPopulatedSurfacePreview(preview) : renderPendingSurfacePreview();
|
|
730
|
-
}
|
|
731
|
-
function renderPendingSurfacePreview() {
|
|
732
|
-
return `
|
|
733
|
-
<details class="surface-preview" data-testid="surface-preview" aria-label="Surface preview">
|
|
734
|
-
<summary class="surface-summary">
|
|
735
|
-
<span class="surface-summary-label">Surface preview</span>
|
|
736
|
-
<span class="state-label">pending</span>
|
|
737
|
-
</summary>
|
|
738
|
-
<p class="preview-disclaimer">Choose a review decision to preview the Raw Source, Source Reference, and review posture that would project toward Surface.</p>
|
|
739
|
-
</details>
|
|
740
|
-
`;
|
|
741
|
-
}
|
|
742
|
-
function renderPopulatedSurfacePreview(preview) {
|
|
709
|
+
function renderFooterTally(session) {
|
|
710
|
+
const counts = { review: 0, accepted: 0, kept: 0, rejected: 0 };
|
|
711
|
+
for (const item of session.items) {
|
|
712
|
+
const state = fieldCardState(item, session.decisionsByItemName[item.metadata.name]);
|
|
713
|
+
counts[state] += 1;
|
|
714
|
+
}
|
|
743
715
|
return `
|
|
744
|
-
<
|
|
745
|
-
<
|
|
746
|
-
<span
|
|
747
|
-
<span
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
${renderSurfacePreviewSections(preview)}
|
|
716
|
+
<footer class="foot" data-testid="review-tally">
|
|
717
|
+
<div class="tally">
|
|
718
|
+
<span>Accepted <b data-testid="tally-accepted">${counts.accepted}</b></span>
|
|
719
|
+
<span>Kept <b data-testid="tally-kept">${counts.kept}</b></span>
|
|
720
|
+
<span>Flagged <b data-testid="tally-rejected">${counts.rejected}</b></span>
|
|
721
|
+
<span>Remaining <b data-testid="tally-review">${counts.review}</b></span>
|
|
751
722
|
</div>
|
|
752
|
-
|
|
753
|
-
</details>
|
|
723
|
+
</footer>
|
|
754
724
|
`;
|
|
755
725
|
}
|
|
756
726
|
function renderSurfacePreviewSections(preview) {
|
|
@@ -762,15 +732,6 @@ function renderSurfacePreviewSections(preview) {
|
|
|
762
732
|
renderAuthorityTrace(preview),
|
|
763
733
|
].join("");
|
|
764
734
|
}
|
|
765
|
-
function renderCanonicalClaim(preview) {
|
|
766
|
-
return renderPreviewSection("Selected claim", "surface-canonical-claim", [
|
|
767
|
-
["Value", preview.canonicalClaim.value],
|
|
768
|
-
["Review status", preview.canonicalClaim.status],
|
|
769
|
-
], undefined, [
|
|
770
|
-
["Candidate ID", preview.canonicalClaim.candidateId],
|
|
771
|
-
["Claim ID", preview.canonicalClaim.claimId],
|
|
772
|
-
]);
|
|
773
|
-
}
|
|
774
735
|
function renderReviewEvent(preview) {
|
|
775
736
|
return `
|
|
776
737
|
<section class="preview-section" data-testid="surface-review-event">
|
|
@@ -825,7 +786,7 @@ function renderCandidateHistory(preview) {
|
|
|
825
786
|
return `
|
|
826
787
|
<section class="preview-section" data-testid="surface-candidate-history">
|
|
827
788
|
<h3>${escapeHtml("Unselected candidate history")}</h3>
|
|
828
|
-
<div class="history-expander
|
|
789
|
+
<div class="history-expander" data-history-expander>
|
|
829
790
|
<dl class="field-stack compact">
|
|
830
791
|
${renderHistoryRows(visibleCandidates)}
|
|
831
792
|
${overflowHtml}
|
|
@@ -840,9 +801,7 @@ function renderCandidateHistory(preview) {
|
|
|
840
801
|
`;
|
|
841
802
|
}
|
|
842
803
|
function renderSourceEvidence(preview) {
|
|
843
|
-
// Excerpt uses a 3-line clamp wrapper with a toggle button.
|
|
844
804
|
const clampedExcerptHtml = fieldItemClamped("Excerpt", preview.sourceEvidence.excerpt, "excerpt");
|
|
845
|
-
// Build Raw Source section with clamped excerpt inline.
|
|
846
805
|
return `
|
|
847
806
|
<section class="preview-section" data-testid="surface-source-evidence">
|
|
848
807
|
<h3>${escapeHtml("Raw Source")}</h3>
|
|
@@ -897,11 +856,12 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
897
856
|
const persistEvents = () => {
|
|
898
857
|
eventStore?.save(session, events);
|
|
899
858
|
};
|
|
900
|
-
const appendEvent = (eventType) => {
|
|
901
|
-
const
|
|
902
|
-
const decision =
|
|
903
|
-
const candidate = decision ? candidateForDecision(
|
|
859
|
+
const appendEvent = (eventType, itemName, dataOverride) => {
|
|
860
|
+
const item = itemName ? session.items.find((entry) => entry.metadata.name === itemName) : undefined;
|
|
861
|
+
const decision = itemName ? session.decisionsByItemName[itemName] : undefined;
|
|
862
|
+
const candidate = item && decision ? candidateForDecision(item, decision) : undefined;
|
|
904
863
|
const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
|
|
864
|
+
const note = itemName ? session.notesByItemName[itemName] : undefined;
|
|
905
865
|
events = [
|
|
906
866
|
...events,
|
|
907
867
|
buildReviewSessionEvent(session, {
|
|
@@ -909,70 +869,74 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
909
869
|
sequence: events.length + 1,
|
|
910
870
|
eventType,
|
|
911
871
|
occurredAt: session.reviewedAt,
|
|
912
|
-
reviewItemName:
|
|
913
|
-
|
|
914
|
-
reviewDecisionName: decision ? `${state.item.metadata.name}-${decision}` : undefined,
|
|
872
|
+
reviewItemName: itemName,
|
|
873
|
+
reviewDecisionName: item && decision ? `${item.metadata.name}-${decision}` : undefined,
|
|
915
874
|
candidateId: candidate?.id,
|
|
916
875
|
status: definition?.status,
|
|
917
|
-
rationale:
|
|
918
|
-
data: decision ? { workbenchDecision: decision } : undefined,
|
|
876
|
+
rationale: note,
|
|
877
|
+
data: dataOverride ?? (decision ? { workbenchDecision: decision } : undefined),
|
|
919
878
|
}),
|
|
920
879
|
];
|
|
921
880
|
};
|
|
922
881
|
const applySessionUpdate = (update) => {
|
|
923
882
|
session = update(session);
|
|
924
883
|
};
|
|
925
|
-
const
|
|
884
|
+
const setDecision = (itemName, decision, rawEditedValue) => {
|
|
885
|
+
applySessionUpdate((current) => {
|
|
886
|
+
const item = current.items.find((entry) => entry.metadata.name === itemName);
|
|
887
|
+
const proposed = item?.spec.candidates.find((candidate) => candidate.role === "proposed");
|
|
888
|
+
const originalText = proposed ? formatValue(proposed.value) : undefined;
|
|
889
|
+
const nextEditedValuesByItemName = { ...current.editedValuesByItemName };
|
|
890
|
+
if (decision === "accept-proposed" && rawEditedValue !== undefined && rawEditedValue !== originalText) {
|
|
891
|
+
nextEditedValuesByItemName[itemName] = rawEditedValue;
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
delete nextEditedValuesByItemName[itemName];
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
...current,
|
|
898
|
+
activeItemName: itemName,
|
|
899
|
+
decisionsByItemName: { ...current.decisionsByItemName, [itemName]: decision },
|
|
900
|
+
editedValuesByItemName: nextEditedValuesByItemName,
|
|
901
|
+
};
|
|
902
|
+
});
|
|
903
|
+
appendEvent("decision-changed", itemName);
|
|
904
|
+
persistEvents();
|
|
905
|
+
};
|
|
906
|
+
const clearDecision = (itemName) => {
|
|
907
|
+
applySessionUpdate((current) => {
|
|
908
|
+
const remainingDecisions = { ...current.decisionsByItemName };
|
|
909
|
+
delete remainingDecisions[itemName];
|
|
910
|
+
const remainingEdits = { ...current.editedValuesByItemName };
|
|
911
|
+
delete remainingEdits[itemName];
|
|
912
|
+
return {
|
|
913
|
+
...current,
|
|
914
|
+
activeItemName: itemName,
|
|
915
|
+
decisionsByItemName: remainingDecisions,
|
|
916
|
+
editedValuesByItemName: remainingEdits,
|
|
917
|
+
};
|
|
918
|
+
});
|
|
919
|
+
appendEvent("decision-changed", itemName, { workbenchDecision: null });
|
|
920
|
+
persistEvents();
|
|
921
|
+
};
|
|
922
|
+
const updateReviewerNote = (itemName, note) => {
|
|
926
923
|
applySessionUpdate((current) => ({
|
|
927
924
|
...current,
|
|
928
|
-
|
|
929
|
-
...current.decisionsByItemName,
|
|
930
|
-
[currentReviewItem(current).metadata.name]: decision,
|
|
931
|
-
},
|
|
925
|
+
notesByItemName: { ...current.notesByItemName, [itemName]: note },
|
|
932
926
|
}));
|
|
933
|
-
appendEvent("
|
|
927
|
+
appendEvent("note-changed", itemName);
|
|
934
928
|
persistEvents();
|
|
935
929
|
};
|
|
936
930
|
const controller = {
|
|
937
|
-
currentState: () => currentReviewWorkbenchState(session),
|
|
938
931
|
currentSession: () => session,
|
|
939
932
|
currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events),
|
|
940
|
-
goToNextUnresolved: () => {
|
|
941
|
-
applyNextUnresolvedSessionUpdate(applySessionUpdate, session);
|
|
942
|
-
appendEvent("item-selected");
|
|
943
|
-
persistEvents();
|
|
944
|
-
},
|
|
945
933
|
renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter),
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
appendEvent("item-selected");
|
|
950
|
-
persistEvents();
|
|
951
|
-
},
|
|
952
|
-
updateReviewerNote: (note) => {
|
|
953
|
-
applyReviewerNoteSessionUpdate(applySessionUpdate, session, note);
|
|
954
|
-
appendEvent("note-changed");
|
|
955
|
-
persistEvents();
|
|
956
|
-
},
|
|
934
|
+
setDecision,
|
|
935
|
+
clearDecision,
|
|
936
|
+
updateReviewerNote,
|
|
957
937
|
};
|
|
958
938
|
return controller;
|
|
959
939
|
}
|
|
960
|
-
function applyReviewerNoteSessionUpdate(applySessionUpdate, session, note) {
|
|
961
|
-
const itemName = currentReviewItem(session).metadata.name;
|
|
962
|
-
applySessionUpdate((current) => ({
|
|
963
|
-
...current,
|
|
964
|
-
notesByItemName: {
|
|
965
|
-
...current.notesByItemName,
|
|
966
|
-
[itemName]: note,
|
|
967
|
-
},
|
|
968
|
-
}));
|
|
969
|
-
}
|
|
970
|
-
function applyNextUnresolvedSessionUpdate(applySessionUpdate, session) {
|
|
971
|
-
const next = nextUnresolvedItemName(session);
|
|
972
|
-
if (next) {
|
|
973
|
-
applySessionUpdate((current) => ({ ...current, activeItemName: next }));
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
940
|
function browserReviewSessionEventStore() {
|
|
977
941
|
if (typeof window === "undefined" || !window.localStorage) {
|
|
978
942
|
return undefined;
|
|
@@ -984,22 +948,9 @@ function reviewSessionStorageKey(session, keyPrefix) {
|
|
|
984
948
|
return `${keyPrefix}:${itemNames}`;
|
|
985
949
|
}
|
|
986
950
|
function renderCurrentState(root, session, controller, presentationAdapter) {
|
|
987
|
-
root.innerHTML = renderReviewWorkbenchHtml(session,
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
controller.selectDecision(decision);
|
|
991
|
-
controller.renderCurrentState();
|
|
992
|
-
});
|
|
993
|
-
bindQueueRows(root, (itemName) => {
|
|
994
|
-
controller.selectQueueItem(itemName);
|
|
995
|
-
controller.renderCurrentState();
|
|
996
|
-
closeQueueDrawer(root);
|
|
997
|
-
});
|
|
998
|
-
bindNextUnresolved(root, () => {
|
|
999
|
-
controller.goToNextUnresolved();
|
|
1000
|
-
controller.renderCurrentState();
|
|
1001
|
-
});
|
|
1002
|
-
bindQueueDrawer(root);
|
|
951
|
+
root.innerHTML = renderReviewWorkbenchHtml(session, undefined, { presentationAdapter });
|
|
952
|
+
bindFieldCardInteractions(root, controller);
|
|
953
|
+
bindApplyButton(root, controller);
|
|
1003
954
|
bindClampToggles(root);
|
|
1004
955
|
bindHistoryExpanders(root);
|
|
1005
956
|
}
|
|
@@ -1012,41 +963,88 @@ function queueSessionFromStartState(startState) {
|
|
|
1012
963
|
activeItemName: startState.item.metadata.name,
|
|
1013
964
|
notesByItemName: startState.note ? { [startState.item.metadata.name]: startState.note } : {},
|
|
1014
965
|
decisionsByItemName: startState.decision ? { [startState.item.metadata.name]: startState.decision } : {},
|
|
966
|
+
editedValuesByItemName: startState.editedValue !== undefined
|
|
967
|
+
? { [startState.item.metadata.name]: startState.editedValue }
|
|
968
|
+
: {},
|
|
1015
969
|
reviewedAt: startState.reviewedAt,
|
|
1016
970
|
actorId: startState.actorId,
|
|
1017
971
|
};
|
|
1018
972
|
}
|
|
1019
|
-
function
|
|
1020
|
-
root.
|
|
1021
|
-
updateReviewerNote(event.target.value);
|
|
1022
|
-
refreshDecisionOutputs(root, currentState(), renderCurrentState, presentationAdapter);
|
|
1023
|
-
refreshSessionAudit(root, currentSession(), currentSessionExport(), renderCurrentState);
|
|
1024
|
-
});
|
|
1025
|
-
}
|
|
1026
|
-
function bindDecisionButtons(root, selectDecision) {
|
|
1027
|
-
root.querySelectorAll("[data-decision]").forEach((button) => {
|
|
973
|
+
function bindFieldCardInteractions(root, controller) {
|
|
974
|
+
root.querySelectorAll("[data-testid='use-proposed']").forEach((button) => {
|
|
1028
975
|
button.addEventListener("click", () => {
|
|
1029
|
-
|
|
976
|
+
const itemName = button.dataset.itemName ?? "";
|
|
977
|
+
const field = button.closest("[data-testid='review-field']");
|
|
978
|
+
const input = field?.querySelector("[data-testid='edit-proposed-value']");
|
|
979
|
+
controller.setDecision(itemName, "accept-proposed", input?.value);
|
|
980
|
+
controller.renderCurrentState();
|
|
1030
981
|
});
|
|
1031
982
|
});
|
|
1032
|
-
|
|
1033
|
-
function bindQueueRows(root, selectQueueItem) {
|
|
1034
|
-
root.querySelectorAll("[data-item-name]").forEach((button) => {
|
|
983
|
+
root.querySelectorAll("[data-testid='keep-current']").forEach((button) => {
|
|
1035
984
|
button.addEventListener("click", () => {
|
|
1036
|
-
|
|
1037
|
-
|
|
985
|
+
const itemName = button.dataset.itemName ?? "";
|
|
986
|
+
const field = button.closest("[data-testid='review-field']");
|
|
987
|
+
const wrong = field?.querySelector("[data-testid='wrong-toggle']");
|
|
988
|
+
const decision = wrong?.checked ? "reject-proposed" : "keep-current";
|
|
989
|
+
controller.setDecision(itemName, decision);
|
|
990
|
+
controller.renderCurrentState();
|
|
1038
991
|
});
|
|
1039
992
|
});
|
|
1040
|
-
|
|
1041
|
-
function bindNextUnresolved(root, goToNextUnresolved) {
|
|
1042
|
-
root
|
|
1043
|
-
.querySelectorAll("[data-testid='next-unresolved'], [data-testid='active-next-unresolved']")
|
|
1044
|
-
.forEach((button) => {
|
|
993
|
+
root.querySelectorAll("[data-testid='undo-decision']").forEach((button) => {
|
|
1045
994
|
button.addEventListener("click", () => {
|
|
1046
|
-
|
|
1047
|
-
|
|
995
|
+
controller.clearDecision(button.dataset.itemName ?? "");
|
|
996
|
+
controller.renderCurrentState();
|
|
1048
997
|
});
|
|
1049
998
|
});
|
|
999
|
+
root.querySelectorAll("[data-testid='reviewer-note']").forEach((textarea) => {
|
|
1000
|
+
textarea.addEventListener("input", () => {
|
|
1001
|
+
const itemName = textarea.dataset.itemName ?? "";
|
|
1002
|
+
controller.updateReviewerNote(itemName, textarea.value);
|
|
1003
|
+
refreshAuditPayloadForItem(textarea, controller, itemName);
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
function bindApplyButton(root, controller) {
|
|
1008
|
+
const button = root.querySelector("[data-testid='apply-button']");
|
|
1009
|
+
button?.addEventListener("click", () => {
|
|
1010
|
+
if (typeof CustomEvent !== "function" || typeof root.dispatchEvent !== "function") {
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
root.dispatchEvent(new CustomEvent("survey:review-workbench-apply", {
|
|
1014
|
+
bubbles: true,
|
|
1015
|
+
composed: true,
|
|
1016
|
+
detail: {
|
|
1017
|
+
session: controller.currentSession(),
|
|
1018
|
+
sessionExport: controller.currentSessionExport(),
|
|
1019
|
+
},
|
|
1020
|
+
}));
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* After a reviewer-note keystroke, patch just that field's decision-payload `<pre>`
|
|
1025
|
+
* in place instead of a full re-render — a full re-render would close the audit
|
|
1026
|
+
* `<details>` and drop focus/caret position on every keystroke.
|
|
1027
|
+
*/
|
|
1028
|
+
function refreshAuditPayloadForItem(textarea, controller, itemName) {
|
|
1029
|
+
const field = textarea.closest("[data-testid='review-field']");
|
|
1030
|
+
const payload = field?.querySelector("[data-testid='decision-payload']");
|
|
1031
|
+
if (!payload) {
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const session = controller.currentSession();
|
|
1035
|
+
const item = session.items.find((entry) => entry.metadata.name === itemName);
|
|
1036
|
+
if (!item) {
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
const state = {
|
|
1040
|
+
item,
|
|
1041
|
+
note: textarea.value,
|
|
1042
|
+
decision: session.decisionsByItemName[itemName],
|
|
1043
|
+
editedValue: session.editedValuesByItemName?.[itemName],
|
|
1044
|
+
reviewedAt: session.reviewedAt,
|
|
1045
|
+
actorId: session.actorId,
|
|
1046
|
+
};
|
|
1047
|
+
payload.textContent = JSON.stringify(buildReviewDecision(state) ?? null, null, 2);
|
|
1050
1048
|
}
|
|
1051
1049
|
function bindClampToggles(root) {
|
|
1052
1050
|
root.querySelectorAll("[data-clamp-toggle]").forEach((button) => {
|
|
@@ -1071,152 +1069,9 @@ function bindHistoryExpanders(root) {
|
|
|
1071
1069
|
});
|
|
1072
1070
|
});
|
|
1073
1071
|
}
|
|
1074
|
-
function bindQueueDrawer(root) {
|
|
1075
|
-
const openButton = root.querySelector("[data-testid='mobile-queue-open']");
|
|
1076
|
-
const closeButton = root.querySelector("[data-testid='queue-drawer-close']");
|
|
1077
|
-
const backdrop = root.querySelector("[data-testid='queue-drawer-backdrop']");
|
|
1078
|
-
const panel = root.querySelector("#queue-panel");
|
|
1079
|
-
openButton?.addEventListener("click", () => openQueueDrawer(root));
|
|
1080
|
-
closeButton?.addEventListener("click", () => closeQueueDrawer(root));
|
|
1081
|
-
backdrop?.addEventListener("click", () => closeQueueDrawer(root));
|
|
1082
|
-
if (typeof root.addEventListener === "function") {
|
|
1083
|
-
root.addEventListener("keydown", (event) => {
|
|
1084
|
-
if (event.key === "Escape" && panel?.classList.contains("is-open")) {
|
|
1085
|
-
closeQueueDrawer(root);
|
|
1086
|
-
}
|
|
1087
|
-
});
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
function openQueueDrawer(root) {
|
|
1091
|
-
const panel = root.querySelector("#queue-panel");
|
|
1092
|
-
const backdrop = root.querySelector("[data-testid='queue-drawer-backdrop']");
|
|
1093
|
-
const openButton = root.querySelector("[data-testid='mobile-queue-open']");
|
|
1094
|
-
if (!panel) {
|
|
1095
|
-
return;
|
|
1096
|
-
}
|
|
1097
|
-
panel.classList.add("is-open");
|
|
1098
|
-
panel.setAttribute("aria-modal", "true");
|
|
1099
|
-
backdrop?.classList.add("is-visible");
|
|
1100
|
-
openButton?.setAttribute("aria-expanded", "true");
|
|
1101
|
-
panel.querySelector("button, [tabindex]")?.focus();
|
|
1102
|
-
}
|
|
1103
|
-
function closeQueueDrawer(root) {
|
|
1104
|
-
const panel = root.querySelector("#queue-panel");
|
|
1105
|
-
const backdrop = root.querySelector("[data-testid='queue-drawer-backdrop']");
|
|
1106
|
-
const openButton = root.querySelector("[data-testid='mobile-queue-open']");
|
|
1107
|
-
if (!panel) {
|
|
1108
|
-
return;
|
|
1109
|
-
}
|
|
1110
|
-
panel.classList.remove("is-open");
|
|
1111
|
-
panel.setAttribute("aria-modal", "false");
|
|
1112
|
-
backdrop?.classList.remove("is-visible");
|
|
1113
|
-
openButton?.setAttribute("aria-expanded", "false");
|
|
1114
|
-
openButton?.focus();
|
|
1115
|
-
}
|
|
1116
|
-
function scrollActiveReviewIntoView(root) {
|
|
1117
|
-
if (typeof window === "undefined" || !window.matchMedia("(max-width: 980px)").matches) {
|
|
1118
|
-
return;
|
|
1119
|
-
}
|
|
1120
|
-
root.querySelector("[data-testid='active-review-strip']")?.scrollIntoView({
|
|
1121
|
-
block: "start",
|
|
1122
|
-
behavior: "smooth",
|
|
1123
|
-
});
|
|
1124
|
-
}
|
|
1125
|
-
function refreshDecisionOutputs(root, state, renderCurrentState, presentationAdapter) {
|
|
1126
|
-
const payload = root.querySelector("[data-testid='decision-payload']");
|
|
1127
|
-
if (payload) {
|
|
1128
|
-
payload.textContent = JSON.stringify(buildReviewDecision(state) ?? null, null, 2);
|
|
1129
|
-
}
|
|
1130
|
-
const preview = root.querySelector("[data-testid='surface-preview']");
|
|
1131
|
-
if (!preview) {
|
|
1132
|
-
return;
|
|
1133
|
-
}
|
|
1134
|
-
if (typeof document === "undefined") {
|
|
1135
|
-
renderCurrentState();
|
|
1136
|
-
return;
|
|
1137
|
-
}
|
|
1138
|
-
const wrapper = document.createElement("div");
|
|
1139
|
-
wrapper.innerHTML = renderSurfacePreview(state, presentationAdapter);
|
|
1140
|
-
const newPreview = wrapper.firstElementChild;
|
|
1141
|
-
preview.replaceWith(newPreview);
|
|
1142
|
-
if (newPreview && typeof newPreview.querySelectorAll === "function") {
|
|
1143
|
-
bindClampToggles(newPreview);
|
|
1144
|
-
bindHistoryExpanders(newPreview);
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
function refreshSessionAudit(root, session, sessionExport, renderCurrentState) {
|
|
1148
|
-
const audit = root.querySelector("[data-testid='session-audit']");
|
|
1149
|
-
if (!audit) {
|
|
1150
|
-
return;
|
|
1151
|
-
}
|
|
1152
|
-
if (typeof document === "undefined") {
|
|
1153
|
-
renderCurrentState();
|
|
1154
|
-
return;
|
|
1155
|
-
}
|
|
1156
|
-
const wrapper = document.createElement("div");
|
|
1157
|
-
wrapper.innerHTML = renderSessionAudit(session, sessionExport);
|
|
1158
|
-
audit.replaceWith(wrapper.firstElementChild);
|
|
1159
|
-
}
|
|
1160
|
-
function producerFeedbackTags(item) {
|
|
1161
|
-
const tags = item.spec.producerPolicy?.feedbackTags;
|
|
1162
|
-
return Array.isArray(tags) ? tags.map(formatValue) : [];
|
|
1163
|
-
}
|
|
1164
|
-
function renderCandidateCard(candidate, state, presentationAdapter) {
|
|
1165
|
-
const selectedRole = selectedCandidateRole(state);
|
|
1166
|
-
const candidateState = state.decision
|
|
1167
|
-
? candidate.role === selectedRole ? "selected" : "unselected"
|
|
1168
|
-
: "pending";
|
|
1169
|
-
const cssState = candidateState === "selected" ? " is-selected" : candidateState === "unselected" ? " is-unselected" : "";
|
|
1170
|
-
const confidence = candidate.extraction.confidence ?? candidate.confidence;
|
|
1171
|
-
const itemPresentation = buildReviewItemPresentation(state.item, presentationAdapter);
|
|
1172
|
-
const presentation = buildReviewCandidatePresentation(state.item, candidate, presentationAdapter, itemPresentation.targetLabel);
|
|
1173
|
-
return `
|
|
1174
|
-
<article class="candidate-card${cssState}" data-testid="candidate-${escapeHtml(candidate.role ?? candidate.id)}" data-outcome="${candidateState}">
|
|
1175
|
-
<div class="card-head">
|
|
1176
|
-
<div>
|
|
1177
|
-
<p class="eyebrow">${escapeHtml(candidate.role === "proposed" ? "incoming value" : candidate.role === "current" ? "existing value" : "candidate")}</p>
|
|
1178
|
-
<h2 class="role">${escapeHtml(presentation.roleLabel)}</h2>
|
|
1179
|
-
</div>
|
|
1180
|
-
<span class="state-label">${escapeHtml(candidateState)}</span>
|
|
1181
|
-
</div>
|
|
1182
|
-
<div class="candidate-value">
|
|
1183
|
-
<span class="field-label">${escapeHtml(presentation.valueLabel)}</span>
|
|
1184
|
-
<p class="field-value">${escapeHtml(presentation.valueText)}</p>
|
|
1185
|
-
</div>
|
|
1186
|
-
<dl class="field-stack">
|
|
1187
|
-
${fieldItem(presentation.sourceLabel, presentation.sourceText)}
|
|
1188
|
-
${fieldItem("Locator", candidate.locator?.locator ?? candidate.locator?.scheme ?? "none")}
|
|
1189
|
-
${fieldItemClamped("Excerpt", candidate.locator?.excerpt ?? "none", "excerpt")}
|
|
1190
|
-
${fieldItem("Extraction confidence", confidence === undefined ? "unknown" : formatConfidence(confidence))}
|
|
1191
|
-
${fieldItem("Extractor", candidate.extraction.extractor ?? "unknown")}
|
|
1192
|
-
</dl>
|
|
1193
|
-
<details class="reference-details">
|
|
1194
|
-
<summary>IDs and trace links</summary>
|
|
1195
|
-
<dl class="field-stack compact">
|
|
1196
|
-
${presentation.traceRefs.map((ref) => fieldItem(ref.label, ref.value)).join("")}
|
|
1197
|
-
</dl>
|
|
1198
|
-
</details>
|
|
1199
|
-
</article>
|
|
1200
|
-
`;
|
|
1201
|
-
}
|
|
1202
|
-
function candidateByRole(item, role) {
|
|
1203
|
-
const candidate = item.spec.candidates.find((entry) => entry.role === role);
|
|
1204
|
-
if (!candidate) {
|
|
1205
|
-
throw new Error(`ReviewItem ${item.metadata.name} has no ${role} candidate.`);
|
|
1206
|
-
}
|
|
1207
|
-
return candidate;
|
|
1208
|
-
}
|
|
1209
|
-
function metaItem(label, value) {
|
|
1210
|
-
return `
|
|
1211
|
-
<div class="meta-item">
|
|
1212
|
-
<dt class="field-label">${escapeHtml(label)}</dt>
|
|
1213
|
-
<dd class="meta-value">${escapeHtml(value)}</dd>
|
|
1214
|
-
</div>
|
|
1215
|
-
`;
|
|
1216
|
-
}
|
|
1217
1072
|
function fieldItemClamped(label, value, extraClass = "") {
|
|
1218
1073
|
return `
|
|
1219
|
-
<div class="
|
|
1074
|
+
<div class="kv ${extraClass}">
|
|
1220
1075
|
<dt class="field-label">${escapeHtml(label)}</dt>
|
|
1221
1076
|
<div class="excerpt-clamp" data-clamp>
|
|
1222
1077
|
<dd class="field-value">${escapeHtml(value)}</dd>
|
|
@@ -1227,15 +1082,12 @@ function fieldItemClamped(label, value, extraClass = "") {
|
|
|
1227
1082
|
}
|
|
1228
1083
|
function fieldItem(label, value, extraClass = "") {
|
|
1229
1084
|
return `
|
|
1230
|
-
<div class="
|
|
1085
|
+
<div class="kv ${extraClass}">
|
|
1231
1086
|
<dt class="field-label">${escapeHtml(label)}</dt>
|
|
1232
1087
|
<dd class="field-value">${escapeHtml(value)}</dd>
|
|
1233
1088
|
</div>
|
|
1234
1089
|
`;
|
|
1235
1090
|
}
|
|
1236
|
-
function formatConfidence(value) {
|
|
1237
|
-
return `${Math.round(value * 100)}% (${value.toFixed(2)})`;
|
|
1238
|
-
}
|
|
1239
1091
|
function escapeHtml(value) {
|
|
1240
1092
|
return formatValue(value)
|
|
1241
1093
|
.replaceAll("&", "&")
|