Hands-on · ~30 min

Persistence: The Contract Data Model

Extend the schema with suppliers, agreements, extractions tables. Upsert extraction results idempotently per (document_id, model_version). Create agreements from MSA extractions.

What we're building

  • Suppliers, agreements, extractions tables in Postgres.
  • A function that takes an MSA extraction and writes it to the DB.
  • Supplier creation / lookup based on extracted party names.
  • Idempotency: re-extracting with the same model_version updates rather than duplicates.

SQLAlchemy models

Extend src/ci/models.py.

src/ci/models.py — additions
from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import String, Integer, DateTime, Date, Float, ForeignKey, UniqueConstraint, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship


class Supplier(Base):
    __tablename__ = "suppliers"
    __table_args__ = (UniqueConstraint("customer_id", "canonical_name"),)

    supplier_id: Mapped[str] = mapped_column(String, primary_key=True)
    customer_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
    canonical_name: Mapped[str] = mapped_column(String, nullable=False)
    parent_supplier_id: Mapped[str | None] = mapped_column(ForeignKey("suppliers.supplier_id"), nullable=True)
    country: Mapped[str | None] = mapped_column(String, nullable=True)
    tax_id: Mapped[str | None] = mapped_column(String, nullable=True)
    industry: Mapped[str | None] = mapped_column(String, nullable=True)
    first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)


class Agreement(Base):
    __tablename__ = "agreements"

    agreement_id: Mapped[str] = mapped_column(String, primary_key=True)
    customer_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
    supplier_id: Mapped[str] = mapped_column(ForeignKey("suppliers.supplier_id"), nullable=False)
    agreement_type: Mapped[str] = mapped_column(String, nullable=False)
    effective_date: Mapped[date | None] = mapped_column(Date, nullable=True)
    current_term_end: Mapped[date | None] = mapped_column(Date, nullable=True)
    parent_agreement_id: Mapped[str | None] = mapped_column(ForeignKey("agreements.agreement_id"), nullable=True)
    status: Mapped[str] = mapped_column(String, default="active", nullable=False)
    status_changed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)


class Extraction(Base):
    __tablename__ = "extractions"
    __table_args__ = (UniqueConstraint("document_id", "model_version"),)

    extraction_id: Mapped[str] = mapped_column(String, primary_key=True)
    document_id: Mapped[str] = mapped_column(ForeignKey("documents.document_id"), nullable=False, index=True)
    customer_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
    model_version: Mapped[str] = mapped_column(String, nullable=False)
    pipeline_version: Mapped[str] = mapped_column(String, nullable=False)
    fields: Mapped[dict] = mapped_column(JSON, nullable=False)
    qa: Mapped[dict | None] = mapped_column(JSON, nullable=True)
    extracted_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)


class DocumentAgreementLink(Base):
    __tablename__ = "document_agreement_links"

    document_id: Mapped[str] = mapped_column(ForeignKey("documents.document_id"), primary_key=True)
    agreement_id: Mapped[str] = mapped_column(ForeignKey("agreements.agreement_id"), primary_key=True)
    role: Mapped[str] = mapped_column(String, nullable=False)

Migration

alembic revision --autogenerate -m "suppliers, agreements, extractions"
alembic upgrade head

docker compose exec postgres psql -U ci -d contract_intel \
  -c "\dt"

Verify all five tables exist: documents, suppliers, agreements, extractions, document_agreement_links.

Persist extractions

src/ci/persistence.py
from __future__ import annotations
import uuid
from datetime import datetime

from ci.config import settings
from ci.db import db_session
from ci.models import Extraction
from ci.extraction.schemas import MSAExtraction


def persist_extraction(
    document_id: str,
    extraction: MSAExtraction,
    text_source: str,  # 'native' or 'ocr'
) -> str:
    """Upsert the extraction record. Idempotent per (document_id, model_version)."""
    extraction_id = f"ext_{document_id}_{settings.extraction_model}"

    with db_session() as s:
        existing = s.get(Extraction, extraction_id)
        fields_json = extraction.model_dump(mode="json")
        qa = {"text_source": text_source}
        if existing:
            existing.fields = fields_json
            existing.qa = qa
            existing.extracted_at = datetime.utcnow()
        else:
            s.add(Extraction(
                extraction_id=extraction_id,
                document_id=document_id,
                customer_id=settings.customer_id,
                model_version=settings.extraction_model,
                pipeline_version=settings.pipeline_version,
                fields=fields_json,
                qa=qa,
            ))
    return extraction_id

