AI legal agents contract review has moved from demo to production for teams that need to triage hundreds of vendor agreements weekly. The core workflow is mechanical: pull text, isolate clauses, classify each against a risk taxonomy with an LLM, and surface the dangerous ones to a human. This post walks through building that pipeline with code you can run today.
Step 1: Extract contract text from source files
Most contracts arrive as PDFs or Word docs. Start by normalizing everything to plain text. For PDFs, pdfminer.six handles multi-column layouts better than PyPDF2 for legal formatting.
pip install pdfminer.six python-docx
from pdfminer.high_level import extract_text
def load_contract(path: str) -> str:
if path.endswith(".pdf"):
return extract_text(path)
elif path.endswith(".docx"):
from docx import Document
doc = Document(path)
return "\n".join(p.text for p in doc.paragraphs)
else:
with open(path, "r", encoding="utf-8") as f:
return f.read()
text = load_contract("msa_acme.pdf")
print(len(text.split())) # word count sanity check
Verify success: the printed word count should match the document’s page count times roughly 250–500 words per page. If you get a fraction of expected words, the PDF is likely scanned—add an OCR step before this.
Step 2: Segment text into clauses
LLMs lose coherence on 50-page blobs. Split the document into candidate clauses using heading patterns and numbered lists common in legal text. A regex over section markers works surprisingly well.
import re
def split_clauses(text: str) -> list[str]:
# Match "1.", "1.1", "Article IV", or "Section 3.2" style headers
pattern = r"(?=\n\s*(?:Article|Section|\d+(?:\.\d+)*)\.?\s+[A-Z])"
parts = re.split(pattern, text)
return [p.strip() for p in parts if len(p.strip()) > 50]
clauses = split_clauses(text)
print(f"Found {len(clauses)} candidate clauses")
Do not over-engineer segmentation. If a clause is too long, the classifier in Step 4 will still work, just with more context. Aim for chunks between 100 and 1,500 words.
Step 3: Define a risk taxonomy
Before prompting, decide what “risk” means for your use case. A generic enterprise taxonomy includes:
- Indemnification (broad or unilateral)
- Limitation of liability (caps too low or absent)
- Termination (auto-renewal, no exit)
- IP assignment (work-for-hire surprises)
- Non-compete / exclusivity (overbroad)
- Data privacy (weak breach notification)
Encode this as a JSON schema so the model returns structured data instead of prose.
{
"type": "object",
"properties": {
"clause_id": {"type": "integer"},
"category": {
"type": "string",
"enum": ["indemnification", "liability", "termination", "ip", "restrictive", "privacy", "none"]
},
"risk_level": {"type": "string", "enum": ["low", "medium", "high"]},
"summary": {"type": "string"},
"quote": {"type": "string"}
},
"required": ["clause_id", "category", "risk_level", "summary"]
}
Step 4: Classify clauses with an LLM
Use an OpenAI-compatible chat completion call with structured output. Point the client at any endpoint that supports JSON schema response format. For example, n4n.ai exposes one OpenAI-compatible endpoint covering 240+ models and automatically falls back when a provider is rate-limited, which keeps a batch review job from dying mid-run.
from openai import OpenAI
import json
client = OpenAI(
base_url="https://api.n4n.ai/v1", # swap for your own gateway
api_key="YOUR_KEY",
)
SYSTEM = "You are a contract risk reviewer. Classify each clause against the schema."
def review_clause(idx: int, clause_text: str) -> dict:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"Clause {idx}:\n{clause_text}"}
],
response_format={
"type": "json_schema",
"schema": json.load(open("taxonomy.json"))
},
temperature=0.0,
)
return json.loads(resp.choices[0].message.content)
results = [review_clause(i, c) for i, c in enumerate(clauses[:10])]
Set temperature=0.0. Contract review is not a creative task; you want deterministic labels.
Step 5: Aggregate and score portfolio risk
Single clauses are useful, but counsel cares about the whole agreement. Roll up high-risk counts and surface the worst offenders.
from collections import Counter
risk_counts = Counter(r["risk_level"] for r in results)
high_risks = [r for r in results if r["risk_level"] == "high"]
print(f"Risk distribution: {dict(risk_counts)}")
for r in high_risks:
print(f" Clause {r['clause_id']} [{r['category']}]: {r['summary']}")
If you process many contracts, persist these rows to a database keyed by contract_hash so you can track vendor risk over time.
Step 6: Generate a human-reviewable report
The agent should never be the final word. Emit a markdown report that a lawyer opens in two minutes.
def build_report(contract_name: str, results: list[dict]) -> str:
lines = [f"# Review: {contract_name}", ""]
for r in sorted(results, key=lambda x: x["risk_level"], reverse=True):
if r["category"] == "none":
continue
lines.append(f"## Clause {r['clause_id']} — {r['category']} ({r['risk_level']})")
lines.append(f"> {r['quote'][:200]}")
lines.append(r["summary"])
lines.append("")
return "\n".join(lines)
report = build_report("msa_acme.pdf", results)
open("review_acme.md", "w").write(report)
Counsel scans the markdown, clicks into the source quote, and decides. The AI legal agents contract review system has done the tedious first pass.
Step 7: Verify the pipeline end to end
Test on a known contract with at least one egregious clause. A good fixture: a vendor MSA with uncapped indemnification and a 30-day auto-renewal.
Verification checklist:
- Extraction yields >90% of visible text (spot check against copy-paste).
- Segmentation produces clauses that align with human-read section numbers.
- The LLM tags the uncapped indemnification as
highinindemnification. - The report markdown contains the exact quoted sentence you inserted as bait.
If any step fails, isolate it. Usually the prompt is too vague (“flag risky things”) or the schema enum misses a category your domain needs. Tighten both.
Operating notes for production
Run classification concurrently with asyncio or a worker queue; 200 clauses per contract is normal. Cache embeddings or raw completions if the same contract revision hits twice—most gateways forward provider cache-control hints, so set cache_control on stable system prompts.
AI legal agents contract review will not replace lawyers, but they compress the time from “inbox full of PDFs” to “here are the three clauses that matter” from hours to minutes. Build the pipeline above, measure false negatives on a labeled set, and iterate on the taxonomy before trusting it unsupervised.