Skip to content

Concepts

scriva is built on two pillars: a Worker that turns a crop into text, and an Orchestrator that turns a document into a result. Every other type in the library is either a primitive they exchange (Document, Region, Recognition, …) or a decorator that sits on one of them.

Internalise this page and the rest of the library reads itself.

The Worker is responsible for converting image regions or cells into text.

The Worker supports:

  • OCR execution per split region or cell
  • cell-type-specific OCR algorithms
  • confidence scoring
  • few-shot OCR
  • specialised OCR for numbers, dates, text, handwriting, checkboxes, and other cell types

In short:

Worker = image region → text + confidence

The Orchestrator is responsible for managing the full OCR workflow around the Workers.

The Orchestrator supports:

  • rotation, cropping, deskewing, and denoising
  • vertical, horizontal, and grid-based splitting
  • blank-cell and merged-cell detection
  • cell classification and worker assignment
  • parallel OCR execution
  • HITL review for uncertain results
  • reconstruction of OCR results into tables or documents
  • export to CSV, JSON, XLSX, HTML, Parquet, and debug reports

In short:

Orchestrator = preprocess → split → assign workers → run OCR → review → reconstruct → export

A Worker is plugged into an Orchestrator at the recognise step:

from scriva import Orchestrator, Worker
worker = Worker.openai("gpt-4o").cache(".scriva_cache").score()
recipe = (
Orchestrator()
.deskew()
.split.grid()
.classify()
.recognize(worker)
.reconstruct()
.export.xlsx("out.xlsx")
)
result = recipe("scan.png")

An Orchestrator is a recipe — an immutable description of the flow. Calling it on a source (path, bytes, or a Document) runs the recipe and returns a DocumentResult. The same recipe can be re-run on as many documents as you like.

Both pillars are designed for method chaining, but they chain different things:

  • A Worker chains capabilities on a recogniser. Each call returns a new Worker that wraps the previous one with the added behaviour (cache, scoring, few-shot, retry, fallback). The chain is a stack of decorators around a base engine.
  • An Orchestrator chains steps in a flow. Each call returns a new Orchestrator with the step appended. The chain is the recipe itself, read top-to-bottom.

Both are immutable. Chaining never mutates; it always returns a new instance. That makes recipes safe to share across threads, store in module scope, or build incrementally.

A Document is the input. Not “a PDF” or “an image” — an opaque handle to one or more rendered pages with optional metadata (filename, source URI, MIME, capture rotation, DPI).

doc = Document.load("scan.pdf") # multi-page
doc = Document.load("photo.jpg") # single page
doc = Document.from_bytes(png_bytes) # in-memory

Orchestrators accept a path, bytes, or a Document directly — recipe("x.pdf") loads internally. Pages are lazy: doc.pages() yields one Page at a time. Workers see one crop at a time.

A Region is a bounded area on a page that the rest of the recipe can talk about. Regions are intentionally general:

  • A grid cell in a form
  • A polygon around a P&ID symbol
  • A free-form bounding box around a paragraph
  • An entire page
region.bbox # (x, y, w, h) — origin top-left
region.polygon # list[(x, y)] | None
region.role # "data" | "header" | "blank" | "merged" | str
region.kind # "text" | "number" | "date" | "checkbox" | "handwriting" | …
region.grid # GridCell(row=2, col=3, rowspan=1, colspan=1) | None
region.merge_group_id # str | None — shared by regions in one logical merge
region.parent_region_id # str | None — set by splitters
region.crop_override # PageCrop | None — set by preprocessors
region.attrs # open dict for engine-specific extension

role is the classifier-assigned label ("data", "header", "blank", "merged"). kind is the content type ("number", "date", "checkbox", "handwriting", …) — this is what recognize.by_role(...) and recognize.by_kind(...) dispatch on so specialised Workers see only the crops they understand.

Regions can be nested (a row contains cells; a section contains paragraphs) and merged (rowspan/colspan, or “this polygon and that polygon are one logical region”). The library never assumes a rectangular grid; the grid case is just region.grid is not None.

A Layout is the set of regions on a page plus the relationships between them. It is the output of the split and classify steps and the input to the recognize step.

A layout is a graph, not a list: regions know their parent, their siblings, their merge-group, and (for grids) their row/column index. Iterating a layout in reading order is a single call — layout.in_reading_order().

