This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
// What the extraction does with a page by its class (ocr-service OCR3.15): a drawing and an empty page
|
||||
// are never asked about and are reported; a scan and a table are sent as image and text; a text page as
|
||||
// text; the lines that repeat on most pages are not in the prompt; and a verified quote names the
|
||||
// reader's block. The model is a recording gateway: what it was sent is what is asserted.
|
||||
|
||||
import type { ChatMessage, GenerateObjectOptions, ModelGateway } from "@conusai-kit/platform"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { LOW_TRUST_NOTE, modelLineExtractor, PAGE_TEXT_CLOSE, PAGE_TEXT_OPEN } from "../adapters/model-extractor.js"
|
||||
import type { PageClass } from "../contract/index.js"
|
||||
import { planPage, promptTextOf } from "../core/page-plan.js"
|
||||
import type { ExtractionInput, PageImage, ReadDocument, ReadPage } from "../core/ports.js"
|
||||
import { noCache } from "../core/stage-cache.js"
|
||||
|
||||
/** A row long enough that a page of it is not weak for want of text (MIN_PAGE_TEXT_CHARS). */
|
||||
const SHEET_ROW = "Reference: QR-2026-0417 (rev 2)"
|
||||
|
||||
const png: PageImage = { bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]), mediaType: "image/png" }
|
||||
|
||||
function page(index: number, pageClass: PageClass, rows: readonly string[], over: Partial<ReadPage> = {}): ReadPage {
|
||||
return {
|
||||
index,
|
||||
width: 1000,
|
||||
height: 1400,
|
||||
class: pageClass,
|
||||
source: pageClass === "text" ? "text_layer" : "ocr",
|
||||
text: rows.join("\n"),
|
||||
lines: rows.map((text, order) => ({
|
||||
text,
|
||||
bbox: [100, 100 + order * 40, 600, 130 + order * 40] as const,
|
||||
confidence: 0.97,
|
||||
order,
|
||||
repeated: false,
|
||||
})),
|
||||
blocks: rows.map((_, order) => ({
|
||||
id: `p${index + 1}-b${order + 1}`,
|
||||
bbox: [90, 90 + order * 40, 700, 140 + order * 40] as const,
|
||||
order,
|
||||
lines: [order],
|
||||
})),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const documentOf = (...pages: ReadPage[]): ReadDocument => ({
|
||||
engine: { name: "fixture", version: "1" },
|
||||
page_count: pages.length,
|
||||
pages,
|
||||
})
|
||||
|
||||
interface Sent {
|
||||
messages: ChatMessage[]
|
||||
options: GenerateObjectOptions
|
||||
}
|
||||
|
||||
/** A gateway that records every request and answers with the lines it is given for the page it sees. */
|
||||
function recording(answer: (text: string) => Array<{ label: string; value: string; quote: string }>) {
|
||||
const sent: Sent[] = []
|
||||
const gateway: ModelGateway = {
|
||||
async generateObject(_schema, messages, options) {
|
||||
sent.push({ messages: [...messages], options: options ?? {} })
|
||||
const user = messages.find((message) => message.role === "user")
|
||||
const parts = Array.isArray(user?.content) ? user.content : []
|
||||
const text = parts.map((part) => ("text" in part ? part.text : "")).join("\n")
|
||||
const from = text.indexOf(PAGE_TEXT_OPEN) + PAGE_TEXT_OPEN.length + 1
|
||||
const to = text.lastIndexOf(PAGE_TEXT_CLOSE) - 1
|
||||
return { lines: answer(text.slice(from, to)) } as never
|
||||
},
|
||||
}
|
||||
return { gateway, sent }
|
||||
}
|
||||
|
||||
const partsOf = (sent: Sent): Array<{ type: string; text?: string }> => {
|
||||
const user = sent.messages.find((message) => message.role === "user")
|
||||
return (Array.isArray(user?.content) ? user.content : []) as Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
const input = (document: ReadDocument, pageImage: ExtractionInput["pageImage"]): ExtractionInput => ({
|
||||
document,
|
||||
pageImage,
|
||||
cache: noCache,
|
||||
})
|
||||
|
||||
const reference = (text: string) =>
|
||||
text
|
||||
.split("\n")
|
||||
.filter((row) => row.includes(":"))
|
||||
.map((row) => ({ label: row.split(":")[0] as string, value: (row.split(":")[1] as string).trim(), quote: row }))
|
||||
|
||||
describe("the plan for a page", () => {
|
||||
const by = (pageClass: PageClass, over: Partial<ReadPage> = {}) =>
|
||||
planPage(page(0, pageClass, [SHEET_ROW], over), ["drawing", "empty"])
|
||||
|
||||
it("leaves out the classes it is told to, whatever else is true of the page", () => {
|
||||
expect(by("drawing")).toEqual({ kind: "skip" })
|
||||
expect(by("empty")).toEqual({ kind: "skip" })
|
||||
expect(planPage(page(0, "scan", [SHEET_ROW]), ["scan"])).toEqual({ kind: "skip" })
|
||||
})
|
||||
|
||||
it("shows a scan its image and says its text is a recognition; a table gets image and text", () => {
|
||||
expect(by("scan")).toEqual({ kind: "read", image: true, lowTrustText: true })
|
||||
expect(by("table")).toMatchObject({ kind: "read", image: true })
|
||||
})
|
||||
|
||||
it("reads a text page as text, with the image only when the reader was unsure of it", () => {
|
||||
expect(by("text")).toEqual({ kind: "read", image: false, lowTrustText: false })
|
||||
const unsure = page(0, "text", [SHEET_ROW])
|
||||
const weak = { ...unsure, lines: unsure.lines.map((line) => ({ ...line, confidence: 0.4 })) }
|
||||
expect(planPage(weak, ["drawing", "empty"])).toMatchObject({ kind: "read", image: true })
|
||||
})
|
||||
|
||||
it("reads a drawing when the deployment lets it, with its image", () => {
|
||||
expect(planPage(page(0, "drawing", ["A-101"]), [])).toMatchObject({ kind: "read", image: true })
|
||||
})
|
||||
|
||||
it("drops the lines flagged as repeating from the text the model is shown", () => {
|
||||
const rows = ["ACME Specification", "Reference: QR-1", "Page footer"]
|
||||
const shown = page(0, "text", rows)
|
||||
const lines = shown.lines.map((line, at) => ({ ...line, repeated: at !== 1 }))
|
||||
expect(promptTextOf({ ...shown, lines })).toBe("Reference: QR-1")
|
||||
expect(promptTextOf({ ...shown, lines: [] })).toBe(rows.join("\n"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("the extractor and the page classes", () => {
|
||||
it("asks nothing about a drawing or an empty page and reports them as skipped, in page order", async () => {
|
||||
const { gateway, sent } = recording(reference)
|
||||
const document = documentOf(
|
||||
page(0, "text", ["Reference: QR-1"]),
|
||||
page(
|
||||
1,
|
||||
"drawing",
|
||||
Array.from({ length: 400 }, (_, at) => `A-${at} 2400`),
|
||||
),
|
||||
page(2, "empty", []),
|
||||
)
|
||||
const result = await modelLineExtractor({ gateway, model: "m" }).extract(input(document, async () => png))
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(result.lines.map((line) => [line.page, line.value])).toEqual([[1, "QR-1"]])
|
||||
expect(result.skipped).toEqual([
|
||||
{ page: 2, pageClass: "drawing" },
|
||||
{ page: 3, pageClass: "empty" },
|
||||
])
|
||||
})
|
||||
|
||||
it("lets a deployment read drawings: the list of excluded classes is the option's", async () => {
|
||||
const { gateway, sent } = recording(reference)
|
||||
const document = documentOf(page(0, "drawing", ["Scale: 1:50"]))
|
||||
const result = await modelLineExtractor({ gateway, model: "m", excludedPageClasses: [] }).extract(
|
||||
input(document, async () => png),
|
||||
)
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(result.skipped).toEqual([])
|
||||
})
|
||||
|
||||
it("sends a scan as text and image, the text marked low trust; a text page as text alone", async () => {
|
||||
const { gateway, sent } = recording(reference)
|
||||
const asked: number[] = []
|
||||
const document = documentOf(page(0, "scan", ["Reference: QR-1"]), page(1, "text", [SHEET_ROW]))
|
||||
await modelLineExtractor({ gateway, model: "m" }).extract(
|
||||
input(document, async (index) => {
|
||||
asked.push(index)
|
||||
return png
|
||||
}),
|
||||
)
|
||||
expect(asked).toEqual([0])
|
||||
const kinds = sent.map((one) => partsOf(one).map((part) => part.type))
|
||||
expect(kinds).toContainEqual(["text", "image_url"])
|
||||
expect(kinds).toContainEqual(["text"])
|
||||
const scanText = partsOf(sent.find((one) => partsOf(one).length === 2) as Sent)[0]?.text
|
||||
expect(scanText).toContain(LOW_TRUST_NOTE)
|
||||
const textText = partsOf(sent.find((one) => partsOf(one).length === 1) as Sent)[0]?.text
|
||||
expect(textText).not.toContain(LOW_TRUST_NOTE)
|
||||
})
|
||||
|
||||
it("leaves a header that repeats on most pages out of the prompt, and still verifies against the page", async () => {
|
||||
const { gateway, sent } = recording(reference)
|
||||
const rows = ["ACME: Specification", "Reference: QR-1"]
|
||||
const withHeader = page(0, "text", rows)
|
||||
const document = documentOf({
|
||||
...withHeader,
|
||||
lines: withHeader.lines.map((line, at) => ({ ...line, repeated: at === 0 })),
|
||||
})
|
||||
const result = await modelLineExtractor({ gateway, model: "m" }).extract(input(document, async () => undefined))
|
||||
const text = partsOf(sent[0] as Sent)[0]?.text ?? ""
|
||||
expect(text).toContain("Reference: QR-1")
|
||||
expect(text).not.toContain("ACME")
|
||||
expect(result.lines.map((line) => [line.label, line.verification])).toEqual([["Reference", "verified"]])
|
||||
})
|
||||
|
||||
it("gives a verified line the block it sits in, and that block's box in place of the lines'", async () => {
|
||||
const { gateway } = recording(reference)
|
||||
const document = documentOf(page(0, "text", ["Reference: QR-1", "Issued: 2026-04-01"]))
|
||||
const { lines } = await modelLineExtractor({ gateway, model: "m" }).extract(input(document, async () => undefined))
|
||||
const second = lines.find((line) => line.label === "Issued")
|
||||
expect(second?.blockId).toBe("p1-b2")
|
||||
// block p1-b2 is [90, 130, 700, 180] on a 1000 x 1400 page, not the line's [100, 140, 600, 170]
|
||||
expect(second?.box).toEqual({ x: 0.09, y: 130 / 1400, width: 0.61, height: 50 / 1400 })
|
||||
})
|
||||
|
||||
it("marks a quote the model read off the image, and the text does not hold, as a scanned quote to check", async () => {
|
||||
const { gateway } = recording(() => [{ label: "Qty", value: "12", quote: "Qty 12 pcs" }])
|
||||
const document = documentOf(page(0, "scan", ["Delivery note"]), page(1, "text", ["Delivery note"]))
|
||||
const { lines } = await modelLineExtractor({ gateway, model: "m" }).extract(
|
||||
input(document, async (index) => (index === 0 ? png : undefined)),
|
||||
)
|
||||
const [scan, text] = lines
|
||||
expect(scan).toMatchObject({ page: 1, verification: "needs_review", note: "scanned_quote", confidence: 0 })
|
||||
// the same unfound quote on a page the model saw no image of carries no such note
|
||||
expect(text).toMatchObject({ page: 2, verification: "needs_review" })
|
||||
expect(text?.note).toBeUndefined()
|
||||
})
|
||||
|
||||
it("keys the cache by the image: another image of the same page is asked again", async () => {
|
||||
const stored = new Map<string, unknown>()
|
||||
const cache = {
|
||||
async get(stage: string, key: string) {
|
||||
return stored.get(`${stage}/${key}`) as never
|
||||
},
|
||||
async put(stage: string, key: string, value: unknown) {
|
||||
stored.set(`${stage}/${key}`, value)
|
||||
},
|
||||
}
|
||||
const { gateway, sent } = recording(reference)
|
||||
const document = documentOf(page(0, "scan", ["Reference: QR-1"]))
|
||||
const extractor = modelLineExtractor({ gateway, model: "m" })
|
||||
const run = (image: PageImage) => extractor.extract({ document, pageImage: async () => image, cache })
|
||||
await run(png)
|
||||
await run(png)
|
||||
expect(sent).toHaveLength(1)
|
||||
await run({ ...png, bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 9, 9, 9]) })
|
||||
expect(sent).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user