Domain packs
Domain packs
Section titled “Domain packs”A domain pack is a pre-built Orchestrator
recipe paired with a tuned Worker for a specific
document family. Each one is a function that returns a ready-to-call
recipe — read the source for any of them, copy it, adapt it.
import scrivarecipe = scriva.domains.forms.tabular(language="ja")result = recipe("scan.pdf")Domain packs are examples, not abstractions. They live in
scriva/domains/ and depend only on adapters already in the library.
domains.forms
Section titled “domains.forms”Tabular forms with visible rules (scanned spreadsheets, mill sheets, inspection reports).
forms.tabular( *, language: str = "en", model: str = "gpt-4o", cache: Cache | str = ".scriva_cache", excel_out: Path | str | None = None, json_out: Path | str | None = None,) -> OrchestratorComposes:
worker = ( Worker.openai(model) .prompt(Prompt.cell.localised(language)) .cache(Cache.layered(cache)) # filesystem + vector .score(method="rendering"))
recipe = ( Orchestrator() .deskew() .split.grid() # falls back to hough on miss .classify(blank=True, merged=True) # rule + embedding hybrid .recognize(worker) .reconstruct.dictionary.from_yaml("dict.yaml") # if it exists .reconstruct.rule_splitter() .reconstruct.grid() .export.xlsx(excel_out) # both optional .export.json(json_out))The result is the same shape as the original ocr-agent produced, but
every step is replaceable from the call site:
recipe = scriva.domains.forms.tabular(language="ja")recipe = recipe.replace("recognize", Worker.anthropic("claude-opus-4-7"))result = recipe("scan.pdf")If you need to insert a step into a domain pack mid-chain, use
.insert_after:
recipe = scriva.domains.forms.tabular(language="ja")recipe = recipe.insert_after("recognize", scriva.reconstruct.language_detector())result = recipe("scan.pdf")domains.pid
Section titled “domains.pid”Piping & instrumentation diagrams. Different layout, different Worker prompt, different output schema.
pid.diagram( *, box_splitter: Splitter | None = None, model: str = "gpt-4o", json_out: Path | str,) -> OrchestratorComposes:
worker = Worker.openai(model).prompt(Prompt.structured(schema=PidSymbol))
recipe = ( Orchestrator() .split.boxes(detector=box_splitter) # or .split.whole_page() if None .recognize(worker) .reconstruct() .export.json(json_out))The output is a list of {type, text, bbox} objects, not a grid. The
export step exists because P&ID consumers want a stable structured
dump, not human-readable Excel.
domains.annotation
Section titled “domains.annotation”The annotation pack runs a primary Worker over every region, escalates
the most-uncertain k to an oracle Worker, and persists both versions to
a SampleStore for downstream training. It is the
reference workflow for active learning on top of scriva.
annotation.review( *, primary: Worker, oracle: Worker, store: SampleStore, k: int = 20, # how many uncertain regions to route to the oracle json_out: Path | str,) -> OrchestratorComposes:
worker = Worker.escalate( primary=primary.score(method="rendering"), oracle=oracle, when=scriva.uncertainty.top_k(k=k), # selects the k lowest-confidence regions)
recipe = ( Orchestrator() .split.grid() .classify.rule_based() .recognize(worker) .reconstruct() .export.json(json_out) .export.samples(store))A typical loop is three calls:
import scrivafrom scriva import Worker, SampleStore
store = SampleStore.fs(".scriva_samples")
# 1. Run the annotation recipe over a batchrecipe = scriva.domains.annotation.review( primary=Worker.openai("gpt-4o").cache(".scriva_cache"), oracle=Worker.anthropic("claude-opus-4-7"), store=store, k=20, json_out="annotations.json",)recipe("scan.pdf")
# 2. (Optional) human review — mark good samples in the store
# 3. Train a head on the labelled samplesclf = scriva.classify.embedding.train( samples=store, where=lambda s: s.label is not None, embedder=scriva.embedders.OpenAIEmbedder(model="text-embedding-3-small"),)clf.save("models/cells.joblib")The pack does not bundle a UI — annotation review is the application’s
job. See samples.put / .with_label for the
machine-readable side of the loop.
domains.agentic
Section titled “domains.agentic”Free-form documents where the schema is not known up front. Two phases:
- Schema discovery — repeated VLM calls produce a candidate field list.
- Row OCR + extraction — OCR the document, then prompt the VLM with the discovered schema to extract field→value pairs.
agentic.extract( *, schema: type[BaseModel] | None = None, # if None, discover rounds: int = 3, model: str = "gpt-4o", json_out: Path | str,) -> OrchestratorThe recipe wires a discovery .classify(...) step ahead of recognise
when schema is omitted; when schema is supplied, discovery is
skipped and the structured Worker runs directly.
The output is one row per discovered field with field, value,
source_region, and note.
Writing your own domain pack
Section titled “Writing your own domain pack”A domain pack is a function that returns an Orchestrator. Keep them
flat and read-once:
from scriva import Orchestrator, Workerfrom scriva.prompts import Promptfrom scriva.schemas import Invoice
def invoice(*, model: str = "gpt-4o", json_out: str) -> Orchestrator: worker = Worker.openai(model).prompt(Prompt.structured(schema=Invoice)) return ( Orchestrator() .split.whole_page() .recognize(worker) .reconstruct() .export.json(json_out) )Domain packs do not extend the library; they configure it. Anything they need that is not in core should land as a generic Worker decorator or Orchestrator step first, then be used from the domain pack.
What to read next
Section titled “What to read next”- Presets — single-schema bundles; lighter weight than a domain pack.
- Orchestrator — the recipe shape every pack returns.
- Worker — the recogniser side every pack tunes.
- Cookbook — worked end-to-end examples per document kind.