// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // The documents extraction eval (plan item AT2.3), as a test. // // Offline, which is what `pnpm test` and CI run: the golden set goes through the real extractor and the // real gateway client to the fake platform, whose model is the deterministic reader of // test/support/fake-model.ts. A page the extractor wants to show the model is drawn through the fake // platform's page route (`GET /v1/ocr/jobs/{id}/pages/{n}.png`) by the same OCR adapter the worker uses. // That measures this module's code (request, schema, page plan, verification, scoring), against // thresholds of 100%, and it must be able to fail, which the negative controls below prove. // // Live, `EVAL_LIVE=1 pnpm eval`: the same set and the same scorer against the platform's AI gateway // (CONUSAI_API_URL, CONUSAI_API_TOKEN, and AI_MODEL for the model), against the live thresholds of // thresholds.json. It costs credits and is not run by the gate. import { readFileSync } from "node:fs" import { aiModelOf } from "@app/config" import type { ExtractionInput } from "@app/modules/documents" import { LOW_TRUST_NOTE, modelLineExtractor, sdkDocumentOcr } from "@app/modules/documents" import { createDocumentReader, createModelGateway, type ModelGateway } from "@conusai-kit/platform" import { type FakeChatResponder, startFakePlatform } from "@conusai-kit/testing/fake-platform" import { afterAll, beforeAll, describe, expect, it } from "vitest" import { parse } from "yaml" import { deterministicModel } from "../../test/support/fake-model.js" import { aggregate, type CaseResult, extractCase, failuresOf, type GoldenCase, loadCases, loadThresholds, readDocumentOf, report, scoreCase, } from "./harness.js" const live = process.env.EVAL_LIVE === "1" const cases = loadCases() const thresholds = loadThresholds() async function evaluate( gateway: ModelGateway, model: string, pageImageFor?: (golden: GoldenCase) => Promise, ) { const extractor = modelLineExtractor({ gateway, model }) const results: CaseResult[] = [] for (const golden of cases) { const extraction = await extractCase(extractor, golden, pageImageFor ? await pageImageFor(golden) : undefined) results.push(scoreCase(golden, extraction)) } return { results, metrics: aggregate(results) } } describe("the golden set", () => { it("is small, generic and in both languages", () => { expect(cases.length).toBeGreaterThanOrEqual(5) expect(new Set(cases.map((golden) => golden.id)).size).toBe(cases.length) expect(new Set(cases.map((golden) => golden.language))).toEqual(new Set(["en", "lt"])) }) it("is the set the promptfoo config runs", () => { const config = parse(readFileSync(new URL("./promptfooconfig.yaml", import.meta.url), "utf8")) as { tests: Array<{ vars: { case: string } }> } expect(config.tests.map((test) => test.vars.case).sort()).toEqual(cases.map((golden) => golden.id).sort()) }) it("has a scanned PDF with a drawing page to leave out and a scan page to read, in both languages", () => { const withDrawing = cases.find((golden) => golden.skipped?.some((page) => page.pageClass === "drawing")) expect(withDrawing?.pages.some((page) => page.class === "scan")).toBe(true) expect(withDrawing?.expected.length).toBeGreaterThan(0) expect(withDrawing?.expected.every((line) => line.page !== withDrawing.skipped?.[0]?.page)).toBe(true) const scans = cases.filter((golden) => golden.pages.some((page) => page.class === "scan")) expect(new Set(scans.map((golden) => golden.language))).toEqual(new Set(["en", "lt"])) }) it("expects only lines that are on its own pages, so a perfect extractor scores 100%", () => { for (const golden of cases) { for (const wanted of golden.expected) { const text = readDocumentOf(golden).pages[wanted.page - 1]?.text ?? "" expect(text, `${golden.id}: ${wanted.label}`).toContain(wanted.label) expect(text, `${golden.id}: ${wanted.label}`).toContain(wanted.value) } } }) }) describe.skipIf(live)("offline, against the fake gateway", () => { let platform: Awaited> let gateway: ModelGateway beforeAll(async () => { platform = await startFakePlatform() gateway = createModelGateway({ CONUSAI_API_URL: `${platform.url}/api`, CONUSAI_API_TOKEN: "eval" }) }) afterAll(async () => { await platform.close() }) /** The requests the model saw, newest run only. */ let requests: Array> = [] /** * Draws a case's pages the way the worker does: the case is a job of the fake platform (its document * is the OCR document the case stands for), and a page is fetched from that job's page route. */ const pagesThroughPlatform = async (golden: GoldenCase): Promise => { platform.ocr.setDocument({ schema_version: "conusai.ocr.document/v1", ...readDocumentOf(golden) }) const ocr = sdkDocumentOcr( createDocumentReader({ CONUSAI_API_URL: `${platform.url}/api`, CONUSAI_API_TOKEN: "eval" }), { intervalMs: 1, }, ) const { jobId } = await ocr.submit(new Uint8Array([1]), { filename: `${golden.id}.pdf`, idempotencyKey: golden.id }) await ocr.wait(jobId) return (index) => ocr.page(jobId, index + 1) } const run = async (responder: FakeChatResponder) => { requests = [] platform.ai.respond((request) => { requests.push(request) return responder(request) }) return evaluate(gateway, "eval-model", pagesThroughPlatform) } type Part = { type: string; text?: string } const partsOf = (request: Record): Part[] => { const user = (request.messages as Array<{ role: string; content: string | Part[] }>).find( (message) => message.role === "user", ) return typeof user?.content === "string" ? [] : (user?.content ?? []) } it("scores line recall, precision and quote verification above the thresholds", async () => { const { results, metrics } = await run(deterministicModel) console.log(report(results, metrics)) expect(failuresOf(metrics, thresholds.offline)).toEqual([]) // A page skipped or read against the case's word is a failure even when the ratios pass. expect(results.flatMap((result) => result.skippedWrongly)).toEqual([]) expect(metrics.expected).toBeGreaterThan(30) }) it("draws the scanned pages through the platform's page route and sends them with their text, and nothing else", async () => { await run(deterministicModel) const scans = cases .flatMap((golden) => golden.pages) .filter((page) => (page.class ?? (page.source === "ocr" ? "scan" : "text")) === "scan") expect(scans.length).toBeGreaterThanOrEqual(3) const withImage = requests.filter((request) => partsOf(request).some((part) => part.type === "image_url")) expect(withImage).toHaveLength(scans.length) for (const request of withImage) expect(partsOf(request)[0]?.text).toContain(LOW_TRUST_NOTE) const state = (await (await fetch(`${platform.url}/__e2e/state`)).json()) as { ocrPages: unknown[] } expect(state.ocrPages.length).toBeGreaterThanOrEqual(scans.length) }) it("never asks about a drawing, an empty page or a repeated line", async () => { await run(deterministicModel) const texts = requests.map((request) => partsOf(request)[0]?.text ?? "") expect(texts.some((text) => text.includes("A-100 2400"))).toBe(false) expect(texts.some((text) => text.includes("Customer: Example Ltd"))).toBe(false) expect(texts.some((text) => text.includes("Puslapio poraštė"))).toBe(false) }) it("fails when a drawing is read: the lines nobody expected cost precision", async () => { const reading = modelLineExtractor({ gateway, model: "eval-model", excludedPageClasses: [] }) platform.ai.respond(deterministicModel) const results: CaseResult[] = [] for (const golden of cases) { results.push(scoreCase(golden, await extractCase(reading, golden, await pagesThroughPlatform(golden)))) } const metrics = aggregate(results) expect(metrics.precision).toBeLessThan(thresholds.offline.precision) expect(results.flatMap((result) => result.skippedWrongly).join(" ")).toContain("not skipped") }) it("fails when the model's quotes are not on the page: the verification is measured, not assumed", async () => { // every digit of every quote shifted by one: plausible-looking lines whose words are not on the page const { metrics } = await run((request) => { const reply = deterministicModel(request) const body = JSON.parse(reply.content ?? "{}") as { lines?: Array<{ quote: string }> } for (const line of body.lines ?? []) line.quote = line.quote.replace(/\d/g, (digit) => String((Number(digit) + 1) % 10)) return { content: JSON.stringify(body) } }) expect(metrics.verification).toBeLessThan(thresholds.offline.verification) expect(failuresOf(metrics, thresholds.offline).join(" ")).toContain("verification") }) it("fails when the model finds nothing: the recall is measured", async () => { const { metrics } = await run(() => ({ content: JSON.stringify({ lines: [] }) })) expect(metrics.recall).toBe(0) expect(failuresOf(metrics, thresholds.offline).join(" ")).toContain("recall") }) it("fails when the model invents lines: the precision is measured", async () => { const { metrics } = await run((request) => { const reply = deterministicModel(request) const body = JSON.parse(reply.content ?? "{}") as { lines?: Array<{ label: string; value: string; quote: string }> } body.lines = [...(body.lines ?? []), { label: "Invented", value: "1", quote: "Invented: 1" }] return { content: JSON.stringify(body) } }) expect(metrics.precision).toBeLessThan(thresholds.offline.precision) }) }) describe.skipIf(!live)("live, against the platform's AI gateway", () => { it("scores line recall, precision and quote verification above the live thresholds", async () => { const url = process.env.CONUSAI_API_URL const token = process.env.CONUSAI_API_TOKEN if (!url || !token) throw new Error("EVAL_LIVE=1 needs CONUSAI_API_URL and CONUSAI_API_TOKEN") const model = aiModelOf() const { results, metrics } = await evaluate( createModelGateway({ CONUSAI_API_URL: url, CONUSAI_API_TOKEN: token }, { model }), model, ) console.log(`model: ${model}\n${report(results, metrics)}`) expect(failuresOf(metrics, thresholds.live)).toEqual([]) }, 600_000) })