432 lines
19 KiB
TypeScript
432 lines
19 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// The extraction of the documents module through the ModelGateway (plan item AT2.3): what the model is
|
|
// sent, what is done with its answer, and what happens when it does not answer. The model is the fake
|
|
// platform's gateway route, scripted per test; the gateway client, the extractor, the verification, the
|
|
// cache, the proposals and the review all run for real.
|
|
|
|
import { type LineView, LOW_TRUST_NOTE } from "@app/modules/documents"
|
|
import { uploadViaProcedures } from "@conusai-kit/testing"
|
|
import type { FakeChatResponder } from "@conusai-kit/testing/fake-platform"
|
|
import { afterEach, describe, expect, it } from "vitest"
|
|
import {
|
|
bytesOf,
|
|
closeApps,
|
|
type DocumentsApp,
|
|
makeDocumentsApp,
|
|
type SeededProject,
|
|
uploadAndProcess,
|
|
} from "./support/documents-app.js"
|
|
import { deterministicModel } from "./support/fake-model.js"
|
|
|
|
afterEach(closeApps)
|
|
|
|
/** Every page image the platform drew, as the fake platform recorded it. */
|
|
async function pagesDrawn(app: DocumentsApp): Promise<Array<{ job: number; page: number; dpi: number }>> {
|
|
const state = (await (await fetch(`${app.platform.url}/__e2e/state`)).json()) as {
|
|
ocrPages: Array<{ job: number; page: number; dpi: number }>
|
|
}
|
|
return state.ocrPages
|
|
}
|
|
|
|
const PAGE = { width: 1000, height: 1400, unit: "px", dpi: 150, rotation: 0 }
|
|
|
|
/**
|
|
* One page of an OCR document whose lines are `rows`, 40 px apart, one block per line (`p{n}-b{m}`), as
|
|
* the platform returns it once the page is classified: a scan when it was recognised, text otherwise.
|
|
* `repeated` lists the rows the reader flagged as repeating on most pages.
|
|
*/
|
|
function pageOf(
|
|
index: number,
|
|
rows: string[],
|
|
source: "text_layer" | "ocr" = "text_layer",
|
|
confidence = 0.97,
|
|
options: { pageClass?: string; repeated?: readonly string[] } = {},
|
|
) {
|
|
return {
|
|
...PAGE,
|
|
index,
|
|
class: options.pageClass ?? (source === "ocr" ? "scan" : "text"),
|
|
source,
|
|
text: rows.join("\n"),
|
|
lines: rows.map((text, order) => ({
|
|
text,
|
|
bbox: [100, 100 + order * 40, 600, 130 + order * 40],
|
|
polygon: null,
|
|
confidence,
|
|
order,
|
|
words: [],
|
|
repeated: options.repeated?.includes(text) ?? false,
|
|
})),
|
|
blocks: rows.map((_, order) => ({
|
|
id: `p${index + 1}-b${order + 1}`,
|
|
bbox: [90, 90 + order * 40, 700, 140 + order * 40],
|
|
order,
|
|
lines: [order],
|
|
})),
|
|
}
|
|
}
|
|
|
|
/** A row long enough that a page of it is not weak for want of text: its image is not asked for. */
|
|
const SHEET_ROW = "Reference: QR-2026-0417 (rev 2)"
|
|
|
|
const documentOf = (...pages: Array<ReturnType<typeof pageOf>>) => ({
|
|
schema_version: "conusai.ocr.document/v1",
|
|
engine: { name: "fixture-engine", version: "1", detection_model: null, recognition_model: null },
|
|
page_count: pages.length,
|
|
pages,
|
|
})
|
|
|
|
/** A one-page OCR document whose lines are `rows`. */
|
|
const onePage = (rows: string[], source: "text_layer" | "ocr" = "text_layer", confidence = 0.97) =>
|
|
documentOf(pageOf(0, rows, source, confidence))
|
|
|
|
interface Detail {
|
|
document: {
|
|
status: string
|
|
failureCode: string | null
|
|
pending: number
|
|
skippedPages: Array<{ page: number; pageClass: string }>
|
|
}
|
|
lines: LineView[]
|
|
}
|
|
|
|
async function run(
|
|
responder: FakeChatResponder | undefined,
|
|
document: Record<string, unknown>,
|
|
upload: { filename: string; contentType: string } = { filename: "report.pdf", contentType: "application/pdf" },
|
|
): Promise<{ app: DocumentsApp; project: SeededProject; detail: Detail }> {
|
|
const app = await makeDocumentsApp({ document, ...(responder ? { responder } : {}) })
|
|
const project = await app.seedProject("acme", "owner@acme.test")
|
|
const file = await uploadAndProcess(app, project, upload.filename, upload.contentType)
|
|
const detail = (await project.call.callByName("documents.document.get", {
|
|
...project.slugs,
|
|
fileId: file.fileId,
|
|
})) as Detail
|
|
return { app, project, detail }
|
|
}
|
|
|
|
const reply = (lines: Array<{ label: string; value: string; quote: string }>) => ({
|
|
content: JSON.stringify({ lines }),
|
|
})
|
|
|
|
type Message = { role: string; content: string | Array<{ type: string; text?: string; image_url?: { url: string } }> }
|
|
const messagesOf = (request: Record<string, unknown>) => request.messages as Message[]
|
|
const userParts = (request: Record<string, unknown>) => {
|
|
const content = messagesOf(request).find((message) => message.role === "user")?.content
|
|
return typeof content === "string" ? [] : (content ?? [])
|
|
}
|
|
|
|
describe("what the model is sent", () => {
|
|
it("asks once per page, with the page's text and the schema of the module's line", async () => {
|
|
const { app } = await run(undefined, onePage(["Reference: QR-1", "Issued on: 2026-04-01"]))
|
|
expect(app.model.requests).toHaveLength(1)
|
|
const request = app.model.requests[0] as Record<string, unknown>
|
|
expect(request.model).toBe("fake-model")
|
|
expect(request.temperature).toBe(0)
|
|
|
|
const [system] = messagesOf(request)
|
|
expect(system?.role).toBe("system")
|
|
const [text, ...rest] = userParts(request)
|
|
expect(text?.text).toContain("Page 1 of 1.")
|
|
expect(text?.text).toContain("Reference: QR-1\nIssued on: 2026-04-01")
|
|
// a text-layer PDF page needs no image
|
|
expect(rest).toEqual([])
|
|
|
|
const format = request.response_format as {
|
|
type: string
|
|
json_schema: { schema: { properties: { lines: { items: { required: string[]; properties: object } } } } }
|
|
}
|
|
expect(format.type).toBe("json_schema")
|
|
const item = format.json_schema.schema.properties.lines.items
|
|
expect(item.required.sort()).toEqual(["label", "quote", "value"])
|
|
// the page, the position and the confidence are the code's to set, so the model is not asked for them
|
|
expect(Object.keys(item.properties).sort()).toEqual(["label", "quote", "value"])
|
|
})
|
|
|
|
it("adds the page image, as a content part, for a scanned page of an image upload", async () => {
|
|
const { app } = await run(undefined, onePage(["Reference: QR-1"], "ocr", 0.7), {
|
|
filename: "scan.png",
|
|
contentType: "image/png",
|
|
})
|
|
const parts = userParts(app.model.requests[0] as Record<string, unknown>)
|
|
expect(parts.map((part) => part.type)).toEqual(["text", "image_url"])
|
|
expect(parts[1]?.image_url?.url).toMatch(/^data:image\/png;base64,/)
|
|
})
|
|
|
|
it("fetches the image of a scanned page of a PDF from the platform's page route and sends it with the text", async () => {
|
|
const { app, detail } = await run(undefined, onePage(["Reference: QR-1"], "ocr", 0.7))
|
|
const parts = userParts(app.model.requests[0] as Record<string, unknown>)
|
|
expect(parts.map((part) => part.type)).toEqual(["text", "image_url"])
|
|
expect(parts[0]?.text).toContain(LOW_TRUST_NOTE)
|
|
expect(parts[1]?.image_url?.url).toMatch(/^data:image\/png;base64,iVBORw0KGgo/)
|
|
// the page was drawn once, for the job that read the file, at the default dpi
|
|
expect(await pagesDrawn(app)).toEqual([{ job: 1, page: 1, dpi: 150 }])
|
|
expect(detail.lines.map((line) => line.value)).toEqual(["QR-1"])
|
|
})
|
|
|
|
it("sends a table page as image and text, and a text page as text alone without asking for its image", async () => {
|
|
const { app } = await run(
|
|
undefined,
|
|
documentOf(pageOf(0, ["Item | Qty", "Bolts | 12"], "ocr", 0.97, { pageClass: "table" }), pageOf(1, [SHEET_ROW])),
|
|
)
|
|
const kinds = app.model.requests.map((request) =>
|
|
userParts(request as Record<string, unknown>).map((part) => part.type),
|
|
)
|
|
expect(kinds).toContainEqual(["text", "image_url"])
|
|
expect(kinds).toContainEqual(["text"])
|
|
expect(await pagesDrawn(app)).toEqual([{ job: 1, page: 1, dpi: 150 }])
|
|
})
|
|
|
|
it("leaves the header that repeats on the pages out of the text the model is shown", async () => {
|
|
const header = "ACME: Specification sheet"
|
|
const { app, detail } = await run(
|
|
undefined,
|
|
documentOf(
|
|
pageOf(0, [header, "Reference: QR-1"], "text_layer", 0.97, { repeated: [header] }),
|
|
pageOf(1, [header, "Issued on: 2026-04-01"], "text_layer", 0.97, { repeated: [header] }),
|
|
),
|
|
)
|
|
for (const request of app.model.requests) {
|
|
expect(userParts(request as Record<string, unknown>)[0]?.text).not.toContain("ACME")
|
|
}
|
|
expect(detail.lines.map((line) => line.label)).toEqual(["Reference", "Issued on"])
|
|
})
|
|
|
|
it("puts the reader's block id in the evidence of a line, and its block's box", async () => {
|
|
const { project, detail } = await run(undefined, onePage(["Reference: QR-1", "Issued on: 2026-04-01"]))
|
|
expect(detail.lines.map((line) => line.blockId)).toEqual(["p1-b1", "p1-b2"])
|
|
expect(detail.lines[1]?.box).toEqual({ x: 0.09, y: 130 / 1400, width: 0.61, height: 50 / 1400 })
|
|
const proposals = (await project.call.callByName("proposals.proposal.list", {
|
|
...project.slugs,
|
|
status: "pending",
|
|
})) as { proposals: Array<{ evidence: Array<{ blockId?: string }> }> }
|
|
expect(proposals.proposals.flatMap((proposal) => proposal.evidence.map((item) => item.blockId)).sort()).toEqual([
|
|
"p1-b1",
|
|
"p1-b2",
|
|
])
|
|
})
|
|
})
|
|
|
|
describe("the pages left out", () => {
|
|
const labels = Array.from({ length: 300 }, (_, at) => `A-${at} 2400`)
|
|
|
|
it("never asks the model about a drawing or an empty page, and reports them on the document", async () => {
|
|
const { app, detail } = await run(
|
|
undefined,
|
|
documentOf(
|
|
pageOf(0, [SHEET_ROW]),
|
|
pageOf(1, labels, "text_layer", 0.97, { pageClass: "drawing" }),
|
|
pageOf(2, [], "ocr", 0.97, { pageClass: "empty" }),
|
|
),
|
|
)
|
|
expect(app.model.requests).toHaveLength(1)
|
|
expect(detail.document).toMatchObject({
|
|
status: "extracted",
|
|
skippedPages: [
|
|
{ page: 2, pageClass: "drawing" },
|
|
{ page: 3, pageClass: "empty" },
|
|
],
|
|
})
|
|
expect(detail.lines.map((line) => line.value)).toEqual(["QR-2026-0417 (rev 2)"])
|
|
// no image was drawn for a page nobody reads
|
|
expect(await pagesDrawn(app)).toEqual([])
|
|
})
|
|
|
|
it("reads a scan beside a drawing: the scan produces lines, the drawing none", async () => {
|
|
const { detail } = await run(
|
|
undefined,
|
|
documentOf(
|
|
pageOf(0, labels, "text_layer", 0.97, { pageClass: "drawing" }),
|
|
pageOf(1, ["Delivery no: DN-77", "Received by: A. Sample"], "ocr", 0.8),
|
|
),
|
|
)
|
|
expect(detail.lines.map((line) => [line.page, line.value])).toEqual([
|
|
[2, "DN-77"],
|
|
[2, "A. Sample"],
|
|
])
|
|
expect(detail.document.skippedPages).toEqual([{ page: 1, pageClass: "drawing" }])
|
|
})
|
|
|
|
it("reads a drawing when the deployment's list does not exclude it", async () => {
|
|
const app = await makeDocumentsApp({
|
|
document: documentOf(pageOf(0, ["Scale: 1:50"], "ocr", 0.9, { pageClass: "drawing" })),
|
|
excludedPageClasses: [],
|
|
})
|
|
const project = await app.seedProject("acme", "owner@acme.test")
|
|
const file = await uploadAndProcess(app, project, "plan.pdf", "application/pdf")
|
|
const detail = (await project.call.callByName("documents.document.get", {
|
|
...project.slugs,
|
|
fileId: file.fileId,
|
|
})) as Detail
|
|
expect(detail.document.skippedPages).toEqual([])
|
|
expect(detail.lines.map((line) => line.value)).toEqual(["1:50"])
|
|
})
|
|
})
|
|
|
|
describe("a quote taken from the image of a scanned page", () => {
|
|
it("stays needs_review with the scanned-quote note when the page text does not hold it", async () => {
|
|
const { detail } = await run(
|
|
() => reply([{ label: "Qty", value: "12", quote: "Qty 12 pcs" }]),
|
|
onePage(["Delivery note"], "ocr", 0.7),
|
|
)
|
|
expect(detail.lines).toHaveLength(1)
|
|
expect(detail.lines[0]).toMatchObject({ verification: "needs_review", note: "scanned_quote", confidence: 0 })
|
|
})
|
|
|
|
it("carries no note for a quote the text holds", async () => {
|
|
const { detail } = await run(undefined, onePage(["Reference: QR-1"], "ocr", 0.7))
|
|
expect(detail.lines[0]).toMatchObject({ verification: "verified", note: null, blockId: "p1-b1" })
|
|
})
|
|
})
|
|
|
|
describe("what is done with the answer", () => {
|
|
const rows = ["Sąskaita: INV-2026-07", "Suma: 1 250,00 EUR", "Pastaba: apmokėti per 14 dienų"]
|
|
|
|
it("verifies each quote on the page it was asked about, folding only whitespace, case and diacritics", async () => {
|
|
const { detail } = await run(
|
|
() =>
|
|
reply([
|
|
{ label: "Sąskaita", value: "INV-2026-07", quote: "Sąskaita: INV-2026-07" },
|
|
// case, diacritics and whitespace differ from the page: still the same words
|
|
{ label: "Suma", value: "1 250,00 EUR", quote: "suma: 1 250,00\n eur" },
|
|
{ label: "Pastaba", value: "apmokėti per 14 dienų", quote: "PASTABA: APMOKETI PER 14 DIENU" },
|
|
]),
|
|
onePage(rows),
|
|
)
|
|
expect(detail.lines.map((line) => [line.label, line.verification])).toEqual([
|
|
["Sąskaita", "verified"],
|
|
["Suma", "verified"],
|
|
["Pastaba", "verified"],
|
|
])
|
|
// a verified line is located from the reader's own lines, and carries the reader's confidence
|
|
expect(detail.lines[0]?.box).not.toBeNull()
|
|
expect(detail.lines[0]?.confidence).toBe(0.97)
|
|
expect(detail.lines.every((line) => line.page === 1 && line.status === "pending")).toBe(true)
|
|
})
|
|
|
|
it("keeps a line whose quote is not on the page as needs_review, with no box and no confidence", async () => {
|
|
const { detail } = await run(
|
|
() =>
|
|
reply([
|
|
{ label: "Sąskaita", value: "INV-2026-07", quote: "Sąskaita: INV-2026-07" },
|
|
// a changed digit is a different word: folding does not forgive it
|
|
{ label: "Suma", value: "1 520,00 EUR", quote: "Suma: 1 520,00 EUR" },
|
|
{ label: "Made up", value: "x", quote: "Nothing like this is written here" },
|
|
]),
|
|
onePage(rows),
|
|
)
|
|
expect(detail.lines.map((line) => [line.label, line.verification])).toEqual([
|
|
["Sąskaita", "verified"],
|
|
["Suma", "needs_review"],
|
|
["Made up", "needs_review"],
|
|
])
|
|
const flagged = detail.lines.filter((line) => line.verification === "needs_review")
|
|
expect(flagged.every((line) => line.box === null && line.confidence === 0 && line.status === "pending")).toBe(true)
|
|
// kept, not dropped and not approved: the document is waiting for a person
|
|
expect(detail.document).toMatchObject({ status: "extracted", pending: 3 })
|
|
})
|
|
|
|
it("verifies against the page the model was asked about, not against another page", async () => {
|
|
const two = {
|
|
...onePage(["Reference: QR-1"]),
|
|
page_count: 2,
|
|
pages: [...onePage(["Reference: QR-1"]).pages, { ...onePage(["Total: 5 EUR"]).pages[0], index: 1 }],
|
|
}
|
|
// the model answers every page with page 1's line: on page 2 it is not there
|
|
const { detail } = await run(() => reply([{ label: "Reference", value: "QR-1", quote: "Reference: QR-1" }]), two)
|
|
expect(detail.lines.map((line) => [line.page, line.verification])).toEqual([
|
|
[1, "verified"],
|
|
[2, "needs_review"],
|
|
])
|
|
})
|
|
|
|
it("drops a line the model repeats", async () => {
|
|
const line = { label: "Reference", value: "QR-1", quote: "Reference: QR-1" }
|
|
const { detail } = await run(() => reply([line, line]), onePage(["Reference: QR-1"]))
|
|
expect(detail.lines).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe("when the model does not answer", () => {
|
|
const document = onePage(["Reference: QR-1"])
|
|
|
|
it("a refusal is asked again once, then ends the document with a typed code and no proposals", async () => {
|
|
const { app, detail } = await run(() => ({ content: null, refusal: "I cannot help with that." }), document)
|
|
expect(app.model.requests).toHaveLength(2)
|
|
expect(detail.document).toMatchObject({ status: "failed", failureCode: "extraction_refused" })
|
|
expect(detail.lines).toEqual([])
|
|
})
|
|
|
|
it("a reply cut off at the token ceiling is asked again with more room, and the second answer is used", async () => {
|
|
let calls = 0
|
|
const { app, detail } = await run((request) => {
|
|
calls += 1
|
|
return calls === 1 ? { content: '{"lines": [{"label"', finishReason: "length" } : deterministicModel(request)
|
|
}, document)
|
|
expect(app.model.requests.map((request) => request.max_tokens)).toEqual([4096, 8192])
|
|
expect(detail.document.status).toBe("extracted")
|
|
expect(detail.lines).toHaveLength(1)
|
|
})
|
|
|
|
it("a reply that is always cut off ends the document as truncated", async () => {
|
|
const { app, detail } = await run(() => ({ content: '{"lines": [', finishReason: "length" }), document)
|
|
expect(app.model.requests).toHaveLength(2)
|
|
expect(detail.document).toMatchObject({ status: "failed", failureCode: "extraction_truncated" })
|
|
})
|
|
|
|
it("a reply outside the schema is asked again once, by the gateway, and then ends the document as invalid", async () => {
|
|
const { app, detail } = await run(() => ({ content: JSON.stringify({ lines: "none" }) }), document)
|
|
// two asks in all: the gateway's retry with the validation errors is the one retry
|
|
expect(app.model.requests).toHaveLength(2)
|
|
expect(detail.document).toMatchObject({ status: "failed", failureCode: "extraction_invalid" })
|
|
const retry = messagesOf(app.model.requests[1] as Record<string, unknown>).at(-1)
|
|
expect(retry?.content).toContain("did not match the required schema")
|
|
})
|
|
|
|
it("records no document text in the failure: the code is all the document keeps", async () => {
|
|
const { detail } = await run(() => ({ content: null, refusal: "Reference: QR-1" }), document)
|
|
expect(JSON.stringify(detail.document)).not.toContain("QR-1")
|
|
})
|
|
})
|
|
|
|
describe("the cache", () => {
|
|
it("a file with the same bytes is neither read nor asked about again", async () => {
|
|
const { app, project } = await run(undefined, onePage(["Reference: QR-1", "Issued on: 2026-04-01"]))
|
|
expect(app.ocr.submitted).toHaveLength(1)
|
|
expect(app.model.requests).toHaveLength(1)
|
|
const stored = app.store.keysOf({ level: "project", orgId: project.orgId } as never)
|
|
// the OCR result, the job it came from (so its pages can still be drawn), and the page's model reply
|
|
expect(stored.filter((key) => key.startsWith("documents/cache/"))).toHaveLength(3)
|
|
|
|
// the same bytes under another name: a second file, the same content hash
|
|
const second = await uploadViaProcedures(project.call, project.slugs, {
|
|
filename: "copy.pdf",
|
|
contentType: "application/pdf",
|
|
bytes: bytesOf("%PDF-1.4 fixture report.pdf"),
|
|
})
|
|
await app.deliver()
|
|
await app.runJobs()
|
|
|
|
expect(app.ocr.submitted).toHaveLength(1)
|
|
expect(app.model.requests).toHaveLength(1)
|
|
const view = (await project.call.callByName("documents.document.get", {
|
|
...project.slugs,
|
|
fileId: second.fileId,
|
|
})) as Detail
|
|
expect(view.lines.map((line) => line.value)).toEqual(["QR-1", "2026-04-01"])
|
|
})
|
|
|
|
it("a change of the page's text is a different request", async () => {
|
|
const { app, project } = await run(undefined, onePage(["Reference: QR-1"]))
|
|
app.platform.ocr.setDocument(onePage(["Reference: QR-2"]))
|
|
await uploadViaProcedures(project.call, project.slugs, {
|
|
filename: "other.pdf",
|
|
contentType: "application/pdf",
|
|
bytes: bytesOf("%PDF-1.4 something else"),
|
|
})
|
|
await app.deliver()
|
|
await app.runJobs()
|
|
expect(app.model.requests).toHaveLength(2)
|
|
})
|
|
})
|