Agentic Document Extraction
Agentic pipeline that reconciles offering memoranda against rent rolls and T-12s for commercial property underwriting.
At a glance
- Extraction across offering memoranda, rent rolls, and T-12s
- Agentic reconciliation that surfaces disagreements between documents
- Structured output tied back to source pages
- PostgreSQL store for extracted facts and provenance
- Reviewable diffs instead of a single opaque number
From the repository
README
Agentic Document Extraction & Underwriting Pipeline
Turns a commercial real estate Offering Memorandum and rent roll into an audited, investment-committee-ready underwriting memo — with every financial figure computed deterministically, never guessed by a model.
Overview
Underwriting a commercial property means reading two documents that actively disagree with each other: a glossy Offering Memorandum full of marketing claims, and the underlying rent roll / T-12 line items that actually produce income. Analysts spend days reconciling them by hand.
This project automates that reconciliation as a LangGraph state machine:
- Ingest — layout-aware PDF parsing recovers text and spatial tables with bounding boxes.
- Extract — narrative claims become strictly validated Pydantic models.
- Calculate — every metric is derived in
decimal.Decimalfrom the raw line items. - Audit — the broker's claims are re-derived and compared against the calculations at a 2% variance threshold.
- Export — an investment-committee PDF memo, with optional PostgreSQL persistence.
🛡️ The zero-hallucination invariant
A language model may read text. It may never produce a number.
StructuredExtractorcan call OpenAI/Anthropic tool-calling, but its entire contract is to copy verbatim values into a Pydantic schema. All arithmetic — GPR, EGI, OpEx, NOI, cap rate, DSCR, debt yield, IRR — lives ininternal/financial/and usesdecimal.Decimal. If the OM says NOI is $900,000 and the line items say $815,000, the pipeline reports the $85,000 discrepancy instead of quietly picking a number.
📖 Interactive diagrams · 🔍 System Architecture · 🔍 Data Lineage
✨ Key Features
| Capability | |
|---|---|
| 📄 | Layout-aware ingestion — pdfplumber recovers page text, table bounding boxes and cell grids; pypdf supplies document metadata |
| 🧾 | Header-mapping table parsing — rent rolls and T-12 statements are normalised from either CSV or embedded PDF tables, tolerating column reordering |
| 🧠 | Pydantic-guarded extraction — a typed OMExtractionSchema plus field/model validators (temporal sanity, vacancy/rent invariants) |
| 🔢 | Deterministic math engine — decimal.Decimal throughout, standard amortisation formula, ROUND_HALF_UP currency rounding |
| 📈 | DCF & IRR modelling — 5-year hold, configurable revenue/expense growth, exit cap-rate terminal value, unlevered and levered IRR |
| 🔍 | Cross-reconciliation audit — occupancy, unit count, rentable SF and NOI re-derived from line items against broker claims at a 2% tolerance |
| 🕸️ | LangGraph orchestration — a 5-node StateGraph with a typed UnderwritingState passed between nodes |
| 📑 | Investment Committee memo — Jinja2 template rendered to PDF via WeasyPrint, with xhtml2pdf and ReportLab fallbacks |
| 🗄️ | Optional persistence — asyncpg pool writing deal, unit rows and evaluation (audit flags as JSONB) to PostgreSQL 16 |
| 🧪 | 14 tests covering extraction, exact-penny math, DCF/IRR, audit thresholds, memo rendering, config and end-to-end graph execution |
🏗️ System Architecture

