@kontourai/survey 4.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/dist/example-data/public-directory-review-resource.d.ts +3 -3
- package/dist/examples/review-workbench/server-apply-consumer.js +5 -0
- package/dist/src/canonical-reviewed-trust-input.js +73 -34
- package/dist/src/extraction-envelope.d.ts +59 -3
- package/dist/src/extraction-envelope.js +159 -21
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/mcp/review-mcp.js +42 -25
- 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 +2 -1
- package/dist/src/review-workbench/review-presentation.js +23 -1
- package/dist/src/review-workbench/review-queue-session.d.ts +19 -5
- package/dist/src/review-workbench/review-queue-session.js +46 -13
- package/dist/src/review-workbench/review-session-replay.js +5 -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 +14 -7
- package/dist/src/review-workbench/review-workbench.js +72 -19
- package/dist/src/review-workbench/review-workbench.standalone.css +2 -0
- package/dist/src/surface-reviewed-extraction.js +4 -0
- package/dist/src/to-surface.js +116 -0
- package/dist/src/types.d.ts +5 -1
- package/package.json +1 -1
package/dist/src/index.d.ts
CHANGED
|
@@ -2,13 +2,13 @@ export type { CandidateSetStatus, Candidate, CandidateSet, ClaimTarget, Escalati
|
|
|
2
2
|
export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
|
|
3
3
|
export { reviewResourceApiVersion } from "./review-resource.js";
|
|
4
4
|
export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
|
|
5
|
-
export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
|
|
5
|
+
export { buildExtractionInspectorModel, exportExtractionInspector, inspectorSourcePosture, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
|
|
6
6
|
export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
|
|
7
7
|
export type { BindReviewQueueOptions, ReviewQueueBinding, ReviewQueueBindingIssue, ReviewQueueBindingIssueCode, ReviewQueueExtractionIssue, ReviewQueueExtractionIssueCode, ValidateReviewQueueBindingOptions, } from "./review-workbench/queue-binding.js";
|
|
8
8
|
export { resolvePortablePdfRegion } from "./pdf-layout.js";
|
|
9
9
|
export type { PortablePdfBoundingBox, PortablePdfLayout, PortablePdfPageGeometry, PortablePdfRegionContext, PortablePdfTable, PortablePdfTableCell, PortablePdfTextElement, PortablePdfTextRange, } from "./pdf-layout.js";
|
|
10
10
|
export type { ExtractionAlignmentState, ArtifactUnavailableCode, BuiltExtractionInspectorCandidate, BuiltExtractionInspectorModel, ExtractionInspectorCandidate, ExtractionInspectorEntry, ExtractionInspectorExportOptions, ExtractionInspectorFilters, ExtractionInspectorInput, ExtractionInspectorModel, ExtractionInspectorSource, ResolvedExtractionArtifact, } from "./review-workbench/extraction-inspector.js";
|
|
11
|
-
export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, ExtractionEnvelopeImportResult, ExtractionEnvelopeResolutionIdentity, PortableExtractionEvidenceMatch, PortableExtractionOccurrence, PortableExtractionProducedBy, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
|
|
11
|
+
export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, ExtractionEnvelopeImportResult, ExtractionEnvelopeResolutionIdentity, PortableExtractionCoverageEntry, PortableExtractionEvidenceMatch, PortableExtractionPartialReason, PortableExtractionOccurrence, PortableExtractionProducedBy, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
|
|
12
12
|
export type { CandidateRole, ClaimTargetHint, ProducerPolicy, ExtractionReference, ResourceEnvelope, ResourceMetadata, ReviewActor, ReviewCandidate, ReviewDecision, ReviewDecisionMode, ReviewDecisionSpec, ReviewDecisionStatus, ReviewItem, ReviewItemSpec, ReviewItemStatus, ReviewLocator, ReviewResource, ReviewResourceApiVersion, ReviewResourceKind, ReviewSession, ReviewSessionEvent, ReviewSessionEventSpec, ReviewSessionEventStatus, ReviewSessionEventType, ReviewSessionSpec, ReviewSessionStatus, ReviewValueDescriptor, ReviewValueType, SourceReference, SurveyRecordProjectionHint, } from "./review-resource.js";
|
|
13
13
|
export { toSurfaceReviewedExtractionDecision, toSurfaceReviewedExtractionImport, toSurfaceReviewedExtractionItem, } from "./surface-reviewed-extraction.js";
|
|
14
14
|
export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
|
package/dist/src/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
|
|
2
2
|
export { reviewResourceApiVersion } from "./review-resource.js";
|
|
3
3
|
export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
|
|
4
|
-
export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
|
|
4
|
+
export { buildExtractionInspectorModel, exportExtractionInspector, inspectorSourcePosture, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
|
|
5
5
|
export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
|
|
6
6
|
export { resolvePortablePdfRegion } from "./pdf-layout.js";
|
|
7
7
|
export { toSurfaceReviewedExtractionDecision, toSurfaceReviewedExtractionImport, toSurfaceReviewedExtractionItem, } from "./surface-reviewed-extraction.js";
|
|
@@ -3,7 +3,7 @@ import { resolve, dirname } from "node:path";
|
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
4
4
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { buildReviewSessionEvents, currentReviewItem, deriveQueueRowStatus, nextUnresolvedItemName, reviewSessionSummary, workbenchDecisionDefinitions, } from "../review-workbench/review-workbench.js";
|
|
6
|
+
import { buildReviewSessionEvents, currentReviewItem, decisionSelectsNoCandidate, deriveQueueRowStatus, nextUnresolvedItemName, reviewSessionSummary, workbenchDecisionDefinitions, } from "../review-workbench/review-workbench.js";
|
|
7
7
|
import { createServerReviewSessionRecord, currentSessionState, deriveServerReviewSessionApplyResult, } from "../review-workbench/server-review-session.js";
|
|
8
8
|
import { appendReviewSessionEvents, readReviewSessionFile, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
|
|
9
9
|
const SESSION_NAME = "mcp-review-session";
|
|
@@ -53,7 +53,7 @@ function itemDetailText(item, snapshot, events) {
|
|
|
53
53
|
const decision = current.decisionsByItemName[item.metadata.name];
|
|
54
54
|
const note = current.notesByItemName[item.metadata.name];
|
|
55
55
|
const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
|
|
56
|
-
const
|
|
56
|
+
const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
|
|
57
57
|
const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v);
|
|
58
58
|
const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "unknown";
|
|
59
59
|
const lines = [
|
|
@@ -69,10 +69,15 @@ function itemDetailText(item, snapshot, events) {
|
|
|
69
69
|
` source: ${currentCandidate?.source?.sourceRef ?? "none"}`,
|
|
70
70
|
...(currentCandidate?.locator?.excerpt ? [` excerpt: ${currentCandidate.locator.excerpt}`] : []),
|
|
71
71
|
``,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
...(
|
|
72
|
+
...(proposedCandidates.length > 1
|
|
73
|
+
? [`Conflict: ${proposedCandidates.length} proposed values. Accept is refused; reject them all or use could-not-confirm with a reason.`]
|
|
74
|
+
: []),
|
|
75
|
+
...(proposedCandidates.length === 0 ? [`Proposed value: (none)`] : proposedCandidates.flatMap((candidate) => [
|
|
76
|
+
`Proposed value: ${valueStr(candidate.value)}`,
|
|
77
|
+
` confidence: ${confStr(candidate.extraction?.confidence ?? candidate.confidence)}`,
|
|
78
|
+
` source: ${candidate.source?.sourceRef ?? "none"}`,
|
|
79
|
+
...(candidate.locator?.excerpt ? [` excerpt: ${candidate.locator.excerpt}`] : []),
|
|
80
|
+
])),
|
|
76
81
|
];
|
|
77
82
|
if (item.spec.rationale) {
|
|
78
83
|
lines.push(``, `Rationale: ${item.spec.rationale}`);
|
|
@@ -99,19 +104,32 @@ function buildReviewCardHtml(item, snapshot, events) {
|
|
|
99
104
|
const total = current.items.length;
|
|
100
105
|
const resolved = total - summary.unresolved;
|
|
101
106
|
const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
|
|
102
|
-
const
|
|
107
|
+
const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
|
|
108
|
+
// Several proposed values are a conflict: every value is shown, and accept
|
|
109
|
+
// (which names a role, not a value) is not offered.
|
|
110
|
+
const conflict = proposedCandidates.length > 1;
|
|
103
111
|
const decision = current.decisionsByItemName[item.metadata.name];
|
|
104
112
|
const status = deriveQueueRowStatus(item, current);
|
|
105
113
|
const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v, null, 2);
|
|
106
114
|
const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "—";
|
|
107
115
|
const currentValue = valueStr(currentCandidate?.value ?? "—");
|
|
108
|
-
const proposedValue = valueStr(proposedCandidate?.value ?? "—");
|
|
109
116
|
const currentConf = confStr(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence);
|
|
110
|
-
const proposedConf = confStr(proposedCandidate?.extraction?.confidence ?? proposedCandidate?.confidence);
|
|
111
117
|
const currentSource = currentCandidate?.source?.sourceRef ?? "—";
|
|
112
|
-
const proposedSource = proposedCandidate?.source?.sourceRef ?? "—";
|
|
113
118
|
const currentExcerpt = currentCandidate?.locator?.excerpt ?? "";
|
|
114
|
-
const
|
|
119
|
+
const proposedCard = (candidate, label) => {
|
|
120
|
+
const value = valueStr(candidate?.value ?? "—");
|
|
121
|
+
const excerpt = candidate?.locator?.excerpt ?? "";
|
|
122
|
+
return `<div class="card is-proposed">
|
|
123
|
+
<div class="card-label">${escapeHtml(label)}</div>
|
|
124
|
+
<div class="value">${value.includes("\n") ? `<pre>${escapeHtml(value)}</pre>` : escapeHtml(value)}</div>
|
|
125
|
+
<div class="conf">confidence ${confStr(candidate?.extraction?.confidence ?? candidate?.confidence)}</div>
|
|
126
|
+
<div class="source-ref">${escapeHtml(candidate?.source?.sourceRef ?? "—")}</div>
|
|
127
|
+
${excerpt ? `<div class="excerpt">${escapeHtml(excerpt)}</div>` : ""}
|
|
128
|
+
</div>`;
|
|
129
|
+
};
|
|
130
|
+
const proposedCards = conflict
|
|
131
|
+
? proposedCandidates.map((candidate, index) => proposedCard(candidate, `Proposed ${index + 1} of ${proposedCandidates.length}`)).join("\n ")
|
|
132
|
+
: proposedCard(proposedCandidates[0], "Proposed");
|
|
115
133
|
const itemNameJson = escapeJsonInHtml(item.metadata.name);
|
|
116
134
|
const decisionBadge = decision
|
|
117
135
|
? `<span class="badge badge-${decision === "accept-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(workbenchDecisionDefinitions[decision].label)}</span>`
|
|
@@ -200,6 +218,7 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
200
218
|
<div class="meta">
|
|
201
219
|
<span>${escapeHtml(item.metadata.name)}</span>
|
|
202
220
|
${decisionBadge}
|
|
221
|
+
${conflict ? `<span class="badge badge-hold" id="conflict-badge">Conflict: ${proposedCandidates.length} values</span>` : ""}
|
|
203
222
|
<span class="progress">${resolved}/${total} resolved</span>
|
|
204
223
|
</div>
|
|
205
224
|
|
|
@@ -211,14 +230,9 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
211
230
|
<div class="source-ref">${escapeHtml(currentSource)}</div>
|
|
212
231
|
${currentExcerpt ? `<div class="excerpt">${escapeHtml(currentExcerpt)}</div>` : ""}
|
|
213
232
|
</div>
|
|
214
|
-
|
|
215
|
-
<div class="card-label">Proposed</div>
|
|
216
|
-
<div class="value">${proposedValue.includes("\n") ? `<pre>${escapeHtml(proposedValue)}</pre>` : escapeHtml(proposedValue)}</div>
|
|
217
|
-
<div class="conf">confidence ${proposedConf}</div>
|
|
218
|
-
<div class="source-ref">${escapeHtml(proposedSource)}</div>
|
|
219
|
-
${proposedExcerpt ? `<div class="excerpt">${escapeHtml(proposedExcerpt)}</div>` : ""}
|
|
220
|
-
</div>
|
|
233
|
+
${proposedCards}
|
|
221
234
|
</div>
|
|
235
|
+
${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length} different values were proposed. This card cannot choose one of them yet: reject them all, or use Could not confirm with a reason.</p>` : ""}
|
|
222
236
|
|
|
223
237
|
<div class="divider"></div>
|
|
224
238
|
|
|
@@ -226,9 +240,9 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
226
240
|
<textarea class="note-input" id="note" placeholder="Add a rationale for this decision...">${escapeHtml(current.notesByItemName[item.metadata.name] ?? "")}</textarea>
|
|
227
241
|
|
|
228
242
|
<div class="btn-row">
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
<button class="btn btn-reject${decision === "reject-proposed" ? " active" : ""}" id="btn-reject"
|
|
243
|
+
${conflict ? "" : `<button class="btn btn-accept${decision === "accept-proposed" ? " active" : ""}" id="btn-accept">Accept proposed</button>`}
|
|
244
|
+
${currentCandidate ? `<button class="btn btn-hold${decision === "keep-current" ? " active" : ""}" id="btn-hold">Hold / Keep current</button>` : ""}
|
|
245
|
+
<button class="btn btn-reject${decision === "reject-proposed" ? " active" : ""}" id="btn-reject">${conflict ? "Reject all values" : "Reject proposed"}</button>
|
|
232
246
|
<button class="btn btn-unconfirmed${decision === "could-not-confirm" ? " active" : ""}" id="btn-unconfirmed">Could not confirm</button>
|
|
233
247
|
</div>
|
|
234
248
|
<div class="feedback" id="feedback"></div>
|
|
@@ -259,8 +273,10 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
259
273
|
return true;
|
|
260
274
|
}
|
|
261
275
|
|
|
262
|
-
|
|
263
|
-
|
|
276
|
+
var acceptButton = document.getElementById('btn-accept');
|
|
277
|
+
if (acceptButton) acceptButton.addEventListener('click', function () { postDecision('accept'); document.getElementById('feedback').textContent = 'Submitting accept…'; });
|
|
278
|
+
var holdButton = document.getElementById('btn-hold');
|
|
279
|
+
if (holdButton) holdButton.addEventListener('click', function () { postDecision('hold'); document.getElementById('feedback').textContent = 'Submitting hold…'; });
|
|
264
280
|
document.getElementById('btn-reject').addEventListener('click', function () { postDecision('reject'); document.getElementById('feedback').textContent = 'Submitting reject…'; });
|
|
265
281
|
document.getElementById('btn-unconfirmed').addEventListener('click', function () { if (postDecision('could-not-confirm')) document.getElementById('feedback').textContent = 'Submitting could not confirm…'; });
|
|
266
282
|
|
|
@@ -421,9 +437,10 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
|
|
|
421
437
|
const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents) : `Item: ${itemName}`;
|
|
422
438
|
const remainingText = queueSummaryText(snapshot, newEvents);
|
|
423
439
|
const definition = workbenchDecisionDefinitions[wbDecision];
|
|
440
|
+
const conflictRejected = wbDecision === "reject-proposed" && updatedItem !== undefined && decisionSelectsNoCandidate(updatedItem, wbDecision);
|
|
424
441
|
const text = [
|
|
425
|
-
`Decision recorded: ${definition.label}`,
|
|
426
|
-
`Effect: ${definition.effect}`,
|
|
442
|
+
`Decision recorded: ${conflictRejected ? "Reject all values" : definition.label}`,
|
|
443
|
+
`Effect: ${conflictRejected ? "Every proposed value is rejected; none becomes the claim's value." : definition.effect}`,
|
|
427
444
|
"",
|
|
428
445
|
itemText,
|
|
429
446
|
"",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
|
|
1
|
+
import { type ExtractionEnvelopeImportDiagnostic, type ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
|
|
2
2
|
import { type PortablePdfRegionContext } from "../pdf-layout.js";
|
|
3
3
|
export type ExtractionAlignmentState = "aligned" | "excerpt-mismatch" | "artifact-unavailable" | "digest-mismatch";
|
|
4
4
|
export type ArtifactUnavailableCode = "not-found" | "storage-error" | "access-denied" | "invalid-artifact" | "unknown";
|
|
@@ -89,6 +89,14 @@ export interface ExtractionInspectorSource {
|
|
|
89
89
|
artifactText?: string;
|
|
90
90
|
ocrDerived?: true;
|
|
91
91
|
alignment: ExtractionAlignmentState;
|
|
92
|
+
/**
|
|
93
|
+
* Present when the extraction failed, or stopped short without proposing
|
|
94
|
+
* anything: the import's `extraction-failed` / `extraction-incomplete`
|
|
95
|
+
* diagnostic. An empty source with this set is not a run that found nothing.
|
|
96
|
+
*/
|
|
97
|
+
extractionDiagnostic?: Extract<ExtractionEnvelopeImportDiagnostic, {
|
|
98
|
+
kind: "extraction-failed" | "extraction-incomplete";
|
|
99
|
+
}>;
|
|
92
100
|
message: string;
|
|
93
101
|
}
|
|
94
102
|
export interface ExtractionInspectorModel {
|
|
@@ -146,6 +154,12 @@ export interface ExtractionInspectorMountOptions {
|
|
|
146
154
|
* and fails closed if mutable caller data has drifted since import.
|
|
147
155
|
*/
|
|
148
156
|
export declare function buildExtractionInspectorModel(input: ExtractionInspectorInput): BuiltExtractionInspectorModel;
|
|
157
|
+
/**
|
|
158
|
+
* The posture a source is shown with: the extraction's own failure or early
|
|
159
|
+
* stop when there is one, otherwise the artifact alignment. A failed
|
|
160
|
+
* extraction over an aligned artifact must not show the aligned posture.
|
|
161
|
+
*/
|
|
162
|
+
export declare function inspectorSourcePosture(source: Pick<ExtractionInspectorSource, "alignment" | "extractionDiagnostic">): ExtractionAlignmentState | "extraction-failed" | "extraction-incomplete";
|
|
149
163
|
export declare function filterExtractionInspectorCandidates(model: ExtractionInspectorModel, filters: ExtractionInspectorFilters): ExtractionInspectorCandidate[];
|
|
150
164
|
export declare function exportExtractionInspector(model: ExtractionInspectorModel, options?: ExtractionInspectorExportOptions): string;
|
|
151
165
|
export declare function mountExtractionInspector(container: HTMLElement, model: ExtractionInspectorModel, options?: ExtractionInspectorMountOptions): () => void;
|
|
@@ -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) {
|
|
@@ -57,7 +57,8 @@ export interface ReviewResultPresentation {
|
|
|
57
57
|
readonly target: string;
|
|
58
58
|
readonly targetLabel: string;
|
|
59
59
|
readonly decisionLabel: string;
|
|
60
|
-
|
|
60
|
+
/** Absent when the decision selects no candidate (reject-all or could-not-confirm on a conflict). */
|
|
61
|
+
readonly selectedValueText?: string;
|
|
61
62
|
readonly applyMeaning: string;
|
|
62
63
|
readonly reviewItemLink?: ReviewPresentationLink;
|
|
63
64
|
readonly traceRefs: readonly ReviewTraceRef[];
|
|
@@ -85,6 +85,28 @@ export function buildReviewResultPresentation(result, item, adapter = {}) {
|
|
|
85
85
|
? adapter.labelForTarget?.(target, itemContext) ?? humanizeIdentifier(target)
|
|
86
86
|
: humanizeIdentifier(target);
|
|
87
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
|
+
}
|
|
88
110
|
return {
|
|
89
111
|
result,
|
|
90
112
|
item,
|
|
@@ -168,7 +190,7 @@ function traceRefsForCandidate(item, candidate, adapter) {
|
|
|
168
190
|
function traceRefsForResult(item, result, selectedCandidate, adapter) {
|
|
169
191
|
return withTraceLinks([
|
|
170
192
|
{ label: "Survey ReviewItem", value: result.reviewItemName, kind: "review-item" },
|
|
171
|
-
{ label: "Selected candidate", value: result.selectedCandidateId, kind: "candidate" },
|
|
193
|
+
{ label: "Selected candidate", value: result.selectedCandidateId ?? "none", kind: "candidate" },
|
|
172
194
|
{
|
|
173
195
|
label: "Selected claim",
|
|
174
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
|
|
@@ -143,32 +143,65 @@ export function reviewSessionSummary(session) {
|
|
|
143
143
|
* would invent a prior value, with provenance, that the source never had.
|
|
144
144
|
*/
|
|
145
145
|
export function keepActionDecision(item, flaggedWrong) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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;
|
|
149
152
|
}
|
|
150
|
-
return "keep-current";
|
|
153
|
+
return count("current") === 1 ? "keep-current" : undefined;
|
|
151
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"]);
|
|
152
161
|
/**
|
|
153
162
|
* The candidate a workbench decision applies to.
|
|
154
163
|
*
|
|
155
|
-
* Selection is by role,
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* leaves here. Guarding the render path alone let the workbench emit an
|
|
159
|
-
* 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
|
|
160
167
|
* candidate's value against it; this is the shared selector all of those go
|
|
161
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.
|
|
162
178
|
*/
|
|
163
179
|
export function candidateForDecision(item, decision) {
|
|
164
180
|
const definition = workbenchDecisionDefinitions[decision];
|
|
165
|
-
const
|
|
181
|
+
const matches = item.spec.candidates.filter((entry) => entry.role === definition.candidateRole);
|
|
182
|
+
const candidate = matches[0];
|
|
166
183
|
if (!candidate) {
|
|
167
184
|
throw new Error(`ReviewItem ${item.metadata.name} has no ${definition.candidateRole} candidate.`);
|
|
168
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
|
+
}
|
|
169
189
|
assertSoleCandidateId(item, candidate.id);
|
|
170
190
|
return candidate;
|
|
171
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
|
+
}
|
|
172
205
|
/**
|
|
173
206
|
* The value that should actually be applied for a decision: the reviewer's inline
|
|
174
207
|
* edit when one was made for an accept-proposed decision, otherwise the selected
|
|
@@ -254,7 +287,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
|
|
|
254
287
|
if (decision === "could-not-confirm" && !note?.trim()) {
|
|
255
288
|
throw new Error(`ReviewItem ${item.metadata.name} could not confirm requires a non-empty reason.`);
|
|
256
289
|
}
|
|
257
|
-
const
|
|
290
|
+
const candidateId = decisionCandidateId(item, decision);
|
|
258
291
|
const definition = workbenchDecisionDefinitions[decision];
|
|
259
292
|
const reviewDecisionName = `${item.metadata.name}-${decision}`;
|
|
260
293
|
// Carry the reviewer's inline edit in the event itself (accept-proposed
|
|
@@ -284,7 +317,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
|
|
|
284
317
|
occurredAt: session.reviewedAt,
|
|
285
318
|
reviewItemName: item.metadata.name,
|
|
286
319
|
reviewDecisionName,
|
|
287
|
-
candidateId:
|
|
320
|
+
...(candidateId !== undefined ? { candidateId } : {}),
|
|
288
321
|
status: definition.status,
|
|
289
322
|
...(decision === "could-not-confirm"
|
|
290
323
|
? {
|
|
@@ -302,7 +335,7 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
|
|
|
302
335
|
occurredAt: session.reviewedAt,
|
|
303
336
|
reviewItemName: item.metadata.name,
|
|
304
337
|
reviewDecisionName,
|
|
305
|
-
candidateId:
|
|
338
|
+
...(candidateId !== undefined ? { candidateId } : {}),
|
|
306
339
|
status: definition.status,
|
|
307
340
|
rationale: note,
|
|
308
341
|
...(decision === "could-not-confirm"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { candidateForDecision, isClearedWorkbenchDecisionEvent, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
1
|
+
import { candidateForDecision, decisionSelectsNoCandidate, isClearedWorkbenchDecisionEvent, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
2
2
|
import { checkEditedValueForItem } from "./edited-value.js";
|
|
3
3
|
export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
4
4
|
const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
|
|
@@ -56,17 +56,19 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
|
56
56
|
else if (itemName && itemsByName.has(itemName)) {
|
|
57
57
|
const item = itemsByName.get(itemName);
|
|
58
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;
|
|
59
61
|
const expectedStatus = workbenchDecisionDefinitions[decision].status;
|
|
60
62
|
const referencedCandidateExists = event.spec.candidateId
|
|
61
63
|
? item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId)
|
|
62
64
|
: false;
|
|
63
|
-
if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !==
|
|
65
|
+
if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !== expectedCandidateId) {
|
|
64
66
|
issues.push({
|
|
65
67
|
...eventRef,
|
|
66
68
|
code: "decision-candidate-mismatch",
|
|
67
69
|
reviewItemName: itemName,
|
|
68
70
|
candidateId: event.spec.candidateId,
|
|
69
|
-
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"}.`,
|
|
70
72
|
});
|
|
71
73
|
}
|
|
72
74
|
if (event.spec.status !== expectedStatus) {
|
|
@@ -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; }
|
|
@@ -244,6 +244,8 @@
|
|
|
244
244
|
.survey-workbench-embed .inspector-heading h2{ margin: 0; }
|
|
245
245
|
.survey-workbench-embed .inspector-posture{ display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
|
|
246
246
|
.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; }
|
|
247
|
+
.survey-workbench-embed .inspector-posture.extraction-failed{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
248
|
+
.survey-workbench-embed .inspector-posture.extraction-incomplete{ background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
|
|
247
249
|
.survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
|
|
248
250
|
.survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
|
|
249
251
|
.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; }
|