@@ -0,0 +1,211 @@
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user