@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
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { readFile
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
2
|
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
|
+
import { appendReviewSessionEvents, readReviewSessionFile, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
|
|
8
9
|
const SESSION_NAME = "mcp-review-session";
|
|
9
10
|
const UI_RESOURCE_URI_META_KEY = "ui/resourceUri";
|
|
10
11
|
const UI_CAPABILITY_EXTENSION = "io.modelcontextprotocol/ui";
|
|
@@ -19,13 +20,7 @@ const MCP_DECISION_MAP = {
|
|
|
19
20
|
"could-not-confirm": "could-not-confirm",
|
|
20
21
|
};
|
|
21
22
|
async function readSessionFile(path) {
|
|
22
|
-
|
|
23
|
-
return JSON.parse(raw);
|
|
24
|
-
}
|
|
25
|
-
async function writeSessionFileAtomic(path, content) {
|
|
26
|
-
const tmp = `${path}.tmp`;
|
|
27
|
-
await writeFile(tmp, JSON.stringify(content, null, 2), "utf8");
|
|
28
|
-
await rename(tmp, path);
|
|
23
|
+
return readReviewSessionFile(path);
|
|
29
24
|
}
|
|
30
25
|
// ---- Queue helpers -------------------------------------------------------
|
|
31
26
|
function queueSummaryText(snapshot, events) {
|
|
@@ -58,7 +53,7 @@ function itemDetailText(item, snapshot, events) {
|
|
|
58
53
|
const decision = current.decisionsByItemName[item.metadata.name];
|
|
59
54
|
const note = current.notesByItemName[item.metadata.name];
|
|
60
55
|
const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
|
|
61
|
-
const
|
|
56
|
+
const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
|
|
62
57
|
const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v);
|
|
63
58
|
const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "unknown";
|
|
64
59
|
const lines = [
|
|
@@ -74,10 +69,15 @@ function itemDetailText(item, snapshot, events) {
|
|
|
74
69
|
` source: ${currentCandidate?.source?.sourceRef ?? "none"}`,
|
|
75
70
|
...(currentCandidate?.locator?.excerpt ? [` excerpt: ${currentCandidate.locator.excerpt}`] : []),
|
|
76
71
|
``,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
...(
|
|
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
|
+
])),
|
|
81
81
|
];
|
|
82
82
|
if (item.spec.rationale) {
|
|
83
83
|
lines.push(``, `Rationale: ${item.spec.rationale}`);
|
|
@@ -104,19 +104,32 @@ function buildReviewCardHtml(item, snapshot, events) {
|
|
|
104
104
|
const total = current.items.length;
|
|
105
105
|
const resolved = total - summary.unresolved;
|
|
106
106
|
const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
|
|
107
|
-
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;
|
|
108
111
|
const decision = current.decisionsByItemName[item.metadata.name];
|
|
109
112
|
const status = deriveQueueRowStatus(item, current);
|
|
110
113
|
const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v, null, 2);
|
|
111
114
|
const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "—";
|
|
112
115
|
const currentValue = valueStr(currentCandidate?.value ?? "—");
|
|
113
|
-
const proposedValue = valueStr(proposedCandidate?.value ?? "—");
|
|
114
116
|
const currentConf = confStr(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence);
|
|
115
|
-
const proposedConf = confStr(proposedCandidate?.extraction?.confidence ?? proposedCandidate?.confidence);
|
|
116
117
|
const currentSource = currentCandidate?.source?.sourceRef ?? "—";
|
|
117
|
-
const proposedSource = proposedCandidate?.source?.sourceRef ?? "—";
|
|
118
118
|
const currentExcerpt = currentCandidate?.locator?.excerpt ?? "";
|
|
119
|
-
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");
|
|
120
133
|
const itemNameJson = escapeJsonInHtml(item.metadata.name);
|
|
121
134
|
const decisionBadge = decision
|
|
122
135
|
? `<span class="badge badge-${decision === "accept-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(workbenchDecisionDefinitions[decision].label)}</span>`
|
|
@@ -205,6 +218,7 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
205
218
|
<div class="meta">
|
|
206
219
|
<span>${escapeHtml(item.metadata.name)}</span>
|
|
207
220
|
${decisionBadge}
|
|
221
|
+
${conflict ? `<span class="badge badge-hold" id="conflict-badge">Conflict: ${proposedCandidates.length} values</span>` : ""}
|
|
208
222
|
<span class="progress">${resolved}/${total} resolved</span>
|
|
209
223
|
</div>
|
|
210
224
|
|
|
@@ -216,14 +230,9 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
216
230
|
<div class="source-ref">${escapeHtml(currentSource)}</div>
|
|
217
231
|
${currentExcerpt ? `<div class="excerpt">${escapeHtml(currentExcerpt)}</div>` : ""}
|
|
218
232
|
</div>
|
|
219
|
-
|
|
220
|
-
<div class="card-label">Proposed</div>
|
|
221
|
-
<div class="value">${proposedValue.includes("\n") ? `<pre>${escapeHtml(proposedValue)}</pre>` : escapeHtml(proposedValue)}</div>
|
|
222
|
-
<div class="conf">confidence ${proposedConf}</div>
|
|
223
|
-
<div class="source-ref">${escapeHtml(proposedSource)}</div>
|
|
224
|
-
${proposedExcerpt ? `<div class="excerpt">${escapeHtml(proposedExcerpt)}</div>` : ""}
|
|
225
|
-
</div>
|
|
233
|
+
${proposedCards}
|
|
226
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>` : ""}
|
|
227
236
|
|
|
228
237
|
<div class="divider"></div>
|
|
229
238
|
|
|
@@ -231,9 +240,9 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
231
240
|
<textarea class="note-input" id="note" placeholder="Add a rationale for this decision...">${escapeHtml(current.notesByItemName[item.metadata.name] ?? "")}</textarea>
|
|
232
241
|
|
|
233
242
|
<div class="btn-row">
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
<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>
|
|
237
246
|
<button class="btn btn-unconfirmed${decision === "could-not-confirm" ? " active" : ""}" id="btn-unconfirmed">Could not confirm</button>
|
|
238
247
|
</div>
|
|
239
248
|
<div class="feedback" id="feedback"></div>
|
|
@@ -264,8 +273,10 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
264
273
|
return true;
|
|
265
274
|
}
|
|
266
275
|
|
|
267
|
-
|
|
268
|
-
|
|
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…'; });
|
|
269
280
|
document.getElementById('btn-reject').addEventListener('click', function () { postDecision('reject'); document.getElementById('feedback').textContent = 'Submitting reject…'; });
|
|
270
281
|
document.getElementById('btn-unconfirmed').addEventListener('click', function () { if (postDecision('could-not-confirm')) document.getElementById('feedback').textContent = 'Submitting could not confirm…'; });
|
|
271
282
|
|
|
@@ -353,72 +364,83 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
|
|
|
353
364
|
if (wbDecision === "could-not-confirm" && !note?.trim()) {
|
|
354
365
|
throw new DomainError("survey_review_decide requires a non-empty reason for could-not-confirm");
|
|
355
366
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
...current
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
367
|
+
// Read, validate and write inside the shared session lock so a concurrent
|
|
368
|
+
// decide or console save cannot interleave and drop this decision (#281).
|
|
369
|
+
const { snapshot, sessionWithDecision, newEvents } = await updateReviewSessionFile(options.sessionPath, (file) => {
|
|
370
|
+
const { snapshot, events } = file;
|
|
371
|
+
const current = currentSessionState(snapshot, events);
|
|
372
|
+
const item = current.items.find((i) => i.metadata.name === itemName);
|
|
373
|
+
if (!item) {
|
|
374
|
+
throw new DomainError(`Unknown review item: ${itemName}`);
|
|
375
|
+
}
|
|
376
|
+
const existingDecision = current.decisionsByItemName[item.metadata.name];
|
|
377
|
+
if (existingDecision) {
|
|
378
|
+
throw new DomainError(`Item ${itemName} already has a decision: ${existingDecision}. Use a new session to re-decide.`);
|
|
379
|
+
}
|
|
380
|
+
// Build the updated session state with the decision
|
|
381
|
+
const sessionWithDecision = {
|
|
382
|
+
...current,
|
|
383
|
+
decisionsByItemName: {
|
|
384
|
+
...current.decisionsByItemName,
|
|
385
|
+
[itemName]: wbDecision,
|
|
386
|
+
},
|
|
387
|
+
...(note !== undefined
|
|
388
|
+
? {
|
|
389
|
+
notesByItemName: {
|
|
390
|
+
...current.notesByItemName,
|
|
391
|
+
[itemName]: note,
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
: {}),
|
|
395
|
+
...(attemptEvidenceIds?.length
|
|
396
|
+
? {
|
|
397
|
+
attemptEvidenceIdsByItemName: {
|
|
398
|
+
...current.attemptEvidenceIdsByItemName,
|
|
399
|
+
[itemName]: [...attemptEvidenceIds],
|
|
400
|
+
},
|
|
401
|
+
}
|
|
402
|
+
: {}),
|
|
403
|
+
};
|
|
404
|
+
// Append only this decision's events (its note, then the decision) to the
|
|
405
|
+
// stored log. Regenerating the whole log from state would erase earlier
|
|
406
|
+
// reversals and note changes recorded by the console (#281).
|
|
407
|
+
const sessionName = storedReviewSessionName(file, SESSION_NAME);
|
|
408
|
+
const decisionEvents = buildReviewSessionEvents(sessionWithDecision, sessionName).filter((event) => event.spec.reviewItemName === itemName
|
|
409
|
+
&& (event.spec.eventType === "decision-changed"
|
|
410
|
+
|| event.spec.eventType === "decision-submitted"
|
|
411
|
+
|| (event.spec.eventType === "note-changed" && note !== undefined)));
|
|
412
|
+
const newEvents = appendReviewSessionEvents(file, decisionEvents);
|
|
413
|
+
// Use the server session APIs for apply-path validation
|
|
414
|
+
const record = createServerReviewSessionRecord({
|
|
415
|
+
sessionName,
|
|
416
|
+
snapshot,
|
|
417
|
+
eventCount: events.length,
|
|
418
|
+
updatedAt: new Date(),
|
|
419
|
+
});
|
|
420
|
+
const applyResult = deriveServerReviewSessionApplyResult({
|
|
421
|
+
record,
|
|
422
|
+
events: newEvents,
|
|
423
|
+
requiredResolvedItems: "none",
|
|
424
|
+
});
|
|
425
|
+
if (!applyResult.ok) {
|
|
426
|
+
throw new DomainError(`Decision validation failed: ${applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue)).join("; ")}`);
|
|
427
|
+
}
|
|
428
|
+
const updatedFile = {
|
|
429
|
+
session: file.session,
|
|
430
|
+
snapshot,
|
|
431
|
+
events: newEvents,
|
|
432
|
+
};
|
|
433
|
+
return { next: updatedFile, result: { snapshot, sessionWithDecision, newEvents } };
|
|
403
434
|
});
|
|
404
|
-
if (!applyResult.ok) {
|
|
405
|
-
throw new DomainError(`Decision validation failed: ${applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue)).join("; ")}`);
|
|
406
|
-
}
|
|
407
|
-
// Persist atomically
|
|
408
|
-
const updatedFile = {
|
|
409
|
-
session: file.session,
|
|
410
|
-
snapshot,
|
|
411
|
-
events: newEvents,
|
|
412
|
-
};
|
|
413
|
-
await writeSessionFileAtomic(options.sessionPath, updatedFile);
|
|
414
435
|
// Summarize the result
|
|
415
436
|
const updatedItem = sessionWithDecision.items.find((i) => i.metadata.name === itemName);
|
|
416
437
|
const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents) : `Item: ${itemName}`;
|
|
417
438
|
const remainingText = queueSummaryText(snapshot, newEvents);
|
|
418
439
|
const definition = workbenchDecisionDefinitions[wbDecision];
|
|
440
|
+
const conflictRejected = wbDecision === "reject-proposed" && updatedItem !== undefined && decisionSelectsNoCandidate(updatedItem, wbDecision);
|
|
419
441
|
const text = [
|
|
420
|
-
`Decision recorded: ${definition.label}`,
|
|
421
|
-
`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}`,
|
|
422
444
|
"",
|
|
423
445
|
itemText,
|
|
424
446
|
"",
|
|
@@ -129,6 +129,20 @@ export declare const AUTO_ACCEPT_WITHIN_COMFORT_ZONE: true;
|
|
|
129
129
|
* candidate's evidence gets passed into that decision.
|
|
130
130
|
*/
|
|
131
131
|
export declare function meetsAutoAcceptThreshold(confidence: number, minConfidence: number): boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Refuse an auto-accept policy threshold that cannot express a comfort zone:
|
|
134
|
+
* `minConfidence` must be a finite number in (0, 1]. A threshold of 0 (or
|
|
135
|
+
* below) would accept every proposal, and one above 1 accepts only
|
|
136
|
+
* out-of-range self-reports, so either makes `withinComfortZone: true` a
|
|
137
|
+
* false statement. Throws `RangeError`.
|
|
138
|
+
*/
|
|
139
|
+
export declare function assertValidAutoAcceptThreshold(minConfidence: number): void;
|
|
140
|
+
/**
|
|
141
|
+
* Whether a proposal's self-reported confidence is usable by the auto-accept
|
|
142
|
+
* gate: a finite number in [0, 1]. Anything else (7, -5, NaN) is never
|
|
143
|
+
* auto-accepted; the proposal stays in human review.
|
|
144
|
+
*/
|
|
145
|
+
export declare function isAutoAcceptConfidenceInRange(confidence: number): boolean;
|
|
132
146
|
/**
|
|
133
147
|
* The accepted-candidate-shaped evidence `evaluateAutoAccept` decides over.
|
|
134
148
|
* Deliberately narrow: only the fields the auto-accept policy itself reads,
|
|
@@ -157,6 +171,18 @@ export interface AutoAcceptEvidence {
|
|
|
157
171
|
*/
|
|
158
172
|
proposedAt?: string;
|
|
159
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* A proposal the auto-accept policy refused because its self-reported
|
|
176
|
+
* confidence is not a finite number in [0, 1]. The proposal is left for human
|
|
177
|
+
* review; the warning records why it was not auto-accepted.
|
|
178
|
+
*/
|
|
179
|
+
export interface AutoAcceptWarning {
|
|
180
|
+
code: "confidence-out-of-range";
|
|
181
|
+
/** The refused proposal's id. */
|
|
182
|
+
proposalId: string;
|
|
183
|
+
/** The out-of-range confidence exactly as reported. */
|
|
184
|
+
confidence: number;
|
|
185
|
+
}
|
|
160
186
|
/** The auto-accept policy `evaluateAutoAccept` gates against. */
|
|
161
187
|
export interface AutoAcceptPolicy {
|
|
162
188
|
/** Minimum confidence (inclusive) a proposal must clear to auto-accept. */
|
|
@@ -164,8 +190,17 @@ export interface AutoAcceptPolicy {
|
|
|
164
190
|
}
|
|
165
191
|
/** The unified auto-accept decision `evaluateAutoAccept` returns. */
|
|
166
192
|
export interface AutoAcceptDecision {
|
|
167
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* `true` iff there is no conflict, `evidence.confidence` is a finite number
|
|
195
|
+
* in [0, 1], and it clears `policy.minConfidence`.
|
|
196
|
+
*/
|
|
168
197
|
accepted: boolean;
|
|
198
|
+
/**
|
|
199
|
+
* Set when the proposal was refused because its self-reported confidence
|
|
200
|
+
* is not a finite number in [0, 1]. The proposal is not auto-accepted and
|
|
201
|
+
* stays in human review.
|
|
202
|
+
*/
|
|
203
|
+
warning?: "confidence-out-of-range";
|
|
169
204
|
/** The confidence value that was gated on (== `evidence.confidence`). */
|
|
170
205
|
confidence: number;
|
|
171
206
|
/** Composed rationale — always computed; callers only use it when `accepted`. */
|
|
@@ -192,7 +227,11 @@ export interface AutoAcceptDecision {
|
|
|
192
227
|
* back to `fallbackTimestamp` (and reporting which source was used via
|
|
193
228
|
* `reviewedAtSource`) when a profile's evidence carries no timestamp of
|
|
194
229
|
* its own.
|
|
195
|
-
* 4.
|
|
230
|
+
* 4. Refuse out-of-range inputs: throw `RangeError` unless
|
|
231
|
+
* `policy.minConfidence` is a finite number in (0, 1], and never accept a
|
|
232
|
+
* proposal whose confidence is not a finite number in [0, 1] (reported via
|
|
233
|
+
* `warning`). This is what keeps `withinComfortZone: true` truthful.
|
|
234
|
+
* 5. Always report `actor: AUTO_ACCEPT_ACTOR` and
|
|
196
235
|
* `withinComfortZone: AUTO_ACCEPT_WITHIN_COMFORT_ZONE` (ADR 0003 §4:
|
|
197
236
|
* auto-accept only ever yields "assumed" with the comfort-zone posture).
|
|
198
237
|
*
|
|
@@ -119,6 +119,26 @@ export const AUTO_ACCEPT_WITHIN_COMFORT_ZONE = true;
|
|
|
119
119
|
export function meetsAutoAcceptThreshold(confidence, minConfidence) {
|
|
120
120
|
return confidence >= minConfidence;
|
|
121
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Refuse an auto-accept policy threshold that cannot express a comfort zone:
|
|
124
|
+
* `minConfidence` must be a finite number in (0, 1]. A threshold of 0 (or
|
|
125
|
+
* below) would accept every proposal, and one above 1 accepts only
|
|
126
|
+
* out-of-range self-reports, so either makes `withinComfortZone: true` a
|
|
127
|
+
* false statement. Throws `RangeError`.
|
|
128
|
+
*/
|
|
129
|
+
export function assertValidAutoAcceptThreshold(minConfidence) {
|
|
130
|
+
if (typeof minConfidence !== "number" || !Number.isFinite(minConfidence) || minConfidence <= 0 || minConfidence > 1) {
|
|
131
|
+
throw new RangeError(`Auto-accept minConfidence must be a finite number in (0, 1]; received ${String(minConfidence)}.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Whether a proposal's self-reported confidence is usable by the auto-accept
|
|
136
|
+
* gate: a finite number in [0, 1]. Anything else (7, -5, NaN) is never
|
|
137
|
+
* auto-accepted; the proposal stays in human review.
|
|
138
|
+
*/
|
|
139
|
+
export function isAutoAcceptConfidenceInRange(confidence) {
|
|
140
|
+
return typeof confidence === "number" && Number.isFinite(confidence) && confidence >= 0 && confidence <= 1;
|
|
141
|
+
}
|
|
122
142
|
/**
|
|
123
143
|
* The one core auto-accept policy decision every Producer Profile delegates
|
|
124
144
|
* to, per the owner-accepted semantics recorded in
|
|
@@ -132,7 +152,11 @@ export function meetsAutoAcceptThreshold(confidence, minConfidence) {
|
|
|
132
152
|
* back to `fallbackTimestamp` (and reporting which source was used via
|
|
133
153
|
* `reviewedAtSource`) when a profile's evidence carries no timestamp of
|
|
134
154
|
* its own.
|
|
135
|
-
* 4.
|
|
155
|
+
* 4. Refuse out-of-range inputs: throw `RangeError` unless
|
|
156
|
+
* `policy.minConfidence` is a finite number in (0, 1], and never accept a
|
|
157
|
+
* proposal whose confidence is not a finite number in [0, 1] (reported via
|
|
158
|
+
* `warning`). This is what keeps `withinComfortZone: true` truthful.
|
|
159
|
+
* 5. Always report `actor: AUTO_ACCEPT_ACTOR` and
|
|
136
160
|
* `withinComfortZone: AUTO_ACCEPT_WITHIN_COMFORT_ZONE` (ADR 0003 §4:
|
|
137
161
|
* auto-accept only ever yields "assumed" with the comfort-zone posture).
|
|
138
162
|
*
|
|
@@ -142,7 +166,9 @@ export function meetsAutoAcceptThreshold(confidence, minConfidence) {
|
|
|
142
166
|
* inline `ReviewOutcome`) from this decision's fields.
|
|
143
167
|
*/
|
|
144
168
|
export function evaluateAutoAccept(evidence, hasConflict, policy, fallbackTimestamp) {
|
|
145
|
-
|
|
169
|
+
assertValidAutoAcceptThreshold(policy.minConfidence);
|
|
170
|
+
const inRange = isAutoAcceptConfidenceInRange(evidence.confidence);
|
|
171
|
+
const accepted = !hasConflict && inRange && meetsAutoAcceptThreshold(evidence.confidence, policy.minConfidence);
|
|
146
172
|
const rationale = `Auto-accepted: confidence ${evidence.confidence} >= threshold ${policy.minConfidence}.` +
|
|
147
173
|
(evidence.rationale !== undefined ? ` ${evidence.rationale}` : "");
|
|
148
174
|
const reviewedAt = evidence.proposedAt ?? fallbackTimestamp;
|
|
@@ -154,5 +180,6 @@ export function evaluateAutoAccept(evidence, hasConflict, policy, fallbackTimest
|
|
|
154
180
|
reviewedAtSource: evidence.proposedAt !== undefined ? "proposedAt" : "fallback",
|
|
155
181
|
actor: AUTO_ACCEPT_ACTOR,
|
|
156
182
|
withinComfortZone: AUTO_ACCEPT_WITHIN_COMFORT_ZONE,
|
|
183
|
+
...(inRange ? {} : { warning: "confidence-out-of-range" }),
|
|
157
184
|
};
|
|
158
185
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared session-file persistence for the local review writers
|
|
3
|
+
* (`survey-review-console` and `survey-review-mcp`).
|
|
4
|
+
*
|
|
5
|
+
* Every write goes through {@link updateReviewSessionFile}, which takes one
|
|
6
|
+
* exclusive lock file next to the session, re-reads the session inside the
|
|
7
|
+
* lock, applies the caller's change, writes a uniquely named temp file and
|
|
8
|
+
* renames it into place. Both writers must use this helper: a lock that only
|
|
9
|
+
* one of them takes still races with the other (kontourai/survey#281).
|
|
10
|
+
*/
|
|
11
|
+
import type { ReviewQueueSessionState } from "./review-workbench/review-queue-session.js";
|
|
12
|
+
import type { ReviewSessionEvent } from "./review-resource.js";
|
|
13
|
+
export interface ReviewSessionFileContent {
|
|
14
|
+
readonly session: unknown;
|
|
15
|
+
readonly snapshot: ReviewQueueSessionState;
|
|
16
|
+
readonly events: readonly ReviewSessionEvent[];
|
|
17
|
+
}
|
|
18
|
+
export interface ReviewSessionFileLockOptions {
|
|
19
|
+
/** How long to wait for a busy lock before failing. Defaults to 10s. */
|
|
20
|
+
readonly timeoutMs?: number;
|
|
21
|
+
/** A lock older than this is treated as abandoned. Defaults to 30s. */
|
|
22
|
+
readonly staleMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare class ReviewSessionFileLockTimeoutError extends Error {
|
|
25
|
+
constructor(lockPath: string, timeoutMs: number);
|
|
26
|
+
}
|
|
27
|
+
export declare function reviewSessionLockPath(sessionPath: string): string;
|
|
28
|
+
export declare function readReviewSessionFile<T extends ReviewSessionFileContent = ReviewSessionFileContent>(sessionPath: string): Promise<T>;
|
|
29
|
+
/**
|
|
30
|
+
* Opaque revision token for an event log: a digest of its serialized form.
|
|
31
|
+
* An event count is not enough, because the log is regenerated from session
|
|
32
|
+
* state and a changed decision keeps the count while changing the content.
|
|
33
|
+
*/
|
|
34
|
+
export declare function reviewSessionRevision(events: readonly ReviewSessionEvent[]): string;
|
|
35
|
+
/**
|
|
36
|
+
* Acquire the exclusive session lock. Resolves with a release function.
|
|
37
|
+
* Exported so tests can hold the lock and observe that writers wait for it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function acquireReviewSessionFileLock(sessionPath: string, options?: ReviewSessionFileLockOptions): Promise<() => Promise<void>>;
|
|
40
|
+
/**
|
|
41
|
+
* Locked read-modify-write of a session file. `mutate` receives the content
|
|
42
|
+
* read inside the lock and returns the content to write, or `undefined` to
|
|
43
|
+
* leave the file untouched. Whatever `mutate` throws propagates after the lock
|
|
44
|
+
* is released.
|
|
45
|
+
*/
|
|
46
|
+
export declare function updateReviewSessionFile<T extends ReviewSessionFileContent, R>(sessionPath: string, mutate: (current: T) => Promise<{
|
|
47
|
+
readonly next?: T;
|
|
48
|
+
readonly result: R;
|
|
49
|
+
}> | {
|
|
50
|
+
readonly next?: T;
|
|
51
|
+
readonly result: R;
|
|
52
|
+
}, options?: ReviewSessionFileLockOptions): Promise<R>;
|
|
53
|
+
/**
|
|
54
|
+
* The session name the stored log is recorded under: the name its events
|
|
55
|
+
* already carry, else the stored ReviewSession's name, else `fallback` (the
|
|
56
|
+
* writer's own default). Appended events are renamed into it so one log never mixes names.
|
|
57
|
+
*/
|
|
58
|
+
export declare function storedReviewSessionName(content: ReviewSessionFileContent, fallback?: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* Append events to the stored log, renumbering them after the stored events
|
|
61
|
+
* and renaming them into the stored session. The stored log is never
|
|
62
|
+
* rewritten, so decision reversals and note changes stay on record.
|
|
63
|
+
*/
|
|
64
|
+
export declare function appendReviewSessionEvents(content: ReviewSessionFileContent, appended: readonly ReviewSessionEvent[]): ReviewSessionEvent[];
|