Bhanu Prakash Putta · AI Engineer

Work / 03 / contract-review-ai

Contract clauses you can verify, not just read.

Upload a contract and get its 12 highest-stakes clauses: renewal, liability cap, exclusivity, assignment and more. Each one comes with the exact text and its location, checked against the document by code. A team-owned playbook flags risks, and Q&A answers with verified citations. About $0.0015 per contract.

0.821→0.893micro-F1 on 102 held-out CUAD contracts
−44%input tokens per contract, no measurable quality loss
$0.0015mean cost per contract, one model call
2 / 599quotes the model invented, both caught and dropped

The idea

Small legal and ops teams spend hours on the first read of vendor and partner contracts, hunting for the renewal terms, the liability cap, the exclusivity and assignment clauses. A generic AI summary doesn't help much, because it paraphrases, and sometimes invents, the clause you need to rely on.

The rule here is simple: the model quotes, code checks.

  • Extract. One Claude Haiku 5.5 call with JSON-schema output returns, for each of 12 clause types, whether it is present and the quotes that show it.
  • Verify. Code matches every quote against the document and maps it to exact character offsets. A quote that can't be found is dropped, and a clause counts as found only if one of its quotes verifies.
  • Flag. Risk comes from the team's own YAML playbook, evaluated in code over verified clauses only. Notice periods and jurisdictions are parsed from the quote text, not taken from the model.
  • Ask. Questions get answers with citations, and every citation is verified the same way. An answer with no verified citation is marked unsupported.
Architecture: the web UI or any HTTP client calls a FastAPI service. Inside, a parser splits the contract into sections stored in SQLite; a LangGraph review graph and a cited Q&A graph call Claude only through llm.py, which checks a spend ledger, uses a response cache, retries and logs cost. llm.py calls the Anthropic Messages API and writes the cache and spend ledger to a data volume. Web UI vanilla JS · highlights Any HTTP client curl · your app upload · review · ask FastAPI service · api.py Parser sections + offsets SQLite store documents · reviews Review graph LangGraph · 6 nodes Cited Q&A graph retrieve→answer→verify llm.py · the only module that calls Claude spend-ledger pre-check · response cache · retries + timeout · usage → cost → JSON log data/ volume contracts.db · llm_cache.sqlite · spend.jsonl Anthropic Messages API Claude Haiku 5.5 · JSON schema
Fig. 1scroll → System architecture. One module talks to Claude, and it refuses any call whose worst-case cost would break the hard spend cap.
contract-review-ai reviewing a 101,639-character outsourcing agreement from the CUAD test set: the document list on the left, the contract rendered as a page in the centre with the verified liability-cap quotes highlighted, and the clause findings panel on the right with the liability-cap clause selected
Fig. 2A 101K-character outsourcing agreement from the CUAD test set. The prefilter sent 48 of 176 sections; the review cost $0.0023 and took 7.5 s cold. Clicking a clause in the findings panel scrolls the page to its verified span.

The review graph

Review is a six-node LangGraph pipeline. Only one node uses a model. Long contracts take a detour through a free lexical prefilter, and short ones (under 3K tokens) go to the model whole.

Review graph: parse, then prefilter for long documents, or skip straight to extract for short documents. Extract is the only model call. Then verify, where unverified quotes are dropped, then playbook rules and the report. short doc or mode=full: skip the prefilter 01 · code parse pypdf · docx sections S1…Sn 02 · code prefilter BM25 + keywords top 3 per type 03 · model extract Haiku 5.5 JSON schema 04 · code verify match + offsets exact · repaired 05 · code playbook YAML rules verified only 06 · code report clauses · flags tokens · cost unverified quote dropped, never flagged
Fig. 3scroll → The LangGraph review pipeline. In mode=auto, documents of 3,000 estimated tokens or more go through the prefilter; 81 of the 102 test contracts did.

Results on held-out contracts

The dataset is CUAD v1 (The Atticus Project, CC BY 4.0): 510 commercial contracts with lawyer-annotated clause spans. I used the authors' official split. The 408 train contracts were the dev set for prefilter tuning, prompt iteration and verifier fixes. Everything below is on the 102 test contracts, scored as 102 × 12 = 1,224 present/absent decisions, after verification.

