@kontourai/survey 6.1.0 → 7.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/src/canonical-reviewed-trust-input.js +30 -1
- package/dist/src/console/review-console-server.js +22 -4
- package/dist/src/index.d.ts +3 -3
- package/dist/src/index.js +1 -1
- package/dist/src/mcp/review-mcp.js +139 -36
- package/dist/src/review-resource.d.ts +7 -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/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 +47 -4
- package/dist/src/review-workbench/review-queue-session.js +73 -8
- package/dist/src/review-workbench/review-session-replay.d.ts +11 -1
- package/dist/src/review-workbench/review-session-replay.js +35 -1
- package/dist/src/review-workbench/review-workbench-css.generated.js +94 -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 +94 -0
- package/dist/src/review-workbench/review-workbench.d.ts +28 -8
- package/dist/src/review-workbench/review-workbench.js +157 -62
- package/dist/src/review-workbench/review-workbench.standalone.css +94 -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
|
@@ -3,9 +3,9 @@ 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, decisionSelectsNoCandidate, deriveQueueRowStatus, nextUnresolvedItemName, reviewSessionSummary, workbenchDecisionDefinitions, } from "../review-workbench/review-workbench.js";
|
|
6
|
+
import { attestReviewQueueExtraction, buildReviewSessionEvents, conflictSelectionIssue, 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
|
-
import { buildReviewItemPresentation, candidateVerificationNotes, excludedProposalsSentence } from "../review-workbench/review-presentation.js";
|
|
8
|
+
import { buildReviewItemPresentation, candidateVerificationNotes, excludedProposalsSentence, excludedProposalsUnreadableSentence } from "../review-workbench/review-presentation.js";
|
|
9
9
|
import { appendReviewSessionEvents, readReviewSessionFile, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
|
|
10
10
|
const SESSION_NAME = "mcp-review-session";
|
|
11
11
|
const UI_RESOURCE_URI_META_KEY = "ui/resourceUri";
|
|
@@ -16,15 +16,33 @@ const SERVER_INSTRUCTIONS = "Use survey_review_queue to inspect the queue, surve
|
|
|
16
16
|
// MCP tool decision strings → ReviewWorkbenchDecision
|
|
17
17
|
const MCP_DECISION_MAP = {
|
|
18
18
|
accept: "accept-proposed",
|
|
19
|
+
select: "select-proposed",
|
|
19
20
|
hold: "keep-current",
|
|
20
21
|
reject: "reject-proposed",
|
|
21
22
|
"could-not-confirm": "could-not-confirm",
|
|
22
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* Reads the session and checks its queue against the extraction import stored
|
|
26
|
+
* beside it. A queue that does not match its import is refused outright; one
|
|
27
|
+
* whose items came from an import but has no record stored is presented with
|
|
28
|
+
* an "Unverified queue" notice on every surface.
|
|
29
|
+
*/
|
|
23
30
|
async function readSessionFile(path) {
|
|
24
|
-
|
|
31
|
+
const file = await readReviewSessionFile(path);
|
|
32
|
+
return { ...file, attestation: attestedQueue(file) };
|
|
33
|
+
}
|
|
34
|
+
function attestedQueue(file) {
|
|
35
|
+
const attestation = attestReviewQueueExtraction(file.snapshot.items, file.extractionImport);
|
|
36
|
+
if (attestation.state === "diverges") {
|
|
37
|
+
throw new DomainError(attestation.message);
|
|
38
|
+
}
|
|
39
|
+
return attestation;
|
|
40
|
+
}
|
|
41
|
+
function attestationLines(attestation) {
|
|
42
|
+
return attestation.state === "unverified" ? [attestation.message, ``] : [];
|
|
25
43
|
}
|
|
26
44
|
// ---- Queue helpers -------------------------------------------------------
|
|
27
|
-
function queueSummaryText(snapshot, events) {
|
|
45
|
+
function queueSummaryText(snapshot, events, attestation) {
|
|
28
46
|
const current = currentSessionState(snapshot, events);
|
|
29
47
|
const summary = reviewSessionSummary(current);
|
|
30
48
|
const total = current.items.length;
|
|
@@ -38,6 +56,7 @@ function queueSummaryText(snapshot, events) {
|
|
|
38
56
|
return ` ${item.metadata.name} — ${item.spec.target} — ${status}${marker}`;
|
|
39
57
|
});
|
|
40
58
|
return [
|
|
59
|
+
...attestationLines(attestation),
|
|
41
60
|
`Review queue: ${resolved}/${total} resolved`,
|
|
42
61
|
`Active item: ${activeItem.metadata.name} (${activeItem.spec.target})`,
|
|
43
62
|
...(nextItem ? [`Next unresolved: ${nextItem}`] : ["All items resolved."]),
|
|
@@ -48,16 +67,18 @@ function queueSummaryText(snapshot, events) {
|
|
|
48
67
|
...rows,
|
|
49
68
|
].join("\n");
|
|
50
69
|
}
|
|
51
|
-
function itemDetailText(item, snapshot, events) {
|
|
70
|
+
function itemDetailText(item, snapshot, events, attestation) {
|
|
52
71
|
const current = currentSessionState(snapshot, events);
|
|
53
72
|
const status = deriveQueueRowStatus(item, current);
|
|
54
73
|
const decision = current.decisionsByItemName[item.metadata.name];
|
|
55
74
|
const note = current.notesByItemName[item.metadata.name];
|
|
75
|
+
const chosenId = decision === "select-proposed" ? current.selectedCandidateIdsByItemName?.[item.metadata.name] : undefined;
|
|
56
76
|
const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
|
|
57
77
|
const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
|
|
58
78
|
const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v);
|
|
59
79
|
const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "unknown";
|
|
60
80
|
const lines = [
|
|
81
|
+
...attestationLines(attestation),
|
|
61
82
|
`Item: ${item.metadata.name}`,
|
|
62
83
|
`Target: ${item.spec.target}`,
|
|
63
84
|
`Status: ${status}`,
|
|
@@ -71,10 +92,14 @@ function itemDetailText(item, snapshot, events) {
|
|
|
71
92
|
...(currentCandidate?.locator?.excerpt ? [` excerpt: ${currentCandidate.locator.excerpt}`] : []),
|
|
72
93
|
``,
|
|
73
94
|
...(proposedCandidates.length > 1
|
|
74
|
-
? [`Conflict: ${proposedCandidates.length} proposed values. Accept is refused; reject them all or use could-not-confirm with a reason.`]
|
|
95
|
+
? [`Conflict: ${proposedCandidates.length} proposed values. Accept is refused; choose one with decision "select" and its candidateId, reject them all, or use could-not-confirm with a reason.`]
|
|
96
|
+
: []),
|
|
97
|
+
...(chosenId !== undefined
|
|
98
|
+
? [`Chosen value: ${valueStr(proposedCandidates.find((c) => c.id === chosenId)?.value)} (candidate ${chosenId}); not chosen: ${proposedCandidates.filter((c) => c.id !== chosenId).map((c) => `${valueStr(c.value)} (candidate ${c.id})`).join(", ")}`]
|
|
75
99
|
: []),
|
|
76
100
|
...(proposedCandidates.length === 0 ? [`Proposed value: (none)`] : proposedCandidates.flatMap((candidate) => [
|
|
77
|
-
`Proposed value: ${valueStr(candidate.value)}`,
|
|
101
|
+
`Proposed value: ${valueStr(candidate.value)}${chosenId === undefined ? "" : candidate.id === chosenId ? " [chosen]" : " [not chosen]"}`,
|
|
102
|
+
...(proposedCandidates.length > 1 ? [` candidateId: ${candidate.id}`] : []),
|
|
78
103
|
` confidence: ${confStr(candidate.extraction?.confidence ?? candidate.confidence)}`,
|
|
79
104
|
` source: ${candidate.source?.sourceRef ?? "none"}`,
|
|
80
105
|
...(candidate.locator?.excerpt ? [` excerpt: ${candidate.locator.excerpt}`] : []),
|
|
@@ -95,8 +120,10 @@ function editedValueFor(item, state) {
|
|
|
95
120
|
function extractionImportLines(item) {
|
|
96
121
|
const presentation = buildReviewItemPresentation(item);
|
|
97
122
|
const excluded = excludedProposalsSentence(presentation.excludedProposals);
|
|
123
|
+
const unreadable = excludedProposalsUnreadableSentence(presentation.excludedProposalsUnreadable);
|
|
98
124
|
return [
|
|
99
125
|
...(excluded ? [``, `Excluded: ${excluded} Check the source before accepting.`] : []),
|
|
126
|
+
...(unreadable ? [``, `Excluded: ${unreadable} Check the source before accepting.`] : []),
|
|
100
127
|
...(presentation.excerptVerification ? [``, `Excerpts ${presentation.excerptVerification === "verified" ? "were" : "were not"} checked against the prepared source text at import.`] : []),
|
|
101
128
|
];
|
|
102
129
|
}
|
|
@@ -114,17 +141,19 @@ function escapeHtml(text) {
|
|
|
114
141
|
.replace(/>/g, ">")
|
|
115
142
|
.replace(/"/g, """);
|
|
116
143
|
}
|
|
117
|
-
function buildReviewCardHtml(item, snapshot, events) {
|
|
144
|
+
function buildReviewCardHtml(item, snapshot, events, attestation) {
|
|
118
145
|
const current = currentSessionState(snapshot, events);
|
|
119
146
|
const summary = reviewSessionSummary(current);
|
|
120
147
|
const total = current.items.length;
|
|
121
148
|
const resolved = total - summary.unresolved;
|
|
122
149
|
const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
|
|
123
150
|
const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
|
|
124
|
-
// Several proposed values are a conflict: every value is shown,
|
|
125
|
-
// (which names a role, not a value) is not offered
|
|
151
|
+
// Several proposed values are a conflict: every value is shown, accept
|
|
152
|
+
// (which names a role, not a value) is not offered, and each value has its
|
|
153
|
+
// own "Use this value" control that names its candidate id.
|
|
126
154
|
const conflict = proposedCandidates.length > 1;
|
|
127
155
|
const decision = current.decisionsByItemName[item.metadata.name];
|
|
156
|
+
const chosenId = decision === "select-proposed" ? current.selectedCandidateIdsByItemName?.[item.metadata.name] : undefined;
|
|
128
157
|
const status = deriveQueueRowStatus(item, current);
|
|
129
158
|
const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v, null, 2);
|
|
130
159
|
const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "—";
|
|
@@ -135,22 +164,32 @@ function buildReviewCardHtml(item, snapshot, events) {
|
|
|
135
164
|
const proposedCard = (candidate, label) => {
|
|
136
165
|
const value = valueStr(candidate?.value ?? "—");
|
|
137
166
|
const excerpt = candidate?.locator?.excerpt ?? "";
|
|
138
|
-
|
|
167
|
+
const choice = conflict && candidate
|
|
168
|
+
? chosenId !== undefined
|
|
169
|
+
? `<div class="choice ${candidate.id === chosenId ? "chosen" : "not-chosen"}" data-candidate-id="${escapeHtml(candidate.id)}">${candidate.id === chosenId ? "Chosen" : "Not chosen"}</div>`
|
|
170
|
+
: decision === undefined
|
|
171
|
+
? `<button class="btn btn-accept btn-select" data-candidate-id="${escapeHtml(candidate.id)}">Use this value</button>`
|
|
172
|
+
: ""
|
|
173
|
+
: "";
|
|
174
|
+
return `<div class="card is-proposed"${conflict && candidate ? ` data-candidate-id="${escapeHtml(candidate.id)}"` : ""}>
|
|
139
175
|
<div class="card-label">${escapeHtml(label)}</div>
|
|
140
176
|
<div class="value">${value.includes("\n") ? `<pre>${escapeHtml(value)}</pre>` : escapeHtml(value)}</div>
|
|
141
177
|
<div class="conf">confidence ${confStr(candidate?.extraction?.confidence ?? candidate?.confidence)}</div>
|
|
142
178
|
<div class="source-ref">${escapeHtml(candidate?.source?.sourceRef ?? "—")}</div>
|
|
143
179
|
${excerpt ? `<div class="excerpt">${escapeHtml(excerpt)}</div>` : ""}
|
|
180
|
+
${choice}
|
|
144
181
|
</div>`;
|
|
145
182
|
};
|
|
146
183
|
const proposedCards = conflict
|
|
147
184
|
? proposedCandidates.map((candidate, index) => proposedCard(candidate, `Proposed ${index + 1} of ${proposedCandidates.length}`)).join("\n ")
|
|
148
185
|
: proposedCard(proposedCandidates[0], "Proposed");
|
|
149
186
|
const itemNameJson = escapeJsonInHtml(item.metadata.name);
|
|
150
|
-
const
|
|
187
|
+
const itemPresentation = buildReviewItemPresentation(item);
|
|
188
|
+
const excludedNote = excludedProposalsSentence(itemPresentation.excludedProposals);
|
|
189
|
+
const unreadableNote = excludedProposalsUnreadableSentence(itemPresentation.excludedProposalsUnreadable);
|
|
151
190
|
const verificationNotes = candidateVerificationNotes(item, editedValueFor(item, current));
|
|
152
191
|
const decisionBadge = decision
|
|
153
|
-
? `<span class="badge badge-${decision === "accept-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(workbenchDecisionDefinitions[decision].label)}</span>`
|
|
192
|
+
? `<span class="badge badge-${decision === "accept-proposed" || decision === "select-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(decision === "select-proposed" ? `Chose 1 of ${proposedCandidates.length} values` : workbenchDecisionDefinitions[decision].label)}</span>`
|
|
154
193
|
: `<span class="badge badge-pending">${escapeHtml(status)}</span>`;
|
|
155
194
|
return `<!doctype html>
|
|
156
195
|
<html lang="en">
|
|
@@ -228,9 +267,15 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
228
267
|
.btn-reject:hover,.btn-reject.active{background:color-mix(in srgb,var(--k-negative) 16%,transparent);border-color:var(--k-negative);color:var(--k-negative)}
|
|
229
268
|
.btn-unconfirmed:hover,.btn-unconfirmed.active{background:color-mix(in srgb,var(--k-caution) 16%,transparent);border-color:var(--k-caution);color:var(--k-caution)}
|
|
230
269
|
.feedback{font-size:11px;color:var(--k-text-faint);margin-top:8px;min-height:16px}
|
|
270
|
+
.btn-select{width:100%;margin-top:8px}
|
|
271
|
+
.choice{font-family:var(--k-font-mono);font-size:10px;font-weight:600;margin-top:8px;text-transform:uppercase;letter-spacing:.06em}
|
|
272
|
+
.choice.chosen{color:var(--k-positive)}
|
|
273
|
+
.choice.not-chosen{color:var(--k-text-faint)}
|
|
274
|
+
.notice{font-size:11px;color:var(--k-caution);background:color-mix(in srgb,var(--k-caution) 12%,transparent);border-radius:var(--k-radius-sm);padding:7px 10px;margin:0 0 12px}
|
|
231
275
|
</style>
|
|
232
276
|
</head>
|
|
233
277
|
<body>
|
|
278
|
+
${attestation.state === "unverified" ? `<p class="notice" id="unverified-queue-note">${escapeHtml(attestation.message)}</p>` : ""}
|
|
234
279
|
<p class="eyebrow">Survey Review</p>
|
|
235
280
|
<h1>${escapeHtml(item.spec.target)}</h1>
|
|
236
281
|
<div class="meta">
|
|
@@ -251,8 +296,11 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
251
296
|
${proposedCards}
|
|
252
297
|
</div>
|
|
253
298
|
${excludedNote ? `<p class="feedback" id="excluded-note">${escapeHtml(excludedNote)}</p>` : ""}
|
|
299
|
+
${unreadableNote ? `<p class="feedback" id="excluded-unreadable-note">${escapeHtml(unreadableNote)}</p>` : ""}
|
|
254
300
|
${verificationNotes.map((entry) => `<p class="feedback verification-note" data-candidate-id="${escapeHtml(entry.candidateId)}">${escapeHtml(entry.sentence)}</p>`).join("\n")}
|
|
255
|
-
${conflict ? `<p class="feedback" id="conflict-note">${
|
|
301
|
+
${conflict ? `<p class="feedback" id="conflict-note">${escapeHtml(chosenId !== undefined
|
|
302
|
+
? `${proposedCandidates.length} different values were proposed. One was chosen; ${proposedCandidates.length === 2 ? "the other was" : "the others were"} seen and not chosen.`
|
|
303
|
+
: `${proposedCandidates.length} different values were proposed. Use the value the source supports, reject them all, or use Could not confirm with a reason.`)}</p>` : ""}
|
|
256
304
|
|
|
257
305
|
<div class="divider"></div>
|
|
258
306
|
|
|
@@ -272,7 +320,7 @@ ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length
|
|
|
272
320
|
var itemName = ${itemNameJson};
|
|
273
321
|
var msgId = 1;
|
|
274
322
|
|
|
275
|
-
function postDecision(decision) {
|
|
323
|
+
function postDecision(decision, candidateId) {
|
|
276
324
|
var note = document.getElementById('note').value;
|
|
277
325
|
if (decision === 'could-not-confirm' && !note.trim()) {
|
|
278
326
|
document.getElementById('feedback').textContent = 'A reason is required when you could not confirm.';
|
|
@@ -287,12 +335,18 @@ ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length
|
|
|
287
335
|
name: "survey_review_decide",
|
|
288
336
|
arguments: decision === 'could-not-confirm'
|
|
289
337
|
? { itemName: itemName, decision: decision, reason: note }
|
|
290
|
-
:
|
|
338
|
+
: decision === 'select'
|
|
339
|
+
? { itemName: itemName, decision: decision, candidateId: candidateId, note: note || undefined }
|
|
340
|
+
: { itemName: itemName, decision: decision, note: note || undefined }
|
|
291
341
|
}
|
|
292
342
|
}, "*");
|
|
293
343
|
return true;
|
|
294
344
|
}
|
|
295
345
|
|
|
346
|
+
Array.prototype.forEach.call(document.querySelectorAll('.btn-select'), function (button) {
|
|
347
|
+
button.addEventListener('click', function () { postDecision('select', button.getAttribute('data-candidate-id')); document.getElementById('feedback').textContent = 'Submitting choice…'; });
|
|
348
|
+
});
|
|
349
|
+
|
|
296
350
|
var acceptButton = document.getElementById('btn-accept');
|
|
297
351
|
if (acceptButton) acceptButton.addEventListener('click', function () { postDecision('accept'); document.getElementById('feedback').textContent = 'Submitting accept…'; });
|
|
298
352
|
var holdButton = document.getElementById('btn-hold');
|
|
@@ -318,16 +372,21 @@ ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length
|
|
|
318
372
|
// ---- Tool implementations ------------------------------------------------
|
|
319
373
|
async function toolQueue(options) {
|
|
320
374
|
const file = await readSessionFile(options.sessionPath);
|
|
321
|
-
const { snapshot, events } = file;
|
|
322
|
-
const text = queueSummaryText(snapshot, events);
|
|
375
|
+
const { snapshot, events, attestation } = file;
|
|
376
|
+
const text = queueSummaryText(snapshot, events, attestation);
|
|
323
377
|
const queueData = {
|
|
378
|
+
queueAttestation: attestation.state,
|
|
324
379
|
items: snapshot.items.map((item) => {
|
|
325
380
|
const current = currentSessionState(snapshot, events);
|
|
381
|
+
const selectedCandidateId = current.decisionsByItemName[item.metadata.name] === "select-proposed"
|
|
382
|
+
? current.selectedCandidateIdsByItemName?.[item.metadata.name]
|
|
383
|
+
: undefined;
|
|
326
384
|
return {
|
|
327
385
|
name: item.metadata.name,
|
|
328
386
|
target: item.spec.target,
|
|
329
387
|
status: deriveQueueRowStatus(item, current),
|
|
330
388
|
decision: current.decisionsByItemName[item.metadata.name],
|
|
389
|
+
...(selectedCandidateId !== undefined ? { selectedCandidateId } : {}),
|
|
331
390
|
candidateSetStatus: item.spec.candidateSetStatus,
|
|
332
391
|
};
|
|
333
392
|
}),
|
|
@@ -339,24 +398,28 @@ async function toolQueue(options) {
|
|
|
339
398
|
];
|
|
340
399
|
if (!options.noUi) {
|
|
341
400
|
const activeItem = currentReviewItem(currentSessionState(snapshot, events));
|
|
342
|
-
content.push(buildUiResource(activeItem, snapshot, events, "queue"));
|
|
401
|
+
content.push(buildUiResource(activeItem, snapshot, events, "queue", attestation));
|
|
343
402
|
}
|
|
344
403
|
return content;
|
|
345
404
|
}
|
|
346
405
|
async function toolItem(itemName, options) {
|
|
347
406
|
const file = await readSessionFile(options.sessionPath);
|
|
348
|
-
const { snapshot, events } = file;
|
|
407
|
+
const { snapshot, events, attestation } = file;
|
|
349
408
|
const current = currentSessionState(snapshot, events);
|
|
350
409
|
const item = current.items.find((i) => i.metadata.name === itemName);
|
|
351
410
|
if (!item) {
|
|
352
411
|
throw new DomainError(`Unknown review item: ${itemName}`);
|
|
353
412
|
}
|
|
354
|
-
const text = itemDetailText(item, snapshot, events);
|
|
413
|
+
const text = itemDetailText(item, snapshot, events, attestation);
|
|
414
|
+
const decision = current.decisionsByItemName[item.metadata.name];
|
|
415
|
+
const selectedCandidateId = decision === "select-proposed" ? current.selectedCandidateIdsByItemName?.[item.metadata.name] : undefined;
|
|
355
416
|
const itemData = {
|
|
417
|
+
queueAttestation: attestation.state,
|
|
356
418
|
name: item.metadata.name,
|
|
357
419
|
target: item.spec.target,
|
|
358
420
|
status: deriveQueueRowStatus(item, current),
|
|
359
|
-
decision
|
|
421
|
+
decision,
|
|
422
|
+
...(selectedCandidateId !== undefined ? { selectedCandidateId } : {}),
|
|
360
423
|
note: current.notesByItemName[item.metadata.name],
|
|
361
424
|
candidateSetStatus: item.spec.candidateSetStatus,
|
|
362
425
|
...(() => {
|
|
@@ -364,6 +427,7 @@ async function toolItem(itemName, options) {
|
|
|
364
427
|
return {
|
|
365
428
|
...(presentation.excerptVerification ? { excerptVerification: presentation.excerptVerification } : {}),
|
|
366
429
|
...(presentation.excludedProposals.length ? { excludedProposals: presentation.excludedProposals.map(({ proposalIndex, value, locator, excerpt }) => ({ proposalIndex, value, locator, excerpt })) } : {}),
|
|
430
|
+
...(presentation.excludedProposalsUnreadable ? { excludedProposalsUnreadable: presentation.excludedProposalsUnreadable } : {}),
|
|
367
431
|
};
|
|
368
432
|
})(),
|
|
369
433
|
candidates: (() => {
|
|
@@ -377,6 +441,7 @@ async function toolItem(itemName, options) {
|
|
|
377
441
|
confidence: c.extraction?.confidence ?? c.confidence,
|
|
378
442
|
sourceRef: c.source?.sourceRef,
|
|
379
443
|
excerpt: c.locator?.excerpt,
|
|
444
|
+
...(selectedCandidateId !== undefined && c.role === "proposed" ? { chosen: c.id === selectedCandidateId } : {}),
|
|
380
445
|
...(note ? { verification: { subject: note.subjectLabel, status: note.status, records: note.records, inapplicableCount: note.inapplicableCount, rejectedCount: note.rejectedCount } } : {}),
|
|
381
446
|
};
|
|
382
447
|
});
|
|
@@ -386,22 +451,23 @@ async function toolItem(itemName, options) {
|
|
|
386
451
|
{ type: "text", text: `${text}\n\n${JSON.stringify(itemData, null, 2)}` },
|
|
387
452
|
];
|
|
388
453
|
if (!options.noUi) {
|
|
389
|
-
content.push(buildUiResource(item, snapshot, events, itemName));
|
|
454
|
+
content.push(buildUiResource(item, snapshot, events, itemName, attestation));
|
|
390
455
|
}
|
|
391
456
|
return content;
|
|
392
457
|
}
|
|
393
|
-
async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, options) {
|
|
458
|
+
async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, options, selectedCandidateId) {
|
|
394
459
|
const wbDecision = MCP_DECISION_MAP[mcpDecision];
|
|
395
460
|
if (!wbDecision) {
|
|
396
|
-
throw new DomainError(`Invalid decision: ${mcpDecision}. Must be accept, hold, reject, or could-not-confirm.`);
|
|
461
|
+
throw new DomainError(`Invalid decision: ${mcpDecision}. Must be accept, select, hold, reject, or could-not-confirm.`);
|
|
397
462
|
}
|
|
398
463
|
if (wbDecision === "could-not-confirm" && !note?.trim()) {
|
|
399
464
|
throw new DomainError("survey_review_decide requires a non-empty reason for could-not-confirm");
|
|
400
465
|
}
|
|
401
466
|
// Read, validate and write inside the shared session lock so a concurrent
|
|
402
467
|
// decide or console save cannot interleave and drop this decision (#281).
|
|
403
|
-
const { snapshot, sessionWithDecision, newEvents } = await updateReviewSessionFile(options.sessionPath, (file) => {
|
|
468
|
+
const { snapshot, sessionWithDecision, newEvents, attestation } = await updateReviewSessionFile(options.sessionPath, (file) => {
|
|
404
469
|
const { snapshot, events } = file;
|
|
470
|
+
const attestation = attestedQueue(file);
|
|
405
471
|
const current = currentSessionState(snapshot, events);
|
|
406
472
|
const item = current.items.find((i) => i.metadata.name === itemName);
|
|
407
473
|
if (!item) {
|
|
@@ -411,6 +477,12 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
|
|
|
411
477
|
if (existingDecision) {
|
|
412
478
|
throw new DomainError(`Item ${itemName} already has a decision: ${existingDecision}. Use a new session to re-decide.`);
|
|
413
479
|
}
|
|
480
|
+
if (wbDecision === "select-proposed") {
|
|
481
|
+
const selectionIssue = conflictSelectionIssue(item, selectedCandidateId);
|
|
482
|
+
if (selectionIssue) {
|
|
483
|
+
throw new DomainError(selectionIssue);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
414
486
|
// Build the updated session state with the decision
|
|
415
487
|
const sessionWithDecision = {
|
|
416
488
|
...current,
|
|
@@ -434,6 +506,14 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
|
|
|
434
506
|
},
|
|
435
507
|
}
|
|
436
508
|
: {}),
|
|
509
|
+
...(wbDecision === "select-proposed" && selectedCandidateId !== undefined
|
|
510
|
+
? {
|
|
511
|
+
selectedCandidateIdsByItemName: {
|
|
512
|
+
...current.selectedCandidateIdsByItemName,
|
|
513
|
+
[itemName]: selectedCandidateId,
|
|
514
|
+
},
|
|
515
|
+
}
|
|
516
|
+
: {}),
|
|
437
517
|
};
|
|
438
518
|
// Append only this decision's events (its note, then the decision) to the
|
|
439
519
|
// stored log. Regenerating the whole log from state would erase earlier
|
|
@@ -455,26 +535,36 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
|
|
|
455
535
|
record,
|
|
456
536
|
events: newEvents,
|
|
457
537
|
requiredResolvedItems: "none",
|
|
538
|
+
extractionImport: file.extractionImport,
|
|
458
539
|
});
|
|
459
540
|
if (!applyResult.ok) {
|
|
460
541
|
throw new DomainError(`Decision validation failed: ${applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue)).join("; ")}`);
|
|
461
542
|
}
|
|
462
543
|
const updatedFile = {
|
|
544
|
+
...file,
|
|
463
545
|
session: file.session,
|
|
464
546
|
snapshot,
|
|
465
547
|
events: newEvents,
|
|
466
548
|
};
|
|
467
|
-
return { next: updatedFile, result: { snapshot, sessionWithDecision, newEvents } };
|
|
549
|
+
return { next: updatedFile, result: { snapshot, sessionWithDecision, newEvents, attestation } };
|
|
468
550
|
});
|
|
469
551
|
// Summarize the result
|
|
470
552
|
const updatedItem = sessionWithDecision.items.find((i) => i.metadata.name === itemName);
|
|
471
|
-
const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents) : `Item: ${itemName}`;
|
|
472
|
-
const remainingText = queueSummaryText(snapshot, newEvents);
|
|
553
|
+
const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents, attestation) : `Item: ${itemName}`;
|
|
554
|
+
const remainingText = queueSummaryText(snapshot, newEvents, attestation);
|
|
473
555
|
const definition = workbenchDecisionDefinitions[wbDecision];
|
|
474
556
|
const conflictRejected = wbDecision === "reject-proposed" && updatedItem !== undefined && decisionSelectsNoCandidate(updatedItem, wbDecision);
|
|
557
|
+
const valueStr = (v) => (typeof v === "string" ? v : JSON.stringify(v));
|
|
558
|
+
const proposed = updatedItem?.spec.candidates.filter((c) => c.role === "proposed") ?? [];
|
|
559
|
+
const chosen = proposed.find((c) => c.id === selectedCandidateId);
|
|
560
|
+
const effect = conflictRejected
|
|
561
|
+
? "Every proposed value is rejected; none becomes the claim's value."
|
|
562
|
+
: wbDecision === "select-proposed"
|
|
563
|
+
? `${valueStr(chosen?.value)} becomes the verified value; not chosen: ${proposed.filter((c) => c.id !== selectedCandidateId).map((c) => valueStr(c.value)).join(", ")}.`
|
|
564
|
+
: definition.effect;
|
|
475
565
|
const text = [
|
|
476
566
|
`Decision recorded: ${conflictRejected ? "Reject all values" : definition.label}`,
|
|
477
|
-
`Effect: ${
|
|
567
|
+
`Effect: ${effect}`,
|
|
478
568
|
"",
|
|
479
569
|
itemText,
|
|
480
570
|
"",
|
|
@@ -483,13 +573,13 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
|
|
|
483
573
|
].join("\n");
|
|
484
574
|
return [{ type: "text", text }];
|
|
485
575
|
}
|
|
486
|
-
function buildUiResource(item, snapshot, events, instance) {
|
|
576
|
+
function buildUiResource(item, snapshot, events, instance, attestation) {
|
|
487
577
|
return {
|
|
488
578
|
type: "resource",
|
|
489
579
|
resource: {
|
|
490
580
|
uri: `ui://survey/review-card/${encodeURIComponent(instance)}`,
|
|
491
581
|
mimeType: "text/html;profile=mcp-app",
|
|
492
|
-
text: buildReviewCardHtml(item, snapshot, events),
|
|
582
|
+
text: buildReviewCardHtml(item, snapshot, events, attestation),
|
|
493
583
|
_meta: {
|
|
494
584
|
ui: {
|
|
495
585
|
csp: {
|
|
@@ -505,10 +595,10 @@ function buildUiResource(item, snapshot, events, instance) {
|
|
|
505
595
|
// Render the declared review card from the same function used by embedded tool
|
|
506
596
|
// results, so Apps and text-first hosts cannot drift.
|
|
507
597
|
async function readQueuePanelResource(options) {
|
|
508
|
-
const { snapshot, events } = await readSessionFile(options.sessionPath);
|
|
598
|
+
const { snapshot, events, attestation } = await readSessionFile(options.sessionPath);
|
|
509
599
|
const current = currentSessionState(snapshot, events);
|
|
510
600
|
const activeItem = currentReviewItem(current);
|
|
511
|
-
return buildUiResource(activeItem, snapshot, events, "queue").resource;
|
|
601
|
+
return buildUiResource(activeItem, snapshot, events, "queue", attestation).resource;
|
|
512
602
|
}
|
|
513
603
|
// ---- Domain error (maps to isError:true, not a JSON-RPC error) -----------
|
|
514
604
|
class DomainError extends Error {
|
|
@@ -557,7 +647,7 @@ function createReviewMcpServer(options, serverVersion) {
|
|
|
557
647
|
}, async ({ itemName }) => runReviewTool(() => toolItem(itemName, options)));
|
|
558
648
|
server.registerTool("survey_review_decide", {
|
|
559
649
|
title: "Record a review decision",
|
|
560
|
-
description: "Apply a decision to a review item and persist it through Survey's server-owned validation boundary. Decision must be accept, hold, reject, or could-not-confirm. Could-not-confirm requires a reason. Domain failures return isError:true.",
|
|
650
|
+
description: "Apply a decision to a review item and persist it through Survey's server-owned validation boundary. Decision must be accept, select, hold, reject, or could-not-confirm. Select chooses one value of a conflict (several proposed values) by candidateId. Could-not-confirm requires a reason. Domain failures return isError:true.",
|
|
561
651
|
inputSchema: z.discriminatedUnion("decision", [
|
|
562
652
|
z.object({
|
|
563
653
|
itemName: z.string().min(1).describe("The ReviewItem name to decide."),
|
|
@@ -581,10 +671,23 @@ function createReviewMcpServer(options, serverVersion) {
|
|
|
581
671
|
.optional()
|
|
582
672
|
.describe("Evidence ids attempted before a could-not-confirm decision."),
|
|
583
673
|
}),
|
|
674
|
+
z.object({
|
|
675
|
+
itemName: z.string().min(1).describe("The ReviewItem name to decide."),
|
|
676
|
+
decision: z
|
|
677
|
+
.literal("select")
|
|
678
|
+
.describe("select = select-proposed: choose one value of a conflict; the other proposed values are recorded as seen and not chosen."),
|
|
679
|
+
candidateId: z
|
|
680
|
+
.string()
|
|
681
|
+
.min(1)
|
|
682
|
+
.describe("The id of the proposed candidate to use, from survey_review_item."),
|
|
683
|
+
note: z.string().optional().describe("Optional reviewer note or rationale."),
|
|
684
|
+
}),
|
|
584
685
|
]),
|
|
585
686
|
}, async (input) => runReviewTool(() => input.decision === "could-not-confirm"
|
|
586
687
|
? toolDecide(input.itemName, input.decision, input.reason, input.attemptEvidenceIds, options)
|
|
587
|
-
:
|
|
688
|
+
: input.decision === "select"
|
|
689
|
+
? toolDecide(input.itemName, input.decision, input.note, undefined, options, input.candidateId)
|
|
690
|
+
: toolDecide(input.itemName, input.decision, input.note, undefined, options)));
|
|
588
691
|
if (!options.noUi) {
|
|
589
692
|
server.registerResource("survey-review-workbench", QUEUE_PANEL_URI, {
|
|
590
693
|
title: "Survey review workbench",
|
|
@@ -200,6 +200,13 @@ export interface ReviewDecisionSpec {
|
|
|
200
200
|
* Additive/optional: absent means the candidate's original value was used unchanged.
|
|
201
201
|
*/
|
|
202
202
|
editedValue?: unknown;
|
|
203
|
+
/**
|
|
204
|
+
* The proposed candidates a `select-proposed` decision saw and did not
|
|
205
|
+
* choose, when the reviewer chose one value of a conflict (`candidateId` is
|
|
206
|
+
* the chosen one). Additive/optional: absent on every other decision, and on
|
|
207
|
+
* decisions recorded before choosing between conflicting values existed.
|
|
208
|
+
*/
|
|
209
|
+
unselectedCandidateIds?: string[];
|
|
203
210
|
}
|
|
204
211
|
export interface ReviewDecisionStatus {
|
|
205
212
|
appliedToClaimIds?: string[];
|
|
@@ -14,6 +14,13 @@ export interface ReviewSessionFileContent {
|
|
|
14
14
|
readonly session: unknown;
|
|
15
15
|
readonly snapshot: ReviewQueueSessionState;
|
|
16
16
|
readonly events: readonly ReviewSessionEvent[];
|
|
17
|
+
/**
|
|
18
|
+
* The extraction import record (`ExtractionEnvelopeImport`) the snapshot's
|
|
19
|
+
* items were built from, stored beside them so every reload path can check
|
|
20
|
+
* the queue against it. Optional: a session without one whose items came
|
|
21
|
+
* from an import is presented as an unverified queue.
|
|
22
|
+
*/
|
|
23
|
+
readonly extractionImport?: unknown;
|
|
17
24
|
}
|
|
18
25
|
export interface ReviewSessionFileLockOptions {
|
|
19
26
|
/** How long to wait for a busy lock before failing. Defaults to 10s. */
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* it is a duplicate placement or a constant, but a key is never renamed or
|
|
26
26
|
* repointed at a different fact. Hosts may hold selectors against these values.
|
|
27
27
|
*/
|
|
28
|
-
export declare const reviewAuditRowKeys: readonly ["current-candidate-id", "proposed-candidate-id", "claim-id", "raw-source-id", "locator", "model", "extractor", "extracted-at", "excluded-proposal", "history-value", "candidate-id", "source-reference", "excerpt", "observed", "source-authority-class", "declared-by", "authority-scope", "extraction-id", "actor", "reviewed-at", "status", "rationale", "outcome", "checksum", "candidate-set-id", "authority-trace-status", "authority-trace-detail"];
|
|
28
|
+
export declare const reviewAuditRowKeys: readonly ["current-candidate-id", "proposed-candidate-id", "claim-id", "raw-source-id", "locator", "model", "extractor", "extracted-at", "excluded-proposal", "excluded-proposals-unreadable", "history-value", "candidate-id", "source-reference", "excerpt", "observed", "source-authority-class", "declared-by", "authority-scope", "extraction-id", "actor", "reviewed-at", "status", "rationale", "outcome", "checksum", "candidate-set-id", "authority-trace-status", "authority-trace-detail"];
|
|
29
29
|
/** One of {@link reviewAuditRowKeys}. */
|
|
30
30
|
export type ReviewAuditRowKey = (typeof reviewAuditRowKeys)[number];
|
|
31
31
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReviewQueueSessionState } from "./review-queue-session.js";
|
|
2
2
|
import { reviewResourceApiVersion, type ReviewItem } from "../review-resource.js";
|
|
3
|
-
import { type ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
|
|
3
|
+
import { type ExtractionEnvelopeImport, type ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
|
|
4
4
|
/**
|
|
5
5
|
* The durable attestation record. Serializable; a consumer stores it beside the
|
|
6
6
|
* queue when the round opens and presents it, unchanged, at every later
|
|
@@ -78,7 +78,7 @@ export declare function bindReviewQueue(snapshot: ReviewQueueSessionState, optio
|
|
|
78
78
|
*/
|
|
79
79
|
export declare function validateReviewQueueBinding(binding: ReviewQueueBinding, snapshot: ReviewQueueSessionState, options?: ValidateReviewQueueBindingOptions): ReviewQueueBindingIssue[];
|
|
80
80
|
export declare function assertReviewQueueBinding(binding: ReviewQueueBinding, snapshot: ReviewQueueSessionState, options?: ValidateReviewQueueBindingOptions): void;
|
|
81
|
-
export type ReviewQueueExtractionIssueCode = "import-not-grounded" | "empty-queue" | "item-missing-from-queue" | "item-not-in-extraction" | "item-diverges-from-extraction";
|
|
81
|
+
export type ReviewQueueExtractionIssueCode = "import-invalid" | "import-not-grounded" | "empty-queue" | "item-missing-from-queue" | "item-not-in-extraction" | "item-diverges-from-extraction";
|
|
82
82
|
export interface ReviewQueueExtractionIssue {
|
|
83
83
|
readonly code: ReviewQueueExtractionIssueCode;
|
|
84
84
|
readonly message: string;
|
|
@@ -124,4 +124,47 @@ export declare class UnattestedExtractionQueueError extends Error {
|
|
|
124
124
|
* cross-check the label.
|
|
125
125
|
*/
|
|
126
126
|
export declare function validateReviewQueueAgainstExtractionImport(items: readonly ReviewItem[], importResult: ExtractionEnvelopeImportResult): ReviewQueueExtractionIssue[];
|
|
127
|
+
/**
|
|
128
|
+
* What a reload path can say about a stored queue and the extraction import it
|
|
129
|
+
* came from:
|
|
130
|
+
*
|
|
131
|
+
* - `attested`: an import record was supplied and the queue matches it (see
|
|
132
|
+
* {@link validateReviewQueueAgainstExtractionImport}).
|
|
133
|
+
* - `diverges`: an import record was supplied and the queue does not match it,
|
|
134
|
+
* or the record itself is invalid. The queue must not be reviewed.
|
|
135
|
+
* - `unverified`: items carry the extraction-envelope binding but no import
|
|
136
|
+
* record was supplied, so nothing checked them. A rival value deleted from
|
|
137
|
+
* the stored queue would not show; every surface says so.
|
|
138
|
+
* - `not-extraction`: no item carries the binding and no record was supplied;
|
|
139
|
+
* there is no import to check against.
|
|
140
|
+
*
|
|
141
|
+
* Detection reads the binding the import writes on every item and candidate.
|
|
142
|
+
* A writer that strips it from every item also strips every other extraction
|
|
143
|
+
* fact from the queue; keeping the stored import record beside the queue is
|
|
144
|
+
* what makes a reload path able to check it.
|
|
145
|
+
*/
|
|
146
|
+
export type ReviewQueueExtractionAttestation = {
|
|
147
|
+
readonly state: "attested";
|
|
148
|
+
} | {
|
|
149
|
+
readonly state: "not-extraction";
|
|
150
|
+
} | {
|
|
151
|
+
readonly state: "unverified";
|
|
152
|
+
readonly itemNames: readonly string[];
|
|
153
|
+
readonly message: string;
|
|
154
|
+
} | {
|
|
155
|
+
readonly state: "diverges";
|
|
156
|
+
readonly issues: readonly ReviewQueueExtractionIssue[];
|
|
157
|
+
readonly message: string;
|
|
158
|
+
};
|
|
159
|
+
/** Whether a ReviewItem, or any of its candidates, carries the extraction-envelope binding. */
|
|
160
|
+
export declare function reviewItemCarriesExtractionBinding(item: ReviewItem): boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Check a queue a reload path is about to present against its extraction
|
|
163
|
+
* import. Pass the import record (or import result) stored beside the queue;
|
|
164
|
+
* pass `undefined` when the path has none, and the result says whether that
|
|
165
|
+
* leaves the queue unverified.
|
|
166
|
+
*/
|
|
167
|
+
export declare function attestReviewQueueExtraction(items: readonly ReviewItem[], extractionImport: ExtractionEnvelopeImport | ExtractionEnvelopeImportResult | undefined): ReviewQueueExtractionAttestation;
|
|
168
|
+
/** The notice every surface shows for an `unverified` queue. */
|
|
169
|
+
export declare function unverifiedExtractionQueueSentence(itemCount: number): string;
|
|
127
170
|
export declare function assertReviewQueueAgainstExtractionImport(items: readonly ReviewItem[], importResult: ExtractionEnvelopeImportResult): void;
|
|
@@ -276,6 +276,51 @@ export function validateReviewQueueAgainstExtractionImport(items, importResult)
|
|
|
276
276
|
}
|
|
277
277
|
return issues;
|
|
278
278
|
}
|
|
279
|
+
const EXTRACTION_ENVELOPE_PRODUCER = "survey.kontourai.io/extraction-envelope";
|
|
280
|
+
/** Whether a ReviewItem, or any of its candidates, carries the extraction-envelope binding. */
|
|
281
|
+
export function reviewItemCarriesExtractionBinding(item) {
|
|
282
|
+
const bound = (producer) => producer !== undefined && producer !== null && typeof producer === "object" && Object.hasOwn(producer, EXTRACTION_ENVELOPE_PRODUCER);
|
|
283
|
+
return bound(item.metadata?.producer) || (Array.isArray(item.spec?.candidates) && item.spec.candidates.some((candidate) => bound(candidate?.producer)));
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Check a queue a reload path is about to present against its extraction
|
|
287
|
+
* import. Pass the import record (or import result) stored beside the queue;
|
|
288
|
+
* pass `undefined` when the path has none, and the result says whether that
|
|
289
|
+
* leaves the queue unverified.
|
|
290
|
+
*/
|
|
291
|
+
export function attestReviewQueueExtraction(items, extractionImport) {
|
|
292
|
+
if (extractionImport === undefined || extractionImport === null) {
|
|
293
|
+
const itemNames = items.filter(reviewItemCarriesExtractionBinding).map((item) => item.metadata.name);
|
|
294
|
+
return itemNames.length === 0
|
|
295
|
+
? { state: "not-extraction" }
|
|
296
|
+
: { state: "unverified", itemNames, message: unverifiedExtractionQueueSentence(itemNames.length) };
|
|
297
|
+
}
|
|
298
|
+
const record = isImportResult(extractionImport) ? extractionImport.record : extractionImport;
|
|
299
|
+
let issues;
|
|
300
|
+
try {
|
|
301
|
+
issues = validateReviewQueueAgainstExtractionImport(items, { record, reviewItems: [] });
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
issues = [{
|
|
305
|
+
code: "import-invalid",
|
|
306
|
+
message: `The stored extraction import is invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
307
|
+
}];
|
|
308
|
+
}
|
|
309
|
+
return issues.length === 0
|
|
310
|
+
? { state: "attested" }
|
|
311
|
+
: {
|
|
312
|
+
state: "diverges",
|
|
313
|
+
issues,
|
|
314
|
+
message: `This review queue does not match the extraction import stored with it, so it cannot be reviewed: ${issues.map((issue) => issue.message).join(" ")}`,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/** The notice every surface shows for an `unverified` queue. */
|
|
318
|
+
export function unverifiedExtractionQueueSentence(itemCount) {
|
|
319
|
+
return `Unverified queue: ${itemCount === 1 ? "1 item came" : `${itemCount} items came`} from an extraction import, but no import record was stored with this queue, so its items were not checked against it. A rival value removed from the stored queue would not be shown.`;
|
|
320
|
+
}
|
|
321
|
+
function isImportResult(value) {
|
|
322
|
+
return typeof value === "object" && value !== null && "record" in value && !("kind" in value);
|
|
323
|
+
}
|
|
279
324
|
export function assertReviewQueueAgainstExtractionImport(items, importResult) {
|
|
280
325
|
const issues = validateReviewQueueAgainstExtractionImport(items, importResult);
|
|
281
326
|
if (issues.length > 0) {
|