@kontourai/survey 6.0.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/example-data/public-directory-review-resource.d.ts +3 -3
- package/dist/src/candidate-verification.d.ts +175 -0
- package/dist/src/candidate-verification.js +334 -0
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +2 -1
- package/dist/src/mcp/review-mcp.js +23 -9
- package/dist/src/review-resource.d.ts +9 -0
- package/dist/src/review-workbench/review-presentation.d.ts +37 -0
- package/dist/src/review-workbench/review-presentation.js +56 -0
- package/dist/src/review-workbench/review-workbench-css.generated.js +2 -1
- package/dist/src/review-workbench/review-workbench.css +2 -1
- package/dist/src/review-workbench/review-workbench.js +19 -3
- package/dist/src/review-workbench/review-workbench.standalone.css +2 -1
- package/package.json +1 -1
|
@@ -35,12 +35,12 @@ export declare const publicDirectoryReviewItemExample: {
|
|
|
35
35
|
excerpt: string;
|
|
36
36
|
};
|
|
37
37
|
extraction: {
|
|
38
|
+
model?: undefined;
|
|
38
39
|
extractionId: string;
|
|
39
40
|
target: string;
|
|
40
41
|
confidence: number;
|
|
41
42
|
extractor: string;
|
|
42
43
|
extractedAt: string;
|
|
43
|
-
model?: undefined;
|
|
44
44
|
};
|
|
45
45
|
claimTarget: {
|
|
46
46
|
claimId: string;
|
|
@@ -61,13 +61,13 @@ export declare const publicDirectoryReviewItemExample: {
|
|
|
61
61
|
claimId: string;
|
|
62
62
|
};
|
|
63
63
|
producer: {
|
|
64
|
+
proposalId?: undefined;
|
|
65
|
+
oldValue?: undefined;
|
|
64
66
|
sourceAuthority: {
|
|
65
67
|
authorityClass: string;
|
|
66
68
|
declaredBy: string;
|
|
67
69
|
scope: string;
|
|
68
70
|
};
|
|
69
|
-
proposalId?: undefined;
|
|
70
|
-
oldValue?: undefined;
|
|
71
71
|
};
|
|
72
72
|
} | {
|
|
73
73
|
id: string;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Support verification: a port for an independent check of whether a
|
|
3
|
+
* candidate's value is supported by its evidence, and the immutable record of
|
|
4
|
+
* what such a check said.
|
|
5
|
+
*
|
|
6
|
+
* A {@link CandidateVerification} is a record of a verifier's output, bound to
|
|
7
|
+
* the exact value and evidence it checked. It is not a trust decision: nothing
|
|
8
|
+
* in Survey reads it to route, auto-accept, or calibrate a candidate. Survey
|
|
9
|
+
* ships no verifier implementations; producers or separate adapter packages
|
|
10
|
+
* implement {@link SupportVerifier}.
|
|
11
|
+
*
|
|
12
|
+
* Runtime-neutral (no Node built-ins) so the review workbench bundle can
|
|
13
|
+
* validate records in the browser.
|
|
14
|
+
*/
|
|
15
|
+
export declare const candidateVerificationKind: "survey.candidate-verification";
|
|
16
|
+
export type SupportVerificationMethod = "deterministic" | "model" | "human";
|
|
17
|
+
export type SupportVerificationResult = "supported" | "contradicted" | "not-addressed" | "abstain";
|
|
18
|
+
export type SupportAbstainReason = "malformed" | "empty" | "timeout" | "unsupported" | "error";
|
|
19
|
+
export interface SupportEvidence {
|
|
20
|
+
id: string;
|
|
21
|
+
excerpt: string;
|
|
22
|
+
locator: string;
|
|
23
|
+
context?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface SupportVerificationInput {
|
|
26
|
+
candidateId: string;
|
|
27
|
+
value: unknown;
|
|
28
|
+
/** Declared value type when the producer knows it (for example `ReviewValueType`). */
|
|
29
|
+
valueType?: string;
|
|
30
|
+
/** At least one item; ids are unique. */
|
|
31
|
+
evidence: SupportEvidence[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* What a verifier returns. `score` has no meaning unless a calibration record
|
|
35
|
+
* says otherwise: it is optional, never defaulted, and never feeds a
|
|
36
|
+
* confidence. An abstention carries its reason and no score.
|
|
37
|
+
*/
|
|
38
|
+
export type SupportVerdict = {
|
|
39
|
+
result: "supported" | "contradicted" | "not-addressed";
|
|
40
|
+
score?: number;
|
|
41
|
+
} | {
|
|
42
|
+
result: "abstain";
|
|
43
|
+
abstainReason: SupportAbstainReason;
|
|
44
|
+
};
|
|
45
|
+
/** The port. Implementations live with producers or in adapter packages. */
|
|
46
|
+
export interface SupportVerifier {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly version: string;
|
|
49
|
+
readonly method: SupportVerificationMethod;
|
|
50
|
+
verify(input: Readonly<SupportVerificationInput>, options?: {
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
}): Promise<SupportVerdict>;
|
|
53
|
+
}
|
|
54
|
+
export interface CandidateVerification {
|
|
55
|
+
/** `sha256:` digest of every other field; recomputed on validation. */
|
|
56
|
+
id: string;
|
|
57
|
+
kind: typeof candidateVerificationKind;
|
|
58
|
+
schemaVersion: 1;
|
|
59
|
+
candidateId: string;
|
|
60
|
+
/** Sorted, unique ids of the evidence the verifier was given. */
|
|
61
|
+
evidenceIds: string[];
|
|
62
|
+
/** {@link valueDigest} of the value checked. */
|
|
63
|
+
valueDigest: string;
|
|
64
|
+
/** {@link supportVerificationInputDigest} of the full input checked. */
|
|
65
|
+
inputDigest: string;
|
|
66
|
+
verifier: {
|
|
67
|
+
id: string;
|
|
68
|
+
version: string;
|
|
69
|
+
};
|
|
70
|
+
method: SupportVerificationMethod;
|
|
71
|
+
result: SupportVerificationResult;
|
|
72
|
+
/** Present exactly when `result` is `abstain`. */
|
|
73
|
+
abstainReason?: SupportAbstainReason;
|
|
74
|
+
/** Uncalibrated unless a calibration record says otherwise. Never defaulted. */
|
|
75
|
+
score?: number;
|
|
76
|
+
createdAt: string;
|
|
77
|
+
}
|
|
78
|
+
export interface BuildCandidateVerificationInput {
|
|
79
|
+
input: SupportVerificationInput;
|
|
80
|
+
verifier: {
|
|
81
|
+
id: string;
|
|
82
|
+
version: string;
|
|
83
|
+
method: SupportVerificationMethod;
|
|
84
|
+
};
|
|
85
|
+
verdict: SupportVerdict;
|
|
86
|
+
createdAt: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* `"sha256:" + hex SHA-256` of the canonical JSON of a value: the same
|
|
90
|
+
* definition as Surface's `valueDigest`, so a record binds to a value the way a
|
|
91
|
+
* Surface claim does.
|
|
92
|
+
*/
|
|
93
|
+
export declare function valueDigest(value: unknown): string;
|
|
94
|
+
/** Digest of everything a verifier was given: candidate id, value, value type and evidence (ordered by id). */
|
|
95
|
+
export declare function supportVerificationInputDigest(input: SupportVerificationInput): string;
|
|
96
|
+
/**
|
|
97
|
+
* Builds a frozen record from a verifier's verdict. Throws on a malformed
|
|
98
|
+
* verdict: use {@link runSupportVerifier} to turn a verifier's failure into an
|
|
99
|
+
* abstention instead.
|
|
100
|
+
*/
|
|
101
|
+
export declare function buildCandidateVerification(input: BuildCandidateVerificationInput): CandidateVerification;
|
|
102
|
+
/** How long {@link runSupportVerifier} waits when no `timeoutMs` is given. */
|
|
103
|
+
export declare const DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS = 30000;
|
|
104
|
+
/**
|
|
105
|
+
* The largest `timeoutMs` {@link runSupportVerifier} accepts: the largest
|
|
106
|
+
* delay a timer honours (2^31 - 1 ms). A larger delay would be clamped to
|
|
107
|
+
* 1 ms, turning a long bound into an immediate timeout, so it is refused.
|
|
108
|
+
*/
|
|
109
|
+
export declare const MAX_SUPPORT_VERIFIER_TIMEOUT_MS = 2147483647;
|
|
110
|
+
export interface RunSupportVerifierOptions {
|
|
111
|
+
/**
|
|
112
|
+
* Abstain with `timeout` when the verifier has not answered by then.
|
|
113
|
+
* Defaults to {@link DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS}; a verifier is
|
|
114
|
+
* never waited on without a bound. At most
|
|
115
|
+
* {@link MAX_SUPPORT_VERIFIER_TIMEOUT_MS}.
|
|
116
|
+
*/
|
|
117
|
+
timeoutMs?: number;
|
|
118
|
+
/** Clock for `createdAt`; defaults to the current time. */
|
|
119
|
+
now?: () => string;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Calls a verifier and records what it said. A verifier that throws abstains
|
|
123
|
+
* with `error`, one that returns nothing with `empty`, one that returns a
|
|
124
|
+
* malformed verdict with `malformed`, and one that exceeds `timeoutMs`
|
|
125
|
+
* (default {@link DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS}) with `timeout`. None of these ever records a pass or a fail.
|
|
126
|
+
*
|
|
127
|
+
* Throws only for caller errors: a malformed input, or a verifier without a
|
|
128
|
+
* usable identity (a record needs one).
|
|
129
|
+
*/
|
|
130
|
+
export declare function runSupportVerifier(verifier: SupportVerifier, input: SupportVerificationInput, options?: RunSupportVerifierOptions): Promise<CandidateVerification>;
|
|
131
|
+
/**
|
|
132
|
+
* Validates a record read back from storage and returns a frozen copy. Throws
|
|
133
|
+
* on any unknown field, inconsistent verdict, or an id that is not the digest
|
|
134
|
+
* of the record's own fields (an edited or inconsistent record). The id is a
|
|
135
|
+
* content digest, not a signature: it does not prove who wrote the record, and
|
|
136
|
+
* a record rebuilt with a recomputed id validates.
|
|
137
|
+
*/
|
|
138
|
+
export declare function validateCandidateVerification(value: unknown): CandidateVerification;
|
|
139
|
+
/** The candidate a fold reads records for: its id and its value as it is now. */
|
|
140
|
+
export interface CandidateVerificationSubject {
|
|
141
|
+
candidateId: string;
|
|
142
|
+
value: unknown;
|
|
143
|
+
valueType?: string;
|
|
144
|
+
/** When supplied, records must also match the full input digest. */
|
|
145
|
+
evidence?: SupportEvidence[];
|
|
146
|
+
}
|
|
147
|
+
export type CandidateVerificationStatus = "not-evaluated" | "abstained" | "evaluated";
|
|
148
|
+
export type CandidateVerificationInapplicableReason = "other-candidate" | "value-changed" | "input-changed";
|
|
149
|
+
export interface FoldCandidateVerificationsResult {
|
|
150
|
+
/**
|
|
151
|
+
* `not-evaluated` when no record applies to the subject's current value,
|
|
152
|
+
* `abstained` when every applicable record is an abstention, `evaluated`
|
|
153
|
+
* when at least one applicable record carries a verdict (supported,
|
|
154
|
+
* contradicted or not-addressed). Not itself a verdict.
|
|
155
|
+
*/
|
|
156
|
+
status: CandidateVerificationStatus;
|
|
157
|
+
/** Valid records bound to this candidate and value, ordered by id. Abstentions included. */
|
|
158
|
+
applicable: readonly CandidateVerification[];
|
|
159
|
+
/** Valid records bound to another candidate, value, or input. */
|
|
160
|
+
inapplicable: readonly {
|
|
161
|
+
record: CandidateVerification;
|
|
162
|
+
reason: CandidateVerificationInapplicableReason;
|
|
163
|
+
}[];
|
|
164
|
+
/** Records that failed validation, by input position. Never read further. */
|
|
165
|
+
rejected: readonly {
|
|
166
|
+
index: number;
|
|
167
|
+
reason: string;
|
|
168
|
+
}[];
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Sorts records for one candidate into those that apply to its current value,
|
|
172
|
+
* those bound to something else, and those that failed validation. Never
|
|
173
|
+
* selects a winner among applicable records and never derives a trust state.
|
|
174
|
+
*/
|
|
175
|
+
export declare function foldCandidateVerifications(subject: CandidateVerificationSubject, records: readonly unknown[]): FoldCandidateVerificationsResult;
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { sha256Hex } from "./sha256.js";
|
|
2
|
+
/**
|
|
3
|
+
* Support verification: a port for an independent check of whether a
|
|
4
|
+
* candidate's value is supported by its evidence, and the immutable record of
|
|
5
|
+
* what such a check said.
|
|
6
|
+
*
|
|
7
|
+
* A {@link CandidateVerification} is a record of a verifier's output, bound to
|
|
8
|
+
* the exact value and evidence it checked. It is not a trust decision: nothing
|
|
9
|
+
* in Survey reads it to route, auto-accept, or calibrate a candidate. Survey
|
|
10
|
+
* ships no verifier implementations; producers or separate adapter packages
|
|
11
|
+
* implement {@link SupportVerifier}.
|
|
12
|
+
*
|
|
13
|
+
* Runtime-neutral (no Node built-ins) so the review workbench bundle can
|
|
14
|
+
* validate records in the browser.
|
|
15
|
+
*/
|
|
16
|
+
export const candidateVerificationKind = "survey.candidate-verification";
|
|
17
|
+
const METHODS = ["deterministic", "model", "human"];
|
|
18
|
+
const RESULTS = ["supported", "contradicted", "not-addressed", "abstain"];
|
|
19
|
+
const ABSTAIN_REASONS = ["malformed", "empty", "timeout", "unsupported", "error"];
|
|
20
|
+
/**
|
|
21
|
+
* Canonical JSON with object keys sorted by UTF-16 code unit, matching
|
|
22
|
+
* `canonicalJson` in `@kontourai/surface` (`src/canonical-digest.ts`) byte for
|
|
23
|
+
* byte. Survey's review-workbench `canonicalJson` sorts with `localeCompare`
|
|
24
|
+
* and would disagree on mixed-case keys, so it is not used here.
|
|
25
|
+
*/
|
|
26
|
+
function surfaceCanonicalJson(value) {
|
|
27
|
+
if (value === null || typeof value !== "object")
|
|
28
|
+
return JSON.stringify(value);
|
|
29
|
+
if (Array.isArray(value))
|
|
30
|
+
return `[${value.map(surfaceCanonicalJson).join(",")}]`;
|
|
31
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${surfaceCanonicalJson(value[key])}`).join(",")}}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `"sha256:" + hex SHA-256` of the canonical JSON of a value: the same
|
|
35
|
+
* definition as Surface's `valueDigest`, so a record binds to a value the way a
|
|
36
|
+
* Surface claim does.
|
|
37
|
+
*/
|
|
38
|
+
export function valueDigest(value) {
|
|
39
|
+
return `sha256:${sha256Hex(surfaceCanonicalJson(value))}`;
|
|
40
|
+
}
|
|
41
|
+
function digest(domain, payload) {
|
|
42
|
+
return `sha256:${sha256Hex(surfaceCanonicalJson({ domain, payload }))}`;
|
|
43
|
+
}
|
|
44
|
+
/** Digest of everything a verifier was given: candidate id, value, value type and evidence (ordered by id). */
|
|
45
|
+
export function supportVerificationInputDigest(input) {
|
|
46
|
+
const normalized = normalizeInput(input);
|
|
47
|
+
return digest("survey.support-verification-input/v1", {
|
|
48
|
+
candidateId: normalized.candidateId,
|
|
49
|
+
valueDigest: valueDigest(normalized.value),
|
|
50
|
+
...(normalized.valueType !== undefined ? { valueType: normalized.valueType } : {}),
|
|
51
|
+
evidence: normalized.evidence,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Builds a frozen record from a verifier's verdict. Throws on a malformed
|
|
56
|
+
* verdict: use {@link runSupportVerifier} to turn a verifier's failure into an
|
|
57
|
+
* abstention instead.
|
|
58
|
+
*/
|
|
59
|
+
export function buildCandidateVerification(input) {
|
|
60
|
+
const normalized = normalizeInput(input.input);
|
|
61
|
+
const verifier = normalizeVerifier(input.verifier);
|
|
62
|
+
const verdict = normalizeVerdict(input.verdict);
|
|
63
|
+
const payload = {
|
|
64
|
+
kind: candidateVerificationKind,
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
candidateId: normalized.candidateId,
|
|
67
|
+
evidenceIds: normalized.evidence.map(({ id }) => id),
|
|
68
|
+
valueDigest: valueDigest(normalized.value),
|
|
69
|
+
inputDigest: supportVerificationInputDigest(normalized),
|
|
70
|
+
verifier: { id: verifier.id, version: verifier.version },
|
|
71
|
+
method: verifier.method,
|
|
72
|
+
result: verdict.result,
|
|
73
|
+
...(verdict.result === "abstain" ? { abstainReason: verdict.abstainReason } : {}),
|
|
74
|
+
...(verdict.result !== "abstain" && verdict.score !== undefined ? { score: verdict.score } : {}),
|
|
75
|
+
createdAt: canonicalTimestamp(input.createdAt, "createdAt"),
|
|
76
|
+
};
|
|
77
|
+
return deepFreeze({ id: recordId(payload), ...payload });
|
|
78
|
+
}
|
|
79
|
+
/** How long {@link runSupportVerifier} waits when no `timeoutMs` is given. */
|
|
80
|
+
export const DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS = 30_000;
|
|
81
|
+
/**
|
|
82
|
+
* The largest `timeoutMs` {@link runSupportVerifier} accepts: the largest
|
|
83
|
+
* delay a timer honours (2^31 - 1 ms). A larger delay would be clamped to
|
|
84
|
+
* 1 ms, turning a long bound into an immediate timeout, so it is refused.
|
|
85
|
+
*/
|
|
86
|
+
export const MAX_SUPPORT_VERIFIER_TIMEOUT_MS = 2_147_483_647;
|
|
87
|
+
/**
|
|
88
|
+
* Calls a verifier and records what it said. A verifier that throws abstains
|
|
89
|
+
* with `error`, one that returns nothing with `empty`, one that returns a
|
|
90
|
+
* malformed verdict with `malformed`, and one that exceeds `timeoutMs`
|
|
91
|
+
* (default {@link DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS}) with `timeout`. None of these ever records a pass or a fail.
|
|
92
|
+
*
|
|
93
|
+
* Throws only for caller errors: a malformed input, or a verifier without a
|
|
94
|
+
* usable identity (a record needs one).
|
|
95
|
+
*/
|
|
96
|
+
export async function runSupportVerifier(verifier, input, options = {}) {
|
|
97
|
+
const identity = normalizeVerifier(verifier);
|
|
98
|
+
const normalized = normalizeInput(input);
|
|
99
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS;
|
|
100
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_SUPPORT_VERIFIER_TIMEOUT_MS) {
|
|
101
|
+
throw new Error(`timeoutMs must be a positive finite number no greater than ${MAX_SUPPORT_VERIFIER_TIMEOUT_MS}`);
|
|
102
|
+
}
|
|
103
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
104
|
+
const verdict = await callVerifier(verifier, normalized, timeoutMs);
|
|
105
|
+
return buildCandidateVerification({ input: normalized, verifier: identity, verdict, createdAt: now() });
|
|
106
|
+
}
|
|
107
|
+
async function callVerifier(verifier, input, timeoutMs) {
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
let timer;
|
|
110
|
+
const timedOut = Symbol("timeout");
|
|
111
|
+
// The verifier gets its own frozen copy: it cannot change what the record binds to.
|
|
112
|
+
const frozenInput = deepFreeze(structuredClone(input));
|
|
113
|
+
try {
|
|
114
|
+
const call = Promise.resolve().then(() => verifier.verify(frozenInput, { signal: controller.signal }));
|
|
115
|
+
const raced = await Promise.race([call, new Promise((resolve) => { timer = setTimeout(() => resolve(timedOut), timeoutMs); })]);
|
|
116
|
+
if (raced === timedOut) {
|
|
117
|
+
controller.abort();
|
|
118
|
+
call.catch(() => undefined);
|
|
119
|
+
return { result: "abstain", abstainReason: "timeout" };
|
|
120
|
+
}
|
|
121
|
+
if (raced === undefined || raced === null)
|
|
122
|
+
return { result: "abstain", abstainReason: "empty" };
|
|
123
|
+
try {
|
|
124
|
+
return normalizeVerdict(raced);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return { result: "abstain", abstainReason: "malformed" };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return { result: "abstain", abstainReason: "error" };
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
if (timer !== undefined)
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const RECORD_KEYS = new Set(["id", "kind", "schemaVersion", "candidateId", "evidenceIds", "valueDigest", "inputDigest", "verifier", "method", "result", "abstainReason", "score", "createdAt"]);
|
|
139
|
+
/**
|
|
140
|
+
* Validates a record read back from storage and returns a frozen copy. Throws
|
|
141
|
+
* on any unknown field, inconsistent verdict, or an id that is not the digest
|
|
142
|
+
* of the record's own fields (an edited or inconsistent record). The id is a
|
|
143
|
+
* content digest, not a signature: it does not prove who wrote the record, and
|
|
144
|
+
* a record rebuilt with a recomputed id validates.
|
|
145
|
+
*/
|
|
146
|
+
export function validateCandidateVerification(value) {
|
|
147
|
+
const record = requiredObject(value, "CandidateVerification");
|
|
148
|
+
for (const key of Object.keys(record)) {
|
|
149
|
+
if (!RECORD_KEYS.has(key))
|
|
150
|
+
throw new Error(`CandidateVerification has unknown field ${key}`);
|
|
151
|
+
}
|
|
152
|
+
if (record.kind !== candidateVerificationKind)
|
|
153
|
+
throw new Error(`CandidateVerification kind must be ${candidateVerificationKind}`);
|
|
154
|
+
if (record.schemaVersion !== 1)
|
|
155
|
+
throw new Error("CandidateVerification schemaVersion must be 1");
|
|
156
|
+
const evidenceIds = record.evidenceIds;
|
|
157
|
+
if (!Array.isArray(evidenceIds) || evidenceIds.length === 0)
|
|
158
|
+
throw new Error("CandidateVerification evidenceIds must be a non-empty array");
|
|
159
|
+
evidenceIds.forEach((id, index) => {
|
|
160
|
+
nonEmpty(id, `evidenceIds[${index}]`);
|
|
161
|
+
if (index > 0 && !(evidenceIds[index - 1] < id))
|
|
162
|
+
throw new Error("CandidateVerification evidenceIds must be sorted and unique");
|
|
163
|
+
});
|
|
164
|
+
const verifier = requiredObject(record.verifier, "verifier");
|
|
165
|
+
if (Object.keys(verifier).some((key) => key !== "id" && key !== "version"))
|
|
166
|
+
throw new Error("CandidateVerification verifier has an unknown field");
|
|
167
|
+
const verdict = normalizeVerdict({
|
|
168
|
+
result: record.result,
|
|
169
|
+
...("abstainReason" in record ? { abstainReason: record.abstainReason } : {}),
|
|
170
|
+
...("score" in record ? { score: record.score } : {}),
|
|
171
|
+
});
|
|
172
|
+
const payload = {
|
|
173
|
+
kind: candidateVerificationKind,
|
|
174
|
+
schemaVersion: 1,
|
|
175
|
+
candidateId: nonEmpty(record.candidateId, "candidateId"),
|
|
176
|
+
evidenceIds: evidenceIds,
|
|
177
|
+
valueDigest: sha256Digest(record.valueDigest, "valueDigest"),
|
|
178
|
+
inputDigest: sha256Digest(record.inputDigest, "inputDigest"),
|
|
179
|
+
verifier: { id: nonEmpty(verifier.id, "verifier.id"), version: nonEmpty(verifier.version, "verifier.version") },
|
|
180
|
+
method: oneOf(record.method, METHODS, "method"),
|
|
181
|
+
result: verdict.result,
|
|
182
|
+
...(verdict.result === "abstain" ? { abstainReason: verdict.abstainReason } : {}),
|
|
183
|
+
...(verdict.result !== "abstain" && verdict.score !== undefined ? { score: verdict.score } : {}),
|
|
184
|
+
createdAt: canonicalTimestamp(record.createdAt, "createdAt"),
|
|
185
|
+
};
|
|
186
|
+
const id = recordId(payload);
|
|
187
|
+
if (record.id !== id)
|
|
188
|
+
throw new Error("CandidateVerification id is not the digest of its fields");
|
|
189
|
+
return deepFreeze({ id, ...structuredClone(payload) });
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Sorts records for one candidate into those that apply to its current value,
|
|
193
|
+
* those bound to something else, and those that failed validation. Never
|
|
194
|
+
* selects a winner among applicable records and never derives a trust state.
|
|
195
|
+
*/
|
|
196
|
+
export function foldCandidateVerifications(subject, records) {
|
|
197
|
+
const candidateId = nonEmpty(subject.candidateId, "candidateId");
|
|
198
|
+
const currentValueDigest = valueDigest(subject.value);
|
|
199
|
+
const currentInputDigest = subject.evidence === undefined
|
|
200
|
+
? undefined
|
|
201
|
+
: supportVerificationInputDigest({ candidateId, value: subject.value, ...(subject.valueType !== undefined ? { valueType: subject.valueType } : {}), evidence: subject.evidence });
|
|
202
|
+
const applicable = new Map();
|
|
203
|
+
const inapplicable = new Map();
|
|
204
|
+
const rejected = [];
|
|
205
|
+
records.forEach((raw, index) => {
|
|
206
|
+
let record;
|
|
207
|
+
try {
|
|
208
|
+
record = validateCandidateVerification(raw);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
rejected.push(Object.freeze({ index, reason: error instanceof Error ? error.message : String(error) }));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const reason = record.candidateId !== candidateId
|
|
215
|
+
? "other-candidate"
|
|
216
|
+
: record.valueDigest !== currentValueDigest
|
|
217
|
+
? "value-changed"
|
|
218
|
+
: currentInputDigest !== undefined && record.inputDigest !== currentInputDigest
|
|
219
|
+
? "input-changed"
|
|
220
|
+
: undefined;
|
|
221
|
+
if (reason)
|
|
222
|
+
inapplicable.set(record.id, Object.freeze({ record, reason }));
|
|
223
|
+
else
|
|
224
|
+
applicable.set(record.id, record);
|
|
225
|
+
});
|
|
226
|
+
const byId = (entries) => [...entries.keys()].sort().map((id) => entries.get(id));
|
|
227
|
+
const applicableRecords = byId(applicable);
|
|
228
|
+
return Object.freeze({
|
|
229
|
+
status: applicableRecords.length === 0
|
|
230
|
+
? "not-evaluated"
|
|
231
|
+
: applicableRecords.every((record) => record.result === "abstain") ? "abstained" : "evaluated",
|
|
232
|
+
applicable: Object.freeze(applicableRecords),
|
|
233
|
+
inapplicable: Object.freeze(byId(inapplicable)),
|
|
234
|
+
rejected: Object.freeze(rejected),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
function recordId(payload) {
|
|
238
|
+
return digest("survey.candidate-verification/v1", payload);
|
|
239
|
+
}
|
|
240
|
+
function normalizeInput(input) {
|
|
241
|
+
const value = requiredObject(input, "SupportVerificationInput");
|
|
242
|
+
if (!("value" in value))
|
|
243
|
+
throw new Error("SupportVerificationInput value is required");
|
|
244
|
+
if (!Array.isArray(value.evidence) || value.evidence.length === 0)
|
|
245
|
+
throw new Error("SupportVerificationInput evidence must be a non-empty array");
|
|
246
|
+
const evidence = value.evidence.map((entry, index) => {
|
|
247
|
+
const item = requiredObject(entry, `evidence[${index}]`);
|
|
248
|
+
return {
|
|
249
|
+
id: nonEmpty(item.id, `evidence[${index}].id`),
|
|
250
|
+
excerpt: text(item.excerpt, `evidence[${index}].excerpt`),
|
|
251
|
+
locator: nonEmpty(item.locator, `evidence[${index}].locator`),
|
|
252
|
+
...(item.context !== undefined ? { context: text(item.context, `evidence[${index}].context`) } : {}),
|
|
253
|
+
};
|
|
254
|
+
}).sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
|
|
255
|
+
evidence.forEach((entry, index) => {
|
|
256
|
+
if (index > 0 && evidence[index - 1].id === entry.id)
|
|
257
|
+
throw new Error(`SupportVerificationInput evidence id ${entry.id} is repeated`);
|
|
258
|
+
});
|
|
259
|
+
return {
|
|
260
|
+
candidateId: nonEmpty(value.candidateId, "candidateId"),
|
|
261
|
+
value: value.value,
|
|
262
|
+
...(value.valueType !== undefined ? { valueType: nonEmpty(value.valueType, "valueType") } : {}),
|
|
263
|
+
evidence,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function normalizeVerifier(value) {
|
|
267
|
+
const verifier = requiredObject(value, "verifier");
|
|
268
|
+
return {
|
|
269
|
+
id: nonEmpty(verifier.id, "verifier.id"),
|
|
270
|
+
version: nonEmpty(verifier.version, "verifier.version"),
|
|
271
|
+
method: oneOf(verifier.method, METHODS, "verifier.method"),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function normalizeVerdict(value) {
|
|
275
|
+
const verdict = requiredObject(value, "verdict");
|
|
276
|
+
for (const key of Object.keys(verdict)) {
|
|
277
|
+
if (key !== "result" && key !== "abstainReason" && key !== "score")
|
|
278
|
+
throw new Error(`verdict has unknown field ${key}`);
|
|
279
|
+
}
|
|
280
|
+
const result = oneOf(verdict.result, RESULTS, "verdict.result");
|
|
281
|
+
if (result === "abstain") {
|
|
282
|
+
if ("score" in verdict)
|
|
283
|
+
throw new Error("an abstaining verdict carries no score");
|
|
284
|
+
return { result, abstainReason: oneOf(verdict.abstainReason, ABSTAIN_REASONS, "verdict.abstainReason") };
|
|
285
|
+
}
|
|
286
|
+
if ("abstainReason" in verdict)
|
|
287
|
+
throw new Error("abstainReason is only allowed when result is abstain");
|
|
288
|
+
if (!("score" in verdict))
|
|
289
|
+
return { result };
|
|
290
|
+
const score = verdict.score;
|
|
291
|
+
if (typeof score !== "number" || !Number.isFinite(score) || score < 0 || score > 1)
|
|
292
|
+
throw new Error("verdict.score must be a number from 0 to 1");
|
|
293
|
+
return { result, score };
|
|
294
|
+
}
|
|
295
|
+
function oneOf(value, allowed, label) {
|
|
296
|
+
if (typeof value !== "string" || !allowed.includes(value))
|
|
297
|
+
throw new Error(`${label} must be one of ${allowed.join(", ")}`);
|
|
298
|
+
return value;
|
|
299
|
+
}
|
|
300
|
+
function requiredObject(value, label) {
|
|
301
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
302
|
+
throw new Error(`${label} must be an object`);
|
|
303
|
+
return value;
|
|
304
|
+
}
|
|
305
|
+
function nonEmpty(value, label) {
|
|
306
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
307
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
310
|
+
function text(value, label) {
|
|
311
|
+
if (typeof value !== "string")
|
|
312
|
+
throw new Error(`${label} must be a string`);
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
function sha256Digest(value, label) {
|
|
316
|
+
if (typeof value !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value))
|
|
317
|
+
throw new Error(`${label} must be a sha256:<64 hex> digest`);
|
|
318
|
+
return value;
|
|
319
|
+
}
|
|
320
|
+
function canonicalTimestamp(value, label) {
|
|
321
|
+
const timestamp = nonEmpty(value, label);
|
|
322
|
+
const parsed = new Date(timestamp);
|
|
323
|
+
if (Number.isNaN(parsed.valueOf()) || parsed.toISOString() !== timestamp)
|
|
324
|
+
throw new Error(`${label} must be a canonical ISO timestamp`);
|
|
325
|
+
return timestamp;
|
|
326
|
+
}
|
|
327
|
+
function deepFreeze(value) {
|
|
328
|
+
if (value && typeof value === "object") {
|
|
329
|
+
for (const nested of Object.values(value))
|
|
330
|
+
deepFreeze(nested);
|
|
331
|
+
Object.freeze(value);
|
|
332
|
+
}
|
|
333
|
+
return value;
|
|
334
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -35,8 +35,10 @@ export { repeatedObservation } from "./repeated-observation.js";
|
|
|
35
35
|
export type { RepeatedObservationInput } from "./repeated-observation.js";
|
|
36
36
|
export { sourceOfAuthorityObservation, sourceOfAuthorityObservationBuilder, SourceOfAuthorityObservationBuilder, } from "./source-of-authority-observation.js";
|
|
37
37
|
export type { SourceAuthorityClass, SourceAuthorityMetadata, SourceOfAuthorityObservationBuilderArgs, SourceOfAuthorityObservationInput, } from "./source-of-authority-observation.js";
|
|
38
|
-
export {
|
|
39
|
-
export type {
|
|
38
|
+
export { buildCandidateVerification, candidateVerificationKind, DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS, MAX_SUPPORT_VERIFIER_TIMEOUT_MS, foldCandidateVerifications, runSupportVerifier, supportVerificationInputDigest, validateCandidateVerification, valueDigest, } from "./candidate-verification.js";
|
|
39
|
+
export type { BuildCandidateVerificationInput, CandidateVerification, CandidateVerificationInapplicableReason, CandidateVerificationStatus, CandidateVerificationSubject, FoldCandidateVerificationsResult, RunSupportVerifierOptions, SupportAbstainReason, SupportEvidence, SupportVerdict, SupportVerificationInput, SupportVerificationMethod, SupportVerificationResult, SupportVerifier, } from "./candidate-verification.js";
|
|
40
|
+
export { buildInterpretationReadingPresentation, buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, candidateVerificationNotes, humanizeIdentifier, } from "./review-workbench/review-presentation.js";
|
|
41
|
+
export type { CandidateVerificationNote, CandidateVerificationRecordPresentation, InterpretationReadingPresentation, InterpretationReadingSource, ReviewCandidatePresentation, ReviewCandidatePresentationContext, ReviewItemPresentation, ReviewItemPresentationContext, ReviewPresentationAdapter, ReviewPresentationLink, ReviewResultPresentation, ReviewTracePresentationContext, ReviewTraceRef, ReviewValuePresentationContext, } from "./review-workbench/review-presentation.js";
|
|
40
42
|
export { apiRecordSource, manualEntrySource, policyStandardSource, uploadedDocumentSource, webPageSource, } from "./raw-source.js";
|
|
41
43
|
export type { ApiRecordSourceInput, ChecksumInput, ManualEntrySourceInput, PolicyStandardMetadata, PolicyStandardSourceInput, RawSourceInput, UploadedDocumentSourceInput, WebPageSourceInput, } from "./raw-source.js";
|
|
42
44
|
export { applyAutoAcceptPolicy, applyMappingReview, buildMappingReviewItems, lookupMapping, lookupRejectedMapping, normalizeQuestion, proposalsToCandidateSet, referenceMappingProposer, resolveQuestion, } from "./inquiry-mapping.js";
|
package/dist/src/index.js
CHANGED
|
@@ -17,7 +17,8 @@ export { buildCanonicalReviewProofPayload, buildReviewProofAnchor, canonicalRevi
|
|
|
17
17
|
export { fieldObservation } from "./field-observation.js";
|
|
18
18
|
export { repeatedObservation } from "./repeated-observation.js";
|
|
19
19
|
export { sourceOfAuthorityObservation, sourceOfAuthorityObservationBuilder, SourceOfAuthorityObservationBuilder, } from "./source-of-authority-observation.js";
|
|
20
|
-
export {
|
|
20
|
+
export { buildCandidateVerification, candidateVerificationKind, DEFAULT_SUPPORT_VERIFIER_TIMEOUT_MS, MAX_SUPPORT_VERIFIER_TIMEOUT_MS, foldCandidateVerifications, runSupportVerifier, supportVerificationInputDigest, validateCandidateVerification, valueDigest, } from "./candidate-verification.js";
|
|
21
|
+
export { buildInterpretationReadingPresentation, buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, candidateVerificationNotes, humanizeIdentifier, } from "./review-workbench/review-presentation.js";
|
|
21
22
|
export { apiRecordSource, manualEntrySource, policyStandardSource, uploadedDocumentSource, webPageSource, } from "./raw-source.js";
|
|
22
23
|
export { applyAutoAcceptPolicy, applyMappingReview, buildMappingReviewItems, lookupMapping, lookupRejectedMapping, normalizeQuestion, proposalsToCandidateSet, referenceMappingProposer, resolveQuestion, } from "./inquiry-mapping.js";
|
|
23
24
|
export { referenceUtteranceExtractor, surveyAgentUtterance, utteranceToSurveyInput, } from "./agent-utterance.js";
|
|
@@ -5,7 +5,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
|
5
5
|
import { z } from "zod";
|
|
6
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 { buildReviewItemPresentation, excludedProposalsSentence } from "../review-workbench/review-presentation.js";
|
|
8
|
+
import { buildReviewItemPresentation, candidateVerificationNotes, excludedProposalsSentence } from "../review-workbench/review-presentation.js";
|
|
9
9
|
import { appendReviewSessionEvents, readReviewSessionFile, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
|
|
10
10
|
const SESSION_NAME = "mcp-review-session";
|
|
11
11
|
const UI_RESOURCE_URI_META_KEY = "ui/resourceUri";
|
|
@@ -80,12 +80,17 @@ function itemDetailText(item, snapshot, events) {
|
|
|
80
80
|
...(candidate.locator?.excerpt ? [` excerpt: ${candidate.locator.excerpt}`] : []),
|
|
81
81
|
])),
|
|
82
82
|
...extractionImportLines(item),
|
|
83
|
+
...candidateVerificationNotes(item, editedValueFor(item, current)).flatMap((entry) => [``, `Verification: ${entry.sentence}`]),
|
|
83
84
|
];
|
|
84
85
|
if (item.spec.rationale) {
|
|
85
86
|
lines.push(``, `Rationale: ${item.spec.rationale}`);
|
|
86
87
|
}
|
|
87
88
|
return lines.join("\n");
|
|
88
89
|
}
|
|
90
|
+
/** The reviewer's edit when the item's decision accepts one; verifier records are read against it. */
|
|
91
|
+
function editedValueFor(item, state) {
|
|
92
|
+
return state.decisionsByItemName[item.metadata.name] === "accept-proposed" ? state.editedValuesByItemName?.[item.metadata.name] : undefined;
|
|
93
|
+
}
|
|
89
94
|
/** What an envelope import says about the item's evidence, including rival values it excluded. */
|
|
90
95
|
function extractionImportLines(item) {
|
|
91
96
|
const presentation = buildReviewItemPresentation(item);
|
|
@@ -143,6 +148,7 @@ function buildReviewCardHtml(item, snapshot, events) {
|
|
|
143
148
|
: proposedCard(proposedCandidates[0], "Proposed");
|
|
144
149
|
const itemNameJson = escapeJsonInHtml(item.metadata.name);
|
|
145
150
|
const excludedNote = excludedProposalsSentence(buildReviewItemPresentation(item).excludedProposals);
|
|
151
|
+
const verificationNotes = candidateVerificationNotes(item, editedValueFor(item, current));
|
|
146
152
|
const decisionBadge = decision
|
|
147
153
|
? `<span class="badge badge-${decision === "accept-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(workbenchDecisionDefinitions[decision].label)}</span>`
|
|
148
154
|
: `<span class="badge badge-pending">${escapeHtml(status)}</span>`;
|
|
@@ -245,6 +251,7 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
245
251
|
${proposedCards}
|
|
246
252
|
</div>
|
|
247
253
|
${excludedNote ? `<p class="feedback" id="excluded-note">${escapeHtml(excludedNote)}</p>` : ""}
|
|
254
|
+
${verificationNotes.map((entry) => `<p class="feedback verification-note" data-candidate-id="${escapeHtml(entry.candidateId)}">${escapeHtml(entry.sentence)}</p>`).join("\n")}
|
|
248
255
|
${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>` : ""}
|
|
249
256
|
|
|
250
257
|
<div class="divider"></div>
|
|
@@ -359,14 +366,21 @@ async function toolItem(itemName, options) {
|
|
|
359
366
|
...(presentation.excludedProposals.length ? { excludedProposals: presentation.excludedProposals.map(({ proposalIndex, value, locator, excerpt }) => ({ proposalIndex, value, locator, excerpt })) } : {}),
|
|
360
367
|
};
|
|
361
368
|
})(),
|
|
362
|
-
candidates:
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
369
|
+
candidates: (() => {
|
|
370
|
+
const notes = candidateVerificationNotes(item, editedValueFor(item, current));
|
|
371
|
+
return item.spec.candidates.map((c, index) => {
|
|
372
|
+
const note = notes.find((entry) => entry.candidateIndex === index);
|
|
373
|
+
return {
|
|
374
|
+
id: c.id,
|
|
375
|
+
role: c.role,
|
|
376
|
+
value: c.value,
|
|
377
|
+
confidence: c.extraction?.confidence ?? c.confidence,
|
|
378
|
+
sourceRef: c.source?.sourceRef,
|
|
379
|
+
excerpt: c.locator?.excerpt,
|
|
380
|
+
...(note ? { verification: { subject: note.subjectLabel, status: note.status, records: note.records, inapplicableCount: note.inapplicableCount, rejectedCount: note.rejectedCount } } : {}),
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
})(),
|
|
370
384
|
};
|
|
371
385
|
const content = [
|
|
372
386
|
{ type: "text", text: `${text}\n\n${JSON.stringify(itemData, null, 2)}` },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CandidateVerification } from "./candidate-verification.js";
|
|
1
2
|
import type { CandidateSetStatus, ClaimTarget, Extraction, RawSource, ReviewAuthorizing, ReviewOutcome } from "./types.js";
|
|
2
3
|
export declare const reviewResourceApiVersion = "survey.kontourai.io/v1alpha1";
|
|
3
4
|
export type ReviewResourceApiVersion = typeof reviewResourceApiVersion;
|
|
@@ -104,6 +105,14 @@ export interface ReviewCandidate {
|
|
|
104
105
|
claimTarget: ClaimTargetHint;
|
|
105
106
|
projection?: SurveyRecordProjectionHint;
|
|
106
107
|
producer?: Record<string, unknown>;
|
|
108
|
+
/**
|
|
109
|
+
* Records of what independent support verifiers said about this candidate's
|
|
110
|
+
* value (see `CandidateVerification`). A record of a verifier's output, not a
|
|
111
|
+
* trust decision: nothing routes, accepts, or calibrates on it. Every decision
|
|
112
|
+
* surface validates each record on read, ignores any that fail, and shows only
|
|
113
|
+
* those bound to the value under review.
|
|
114
|
+
*/
|
|
115
|
+
verifications?: CandidateVerification[];
|
|
107
116
|
}
|
|
108
117
|
/**
|
|
109
118
|
* Well-known neutral value-type vocabulary for {@link ReviewValueDescriptor}.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type CandidateVerificationStatus, type SupportAbstainReason, type SupportVerificationMethod, type SupportVerificationResult } from "../candidate-verification.js";
|
|
1
2
|
import { type ReviewCandidate, type ReviewItem } from "../review-resource.js";
|
|
2
3
|
import type { InterpretationAnswerImpact, InterpretationReadingKind } from "../types.js";
|
|
3
4
|
import { type ReviewWorkbenchResult } from "./review-workbench.js";
|
|
@@ -130,6 +131,42 @@ export declare function buildInterpretationReadingPresentation(source: Interpret
|
|
|
130
131
|
* text (the MCP item, the recorded decision prompt). Undefined when none.
|
|
131
132
|
*/
|
|
132
133
|
export declare function excludedProposalsSentence(excluded: readonly ExcludedProposalPresentation[]): string | undefined;
|
|
134
|
+
export interface CandidateVerificationRecordPresentation {
|
|
135
|
+
readonly recordId: string;
|
|
136
|
+
readonly verifierId: string;
|
|
137
|
+
readonly verifierVersion: string;
|
|
138
|
+
readonly method: SupportVerificationMethod;
|
|
139
|
+
readonly result: SupportVerificationResult;
|
|
140
|
+
readonly abstainReason?: SupportAbstainReason;
|
|
141
|
+
readonly createdAt: string;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* What the verifier records on one candidate say about the value under
|
|
145
|
+
* review. Only candidates that carry `verifications` get a note, so a
|
|
146
|
+
* candidate no verifier looked at shows nothing rather than a verdict.
|
|
147
|
+
*/
|
|
148
|
+
export interface CandidateVerificationNote {
|
|
149
|
+
/** Position in `item.spec.candidates`; ids are not assumed unique. */
|
|
150
|
+
readonly candidateIndex: number;
|
|
151
|
+
readonly candidateId: string;
|
|
152
|
+
readonly subjectLabel: string;
|
|
153
|
+
/** As {@link foldCandidateVerifications}: `not-evaluated`, `abstained` (only abstentions apply) or `evaluated`. */
|
|
154
|
+
readonly status: CandidateVerificationStatus;
|
|
155
|
+
readonly records: readonly CandidateVerificationRecordPresentation[];
|
|
156
|
+
/** Valid records bound to another value, candidate or input. */
|
|
157
|
+
readonly inapplicableCount: number;
|
|
158
|
+
/** Records that failed validation (edited, inconsistent or malformed); never shown as verdicts. */
|
|
159
|
+
readonly rejectedCount: number;
|
|
160
|
+
readonly sentence: string;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Verifier notes for every candidate that carries records, read against the
|
|
164
|
+
* value each surface is deciding on: the candidate's own value, or the
|
|
165
|
+
* reviewer's edit when an accept carries one (records on the proposed value do
|
|
166
|
+
* not apply to an edited value). Records are validated here, so an inconsistent
|
|
167
|
+
* record is counted and ignored on every surface alike.
|
|
168
|
+
*/
|
|
169
|
+
export declare function candidateVerificationNotes(item: ReviewItem, editedValue?: unknown): CandidateVerificationNote[];
|
|
133
170
|
export declare function buildReviewItemPresentation(item: ReviewItem, adapter?: ReviewPresentationAdapter): ReviewItemPresentation;
|
|
134
171
|
export declare function buildReviewCandidatePresentation(item: ReviewItem, candidate: ReviewCandidate, adapter?: ReviewPresentationAdapter, targetLabel?: string): ReviewCandidatePresentation;
|
|
135
172
|
export declare function buildReviewResultPresentation(result: ReviewWorkbenchResult, item: ReviewItem | undefined, adapter?: ReviewPresentationAdapter): ReviewResultPresentation;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { foldCandidateVerifications } from "../candidate-verification.js";
|
|
1
2
|
import { findSoleCandidateById } from "../review-resource.js";
|
|
2
3
|
import { formatValue } from "./review-surface-preview.js";
|
|
3
4
|
const INTERPRETATION_KIND_LABELS = {
|
|
@@ -93,6 +94,61 @@ export function excludedProposalsSentence(excluded) {
|
|
|
93
94
|
const named = excluded.map((entry) => `${entry.valueText} (proposal ${entry.proposalIndex}, ${entry.locator})`).join(", ");
|
|
94
95
|
return `${excluded.length === 1 ? "Another proposed value was" : `${excluded.length} other proposed values were`} excluded at import because the source text at the cited span is not the excerpt: ${named}. Unverifiable is not disproven.`;
|
|
95
96
|
}
|
|
97
|
+
const VERIFICATION_RESULT_TEXT = {
|
|
98
|
+
supported: "supported",
|
|
99
|
+
contradicted: "contradicted",
|
|
100
|
+
"not-addressed": "not addressed by the evidence",
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Verifier notes for every candidate that carries records, read against the
|
|
104
|
+
* value each surface is deciding on: the candidate's own value, or the
|
|
105
|
+
* reviewer's edit when an accept carries one (records on the proposed value do
|
|
106
|
+
* not apply to an edited value). Records are validated here, so an inconsistent
|
|
107
|
+
* record is counted and ignored on every surface alike.
|
|
108
|
+
*/
|
|
109
|
+
export function candidateVerificationNotes(item, editedValue) {
|
|
110
|
+
const proposed = item.spec.candidates.filter((candidate) => candidate.role === "proposed");
|
|
111
|
+
return item.spec.candidates.flatMap((candidate, candidateIndex) => {
|
|
112
|
+
if (candidate.verifications === undefined)
|
|
113
|
+
return [];
|
|
114
|
+
const edited = editedValue !== undefined && proposed.length === 1 && candidate === proposed[0];
|
|
115
|
+
const subjectLabel = edited
|
|
116
|
+
? "the edited value"
|
|
117
|
+
: candidate.role === "current"
|
|
118
|
+
? "the current value"
|
|
119
|
+
: candidate.role === "proposed"
|
|
120
|
+
? proposed.length > 1 ? `proposed value ${proposed.indexOf(candidate) + 1} of ${proposed.length}` : "the proposed value"
|
|
121
|
+
: `candidate ${candidate.id}`;
|
|
122
|
+
const records = Array.isArray(candidate.verifications) ? candidate.verifications : [candidate.verifications];
|
|
123
|
+
const fold = foldCandidateVerifications({ candidateId: candidate.id, value: edited ? editedValue : candidate.value }, records);
|
|
124
|
+
const presented = fold.applicable.map((record) => ({
|
|
125
|
+
recordId: record.id,
|
|
126
|
+
verifierId: record.verifier.id,
|
|
127
|
+
verifierVersion: record.verifier.version,
|
|
128
|
+
method: record.method,
|
|
129
|
+
result: record.result,
|
|
130
|
+
...(record.abstainReason ? { abstainReason: record.abstainReason } : {}),
|
|
131
|
+
createdAt: record.createdAt,
|
|
132
|
+
}));
|
|
133
|
+
const said = presented.map((record) => `${record.verifierId} ${record.verifierVersion} (${record.method}) ${record.result === "abstain" ? `abstained: ${record.abstainReason}` : `said ${VERIFICATION_RESULT_TEXT[record.result]}`}`);
|
|
134
|
+
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
135
|
+
const sentence = [
|
|
136
|
+
said.length ? `Verifier records for ${subjectLabel} (what a verifier said: not proof, not a review decision): ${said.join("; ")}.` : `No verifier record applies to ${subjectLabel}.`,
|
|
137
|
+
...(fold.inapplicable.length ? [`${plural(fold.inapplicable.length, "record was", "records were")} made for a different value or evidence and ${fold.inapplicable.length === 1 ? "does" : "do"} not apply.`] : []),
|
|
138
|
+
...(fold.rejected.length ? [`${plural(fold.rejected.length, "record", "records")} failed validation and ${fold.rejected.length === 1 ? "was" : "were"} ignored.`] : []),
|
|
139
|
+
].join(" ");
|
|
140
|
+
return [{
|
|
141
|
+
candidateIndex,
|
|
142
|
+
candidateId: candidate.id,
|
|
143
|
+
subjectLabel,
|
|
144
|
+
status: fold.status,
|
|
145
|
+
records: presented,
|
|
146
|
+
inapplicableCount: fold.inapplicable.length,
|
|
147
|
+
rejectedCount: fold.rejected.length,
|
|
148
|
+
sentence,
|
|
149
|
+
}];
|
|
150
|
+
});
|
|
151
|
+
}
|
|
96
152
|
export function buildReviewItemPresentation(item, adapter = {}) {
|
|
97
153
|
const context = { item };
|
|
98
154
|
const targetLabel = adapter.labelForTarget?.(item.spec.target, context) ?? humanizeIdentifier(item.spec.target);
|
|
@@ -835,7 +835,8 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
835
835
|
font-size: 12px;
|
|
836
836
|
}
|
|
837
837
|
|
|
838
|
-
.survey-workbench-embed .excerpt-verification
|
|
838
|
+
.survey-workbench-embed .excerpt-verification,
|
|
839
|
+
.survey-workbench-embed .support-verification{
|
|
839
840
|
margin: 0;
|
|
840
841
|
font-size: 12px;
|
|
841
842
|
color: var(--k-muted);
|
|
@@ -6,7 +6,7 @@ import { buildSurfaceProjectionPreview, formatValue, } from "./review-surface-pr
|
|
|
6
6
|
import { buildReviewCandidatePresentation, buildReviewItemPresentation, } from "./review-presentation.js";
|
|
7
7
|
import { findSoleCandidateById, reviewResourceApiVersion, } from "../review-resource.js";
|
|
8
8
|
import { validateAuthorizing, buildAuthorizedActionAuthorizing } from "../review-authorizing.js";
|
|
9
|
-
import { excludedProposalsSentence, humanizeIdentifier } from "./review-presentation.js";
|
|
9
|
+
import { candidateVerificationNotes, excludedProposalsSentence, humanizeIdentifier } from "./review-presentation.js";
|
|
10
10
|
import { editedValueFromEditorText, isIsoCalendarDate, parsePlainDecimal } from "./edited-value.js";
|
|
11
11
|
import { createAuditFactTrace, } from "./audit-rows.js";
|
|
12
12
|
export { reviewAuditRowKeys } from "./audit-rows.js";
|
|
@@ -132,8 +132,9 @@ function decisionCardRenderedPrompt(state, targetLabel) {
|
|
|
132
132
|
// The card shows proposals excluded at import; the recorded prompt must say
|
|
133
133
|
// the reviewer was told about them.
|
|
134
134
|
const excluded = excludedProposalsSentence(buildReviewItemPresentation(state.item).excludedProposals);
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
// Likewise the verifier records the card showed, read against the same value.
|
|
136
|
+
const verification = candidateVerificationNotes(state.item, state.decision === "accept-proposed" ? state.editedValue : undefined).map((note) => note.sentence);
|
|
137
|
+
return [decisionCardBasePrompt(state, targetLabel), ...(excluded ? [excluded] : []), ...verification].join(" ");
|
|
137
138
|
}
|
|
138
139
|
function decisionCardBasePrompt(state, targetLabel) {
|
|
139
140
|
const currentCandidate = state.item.spec.candidates.find((c) => c.role === "current");
|
|
@@ -701,6 +702,7 @@ function renderFieldCard(item, session, presentationAdapter) {
|
|
|
701
702
|
: "<p class=\"field-value\">No proposed value is available for this field.</p>"}
|
|
702
703
|
${proposed ? renderProvenanceRow(item, proposed, presentationAdapter) : ""}
|
|
703
704
|
${renderExtractionImportNotes(presentation)}
|
|
705
|
+
${renderVerificationNotes(candidateVerificationNotes(item, decision === "accept-proposed" ? editedValue : undefined))}
|
|
704
706
|
<div class="decide">
|
|
705
707
|
${keepDecision === undefined ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>`}
|
|
706
708
|
${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">Use proposed</button>
|
|
@@ -961,6 +963,20 @@ function renderExtractionImportNotes(presentation) {
|
|
|
961
963
|
</div>
|
|
962
964
|
`;
|
|
963
965
|
}
|
|
966
|
+
/**
|
|
967
|
+
* What verifier records say about the value on this card. A record is what a
|
|
968
|
+
* verifier said, not a review decision; records bound to another value and
|
|
969
|
+
* records that failed validation are counted, never shown as verdicts.
|
|
970
|
+
*/
|
|
971
|
+
function renderVerificationNotes(notes) {
|
|
972
|
+
if (notes.length === 0)
|
|
973
|
+
return "";
|
|
974
|
+
return `
|
|
975
|
+
<div class="prov import-notes">${notes.map((note) => `
|
|
976
|
+
<p class="support-verification" data-testid="support-verification" data-candidate-id="${escapeHtml(note.candidateId)}" data-status="${note.status}">${escapeHtml(note.sentence)}</p>`).join("")}
|
|
977
|
+
</div>
|
|
978
|
+
`;
|
|
979
|
+
}
|
|
964
980
|
function renderConfidenceMeter(confidence) {
|
|
965
981
|
const clamped = Math.max(0, Math.min(1, confidence));
|
|
966
982
|
const pct = Math.round(clamped * 100);
|
package/package.json
CHANGED