Files
template-app/modules/documents/__tests__/platform-items-extractor.test.ts
T
2026-11-14 09:56:45 +00:00

212 lines
7.4 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import type { DocumentItems, Item, ItemCitation } from "@conusai-kit/platform"
import { describe, expect, it, vi } from "vitest"
import { boxOf, candidateOf, labelAndValueOf, platformItemsExtractor } from "../adapters/platform-items-extractor.js"
import { feedbackOf, itemRefOf } from "../core/item-feedback.js"
import { ExtractionFailed, type ReadDocument, type ReadPage } from "../core/ports.js"
import { noCache } from "../core/stage-cache.js"
const SHA = "a".repeat(64)
const cited = (page: number): ItemCitation => ({
kind: "pdf_page",
file_sha256: SHA,
page,
lines: [1],
bbox: [100, 400, 900, 500],
quote: "1 Kabelis Al 4x25 m 120",
})
const item = (overrides: Partial<Item> = {}): Item => ({
id: "p1-l1",
section: { title: "Medžiagos", class: "material", page: 1 },
position: "1",
name: "Kabelis Al 4x25",
class: "material",
unit: { text: "m", code: "MTR" },
quantity: { value: "120", text: "120", column: "Kiekis", refusal: null },
spec: null,
citations: [cited(1)],
checks: [{ check: "quote_verbatim", status: "passed" }],
agreement: { status: "auto_accepted", reader: true, model: true },
method: "reader_and_model",
confidence: 0.98,
...overrides,
})
const page = (index: number, text: string, cls: ReadPage["class"] = "table"): ReadPage => ({
index,
width: 1000,
height: 2000,
class: cls,
text,
lines: [],
blocks: [],
})
const first = page(0, "Medžiagos\n1 Kabelis Al 4x25 m 120")
const result: Pick<DocumentItems, "run_id" | "models"> = {
run_id: 31,
models: [{ stage: "rows", model: "claude-haiku-5-5" }],
}
const pageOf = (n: number) => (n === 1 ? first : undefined)
describe("platform items as lines", () => {
it("maps an item to a verified line, its box as fractions of the page read", () => {
expect(candidateOf(item(), result, pageOf)).toEqual({
label: "Kabelis Al 4x25",
value: "120 m",
page: 1,
quote: "1 Kabelis Al 4x25 m 120",
box: { x: 0.1, y: 0.2, width: 0.8, height: expect.closeTo(0.05) },
confidence: 0.98,
model: "claude-haiku-5-5",
verification: "verified",
itemRef: "31:p1-l1",
})
})
it("asks for review when the platform does, when a check failed, or when the quote is not on the page", () => {
expect(
candidateOf(item({ agreement: { status: "review_required", reader: true, model: false } }), result, pageOf)
?.verification,
).toBe("needs_review")
expect(
candidateOf(item({ checks: [{ check: "unit_known", status: "failed" }] }), result, pageOf)?.verification,
).toBe("needs_review")
expect(candidateOf(item(), result, () => page(0, "another page"))?.verification).toBe("needs_review")
})
it("proposes nothing for an item without a citation, and no reference without a run", () => {
expect(candidateOf(item({ citations: [] }), result, pageOf)).toBeUndefined()
expect(candidateOf(item(), { ...result, run_id: null }, pageOf)?.itemRef).toBeUndefined()
})
it("cites only a PDF page: an item cited by a sheet's cells alone is not proposed", () => {
const sheet: ItemCitation = {
kind: "sheet",
file_sha256: SHA,
sheet: "BOQ",
cell_range: "A3:F3",
quote: "1 Kabelis",
}
expect(candidateOf(item({ citations: [sheet] }), result, pageOf)).toBeUndefined()
expect(candidateOf(item({ citations: [sheet, cited(1)] }), result, pageOf)).toMatchObject({ page: 1 })
})
it("keeps no box it cannot place: no geometry, no page size, or an empty box", () => {
expect(boxOf(null, first)).toBeUndefined()
expect(boxOf([0, 0, 10, 10], undefined)).toBeUndefined()
expect(boxOf([10, 10, 10, 20], first)).toBeUndefined()
expect(boxOf([-5, 0, 2000, 2000], first)).toEqual({ x: 0, y: 0, width: 1, height: 1 })
})
it("reads a label/value item with no unit as its quantity text", () => {
expect(
labelAndValueOf(
item({
name: "Total",
unit: { text: "", code: null },
quantity: { value: null, text: "EUR 5", column: "", refusal: "not_a_number" },
}),
),
).toEqual({
label: "Total",
value: "EUR 5",
})
})
it("runs the profile over the job that read the document, once per job, and leaves drawings out", async () => {
const submit = vi.fn(async () => ({ id: 31, jobId: 4, task: "items" as const, status: "queued" as const }) as never)
const wait = vi.fn(async () => ({
schema_version: "conusai.documents.items/v1" as const,
job_id: 4,
run_id: 31,
file_sha256: SHA,
profile: { id: "bill_of_quantities", version: 1 },
pipeline_revision: "x",
models: result.models,
pages: [],
items: [item(), item({ id: "p2-l1", citations: [cited(2)] })],
numbering: [],
usage: {
pages_read: 2,
pages_sent_to_model: 1,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
wall_ms: 1,
api_ms: 1,
cost_usd: "0",
},
warnings: [],
}))
const document: ReadDocument = {
engine: { name: "t", version: "1" },
page_count: 2,
pages: [first, page(1, "1 Kabelis Al 4x25 m 120", "drawing")],
}
const extractor = platformItemsExtractor({
items: { submit, wait },
profile: "bill_of_quantities",
keep: ["material"],
})
const extraction = await extractor.extract({
document,
ocrJobId: 4,
pageImage: async () => undefined,
cache: noCache,
})
expect(submit).toHaveBeenCalledWith(4, {
profile: "bill_of_quantities",
keep: ["material"],
idempotencyKey: "documents.items.4.bill_of_quantities",
})
expect(extraction.lines.map((line) => line.page)).toEqual([1])
expect(extraction.skipped).toEqual([{ page: 2, pageClass: "drawing" }])
expect(extractor.version).toBe("items.bill_of_quantities.v1")
await expect(
extractor.extract({ document, pageImage: async () => undefined, cache: noCache }),
).rejects.toBeInstanceOf(ExtractionFailed)
})
})
describe("a decision as item feedback", () => {
const change = {
lineKey: "f".repeat(64),
label: "Kabelis Al 4x25",
value: "120 m",
page: 1,
position: 0,
quote: "1 Kabelis Al 4x25 m 120",
confidence: 0.98,
verification: "verified",
edited: false,
source: {
fileSha256: SHA,
ocrJobId: 4,
ocrEngine: { name: "t", version: "1" },
model: "claude-haiku-5-5",
promptVersion: "items.bill_of_quantities.v1",
itemRef: "31:p1-l1",
},
}
it("names the run and the item, and sends the reviewer's words only for a correction", () => {
expect(itemRefOf(change)).toEqual({ runId: 31, itemId: "p1-l1" })
expect(feedbackOf("approved", change)).toEqual({ runId: 31, feedback: { item_id: "p1-l1", decision: "approved" } })
expect(feedbackOf("rejected", change)).toEqual({ runId: 31, feedback: { item_id: "p1-l1", decision: "rejected" } })
expect(feedbackOf("approved", { ...change, edited: true, value: "125 m" })).toEqual({
runId: 31,
feedback: { item_id: "p1-l1", decision: "edited", fields: { label: "Kabelis Al 4x25", value: "125 m" } },
})
})
it("is nothing for a line the platform did not extract", () => {
const { itemRef: _, ...source } = change.source
expect(feedbackOf("approved", { ...change, source })).toBeUndefined()
expect(feedbackOf("approved", { not: "a line" })).toBeUndefined()
})
})