Hands-on · ~45 min

Extraction: OCR + LLM

Get text from PDFs (native + OCR fallback), then call Claude with a structured-output prompt to extract parties, effective date, term, renewal terms. Pydantic-validated output.

What we're building

  • A text-extraction function that handles native and image PDFs.
  • A Pydantic schema defining what we extract.
  • A prompt that asks Claude for structured output.
  • An end-to-end extract_msa(document_id) that ties them together.

Text extraction

Native first; OCR fallback if pdfplumber returns little text.

src/ci/extraction/ocr.py
from __future__ import annotations
import io
import pdfplumber
import pytesseract
from pdf2image import convert_from_bytes


def text_from_native(file_bytes: bytes) -> str:
    parts = []
    with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
        for page in pdf.pages:
            t = page.extract_text() or ""
            parts.append(t)
    return "\n\n".join(parts)


def text_from_ocr(file_bytes: bytes) -> str:
    images = convert_from_bytes(file_bytes, dpi=200)
    parts = []
    for img in images:
        parts.append(pytesseract.image_to_string(img))
    return "\n\n".join(parts)


def extract_text(file_bytes: bytes) -> tuple[str, str]:
    """Return (text, source) where source is 'native' or 'ocr'."""
    native = text_from_native(file_bytes)
    if len(native.strip()) > 200:
        return native, "native"
    # Fall back to OCR for image-heavy docs
    return text_from_ocr(file_bytes), "ocr"

The 200-character threshold avoids OCR'ing native PDFs that just have very short content.

Pydantic schema

What we're extracting. The schema becomes the contract with Claude — we pass it via tool-calling.

src/ci/extraction/schemas.py
from __future__ import annotations
from pydantic import BaseModel, Field


class Citation(BaseModel):
    quote: str = Field(description="The exact text from the document supporting this value")
    page: int | None = Field(default=None, description="Page number (1-indexed) if known")


class FieldWithCitation(BaseModel):
    value: str | None
    confidence: float = Field(ge=0.0, le=1.0)
    citation: Citation | None = None


class RenewalTerms(BaseModel):
    auto_renew: bool
    term_length_months: int
    notice_period_days: int


class RenewalTermsWithCitation(BaseModel):
    value: RenewalTerms | None
    confidence: float = Field(ge=0.0, le=1.0)
    citation: Citation | None = None


class MSAExtraction(BaseModel):
    """Fields extracted from a Master Service Agreement."""
    parties: list[str] = Field(description="The contracting entities (e.g., ['Acme Corp', 'Customer Inc'])")
    parties_confidence: float = Field(ge=0.0, le=1.0)
    effective_date: FieldWithCitation
    term_length_months: FieldWithCitation
    renewal_terms: RenewalTermsWithCitation
    governing_law: FieldWithCitation

The extraction prompt

System prompt + user message with the document. We ask for citations explicitly — they enable provenance later.

src/ci/extraction/llm.py — prompt
SYSTEM_PROMPT = """You are an expert at extracting structured information from Master Service Agreements (MSAs).

You will be given the text of an MSA. Extract the fields requested.

Rules:
1. If a field cannot be found in the document, return null for that field's value.
2. Do not guess. Do not fabricate. If you are uncertain, return null and set confidence below 0.7.
3. For every extracted value, return a citation: the exact quote from the document supporting it, and the page if known.
4. Confidence is your honest estimate of correctness: 1.0 means certain, 0.7 is the default for HITL routing, < 0.5 means very uncertain.
5. Normalize dates to ISO format (YYYY-MM-DD). If only a month/year is present, leave value as null.
"""

USER_TEMPLATE = """Extract structured fields from the following Master Service Agreement.

<document>
{text}
</document>

Return your answer as a single JSON object matching the schema."""

LLM call

Anthropic's structured output via tool definitions. Pass the Pydantic schema; get back a validated dict.

src/ci/extraction/llm.py
from __future__ import annotations
import json
from anthropic import Anthropic
from pydantic import ValidationError

from ci.config import settings
from ci.extraction.schemas import MSAExtraction


SYSTEM_PROMPT = "..."  # as above
USER_TEMPLATE = "..."  # as above

_client = Anthropic(api_key=settings.anthropic_api_key)


def extract_msa(text: str) -> MSAExtraction:
    """Extract MSA fields from document text."""
    tool = {
        "name": "extract_msa_fields",
        "description": "Extract structured MSA fields",
        "input_schema": MSAExtraction.model_json_schema(),
    }

    response = _client.messages.create(
        model=settings.extraction_model,
        max_tokens=settings.extraction_max_tokens,
        temperature=settings.extraction_temperature,
        system=SYSTEM_PROMPT,
        tools=[tool],
        tool_choice={"type": "tool", "name": "extract_msa_fields"},
        messages=[
            {"role": "user", "content": USER_TEMPLATE.format(text=text[:30000])}
            # 30k char truncation; LLMs handle longer but cost / latency adds up.
        ],
    )

    # Anthropic returns the tool_use block with structured input
    tool_block = next(b for b in response.content if b.type == "tool_use")
    try:
        return MSAExtraction.model_validate(tool_block.input)
    except ValidationError as e:
        # In production, raise a parse-or-retry; here we surface the error
        raise RuntimeError(f"Extraction schema validation failed: {e}") from e

Notes:

  • tool_choice forces Claude to call the tool — no free-form text.
  • Truncation at 30k chars: for very long documents (50-page MSAs), you'd want chunking or page-targeted extraction. Out of scope for this build.
  • temperature=0.0 for reproducibility.

Putting it together

