Pràctica PR5073/02: API d'IA en Producció amb FastAPI i Docker
Objectius
- Dissenyar una API REST per a serveis d'IA seguint bones pràctiques
- Implementar endpoints de classificació i generació de text
- Integrar models de Hugging Face Transformers
- Conteneiritzar l'API amb Docker i docker-compose
- Implementar tests automàtics amb pytest
- Configurar health checks i documentació Swagger automàtica
- Personalitzar tots els endpoints i respostes amb el nom de l'alumne
Prerequisits
| Requisit | Detall |
|---|---|
| Temps estimat | 8-10 hores |
| RAM mínima | 4 GB (8 GB recomanat per als models Transformers) |
| Espai en disc | 5 GB lliures |
| Docker Desktop | v4.25+ instal·lat i funcionant |
| Coneixements | Python intermedi, REST APIs bàsic, conceptes HTTP |
Introducció
Per quin motiu FastAPI per a IA?
FastAPI es el framework web de Python més popular per a APIs d'IA en producció el 2025. Les raons:
- Velocitat: basat en Starlette i Uvicorn (ASGI), es 2-3x més ràpid que Flask en benchmark estàndard
- Tipat automàtic: usa anotacions de tipus Python + Pydantic per validació automàtica
- Documentació automàtica: genera Swagger UI i ReDoc sense cap configuració addicional
- Async nativa: suporta operacions asíncrones per a operacions I/O-bound (cridades a APIs, base de dades)
- Estàndard OpenAPI: la documentació generada es compatible amb l'estàndard OpenAPI 3.0
Comparativa de frameworks per a APIs d'IA:
| Framework | Velocitat | IA ecosystem | Async | Documentacio auto |
|---|---|---|---|---|
| FastAPI | Alta | Excellent | Si | Si (Swagger) |
| Flask | Mitja | Bona | Parcial | No |
| Django REST | Mitja | Bona | Parcial | Parcial |
| Tornado | Alta | Limitada | Si | No |
| Sanic | Alta | Limitada | Si | No |
Arquitectura de l'API
flowchart LR
Client[Client HTTP] --> |Request JSON| API[FastAPI]
API --> |Validacio Pydantic| Router[Routers]
Router --> Classify[classify.py]
Router --> Generate[generate.py]
Classify --> Model1[Sentiment Model\nHugging Face]
Generate --> Model2[Text Gen Model\nOllama API]
API --> |Response JSON| Client
API --> |/healthz| Health[Health Check]
API --> |/docs| Swagger[Swagger UI]
Part 1: Estructura del projecte
Creeu l'estructura de directoris:
mkdir api-ia-joan-garcia
cd api-ia-joan-garcia
# Crear estructura de directoris
mkdir -p app/routers tests
# Crear fitxers buits
touch app/__init__.py
touch app/main.py
touch app/models.py
touch app/config.py
touch app/routers/__init__.py
touch app/routers/classify.py
touch app/routers/generate.py
touch tests/__init__.py
touch tests/test_api.py
touch Dockerfile
touch docker-compose.yml
touch requirements.txt
touch .env.example
L'estructura final hauria de ser:
api-ia-joan-garcia/
├── app/
│ ├── __init__.py
│ ├── main.py # Punt d'entrada FastAPI
│ ├── models.py # Schemas Pydantic (validació de dades)
│ ├── config.py # Variables d'entorn i configuració
│ └── routers/
│ ├── __init__.py
│ ├── classify.py # Endpoints de classificació
│ └── generate.py # Endpoints de generació
├── tests/
│ ├── __init__.py
│ └── test_api.py # Tests amb pytest
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── .env.example
Part 2: Configuració i models de dades
requirements.txt
fastapi==0.115.4
uvicorn[standard]==0.32.0
pydantic==2.9.2
pydantic-settings==2.6.1
transformers==4.46.2
torch==2.5.1
requests==2.32.3
httpx==0.27.2
pytest==8.3.3
pytest-asyncio==0.24.0
httpx==0.27.2
python-multipart==0.0.12
slowapi==0.1.9
app/config.py
# app/config.py
# Pràctica PR5073/02 - Configuracio de l'API
# Alumne: Joan Garcia
from pydantic_settings import BaseSettings
from typing import Optional
class Configuracio(BaseSettings):
"""
Configuracio de l'API carregada des de variables d'entorn.
Pydantic BaseSettings llegeix automaticament les variables d'entorn.
"""
# Identificacio de l'alumne (apareix a tots els endpoints)
alumne: str = "joan_garcia"
alumne_nom_complet: str = "Joan Garcia"
# Configuracio del servidor
host: str = "0.0.0.0"
port: int = 8000
workers: int = 1
log_level: str = "info"
# APIs externes (opcionals)
openai_api_key: Optional[str] = None
anthropic_api_key: Optional[str] = None
# Ollama (per a generacio de text local)
ollama_url: str = "http://ollama:11434"
ollama_model: str = "llama3.2:3b"
# Configuracio dels models Hugging Face
model_classificacio: str = "nlptown/bert-base-multilingual-uncased-sentiment"
model_cache_dir: str = "/app/model_cache"
# Rate limiting
rate_limit_per_minut: int = 60
class Config:
env_file = ".env"
case_sensitive = False
# Instancia global de configuracio
config = Configuracio()
app/models.py
# app/models.py
# Pràctica PR5073/02 - Schemas Pydantic per a l'API
# Alumne: Joan Garcia
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
from app.config import config
ALUMNE = config.alumne
# ============================================================
# MODELS DE CLASSIFICACIO
# ============================================================
class ClassifyRequest(BaseModel):
"""Schema per a peticions de classificació de text."""
text: str = Field(
...,
min_length=1,
max_length=5000,
description="Text a classificar (1-5000 caracters)",
examples=["M'encanta aquest producte, funciona molt be!"]
)
model: str = Field(
default="sentiment",
description="Model a usar: 'sentiment' o 'zero-shot'",
examples=["sentiment"]
)
idioma: Optional[str] = Field(
default=None,
description="Codi ISO 639-1 de l'idioma (ca, es, en). Auto-detectat si no s'especifica."
)
@field_validator("text")
@classmethod
def text_no_buit(cls, v: str) -> str:
if not v.strip():
raise ValueError("El text no pot estar buit o ser sols espais")
return v.strip()
class ClassifyResponse(BaseModel):
"""Schema per a respostes de classificació."""
etiqueta: str = Field(description="Etiqueta predita (ex: 'positiu', 'negatiu')")
puntuacio: float = Field(description="Probabilitat de l'etiqueta (0-1)")
totes_les_etiquetes: Optional[Dict[str, float]] = Field(
default=None,
description="Probabilitats per a totes les etiquetes"
)
text_entrada: str = Field(description="Text analitzat (primeres 100 chars)")
model_usat: str = Field(description="Model usat per a la prediccio")
alumne: str = Field(default=ALUMNE, description="Identificador de l'alumne")
timestamp: datetime = Field(default_factory=datetime.utcnow)
# ============================================================
# MODELS DE GENERACIO
# ============================================================
class GenerateRequest(BaseModel):
"""Schema per a peticions de generacio de text."""
prompt: str = Field(
...,
min_length=5,
max_length=2000,
description="Prompt per a la generacio de text",
examples=["Explica en catala que es la intel·ligencia artificial"]
)
max_tokens: int = Field(
default=500,
ge=50,
le=2000,
description="Maxim de tokens a generar (50-2000)"
)
temperatura: float = Field(
default=0.7,
ge=0.0,
le=2.0,
description="Temperatura de la generacio (0=determinista, 2=creatiu)"
)
sistema: Optional[str] = Field(
default=None,
description="Prompt del sistema que defineix el comportament del model"
)
class GenerateResponse(BaseModel):
"""Schema per a respostes de generacio."""
text_generat: str = Field(description="Text generat pel model")
tokens_usats: Optional[int] = Field(default=None)
model_usat: str = Field(description="Model usat per a la generacio")
prompt_original: str = Field(description="Prompt original (primeres 100 chars)")
alumne: str = Field(default=ALUMNE)
timestamp: datetime = Field(default_factory=datetime.utcnow)
# ============================================================
# MODELS DE SALUT I ERROR
# ============================================================
class HealthResponse(BaseModel):
"""Schema per al health check."""
estat: str = Field(description="'ok' o 'error'")
alumne: str = Field(default=ALUMNE)
version: str = Field(default="1.0.0")
models_carregats: List[str] = Field(default_factory=list)
timestamp: datetime = Field(default_factory=datetime.utcnow)
class ErrorResponse(BaseModel):
"""Schema per a respostes d'error."""
error: str
detall: Optional[str] = None
codi_error: int
alumne: str = Field(default=ALUMNE)
Part 3: Implementació dels routers
app/routers/classify.py
# app/routers/classify.py
# Pràctica PR5073/02 - Endpoints de classificació
# Alumne: Joan Garcia
from fastapi import APIRouter, HTTPException, BackgroundTasks
from transformers import pipeline, Pipeline
from typing import Optional
import logging
import time
from app.models import ClassifyRequest, ClassifyResponse
from app.config import config
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/v1",
tags=["Classificacio de Text"],
responses={
422: {"description": "Error de validació de dades"},
503: {"description": "Model no disponible"}
}
)
# Cache dels models (carregats un cop, reutilitzats)
_models: dict[str, Pipeline] = {}
def obtenir_model_sentiment() -> Pipeline:
"""
Carrega el model de sentiment si no esta ja en memoria.
Singleton pattern: el model es carrega una sola vegada.
"""
model_key = "sentiment"
if model_key not in _models:
logger.info(f"Carregant model de sentiment: {config.model_classificacio}")
t_inici = time.time()
# nlptown/bert-base-multilingual-uncased-sentiment:
# - Multilingüe (suporta catala)
# - 5 etiquetes: 1-5 estrelles
# - Model relativament lleuger (669 MB)
_models[model_key] = pipeline(
"sentiment-analysis",
model=config.model_classificacio,
tokenizer=config.model_classificacio,
device=-1, # CPU (-1) o GPU (0)
truncation=True,
max_length=512
)
temps = time.time() - t_inici
logger.info(f"Model carregat en {temps:.2f}s")
return _models[model_key]
@router.post(
"/classify/sentiment",
response_model=ClassifyResponse,
summary="Classificació de sentiment",
description="""
Analitza el sentiment d'un text (positiu/negatiu/neutral).
Suporta català, castellà, anglès i altres idiomes europeus.
Model usat: nlptown/bert-base-multilingual-uncased-sentiment
Escala: 1 estrella (molt negatiu) → 5 estrelles (molt positiu)
"""
)
async def classificar_sentiment(
peticio: ClassifyRequest,
background_tasks: BackgroundTasks
) -> ClassifyResponse:
"""Endpoint principal de classificació de sentiment."""
try:
model = obtenir_model_sentiment()
t_inici = time.time()
# Inferencia
resultat = model(peticio.text)[0]
temps_inferencia = (time.time() - t_inici) * 1000
# Convertir etiqueta numica a text llegible
etiqueta_num = resultat["label"] # Ex: "4 stars"
num_estrelles = int(etiqueta_num.split()[0])
if num_estrelles >= 4:
etiqueta_llegible = "molt_positiu" if num_estrelles == 5 else "positiu"
elif num_estrelles == 3:
etiqueta_llegible = "neutral"
else:
etiqueta_llegible = "molt_negatiu" if num_estrelles == 1 else "negatiu"
logger.info(
f"Classificat en {temps_inferencia:.0f}ms: "
f"'{peticio.text[:50]}...' -> {etiqueta_llegible} ({resultat['score']:.3f})"
)
# Log en background (sense bloquejar la resposta)
background_tasks.add_task(
registrar_estadistica,
endpoint="classify/sentiment",
temps_ms=temps_inferencia,
resultat=etiqueta_llegible
)
return ClassifyResponse(
etiqueta=etiqueta_llegible,
puntuacio=round(resultat["score"], 4),
text_entrada=peticio.text[:100],
model_usat=config.model_classificacio
)
except Exception as e:
logger.error(f"Error en classificació: {e}")
raise HTTPException(
status_code=503,
detail=f"Error al model de classificació: {str(e)}"
)
@router.post(
"/classify/zero-shot",
response_model=ClassifyResponse,
summary="Classificació Zero-Shot",
description="""
Classifica text en categories personalitzades sense entrenament específic.
Util quan les categories no son fixes o canvien freqüentment.
"""
)
async def classificar_zero_shot(
peticio: ClassifyRequest,
categories: list[str] = ["positiu", "negatiu", "neutral", "urgent"]
) -> ClassifyResponse:
"""Endpoint de classificació zero-shot amb categories personalitzades."""
if "zero-shot" not in _models:
logger.info("Carregant model zero-shot...")
_models["zero-shot"] = pipeline(
"zero-shot-classification",
model="facebook/bart-large-mnli",
device=-1
)
model = _models["zero-shot"]
resultat = model(peticio.text, candidate_labels=categories)
return ClassifyResponse(
etiqueta=resultat["labels"][0],
puntuacio=round(resultat["scores"][0], 4),
totes_les_etiquetes=dict(zip(resultat["labels"], resultat["scores"])),
text_entrada=peticio.text[:100],
model_usat="facebook/bart-large-mnli"
)
async def registrar_estadistica(endpoint: str, temps_ms: float, resultat: str):
"""Registra estadistiques d'us en background."""
logger.info(f"ESTADISTICA | endpoint={endpoint} | temps={temps_ms:.0f}ms | resultat={resultat}")
app/routers/generate.py
# app/routers/generate.py
# Pràctica PR5073/02 - Endpoints de generació de text
# Alumne: Joan Garcia
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
import requests
import json
import logging
from typing import AsyncGenerator
from app.models import GenerateRequest, GenerateResponse
from app.config import config
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/v1",
tags=["Generacio de Text"],
responses={
503: {"description": "Model de generacio no disponible"}
}
)
def generar_amb_ollama(prompt: str, sistema: str, max_tokens: int, temperatura: float) -> str:
"""
Genera text usant Ollama (model local).
Es fa una crida HTTP síncrona a l'API d'Ollama.
"""
payload = {
"model": config.ollama_model,
"messages": [
{"role": "system", "content": sistema},
{"role": "user", "content": prompt}
],
"options": {
"num_predict": max_tokens,
"temperature": temperatura
},
"stream": False
}
try:
response = requests.post(
f"{config.ollama_url}/api/chat",
json=payload,
timeout=60
)
response.raise_for_status()
data = response.json()
return data["message"]["content"]
except requests.exceptions.ConnectionError:
raise HTTPException(
status_code=503,
detail=f"No es pot connectar a Ollama a {config.ollama_url}. "
"Comprova que el servei esta en marxa."
)
except requests.exceptions.Timeout:
raise HTTPException(
status_code=504,
detail="Timeout: el model tarda massa a respondre. Redueix max_tokens."
)
@router.post(
"/generate",
response_model=GenerateResponse,
summary="Generació de text amb LLM",
description="""
Genera text en catala usando un LLM local via Ollama.
El prompt del sistema esta configurat per defecte per a respondre en catala.
Model per defecte: llama3.2:3b (local, sense cost d'API)
"""
)
async def generar_text(peticio: GenerateRequest) -> GenerateResponse:
"""Endpoint principal de generació de text."""
# Prompt del sistema per defecte
sistema = peticio.sistema or (
f"Ets un assistent expert creat per {config.alumne_nom_complet}. "
f"Respon sempre en català, de manera clara i precisa. "
f"Ets rigorós amb els fets i honest quan no coneixes la resposta."
)
logger.info(f"Generant text: '{peticio.prompt[:50]}...' (max_tokens={peticio.max_tokens})")
text_generat = generar_amb_ollama(
prompt=peticio.prompt,
sistema=sistema,
max_tokens=peticio.max_tokens,
temperatura=peticio.temperatura
)
return GenerateResponse(
text_generat=text_generat,
model_usat=config.ollama_model,
prompt_original=peticio.prompt[:100]
)
@router.post(
"/generate/stream",
summary="Generació de text en streaming",
description="Igual que /generate però retorna el text progressivament (Server-Sent Events)."
)
async def generar_text_streaming(peticio: GenerateRequest):
"""Endpoint de generació amb streaming (tokens progressius)."""
sistema = peticio.sistema or f"Respon sempre en català. Creat per {config.alumne_nom_complet}."
async def generador_tokens() -> AsyncGenerator[str, None]:
payload = {
"model": config.ollama_model,
"messages": [
{"role": "system", "content": sistema},
{"role": "user", "content": peticio.prompt}
],
"options": {"num_predict": peticio.max_tokens, "temperature": peticio.temperatura},
"stream": True
}
try:
with requests.post(
f"{config.ollama_url}/api/chat",
json=payload,
stream=True,
timeout=120
) as response:
for linia in response.iter_lines():
if linia:
try:
chunk = json.loads(linia)
if "message" in chunk and "content" in chunk["message"]:
token = chunk["message"]["content"]
yield f"data: {json.dumps({'token': token})}\n\n"
except json.JSONDecodeError:
continue
yield f"data: {json.dumps({'finalitzat': True, 'alumne': config.alumne})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return StreamingResponse(
generador_tokens(),
media_type="text/event-stream",
headers={"X-Alumne": config.alumne}
)
Part 4: Punt d'entrada principal
app/main.py
# app/main.py
# Pràctica PR5073/02 - Punt d'entrada FastAPI
# Alumne: Joan Garcia
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
import logging
import time
from datetime import datetime
from app.config import config
from app.models import HealthResponse, ErrorResponse
from app.routers import classify, generate
# Configuracio del logging
logging.basicConfig(
level=getattr(logging, config.log_level.upper()),
format="%(asctime)s | %(name)s | %(levelname)s | %(message)s"
)
logger = logging.getLogger(__name__)
# Rate limiter
limiter = Limiter(key_func=get_remote_address)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifecycle de l'aplicacio: startup i shutdown."""
# STARTUP
logger.info(f"=== Arrancant API Intel·ligencia Artificial - {config.alumne} ===")
logger.info(f"Alumne: {config.alumne_nom_complet}")
logger.info(f"Versio: 1.0.0")
logger.info(f"Ollama URL: {config.ollama_url}")
logger.info(f"Model generacio: {config.ollama_model}")
logger.info(f"Model classificacio: {config.model_classificacio}")
yield
# SHUTDOWN
logger.info(f"=== Tancant API - {config.alumne} ===")
# Crear aplicacio FastAPI
app = FastAPI(
title=f"API Intel·ligencia Artificial - {config.alumne}",
description=f"""
API REST per a inferencia de models d'IA.
Creat per: **{config.alumne_nom_complet}**
Curs: IABD - Programació IA (5073)
Institut: Sa Palomera, Blanes
## Endpoints disponibles
### Classificacio
- `POST /api/v1/classify/sentiment` — Analisi de sentiment multilingüe
- `POST /api/v1/classify/zero-shot` — Classificacio amb categories personalitzades
### Generacio
- `POST /api/v1/generate` — Generacio de text amb LLM local
- `POST /api/v1/generate/stream` — Generacio en streaming (SSE)
### Sistema
- `GET /healthz` — Health check
- `GET /` — Informacio de l'API
""",
version="1.0.0",
contact={
"name": config.alumne_nom_complet,
"email": f"{config.alumne}@sapalomera.cat"
},
lifespan=lifespan
)
# Rate limiting
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
# Middleware per a logging de peticions
@app.middleware("http")
async def logging_middleware(request: Request, call_next):
t_inici = time.time()
response = await call_next(request)
temps_ms = (time.time() - t_inici) * 1000
logger.info(
f"{request.method} {request.url.path} "
f"| Status: {response.status_code} "
f"| Temps: {temps_ms:.0f}ms "
f"| IP: {request.client.host if request.client else 'unknown'}"
)
# Afegir headers personalitzats
response.headers["X-Alumne"] = config.alumne
response.headers["X-Temps-Resposta-Ms"] = str(round(temps_ms))
return response
# Registrar routers
app.include_router(classify.router)
app.include_router(generate.router)
# ============================================================
# ENDPOINTS PRINCIPALS
# ============================================================
@app.get("/", tags=["Sistema"])
async def arrel():
"""Endpoint arrel amb informacio de l'API."""
return {
"api": "API Intel·ligencia Artificial",
"alumne": config.alumne,
"alumne_nom_complet": config.alumne_nom_complet,
"versio": "1.0.0",
"documentacio": "/docs",
"healthcheck": "/healthz",
"endpoints_disponibles": [
"/api/v1/classify/sentiment",
"/api/v1/classify/zero-shot",
"/api/v1/generate",
"/api/v1/generate/stream"
]
}
@app.get(
"/healthz",
response_model=HealthResponse,
tags=["Sistema"],
summary="Health Check",
description="Comprova l'estat de salut de l'API i dels models carregats."
)
async def health_check():
"""Endpoint de health check per a Docker i load balancers."""
models_disponibles = []
# Comprovar Ollama
import requests
try:
resp = requests.get(f"{config.ollama_url}/api/tags", timeout=5)
if resp.status_code == 200:
models_disponibles.append(f"ollama:{config.ollama_model}")
except Exception:
pass
return HealthResponse(
estat="ok",
models_carregats=models_disponibles
)
Part 5: Dockerfile i docker-compose
Dockerfile
# Dockerfile
# Pràctica PR5073/02 - API IA en Producció
# Alumne: Joan Garcia
# --- FASE 1: Builder ---
# Instal·lem les dependències en una imatge temporal
FROM python:3.11-slim AS builder
WORKDIR /app
# Copiar i instal·lar dependències (en una capa separada per cache)
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# --- FASE 2: Runtime ---
# Imatge final lleugera amb només el necessari
FROM python:3.11-slim AS runtime
WORKDIR /app
# Metadades
LABEL maintainer="joan_garcia@sapalomera.cat"
LABEL alumne="joan_garcia"
LABEL practica="PR5073/02"
LABEL version="1.0.0"
# Copiar dependències instal·lades del builder
COPY --from=builder /root/.local /root/.local
# Copiar codi de l'aplicació
COPY app/ ./app/
# Variables d'entorn per defecte
ENV ALUMNE=joan_garcia
ENV ALUMNE_NOM_COMPLET="Joan Garcia"
ENV LOG_LEVEL=info
ENV OLLAMA_URL=http://ollama:11434
ENV OLLAMA_MODEL=llama3.2:3b
ENV PATH=/root/.local/bin:$PATH
# Exposar port
EXPOSE 8000
# Health check intern
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/healthz', timeout=5)" || exit 1
# Usuari no-root per seguretat
RUN adduser --disabled-password --gecos '' apiuser
USER apiuser
# Punt d'entrada
CMD ["uvicorn", "app.main:app", \
"--host", "0.0.0.0", \
"--port", "8000", \
"--workers", "1", \
"--log-level", "info"]
docker-compose.yml
# docker-compose.yml
# Pràctica PR5073/02 - API IA en Producció
# Alumne: Joan Garcia
services:
# Servei Ollama (model LLM local)
ollama-joan-garcia:
image: ollama/ollama:latest
container_name: ollama-joan-garcia
volumes:
- ollama-joan-garcia-data:/root/.ollama
ports:
- "11434:11434"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
restart: unless-stopped
# Descàrrega del model Ollama (s'executa una vegada)
ollama-pull-joan-garcia:
image: ollama/ollama:latest
container_name: ollama-pull-joan-garcia
volumes:
- ollama-joan-garcia-data:/root/.ollama
depends_on:
ollama-joan-garcia:
condition: service_healthy
entrypoint: >
sh -c "ollama pull llama3.2:3b && echo 'Model descarregat correctament'"
restart: "no"
# API FastAPI principal
api-ia-joan-garcia:
build:
context: .
dockerfile: Dockerfile
target: runtime
container_name: api-ia-joan-garcia
ports:
- "8000:8000"
environment:
- ALUMNE=joan_garcia
- ALUMNE_NOM_COMPLET=Joan Garcia
- LOG_LEVEL=info
- OLLAMA_URL=http://ollama-joan-garcia:11434
- OLLAMA_MODEL=llama3.2:3b
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
volumes:
- model-cache-joan-garcia:/app/model_cache
depends_on:
ollama-joan-garcia:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
restart: unless-stopped
labels:
- "alumne=joan_garcia"
- "practica=PR5073/02"
volumes:
ollama-joan-garcia-data:
name: ollama-joan-garcia-data
model-cache-joan-garcia:
name: model-cache-joan-garcia
Part 6: Tests automàtics
tests/test_api.py
# tests/test_api.py
# Pràctica PR5073/02 - Tests automatics de l'API
# Alumne: Joan Garcia
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, MagicMock
from app.main import app
# Client de test (no fa servir el servidor real)
client = TestClient(app)
ALUMNE = "joan_garcia"
# ============================================================
# TESTS DE SISTEMA
# ============================================================
class TestSistema:
"""Tests dels endpoints de sistema."""
def test_arrel_endpoint(self):
"""L'endpoint arrel ha de retornar info de l'API."""
response = client.get("/")
assert response.status_code == 200
data = response.json()
assert data["alumne"] == ALUMNE
assert "documentacio" in data
assert "/api/v1/classify/sentiment" in data["endpoints_disponibles"]
def test_health_check_retorna_ok(self):
"""El health check ha de retornar estat 'ok'."""
response = client.get("/healthz")
assert response.status_code == 200
data = response.json()
assert data["estat"] == "ok"
assert data["alumne"] == ALUMNE
assert "versio" in data
assert "timestamp" in data
def test_header_alumne_present(self):
"""Totes les respostes han d'incloure el header X-Alumne."""
response = client.get("/healthz")
assert "x-alumne" in response.headers
assert response.headers["x-alumne"] == ALUMNE
def test_documentacio_swagger_disponible(self):
"""La documentació Swagger ha d'estar disponible."""
response = client.get("/docs")
assert response.status_code == 200
def test_openapi_json_disponible(self):
"""L'OpenAPI JSON ha d'estar disponible."""
response = client.get("/openapi.json")
assert response.status_code == 200
data = response.json()
assert ALUMNE in data["info"]["title"]
# ============================================================
# TESTS DE CLASSIFICACIO
# ============================================================
class TestClassificacio:
"""Tests dels endpoints de classificació."""
@patch("app.routers.classify.obtenir_model_sentiment")
def test_classificar_text_positiu(self, mock_model):
"""Ha de classificar correctament text positiu."""
# Mock del model de Hugging Face
mock_model.return_value = MagicMock(
return_value=[{"label": "5 stars", "score": 0.92}]
)
response = client.post(
"/api/v1/classify/sentiment",
json={"text": "M'encanta aquest producte, es fantàstic!"}
)
assert response.status_code == 200
data = response.json()
assert data["etiqueta"] == "molt_positiu"
assert data["puntuacio"] > 0.9
assert data["alumne"] == ALUMNE
@patch("app.routers.classify.obtenir_model_sentiment")
def test_classificar_text_negatiu(self, mock_model):
"""Ha de classificar correctament text negatiu."""
mock_model.return_value = MagicMock(
return_value=[{"label": "1 stars", "score": 0.88}]
)
response = client.post(
"/api/v1/classify/sentiment",
json={"text": "Terrible, no funciona per res."}
)
assert response.status_code == 200
data = response.json()
assert data["etiqueta"] == "molt_negatiu"
def test_classificar_text_buit_retorna_422(self):
"""Text buit ha de retornar error 422."""
response = client.post(
"/api/v1/classify/sentiment",
json={"text": ""}
)
assert response.status_code == 422
def test_classificar_text_massa_llarg_retorna_422(self):
"""Text de més de 5000 caracters ha de retornar error 422."""
text_llarg = "a" * 5001
response = client.post(
"/api/v1/classify/sentiment",
json={"text": text_llarg}
)
assert response.status_code == 422
def test_classificar_sense_body_retorna_422(self):
"""Petició sense body ha de retornar error 422."""
response = client.post("/api/v1/classify/sentiment")
assert response.status_code == 422
# ============================================================
# TESTS DE GENERACIO
# ============================================================
class TestGeneracio:
"""Tests dels endpoints de generació."""
@patch("app.routers.generate.requests.post")
def test_generar_text_basic(self, mock_post):
"""Ha de generar text correctament."""
mock_post.return_value = MagicMock(
status_code=200,
json=lambda: {
"message": {"content": "La IA es la simulació de processos intel·ligents per ordinadors."}
}
)
response = client.post(
"/api/v1/generate",
json={"prompt": "Explica que es la intel·ligencia artificial"}
)
assert response.status_code == 200
data = response.json()
assert len(data["text_generat"]) > 10
assert data["alumne"] == ALUMNE
assert "prompt_original" in data
def test_generar_prompt_massa_curt_retorna_422(self):
"""Prompt de menys de 5 caracters ha de fallar."""
response = client.post(
"/api/v1/generate",
json={"prompt": "Hola"}
)
assert response.status_code == 422
def test_generar_temperatura_fora_de_rang_retorna_422(self):
"""Temperatura fora del rang [0, 2] ha de fallar."""
response = client.post(
"/api/v1/generate",
json={"prompt": "Pregunta de prova", "temperatura": 5.0}
)
assert response.status_code == 422
Part 7: Execució i verificació
Construir i arrancar
# Build complet i arrancar tots els serveis
cd api-ia-joan-garcia
docker compose up --build -d
# Verificar que tots els serveis estan en marxa
docker compose ps
# Veure els logs en temps real
docker compose logs -f api-ia-joan-garcia
Provar l'API manualment
# 1. Verificar health check
curl http://localhost:8000/healthz | python -m json.tool
# 2. Classificar sentiment (text en catala)
curl -X POST http://localhost:8000/api/v1/classify/sentiment \
-H "Content-Type: application/json" \
-d '{"text": "Aquest curs m'\''ha semblat molt interessant i he après molt!"}' \
| python -m json.tool
# 3. Generar text
curl -X POST http://localhost:8000/api/v1/generate \
-H "Content-Type: application/json" \
-d '{"prompt": "Explica en catala els avantatges de FastAPI", "max_tokens": 200}' \
| python -m json.tool
# 4. Documentació Swagger
open http://localhost:8000/docs
Executar tests
# Executar tots els tests
docker exec api-ia-joan-garcia pytest tests/ -v --tb=short
# Executar amb cobertura de codi
docker exec api-ia-joan-garcia pytest tests/ -v --cov=app --cov-report=html
# Veure el report de cobertura
# (el report es genera a htmlcov/index.html dins del contenidor)
Aturar els serveis
# Aturar i eliminar contenidors (manté els volums)
docker compose down
# Aturar, eliminar contenidors I volums (elimina els models descarregats!)
docker compose down -v
Preguntes de reflexió
Preguntes de reflexió
Responeu aquestes preguntes al document de memòria:
-
Pydantic vs dict: Per quin motiu usar schemas Pydantic en lloc de diccionaris simples per a validar les dades d'entrada i sortida de l'API?
-
Multi-stage Dockerfile: El Dockerfile usa dues fases (builder i runtime). Per quin motiu es una bona pràctica? Quina es la diferència de mida entre una imatge amb una sola fase i la imatge multi-stage?
-
Tests amb mocks: Per quin motiu els tests usen
@patchper a simular els models d'IA en lloc de carregar els models reals? -
Health checks: Per quin motiu es important tenir un endpoint
/healthz? Qui el crida i quan? -
Async vs sync: L'API usa
async defper als endpoints. En quins casos l'ús d'async millora el rendiment i en quins casos no?
Lliurament
Que heu de lliurar
- Codi font complet:
- Tots els fitxers
.pydeapp/itests/ -
Dockerfile,docker-compose.yml,requirements.txt -
Captures de pantalla (5 obligatòries):
docker compose psmostrant tots els serveis en marxa- Swagger UI (
/docs) amb els endpoints documentats - Resultat d'una crida
curla/api/v1/classify/sentiment - Resultat d'una crida
curla/api/v1/generate -
Resultat del
pytestamb tots els tests passats -
Memòria tècnica (PDF, 3-5 pàgines):
- Descripció de l'arquitectura (diagrama inclòs)
- Decisions de disseny: per quin motiu aquests endpoints, aquests models, aquesta estructura
- Resultats dels tests (nombre, cobertura)
- Respostes a les preguntes de reflexió
-
Millores futures proposades
-
Rúbrica emplenada (vegeu
rubriques/rubrica_api_ia.md)
Format de lliurament
- Comprimir tot en un ZIP:
PR5073/02_Joan_Garcia.zip - Lliurar a la plataforma Moodle del curs
Rúbrica de correcció: PR5073/02 - Rúbrica