@kontourai/survey 5.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/example-data/public-directory-review-resource.d.ts +3 -3
- package/dist/src/extraction-envelope.d.ts +66 -0
- package/dist/src/extraction-envelope.js +176 -7
- package/dist/src/index.d.ts +1 -1
- package/dist/src/mcp/review-mcp.js +20 -0
- package/dist/src/review-workbench/audit-rows.d.ts +1 -1
- package/dist/src/review-workbench/audit-rows.js +1 -0
- package/dist/src/review-workbench/extraction-inspector.d.ts +18 -14
- package/dist/src/review-workbench/extraction-inspector.js +29 -35
- package/dist/src/review-workbench/review-presentation.d.ts +24 -0
- package/dist/src/review-workbench/review-presentation.js +52 -0
- package/dist/src/review-workbench/review-workbench-css.generated.js +9 -2
- package/dist/src/review-workbench/review-workbench.css +9 -2
- package/dist/src/review-workbench/review-workbench.js +39 -1
- package/dist/src/review-workbench/review-workbench.standalone.css +9 -2
- 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;
|
|
39
38
|
extractionId: string;
|
|
40
39
|
target: string;
|
|
41
40
|
confidence: number;
|
|
42
41
|
extractor: string;
|
|
43
42
|
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;
|
|
66
64
|
sourceAuthority: {
|
|
67
65
|
authorityClass: string;
|
|
68
66
|
declaredBy: string;
|
|
69
67
|
scope: string;
|
|
70
68
|
};
|
|
69
|
+
proposalId?: undefined;
|
|
70
|
+
oldValue?: undefined;
|
|
71
71
|
};
|
|
72
72
|
} | {
|
|
73
73
|
id: string;
|
|
@@ -168,7 +168,39 @@ export interface ExtractionEnvelopeImportOptions {
|
|
|
168
168
|
sourceKind: RawSource["kind"];
|
|
169
169
|
/** Survey meaning is supplied at the boundary; it is not added to the upstream wire contract. */
|
|
170
170
|
claimTarget: (proposal: PortableExtractionProposal, index: number) => ClaimTargetHint;
|
|
171
|
+
/**
|
|
172
|
+
* The prepared artifact as the caller resolved it (the same shape the source
|
|
173
|
+
* inspector takes). When supplied, the import checks the text against the
|
|
174
|
+
* envelope's prepared-artifact digest and length, and every proposal's span
|
|
175
|
+
* against its excerpt: a proposal whose span does not match gets no
|
|
176
|
+
* ReviewItem and an `excerpt-mismatch` diagnostic, and an artifact that does
|
|
177
|
+
* not verify makes the import `unresolved`. The text is not stored in the
|
|
178
|
+
* record. Without it the import records `status.provenance: "unverified"`.
|
|
179
|
+
*/
|
|
180
|
+
artifact?: ResolvedExtractionArtifact;
|
|
171
181
|
}
|
|
182
|
+
export type ArtifactUnavailableCode = "not-found" | "storage-error" | "access-denied" | "invalid-artifact" | "unknown";
|
|
183
|
+
/** A prepared artifact as a caller resolved it: its text and digest, or why it could not be read. */
|
|
184
|
+
export type ResolvedExtractionArtifact = {
|
|
185
|
+
status: "available";
|
|
186
|
+
text: string;
|
|
187
|
+
actualDigest: string;
|
|
188
|
+
} | {
|
|
189
|
+
status: "unavailable";
|
|
190
|
+
code: ArtifactUnavailableCode;
|
|
191
|
+
} | {
|
|
192
|
+
status: "digest-mismatch";
|
|
193
|
+
actualDigest: string;
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* Whether the import checked its proposals' excerpts against the prepared
|
|
197
|
+
* artifact text. `verified` means the supplied text matched the envelope's
|
|
198
|
+
* digest and length and every proposal that produced a ReviewItem matched its
|
|
199
|
+
* span; `unverified` means no text was supplied, or the text did not verify
|
|
200
|
+
* (a diagnostic then says why). Records written before this field existed have
|
|
201
|
+
* no `provenance` and are unverified.
|
|
202
|
+
*/
|
|
203
|
+
export type ExtractionEnvelopeImportProvenance = "verified" | "unverified";
|
|
172
204
|
/**
|
|
173
205
|
* Why an import is `unresolved`: its prepared artifact did not resolve, or the
|
|
174
206
|
* extraction itself produced nothing reviewable. `extraction-failed` is a
|
|
@@ -180,6 +212,8 @@ export type ExtractionEnvelopeImportDiagnostic = {
|
|
|
180
212
|
kind: "artifact-unavailable";
|
|
181
213
|
status: "unavailable" | "storage-error" | "identity-mismatch" | "invalid-artifact";
|
|
182
214
|
artifactRef?: string;
|
|
215
|
+
/** The caller's resolver code, present when the artifact supplied at import could not be read. */
|
|
216
|
+
code?: ArtifactUnavailableCode;
|
|
183
217
|
message: string;
|
|
184
218
|
} | {
|
|
185
219
|
kind: "digest-mismatch";
|
|
@@ -196,6 +230,13 @@ export type ExtractionEnvelopeImportDiagnostic = {
|
|
|
196
230
|
kind: "extraction-incomplete";
|
|
197
231
|
reason: PortableExtractionPartialReason;
|
|
198
232
|
message: string;
|
|
233
|
+
}
|
|
234
|
+
/** The prepared text at this proposal's span is not its excerpt; the proposal produced no ReviewItem. */
|
|
235
|
+
| {
|
|
236
|
+
kind: "excerpt-mismatch";
|
|
237
|
+
proposalIndex: number;
|
|
238
|
+
locator: string;
|
|
239
|
+
message: string;
|
|
199
240
|
};
|
|
200
241
|
export interface ExtractionEnvelopeImport {
|
|
201
242
|
apiVersion: typeof extractionEnvelopeImportApiVersion;
|
|
@@ -212,6 +253,7 @@ export interface ExtractionEnvelopeImport {
|
|
|
212
253
|
status: {
|
|
213
254
|
state: "grounded" | "unresolved";
|
|
214
255
|
diagnostics: ExtractionEnvelopeImportDiagnostic[];
|
|
256
|
+
provenance?: ExtractionEnvelopeImportProvenance;
|
|
215
257
|
};
|
|
216
258
|
}
|
|
217
259
|
export interface ExtractionEnvelopeImportResult {
|
|
@@ -242,5 +284,29 @@ export declare function buildReviewItemsFromExtractionEnvelopeImport(record: Ext
|
|
|
242
284
|
export declare function exportExtractionEnvelopeImport(record: ExtractionEnvelopeImport): string;
|
|
243
285
|
export declare function reimportExtractionEnvelope(serialized: string): ExtractionEnvelopeImport;
|
|
244
286
|
export declare function createExtractionEnvelopeResolutionIdentity(record: ExtractionEnvelopeImport, proposalIndex: number): ExtractionEnvelopeResolutionIdentity;
|
|
287
|
+
/** Proposals the import excluded because their span did not match their excerpt. */
|
|
288
|
+
export declare function excerptMismatchProposalIndices(record: ExtractionEnvelopeImport): Set<number>;
|
|
289
|
+
/** Validate the caller-resolved artifact shape; resolver detail beyond the code is refused. */
|
|
290
|
+
export declare function assertResolvedExtractionArtifact(artifact: unknown): asserts artifact is ResolvedExtractionArtifact;
|
|
291
|
+
export type PreparedArtifactCheck = {
|
|
292
|
+
status: "aligned";
|
|
293
|
+
text: string;
|
|
294
|
+
} | {
|
|
295
|
+
status: "unavailable";
|
|
296
|
+
code: ArtifactUnavailableCode;
|
|
297
|
+
} | {
|
|
298
|
+
status: "digest-mismatch";
|
|
299
|
+
actualDigest: string;
|
|
300
|
+
} | {
|
|
301
|
+
status: "wrong-length";
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* Checks resolved text against the envelope's prepared-artifact identity: the
|
|
305
|
+
* caller's reported digest, the digest of the text itself, and the declared
|
|
306
|
+
* length. Shared by import-time verification and the source inspector.
|
|
307
|
+
*/
|
|
308
|
+
export declare function checkPreparedArtifact(prepared: PortableExtractionResultEnvelope["result"]["preparedArtifact"], artifact: ResolvedExtractionArtifact): PreparedArtifactCheck;
|
|
309
|
+
/** Whether the verified prepared text at a proposal's `chars:` span is exactly its excerpt. */
|
|
310
|
+
export declare function proposalSpanMatches(text: string, proposal: PortableExtractionProposal): boolean;
|
|
245
311
|
/** Validate and defensively clone a persisted or browser-delivered import. */
|
|
246
312
|
export declare function validateExtractionEnvelopeImport(value: unknown): ExtractionEnvelopeImport;
|
|
@@ -26,13 +26,16 @@ export function importExtractionEnvelope(serialized, options) {
|
|
|
26
26
|
const producerNamespace = options.producerNamespace ?? envelope.result.provider;
|
|
27
27
|
nonEmpty(name, "Extraction envelope import name");
|
|
28
28
|
nonEmpty(producerNamespace, "Extraction envelope producer namespace");
|
|
29
|
-
const
|
|
29
|
+
const verification = options.artifact === undefined
|
|
30
|
+
? { provenance: "unverified", diagnostics: [] }
|
|
31
|
+
: verifyAgainstArtifact(envelope, options.artifact);
|
|
32
|
+
const diagnostics = [...diagnosticsFor(envelope), ...verification.diagnostics];
|
|
30
33
|
const record = {
|
|
31
34
|
apiVersion: extractionEnvelopeImportApiVersion,
|
|
32
35
|
kind: "ExtractionEnvelopeImport",
|
|
33
36
|
metadata: { name, producerNamespace },
|
|
34
37
|
spec: { envelope, sourceKind: options.sourceKind, claimTargets: cloneJson(claimTargets) },
|
|
35
|
-
status: { state: diagnostics.length
|
|
38
|
+
status: { state: stateFor(diagnostics, envelope.result.proposals.length), diagnostics, provenance: verification.provenance },
|
|
36
39
|
};
|
|
37
40
|
return { record, reviewItems: buildReviewItemsFromExtractionEnvelopeImport(record) };
|
|
38
41
|
}
|
|
@@ -58,16 +61,23 @@ export function buildReviewItemsFromExtractionEnvelopeImport(record) {
|
|
|
58
61
|
}
|
|
59
62
|
function claimSlotGroups(record) {
|
|
60
63
|
const groups = new Map();
|
|
61
|
-
|
|
64
|
+
const excluded = excerptMismatchProposalIndices(record);
|
|
65
|
+
const slotOf = (proposal, index) => {
|
|
62
66
|
const target = record.spec.claimTargets[index];
|
|
63
|
-
|
|
67
|
+
return {
|
|
64
68
|
subjectType: target.subjectType, subjectId: target.subjectId, facet: target.facet, claimType: target.claimType,
|
|
65
69
|
fieldOrBehavior: target.fieldOrBehavior, claimId: target.claimId ?? null, pathIndices: proposal.pathIndices ?? null,
|
|
66
70
|
};
|
|
71
|
+
};
|
|
72
|
+
record.spec.envelope.result.proposals.forEach((proposal, index) => {
|
|
73
|
+
if (excluded.has(index))
|
|
74
|
+
return;
|
|
75
|
+
const target = record.spec.claimTargets[index];
|
|
76
|
+
const slot = slotOf(proposal, index);
|
|
67
77
|
const key = canonicalJson(slot);
|
|
68
78
|
const group = groups.get(key);
|
|
69
79
|
if (!group) {
|
|
70
|
-
groups.set(key, { slot, members: [{ proposal, index }] });
|
|
80
|
+
groups.set(key, { slot, members: [{ proposal, index }], excluded: [] });
|
|
71
81
|
return;
|
|
72
82
|
}
|
|
73
83
|
const first = group.members[0].index;
|
|
@@ -77,6 +87,14 @@ function claimSlotGroups(record) {
|
|
|
77
87
|
}
|
|
78
88
|
group.members.push({ proposal, index });
|
|
79
89
|
});
|
|
90
|
+
// An excluded proposal is unverifiable, not disproven: the slot it would have
|
|
91
|
+
// joined keeps a record of it, so a rival value cannot vanish without trace.
|
|
92
|
+
// A slot whose every proposal was excluded has no item; the import
|
|
93
|
+
// diagnostics are then the only record, as for any excluded proposal.
|
|
94
|
+
record.spec.envelope.result.proposals.forEach((proposal, index) => {
|
|
95
|
+
if (excluded.has(index))
|
|
96
|
+
groups.get(canonicalJson(slotOf(proposal, index)))?.excluded.push({ proposal, index });
|
|
97
|
+
});
|
|
80
98
|
return [...groups.values()];
|
|
81
99
|
}
|
|
82
100
|
export function exportExtractionEnvelopeImport(record) {
|
|
@@ -123,6 +141,13 @@ function buildReviewItem(record, group) {
|
|
|
123
141
|
proposalIndices: group.members.map((member) => member.index),
|
|
124
142
|
source: envelope.source,
|
|
125
143
|
...(envelope.result.preparedArtifact ? { preparedArtifact: envelope.result.preparedArtifact } : {}),
|
|
144
|
+
// Present only on verified imports, so an unverified import's items stay
|
|
145
|
+
// byte-identical to the ones earlier releases produced.
|
|
146
|
+
...(record.status.provenance === "verified" ? { excerptVerification: "verified" } : {}),
|
|
147
|
+
...(group.excluded.length ? { excludedProposals: group.excluded.map(({ proposal, index }) => ({
|
|
148
|
+
proposalIndex: index, value: proposal.candidateValue, locator: proposal.provenance.locator,
|
|
149
|
+
excerpt: proposal.provenance.excerpt, reason: "excerpt-mismatch",
|
|
150
|
+
})) } : {}),
|
|
126
151
|
} } },
|
|
127
152
|
spec: {
|
|
128
153
|
target: lead.fieldPath, candidates,
|
|
@@ -207,6 +232,146 @@ function evidenceInputs(record, proposal) {
|
|
|
207
232
|
extractedAt: record.spec.envelope.result.extractedAt,
|
|
208
233
|
provenance: proposal.provenance };
|
|
209
234
|
}
|
|
235
|
+
/** Proposals the import excluded because their span did not match their excerpt. */
|
|
236
|
+
export function excerptMismatchProposalIndices(record) {
|
|
237
|
+
return new Set(record.status.diagnostics.flatMap((diagnostic) => diagnostic.kind === "excerpt-mismatch" ? [diagnostic.proposalIndex] : []));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* An import is grounded when nothing but excerpt mismatches was diagnosed and
|
|
241
|
+
* at least one proposal survived them. Every proposal excluded must not read as
|
|
242
|
+
* a complete run that found nothing.
|
|
243
|
+
*/
|
|
244
|
+
function stateFor(diagnostics, proposalCount) {
|
|
245
|
+
const mismatches = diagnostics.filter((diagnostic) => diagnostic.kind === "excerpt-mismatch").length;
|
|
246
|
+
if (mismatches !== diagnostics.length)
|
|
247
|
+
return "unresolved";
|
|
248
|
+
return mismatches > 0 && mismatches === proposalCount ? "unresolved" : "grounded";
|
|
249
|
+
}
|
|
250
|
+
/** Validate the caller-resolved artifact shape; resolver detail beyond the code is refused. */
|
|
251
|
+
export function assertResolvedExtractionArtifact(artifact) {
|
|
252
|
+
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact))
|
|
253
|
+
throw new Error("Invalid resolved extraction artifact.");
|
|
254
|
+
const a = artifact;
|
|
255
|
+
const keys = Object.keys(a).sort();
|
|
256
|
+
if (a.status === "available") {
|
|
257
|
+
if (keys.join(",") !== "actualDigest,status,text" || typeof a.text !== "string" || typeof a.actualDigest !== "string" || !/^[a-f0-9]{64}$/.test(a.actualDigest))
|
|
258
|
+
throw new Error("Invalid available extraction artifact.");
|
|
259
|
+
}
|
|
260
|
+
else if (a.status === "digest-mismatch") {
|
|
261
|
+
if (keys.join(",") !== "actualDigest,status" || typeof a.actualDigest !== "string" || !/^[a-f0-9]{64}$/.test(a.actualDigest))
|
|
262
|
+
throw new Error("Invalid digest-mismatch extraction artifact.");
|
|
263
|
+
}
|
|
264
|
+
else if (a.status === "unavailable") {
|
|
265
|
+
if (keys.join(",") !== "code,status" || !ARTIFACT_UNAVAILABLE_CODES.has(a.code))
|
|
266
|
+
throw new Error("Invalid unavailable extraction artifact.");
|
|
267
|
+
}
|
|
268
|
+
else
|
|
269
|
+
throw new Error("Invalid resolved extraction artifact status.");
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Checks resolved text against the envelope's prepared-artifact identity: the
|
|
273
|
+
* caller's reported digest, the digest of the text itself, and the declared
|
|
274
|
+
* length. Shared by import-time verification and the source inspector.
|
|
275
|
+
*/
|
|
276
|
+
export function checkPreparedArtifact(prepared, artifact) {
|
|
277
|
+
if (artifact.status === "unavailable")
|
|
278
|
+
return { status: "unavailable", code: artifact.code };
|
|
279
|
+
if (artifact.status === "digest-mismatch")
|
|
280
|
+
return { status: "digest-mismatch", actualDigest: artifact.actualDigest };
|
|
281
|
+
const textDigest = sha256Hex(artifact.text);
|
|
282
|
+
if (!prepared || artifact.actualDigest !== prepared.digest || textDigest !== artifact.actualDigest)
|
|
283
|
+
return { status: "digest-mismatch", actualDigest: textDigest };
|
|
284
|
+
if (artifact.text.length !== prepared.contentLength)
|
|
285
|
+
return { status: "wrong-length" };
|
|
286
|
+
return { status: "aligned", text: artifact.text };
|
|
287
|
+
}
|
|
288
|
+
/** Whether the verified prepared text at a proposal's `chars:` span is exactly its excerpt. */
|
|
289
|
+
export function proposalSpanMatches(text, proposal) {
|
|
290
|
+
const match = /^chars:(\d+)-(\d+)$/.exec(proposal.provenance.locator);
|
|
291
|
+
if (!match)
|
|
292
|
+
return false;
|
|
293
|
+
return text.slice(Number(match[1]), Number(match[2])) === proposal.provenance.excerpt;
|
|
294
|
+
}
|
|
295
|
+
function verifyAgainstArtifact(envelope, artifact) {
|
|
296
|
+
assertResolvedExtractionArtifact(artifact);
|
|
297
|
+
const prepared = envelope.result.preparedArtifact;
|
|
298
|
+
if (!prepared)
|
|
299
|
+
throw new Error("The artifact option requires result.preparedArtifact to verify against.");
|
|
300
|
+
const check = checkPreparedArtifact(prepared, artifact);
|
|
301
|
+
if (check.status === "unavailable")
|
|
302
|
+
return { provenance: "unverified", diagnostics: [unreadableArtifactDiagnostic(prepared.ref, check.code)] };
|
|
303
|
+
if (check.status === "wrong-length")
|
|
304
|
+
return { provenance: "unverified", diagnostics: [wrongLengthDiagnostic(prepared.ref)] };
|
|
305
|
+
if (check.status === "digest-mismatch")
|
|
306
|
+
return { provenance: "unverified", diagnostics: [suppliedDigestMismatchDiagnostic(prepared, check.actualDigest)] };
|
|
307
|
+
const diagnostics = [];
|
|
308
|
+
envelope.result.proposals.forEach((proposal, index) => {
|
|
309
|
+
if (!proposalSpanMatches(check.text, proposal))
|
|
310
|
+
diagnostics.push(excerptMismatchDiagnostic(index, proposal));
|
|
311
|
+
});
|
|
312
|
+
return { provenance: "verified", diagnostics };
|
|
313
|
+
}
|
|
314
|
+
function unreadableArtifactDiagnostic(artifactRef, code) {
|
|
315
|
+
const status = code === "storage-error" ? "storage-error" : code === "invalid-artifact" ? "invalid-artifact" : "unavailable";
|
|
316
|
+
return { kind: "artifact-unavailable", status, artifactRef, code, message: `Prepared artifact could not be read to verify excerpts (${code}); no candidate is grounded.` };
|
|
317
|
+
}
|
|
318
|
+
function wrongLengthDiagnostic(artifactRef) {
|
|
319
|
+
return { kind: "artifact-unavailable", status: "invalid-artifact", artifactRef, message: "Prepared artifact text does not have the declared content length; no candidate is grounded." };
|
|
320
|
+
}
|
|
321
|
+
function suppliedDigestMismatchDiagnostic(prepared, actualDigest) {
|
|
322
|
+
return { kind: "digest-mismatch", artifactRef: prepared.ref, expectedDigest: prepared.digest, actualDigest,
|
|
323
|
+
message: "Prepared artifact text supplied at import does not match the extraction artifact digest; no candidate is grounded." };
|
|
324
|
+
}
|
|
325
|
+
function excerptMismatchDiagnostic(index, proposal) {
|
|
326
|
+
return { kind: "excerpt-mismatch", proposalIndex: index, locator: proposal.provenance.locator,
|
|
327
|
+
message: `Proposal ${index} (${proposal.fieldPath}): the prepared text at ${proposal.provenance.locator} is not its excerpt, so it produced no review item.` };
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The import-time verification diagnostics a record may carry after the
|
|
331
|
+
* envelope-derived ones. They cannot be recomputed without the artifact text,
|
|
332
|
+
* so a reloaded record is checked for being one the import could have
|
|
333
|
+
* written: each diagnostic is rebuilt from its own fields and must match.
|
|
334
|
+
*/
|
|
335
|
+
function verificationDiagnosticsAreCoherent(envelope, provenance, diagnostics) {
|
|
336
|
+
if (provenance === undefined)
|
|
337
|
+
return diagnostics.length === 0;
|
|
338
|
+
const prepared = envelope.result.preparedArtifact;
|
|
339
|
+
const rebuild = (value) => {
|
|
340
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !prepared)
|
|
341
|
+
return undefined;
|
|
342
|
+
const d = value;
|
|
343
|
+
if (provenance === "verified") {
|
|
344
|
+
const proposal = Number.isSafeInteger(d.proposalIndex) ? envelope.result.proposals[d.proposalIndex] : undefined;
|
|
345
|
+
return d.kind === "excerpt-mismatch" && proposal ? excerptMismatchDiagnostic(d.proposalIndex, proposal) : undefined;
|
|
346
|
+
}
|
|
347
|
+
if (d.kind === "artifact-unavailable") {
|
|
348
|
+
if (d.code === undefined)
|
|
349
|
+
return wrongLengthDiagnostic(prepared.ref);
|
|
350
|
+
return ARTIFACT_UNAVAILABLE_CODES.has(d.code) ? unreadableArtifactDiagnostic(prepared.ref, d.code) : undefined;
|
|
351
|
+
}
|
|
352
|
+
if (d.kind === "digest-mismatch" && typeof d.actualDigest === "string" && /^[a-f0-9]{64}$/.test(d.actualDigest))
|
|
353
|
+
return suppliedDigestMismatchDiagnostic(prepared, d.actualDigest);
|
|
354
|
+
return undefined;
|
|
355
|
+
};
|
|
356
|
+
if (provenance !== "verified" && provenance !== "unverified")
|
|
357
|
+
return false;
|
|
358
|
+
if (provenance === "verified" && !prepared)
|
|
359
|
+
return false;
|
|
360
|
+
if (provenance === "unverified" && diagnostics.length > 1)
|
|
361
|
+
return false;
|
|
362
|
+
let previous = -1;
|
|
363
|
+
for (const diagnostic of diagnostics) {
|
|
364
|
+
const expected = rebuild(diagnostic);
|
|
365
|
+
if (!expected || canonicalJson(expected) !== canonicalJson(diagnostic))
|
|
366
|
+
return false;
|
|
367
|
+
if (expected.kind === "excerpt-mismatch") {
|
|
368
|
+
if (expected.proposalIndex <= previous)
|
|
369
|
+
return false;
|
|
370
|
+
previous = expected.proposalIndex;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
210
375
|
function diagnosticsFor(envelope) {
|
|
211
376
|
return [...artifactDiagnostics(envelope), ...outcomeDiagnostics(envelope)];
|
|
212
377
|
}
|
|
@@ -257,9 +422,12 @@ function validateImport(value) {
|
|
|
257
422
|
if (targets.length !== envelope.result.proposals.length)
|
|
258
423
|
throw new Error("spec.claimTargets must align with proposals.");
|
|
259
424
|
const status = obj(record.status, "status");
|
|
260
|
-
exact(status, ["state", "diagnostics"], "status");
|
|
425
|
+
exact(status, ["state", "diagnostics"], "status", ["provenance"]);
|
|
426
|
+
const diagnostics = array(status.diagnostics, "status.diagnostics");
|
|
261
427
|
const expected = diagnosticsFor(envelope);
|
|
262
|
-
if (canonicalJson(
|
|
428
|
+
if (canonicalJson(diagnostics.slice(0, expected.length)) !== canonicalJson(expected)
|
|
429
|
+
|| !verificationDiagnosticsAreCoherent(envelope, status.provenance, diagnostics.slice(expected.length))
|
|
430
|
+
|| status.state !== stateFor(diagnostics, envelope.result.proposals.length))
|
|
263
431
|
throw new Error("Import status does not match envelope state.");
|
|
264
432
|
}
|
|
265
433
|
/** Validate and defensively clone a persisted or browser-delivered import. */
|
|
@@ -592,6 +760,7 @@ const FAILURE_KINDS = new Set(["authentication", "rate-limit", "timeout", "inval
|
|
|
592
760
|
const MODEL_SOURCES = new Set(["provider-reported", "configured"]);
|
|
593
761
|
const SCHEMA_MATCHES = new Set(["ok", "type-mismatch", "enum-mismatch", "format-invalid"]);
|
|
594
762
|
const VALUE_IN_EXCERPT_MATCHES = new Set(["match", "mismatch", "not-evaluated", "not-applicable"]);
|
|
763
|
+
const ARTIFACT_UNAVAILABLE_CODES = new Set(["not-found", "storage-error", "access-denied", "invalid-artifact", "unknown"]);
|
|
595
764
|
const PREPARATION_MODES = new Set(["text", "markdown", "transcript", "pdf-text", "image-ocr"]);
|
|
596
765
|
const ARTIFACT_INVALID_REASONS = new Set(["not-an-object", "invalid-format", "invalid-version", "invalid-digest", "invalid-ref", "invalid-preparation-mode", "invalid-preparation-version", "invalid-content-length", "invalid-source-snapshot-ref", "ill-formed-unicode", "invalid-resolved-text"]);
|
|
597
766
|
const STABLE_IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:@/+~-]{0,255}$/;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type { BindReviewQueueOptions, ReviewQueueBinding, ReviewQueueBindingIssu
|
|
|
8
8
|
export { resolvePortablePdfRegion } from "./pdf-layout.js";
|
|
9
9
|
export type { PortablePdfBoundingBox, PortablePdfLayout, PortablePdfPageGeometry, PortablePdfRegionContext, PortablePdfTable, PortablePdfTableCell, PortablePdfTextElement, PortablePdfTextRange, } from "./pdf-layout.js";
|
|
10
10
|
export type { ExtractionAlignmentState, ArtifactUnavailableCode, BuiltExtractionInspectorCandidate, BuiltExtractionInspectorModel, ExtractionInspectorCandidate, ExtractionInspectorEntry, ExtractionInspectorExportOptions, ExtractionInspectorFilters, ExtractionInspectorInput, ExtractionInspectorModel, ExtractionInspectorSource, ResolvedExtractionArtifact, } from "./review-workbench/extraction-inspector.js";
|
|
11
|
-
export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, ExtractionEnvelopeImportResult, ExtractionEnvelopeResolutionIdentity, PortableExtractionCoverageEntry, PortableExtractionEvidenceMatch, PortableExtractionPartialReason, PortableExtractionOccurrence, PortableExtractionProducedBy, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
|
|
11
|
+
export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, ExtractionEnvelopeImportProvenance, ExtractionEnvelopeImportResult, ExtractionEnvelopeResolutionIdentity, PortableExtractionCoverageEntry, PortableExtractionEvidenceMatch, PortableExtractionPartialReason, PortableExtractionOccurrence, PortableExtractionProducedBy, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
|
|
12
12
|
export type { CandidateRole, ClaimTargetHint, ProducerPolicy, ExtractionReference, ResourceEnvelope, ResourceMetadata, ReviewActor, ReviewCandidate, ReviewDecision, ReviewDecisionMode, ReviewDecisionSpec, ReviewDecisionStatus, ReviewItem, ReviewItemSpec, ReviewItemStatus, ReviewLocator, ReviewResource, ReviewResourceApiVersion, ReviewResourceKind, ReviewSession, ReviewSessionEvent, ReviewSessionEventSpec, ReviewSessionEventStatus, ReviewSessionEventType, ReviewSessionSpec, ReviewSessionStatus, ReviewValueDescriptor, ReviewValueType, SourceReference, SurveyRecordProjectionHint, } from "./review-resource.js";
|
|
13
13
|
export { toSurfaceReviewedExtractionDecision, toSurfaceReviewedExtractionImport, toSurfaceReviewedExtractionItem, } from "./surface-reviewed-extraction.js";
|
|
14
14
|
export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
|
|
@@ -5,6 +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
9
|
import { appendReviewSessionEvents, readReviewSessionFile, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
|
|
9
10
|
const SESSION_NAME = "mcp-review-session";
|
|
10
11
|
const UI_RESOURCE_URI_META_KEY = "ui/resourceUri";
|
|
@@ -78,12 +79,22 @@ function itemDetailText(item, snapshot, events) {
|
|
|
78
79
|
` source: ${candidate.source?.sourceRef ?? "none"}`,
|
|
79
80
|
...(candidate.locator?.excerpt ? [` excerpt: ${candidate.locator.excerpt}`] : []),
|
|
80
81
|
])),
|
|
82
|
+
...extractionImportLines(item),
|
|
81
83
|
];
|
|
82
84
|
if (item.spec.rationale) {
|
|
83
85
|
lines.push(``, `Rationale: ${item.spec.rationale}`);
|
|
84
86
|
}
|
|
85
87
|
return lines.join("\n");
|
|
86
88
|
}
|
|
89
|
+
/** What an envelope import says about the item's evidence, including rival values it excluded. */
|
|
90
|
+
function extractionImportLines(item) {
|
|
91
|
+
const presentation = buildReviewItemPresentation(item);
|
|
92
|
+
const excluded = excludedProposalsSentence(presentation.excludedProposals);
|
|
93
|
+
return [
|
|
94
|
+
...(excluded ? [``, `Excluded: ${excluded} Check the source before accepting.`] : []),
|
|
95
|
+
...(presentation.excerptVerification ? [``, `Excerpts ${presentation.excerptVerification === "verified" ? "were" : "were not"} checked against the prepared source text at import.`] : []),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
87
98
|
// ---- UI card -------------------------------------------------------------
|
|
88
99
|
function escapeJsonInHtml(value) {
|
|
89
100
|
return JSON.stringify(value)
|
|
@@ -131,6 +142,7 @@ function buildReviewCardHtml(item, snapshot, events) {
|
|
|
131
142
|
? proposedCandidates.map((candidate, index) => proposedCard(candidate, `Proposed ${index + 1} of ${proposedCandidates.length}`)).join("\n ")
|
|
132
143
|
: proposedCard(proposedCandidates[0], "Proposed");
|
|
133
144
|
const itemNameJson = escapeJsonInHtml(item.metadata.name);
|
|
145
|
+
const excludedNote = excludedProposalsSentence(buildReviewItemPresentation(item).excludedProposals);
|
|
134
146
|
const decisionBadge = decision
|
|
135
147
|
? `<span class="badge badge-${decision === "accept-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(workbenchDecisionDefinitions[decision].label)}</span>`
|
|
136
148
|
: `<span class="badge badge-pending">${escapeHtml(status)}</span>`;
|
|
@@ -232,6 +244,7 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
|
232
244
|
</div>
|
|
233
245
|
${proposedCards}
|
|
234
246
|
</div>
|
|
247
|
+
${excludedNote ? `<p class="feedback" id="excluded-note">${escapeHtml(excludedNote)}</p>` : ""}
|
|
235
248
|
${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>` : ""}
|
|
236
249
|
|
|
237
250
|
<div class="divider"></div>
|
|
@@ -339,6 +352,13 @@ async function toolItem(itemName, options) {
|
|
|
339
352
|
decision: current.decisionsByItemName[item.metadata.name],
|
|
340
353
|
note: current.notesByItemName[item.metadata.name],
|
|
341
354
|
candidateSetStatus: item.spec.candidateSetStatus,
|
|
355
|
+
...(() => {
|
|
356
|
+
const presentation = buildReviewItemPresentation(item);
|
|
357
|
+
return {
|
|
358
|
+
...(presentation.excerptVerification ? { excerptVerification: presentation.excerptVerification } : {}),
|
|
359
|
+
...(presentation.excludedProposals.length ? { excludedProposals: presentation.excludedProposals.map(({ proposalIndex, value, locator, excerpt }) => ({ proposalIndex, value, locator, excerpt })) } : {}),
|
|
360
|
+
};
|
|
361
|
+
})(),
|
|
342
362
|
candidates: item.spec.candidates.map((c) => ({
|
|
343
363
|
id: c.id,
|
|
344
364
|
role: c.role,
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* it is a duplicate placement or a constant, but a key is never renamed or
|
|
26
26
|
* repointed at a different fact. Hosts may hold selectors against these values.
|
|
27
27
|
*/
|
|
28
|
-
export declare const reviewAuditRowKeys: readonly ["current-candidate-id", "proposed-candidate-id", "claim-id", "raw-source-id", "locator", "model", "extractor", "extracted-at", "history-value", "candidate-id", "source-reference", "excerpt", "observed", "source-authority-class", "declared-by", "authority-scope", "extraction-id", "actor", "reviewed-at", "status", "rationale", "outcome", "checksum", "candidate-set-id", "authority-trace-status", "authority-trace-detail"];
|
|
28
|
+
export declare const reviewAuditRowKeys: readonly ["current-candidate-id", "proposed-candidate-id", "claim-id", "raw-source-id", "locator", "model", "extractor", "extracted-at", "excluded-proposal", "history-value", "candidate-id", "source-reference", "excerpt", "observed", "source-authority-class", "declared-by", "authority-scope", "extraction-id", "actor", "reviewed-at", "status", "rationale", "outcome", "checksum", "candidate-set-id", "authority-trace-status", "authority-trace-detail"];
|
|
29
29
|
/** One of {@link reviewAuditRowKeys}. */
|
|
30
30
|
export type ReviewAuditRowKey = (typeof reviewAuditRowKeys)[number];
|
|
31
31
|
/**
|
|
@@ -1,18 +1,7 @@
|
|
|
1
|
-
import { type ExtractionEnvelopeImportDiagnostic, type ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
|
|
1
|
+
import { type ExtractionEnvelopeImportDiagnostic, type ExtractionEnvelopeImportResult, type ResolvedExtractionArtifact } from "../extraction-envelope.js";
|
|
2
2
|
import { type PortablePdfRegionContext } from "../pdf-layout.js";
|
|
3
3
|
export type ExtractionAlignmentState = "aligned" | "excerpt-mismatch" | "artifact-unavailable" | "digest-mismatch";
|
|
4
|
-
export type ArtifactUnavailableCode
|
|
5
|
-
export type ResolvedExtractionArtifact = {
|
|
6
|
-
status: "available";
|
|
7
|
-
text: string;
|
|
8
|
-
actualDigest: string;
|
|
9
|
-
} | {
|
|
10
|
-
status: "unavailable";
|
|
11
|
-
code: ArtifactUnavailableCode;
|
|
12
|
-
} | {
|
|
13
|
-
status: "digest-mismatch";
|
|
14
|
-
actualDigest: string;
|
|
15
|
-
};
|
|
4
|
+
export type { ArtifactUnavailableCode, ResolvedExtractionArtifact } from "../extraction-envelope.js";
|
|
16
5
|
export interface ExtractionInspectorEntry {
|
|
17
6
|
/** Result returned by importExtractionEnvelope; includes authoritative ReviewItem identities. */
|
|
18
7
|
importResult: ExtractionEnvelopeImportResult;
|
|
@@ -97,6 +86,21 @@ export interface ExtractionInspectorSource {
|
|
|
97
86
|
extractionDiagnostic?: Extract<ExtractionEnvelopeImportDiagnostic, {
|
|
98
87
|
kind: "extraction-failed" | "extraction-incomplete";
|
|
99
88
|
}>;
|
|
89
|
+
/**
|
|
90
|
+
* Whether the import checked excerpts against the prepared artifact
|
|
91
|
+
* (`status.provenance`; a record without it is `unverified`). Always set by
|
|
92
|
+
* {@link buildExtractionInspectorModel}; optional for hand-authored models.
|
|
93
|
+
*/
|
|
94
|
+
importProvenance?: "verified" | "unverified";
|
|
95
|
+
/**
|
|
96
|
+
* Proposals the import left out because the prepared text at their span is
|
|
97
|
+
* not their excerpt. They have no candidate row; this is where they are shown.
|
|
98
|
+
*/
|
|
99
|
+
excludedProposals?: Array<{
|
|
100
|
+
proposalIndex: number;
|
|
101
|
+
field: string;
|
|
102
|
+
locator: string;
|
|
103
|
+
}>;
|
|
100
104
|
message: string;
|
|
101
105
|
}
|
|
102
106
|
export interface ExtractionInspectorModel {
|
|
@@ -159,7 +163,7 @@ export declare function buildExtractionInspectorModel(input: ExtractionInspector
|
|
|
159
163
|
* stop when there is one, otherwise the artifact alignment. A failed
|
|
160
164
|
* extraction over an aligned artifact must not show the aligned posture.
|
|
161
165
|
*/
|
|
162
|
-
export declare function inspectorSourcePosture(source: Pick<ExtractionInspectorSource, "alignment" | "extractionDiagnostic">): ExtractionAlignmentState | "extraction-failed" | "extraction-incomplete";
|
|
166
|
+
export declare function inspectorSourcePosture(source: Pick<ExtractionInspectorSource, "alignment" | "extractionDiagnostic" | "excludedProposals">): ExtractionAlignmentState | "extraction-failed" | "extraction-incomplete" | "proposals-excluded";
|
|
163
167
|
export declare function filterExtractionInspectorCandidates(model: ExtractionInspectorModel, filters: ExtractionInspectorFilters): ExtractionInspectorCandidate[];
|
|
164
168
|
export declare function exportExtractionInspector(model: ExtractionInspectorModel, options?: ExtractionInspectorExportOptions): string;
|
|
165
169
|
export declare function mountExtractionInspector(container: HTMLElement, model: ExtractionInspectorModel, options?: ExtractionInspectorMountOptions): () => void;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { buildReviewItemsFromExtractionEnvelopeImport, validateExtractionEnvelopeImport } from "../extraction-envelope.js";
|
|
1
|
+
import { assertResolvedExtractionArtifact, buildReviewItemsFromExtractionEnvelopeImport, checkPreparedArtifact, excerptMismatchProposalIndices, proposalSpanMatches, validateExtractionEnvelopeImport } from "../extraction-envelope.js";
|
|
2
2
|
import { canonicalJson } from "./canonical.js";
|
|
3
|
-
import { sha256Hex } from "../sha256.js";
|
|
4
3
|
import { resolvePortablePdfRegion, } from "../pdf-layout.js";
|
|
5
4
|
/**
|
|
6
5
|
* Build a read-only view from one or more results already produced by Survey's
|
|
@@ -19,14 +18,14 @@ export function buildExtractionInspectorModel(input) {
|
|
|
19
18
|
throw new Error("Invalid extraction import result.");
|
|
20
19
|
const record = validateExtractionEnvelopeImport(entry.importResult.record);
|
|
21
20
|
assertImportedResult(entry.importResult, record);
|
|
22
|
-
|
|
21
|
+
assertResolvedExtractionArtifact(entry.artifact);
|
|
23
22
|
const envelope = record.spec.envelope;
|
|
24
23
|
const prepared = envelope.result.preparedArtifact;
|
|
25
24
|
const sourceKey = `${record.metadata.producerNamespace}:${record.metadata.name}:${entryIndex}`;
|
|
26
25
|
if (sourceKeys.has(sourceKey))
|
|
27
26
|
throw new Error("Extraction inspector source identity collision.");
|
|
28
27
|
sourceKeys.add(sourceKey);
|
|
29
|
-
const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.diagnostics, entry.artifact, envelope.result.ocrDerived);
|
|
28
|
+
const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.diagnostics, entry.artifact, envelope.result.ocrDerived, envelope.result.proposals, record.status.provenance ?? "unverified");
|
|
30
29
|
sources.push(source);
|
|
31
30
|
const candidateStart = candidates.length;
|
|
32
31
|
const itemByProposalIndex = new Map();
|
|
@@ -118,25 +117,6 @@ function resolveHighlightElementIds(model) {
|
|
|
118
117
|
}
|
|
119
118
|
return resolved;
|
|
120
119
|
}
|
|
121
|
-
function assertResolvedArtifact(artifact) {
|
|
122
|
-
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact))
|
|
123
|
-
throw new Error("Invalid resolved extraction artifact.");
|
|
124
|
-
const keys = Object.keys(artifact).sort();
|
|
125
|
-
if (artifact.status === "available") {
|
|
126
|
-
if (keys.join(",") !== "actualDigest,status,text" || typeof artifact.text !== "string" || !/^[a-f0-9]{64}$/.test(artifact.actualDigest))
|
|
127
|
-
throw new Error("Invalid available extraction artifact.");
|
|
128
|
-
}
|
|
129
|
-
else if (artifact.status === "digest-mismatch") {
|
|
130
|
-
if (keys.join(",") !== "actualDigest,status" || !/^[a-f0-9]{64}$/.test(artifact.actualDigest))
|
|
131
|
-
throw new Error("Invalid digest-mismatch extraction artifact.");
|
|
132
|
-
}
|
|
133
|
-
else if (artifact.status === "unavailable") {
|
|
134
|
-
if (keys.join(",") !== "code,status" || !["not-found", "storage-error", "access-denied", "invalid-artifact", "unknown"].includes(artifact.code))
|
|
135
|
-
throw new Error("Invalid unavailable extraction artifact.");
|
|
136
|
-
}
|
|
137
|
-
else
|
|
138
|
-
throw new Error("Invalid resolved extraction artifact status.");
|
|
139
|
-
}
|
|
140
120
|
function assertImportedResult(result, record) {
|
|
141
121
|
if (!result || typeof result !== "object" || !result.record || !Array.isArray(result.reviewItems))
|
|
142
122
|
throw new Error("Invalid extraction import result.");
|
|
@@ -148,7 +128,9 @@ function assertImportedResult(result, record) {
|
|
|
148
128
|
const grounded = record.status?.state === "grounded";
|
|
149
129
|
const proposals = record.spec.envelope.result.proposals;
|
|
150
130
|
const covered = reviewItems.flatMap(envelopeItemProposalIndices).sort((left, right) => left - right);
|
|
151
|
-
|
|
131
|
+
const excluded = excerptMismatchProposalIndices(record);
|
|
132
|
+
const expectedCovered = proposals.map((_proposal, index) => index).filter((index) => !excluded.has(index));
|
|
133
|
+
if ((!grounded && reviewItems.length !== 0) || (grounded && canonicalJson(covered) !== canonicalJson(expectedCovered)))
|
|
152
134
|
throw new Error("Extraction import ReviewItems do not match its grounding state.");
|
|
153
135
|
const canonicalItems = buildReviewItemsFromExtractionEnvelopeImport(record);
|
|
154
136
|
if (canonicalJson(reviewItems) !== canonicalJson(canonicalItems))
|
|
@@ -183,39 +165,51 @@ function envelopeItemProposalIndices(item) {
|
|
|
183
165
|
* extraction over an aligned artifact must not show the aligned posture.
|
|
184
166
|
*/
|
|
185
167
|
export function inspectorSourcePosture(source) {
|
|
168
|
+
// Dropped proposals are a loss of evidence: they lead over an aligned
|
|
169
|
+
// artifact and over an extraction that merely stopped short.
|
|
170
|
+
if (source.alignment === "aligned" && source.excludedProposals?.length && source.extractionDiagnostic?.kind !== "extraction-failed")
|
|
171
|
+
return "proposals-excluded";
|
|
186
172
|
if (source.extractionDiagnostic)
|
|
187
173
|
return source.extractionDiagnostic.kind;
|
|
188
174
|
return source.alignment;
|
|
189
175
|
}
|
|
190
|
-
function sourceModel(key, importName, prepared, diagnostics, artifact, ocrDerived) {
|
|
176
|
+
function sourceModel(key, importName, prepared, diagnostics, artifact, ocrDerived, proposals, importProvenance) {
|
|
191
177
|
let alignment;
|
|
192
178
|
let message;
|
|
193
179
|
const artifactUnresolved = diagnostics.some((diagnostic) => diagnostic.kind === "artifact-unavailable" || diagnostic.kind === "digest-mismatch");
|
|
180
|
+
const excludedProposals = diagnostics.flatMap((diagnostic) => diagnostic.kind === "excerpt-mismatch"
|
|
181
|
+
? [{ proposalIndex: diagnostic.proposalIndex, field: proposals[diagnostic.proposalIndex]?.fieldPath ?? "", locator: diagnostic.locator }]
|
|
182
|
+
: []);
|
|
194
183
|
const extractionDiagnostic = diagnostics.find((diagnostic) => diagnostic.kind === "extraction-failed" || diagnostic.kind === "extraction-incomplete");
|
|
195
|
-
|
|
184
|
+
const check = checkPreparedArtifact(prepared, artifact);
|
|
185
|
+
if (artifactUnresolved || check.status === "unavailable") {
|
|
196
186
|
alignment = "artifact-unavailable";
|
|
197
|
-
message = `Prepared artifact unavailable (${
|
|
187
|
+
message = `Prepared artifact unavailable (${check.status === "unavailable" ? check.code : "invalid-artifact"}). Candidates are not grounded.`;
|
|
198
188
|
}
|
|
199
|
-
else if (
|
|
200
|
-
|| sha256Hex(artifact.text) !== artifact.actualDigest) {
|
|
189
|
+
else if (check.status === "digest-mismatch") {
|
|
201
190
|
alignment = "digest-mismatch";
|
|
202
191
|
message = "Prepared artifact digest does not match the extraction artifact. Candidates are not grounded.";
|
|
203
192
|
}
|
|
204
|
-
else if (
|
|
193
|
+
else if (check.status === "wrong-length") {
|
|
205
194
|
alignment = "artifact-unavailable";
|
|
206
195
|
message = "Prepared artifact content has the wrong length. Candidates are not grounded.";
|
|
207
196
|
}
|
|
208
197
|
else {
|
|
209
198
|
alignment = "aligned";
|
|
210
|
-
message = extractionDiagnostic
|
|
199
|
+
message = extractionDiagnostic || excludedProposals.length
|
|
211
200
|
? "Prepared artifact identity verified."
|
|
212
201
|
: `Prepared artifact identity verified. Exact source spans are available.${ocrDerived ? " Prepared text is OCR-derived." : ""}`;
|
|
213
202
|
}
|
|
203
|
+
if (excludedProposals.length) {
|
|
204
|
+
const named = excludedProposals.map((p) => `#${p.proposalIndex} ${p.field} (${p.locator})`).join(", ");
|
|
205
|
+
message = `${excludedProposals.length} proposal${excludedProposals.length === 1 ? " was" : "s were"} excluded at import because the prepared text at the span is not the excerpt: ${named}. ${message}`;
|
|
206
|
+
}
|
|
207
|
+
message = `${message} ${importProvenance === "verified" ? "Excerpts were checked against the prepared artifact at import." : "Excerpts were not checked against the prepared artifact at import."}`;
|
|
214
208
|
// The extraction's own failure leads: an aligned artifact with no candidates
|
|
215
209
|
// must not read as a complete run that found nothing.
|
|
216
210
|
if (extractionDiagnostic)
|
|
217
211
|
message = `${extractionDiagnostic.message} ${message}`;
|
|
218
|
-
return { key, importName, ...(extractionDiagnostic ? { extractionDiagnostic } : {}), ...(prepared?.ref ? { artifactRef: prepared.ref } : {}), ...(prepared?.digest ? { expectedDigest: prepared.digest } : {}), ...("actualDigest" in artifact ? { actualDigest: artifact.actualDigest } : {}), ...(alignment === "aligned" && artifact.status === "available" ? { artifactText: artifact.text } : {}), ...(ocrDerived ? { ocrDerived: true } : {}), alignment, message };
|
|
212
|
+
return { key, importName, importProvenance, ...(excludedProposals.length ? { excludedProposals } : {}), ...(extractionDiagnostic ? { extractionDiagnostic } : {}), ...(prepared?.ref ? { artifactRef: prepared.ref } : {}), ...(prepared?.digest ? { expectedDigest: prepared.digest } : {}), ...("actualDigest" in artifact ? { actualDigest: artifact.actualDigest } : {}), ...(alignment === "aligned" && artifact.status === "available" ? { artifactText: artifact.text } : {}), ...(ocrDerived ? { ocrDerived: true } : {}), alignment, message };
|
|
219
213
|
}
|
|
220
214
|
function candidateModel(source, reviewItemName, proposal, index, provider, model, attempt, pass, pdfPageOffsets, pdfLayout, ocrDerived) {
|
|
221
215
|
const match = /^chars:(\d+)-(\d+)$/.exec(proposal.provenance.locator);
|
|
@@ -224,10 +218,10 @@ function candidateModel(source, reviewItemName, proposal, index, provider, model
|
|
|
224
218
|
const start = Number(match[1]), end = Number(match[2]);
|
|
225
219
|
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start)
|
|
226
220
|
throw new Error(`Extraction proposal ${index} has an invalid text span.`);
|
|
227
|
-
const alignment = source.alignment === "aligned" && source.artifactText
|
|
221
|
+
const alignment = source.alignment === "aligned" && !proposalSpanMatches(source.artifactText, proposal) ? "excerpt-mismatch" : source.alignment;
|
|
228
222
|
if (alignment === "excerpt-mismatch") {
|
|
229
223
|
source.alignment = alignment;
|
|
230
|
-
source.message =
|
|
224
|
+
source.message = `One or more source spans do not match their recorded excerpts. Affected candidates are not grounded.${source.importProvenance === "unverified" ? " Excerpts were not checked against the prepared artifact at import." : ""}`;
|
|
231
225
|
}
|
|
232
226
|
let pdfRegion = pdfLayout ? resolvePortablePdfRegion(pdfLayout, proposal.provenance.locator) : undefined;
|
|
233
227
|
const page = resolvePdfPage(pdfPageOffsets, start);
|
|
@@ -306,7 +300,7 @@ export function mountExtractionInspector(container, model, options = {}) {
|
|
|
306
300
|
next.hidden = pageCount === 1;
|
|
307
301
|
previous.disabled = page === 0;
|
|
308
302
|
next.disabled = page >= pageCount - 1;
|
|
309
|
-
postures.innerHTML = model.sources.map(s => { const posture = inspectorSourcePosture(s);
|
|
303
|
+
postures.innerHTML = model.sources.map(s => { const posture = inspectorSourcePosture(s); const classes = [...new Set([s.alignment, s.extractionDiagnostic?.kind, s.excludedProposals?.length ? "proposals-excluded" : undefined, posture].filter(Boolean))].join(" "); return `<div class="inspector-posture ${classes}" role="status" data-posture="${escapeHtml(posture)}"><strong>${escapeHtml(s.importName)}: ${escapeHtml(posture)}</strong><span>${escapeHtml(s.message)}</span></div>`; }).join("");
|
|
310
304
|
sourcesRoot.innerHTML = model.sources.map(s => { const anchored = model.candidates.filter(c => c.sourceKey === s.key); const marked = visible.filter(c => c.sourceKey === s.key); return `<div class="inspector-source" aria-label="Prepared source for ${escapeHtml(s.importName)}"><h3>${escapeHtml(s.importName)}</h3><pre tabindex="0">${s.artifactText === undefined ? `${anchored.map(c => anchorHtml(c, highlightIdFor(c))).join("")}<span class="source-unavailable">${escapeHtml(s.message)}</span>` : renderSource(s.artifactText, anchored, marked, highlightIdFor)}</pre></div>`; }).join("");
|
|
311
305
|
};
|
|
312
306
|
root.querySelectorAll("select").forEach(select => select.addEventListener("change", event => { event.stopPropagation(); const key = select.dataset.filter; if (select.value)
|
|
@@ -50,6 +50,25 @@ export interface ReviewItemPresentation {
|
|
|
50
50
|
readonly reviewItemLink?: ReviewPresentationLink;
|
|
51
51
|
readonly traceRefs: readonly ReviewTraceRef[];
|
|
52
52
|
readonly candidates: readonly ReviewCandidatePresentation[];
|
|
53
|
+
/**
|
|
54
|
+
* Proposals for this claim that an envelope import left out because the
|
|
55
|
+
* source text at their cited span is not their excerpt. Unverifiable, not
|
|
56
|
+
* disproven: every decision surface shows them. Empty for other items.
|
|
57
|
+
*/
|
|
58
|
+
readonly excludedProposals: readonly ExcludedProposalPresentation[];
|
|
59
|
+
/**
|
|
60
|
+
* For an envelope-imported item, whether its import checked excerpts
|
|
61
|
+
* against the prepared artifact. Absent for other items, and for an item
|
|
62
|
+
* whose envelope binding is missing, which cannot claim either.
|
|
63
|
+
*/
|
|
64
|
+
readonly excerptVerification?: "verified" | "unverified";
|
|
65
|
+
}
|
|
66
|
+
export interface ExcludedProposalPresentation {
|
|
67
|
+
readonly proposalIndex: number;
|
|
68
|
+
readonly value: unknown;
|
|
69
|
+
readonly valueText: string;
|
|
70
|
+
readonly locator: string;
|
|
71
|
+
readonly excerpt: string;
|
|
53
72
|
}
|
|
54
73
|
export interface ReviewResultPresentation {
|
|
55
74
|
readonly result: ReviewWorkbenchResult;
|
|
@@ -106,6 +125,11 @@ export interface InterpretationReadingPresentation {
|
|
|
106
125
|
* authored record under a label nothing derived.
|
|
107
126
|
*/
|
|
108
127
|
export declare function buildInterpretationReadingPresentation(source: InterpretationReadingSource): InterpretationReadingPresentation;
|
|
128
|
+
/**
|
|
129
|
+
* One sentence naming every excluded proposal, for surfaces that speak in
|
|
130
|
+
* text (the MCP item, the recorded decision prompt). Undefined when none.
|
|
131
|
+
*/
|
|
132
|
+
export declare function excludedProposalsSentence(excluded: readonly ExcludedProposalPresentation[]): string | undefined;
|
|
109
133
|
export declare function buildReviewItemPresentation(item: ReviewItem, adapter?: ReviewPresentationAdapter): ReviewItemPresentation;
|
|
110
134
|
export declare function buildReviewCandidatePresentation(item: ReviewItem, candidate: ReviewCandidate, adapter?: ReviewPresentationAdapter, targetLabel?: string): ReviewCandidatePresentation;
|
|
111
135
|
export declare function buildReviewResultPresentation(result: ReviewWorkbenchResult, item: ReviewItem | undefined, adapter?: ReviewPresentationAdapter): ReviewResultPresentation;
|
|
@@ -49,6 +49,50 @@ export function buildInterpretationReadingPresentation(source) {
|
|
|
49
49
|
provenanceLabel: "Authored judgment",
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
+
const EXTRACTION_ENVELOPE_PRODUCER = "survey.kontourai.io/extraction-envelope";
|
|
53
|
+
/**
|
|
54
|
+
* The envelope-import binding an item carries, or undefined when it has none.
|
|
55
|
+
* A binding names its import and proposals, and every candidate points back
|
|
56
|
+
* at the same import; anything less cannot vouch for what the import checked.
|
|
57
|
+
*/
|
|
58
|
+
function extractionEnvelopeBinding(item) {
|
|
59
|
+
const metadata = item.metadata?.producer?.[EXTRACTION_ENVELOPE_PRODUCER];
|
|
60
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
|
|
61
|
+
return undefined;
|
|
62
|
+
const binding = metadata;
|
|
63
|
+
if (typeof binding.importName !== "string" || !binding.importName || !Array.isArray(binding.proposalIndices) || binding.proposalIndices.length === 0)
|
|
64
|
+
return undefined;
|
|
65
|
+
const bound = item.spec.candidates.length > 0 && item.spec.candidates.every((candidate) => {
|
|
66
|
+
const own = candidate.producer?.[EXTRACTION_ENVELOPE_PRODUCER];
|
|
67
|
+
return own?.importName === binding.importName;
|
|
68
|
+
});
|
|
69
|
+
return bound ? binding : undefined;
|
|
70
|
+
}
|
|
71
|
+
/** The well-formed `excludedProposals` entries of a bound envelope item; malformed entries are ignored, never rendered. */
|
|
72
|
+
function excludedProposalsOf(item, binding, adapter) {
|
|
73
|
+
if (!binding || !Array.isArray(binding.excludedProposals))
|
|
74
|
+
return [];
|
|
75
|
+
return binding.excludedProposals.flatMap((entry) => {
|
|
76
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
77
|
+
return [];
|
|
78
|
+
const e = entry;
|
|
79
|
+
if (!Number.isSafeInteger(e.proposalIndex) || typeof e.locator !== "string" || typeof e.excerpt !== "string" || !("value" in e))
|
|
80
|
+
return [];
|
|
81
|
+
const proposed = item.spec.candidates.find((candidate) => candidate.role === "proposed");
|
|
82
|
+
const valueText = (proposed ? adapter.summarizeValue?.(e.value, { item, candidate: proposed, value: e.value }) : undefined) ?? formatValue(e.value);
|
|
83
|
+
return [{ proposalIndex: e.proposalIndex, value: e.value, valueText, locator: e.locator, excerpt: e.excerpt }];
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* One sentence naming every excluded proposal, for surfaces that speak in
|
|
88
|
+
* text (the MCP item, the recorded decision prompt). Undefined when none.
|
|
89
|
+
*/
|
|
90
|
+
export function excludedProposalsSentence(excluded) {
|
|
91
|
+
if (excluded.length === 0)
|
|
92
|
+
return undefined;
|
|
93
|
+
const named = excluded.map((entry) => `${entry.valueText} (proposal ${entry.proposalIndex}, ${entry.locator})`).join(", ");
|
|
94
|
+
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
|
+
}
|
|
52
96
|
export function buildReviewItemPresentation(item, adapter = {}) {
|
|
53
97
|
const context = { item };
|
|
54
98
|
const targetLabel = adapter.labelForTarget?.(item.spec.target, context) ?? humanizeIdentifier(item.spec.target);
|
|
@@ -61,6 +105,14 @@ export function buildReviewItemPresentation(item, adapter = {}) {
|
|
|
61
105
|
reviewItemLink: adapter.linkForReviewItem?.(item, context),
|
|
62
106
|
traceRefs: traceRefsForReviewItem(item, adapter),
|
|
63
107
|
candidates: item.spec.candidates.map((candidate) => buildReviewCandidatePresentation(item, candidate, adapter, targetLabel)),
|
|
108
|
+
...extractionPresentation(item, adapter),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function extractionPresentation(item, adapter) {
|
|
112
|
+
const binding = extractionEnvelopeBinding(item);
|
|
113
|
+
return {
|
|
114
|
+
excludedProposals: excludedProposalsOf(item, binding, adapter),
|
|
115
|
+
...(binding ? { excerptVerification: binding.excerptVerification === "verified" ? "verified" : "unverified" } : {}),
|
|
64
116
|
};
|
|
65
117
|
}
|
|
66
118
|
export function buildReviewCandidatePresentation(item, candidate, adapter = {}, targetLabel = adapter.labelForTarget?.(item.spec.target, { item }) ?? humanizeIdentifier(item.spec.target)) {
|
|
@@ -246,9 +246,10 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
246
246
|
.survey-workbench-embed .inspector-heading, .survey-workbench-embed .inspector-layout{ display: grid; grid-template-columns: minmax(15rem, .7fr) minmax(0, 1.3fr); gap: 1rem; }
|
|
247
247
|
.survey-workbench-embed .inspector-heading h2{ margin: 0; }
|
|
248
248
|
.survey-workbench-embed .inspector-posture{ display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
|
|
249
|
-
|
|
250
|
-
.survey-workbench-embed .inspector-posture.extraction-failed{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
249
|
+
/* Caution first: an artifact failure on the same source must win the tie and stay negative. */
|
|
251
250
|
.survey-workbench-embed .inspector-posture.extraction-incomplete{ background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
|
|
251
|
+
.survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch, .survey-workbench-embed .inspector-posture.proposals-excluded{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
252
|
+
.survey-workbench-embed .inspector-posture.extraction-failed{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
252
253
|
.survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
|
|
253
254
|
.survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
|
|
254
255
|
.survey-workbench-embed .inspector-filters select, .survey-workbench-embed .inspector-filters input{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
|
|
@@ -834,6 +835,12 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
834
835
|
font-size: 12px;
|
|
835
836
|
}
|
|
836
837
|
|
|
838
|
+
.survey-workbench-embed .excerpt-verification{
|
|
839
|
+
margin: 0;
|
|
840
|
+
font-size: 12px;
|
|
841
|
+
color: var(--k-muted);
|
|
842
|
+
}
|
|
843
|
+
|
|
837
844
|
/* decisions */
|
|
838
845
|
|
|
839
846
|
/* The decision row is the point of the card, so it sits on its own line,
|
|
@@ -243,9 +243,10 @@
|
|
|
243
243
|
.survey-workbench-embed .inspector-heading, .survey-workbench-embed .inspector-layout{ display: grid; grid-template-columns: minmax(15rem, .7fr) minmax(0, 1.3fr); gap: 1rem; }
|
|
244
244
|
.survey-workbench-embed .inspector-heading h2{ margin: 0; }
|
|
245
245
|
.survey-workbench-embed .inspector-posture{ display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
|
|
246
|
-
|
|
247
|
-
.survey-workbench-embed .inspector-posture.extraction-failed{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
246
|
+
/* Caution first: an artifact failure on the same source must win the tie and stay negative. */
|
|
248
247
|
.survey-workbench-embed .inspector-posture.extraction-incomplete{ background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
|
|
248
|
+
.survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch, .survey-workbench-embed .inspector-posture.proposals-excluded{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
249
|
+
.survey-workbench-embed .inspector-posture.extraction-failed{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
249
250
|
.survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
|
|
250
251
|
.survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
|
|
251
252
|
.survey-workbench-embed .inspector-filters select, .survey-workbench-embed .inspector-filters input{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
|
|
@@ -831,6 +832,12 @@
|
|
|
831
832
|
font-size: 12px;
|
|
832
833
|
}
|
|
833
834
|
|
|
835
|
+
.survey-workbench-embed .excerpt-verification{
|
|
836
|
+
margin: 0;
|
|
837
|
+
font-size: 12px;
|
|
838
|
+
color: var(--k-muted);
|
|
839
|
+
}
|
|
840
|
+
|
|
834
841
|
/* decisions */
|
|
835
842
|
|
|
836
843
|
/* The decision row is the point of the card, so it sits on its own line,
|
|
@@ -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 { humanizeIdentifier } from "./review-presentation.js";
|
|
9
|
+
import { 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";
|
|
@@ -129,6 +129,13 @@ function buildDecisionCardAuthorizing(state) {
|
|
|
129
129
|
* followed by the selected decision label so the block is self-contained.
|
|
130
130
|
*/
|
|
131
131
|
function decisionCardRenderedPrompt(state, targetLabel) {
|
|
132
|
+
// The card shows proposals excluded at import; the recorded prompt must say
|
|
133
|
+
// the reviewer was told about them.
|
|
134
|
+
const excluded = excludedProposalsSentence(buildReviewItemPresentation(state.item).excludedProposals);
|
|
135
|
+
const base = decisionCardBasePrompt(state, targetLabel);
|
|
136
|
+
return excluded ? `${base} ${excluded}` : base;
|
|
137
|
+
}
|
|
138
|
+
function decisionCardBasePrompt(state, targetLabel) {
|
|
132
139
|
const currentCandidate = state.item.spec.candidates.find((c) => c.role === "current");
|
|
133
140
|
const proposedCandidates = state.item.spec.candidates.filter((c) => c.role === "proposed");
|
|
134
141
|
// A conflict card lists every proposed value and offers reject-all or could
|
|
@@ -693,6 +700,7 @@ function renderFieldCard(item, session, presentationAdapter) {
|
|
|
693
700
|
? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel)
|
|
694
701
|
: "<p class=\"field-value\">No proposed value is available for this field.</p>"}
|
|
695
702
|
${proposed ? renderProvenanceRow(item, proposed, presentationAdapter) : ""}
|
|
703
|
+
${renderExtractionImportNotes(presentation)}
|
|
696
704
|
<div class="decide">
|
|
697
705
|
${keepDecision === undefined ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>`}
|
|
698
706
|
${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">Use proposed</button>
|
|
@@ -924,6 +932,35 @@ function renderProvenanceRow(item, proposed, presentationAdapter) {
|
|
|
924
932
|
</div>
|
|
925
933
|
`;
|
|
926
934
|
}
|
|
935
|
+
/**
|
|
936
|
+
* What an envelope import says about this field's evidence: whether its
|
|
937
|
+
* excerpts were checked against the prepared source text, and any rival
|
|
938
|
+
* proposals the import left out because their excerpt was not at their span.
|
|
939
|
+
* Such a proposal is unverifiable, not disproven, so the reviewer sees it
|
|
940
|
+
* before accepting the value that remained. Empty for items from other
|
|
941
|
+
* producers.
|
|
942
|
+
*/
|
|
943
|
+
function renderExtractionImportNotes(presentation) {
|
|
944
|
+
const { excerptVerification: verification, excludedProposals: excluded } = presentation;
|
|
945
|
+
if (verification === undefined && excluded.length === 0)
|
|
946
|
+
return "";
|
|
947
|
+
const excludedHtml = excluded.length === 0 ? "" : `
|
|
948
|
+
<div class="noprov" data-testid="excluded-proposals" role="note">
|
|
949
|
+
${WARNING_SVG}
|
|
950
|
+
<span class="tag">${excluded.length} excluded</span>
|
|
951
|
+
<span>${excluded.length === 1 ? "Another proposal for this field was" : `${excluded.length} other proposals for this field were`} left out because the source text at the cited span is not the excerpt:
|
|
952
|
+
${excluded.map((entry) => `<span data-testid="excluded-proposal" data-proposal-index="${escapeHtml(String(entry.proposalIndex))}"><q>${escapeHtml(entry.valueText)}</q> (proposal ${escapeHtml(String(entry.proposalIndex))}, ${escapeHtml(entry.locator)})</span>`).join(", ")}.
|
|
953
|
+
Unverifiable is not disproven: check the source before deciding.</span>
|
|
954
|
+
</div>`;
|
|
955
|
+
const verificationHtml = verification === undefined ? "" : `
|
|
956
|
+
<p class="excerpt-verification" data-testid="excerpt-verification" data-verified="${verification === "verified" ? "true" : "false"}">${verification === "verified"
|
|
957
|
+
? "Excerpts checked against the prepared source text at import."
|
|
958
|
+
: "Excerpts not checked against the prepared source text at import."}</p>`;
|
|
959
|
+
return `
|
|
960
|
+
<div class="prov import-notes">${excludedHtml}${verificationHtml}
|
|
961
|
+
</div>
|
|
962
|
+
`;
|
|
963
|
+
}
|
|
927
964
|
function renderConfidenceMeter(confidence) {
|
|
928
965
|
const clamped = Math.max(0, Math.min(1, confidence));
|
|
929
966
|
const pct = Math.round(clamped * 100);
|
|
@@ -980,6 +1017,7 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
|
|
|
980
1017
|
${proposed?.extraction.model ? fieldItem("model", "Model", proposed.extraction.model) : ""}
|
|
981
1018
|
${proposed ? placementItem(trace, { of: proposed.id, property: "extraction.extractor" }, "extractor", "Extractor", proposed.extraction.extractor ?? "unknown") : ""}
|
|
982
1019
|
${proposed ? fieldItem("extracted-at", "Extracted at", proposed.extraction.extractedAt ?? "unknown") : ""}
|
|
1020
|
+
${buildReviewItemPresentation(item, presentationAdapter).excludedProposals.map((entry) => fieldItem("excluded-proposal", `Excluded proposal ${entry.proposalIndex}`, `${entry.valueText} at ${entry.locator}: excerpt not at the cited span`)).join("")}
|
|
983
1021
|
</dl>
|
|
984
1022
|
${preview
|
|
985
1023
|
? `<div class="preview-section-grid">${renderSurfacePreviewSections(preview, trace)}</div><p class="preview-disclaimer preview-disclaimer-footer">${escapeHtml(preview.postureDisclaimer)}</p>`
|
|
@@ -38,9 +38,10 @@
|
|
|
38
38
|
.inspector-heading, .inspector-layout { display: grid; grid-template-columns: minmax(15rem, .7fr) minmax(0, 1.3fr); gap: 1rem; }
|
|
39
39
|
.inspector-heading h2 { margin: 0; }
|
|
40
40
|
.inspector-posture { display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
|
|
41
|
-
|
|
42
|
-
.inspector-posture.extraction-failed { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
41
|
+
/* Caution first: an artifact failure on the same source must win the tie and stay negative. */
|
|
43
42
|
.inspector-posture.extraction-incomplete { background: var(--k-caution-wash); color: var(--k-caution); border: 2px solid currentColor; }
|
|
43
|
+
.inspector-posture.digest-mismatch, .inspector-posture.artifact-unavailable, .inspector-posture.excerpt-mismatch, .inspector-posture.proposals-excluded { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
44
|
+
.inspector-posture.extraction-failed { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
44
45
|
.inspector-filters { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
|
|
45
46
|
.inspector-filters label { display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
|
|
46
47
|
.inspector-filters select, .inspector-filters input { color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
|
|
@@ -626,6 +627,12 @@ h1, h2, h3, p {
|
|
|
626
627
|
font-size: 12px;
|
|
627
628
|
}
|
|
628
629
|
|
|
630
|
+
.excerpt-verification {
|
|
631
|
+
margin: 0;
|
|
632
|
+
font-size: 12px;
|
|
633
|
+
color: var(--k-muted);
|
|
634
|
+
}
|
|
635
|
+
|
|
629
636
|
/* decisions */
|
|
630
637
|
|
|
631
638
|
/* The decision row is the point of the card, so it sits on its own line,
|
package/package.json
CHANGED