@kontourai/survey 3.0.0 → 5.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/README.md +4 -0
- package/dist/examples/calibrated-auto-accept.d.ts +22 -15
- package/dist/examples/calibrated-auto-accept.js +40 -36
- package/dist/examples/review-workbench/server-apply-consumer.js +5 -0
- package/dist/src/calibration.d.ts +48 -21
- package/dist/src/calibration.js +72 -33
- package/dist/src/canonical-reviewed-trust-input.js +73 -34
- package/dist/src/console/review-console-server.d.ts +3 -1
- package/dist/src/console/review-console-server.js +203 -50
- package/dist/src/extraction-envelope.d.ts +81 -3
- package/dist/src/extraction-envelope.js +183 -24
- package/dist/src/index.d.ts +9 -8
- package/dist/src/index.js +3 -3
- package/dist/src/inquiry-mapping.d.ts +15 -1
- package/dist/src/inquiry-mapping.js +10 -2
- package/dist/src/mcp/review-mcp.js +112 -90
- package/dist/src/producer-profile.d.ts +41 -2
- package/dist/src/producer-profile.js +29 -2
- package/dist/src/review-session-file.d.ts +64 -0
- package/dist/src/review-session-file.js +320 -0
- package/dist/src/review-workbench/edited-value.d.ts +70 -0
- package/dist/src/review-workbench/edited-value.js +147 -0
- package/dist/src/review-workbench/extraction-inspector.d.ts +15 -1
- package/dist/src/review-workbench/extraction-inspector.js +55 -19
- package/dist/src/review-workbench/queue-binding.js +1 -1
- package/dist/src/review-workbench/review-presentation.d.ts +46 -1
- package/dist/src/review-workbench/review-presentation.js +72 -1
- package/dist/src/review-workbench/review-queue-session.d.ts +19 -5
- package/dist/src/review-workbench/review-queue-session.js +54 -14
- package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
- package/dist/src/review-workbench/review-session-replay.js +82 -3
- package/dist/src/review-workbench/review-workbench-css.generated.js +2 -0
- package/dist/src/review-workbench/review-workbench.css +2 -0
- package/dist/src/review-workbench/review-workbench.d.ts +22 -11
- package/dist/src/review-workbench/review-workbench.js +91 -26
- package/dist/src/review-workbench/review-workbench.standalone.css +2 -0
- package/dist/src/review-workbench/server-review-session.d.ts +3 -1
- package/dist/src/review-workbench/server-review-session.js +1 -0
- package/dist/src/reviewed-candidate-resolution.js +13 -7
- package/dist/src/schema-mapping.d.ts +23 -0
- package/dist/src/schema-mapping.js +30 -20
- package/dist/src/surface-reviewed-extraction.js +4 -0
- package/dist/src/to-surface.d.ts +30 -6
- package/dist/src/to-surface.js +312 -18
- package/dist/src/types.d.ts +44 -1
- package/package.json +5 -4
|
@@ -26,14 +26,19 @@ export function buildExtractionInspectorModel(input) {
|
|
|
26
26
|
if (sourceKeys.has(sourceKey))
|
|
27
27
|
throw new Error("Extraction inspector source identity collision.");
|
|
28
28
|
sourceKeys.add(sourceKey);
|
|
29
|
-
const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.
|
|
29
|
+
const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.diagnostics, entry.artifact, envelope.result.ocrDerived);
|
|
30
30
|
sources.push(source);
|
|
31
31
|
const candidateStart = candidates.length;
|
|
32
|
+
const itemByProposalIndex = new Map();
|
|
33
|
+
for (const item of entry.importResult.reviewItems) {
|
|
34
|
+
for (const proposalIndex of envelopeItemProposalIndices(item))
|
|
35
|
+
itemByProposalIndex.set(proposalIndex, item.metadata.name);
|
|
36
|
+
}
|
|
32
37
|
envelope.result.proposals.forEach((proposal, proposalIndex) => {
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
38
|
+
const itemName = itemByProposalIndex.get(proposalIndex);
|
|
39
|
+
if (!itemName)
|
|
35
40
|
return; // unresolved imports legitimately produce no ReviewItems
|
|
36
|
-
candidates.push(candidateModel(source,
|
|
41
|
+
candidates.push(candidateModel(source, itemName, proposal, proposalIndex, envelope.result.provider, envelope.result.model, envelope.result.runId, entry.pass, envelope.result.pdfPageOffsets, envelope.result.pdfLayout, envelope.result.ocrDerived));
|
|
37
42
|
});
|
|
38
43
|
if (source.alignment === "excerpt-mismatch") {
|
|
39
44
|
delete source.artifactText;
|
|
@@ -141,28 +146,53 @@ function assertImportedResult(result, record) {
|
|
|
141
146
|
if (!record.metadata?.name || !record.metadata.producerNamespace || !record.spec?.envelope?.result || !Array.isArray(record.spec.envelope.result.proposals))
|
|
142
147
|
throw new Error("Malformed extraction import result.");
|
|
143
148
|
const grounded = record.status?.state === "grounded";
|
|
144
|
-
|
|
149
|
+
const proposals = record.spec.envelope.result.proposals;
|
|
150
|
+
const covered = reviewItems.flatMap(envelopeItemProposalIndices).sort((left, right) => left - right);
|
|
151
|
+
if ((!grounded && reviewItems.length !== 0) || (grounded && canonicalJson(covered) !== canonicalJson(proposals.map((_proposal, index) => index))))
|
|
145
152
|
throw new Error("Extraction import ReviewItems do not match its grounding state.");
|
|
146
153
|
const canonicalItems = buildReviewItemsFromExtractionEnvelopeImport(record);
|
|
147
154
|
if (canonicalJson(reviewItems) !== canonicalJson(canonicalItems))
|
|
148
155
|
throw new Error("Extraction import ReviewItems do not match their canonical identities and bindings.");
|
|
149
|
-
reviewItems.forEach((item,
|
|
150
|
-
const proposal = record.spec.envelope.result.proposals[index];
|
|
156
|
+
reviewItems.forEach((item, itemIndex) => {
|
|
151
157
|
const metadata = item.metadata?.producer?.["survey.kontourai.io/extraction-envelope"];
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
if (item.kind !== "ReviewItem" || !item.metadata.name || item.spec.candidates.length === 0 || metadata?.importName !== record.metadata.name) {
|
|
159
|
+
throw new Error(`Extraction import ReviewItem ${itemIndex} is inconsistent with its validated proposals.`);
|
|
160
|
+
}
|
|
161
|
+
for (const candidate of item.spec.candidates) {
|
|
162
|
+
const binding = candidate.producer?.["survey.kontourai.io/extraction-envelope"];
|
|
163
|
+
const proposal = typeof binding?.proposalIndex === "number" ? proposals[binding.proposalIndex] : undefined;
|
|
164
|
+
if (!proposal || binding?.importName !== record.metadata.name || binding.runId !== record.spec.envelope.result.runId || binding.provider !== record.spec.envelope.result.provider
|
|
165
|
+
|| candidate.extraction.target !== proposal.fieldPath || candidate.locator?.locator !== proposal.provenance.locator || candidate.locator.excerpt !== proposal.provenance.excerpt) {
|
|
166
|
+
throw new Error(`Extraction import ReviewItem ${itemIndex} is inconsistent with its validated proposals.`);
|
|
167
|
+
}
|
|
159
168
|
}
|
|
160
169
|
});
|
|
161
170
|
}
|
|
162
|
-
|
|
171
|
+
/** The proposal indices one imported ReviewItem stands for, as its producer metadata records them. */
|
|
172
|
+
function envelopeItemProposalIndices(item) {
|
|
173
|
+
const metadata = item.metadata?.producer?.["survey.kontourai.io/extraction-envelope"];
|
|
174
|
+
const indices = metadata?.proposalIndices;
|
|
175
|
+
if (!Array.isArray(indices) || indices.length === 0 || !indices.every((index) => Number.isSafeInteger(index) && index >= 0)) {
|
|
176
|
+
throw new Error(`Extraction import ReviewItem ${item.metadata?.name ?? "(unnamed)"} does not record its proposals.`);
|
|
177
|
+
}
|
|
178
|
+
return indices;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The posture a source is shown with: the extraction's own failure or early
|
|
182
|
+
* stop when there is one, otherwise the artifact alignment. A failed
|
|
183
|
+
* extraction over an aligned artifact must not show the aligned posture.
|
|
184
|
+
*/
|
|
185
|
+
export function inspectorSourcePosture(source) {
|
|
186
|
+
if (source.extractionDiagnostic)
|
|
187
|
+
return source.extractionDiagnostic.kind;
|
|
188
|
+
return source.alignment;
|
|
189
|
+
}
|
|
190
|
+
function sourceModel(key, importName, prepared, diagnostics, artifact, ocrDerived) {
|
|
163
191
|
let alignment;
|
|
164
192
|
let message;
|
|
165
|
-
|
|
193
|
+
const artifactUnresolved = diagnostics.some((diagnostic) => diagnostic.kind === "artifact-unavailable" || diagnostic.kind === "digest-mismatch");
|
|
194
|
+
const extractionDiagnostic = diagnostics.find((diagnostic) => diagnostic.kind === "extraction-failed" || diagnostic.kind === "extraction-incomplete");
|
|
195
|
+
if (artifactUnresolved || artifact.status === "unavailable") {
|
|
166
196
|
alignment = "artifact-unavailable";
|
|
167
197
|
message = `Prepared artifact unavailable (${artifact.status === "unavailable" ? artifact.code : "invalid-artifact"}). Candidates are not grounded.`;
|
|
168
198
|
}
|
|
@@ -177,9 +207,15 @@ function sourceModel(key, importName, prepared, state, artifact, ocrDerived) {
|
|
|
177
207
|
}
|
|
178
208
|
else {
|
|
179
209
|
alignment = "aligned";
|
|
180
|
-
message =
|
|
210
|
+
message = extractionDiagnostic
|
|
211
|
+
? "Prepared artifact identity verified."
|
|
212
|
+
: `Prepared artifact identity verified. Exact source spans are available.${ocrDerived ? " Prepared text is OCR-derived." : ""}`;
|
|
181
213
|
}
|
|
182
|
-
|
|
214
|
+
// The extraction's own failure leads: an aligned artifact with no candidates
|
|
215
|
+
// must not read as a complete run that found nothing.
|
|
216
|
+
if (extractionDiagnostic)
|
|
217
|
+
message = `${extractionDiagnostic.message} ${message}`;
|
|
218
|
+
return { key, importName, ...(extractionDiagnostic ? { extractionDiagnostic } : {}), ...(prepared?.ref ? { artifactRef: prepared.ref } : {}), ...(prepared?.digest ? { expectedDigest: prepared.digest } : {}), ...("actualDigest" in artifact ? { actualDigest: artifact.actualDigest } : {}), ...(alignment === "aligned" && artifact.status === "available" ? { artifactText: artifact.text } : {}), ...(ocrDerived ? { ocrDerived: true } : {}), alignment, message };
|
|
183
219
|
}
|
|
184
220
|
function candidateModel(source, reviewItemName, proposal, index, provider, model, attempt, pass, pdfPageOffsets, pdfLayout, ocrDerived) {
|
|
185
221
|
const match = /^chars:(\d+)-(\d+)$/.exec(proposal.provenance.locator);
|
|
@@ -270,7 +306,7 @@ export function mountExtractionInspector(container, model, options = {}) {
|
|
|
270
306
|
next.hidden = pageCount === 1;
|
|
271
307
|
previous.disabled = page === 0;
|
|
272
308
|
next.disabled = page >= pageCount - 1;
|
|
273
|
-
postures.innerHTML = model.sources.map(s => `<div class="inspector-posture ${s.alignment}" role="status"><strong>${escapeHtml(s.importName)}: ${escapeHtml(
|
|
309
|
+
postures.innerHTML = model.sources.map(s => { const posture = inspectorSourcePosture(s); return `<div class="inspector-posture ${s.alignment}${posture !== s.alignment ? ` ${posture}` : ""}" role="status" data-posture="${escapeHtml(posture)}"><strong>${escapeHtml(s.importName)}: ${escapeHtml(posture)}</strong><span>${escapeHtml(s.message)}</span></div>`; }).join("");
|
|
274
310
|
sourcesRoot.innerHTML = model.sources.map(s => { const anchored = model.candidates.filter(c => c.sourceKey === s.key); const marked = visible.filter(c => c.sourceKey === s.key); return `<div class="inspector-source" aria-label="Prepared source for ${escapeHtml(s.importName)}"><h3>${escapeHtml(s.importName)}</h3><pre tabindex="0">${s.artifactText === undefined ? `${anchored.map(c => anchorHtml(c, highlightIdFor(c))).join("")}<span class="source-unavailable">${escapeHtml(s.message)}</span>` : renderSource(s.artifactText, anchored, marked, highlightIdFor)}</pre></div>`; }).join("");
|
|
275
311
|
};
|
|
276
312
|
root.querySelectorAll("select").forEach(select => select.addEventListener("change", event => { event.stopPropagation(); const key = select.dataset.filter; if (select.value)
|
|
@@ -229,7 +229,7 @@ export function validateReviewQueueAgainstExtractionImport(items, importResult)
|
|
|
229
229
|
if (record.status.state !== "grounded") {
|
|
230
230
|
return [{
|
|
231
231
|
code: "import-not-grounded",
|
|
232
|
-
message: `Extraction import ${record.metadata.name} is ${record.status.state}, not grounded; it cannot attest a review queue.`,
|
|
232
|
+
message: `Extraction import ${record.metadata.name} is ${record.status.state}, not grounded (${record.status.diagnostics.map((diagnostic) => diagnostic.message).join(" ")}); it cannot attest a review queue.`,
|
|
233
233
|
}];
|
|
234
234
|
}
|
|
235
235
|
if (items.length === 0) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ReviewCandidate, type ReviewItem } from "../review-resource.js";
|
|
2
|
+
import type { InterpretationAnswerImpact, InterpretationReadingKind } from "../types.js";
|
|
2
3
|
import { type ReviewWorkbenchResult } from "./review-workbench.js";
|
|
3
4
|
export interface ReviewPresentationAdapter {
|
|
4
5
|
readonly labelForTarget?: (target: string, context: ReviewItemPresentationContext) => string | undefined;
|
|
@@ -56,11 +57,55 @@ export interface ReviewResultPresentation {
|
|
|
56
57
|
readonly target: string;
|
|
57
58
|
readonly targetLabel: string;
|
|
58
59
|
readonly decisionLabel: string;
|
|
59
|
-
|
|
60
|
+
/** Absent when the decision selects no candidate (reject-all or could-not-confirm on a conflict). */
|
|
61
|
+
readonly selectedValueText?: string;
|
|
60
62
|
readonly applyMeaning: string;
|
|
61
63
|
readonly reviewItemLink?: ReviewPresentationLink;
|
|
62
64
|
readonly traceRefs: readonly ReviewTraceRef[];
|
|
63
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Structural input for {@link buildInterpretationReadingPresentation}: either
|
|
68
|
+
* a Survey `Interpretation` record (`id`) or the entry Survey projects onto a
|
|
69
|
+
* claim at `metadata.survey.interpretations[]` (`interpretationId`). Kind and
|
|
70
|
+
* impact arrive as plain strings when read back from projected metadata.
|
|
71
|
+
*/
|
|
72
|
+
export interface InterpretationReadingSource {
|
|
73
|
+
readonly id?: string;
|
|
74
|
+
readonly interpretationId?: string;
|
|
75
|
+
readonly readingKind?: string;
|
|
76
|
+
readonly answerImpact?: string;
|
|
77
|
+
readonly ruleLocator: string;
|
|
78
|
+
readonly reading: string;
|
|
79
|
+
readonly actor: string;
|
|
80
|
+
readonly recordedAt: string;
|
|
81
|
+
}
|
|
82
|
+
export interface InterpretationReadingPresentation {
|
|
83
|
+
readonly interpretationId: string;
|
|
84
|
+
readonly readingKind: InterpretationReadingKind;
|
|
85
|
+
readonly kindLabel: string;
|
|
86
|
+
readonly answerImpact?: InterpretationAnswerImpact;
|
|
87
|
+
readonly answerImpactLabel?: string;
|
|
88
|
+
readonly reading: string;
|
|
89
|
+
readonly actor: string;
|
|
90
|
+
readonly recordedAt: string;
|
|
91
|
+
readonly ruleLocator: string;
|
|
92
|
+
/**
|
|
93
|
+
* Always `"authored-judgment"`. This is DERIVED from the record type, not a
|
|
94
|
+
* stored flag: every Interpretation reading is a producer-authored reading
|
|
95
|
+
* by contract (CONTEXT.md "Interpretation Record"), never a machine-observed
|
|
96
|
+
* fact. Renderers must present readings under this marking, visually
|
|
97
|
+
* distinct from machine-observed values — the StatementBadge / ADR 0003 §4
|
|
98
|
+
* discipline (blending the two is the defect class of #247).
|
|
99
|
+
*/
|
|
100
|
+
readonly provenance: "authored-judgment";
|
|
101
|
+
readonly provenanceLabel: string;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Presents one interpretation reading as authored judgment. Fails closed on
|
|
105
|
+
* unknown reading-kind / answer-impact vocabulary rather than rendering an
|
|
106
|
+
* authored record under a label nothing derived.
|
|
107
|
+
*/
|
|
108
|
+
export declare function buildInterpretationReadingPresentation(source: InterpretationReadingSource): InterpretationReadingPresentation;
|
|
64
109
|
export declare function buildReviewItemPresentation(item: ReviewItem, adapter?: ReviewPresentationAdapter): ReviewItemPresentation;
|
|
65
110
|
export declare function buildReviewCandidatePresentation(item: ReviewItem, candidate: ReviewCandidate, adapter?: ReviewPresentationAdapter, targetLabel?: string): ReviewCandidatePresentation;
|
|
66
111
|
export declare function buildReviewResultPresentation(result: ReviewWorkbenchResult, item: ReviewItem | undefined, adapter?: ReviewPresentationAdapter): ReviewResultPresentation;
|
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
import { findSoleCandidateById } from "../review-resource.js";
|
|
2
2
|
import { formatValue } from "./review-surface-preview.js";
|
|
3
|
+
const INTERPRETATION_KIND_LABELS = {
|
|
4
|
+
"policy-standard": "Policy-standard reading",
|
|
5
|
+
gleaned: "Gleaned from results",
|
|
6
|
+
answerImpact: "Answer impact",
|
|
7
|
+
};
|
|
8
|
+
const ANSWER_IMPACT_LABELS = {
|
|
9
|
+
supported: "Supported the answer",
|
|
10
|
+
narrowed: "Narrowed the answer",
|
|
11
|
+
"accepted-risk": "Accepted as a risk",
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Presents one interpretation reading as authored judgment. Fails closed on
|
|
15
|
+
* unknown reading-kind / answer-impact vocabulary rather than rendering an
|
|
16
|
+
* authored record under a label nothing derived.
|
|
17
|
+
*/
|
|
18
|
+
export function buildInterpretationReadingPresentation(source) {
|
|
19
|
+
const interpretationId = source.interpretationId ?? source.id;
|
|
20
|
+
if (!interpretationId) {
|
|
21
|
+
throw new Error("Interpretation reading presentation requires an id or interpretationId.");
|
|
22
|
+
}
|
|
23
|
+
const readingKind = (source.readingKind ?? "policy-standard");
|
|
24
|
+
const kindLabel = INTERPRETATION_KIND_LABELS[readingKind];
|
|
25
|
+
if (!kindLabel) {
|
|
26
|
+
throw new Error(`Interpretation ${interpretationId} has unknown readingKind ${String(source.readingKind)}`);
|
|
27
|
+
}
|
|
28
|
+
const answerImpact = source.answerImpact;
|
|
29
|
+
const answerImpactLabel = answerImpact === undefined ? undefined : ANSWER_IMPACT_LABELS[answerImpact];
|
|
30
|
+
if (answerImpact !== undefined && !answerImpactLabel) {
|
|
31
|
+
throw new Error(`Interpretation ${interpretationId} has unknown answerImpact ${String(source.answerImpact)}`);
|
|
32
|
+
}
|
|
33
|
+
if (readingKind === "answerImpact" && answerImpact === undefined) {
|
|
34
|
+
throw new Error(`Interpretation ${interpretationId} readingKind answerImpact requires an answerImpact value`);
|
|
35
|
+
}
|
|
36
|
+
if (readingKind !== "answerImpact" && answerImpact !== undefined) {
|
|
37
|
+
throw new Error(`Interpretation ${interpretationId} sets answerImpact but readingKind is ${readingKind}`);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
interpretationId,
|
|
41
|
+
readingKind,
|
|
42
|
+
kindLabel,
|
|
43
|
+
...(answerImpact !== undefined ? { answerImpact, answerImpactLabel } : {}),
|
|
44
|
+
reading: source.reading,
|
|
45
|
+
actor: source.actor,
|
|
46
|
+
recordedAt: source.recordedAt,
|
|
47
|
+
ruleLocator: source.ruleLocator,
|
|
48
|
+
provenance: "authored-judgment",
|
|
49
|
+
provenanceLabel: "Authored judgment",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
3
52
|
export function buildReviewItemPresentation(item, adapter = {}) {
|
|
4
53
|
const context = { item };
|
|
5
54
|
const targetLabel = adapter.labelForTarget?.(item.spec.target, context) ?? humanizeIdentifier(item.spec.target);
|
|
@@ -36,6 +85,28 @@ export function buildReviewResultPresentation(result, item, adapter = {}) {
|
|
|
36
85
|
? adapter.labelForTarget?.(target, itemContext) ?? humanizeIdentifier(target)
|
|
37
86
|
: humanizeIdentifier(target);
|
|
38
87
|
const selectedCandidate = item ? selectedCandidateForResult(item, result) : undefined;
|
|
88
|
+
// A decision that selects no candidate presents no selected value and no
|
|
89
|
+
// selected trace; it names every candidate instead.
|
|
90
|
+
if (result.selectedCandidateId === undefined) {
|
|
91
|
+
const rejected = result.decision === "reject-proposed";
|
|
92
|
+
return {
|
|
93
|
+
result,
|
|
94
|
+
item,
|
|
95
|
+
target,
|
|
96
|
+
targetLabel,
|
|
97
|
+
decisionLabel: humanizeIdentifier(result.decision),
|
|
98
|
+
applyMeaning: rejected
|
|
99
|
+
? "Saved decision rejects every proposed value; none is applied"
|
|
100
|
+
: "Saved decision records that no proposed value could be confirmed; none is applied",
|
|
101
|
+
reviewItemLink: item && itemContext ? adapter.linkForReviewItem?.(item, itemContext) : undefined,
|
|
102
|
+
traceRefs: [
|
|
103
|
+
{ label: "Survey ReviewItem", value: result.reviewItemName, kind: "review-item", context: undefined },
|
|
104
|
+
...result.unselectedCandidates.map((candidate) => ({
|
|
105
|
+
label: rejected ? "Rejected candidate" : "Unconfirmed candidate", value: candidate.id, kind: "candidate", context: candidate,
|
|
106
|
+
})),
|
|
107
|
+
].flatMap(({ context, ...ref }) => (item ? withTraceLinks([ref], { item, candidate: context }, adapter) : [ref])),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
39
110
|
return {
|
|
40
111
|
result,
|
|
41
112
|
item,
|
|
@@ -119,7 +190,7 @@ function traceRefsForCandidate(item, candidate, adapter) {
|
|
|
119
190
|
function traceRefsForResult(item, result, selectedCandidate, adapter) {
|
|
120
191
|
return withTraceLinks([
|
|
121
192
|
{ label: "Survey ReviewItem", value: result.reviewItemName, kind: "review-item" },
|
|
122
|
-
{ label: "Selected candidate", value: result.selectedCandidateId, kind: "candidate" },
|
|
193
|
+
{ label: "Selected candidate", value: result.selectedCandidateId ?? "none", kind: "candidate" },
|
|
123
194
|
{
|
|
124
195
|
label: "Selected claim",
|
|
125
196
|
value: selectedCandidate?.claimTarget.claimId ?? "not provided",
|
|
@@ -94,15 +94,29 @@ export declare function keepActionDecision(item: ReviewItem, flaggedWrong: boole
|
|
|
94
94
|
/**
|
|
95
95
|
* The candidate a workbench decision applies to.
|
|
96
96
|
*
|
|
97
|
-
* Selection is by role,
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* leaves here. Guarding the render path alone let the workbench emit an
|
|
101
|
-
* undecidable decision through the export path and then present a different
|
|
97
|
+
* Selection is by role, so the role has to name exactly one candidate before a
|
|
98
|
+
* decision may trust it. Guarding the render path alone let the workbench emit
|
|
99
|
+
* an undecidable decision through the export path and then present a different
|
|
102
100
|
* candidate's value against it; this is the shared selector all of those go
|
|
103
101
|
* through, which is why the check belongs here rather than at each of them.
|
|
102
|
+
*
|
|
103
|
+
* A decision that would make one of several candidates in its role the trusted
|
|
104
|
+
* value is refused: picking the first would settle the conflict for the
|
|
105
|
+
* reviewer without showing it. A value-neutral decision (reject, could not
|
|
106
|
+
* confirm) on such an item selects none of them. It still returns the first
|
|
107
|
+
* candidate so in-process callers that need one candidate (rendering, the
|
|
108
|
+
* in-memory `ReviewWorkbenchResult`) have one, but that anchor is not recorded:
|
|
109
|
+
* {@link decisionCandidateId} is `undefined` for it, so the decision, its
|
|
110
|
+
* session events and the canonical projection name no candidate.
|
|
104
111
|
*/
|
|
105
112
|
export declare function candidateForDecision(item: ReviewItem, decision: ReviewWorkbenchDecision): ReviewCandidate;
|
|
113
|
+
/** Whether a decision on this item selects no candidate at all (see {@link candidateForDecision}). */
|
|
114
|
+
export declare function decisionSelectsNoCandidate(item: ReviewItem, decision: ReviewWorkbenchDecision): boolean;
|
|
115
|
+
/**
|
|
116
|
+
* The candidate id a decision records: the selected candidate's, or
|
|
117
|
+
* `undefined` when the decision selects no candidate.
|
|
118
|
+
*/
|
|
119
|
+
export declare function decisionCandidateId(item: ReviewItem, decision: ReviewWorkbenchDecision): string | undefined;
|
|
106
120
|
/**
|
|
107
121
|
* The value that should actually be applied for a decision: the reviewer's inline
|
|
108
122
|
* edit when one was made for an accept-proposed decision, otherwise the selected
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { publicDirectoryReviewItemExample, reviewWorkbenchQueueExamples } from "./review-workbench-data.js";
|
|
2
2
|
import { assertReviewResolutionConsistency } from "../producer-discipline.js";
|
|
3
|
+
import { checkEditedValueForItem } from "./edited-value.js";
|
|
3
4
|
import { assertSoleCandidateId, reviewResourceApiVersion, } from "../../src/review-resource.js";
|
|
4
5
|
export const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
|
|
5
6
|
export const defaultReviewSessionName = "review-workbench-session";
|
|
@@ -142,32 +143,65 @@ export function reviewSessionSummary(session) {
|
|
|
142
143
|
* would invent a prior value, with provenance, that the source never had.
|
|
143
144
|
*/
|
|
144
145
|
export function keepActionDecision(item, flaggedWrong) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
// Keeping the current value is recordable only when exactly one candidate is
|
|
147
|
+
// current (see candidateForDecision); rejecting the proposed values is
|
|
148
|
+
// recordable however many there are.
|
|
149
|
+
const count = (role) => item.spec.candidates.filter((candidate) => candidate.role === role).length;
|
|
150
|
+
if (flaggedWrong || count("current") === 0) {
|
|
151
|
+
return count("proposed") > 0 ? "reject-proposed" : undefined;
|
|
148
152
|
}
|
|
149
|
-
return "keep-current";
|
|
153
|
+
return count("current") === 1 ? "keep-current" : undefined;
|
|
150
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Decisions that make no candidate the trusted value: rejecting the proposed
|
|
157
|
+
* values and ending the round as could-not-confirm. On an item whose role holds
|
|
158
|
+
* several candidates (conflicting values for one claim) they select none of them.
|
|
159
|
+
*/
|
|
160
|
+
const VALUE_NEUTRAL_DECISIONS = new Set(["reject-proposed", "could-not-confirm"]);
|
|
151
161
|
/**
|
|
152
162
|
* The candidate a workbench decision applies to.
|
|
153
163
|
*
|
|
154
|
-
* Selection is by role,
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* leaves here. Guarding the render path alone let the workbench emit an
|
|
158
|
-
* undecidable decision through the export path and then present a different
|
|
164
|
+
* Selection is by role, so the role has to name exactly one candidate before a
|
|
165
|
+
* decision may trust it. Guarding the render path alone let the workbench emit
|
|
166
|
+
* an undecidable decision through the export path and then present a different
|
|
159
167
|
* candidate's value against it; this is the shared selector all of those go
|
|
160
168
|
* through, which is why the check belongs here rather than at each of them.
|
|
169
|
+
*
|
|
170
|
+
* A decision that would make one of several candidates in its role the trusted
|
|
171
|
+
* value is refused: picking the first would settle the conflict for the
|
|
172
|
+
* reviewer without showing it. A value-neutral decision (reject, could not
|
|
173
|
+
* confirm) on such an item selects none of them. It still returns the first
|
|
174
|
+
* candidate so in-process callers that need one candidate (rendering, the
|
|
175
|
+
* in-memory `ReviewWorkbenchResult`) have one, but that anchor is not recorded:
|
|
176
|
+
* {@link decisionCandidateId} is `undefined` for it, so the decision, its
|
|
177
|
+
* session events and the canonical projection name no candidate.
|
|
161
178
|
*/
|
|
162
179
|
export function candidateForDecision(item, decision) {
|
|
163
180
|
const definition = workbenchDecisionDefinitions[decision];
|
|
164
|
-
const
|
|
181
|
+
const matches = item.spec.candidates.filter((entry) => entry.role === definition.candidateRole);
|
|
182
|
+
const candidate = matches[0];
|
|
165
183
|
if (!candidate) {
|
|
166
184
|
throw new Error(`ReviewItem ${item.metadata.name} has no ${definition.candidateRole} candidate.`);
|
|
167
185
|
}
|
|
186
|
+
if (matches.length > 1 && !VALUE_NEUTRAL_DECISIONS.has(decision)) {
|
|
187
|
+
throw new Error(`ReviewItem ${item.metadata.name} has ${matches.length} ${definition.candidateRole} candidates; the ${decision} decision cannot choose between them.`);
|
|
188
|
+
}
|
|
168
189
|
assertSoleCandidateId(item, candidate.id);
|
|
169
190
|
return candidate;
|
|
170
191
|
}
|
|
192
|
+
/** Whether a decision on this item selects no candidate at all (see {@link candidateForDecision}). */
|
|
193
|
+
export function decisionSelectsNoCandidate(item, decision) {
|
|
194
|
+
const role = workbenchDecisionDefinitions[decision].candidateRole;
|
|
195
|
+
return VALUE_NEUTRAL_DECISIONS.has(decision) && item.spec.candidates.filter((entry) => entry.role === role).length > 1;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The candidate id a decision records: the selected candidate's, or
|
|
199
|
+
* `undefined` when the decision selects no candidate.
|
|
200
|
+
*/
|
|
201
|
+
export function decisionCandidateId(item, decision) {
|
|
202
|
+
const candidate = candidateForDecision(item, decision);
|
|
203
|
+
return decisionSelectsNoCandidate(item, decision) ? undefined : candidate.id;
|
|
204
|
+
}
|
|
171
205
|
/**
|
|
172
206
|
* The value that should actually be applied for a decision: the reviewer's inline
|
|
173
207
|
* edit when one was made for an accept-proposed decision, otherwise the selected
|
|
@@ -253,7 +287,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
|
|
|
253
287
|
if (decision === "could-not-confirm" && !note?.trim()) {
|
|
254
288
|
throw new Error(`ReviewItem ${item.metadata.name} could not confirm requires a non-empty reason.`);
|
|
255
289
|
}
|
|
256
|
-
const
|
|
290
|
+
const candidateId = decisionCandidateId(item, decision);
|
|
257
291
|
const definition = workbenchDecisionDefinitions[decision];
|
|
258
292
|
const reviewDecisionName = `${item.metadata.name}-${decision}`;
|
|
259
293
|
// Carry the reviewer's inline edit in the event itself (accept-proposed
|
|
@@ -283,7 +317,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
|
|
|
283
317
|
occurredAt: session.reviewedAt,
|
|
284
318
|
reviewItemName: item.metadata.name,
|
|
285
319
|
reviewDecisionName,
|
|
286
|
-
candidateId:
|
|
320
|
+
...(candidateId !== undefined ? { candidateId } : {}),
|
|
287
321
|
status: definition.status,
|
|
288
322
|
...(decision === "could-not-confirm"
|
|
289
323
|
? {
|
|
@@ -301,7 +335,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
|
|
|
301
335
|
occurredAt: session.reviewedAt,
|
|
302
336
|
reviewItemName: item.metadata.name,
|
|
303
337
|
reviewDecisionName,
|
|
304
|
-
candidateId:
|
|
338
|
+
...(candidateId !== undefined ? { candidateId } : {}),
|
|
305
339
|
status: definition.status,
|
|
306
340
|
rationale: note,
|
|
307
341
|
...(decision === "could-not-confirm"
|
|
@@ -368,7 +402,13 @@ export function replayReviewSessionEvents(startState, events) {
|
|
|
368
402
|
const editedValuesByItemName = { ...session.editedValuesByItemName };
|
|
369
403
|
const attemptEvidenceIdsByItemName = { ...session.attemptEvidenceIdsByItemName };
|
|
370
404
|
if (decision === "accept-proposed" && editedValue !== undefined) {
|
|
371
|
-
|
|
405
|
+
// Legacy sessions stored typed edits as editor text ("42"); store the
|
|
406
|
+
// descriptor-typed value so effectiveValue is 42. An edit the item does
|
|
407
|
+
// not allow is left as carried: validated replay refuses it before this
|
|
408
|
+
// point (kontourai/survey#278).
|
|
409
|
+
const item = session.items.find((entry) => entry.metadata.name === itemName);
|
|
410
|
+
const check = item ? checkEditedValueForItem(item, editedValue) : undefined;
|
|
411
|
+
editedValuesByItemName[itemName] = check?.ok ? check.value : editedValue;
|
|
372
412
|
}
|
|
373
413
|
else {
|
|
374
414
|
delete editedValuesByItemName[itemName];
|
|
@@ -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";
|
|
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";
|
|
4
4
|
export interface ReviewSessionReplayIssue {
|
|
5
5
|
readonly code: ReviewSessionReplayIssueCode;
|
|
6
6
|
readonly eventName: string;
|
|
@@ -10,3 +10,37 @@ export interface ReviewSessionReplayIssue {
|
|
|
10
10
|
readonly message: string;
|
|
11
11
|
}
|
|
12
12
|
export declare function validateReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayIssue[];
|
|
13
|
+
/**
|
|
14
|
+
* A ReviewSessionEvent whose `data.workbenchEditedValue` was rewritten from
|
|
15
|
+
* legacy editor text to its descriptor-typed value during replay (see
|
|
16
|
+
* checkEditedValueForItem). Not an error: the apply result still succeeds, and
|
|
17
|
+
* the warning lets a consumer see that a saved session was normalized.
|
|
18
|
+
*/
|
|
19
|
+
export type ReviewSessionReplayWarning = {
|
|
20
|
+
readonly code: "edited-value-converted-from-text";
|
|
21
|
+
readonly eventName: string;
|
|
22
|
+
readonly sequence: number;
|
|
23
|
+
readonly reviewItemName: string;
|
|
24
|
+
readonly originalValue: string;
|
|
25
|
+
readonly convertedValue: unknown;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
} | {
|
|
28
|
+
/**
|
|
29
|
+
* An edit that would be refused (see checkEditedValueForItem) on a
|
|
30
|
+
* decision event that a later decision for the same item superseded. It
|
|
31
|
+
* never reaches effectiveValue, so it does not fail the session.
|
|
32
|
+
*/
|
|
33
|
+
readonly code: "superseded-edited-value-refused";
|
|
34
|
+
readonly eventName: string;
|
|
35
|
+
readonly sequence: number;
|
|
36
|
+
readonly reviewItemName: string;
|
|
37
|
+
readonly refusedCode: "edited-value-not-editable" | "edited-value-type-mismatch";
|
|
38
|
+
readonly editedValue: unknown;
|
|
39
|
+
readonly message: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Lists the legacy text edits replay converts to typed values, and refused
|
|
43
|
+
* edits that a later decision superseded. Call it only on an event stream that
|
|
44
|
+
* validateReviewSessionEventsForSnapshot accepted.
|
|
45
|
+
*/
|
|
46
|
+
export declare function reviewSessionReplayWarningsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayWarning[];
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { candidateForDecision, isClearedWorkbenchDecisionEvent, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
1
|
+
import { candidateForDecision, decisionSelectsNoCandidate, isClearedWorkbenchDecisionEvent, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
2
|
+
import { checkEditedValueForItem } from "./edited-value.js";
|
|
2
3
|
export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
3
4
|
const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
|
|
4
5
|
const sequenceIssues = validateEventSequence(events);
|
|
6
|
+
const finalDecisionEvents = finalDecisionEventPerItem(events);
|
|
5
7
|
const replayIssues = events.flatMap((event) => {
|
|
6
8
|
const issues = [];
|
|
7
9
|
const activeItemName = event.spec.activeItemName;
|
|
@@ -54,17 +56,19 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
|
54
56
|
else if (itemName && itemsByName.has(itemName)) {
|
|
55
57
|
const item = itemsByName.get(itemName);
|
|
56
58
|
const expectedCandidate = item ? candidateForDecision(item, decision) : undefined;
|
|
59
|
+
// A decision that selects no candidate must reference none.
|
|
60
|
+
const expectedCandidateId = item && !decisionSelectsNoCandidate(item, decision) ? expectedCandidate?.id : undefined;
|
|
57
61
|
const expectedStatus = workbenchDecisionDefinitions[decision].status;
|
|
58
62
|
const referencedCandidateExists = event.spec.candidateId
|
|
59
63
|
? item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId)
|
|
60
64
|
: false;
|
|
61
|
-
if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !==
|
|
65
|
+
if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !== expectedCandidateId) {
|
|
62
66
|
issues.push({
|
|
63
67
|
...eventRef,
|
|
64
68
|
code: "decision-candidate-mismatch",
|
|
65
69
|
reviewItemName: itemName,
|
|
66
70
|
candidateId: event.spec.candidateId,
|
|
67
|
-
message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects candidate ${
|
|
71
|
+
message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects ${expectedCandidateId ? `candidate ${expectedCandidateId}` : "no candidate"}, but references ${event.spec.candidateId ?? "no candidate"}.`,
|
|
68
72
|
});
|
|
69
73
|
}
|
|
70
74
|
if (event.spec.status !== expectedStatus) {
|
|
@@ -86,6 +90,22 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
|
86
90
|
message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects resolution ${expectedResolution ?? "none"}, but references ${event.spec.resolution ?? "no resolution"}.`,
|
|
87
91
|
});
|
|
88
92
|
}
|
|
93
|
+
const editedValue = editedValueFromDecisionEvent(event);
|
|
94
|
+
// Only the item's final decision decides its effectiveValue, so only an
|
|
95
|
+
// edit on that event is refused. An edit a later decision superseded is
|
|
96
|
+
// reported as a warning instead of failing the whole session.
|
|
97
|
+
if (item && decision === "accept-proposed" && editedValue !== undefined && finalDecisionEvents.has(event)) {
|
|
98
|
+
const check = checkEditedValueForItem(item, editedValue);
|
|
99
|
+
if (!check.ok) {
|
|
100
|
+
issues.push({
|
|
101
|
+
...eventRef,
|
|
102
|
+
code: check.code,
|
|
103
|
+
reviewItemName: itemName,
|
|
104
|
+
candidateId: event.spec.candidateId,
|
|
105
|
+
message: `ReviewSessionEvent ${event.metadata.name}: ${check.message}`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
89
109
|
if (decision === "could-not-confirm" && !event.spec.resolutionReason?.trim()) {
|
|
90
110
|
issues.push({
|
|
91
111
|
...eventRef,
|
|
@@ -114,6 +134,65 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
|
114
134
|
});
|
|
115
135
|
return [...sequenceIssues, ...replayIssues];
|
|
116
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Lists the legacy text edits replay converts to typed values, and refused
|
|
139
|
+
* edits that a later decision superseded. Call it only on an event stream that
|
|
140
|
+
* validateReviewSessionEventsForSnapshot accepted.
|
|
141
|
+
*/
|
|
142
|
+
export function reviewSessionReplayWarningsForSnapshot(snapshot, events) {
|
|
143
|
+
const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
|
|
144
|
+
const finalDecisionEvents = finalDecisionEventPerItem(events);
|
|
145
|
+
return events.flatMap((event) => {
|
|
146
|
+
const itemName = event.spec.reviewItemName;
|
|
147
|
+
const item = itemName ? itemsByName.get(itemName) : undefined;
|
|
148
|
+
const editedValue = editedValueFromDecisionEvent(event);
|
|
149
|
+
if (!item || !itemName || editedValue === undefined
|
|
150
|
+
|| replayableWorkbenchDecision(event.spec.data?.workbenchDecision) !== "accept-proposed") {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
const check = checkEditedValueForItem(item, editedValue);
|
|
154
|
+
if (!check.ok) {
|
|
155
|
+
return finalDecisionEvents.has(event) ? [] : [{
|
|
156
|
+
code: "superseded-edited-value-refused",
|
|
157
|
+
eventName: event.metadata.name,
|
|
158
|
+
sequence: event.spec.sequence,
|
|
159
|
+
reviewItemName: itemName,
|
|
160
|
+
refusedCode: check.code,
|
|
161
|
+
editedValue,
|
|
162
|
+
message: `ReviewSessionEvent ${event.metadata.name} carries an edit a later decision superseded, which would be refused: ${check.message}`,
|
|
163
|
+
}];
|
|
164
|
+
}
|
|
165
|
+
if (!check.convertedFromText || typeof editedValue !== "string") {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
return [{
|
|
169
|
+
code: "edited-value-converted-from-text",
|
|
170
|
+
eventName: event.metadata.name,
|
|
171
|
+
sequence: event.spec.sequence,
|
|
172
|
+
reviewItemName: itemName,
|
|
173
|
+
originalValue: editedValue,
|
|
174
|
+
convertedValue: check.value,
|
|
175
|
+
message: `ReviewSessionEvent ${event.metadata.name} stores edited value ${JSON.stringify(editedValue)} as text; replay converted it to ${JSON.stringify(check.value)} per ReviewItem ${itemName}'s value descriptor.`,
|
|
176
|
+
}];
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/** The last decision event (by sequence, as replay applies them) for each ReviewItem. */
|
|
180
|
+
function finalDecisionEventPerItem(events) {
|
|
181
|
+
const lastByItem = new Map();
|
|
182
|
+
for (const event of [...events].sort((left, right) => left.spec.sequence - right.spec.sequence)) {
|
|
183
|
+
if ((event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted")
|
|
184
|
+
&& event.spec.reviewItemName) {
|
|
185
|
+
lastByItem.set(event.spec.reviewItemName, event);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return new Set(lastByItem.values());
|
|
189
|
+
}
|
|
190
|
+
function editedValueFromDecisionEvent(event) {
|
|
191
|
+
return (event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted")
|
|
192
|
+
&& event.spec.data && "workbenchEditedValue" in event.spec.data
|
|
193
|
+
? event.spec.data.workbenchEditedValue
|
|
194
|
+
: undefined;
|
|
195
|
+
}
|
|
117
196
|
function replayableWorkbenchDecision(value) {
|
|
118
197
|
return typeof value === "string" && value in workbenchDecisionDefinitions
|
|
119
198
|
? value
|
|
@@ -247,6 +247,8 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
247
247
|
.survey-workbench-embed .inspector-heading h2{ margin: 0; }
|
|
248
248
|
.survey-workbench-embed .inspector-posture{ display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
|
|
249
249
|
.survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
250
|
+
.survey-workbench-embed .inspector-posture.extraction-failed{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
251
|
+
.survey-workbench-embed .inspector-posture.extraction-incomplete{ background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
|
|
250
252
|
.survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
|
|
251
253
|
.survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
|
|
252
254
|
.survey-workbench-embed .inspector-filters select, .survey-workbench-embed .inspector-filters input{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
|