Skip to content

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 → export

An 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.

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.

A recipe is callable:

result = recipe("scan.png") # sync; blocks until done
result = await recipe.aio("scan.png") # async
async for page_result in recipe.stream("scan.pdf"):
... # streaming per page

The 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)

Steps are grouped into seven phases. Each step is repeatable unless noted; relative order within a phase is preserved.

PhaseMethodsCardinality
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.

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.

Exactly one split step. The split owns layout detection: it produces a Layout of Regions that the rest of the recipe operates on.

MethodWhat 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 detector

Per-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(...) 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) # callable

See postprocessors.md for the classifier adapters and region.kind taxonomy.

Exactly one recognize step. Three shapes:

.recognize(worker)
.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).

.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(...) 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:

  • Blockingrecipe("scan.png") blocks at the review step until recipe.resume(sidecar="review.json") is called from another thread.
  • Two-phasephase1, 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.* assembles the recognitions back into a structured output. Without this step, the DocumentResult carries the raw regions in reading order.

MethodWhat 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.

Repeatable. Every .export.* adds one write at the end of the run.

MethodFormatExtra
.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.

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,
)
OptionTypeDefaultMeaning
page_concurrencyint1Parallel pages within one document run.
error_policystr"continue""continue", "page", or "abort".
default_languagestrNoneISO 639-1; passed to language-aware steps and Workers.
event_bufferint1024Max queued events before back-pressure.
cancel_on_signalboolFalseInstall SIGINT handler tied to recipe.cancel().
  • "continue" — log the error, attach an empty Recognition with error set, 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.

Pick the path that fits your host. See architecture.md › Observability for the event shape.

recipe("scan.png", on_event=lambda e: print(e.stage, e.kind))
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.result
from scriva.events import to_sse
async for chunk in to_sse(recipe.events("scan.png")):
yield chunk
import asyncio
task = asyncio.create_task(recipe.aio("scan.pdf"))
...
recipe.cancel()
await task # raises CancelledError once steps yield

Standard steps check ctx.cancelled between regions and around every network call.

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").

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.

LeverWhat it isWhen to reach for it
Human-in-the-loopPause for a human to confirm layout / text / fields before commitHigh-stakes documents, low volume, or first-pass quality unknown
Cross-check vs. the original cropRender the recognised text back into pixels and score the match; or run two Workers and score agreementHigh volume where humans can’t review every page; production confidence gating
Cross-check vs. ground-truth dataScore every run against a labelled corpus or known-good values; gate deploys on the scoreCI for pipeline / prompt / model changes; drift monitoring

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.

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.

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=...).

first = recipe
result = 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.

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()
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.

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 ctx

Decorate 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.