Map · you are at Projects

Record 010/0172026

Building a Contract Review Agent with LLMs, pgvector, and a Dash of Production Regret

How I wired Claude, FastAPI, and Postgres into a legal review pipeline — clause extraction, risk scoring, RAG-backed redlines, and the migration bug that silently broke production.

SpecSheet 010

Role
Sole developer
Year
2026
Language
Python
Platform
Web
Stack
  • LangChain
  • LangGraph
  • OpenRouter (Claude / GPT-4o)
  • FastAPI
  • PostgreSQL
  • pgvector
  • Pydantic v2
  • Alembic
  • Next.js
Contract review agent cover illustration
Contract review agent cover illustration

The problem

Legal review for MSAs and vendor paper is expensive, repetitive and deeply adversarial.

This agent compresses the first pass into a structured pipeline: upload, clause extraction, risk scoring against a playbook and RAG-backed redline proposals. People only review the items it flags.

The system

8 components · 7 connections · 2 request flows

Claude, FastAPI and Postgres wired into a legal review pipeline: a contract is uploaded, typed clauses are extracted, risk is scored against a living playbook, RAG-backed redlines are proposed, and only the flagged items go to human review.

System mapDetailed view

100%

Play a request

  • Client
  • Service
  • Model / AI
  • Data store
  • Data / control flow
  • The request

Select a component to see its role, the technology behind it and what it talks to — or play a request and watch it travel the system.

Start at

Arrow keys move between components, Home and End jump to the first and last. Enter or Space selects one; Escape clears. Plus and minus zoom, 0 fits the map.

Component reference8 components · 2 flows · as text

Components · in request order

Client · layer 01Contract upload
Starts the first pass: a contract is uploaded into the structured review workflow.
ToClause extraction (contract)
Service · layer 01Guardrails
Deterministic checks that fire before any scoring; they run faster and catch anything catastrophic without spending tokens.
Each bright-line flag from this stage carries a pre-assigned severity (0–30 points).
ToLLM holistic score (rule violations); Risk scoring (rule weights)
Service · layer 02Clause extraction
Extracts typed clauses from the uploaded contract.
FromContract upload (contract)
ToLLM holistic score (clause context)
Model / AI · layer 03LLM holistic score
Sees the full clause context and the rule violations and returns a score.
TechOpenRouter (Claude / GPT-4o)
FromClause extraction (clause context); Guardrails (rule violations)
ToRisk scoring (holistic score)
Service · layer 04Risk scoring
Scores risk against a living playbook, combining two signals into one number: the guardrail rule weights and the LLM holistic score.
FromGuardrails (rule weights); LLM holistic score (holistic score)
ToRedline proposals
Data store · layer 04Postgres
The Postgres database wired into the legal review pipeline; its schema is migrated with Alembic.
The alembic_version.version_num column is VARCHAR(32); a 34-character revision string was the migration bug that silently broke production.
TechPostgres, pgvector, Alembic
Service · layer 05Redline proposals
Proposes RAG-backed redlines for the contract.
FromRisk scoring
ToHuman review (flagged items only)
Client · layer 06Human review
Receives only the flagged items from the first pass.
FromRedline proposals (flagged items only)

Request flows

  1. First-pass contract review

    Upload, extract typed clauses, score risk against the playbook, propose redlines, and send only the flagged items to a human. The guardrails fire before any scoring.

    1. Step 1: Contract upload
    2. Step 2: Clause extraction
    3. Step 3: LLM holistic score
    4. Step 4: Risk scoring
    5. Step 5: Redline proposals
    6. Step 6: Human review
  2. Two signals, one number

    Guardrail flags carry rule weights; the model sees the clause context and the rule violations and returns a score; the two signals become one risk number.

    1. Step 1: Guardrails
    2. Step 2: LLM holistic score
    3. Step 3: Risk scoring

Decisions & trade-offs

3 decisions

  1. Deterministic guardrails before any scoring

    The guardrails fire before any scoring because deterministic checks run faster and catch anything catastrophic without spending tokens.

  2. Automate the first pass, send people only the flagged items

    Legal review for MSAs and vendor paper is expensive, repetitive and deeply adversarial, so the first pass is compressed into a structured workflow and only the flagged items go to human review.

  3. Alembic revision IDs under 32 characters

    The alembic_version.version_num column is VARCHAR(32) and the revision string was 34 characters. Alembic doesn't validate this for you.

The write-up

Somewhere around the third time a lawyer friend showed me a vendor contract with a liability cap buried in a footnote, I decided this was a tooling problem. Legal review for MSAs and vendor paper is expensive, repetitive, and deeply adversarial. Vendors use the same tricks. The same indemnity clauses, same microscopic caps, same auto-renewal traps. Every senior lawyer spends part of their week catching the same things they caught last week in slightly different fonts.

Legal Agent is my attempt at compressing that first-pass into a structured workflow: upload a contract, extract typed clauses, score risk against a living playbook, propose redlines, and send only the flagged items to human review. Here's how I built it, what actually runs, and what nearly took the backend down on day one.

The Pipeline (Six Stages, One Goal)

hld
hld

Each stage is a distinct concern. The guardrails fire before any scoring — deterministic checks run faster and catch anything catastrophic without spending tokens on a nuanced risk narrative that won't matter if the indemnity clause is outright unlimited.

Extraction: Getting Typed Clauses from Unstructured Text

The LLM extraction stage uses with_structured_output to get Pydantic models back directly from the model. No regex parsing, no brittle JSON prompts. You hand the model a schema and it returns a typed object or raises.

Python4 lines

