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()
    # Hash the whole customer_id with the content, so tenants never share an id
    scoped = hashlib.sha256(f"{customer_id}:{h}".encode()).hexdigest()
    document_id = f"doc_{customer_id[:8]}_{scoped[: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: the id hashes the full customer_id together with the content hash, so two customers who upload the same "Acme_MSA.pdf" get different ids. The readable prefix is cosmetic and cannot carry the uniqueness.

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.

✓ Check yourself

Answer from memory before opening each one.

  1. A PDF already ingested as acme_msa.pdf is copied into the inbox again as acme_nda.pdf, byte for byte. What does ci ingest report, and which doc_class does the table hold afterwards?

    Show answer

    It reports exists for the new file, and the row keeps master_service_agreement. The id comes from the content, so the lookup finds the old row and returns before classification is stored. A better filename never corrects a wrong class. See document_id.

  2. You run ci ingest twice on a folder of two PDFs and psql now shows three rows, not two. What happened, and what does it say about the id?

    Show answer

    One file changed between runs: it was re-saved, annotated or re-exported, so its bytes and hash changed and it became a new document. A content-derived id treats every byte change as a new identity. If versions of one contract must stay linked, that link needs its own column or table. See document_id.

  3. The id suffix hashes customer_id together with the content hash, rather than prefixing only the first eight characters of the customer id. Which two tenants break the shortcut, and what would the second one see?

    Show answer

    Tenants such as customer_1 and customer_2 share the same first eight characters, so an identical file gets an identical id. The second tenant's s.get finds the first tenant's row and returns exists, so it never gets a row of its own and its file is attached to another tenant's record. See Ingest function.

  4. Two ci ingest processes start on the same inbox at the same moment. What can go wrong with the check-then-insert in ingest_file, and how do you make it safe?

    Show answer

    Both can see no row, both add one, and the slower commit fails on the primary key with an integrity error. Catch that error and report exists, or write the insert as INSERT ... ON CONFLICT DO NOTHING so the database decides. The lookup is a fast path, not a lock. See Ingest function.

  5. A customer re-exports the same contract from their system every week. The text is identical, but the PDF bytes differ because of an embedded timestamp. What does ingestion do, and would you hash the text instead?

    Show answer

    Each export becomes a new document, so storage and extraction cost grow and the same contract appears many times. Hashing normalised text removes those duplicates. The price is that two files with the same words but different signatures or stamps collapse into one. Choose by whether the signed bytes or the words are your record. See document_id.

  6. You run alembic revision --autogenerate and the new migration file contains empty upgrade and downgrade functions. What is the most likely cause?

    Show answer

    Alembic compares the database with target_metadata, and that value is unset or the models module was never imported, so the metadata holds no tables. Set target_metadata = Base.metadata in env.py and import the models before it. See Initial migration.

Takeaway

  • The document id comes from the file's content and the customer, so the same bytes give the same id from any path and re-running ingest adds no rows.
  • Any byte change makes a new document, so versions of one contract need an explicit link; the hash cannot express them.
  • A readable prefix cannot carry uniqueness: the id must depend on the whole customer id, or tenants with similar names collide.
  • A lookup before an insert is a fast path; only a constraint, or a conflict-tolerant insert, is safe when two processes run at once.
  • Classification by filename runs once, at first ingest, and a re-ingest never corrects it.
  • An empty autogenerated migration means Alembic cannot see your models, not that the database is already right.

Documents are in the table and identified, so the next step turns their pages into structured fields. → Extraction: OCR + LLM

Further reading

  • INSERT — PostgreSQL 16 docs. The ON CONFLICT clause for conflict-tolerant, idempotent writes.
  • Constraints — PostgreSQL 16 docs. Primary key and unique constraints as the real guard against duplicates.
  • Auto Generating Migrations — Alembic docs, accessed 2026. What autogenerate compares, and why it can return an empty migration.
  • Session Basics — SQLAlchemy 2.0 docs. Session lifetime, commit and rollback, the pattern behind db_session.
  • hashlib — Python docs. SHA-256 and the other digests in the standard library.