DEV Community

Cover image for Turning a multi page financial PDF into clean, traceable JSON with LandingAI ADE
sushrut mishra for LandingAI

Posted on

Turning a multi page financial PDF into clean, traceable JSON with LandingAI ADE

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 }
    }
  }
]
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)