@kontourai/survey 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +4 -0
- package/dist/src/review-workbench/extraction-inspector.js +43 -8
- package/package.json +4 -4
|
@@ -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
|
}
|
|
@@ -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,11 +123,31 @@ 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
|
return model.candidates.filter((c) => (!filters.field || c.field === filters.field) && (!filters.provider || c.provider === filters.provider) && (!filters.model || c.model === filters.model) && (!filters.attempt || c.attempt === filters.attempt) && (!filters.pass || c.pass === filters.pass) && (!filters.inferenceType || c.inferenceType === filters.inferenceType) && (!filters.alignment || c.alignment === filters.alignment));
|
|
132
153
|
}
|
|
@@ -148,7 +169,7 @@ export function mountExtractionInspector(container, model) {
|
|
|
148
169
|
const filters = {};
|
|
149
170
|
const render = () => {
|
|
150
171
|
const visible = filterExtractionInspectorCandidates(model, filters);
|
|
151
|
-
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
|
|
172
|
+
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>";
|
|
152
173
|
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
174
|
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
175
|
};
|
|
@@ -193,6 +214,20 @@ function renderSource(text, candidates) {
|
|
|
193
214
|
}
|
|
194
215
|
return html;
|
|
195
216
|
}
|
|
217
|
+
function formatContext(candidate) {
|
|
218
|
+
const context = [];
|
|
219
|
+
if (candidate.pdfRegion) {
|
|
220
|
+
if (candidate.pdfRegion.pages.length > 0)
|
|
221
|
+
context.push(`PDF page${candidate.pdfRegion.pages.length === 1 ? "" : "s"} ${candidate.pdfRegion.pages.join(", ")}`);
|
|
222
|
+
if (candidate.pdfRegion.elements.length > 0)
|
|
223
|
+
context.push(`${candidate.pdfRegion.elements.length} layout element${candidate.pdfRegion.elements.length === 1 ? "" : "s"}`);
|
|
224
|
+
if (candidate.pdfRegion.tableCells.length > 0)
|
|
225
|
+
context.push(`${candidate.pdfRegion.tableCells.length} table cell${candidate.pdfRegion.tableCells.length === 1 ? "" : "s"}`);
|
|
226
|
+
}
|
|
227
|
+
if (candidate.ocrDerived)
|
|
228
|
+
context.push("OCR-derived");
|
|
229
|
+
return context.length > 0 ? `<span class="inspector-format-context">${escapeHtml(context.join(" · "))}</span>` : "";
|
|
230
|
+
}
|
|
196
231
|
function filterSelect(key, label, choices) { return `<label>${escapeHtml(label)}<select data-filter="${key}"><option value="">All</option>${choices}</select></label>`; }
|
|
197
232
|
function safeId(value) { return value.replace(/[^a-zA-Z0-9_-]/g, "-"); }
|
|
198
233
|
function escapeHtml(text) { return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontourai/survey",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.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",
|