CALIBER
Quickstart

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.

  1. Knowledge › Object Store › Bucket browser creates · bucket: doc-intake
    Create 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 in
    new-bucket-namedoc-intake
    Click + (Create bucket)
    You'll see: A new empty bucket doc-intake appears in the Bucket browser list and opens automatically, ready for uploads.
    Screen snapshot
    caliber · Knowledge › Object Store › Bucket browser
    Knowledge BaseBucket browser
    + (Create bucket)
  2. Knowledge › Object Store › doc-intake creates · uploaded source documents invoice-clean.md ↓ invoice-clean.csv ↓ invoice-partial.md ↓
    Open the doc-intake bucket 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_document reads .md/.csv/.txt); to exercise the real Office path, first convert them to .docx/.xlsx using the one-liners in the sources README. Upload each file under the exact key the dataset expects.
    Fill in
    Golden invoice (all fields → pass)Upload invoice-clean.md (dry run) or its converted invoice-clean.docx / invoice-clean.xlsx
    Spreadsheet stand-inUpload 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.docx per sources/README.md, then upload
    Click Upload
    You'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 (the doc_path field) rather than a bucket key — so after landing each file locally, the doc_path values point at the on-disk copy the platform host can open.
    Screen snapshot
    caliber · Knowledge › Object Store › doc-intake
    Knowledge Basedoc-intake
    Upload
  3. Knowledge › Object Store › doc-intake › (preview a file) creates · extraction preview + readable unsupported error
    Confirm 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 legacy invoice-legacy.doc the same way to capture the readable unsupported diagnostic. (The Object Store preview is what produces the friendly kind:"unsupported" message; the extract_document tool would instead read a .doc as 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); for invoice-legacy.doc a readable kind:"unsupported" message naming the format — not a crash.
    Screen snapshot
    caliber · Knowledge › Object Store › doc-intake › (preview a file)
    Knowledge Base(preview a file)
    Read / inspect this surface — no form to fill.
  4. Library › Tools › Register Tool creates · tool: extract_document extract-document.tool.json ↓
    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 read tool 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 in
    Step 1 Identity — Nameextract_document · Version 1.0
    Step 2 Implementation — Module Pathcaliber.workflows.ingestion_tools
    Step 2 Implementation — Callable Nameextract_document
    Step 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 LevelRead
    Step 5 Safety & Review — Allow in Previewchecked (so the read tool runs live in the sandbox)
    Click Register Tool
    You'll see: extract_document appears in the Tools list; opening it shows the per-tool workspace with a Sandbox stage tab.
    Screen snapshot
    caliber · Library › Tools › Register Tool
    ToolsRegister Tool
    Register Tool
  5. Library › Tools › extract_document › Sandbox creates · sandbox test run
    Open the tool, switch to the Sandbox stage tab, and run it once against a real file to confirm it returns clean text. Important: extract_document resolves its ref as a local filesystem path — it opens Path(ref) and requires path.is_file() — so it cannot read an object-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 in
    Input 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-out invoice-clean.md stand-in (a .md/.csv/.txt file needs no conversion). For the real Office path, convert it to invoice-clean.docx per the sources README first, then point ref at that converted file's absolute path.
    Click Test Run
    You'll see: A live result object: text populated, format = docx/markdown, a positive chars count, and truncated:false. The invoke is also saved under the Test Runs tab.
    Screen snapshot
    caliber · Library › Tools › extract_document › Sandbox
    ToolsSandbox
    Test Run
  6. Library › Prompts › New prompt creates · prompt: doc-structurer doc-structurer.md ↓
    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 in
    Namedoc-structurer
    Commit messagev1 verifiable-facts document structurer
    Template body (paste)Paste the body of doc-structurer.md — it declares variables {{ document_text }}, {{ document_type }}, {{ target_schema }} and demands JSON-only output
    Click Create
    You'll see: doc-structurer v1 is created and resolvable under the prod alias.
    Screen snapshot
    caliber · Library › Prompts › New prompt
    PromptsNew prompt
    Create
  7. Compose › Workflows › New Workflow creates · workflow: doc-intake-to-json
    Create 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 in
    Give your workflow a name…doc-intake-to-json
    Click 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 snapshot
    caliber · Compose › Workflows › New Workflow
    WorkflowsNew Workflow
    Blank Canvas (template tile)
  8. 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 to extract_document. The run input carries a local file path (the run’s doc_path field), so map that run input into the tool’s ref port. extract_document opens ref as a local filesystem path (Path(ref).is_file()) — it does not understand object-store:// URIs or bucket keys — so the file must already exist on the platform host. Land the file locally first: download the document from the doc-intake bucket onto the platform host (Object Store preview/download, or mc cp from MinIO) and pass that absolute local path; or just point at the checked-out stand-in under assets/dataset/sources/.
    Fill in
    Tool nodeselect tool extract_document; map run input doc_path (an absolute local file path) → ref
    You'll see: A Tool node bound to extract_document on the canvas, showing its ref input port wired from the run input’s local path.
    Screen snapshot
    caliber · Compose › Workflows › doc-intake-to-json (Studio editor)
    Workflowsdoc-intake-to-json (Studio editor)
  9. 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 the doc-structurer prompt to the extracted text. Bind the prompt and pass the extractor's text output into document_text, the run's document_type into document_type, and paste the target schema JSON into target_schema.
    Fill in
    Bind promptdoc-structurer (prod)
    document_textextract_document.text
    document_type← run input document_type
    target_schemaPaste the JSON from extracted-fields.schema.json
    You'll see: An agent node wired after the extractor, emitting a JSON record on its output.
    Screen snapshot
    caliber · Compose › Workflows › doc-intake-to-json (Studio editor)
    Workflowsdoc-intake-to-json (Studio editor)
  10. Compose › Workflows › doc-intake-to-json (Studio editor) › Python Code node creates · 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 define run_python_node(...) returning a dict). Wire the agent's JSON output into the node input named extracted_json and the schema into target_schema. The node sets validation_status (pass/partial/fail) and missing_fields.
    Fill in
    Node body (paste)Paste all of validate_document_json.py (stdlib-only; defines run_python_node)
    inputs.extracted_jsondoc-structurer node JSON output
    inputs.target_schema← the same extracted-fields.schema.json
    You'll see: A python_code node whose result port carries {validation_status, missing_fields, errors}.
    Screen snapshot
    caliber · Compose › Workflows › doc-intake-to-json (Studio editor) › Python Code node
    WorkflowsPython Code node
  11. Compose › Workflows › doc-intake-to-json (Studio editor) creates · workflow saved
    Connect the validator to the workflow output, then click Save in the editor toolbar. The full chain is extract_document (Tool) → doc-structurer (Agent) → validate_document_json (Python Code) → output.
    Click Save
    You'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 snapshot
    caliber · Compose › Workflows › doc-intake-to-json (Studio editor)
    Workflowsdoc-intake-to-json (Studio editor)
    Save
  12. Compose › Workflows › doc-intake-to-json › Runs creates · workflow runs: pass / partial / fail
    On the workflow detail page open the Runs tab. Land each file locally firstextract_document reads its ref as a local filesystem path, so the file must exist on the platform host (download it from the doc-intake bucket, or use the checked-out stand-ins under assets/dataset/sources/). For each case, put the run input (an absolute local doc_path + document type) in the run input box and click Run Pipeline (labeled “Queue Run” if queued runs are enabled). Replace /ABSOLUTE/PATH/TO with 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 in
    Golden run (D01){"doc_path":"/ABSOLUTE/PATH/TO/sources/invoice-clean.docx","document_type":"invoice"}validation_status: pass
    Partial 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"} → run fail with a readable unsupported error
    Corrupt run (D06){"doc_path":"/ABSOLUTE/PATH/TO/sources/invoice-corrupt.docx","document_type":"invoice"} → run fail attributed to the extract node
    Click Run Pipeline
    You'll see: Distinct run rows under the Runs tab: pass, partial (with missing_fields), and clean failures. Note each run id for your evidence list.
    Screen snapshot
    caliber · Compose › Workflows › doc-intake-to-json › Runs
    WorkflowsRuns
    Run Pipeline
  13. Observe › Observability creates · trace evidence (stage-attributed failures)
    Open each run's trace and read the node tree top-to-bottom. Separate extraction failures (the extract_document node) from schema failures (the validate_document_json node) — 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 snapshot
    caliber · Observe › Observability
    ObservabilityObservability
    Read / inspect this surface — no form to fill.
  14. Evaluate › Test Sets › + New Test Set creates · 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/:id and author rows with + Add example, setting inputs = {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 becomes inputs and its response the expected answer). The 6-row cases file (D01–D06) is the reference for which rows to add and the expected validation_status.
    Fill in
    Namedoc-extraction-cases
    Owneryour handle (required, e.g. @you)
    Click Create
    You'll see: A dataset doc-extraction-cases appears; add its golden, edge (missing-field), and negative rows from Observability per extraction-cases.jsonl.
    Screen snapshot
    caliber · Evaluate › Test Sets › + New Test Set
    Test Sets+ New Test Set
    Create
  15. Evaluate › Evaluations › Run evaluation creates · 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 in
    Datasetdoc-extraction-cases
    Scorerscheck Contains expected (contains_expected) and/or Exact match (exact_match) — deterministic, no judge
    Click Run
    You'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 snapshot
    caliber · Evaluate › Evaluations › Run evaluation
    EvaluationsRun evaluation
    Run

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

assets/dataset/sources/invoice-clean.md
# 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.
-->
assets/dataset/sources/invoice-clean.csv
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
assets/dataset/sources/invoice-partial.md
# 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.
-->
assets/dataset/sources/README.md
# 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.
assets/tools/extract-document.tool.json
{
  "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."
}
assets/prompts/doc-structurer.md
---
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.
assets/schema/extracted-fields.schema.json
{
  "$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."
    }
  }
}
assets/tools/validate_document_json.py
"""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}
assets/dataset/extraction-cases.jsonl
{"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 gateTarget
Golden schema pass rate≥ 0.95
Unsupported format error readability100% (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.

CALIBER : Contextual Adaptive Lifecycle for Intelligent Build, Evaluation, and Refinement — Cookbooks — every recipe is UI-implementable on the shipped platform. Source + assets under docs-site/cookbooks/<nn>-…/. Regenerate with python3 docs-site/cookbooks/training/build.py.