🔍 Explore the interactive Archify diagram — pan, zoom, filter by node type, inspect relationship labels, and export PNG/SVG/WebP.
Component breakdown
The pipeline separates into an entry point, a trust boundary, and optional side channels.
Outside the boundary — untrusted input and orchestration
| Component | Source | Responsibility |
|---|---|---|
| Offering Memorandum (PDF) | sample_data/sample_om.pdf | Broker-authored narrative. Marketing claims are treated as assertions to be verified, never as inputs to the math. |
| Rent Roll CSV | sample_data/sample_rent_roll.csv | Unit-level truth: 50 rows of tenant, floorplan, rentable SF, in-place rent, lease dates and vacancy status. |
| CLI Entrypoint | cmd/cli/main.py | Parses flags, builds loan terms and DCF assumptions, invokes the graph, prints the executive summary, and owns optional persistence. |
| PostgreSQL 16 | internal/repository/ | Three tables, written through an asyncpg pool with ON CONFLICT upserts. Reached only when --persist-db is passed. |
Inside the trust boundary — internal/agent/graph.py
The orange dashed region is the LangGraph StateGraph. Every node takes a UnderwritingState and returns a partial dict that is merged back into it, so each stage is independently testable and its output is inspectable on the returned state.
| Node | Source | Responsibility |
|---|---|---|
| Ingest | ingest_node · internal/ingestion/ | Loads pages, cleans whitespace, extracts tables with bounding boxes, then picks the rent roll (CSV preferred, PDF fallback) and the T-12. |
| Extract | extract_node · internal/agent/extractors.py | Turns narrative text into DealMetadata plus broker-claimed NOI and occupancy, kept separate from any calculation. |
| Calculate | calculate_node · internal/financial/engine.py | Derives GPR → vacancy → EGI → OpEx → NOI, then cap rate, DSCR and debt yield from loan terms. |
| Audit | audit_node · internal/agent/auditor.py | Re-derives each broker claim from the line items and emits AuditVarianceFlag records with a variance_pct and is_violation. |
| Export | export_memo_node · internal/export/memo_generator.py | Renders the investment memo and writes output/memo_<deal>.pdf. Failures are recorded without killing the run. |
Branches and optional paths
The first two rows are drawn in the diagram above. The third is an optional capability deliberately left out of the picture so the happy path stays legible.
| Relationship | Meaning |
|---|---|
| Calculate → DCF & IRR Engine | CashFlowEngine.project() builds the multi-year waterfall and attaches unlevered/levered IRR back onto the underwriting result. |
| CLI → PostgreSQL | DealRepository writes deal, unit rows and evaluation. Dashed = opt-in; the pipeline runs fully without a database. |
| StructuredExtractor → LLM | Used only when OPENAI_API_KEY or ANTHROPIC_API_KEY is set; otherwise deterministic regex heuristics. The LLM never computes. |
🔄 Core Workflow — Data Lineage

