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.
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.
[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.
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.
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()
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.
"""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.
-
You export
ANTHROPIC_API_KEYin your shell, leave no.envfile, 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 forCI_ANTHROPIC_API_KEYand 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. -
docker compose up -dreturns at once with no error, but the verify script then reports connection refused. What do you check first, and why canupsucceed while the database is unusable?Show answer
Run
docker compose logs postgresand look for the line saying the server is ready.up -dreturns 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. -
You change
POSTGRES_PASSWORDin 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_PASSWORDandPOSTGRES_DBonly when the data directory is empty. Thepgdatavolume already holds a database created with the old password, so the new value is ignored. Change the password withALTER USER, or delete the volume and lose the data. See Postgres in Docker. -
Next week you run
docker compose down -vand thenup -d. What state is the database in, and what do you re-run before chapter 02's ingest works?Show answer
The
-vflag removes the named volume, so the database is empty again and every table is gone. Without-vthe volume survives and so does the data. After-v, re-runalembic upgrade headto rebuild the schema. See Postgres in Docker. -
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
pytesseractandpdf2imageare thin wrappers that call the system binariestesseractandpdftoppm. 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 -dreturns 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 -vdeletes 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.tomllet 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
- Postgres official image — Docker, accessed 2026. The environment variables, the data directory rule and the volume layout.
- Control startup and shutdown order in Compose — Docker docs, accessed 2026. Why started is not ready, and how a health check closes the gap.
- Settings management — Pydantic docs, accessed 2026. Prefixes, env files and how values are resolved.
- Tesseract documentation — Tesseract OCR project, accessed 2026. Installing the engine and its language data.
- pdfplumber — Jeremy Singer-Vine, accessed 2026. What the library reads from a native PDF and what it does not.