Hands-on · ~20 min

Project Setup

Create the project skeleton, install dependencies, bring up Postgres in Docker, wire config & secrets. End of this chapter: a verifiable empty project ready for code.

Repo layout

Create the directory structure first.

shell
mkdir -p contract-intelligence/{data/{inbox,gold},src/ci/{extraction,eval},tests}
cd contract-intelligence
touch src/ci/__init__.py
touch src/ci/{config,db,models,ingestion,api}.py
touch src/ci/extraction/{__init__,ocr,llm,schemas}.py
touch src/ci/eval/{__init__,runner,scoring}.py
touch tests/{__init__,test_ingestion,test_extraction,test_api}.py

Dependencies

Pyproject.toml. Standard modern Python project; uv or poetry both work.

pyproject.toml
[project]
name = "contract-intelligence"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "fastapi>=0.110",
    "uvicorn[standard]>=0.27",
    "sqlalchemy>=2.0",
    "psycopg[binary]>=3.1",
    "alembic>=1.13",
    "anthropic>=0.30",
    "pdfplumber>=0.10",
    "pytesseract>=0.3.10",
    "pillow>=10.0",
    "pdf2image>=1.17",
    "pydantic>=2.6",
    "pydantic-settings>=2.2",
    "rich>=13.7",
    "typer>=0.12",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "pytest-asyncio>=0.23",
    "httpx>=0.27",
    "mypy>=1.10",
    "ruff>=0.4",
]

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]

[tool.mypy]
strict = true

Install:

python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

# Also install Tesseract on the system (skip if you only have native PDFs)
# macOS:   brew install tesseract poppler
# Ubuntu:  sudo apt install tesseract-ocr poppler-utils

Postgres in Docker

One container, port 5432, persistent volume.

docker-compose.yml
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: ci
      POSTGRES_PASSWORD: ci_dev
      POSTGRES_DB: contract_intel
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata: {}

Bring it up:

docker compose up -d
# Verify
docker compose exec postgres psql -U ci -d contract_intel -c "SELECT version();"

Config & secrets

Pydantic-settings reads from environment + .env file.

src/ci/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="CI_", extra="ignore")

    # Database
    db_dsn: str = "postgresql+psycopg://ci:ci_dev@localhost:5432/contract_intel"

    # LLM
    anthropic_api_key: str  # required, no default
    extraction_model: str = "claude-sonnet-5-5"
    extraction_temperature: float = 0.0
    extraction_max_tokens: int = 2048

    # Paths
    inbox_dir: str = "data/inbox"
    gold_dir: str = "data/gold"

    # Pipeline
    pipeline_version: str = "v0.1.0"
    customer_id: str = "demo_customer"


settings = Settings()
.env (gitignored)
CI_ANTHROPIC_API_KEY=sk-ant-...
# Override others as needed

Add .env to .gitignore.

Verify it works

Smoke test: import the module, instantiate settings, hit Postgres.

scripts/verify_setup.py
"""Verify project setup: imports work, Postgres reachable, LLM key present."""
from sqlalchemy import create_engine, text
from ci.config import settings

def main() -> None:
    print(f"Pipeline version: {settings.pipeline_version}")
    print(f"Model: {settings.extraction_model}")
    print(f"DB: {settings.db_dsn}")

    eng = create_engine(settings.db_dsn)
    with eng.connect() as conn:
        result = conn.execute(text("SELECT version();")).scalar()
        print(f"Postgres: {result}")

    assert settings.anthropic_api_key, "No Anthropic API key"
    print("✓ Setup OK.")

if __name__ == "__main__":
    main()

Run:

python scripts/verify_setup.py

You should see:

Pipeline version: v0.1.0
Model: claude-sonnet-5-5
DB: postgresql+psycopg://ci:ci_dev@localhost:5432/contract_intel
Postgres: PostgreSQL 16.x ...
✓ Setup OK.

If anything fails, troubleshoot before moving to chapter 02 — every later chapter assumes this works.

✓ Check yourself

Answer from memory before opening each one.

  1. You export ANTHROPIC_API_KEY in your shell, leave no .env file, and run the verify script. It fails while building settings. Why, and what is the fix?

    Show answer

    Settings reads variables with the CI_ prefix, so it looks for CI_ANTHROPIC_API_KEY and never sees the unprefixed one. The field has no default, so construction fails. Export the prefixed name or put it in .env. See Config & secrets.

  2. docker compose up -d returns at once with no error, but the verify script then reports connection refused. What do you check first, and why can up succeed while the database is unusable?

    Show answer

    Run docker compose logs postgres and look for the line saying the server is ready. up -d returns when the container has started, and this compose file has no health check, so Postgres may still be initialising. A second cause is a local Postgres already holding port 5432. See Postgres in Docker.

  3. You change POSTGRES_PASSWORD in the compose file and the DSN in your settings to match, then restart the container. Authentication still fails. Why?

    Show answer

    The image applies POSTGRES_USER, POSTGRES_PASSWORD and POSTGRES_DB only when the data directory is empty. The pgdata volume already holds a database created with the old password, so the new value is ignored. Change the password with ALTER USER, or delete the volume and lose the data. See Postgres in Docker.

  4. Next week you run docker compose down -v and then up -d. What state is the database in, and what do you re-run before chapter 02's ingest works?

    Show answer

    The -v flag removes the named volume, so the database is empty again and every table is gone. Without -v the volume survives and so does the data. After -v, re-run alembic upgrade head to rebuild the schema. See Postgres in Docker.

  5. pip install -e ".[dev]" finishes cleanly, yet the OCR path later fails with an error that Tesseract or Poppler cannot be found. Why did the install not catch it?

    Show answer

    pytesseract and pdf2image are thin wrappers that call the system binaries tesseract and pdftoppm. Pip installs the wrapper but cannot install the program, so the failure shows only at the first scanned PDF. Install both with the system package manager and re-run the verify script. See Dependencies.

Takeaway

  • Settings come from CI_-prefixed environment variables or .env, and a required key with no default fails at import rather than at the first model call.
  • docker compose up -d returns when the container starts, not when Postgres accepts connections, so a refused connection right after it is a timing problem first.
  • The Postgres image reads its user, password and database name only for an empty data directory, so changing them against an existing volume does nothing.
  • A named volume keeps the data across restarts, and only down -v deletes it, which means the schema must be rebuilt.
  • OCR depends on system programs that pip cannot install, so the verify script is the only place those gaps show before real documents arrive.
  • Lower-bound version pins in pyproject.toml let two machines resolve different versions, so add a lockfile before you compare results across machines.

The empty project works, so the next step gives the pipeline its first table and its first idempotent write. → Ingestion: Folder Watcher to Postgres

Further reading