Glue function: takes a document_id, fetches the file, extracts text, calls the LLM, returns the result.

src/ci/extraction/__init__.py
from __future__ import annotations
from pathlib import Path

from ci.db import db_session
from ci.models import Document
from ci.extraction.ocr import extract_text
from ci.extraction.llm import extract_msa
from ci.extraction.schemas import MSAExtraction


def extract_document(document_id: str) -> MSAExtraction:
    """End-to-end: fetch document, OCR, LLM extract."""
    with db_session() as s:
        doc = s.get(Document, document_id)
        if not doc:
            raise ValueError(f"Document {document_id} not found")
        path = Path(doc.source_uri)
        if not path.exists():
            raise FileNotFoundError(f"Source file missing: {path}")
        doc_class = doc.doc_class

    file_bytes = path.read_bytes()
    text, source = extract_text(file_bytes)

    if doc_class == "master_service_agreement":
        return extract_msa(text)
    # Add per-class extractors as you grow the build.
    raise NotImplementedError(f"No extractor for doc_class={doc_class}")

Verify

Add a CLI subcommand and run against the ingested documents from chapter 02.

add to src/ci/cli.py
from sqlalchemy import select

from ci.db import db_session
from ci.extraction import extract_document
from ci.models import Document


@app.command()
def extract(document_id: str | None = None) -> None:
    """Run extraction on a document (or all MSAs if none specified)."""
    with db_session() as s:
        if document_id:
            doc_ids = [document_id]
        else:
            doc_ids = list(
                s.scalars(select(Document.document_id)
                          .where(Document.doc_class == "master_service_agreement"))
            )

    for doc_id in doc_ids:
        console.print(f"[blue]Extracting {doc_id}...[/blue]")
        try:
            result = extract_document(doc_id)
            console.print(result.model_dump_json(indent=2))
        except Exception as e:
            console.print(f"[red]Failed: {e}[/red]")

Run:

ci extract
# Should print extracted fields per document.

Check the output by eye against the source PDFs. The values should match. Confidence scores should be high for fields the model is sure about, lower for ambiguous ones.

Next chapter: persist these extractions to Postgres alongside the documents.

✓ Check yourself

Answer from memory before opening each one.

  1. A 30-page scanned MSA carries a searchable stamp, "CONFIDENTIAL DRAFT", in the text layer of every page. What does extract_text return, and what will ci extract print?

    Show answer

    The stamps add up to more than 200 characters, so the function returns native text and never runs OCR. The model sees 30 stamps and no clauses, so it should return null values with low confidence. Count characters per page, or fall back for any page with little text. See Text extraction.

  2. The call forces the tool with tool_choice. What does forcing buy you over asking for JSON in the message, and what does it still not guarantee?

    Show answer

    It guarantees the reply contains a tool call whose input is meant to follow your schema, so you parse a structured block instead of free text. It does not guarantee the input passes validation without strict tool use, and valid input can still hold a wrong value. That is why the code validates with Pydantic. See LLM call.

  3. The model returns effective_date 2024-03-15 with the citation "This Agreement is effective as of March 15, 2024". Which cheap check tells you the quote is real, and what does a pass leave unproven?

    Show answer

    Normalise whitespace and case, then test whether the quote occurs in the extracted text. A quote that is not there marks a fabricated value. A pass proves only that the sentence exists. It does not prove that the sentence supports the value or that it is the effective date and not a signature date. See Pydantic schema.

  4. ci extract prints a null effective_date with confidence below 0.7 for a contract whose date reads "March 2024". Bug or expected, and where is the behaviour set?

    Show answer

    Expected. Rule 5 of the system prompt says to leave the value null when only a month and year appear, and rule 2 says to lower confidence when unsure. Check it against the PDF: if no day is printed, the output is correct. A review queue should catch these. See The extraction prompt.

  5. A 60-page MSA runs to about 150,000 characters. Governing law and notice terms sit in the last pages. What does the pipeline do, how would you notice, and what would you change?

    Show answer

    It cuts the text at 30,000 characters, so the model never sees the tail. Those fields come back null or low-confidence, and the eval shows lower accuracy on long documents. The loss is silent unless you measure it. Chunk the document or locate the relevant pages first, and log when truncation happens. See LLM call.

Takeaway

  • Native text comes first and OCR is the fallback, but a whole-document character count can be fooled by stamps and headers, so judge each page.
  • The Pydantic model is the contract: it generates the tool schema and validates the reply, and a reply that fails validation is an error, not a result.
  • Forcing the tool call gives structured output but not correct values, so validation and evaluation remain your job.
  • Citations make provenance checkable, but a quote found in the text proves the quote exists and nothing more.
  • Null with low confidence is the designed answer for a missing or partial field, and the review queue is where it gets resolved.
  • Truncating at 30,000 characters fails silently on long contracts, so measure by document length before you trust per-field accuracy.

Extraction returns validated objects but nothing keeps them, so the next step stores them in a contract data model. → Persistence: The Contract Data Model

Further reading

  • Define tools and handle tool calls — Anthropic docs, accessed 2026. Tool definitions, tool_choice and the shape of tool_use blocks.
  • Strict tool use — Anthropic docs, accessed 2026. How to guarantee schema-valid tool input.
  • PDF support — Anthropic docs, accessed 2026. Sending a PDF directly to the model as an alternative to a separate OCR step.
  • JSON Schema — Pydantic docs, accessed 2026. How a model becomes the schema you pass as input_schema.
  • Improving the quality of the output — Tesseract OCR project, accessed 2026. Resolution, binarisation and other causes of poor OCR text.