The hard part of a financial PDF is rarely the text. It is a table that runs across pages, a total that carries to the last page, and footnotes that change what a number means. You want that as typed JSON your pipeline can use, and in anything regulated you also want to prove each value came from a specific spot on the page. This walks through the three call pipeline in LandingAI's Agentic Document Extraction, our ADE platform, that gets you both: Parse turns the document into grounded structure, Extract shapes it into your schema, and Ground ties every value back to its page.
The pipeline at a glance
| Call | Input | What it returns |
|---|---|---|
| Parse | The PDF or image | Reading order Markdown with tables as HTML, plus a grounded tree of pages and blocks |
| Extract | The parsed Markdown and a schema | Your fields as typed JSON, with per field text ranges |
| Ground | The extract metadata and the parse structure | Every value mapped to a page and a bounding box |
Setup
Install the library and set your key. The client reads VISION_AGENT_API_KEY from the environment, and you pass environment="eu" if your key is an EU key, since keys are region specific.
pip install landingai-ade
from pathlib import Path
from landingai_ade import LandingAIADE
client = LandingAIADE() # reads VISION_AGENT_API_KEY
Step 1: Parse the PDF
client.v2.parse sends the document to the DPT-3 model and returns a single response with three parts: markdown, structure, and metadata. The Parse v2 API takes PDFs and images, so convert other formats first. Tables come back as HTML inside the Markdown, which keeps rows, columns, and spanning cells intact rather than flattening them into text.
parsed = client.v2.parse(
document=Path("annual-report.pdf"),
model="dpt-3-pro-latest",
options={"pages": [2, 3, 4]}, # optional: limit the parse to specific pages
)
print(parsed.markdown) # reading-order Markdown; tables render as HTML
structure = parsed.structure # tree: document -> pages -> blocks, each grounded
print(parsed.metadata.page_count, parsed.metadata.billing.total_credits)
Two production details worth knowing up front. If some pages fail, the request still succeeds with HTTP 206 and lists the bad pages in metadata.failed_pages, so a single unreadable scan will not sink the whole job. And a synchronous parse is meant for documents you can wait on; for long filings you move to the jobs API, covered at the end, which is built to handle multi hundred page documents.
Walk the structure to find every table
The structure field is a tree. The root is the document, its children are pages, and a page's children are the blocks on it. Every node carries the same base shape: a type, a stable id, a span giving the start and end character offsets in the Markdown, and a grounding object holding the page, the range of characters, and a normalized bounding box. A table node also has children, which are its cells.
That regular shape means you can walk the tree once and pull out exactly what you need. Here is a short recursive walk that finds every table and reports the page it sits on.
def find_tables(node, found=None):
found = [] if found is None else found
if getattr(node, "type", None) == "table":
found.append(node)
for child in getattr(node, "children", []) or []:
find_tables(child, found)
return found
for table in find_tables(structure):
page = table.grounding.page
print(f"table {table.id} on page {page}, {len(table.children)} cells")
Because each table knows its own page, a statement that breaks across three pages comes back as three table blocks you can read in order rather than one flattened blob. That is the difference that lets carried totals and continued rows stay meaningful.
Step 2: Extract into a typed schema
client.v2.extract reads the Markdown and returns JSON in the shape of a schema you define, as a Pydantic model, a dict, or a JSON string. Because it works across the whole document at once, a grand total on the final page and its line items on earlier pages land in one object, and footnotes come through as their own field instead of being dropped.
from pydantic import BaseModel, Field
class LineItem(BaseModel):
label: str = Field(description="Row label in the statement")
amount: str = Field(description="Amount for the row")
class Financials(BaseModel):
line_items: list[LineItem]
grand_total: str = Field(description="Total that carries to the final page")
footnotes: list[str] = Field(description="Footnote text tied to the table")
result = client.v2.extract(schema=Financials, markdown=parsed.markdown)
print(result.extraction) # typed JSON in the shape of Financials
By default, fields your schema asks for that the model cannot support are skipped so extraction continues; pass strict=True to reject those with a 422 instead, which you want in a pipeline that should fail loudly. Alongside extraction, you get extraction_metadata, where every field carries the value and the character ranges it was quoted from. That metadata is what makes the next step possible, and it is the clean, structured JSON downstream systems and vector databases consume, the same output the best document parsing APIs are judged on.
Step 3: Ground each value to its page
Extract knows which characters each value came from; Ground turns those text positions into visual ones. You send it the extraction_metadata from Extract and the structure from Parse, and it returns every field mapped to the block it was quoted from, with a page number and a bounding box. It runs synchronously and consumes no credits.
grounded = client.v2.ground(
extraction_metadata=result.extraction_metadata,
structure=parsed.structure,
)
print(grounded.grounding)
Each field comes back tied to its exact place on the page:
"grand_total": [
{
"block_id": "table-cell-42",
"type": "table_cell",
"grounding": {
"page": 4,
"range": { "start": 8120, "end": 8131 },
"box": { "xmin": 0.71, "ymin": 0.88, "xmax": 0.86, "ymax": 0.90 }
}
}
]
One rule keeps this honest: the extraction metadata must come from an Extract run on the Markdown of the same Parse response you pass as structure. Re parsing shifts the character ranges and invalidates an older extraction, so pair them by the doc_id the extract metadata carries. The box is normalized zero to one on the page, so you can highlight the value on a rendering, crop the region, or attach it as a citation in a review interface. If your organization runs Zero Data Retention, Ground is off by design, and you compute the same overlap client side by matching each field's ranges against each block's grounding.range. Either way, that grand total on page 4 now carries proof of where it came from, which is the audit trail a finance or compliance reviewer needs before trusting an automated number.
Scale it in production
A synchronous call is fine while you build, and it raises a timeout error on documents too large to finish inline. For those, the jobs API mirrors the same shape: client.v2.parse_jobs and client.v2.extract_jobs each expose create, get, list, and wait, and a single job handles up to 6,000 pages or one gigabyte per PDF.
job = client.v2.parse_jobs.create(
document=Path("full-10k.pdf"),
model="dpt-3-pro-latest",
)
parsed = client.v2.parse_jobs.wait(job.job_id) # or poll with get() and list()
For high concurrency, AsyncLandingAIADE exposes the same calls with await. Wrap the work in the library's error types so a pipeline degrades cleanly rather than crashing: a partial parse returns 206 with failed_pages, a job that times out or fails raises JobWaitTimeoutError or JobFailedError, and any non success status raises an APIStatusError carrying the status code and response.
What you end up with
Three calls take a messy multi page filing to typed JSON where every value knows its page and its box. Parse preserves the structure and grounds each block, Extract shapes the content to your schema, and Ground makes each number verifiable against the source. Try the flow on your own document in the Playground at ade.landing.ai, then move the long files to the jobs API when you take it to production.
Top comments (0)