// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // The evaluation of the documents module's extraction: a small golden set of synthetic documents, a // scorer, and thresholds kept in the repository. It is written against the `LineExtractor` port and // imports nothing from an app alias, so the vitest suite (offline, against the fake gateway) and the // promptfoo provider (live, against the real one) run the same cases through the same scorer. // // Three numbers, each over the whole set: // recall of the lines a person would expect, the share the extractor proposed (same page, and // the same label and value once whitespace, case and diacritics are folded) // precision of the lines it proposed, the share that were expected // verification of the lines it proposed, the share whose quote is on the page it cites. This is // checked here again, from the case's own text, and is not read from the extractor's // claim: a verification that trusted the verifier would measure nothing. // // A case also says which of its pages the extraction must leave out (a drawing, an empty page) and which // rows the reader flags as repeating (a running header): a drawing read anyway adds lines nobody // expected and costs precision, and a repeated row read anyway does the same. // // Every document is invented for this purpose: no real, customer or personal data. import { readdirSync, readFileSync } from "node:fs" import type { PageClass } from "../../modules/documents/contract/constants.js" import type { Extraction, ExtractionInput, LineExtractor, ReadDocument, SkippedPage, } from "../../modules/documents/core/ports.js" import type { StageCache } from "../../modules/documents/core/stage-cache.js" import { foldForComparison, quoteIsOnPage } from "../../modules/documents/core/verify.js" export interface GoldenPage { readonly rows: readonly string[] readonly source?: "text_layer" | "ocr" readonly confidence?: number /** What the reader made of the page; by default `scan` for a recognised page and `text` for a text layer. */ readonly class?: PageClass /** The rows the reader flags as repeating on most pages of the document (headers, footers). */ readonly repeated?: readonly string[] } export interface ExpectedLine { readonly page: number readonly label: string readonly value: string } export interface GoldenCase { readonly id: string readonly language: "en" | "lt" readonly description: string readonly pages: readonly GoldenPage[] readonly expected: readonly ExpectedLine[] /** The pages the extraction must leave out, 1-based, with the class that leaves each out. */ readonly skipped?: readonly SkippedPage[] } export interface Thresholds { readonly recall: number readonly precision: number readonly verification: number } export interface EvalThresholds { readonly offline: Thresholds readonly live: Thresholds } const here = (path: string): URL => new URL(path, import.meta.url) export const loadThresholds = (): EvalThresholds => JSON.parse(readFileSync(here("./thresholds.json"), "utf8")) as EvalThresholds export function loadCases(): GoldenCase[] { return readdirSync(here("./golden/")) .filter((name) => name.endsWith(".json")) .sort() .map((name) => JSON.parse(readFileSync(here(`./golden/${name}`), "utf8")) as GoldenCase) } /** * The OCR document a case stands for: one line per row, forty pixels apart, on an A4 page at 150 dpi, * one block per line with the reader's id `p{n}-b{m}`, and the rows the case lists as repeated flagged. */ export function readDocumentOf(golden: GoldenCase): ReadDocument { return { engine: { name: "golden-set", version: "1" }, page_count: golden.pages.length, pages: golden.pages.map((page, index) => ({ index, width: 1240, height: 1754, class: page.class ?? (page.source === "ocr" ? "scan" : "text"), source: page.source ?? "text_layer", text: page.rows.join("\n"), lines: page.rows.map((text, order) => ({ text, bbox: [100, 100 + order * 40, 1100, 130 + order * 40] as const, confidence: page.confidence ?? 0.97, order, repeated: page.repeated?.includes(text) ?? false, })), blocks: page.rows.map((_, order) => ({ id: `p${index + 1}-b${order + 1}`, bbox: [90, 90 + order * 40, 1110, 140 + order * 40] as const, order, lines: [order], })), })), } } /** No cache: an evaluation asks the model every time, which is the point of running one. */ export const noCache: StageCache = { async get() { return undefined }, async put() {}, } /** `pageImage` is how the case's pages are drawn: through the platform's page route offline, none live. */ export async function extractCase( extractor: LineExtractor, golden: GoldenCase, pageImage: ExtractionInput["pageImage"] = async () => undefined, ): Promise { return extractor.extract({ document: readDocumentOf(golden), pageImage, cache: noCache }) } export interface CaseResult { readonly id: string readonly expected: number readonly found: number readonly matched: number readonly onPage: number /** The expected lines nobody proposed, and the proposed lines nobody expected, for the report. */ readonly missed: readonly string[] readonly extra: readonly string[] /** The pages the case says must be left out that were not, and the pages left out that the case did not say. */ readonly skippedWrongly: readonly string[] } const same = (a: string, b: string): boolean => foldForComparison(a) === foldForComparison(b) const describeLine = (line: { page: number; label: string; value: string }): string => `p${line.page} ${line.label} = ${line.value}` const describeSkipped = (skipped: SkippedPage): string => `p${skipped.page} ${skipped.pageClass}` export function scoreCase(golden: GoldenCase, extraction: Extraction): CaseResult { const found = extraction.lines const pending = [...found] const missed: string[] = [] let matched = 0 for (const wanted of golden.expected) { const at = pending.findIndex( (line) => line.page === wanted.page && same(line.label, wanted.label) && same(line.value, wanted.value), ) if (at < 0) { missed.push(describeLine(wanted)) continue } pending.splice(at, 1) matched += 1 } const onPage = found.filter((line) => { const page = golden.pages[line.page - 1] return page !== undefined && quoteIsOnPage(page.rows.join("\n"), line.quote) }).length return { id: golden.id, expected: golden.expected.length, found: found.length, matched, onPage, missed, extra: pending.map(describeLine), skippedWrongly: [ ...(golden.skipped ?? []) .filter( (wanted) => !extraction.skipped.some((one) => one.page === wanted.page && one.pageClass === wanted.pageClass), ) .map((one) => `not skipped: ${describeSkipped(one)}`), ...extraction.skipped .filter( (one) => !(golden.skipped ?? []).some((wanted) => wanted.page === one.page && wanted.pageClass === one.pageClass), ) .map((one) => `skipped: ${describeSkipped(one)}`), ], } } export interface Metrics extends Thresholds { readonly expected: number readonly found: number } const ratio = (part: number, whole: number): number => (whole === 0 ? 1 : part / whole) export function aggregate(results: readonly CaseResult[]): Metrics { const sum = (pick: (result: CaseResult) => number): number => results.reduce((total, result) => total + pick(result), 0) return { expected: sum((result) => result.expected), found: sum((result) => result.found), recall: ratio( sum((result) => result.matched), sum((result) => result.expected), ), precision: ratio( sum((result) => result.matched), sum((result) => result.found), ), verification: ratio( sum((result) => result.onPage), sum((result) => result.found), ), } } /** What is below its threshold, as sentences; empty when the run passes. */ export function failuresOf(metrics: Thresholds, thresholds: Thresholds): string[] { return (["recall", "precision", "verification"] as const) .filter((name) => metrics[name] < thresholds[name]) .map( (name) => `${name} ${(metrics[name] * 100).toFixed(1)}% is below the threshold ${(thresholds[name] * 100).toFixed(1)}%`, ) } export function report(results: readonly CaseResult[], metrics: Metrics): string { const pct = (value: number): string => `${(value * 100).toFixed(1)}%` const rows = results.map( (result) => ` ${result.id.padEnd(24)} expected ${String(result.expected).padStart(2)} found ${String(result.found).padStart(2)} matched ${String(result.matched).padStart(2)} quote on page ${result.onPage}/${result.found}` + [ ...result.missed.map((line) => `\n missed: ${line}`), ...result.extra.map((line) => `\n extra: ${line}`), ...result.skippedWrongly.map((line) => `\n pages: ${line}`), ].join(""), ) return [ "documents extraction eval", ...rows, ` recall ${pct(metrics.recall)} precision ${pct(metrics.precision)} verification ${pct(metrics.verification)} (${metrics.found} proposed, ${metrics.expected} expected)`, ].join("\n") }