🔍 Explore the interactive Archify diagram
Request lifecycle, step by step
-
Ingest the offering memorandum
PDFLoader.load()walks each page withpdfplumber, extracting raw and normalised text plus every table's bounding box and cell grid.pypdfsupplies document metadata. (source:internal/ingestion/pdf_loader.py) -
Normalise the rent roll and T-12
TableExtractorlocates the header row by fuzzy column matching (unit,rent,sq ft,status, …) and maps columns positionally, so column order may vary. Currency strings like(1,200.00)and-are sanitised intoDecimal. If no CSV is supplied, the rent roll is recovered from the PDF's own tables. (source:internal/ingestion/table_extractor.py) -
Extract typed deal metadata and broker claims
StructuredExtractoremits aDealMetadatarecord (name, address, asset class, year built, units, rentable SF, asking price) and separately the broker's claimed NOI and occupancy. The claims are stored as assertions, quarantined from the calculation path. (source:internal/agent/extractors.py) -
Compute the underwriting, deterministically
UnderwritingEngine.underwrite()chains GPR → vacancy/credit loss → EGI → OpEx → NOI, then cap rate (NOI / price), DSCR (NOI / annual debt service) and debt yield. Annual debt service uses the standardPMT = P·[r(1+r)ⁿ] / [(1+r)ⁿ − 1]formula, or interest-only when the loan terms demand it. Every value isdecimal.Decimal, roundedROUND_HALF_UP. (source:internal/financial/engine.py) -
Project the hold and terminal value
CashFlowEngine.project()grows revenue and expenses independently over the hold period, capitalises the year-N+1 NOI at the exit cap rate, subtracts sales costs, and solves unlevered and levered IRR from the resulting cash-flow series. (source:internal/financial/cashflow.py) -
Reconcile claims against line items
CrossReconciliationAuditor.audit()checks the unit-conservation invariant, OM-vs-rent-roll unit count, rentable SF variance, claimed-vs-underwritten NOI, and claimed-vs-actual occupancy. Anything beyondVARIANCE_THRESHOLD(2.0%) becomes anis_violation=Trueflag. (source:internal/agent/auditor.py) -
Export the Investment Committee memo
MemoGeneratorrendersinvestment_memo.htmlwith Jinja2 and converts it to PDF, falling back toxhtml2pdfand then ReportLab if WeasyPrint is unavailable. (source:internal/export/memo_generator.py) -
Optionally persist the deal With
--persist-db,DealRepositoryupserts the deal, batch-inserts every unit row, and stores the evaluation with its audit flags serialised as JSONB. (source:internal/repository/deal_repo.py)
Reading the audit output
Every reconciliation prints as a finding, whether it passes or fails — a clean run is evidence too:
AUDIT FLAGS (1 findings):
* [VERIFIED ACCURATE] Physical Occupancy Rate: Occupancy verified at 94.0%
A violation looks like this (raised whenever a variance exceeds
VARIANCE_THRESHOLD):
* [FLAGGED VIOLATION] Net Operating Income (NOI): AUDIT WARNING: Claimed NOI
($900,000.00) diverges from underwritten NOI ($815,000.00) by 10.43%
(Threshold: 2.0%)
🧰 Tech Stack
| Layer | Technology | Role |
|---|---|---|
| Orchestration | langgraph ≥ 0.2 · langchain-core ≥ 0.3 | 5-node StateGraph workflow engine |
| Validation | pydantic ≥ 2.0 | Domain schemas, field/model validators, OMExtractionSchema |
| Ingestion | pdfplumber ≥ 0.10 · pypdf ≥ 4.0 | Text, table bboxes, document metadata |
| Financial math | numpy-financial ≥ 1.0 · decimal (stdlib) | IRR solver; exact currency arithmetic |
| Rendering | jinja2 ≥ 3.1 · weasyprint ≥ 60 | HTML template → IC memo PDF (with xhtml2pdf / ReportLab fallbacks) |
| Database | asyncpg ≥ 0.29 · PostgreSQL 16 | Async connection pool, migrations, upserts |
| CLI | argparse (stdlib) | Single-command entry point |
| Testing | pytest ≥ 8 · pytest-asyncio ≥ 0.23 | 14 tests, asyncio_mode = "auto" |
| Lint / dev | ruff ≥ 0.4 | Formatting and linting |
| Local infra | Docker Compose · postgres:16-alpine | One-command database with a healthcheck |
📁 Project Structure
.
├── cmd/
│ └── cli/
│ └── main.py # CLI entry point (argparse, orchestration, reporting)
├── internal/
│ ├── agent/ # ── LangGraph workflow ──
│ │ ├── graph.py # 5-node StateGraph: ingest → extract → calculate → audit → export_memo
│ │ ├── state.py # UnderwritingState TypedDict shared between nodes
│ │ ├── extractors.py # StructuredExtractor + OMExtractionSchema (LLM optional)
│ │ └── auditor.py # CrossReconciliationAuditor + 2% variance threshold
│ ├── ingestion/ # ── Document intake ──
│ │ ├── pdf_loader.py # pdfplumber layout parser, table bboxes, metadata
│ │ └── table_extractor.py # Header-mapping CSV/PDF table normalizer
│ ├── financial/ # ── Deterministic math (no LLM) ──
│ │ ├── models.py # Pydantic domain schemas + validators
│ │ ├── engine.py # GPR/EGI/OpEx/NOI, cap rate, DSCR, debt yield
│ │ └── cashflow.py # DCFParameters, projection, unlevered/levered IRR
│ ├── export/
│ │ ├── memo_generator.py # Triple-engine PDF memo compiler (WeasyPrint/xhtml2pdf/ReportLab)
│ │ └── templates/
│ │ └── investment_memo.html # Jinja2 IC memo template
│ └── repository/ # ── Persistence ──
│ ├── db.py # asyncpg pool + DatabaseConfig + migration runner
│ └── deal_repo.py # DealRepository upserts
├── migrations/
│ └── 001_init_real_estate_schema.sql # deals, rent_roll_records, underwriting_evaluations
├── scripts/
│ ├── migrate.py # Async CLI wrapper around run_migrations()
│ └── generate_sample_pdf.py # Regenerates sample_data/sample_om.pdf
├── sample_data/
│ ├── sample_om.pdf # 2-page synthetic OM with a unit mix + T-12
│ └── sample_rent_roll.csv # 50 units, 47 occupied / 3 vacant
├── tests/ # 6 files, 14 tests
├── docs/ # ── Diagrams ──
│ ├── architecture.html # Interactive system architecture (Archify)
│ ├── underwriting-lifecycle.html # Interactive data lineage (Archify)
│ ├── architecture.delivery.json # Provenance sidecar (SHA-256 of the verified artifact)
│ ├── underwriting-lifecycle.delivery.json
│ └── assets/
│ ├── architecture.png # Static render used by this README
│ └── underwriting-lifecycle.png
├── .archify/ # Archify candidate JSON + build receipts
├── output/ # Generated memos (memo_<deal>.pdf, investment_memo.pdf)
├── docker-compose.yml # PostgreSQL 16 + healthcheck + auto-applied migrations
├── Makefile # up / down / migrate / test / run-underwrite
└── pyproject.toml
🚀 Quick Start
Prerequisites
- Python 3.11+
- Docker Desktop (optional — only needed for PostgreSQL persistence)
1. Clone and install
git clone https://github.com/<your-username>/real-estate-underwriting-pipeline.git
cd real-estate-underwriting-pipeline
python -m venv .venv
# Windows (PowerShell)
.\.venv\Scripts\Activate.ps1
# macOS / Linux
source .venv/bin/activate
pip install -e ".[dev]"
2. Run the pipeline on the sample deal
python cmd/cli/main.py
======================================================================
REAL ESTATE AGENTIC DOCUMENT EXTRACTION & UNDERWRITING PIPELINE
======================================================================
OM PDF: ...\sample_data\sample_om.pdf
Rent Roll: ...\sample_data\sample_rent_roll.csv
[Executing LangGraph Pipeline: Ingest -> Extract -> Calculate -> Audit -> Memo]
----------------------------------------------------------------------
DEAL: Highland Heights Apartments
Address: 4200 Highland Park Way, Dallas, TX 75205 | Units: 50 | SF: 48,700
----------------------------------------------------------------------
Offering Price: $12,500,000.00
Gross Potential Rent (GPR): $1,088,520.00
Effective Gross Income (EGI): $1,101,818.00
Total OpEx: $343,000.00
Net Operating Income (NOI): $758,818.00
Underwritten Cap Rate: 6.07%
5-Year Unlevered IRR: 9.10%
5-Year Levered IRR: 9.10%
----------------------------------------------------------------------
AUDIT FLAGS (1 findings):
* [VERIFIED ACCURATE] Physical Occupancy Rate: Occupancy verified at 94.0%
[MEMO EXPORT] Investment Committee Memo generated at:
--> ...\output\investment_memo.pdf
Pass
--loan-amountto also print DSCR and Debt Yield — the deterministic engine skips all debt metrics when no loan terms are supplied.
3. Run with your own deal and custom financing
python cmd/cli/main.py \
--om path/to/offering_memorandum.pdf \
--rent-roll path/to/rent_roll.csv \
--loan-amount 8000000 \
--interest-rate 0.0625 \
--output-memo output/my_deal_memo.pdf
4. Enable PostgreSQL persistence (optional)
# Start PostgreSQL 16 (migrations auto-apply on first boot)
make up
# Apply migrations manually if the volume already exists
make migrate
# Run and persist
python cmd/cli/main.py --persist-db
5. Run the tests
make test
# or
pytest -v
14 passed
Makefile targets (POSIX note)
| Target | Action |
|---|---|
make up | Start the PostgreSQL container |
make down | Stop the container |
make migrate | Apply migrations/*.sql in lexical order |
make test | Run the full pytest suite |
make run-underwrite | Run the CLI on the sample deal |
make help | List targets |
The Makefile defaults PYTHON to .venv/Scripts/python (Windows layout). On macOS/Linux:
make test PYTHON=.venv/bin/python
⚙️ CLI Reference
| Flag | Default | Description |
|---|---|---|
--om PATH | sample_data/sample_om.pdf | Offering Memorandum PDF |
--rent-roll PATH | sample_data/sample_rent_roll.csv | Rent roll CSV (falls back to PDF tables when absent) |
--loan-amount FLOAT | unset | Loan principal. When omitted, no debt metrics are computed |
--interest-rate FLOAT | 0.065 | Annual coupon rate (0.065 = 6.5%) |
--output-memo PATH | output/investment_memo.pdf | Destination for the generated memo PDF |
--persist-db | off | Persist deal, unit rows and evaluation to PostgreSQL |
Environment variables
All variables are optional — the pipeline runs end-to-end with none of them set.
| Variable | Default | Purpose |
|---|---|---|
OPENAI_API_KEY | — | Enables LLM structured extraction (falls back to heuristics) |
ANTHROPIC_API_KEY | — | Same, for the Anthropic path |
POSTGRES_HOST | localhost | Database host |
POSTGRES_PORT | 5432 | Database port |
POSTGRES_USER | postgres | Database user |
POSTGRES_PASSWORD | postgres_password | Database password |
POSTGRES_DB | cre_underwriting | Database name |
DB_POOL_MIN_SIZE | 1 | asyncpg pool minimum |
DB_POOL_MAX_SIZE | 10 | asyncpg pool maximum |
Copy-paste .env template
# .env — only needed for --persist-db and optional LLM extraction
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres_password
POSTGRES_DB=cre_underwriting
# Optional: unlocks StructuredExtractor's LLM path
# OPENAI_API_KEY=sk-...
# ANTHROPIC_API_KEY=sk-ant-...
docker-compose.yml and internal/repository/db.py read these directly from the
environment; export them in your shell or load them with your preferred .env
tool before make up.
🗄️ Database
migrations/001_init_real_estate_schema.sql creates three tables with
ON DELETE CASCADE from real_estate_deals:
| Table | Purpose | Notable columns |
|---|---|---|
real_estate_deals | Deal / property master | asset_class, total_units, total_square_feet, asking_price |
rent_roll_records | Unit-level snapshot per deal | unit_number, tenant_name, monthly_rent, lease_start, lease_end, is_vacant |
underwriting_evaluations | Computed outputs per deal | net_operating_income, cap_rate, dscr, debt_yield, unlevered_irr, audit_discrepancy_flags (JSONB) |
All money is stored as NUMERIC, never floating point.
🧪 Testing
pytest -v
| File | Tests | Covers |
|---|---|---|
tests/test_agent_graph.py | 1 | End-to-end LangGraph execution on the sample OM + rent roll |
tests/test_financial_engine.py | 4 | Exact-penny GPR/EGI/OpEx/NOI, DSCR, debt yield, occupancy invariants, lease-date sanity, DCF + IRR bounds |
tests/test_audit_reconciliation.py | 3 | NOI variance above/below the 2% threshold, unit-count and occupancy mismatches |
tests/test_table_extractor.py | 3 | Layout-aware PDF extraction, rent roll CSV parsing, T-12 line-item parsing |
tests/test_db_config.py | 2 | DatabaseConfig defaults and DSN, migration file validity |
tests/test_memo_generator.py | 1 | Jinja2 HTML rendering and PDF generation |
| Total | 14 |
🎨 Diagrams
Both diagrams are authored as Archify JSON and render to self-contained, offline-capable HTML viewers.
| Diagram | Static asset | Interactive viewer |
|---|---|---|
| System architecture | docs/assets/architecture.png | docs/architecture.html |
| Data lineage | docs/assets/underwriting-lifecycle.png | docs/underwriting-lifecycle.html |
Sources live in .archify/. Each diagram also ships a docs/*.delivery.json
provenance sidecar recording the SHA-256 of the exact artifact that passed the
automated gates. To regenerate after changing the architecture:
node <archify>/bin/archify.mjs finalize architecture \
.archify/architecture-underwriting-pipeline-*/candidate.json \
docs/architecture.html --quality showcase --json
Note for Windows contributors: Archify's atomic publisher compares
fs.lstat()andfs.fstat()device IDs, which differ on Windows and cause a spuriouschanged-during-inspectionfailure. The collapsed section below shows a one-file workaround.
Regenerating diagrams on Windows
Node reports dev = 0 from lstat/stat but dev = <volume serial> from
fstat on Windows. Archify treats that difference as a file-identity change, so
every publish fails. A preload that normalizes only the fstat device ID to 0
restores the tool without weakening its integrity checks — ino, nlink, mode,
size, mtime, ctime and the content digest are all still verified.
// win32-dev-normalize.mjs (keep this outside the repository)
import fs from 'node:fs';
const normalizeDev = (s) => {
if (!s || typeof s !== 'object' || typeof s.dev !== 'bigint' || s.dev === 0n) return s;
try { Object.defineProperty(s, 'dev', { value: 0n, enumerable: true, configurable: true }); } catch { /* frozen */ }
return s;
};
const fstatSync = fs.fstatSync;
fs.fstatSync = (file, options) => normalizeDev(fstatSync.call(fs, file, options));
const fstat = fs.fstat;
fs.fstat = (file, options, callback) => {
if (typeof options === 'function') return fstat.call(fs, file, (e, s) => options(e, e ? s : normalizeDev(s)));
return fstat.call(fs, file, options, (e, s) => callback(e, e ? s : normalizeDev(s)));
};
$env:NODE_OPTIONS = "--import file:///C:/path/to/win32-dev-normalize.mjs"
node <archify>/bin/archify.mjs finalize architecture <candidate.json> docs/architecture.html --quality showcase --json
🤝 Contributing
- Never compute a number with a model. New financial logic belongs in
internal/financial/usingdecimal.Decimal, with a test asserting the exact value to the cent. - Keep extraction honest.
StructuredExtractorcopies verbatim values. If a field cannot be found, returnNone— do not infer it. - Add an audit rule, not a silent correction. New reconciliations belong in
CrossReconciliationAuditorso the discrepancy is reported. - Exercise a node in isolation — every node is a single
(UnderwritingState) -> dictfunction, so it should be callable with a hand-built state before it is wired into the graph. - Run
pytest -vandruff check .before opening a PR.
Built with pdfplumber, LangGraph, Pydantic and decimal — because the numbers in a term sheet should be reproducible.