chain = llm.with_structured_output(ContractClauses)
clauses: ContractClauses = await asyncio.get_event_loop().run_in_executor(
    _cpu_pool, lambda: chain.invoke({"text": chunk})
)

The run_in_executor detail matters. SQLAlchemy async sessions and LangChain's sync .invoke() don't coexist happily in the same coroutine. The executor keeps the blocking call off the event loop without spawning a thread per request.

Chunking strategy for large contracts: I split on clause headings when available (regex against common MSA section patterns), fall back to 1000-token sliding windows with 200-token overlap if the document doesn't have obvious structure. This is the part I'd do differently (more on that later).

Risk Scoring: Two Signals, One Number

The risk.py chain combines two signals before generating the narrative:

  1. Rule weights — each bright-line flag from the guardrail stage carries a pre-assigned severity (0–30 points).

  2. LLM holistic score — the model sees the full clause context and the rule violations and returns a score with a chain-of-thought reasoning block I keep for audit but don't surface to users.

These are merged:

Python3 lines

def merge_levels(rule_score: int, llm_score: int) -> int:
    # rule engine is authoritative for hard violations
    return max(rule_score, int(llm_score * 0.7 + rule_score * 0.3))

Simple, but effective. Hard violations can't be "reasoned down" by an LLM that thinks the overall contract is fair.

Playbook RAG: Why pgvector, Not Pinecone

The playbook lives in Postgres as PlaybookEntry rows — preferred language for each clause type, policy rationale, and a Vector(1536) column for the embedding:

Python6 lines

class PlaybookEntry(Base):
    __tablename__ = "playbook_entries"
    clause_type: Mapped[str]
    preferred_language: Mapped[str]
    policy_rationale: Mapped[Optional[str]]
    embedding: Mapped[list[float]] = mapped_column(Vector(1536))

The retrieval path is a hybrid RRF (Reciprocal Rank Fusion): cosine similarity on the embedding column plus Postgres full-text search on preferred_language, results merged by rank rather than score. This degrades gracefully — if embeddings are stale (e.g., you changed playbook wording and haven't re-run make vector.index), FTS still returns something sane.

Why pgvector over Pinecone or Weaviate? The playbook is O(hundreds) of entries, not millions. Another managed service meant another auth secret, another billing account, another failure mode. The operational simplicity of "it's just a Postgres column" wins at this scale. If we ever hit tens of millions of vectors, that calculus changes.

The Migration That Almost Wasn't

Here's the war story. I added the submitted_for_review_at column to documents and a reviewer workflow to redlines in Alembic migration 0004, named 0004_redline_review_document_submit. Ran alembic upgrade head. The DDL executed — ALTER TABLE documents ADD COLUMN submitted_for_review_at TIMESTAMPTZ — successfully. Then Alembic tried to update alembic_version:

Code2 lines

asyncpg.exceptions.StringDataRightTruncationError:
  value too long for type character varying(32)

The alembic_version.version_num column is VARCHAR(32). The revision string 0004_redline_review_document_submit is 34 characters. So: columns exist in the database, version table never updated, migration "never ran" according to Alembic. Next upgrade attempt? Alembic tries to run 0004 again, hits column already exists, crashes differently.

Fix: shorten the revision ID to 0004_redline_submit (19 chars), then alembic stamp 0004_redline_submit to sync the version table with reality.

Lesson: keep Alembic revision IDs under 32 characters. Alembic doesn't validate this for you.

The Agent Layer: LangGraph + ReAct

For the orchestration layer I used LangGraph's ReAct pattern. The agent has tools: extract_clauses, run_guardrails, score_risk, retrieve_playbook, propose_redlines. The two-phase pattern was deliberate: the agent first reasons about which clauses need deeper scrutiny (given the rule violations), then invokes retrieval only for those — rather than retrieving against every clause regardless.

SSE streaming (/api/v1/review/stream) sends incremental events as each stage completes:

Code4 lines

data: {"stage": "extraction", "progress": 40, "clauses_found": 12}
data: {"stage": "risk_scoring", "progress": 70, "score": 67}
data: {"stage": "redlines", "progress": 90, "count": 5}
data: {"stage": "complete", "progress": 100}

The frontend renders a live progress rail instead of a spinner. For a process that can take 20–40 seconds on a long contract, this matters more than it sounds.

Honest Retrospective

What I'd change:

The clause chunking loop is sequential — one LLM call per clause type. For a 40-clause contract that's 40 serial round-trips. Batching into a single structured extraction call with the full contract would cut latency by ~80%. I kept sequential extraction for simplicity during development and never went back.

I also don't have an eval harness. Prompts get adjusted by feel, not by regression. That's fine for a solo project; it's not fine the moment a second engineer touches the prompts or a model upgrade silently changes extraction behavior.

What's next:

  • Multi-agent supervision: a second LangGraph agent that challenges the first's risk scores — adversarial review

  • Slack approval routing: send flagged redlines to a reviewer channel, capture approval/rejection via slash command

  • Prompt versioning tied to migration IDs so you know which prompt produced which clause extraction in the audit trail

The Takeaway

LLMs don't replace legal judgment. What they do is handle the volume problem — getting a structured, annotated, risk-scored first pass in front of a human reviewer who can then spend their time on actual judgment calls instead of hunting for auto-renewal clauses.

The tooling complexity is real: async SQLAlchemy, RAG retrieval tuning, streaming UX, migration hygiene. None of it is magic. It's just software engineering applied to a domain that hasn't had much of it yet.

The lesson

As marked in the write-up

  1. Keep Alembic revision IDs under 32 characters. Alembic doesn't validate this for you.

esc
↑↓ move ↵ open ⌘↵ new tab esc close37 indexed

rk@request · simulated terminal