@kontourai/survey 6.1.0 → 8.0.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/src/calibration.d.ts +15 -0
- package/dist/src/calibration.js +11 -0
- package/dist/src/canonical-reviewed-trust-input.d.ts +10 -0
- package/dist/src/canonical-reviewed-trust-input.js +45 -1
- package/dist/src/console/review-console-server.js +22 -4
- package/dist/src/decision-carry-forward.d.ts +157 -0
- package/dist/src/decision-carry-forward.js +295 -0
- package/dist/src/extraction-envelope.d.ts +4 -3
- package/dist/src/extraction-envelope.js +3 -3
- package/dist/src/field-states.d.ts +100 -0
- package/dist/src/field-states.js +169 -0
- package/dist/src/index.d.ts +6 -4
- package/dist/src/index.js +3 -1
- package/dist/src/mcp/review-mcp.js +173 -45
- package/dist/src/review-resource.d.ts +36 -0
- package/dist/src/review-session-file.d.ts +7 -0
- package/dist/src/review-workbench/audit-rows.d.ts +1 -1
- package/dist/src/review-workbench/audit-rows.js +1 -0
- package/dist/src/review-workbench/audit-sample.d.ts +32 -0
- package/dist/src/review-workbench/audit-sample.js +53 -0
- package/dist/src/review-workbench/extraction-inspector.d.ts +1 -1
- package/dist/src/review-workbench/extraction-inspector.js +1 -1
- package/dist/src/review-workbench/field-state-view.d.ts +15 -0
- package/dist/src/review-workbench/field-state-view.js +84 -0
- package/dist/src/review-workbench/queue-binding.d.ts +45 -2
- package/dist/src/review-workbench/queue-binding.js +45 -0
- package/dist/src/review-workbench/review-presentation.d.ts +23 -0
- package/dist/src/review-workbench/review-presentation.js +51 -10
- package/dist/src/review-workbench/review-queue-session.d.ts +93 -5
- package/dist/src/review-workbench/review-queue-session.js +147 -11
- package/dist/src/review-workbench/review-session-replay.d.ts +11 -1
- package/dist/src/review-workbench/review-session-replay.js +49 -1
- package/dist/src/review-workbench/review-workbench-css.generated.js +126 -0
- package/dist/src/review-workbench/review-workbench-element.d.ts +10 -0
- package/dist/src/review-workbench/review-workbench-element.js +21 -3
- package/dist/src/review-workbench/review-workbench.css +126 -0
- package/dist/src/review-workbench/review-workbench.d.ts +40 -8
- package/dist/src/review-workbench/review-workbench.js +192 -71
- package/dist/src/review-workbench/review-workbench.standalone.css +126 -0
- package/dist/src/review-workbench/server-review-session.d.ts +18 -0
- package/dist/src/review-workbench/server-review-session.js +20 -2
- package/dist/src/surface-reviewed-extraction.d.ts +23 -0
- package/dist/src/surface-reviewed-extraction.js +64 -1
- package/dist/src/to-surface.js +32 -3
- package/package.json +1 -1
|
@@ -4,6 +4,71 @@ import { checkEditedValueForItem } from "./edited-value.js";
|
|
|
4
4
|
import { assertSoleCandidateId, reviewResourceApiVersion, } from "../../src/review-resource.js";
|
|
5
5
|
export const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
|
|
6
6
|
export const defaultReviewSessionName = "review-workbench-session";
|
|
7
|
+
/** Whether the session hides confidence and verifier results from its reviewer. */
|
|
8
|
+
export function isScoreBlind(session) {
|
|
9
|
+
return session.presentation?.scoreBlind === true;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Throws unless the session's presentation and sampling are well formed:
|
|
13
|
+
* `scoreBlind` a boolean, and a `random-audit` sampling with a rate in (0, 1]
|
|
14
|
+
* and a non-empty seed. Unknown keys are refused so a decision never records
|
|
15
|
+
* a condition nothing reads.
|
|
16
|
+
*/
|
|
17
|
+
export function assertReviewSessionConditions(session) {
|
|
18
|
+
const { presentation, sampling } = session;
|
|
19
|
+
if (presentation !== undefined) {
|
|
20
|
+
if (!isPlainRecord(presentation) || Object.keys(presentation).join(",") !== "scoreBlind" || typeof presentation.scoreBlind !== "boolean") {
|
|
21
|
+
throw new Error("Review session presentation must be { scoreBlind: boolean }.");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (sampling !== undefined) {
|
|
25
|
+
if (!isPlainRecord(sampling))
|
|
26
|
+
throw new Error("Review session sampling must be an object.");
|
|
27
|
+
const keys = Object.keys(sampling).sort().join(",");
|
|
28
|
+
if (sampling.kind === "queue") {
|
|
29
|
+
if (keys !== "kind")
|
|
30
|
+
throw new Error("Review session sampling of kind queue takes no rate or seed.");
|
|
31
|
+
}
|
|
32
|
+
else if (sampling.kind === "random-audit") {
|
|
33
|
+
if (keys !== "kind,rate,seed")
|
|
34
|
+
throw new Error("Review session sampling of kind random-audit requires exactly rate and seed.");
|
|
35
|
+
assertAuditRate(sampling.rate);
|
|
36
|
+
assertAuditSeed(sampling.seed);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
throw new Error("Review session sampling kind must be queue or random-audit.");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The session conditions a decision event carries, or `undefined` for a
|
|
45
|
+
* session that declares none. Stamped on every decision event so replay can
|
|
46
|
+
* refuse events recorded under other conditions than the snapshot's: sighted
|
|
47
|
+
* events replayed over a blind snapshot, or a snapshot whose presentation was
|
|
48
|
+
* changed after decisions were recorded.
|
|
49
|
+
*/
|
|
50
|
+
export function sessionConditionsStamp(session) {
|
|
51
|
+
if (session.presentation === undefined && session.sampling === undefined)
|
|
52
|
+
return undefined;
|
|
53
|
+
return {
|
|
54
|
+
...(session.presentation !== undefined ? { presentation: { scoreBlind: session.presentation.scoreBlind } } : {}),
|
|
55
|
+
...(session.sampling !== undefined ? { sampling: { ...session.sampling } } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function assertAuditRate(rate) {
|
|
59
|
+
if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0 || rate > 1) {
|
|
60
|
+
throw new Error("A random-audit rate must be a number greater than 0 and at most 1.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function assertAuditSeed(seed) {
|
|
64
|
+
if (typeof seed !== "string" || !AUDIT_SEED.test(seed)) {
|
|
65
|
+
throw new Error("A random-audit seed must be a stable identity: 1-256 characters from letters, digits and . _ : @ / + ~ -, starting with a letter or digit.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const AUDIT_SEED = /^[A-Za-z0-9][A-Za-z0-9._:@/+~-]{0,255}$/;
|
|
69
|
+
function isPlainRecord(value) {
|
|
70
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
|
+
}
|
|
7
72
|
export const workbenchDecisionDefinitions = {
|
|
8
73
|
"accept-proposed": {
|
|
9
74
|
label: "Accept proposed",
|
|
@@ -11,6 +76,12 @@ export const workbenchDecisionDefinitions = {
|
|
|
11
76
|
candidateRole: "proposed",
|
|
12
77
|
status: "verified",
|
|
13
78
|
},
|
|
79
|
+
"select-proposed": {
|
|
80
|
+
label: "Use this value",
|
|
81
|
+
effect: "The chosen value becomes the verified review outcome; the other proposed values were seen and not chosen.",
|
|
82
|
+
candidateRole: "proposed",
|
|
83
|
+
status: "verified",
|
|
84
|
+
},
|
|
14
85
|
"keep-current": {
|
|
15
86
|
label: "Keep current",
|
|
16
87
|
effect: "Current value remains the verified review outcome.",
|
|
@@ -58,8 +129,11 @@ export function currentReviewWorkbenchState(session) {
|
|
|
58
129
|
decision: session.decisionsByItemName[item.metadata.name],
|
|
59
130
|
editedValue: session.editedValuesByItemName?.[item.metadata.name],
|
|
60
131
|
attemptEvidenceIds: session.attemptEvidenceIdsByItemName?.[item.metadata.name],
|
|
132
|
+
selectedCandidateId: session.selectedCandidateIdsByItemName?.[item.metadata.name],
|
|
61
133
|
reviewedAt: session.reviewedAt,
|
|
62
134
|
actorId: session.actorId,
|
|
135
|
+
...(session.presentation !== undefined ? { presentation: session.presentation } : {}),
|
|
136
|
+
...(session.sampling !== undefined ? { sampling: session.sampling } : {}),
|
|
63
137
|
};
|
|
64
138
|
}
|
|
65
139
|
export function currentReviewItem(session) {
|
|
@@ -77,7 +151,7 @@ export function deriveQueueRowStatus(item, session) {
|
|
|
77
151
|
if (decision === "could-not-confirm") {
|
|
78
152
|
return "could-not-confirm";
|
|
79
153
|
}
|
|
80
|
-
if (decision === "accept-proposed" || decision === "keep-current" || item.spec.candidateSetStatus === "resolved") {
|
|
154
|
+
if (decision === "accept-proposed" || decision === "select-proposed" || decision === "keep-current" || item.spec.candidateSetStatus === "resolved") {
|
|
81
155
|
return "resolved";
|
|
82
156
|
}
|
|
83
157
|
if (item.spec.candidateSetStatus === "escalated") {
|
|
@@ -99,7 +173,7 @@ export function nextUnresolvedItemName(session) {
|
|
|
99
173
|
export function reviewSessionSummary(session) {
|
|
100
174
|
return session.items.reduce((summary, item) => {
|
|
101
175
|
const decision = session.decisionsByItemName[item.metadata.name];
|
|
102
|
-
if (decision === "accept-proposed") {
|
|
176
|
+
if (decision === "accept-proposed" || decision === "select-proposed") {
|
|
103
177
|
return { ...summary, accepted: summary.accepted + 1 };
|
|
104
178
|
}
|
|
105
179
|
if (decision === "keep-current") {
|
|
@@ -176,7 +250,14 @@ const VALUE_NEUTRAL_DECISIONS = new Set(["reject-proposed", "could-not-confirm"]
|
|
|
176
250
|
* {@link decisionCandidateId} is `undefined` for it, so the decision, its
|
|
177
251
|
* session events and the canonical projection name no candidate.
|
|
178
252
|
*/
|
|
179
|
-
export function candidateForDecision(item, decision) {
|
|
253
|
+
export function candidateForDecision(item, decision, selectedCandidateId) {
|
|
254
|
+
if (decision === "select-proposed") {
|
|
255
|
+
const issue = conflictSelectionIssue(item, selectedCandidateId);
|
|
256
|
+
if (issue) {
|
|
257
|
+
throw new Error(issue);
|
|
258
|
+
}
|
|
259
|
+
return item.spec.candidates.find((entry) => entry.id === selectedCandidateId);
|
|
260
|
+
}
|
|
180
261
|
const definition = workbenchDecisionDefinitions[decision];
|
|
181
262
|
const matches = item.spec.candidates.filter((entry) => entry.role === definition.candidateRole);
|
|
182
263
|
const candidate = matches[0];
|
|
@@ -189,6 +270,45 @@ export function candidateForDecision(item, decision) {
|
|
|
189
270
|
assertSoleCandidateId(item, candidate.id);
|
|
190
271
|
return candidate;
|
|
191
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Why a `select-proposed` decision naming `candidateId` cannot be recorded on
|
|
275
|
+
* this item, or `undefined` when it can.
|
|
276
|
+
*
|
|
277
|
+
* The choice must name, by a unique id, one of the item's `proposed`
|
|
278
|
+
* candidates, and the item must hold at least two of them: choosing "over a
|
|
279
|
+
* rival" on an item with no rival would record a conflict that never existed
|
|
280
|
+
* (use `accept-proposed` there). Proposals excluded at import are not
|
|
281
|
+
* candidates, so they can never be chosen.
|
|
282
|
+
*/
|
|
283
|
+
export function conflictSelectionIssue(item, candidateId) {
|
|
284
|
+
const proposed = item.spec.candidates.filter((entry) => entry.role === "proposed");
|
|
285
|
+
if (proposed.length < 2) {
|
|
286
|
+
return `ReviewItem ${item.metadata.name} has ${proposed.length} proposed candidate${proposed.length === 1 ? "" : "s"}; select-proposed chooses between conflicting proposed values, so it needs at least two.`;
|
|
287
|
+
}
|
|
288
|
+
if (!candidateId) {
|
|
289
|
+
return `ReviewItem ${item.metadata.name}: select-proposed must name the chosen candidate id.`;
|
|
290
|
+
}
|
|
291
|
+
const matches = item.spec.candidates.filter((entry) => entry.id === candidateId);
|
|
292
|
+
if (matches.length === 0) {
|
|
293
|
+
return `ReviewItem ${item.metadata.name} has no candidate ${candidateId}; select-proposed can only choose one of its proposed candidates.`;
|
|
294
|
+
}
|
|
295
|
+
if (matches.length > 1) {
|
|
296
|
+
return `ReviewItem ${item.metadata.name} has ${matches.length} candidates with id ${candidateId}; candidate ids must be unique.`;
|
|
297
|
+
}
|
|
298
|
+
if (matches[0].role !== "proposed") {
|
|
299
|
+
return `ReviewItem ${item.metadata.name} candidate ${candidateId} has role ${matches[0].role ?? "none"}; select-proposed can only choose a proposed candidate.`;
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The proposed candidates a `select-proposed` decision saw and did not choose,
|
|
305
|
+
* in item order. Empty for every other decision.
|
|
306
|
+
*/
|
|
307
|
+
export function unchosenProposedCandidates(item, decision, selectedCandidateId) {
|
|
308
|
+
return decision === "select-proposed"
|
|
309
|
+
? item.spec.candidates.filter((entry) => entry.role === "proposed" && entry.id !== selectedCandidateId)
|
|
310
|
+
: [];
|
|
311
|
+
}
|
|
192
312
|
/** Whether a decision on this item selects no candidate at all (see {@link candidateForDecision}). */
|
|
193
313
|
export function decisionSelectsNoCandidate(item, decision) {
|
|
194
314
|
const role = workbenchDecisionDefinitions[decision].candidateRole;
|
|
@@ -198,8 +318,8 @@ export function decisionSelectsNoCandidate(item, decision) {
|
|
|
198
318
|
* The candidate id a decision records: the selected candidate's, or
|
|
199
319
|
* `undefined` when the decision selects no candidate.
|
|
200
320
|
*/
|
|
201
|
-
export function decisionCandidateId(item, decision) {
|
|
202
|
-
const candidate = candidateForDecision(item, decision);
|
|
321
|
+
export function decisionCandidateId(item, decision, selectedCandidateId) {
|
|
322
|
+
const candidate = candidateForDecision(item, decision, selectedCandidateId);
|
|
203
323
|
return decisionSelectsNoCandidate(item, decision) ? undefined : candidate.id;
|
|
204
324
|
}
|
|
205
325
|
/**
|
|
@@ -210,8 +330,8 @@ export function decisionCandidateId(item, decision) {
|
|
|
210
330
|
* this rule; this helper exists for callers deriving the value from raw session
|
|
211
331
|
* state directly.
|
|
212
332
|
*/
|
|
213
|
-
export function effectiveValueForDecision(item, decision, editedValue) {
|
|
214
|
-
const candidate = candidateForDecision(item, decision);
|
|
333
|
+
export function effectiveValueForDecision(item, decision, editedValue, selectedCandidateId) {
|
|
334
|
+
const candidate = candidateForDecision(item, decision, selectedCandidateId);
|
|
215
335
|
return decision === "accept-proposed" && editedValue !== undefined ? editedValue : candidate.value;
|
|
216
336
|
}
|
|
217
337
|
export function selectedCandidateRole(state) {
|
|
@@ -287,7 +407,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
|
|
|
287
407
|
if (decision === "could-not-confirm" && !note?.trim()) {
|
|
288
408
|
throw new Error(`ReviewItem ${item.metadata.name} could not confirm requires a non-empty reason.`);
|
|
289
409
|
}
|
|
290
|
-
const candidateId = decisionCandidateId(item, decision);
|
|
410
|
+
const candidateId = decisionCandidateId(item, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name]);
|
|
291
411
|
const definition = workbenchDecisionDefinitions[decision];
|
|
292
412
|
const reviewDecisionName = `${item.metadata.name}-${decision}`;
|
|
293
413
|
// Carry the reviewer's inline edit in the event itself (accept-proposed
|
|
@@ -307,9 +427,13 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
|
|
|
307
427
|
actor: session.actorId,
|
|
308
428
|
reviewedAt: session.reviewedAt,
|
|
309
429
|
});
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
430
|
+
const conditions = sessionConditionsStamp(session);
|
|
431
|
+
const data = {
|
|
432
|
+
...(editedValue !== undefined
|
|
433
|
+
? { workbenchDecision: decision, workbenchEditedValue: editedValue }
|
|
434
|
+
: { workbenchDecision: decision, ...(attemptEvidenceIds?.length ? { attemptEvidenceIds } : {}) }),
|
|
435
|
+
...(conditions ? { sessionConditions: conditions } : {}),
|
|
436
|
+
};
|
|
313
437
|
events.push(buildReviewSessionEvent(session, {
|
|
314
438
|
sessionName,
|
|
315
439
|
sequence: events.length + 1,
|
|
@@ -383,11 +507,13 @@ export function replayReviewSessionEvents(startState, events) {
|
|
|
383
507
|
const { [itemName]: _removedDecision, ...remainingDecisions } = session.decisionsByItemName;
|
|
384
508
|
const { [itemName]: _removedEdit, ...remainingEdits } = session.editedValuesByItemName ?? {};
|
|
385
509
|
const { [itemName]: _removedAttempts, ...remainingAttempts } = session.attemptEvidenceIdsByItemName ?? {};
|
|
510
|
+
const { [itemName]: _removedSelection, ...remainingSelections } = session.selectedCandidateIdsByItemName ?? {};
|
|
386
511
|
return {
|
|
387
512
|
...session,
|
|
388
513
|
decisionsByItemName: remainingDecisions,
|
|
389
514
|
editedValuesByItemName: remainingEdits,
|
|
390
515
|
attemptEvidenceIdsByItemName: remainingAttempts,
|
|
516
|
+
...(session.selectedCandidateIdsByItemName ? { selectedCandidateIdsByItemName: remainingSelections } : {}),
|
|
391
517
|
};
|
|
392
518
|
}
|
|
393
519
|
const decision = workbenchDecisionFromEvent(event);
|
|
@@ -419,6 +545,15 @@ export function replayReviewSessionEvents(startState, events) {
|
|
|
419
545
|
else {
|
|
420
546
|
delete attemptEvidenceIdsByItemName[itemName];
|
|
421
547
|
}
|
|
548
|
+
// A choice between conflicting values is the event's candidateId: that
|
|
549
|
+
// id is the authority for select-proposed (validated replay checks it).
|
|
550
|
+
const selectedCandidateIdsByItemName = { ...session.selectedCandidateIdsByItemName };
|
|
551
|
+
if (decision === "select-proposed" && event.spec.candidateId) {
|
|
552
|
+
selectedCandidateIdsByItemName[itemName] = event.spec.candidateId;
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
delete selectedCandidateIdsByItemName[itemName];
|
|
556
|
+
}
|
|
422
557
|
return {
|
|
423
558
|
...session,
|
|
424
559
|
decisionsByItemName: {
|
|
@@ -427,6 +562,7 @@ export function replayReviewSessionEvents(startState, events) {
|
|
|
427
562
|
},
|
|
428
563
|
editedValuesByItemName,
|
|
429
564
|
attemptEvidenceIdsByItemName,
|
|
565
|
+
...(session.selectedCandidateIdsByItemName || decision === "select-proposed" ? { selectedCandidateIdsByItemName } : {}),
|
|
430
566
|
};
|
|
431
567
|
}
|
|
432
568
|
return session;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReviewSessionEvent } from "../review-resource.js";
|
|
2
2
|
import { type ReviewQueueSessionState } from "./review-queue-session.js";
|
|
3
|
-
export type ReviewSessionReplayIssueCode = "invalid-sequence" | "duplicate-sequence" | "non-contiguous-sequence" | "unknown-active-item" | "unknown-review-item" | "unknown-candidate" | "missing-review-item" | "invalid-workbench-decision" | "decision-candidate-mismatch" | "decision-status-mismatch" | "decision-resolution-mismatch" | "missing-resolution-reason" | "edited-value-not-editable" | "edited-value-type-mismatch";
|
|
3
|
+
export type ReviewSessionReplayIssueCode = "invalid-sequence" | "duplicate-sequence" | "non-contiguous-sequence" | "unknown-active-item" | "unknown-review-item" | "unknown-candidate" | "missing-review-item" | "invalid-workbench-decision" | "decision-candidate-mismatch" | "invalid-conflict-selection" | "decision-status-mismatch" | "decision-resolution-mismatch" | "missing-resolution-reason" | "edited-value-not-editable" | "edited-value-type-mismatch" | "session-conditions-mismatch";
|
|
4
4
|
export interface ReviewSessionReplayIssue {
|
|
5
5
|
readonly code: ReviewSessionReplayIssueCode;
|
|
6
6
|
readonly eventName: string;
|
|
@@ -37,6 +37,16 @@ export type ReviewSessionReplayWarning = {
|
|
|
37
37
|
readonly refusedCode: "edited-value-not-editable" | "edited-value-type-mismatch";
|
|
38
38
|
readonly editedValue: unknown;
|
|
39
39
|
readonly message: string;
|
|
40
|
+
} | {
|
|
41
|
+
/**
|
|
42
|
+
* The session's items came from an extraction import, but the apply
|
|
43
|
+
* boundary was not given the import record to check them against (see
|
|
44
|
+
* `DeriveServerReviewSessionApplyResultOptions.extractionImport`). Emitted
|
|
45
|
+
* by the server apply boundary, never by replay itself.
|
|
46
|
+
*/
|
|
47
|
+
readonly code: "unverified-extraction-queue";
|
|
48
|
+
readonly itemNames: readonly string[];
|
|
49
|
+
readonly message: string;
|
|
40
50
|
};
|
|
41
51
|
/**
|
|
42
52
|
* Lists the legacy text edits replay converts to typed values, and refused
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { candidateForDecision, decisionSelectsNoCandidate, isClearedWorkbenchDecisionEvent, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
1
|
+
import { candidateForDecision, conflictSelectionIssue, decisionSelectsNoCandidate, isClearedWorkbenchDecisionEvent, sessionConditionsStamp, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
2
2
|
import { checkEditedValueForItem } from "./edited-value.js";
|
|
3
|
+
import { canonicalJson } from "./canonical.js";
|
|
3
4
|
export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
4
5
|
const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
|
|
5
6
|
const sequenceIssues = validateEventSequence(events);
|
|
@@ -44,6 +45,19 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
|
44
45
|
}
|
|
45
46
|
else if (event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted") {
|
|
46
47
|
const decision = replayableWorkbenchDecision(event.spec.data?.workbenchDecision);
|
|
48
|
+
// A decision recorded under other presentation or sampling than the
|
|
49
|
+
// snapshot's must not be replayed as if made under the snapshot's.
|
|
50
|
+
const expectedConditions = sessionConditionsStamp(snapshot);
|
|
51
|
+
const carried = event.spec.data?.sessionConditions;
|
|
52
|
+
if (decision && canonicalJson(carried ?? null) !== canonicalJson(expectedConditions ?? null)) {
|
|
53
|
+
issues.push({
|
|
54
|
+
...eventRef,
|
|
55
|
+
code: "session-conditions-mismatch",
|
|
56
|
+
reviewItemName,
|
|
57
|
+
candidateId: event.spec.candidateId,
|
|
58
|
+
message: `ReviewSessionEvent ${event.metadata.name} was recorded under session conditions ${JSON.stringify(carried ?? "none")}, but the snapshot's are ${JSON.stringify(expectedConditions ?? "none")}.`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
47
61
|
if (!decision) {
|
|
48
62
|
issues.push({
|
|
49
63
|
...eventRef,
|
|
@@ -53,6 +67,40 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
|
53
67
|
message: `ReviewSessionEvent ${event.metadata.name} is a decision event but does not include a replayable workbench decision.`,
|
|
54
68
|
});
|
|
55
69
|
}
|
|
70
|
+
else if (itemName && itemsByName.has(itemName) && decision === "select-proposed") {
|
|
71
|
+
// The event's candidateId is the choice; it must name one proposed
|
|
72
|
+
// candidate of an item that holds a conflict.
|
|
73
|
+
const item = itemsByName.get(itemName);
|
|
74
|
+
const selectionIssue = conflictSelectionIssue(item, event.spec.candidateId);
|
|
75
|
+
if (selectionIssue) {
|
|
76
|
+
issues.push({
|
|
77
|
+
...eventRef,
|
|
78
|
+
code: "invalid-conflict-selection",
|
|
79
|
+
reviewItemName: itemName,
|
|
80
|
+
candidateId: event.spec.candidateId,
|
|
81
|
+
message: `ReviewSessionEvent ${event.metadata.name}: ${selectionIssue}`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const expectedStatus = workbenchDecisionDefinitions[decision].status;
|
|
85
|
+
if (event.spec.status !== expectedStatus) {
|
|
86
|
+
issues.push({
|
|
87
|
+
...eventRef,
|
|
88
|
+
code: "decision-status-mismatch",
|
|
89
|
+
reviewItemName: itemName,
|
|
90
|
+
candidateId: event.spec.candidateId,
|
|
91
|
+
message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects status ${expectedStatus}, but references ${event.spec.status ?? "no status"}.`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (event.spec.resolution !== undefined) {
|
|
95
|
+
issues.push({
|
|
96
|
+
...eventRef,
|
|
97
|
+
code: "decision-resolution-mismatch",
|
|
98
|
+
reviewItemName: itemName,
|
|
99
|
+
candidateId: event.spec.candidateId,
|
|
100
|
+
message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects resolution none, but references ${event.spec.resolution}.`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
56
104
|
else if (itemName && itemsByName.has(itemName)) {
|
|
57
105
|
const item = itemsByName.get(itemName);
|
|
58
106
|
const expectedCandidate = item ? candidateForDecision(item, decision) : undefined;
|
|
@@ -617,6 +617,30 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
617
617
|
.survey-workbench-embed .chip.rejected{ color: var(--k-negative); background: var(--k-negative-wash); }
|
|
618
618
|
.survey-workbench-embed .chip.could-not-confirm{ color: var(--k-caution); background: var(--k-caution-wash); }
|
|
619
619
|
|
|
620
|
+
/* Field-state panel: one row per field, content and lifecycle derived from
|
|
621
|
+
the records, never stored labels. A field that was not read is listed. */
|
|
622
|
+
.survey-workbench-embed .field-states{ padding: var(--k-space-3, 12px) 1rem; border-bottom: 1px solid var(--k-line); background: var(--k-panel); }
|
|
623
|
+
.survey-workbench-embed .field-states h2{ margin: 0 0 8px; font-size: 13px; font-weight: 700; color: var(--k-text); }
|
|
624
|
+
.survey-workbench-embed .field-states ul{ list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
|
625
|
+
.survey-workbench-embed .field-states-lead{ margin: 0 0 8px; font-size: 13px; line-height: 1.45; color: var(--k-caution); }
|
|
626
|
+
.survey-workbench-embed .field-state-row{ display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; align-items: center; padding: 6px 0; border-top: 1px solid var(--k-line); }
|
|
627
|
+
.survey-workbench-embed .field-state-row:first-child{ border-top: 0; }
|
|
628
|
+
.survey-workbench-embed .field-state-name{ font-family: var(--k-font-mono, ui-monospace, monospace); font-size: 12px; color: var(--k-text); overflow-wrap: anywhere; }
|
|
629
|
+
.survey-workbench-embed .field-state-chips{ display: inline-flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
|
|
630
|
+
.survey-workbench-embed .field-state-note{ grid-column: 1 / -1; font-size: 12px; color: var(--k-muted); }
|
|
631
|
+
.survey-workbench-embed .field-state-note.signal{ color: var(--k-caution); }
|
|
632
|
+
.survey-workbench-embed .chip.state-content.value{ color: var(--k-positive); background: var(--k-positive-wash); }
|
|
633
|
+
.survey-workbench-embed .chip.state-content.conflicting,
|
|
634
|
+
.survey-workbench-embed .chip.state-content.excluded,
|
|
635
|
+
.survey-workbench-embed .chip.state-content.not_covered{ color: var(--k-caution); background: var(--k-caution-wash); }
|
|
636
|
+
.survey-workbench-embed .chip.state-content.unsupported{ color: var(--k-negative); background: var(--k-negative-wash); }
|
|
637
|
+
.survey-workbench-embed .chip.state-content.none,
|
|
638
|
+
.survey-workbench-embed .chip.state-lifecycle.superseded{ color: var(--k-muted); background: var(--k-sunken); }
|
|
639
|
+
.survey-workbench-embed .chip.state-lifecycle.pending{ color: var(--k-brand); background: var(--k-brand-wash); }
|
|
640
|
+
.survey-workbench-embed .chip.state-lifecycle.accepted{ color: var(--k-positive); background: var(--k-positive-wash); }
|
|
641
|
+
.survey-workbench-embed .chip.state-lifecycle.rejected{ color: var(--k-negative); background: var(--k-negative-wash); }
|
|
642
|
+
.survey-workbench-embed .chip.state-lifecycle.could_not_confirm{ color: var(--k-caution); background: var(--k-caution-wash); }
|
|
643
|
+
|
|
620
644
|
.survey-workbench-embed .frow1 .push{
|
|
621
645
|
margin-left: auto;
|
|
622
646
|
}
|
|
@@ -835,6 +859,106 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
835
859
|
font-size: 12px;
|
|
836
860
|
}
|
|
837
861
|
|
|
862
|
+
/* A queue that came from an extraction import but was not checked against
|
|
863
|
+
it (unverified), or that does not match it (diverges, shown instead of the
|
|
864
|
+
queue). */
|
|
865
|
+
.survey-workbench-embed .queue-attestation{
|
|
866
|
+
display: flex;
|
|
867
|
+
align-items: flex-start;
|
|
868
|
+
gap: var(--k-space-2);
|
|
869
|
+
margin: 0 0 var(--k-space-3);
|
|
870
|
+
padding: var(--k-space-2) var(--k-space-3);
|
|
871
|
+
border-radius: 9px;
|
|
872
|
+
font-size: 13px;
|
|
873
|
+
line-height: 1.45;
|
|
874
|
+
color: var(--k-caution);
|
|
875
|
+
background: var(--k-caution-wash);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
.survey-workbench-embed .queue-attestation svg{
|
|
879
|
+
flex: none;
|
|
880
|
+
margin-top: 2px;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
.survey-workbench-embed .queue-attestation.diverges{
|
|
884
|
+
color: var(--k-negative);
|
|
885
|
+
background: var(--k-negative-wash);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/* A score-blind session: an informational note, not a warning. */
|
|
889
|
+
.survey-workbench-embed .queue-attestation.score-blind{
|
|
890
|
+
color: var(--k-active);
|
|
891
|
+
background: var(--k-active-soft);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/* Conflicting proposed values: each value is listed with its own choice
|
|
895
|
+
control until the field is decided, then marked chosen or not chosen. */
|
|
896
|
+
.survey-workbench-embed .conflict-values ul{
|
|
897
|
+
list-style: none;
|
|
898
|
+
margin: var(--k-space-2) 0 0;
|
|
899
|
+
padding: 0;
|
|
900
|
+
display: grid;
|
|
901
|
+
gap: var(--k-space-2);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
.survey-workbench-embed .conflict-values li{
|
|
905
|
+
display: flex;
|
|
906
|
+
align-items: center;
|
|
907
|
+
justify-content: space-between;
|
|
908
|
+
gap: var(--k-space-3);
|
|
909
|
+
flex-wrap: wrap;
|
|
910
|
+
padding: var(--k-space-2) var(--k-space-3);
|
|
911
|
+
border: 1px solid var(--k-line);
|
|
912
|
+
border-radius: 9px;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
.survey-workbench-embed .conflict-values li[data-chosen="true"]{
|
|
916
|
+
border-color: var(--k-positive);
|
|
917
|
+
background: var(--k-positive-wash);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
.survey-workbench-embed .conflict-values .conflict-text{
|
|
921
|
+
display: inline-flex;
|
|
922
|
+
align-items: baseline;
|
|
923
|
+
gap: var(--k-space-2);
|
|
924
|
+
flex-wrap: wrap;
|
|
925
|
+
min-width: 0;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
.survey-workbench-embed .conflict-values .vtext{
|
|
929
|
+
font-weight: 600;
|
|
930
|
+
overflow-wrap: anywhere;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
.survey-workbench-embed .conflict-values q{
|
|
934
|
+
color: var(--k-muted);
|
|
935
|
+
font-size: 12.5px;
|
|
936
|
+
overflow-wrap: anywhere;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
.survey-workbench-embed .conflict-values .btn.choose{
|
|
940
|
+
min-height: 36px;
|
|
941
|
+
padding: 0 14px;
|
|
942
|
+
font-size: 13px;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
.survey-workbench-embed .choice-tag{
|
|
946
|
+
font-size: 12px;
|
|
947
|
+
font-weight: 600;
|
|
948
|
+
padding: 2px 8px;
|
|
949
|
+
border-radius: 999px;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
.survey-workbench-embed .choice-tag.chosen{
|
|
953
|
+
color: var(--k-positive);
|
|
954
|
+
background: var(--k-positive-wash);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
.survey-workbench-embed .choice-tag.not-chosen{
|
|
958
|
+
color: var(--k-muted);
|
|
959
|
+
background: var(--k-sunken);
|
|
960
|
+
}
|
|
961
|
+
|
|
838
962
|
.survey-workbench-embed .excerpt-verification,
|
|
839
963
|
.survey-workbench-embed .support-verification{
|
|
840
964
|
margin: 0;
|
|
@@ -1327,6 +1451,8 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
1327
1451
|
/* The diff-collapse must also hold in host contexts without a query container
|
|
1328
1452
|
(e.g. an embed that doesn't establish \`container-type\`): fall back to viewport. */
|
|
1329
1453
|
@media (max-width: 620px) {
|
|
1454
|
+
.survey-workbench-embed .field-state-row{ grid-template-columns: minmax(0, 1fr); }
|
|
1455
|
+
.survey-workbench-embed .field-state-chips{ justify-content: flex-start; }
|
|
1330
1456
|
.survey-workbench-embed .diff{
|
|
1331
1457
|
grid-template-columns: 1fr;
|
|
1332
1458
|
}
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { type ReviewQueueSessionState, type ReviewWorkbenchState } from "./review-workbench.js";
|
|
36
36
|
import type { ReviewPresentationAdapter } from "./review-presentation.js";
|
|
37
|
+
import type { ExtractionEnvelopeImport, ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
|
|
37
38
|
import { type ExtractionInspectorInput } from "./extraction-inspector.js";
|
|
38
39
|
export declare class SurveyReviewWorkbenchElement extends HTMLElement {
|
|
39
40
|
#private;
|
|
@@ -45,6 +46,15 @@ export declare class SurveyReviewWorkbenchElement extends HTMLElement {
|
|
|
45
46
|
/** Optional presentation adapter for custom labels, value summaries, and links. */
|
|
46
47
|
get presentationAdapter(): ReviewPresentationAdapter | undefined;
|
|
47
48
|
set presentationAdapter(value: ReviewPresentationAdapter | undefined);
|
|
49
|
+
/**
|
|
50
|
+
* The extraction import record the queue was built from, as stored beside
|
|
51
|
+
* it. The workbench checks the queue against it (see
|
|
52
|
+
* `MountReviewWorkbenchOptions.extractionImport`). When unset, the import of
|
|
53
|
+
* a single-import `extractionInspector` is used; with neither, a queue whose
|
|
54
|
+
* items came from an extraction import shows an "Unverified queue" notice.
|
|
55
|
+
*/
|
|
56
|
+
get extractionImport(): ExtractionEnvelopeImport | ExtractionEnvelopeImportResult | null;
|
|
57
|
+
set extractionImport(value: ExtractionEnvelopeImport | ExtractionEnvelopeImportResult | null | undefined);
|
|
48
58
|
/** Optional read-only source pane attached to this workbench's existing review lifecycle. */
|
|
49
59
|
get extractionInspector(): ExtractionInspectorInput | null;
|
|
50
60
|
set extractionInspector(value: ExtractionInspectorInput | null | undefined);
|
|
@@ -169,6 +169,7 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
|
|
|
169
169
|
#session = null;
|
|
170
170
|
#presentationAdapter = undefined;
|
|
171
171
|
#extractionInspector = null;
|
|
172
|
+
#extractionImport = null;
|
|
172
173
|
#unmountInspector;
|
|
173
174
|
#root;
|
|
174
175
|
#mountRoot;
|
|
@@ -340,6 +341,18 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
|
|
|
340
341
|
this.#presentationAdapter = value;
|
|
341
342
|
this.#remount();
|
|
342
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* The extraction import record the queue was built from, as stored beside
|
|
346
|
+
* it. The workbench checks the queue against it (see
|
|
347
|
+
* `MountReviewWorkbenchOptions.extractionImport`). When unset, the import of
|
|
348
|
+
* a single-import `extractionInspector` is used; with neither, a queue whose
|
|
349
|
+
* items came from an extraction import shows an "Unverified queue" notice.
|
|
350
|
+
*/
|
|
351
|
+
get extractionImport() { return this.#extractionImport; }
|
|
352
|
+
set extractionImport(value) {
|
|
353
|
+
this.#extractionImport = value ?? null;
|
|
354
|
+
this.#remount();
|
|
355
|
+
}
|
|
343
356
|
/** Optional read-only source pane attached to this workbench's existing review lifecycle. */
|
|
344
357
|
get extractionInspector() { return this.#extractionInspector; }
|
|
345
358
|
set extractionInspector(value) {
|
|
@@ -423,9 +436,14 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
|
|
|
423
436
|
this.#renderEmpty();
|
|
424
437
|
return;
|
|
425
438
|
}
|
|
426
|
-
const
|
|
427
|
-
?
|
|
428
|
-
:
|
|
439
|
+
const inspectorImport = this.#extractionInspector && !("imports" in this.#extractionInspector)
|
|
440
|
+
? this.#extractionInspector.importResult
|
|
441
|
+
: undefined;
|
|
442
|
+
const extractionImport = this.#extractionImport ?? inspectorImport;
|
|
443
|
+
const options = {
|
|
444
|
+
...(this.#presentationAdapter ? { presentationAdapter: this.#presentationAdapter } : {}),
|
|
445
|
+
...(extractionImport ? { extractionImport } : {}),
|
|
446
|
+
};
|
|
429
447
|
this.#applyThemeClasses();
|
|
430
448
|
mountReviewWorkbench(this.#mountRoot, this.#session, options);
|
|
431
449
|
this.#unmountInspector?.();
|