176 lines
7.3 KiB
TypeScript
176 lines
7.3 KiB
TypeScript
// The ports the documents module's use cases are written against. Core imports no SDK (spec §2), so
|
|
// the shapes of an OCR result and of an extractor are declared here, structurally: the SDK's own
|
|
// `OcrDocument` is assignable to `ReadDocument`, and the adapter in adapters/ocr.ts is the one place
|
|
// that knows the SDK exists.
|
|
|
|
import { type TenantScope, token } from "@conusai-kit/core"
|
|
import type { FailureCode, LineNote, LineVerification, PageClass } from "../contract/index.js"
|
|
import type { StageCache } from "./stage-cache.js"
|
|
|
|
/** `[x0, y0, x1, y1]` in pixels of the page image, origin top-left. */
|
|
export type PixelBox = readonly [number, number, number, number]
|
|
|
|
export interface ReadLine {
|
|
/** The line as the reader returned it. A quote is this string, unchanged. */
|
|
readonly text: string
|
|
readonly bbox: PixelBox
|
|
/** 0 to 1. */
|
|
readonly confidence: number
|
|
/** Reading order within the page. */
|
|
readonly order: number
|
|
/** True when the line's text is on most pages of the document: a running header, footer or stamp, not the document's content. */
|
|
readonly repeated: boolean
|
|
}
|
|
|
|
/** A run of lines the reader grouped, with the stable id a citation points at. */
|
|
export interface ReadBlock {
|
|
/** `p{page}-b{n}`: the 1-based page of the source document and the block's place in reading order. */
|
|
readonly id: string
|
|
readonly bbox: PixelBox
|
|
readonly order: number
|
|
/** Indexes into the page's `lines`. */
|
|
readonly lines: readonly number[]
|
|
}
|
|
|
|
export interface ReadPage {
|
|
/** 0-based position in the source document. */
|
|
readonly index: number
|
|
readonly width: number
|
|
readonly height: number
|
|
/** What the page is mostly made of; decides whether the extraction reads it, and whether it is shown the image. */
|
|
readonly class: PageClass
|
|
/** `text_layer`: read from the file's own text; `ocr`: recognised from the page image (a scan). */
|
|
readonly source?: "ocr" | "text_layer" | "spreadsheet" | undefined
|
|
/** The page's text in reading order, one row per line. */
|
|
readonly text: string
|
|
readonly lines: readonly ReadLine[]
|
|
readonly blocks: readonly ReadBlock[]
|
|
}
|
|
|
|
export interface ReadDocument {
|
|
readonly engine: { readonly name: string; readonly version: string }
|
|
readonly page_count: number
|
|
readonly pages: readonly ReadPage[]
|
|
}
|
|
|
|
/** A box as fractions of the page, so it survives a render at any width (kit-proposals AT-D27). */
|
|
export interface FractionBox {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly width: number
|
|
readonly height: number
|
|
}
|
|
|
|
/** What an extractor proposes: a value, where it was read, and the words it was read from. */
|
|
export interface CandidateLine {
|
|
readonly label: string
|
|
readonly value: string
|
|
/** 1-based. */
|
|
readonly page: number
|
|
/** What the extractor quoted: verbatim from the page text, unless `verification` says it was not found there. */
|
|
readonly quote: string
|
|
/** The reader's id of the block the quote sits in, when the quote was found on the page's lines. */
|
|
readonly blockId?: string
|
|
/** The block's box (or, without a block, the box of the lines the quote falls on). */
|
|
readonly box?: FractionBox
|
|
/** The reader's confidence in the words, 0 to 1. */
|
|
readonly confidence: number
|
|
/** Set by code, never by a model: was the quote found on the page it cites. */
|
|
readonly verification: LineVerification
|
|
/** Why a reviewer should look at the line besides `verification`. */
|
|
readonly note?: LineNote
|
|
}
|
|
|
|
/** The image of one page, for a model that must read what the text layer could not. */
|
|
export interface PageImage {
|
|
readonly bytes: Uint8Array
|
|
/** `image/png` or `image/jpeg`. */
|
|
readonly mediaType: string
|
|
}
|
|
|
|
/** What an extractor is given: the read document, and what it needs to avoid paying twice. */
|
|
export interface ExtractionInput {
|
|
readonly document: ReadDocument
|
|
/** The image of a 0-based page, or undefined when there is none to give. */
|
|
readonly pageImage: (pageIndex: number) => Promise<PageImage | undefined>
|
|
/** Model replies are cached here by content hash, so a retried job pays for nothing twice. */
|
|
readonly cache: StageCache
|
|
readonly signal?: AbortSignal
|
|
}
|
|
|
|
/** The failure codes an extraction can end a document with. */
|
|
export type ExtractionFailureCode = Extract<FailureCode, `extraction_${string}`>
|
|
|
|
/**
|
|
* A permanent failure of the extraction, as a code the document records: the model declined
|
|
* (`extraction_refused`), kept running into its token ceiling (`extraction_truncated`) or never
|
|
* answered in the schema (`extraction_invalid`), each after one retry. Anything else an extractor
|
|
* throws is transient, and the queue retries the job.
|
|
*/
|
|
export class ExtractionFailed extends Error {
|
|
constructor(public readonly code: ExtractionFailureCode) {
|
|
super(`the extraction failed: ${code}`)
|
|
this.name = "ExtractionFailed"
|
|
}
|
|
}
|
|
|
|
/** A page the extraction did not read, and the class that left it out. */
|
|
export interface SkippedPage {
|
|
/** 1-based. */
|
|
readonly page: number
|
|
readonly pageClass: PageClass
|
|
}
|
|
|
|
/** What an extractor produced: the lines it proposes, and the pages it left out on purpose. */
|
|
export interface Extraction {
|
|
readonly lines: readonly CandidateLine[]
|
|
readonly skipped: readonly SkippedPage[]
|
|
}
|
|
|
|
/**
|
|
* Turns a read document into candidate lines. The template ships one behind the `ModelGateway`
|
|
* port (adapters/model-extractor.ts); a product replaces it -- a table reader, a domain's own rules,
|
|
* a different prompt -- by passing its own to the job, and nothing else in the module changes.
|
|
*/
|
|
export interface LineExtractor {
|
|
/** Recorded on every proposal as its prompt version: `<name>.v<n>`. */
|
|
readonly version: string
|
|
/** Recorded on every proposal as its producing model, besides the OCR engine. */
|
|
readonly name: string
|
|
/** Throws `ExtractionFailed` for a failure no retry would change. */
|
|
extract(input: ExtractionInput): Promise<Extraction>
|
|
}
|
|
|
|
/**
|
|
* Asynchronous OCR: `submit` returns when the platform has accepted the document, `wait` when it
|
|
* has read it. Two calls, because a retried job resumes a job it already submitted instead of
|
|
* paying for the document twice.
|
|
*/
|
|
export interface DocumentOcr {
|
|
submit(bytes: Uint8Array, options: { filename: string; idempotencyKey: string }): Promise<{ jobId: number }>
|
|
/** Throws `OcrJobFailed` when the platform says the job failed, which no retry will change. */
|
|
wait(jobId: number, options?: { signal?: AbortSignal }): Promise<ReadDocument>
|
|
/**
|
|
* The image of a 1-based page of a finished job, drawn by the platform. Undefined when the platform
|
|
* has none to give (the job's input expired, the page is past the end): the page is then read from its text.
|
|
*/
|
|
page(jobId: number, page: number, options?: { signal?: AbortSignal }): Promise<PageImage | undefined>
|
|
}
|
|
|
|
/** The platform read the document and could not: permanent, so the document is marked failed. */
|
|
export class OcrJobFailed extends Error {
|
|
constructor(message = "the OCR job failed") {
|
|
super(message)
|
|
this.name = "OcrJobFailed"
|
|
}
|
|
}
|
|
|
|
// An OCR read is metered and leaves the process: only an effect (a job) may resolve it.
|
|
export const DOCUMENT_OCR = token<DocumentOcr>("documents.ocr", { access: "effect" })
|
|
|
|
/**
|
|
* The project a job runs in, with a system actor. The worker binds it per job from the enqueueing
|
|
* scope (the same arrangement as `FILES_SCOPE`, decision AT-F6): a payload never names a tenant.
|
|
*/
|
|
export const DOCUMENTS_SCOPE = token<TenantScope>("documents.scope")
|