diff --git a/README.md b/README.md index ae84cfc..b3d697d 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,28 @@ # 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) @@ -29,19 +31,80 @@ A reference Python **FastAPI** project demonstrating clean, scalable, and mainta --- +## 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` | --- @@ -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/ @@ -90,14 +153,32 @@ 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) @@ -105,8 +186,9 @@ alembic/ 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) @@ -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 @@ -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 @@ -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) @@ -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. | --- diff --git a/docs/step-15-readme-genai.md b/docs/step-15-readme-genai.md new file mode 100644 index 0000000..619e68b --- /dev/null +++ b/docs/step-15-readme-genai.md @@ -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.