Skip to content

Working with results

Every Orchestrator recipe returns a DocumentResult. This page is the catalogue of what you can do with one before you reach for a custom export step.

The shape:

class DocumentResult:
pages: list[PageResult]
schema: type[BaseModel] | None # set when a structured prompt was used
# Typed views (lazy)
fields: FieldView # for schema-driven extraction
tables: TableView # for grid / tabular content
regions_with_text: Iterator[Region] # everything that has a recognition
# Scalars
mean_confidence: float
page_count: int
# Serialisation
def render(self) -> str: ...
def to_dict(self) -> dict: ...
def to_json(self, path: str | Path) -> Path: ...
def to_dataframe(self) -> pandas.DataFrame: ...
def to_excel(self, path, **kwargs) -> Path: ...
def to_markdown(self, path: str | Path | None = None) -> str | Path: ...
def show(self, *, page: int = 0): ...
# Composition
def merge(self, other: "DocumentResult", strategy: MergeStrategy = "highest_confidence") -> "DocumentResult": ...
def diff(self, other: "DocumentResult") -> "ResultDiff": ...
def filter(self, where: Callable[[Region], bool]) -> "DocumentResult": ...
# Filtering
def low_confidence(self, threshold: float = 0.6) -> list[Region]: ...

PageResult is the same shape, scoped to one page.

When the recipe ran with a schema=, result.fields is the typed instance:

import scriva
from scriva.schemas import Invoice
result = scriva.extract("acme.pdf", schema=Invoice, return_="result")
# Or, when you built the recipe yourself:
result = recipe("acme.pdf")
invoice = result.fields # -> Invoice
print(invoice.total_amount, invoice.vendor.name)

result.fields is the parsed pydantic model — no dict diving. Each field exposes a .region reference for click-to-source:

result.fields.total_amount # -> Decimal("1240.00")
result.fields_with_provenance["total_amount"].region.bbox # -> BBox(...)
result.fields_with_provenance["total_amount"].confidence # -> 0.92

fields_with_provenance is the same data wrapped with region, page_index, and per-field confidence. Use it when you need to draw overlays or feed a human-review UI.

result.tables is a list of detected tables, each a thin wrapper around a pandas DataFrame:

result.tables # -> list[Table]
len(result.tables) # -> 2
result.tables[0].to_dataframe() # -> pandas.DataFrame
result.tables[0].caption # -> "Line items" (when the Worker names it)
result.tables[0].page_index # -> 0
result.tables[0].bbox # -> BBox enclosing the whole table

to_dataframe() returns headers + rows. Multi-page tables are stitched automatically when the splitter emits a continued_from annotation; pass stitch=False to keep them split.

result.render() # full document as text, reading order
result.pages[0].render() # one page
result.render(separator="\n\n---\n\n") # between pages

render() walks regions in reading order (top-to-bottom, left-to-right for LTR locales; the splitter can override the iteration order for RTL or vertical scripts).

The escape hatch when none of the typed views fit:

for region in result.regions_with_text():
print(region.id, region.role, region.text, region.confidence)

.regions_with_text() skips blanks and merged-source regions; iterate result.pages[0].layout.regions if you need everything.

result.mean_confidence # mean over recognitions that report it
result.confidence_histogram(bins=10) # -> list[int]
result.low_confidence(threshold=0.6) # -> list[Region]

low_confidence is the input to confidence-driven re-OCR (see Orchestrator › Confidence-driven re-OCR).

result.render() # str — reading-order text
result.to_dict() # dict; same schema as .export.json
result.to_json("out.json") # writes file; returns Path
result.to_excel("out.xlsx") # MS Gothic default; honours confidence colouring
result.to_dataframe() # flat regions table
result.to_markdown("out.md") # render() but with table syntax for .tables
result.show(page=0) # matplotlib viz of boxes (optional extra)

to_dict() returns an in-memory dict; to_json(path) writes a file. Use the in-recipe .export.xlsx(...) / .export.json(...) steps when you want the write to happen during the run (they emit events and respect page_concurrency); use result.to_excel(...) for ad-hoc serialisation.

a = primary_recipe("scan.png")
b = refined_recipe("scan.png")
final = a.merge(b, strategy="highest_confidence")

Merge strategies:

StrategyBehaviour
"highest_confidence"Per region, keep the side with the higher .confidence (default).
"right_wins"Right’s recognition replaces left’s for every region in other; missing left regions are appended.
"left_wins"Left’s recognition is kept; other only contributes missing regions.
"consensus"Same text → keep; different text → fall back to tiebreaker=....
callable(left, right) -> Recognition. Custom resolution.

Regions match by id (which is stable across re-OCR within the same splitter configuration). Layout is taken from self unchanged.

diff = a.diff(b)
diff.changed # list[(region_id, left_text, right_text)]
diff.added # regions only in b
diff.removed # regions only in a
diff.to_markdown("diff.md")

diff is the right shape for “did my prompt change actually move anything?” — pair it with scriva.eval when you care about the ground-truth direction of the move.

high_conf = result.filter(lambda r: (r.confidence or 0) >= 0.8)
high_conf.to_excel("clean.xlsx")

filter returns a view, not a copy — region objects are shared.

for page in result.pages:
for region in page.layout.regions:
...
for region in result.iter_regions(): # all pages, flat
...
result.find(role="title") # first matching region or None
result.find_all(role="title") # list

DocumentResult is not hashable. It implements __eq__ based on the serialised dict form, which is what you want for snapshot tests:

def test_invoice_extraction(snapshot):
result = recipe("fixture.pdf")
assert result.to_dict() == snapshot
  • result.fields raises AttributeError if the recipe did not run a structured-output Worker. Check result.schema is not None first when you don’t control the recipe.
  • result.tables is empty when the splitter did not declare Capability.GRID. Use a grid splitter (.split.grid(), .split.whole_page() with a table-aware Worker) to populate it.
  • merge only works when both sides share the same splitter configuration; mismatching region IDs raise ResultMergeError. Re-run through the same splitter first, or pass a callable strategy that doesn’t rely on ID alignment.