Skip to content

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 scriva
recipe = 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.

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,
) -> Orchestrator

Composes:

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

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,
) -> Orchestrator

Composes:

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.

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,
) -> Orchestrator

Composes:

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 scriva
from scriva import Worker, SampleStore
store = SampleStore.fs(".scriva_samples")
# 1. Run the annotation recipe over a batch
recipe = 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 samples
clf = 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.

Free-form documents where the schema is not known up front. Two phases:

  1. Schema discovery — repeated VLM calls produce a candidate field list.
  2. 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,
) -> Orchestrator

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

A domain pack is a function that returns an Orchestrator. Keep them flat and read-once:

scriva/domains/invoices.py
from scriva import Orchestrator, Worker
from scriva.prompts import Prompt
from 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.

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