@@ -0,0 +1,145 @@
|
||||
// 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<Pick<OcrItems, "submit" | "wait" | "feedback">>("documents.items", {
|
||||
access: "effect",
|
||||
})
|
||||
/** The profile the run uses; without a binding, `DEFAULT_ITEMS_PROFILE`. */
|
||||
export const DOCUMENT_ITEMS_PROFILE = token<string>("documents.items-profile")
|
||||
|
||||
export const DEFAULT_ITEMS_PROFILE = "invoice"
|
||||
|
||||
export interface PlatformItemsExtractorOptions {
|
||||
readonly items: Pick<OcrItems, "submit" | "wait">
|
||||
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<ReadPage, "width" | "height"> | 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<ItemCitation, { kind: "pdf_page" }>
|
||||
|
||||
/**
|
||||
* 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<DocumentItems, "run_id" | "models">,
|
||||
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<PageClass>(options.excludedPageClasses ?? DEFAULT_EXCLUDED_PAGE_CLASSES)
|
||||
return {
|
||||
name: `platform-items:${profile}`,
|
||||
version: `items.${profile}.v1`,
|
||||
async extract(input: ExtractionInput): Promise<Extraction> {
|
||||
// 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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user