Concepts
Concepts
Section titled “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 two pillars
Section titled “The two pillars”Worker
Section titled “Worker”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
Orchestrator
Section titled “Orchestrator”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
How they meet
Section titled “How they meet”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.
Method chaining
Section titled “Method chaining”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.
Primitives the pillars exchange
Section titled “Primitives the pillars exchange”Document and Page
Section titled “Document and Page”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-pagedoc = Document.load("photo.jpg") # single pagedoc = Document.from_bytes(png_bytes) # in-memoryOrchestrators 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.
Region
Section titled “Region”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-leftregion.polygon # list[(x, y)] | Noneregion.role # "data" | "header" | "blank" | "merged" | strregion.kind # "text" | "number" | "date" | "checkbox" | "handwriting" | …region.grid # GridCell(row=2, col=3, rowspan=1, colspan=1) | Noneregion.merge_group_id # str | None — shared by regions in one logical mergeregion.parent_region_id # str | None — set by splittersregion.crop_override # PageCrop | None — set by preprocessorsregion.attrs # open dict for engine-specific extensionrole 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.
Layout
Section titled “Layout”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().
Recognition
Section titled “Recognition”A Recognition is the recognised content for one region — what a
Worker returns. It carries:
text— the recognised string (may beNoneif the region is blank)confidence—[0.0, 1.0](may beNoneif no scorer ran)language— ISO 639-1 if detectedkind— the recognised content type if the Worker discriminatessource— which Worker produced itcache—CacheProvenance(tier, similarity, key)if it came from a cachealternatives— optional n-best listerror— populated when anerror_policyother than"abort"swallowed an exceptionattrs— 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.
PageResult and DocumentResult
Section titled “PageResult and DocumentResult”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 textresult.to_dict() # dict; same schema as .export.jsonresult.to_dataframe() # pandas; one row per regionresult.to_excel("out.xlsx") # writes fileresult.to_markdown() # strresult.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
Recognitionverbatim. - 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.
SampleStore
Section titled “SampleStore”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.
Engine (the unifying protocol)
Section titled “Engine (the unifying protocol)”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.
Putting it together
Section titled “Putting it together”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 reciperecipe = ( 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"))
# Runresult = 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.
What to read next
Section titled “What to read next”- 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.