Create agreements

From an MSA extraction, create or update the corresponding agreement and link the document.

src/ci/persistence.py — additions
from datetime import date, timedelta
from sqlalchemy import select

from ci.models import Supplier, Agreement, DocumentAgreementLink


def normalize_supplier_name(name: str) -> str:
    s = " ".join(name.split())
    for suffix in (", Inc.", " Inc.", ", LLC", " LLC", ", Ltd", " Ltd", " Corp.", ", Corp."):
        if s.lower().endswith(suffix.lower()):
            s = s[:-len(suffix)].strip()
            break
    return s


def get_or_create_supplier(session, customer_id: str, raw_name: str) -> str:
    canonical = normalize_supplier_name(raw_name)
    existing = session.scalar(
        select(Supplier)
        .where(Supplier.customer_id == customer_id, Supplier.canonical_name == canonical)
    )
    if existing:
        return existing.supplier_id
    supplier_id = f"sup_{customer_id[:8]}_{uuid.uuid4().hex[:12]}"
    session.add(Supplier(
        supplier_id=supplier_id,
        customer_id=customer_id,
        canonical_name=canonical,
    ))
    return supplier_id


def create_agreement_from_extraction(
    document_id: str,
    extraction: MSAExtraction,
    customer_party_index: int = 1,  # which entry in parties is the customer
) -> str:
    """Create an agreement row from an MSA extraction. Idempotent by document_id."""
    with db_session() as s:
        # Skip if already linked
        existing_link = s.execute(
            select(DocumentAgreementLink)
            .where(DocumentAgreementLink.document_id == document_id,
                   DocumentAgreementLink.role == "master")
        ).first()
        if existing_link:
            return existing_link[0].agreement_id

        # Pick the supplier (the non-customer party)
        if not extraction.parties or len(extraction.parties) < 2:
            raise ValueError(f"MSA {document_id} doesn't have ≥2 parties")
        supplier_raw = extraction.parties[1 - customer_party_index]
        supplier_id = get_or_create_supplier(s, settings.customer_id, supplier_raw)

        # Compute current_term_end
        eff = extraction.effective_date.value
        term_months = extraction.term_length_months.value
        current_term_end = None
        if eff and term_months:
            try:
                eff_date = date.fromisoformat(eff)
                # Approximate: 30 days per month
                current_term_end = eff_date + timedelta(days=int(term_months) * 30)
            except (ValueError, TypeError):
                pass

        agreement_id = f"agr_{settings.customer_id[:8]}_{uuid.uuid4().hex[:12]}"
        s.add(Agreement(
            agreement_id=agreement_id,
            customer_id=settings.customer_id,
            supplier_id=supplier_id,
            agreement_type="master_service_agreement",
            effective_date=date.fromisoformat(eff) if eff else None,
            current_term_end=current_term_end,
            status="active",
        ))
        s.add(DocumentAgreementLink(
            document_id=document_id,
            agreement_id=agreement_id,
            role="master",
        ))
    return agreement_id

CLI

Add an end-to-end command: extract + persist + create agreement.

src/ci/cli.py — process command
from ci.extraction import extract_document
from ci.persistence import persist_extraction, create_agreement_from_extraction


@app.command()
def process(document_id: str | None = None) -> None:
    """Extract + persist + create agreement for one or all MSAs."""
    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:
        try:
            extraction = extract_document(doc_id)
            ext_id = persist_extraction(doc_id, extraction, text_source="native")  # simplified
            agreement_id = create_agreement_from_extraction(doc_id, extraction)
            console.print(f"[green]✓[/green] {doc_id} → ext={ext_id}, agreement={agreement_id}")
        except Exception as e:
            console.print(f"[red]✗[/red] {doc_id}: {e}")

Verify

ci process
# Each MSA: extract, persist extraction, create supplier + agreement

Check the database:

-- Suppliers
SELECT supplier_id, canonical_name FROM suppliers;

-- Agreements with their suppliers
SELECT a.agreement_id, s.canonical_name AS supplier, a.effective_date, a.current_term_end
FROM agreements a
JOIN suppliers s ON s.supplier_id = a.supplier_id;

-- Extraction confidence per field
SELECT
  document_id,
  fields->'effective_date'->>'value' AS effective_date,
  fields->'effective_date'->>'confidence' AS conf
FROM extractions;

You should see a complete view: documents → extractions → agreements → suppliers, all linked via foreign keys. Re-run ci process — no duplicates; extractions update in place.