Hands-on · ~30 min

Ingestion: Folder Watcher to Postgres

Watch data/inbox/ for PDFs; for each new file, compute a stable document_id (content hash) and persist file metadata to Postgres. Idempotent on re-ingest.

What we're building

  • A documents table in Postgres.
  • An idempotent ingest_file(path) function.
  • A CLI that processes data/inbox/*.pdf.
  • Re-running the CLI doesn't duplicate rows.

SQLAlchemy models

Minimum to get ingestion working. We'll extend in chapter 04 when we add extractions, agreements, etc.

src/ci/models.py
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Document(Base):
    __tablename__ = "documents"
    __table_args__ = (UniqueConstraint("customer_id", "file_hash"),)

    document_id: Mapped[str] = mapped_column(String, primary_key=True)
    customer_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
    source_uri: Mapped[str] = mapped_column(String, nullable=False)
    file_hash: Mapped[str] = mapped_column(String, nullable=False)
    doc_class: Mapped[str] = mapped_column(String, nullable=False, index=True)
    page_count: Mapped[int] = mapped_column(Integer, nullable=False)
    ingested_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
src/ci/db.py
from collections.abc import Iterator
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker

from ci.config import settings

engine = create_engine(settings.db_dsn, future=True)
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)


@contextmanager
def db_session() -> Iterator[Session]:
    s = SessionLocal()
    try:
        yield s
        s.commit()
    except Exception:
        s.rollback()
        raise
    finally:
        s.close()

Initial migration

Alembic for migrations.

alembic init -t async alembic   # creates alembic/ directory
# Edit alembic.ini: set sqlalchemy.url = (your db_dsn)
# Edit alembic/env.py: import Base and set target_metadata = Base.metadata

alembic revision --autogenerate -m "documents table"
alembic upgrade head

Verify in psql:

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

document_id

Content-hash derivation. From Contract Data Model §03.

src/ci/ingestion.py
from __future__ import annotations
import hashlib
from pathlib import Path
import pdfplumber

from ci.config import settings
from ci.db import db_session
from ci.models import Document


def derive_document_id(file_bytes: bytes, customer_id: str) -> tuple[str, str]:
    h = hashlib.sha256(file_bytes).hexdigest()
    document_id = f"doc_{customer_id[:8]}_{h[:16]}"
    return document_id, h


def classify_doc(file_bytes: bytes, filename: str) -> str:
    """Heuristic classification by filename. Replace with a real classifier later."""
    name = filename.lower()
    if "msa" in name or "master_service" in name:
        return "master_service_agreement"
    if "sow" in name or "statement_of_work" in name:
        return "statement_of_work"
    if "amendment" in name:
        return "amendment"
    if "nda" in name:
        return "nda"
    if "invoice" in name:
        return "invoice"
    return "unclassified"


def page_count(file_bytes: bytes) -> int:
    import io
    with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
        return len(pdf.pages)


def ingest_file(path: Path, customer_id: str | None = None) -> dict:
    customer_id = customer_id or settings.customer_id
    file_bytes = path.read_bytes()
    document_id, file_hash = derive_document_id(file_bytes, customer_id)
    doc_class = classify_doc(file_bytes, path.name)
    pages = page_count(file_bytes)

    with db_session() as s:
        existing = s.get(Document, document_id)
        if existing:
            return {"document_id": document_id, "status": "exists", "doc_class": existing.doc_class}
        doc = Document(
            document_id=document_id,
            customer_id=customer_id,
            source_uri=str(path.resolve()),
            file_hash=file_hash,
            doc_class=doc_class,
            page_count=pages,
        )
        s.add(doc)
    return {"document_id": document_id, "status": "ingested", "doc_class": doc_class, "page_count": pages}

Ingest function

The core is above. A few notes:

  • Idempotent: re-running on the same file returns status: exists; no duplicate row.
  • Content-derived ID: same file from a different path produces the same document_id.
  • Per-customer namespace: prefix the hash with customer_id, so two customers' "Acme_MSA.pdf" don't collide.

CLI wrapper

Use Typer for a nice CLI.

src/ci/cli.py
from __future__ import annotations
from pathlib import Path
import typer
from rich.console import Console
from rich.table import Table

from ci.config import settings
from ci.ingestion import ingest_file

app = typer.Typer()
console = Console()


@app.command()
def ingest(directory: Path = Path(settings.inbox_dir)) -> None:
    """Ingest all PDFs in DIRECTORY."""
    pdfs = sorted(directory.glob("*.pdf"))
    if not pdfs:
        console.print(f"[yellow]No PDFs in {directory}[/yellow]")
        return

    table = Table(title=f"Ingested from {directory}")
    table.add_column("File")
    table.add_column("Status")
    table.add_column("doc_class")
    table.add_column("pages", justify="right")

    for path in pdfs:
        result = ingest_file(path)
        table.add_row(path.name, result["status"],
                      result.get("doc_class", "-"),
                      str(result.get("page_count", "-")))
    console.print(table)


if __name__ == "__main__":
    app()

Add a console-script entry to pyproject.toml for convenience:

[project.scripts]
ci = "ci.cli:app"

Then pip install -e . again to pick it up.

Verify

Drop two PDFs into data/inbox/ (sample MSAs are fine — public templates work). Run:

ci ingest
# Should show: 2 PDFs, status=ingested, doc_class=master_service_agreement

ci ingest
# Re-run: 2 PDFs, status=exists. No duplicates.

docker compose exec postgres psql -U ci -d contract_intel -c "SELECT document_id, doc_class, page_count FROM documents;"

You should see two rows. Re-running ingest doesn't add more. That's the idempotency working.

Test

tests/test_ingestion.py
from pathlib import Path
from ci.ingestion import derive_document_id, ingest_file


def test_derive_document_id_deterministic():
    content = b"hello world"
    id1, h1 = derive_document_id(content, "cust_test")
    id2, h2 = derive_document_id(content, "cust_test")
    assert id1 == id2
    assert h1 == h2


def test_derive_document_id_customer_specific():
    content = b"hello world"
    id1, _ = derive_document_id(content, "cust_a")
    id2, _ = derive_document_id(content, "cust_b")
    assert id1 != id2


def test_ingest_is_idempotent(tmp_path: Path):
    pdf = tmp_path / "test.pdf"
    pdf.write_bytes(b"%PDF-1.4\n%%EOF")  # not a real PDF — pdfplumber will fail on page_count
    # In practice, use a real sample PDF in test fixtures.

Run: pytest tests/test_ingestion.py -v. The first two tests should pass; the third requires a real fixture PDF.