Orchestrator
Orchestrator
Section titled “Orchestrator”The Orchestrator owns the full OCR workflow around the
Workers. It is the second pillar of scriva; see
Concepts for the first.
Orchestrator = preprocess → split → assign workers → run OCR → review → reconstruct → exportAn Orchestrator is a recipe — an immutable description of the
flow. Calling it on a source runs the recipe and returns a
DocumentResult. The same recipe can be re-run on as many documents
as you like.
Building a recipe
Section titled “Building a recipe”from scriva import Orchestrator, Worker
worker = Worker.openai("gpt-4o").cache(".scriva_cache").score()
recipe = ( Orchestrator() .deskew() .crop(margins="auto") .split.grid() .classify(blank=True, merged=True) .recognize(worker) .reconstruct.grid() .export.xlsx("out.xlsx"))Every method returns a new Orchestrator with the step appended. Chaining never mutates — recipes are safe to share, store, and reuse.
Running
Section titled “Running”A recipe is callable:
result = recipe("scan.png") # sync; blocks until doneresult = await recipe.aio("scan.png") # asyncasync for page_result in recipe.stream("scan.pdf"): ... # streaming per pageThe source may be a path, bytes, or a Document. Calling
recipe(source) from inside a running event loop raises with a
pointer to recipe.aio(source) instead of silently hanging.
A recipe is reusable:
for path in glob("scans/*.png"): result = recipe(path) archive(result)The steps
Section titled “The steps”Steps are grouped into seven phases. Each step is repeatable unless noted; relative order within a phase is preserved.
| Phase | Methods | Cardinality |
|---|---|---|
| preprocess | .rotate() .crop() .deskew() .denoise() .normalise() .orient() | 0..N |
| split | .split.grid() .split.vertical() .split.horizontal() .split.boxes() .split.from_layout() | exactly 1 |
| classify | .classify(...) .classify.rule_based() .classify.ml(...) | 0..1 |
| recognize | .recognize(worker) .recognize.by_kind({...}) .recognize.by_role({...}) | exactly 1 |
| review | .review.hitl(...) .review.queue(...) .review.gate(...) | 0..1 |
| reconstruct | .reconstruct() .reconstruct.grid() .reconstruct.document() .reconstruct.table(...) | 0..1 |
| export | .export.xlsx(...) .csv(...) .json(...) .html(...) .parquet(...) .markdown(...) .debug(...) .samples(...) | 0..N |
The Orchestrator slots each call into the correct phase regardless of
the order you chain them in — .export.xlsx(...).split.grid()... is
valid and produces the same recipe as the conventional order. Reading
order still matters for the reader, so the conventional order is
recommended.
Preprocess — pixel prep
Section titled “Preprocess — pixel prep”Page-level transforms applied before splitting. See preprocessors.md for the full list.
recipe = ( Orchestrator() .orient() # auto-rotate from EXIF or content .rotate(degrees=-1.2) # fixed rotation .crop(margins="auto") # or .crop(box=(x, y, w, h)) .deskew() # straighten ruled lines .denoise(method="bilateral") .normalise(target_dpi=300))Region-level preprocessing (per-cell binarise, slice, pad) lives
under .split.* because it produces or transforms regions; see
Splitters below.
Split — page to regions
Section titled “Split — page to regions”Exactly one split step. The split owns layout detection: it produces
a Layout of Regions that the rest of the recipe operates on.
| Method | What it does |
|---|---|
.split.grid(rows=None, cols=None) | Detect a tabular grid (auto by default). |
.split.vertical(at=...) | Split into vertical strips at given x-coords or auto. |
.split.horizontal(at=...) | Split into horizontal strips. |
.split.boxes(detector=...) | Free-form box detection (ML model or callable). |
.split.from_layout("layout.json") | Read regions from a sidecar JSON. Used for HITL. |
.split.whole_page() | One region per page. The default if you omit .split.*. |
Examples:
.split.grid() # auto-detect grid.split.grid(rows=10, cols=5) # fixed.split.vertical(at=[100, 200, 300]) # explicit cuts.split.horizontal() # auto horizontal cuts.split.boxes(detector=MyMLDetector()) # custom detectorPer-region preprocessing happens after split, as a .split.refine(...)
attached call:
.split.grid().refine.pad(top=4, bottom=4).split.grid().refine.binarise(method="otsu").split.grid().refine.slice_overflow(direction="vertical")See detectors.md for the full splitter reference and preprocessors.md › Region preprocessors for the refine list.
Classify — assign roles and kinds
Section titled “Classify — assign roles and kinds”.classify(...) assigns region.role (header / data / blank /
merged) and region.kind (text / number / date / checkbox / …). The
recognise step dispatches on these.
.classify(blank=True, merged=True) # blank + merged detection on.classify.rule_based(blank_density=0.002) # rule-based with custom threshold.classify.ml(model="scriva-cell-classifier-v1") # ML classifier.classify(roles={(0, 0): "header"}) # static overrides.classify(kinds=infer_kinds_from_header) # callableSee postprocessors.md for the classifier
adapters and region.kind taxonomy.
Recognize — run the Workers
Section titled “Recognize — run the Workers”Exactly one recognize step. Three shapes:
One Worker for everything
Section titled “One Worker for everything”.recognize(worker)Dispatch by region.kind
Section titled “Dispatch by region.kind”.recognize.by_kind({ "text": Worker.openai("gpt-4o"), "number": Worker.number(), "date": Worker.date(), "checkbox": Worker.checkbox(), "handwriting": Worker.handwriting().few_shot(samples), "blank": Worker.skip(),})Regions whose kind is missing from the mapping fall through to a
default= Worker (or Worker.text() if none is given).
Dispatch by region.role
Section titled “Dispatch by region.role”.recognize.by_role({ "header": Worker.openai("gpt-4o").prompt(Prompt.header), "data": Worker.openai("gpt-4o").prompt(Prompt.cell), "blank": Worker.skip(),})by_role and by_kind can compose — a single recipe can do both:
.recognize.by_role({ "header": header_worker, "data": Worker.router({ "number": numeric_worker, "date": date_worker, "text": text_worker, }), "blank": Worker.skip(),})Review — HITL
Section titled “Review — HITL”.review.hitl(...) pauses the recipe and writes a sidecar a human
edits. The Orchestrator emits a review/paused event and waits for
an external resume.
.review.hitl( when=lambda r: (r.confidence or 0) < 0.7, sidecar="review.json",)Two execution modes:
- Blocking —
recipe("scan.png")blocks at the review step untilrecipe.resume(sidecar="review.json")is called from another thread. - Two-phase —
phase1, phase2 = recipe.split_at_review()cuts the recipe in two. Run phase1, hand the sidecar to your UI, run phase2 when the UI POSTs back.
phase1, phase2 = recipe.split_at_review()phase1("scan.png", sidecar="review.json")# … your UI shows review.json to a human, gets edits, saves it …result = phase2("scan.png", sidecar="review.json").review.queue(queue) is the async-queue variant; .review.gate(...)
is a programmatic gate that runs a callback instead of pausing. The
handwritten-forms cookbook walks through a
confidence-driven HITL run end-to-end.
Reconstruct — regions to structure
Section titled “Reconstruct — regions to structure”.reconstruct.* assembles the recognitions back into a structured
output. Without this step, the DocumentResult carries the raw
regions in reading order.
| Method | What it produces |
|---|---|
.reconstruct() | Default: by layout shape (grid → table, polygons → doc). |
.reconstruct.grid() | Table with merged cells preserved. |
.reconstruct.document() | Reading-order document with sections / paragraphs. |
.reconstruct.table(...) | Table with explicit row/column headers. |
.reconstruct.kv(...) | Key/value pairs (form-style). |
The reconstructed structure is what .export.* writes and what
result.to_excel(...) / result.to_dict(...) read.
Export — structure to file(s)
Section titled “Export — structure to file(s)”Repeatable. Every .export.* adds one write at the end of the run.
| Method | Format | Extra |
|---|---|---|
.export.xlsx(path, ...) | Excel; preserves merges and colours by confidence. | excel |
.export.csv(path, ...) | One row per cell, with confidence. | — |
.export.json(path, select=...) | JSON; full or selected fields. | — |
.export.html(path, ...) | HTML with embedded crops and confidence styling. | — |
.export.parquet(path, ...) | Columnar; for downstream analytics. | parquet |
.export.markdown(path, ...) | Reading-order markdown. | — |
.export.hocr(path) | hOCR / ALTO XML. | — |
.export.debug(dir, ...) | Per-region crops + raw responses + events. Audit aid. | — |
.export.samples(store, ...) | Persist labelled crops back to a SampleStore. | — |
.export.callback(fn) | Hand the DocumentResult to your code. | — |
See exporters.md for per-exporter options.
Options
Section titled “Options”Global recipe options live on .options(...):
recipe = recipe.options( page_concurrency=4, error_policy="continue", default_language="ja", event_buffer=1024, cancel_on_signal=False,)| Option | Type | Default | Meaning |
|---|---|---|---|
page_concurrency | int | 1 | Parallel pages within one document run. |
error_policy | str | "continue" | "continue", "page", or "abort". |
default_language | str | None | ISO 639-1; passed to language-aware steps and Workers. |
event_buffer | int | 1024 | Max queued events before back-pressure. |
cancel_on_signal | bool | False | Install SIGINT handler tied to recipe.cancel(). |
Error policies
Section titled “Error policies”"continue"— log the error, attach an emptyRecognitionwitherrorset, keep going. Default; right for batch runs."page"— abort the current page, continue with the next. Right for multi-page PDFs where one bad page should not poison the rest."abort"— raise and stop. Right for CLI / one-shot.
Observing
Section titled “Observing”Pick the path that fits your host. See architecture.md › Observability for the event shape.
Callback (recommended)
Section titled “Callback (recommended)”recipe("scan.png", on_event=lambda e: print(e.stage, e.kind))Async iterator of events + result
Section titled “Async iterator of events + result”async for item in recipe.events("scan.png"): if item.kind == "event": print(item.event.stage, item.event.kind) elif item.kind == "result": result = item.resultServer-Sent Events
Section titled “Server-Sent Events”from scriva.events import to_sseasync for chunk in to_sse(recipe.events("scan.png")): yield chunkCancelling
Section titled “Cancelling”import asynciotask = asyncio.create_task(recipe.aio("scan.pdf"))...recipe.cancel()await task # raises CancelledError once steps yieldStandard steps check ctx.cancelled between regions and around every
network call.
Replacing and re-using steps
Section titled “Replacing and re-using steps”recipe = recipe.replace("recognize", Worker.anthropic("claude-opus-4-7"))recipe = recipe.insert_after("recognize", language_detector())recipe = recipe.remove("export.xlsx")Names are the step’s name field — or the auto-derived kebab-case of
the method (split.grid → "split-grid").
High-accuracy patterns
Section titled “High-accuracy patterns”When the bar is “wrong answers are unacceptable, not just
inconvenient,” the Orchestrator exposes three composable accuracy
levers. They share the same primitives —
RecognitionHint, .score() on a
Worker, result.merge, result.diff, scriva.eval — in different
shapes.
| Lever | What it is | When to reach for it |
|---|---|---|
| Human-in-the-loop | Pause for a human to confirm layout / text / fields before commit | High-stakes documents, low volume, or first-pass quality unknown |
| Cross-check vs. the original crop | Render the recognised text back into pixels and score the match; or run two Workers and score agreement | High volume where humans can’t review every page; production confidence gating |
| Cross-check vs. ground-truth data | Score every run against a labelled corpus or known-good values; gate deploys on the score | CI for pipeline / prompt / model changes; drift monitoring |
Lever 1 — HITL review
Section titled “Lever 1 — HITL review”The .review.hitl(...) step pauses the recipe; the sidecar JSON
matches the regions field of result.to_dict() so round-tripping
is lossless.
recipe = ( Orchestrator() .orient().deskew() .split.grid() .classify(blank=True, merged=True) .review.hitl(sidecar="review-layout.json") # before recognition .recognize(worker) .reconstruct.grid() .export.xlsx("out.xlsx"))The review can sit before recognise (layout review) or after recognise (text review) — both are supported, and a single recipe can have one of each.
Lever 2 — cross-check vs. the original crop
Section titled “Lever 2 — cross-check vs. the original crop”Two flavours.
2a. Round-trip rendering check
Section titled “2a. Round-trip rendering check”worker = Worker.openai("gpt-4o").score(method="rendering")recipe = ( Orchestrator() .split.grid() .recognize(worker) .export.xlsx("out.xlsx", confidence_thresholds=(0.6, 0.8)))result = recipe("scan.png")to_review = result.low_confidence(threshold=0.6)This is the foundation lever: the confidence it produces is what every other lever reads.
2b. Multi-Worker consensus
Section titled “2b. Multi-Worker consensus”worker = Worker.consensus( Worker.openai("gpt-4o"), Worker.anthropic("claude-opus-4-7"), Worker.bedrock("qwen.qwen3-vl-235b-a22b"), resolve="confidence",)For the cheaper softer variant, use
Worker.escalate(primary, oracle, when=...).
2c. Confidence-driven re-OCR
Section titled “2c. Confidence-driven re-OCR”first = reciperesult = first("scan.png")
refine = ( Orchestrator() .split.from_layout(result, where=lambda r: (r.confidence or 0) < 0.6) .recognize( Worker.openai("gpt-4o") .prompt(Prompt.ocr_with_hint) .with_hints(RecognitionHint.from_result(result)) ) .reconstruct.grid() .export.xlsx("out_refined.xlsx"))refined = refine("scan.png")combined = result.merge(refined)DocumentResult.merge(other, strategy=...) aligns regions by
region_id. Strategies: "right_wins" (default), "left_wins",
"highest_confidence", or a callable. Layout is taken from self
unchanged.
Lever 3 — cross-check vs. ground-truth
Section titled “Lever 3 — cross-check vs. ground-truth”scriva.eval(recipe=..., ground_truth=...) runs the recipe against a
labelled corpus and reports precision / recall / F1 / calibration.
The same machinery is the right shape for production drift
monitoring. See evaluation.md.
report = scriva.eval( recipe=scriva.presets.invoice.recipe(), ground_truth="./annotations/invoices/",)assert report.f1 >= 0.92, report.to_markdown()Combining the levers
Section titled “Combining the levers”recipe = ( Orchestrator() .deskew() .split.grid() .classify(blank=True, merged=True) .recognize( Worker.escalate( # lever 2b primary=Worker.openai("gpt-4o-mini") .cache(".scriva_cache") .score(method="rendering"), oracle=Worker.openai("gpt-4o"), when=lambda r: (r.confidence or 0) < 0.7, ) ) .review.hitl(when=lambda r: (r.confidence or 0) < 0.5) # lever 1 .reconstruct.grid() .export.xlsx("out.xlsx") .export.samples(SampleStore.fs(".scriva_samples")) # lever 3 feed)— and offline CI runs scriva.eval against ./annotations/ to gate
the deploy. Cross-check on every run, gate-then-route by
confidence, escalate to a human when the confidence isn’t there.
Writing your own step
Section titled “Writing your own step”Subclass for stateful steps:
from scriva import Step, Context
class StripQuotes(Step): phase = "reconstruct" # which phase this slots into name = "strip-quotes"
async def run(self, ctx: Context) -> Context: for rid, r in ctx.recognitions.items(): if r.text: ctx.recognitions[rid] = r.with_text(r.text.strip('"')) return ctxDecorate a function for stateless ones:
from scriva import step
@step(phase="reconstruct", name="strip-quotes")async def strip_quotes(ctx): for rid, r in ctx.recognitions.items(): if r.text: ctx.recognitions[rid] = r.with_text(r.text.strip('"')) return ctx
recipe = recipe.then(strip_quotes)recipe.then(step) is the escape hatch for steps that don’t fit a
predefined phase.
What to read next
Section titled “What to read next”- Worker — what plugs into
.recognize(...). - Preprocessors —
.rotate() .crop() .deskew() .denoise(). - Detectors —
.split.*adapters. - Exporters —
.export.*adapters. - Cookbook — worked recipes per document kind.