Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 115 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,109 @@

# python-fastapi

**FastAPI, done the clean way.**
**Generative AI on FastAPI, done the clean way.**

A reference Python **FastAPI** project demonstrating clean, scalable, and maintainable architecture using **Domain-Driven Design (DDD)** and the **Hexagonal (Ports & Adapters)** pattern.
A reference **Generative AI backend** in Python: an LLM-powered assistant over a product catalog, built with **FastAPI**, **Domain-Driven Design (DDD)** and the **Hexagonal (Ports & Adapters)** pattern. The model is an adapter behind a port — swap providers, fake it in tests, and keep it out of your domain.

[![CI](https://github.com/jcmoro/python-fastapi/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jcmoro/python-fastapi/actions/workflows/ci.yml)
![Tests](https://img.shields.io/badge/tests-85%20passing-2e7d32?style=flat)
![Tests](https://img.shields.io/badge/tests-99%20passing-2e7d32?style=flat)
![Architecture](https://img.shields.io/badge/architecture-DDD%20%2B%20Hexagonal-2e7d32?style=flat)
![mypy](https://img.shields.io/badge/mypy-strict-1565c0?style=flat)
![GenAI](https://img.shields.io/badge/GenAI-LLM%20assistant-6a1b9a?style=flat)
![Claude](https://img.shields.io/badge/LLM-Claude-D97757?style=flat&logo=anthropic&logoColor=white)
![Python](https://img.shields.io/badge/Python-3.12-3776AB?style=flat&logo=python&logoColor=white)
![FastAPI](https://img.shields.io/badge/FastAPI-0.162-009688?style=flat&logo=fastapi&logoColor=white)
![Pydantic](https://img.shields.io/badge/Pydantic-v2-E92063?style=flat&logo=pydantic&logoColor=white)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169E1?style=flat&logo=postgresql&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-compose-2496ED?style=flat&logo=docker&logoColor=white)
![Claude](https://img.shields.io/badge/LLM-Claude-D97757?style=flat&logo=anthropic&logoColor=white)

### [**→ Quick Start**](#quick-start)
### [**→ What works today**](#what-works-today)

[Stack](#stack) ·
[Why this project](#why-this-project) ·
[Roadmap](#roadmap) ·
[Quick Start](#quick-start) ·
[Architecture](#architecture) ·
[Project Layout](#project-layout) ·
[Testing](#testing) ·
[Issues](https://github.com/jcmoro/python-fastapi/issues)

</div>

---

## Why this project

Most GenAI demos wire the SDK straight into the route handler. This one treats the LLM like any other infrastructure dependency:

- **The LLM is a port.** The domain defines `LLMClient`; `AnthropicLLMClient` is just one adapter. The domain and application layers never import `anthropic`.
- **Tests never call a real model.** Unit and e2e tests use `FakeLLMClient` at the port boundary. The real adapter is tested against a mocked HTTP transport — no network, no cost.
- **Failures have a contract.** Transient LLM failures (connection, timeout, 429, 5xx) return **503**. Configuration errors (bad key, unknown model) are not disguised as outages.
- **Latency is bounded.** `LLM_TIMEOUT_S` applies per attempt and `LLM_MAX_RETRIES` defaults to 0, so the worst case is `timeout * (retries + 1)`.
- **Every answer reports its cost.** Responses include the model used, input/output tokens and latency.

---

## What works today

`POST /assistant/ask` — ask a natural-language question and get the model's answer plus usage.

```bash
curl -s -X POST http://localhost:8000/assistant/ask \
-H 'Content-Type: application/json' \
-d '{"question": "Which widgets cost less than 20 EUR?"}'
```

Example response (values are illustrative):

```json
{
"text": "…",
"usage": {
"model": "claude-haiku-5-5",
"input_tokens": 52,
"output_tokens": 87,
"latency_ms": 1240
}
}
```

| Status | When |
|--------|------|
| `200` | The model answered. |
| `422` | Empty or blank question, more than 1,000 characters, or unknown fields. |
| `503` | The LLM is unavailable: connection error, timeout, rate limit (429) or server error (5xx). |
| `500` | Misconfiguration: missing or invalid API key, unknown model, invalid request. |

The assistant does not read the catalog yet — that is what RAG (next on the roadmap) adds.

---

## Roadmap

Delivered as small, shippable PRs. Full plan: [`docs/plan-assistant-genai.md`](docs/plan-assistant-genai.md).

| Step | Scope | Status |
|------|-------|--------|
| 1 | `LLMClient` port, Anthropic adapter, `POST /assistant/ask`, failure contract | ✅ Done |
| 2 | **RAG** with `pgvector` in the existing Postgres: embeddings behind a port, top-k retrieval, `sources` in the answer, deterministic fallback when the LLM fails | ⏳ Planned |
| 3 | **Agent** with LangGraph: catalog tools, step limits, token usage summed across steps, offline evals | ⏳ Planned |
| 4 | **Deploy**: Kubernetes manifests, local `kind` cluster, optional cloud target | ⏳ Planned |

---

## Stack

| Layer | Choice |
|------------------|---------------------------------------|
| Language | Python ≥ 3.10 (type hints required) |
| Web framework | FastAPI + Uvicorn |
| Validation | Pydantic v2 |
| Settings | `pydantic-settings` (`BaseSettings`) |
| Serialization | FastAPI native (Pydantic core) |
| Tests | `pytest`, `pytest-asyncio`, `httpx` |
| Static checks | `pylint`, `ruff`, `mypy` |
| Packaging | `pyproject.toml` |
| Container / CLI | Docker + `make` |
| Layer | Choice |
|------------------|----------------------------------------------------------|
| Language | Python ≥ 3.10 (type hints required) |
| Web framework | FastAPI + Uvicorn |
| LLM | Anthropic Claude (`anthropic` SDK), behind `LLMClient` |
| Validation | Pydantic v2 |
| Settings | `pydantic-settings` (`BaseSettings`) |
| Serialization | FastAPI native (Pydantic core) |
| Tests | `pytest`, `pytest-asyncio`, `httpx` |
| Static checks | `pylint`, `ruff`, `mypy` |
| Packaging | `pyproject.toml` |
| Container / CLI | Docker + `make` |

---

Expand All @@ -53,16 +116,16 @@ DDD + Hexagonal. Each **bounded context** is a vertical slice with four layers:
|------------------|-------------------------------------|----------------------------------------------------------------------|
| `domain/` | none | Entities, value objects, domain services, **port** interfaces. |
| `application/` | `domain/` | Use cases (commands and queries), DTOs, application services. |
| `infrastructure/`| `domain/`, `application/` | Adapter implementations (repositories, HTTP clients, persistence). |
| `infrastructure/`| `domain/`, `application/` | Adapter implementations (repositories, LLM clients, persistence). |
| `ui/` | `application/`, DTOs | FastAPI routers and Pydantic schemas. Translates HTTP ↔ use cases. |

**Direction of dependencies points inward.** Outer layers depend on inner ones, never the reverse.

Cross-context contracts travel as **DTOs only** — no shared domain models between bounded contexts.
Cross-context contracts travel as **DTOs only** — no shared domain models between bounded contexts. `assistant` never imports `catalog`.

### Project Layout

The current shape (`catalog` is the only context so far). Subdirectories are introduced only when they earn their place — single-file modules are preferred for the first occupant of each layer.
Two contexts so far: `catalog` (products CRUD) and `assistant` (GenAI). Subdirectories are introduced only when they earn their place — single-file modules are preferred for the first occupant of each layer.

```
src/
Expand Down Expand Up @@ -90,23 +153,42 @@ src/
schemas.py # Pydantic request/response models
routers/products.py # Endpoints (POST/GET/PATCH/DELETE)
dependencies.py # Per-context DI wiring
assistant/
domain/ # No anthropic, no FastAPI.
question.py # Value object (stripped, non-empty, ≤ 1,000 chars)
completion.py # Completion: text, model, tokens, latency
exceptions.py # InvalidQuestionError, LLMUnavailableError
ports/
llm_client.py # LLMClient ABC — documents which failures it raises
application/
dtos.py # AskCommand, AnswerDTO, UsageDTO
use_cases/
ask_assistant.py
infrastructure/ # The only place the anthropic SDK lives.
llm/
anthropic_llm_client.py # Timeout, retries, error classification
ui/
schemas.py # AskRequest, AnswerResponse
routers/assistant.py # POST /assistant/ask
dependencies.py # Per-context DI wiring
shared/ # Cross-cutting only. Keep it small.
domain/
errors.py # DomainError + NotFoundError / ConflictError / InvalidInputError
errors.py # DomainError + NotFound / Conflict / InvalidInput / ServiceUnavailable
infrastructure/
settings.py # Pydantic BaseSettings
settings.py # Pydantic BaseSettings (DB + LLM)
database.py # async engine + session factory + get_session dep
db_base.py # SQLAlchemy declarative Base
exception_handlers.py # Domain errors → HTTP responses
exception_handlers.py # Domain errors → 404 / 409 / 422 / 503
alembic/
env.py # Async, reads URL from settings
script.py.mako # Migration template (Python 3.10 unions)
versions/ # Generated migration files
alembic.ini
tests/
conftest.py # make_product, db_engine, db_session, client (sync)
fakes/ # Port-boundary fakes shared by suites (FakeLLMClient)
unit/ # Pure logic. No I/O.
integration/ # Real postgres, transaction-rollback isolation.
integration/ # Real adapters: postgres, Anthropic client on a mocked transport.
e2e/ # AsyncClient + ASGITransport against the full app.
main.py # create_app() — composes routers + handlers
docker-compose.yml # api + db + db-test (db-test on tmpfs)
Expand All @@ -123,7 +205,7 @@ When a layer's single file grows or a second concept ships, that file gets promo
**Prerequisites:** Docker + Docker Compose. Everything (deps, server, tests, linters) runs inside containers — no local Python install required.

```bash
cp .env.example .env
cp .env.example .env # then set ANTHROPIC_API_KEY to use the assistant

make install # Build the docker image (installs deps inside)
make migrate # Apply migrations to the dev database
Expand All @@ -132,9 +214,9 @@ make run # Start dev server in the foreground (uvicorn --reload)
make up # Same, in the background
make down # Stop and remove containers

make test # Full test suite
make test # Full test suite (never calls a real model)
make unit-test # Unit tests only (no I/O)
make integration-test # Integration tests (real postgres)
make integration-test # Integration tests (real postgres, mocked LLM transport)
make e2e-test # E2E tests (HTTP via TestClient/AsyncClient)
make lint # ruff + pylint + mypy
make format # auto-format
Expand All @@ -143,6 +225,8 @@ make sh # Shell into a fresh api container
make migration m="add foo" # Generate a new Alembic migration via autogenerate
```

LLM settings (all in `.env.example`): `ANTHROPIC_API_KEY`, `LLM_MODEL` (default `claude-haiku-5-5`), `LLM_TIMEOUT_S`, `LLM_MAX_RETRIES`, `LLM_MAX_TOKENS`.

API is served on `http://localhost:8000` with auto-generated docs:

- `/docs` — Swagger UI (interactive)
Expand All @@ -155,8 +239,8 @@ API is served on `http://localhost:8000` with auto-generated docs:

| Suite | Location | What it covers |
|--------------|------------------------|-------------------------------------------------------------|
| Unit | `tests/unit/` | Pure domain and application logic. Mocks at the port boundary via `FakeProductRepository`. **No I/O.** |
| Integration | `tests/integration/` | Real `SqlAlchemyProductRepository` against `db-test`. Transaction-rollback per test for isolation. |
| E2E | `tests/e2e/` | Full HTTP via `httpx.AsyncClient` + `ASGITransport`. App's `get_session` is overridden to share the test transaction. |
| Unit | `tests/unit/` | Pure domain and application logic. Mocks at the port boundary via `FakeProductRepository` and `FakeLLMClient`. **No I/O.** |
| Integration | `tests/integration/` | Real `SqlAlchemyProductRepository` against `db-test` (transaction-rollback per test). Real `AnthropicLLMClient` on an `httpx2.MockTransport`: error classification and retry bound. |
| E2E | `tests/e2e/` | Full HTTP via `httpx.AsyncClient` + `ASGITransport`. The DB session and the `LLMClient` are overridden; each domain error is checked against its status code. |

---
84 changes: 84 additions & 0 deletions docs/step-15-readme-genai.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Paso 15 — README: presentar el proyecto como IA Generativa

Rama `feature/assistant-llm-port` (PR #4).

## Qué

Reescribir la cabecera y las secciones del `README.md` para que el proyecto se presente como una
**referencia de backend de IA Generativa** con arquitectura limpia (FastAPI + DDD + Hexagonal), sin
atribuirle nada que no exista en el código.

Concretamente:

1. Cabecera centrada con el posicionamiento GenAI y un enlace rápido a la sección «What works today».
2. Sección **Why this project**, con lo que el diseño ya garantiza hoy.
3. Sección **What works today**: el endpoint real `POST /assistant/ask`, un ejemplo de llamada y su
contrato de errores.
4. Sección **Roadmap** con los 4 PR del plan (`docs/plan-assistant-genai.md`) y su estado.
5. Corregir lo que ha quedado desfasado: el badge de tests (decía 85), la frase «`catalog` is the
only context so far», el layout sin el contexto `assistant` y la tabla de testing.

## Por qué

- Petición del usuario: «vender el proyecto como IA Generativa».
- Decidido con el usuario:
- **Dónde**: en la PR #4. El README entra en `main` junto con el código que describe, así que
`main` nunca promete algo que no tiene.
- **Cómo tratar lo no construido**: un roadmap con estado explícito (✅ hecho / ⏳ planificado).
Se puede vender con ambición sin presentar RAG, agente o despliegue como si existieran.
- Idioma del README: inglés, como el resto del fichero. Este documento va en español.

## Criterios de aceptación

| # | Criterio | Cómo se demuestra |
|---|---|---|
| 1 | Todo lo que el README presenta como **hecho** existe en el código de la rama | Tabla de trazabilidad: afirmación → fichero o test |
| 2 | Nada del roadmap aparece como hecho salvo el PR 1 | Revisión de la tabla Roadmap |
| 3 | Las cifras son reales | Badge de tests = recuento de `pytest --collect-only` en la rama |
| 4 | Las anclas internas de la cabecera apuntan a secciones existentes | Comprobación de anclas contra los encabezados |
| 5 | Los ejemplos de request y response coinciden con el contrato real | Contrastados con `ui/schemas.py` y los tests e2e |
| 6 | No se tocan código ni tests | `git diff --stat` solo muestra `README.md` y este documento |

## Evidencia

### C1 — Trazabilidad: afirmación del README → código

| Afirmación (README) | Dónde se comprueba |
|---|---|
| «The LLM is a port» / el dominio define `LLMClient` | `src/contexts/assistant/domain/ports/llm_client.py` |
| Domain y application no importan `anthropic` | `grep -rl anthropic src/contexts/assistant` → solo `infrastructure/llm/anthropic_llm_client.py` y `ui/dependencies.py` |
| `assistant` nunca importa `catalog` | `grep -rn contexts.catalog src/contexts/assistant` → sin resultados |
| Los tests nunca llaman a un modelo real | `tests/fakes/llm_client.py` (`FakeLLMClient`). El adaptador se prueba con `httpx2.MockTransport` en `tests/integration/.../test_anthropic_llm_client.py` |
| 503 para conexión, timeout, 429 y 5xx; los errores de configuración no se convierten en 503 | `anthropic_llm_client.py` (`except (APIConnectionError, RateLimitError)`, `status_code < 500 → raise`). Tests `test_transient_*` y `test_configuration_error_propagates` |
| `LLM_TIMEOUT_S` por intento, `LLM_MAX_RETRIES` = 0 por defecto, cota `timeout * (retries + 1)` | `src/shared/infrastructure/settings.py`. Test `test_timing_out_upstream_is_attempted_once` |
| La respuesta incluye modelo, tokens y latencia | `ui/schemas.py` (`UsageResponse`). Test e2e `test_returns_200_with_answer_and_usage` |
| 422: pregunta vacía, en blanco, de más de 1.000 caracteres o con campos extra | `AskRequest` (`min_length=1`, `max_length=1000`, `extra="forbid"`) + `Question`. Tests e2e 422 y `test_question.py` |
| Modelo por defecto `claude-haiku-5-5`; todas las variables LLM en `.env.example` | `settings.py`, `.env.example` |
| Cada error de dominio se comprueba contra su código de estado | `tests/e2e/test_exception_handlers.py` |

### C2 — Roadmap

Solo el paso 1 aparece como ✅, y se fusiona junto con este README en la PR #4. Los pasos 2, 3 y 4
aparecen como ⏳ Planned y su alcance copia el de `docs/plan-assistant-genai.md`. El README
también aclara que el assistant todavía no lee el catálogo.

### C3 — Cifras

`pytest --collect-only` sobre la rama (`9258f58`) recoge **99 tests**, 31 de ellos del assistant.
El badge dice `tests-99 passing`. Ambos badges nuevos o cambiados devuelven HTTP 200 en shields.io.

### C4 — Anclas

Las 6 anclas de la cabecera (`#what-works-today`, `#why-this-project`, `#roadmap`,
`#quick-start`, `#architecture`, `#testing`) tienen su encabezado `##` correspondiente.

### C5 — Ejemplos

- El request `{"question": ...}` coincide con `AskRequest`.
- La response `{"text", "usage": {"model", "input_tokens", "output_tokens", "latency_ms"}}`
coincide con `AnswerResponse` y `UsageResponse`.
- Los valores del ejemplo están marcados como ilustrativos.

### C6 — Alcance

`git status` solo muestra `README.md` modificado y este documento. No se toca código ni tests.
Loading