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.
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)
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.
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.
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
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.
-
A PDF already ingested as
acme_msa.pdfis copied into the inbox again asacme_nda.pdf, byte for byte. What doesci ingestreport, and whichdoc_classdoes the table hold afterwards?Show answer
It reports
existsfor the new file, and the row keepsmaster_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. -
You run
ci ingesttwice on a folder of two PDFs andpsqlnow 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.
-
The id suffix hashes
customer_idtogether 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_1andcustomer_2share the same first eight characters, so an identical file gets an identical id. The second tenant'ss.getfinds the first tenant's row and returnsexists, so it never gets a row of its own and its file is attached to another tenant's record. See Ingest function. -
Two
ci ingestprocesses start on the same inbox at the same moment. What can go wrong with the check-then-insert iningest_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 asINSERT ... ON CONFLICT DO NOTHINGso the database decides. The lookup is a fast path, not a lock. See Ingest function. -
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.
-
You run
alembic revision --autogenerateand 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. Settarget_metadata = Base.metadatainenv.pyand 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.