Cookbook 04 · Build
Document-to-JSON Pipeline
Turn Office documents into schema-valid JSON, with readable failures for bad inputs.
BuildCore35–55 min
What you build. A workflow that fetches a document, extracts its text/tables, structures it to JSON with a prompt, and validates the result against a target schema — separating extraction failures from validation failures.
Surfaces: Object Store Prompts Tools Workflows Observability
What you learn
- Upload + preview + extract Office docs in Object Store
- Register the shipped extractor tool
- Author a structuring prompt that never invents values
- Validate JSON with the Data Transform JSON Schema operation
- Separate extraction vs normalization vs validation failures in traces
Implementation flow
flowchart LR OB[(Object Store: docs)] -.upload + preview/extract.-> LF[/local file path/] LF --> EX[Tool: extract_document · reads a local path] EX --> ST[Agent: doc-structurer prompt] ST --> VAL[Data Transform: JSON Schema] VAL -->|pass / partial / fail| OUT[output]
Step-by-step UI only
Each step shows the exact navigation path, the fields to fill, the button to click, and a snapshot of the CALIBER screen you'll be on.
Knowledge › Object Store › Bucket browsercreates · bucket: doc-intakeCreate a place to hold the source documents. In the Bucket browser panel (left rail), type the bucket name into the new-bucket-name field at the bottom, then click the + create button (titled “Create bucket”). Names must be 3–63 chars, lowercase. This bucket is backed by S3/MinIO storage.Fill innew-bucket-name doc-intakeClick + (Create bucket)You'll see: A new empty bucketdoc-intakeappears in the Bucket browser list and opens automatically, ready for uploads.Screen snapshotcaliber · Knowledge › Object Store › Bucket browser+ (Create bucket)Knowledge › Object Store › doc-intakecreates · uploaded source documents invoice-clean.md ↓ invoice-clean.csv ↓ invoice-partial.md ↓Open thedoc-intakebucket and use the Upload button (or drag-and-drop) to add the sample files. For a no-conversion dry run you can upload the text stand-ins directly (extract_documentreads.md/.csv/.txt); to exercise the real Office path, first convert them to.docx/.xlsxusing the one-liners in the sources README. Upload each file under the exact key the dataset expects.Fill inGolden invoice (all fields → pass) Upload invoice-clean.md (dry run) or its converted invoice-clean.docx/invoice-clean.xlsxSpreadsheet stand-in Upload invoice-clean.csv (or its converted invoice-clean.xlsx)Partial invoice (missing total → partial) Upload invoice-partial.md (or its converted invoice-partial.docx)Negative cases (need real binaries) Create invoice-legacy.doc+invoice-corrupt.docxper sources/README.md, then uploadClick UploadYou'll see: Each uploaded object is listed with its key, size, and a preview action. The eval dataset addresses these files by their local filesystem path (thedoc_pathfield) rather than a bucket key — so after landing each file locally, thedoc_pathvalues point at the on-disk copy the platform host can open.Screen snapshotcaliber · Knowledge › Object Store › doc-intakeUploadKnowledge › Object Store › doc-intake › (preview a file)creates · extraction preview + readable unsupported errorConfirm the platform can read each source before you build the workflow. In the file list, click a row's preview (eye) action to open the preview modal. For Office documents (.docx/.xlsx) the modal runs server-side extraction automatically and shows the extracted text/tables; markdown and CSV stand-ins render inline. Open the legacyinvoice-legacy.docthe same way to capture the readable unsupported diagnostic. (The Object Store preview is what produces the friendlykind:"unsupported"message; theextract_documenttool would instead read a.docas garbled bytes, so this is the right place to capture the negative case.)You'll see: The preview modal shows extracted plain text for the invoice docs (XLSX renders sheet tables); forinvoice-legacy.doca readablekind:"unsupported"message naming the format — not a crash.Screen snapshotcaliber · Knowledge › Object Store › doc-intake › (preview a file)Read / inspect this surface — no form to fill.- Register the shipped extractor as a reusable Tool so the workflow can call it. Click Register Tool to open the 5-step wizard and fill the steps below. It points at an importable Python callable; because it is a
readtool with Allow in Preview on, it runs live in the sandbox. (On Step 3 — Schema — use the Raw JSON toggle to paste the input/output schema.)Fill inStep 1 Identity — Name extract_document· Version1.0Step 2 Implementation — Module Path caliber.workflows.ingestion_toolsStep 2 Implementation — Callable Name extract_documentStep 3 Schema (Raw JSON toggle) Paste input/output from extract-document.tool.json — input requires ref(the object path); output is{text, format, chars, truncated, source, ocr_used}Step 5 Safety & Review — Side Effect Level ReadStep 5 Safety & Review — Allow in Preview checked (so the read tool runs live in the sandbox) Click Register ToolYou'll see:extract_documentappears in the Tools list; opening it shows the per-tool workspace with a Sandbox stage tab.Screen snapshotcaliber · Library › Tools › Register ToolRegister Tool Library › Tools › extract_document › Sandboxcreates · sandbox test runOpen the tool, switch to the Sandbox stage tab, and run it once against a real file to confirm it returns clean text. Important:extract_documentresolves itsrefas a local filesystem path — it opensPath(ref)and requirespath.is_file()— so it cannot read anobject-store://URI or a bucket key. Pass a real absolute local path to a file that exists on the machine running the platform. Put the call arguments into the Input JSON box, then click Test Run. Read tools with Allow in Preview execute for real here (the result is marked “Live execution”, not mocked).Fill inInput JSON (use a real absolute local path) {"ref": "/ABSOLUTE/PATH/TO/cookbooks/04-document-extraction-structuring-lab/assets/dataset/sources/invoice-clean.md"}— substitute the absolute path to the checked-outinvoice-clean.mdstand-in (a.md/.csv/.txtfile needs no conversion). For the real Office path, convert it toinvoice-clean.docxper the sources README first, then pointrefat that converted file's absolute path.Click Test RunYou'll see: A live result object:textpopulated,format=docx/markdown, a positivecharscount, andtruncated:false. The invoke is also saved under the Test Runs tab.Screen snapshotcaliber · Library › Tools › extract_document › SandboxTest Run- Author the structuring prompt that turns extracted text into a JSON record. Paste the template body (everything below the YAML frontmatter) into the authoring textarea. The body instructs the model to extract only verifiable facts, match the target schema's types exactly, never invent values, and list anything it cannot ground in
missing_fields.Fill inName doc-structurerCommit message v1 verifiable-facts document structurerTemplate body (paste) Paste the body of doc-structurer.md — it declares variables {{ document_text }},{{ document_type }},{{ target_schema }}and demands JSON-only outputClick CreateYou'll see:doc-structurerv1 is created and resolvable under theprodalias.Screen snapshotcaliber · Library › Prompts › New promptCreate Compose › Workflows › New Workflowcreates · workflow: doc-intake-to-jsonCreate the pipeline workflow. Click New Workflow to open the template gallery, type the workflow name in the name box, then click the Blank Canvas template tile — selecting the tile creates the workflow (there is no separate Create button) and opens the Studio editor so you can lay out the nodes yourself. The next steps add five nodes in sequence.Fill inGive your workflow a name… doc-intake-to-jsonClick Blank Canvas (template tile)You'll see: The Workflow Studio editor opens with a node/component palette (Agent · Tool · Knowledge Query · Router · Python Code · MCP Resource).Screen snapshotcaliber · Compose › Workflows › New WorkflowBlank Canvas (template tile)Compose › Workflows › doc-intake-to-json (Studio editor)creates · node: extract_document (Tool)Drag a Tool node from the palette onto the canvas and bind it toextract_document. The run input carries a local file path (the run’sdoc_pathfield), so map that run input into the tool’srefport.extract_documentopensrefas a local filesystem path (Path(ref).is_file()) — it does not understandobject-store://URIs or bucket keys — so the file must already exist on the platform host. Land the file locally first: download the document from thedoc-intakebucket onto the platform host (Object Store preview/download, ormc cpfrom MinIO) and pass that absolute local path; or just point at the checked-out stand-in underassets/dataset/sources/.Fill inTool node select tool extract_document; map run inputdoc_path(an absolute local file path) →refYou'll see: AToolnode bound toextract_documenton the canvas, showing itsrefinput port wired from the run input’s local path.Screen snapshotcaliber · Compose › Workflows › doc-intake-to-json (Studio editor)Compose › Workflows › doc-intake-to-json (Studio editor)creates · node: structure_json (doc-structurer) extracted-fields.schema.json ↓Drag an Agent node from the palette and apply thedoc-structurerprompt to the extracted text. Bind the prompt and pass the extractor'stextoutput intodocument_text, the run'sdocument_typeintodocument_type, and paste the target schema JSON intotarget_schema.Fill inBind prompt doc-structurer(prod)document_text ← extract_document.textdocument_type ← run input document_typetarget_schema Paste the JSON from extracted-fields.schema.json You'll see: Anagentnode wired after the extractor, emitting a JSON record on its output.Screen snapshotcaliber · Compose › Workflows › doc-intake-to-json (Studio editor)Compose › Workflows › doc-intake-to-json (Studio editor) › Python Code nodecreates · python_code: validate_document_json validate_document_json.py ↓Drag a Python Code node from the palette to validate the JSON against the schema — no tool registration needed, it runs sandboxed. Paste the whole file as the node body (it must definerun_python_node(...)returning a dict). Wire the agent's JSON output into the node input namedextracted_jsonand the schema intotarget_schema. The node setsvalidation_status(pass/partial/fail) andmissing_fields.Fill inNode body (paste) Paste all of validate_document_json.py (stdlib-only; defines run_python_node)inputs.extracted_json ← doc-structurernode JSON outputinputs.target_schema ← the same extracted-fields.schema.json You'll see: Apython_codenode whoseresultport carries{validation_status, missing_fields, errors}.Screen snapshotcaliber · Compose › Workflows › doc-intake-to-json (Studio editor) › Python Code nodeCompose › Workflows › doc-intake-to-json (Studio editor)creates · workflow savedConnect the validator to the workflow output, then click Save in the editor toolbar. The full chain isextract_document (Tool) → doc-structurer (Agent) → validate_document_json (Python Code) → output.Click SaveYou'll see: The pipeline is saved as a draft version (an “unsaved” pill clears). The workflow detail page’s Runs tab then lets you start a run.Screen snapshotcaliber · Compose › Workflows › doc-intake-to-json (Studio editor)SaveCompose › Workflows › doc-intake-to-json › Runscreates · workflow runs: pass / partial / failOn the workflow detail page open the Runs tab. Land each file locally first —extract_documentreads itsrefas a local filesystem path, so the file must exist on the platform host (download it from thedoc-intakebucket, or use the checked-out stand-ins underassets/dataset/sources/). For each case, put the run input (an absolute localdoc_path+ document type) in the run input box and click Run Pipeline (labeled “Queue Run” if queued runs are enabled). Replace/ABSOLUTE/PATH/TOwith the real absolute path on the host. Watch the outcomes differ: the golden file passes, the partial file reports the missing field, and the unsupported/corrupt files fail with a clean diagnostic instead of crashing.Fill inGolden run (D01) {"doc_path":"/ABSOLUTE/PATH/TO/sources/invoice-clean.docx","document_type":"invoice"}→validation_status: passPartial run (D03) {"doc_path":"/ABSOLUTE/PATH/TO/sources/invoice-partial.docx","document_type":"invoice"}→partial,missing_fields:["total"]Unsupported run (D05) {"doc_path":"/ABSOLUTE/PATH/TO/sources/invoice-legacy.doc","document_type":"invoice"}→ runfailwith a readable unsupported errorCorrupt run (D06) {"doc_path":"/ABSOLUTE/PATH/TO/sources/invoice-corrupt.docx","document_type":"invoice"}→ runfailattributed to the extract nodeClick Run PipelineYou'll see: Distinct run rows under the Runs tab: pass, partial (withmissing_fields), and clean failures. Note each run id for your evidence list.Screen snapshotcaliber · Compose › Workflows › doc-intake-to-json › RunsRun PipelineObserve › Observabilitycreates · trace evidence (stage-attributed failures)Open each run's trace and read the node tree top-to-bottom. Separate extraction failures (theextract_documentnode) from schema failures (thevalidate_document_jsonnode) — the trace makes clear which stage broke. Capture one readable validation-error payload (which node, which field) for the demo evidence.You'll see: A span tree per run; the failing node carries a human-readable message (e.g. an extract error vs a type/missing-field validation error).Screen snapshotcaliber · Observe › ObservabilityRead / inspect this surface — no form to fill.Evaluate › Test Sets › + New Test Setcreates · test set: doc-extraction-cases extraction-cases.jsonl ↓Build a scored test set so you can prove the schema pass-rate gate. Click + New Test Set, give it a Name and Owner (both required), and click Create. Then open its detail page at/eval-datasets/:idand author rows with + Add example, settinginputs={doc_path, document_type}and the expected answer directly. Alternatively, capture rows from the workflow runs: open Observe › Observability, pick a run's trace, and use Add to test set → Add example (the trace's request becomesinputsand its response the expected answer). The 6-row cases file (D01–D06) is the reference for which rows to add and the expectedvalidation_status.Fill inName doc-extraction-casesOwner your handle (required, e.g. @you)Click CreateYou'll see: A datasetdoc-extraction-casesappears; add its golden, edge (missing-field), and negative rows from Observability per extraction-cases.jsonl.Screen snapshotcaliber · Evaluate › Test Sets › + New Test SetCreateEvaluate › Evaluations › Run evaluationcreates · eval run: schema fidelity (deterministic)Score the runs deterministically — there is no LLM judge here. Click Run evaluation to open the run panel, pick the dataset, and check the deterministic scorers that compare the captured output against each row's expected answer. Click Run. Read the scorecard; if golden cases miss the schema, tune the prompt or schema and re-run. Gate: golden schema pass rate ≥ 0.95, and the unsupported file produces a readable, stage-identifying error.Fill inDataset doc-extraction-casesScorers check Contains expected(contains_expected) and/orExact match(exact_match) — deterministic, no judgeClick RunYou'll see: A scorecard with per-example pass/fail and an overall rate; pin it as a baseline to compare future prompt/schema changes.Screen snapshotcaliber · Evaluate › Evaluations › Run evaluationRun
Assets (copy-paste)
The exact files this cookbook uses — copy each into the matching field. Source: docs-site/cookbooks/04-document-extraction-structuring-lab/assets/.
# INVOICE
**Invoice ID:** INV-2026-0042
**Vendor:** Northwind Traders, Inc.
**Issue Date:** 2026-05-01
**Due Date:** 2026-05-31
**Currency:** USD
Bill To: Caliber Demo Co., 500 Market St, Seattle WA
| # | Description | Quantity | Unit Price | Amount |
|---|-----------------------------------|----------|------------|---------|
| 1 | Standard Widget (part WGT-100) | 10 | 12.50 | 125.00 |
| 2 | Premium Widget (part WGT-200) | 5 | 30.00 | 150.00 |
| 3 | On-site installation (per hour) | 4 | 95.00 | 380.00 |
Subtotal: 655.00
Tax (8%): 52.40
**Total: 707.40 USD**
Payment terms: Net 30. Please reference the invoice ID on remittance.
<!--
STAND-IN for the GOLDEN case (dataset rows D01/D02).
Every required field of schema/extracted-fields.schema.json is present and
verifiable: invoice_id, vendor, line_items[] (3 rows), total (707.40),
currency (USD), issue_date (2026-05-01). Expected validation_status: pass.
Convert to a real .docx (D01) and .xlsx (D02) per sources/README.md before the
live extract path; for a dry run you can extract this .md directly.
-->
field,value
invoice_id,INV-2026-0042
vendor,"Northwind Traders, Inc."
issue_date,2026-05-01
due_date,2026-05-31
currency,USD
line_item,"Standard Widget (part WGT-100)|qty=10|unit_price=12.50|amount=125.00"
line_item,"Premium Widget (part WGT-200)|qty=5|unit_price=30.00|amount=150.00"
line_item,"On-site installation (per hour)|qty=4|unit_price=95.00|amount=380.00"
subtotal,655.00
tax,52.40
total,707.40
# INVOICE
**Invoice ID:** INV-2026-0099
**Vendor:** Contoso Office Supplies
**Issue Date:** 2026-05-10
**Currency:** USD
Bill To: Caliber Demo Co., 500 Market St, Seattle WA
| # | Description | Quantity | Unit Price | Amount |
|---|------------------------------|----------|------------|---------|
| 1 | Managed print service (May) | 1 | 300.00 | 300.00 |
| 2 | Toner cartridge (black) | 2 | 45.00 | 90.00 |
Notes: This is a draft invoice. The grand total has NOT yet been finalized and
is intentionally omitted from this document — do not compute or infer it.
<!--
STAND-IN for the EDGE / missing-field case (dataset row D03).
Required fields invoice_id, vendor, line_items[], currency, issue_date are
present and verifiable, but `total` is deliberately absent. The doc-structurer
prompt must NOT invent a total; it should list "total" in missing_fields, and
validate_document_json must then return validation_status: partial with
missing_fields == ["total"]. Convert to a real .docx per sources/README.md, or
extract this .md directly for a dry run.
-->
# Source documents for SCN-04 — stand-ins + how to make real Office files
The extraction pipeline runs on **real binary Office files** (`.docx` / `.pptx`
/ `.xlsx`). Those are ZIP containers and **cannot be authored as text**, so this
folder ships **stand-in source content** you *can* read and edit:
| Stand-in file | Represents | Dataset row(s) | Expected outcome |
| --- | --- | --- | --- |
| [`invoice-clean.md`](invoice-clean.md) | clean invoice (all fields) | D01 (`.docx`) | `validation_status: pass` |
| [`invoice-clean.csv`](invoice-clean.csv) | same invoice as a sheet | D02 (`.xlsx`) | `validation_status: pass` |
| [`invoice-partial.md`](invoice-partial.md) | invoice missing `total` | D03 (`.docx`) | `partial`, `missing_fields:["total"]` |
The dataset ([`../extraction-cases.jsonl`](../extraction-cases.jsonl)) also
references three files that are **deliberately not provided as content** because
they only exist to exercise error paths — create them with the one-liners below:
- `invoice-no-vendor.docx` (D04) — copy `invoice-clean.md`, delete the
**Vendor** line, save as `.docx`. Expect `partial`, `missing_fields:["vendor"]`.
- `invoice-legacy.doc` (D05) — a **legacy** Word doc (the unsupported-format
negative case). See "Making the unsupported `.doc`" below.
- `invoice-corrupt.docx` (D06) — a deliberately broken `.docx`. See "Making the
corrupt file" below.
## Dry run (no conversion) — fastest
`caliber.workflows.ingestion_tools:extract_document` reads `.md`, `.csv`, and
`.txt` as text directly. To smoke-test the **prompt + validator** wiring without
producing Office binaries, upload the `.md`/`.csv` stand-ins to the `doc-intake`
bucket and point the workflow at them. The text the structurer sees is
equivalent; only the `format` field of the extract output differs
(`markdown`/`text` instead of `docx`/`xlsx`). The golden/partial assertions in
the dataset still hold.
> Note: a dry run does **not** cover the negative cases (D05/D06) — those need a
> real legacy `.doc` and a real corrupt `.docx` (see below).
## Make the real Office files
### Option A — LibreOffice headless (no code; converts in place)
```sh
cd cookbooks/04-document-extraction-structuring-lab/assets/dataset/sources
# .md -> .docx (D01, and the basis for D04 after you remove the Vendor line)
soffice --headless --convert-to docx invoice-clean.md
soffice --headless --convert-to docx invoice-partial.md
# .csv -> .xlsx (D02)
soffice --headless --convert-to xlsx invoice-clean.csv
```
### Option B — Python (`python-docx` + `openpyxl`, from the `caliber-suite[ingest]` extra)
```python
# invoice-clean.docx (D01)
from docx import Document
doc = Document()
for line in open("invoice-clean.md", encoding="utf-8"):
line = line.rstrip("\n")
if line.strip().startswith("<!--"):
break # stop at the trailing HTML comment
doc.add_paragraph(line)
doc.save("invoice-clean.docx")
# invoice-clean.xlsx (D02)
import csv
from openpyxl import Workbook
wb = Workbook(); ws = wb.active; ws.title = "Invoice"
with open("invoice-clean.csv", newline="", encoding="utf-8") as fh:
for row in csv.reader(fh):
ws.append(row)
wb.save("invoice-clean.xlsx")
```
Then land each produced file on the platform host at the **exact absolute
local path** named in the dataset's `doc_path` (e.g. the
`.../assets/dataset/sources/invoice-clean.docx` placeholder). You can also upload
the files to the `doc-intake` bucket (`Object Store → doc-intake → Upload`) for
preview, but the extractor reads them by local path, not by bucket key.
## Making the unsupported `.doc` (negative case D05)
The extractor tool does **not** raise a typed error for a `.doc` — it would read
the binary as best-effort UTF-8 (garbage). The **readable** `kind:"unsupported"`
diagnostic is produced by the **Object Store extract endpoint**, which is the
surface this case targets. Produce a genuine legacy binary `.doc` and confirm
the endpoint rejects it:
```sh
# Real legacy .doc (binary Word 97-2003), NOT a renamed .docx:
soffice --headless --convert-to doc invoice-clean.md # -> invoice-clean.doc
mv invoice-clean.doc invoice-legacy.doc
```
Upload `invoice-legacy.doc`, then in the UI open it and click **Extract** (or
call `GET /object-store/buckets/doc-intake/object/extract?key=invoice-legacy.doc`).
Expect a readable `kind:"unsupported"` error naming the format — that is the
`unsupported_format_returns_readable_error` rule check, and the workflow run
should surface it cleanly (status `fail`) rather than crash.
> Do **not** simply rename a `.docx` to `.doc`; that produces a Zip-with-a-`.doc`
> name, not a real legacy OLE document, and won't represent the case faithfully.
## Making the corrupt file (negative case D06)
Truncate a valid `.docx` so the parser fails mid-read:
```sh
# after producing invoice-clean.docx above:
head -c 2048 invoice-clean.docx > invoice-corrupt.docx # truncated ZIP -> parse error
```
Upload `invoice-corrupt.docx`. The `extract_document` tool raises
`IngestionError("failed to extract docx ...")`; the workflow run ends `fail`
with that message attributed to the **extract** node (not the validate node) —
which is exactly the extraction-vs-validation separation the gate asks for.
{
"name": "extract_document",
"version": "1",
"module_path": "caliber.workflows.ingestion_tools",
"callable_name": "extract_document",
"side_effect_level": "read",
"allow_in_preview": true,
"input_schema": {
"type": "object",
"required": ["ref"],
"properties": {
"ref": {
"type": "string",
"description": "Absolute LOCAL filesystem path of the source file. The callable opens Path(ref) and requires path.is_file(); it does NOT understand object-store:// URIs or bucket keys, so download/land the file locally first. Dispatch is by extension: .pdf/.docx/.pptx/.xlsx/.md/.txt/.csv. Unknown extensions (incl. legacy .doc/.ppt/.xls) are read best-effort as UTF-8 text."
}
}
},
"output_schema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Extracted plain text (XLSX = tab-separated rows under '## Sheet:' headers; PPTX = per-slide blocks)."},
"format": {"type": "string", "description": "Logical format the extension mapped to: pdf|docx|pptx|xlsx|markdown|text."},
"chars": {"type": "integer"},
"truncated": {"type": "boolean"},
"source": {"type": "string"},
"ocr_used": {"type": "boolean"}
}
},
"notes": "Read-only; runs live in the sandbox (allow_in_preview). The shipped callable signature is extract_document(ref, *, max_chars=200000, ocr='auto'); only `ref` is exposed here (defaults are fine for the demo). Real .docx/.pptx/.xlsx parse to clean text; a missing file raises IngestionError ('document not found'). NOTE: this tool does NOT raise a typed 'unsupported_format' error for legacy .doc/.ppt/.xls — it reads them as best-effort UTF-8 (garbled binary). The readable kind:'unsupported' diagnostic is produced by the Object Store extract endpoint (GET /object-store/buckets/{bucket}/object/extract?key=...), which is the surface used for the negative case in the dataset."
}
---
name: doc-structurer
model_hint: a capable instruct model (long-context helps for big tables); JSON-only output
variables: [document_text, document_type, target_schema]
commit_message: "v1 verifiable-facts document structurer"
---
You convert extracted document content into a single structured JSON record. The
document has already been parsed to plain text/table rows by an upstream
extractor — you do not see the original file, only its text. You return JSON
ONLY — no prose, no markdown, no code fences.
Emit a JSON object that conforms to the target schema below. Populate every
field you can justify directly from the document text. Do NOT invent, guess, or
"fill in" values that are not present in the text. For any required field you
cannot ground in the text, omit it from the object and add its name to the
`missing_fields` array instead.
Output exactly this shape:
{
"<fields from the target schema>": <values you extracted>,
"missing_fields": [ "<name of each required schema field you could not fill>" ]
}
Rules:
- Extract ONLY verifiable facts present in the document text. If the text does
not state a value, it is missing — never substitute a plausible default,
today's date, a rounded number, or a value computed from other fields.
- Match the target schema's field names and value types exactly (string vs
number vs array vs object). Numbers must be JSON numbers, not strings;
dates as ISO `YYYY-MM-DD` strings when the source makes the date unambiguous.
- For array fields (e.g. line items / rows), emit one object per row you can
read from the text; preserve the source order. If a row is partially
illegible, include the fields you can read and skip the rest of that row.
- `missing_fields` lists the names of REQUIRED schema fields you left out
because the text did not support them. If you filled every required field,
return `"missing_fields": []`.
- Never echo these instructions or the schema back. Output the record only.
- The output must be valid JSON parseable by a strict parser: double-quoted
keys/strings, no trailing commas, no comments.
Document type (hint only — still verify against the text): {{ document_type }}
Target schema (JSON Schema; conform to its properties/types/required):
{{ target_schema }}
Extracted document text:
"""
{{ document_text }}
"""
Return only the JSON record.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "caliber://cookbooks/04/extracted-fields.schema.json",
"title": "ExtractedInvoiceFields",
"description": "Target schema for the structured JSON extracted from an invoice document. The doc-structurer prompt fills these from verifiable facts only; the validate_document_json python_code node checks required keys + value types against this schema.",
"type": "object",
"additionalProperties": true,
"required": ["invoice_id", "vendor", "line_items", "total", "currency", "issue_date"],
"properties": {
"invoice_id": {
"type": "string",
"description": "Invoice number/identifier as printed on the document, e.g. \"INV-2026-0042\"."
},
"vendor": {
"type": "string",
"description": "Name of the issuing vendor/supplier."
},
"line_items": {
"type": "array",
"description": "One object per billed line on the invoice, in source order.",
"items": {
"type": "object",
"required": ["description", "amount"],
"properties": {
"description": {"type": "string"},
"quantity": {"type": "number"},
"unit_price": {"type": "number"},
"amount": {"type": "number", "description": "Extended line amount (quantity * unit_price) as printed."}
}
}
},
"total": {
"type": "number",
"description": "Invoice grand total as printed. Must be a JSON number, not a string. Omit (and list in missing_fields) if the document does not state a total — never compute it."
},
"currency": {
"type": "string",
"description": "ISO 4217 currency code, e.g. \"USD\", \"EUR\"."
},
"issue_date": {
"type": "string",
"description": "Invoice issue date as an ISO 8601 date string YYYY-MM-DD."
},
"due_date": {
"type": "string",
"description": "Optional payment due date, ISO 8601 YYYY-MM-DD."
}
}
}
"""validate_document_json — Caliber workflow `python_code` node body.
Paste this into the **Python Code** node `validate_document_json` (no tool
registration; the python_code sandbox runs it). It performs a minimal,
stdlib-only JSON-Schema-ish check of the structurer's output against the target
schema and reports whether the record is schema-valid.
CONTRACT (how the sandbox calls this)
The node body runs inside:
run_python_node(input=None, context=None, inputs=None, run_input='')
Wire the upstream ports into this node's `inputs` so it receives:
inputs["extracted_json"] -> the doc-structurer node output (dict, or a
JSON string; both are accepted)
inputs["target_schema"] -> the JSON Schema (schema/extracted-fields.schema.json),
as a dict or a JSON string
If `inputs` is not populated, it falls back to reading those keys off `input`
/ `context` so the node still works when the upstream emits a single dict.
OUTPUTS (returned on the node's `result` port)
{
"validation_status": "pass" | "partial" | "fail",
"missing_fields": [<required schema fields absent from extracted_json>],
"errors": [<human-readable type/shape problems>]
}
- "pass" : every required field present AND all type checks pass.
- "partial" : some required fields missing (or echoed via the structurer's
own `missing_fields`) BUT no type errors on what IS present.
This is the README's "missing_fields populated" edge case
(a.k.a. pass_with_warnings).
- "fail" : at least one type/shape error, or the payload is not a JSON
object at all (unparseable / wrong root type).
The same dict is also returned on `text` (as JSON) so the node's text port and
the downstream `contains_expected` scorer can read `validation_status` /
`missing_fields` directly.
"""
import json
# Map JSON Schema "type" -> Python types for a basic isinstance check.
# bool is excluded from "number"/"integer" on purpose (bool is a subclass of int).
_JSON_TYPES = {
"string": str,
"number": (int, float),
"integer": int,
"boolean": bool,
"array": list,
"object": dict,
"null": type(None),
}
def _coerce_obj(value):
"""Accept a dict directly or a JSON string; anything else -> None."""
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except (ValueError, TypeError):
return None
return parsed if isinstance(parsed, dict) else None
return None
def _type_ok(value, json_type):
py = _JSON_TYPES.get(json_type)
if py is None:
return True # unknown declared type -> don't fail the record on it
if json_type in ("number", "integer") and isinstance(value, bool):
return False # booleans are not numbers here
if json_type == "number":
return isinstance(value, (int, float))
return isinstance(value, py)
def _check_value(name, value, prop_schema, errors):
"""Type/shape-check one value against its property subschema (one level deep
into array item objects, which is enough for the invoice line_items case)."""
declared = prop_schema.get("type")
if declared and not _type_ok(value, declared):
errors.append(
"field {!r}: expected type {!r}, got {!r}".format(
name, declared, type(value).__name__
)
)
return # don't descend into a value of the wrong type
if declared == "array":
item_schema = prop_schema.get("items") or {}
item_required = item_schema.get("required") or []
item_props = item_schema.get("properties") or {}
for idx, item in enumerate(value):
if item_schema.get("type") and not _type_ok(item, item_schema["type"]):
errors.append(
"field {!r}[{}]: expected item type {!r}, got {!r}".format(
name, idx, item_schema["type"], type(item).__name__
)
)
continue
if isinstance(item, dict):
for req in item_required:
if req not in item or item.get(req) is None:
errors.append(
"field {!r}[{}]: missing required item key {!r}".format(
name, idx, req
)
)
for key, sub in item_props.items():
if key in item and item.get(key) is not None:
_check_value("{}[{}].{}".format(name, idx, key), item[key], sub, errors)
def run_python_node(input=None, context=None, inputs=None, run_input=""):
src = inputs if isinstance(inputs, dict) else {}
def _pick(key):
if key in src:
return src[key]
if isinstance(input, dict) and key in input:
return input[key]
if isinstance(context, dict) and key in context:
return context[key]
return None
extracted = _coerce_obj(_pick("extracted_json"))
schema = _coerce_obj(_pick("target_schema")) or {}
errors = []
missing_fields = []
# Root must be a JSON object; otherwise the structurer broke the contract.
if extracted is None:
result = {
"validation_status": "fail",
"missing_fields": [],
"errors": ["extracted_json is missing or is not a JSON object"],
}
return {"text": json.dumps(result), "result": result}
required = schema.get("required") or []
props = schema.get("properties") or {}
# 1) Required-key presence (treat null as absent).
for field in required:
if field not in extracted or extracted.get(field) is None:
missing_fields.append(field)
# Honor the structurer's self-declared missing_fields (the prompt is told to
# list required fields it could not ground). Union, preserving order.
declared_missing = extracted.get("missing_fields")
if isinstance(declared_missing, list):
for field in declared_missing:
if isinstance(field, str) and field not in missing_fields:
missing_fields.append(field)
# 2) Type/shape checks for fields that ARE present.
for field, prop_schema in props.items():
if field in extracted and extracted.get(field) is not None:
_check_value(field, extracted[field], prop_schema, errors)
if errors:
status = "fail"
elif missing_fields:
status = "partial"
else:
status = "pass"
result = {
"validation_status": status,
"missing_fields": missing_fields,
"errors": errors,
}
return {"text": json.dumps(result), "result": result}
{"id": "D01", "tags": ["golden"], "inputs": {"doc_path": "/ABSOLUTE/PATH/TO/cookbooks/04-document-extraction-structuring-lab/assets/dataset/sources/invoice-clean.docx", "document_type": "invoice"}, "expectations": {"validation_status": "pass", "must_contain_fields": ["invoice_id", "vendor", "line_items", "total", "currency", "issue_date"]}}
{"id": "D02", "tags": ["golden"], "inputs": {"doc_path": "/ABSOLUTE/PATH/TO/cookbooks/04-document-extraction-structuring-lab/assets/dataset/sources/invoice-clean.xlsx", "document_type": "spreadsheet"}, "expectations": {"validation_status": "pass", "must_contain_fields": ["invoice_id", "vendor", "line_items", "total", "currency", "issue_date"]}}
{"id": "D03", "tags": ["edge", "missing_field"], "inputs": {"doc_path": "/ABSOLUTE/PATH/TO/cookbooks/04-document-extraction-structuring-lab/assets/dataset/sources/invoice-partial.docx", "document_type": "invoice"}, "expectations": {"validation_status": "partial", "must_contain_fields": ["invoice_id", "vendor", "line_items", "currency", "issue_date"], "missing_fields": ["total"]}}
{"id": "D04", "tags": ["edge", "missing_field"], "inputs": {"doc_path": "/ABSOLUTE/PATH/TO/cookbooks/04-document-extraction-structuring-lab/assets/dataset/sources/invoice-no-vendor.docx", "document_type": "invoice"}, "expectations": {"validation_status": "partial", "must_contain_fields": ["invoice_id", "line_items", "total", "currency", "issue_date"], "missing_fields": ["vendor"]}}
{"id": "D05", "tags": ["negative", "unsupported_format"], "inputs": {"doc_path": "/ABSOLUTE/PATH/TO/cookbooks/04-document-extraction-structuring-lab/assets/dataset/sources/invoice-legacy.doc", "document_type": "unsupported"}, "expectations": {"validation_status": "fail", "error_kind": "unsupported", "error_readable": true, "must_contain_fields": []}}
{"id": "D06", "tags": ["negative", "corrupt"], "inputs": {"doc_path": "/ABSOLUTE/PATH/TO/cookbooks/04-document-extraction-structuring-lab/assets/dataset/sources/invoice-corrupt.docx", "document_type": "invoice"}, "expectations": {"validation_status": "fail", "error_readable": true, "must_contain_fields": []}}
Evaluation & quality gates
| Quality gate | Target |
|---|---|
| Golden schema pass rate | ≥ 0.95 |
| Unsupported format error readability | 100% (identifies the stage) |
Developer notes & gotchas
- Extract supports
.docx/.pptx/.xlsx— not legacy.doc/.ppt/.xls. The readable “unsupported” error comes from the Object Store extract endpoint. - Schema validation is deterministic (Data Transform node) — not an LLM judge.
- Binary .docx/.xlsx can't be authored as text;
assets/dataset/sources/ships stand-ins + conversion notes.