@kontourai/survey 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/src/extraction-envelope.d.ts +2 -0
- package/dist/src/extraction-envelope.js +11 -3
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/pdf-layout.d.ts +53 -0
- package/dist/src/pdf-layout.js +201 -0
- package/dist/src/review-workbench/extraction-inspector.d.ts +10 -1
- package/dist/src/review-workbench/extraction-inspector.js +86 -15
- package/dist/src/review-workbench/review-workbench-css.generated.js +11 -1
- package/dist/src/review-workbench/review-workbench.css +11 -1
- package/dist/src/review-workbench/review-workbench.d.ts +20 -1
- package/dist/src/review-workbench/review-workbench.js +112 -6
- package/dist/src/review-workbench/review-workbench.standalone.css +11 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -212,7 +212,9 @@ The [Consumer Integration Guide](docs/consumer-integration-guide.md) covers the
|
|
|
212
212
|
Portable extraction imports can also attach a source-linked, read-only inspector
|
|
213
213
|
to the same workbench. It verifies resolved artifact identity before showing
|
|
214
214
|
exact highlights, keeps unresolved material visibly non-grounded, and exports
|
|
215
|
-
with source text and excerpts redacted by default.
|
|
215
|
+
with source text and excerpts redacted by default. Review queues and inspector
|
|
216
|
+
candidates use bounded, searchable paging so large validated result sets remain
|
|
217
|
+
navigable without changing canonical review events or exports. See
|
|
216
218
|
[Extraction Envelope Import](docs/extraction-envelope-import.md#source-linked-inspection).
|
|
217
219
|
|
|
218
220
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RawSource } from "./types.js";
|
|
2
2
|
import type { ClaimTargetHint, ReviewItem, ReviewValueType } from "./review-resource.js";
|
|
3
|
+
import { type PortablePdfLayout } from "./pdf-layout.js";
|
|
3
4
|
/** The upstream-owned portable extraction-result wire identifiers accepted by this adapter. */
|
|
4
5
|
export declare const portableExtractionResultFormat = "traverse-extraction-result";
|
|
5
6
|
export declare const portableExtractionResultVersion = 1;
|
|
@@ -96,6 +97,7 @@ export interface PortableExtractionResultEnvelope {
|
|
|
96
97
|
taskDigest?: string;
|
|
97
98
|
exampleDigests?: string[];
|
|
98
99
|
pdfPageOffsets?: number[];
|
|
100
|
+
pdfLayout?: PortablePdfLayout;
|
|
99
101
|
ocrDerived?: true;
|
|
100
102
|
preparedArtifact?: {
|
|
101
103
|
format: "traverse-prepared-artifact";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { sha256Hex } from "./sha256.js";
|
|
2
2
|
import { reviewResourceApiVersion } from "./review-resource.js";
|
|
3
3
|
import { canonicalJson } from "./review-workbench/canonical.js";
|
|
4
|
+
import { validatePortablePdfLayout } from "./pdf-layout.js";
|
|
4
5
|
/** The upstream-owned portable extraction-result wire identifiers accepted by this adapter. */
|
|
5
6
|
export const portableExtractionResultFormat = "traverse-extraction-result";
|
|
6
7
|
export const portableExtractionResultVersion = 1;
|
|
@@ -119,12 +120,14 @@ function buildReviewItem(record, proposal, index) {
|
|
|
119
120
|
}
|
|
120
121
|
function identityInputs(record, proposal, index) {
|
|
121
122
|
return { producerNamespace: record.metadata.producerNamespace, importName: record.metadata.name, source: record.spec.envelope.source,
|
|
122
|
-
preparedArtifact: record.spec.envelope.result.preparedArtifact,
|
|
123
|
+
preparedArtifact: record.spec.envelope.result.preparedArtifact, pdfLayout: record.spec.envelope.result.pdfLayout,
|
|
124
|
+
runId: record.spec.envelope.result.runId,
|
|
123
125
|
proposalIndex: index, proposal, claimTarget: record.spec.claimTargets[index] };
|
|
124
126
|
}
|
|
125
127
|
function evidenceInputs(record, proposal) {
|
|
126
128
|
return { producerNamespace: record.metadata.producerNamespace, importName: record.metadata.name, source: record.spec.envelope.source,
|
|
127
129
|
sourceKind: record.spec.sourceKind, preparedArtifact: record.spec.envelope.result.preparedArtifact,
|
|
130
|
+
pdfLayout: record.spec.envelope.result.pdfLayout,
|
|
128
131
|
extractedAt: record.spec.envelope.result.extractedAt,
|
|
129
132
|
provenance: proposal.provenance };
|
|
130
133
|
}
|
|
@@ -182,7 +185,7 @@ function validateEnvelope(input) {
|
|
|
182
185
|
if (source.snapshotRef !== undefined)
|
|
183
186
|
safeReference(source.snapshotRef, "source.snapshotRef");
|
|
184
187
|
const r = obj(e.result, "result");
|
|
185
|
-
exact(r, ["proposals", "provider", "runId", "raw", "outcome", "extractedAt", "providerCalls", "totalTokensUsed"], "result", ["model", "warningClassifications", "partial", "providerFailures", "taskDigest", "exampleDigests", "pdfPageOffsets", "ocrDerived", "preparedArtifact", "preparedArtifactState"]);
|
|
188
|
+
exact(r, ["proposals", "provider", "runId", "raw", "outcome", "extractedAt", "providerCalls", "totalTokensUsed"], "result", ["model", "warningClassifications", "partial", "providerFailures", "taskDigest", "exampleDigests", "pdfPageOffsets", "pdfLayout", "ocrDerived", "preparedArtifact", "preparedArtifactState"]);
|
|
186
189
|
stableIdentity(r.provider, "result.provider");
|
|
187
190
|
if (r.model !== undefined)
|
|
188
191
|
stableIdentity(r.model, "result.model");
|
|
@@ -212,11 +215,16 @@ function validateEnvelope(input) {
|
|
|
212
215
|
if (r.ocrDerived !== undefined && r.ocrDerived !== true)
|
|
213
216
|
throw new Error("result.ocrDerived must be true.");
|
|
214
217
|
const artifact = r.preparedArtifact === undefined ? undefined : validateArtifact(r.preparedArtifact);
|
|
218
|
+
const pdfLayout = r.pdfLayout === undefined
|
|
219
|
+
? undefined
|
|
220
|
+
: artifact === undefined
|
|
221
|
+
? (() => { throw new Error("result.pdfLayout requires result.preparedArtifact."); })()
|
|
222
|
+
: validatePortablePdfLayout(r.pdfLayout, artifact.contentLength);
|
|
215
223
|
const state = r.preparedArtifactState === undefined ? undefined : validateArtifactState(r.preparedArtifactState, artifact);
|
|
216
224
|
if (source.snapshotRef !== undefined && artifact?.sourceSnapshotRef !== undefined && source.snapshotRef !== artifact.sourceSnapshotRef)
|
|
217
225
|
throw new Error("Source snapshot identity mismatch.");
|
|
218
226
|
const proposals = array(r.proposals, "result.proposals").map((p, i) => validateProposal(p, i, artifact?.contentLength));
|
|
219
|
-
return cloneJson({ ...e, source, result: { ...r, proposals, ...(artifact ? { preparedArtifact: artifact } : {}), ...(state ? { preparedArtifactState: state } : {}) } });
|
|
227
|
+
return cloneJson({ ...e, source, result: { ...r, proposals, ...(pdfLayout ? { pdfLayout } : {}), ...(artifact ? { preparedArtifact: artifact } : {}), ...(state ? { preparedArtifactState: state } : {}) } });
|
|
220
228
|
}
|
|
221
229
|
function validateProposal(input, index, contentLength) {
|
|
222
230
|
const p = obj(input, `proposal[${index}]`);
|
package/dist/src/index.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
|
|
|
3
3
|
export { reviewResourceApiVersion } from "./review-resource.js";
|
|
4
4
|
export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
|
|
5
5
|
export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
|
|
6
|
+
export { resolvePortablePdfRegion } from "./pdf-layout.js";
|
|
7
|
+
export type { PortablePdfBoundingBox, PortablePdfLayout, PortablePdfPageGeometry, PortablePdfRegionContext, PortablePdfTable, PortablePdfTableCell, PortablePdfTextElement, PortablePdfTextRange, } from "./pdf-layout.js";
|
|
6
8
|
export type { ExtractionAlignmentState, ArtifactUnavailableCode, ExtractionInspectorCandidate, ExtractionInspectorEntry, ExtractionInspectorExportOptions, ExtractionInspectorFilters, ExtractionInspectorInput, ExtractionInspectorModel, ExtractionInspectorSource, ResolvedExtractionArtifact, } from "./review-workbench/extraction-inspector.js";
|
|
7
9
|
export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, ExtractionEnvelopeImportResult, ExtractionEnvelopeResolutionIdentity, PortableExtractionOccurrence, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
|
|
8
10
|
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";
|
package/dist/src/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
|
|
|
2
2
|
export { reviewResourceApiVersion } from "./review-resource.js";
|
|
3
3
|
export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
|
|
4
4
|
export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
|
|
5
|
+
export { resolvePortablePdfRegion } from "./pdf-layout.js";
|
|
5
6
|
export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
|
|
6
7
|
export { reviewedCandidateResolution } from "./reviewed-candidate-resolution.js";
|
|
7
8
|
export { reviewedCurrentProposedResolution } from "./reviewed-current-proposed-resolution.js";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface PortablePdfTextRange {
|
|
2
|
+
start: number;
|
|
3
|
+
end: number;
|
|
4
|
+
}
|
|
5
|
+
export interface PortablePdfBoundingBox {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
}
|
|
11
|
+
export interface PortablePdfPageGeometry {
|
|
12
|
+
pageNumber: number;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
unit: "points" | "pixels" | "normalized";
|
|
16
|
+
rotation?: 0 | 90 | 180 | 270;
|
|
17
|
+
}
|
|
18
|
+
export interface PortablePdfTextElement {
|
|
19
|
+
kind: "heading" | "paragraph" | "list" | "table" | "table-cell" | "figure" | "other";
|
|
20
|
+
providerType?: string;
|
|
21
|
+
pageNumber: number;
|
|
22
|
+
range: PortablePdfTextRange;
|
|
23
|
+
bounds?: PortablePdfBoundingBox;
|
|
24
|
+
}
|
|
25
|
+
export interface PortablePdfTableCell {
|
|
26
|
+
rowIndex: number;
|
|
27
|
+
columnIndex: number;
|
|
28
|
+
rowSpan?: number;
|
|
29
|
+
columnSpan?: number;
|
|
30
|
+
range: PortablePdfTextRange;
|
|
31
|
+
bounds?: PortablePdfBoundingBox;
|
|
32
|
+
}
|
|
33
|
+
export interface PortablePdfTable {
|
|
34
|
+
pageNumber: number;
|
|
35
|
+
bounds?: PortablePdfBoundingBox;
|
|
36
|
+
cells: PortablePdfTableCell[];
|
|
37
|
+
}
|
|
38
|
+
export interface PortablePdfLayout {
|
|
39
|
+
pages?: PortablePdfPageGeometry[];
|
|
40
|
+
elements: PortablePdfTextElement[];
|
|
41
|
+
tables?: PortablePdfTable[];
|
|
42
|
+
}
|
|
43
|
+
export interface PortablePdfRegionContext {
|
|
44
|
+
pages: number[];
|
|
45
|
+
elements: PortablePdfTextElement[];
|
|
46
|
+
tableCells: Array<{
|
|
47
|
+
pageNumber: number;
|
|
48
|
+
tableIndex: number;
|
|
49
|
+
cell: PortablePdfTableCell;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
52
|
+
export declare function validatePortablePdfLayout(value: unknown, textLength: number): PortablePdfLayout;
|
|
53
|
+
export declare function resolvePortablePdfRegion(layout: PortablePdfLayout, locator: string): PortablePdfRegionContext | undefined;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
const MAX_LAYOUT_ITEMS = 10_000;
|
|
2
|
+
const COORDINATE_UNITS = new Set(["points", "pixels", "normalized"]);
|
|
3
|
+
const ROTATIONS = new Set([0, 90, 180, 270]);
|
|
4
|
+
const ELEMENT_KINDS = new Set(["heading", "paragraph", "list", "table", "table-cell", "figure", "other"]);
|
|
5
|
+
export function validatePortablePdfLayout(value, textLength) {
|
|
6
|
+
if (!Number.isSafeInteger(textLength) || textLength < 0)
|
|
7
|
+
throw new Error("PDF layout text length is invalid.");
|
|
8
|
+
const layout = record(value, "result.pdfLayout");
|
|
9
|
+
exact(layout, ["elements"], ["pages", "tables"], "result.pdfLayout");
|
|
10
|
+
const rawPages = optionalArray(layout.pages, "result.pdfLayout.pages");
|
|
11
|
+
const rawElements = boundedArray(layout.elements, "result.pdfLayout.elements");
|
|
12
|
+
const rawTables = optionalArray(layout.tables, "result.pdfLayout.tables");
|
|
13
|
+
const pages = rawPages?.map((value, index) => validatePage(value, `result.pdfLayout.pages[${index}]`));
|
|
14
|
+
const pageByNumber = new Map();
|
|
15
|
+
for (const page of pages ?? []) {
|
|
16
|
+
if (pageByNumber.has(page.pageNumber))
|
|
17
|
+
throw new Error("PDF layout page numbers must be unique.");
|
|
18
|
+
pageByNumber.set(page.pageNumber, page);
|
|
19
|
+
}
|
|
20
|
+
const pageExists = (pageNumber) => pageByNumber.size === 0 || pageByNumber.has(pageNumber);
|
|
21
|
+
const elements = rawElements.map((value, index) => validateElement(value, `result.pdfLayout.elements[${index}]`, textLength, pageByNumber, pageExists));
|
|
22
|
+
const tables = rawTables?.map((value, index) => validateTable(value, `result.pdfLayout.tables[${index}]`, textLength, pageByNumber, pageExists));
|
|
23
|
+
pages?.sort((left, right) => left.pageNumber - right.pageNumber);
|
|
24
|
+
elements.sort((left, right) => left.range.start - right.range.start ||
|
|
25
|
+
left.range.end - right.range.end ||
|
|
26
|
+
left.pageNumber - right.pageNumber ||
|
|
27
|
+
left.kind.localeCompare(right.kind));
|
|
28
|
+
tables?.sort((left, right) => left.pageNumber - right.pageNumber ||
|
|
29
|
+
(left.cells[0]?.range.start ?? 0) - (right.cells[0]?.range.start ?? 0));
|
|
30
|
+
return {
|
|
31
|
+
...(pages ? { pages } : {}),
|
|
32
|
+
elements,
|
|
33
|
+
...(tables ? { tables } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function resolvePortablePdfRegion(layout, locator) {
|
|
37
|
+
const match = /^chars:(0|[1-9]\d*)-(0|[1-9]\d*)$/.exec(locator);
|
|
38
|
+
if (!match)
|
|
39
|
+
return undefined;
|
|
40
|
+
const span = { start: Number(match[1]), end: Number(match[2]) };
|
|
41
|
+
if (!Number.isSafeInteger(span.start) || !Number.isSafeInteger(span.end) || span.end <= span.start)
|
|
42
|
+
return undefined;
|
|
43
|
+
const elements = layout.elements.filter((element) => overlaps(element.range, span));
|
|
44
|
+
const tableCells = (layout.tables ?? []).flatMap((table, tableIndex) => table.cells
|
|
45
|
+
.filter((cell) => overlaps(cell.range, span))
|
|
46
|
+
.map((cell) => ({ pageNumber: table.pageNumber, tableIndex, cell })));
|
|
47
|
+
const pages = [...new Set([
|
|
48
|
+
...elements.map((element) => element.pageNumber),
|
|
49
|
+
...tableCells.map((entry) => entry.pageNumber),
|
|
50
|
+
])].sort((left, right) => left - right);
|
|
51
|
+
return { pages, elements, tableCells };
|
|
52
|
+
}
|
|
53
|
+
function validatePage(value, subject) {
|
|
54
|
+
const page = record(value, subject);
|
|
55
|
+
exact(page, ["pageNumber", "width", "height", "unit"], ["rotation"], subject);
|
|
56
|
+
positiveInteger(page.pageNumber, `${subject}.pageNumber`);
|
|
57
|
+
positiveFinite(page.width, `${subject}.width`);
|
|
58
|
+
positiveFinite(page.height, `${subject}.height`);
|
|
59
|
+
if (!COORDINATE_UNITS.has(page.unit))
|
|
60
|
+
throw new Error(`${subject}.unit is invalid.`);
|
|
61
|
+
if (page.rotation !== undefined && !ROTATIONS.has(page.rotation))
|
|
62
|
+
throw new Error(`${subject}.rotation is invalid.`);
|
|
63
|
+
return {
|
|
64
|
+
pageNumber: page.pageNumber,
|
|
65
|
+
width: page.width,
|
|
66
|
+
height: page.height,
|
|
67
|
+
unit: page.unit,
|
|
68
|
+
...(page.rotation === undefined ? {} : { rotation: page.rotation }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function validateElement(value, subject, textLength, pages, pageExists) {
|
|
72
|
+
const element = record(value, subject);
|
|
73
|
+
exact(element, ["kind", "pageNumber", "range"], ["providerType", "bounds"], subject);
|
|
74
|
+
if (!ELEMENT_KINDS.has(element.kind))
|
|
75
|
+
throw new Error(`${subject}.kind is invalid.`);
|
|
76
|
+
positiveInteger(element.pageNumber, `${subject}.pageNumber`);
|
|
77
|
+
if (!pageExists(element.pageNumber))
|
|
78
|
+
throw new Error(`${subject}.pageNumber is not declared.`);
|
|
79
|
+
if (element.providerType !== undefined && typeof element.providerType !== "string")
|
|
80
|
+
throw new Error(`${subject}.providerType is invalid.`);
|
|
81
|
+
return {
|
|
82
|
+
kind: element.kind,
|
|
83
|
+
...(element.providerType === undefined ? {} : { providerType: element.providerType }),
|
|
84
|
+
pageNumber: element.pageNumber,
|
|
85
|
+
range: validateRange(element.range, `${subject}.range`, textLength),
|
|
86
|
+
...(element.bounds === undefined ? {} : {
|
|
87
|
+
bounds: validateBounds(element.bounds, `${subject}.bounds`, pages.get(element.pageNumber)),
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function validateTable(value, subject, textLength, pages, pageExists) {
|
|
92
|
+
const table = record(value, subject);
|
|
93
|
+
exact(table, ["pageNumber", "cells"], ["bounds"], subject);
|
|
94
|
+
positiveInteger(table.pageNumber, `${subject}.pageNumber`);
|
|
95
|
+
if (!pageExists(table.pageNumber))
|
|
96
|
+
throw new Error(`${subject}.pageNumber is not declared.`);
|
|
97
|
+
const occupied = new Set();
|
|
98
|
+
const cells = boundedArray(table.cells, `${subject}.cells`).map((value, index) => {
|
|
99
|
+
const cellSubject = `${subject}.cells[${index}]`;
|
|
100
|
+
const cell = record(value, cellSubject);
|
|
101
|
+
exact(cell, ["rowIndex", "columnIndex", "range"], ["rowSpan", "columnSpan", "bounds"], cellSubject);
|
|
102
|
+
nonnegativeInteger(cell.rowIndex, `${cellSubject}.rowIndex`);
|
|
103
|
+
nonnegativeInteger(cell.columnIndex, `${cellSubject}.columnIndex`);
|
|
104
|
+
if (cell.rowSpan !== undefined)
|
|
105
|
+
positiveInteger(cell.rowSpan, `${cellSubject}.rowSpan`);
|
|
106
|
+
if (cell.columnSpan !== undefined)
|
|
107
|
+
positiveInteger(cell.columnSpan, `${cellSubject}.columnSpan`);
|
|
108
|
+
const key = `${cell.rowIndex}:${cell.columnIndex}`;
|
|
109
|
+
if (occupied.has(key))
|
|
110
|
+
throw new Error(`${subject} contains duplicate table coordinates.`);
|
|
111
|
+
occupied.add(key);
|
|
112
|
+
return {
|
|
113
|
+
rowIndex: cell.rowIndex,
|
|
114
|
+
columnIndex: cell.columnIndex,
|
|
115
|
+
...(cell.rowSpan === undefined ? {} : { rowSpan: cell.rowSpan }),
|
|
116
|
+
...(cell.columnSpan === undefined ? {} : { columnSpan: cell.columnSpan }),
|
|
117
|
+
range: validateRange(cell.range, `${cellSubject}.range`, textLength),
|
|
118
|
+
...(cell.bounds === undefined ? {} : {
|
|
119
|
+
bounds: validateBounds(cell.bounds, `${cellSubject}.bounds`, pages.get(table.pageNumber)),
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
cells.sort((left, right) => left.rowIndex - right.rowIndex ||
|
|
124
|
+
left.columnIndex - right.columnIndex ||
|
|
125
|
+
left.range.start - right.range.start);
|
|
126
|
+
return {
|
|
127
|
+
pageNumber: table.pageNumber,
|
|
128
|
+
...(table.bounds === undefined ? {} : {
|
|
129
|
+
bounds: validateBounds(table.bounds, `${subject}.bounds`, pages.get(table.pageNumber)),
|
|
130
|
+
}),
|
|
131
|
+
cells,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function validateRange(value, subject, textLength) {
|
|
135
|
+
const range = record(value, subject);
|
|
136
|
+
exact(range, ["start", "end"], [], subject);
|
|
137
|
+
nonnegativeInteger(range.start, `${subject}.start`);
|
|
138
|
+
nonnegativeInteger(range.end, `${subject}.end`);
|
|
139
|
+
if (range.end <= range.start || range.end > textLength) {
|
|
140
|
+
throw new Error(`${subject} is outside the prepared text.`);
|
|
141
|
+
}
|
|
142
|
+
return { start: range.start, end: range.end };
|
|
143
|
+
}
|
|
144
|
+
function validateBounds(value, subject, page) {
|
|
145
|
+
const bounds = record(value, subject);
|
|
146
|
+
exact(bounds, ["x", "y", "width", "height"], [], subject);
|
|
147
|
+
nonnegativeFinite(bounds.x, `${subject}.x`);
|
|
148
|
+
nonnegativeFinite(bounds.y, `${subject}.y`);
|
|
149
|
+
positiveFinite(bounds.width, `${subject}.width`);
|
|
150
|
+
positiveFinite(bounds.height, `${subject}.height`);
|
|
151
|
+
const result = {
|
|
152
|
+
x: bounds.x,
|
|
153
|
+
y: bounds.y,
|
|
154
|
+
width: bounds.width,
|
|
155
|
+
height: bounds.height,
|
|
156
|
+
};
|
|
157
|
+
if (page && (result.x + result.width > page.width || result.y + result.height > page.height)) {
|
|
158
|
+
throw new Error(`${subject} exceeds its page geometry.`);
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
function boundedArray(value, subject) {
|
|
163
|
+
if (!Array.isArray(value) || value.length > MAX_LAYOUT_ITEMS)
|
|
164
|
+
throw new Error(`${subject} must be a bounded array.`);
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
function optionalArray(value, subject) {
|
|
168
|
+
return value === undefined ? undefined : boundedArray(value, subject);
|
|
169
|
+
}
|
|
170
|
+
function record(value, subject) {
|
|
171
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
172
|
+
throw new Error(`${subject} must be an object.`);
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
function exact(value, required, optional, subject) {
|
|
176
|
+
for (const key of required)
|
|
177
|
+
if (!Object.hasOwn(value, key))
|
|
178
|
+
throw new Error(`${subject}.${key} is required.`);
|
|
179
|
+
for (const key of Object.keys(value))
|
|
180
|
+
if (![...required, ...optional].includes(key))
|
|
181
|
+
throw new Error(`${subject}.${key} is unexpected.`);
|
|
182
|
+
}
|
|
183
|
+
function nonnegativeInteger(value, subject) {
|
|
184
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
185
|
+
throw new Error(`${subject} must be a non-negative safe integer.`);
|
|
186
|
+
}
|
|
187
|
+
function positiveInteger(value, subject) {
|
|
188
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
189
|
+
throw new Error(`${subject} must be a positive safe integer.`);
|
|
190
|
+
}
|
|
191
|
+
function nonnegativeFinite(value, subject) {
|
|
192
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
|
|
193
|
+
throw new Error(`${subject} must be a non-negative finite number.`);
|
|
194
|
+
}
|
|
195
|
+
function positiveFinite(value, subject) {
|
|
196
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
|
197
|
+
throw new Error(`${subject} must be a positive finite number.`);
|
|
198
|
+
}
|
|
199
|
+
function overlaps(left, right) {
|
|
200
|
+
return left.start < right.end && right.start < left.end;
|
|
201
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
|
|
2
|
+
import { type PortablePdfRegionContext } from "../pdf-layout.js";
|
|
2
3
|
export type ExtractionAlignmentState = "aligned" | "excerpt-mismatch" | "artifact-unavailable" | "digest-mismatch";
|
|
3
4
|
export type ArtifactUnavailableCode = "not-found" | "storage-error" | "access-denied" | "invalid-artifact" | "unknown";
|
|
4
5
|
export type ResolvedExtractionArtifact = {
|
|
@@ -38,6 +39,8 @@ export interface ExtractionInspectorCandidate {
|
|
|
38
39
|
end: number;
|
|
39
40
|
excerpt: string;
|
|
40
41
|
alignment: ExtractionAlignmentState;
|
|
42
|
+
pdfRegion?: PortablePdfRegionContext;
|
|
43
|
+
ocrDerived?: true;
|
|
41
44
|
}
|
|
42
45
|
export interface ExtractionInspectorSource {
|
|
43
46
|
key: string;
|
|
@@ -46,6 +49,7 @@ export interface ExtractionInspectorSource {
|
|
|
46
49
|
expectedDigest?: string;
|
|
47
50
|
actualDigest?: string;
|
|
48
51
|
artifactText?: string;
|
|
52
|
+
ocrDerived?: true;
|
|
49
53
|
alignment: ExtractionAlignmentState;
|
|
50
54
|
message: string;
|
|
51
55
|
}
|
|
@@ -61,11 +65,16 @@ export interface ExtractionInspectorFilters {
|
|
|
61
65
|
pass?: string;
|
|
62
66
|
inferenceType?: "explicit" | "inferred";
|
|
63
67
|
alignment?: ExtractionAlignmentState;
|
|
68
|
+
query?: string;
|
|
64
69
|
}
|
|
65
70
|
export interface ExtractionInspectorExportOptions {
|
|
66
71
|
includePreparedText?: boolean;
|
|
67
72
|
includeExcerpts?: boolean;
|
|
68
73
|
}
|
|
74
|
+
export interface ExtractionInspectorMountOptions {
|
|
75
|
+
/** Maximum candidate rows and source highlights mounted at once. Defaults to 100 and is capped at 500. */
|
|
76
|
+
pageSize?: number;
|
|
77
|
+
}
|
|
69
78
|
/**
|
|
70
79
|
* Build a read-only view from one or more results already produced by Survey's
|
|
71
80
|
* public import boundary. The function rechecks the result/ReviewItem binding
|
|
@@ -74,4 +83,4 @@ export interface ExtractionInspectorExportOptions {
|
|
|
74
83
|
export declare function buildExtractionInspectorModel(input: ExtractionInspectorInput): ExtractionInspectorModel;
|
|
75
84
|
export declare function filterExtractionInspectorCandidates(model: ExtractionInspectorModel, filters: ExtractionInspectorFilters): ExtractionInspectorCandidate[];
|
|
76
85
|
export declare function exportExtractionInspector(model: ExtractionInspectorModel, options?: ExtractionInspectorExportOptions): string;
|
|
77
|
-
export declare function mountExtractionInspector(container: HTMLElement, model: ExtractionInspectorModel): () => void;
|
|
86
|
+
export declare function mountExtractionInspector(container: HTMLElement, model: ExtractionInspectorModel, options?: ExtractionInspectorMountOptions): () => void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildReviewItemsFromExtractionEnvelopeImport, validateExtractionEnvelopeImport } from "../extraction-envelope.js";
|
|
2
2
|
import { canonicalJson } from "./canonical.js";
|
|
3
3
|
import { sha256Hex } from "../sha256.js";
|
|
4
|
+
import { resolvePortablePdfRegion, } from "../pdf-layout.js";
|
|
4
5
|
/**
|
|
5
6
|
* Build a read-only view from one or more results already produced by Survey's
|
|
6
7
|
* public import boundary. The function rechecks the result/ReviewItem binding
|
|
@@ -25,14 +26,14 @@ export function buildExtractionInspectorModel(input) {
|
|
|
25
26
|
if (sourceKeys.has(sourceKey))
|
|
26
27
|
throw new Error("Extraction inspector source identity collision.");
|
|
27
28
|
sourceKeys.add(sourceKey);
|
|
28
|
-
const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.state, entry.artifact);
|
|
29
|
+
const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.state, entry.artifact, envelope.result.ocrDerived);
|
|
29
30
|
sources.push(source);
|
|
30
31
|
const candidateStart = candidates.length;
|
|
31
32
|
envelope.result.proposals.forEach((proposal, proposalIndex) => {
|
|
32
33
|
const item = entry.importResult.reviewItems[proposalIndex];
|
|
33
34
|
if (!item)
|
|
34
35
|
return; // unresolved imports legitimately produce no ReviewItems
|
|
35
|
-
candidates.push(candidateModel(source, item.metadata.name, proposal, proposalIndex, envelope.result.provider, envelope.result.model, envelope.result.runId, entry.pass));
|
|
36
|
+
candidates.push(candidateModel(source, item.metadata.name, proposal, proposalIndex, envelope.result.provider, envelope.result.model, envelope.result.runId, entry.pass, envelope.result.pdfPageOffsets, envelope.result.pdfLayout, envelope.result.ocrDerived));
|
|
36
37
|
});
|
|
37
38
|
if (source.alignment === "excerpt-mismatch") {
|
|
38
39
|
delete source.artifactText;
|
|
@@ -88,7 +89,7 @@ function assertImportedResult(result, record) {
|
|
|
88
89
|
}
|
|
89
90
|
});
|
|
90
91
|
}
|
|
91
|
-
function sourceModel(key, importName, prepared, state, artifact) {
|
|
92
|
+
function sourceModel(key, importName, prepared, state, artifact, ocrDerived) {
|
|
92
93
|
let alignment;
|
|
93
94
|
let message;
|
|
94
95
|
if (state !== "grounded" || artifact.status === "unavailable") {
|
|
@@ -106,11 +107,11 @@ function sourceModel(key, importName, prepared, state, artifact) {
|
|
|
106
107
|
}
|
|
107
108
|
else {
|
|
108
109
|
alignment = "aligned";
|
|
109
|
-
message =
|
|
110
|
+
message = `Prepared artifact identity verified. Exact source spans are available.${ocrDerived ? " Prepared text is OCR-derived." : ""}`;
|
|
110
111
|
}
|
|
111
|
-
return { key, importName, ...(prepared?.ref ? { artifactRef: prepared.ref } : {}), ...(prepared?.digest ? { expectedDigest: prepared.digest } : {}), ...("actualDigest" in artifact ? { actualDigest: artifact.actualDigest } : {}), ...(alignment === "aligned" && artifact.status === "available" ? { artifactText: artifact.text } : {}), alignment, message };
|
|
112
|
+
return { key, importName, ...(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 };
|
|
112
113
|
}
|
|
113
|
-
function candidateModel(source, reviewItemName, proposal, index, provider, model, attempt, pass) {
|
|
114
|
+
function candidateModel(source, reviewItemName, proposal, index, provider, model, attempt, pass, pdfPageOffsets, pdfLayout, ocrDerived) {
|
|
114
115
|
const match = /^chars:(\d+)-(\d+)$/.exec(proposal.provenance.locator);
|
|
115
116
|
if (!match)
|
|
116
117
|
throw new Error(`Extraction proposal ${index} has an invalid text span.`);
|
|
@@ -122,13 +123,42 @@ function candidateModel(source, reviewItemName, proposal, index, provider, model
|
|
|
122
123
|
source.alignment = alignment;
|
|
123
124
|
source.message = "One or more source spans do not match their recorded excerpts. Affected candidates are not grounded.";
|
|
124
125
|
}
|
|
125
|
-
|
|
126
|
+
let pdfRegion = pdfLayout ? resolvePortablePdfRegion(pdfLayout, proposal.provenance.locator) : undefined;
|
|
127
|
+
const page = resolvePdfPage(pdfPageOffsets, start);
|
|
128
|
+
if (page !== undefined) {
|
|
129
|
+
pdfRegion = {
|
|
130
|
+
pages: [...new Set([...(pdfRegion?.pages ?? []), page])].sort((left, right) => left - right),
|
|
131
|
+
elements: pdfRegion?.elements ?? [],
|
|
132
|
+
tableCells: pdfRegion?.tableCells ?? [],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { id: `${source.key}:proposal:${index}`, sourceKey: source.key, reviewItemName, proposalIndex: index, field: proposal.fieldPath, provider, ...(model ? { model } : {}), attempt, ...(pass ? { pass } : {}), valueType: proposal.valueType ?? inferValueType(proposal.candidateValue), inferenceType: proposal.inferenceType ?? "inferred", start, end, excerpt: proposal.provenance.excerpt, alignment, ...(pdfRegion ? { pdfRegion } : {}), ...(ocrDerived ? { ocrDerived: true } : {}) };
|
|
126
136
|
}
|
|
127
137
|
function inferValueType(value) { if (Array.isArray(value))
|
|
128
138
|
return "array"; if (value !== null && typeof value === "object")
|
|
129
139
|
return "object"; return typeof value === "number" ? "number" : typeof value === "boolean" ? "boolean" : "string"; }
|
|
140
|
+
function resolvePdfPage(offsets, start) {
|
|
141
|
+
if (!offsets || offsets.length === 0)
|
|
142
|
+
return undefined;
|
|
143
|
+
let page;
|
|
144
|
+
for (let index = 0; index < offsets.length; index += 1) {
|
|
145
|
+
if (offsets[index] > start)
|
|
146
|
+
break;
|
|
147
|
+
page = index + 1;
|
|
148
|
+
}
|
|
149
|
+
return page;
|
|
150
|
+
}
|
|
130
151
|
export function filterExtractionInspectorCandidates(model, filters) {
|
|
131
|
-
|
|
152
|
+
const query = filters.query?.trim().toLocaleLowerCase();
|
|
153
|
+
return model.candidates.filter((c) => (!filters.field || c.field === filters.field)
|
|
154
|
+
&& (!filters.provider || c.provider === filters.provider)
|
|
155
|
+
&& (!filters.model || c.model === filters.model)
|
|
156
|
+
&& (!filters.attempt || c.attempt === filters.attempt)
|
|
157
|
+
&& (!filters.pass || c.pass === filters.pass)
|
|
158
|
+
&& (!filters.inferenceType || c.inferenceType === filters.inferenceType)
|
|
159
|
+
&& (!filters.alignment || c.alignment === filters.alignment)
|
|
160
|
+
&& (!query || [c.field, c.excerpt, c.reviewItemName, c.provider, c.model ?? "", c.pass ?? ""]
|
|
161
|
+
.some(value => value.toLocaleLowerCase().includes(query))));
|
|
132
162
|
}
|
|
133
163
|
export function exportExtractionInspector(model, options = {}) {
|
|
134
164
|
return canonicalJson({ apiVersion: "survey.kontourai.io/v1alpha1", kind: "ExtractionInspectorExport", spec: {
|
|
@@ -137,25 +167,45 @@ export function exportExtractionInspector(model, options = {}) {
|
|
|
137
167
|
candidates: model.candidates.map((candidate) => ({ ...candidate, excerpt: options.includeExcerpts ? candidate.excerpt : "[redacted]" })),
|
|
138
168
|
} });
|
|
139
169
|
}
|
|
140
|
-
export function mountExtractionInspector(container, model) {
|
|
170
|
+
export function mountExtractionInspector(container, model, options = {}) {
|
|
171
|
+
const pageSize = Number.isSafeInteger(options.pageSize) && Number(options.pageSize) > 0
|
|
172
|
+
? Math.min(Number(options.pageSize), 500)
|
|
173
|
+
: 100;
|
|
174
|
+
let page = 0;
|
|
141
175
|
const root = document.createElement("section");
|
|
142
176
|
root.className = "extraction-inspector";
|
|
143
177
|
root.setAttribute("aria-label", "Source-linked extraction inspector");
|
|
144
|
-
const
|
|
145
|
-
root.innerHTML = `<div class="inspector-heading"><div><p class="eyebrow">Source inspector</p><h2>Extraction evidence</h2></div><div class="inspector-postures" aria-live="polite"></div></div><div class="inspector-filters" aria-label="Extraction filters">${filterSelect("field", "Field",
|
|
178
|
+
const choiceOptions = (values) => [...new Set(values.filter((v) => Boolean(v)))].map((v) => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join("");
|
|
179
|
+
root.innerHTML = `<div class="inspector-heading"><div><p class="eyebrow">Source inspector</p><h2>Extraction evidence</h2></div><div class="inspector-postures" aria-live="polite"></div></div><div class="inspector-filters" aria-label="Extraction filters"><label>Find candidates<input type="search" data-filter="query" placeholder="Field or excerpt"></label>${filterSelect("field", "Field", choiceOptions(model.candidates.map(c => c.field)))}${filterSelect("provider", "Provider", choiceOptions(model.candidates.map(c => c.provider)))}${filterSelect("model", "Model", choiceOptions(model.candidates.map(c => c.model)))}${filterSelect("attempt", "Attempt", choiceOptions(model.candidates.map(c => c.attempt)))}${filterSelect("pass", "Pass", choiceOptions(model.candidates.map(c => c.pass)))}${filterSelect("inferenceType", "Type origin", '<option value="explicit">explicit</option><option value="inferred">inferred</option>')}${filterSelect("alignment", "Alignment", choiceOptions(model.candidates.map(c => c.alignment)))}</div><nav class="inspector-pager" aria-label="Extraction candidate navigation"><span class="inspector-result-count" aria-live="polite"></span><button type="button" data-page="previous">Previous</button><span class="inspector-page"></span><button type="button" data-page="next">Next</button></nav><div class="inspector-layout"><ol class="inspector-candidates" aria-label="Extraction candidates"></ol><div class="inspector-sources"></div></div>`;
|
|
146
180
|
container.appendChild(root);
|
|
147
181
|
const list = root.querySelector("ol"), sourcesRoot = root.querySelector(".inspector-sources"), postures = root.querySelector(".inspector-postures");
|
|
182
|
+
const resultCount = root.querySelector(".inspector-result-count"), pageLabel = root.querySelector(".inspector-page");
|
|
183
|
+
const previous = root.querySelector('[data-page="previous"]'), next = root.querySelector('[data-page="next"]');
|
|
148
184
|
const filters = {};
|
|
149
185
|
const render = () => {
|
|
150
|
-
const
|
|
151
|
-
|
|
186
|
+
const matching = filterExtractionInspectorCandidates(model, filters);
|
|
187
|
+
const pageCount = Math.max(1, Math.ceil(matching.length / pageSize));
|
|
188
|
+
page = Math.min(page, pageCount - 1);
|
|
189
|
+
const start = page * pageSize;
|
|
190
|
+
const visible = matching.slice(start, start + pageSize);
|
|
191
|
+
list.innerHTML = visible.map(c => `<li><button type="button" class="inspector-candidate" id="candidate-${safeId(c.id)}" data-candidate-id="${escapeHtml(c.id)}" aria-controls="highlight-${safeId(c.id)}"><strong>${escapeHtml(c.field)}</strong><span>${escapeHtml(c.provider)}${c.model ? ` / ${escapeHtml(c.model)}` : ""}</span><span>${escapeHtml(c.inferenceType)} ${escapeHtml(c.valueType)} · ${escapeHtml(c.alignment)}</span>${formatContext(c)}</button></li>`).join("") || "<li>No candidates match these filters.</li>";
|
|
192
|
+
resultCount.textContent = matching.length === 0 ? "No matching candidates" : `${start + 1}–${Math.min(start + pageSize, matching.length)} of ${matching.length}`;
|
|
193
|
+
pageLabel.textContent = `Page ${page + 1} of ${pageCount}`;
|
|
194
|
+
previous.disabled = page === 0;
|
|
195
|
+
next.disabled = page >= pageCount - 1;
|
|
152
196
|
postures.innerHTML = model.sources.map(s => `<div class="inspector-posture ${s.alignment}" role="status"><strong>${escapeHtml(s.importName)}: ${escapeHtml(s.alignment)}</strong><span>${escapeHtml(s.message)}</span></div>`).join("");
|
|
153
197
|
sourcesRoot.innerHTML = model.sources.map(s => { const candidates = 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 ? `<span class="source-unavailable">${escapeHtml(s.message)}</span>` : renderSource(s.artifactText, candidates)}</pre></div>`; }).join("");
|
|
154
198
|
};
|
|
155
199
|
root.querySelectorAll("select").forEach(select => select.addEventListener("change", event => { event.stopPropagation(); const key = select.dataset.filter; if (select.value)
|
|
156
200
|
filters[key] = select.value;
|
|
157
201
|
else
|
|
158
|
-
delete filters[key]; render(); }));
|
|
202
|
+
delete filters[key]; page = 0; render(); }));
|
|
203
|
+
root.querySelector('input[data-filter="query"]')?.addEventListener("input", event => { event.stopPropagation(); const input = event.currentTarget; if (input.value)
|
|
204
|
+
filters.query = input.value;
|
|
205
|
+
else
|
|
206
|
+
delete filters.query; page = 0; render(); });
|
|
207
|
+
previous.addEventListener("click", () => { page = Math.max(0, page - 1); render(); list.querySelector("button")?.focus(); });
|
|
208
|
+
next.addEventListener("click", () => { page += 1; render(); list.querySelector("button")?.focus(); });
|
|
159
209
|
const activateCandidate = (id) => { const candidate = model.candidates.find(c => c.id === id); if (!candidate)
|
|
160
210
|
return; root.dispatchEvent(new CustomEvent("survey-extraction-candidate-activate", { bubbles: true, composed: true, detail: { candidateId: id, reviewItemName: candidate.reviewItemName } })); };
|
|
161
211
|
root.addEventListener("click", event => { const candidate = event.target.closest("button[data-candidate-id]"); if (candidate) {
|
|
@@ -168,10 +218,17 @@ export function mountExtractionInspector(container, model) {
|
|
|
168
218
|
event.stopPropagation();
|
|
169
219
|
root.querySelector(`#candidate-${CSS.escape(safeId(highlight.dataset.highlightCandidateId))}`)?.focus();
|
|
170
220
|
} });
|
|
171
|
-
root.addEventListener("keydown", event => { const button = event.target.closest("button[data-candidate-id]"); if (button
|
|
221
|
+
root.addEventListener("keydown", event => { const button = event.target.closest("button[data-candidate-id]"); if (!button)
|
|
222
|
+
return; if (event.key === "Enter" || event.key === " ") {
|
|
172
223
|
event.preventDefault();
|
|
173
224
|
event.stopPropagation();
|
|
174
225
|
activateCandidate(button.dataset.candidateId);
|
|
226
|
+
return;
|
|
227
|
+
} if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
228
|
+
event.preventDefault();
|
|
229
|
+
const buttons = [...list.querySelectorAll("button[data-candidate-id]")];
|
|
230
|
+
const index = buttons.indexOf(button);
|
|
231
|
+
buttons[event.key === "ArrowDown" ? Math.min(index + 1, buttons.length - 1) : Math.max(index - 1, 0)]?.focus();
|
|
175
232
|
} });
|
|
176
233
|
render();
|
|
177
234
|
return () => root.remove();
|
|
@@ -193,6 +250,20 @@ function renderSource(text, candidates) {
|
|
|
193
250
|
}
|
|
194
251
|
return html;
|
|
195
252
|
}
|
|
253
|
+
function formatContext(candidate) {
|
|
254
|
+
const context = [];
|
|
255
|
+
if (candidate.pdfRegion) {
|
|
256
|
+
if (candidate.pdfRegion.pages.length > 0)
|
|
257
|
+
context.push(`PDF page${candidate.pdfRegion.pages.length === 1 ? "" : "s"} ${candidate.pdfRegion.pages.join(", ")}`);
|
|
258
|
+
if (candidate.pdfRegion.elements.length > 0)
|
|
259
|
+
context.push(`${candidate.pdfRegion.elements.length} layout element${candidate.pdfRegion.elements.length === 1 ? "" : "s"}`);
|
|
260
|
+
if (candidate.pdfRegion.tableCells.length > 0)
|
|
261
|
+
context.push(`${candidate.pdfRegion.tableCells.length} table cell${candidate.pdfRegion.tableCells.length === 1 ? "" : "s"}`);
|
|
262
|
+
}
|
|
263
|
+
if (candidate.ocrDerived)
|
|
264
|
+
context.push("OCR-derived");
|
|
265
|
+
return context.length > 0 ? `<span class="inspector-format-context">${escapeHtml(context.join(" · "))}</span>` : "";
|
|
266
|
+
}
|
|
196
267
|
function filterSelect(key, label, choices) { return `<label>${escapeHtml(label)}<select data-filter="${key}"><option value="">All</option>${choices}</select></label>`; }
|
|
197
268
|
function safeId(value) { return value.replace(/[^a-zA-Z0-9_-]/g, "-"); }
|
|
198
269
|
function escapeHtml(text) { return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); }
|
|
@@ -195,7 +195,11 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
195
195
|
.survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
196
196
|
.survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
|
|
197
197
|
.survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
|
|
198
|
-
.survey-workbench-embed .inspector-filters select{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
|
|
198
|
+
.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; }
|
|
199
|
+
.survey-workbench-embed .inspector-pager, .survey-workbench-embed .queue-controls, .survey-workbench-embed .queue-pager{ display: flex; align-items: end; flex-wrap: wrap; gap: .6rem; }
|
|
200
|
+
.survey-workbench-embed .inspector-pager{ justify-content: flex-end; margin: 0 0 .75rem; }
|
|
201
|
+
.survey-workbench-embed .inspector-pager button, .survey-workbench-embed .queue-pager button{ color: var(--k-text); background: var(--k-raised); border: 1px solid var(--k-line-strong); border-radius: var(--k-radius-sm); padding: .45rem .7rem; }
|
|
202
|
+
.survey-workbench-embed .inspector-pager button:disabled, .survey-workbench-embed .queue-pager button:disabled{ opacity: .45; }
|
|
199
203
|
.survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
|
|
200
204
|
.survey-workbench-embed .inspector-candidate{ width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
|
|
201
205
|
.survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
|
|
@@ -206,6 +210,12 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
206
210
|
.survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
|
|
207
211
|
@container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
|
|
208
212
|
|
|
213
|
+
.survey-workbench-embed .queue-controls{ padding: 1rem; border-bottom: 1px solid var(--k-line); background: var(--k-panel); }
|
|
214
|
+
.survey-workbench-embed .queue-controls label{ display: grid; gap: .25rem; color: var(--k-muted); font-size: .75rem; }
|
|
215
|
+
.survey-workbench-embed .queue-controls input, .survey-workbench-embed .queue-controls select{ min-height: 2.25rem; color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); border-radius: var(--k-radius-sm); padding: .4rem .55rem; }
|
|
216
|
+
.survey-workbench-embed .queue-result-count{ margin-left: auto; color: var(--k-muted); }
|
|
217
|
+
.survey-workbench-embed .queue-empty{ padding: 2rem; color: var(--k-muted); text-align: center; }
|
|
218
|
+
|
|
209
219
|
/* Re-assert the base structural tokens for light mode. themes.css's \`.theme-*\`
|
|
210
220
|
preset classes hardcode a dark --k-bg/--k-panel/etc (so the preset "just
|
|
211
221
|
works" without requiring [data-theme] to be set), and a bare class selector
|
|
@@ -192,7 +192,11 @@
|
|
|
192
192
|
.survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
193
193
|
.survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
|
|
194
194
|
.survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
|
|
195
|
-
.survey-workbench-embed .inspector-filters select{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
|
|
195
|
+
.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; }
|
|
196
|
+
.survey-workbench-embed .inspector-pager, .survey-workbench-embed .queue-controls, .survey-workbench-embed .queue-pager{ display: flex; align-items: end; flex-wrap: wrap; gap: .6rem; }
|
|
197
|
+
.survey-workbench-embed .inspector-pager{ justify-content: flex-end; margin: 0 0 .75rem; }
|
|
198
|
+
.survey-workbench-embed .inspector-pager button, .survey-workbench-embed .queue-pager button{ color: var(--k-text); background: var(--k-raised); border: 1px solid var(--k-line-strong); border-radius: var(--k-radius-sm); padding: .45rem .7rem; }
|
|
199
|
+
.survey-workbench-embed .inspector-pager button:disabled, .survey-workbench-embed .queue-pager button:disabled{ opacity: .45; }
|
|
196
200
|
.survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
|
|
197
201
|
.survey-workbench-embed .inspector-candidate{ width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
|
|
198
202
|
.survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
|
|
@@ -203,6 +207,12 @@
|
|
|
203
207
|
.survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
|
|
204
208
|
@container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
|
|
205
209
|
|
|
210
|
+
.survey-workbench-embed .queue-controls{ padding: 1rem; border-bottom: 1px solid var(--k-line); background: var(--k-panel); }
|
|
211
|
+
.survey-workbench-embed .queue-controls label{ display: grid; gap: .25rem; color: var(--k-muted); font-size: .75rem; }
|
|
212
|
+
.survey-workbench-embed .queue-controls input, .survey-workbench-embed .queue-controls select{ min-height: 2.25rem; color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); border-radius: var(--k-radius-sm); padding: .4rem .55rem; }
|
|
213
|
+
.survey-workbench-embed .queue-result-count{ margin-left: auto; color: var(--k-muted); }
|
|
214
|
+
.survey-workbench-embed .queue-empty{ padding: 2rem; color: var(--k-muted); text-align: center; }
|
|
215
|
+
|
|
206
216
|
/* Re-assert the base structural tokens for light mode. themes.css's `.theme-*`
|
|
207
217
|
preset classes hardcode a dark --k-bg/--k-panel/etc (so the preset "just
|
|
208
218
|
works" without requiring [data-theme] to be set), and a bare class selector
|
|
@@ -2,7 +2,7 @@ import { type ReviewQueueSessionState, type ReviewWorkbenchDecision, type Review
|
|
|
2
2
|
import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
|
|
3
3
|
import { type ReviewPresentationAdapter } from "./review-presentation.js";
|
|
4
4
|
import { type ReviewCandidate, type ReviewDecision, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewValueDescriptor } from "../review-resource.js";
|
|
5
|
-
export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, type ExtractionAlignmentState, type ArtifactUnavailableCode, type ExtractionInspectorCandidate, type ExtractionInspectorEntry, type ExtractionInspectorExportOptions, type ExtractionInspectorFilters, type ExtractionInspectorInput, type ExtractionInspectorModel, type ExtractionInspectorSource, type ResolvedExtractionArtifact, } from "./extraction-inspector.js";
|
|
5
|
+
export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, type ExtractionAlignmentState, type ArtifactUnavailableCode, type ExtractionInspectorCandidate, type ExtractionInspectorEntry, type ExtractionInspectorExportOptions, type ExtractionInspectorFilters, type ExtractionInspectorInput, type ExtractionInspectorMountOptions, type ExtractionInspectorModel, type ExtractionInspectorSource, type ResolvedExtractionArtifact, } from "./extraction-inspector.js";
|
|
6
6
|
export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, type ReviewQueueRowStatus, type ReviewQueueSessionState, type ReviewSessionSummary, type ReviewWorkbenchDecision, type ReviewWorkbenchState, } from "./review-queue-session.js";
|
|
7
7
|
export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, type ReviewCandidatePresentation, type ReviewCandidatePresentationContext, type ReviewItemPresentation, type ReviewItemPresentationContext, type ReviewPresentationAdapter, type ReviewPresentationLink, type ReviewResultPresentation, type ReviewTracePresentationContext, type ReviewTraceRef, type ReviewValuePresentationContext, } from "./review-presentation.js";
|
|
8
8
|
export { buildSurfaceProjectionPreview, type PreviewAuthorityTrace, type PreviewCandidateHistory, type PreviewClaim, type PreviewIntegrityPosture, type PreviewReviewEvent, type PreviewSourceAuthority, type PreviewSourceEvidence, type SurfaceProjectionPreview, } from "./review-surface-preview.js";
|
|
@@ -83,6 +83,23 @@ export interface PersistentReviewSessionEventStoreOptions {
|
|
|
83
83
|
export interface MountReviewWorkbenchOptions {
|
|
84
84
|
readonly eventStore?: ReviewSessionEventStore;
|
|
85
85
|
readonly presentationAdapter?: ReviewPresentationAdapter;
|
|
86
|
+
/** Maximum review cards mounted at once. Defaults to 50 and is capped at 250. */
|
|
87
|
+
readonly pageSize?: number;
|
|
88
|
+
}
|
|
89
|
+
export type ReviewQueueDisposition = "all" | "unresolved" | "accepted" | "kept" | "rejected" | "could-not-confirm";
|
|
90
|
+
export interface ReviewQueueView {
|
|
91
|
+
readonly query: string;
|
|
92
|
+
readonly disposition: ReviewQueueDisposition;
|
|
93
|
+
readonly page: number;
|
|
94
|
+
readonly pageSize: number;
|
|
95
|
+
}
|
|
96
|
+
export interface ReviewQueueWindow {
|
|
97
|
+
readonly items: readonly ReviewItem[];
|
|
98
|
+
readonly totalMatching: number;
|
|
99
|
+
readonly page: number;
|
|
100
|
+
readonly pageCount: number;
|
|
101
|
+
readonly start: number;
|
|
102
|
+
readonly end: number;
|
|
86
103
|
}
|
|
87
104
|
export interface BrowserReviewWorkbenchConfig {
|
|
88
105
|
readonly startState?: ReviewQueueSessionState | ReviewWorkbenchState;
|
|
@@ -179,7 +196,9 @@ export declare function createPersistentReviewSessionEventStore(options: Persist
|
|
|
179
196
|
};
|
|
180
197
|
export declare function renderReviewWorkbenchHtml(state: ReviewWorkbenchState | ReviewQueueSessionState, _events?: readonly ReviewSessionEvent[], options?: {
|
|
181
198
|
readonly presentationAdapter?: ReviewPresentationAdapter;
|
|
199
|
+
readonly queueView?: Partial<ReviewQueueView>;
|
|
182
200
|
}): string;
|
|
201
|
+
export declare function buildReviewQueueWindow(session: ReviewQueueSessionState, view?: Partial<ReviewQueueView>): ReviewQueueWindow;
|
|
183
202
|
/**
|
|
184
203
|
* Validates a reviewer's raw (string) edit of a proposed value against the
|
|
185
204
|
* field's neutral {@link ReviewValueDescriptor}, returning a human-readable
|
|
@@ -455,7 +455,7 @@ export function createPersistentReviewSessionEventStore(options) {
|
|
|
455
455
|
}
|
|
456
456
|
export function renderReviewWorkbenchHtml(state, _events, options = {}) {
|
|
457
457
|
const session = queueSessionFromStartState(state);
|
|
458
|
-
return renderReviewQueueSessionHtml(session, options.presentationAdapter);
|
|
458
|
+
return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView));
|
|
459
459
|
}
|
|
460
460
|
/**
|
|
461
461
|
* The visual/decision state of a field card, derived from the reviewer's local
|
|
@@ -484,12 +484,56 @@ function chipLabel(state) {
|
|
|
484
484
|
default: return "Needs review";
|
|
485
485
|
}
|
|
486
486
|
}
|
|
487
|
+
const DEFAULT_REVIEW_QUEUE_PAGE_SIZE = 50;
|
|
488
|
+
function normalizeReviewQueueView(view) {
|
|
489
|
+
const pageSize = Number.isSafeInteger(view?.pageSize) && Number(view?.pageSize) > 0
|
|
490
|
+
? Math.min(Number(view?.pageSize), 250)
|
|
491
|
+
: DEFAULT_REVIEW_QUEUE_PAGE_SIZE;
|
|
492
|
+
return {
|
|
493
|
+
query: view?.query?.trim() ?? "",
|
|
494
|
+
disposition: view?.disposition ?? "all",
|
|
495
|
+
page: Number.isSafeInteger(view?.page) && Number(view?.page) >= 0 ? Number(view?.page) : 0,
|
|
496
|
+
pageSize,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
export function buildReviewQueueWindow(session, view = {}) {
|
|
500
|
+
const normalized = normalizeReviewQueueView(view);
|
|
501
|
+
const query = normalized.query.toLocaleLowerCase();
|
|
502
|
+
const matching = session.items.filter((item) => {
|
|
503
|
+
const state = fieldCardState(item, session.decisionsByItemName[item.metadata.name]);
|
|
504
|
+
const dispositionMatches = normalized.disposition === "all"
|
|
505
|
+
|| (normalized.disposition === "unresolved" ? state === "review" : state === normalized.disposition);
|
|
506
|
+
if (!dispositionMatches)
|
|
507
|
+
return false;
|
|
508
|
+
if (!query)
|
|
509
|
+
return true;
|
|
510
|
+
const producerDisplayName = item.metadata.producer?.displayName;
|
|
511
|
+
return [
|
|
512
|
+
item.metadata.name,
|
|
513
|
+
item.spec.target,
|
|
514
|
+
typeof producerDisplayName === "string" ? producerDisplayName : "",
|
|
515
|
+
...item.spec.candidates.map((candidate) => formatValue(candidate.value)),
|
|
516
|
+
].some((value) => value.toLocaleLowerCase().includes(query));
|
|
517
|
+
});
|
|
518
|
+
const pageCount = Math.max(1, Math.ceil(matching.length / normalized.pageSize));
|
|
519
|
+
const page = Math.min(normalized.page, pageCount - 1);
|
|
520
|
+
const start = page * normalized.pageSize;
|
|
521
|
+
const end = Math.min(start + normalized.pageSize, matching.length);
|
|
522
|
+
return {
|
|
523
|
+
items: matching.slice(start, end),
|
|
524
|
+
totalMatching: matching.length,
|
|
525
|
+
page,
|
|
526
|
+
pageCount,
|
|
527
|
+
start,
|
|
528
|
+
end,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
487
531
|
function isEmptyValue(value) {
|
|
488
532
|
return value === undefined || value === null || value === "";
|
|
489
533
|
}
|
|
490
534
|
const ARROW_SVG = "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><path d=\"M5 12h14M13 6l6 6-6 6\"/></svg>";
|
|
491
535
|
const WARNING_SVG = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><path d=\"M12 9v4M12 17h.01\"/><path d=\"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z\"/></svg>";
|
|
492
|
-
function renderReviewQueueSessionHtml(session, presentationAdapter) {
|
|
536
|
+
function renderReviewQueueSessionHtml(session, presentationAdapter, queueView) {
|
|
493
537
|
const totalCount = session.items.length;
|
|
494
538
|
const decidedCount = session.items.filter((item) => session.decisionsByItemName[item.metadata.name] !== undefined
|
|
495
539
|
|| item.spec.candidateSetStatus === "resolved").length;
|
|
@@ -497,6 +541,7 @@ function renderReviewQueueSessionHtml(session, presentationAdapter) {
|
|
|
497
541
|
const headerItem = session.items[0];
|
|
498
542
|
const producerName = headerItem?.metadata.producer?.displayName;
|
|
499
543
|
const headerTitle = typeof producerName === "string" && producerName.length > 0 ? producerName : "Review queue";
|
|
544
|
+
const window = buildReviewQueueWindow(session, queueView);
|
|
500
545
|
return `
|
|
501
546
|
<section class="workbench-shell review" data-testid="review-workbench-shell" aria-label="Survey review workbench">
|
|
502
547
|
<header class="rhead">
|
|
@@ -515,13 +560,43 @@ function renderReviewQueueSessionHtml(session, presentationAdapter) {
|
|
|
515
560
|
<button class="apply" type="button" data-testid="apply-button"${decidedCount === 0 ? " disabled" : ""}>Apply ${decidedCount} decision${decidedCount === 1 ? "" : "s"}</button>
|
|
516
561
|
</div>
|
|
517
562
|
</header>
|
|
563
|
+
<nav class="queue-controls" aria-label="Review queue navigation">
|
|
564
|
+
<label>
|
|
565
|
+
<span>Find fields</span>
|
|
566
|
+
<input type="search" data-testid="queue-search" value="${escapeHtml(queueView.query)}" placeholder="Field or value">
|
|
567
|
+
</label>
|
|
568
|
+
<label>
|
|
569
|
+
<span>Disposition</span>
|
|
570
|
+
<select data-testid="queue-disposition">${queueDispositionOptions(queueView.disposition)}</select>
|
|
571
|
+
</label>
|
|
572
|
+
<span class="queue-result-count" aria-live="polite">
|
|
573
|
+
${window.totalMatching === 0 ? "No matching fields" : `${window.start + 1}–${window.end} of ${window.totalMatching}`}
|
|
574
|
+
</span>
|
|
575
|
+
<div class="queue-pager">
|
|
576
|
+
<button type="button" data-testid="queue-previous"${window.page === 0 ? " disabled" : ""}>Previous</button>
|
|
577
|
+
<span>Page ${window.page + 1} of ${window.pageCount}</span>
|
|
578
|
+
<button type="button" data-testid="queue-next"${window.page >= window.pageCount - 1 ? " disabled" : ""}>Next</button>
|
|
579
|
+
</div>
|
|
580
|
+
</nav>
|
|
518
581
|
<div class="fields" data-testid="review-fields">
|
|
519
|
-
${
|
|
582
|
+
${window.items.map((item) => renderFieldCard(item, session, presentationAdapter)).join("")
|
|
583
|
+
|| '<p class="queue-empty">No review fields match the current search and disposition.</p>'}
|
|
520
584
|
</div>
|
|
521
585
|
${renderFooterTally(session)}
|
|
522
586
|
</section>
|
|
523
587
|
`;
|
|
524
588
|
}
|
|
589
|
+
function queueDispositionOptions(selected) {
|
|
590
|
+
const options = [
|
|
591
|
+
["all", "All fields"],
|
|
592
|
+
["unresolved", "Needs review"],
|
|
593
|
+
["accepted", "Accepted"],
|
|
594
|
+
["kept", "Kept current"],
|
|
595
|
+
["rejected", "Flagged wrong"],
|
|
596
|
+
["could-not-confirm", "Could not confirm"],
|
|
597
|
+
];
|
|
598
|
+
return options.map(([value, label]) => `<option value="${value}"${selected === value ? " selected" : ""}>${label}</option>`).join("");
|
|
599
|
+
}
|
|
525
600
|
function renderFieldCard(item, session, presentationAdapter) {
|
|
526
601
|
const decision = session.decisionsByItemName[item.metadata.name];
|
|
527
602
|
const state = fieldCardState(item, decision);
|
|
@@ -991,6 +1066,7 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
991
1066
|
const eventStore = options.eventStore ?? browserReviewSessionEventStore();
|
|
992
1067
|
let events = eventStore?.load(baseSession) ?? [];
|
|
993
1068
|
let session = events.length > 0 ? replayReviewSessionEvents(baseSession, events) : baseSession;
|
|
1069
|
+
let queueView = normalizeReviewQueueView({ pageSize: options.pageSize });
|
|
994
1070
|
const persistEvents = () => {
|
|
995
1071
|
eventStore?.save(session, events);
|
|
996
1072
|
};
|
|
@@ -1094,10 +1170,13 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
1094
1170
|
const controller = {
|
|
1095
1171
|
currentSession: () => session,
|
|
1096
1172
|
currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events),
|
|
1097
|
-
renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter),
|
|
1173
|
+
renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter, queueView),
|
|
1098
1174
|
setDecision,
|
|
1099
1175
|
clearDecision,
|
|
1100
1176
|
updateReviewerNote,
|
|
1177
|
+
updateQueueView: (update) => {
|
|
1178
|
+
queueView = normalizeReviewQueueView({ ...queueView, ...update });
|
|
1179
|
+
},
|
|
1101
1180
|
};
|
|
1102
1181
|
return controller;
|
|
1103
1182
|
}
|
|
@@ -1111,12 +1190,39 @@ function reviewSessionStorageKey(session, keyPrefix) {
|
|
|
1111
1190
|
const itemNames = buildReviewSessionResource(session).spec.reviewItemNames.join(",");
|
|
1112
1191
|
return `${keyPrefix}:${itemNames}`;
|
|
1113
1192
|
}
|
|
1114
|
-
function renderCurrentState(root, session, controller, presentationAdapter) {
|
|
1115
|
-
root.innerHTML = renderReviewWorkbenchHtml(session, undefined, { presentationAdapter });
|
|
1193
|
+
function renderCurrentState(root, session, controller, presentationAdapter, queueView) {
|
|
1194
|
+
root.innerHTML = renderReviewWorkbenchHtml(session, undefined, { presentationAdapter, queueView });
|
|
1116
1195
|
bindFieldCardInteractions(root, controller);
|
|
1117
1196
|
bindApplyButton(root, controller);
|
|
1118
1197
|
bindClampToggles(root);
|
|
1119
1198
|
bindHistoryExpanders(root);
|
|
1199
|
+
bindQueueControls(root, controller, queueView ?? normalizeReviewQueueView(undefined));
|
|
1200
|
+
}
|
|
1201
|
+
function bindQueueControls(root, controller, queueView) {
|
|
1202
|
+
const search = root.querySelector('[data-testid="queue-search"]');
|
|
1203
|
+
search?.addEventListener("input", () => {
|
|
1204
|
+
const query = search.value;
|
|
1205
|
+
controller.updateQueueView({ query, page: 0 });
|
|
1206
|
+
controller.renderCurrentState();
|
|
1207
|
+
const nextSearch = root.querySelector('[data-testid="queue-search"]');
|
|
1208
|
+
nextSearch?.focus();
|
|
1209
|
+
nextSearch?.setSelectionRange(query.length, query.length);
|
|
1210
|
+
});
|
|
1211
|
+
root.querySelector('[data-testid="queue-disposition"]')?.addEventListener("change", (event) => {
|
|
1212
|
+
controller.updateQueueView({
|
|
1213
|
+
disposition: event.currentTarget.value,
|
|
1214
|
+
page: 0,
|
|
1215
|
+
});
|
|
1216
|
+
controller.renderCurrentState();
|
|
1217
|
+
});
|
|
1218
|
+
root.querySelector('[data-testid="queue-previous"]')?.addEventListener("click", () => {
|
|
1219
|
+
controller.updateQueueView({ page: Math.max(0, queueView.page - 1) });
|
|
1220
|
+
controller.renderCurrentState();
|
|
1221
|
+
});
|
|
1222
|
+
root.querySelector('[data-testid="queue-next"]')?.addEventListener("click", () => {
|
|
1223
|
+
controller.updateQueueView({ page: queueView.page + 1 });
|
|
1224
|
+
controller.renderCurrentState();
|
|
1225
|
+
});
|
|
1120
1226
|
}
|
|
1121
1227
|
function queueSessionFromStartState(startState) {
|
|
1122
1228
|
if ("items" in startState) {
|
|
@@ -41,7 +41,11 @@
|
|
|
41
41
|
.inspector-posture.digest-mismatch, .inspector-posture.artifact-unavailable, .inspector-posture.excerpt-mismatch { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
|
|
42
42
|
.inspector-filters { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
|
|
43
43
|
.inspector-filters label { display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
|
|
44
|
-
.inspector-filters select { color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
|
|
44
|
+
.inspector-filters select, .inspector-filters input { color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
|
|
45
|
+
.inspector-pager, .queue-controls, .queue-pager { display: flex; align-items: end; flex-wrap: wrap; gap: .6rem; }
|
|
46
|
+
.inspector-pager { justify-content: flex-end; margin: 0 0 .75rem; }
|
|
47
|
+
.inspector-pager button, .queue-pager button { color: var(--k-text); background: var(--k-raised); border: 1px solid var(--k-line-strong); border-radius: var(--k-radius-sm); padding: .45rem .7rem; }
|
|
48
|
+
.inspector-pager button:disabled, .queue-pager button:disabled { opacity: .45; }
|
|
45
49
|
.inspector-candidates { margin: 0; padding-left: 1.5rem; }
|
|
46
50
|
.inspector-candidate { width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
|
|
47
51
|
.inspector-source pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
|
|
@@ -52,6 +56,12 @@
|
|
|
52
56
|
.source-unavailable { color: var(--k-negative); font-weight: 700; }
|
|
53
57
|
@container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
|
|
54
58
|
|
|
59
|
+
.queue-controls { padding: 1rem; border-bottom: 1px solid var(--k-line); background: var(--k-panel); }
|
|
60
|
+
.queue-controls label { display: grid; gap: .25rem; color: var(--k-muted); font-size: .75rem; }
|
|
61
|
+
.queue-controls input, .queue-controls select { min-height: 2.25rem; color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); border-radius: var(--k-radius-sm); padding: .4rem .55rem; }
|
|
62
|
+
.queue-result-count { margin-left: auto; color: var(--k-muted); }
|
|
63
|
+
.queue-empty { padding: 2rem; color: var(--k-muted); text-align: center; }
|
|
64
|
+
|
|
55
65
|
/* Re-assert the base structural tokens for light mode. themes.css's `.theme-*`
|
|
56
66
|
preset classes hardcode a dark --k-bg/--k-panel/etc (so the preset "just
|
|
57
67
|
works" without requiring [data-theme] to be set), and a bare class selector
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontourai/survey",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
"test": "npm run build && node --test dist/tests/*.test.js",
|
|
53
53
|
"test:browser": "playwright test",
|
|
54
54
|
"test:browser:concurrent": "npm run build && node scripts/test-playwright-concurrency.mjs",
|
|
55
|
-
"verify": "
|
|
56
|
-
"check:content-boundary": "node scripts/check-content-boundary.cjs",
|
|
55
|
+
"verify": "npm run check:content-boundary && npm run check:decisions && npm run typecheck && npm test && npm run check:review-workbench-assets && npm run check:review-workbench && npm run test:browser && npm run test:browser:concurrent",
|
|
56
|
+
"check:content-boundary": "node --test tests/content-boundary-script.test.cjs && node scripts/check-content-boundary.cjs",
|
|
57
57
|
"check:decisions": "node scripts/check-decisions.cjs check",
|
|
58
58
|
"gen:decisions-index": "node scripts/check-decisions.cjs gen-index",
|
|
59
59
|
"freeze:adrs": "node scripts/freeze-adrs.mjs",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@kontourai/traverse": "^0.
|
|
82
|
+
"@kontourai/traverse": "^0.22.0",
|
|
83
83
|
"@kontourai/ui": "^1.1.0",
|
|
84
84
|
"@playwright/test": "^1.60.0",
|
|
85
85
|
"@types/node": "^25.6.0",
|