// The line extractor that asks the platform: Document Items (ADR-069) behind the kit's `DocumentReader`. // // The platform already read the document (the job this module submitted); this extractor runs a profile // over that job (`items.submit` then `items.wait`) and maps each item of `conusai.documents.items/v1` to a // `CandidateLine`: the item's name as the label, its quantity and unit as printed as the value, and the // page, quote and box of its first citation (the box comes in the page's units and is kept as fractions of // the page this module read). The platform verifies its quotes; this module checks them again against that // page (core/verify.ts), so a line is `verified` only when the platform auto-accepted the item, no check // failed and the quote is on the page; anything else is proposed as `needs_review`. Each line keeps a // reference to its item (`itemRef`), so a reviewer's decision goes back as feedback (adapters/item-feedback.ts). // // It is chosen by the `documents.platform_items` flag (adapters/jobs.ts); `modelLineExtractor` stays the // fallback and the offline default. import { token } from "@conusai-kit/core" import type { DocumentItems, Item, ItemCitation, ItemClass, OcrItems } from "@conusai-kit/platform" import { DEFAULT_EXCLUDED_PAGE_CLASSES, type PageClass } from "../contract/index.js" import { type CandidateLine, type Extraction, ExtractionFailed, type ExtractionInput, type FractionBox, type LineExtractor, type ReadPage, type SkippedPage, } from "../core/ports.js" import { quoteIsOnPage } from "../core/verify.js" /** The platform's items run, as the worker binds it: the `items` member of the kit's `DocumentReader`. */ export const DOCUMENT_ITEMS = token>("documents.items", { access: "effect", }) /** The profile the run uses; without a binding, `DEFAULT_ITEMS_PROFILE`. */ export const DOCUMENT_ITEMS_PROFILE = token("documents.items-profile") export const DEFAULT_ITEMS_PROFILE = "invoice" export interface PlatformItemsExtractorOptions { readonly items: Pick readonly profile?: string /** Profiles the run links items to, `spec_sections`. */ readonly link?: string[] /** Item classes kept, `material`. */ readonly keep?: ItemClass[] readonly excludedPageClasses?: readonly PageClass[] /** Poll interval of `wait`, in milliseconds. */ readonly intervalMs?: number } /** A citation's box, `[x0, y0, x1, y1]` in the page's units, as fractions of the page it was read on. */ export function boxOf( bbox: readonly number[] | null | undefined, page: Pick | undefined, ): FractionBox | undefined { if (bbox?.length !== 4 || !page || !(page.width > 0 && page.height > 0)) return undefined const [x0, y0, x1, y1] = bbox as [number, number, number, number] if (!(x1 > x0 && y1 > y0)) return undefined const fraction = (value: number, whole: number) => Math.min(1, Math.max(0, value / whole)) const x = fraction(x0, page.width) const y = fraction(y0, page.height) return { x, y, width: fraction(x1, page.width) - x, height: fraction(y1, page.height) - y } } /** The label and value a reviewer sees: the item's name, and its quantity and unit as printed. */ export function labelAndValueOf(item: Item): { label: string; value: string } | undefined { const label = item.name.trim() const value = [item.quantity.text.trim(), item.unit.text.trim()].filter((part) => part !== "").join(" ") if (label === "" || value === "") return undefined return { label: label.slice(0, 200), value: value.slice(0, 2_000) } } /** A citation of a PDF page and its lines; the other kind cites a spreadsheet's cells. */ type PageCitation = Extract /** * One item as the line a person reviews; undefined for an item without a PDF page citation (this module * reads PDFs, so a sheet's cells are nothing it can show). It is `verified` only when the platform * auto-accepted it (reader and model agree, every check passed) and its quote is on the page this module * read. */ export function candidateOf( item: Item, result: Pick, pageOf: (page: number) => ReadPage | undefined, ): CandidateLine | undefined { const cited = item.citations.filter((one): one is PageCitation => one.kind === "pdf_page") const citation = cited[0] const shown = labelAndValueOf(item) if (!citation || !shown) return undefined const quote = cited .filter((one) => one.page === citation.page) .map((one) => one.quote) .join("\n") const page = pageOf(citation.page) const onPage = page !== undefined && quoteIsOnPage(page.text, quote) const checked = item.checks.every((check) => check.status !== "failed") const box = boxOf(citation.bbox, page) const model = result.models[0]?.model return { ...shown, page: citation.page, quote, ...(box ? { box } : {}), confidence: Math.min(1, Math.max(0, item.confidence)), ...(model ? { model } : {}), verification: onPage && checked && item.agreement.status === "auto_accepted" ? "verified" : "needs_review", ...(result.run_id === null ? {} : { itemRef: `${result.run_id}:${item.id}` }), } } export function platformItemsExtractor(options: PlatformItemsExtractorOptions): LineExtractor { const profile = options.profile ?? DEFAULT_ITEMS_PROFILE const excluded = new Set(options.excludedPageClasses ?? DEFAULT_EXCLUDED_PAGE_CLASSES) return { name: `platform-items:${profile}`, version: `items.${profile}.v1`, async extract(input: ExtractionInput): Promise { // The run reads the job this module submitted: without one there is nothing on the platform to read. if (input.ocrJobId === undefined || input.ocrJobId === null) throw new ExtractionFailed("extraction_invalid") const skipped: SkippedPage[] = input.document.pages .filter((page) => excluded.has(page.class)) .map((page) => ({ page: page.index + 1, pageClass: page.class })) const skippedPages = new Set(skipped.map((one) => one.page)) await input.beforeProvider?.() const run = await options.items.submit(input.ocrJobId, { profile, ...(options.link ? { link: options.link } : {}), ...(options.keep ? { keep: options.keep } : {}), // One run per job and profile: a retried job resumes the run it already started. idempotencyKey: `documents.items.${input.ocrJobId}.${profile}`, ...(input.signal ? { signal: input.signal } : {}), }) const result = await options.items.wait(run, { ...(options.intervalMs === undefined ? {} : { intervalMs: options.intervalMs }), ...(input.signal ? { signal: input.signal } : {}), }) const pageOf = (page: number) => input.document.pages.find((one) => one.index === page - 1) const lines = result.items .map((item) => candidateOf(item, result, pageOf)) .filter((line): line is CandidateLine => line !== undefined && !skippedPages.has(line.page)) return { lines, skipped } }, } }