@kontourai/survey 1.15.0 → 1.16.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 +19 -1
- package/dist/src/calibration.d.ts +1 -0
- package/dist/src/calibration.js +5 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/learning-projections.d.ts +2 -2
- package/dist/src/learning-projections.js +25 -0
- package/dist/src/mcp/review-mcp.js +48 -10
- package/dist/src/producer-discipline.d.ts +13 -0
- package/dist/src/producer-discipline.js +41 -0
- package/dist/src/review-proof.d.ts +22 -8
- package/dist/src/review-proof.js +56 -5
- package/dist/src/review-resource.d.ts +6 -0
- package/dist/src/review-workbench/producer-decision-mode.js +2 -0
- package/dist/src/review-workbench/review-queue-session.d.ts +11 -2
- package/dist/src/review-workbench/review-queue-session.js +58 -2
- package/dist/src/review-workbench/review-session-replay.d.ts +1 -1
- package/dist/src/review-workbench/review-session-replay.js +19 -0
- package/dist/src/review-workbench/review-surface-preview.js +1 -1
- package/dist/src/review-workbench/review-workbench-css.generated.js +8 -0
- package/dist/src/review-workbench/review-workbench.css +8 -0
- package/dist/src/review-workbench/review-workbench.js +68 -3
- package/dist/src/review-workbench/review-workbench.standalone.css +8 -0
- package/dist/src/to-surface.js +31 -14
- package/dist/src/types.d.ts +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,6 +105,24 @@ Keep producer operational state outside Survey. Queue status, reviewer form stat
|
|
|
105
105
|
|
|
106
106
|
When you build an `authorized-action` authorizing block outside the workbench, pair `buildAuthorizedActionAuthorizing` with `buildPromptRef({ module, component, version?, scheme? })` — `buildPromptRef` formats a well-formed, versioned `promptRef` (bare `"review-workbench/decision-card@v1"` or scheme-prefixed `"survey://<module>/<component>@v1"`) that `buildAuthorizedActionAuthorizing` accepts directly, instead of hand-formatting the string.
|
|
107
107
|
|
|
108
|
+
## Review lifecycle
|
|
109
|
+
|
|
110
|
+
`ReviewOutcome.resolution` optionally records the terminal result of a review
|
|
111
|
+
round while preserving compatibility with outcomes that infer their result from
|
|
112
|
+
`status`. The explicit `could_not_confirm` resolution requires a non-empty
|
|
113
|
+
`resolutionReason`; `attemptEvidenceIds` may record evidence of what the reviewer
|
|
114
|
+
tried. It is valid only with an unchanged `proposed` or `assumed` status.
|
|
115
|
+
Other explicit resolutions must also agree with status: accepted is
|
|
116
|
+
verified/assumed, rejected is rejected, and held is any non-rejected retained
|
|
117
|
+
posture.
|
|
118
|
+
|
|
119
|
+
Could-not-confirm is terminal for the review round, but it is not rejection,
|
|
120
|
+
verification, or escalation. Surface projection stays quiet: the claim keeps its
|
|
121
|
+
pre-review status, no attempt evidence is projected, and no review-derived
|
|
122
|
+
freshness, validity, gap, or verification posture is added. The outcome remains
|
|
123
|
+
available in Survey review records, canonical review proof v3, and the distinct
|
|
124
|
+
`learning.could-not-confirm` producer-learning signal.
|
|
125
|
+
|
|
108
126
|
## Review Workbench embed
|
|
109
127
|
|
|
110
128
|
**Web component** (shadow DOM, no framework required):
|
|
@@ -181,7 +199,7 @@ Drive review-queue decisions from an MCP agent (Claude Desktop, Cursor, or any M
|
|
|
181
199
|
npx survey-review-mcp --session path/to/session.json
|
|
182
200
|
```
|
|
183
201
|
|
|
184
|
-
Three tools: `survey_review_queue` (queue state), `survey_review_item` (item detail), and `survey_review_decide` (record a decision). Each queue and item call includes an embedded, fully self-contained review card with Accept / Hold / Reject
|
|
202
|
+
Three tools: `survey_review_queue` (queue state), `survey_review_item` (item detail), and `survey_review_decide` (record a decision). Each queue and item call includes an embedded, fully self-contained review card with Accept / Hold / Reject / Could not confirm actions. See [docs/review-mcp.md](docs/review-mcp.md).
|
|
185
203
|
|
|
186
204
|
At viewports ≤ 980 px, the queue panel becomes a slide-in drawer with a compact progress bar. At narrow container widths, `cqi`-based type scaling keeps candidate values from overflowing at 360 px. CSS custom properties (`--k-*`) inherit through the shadow boundary so the host can theme either mode without forking styles.
|
|
187
205
|
|
|
@@ -151,6 +151,7 @@ export interface CalibrationMetrics {
|
|
|
151
151
|
* label. A sample is skipped when it carries no human label or no prediction:
|
|
152
152
|
*
|
|
153
153
|
* - status "proposed" (not yet reviewed);
|
|
154
|
+
* - resolution "could_not_confirm" (no human correctness label);
|
|
154
155
|
* - a machine auto-accept, unless `includeAutoAccepted` is set;
|
|
155
156
|
* - no `selectedCandidateId`, or the selected candidate / its confidence is
|
|
156
157
|
* missing or non-finite (no prediction to calibrate).
|
package/dist/src/calibration.js
CHANGED
|
@@ -37,6 +37,7 @@ const DEFAULT_MIN_BIN_SAMPLES = 1;
|
|
|
37
37
|
* label. A sample is skipped when it carries no human label or no prediction:
|
|
38
38
|
*
|
|
39
39
|
* - status "proposed" (not yet reviewed);
|
|
40
|
+
* - resolution "could_not_confirm" (no human correctness label);
|
|
40
41
|
* - a machine auto-accept, unless `includeAutoAccepted` is set;
|
|
41
42
|
* - no `selectedCandidateId`, or the selected candidate / its confidence is
|
|
42
43
|
* missing or non-finite (no prediction to calibrate).
|
|
@@ -66,6 +67,10 @@ export function deriveCalibration(input, options = {}) {
|
|
|
66
67
|
const samples = [];
|
|
67
68
|
let skippedCount = 0;
|
|
68
69
|
for (const outcome of input.reviewOutcomes) {
|
|
70
|
+
if (outcome.resolution === "could_not_confirm") {
|
|
71
|
+
skippedCount++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
69
74
|
if (cutoff !== undefined) {
|
|
70
75
|
const t = outcome.reviewedAt ? Date.parse(outcome.reviewedAt) : NaN;
|
|
71
76
|
if (isNaN(t) || t < cutoff) {
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { CandidateSetStatus, Candidate, CandidateSet, ClaimTarget, EscalationDimension, EscalationRecord, Extraction, Interpretation, LocatorScheme, ProvenanceResolution, RawSource, RawSourceKind, ReviewAuthorizing, ReviewAuthorizingAuthorizedAction, ReviewAuthorizingExchange, ReviewAuthorizingExplicitStatement, ReviewAuthorizingKind, ReviewOutcome, ReviewStatus, SurveyInput, } from "./types.js";
|
|
1
|
+
export type { CandidateSetStatus, Candidate, CandidateSet, ClaimTarget, EscalationDimension, EscalationRecord, Extraction, Interpretation, LocatorScheme, ProvenanceResolution, RawSource, RawSourceKind, ReviewAuthorizing, ReviewAuthorizingAuthorizedAction, ReviewAuthorizingExchange, ReviewAuthorizingExplicitStatement, ReviewAuthorizingKind, ReviewOutcome, ReviewResolution, ReviewStatus, SurveyInput, } from "./types.js";
|
|
2
2
|
export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
|
|
3
3
|
export { reviewResourceApiVersion } from "./review-resource.js";
|
|
4
4
|
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";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SurveyInput } from "./types.js";
|
|
2
|
-
export type LearningProjectionKind = "learning.comfort-zone" | "learning.escalation" | "learning.rejected-candidate";
|
|
3
|
-
export type LearningProjectionSignal = "comfort-zone.outside" | "escalation.unresolved" | "rejected-candidate.reason";
|
|
2
|
+
export type LearningProjectionKind = "learning.comfort-zone" | "learning.escalation" | "learning.rejected-candidate" | "learning.could-not-confirm";
|
|
3
|
+
export type LearningProjectionSignal = "comfort-zone.outside" | "escalation.unresolved" | "rejected-candidate.reason" | "could-not-confirm.reason";
|
|
4
4
|
export type LearningProjectionSeverity = "info" | "attention";
|
|
5
5
|
export interface LearningProjection {
|
|
6
6
|
id: string;
|
|
@@ -40,6 +40,31 @@ export function buildSurveyLearningProjections(input) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
for (const reviewOutcome of input.reviewOutcomes) {
|
|
43
|
+
if (reviewOutcome.resolution === "could_not_confirm") {
|
|
44
|
+
const target = candidateSetTargets.get(reviewOutcome.candidateSetId);
|
|
45
|
+
const claim = findClaimForReview(claimsByCandidateSet.get(reviewOutcome.candidateSetId) ?? [], reviewOutcome);
|
|
46
|
+
const resolutionReason = normalizeText(reviewOutcome.resolutionReason) ?? "(no reason recorded)";
|
|
47
|
+
projections.push({
|
|
48
|
+
id: `${reviewOutcome.id}.learning.could-not-confirm`,
|
|
49
|
+
kind: "learning.could-not-confirm",
|
|
50
|
+
source: input.source,
|
|
51
|
+
createdAt: reviewOutcome.reviewedAt ?? input.generatedAt,
|
|
52
|
+
target,
|
|
53
|
+
claimId: claim?.id,
|
|
54
|
+
reviewOutcomeId: reviewOutcome.id,
|
|
55
|
+
signal: "could-not-confirm.reason",
|
|
56
|
+
severity: "attention",
|
|
57
|
+
summary: `Could not confirm: ${resolutionReason}`,
|
|
58
|
+
metadata: {
|
|
59
|
+
couldNotConfirm: {
|
|
60
|
+
reason: resolutionReason,
|
|
61
|
+
...(reviewOutcome.attemptEvidenceIds?.length
|
|
62
|
+
? { attemptEvidenceIds: [...reviewOutcome.attemptEvidenceIds] }
|
|
63
|
+
: {}),
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
43
68
|
if (reviewOutcome.withinComfortZone !== false)
|
|
44
69
|
continue;
|
|
45
70
|
const target = candidateSetTargets.get(reviewOutcome.candidateSetId);
|
|
@@ -30,6 +30,7 @@ const MCP_DECISION_MAP = {
|
|
|
30
30
|
accept: "accept-proposed",
|
|
31
31
|
hold: "keep-current",
|
|
32
32
|
reject: "reject-proposed",
|
|
33
|
+
"could-not-confirm": "could-not-confirm",
|
|
33
34
|
};
|
|
34
35
|
async function readSessionFile(path) {
|
|
35
36
|
const raw = await readFile(path, "utf8");
|
|
@@ -59,7 +60,7 @@ function queueSummaryText(snapshot, events) {
|
|
|
59
60
|
`Active item: ${activeItem.metadata.name} (${activeItem.spec.target})`,
|
|
60
61
|
...(nextItem ? [`Next unresolved: ${nextItem}`] : ["All items resolved."]),
|
|
61
62
|
``,
|
|
62
|
-
`Session summary: accepted=${summary.accepted} keptCurrent=${summary.keptCurrent} rejected=${summary.rejected} escalated=${summary.escalated} unresolved=${summary.unresolved}`,
|
|
63
|
+
`Session summary: accepted=${summary.accepted} keptCurrent=${summary.keptCurrent} rejected=${summary.rejected} couldNotConfirm=${summary.couldNotConfirm ?? 0} escalated=${summary.escalated} unresolved=${summary.unresolved}`,
|
|
63
64
|
``,
|
|
64
65
|
`Items:`,
|
|
65
66
|
...rows,
|
|
@@ -202,12 +203,13 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
202
203
|
.note-label{font-size:11px;color:var(--k-text-muted);margin-bottom:4px}
|
|
203
204
|
.note-input{width:100%;background:var(--k-panel-raised);border:1px solid var(--k-line-strong);border-radius:var(--k-radius-sm);color:var(--k-text);font:inherit;font-size:12px;padding:7px 10px;resize:vertical;min-height:52px}
|
|
204
205
|
.note-input:focus{outline:2px solid var(--k-brand);outline-offset:1px;border-color:transparent}
|
|
205
|
-
.btn-row{display:grid;grid-template-columns:1fr 1fr
|
|
206
|
+
.btn-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}
|
|
206
207
|
.btn{padding:9px 4px;border:1px solid var(--k-line-strong);border-radius:var(--k-radius-sm);background:var(--k-panel-raised);color:var(--k-text-muted);font:inherit;font-size:12px;font-weight:600;cursor:pointer;transition:background .12s,color .12s,border-color .12s}
|
|
207
208
|
.btn:hover{background:var(--k-panel);border-color:var(--k-brand);color:var(--k-text)}
|
|
208
209
|
.btn-accept:hover,.btn-accept.active{background:color-mix(in srgb,var(--k-positive) 16%,transparent);border-color:var(--k-positive);color:var(--k-positive)}
|
|
209
210
|
.btn-hold:hover,.btn-hold.active{background:color-mix(in srgb,var(--k-caution) 16%,transparent);border-color:var(--k-caution);color:var(--k-caution)}
|
|
210
211
|
.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)}
|
|
212
|
+
.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)}
|
|
211
213
|
.feedback{font-size:11px;color:var(--k-text-faint);margin-top:8px;min-height:16px}
|
|
212
214
|
</style>
|
|
213
215
|
</head>
|
|
@@ -239,13 +241,14 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
239
241
|
|
|
240
242
|
<div class="divider"></div>
|
|
241
243
|
|
|
242
|
-
<div class="note-label">Reviewer note (
|
|
244
|
+
<div class="note-label">Reviewer note (required for Could not confirm)</div>
|
|
243
245
|
<textarea class="note-input" id="note" placeholder="Add a rationale for this decision...">${escapeHtml(current.notesByItemName[item.metadata.name] ?? "")}</textarea>
|
|
244
246
|
|
|
245
247
|
<div class="btn-row">
|
|
246
248
|
<button class="btn btn-accept${decision === "accept-proposed" ? " active" : ""}" id="btn-accept">Accept proposed</button>
|
|
247
249
|
<button class="btn btn-hold${decision === "keep-current" ? " active" : ""}" id="btn-hold">Hold / Keep current</button>
|
|
248
250
|
<button class="btn btn-reject${decision === "reject-proposed" ? " active" : ""}" id="btn-reject">Reject proposed</button>
|
|
251
|
+
<button class="btn btn-unconfirmed${decision === "could-not-confirm" ? " active" : ""}" id="btn-unconfirmed">Could not confirm</button>
|
|
249
252
|
</div>
|
|
250
253
|
<div class="feedback" id="feedback"></div>
|
|
251
254
|
|
|
@@ -256,20 +259,29 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
256
259
|
|
|
257
260
|
function postDecision(decision) {
|
|
258
261
|
var note = document.getElementById('note').value;
|
|
262
|
+
if (decision === 'could-not-confirm' && !note.trim()) {
|
|
263
|
+
document.getElementById('feedback').textContent = 'A reason is required when you could not confirm.';
|
|
264
|
+
document.getElementById('note').focus();
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
259
267
|
window.parent.postMessage({
|
|
260
268
|
jsonrpc: "2.0",
|
|
261
269
|
id: msgId++,
|
|
262
270
|
method: "tools/call",
|
|
263
271
|
params: {
|
|
264
272
|
name: "survey_review_decide",
|
|
265
|
-
arguments:
|
|
273
|
+
arguments: decision === 'could-not-confirm'
|
|
274
|
+
? { itemName: itemName, decision: decision, reason: note }
|
|
275
|
+
: { itemName: itemName, decision: decision, note: note || undefined }
|
|
266
276
|
}
|
|
267
277
|
}, "*");
|
|
278
|
+
return true;
|
|
268
279
|
}
|
|
269
280
|
|
|
270
281
|
document.getElementById('btn-accept').addEventListener('click', function () { postDecision('accept'); document.getElementById('feedback').textContent = 'Submitting accept…'; });
|
|
271
282
|
document.getElementById('btn-hold').addEventListener('click', function () { postDecision('hold'); document.getElementById('feedback').textContent = 'Submitting hold…'; });
|
|
272
283
|
document.getElementById('btn-reject').addEventListener('click', function () { postDecision('reject'); document.getElementById('feedback').textContent = 'Submitting reject…'; });
|
|
284
|
+
document.getElementById('btn-unconfirmed').addEventListener('click', function () { if (postDecision('could-not-confirm')) document.getElementById('feedback').textContent = 'Submitting could not confirm…'; });
|
|
273
285
|
|
|
274
286
|
window.addEventListener('message', function (evt) {
|
|
275
287
|
var data = evt.data;
|
|
@@ -347,10 +359,13 @@ async function toolItem(itemName, options) {
|
|
|
347
359
|
}
|
|
348
360
|
return content;
|
|
349
361
|
}
|
|
350
|
-
async function toolDecide(itemName, mcpDecision, note, options) {
|
|
362
|
+
async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, options) {
|
|
351
363
|
const wbDecision = MCP_DECISION_MAP[mcpDecision];
|
|
352
364
|
if (!wbDecision) {
|
|
353
|
-
throw new DomainError(`Invalid decision: ${mcpDecision}. Must be accept, hold, or
|
|
365
|
+
throw new DomainError(`Invalid decision: ${mcpDecision}. Must be accept, hold, reject, or could-not-confirm.`);
|
|
366
|
+
}
|
|
367
|
+
if (wbDecision === "could-not-confirm" && !note?.trim()) {
|
|
368
|
+
throw new DomainError("survey_review_decide requires a non-empty reason for could-not-confirm");
|
|
354
369
|
}
|
|
355
370
|
const file = await readSessionFile(options.sessionPath);
|
|
356
371
|
const { snapshot, events } = file;
|
|
@@ -378,6 +393,14 @@ async function toolDecide(itemName, mcpDecision, note, options) {
|
|
|
378
393
|
},
|
|
379
394
|
}
|
|
380
395
|
: {}),
|
|
396
|
+
...(attemptEvidenceIds?.length
|
|
397
|
+
? {
|
|
398
|
+
attemptEvidenceIdsByItemName: {
|
|
399
|
+
...current.attemptEvidenceIdsByItemName,
|
|
400
|
+
[itemName]: [...attemptEvidenceIds],
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
: {}),
|
|
381
404
|
};
|
|
382
405
|
// Use the server session APIs for apply-path validation
|
|
383
406
|
const record = createServerReviewSessionRecord({
|
|
@@ -518,19 +541,29 @@ async function handleLine(line, options, serverVersion) {
|
|
|
518
541
|
{
|
|
519
542
|
name: "survey_review_decide",
|
|
520
543
|
title: "Record a review decision",
|
|
521
|
-
description: "Apply a decision to a review item and persist it to the session file. Decision must be accept
|
|
544
|
+
description: "Apply a decision to a review item and persist it to the session file. Decision must be accept, hold, reject, or could-not-confirm. Could-not-confirm requires a reason. Domain failures return isError:true.",
|
|
522
545
|
inputSchema: {
|
|
523
546
|
type: "object",
|
|
524
547
|
properties: {
|
|
525
548
|
itemName: { type: "string", description: "The ReviewItem name to decide." },
|
|
526
549
|
decision: {
|
|
527
550
|
type: "string",
|
|
528
|
-
enum: ["accept", "hold", "reject"],
|
|
529
|
-
description: "accept = accept-proposed, hold = keep-current, reject = reject-proposed.",
|
|
551
|
+
enum: ["accept", "hold", "reject", "could-not-confirm"],
|
|
552
|
+
description: "accept = accept-proposed, hold = keep-current, reject = reject-proposed, could-not-confirm = terminal non-answer.",
|
|
530
553
|
},
|
|
531
554
|
note: { type: "string", description: "Optional reviewer note / rationale." },
|
|
555
|
+
reason: { type: "string", minLength: 1, description: "Required non-empty reason when decision is could-not-confirm." },
|
|
556
|
+
attemptEvidenceIds: {
|
|
557
|
+
type: "array",
|
|
558
|
+
items: { type: "string" },
|
|
559
|
+
description: "Optional evidence ids recording what was attempted before could-not-confirm.",
|
|
560
|
+
},
|
|
532
561
|
},
|
|
533
562
|
required: ["itemName", "decision"],
|
|
563
|
+
allOf: [{
|
|
564
|
+
if: { properties: { decision: { const: "could-not-confirm" } }, required: ["decision"] },
|
|
565
|
+
then: { required: ["reason"] },
|
|
566
|
+
}],
|
|
534
567
|
},
|
|
535
568
|
},
|
|
536
569
|
],
|
|
@@ -587,11 +620,16 @@ async function handleLine(line, options, serverVersion) {
|
|
|
587
620
|
const itemName = typeof toolArgs.itemName === "string" ? toolArgs.itemName : "";
|
|
588
621
|
const decision = typeof toolArgs.decision === "string" ? toolArgs.decision : "";
|
|
589
622
|
const note = typeof toolArgs.note === "string" ? toolArgs.note : undefined;
|
|
623
|
+
const reason = typeof toolArgs.reason === "string" ? toolArgs.reason : undefined;
|
|
624
|
+
const attemptEvidenceIds = Array.isArray(toolArgs.attemptEvidenceIds)
|
|
625
|
+
&& toolArgs.attemptEvidenceIds.every((value) => typeof value === "string")
|
|
626
|
+
? toolArgs.attemptEvidenceIds
|
|
627
|
+
: undefined;
|
|
590
628
|
if (!itemName)
|
|
591
629
|
throw new DomainError("survey_review_decide requires itemName");
|
|
592
630
|
if (!decision)
|
|
593
631
|
throw new DomainError("survey_review_decide requires decision");
|
|
594
|
-
content = await toolDecide(itemName, decision, note, options);
|
|
632
|
+
content = await toolDecide(itemName, decision, decision === "could-not-confirm" ? reason : note, attemptEvidenceIds, options);
|
|
595
633
|
}
|
|
596
634
|
else {
|
|
597
635
|
send({ jsonrpc: "2.0", id, error: { code: -32602, message: `Unknown tool: ${name || "(missing name)"}` } });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TrustStatus } from "@kontourai/surface";
|
|
2
|
+
import type { CandidateSetStatus, ReviewResolution } from "./types.js";
|
|
2
3
|
/**
|
|
3
4
|
* Producer Discipline core — CONTEXT.md "Producer Discipline" /
|
|
4
5
|
* "Source-of-Authority Observation".
|
|
@@ -28,13 +29,25 @@ import type { TrustStatus } from "@kontourai/surface";
|
|
|
28
29
|
* src/index.ts.
|
|
29
30
|
*/
|
|
30
31
|
export interface ReviewOutcomePosture {
|
|
32
|
+
status?: TrustStatus;
|
|
31
33
|
actor?: string;
|
|
32
34
|
reviewedAt?: string;
|
|
35
|
+
resolution?: ReviewResolution;
|
|
36
|
+
resolutionReason?: string;
|
|
37
|
+
attemptEvidenceIds?: readonly string[];
|
|
33
38
|
}
|
|
39
|
+
/** Explicit review resolutions refine (but may not contradict) status:
|
|
40
|
+
* accepted -> verified/assumed; rejected -> rejected; held -> any non-rejected
|
|
41
|
+
* pre-existing posture; could_not_confirm -> proposed/assumed with reviewer,
|
|
42
|
+
* time, and reason. */
|
|
43
|
+
export declare function assertReviewResolutionConsistency(subject: string, review: ReviewOutcomePosture): void;
|
|
34
44
|
export declare function assertReviewOutcomeDiscipline(input: {
|
|
35
45
|
/** Message subject, e.g. `Claim ${id}` or `Source-of-authority observation ${id}` —
|
|
36
46
|
* each call site supplies its own noun so error text is unchanged. */
|
|
37
47
|
subject: string;
|
|
38
48
|
status: TrustStatus | undefined;
|
|
39
49
|
review?: ReviewOutcomePosture;
|
|
50
|
+
/** Candidate-set conflict/escalation is an independent pre-review posture.
|
|
51
|
+
* A could-not-confirm round must preserve it as disputed, never downgrade it. */
|
|
52
|
+
candidateSetStatus?: CandidateSetStatus;
|
|
40
53
|
}): void;
|
|
@@ -1,4 +1,45 @@
|
|
|
1
|
+
/** Explicit review resolutions refine (but may not contradict) status:
|
|
2
|
+
* accepted -> verified/assumed; rejected -> rejected; held -> any non-rejected
|
|
3
|
+
* pre-existing posture; could_not_confirm -> proposed/assumed with reviewer,
|
|
4
|
+
* time, and reason. */
|
|
5
|
+
export function assertReviewResolutionConsistency(subject, review) {
|
|
6
|
+
if (review.resolution === undefined)
|
|
7
|
+
return;
|
|
8
|
+
const statusAllowed = review.resolution === "accepted"
|
|
9
|
+
? review.status === "verified" || review.status === "assumed"
|
|
10
|
+
: review.resolution === "rejected"
|
|
11
|
+
? review.status === "rejected"
|
|
12
|
+
: review.resolution === "held"
|
|
13
|
+
? review.status === "verified" || review.status === "assumed" || review.status === "proposed"
|
|
14
|
+
: review.status === "proposed" || review.status === "assumed";
|
|
15
|
+
if (!statusAllowed) {
|
|
16
|
+
throw new Error(`${subject} review resolution ${review.resolution} cannot use status ${review.status ?? "undefined"}`);
|
|
17
|
+
}
|
|
18
|
+
if (review.resolution !== "could_not_confirm")
|
|
19
|
+
return;
|
|
20
|
+
if (!review.resolutionReason?.trim()) {
|
|
21
|
+
throw new Error(`${subject} review resolution could_not_confirm requires a non-empty resolutionReason`);
|
|
22
|
+
}
|
|
23
|
+
if (!review.actor?.trim()) {
|
|
24
|
+
throw new Error(`${subject} review resolution could_not_confirm requires a review actor`);
|
|
25
|
+
}
|
|
26
|
+
if (!review.reviewedAt?.trim()) {
|
|
27
|
+
throw new Error(`${subject} review resolution could_not_confirm requires reviewedAt`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
1
30
|
export function assertReviewOutcomeDiscipline(input) {
|
|
31
|
+
if (input.review) {
|
|
32
|
+
assertReviewResolutionConsistency(input.subject, input.review);
|
|
33
|
+
}
|
|
34
|
+
if (input.review?.resolution === "could_not_confirm"
|
|
35
|
+
&& (input.status === "verified" || input.status === "rejected")) {
|
|
36
|
+
throw new Error(`${input.subject} review resolution could_not_confirm cannot use status ${input.status}`);
|
|
37
|
+
}
|
|
38
|
+
if (input.review?.resolution === "could_not_confirm"
|
|
39
|
+
&& (input.candidateSetStatus === "conflict" || input.candidateSetStatus === "escalated")
|
|
40
|
+
&& input.status !== "disputed") {
|
|
41
|
+
throw new Error(`${input.subject} review resolution could_not_confirm cannot mask ${input.candidateSetStatus} as ${input.status}`);
|
|
42
|
+
}
|
|
2
43
|
if (input.status !== "verified" && input.status !== "assumed")
|
|
3
44
|
return;
|
|
4
45
|
if (!input.review) {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { IntegrityAnchor } from "@kontourai/surface";
|
|
2
2
|
import type { Candidate, CandidateSet, ClaimTarget, Extraction, RawSource, ReviewAuthorizing, ReviewOutcome } from "./types.js";
|
|
3
3
|
export declare const REVIEW_PROOF_SCHEMA = "survey.review-proof";
|
|
4
|
-
export declare const REVIEW_PROOF_SCHEMA_VERSION =
|
|
4
|
+
export declare const REVIEW_PROOF_SCHEMA_VERSION = 3;
|
|
5
5
|
export declare const REVIEW_PROOF_PACKAGE_NAME = "@kontourai/survey";
|
|
6
|
-
export declare const REVIEW_PROOF_CONTRACT_VERSION = "
|
|
6
|
+
export declare const REVIEW_PROOF_CONTRACT_VERSION = "3";
|
|
7
7
|
declare const LEGACY_REVIEW_PROOF_SCHEMA_VERSION = 1;
|
|
8
8
|
declare const LEGACY_REVIEW_PROOF_CONTRACT_VERSION = "1";
|
|
9
|
+
declare const REVIEW_PROOF_SCHEMA_VERSION_V2 = 2;
|
|
10
|
+
declare const REVIEW_PROOF_CONTRACT_VERSION_V2 = "2";
|
|
9
11
|
export interface ReviewProofInput {
|
|
10
12
|
rawSource: RawSource;
|
|
11
13
|
extraction: Extraction;
|
|
@@ -128,20 +130,32 @@ type CanonicalReviewAuthorizing = (Extract<ReviewAuthorizing, {
|
|
|
128
130
|
export type CanonicalReviewProofPayloadV2 = Omit<CanonicalReviewProofPayloadV1, "schemaVersion" | "proof" | "reviewOutcome"> & {
|
|
129
131
|
schemaVersion: 2;
|
|
130
132
|
proof: Omit<CanonicalReviewProofPayloadV1["proof"], "schemaVersion" | "packageVersion"> & {
|
|
131
|
-
schemaVersion: typeof
|
|
132
|
-
packageVersion: typeof
|
|
133
|
+
schemaVersion: typeof REVIEW_PROOF_SCHEMA_VERSION_V2;
|
|
134
|
+
packageVersion: typeof REVIEW_PROOF_CONTRACT_VERSION_V2;
|
|
133
135
|
};
|
|
134
136
|
reviewOutcome?: NonNullable<CanonicalReviewProofPayloadV1["reviewOutcome"]> & {
|
|
135
137
|
authorizing?: CanonicalReviewAuthorizing;
|
|
136
138
|
};
|
|
137
139
|
};
|
|
138
|
-
export type
|
|
139
|
-
|
|
140
|
+
export type CanonicalReviewProofPayloadV3 = Omit<CanonicalReviewProofPayloadV2, "schemaVersion" | "proof" | "reviewOutcome"> & {
|
|
141
|
+
schemaVersion: 3;
|
|
142
|
+
proof: Omit<CanonicalReviewProofPayloadV2["proof"], "schemaVersion" | "packageVersion"> & {
|
|
143
|
+
schemaVersion: typeof REVIEW_PROOF_SCHEMA_VERSION;
|
|
144
|
+
packageVersion: typeof REVIEW_PROOF_CONTRACT_VERSION;
|
|
145
|
+
};
|
|
146
|
+
reviewOutcome?: NonNullable<CanonicalReviewProofPayloadV2["reviewOutcome"]> & {
|
|
147
|
+
resolution?: ReviewOutcome["resolution"];
|
|
148
|
+
resolutionReason?: string;
|
|
149
|
+
attemptEvidenceIds?: string[];
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
export type CanonicalReviewProofPayload = CanonicalReviewProofPayloadV1 | CanonicalReviewProofPayloadV2 | CanonicalReviewProofPayloadV3;
|
|
153
|
+
export declare function buildCanonicalReviewProofPayload(input: ReviewProofInput): CanonicalReviewProofPayloadV3;
|
|
140
154
|
export declare function canonicalReviewProofJson(payload: CanonicalReviewProofPayload): string;
|
|
141
155
|
export declare function hashCanonicalReviewProofPayload(payload: CanonicalReviewProofPayload): string;
|
|
142
156
|
/**
|
|
143
|
-
* Verifies the integrity hash of a persisted v1 or
|
|
144
|
-
* Envelope compatibility and
|
|
157
|
+
* Verifies the integrity hash of a persisted v1, v2, or v3 canonical review proof.
|
|
158
|
+
* Envelope compatibility and version-specific admissibility are checked before
|
|
145
159
|
* the expected hash is compared.
|
|
146
160
|
*/
|
|
147
161
|
export declare function verifyCanonicalReviewProofPayload(payload: unknown, expectedHash: string): boolean;
|
package/dist/src/review-proof.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { validateAuthorizing } from "./review-authorizing.js";
|
|
3
|
+
import { assertReviewResolutionConsistency } from "./producer-discipline.js";
|
|
3
4
|
export const REVIEW_PROOF_SCHEMA = "survey.review-proof";
|
|
4
|
-
export const REVIEW_PROOF_SCHEMA_VERSION =
|
|
5
|
+
export const REVIEW_PROOF_SCHEMA_VERSION = 3;
|
|
5
6
|
export const REVIEW_PROOF_PACKAGE_NAME = "@kontourai/survey";
|
|
6
7
|
// Version of the review proof contract emitted by this helper. This is intentionally
|
|
7
8
|
// independent from the npm package release version because it participates in hashes.
|
|
8
|
-
export const REVIEW_PROOF_CONTRACT_VERSION = "
|
|
9
|
+
export const REVIEW_PROOF_CONTRACT_VERSION = "3";
|
|
9
10
|
const LEGACY_REVIEW_PROOF_SCHEMA_VERSION = 1;
|
|
10
11
|
const LEGACY_REVIEW_PROOF_CONTRACT_VERSION = "1";
|
|
12
|
+
const REVIEW_PROOF_SCHEMA_VERSION_V2 = 2;
|
|
13
|
+
const REVIEW_PROOF_CONTRACT_VERSION_V2 = "2";
|
|
11
14
|
export function buildCanonicalReviewProofPayload(input) {
|
|
12
15
|
assertCandidateConsistency(input);
|
|
16
|
+
if (input.reviewOutcome) {
|
|
17
|
+
assertReviewResolutionConsistency("Canonical review proof", input.reviewOutcome);
|
|
18
|
+
}
|
|
13
19
|
return {
|
|
14
20
|
schemaVersion: REVIEW_PROOF_SCHEMA_VERSION,
|
|
15
21
|
proof: {
|
|
@@ -77,6 +83,13 @@ export function buildCanonicalReviewProofPayload(input) {
|
|
|
77
83
|
candidateSetId: input.reviewOutcome.candidateSetId,
|
|
78
84
|
candidateId: input.reviewOutcome.candidateId,
|
|
79
85
|
status: input.reviewOutcome.status,
|
|
86
|
+
...(input.reviewOutcome.resolution !== undefined ? { resolution: input.reviewOutcome.resolution } : {}),
|
|
87
|
+
...(input.reviewOutcome.resolutionReason !== undefined
|
|
88
|
+
? { resolutionReason: input.reviewOutcome.resolutionReason }
|
|
89
|
+
: {}),
|
|
90
|
+
...(input.reviewOutcome.attemptEvidenceIds
|
|
91
|
+
? { attemptEvidenceIds: [...input.reviewOutcome.attemptEvidenceIds].sort() }
|
|
92
|
+
: {}),
|
|
80
93
|
actor: input.reviewOutcome.actor,
|
|
81
94
|
reviewedAt: input.reviewOutcome.reviewedAt,
|
|
82
95
|
rationale: input.reviewOutcome.rationale,
|
|
@@ -187,8 +200,8 @@ export function hashCanonicalReviewProofPayload(payload) {
|
|
|
187
200
|
return createHash("sha256").update(canonicalReviewProofJson(payload)).digest("hex");
|
|
188
201
|
}
|
|
189
202
|
/**
|
|
190
|
-
* Verifies the integrity hash of a persisted v1 or
|
|
191
|
-
* Envelope compatibility and
|
|
203
|
+
* Verifies the integrity hash of a persisted v1, v2, or v3 canonical review proof.
|
|
204
|
+
* Envelope compatibility and version-specific admissibility are checked before
|
|
192
205
|
* the expected hash is compared.
|
|
193
206
|
*/
|
|
194
207
|
export function verifyCanonicalReviewProofPayload(payload, expectedHash) {
|
|
@@ -210,7 +223,30 @@ export function verifyCanonicalReviewProofPayload(payload, expectedHash) {
|
|
|
210
223
|
|| packageVersion !== LEGACY_REVIEW_PROOF_CONTRACT_VERSION) {
|
|
211
224
|
return false;
|
|
212
225
|
}
|
|
213
|
-
if (isRecord(reviewOutcome)
|
|
226
|
+
if (isRecord(reviewOutcome)
|
|
227
|
+
&& (Object.prototype.hasOwnProperty.call(reviewOutcome, "authorizing")
|
|
228
|
+
|| Object.prototype.hasOwnProperty.call(reviewOutcome, "resolution")
|
|
229
|
+
|| Object.prototype.hasOwnProperty.call(reviewOutcome, "resolutionReason")
|
|
230
|
+
|| Object.prototype.hasOwnProperty.call(reviewOutcome, "attemptEvidenceIds"))) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else if (schemaVersion === REVIEW_PROOF_SCHEMA_VERSION_V2) {
|
|
235
|
+
if (proofSchemaVersion !== REVIEW_PROOF_SCHEMA_VERSION_V2 || packageVersion !== REVIEW_PROOF_CONTRACT_VERSION_V2) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
if (reviewOutcome !== undefined && !isRecord(reviewOutcome))
|
|
239
|
+
return false;
|
|
240
|
+
if (isRecord(reviewOutcome)
|
|
241
|
+
&& Object.prototype.hasOwnProperty.call(reviewOutcome, "authorizing")
|
|
242
|
+
&& reviewOutcome.authorizing !== undefined
|
|
243
|
+
&& validateAuthorizing(reviewOutcome.authorizing).length > 0) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
if (isRecord(reviewOutcome)
|
|
247
|
+
&& (Object.prototype.hasOwnProperty.call(reviewOutcome, "resolution")
|
|
248
|
+
|| Object.prototype.hasOwnProperty.call(reviewOutcome, "resolutionReason")
|
|
249
|
+
|| Object.prototype.hasOwnProperty.call(reviewOutcome, "attemptEvidenceIds"))) {
|
|
214
250
|
return false;
|
|
215
251
|
}
|
|
216
252
|
}
|
|
@@ -226,6 +262,21 @@ export function verifyCanonicalReviewProofPayload(payload, expectedHash) {
|
|
|
226
262
|
&& validateAuthorizing(reviewOutcome.authorizing).length > 0) {
|
|
227
263
|
return false;
|
|
228
264
|
}
|
|
265
|
+
if (isRecord(reviewOutcome)) {
|
|
266
|
+
if (reviewOutcome.resolution !== undefined
|
|
267
|
+
&& reviewOutcome.resolution !== "accepted"
|
|
268
|
+
&& reviewOutcome.resolution !== "rejected"
|
|
269
|
+
&& reviewOutcome.resolution !== "held"
|
|
270
|
+
&& reviewOutcome.resolution !== "could_not_confirm") {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
if (reviewOutcome.attemptEvidenceIds !== undefined
|
|
274
|
+
&& (!Array.isArray(reviewOutcome.attemptEvidenceIds)
|
|
275
|
+
|| reviewOutcome.attemptEvidenceIds.some((id) => typeof id !== "string"))) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
assertReviewResolutionConsistency("Canonical review proof", reviewOutcome);
|
|
279
|
+
}
|
|
229
280
|
}
|
|
230
281
|
else {
|
|
231
282
|
return false;
|
|
@@ -168,6 +168,9 @@ export interface ReviewDecisionSpec {
|
|
|
168
168
|
reviewItemName: string;
|
|
169
169
|
candidateId?: string;
|
|
170
170
|
status: ReviewOutcome["status"];
|
|
171
|
+
resolution?: ReviewOutcome["resolution"];
|
|
172
|
+
resolutionReason?: string;
|
|
173
|
+
attemptEvidenceIds?: string[];
|
|
171
174
|
actor?: ReviewActor;
|
|
172
175
|
reviewedAt?: string;
|
|
173
176
|
rationale?: string;
|
|
@@ -216,6 +219,9 @@ export interface ReviewSessionEventSpec {
|
|
|
216
219
|
reviewDecisionName?: string;
|
|
217
220
|
candidateId?: string;
|
|
218
221
|
status?: ReviewOutcome["status"];
|
|
222
|
+
resolution?: ReviewOutcome["resolution"];
|
|
223
|
+
resolutionReason?: string;
|
|
224
|
+
attemptEvidenceIds?: string[];
|
|
219
225
|
rationale?: string;
|
|
220
226
|
data?: Record<string, unknown>;
|
|
221
227
|
}
|
|
@@ -15,6 +15,8 @@ const KNOWN_DECISION_MODES = new Set([
|
|
|
15
15
|
* `unknown-decision-mode` issue.
|
|
16
16
|
*/
|
|
17
17
|
export function validateReviewDecisionMode(item, result) {
|
|
18
|
+
if (result.decision === "could-not-confirm")
|
|
19
|
+
return [];
|
|
18
20
|
const decisionMode = item.spec.producerPolicy?.decisionMode;
|
|
19
21
|
if (decisionMode === undefined) {
|
|
20
22
|
return [];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReviewCandidate, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewSessionEventSpec } from "../../src/review-resource.js";
|
|
2
|
-
export type ReviewWorkbenchDecision = "accept-proposed" | "keep-current" | "reject-proposed";
|
|
3
|
-
export type ReviewQueueRowStatus = "pending" | "in-review" | "resolved" | "rejected" | "escalated";
|
|
2
|
+
export type ReviewWorkbenchDecision = "accept-proposed" | "keep-current" | "reject-proposed" | "could-not-confirm";
|
|
3
|
+
export type ReviewQueueRowStatus = "pending" | "in-review" | "resolved" | "rejected" | "could-not-confirm" | "escalated";
|
|
4
4
|
export declare const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
|
|
5
5
|
export declare const defaultReviewSessionName = "review-workbench-session";
|
|
6
6
|
export interface ReviewWorkbenchState {
|
|
@@ -15,6 +15,7 @@ export interface ReviewWorkbenchState {
|
|
|
15
15
|
* proposed candidate's original value applies.
|
|
16
16
|
*/
|
|
17
17
|
readonly editedValue?: unknown;
|
|
18
|
+
readonly attemptEvidenceIds?: readonly string[];
|
|
18
19
|
}
|
|
19
20
|
export interface ReviewQueueSessionState {
|
|
20
21
|
readonly items: readonly ReviewItem[];
|
|
@@ -30,11 +31,13 @@ export interface ReviewQueueSessionState {
|
|
|
30
31
|
* original value").
|
|
31
32
|
*/
|
|
32
33
|
readonly editedValuesByItemName?: Readonly<Record<string, unknown>>;
|
|
34
|
+
readonly attemptEvidenceIdsByItemName?: Readonly<Record<string, readonly string[]>>;
|
|
33
35
|
}
|
|
34
36
|
export interface ReviewSessionSummary {
|
|
35
37
|
readonly accepted: number;
|
|
36
38
|
readonly keptCurrent: number;
|
|
37
39
|
readonly rejected: number;
|
|
40
|
+
readonly couldNotConfirm?: number;
|
|
38
41
|
readonly escalated: number;
|
|
39
42
|
readonly unresolved: number;
|
|
40
43
|
}
|
|
@@ -57,6 +60,12 @@ export declare const workbenchDecisionDefinitions: {
|
|
|
57
60
|
candidateRole: "proposed";
|
|
58
61
|
status: "rejected";
|
|
59
62
|
};
|
|
63
|
+
"could-not-confirm": {
|
|
64
|
+
label: string;
|
|
65
|
+
effect: string;
|
|
66
|
+
candidateRole: "proposed";
|
|
67
|
+
status: "proposed";
|
|
68
|
+
};
|
|
60
69
|
};
|
|
61
70
|
export declare function initialReviewWorkbenchState(item?: ReviewItem): ReviewWorkbenchState;
|
|
62
71
|
export declare function initialReviewQueueSessionState(items?: readonly ReviewItem[]): ReviewQueueSessionState;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { publicDirectoryReviewItemExample, reviewWorkbenchQueueExamples } from "./review-workbench-data.js";
|
|
2
|
+
import { assertReviewResolutionConsistency } from "../producer-discipline.js";
|
|
2
3
|
import { reviewResourceApiVersion, } from "../../src/review-resource.js";
|
|
3
4
|
export const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
|
|
4
5
|
export const defaultReviewSessionName = "review-workbench-session";
|
|
@@ -21,6 +22,12 @@ export const workbenchDecisionDefinitions = {
|
|
|
21
22
|
candidateRole: "proposed",
|
|
22
23
|
status: "rejected",
|
|
23
24
|
},
|
|
25
|
+
"could-not-confirm": {
|
|
26
|
+
label: "Could not confirm",
|
|
27
|
+
effect: "The review round ends without changing or escalating the proposed claim.",
|
|
28
|
+
candidateRole: "proposed",
|
|
29
|
+
status: "proposed",
|
|
30
|
+
},
|
|
24
31
|
};
|
|
25
32
|
export function initialReviewWorkbenchState(item = publicDirectoryReviewItemExample) {
|
|
26
33
|
return {
|
|
@@ -49,6 +56,7 @@ export function currentReviewWorkbenchState(session) {
|
|
|
49
56
|
note: session.notesByItemName[item.metadata.name] ?? "",
|
|
50
57
|
decision: session.decisionsByItemName[item.metadata.name],
|
|
51
58
|
editedValue: session.editedValuesByItemName?.[item.metadata.name],
|
|
59
|
+
attemptEvidenceIds: session.attemptEvidenceIdsByItemName?.[item.metadata.name],
|
|
52
60
|
reviewedAt: session.reviewedAt,
|
|
53
61
|
actorId: session.actorId,
|
|
54
62
|
};
|
|
@@ -65,6 +73,9 @@ export function deriveQueueRowStatus(item, session) {
|
|
|
65
73
|
if (decision === "reject-proposed") {
|
|
66
74
|
return "rejected";
|
|
67
75
|
}
|
|
76
|
+
if (decision === "could-not-confirm") {
|
|
77
|
+
return "could-not-confirm";
|
|
78
|
+
}
|
|
68
79
|
if (decision === "accept-proposed" || decision === "keep-current" || item.spec.candidateSetStatus === "resolved") {
|
|
69
80
|
return "resolved";
|
|
70
81
|
}
|
|
@@ -96,6 +107,9 @@ export function reviewSessionSummary(session) {
|
|
|
96
107
|
if (decision === "reject-proposed") {
|
|
97
108
|
return { ...summary, rejected: summary.rejected + 1 };
|
|
98
109
|
}
|
|
110
|
+
if (decision === "could-not-confirm") {
|
|
111
|
+
return { ...summary, couldNotConfirm: (summary.couldNotConfirm ?? 0) + 1 };
|
|
112
|
+
}
|
|
99
113
|
if (item.spec.candidateSetStatus === "escalated") {
|
|
100
114
|
return { ...summary, escalated: summary.escalated + 1 };
|
|
101
115
|
}
|
|
@@ -194,6 +208,9 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
|
|
|
194
208
|
if (!decision) {
|
|
195
209
|
continue;
|
|
196
210
|
}
|
|
211
|
+
if (decision === "could-not-confirm" && !note?.trim()) {
|
|
212
|
+
throw new Error(`ReviewItem ${item.metadata.name} could not confirm requires a non-empty reason.`);
|
|
213
|
+
}
|
|
197
214
|
const candidate = candidateForDecision(item, decision);
|
|
198
215
|
const definition = workbenchDecisionDefinitions[decision];
|
|
199
216
|
const reviewDecisionName = `${item.metadata.name}-${decision}`;
|
|
@@ -203,9 +220,20 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
|
|
|
203
220
|
// the server apply boundary derives effectiveValue from it. Without this
|
|
204
221
|
// the edit lives only in browser state and never survives replay.
|
|
205
222
|
const editedValue = decision === "accept-proposed" ? session.editedValuesByItemName?.[item.metadata.name] : undefined;
|
|
223
|
+
const attemptEvidenceIds = decision === "could-not-confirm"
|
|
224
|
+
? session.attemptEvidenceIdsByItemName?.[item.metadata.name]
|
|
225
|
+
: undefined;
|
|
226
|
+
assertReviewResolutionConsistency(`ReviewItem ${item.metadata.name}`, {
|
|
227
|
+
status: definition.status,
|
|
228
|
+
resolution: decision === "could-not-confirm" ? "could_not_confirm" : undefined,
|
|
229
|
+
resolutionReason: decision === "could-not-confirm" ? note : undefined,
|
|
230
|
+
attemptEvidenceIds,
|
|
231
|
+
actor: session.actorId,
|
|
232
|
+
reviewedAt: session.reviewedAt,
|
|
233
|
+
});
|
|
206
234
|
const data = editedValue !== undefined
|
|
207
235
|
? { workbenchDecision: decision, workbenchEditedValue: editedValue }
|
|
208
|
-
: { workbenchDecision: decision };
|
|
236
|
+
: { workbenchDecision: decision, ...(attemptEvidenceIds?.length ? { attemptEvidenceIds } : {}) };
|
|
209
237
|
events.push(buildReviewSessionEvent(session, {
|
|
210
238
|
sessionName,
|
|
211
239
|
sequence: events.length + 1,
|
|
@@ -215,6 +243,13 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
|
|
|
215
243
|
reviewDecisionName,
|
|
216
244
|
candidateId: candidate.id,
|
|
217
245
|
status: definition.status,
|
|
246
|
+
...(decision === "could-not-confirm"
|
|
247
|
+
? {
|
|
248
|
+
resolution: "could_not_confirm",
|
|
249
|
+
resolutionReason: note,
|
|
250
|
+
...(attemptEvidenceIds ? { attemptEvidenceIds: [...attemptEvidenceIds] } : {}),
|
|
251
|
+
}
|
|
252
|
+
: {}),
|
|
218
253
|
data,
|
|
219
254
|
}));
|
|
220
255
|
events.push(buildReviewSessionEvent(session, {
|
|
@@ -227,6 +262,13 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
|
|
|
227
262
|
candidateId: candidate.id,
|
|
228
263
|
status: definition.status,
|
|
229
264
|
rationale: note,
|
|
265
|
+
...(decision === "could-not-confirm"
|
|
266
|
+
? {
|
|
267
|
+
resolution: "could_not_confirm",
|
|
268
|
+
resolutionReason: note,
|
|
269
|
+
...(attemptEvidenceIds ? { attemptEvidenceIds: [...attemptEvidenceIds] } : {}),
|
|
270
|
+
}
|
|
271
|
+
: {}),
|
|
230
272
|
data,
|
|
231
273
|
}));
|
|
232
274
|
}
|
|
@@ -264,7 +306,13 @@ export function replayReviewSessionEvents(startState, events) {
|
|
|
264
306
|
if (isClearedWorkbenchDecisionEvent(event)) {
|
|
265
307
|
const { [itemName]: _removedDecision, ...remainingDecisions } = session.decisionsByItemName;
|
|
266
308
|
const { [itemName]: _removedEdit, ...remainingEdits } = session.editedValuesByItemName ?? {};
|
|
267
|
-
|
|
309
|
+
const { [itemName]: _removedAttempts, ...remainingAttempts } = session.attemptEvidenceIdsByItemName ?? {};
|
|
310
|
+
return {
|
|
311
|
+
...session,
|
|
312
|
+
decisionsByItemName: remainingDecisions,
|
|
313
|
+
editedValuesByItemName: remainingEdits,
|
|
314
|
+
attemptEvidenceIdsByItemName: remainingAttempts,
|
|
315
|
+
};
|
|
268
316
|
}
|
|
269
317
|
const decision = workbenchDecisionFromEvent(event);
|
|
270
318
|
if (!decision) {
|
|
@@ -276,12 +324,19 @@ export function replayReviewSessionEvents(startState, events) {
|
|
|
276
324
|
// reviewer moved away from.
|
|
277
325
|
const editedValue = workbenchEditedValueFromEvent(event);
|
|
278
326
|
const editedValuesByItemName = { ...session.editedValuesByItemName };
|
|
327
|
+
const attemptEvidenceIdsByItemName = { ...session.attemptEvidenceIdsByItemName };
|
|
279
328
|
if (decision === "accept-proposed" && editedValue !== undefined) {
|
|
280
329
|
editedValuesByItemName[itemName] = editedValue;
|
|
281
330
|
}
|
|
282
331
|
else {
|
|
283
332
|
delete editedValuesByItemName[itemName];
|
|
284
333
|
}
|
|
334
|
+
if (decision === "could-not-confirm" && event.spec.attemptEvidenceIds?.length) {
|
|
335
|
+
attemptEvidenceIdsByItemName[itemName] = [...event.spec.attemptEvidenceIds];
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
delete attemptEvidenceIdsByItemName[itemName];
|
|
339
|
+
}
|
|
285
340
|
return {
|
|
286
341
|
...session,
|
|
287
342
|
decisionsByItemName: {
|
|
@@ -289,6 +344,7 @@ export function replayReviewSessionEvents(startState, events) {
|
|
|
289
344
|
[itemName]: decision,
|
|
290
345
|
},
|
|
291
346
|
editedValuesByItemName,
|
|
347
|
+
attemptEvidenceIdsByItemName,
|
|
292
348
|
};
|
|
293
349
|
}
|
|
294
350
|
return session;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReviewSessionEvent } from "../review-resource.js";
|
|
2
2
|
import { type ReviewQueueSessionState } from "./review-queue-session.js";
|
|
3
|
-
export type ReviewSessionReplayIssueCode = "invalid-sequence" | "duplicate-sequence" | "non-contiguous-sequence" | "unknown-active-item" | "unknown-review-item" | "unknown-candidate" | "missing-review-item" | "invalid-workbench-decision" | "decision-candidate-mismatch" | "decision-status-mismatch";
|
|
3
|
+
export type ReviewSessionReplayIssueCode = "invalid-sequence" | "duplicate-sequence" | "non-contiguous-sequence" | "unknown-active-item" | "unknown-review-item" | "unknown-candidate" | "missing-review-item" | "invalid-workbench-decision" | "decision-candidate-mismatch" | "decision-status-mismatch" | "decision-resolution-mismatch" | "missing-resolution-reason";
|
|
4
4
|
export interface ReviewSessionReplayIssue {
|
|
5
5
|
readonly code: ReviewSessionReplayIssueCode;
|
|
6
6
|
readonly eventName: string;
|
|
@@ -76,6 +76,25 @@ export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
|
76
76
|
message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects status ${expectedStatus}, but references ${event.spec.status ?? "no status"}.`,
|
|
77
77
|
});
|
|
78
78
|
}
|
|
79
|
+
const expectedResolution = decision === "could-not-confirm" ? "could_not_confirm" : undefined;
|
|
80
|
+
if (event.spec.resolution !== expectedResolution) {
|
|
81
|
+
issues.push({
|
|
82
|
+
...eventRef,
|
|
83
|
+
code: "decision-resolution-mismatch",
|
|
84
|
+
reviewItemName: itemName,
|
|
85
|
+
candidateId: event.spec.candidateId,
|
|
86
|
+
message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects resolution ${expectedResolution ?? "none"}, but references ${event.spec.resolution ?? "no resolution"}.`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (decision === "could-not-confirm" && !event.spec.resolutionReason?.trim()) {
|
|
90
|
+
issues.push({
|
|
91
|
+
...eventRef,
|
|
92
|
+
code: "missing-resolution-reason",
|
|
93
|
+
reviewItemName: itemName,
|
|
94
|
+
candidateId: event.spec.candidateId,
|
|
95
|
+
message: `ReviewSessionEvent ${event.metadata.name} decision could-not-confirm requires a non-empty resolutionReason.`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
79
98
|
}
|
|
80
99
|
}
|
|
81
100
|
if (event.spec.candidateId && itemName && itemsByName.has(itemName)) {
|
|
@@ -8,7 +8,7 @@ export function buildSurfaceProjectionPreview(item, decision, presentationAdapte
|
|
|
8
8
|
canonicalClaim: buildPreviewClaim(item, selectedCandidate, decision, projection, presentationAdapter),
|
|
9
9
|
candidateHistory: buildCandidateHistory(item, selectedCandidate, presentationAdapter),
|
|
10
10
|
sourceEvidence: buildSourceEvidence(selectedCandidate),
|
|
11
|
-
reviewEvent: buildReviewEvent(decision, projection),
|
|
11
|
+
reviewEvent: decision.spec.resolution === "could_not_confirm" ? undefined : buildReviewEvent(decision, projection),
|
|
12
12
|
integrityPosture: buildIntegrityPosture(item, selectedCandidate, projection),
|
|
13
13
|
authorityTrace: portableAuthorityTrace(selectedCandidate.producer?.authorityTrace),
|
|
14
14
|
postureDisclaimer: postureDisclaimer(),
|
|
@@ -437,6 +437,7 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
437
437
|
.survey-workbench-embed .field[data-state="accepted"] .stripe{ background: var(--k-positive); }
|
|
438
438
|
.survey-workbench-embed .field[data-state="kept"] .stripe{ background: var(--k-faint); }
|
|
439
439
|
.survey-workbench-embed .field[data-state="rejected"] .stripe{ background: var(--k-negative); }
|
|
440
|
+
.survey-workbench-embed .field[data-state="could-not-confirm"] .stripe{ background: var(--k-caution); }
|
|
440
441
|
.survey-workbench-embed .field[data-state="review"] .stripe{ background: var(--k-brand); }
|
|
441
442
|
|
|
442
443
|
.survey-workbench-embed .fbody{
|
|
@@ -491,6 +492,7 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
491
492
|
.survey-workbench-embed .chip.accepted{ color: var(--k-positive); background: var(--k-positive-wash); }
|
|
492
493
|
.survey-workbench-embed .chip.kept{ color: var(--k-muted); background: var(--k-sunken); }
|
|
493
494
|
.survey-workbench-embed .chip.rejected{ color: var(--k-negative); background: var(--k-negative-wash); }
|
|
495
|
+
.survey-workbench-embed .chip.could-not-confirm{ color: var(--k-caution); background: var(--k-caution-wash); }
|
|
494
496
|
|
|
495
497
|
.survey-workbench-embed .frow1 .push{
|
|
496
498
|
margin-left: auto;
|
|
@@ -755,6 +757,12 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
755
757
|
background: var(--k-panel-raised, var(--k-raised));
|
|
756
758
|
}
|
|
757
759
|
|
|
760
|
+
.survey-workbench-embed .btn.unconfirmed{
|
|
761
|
+
border-color: var(--k-caution);
|
|
762
|
+
color: var(--k-caution);
|
|
763
|
+
background: var(--k-caution-wash);
|
|
764
|
+
}
|
|
765
|
+
|
|
758
766
|
/* "Suggestion was wrong" — optional quiet calibration toggle on Keep current.
|
|
759
767
|
Ticking it turns a keep-current decision into the reject signal. Not a third action. */
|
|
760
768
|
.survey-workbench-embed .wrong{
|
|
@@ -434,6 +434,7 @@
|
|
|
434
434
|
.survey-workbench-embed .field[data-state="accepted"] .stripe{ background: var(--k-positive); }
|
|
435
435
|
.survey-workbench-embed .field[data-state="kept"] .stripe{ background: var(--k-faint); }
|
|
436
436
|
.survey-workbench-embed .field[data-state="rejected"] .stripe{ background: var(--k-negative); }
|
|
437
|
+
.survey-workbench-embed .field[data-state="could-not-confirm"] .stripe{ background: var(--k-caution); }
|
|
437
438
|
.survey-workbench-embed .field[data-state="review"] .stripe{ background: var(--k-brand); }
|
|
438
439
|
|
|
439
440
|
.survey-workbench-embed .fbody{
|
|
@@ -488,6 +489,7 @@
|
|
|
488
489
|
.survey-workbench-embed .chip.accepted{ color: var(--k-positive); background: var(--k-positive-wash); }
|
|
489
490
|
.survey-workbench-embed .chip.kept{ color: var(--k-muted); background: var(--k-sunken); }
|
|
490
491
|
.survey-workbench-embed .chip.rejected{ color: var(--k-negative); background: var(--k-negative-wash); }
|
|
492
|
+
.survey-workbench-embed .chip.could-not-confirm{ color: var(--k-caution); background: var(--k-caution-wash); }
|
|
491
493
|
|
|
492
494
|
.survey-workbench-embed .frow1 .push{
|
|
493
495
|
margin-left: auto;
|
|
@@ -752,6 +754,12 @@
|
|
|
752
754
|
background: var(--k-panel-raised, var(--k-raised));
|
|
753
755
|
}
|
|
754
756
|
|
|
757
|
+
.survey-workbench-embed .btn.unconfirmed{
|
|
758
|
+
border-color: var(--k-caution);
|
|
759
|
+
color: var(--k-caution);
|
|
760
|
+
background: var(--k-caution-wash);
|
|
761
|
+
}
|
|
762
|
+
|
|
755
763
|
/* "Suggestion was wrong" — optional quiet calibration toggle on Keep current.
|
|
756
764
|
Ticking it turns a keep-current decision into the reject signal. Not a third action. */
|
|
757
765
|
.survey-workbench-embed .wrong{
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { canonicalJson } from "./canonical.js";
|
|
2
|
+
import { assertReviewResolutionConsistency } from "../producer-discipline.js";
|
|
2
3
|
import { candidateForDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
3
4
|
import { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
|
|
4
5
|
import { buildSurfaceProjectionPreview, formatValue, } from "./review-surface-preview.js";
|
|
@@ -16,13 +17,16 @@ export function buildReviewDecision(state) {
|
|
|
16
17
|
return undefined;
|
|
17
18
|
}
|
|
18
19
|
const definition = workbenchDecisionDefinitions[state.decision];
|
|
20
|
+
if (state.decision === "could-not-confirm" && !state.note.trim()) {
|
|
21
|
+
throw new Error("Could not confirm requires a non-empty reason.");
|
|
22
|
+
}
|
|
19
23
|
const candidate = candidateForDecision(state.item, state.decision);
|
|
20
24
|
const projection = {
|
|
21
25
|
...candidate.projection,
|
|
22
26
|
reviewOutcomeId: candidate.projection?.reviewOutcomeId
|
|
23
27
|
?? `${state.item.metadata.name}:${state.decision}:review-outcome`,
|
|
24
28
|
};
|
|
25
|
-
|
|
29
|
+
const reviewDecision = {
|
|
26
30
|
apiVersion: reviewResourceApiVersion,
|
|
27
31
|
kind: "ReviewDecision",
|
|
28
32
|
metadata: {
|
|
@@ -34,6 +38,13 @@ export function buildReviewDecision(state) {
|
|
|
34
38
|
reviewItemName: state.item.metadata.name,
|
|
35
39
|
candidateId: candidate.id,
|
|
36
40
|
status: definition.status,
|
|
41
|
+
...(state.decision === "could-not-confirm"
|
|
42
|
+
? {
|
|
43
|
+
resolution: "could_not_confirm",
|
|
44
|
+
resolutionReason: state.note.trim(),
|
|
45
|
+
...(state.attemptEvidenceIds?.length ? { attemptEvidenceIds: [...state.attemptEvidenceIds] } : {}),
|
|
46
|
+
}
|
|
47
|
+
: {}),
|
|
37
48
|
actor: {
|
|
38
49
|
id: state.actorId,
|
|
39
50
|
},
|
|
@@ -47,6 +58,15 @@ export function buildReviewDecision(state) {
|
|
|
47
58
|
appliedToClaimIds: candidate.projection?.claimId ? [candidate.projection.claimId] : undefined,
|
|
48
59
|
},
|
|
49
60
|
};
|
|
61
|
+
assertReviewResolutionConsistency(`ReviewDecision ${reviewDecision.metadata.name}`, {
|
|
62
|
+
status: reviewDecision.spec.status,
|
|
63
|
+
resolution: reviewDecision.spec.resolution,
|
|
64
|
+
resolutionReason: reviewDecision.spec.resolutionReason,
|
|
65
|
+
attemptEvidenceIds: reviewDecision.spec.attemptEvidenceIds,
|
|
66
|
+
actor: reviewDecision.spec.actor?.id,
|
|
67
|
+
reviewedAt: reviewDecision.spec.reviewedAt,
|
|
68
|
+
});
|
|
69
|
+
return reviewDecision;
|
|
50
70
|
}
|
|
51
71
|
/**
|
|
52
72
|
* Stable versioned prompt identifier for the workbench decision card control.
|
|
@@ -177,6 +197,9 @@ function duplicateTargetIssues(items) {
|
|
|
177
197
|
return issues;
|
|
178
198
|
}
|
|
179
199
|
function mapReviewApplyResultToActions(input) {
|
|
200
|
+
if (input.result.decision === "could-not-confirm") {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
180
203
|
const context = buildReviewApplyActionContext(input.result, input.itemByName, input.issues);
|
|
181
204
|
if (!context || input.options.skip?.(context)) {
|
|
182
205
|
return [];
|
|
@@ -442,6 +465,8 @@ function fieldCardState(item, decision) {
|
|
|
442
465
|
return "kept";
|
|
443
466
|
if (decision === "reject-proposed")
|
|
444
467
|
return "rejected";
|
|
468
|
+
if (decision === "could-not-confirm")
|
|
469
|
+
return "could-not-confirm";
|
|
445
470
|
if (item.spec.candidateSetStatus === "resolved")
|
|
446
471
|
return "kept";
|
|
447
472
|
return "review";
|
|
@@ -451,6 +476,7 @@ function chipLabel(state) {
|
|
|
451
476
|
case "accepted": return "Accepted";
|
|
452
477
|
case "kept": return "Kept current";
|
|
453
478
|
case "rejected": return "Kept — flagged wrong";
|
|
479
|
+
case "could-not-confirm": return "Could not confirm";
|
|
454
480
|
default: return "Needs review";
|
|
455
481
|
}
|
|
456
482
|
}
|
|
@@ -540,6 +566,7 @@ function renderFieldCard(item, session, presentationAdapter) {
|
|
|
540
566
|
<input type="checkbox" class="wrongbox" data-testid="wrong-toggle" data-item-name="${escapeHtml(item.metadata.name)}">
|
|
541
567
|
Suggestion was wrong
|
|
542
568
|
</label>
|
|
569
|
+
<button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>
|
|
543
570
|
</div>
|
|
544
571
|
<div class="decided">
|
|
545
572
|
<span class="chip ${state}" data-testid="decided-chip">${chipLabel(state)}</span>
|
|
@@ -813,7 +840,7 @@ function renderProducerFeedbackTags(item) {
|
|
|
813
840
|
`;
|
|
814
841
|
}
|
|
815
842
|
function renderFooterTally(session) {
|
|
816
|
-
const counts = { review: 0, accepted: 0, kept: 0, rejected: 0 };
|
|
843
|
+
const counts = { review: 0, accepted: 0, kept: 0, rejected: 0, "could-not-confirm": 0 };
|
|
817
844
|
for (const item of session.items) {
|
|
818
845
|
const state = fieldCardState(item, session.decisionsByItemName[item.metadata.name]);
|
|
819
846
|
counts[state] += 1;
|
|
@@ -824,6 +851,7 @@ function renderFooterTally(session) {
|
|
|
824
851
|
<span>Accepted <b data-testid="tally-accepted">${counts.accepted}</b></span>
|
|
825
852
|
<span>Kept <b data-testid="tally-kept">${counts.kept}</b></span>
|
|
826
853
|
<span>Flagged <b data-testid="tally-rejected">${counts.rejected}</b></span>
|
|
854
|
+
<span>Unconfirmed <b data-testid="tally-could-not-confirm">${counts["could-not-confirm"]}</b></span>
|
|
827
855
|
<span>Remaining <b data-testid="tally-review">${counts.review}</b></span>
|
|
828
856
|
</div>
|
|
829
857
|
</footer>
|
|
@@ -973,10 +1001,13 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
973
1001
|
// effectiveValue from snapshot + events alone. setDecision updates the
|
|
974
1002
|
// session before this runs, so the edit is already in state.
|
|
975
1003
|
const editedValue = itemName && decision === "accept-proposed" ? session.editedValuesByItemName?.[itemName] : undefined;
|
|
1004
|
+
const attemptEvidenceIds = itemName && decision === "could-not-confirm"
|
|
1005
|
+
? session.attemptEvidenceIdsByItemName?.[itemName]
|
|
1006
|
+
: undefined;
|
|
976
1007
|
const defaultData = decision
|
|
977
1008
|
? (editedValue !== undefined
|
|
978
1009
|
? { workbenchDecision: decision, workbenchEditedValue: editedValue }
|
|
979
|
-
: { workbenchDecision: decision })
|
|
1010
|
+
: { workbenchDecision: decision, ...(attemptEvidenceIds?.length ? { attemptEvidenceIds } : {}) })
|
|
980
1011
|
: undefined;
|
|
981
1012
|
events = [
|
|
982
1013
|
...events,
|
|
@@ -989,6 +1020,13 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
989
1020
|
reviewDecisionName: item && decision ? `${item.metadata.name}-${decision}` : undefined,
|
|
990
1021
|
candidateId: candidate?.id,
|
|
991
1022
|
status: definition?.status,
|
|
1023
|
+
...(decision === "could-not-confirm"
|
|
1024
|
+
? {
|
|
1025
|
+
resolution: "could_not_confirm",
|
|
1026
|
+
resolutionReason: note?.trim(),
|
|
1027
|
+
...(attemptEvidenceIds ? { attemptEvidenceIds: [...attemptEvidenceIds] } : {}),
|
|
1028
|
+
}
|
|
1029
|
+
: {}),
|
|
992
1030
|
rationale: note,
|
|
993
1031
|
data: dataOverride ?? defaultData,
|
|
994
1032
|
}),
|
|
@@ -998,6 +1036,9 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
998
1036
|
session = update(session);
|
|
999
1037
|
};
|
|
1000
1038
|
const setDecision = (itemName, decision, rawEditedValue) => {
|
|
1039
|
+
if (decision === "could-not-confirm" && !session.notesByItemName[itemName]?.trim()) {
|
|
1040
|
+
throw new Error("Could not confirm requires a non-empty reason.");
|
|
1041
|
+
}
|
|
1001
1042
|
applySessionUpdate((current) => {
|
|
1002
1043
|
const item = current.items.find((entry) => entry.metadata.name === itemName);
|
|
1003
1044
|
const proposed = item?.spec.candidates.find((candidate) => candidate.role === "proposed");
|
|
@@ -1025,11 +1066,14 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
1025
1066
|
delete remainingDecisions[itemName];
|
|
1026
1067
|
const remainingEdits = { ...current.editedValuesByItemName };
|
|
1027
1068
|
delete remainingEdits[itemName];
|
|
1069
|
+
const remainingAttemptEvidenceIds = { ...current.attemptEvidenceIdsByItemName };
|
|
1070
|
+
delete remainingAttemptEvidenceIds[itemName];
|
|
1028
1071
|
return {
|
|
1029
1072
|
...current,
|
|
1030
1073
|
activeItemName: itemName,
|
|
1031
1074
|
decisionsByItemName: remainingDecisions,
|
|
1032
1075
|
editedValuesByItemName: remainingEdits,
|
|
1076
|
+
attemptEvidenceIdsByItemName: remainingAttemptEvidenceIds,
|
|
1033
1077
|
};
|
|
1034
1078
|
});
|
|
1035
1079
|
appendEvent("decision-changed", itemName, { workbenchDecision: null });
|
|
@@ -1082,6 +1126,9 @@ function queueSessionFromStartState(startState) {
|
|
|
1082
1126
|
editedValuesByItemName: startState.editedValue !== undefined
|
|
1083
1127
|
? { [startState.item.metadata.name]: startState.editedValue }
|
|
1084
1128
|
: {},
|
|
1129
|
+
...(startState.attemptEvidenceIds?.length
|
|
1130
|
+
? { attemptEvidenceIdsByItemName: { [startState.item.metadata.name]: [...startState.attemptEvidenceIds] } }
|
|
1131
|
+
: {}),
|
|
1085
1132
|
reviewedAt: startState.reviewedAt,
|
|
1086
1133
|
actorId: startState.actorId,
|
|
1087
1134
|
};
|
|
@@ -1125,6 +1172,23 @@ function bindFieldCardInteractions(root, controller) {
|
|
|
1125
1172
|
controller.renderCurrentState();
|
|
1126
1173
|
});
|
|
1127
1174
|
});
|
|
1175
|
+
root.querySelectorAll("[data-testid='could-not-confirm']").forEach((button) => {
|
|
1176
|
+
button.addEventListener("click", () => {
|
|
1177
|
+
const itemName = button.dataset.itemName ?? "";
|
|
1178
|
+
const note = controller.currentSession().notesByItemName[itemName]?.trim() ?? "";
|
|
1179
|
+
const field = button.closest("[data-testid='review-field']");
|
|
1180
|
+
const textarea = field?.querySelector("[data-testid='reviewer-note']");
|
|
1181
|
+
if (!note) {
|
|
1182
|
+
textarea?.setCustomValidity?.("A reason is required when you could not confirm.");
|
|
1183
|
+
textarea?.reportValidity?.();
|
|
1184
|
+
textarea?.focus();
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
textarea?.setCustomValidity?.("");
|
|
1188
|
+
controller.setDecision(itemName, "could-not-confirm");
|
|
1189
|
+
controller.renderCurrentState();
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
1128
1192
|
root.querySelectorAll("[data-testid='undo-decision']").forEach((button) => {
|
|
1129
1193
|
button.addEventListener("click", () => {
|
|
1130
1194
|
controller.clearDecision(button.dataset.itemName ?? "");
|
|
@@ -1133,6 +1197,7 @@ function bindFieldCardInteractions(root, controller) {
|
|
|
1133
1197
|
});
|
|
1134
1198
|
root.querySelectorAll("[data-testid='reviewer-note']").forEach((textarea) => {
|
|
1135
1199
|
textarea.addEventListener("input", () => {
|
|
1200
|
+
textarea.setCustomValidity?.("");
|
|
1136
1201
|
const itemName = textarea.dataset.itemName ?? "";
|
|
1137
1202
|
controller.updateReviewerNote(itemName, textarea.value);
|
|
1138
1203
|
refreshAuditPayloadForItem(textarea, controller, itemName);
|
|
@@ -283,6 +283,7 @@ h1, h2, h3, p {
|
|
|
283
283
|
.field[data-state="accepted"] .stripe { background: var(--k-positive); }
|
|
284
284
|
.field[data-state="kept"] .stripe { background: var(--k-faint); }
|
|
285
285
|
.field[data-state="rejected"] .stripe { background: var(--k-negative); }
|
|
286
|
+
.field[data-state="could-not-confirm"] .stripe { background: var(--k-caution); }
|
|
286
287
|
.field[data-state="review"] .stripe { background: var(--k-brand); }
|
|
287
288
|
|
|
288
289
|
.fbody {
|
|
@@ -337,6 +338,7 @@ h1, h2, h3, p {
|
|
|
337
338
|
.chip.accepted { color: var(--k-positive); background: var(--k-positive-wash); }
|
|
338
339
|
.chip.kept { color: var(--k-muted); background: var(--k-sunken); }
|
|
339
340
|
.chip.rejected { color: var(--k-negative); background: var(--k-negative-wash); }
|
|
341
|
+
.chip.could-not-confirm { color: var(--k-caution); background: var(--k-caution-wash); }
|
|
340
342
|
|
|
341
343
|
.frow1 .push {
|
|
342
344
|
margin-left: auto;
|
|
@@ -601,6 +603,12 @@ h1, h2, h3, p {
|
|
|
601
603
|
background: var(--k-panel-raised, var(--k-raised));
|
|
602
604
|
}
|
|
603
605
|
|
|
606
|
+
.btn.unconfirmed {
|
|
607
|
+
border-color: var(--k-caution);
|
|
608
|
+
color: var(--k-caution);
|
|
609
|
+
background: var(--k-caution-wash);
|
|
610
|
+
}
|
|
611
|
+
|
|
604
612
|
/* "Suggestion was wrong" — optional quiet calibration toggle on Keep current.
|
|
605
613
|
Ticking it turns a keep-current decision into the reject signal. Not a third action. */
|
|
606
614
|
.wrong {
|
package/dist/src/to-surface.js
CHANGED
|
@@ -27,11 +27,16 @@ export function buildSurveyTrustBundle(input, options = {}) {
|
|
|
27
27
|
const extraction = requireMapValue(extractions, candidate.extractionId, "extraction");
|
|
28
28
|
const rawSource = requireMapValue(rawSources, extraction.sourceId, "raw source");
|
|
29
29
|
const review = selectReview(reviewsByCandidateSet.get(candidateSet.id) ?? [], candidate.id);
|
|
30
|
-
const
|
|
31
|
-
|
|
30
|
+
const projectionReview = review?.resolution === "could_not_confirm" ? undefined : review;
|
|
31
|
+
const unreviewedStatus = statusFor({ candidateSet, candidate });
|
|
32
|
+
const status = projection.status
|
|
33
|
+
?? (review?.resolution === "could_not_confirm"
|
|
34
|
+
? (unreviewedStatus === "disputed" ? unreviewedStatus : review.status)
|
|
35
|
+
: statusFor({ candidateSet, candidate, review }));
|
|
36
|
+
assertProducerDiscipline({ status, review, candidateSet, extraction, rawSource, projection });
|
|
32
37
|
const claimValue = projection.value ?? candidate.value;
|
|
33
38
|
const createdAt = projection.createdAt ?? extraction.extractedAt;
|
|
34
|
-
const updatedAt = projection.updatedAt ??
|
|
39
|
+
const updatedAt = projection.updatedAt ?? projectionReview?.reviewedAt ?? input.generatedAt;
|
|
35
40
|
const evidenceId = `${projection.id}.evidence.source`;
|
|
36
41
|
const claim = {
|
|
37
42
|
id: projection.id,
|
|
@@ -57,7 +62,15 @@ export function buildSurveyTrustBundle(input, options = {}) {
|
|
|
57
62
|
},
|
|
58
63
|
metadata: {
|
|
59
64
|
...projection.metadata,
|
|
60
|
-
survey: buildSurveyMetadata({
|
|
65
|
+
survey: buildSurveyMetadata({
|
|
66
|
+
projection,
|
|
67
|
+
rawSource,
|
|
68
|
+
extraction,
|
|
69
|
+
candidateSet,
|
|
70
|
+
candidate,
|
|
71
|
+
review: projectionReview,
|
|
72
|
+
comfortZoneReview: review,
|
|
73
|
+
}),
|
|
61
74
|
},
|
|
62
75
|
};
|
|
63
76
|
// Promote the review's comfort-zone signal — and, when calibration is
|
|
@@ -82,7 +95,7 @@ export function buildSurveyTrustBundle(input, options = {}) {
|
|
|
82
95
|
}
|
|
83
96
|
: undefined;
|
|
84
97
|
const affirmedConclusion = status === "verified" || status === "assumed";
|
|
85
|
-
const calibrated = calibrationMetrics &&
|
|
98
|
+
const calibrated = calibrationMetrics && projectionReview && affirmedConclusion
|
|
86
99
|
? lookupCalibratedValue(calibrationMetrics, extraction.extractor, extraction.target, calibrationMinSamples)
|
|
87
100
|
: undefined;
|
|
88
101
|
if (comfortZone || calibrated) {
|
|
@@ -91,13 +104,16 @@ export function buildSurveyTrustBundle(input, options = {}) {
|
|
|
91
104
|
...(comfortZone ? { comfortZone } : {}),
|
|
92
105
|
};
|
|
93
106
|
}
|
|
94
|
-
|
|
107
|
+
// Could-not-confirm stays byte-quiet at the Surface boundary. In particular,
|
|
108
|
+
// do not attach an anchor whose public observedAt would disclose review time;
|
|
109
|
+
// callers can persist/recompute Survey's canonical v3 proof separately.
|
|
110
|
+
if (options.reviewProofs && projectionReview) {
|
|
95
111
|
claim.currentIntegrityAnchor = buildReviewProofAnchor({
|
|
96
112
|
rawSource,
|
|
97
113
|
extraction,
|
|
98
114
|
candidate,
|
|
99
115
|
candidateSet,
|
|
100
|
-
reviewOutcome:
|
|
116
|
+
reviewOutcome: projectionReview,
|
|
101
117
|
claim: {
|
|
102
118
|
...projection,
|
|
103
119
|
value: claimValue,
|
|
@@ -129,16 +145,16 @@ export function buildSurveyTrustBundle(input, options = {}) {
|
|
|
129
145
|
confidence: candidate.confidence ?? extraction.confidence,
|
|
130
146
|
},
|
|
131
147
|
});
|
|
132
|
-
const rationale =
|
|
148
|
+
const rationale = projectionReview?.rationale ?? candidateSet.rationale;
|
|
133
149
|
events.push({
|
|
134
150
|
id: `${projection.id}.event.${status}`,
|
|
135
151
|
claimId: projection.id,
|
|
136
152
|
status,
|
|
137
|
-
actor: projection.actor ??
|
|
153
|
+
actor: projection.actor ?? projectionReview?.actor ?? projection.collectedBy,
|
|
138
154
|
method: projection.eventMethod ?? eventMethodFor(status, candidateSet),
|
|
139
|
-
evidenceIds:
|
|
140
|
-
createdAt:
|
|
141
|
-
verifiedAt: status === "verified" || status === "assumed" ?
|
|
155
|
+
evidenceIds: projectionReview?.evidenceIds?.length ? projectionReview.evidenceIds : [evidenceId],
|
|
156
|
+
createdAt: projectionReview?.reviewedAt ?? input.generatedAt,
|
|
157
|
+
verifiedAt: status === "verified" || status === "assumed" ? projectionReview?.reviewedAt ?? input.generatedAt : undefined,
|
|
142
158
|
notes: rationale,
|
|
143
159
|
});
|
|
144
160
|
}
|
|
@@ -357,11 +373,11 @@ function buildSurveyMetadata(input) {
|
|
|
357
373
|
},
|
|
358
374
|
}
|
|
359
375
|
: {}),
|
|
360
|
-
...(input.
|
|
376
|
+
...(input.comfortZoneReview?.withinComfortZone === false
|
|
361
377
|
? {
|
|
362
378
|
comfortZone: {
|
|
363
379
|
withinComfortZone: false,
|
|
364
|
-
...(input.
|
|
380
|
+
...(input.comfortZoneReview.comfortZoneNote ? { note: input.comfortZoneReview.comfortZoneNote } : {}),
|
|
365
381
|
},
|
|
366
382
|
}
|
|
367
383
|
: {}),
|
|
@@ -384,6 +400,7 @@ function assertProducerDiscipline(input) {
|
|
|
384
400
|
subject: `Claim ${input.projection.id}`,
|
|
385
401
|
status: input.status,
|
|
386
402
|
review: input.review,
|
|
403
|
+
candidateSetStatus: input.candidateSet.status,
|
|
387
404
|
});
|
|
388
405
|
if (input.rawSource.kind !== "manual-entry" && !input.extraction.locator) {
|
|
389
406
|
throw new Error(`Claim ${input.projection.id} needs a source locator for ${input.rawSource.kind}`);
|
package/dist/src/types.d.ts
CHANGED
|
@@ -49,6 +49,7 @@ export interface CandidateSet {
|
|
|
49
49
|
metadata?: Record<string, unknown>;
|
|
50
50
|
}
|
|
51
51
|
export type ReviewStatus = Extract<TrustStatus, "verified" | "assumed" | "rejected" | "proposed">;
|
|
52
|
+
export type ReviewResolution = "accepted" | "rejected" | "held" | "could_not_confirm";
|
|
52
53
|
export type ReviewAuthorizingKind = "explicit-statement" | "exchange" | "authorized-action";
|
|
53
54
|
export interface ReviewAuthorizingExplicitStatement {
|
|
54
55
|
kind: "explicit-statement";
|
|
@@ -74,6 +75,13 @@ export interface ReviewOutcome {
|
|
|
74
75
|
candidateSetId: string;
|
|
75
76
|
candidateId?: string;
|
|
76
77
|
status: ReviewStatus;
|
|
78
|
+
/** Explicit terminal resolution for this review round. Absent outcomes retain
|
|
79
|
+
* the legacy status-inference behavior. */
|
|
80
|
+
resolution?: ReviewResolution;
|
|
81
|
+
/** Required, non-empty explanation when resolution is `could_not_confirm`. */
|
|
82
|
+
resolutionReason?: string;
|
|
83
|
+
/** Evidence of attempts made before the reviewer concluded they could not confirm. */
|
|
84
|
+
attemptEvidenceIds?: string[];
|
|
77
85
|
actor?: string;
|
|
78
86
|
reviewedAt?: string;
|
|
79
87
|
rationale?: string;
|
package/package.json
CHANGED