212 lines
7.4 KiB
TypeScript
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()
|
|
})
|
|
})
|