Configurationmicro F1Nmicro Pmicro Rmacro F1span hitJaccard$/contractp50
Naive: whole contract, category names only · Haiku0.8211020.7510.9060.7800.9590.655$0.002285.1 s
Whole contract + extract.v2 prompt · Haiku0.8891020.8450.9370.8580.9700.692$0.002723.8 s
Final: prefilter + extract.v2 + verify · Haiku0.8931020.8480.9440.8620.9680.702$0.001513.6 s
Final, exact-only verifier (ablation) · Haiku0.8881020.8500.9300.8570.9670.703$0.001513.6 s
Final · Haiku 5.5, 40-contract subset0.901400.8470.9640.8750.9500.715$0.001493.6 s
Final · Sonnet 5.5, same 40 contracts0.902400.8630.9460.8800.9750.692$0.02925.6 s

Span hit: among true positives, the share where a quote overlaps a gold span. Jaccard: character overlap with the gold spans. The Sonnet subset is seeded.

Paired bootstrap over contracts, 5,000 resamples, difference in micro-F1:

ComparisonΔ95% CI
Final vs naive (N = 102)+0.072[+0.049, +0.096]
Final vs whole contract, same prompt (N = 102)+0.005[−0.013, +0.022]
Sonnet 5.5 vs Haiku 5.5 (N = 40)+0.001[−0.024, +0.025]

Read plainly: the prompt work is a real gain. The prefilter's cost saving comes with no measurable change in quality, in either direction. And the stronger model buys nothing measurable here.

Four problems worth solving

01  The real enemy was page footers, not hallucinations

I expected verification to catch invented quotes. It mostly caught real ones. CUAD's text, like most text pulled from PDFs, has page headers, footers and ----- rules in the middle of sentences. The model quotes the sentence the way a person reads it, so an exact match rejects a genuine clause.

  • Normalization treats separator rules as whitespace and keeps an offset map back to the original text.
  • A “stitched” matcher binary-searches the longest matching prefix, then continues within 400 characters, up to 4 in-order fragments of at least 20 characters each.
  • A repaired quote is replaced with the document's own text over the matched span, so the UI only ever shows verbatim text.
Configuration (test)quotesdroppedrepaired
Final599211
Naive645423
Exact-only verifier (ablation)59913–

The exact-only verifier lost 6 true-positive clauses (and removed 3 false positives): F1 0.888 against 0.893. Genuine fabrications were 2 of 599, or 0.3%, and both look plausible, which is why they matter. In one, the model finished a sentence the extracted text cuts off: “which consent shall” became “…shall not be unreasonably withheld.” In the other, it joined clauses 7.1 and 7.1.3 into one quote. Both were dropped.

02  A free prefilter instead of embeddings

Whole-contract requests averaged 17.9K input tokens, and the longest passed 100K, where Haiku's per-token price is 5× higher. Rather than add embeddings or map-reduce, the prefilter scores sections with BM25 against each clause type's description and keywords, keeps the top 3 per type plus up to 6 anchor-keyword hits, and sends the union with [...] marking the gaps.

I picked k on the train split with a sweep that makes no model calls: at k = 3, 98.6% of present clauses stay reachable with 45% of the tokens. On test, mean input tokens fell from 17,881 to 10,060 (−44%), mean cost from $0.00272 to $0.00151, and the most expensive contract from $0.054 to $0.004.

03  Quality came from prompt boundaries

A prompt that only names the categories over-triggers on look-alikes. Version 1 added CUAD's own category definitions. Version 2 added boundary notes written from dev-split false positives:

# prompts/extract.v2.yaml · a few boundary notes
uncapped liability   an indemnity alone is not uncapped liability
IP assignment        a party keeping its own IP is not assignment
auto-renewal         renewal by mutual agreement is not a renewal term
termination for conv “may NOT be terminated for convenience” is not found

On 60 dev contracts, micro-F1 went from 0.865 (v1) to 0.882 (v2). On test, uncapped-liability F1 rose from 0.351 (naive) to 0.743, and IP assignment from 0.582 to 0.750.

04  The model never decides risk, and a bigger model wasn't worth it

Risk is policy, so it belongs to the team, not the model. The playbook is YAML with four rule types (required, forbidden, min_notice_days, allowed_values) and runs over verified clauses only, so a dropped quote can never raise a flag. Code parses “ninety (90) days” to 90 and reads the jurisdiction from the quote.