A Recognition is the recognised content for one region — what a Worker returns. It carries:

  • text — the recognised string (may be None if the region is blank)
  • confidence[0.0, 1.0] (may be None if no scorer ran)
  • language — ISO 639-1 if detected
  • kind — the recognised content type if the Worker discriminates
  • source — which Worker produced it
  • cacheCacheProvenance(tier, similarity, key) if it came from a cache
  • alternatives — optional n-best list
  • error — populated when an error_policy other than "abort" swallowed an exception
  • attrs — open bag for engine-specific output (token boxes, raw JSON, etc.)

Recognition is immutable. Use .with_text(...), .with_confidence(...), or .replace(**kwargs) to derive a new one.

A PageResult bundles a page’s Layout and the Recognition for each region. A DocumentResult is a sequence of PageResults plus document-level metadata, and it knows how to serialise itself — the in-recipe .export.* steps are for in-run writes; these methods are for ad-hoc inspection:

result.render() # str — reading-order text
result.to_dict() # dict; same schema as .export.json
result.to_dataframe() # pandas; one row per region
result.to_excel("out.xlsx") # writes file
result.to_markdown() # str
result.show() # matplotlib visualisation (optional extra)

Decorators and primitives shared between pillars

Section titled “Decorators and primitives shared between pillars”

A Cache is keyed recognition storage. It lives behind Worker.cache(...) as a wrapper — never inside an engine adapter — so cache hits show up on recognition.cache and in the event stream:

  • Exact cache — keyed by a deterministic hash of the crop bytes plus the Worker configuration. A hit returns the previous Recognition verbatim.
  • Semantic cache — keyed by an embedding of the crop. A hit returns the previous result if cosine similarity exceeds a configurable threshold (default 0.99 — high, because OCR is unforgiving).

Caches accept string shorthand: .cache(".scriva_cache") becomes a FileSystemCache. Cache.layered(path) gives you both tiers.

A SampleStore is labelled-crop persistence. It is to labelled crops what Cache is to recognised crops: an opt-in protocol whose adapters back onto filesystem, sqlite, pgvector, or S3.

class SampleStore(Engine, Protocol):
async def put(self, sample: Sample) -> SampleId: ...
async def get(self, id: SampleId) -> Sample | None: ...
async def find(self, *, where=None, near=None, limit=50) -> Sequence[Sample]: ...
async def remove(self, id: SampleId) -> None: ...

Each Sample carries a crop, the primary Worker’s output, an optional human label, an optional embedding, and a source pointer. Three things in the library use sample stores: the .export.samples(...) step, the classifier’s training endpoint, and few-shot retrieval via Worker.few_shot(store). See samples.md › Two roads from a SampleStore.

Every Worker, every Orchestrator step, every cache and sample store conforms to a single root Engine protocol with three things:

class Engine(Protocol):
name: str
version: str
capabilities: frozenset[Capability]

capabilities is what lets the Orchestrator validate itself at build time — wiring a Worker that lacks Capability.LANGUAGE_DETECTION into an Orchestrator step that requires it is a build-time error, not a runtime surprise.

A grid-form OCR run reads naturally as a Worker plugged into an Orchestrator chain:

from scriva import Orchestrator, Worker
# Build the worker(s)
text = Worker.openai("gpt-4o").cache(".scriva_cache").score()
numeric = Worker.number().cache(".scriva_cache")
checkbox = Worker.checkbox()
blank = Worker.skip()
# Build the recipe
recipe = (
Orchestrator()
.deskew()
.crop(margins="auto")
.split.grid()
.classify(blank=True, merged=True)
.recognize.by_kind({
"text": text,
"number": numeric,
"checkbox": checkbox,
"blank": blank,
})
.review.hitl(when=lambda r: (r.confidence or 0) < 0.7)
.reconstruct.grid()
.export.xlsx("out.xlsx")
)
# Run
result = recipe("scan.png")

Every step on the Orchestrator is something you can swap, mock, or extend. Every decorator on a Worker is something you can stack or remove. That is the whole library.

  • Architecture — how the two pillars compose and the flow of a single run.
  • Worker — full Worker reference: engines, specialised factories, decorators, composition.
  • Orchestrator — full Orchestrator reference: steps, options, events, high-accuracy patterns.
  • Quickstart — a runnable end-to-end example.