Before settling on a model, I ran Sonnet 5.5 on the same 40 test contracts. It scored 0.902 against Haiku's 0.901 (Δ +0.001, CI [−0.024, +0.025]) at 19.6× the cost and 2 s more at p50. Haiku stays the default.

Risks tab for the outsourcing agreement: a severity summary (1 high, 2 medium, 2 low, 2 info) above the flags for uncapped liability, termination for convenience and IP assignment, each with a labelled severity chip, its playbook rule id and the verified quote; the change-of-control clause is highlighted in the contract text
Fig. 4Playbook flags on the same contract. Each flag names its rule and shows the verified quote it came from.

Per clause type

Final pipeline, test N = 102. Presence detection is strong on the clearly worded clauses and weaker where CUAD's labels are themselves fuzzy.

Clause typeF1
Governing law0.994
Anti-assignment0.986
Audit rights0.925
Auto-renewal0.923
Notice to stop renewal0.903
Exclusivity0.877
Liability cap0.864
Termination for convenience0.818
Change of control0.815
Non-compete0.750
IP assignment0.750
Uncapped liability0.743

Cited Q&A

Each clause type becomes a plain question, such as “Can either party terminate without cause, just by giving notice?”. Each of 30 test contracts gets up to 2 questions whose clause is present and 2 whose clause is absent: 113 questions in all. Retrieval is BM25 over sections; the answer is structured, and every citation is verified. Each answer costs about $0.0003, with a p50 of 1.2 s.

Retrieval (test)answers iff clause existspresent → answeredpresent → citation on goldabsent → abstainedunverified citations
BM25 on the question0.8320.7820.7640.8790/94
BM25 + category query expansion0.8410.8360.8000.8450/102
How this table came to have two rowsThe first test run used plain BM25. Diagnosing its misses showed that 10 of the 12 unanswered present-clause questions were retrieval failures: the gold clause wasn't in the top 6 sections. I then built query expansion and compared both variants on 30 dev contracts (present-clause hit rate 0.765 → 0.863) before running test a second time. Because the change was motivated by a test run, both test runs are reported. The trade-off is real: recall went up, and abstention on absent clauses went down from 0.879 to 0.845.

Production basics

  • Hard spend cap. A ledger checks each call's worst-case cost before it is sent, and the API returns 429 once the budget is gone. The whole project, including development, every eval run and the demo, spent $2.27 of a $5.00 cap (the Sonnet comparison was about $1.17 of that).
  • Response cache keyed on the exact request body, so identical calls cost $0. Per-call tokens, cost and latency go to JSON logs.
  • Latency: p50 3.6 s and p95 6.9 s per contract, one call.
  • 35 offline pytest tests with a faked LLM transport: normalization and offsets, page-break and truncated-sentence verification, PDF/DOCX/TXT parsing, prefilter, playbook rules, ledger and pricing tiers, pipeline routing, Q&A citation checks, API errors, bootstrap.
  • CI on GitHub Actions: lint, format and tests on Python 3.12 and 3.13, plus a Docker build. docker compose up --build verified end to end.
  • Reproducible evals: make eval-all regenerates every row above into committed JSON and RESULTS.md.

Limitations

  • Precision is weak in three types: uncapped liability (P 0.59), IP assignment (P 0.64) and non-compete (P 0.64). Some of those errors are CUAD label inconsistencies, but not all.
  • Span boundaries are looser than presence: mean Jaccard with gold spans is 0.70. The model often quotes one sentence where annotators marked several.
  • Q&A still answers 15% of absent-clause questions.
  • The Sonnet comparison (N = 40) and the Q&A eval (113 questions) are small, and their confidence intervals are wide.
  • No OCR, so scanned PDFs are rejected. Tuned on English, SEC-filed commercial agreements.
  • Single tenant, no auth, and SQLite allows one writer. This is not legal advice.

Stack

  • Python 3.12
  • uv
  • FastAPI
  • LangGraph
  • Anthropic SDK
  • Claude Haiku 5.5
  • pydantic v2
  • pypdf
  • python-docx
  • rapidfuzz
  • BM25
  • SQLite
  • vanilla JS
  • Docker
  • GitHub Actions
  • pytest
  • ruff

Fits more than contracts

The same pattern (structured extraction, every quote verified against the source by code, rules owned by the team, a measured eval on labeled data, and a hard cost cap) fits policies, claims, invoices, compliance filings and any document where a wrong quote is worse than none.