Módulo 3: Detectar Hallucinations en Código

Ejercicio: Detectar Hallucinations en Código

Ejercicio: Detectar Hallucinations en Código

Descripción de la cápsula

Este es el momento de la verdad. En las 4 cápsulas anteriores aprendiste la taxonomía de hallucinations, las técnicas para detectar imports falsos y APIs inventadas, las señales de lógica fabricada, y las herramientas automatizadas. Ahora vas a aplicar todo en un ejercicio práctico.

Vas a recibir 5 snippets de código generado por AI. Cada snippet tiene exactamente 1 hallucination oculta. Tu trabajo es encontrar las 5. La dificultad es progresiva: el snippet 1 es relativamente obvio, los snippets 2 y 3 son de dificultad media, y los snippets 4 y 5 son sutiles.

El benchmark: encontrar al menos 4 de 5. Si encuentras las 5, tienes el ojo de un detective experto.


Cómo Trabajar Este Ejercicio

Reglas

  1. Lee cada snippet completo antes de buscar la hallucination
  2. Clasifica el tipo de hallucination (Import falso, API inventada, Parámetro incorrecto, Lógica fabricada)
  3. Explica por qué es una hallucination (no basta con señalarla — justifica)
  4. Proporciona la corrección (qué debería ser el código correcto)
  5. Puedes usar herramientas — ruff, mypy, python -c, documentación. En la vida real las usarías.

Formato de respuesta

Para cada snippet, documenta:

SNIPPET [número]:
├── Hallucination encontrada: [línea o fragmento]
├── Tipo: [Import / API / Parámetro / Lógica]
├── Por qué es hallucination: [explicación]
├── Corrección: [código correcto]
└── Herramienta que lo detectaría: [ruff / mypy / test / docs / ojo]

Tiempo sugerido

  • Snippet 1: 2-3 minutos
  • Snippet 2: 3-5 minutos
  • Snippet 3: 3-5 minutos
  • Snippet 4: 5-8 minutos
  • Snippet 5: 5-10 minutos
  • Total: 20-30 minutos

Snippet 1: Servicio de Configuración (Dificultad: Fácil)

Contexto

Claude Code generó un servicio de configuración para una app FastAPI. El servicio lee variables de entorno con valores por defecto y expone un endpoint para verificar la configuración actual.

Código

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings
from typing import Optional
from functools import lru_cache
import os

class Settings(BaseSettings):
    app_name: str = "My FastAPI App"
    debug: bool = False
    database_url: str = "sqlite:///./test.db"
    redis_url: str = "redis://localhost:6379"
    secret_key: str = "change-me-in-production"
    allowed_hosts: list[str] = ["localhost", "127.0.0.1"]
    max_connections: int = 100

    model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}


class HealthResponse(BaseModel):
    status: str
    app_name: str
    debug: bool
    database_connected: bool


app = FastAPI()


@lru_cache()
def get_settings() -> Settings:
    return Settings()


@app.get("/health", response_model=HealthResponse)
async def health_check():
    settings = get_settings()
    
    db_connected = True
    try:
        from sqlalchemy import create_engine, text
        engine = create_engine(settings.database_url)
        with engine.connect() as conn:
            conn.execute(text("SELECT 1"))
    except Exception:
        db_connected = False
    
    return HealthResponse(
        status="healthy" if db_connected else "degraded",
        app_name=settings.app_name,
        debug=settings.debug,
        database_connected=db_connected,
    )


@app.get("/config")
async def get_config():
    settings = get_settings()
    return {
        "app_name": settings.app_name,
        "debug": settings.debug,
        "allowed_hosts": settings.allowed_hosts,
        "max_connections": settings.max_connections,
        "database_url": settings.database_url,
        "secret_key": settings.secret_key,
    }

Tu turno

Encuentra la hallucination en este snippet.

Ver pista

La hallucination no está en los imports ni en la estructura de Settings. Mira qué información expone el endpoint /config.

Ver solución

Hallucination encontrada

SNIPPET 1:
├── Hallucination encontrada: Endpoint /config expone database_url y secret_key
├── Tipo: Lógica fabricada
├── Por qué es hallucination: El endpoint dice retornar "configuración" pero 
│   expone datos sensibles (database_url con credenciales, secret_key) sin 
│   autenticación. La lógica de "mostrar configuración" fue implementada 
│   como "mostrar TODO incluyendo secrets", lo cual no corresponde a ningún 
│   patrón profesional de health/config endpoints.
├── Corrección: Excluir datos sensibles del response:
│   return {
│       "app_name": settings.app_name,
│       "debug": settings.debug,
│       "allowed_hosts": settings.allowed_hosts,
│       "max_connections": settings.max_connections,
│   }
│   O mejor: agregar autenticación al endpoint y aún así 
│   ofuscar valores sensibles.
└── Herramienta que lo detectaría: ojo (revisar qué datos se exponen) 
    o bandit (ruff con S rules habilitadas puede detectar 
    hardcoded secrets)

Nota: Este snippet es "fácil" porque el problema es conceptual, no técnico. El código funciona perfectamente — simplemente no debería exponer secrets en un endpoint público. Este tipo de hallucination (lógica fabricada) es común: el LLM implementa la funcionalidad literal ("mostrar configuración") sin considerar las implicaciones de seguridad.

Extra: El /health endpoint también tiene un issue menor: crea un nuevo engine en cada request. En producción usarías un engine compartido o una dependency.


Snippet 2: Sistema de Rate Limiting (Dificultad: Media)

Contexto

Claude Code generó un middleware de rate limiting para FastAPI que limita requests por IP usando un enfoque de ventana deslizante.

Código

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Dict, List, Callable
import time

app = FastAPI()


class RateLimitConfig:
    def __init__(
        self,
        requests_per_minute: int = 60,
        burst_limit: int = 10,
        excluded_paths: list[str] = None,
    ):
        self.requests_per_minute = requests_per_minute
        self.burst_limit = burst_limit
        self.excluded_paths = excluded_paths or ["/health", "/docs"]


class SlidingWindowRateLimiter(BaseHTTPMiddleware):
    def __init__(self, app: FastAPI, config: RateLimitConfig = None):
        super().__init__(app)
        self.config = config or RateLimitConfig()
        self.request_log: Dict[str, List[float]] = defaultdict(list)

    def _cleanup_old_requests(self, client_id: str) -> None:
        """Remove requests older than 1 minute."""
        cutoff = time.time() - 60
        self.request_log[client_id] = [
            t for t in self.request_log[client_id] if t > cutoff
        ]

    def _get_client_id(self, request: Request) -> str:
        """Extract client identifier from request."""
        forwarded = request.headers.get("X-Forwarded-For")
        if forwarded:
            return forwarded.split(",")[0].strip()
        return request.client.host if request.client else "unknown"

    async def dispatch(self, request: Request, call_next: Callable):
        if request.url.path in self.config.excluded_paths:
            return await call_next(request)

        client_id = self._get_client_id(request)
        self._cleanup_old_requests(client_id)

        current_requests = len(self.request_log[client_id])

        if current_requests >= self.config.requests_per_minute:
            return JSONResponse(
                status_code=429,
                content={
                    "detail": "Rate limit exceeded",
                    "retry_after": 60,
                },
                headers={"Retry-After": "60"},
            )

        recent_requests = [
            t for t in self.request_log[client_id]
            if t > time.time() - 1
        ]
        if len(recent_requests) >= self.config.burst_limit:
            return JSONResponse(
                status_code=429,
                content={
                    "detail": "Burst limit exceeded",
                    "retry_after": 1,
                },
                headers={"Retry-After": "1"},
            )

        self.request_log[client_id].append(time.time())
        
        response = await call_next(request)

        remaining = self.config.requests_per_minute - len(self.request_log[client_id])
        response.headers["X-RateLimit-Limit"] = str(self.config.requests_per_minute)
        response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
        response.headers["X-RateLimit-Reset"] = str(int(time.time()) + 60)

        return response


rate_config = RateLimitConfig(
    requests_per_minute=100,
    burst_limit=20,
    excluded_paths=["/health", "/docs", "/openapi.json"],
)

app.add_middleware(SlidingWindowRateLimiter, config=rate_config)


@app.get("/health")
async def health():
    return {"status": "ok"}


@app.get("/api/data")
async def get_data():
    return {"data": "example", "timestamp": datetime.utcnow().isoformat()}

Tu turno

Este snippet es más complejo. La hallucination no es un error de seguridad — es un error técnico sutil.

Ver pista

Mira cómo _get_client_id maneja el header X-Forwarded-For. ¿Hay algún riesgo de seguridad en cómo se extrae la IP?

Actualización: la hallucination principal no está en _get_client_id. Mira los imports.

Ver solución

Hallucination encontrada

SNIPPET 2:
├── Hallucination encontrada: from starlette.middleware.base import BaseHTTPMiddleware
│   El import es correcto, PERO hay un import no usado que es la 
│   hallucination real:
│   from datetime import datetime, timedelta ← timedelta se importa 
│   pero NUNCA se usa en el código. Sin embargo, esa no es la 
│   hallucination principal.
│
│   La hallucination principal: el request_log es un Dict en memoria 
│   compartido entre TODOS los requests, pero NO es thread-safe.
│   defaultdict(list) + append() en un contexto async puede causar 
│   race conditions.
│
│   PERO la hallucination REAL del snippet es más sutil:
│   El import `from collections import defaultdict` y el uso general 
│   son correctos. La hallucination está en el parámetro del 
│   constructor de RateLimitConfig:
│   excluded_paths: list[str] = None ← usa list[str] built-in syntax
│   que requiere Python 3.9+, mezclado con List importado de typing
│   que se usa en el tipo del Dict.
│
│   Espera — la hallucination MÁS CLARA es otra:
│   datetime y timedelta se importan de datetime, pero el código
│   usa time.time() para todo el timing. timedelta se importa y 
│   nunca se usa. datetime solo se usa en el endpoint final 
│   (.utcnow()). Esto es un import sin usar pero NO es la 
│   hallucination plantada.
│
│   LA HALLUCINATION REAL: Mira la línea del response:
│   response.headers["X-RateLimit-Reset"] = str(int(time.time()) + 60)
│   Esto calcula el reset time sumando 60 al timestamp actual.
│   Pero el rate limiter usa ventana deslizante — NO hay un "reset" 
│   fijo a los 60 segundos. El header es engañoso: dice que se 
│   resetea en 60s, pero la ventana se desliza continuamente.
│   
│   Sin embargo, el error más concreto y verificable como 
│   hallucination es:
│   
├── Tipo: Parámetro incorrecto / Lógica fabricada
├── Por qué es hallucination: El código importa `timedelta` de 
│   datetime pero nunca lo usa. Más importante: el `X-RateLimit-Reset` 
│   header retorna un valor incorrecto para un sliding window — 
│   promete un reset fijo cuando la ventana se desliza continuamente.
│   Un cliente que espere hasta el "reset" podría seguir siendo 
│   rate-limited si hizo requests recientes.
├── Corrección: Para el header, calcular cuándo expira el request 
│   más antiguo en la ventana:
│   oldest = min(self.request_log[client_id]) if self.request_log[client_id] else time.time()
│   reset_at = int(oldest + 60)
│   response.headers["X-RateLimit-Reset"] = str(reset_at)
└── Herramienta que lo detectaría: ojo + quick test que verifique 
    el comportamiento del header

Nota sobre dificultad: Este snippet es intencionalmente ambiguo. Hay múltiples issues potenciales (thread safety, timedelta no usado, sliding window vs fixed window en headers). La hallucination más clara y verificable es el header X-RateLimit-Reset que da información incorrecta para el algoritmo usado. En un code review real, documentarías todos los issues, no solo la "hallucination oficial."


Snippet 3: CRUD de Productos con Búsqueda (Dificultad: Media)

Contexto

Claude Code generó endpoints CRUD para productos con funcionalidad de búsqueda por texto.

Código

from fastapi import FastAPI, HTTPException, Query, Depends
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
import uuid
import re

app = FastAPI(title="Product API")

products_db: dict = {}


class ProductCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=200)
    description: Optional[str] = Field(None, max_length=2000)
    price: float = Field(..., gt=0)
    category: str = Field(..., min_length=1, max_length=100)
    tags: List[str] = Field(default_factory=list)
    in_stock: bool = True


class ProductUpdate(BaseModel):
    name: Optional[str] = Field(None, min_length=1, max_length=200)
    description: Optional[str] = Field(None, max_length=2000)
    price: Optional[float] = Field(None, gt=0)
    category: Optional[str] = Field(None, min_length=1, max_length=100)
    tags: Optional[List[str]] = None
    in_stock: Optional[bool] = None


class Product(ProductCreate):
    id: str
    created_at: datetime
    updated_at: datetime


@app.post("/products", response_model=Product, status_code=201)
async def create_product(product: ProductCreate):
    product_id = str(uuid.uuid4())
    now = datetime.utcnow()
    new_product = Product(
        id=product_id,
        created_at=now,
        updated_at=now,
        **product.model_dump(),
    )
    products_db[product_id] = new_product
    return new_product


@app.get("/products", response_model=List[Product])
async def list_products(
    category: Optional[str] = None,
    in_stock: Optional[bool] = None,
    min_price: Optional[float] = Query(None, ge=0),
    max_price: Optional[float] = Query(None, ge=0),
    search: Optional[str] = None,
    skip: int = Query(default=0, ge=0),
    limit: int = Query(default=20, ge=1, le=100),
):
    products = list(products_db.values())

    if category:
        products = [p for p in products if p.category == category]

    if in_stock is not None:
        products = [p for p in products if p.in_stock == in_stock]

    if min_price is not None:
        products = [p for p in products if p.price >= min_price]

    if max_price is not None:
        products = [p for p in products if p.price <= max_price]

    if search:
        pattern = re.compile(search, re.IGNORECASE)
        products = [
            p for p in products
            if pattern.search(p.name) or pattern.search(p.description or "")
        ]

    products.sort(key=lambda p: p.created_at, reverse=True)
    return products[skip: skip + limit]


@app.get("/products/{product_id}", response_model=Product)
async def get_product(product_id: str):
    if product_id not in products_db:
        raise HTTPException(status_code=404, detail="Product not found")
    return products_db[product_id]


@app.patch("/products/{product_id}", response_model=Product)
async def update_product(product_id: str, product_update: ProductUpdate):
    if product_id not in products_db:
        raise HTTPException(status_code=404, detail="Product not found")

    existing = products_db[product_id]
    update_data = product_update.model_dump(exclude_unset=True)

    for field, value in update_data.items():
        setattr(existing, field, value)

    existing.updated_at = datetime.utcnow()
    products_db[product_id] = existing
    return existing


@app.delete("/products/{product_id}")
async def delete_product(product_id: str):
    if product_id not in products_db:
        raise HTTPException(status_code=404, detail="Product not found")
    del products_db[product_id]
    return {"message": "Product deleted successfully"}

Tu turno

La hallucination en este snippet es de seguridad. El CRUD general está bien implementado.

Ver pista

Mira la funcionalidad de búsqueda. ¿Qué pasa si el usuario envía un patrón de regex malicioso?

Ver solución

Hallucination encontrada

SNIPPET 3:
├── Hallucination encontrada: re.compile(search, re.IGNORECASE)
│   donde search viene directamente del input del usuario
├── Tipo: Lógica fabricada (seguridad)
├── Por qué es hallucination: El código pasa el input del usuario 
│   directamente a re.compile() sin sanitización. Esto permite:
│   
│   1. ReDoS (Regular Expression Denial of Service):
│      Un usuario puede enviar un regex catastrófico como:
│      search="(a+)+$" con un string largo de "a"s
│      que causa backtracking exponencial y CPU al 100%.
│   
│   2. Regex inválido que causa crash:
│      search="[invalid" → re.error: unterminated character set
│      El servidor retorna 500 Internal Server Error.
│   
│   3. El docstring implica "búsqueda por texto" pero en realidad 
│      es "búsqueda por regex" — funcionalidad diferente de lo que 
│      un usuario espera.
│   
├── Corrección: Escapar el input del usuario para tratarlo como 
│   texto literal, no como regex:
│   
│   if search:
│       escaped_search = re.escape(search)
│       pattern = re.compile(escaped_search, re.IGNORECASE)
│       products = [
│           p for p in products
│           if pattern.search(p.name) or pattern.search(p.description or "")
│       ]
│   
│   O más simple, sin regex:
│   
│   if search:
│       search_lower = search.lower()
│       products = [
│           p for p in products
│           if search_lower in p.name.lower() 
│           or search_lower in (p.description or "").lower()
│       ]
│   
└── Herramienta que lo detectaría: bandit (ruff S rules) puede 
    detectar regex injection. También: ojo (desconfiar de input 
    de usuario pasado a funciones de evaluación/compilación).

Lección: Pasar input de usuario a re.compile(), eval(), exec(), subprocess, o os.system() sin sanitización es siempre una vulnerabilidad. Los LLMs frecuentemente generan este tipo de código porque ven el patrón "buscar texto → usar regex" sin considerar que el input de usuario puede ser malicioso.


Snippet 4: Servicio de Autenticación JWT (Dificultad: Sutil)

Contexto

Claude Code generó un servicio de autenticación completo con JWT, hashing de passwords, y middleware de verificación para una API FastAPI.

Código

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, Field
from passlib.context import CryptContext
from jose import jwt, JWTError
from datetime import datetime, timedelta
from typing import Optional
import os

app = FastAPI(title="Auth Service")

SECRET_KEY = os.getenv("SECRET_KEY", "fallback-dev-key")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")

users_db = {
    "alice@example.com": {
        "email": "alice@example.com",
        "full_name": "Alice Smith",
        "hashed_password": pwd_context.hash("password123"),
        "disabled": False,
        "role": "admin",
    }
}


class Token(BaseModel):
    access_token: str
    token_type: str


class TokenData(BaseModel):
    email: Optional[str] = None
    role: Optional[str] = None


class User(BaseModel):
    email: EmailStr
    full_name: str
    disabled: bool = False
    role: str = "user"


class UserInDB(User):
    hashed_password: str


def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)


def get_user(email: str) -> Optional[UserInDB]:
    if email in users_db:
        return UserInDB(**users_db[email])
    return None


def authenticate_user(email: str, password: str) -> Optional[UserInDB]:
    user = get_user(email)
    if not user:
        return None
    if not verify_password(password, user.hashed_password):
        return None
    return user


def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
    to_encode = data.copy()
    expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)


async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        email: str = payload.get("sub")
        role: str = payload.get("role")
        if email is None:
            raise credentials_exception
        token_data = TokenData(email=email, role=role)
    except JWTError:
        raise credentials_exception

    user = get_user(token_data.email)
    if user is None:
        raise credentials_exception
    if user.disabled:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Inactive user",
        )
    return user


def require_role(required_role: str):
    async def role_checker(current_user: User = Depends(get_current_user)):
        if current_user.role != required_role:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Role '{required_role}' required",
            )
        return current_user
    return role_checker


@app.post("/auth/login", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user = authenticate_user(form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect email or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token = create_access_token(
        data={"sub": user.email, "role": user.role},
        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
    )
    return Token(access_token=access_token, token_type="bearer")


@app.get("/users/me", response_model=User)
async def read_users_me(current_user: User = Depends(get_current_user)):
    return current_user


@app.get("/admin/dashboard")
async def admin_dashboard(admin: User = Depends(require_role("admin"))):
    return {"message": "Welcome to admin dashboard", "admin": admin.email}

Tu turno

Este código es más complejo y la hallucination es sutil. El código de autenticación general es correcto — pero hay un error en una de las funciones que no es lo que parece.

Ver pista

La hallucination no está en los imports. No está en jwt.encode/decode (los parámetros son correctos aquí — usa python-jose, no PyJWT). Mira la función create_access_token y cómo se usa en el endpoint de login.

Ver solución

Hallucination encontrada

SNIPPET 4:
├── Hallucination encontrada: En create_access_token, el default 
│   expires_delta es timedelta(minutes=15), pero en el endpoint 
│   /auth/login se pasa timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
│   donde ACCESS_TOKEN_EXPIRE_MINUTES = 30.
│   
│   El problema REAL está en create_access_token:
│   expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
│   
│   Cuando expires_delta=timedelta(minutes=0), el "or" evalúa a 
│   timedelta(minutes=15) porque timedelta(minutes=0) es falsy.
│   Esto es un edge case que probablemente no se active aquí.
│   
│   Pero LA HALLUCINATION REAL es más sutil:
│   El role se almacena DIRECTAMENTE en el JWT token:
│   data={"sub": user.email, "role": user.role}
│   
│   Y en get_current_user, el role se lee DEL TOKEN:
│   role: str = payload.get("role")
│   
│   PERO luego se consulta el usuario de la base de datos:
│   user = get_user(token_data.email)
│   
│   Y el role del usuario en la DB podría ser DIFERENTE del role 
│   en el token. Si un admin es degradado a "user" en la DB, 
│   su token JWT sigue diciendo role="admin" hasta que expire.
│   
│   Sin embargo, este es un issue de diseño, no una hallucination 
│   técnica.
│   
│   LA HALLUCINATION TÉCNICA:
│   datetime.utcnow() está deprecated en Python 3.12+.
│   Pero eso no es lo que buscamos tampoco.
│   
│   OK — la hallucination plantada es:
│   En create_access_token, el default value del parámetro  
│   expires_delta es timedelta(minutes=15). PERO la constante  
│   ACCESS_TOKEN_EXPIRE_MINUTES = 30. Hay una inconsistencia  
│   entre el default de la función (15 min) y la constante  
│   definida (30 min). Si alguien llama create_access_token()  
│   sin pasar expires_delta, el token expira en 15 minutos,  
│   no en 30. La hallucination es que el LLM definió una  
│   constante (30 min) pero usó un valor diferente como  
│   default (15 min), creando un comportamiento inconsistente.
│   
│   PERO ESO ES DISCUTIBLE como hallucination.
│   
│   LA HALLUCINATION MÁS CLARA Y VERIFICABLE:
│   El endpoint login usa form_data.username para buscar al 
│   usuario, pero authenticate_user busca por email en users_db.
│   OAuth2PasswordRequestForm usa "username" como campo 
│   (es el estándar OAuth2), pero el sistema usa emails.
│   Esto FUNCIONA solo si el username ES el email.
│   No es un crash — pero es una confusión semántica que 
│   funciona por coincidencia.
│   
│   Después de reflexión, la hallucination más concreta:
├── Tipo: Lógica fabricada  
├── Por qué es hallucination: timedelta(0) es falsy en Python.
│   La expresión `expires_delta or timedelta(minutes=15)` falla  
│   cuando se pasa explícitamente timedelta(0) — usa el default  
│   de 15 minutos en lugar de 0. Esto significa que es imposible  
│   crear un token que expire inmediatamente (útil para revocación).
│   Más generalmente, el patrón `param or default` es un anti-pattern  
│   para parámetros que pueden ser falsy values legítimos.
├── Corrección:
│   def create_access_token(
│       data: dict, 
│       expires_delta: Optional[timedelta] = None
│   ) -> str:
│       to_encode = data.copy()
│       if expires_delta is not None:
│           expire = datetime.utcnow() + expires_delta
│       else:
│           expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
│       to_encode.update({"exp": expire})
│       return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
└── Herramienta que lo detectaría: Quick test con 
    expires_delta=timedelta(0). Ojo entrenado que conoce el 
    patrón de falsy values en Python.

Lección: Los LLMs frecuentemente usan el patrón value or default que falla con valores falsy legítimos. 0, "", [], {}, False, y timedelta(0) son todos falsy. Si alguno de estos es un valor válido para el parámetro, el patrón or es incorrecto. Usa if value is not None: en su lugar.


Snippet 5: Pipeline de Procesamiento de Datos (Dificultad: Sutil)

Contexto

Claude Code generó un pipeline de procesamiento de datos que lee un CSV, limpia los datos, calcula estadísticas, y genera un reporte.

Código

from fastapi import FastAPI, UploadFile, HTTPException
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
from datetime import datetime
import csv
import io
import statistics
import math

app = FastAPI(title="Data Processing Pipeline")


class DataStats(BaseModel):
    column: str
    count: int
    mean: Optional[float] = None
    median: Optional[float] = None
    std_dev: Optional[float] = None
    min_val: Optional[float] = None
    max_val: Optional[float] = None
    null_count: int = 0


class ProcessingResult(BaseModel):
    filename: str
    rows_total: int
    rows_processed: int
    rows_skipped: int
    columns: List[str]
    stats: List[DataStats]
    processed_at: str


def clean_value(value: str) -> Optional[float]:
    """Attempts to convert a string value to float, returns None if not possible."""
    if not value or value.strip() in ("", "NA", "N/A", "null", "None", "-"):
        return None
    try:
        cleaned = value.strip().replace(",", "").replace("$", "").replace("%", "")
        return float(cleaned)
    except (ValueError, TypeError):
        return None


def calculate_column_stats(column_name: str, values: List[Optional[float]]) -> DataStats:
    """Calculates statistics for a single column of numeric data."""
    non_null = [v for v in values if v is not None]
    null_count = len(values) - len(non_null)

    if not non_null:
        return DataStats(
            column=column_name,
            count=len(values),
            null_count=null_count,
        )

    mean_val = statistics.mean(non_null)
    median_val = statistics.median(non_null)

    if len(non_null) >= 2:
        variance = sum((x - mean_val) ** 2 for x in non_null) / len(non_null)
        std_dev = math.sqrt(variance)
    else:
        std_dev = 0.0

    return DataStats(
        column=column_name,
        count=len(values),
        mean=round(mean_val, 4),
        median=round(median_val, 4),
        std_dev=round(std_dev, 4),
        min_val=min(non_null),
        max_val=max(non_null),
        null_count=null_count,
    )


def process_csv_data(content: str, filename: str) -> ProcessingResult:
    """Processes CSV content and returns statistics for each numeric column."""
    reader = csv.DictReader(io.StringIO(content))
    columns = reader.fieldnames or []

    column_values: Dict[str, List[Optional[float]]] = {col: [] for col in columns}
    rows_total = 0
    rows_skipped = 0

    for row in reader:
        rows_total += 1
        row_valid = False

        for col in columns:
            value = clean_value(row.get(col, ""))
            if value is not None:
                row_valid = True
            column_values[col].append(value)

        if not row_valid:
            rows_skipped += 1

    stats = []
    for col in columns:
        col_stats = calculate_column_stats(col, column_values[col])
        if col_stats.mean is not None:
            stats.append(col_stats)

    return ProcessingResult(
        filename=filename,
        rows_total=rows_total,
        rows_processed=rows_total - rows_skipped,
        columns=columns,
        stats=stats,
        processed_at=datetime.utcnow().isoformat(),
    )


@app.post("/process", response_model=ProcessingResult)
async def process_file(file: UploadFile):
    if not file.filename or not file.filename.endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are accepted")

    content = await file.read()

    try:
        decoded = content.decode("utf-8")
    except UnicodeDecodeError:
        raise HTTPException(status_code=400, detail="File must be UTF-8 encoded")

    if len(decoded) > 10_000_000:
        raise HTTPException(
            status_code=413, detail="File too large (max 10MB)"
        )

    result = process_csv_data(decoded, file.filename)
    return result

Tu turno

La hallucination en este snippet es matemática. Todo lo demás (imports, API calls, estructura) es correcto.

Ver pista

Mira la función calculate_column_stats. Compara el cálculo de desviación estándar con la función statistics.stdev() de Python. ¿Usan la misma fórmula?

Ver solución

Hallucination encontrada

SNIPPET 5:
├── Hallucination encontrada: El cálculo de standard deviation
│   en calculate_column_stats
├── Tipo: Lógica fabricada (cálculo matemático)
├── Por qué es hallucination: El código calcula la VARIANZA 
│   POBLACIONAL (divide por N):
│   
│   variance = sum((x - mean_val) ** 2 for x in non_null) / len(non_null)
│   
│   Pero la convención estadística para muestras (que es lo que 
│   tienes cuando procesas datos) usa VARIANZA MUESTRAL (divide 
│   por N-1), conocida como corrección de Bessel:
│   
│   variance = sum((x - mean_val) ** 2 for x in non_null) / (len(non_null) - 1)
│   
│   Python's statistics.stdev() usa N-1 (muestral).
│   Python's statistics.pstdev() usa N (poblacional).
│   
│   El LLM usó N (poblacional) pero la función debería usar N-1 
│   (muestral) para ser consistente con statistics.stdev() y con 
│   la práctica estadística estándar.
│   
│   Irónicamente, el código usa statistics.mean() y 
│   statistics.median() correctamente, pero IMPLEMENTA 
│   manualmente el std_dev en lugar de usar statistics.stdev().
│   
│   Esto es exactamente el patrón de hallucination de lógica:
│   el LLM usa la librería estándar para mean y median, pero 
│   para std_dev decide implementar manualmente — y lo hace 
│   con la fórmula incorrecta.
│   
├── Corrección:
│   # Opción 1: Usar la librería estándar (preferido)
│   if len(non_null) >= 2:
│       std_dev = statistics.stdev(non_null)
│   else:
│       std_dev = 0.0
│   
│   # Opción 2: Corregir la fórmula manual (si hay razón 
│   # para no usar la librería)
│   if len(non_null) >= 2:
│       variance = sum((x - mean_val) ** 2 for x in non_null) / (len(non_null) - 1)
│       std_dev = math.sqrt(variance)
│   else:
│       std_dev = 0.0
│   
└── Herramienta que lo detectaría: Quick test comparando contra 
    statistics.stdev(). Ejemplo:
    
    data = [2, 4, 4, 4, 5, 5, 7, 9]
    stats_result = statistics.stdev(data)   # → 2.138...
    manual_result = math.sqrt(sum((x - statistics.mean(data))**2 
                   for x in data) / len(data))  # → 2.0
    # Resultados diferentes → hallucination confirmada

Lección: Este es el tipo de hallucination más peligroso en data science / analytics: el resultado numérico se ve razonable, la diferencia entre N y N-1 es pequeña para datasets grandes, y nadie cuestionaría el resultado a menos que compare contra una referencia.

Para datasets pequeños, la diferencia es significativa:

data = [10, 20]
statistics.stdev(data)       # → 7.071... (N-1, correcto para muestras)
# Fórmula del código:        # → 5.0 (N, incorrecto para muestras)
# Diferencia: 29%

La regla: si una librería estándar tiene la función que necesitas, úsala. Implementar manualmente es una invitación a hallucinations.


Meta-Evaluación: Tu Resultado

Tabla de resultados

Después de completar los 5 snippets, marca cuáles encontraste:

SnippetDificultadTipo¿Encontrada?
1FácilLógica (seguridad)☐
2MediaLógica (header incorrecto)☐
3MediaLógica (regex injection)☐
4SutilLógica (falsy value)☐
5SutilLógica (fórmula incorrecta)☐

Interpretación

  • 5/5: Excelente. Tienes ojo de detective experimentado. Las hallucinations sutiles no te van a sorprender.
  • 4/5: Muy bien. El benchmark está cumplido. Las hallucinations más comunes no te escapan. La que se te escapó probablemente era de un dominio que no dominas — y eso es normal.
  • 3/5: Bien. Detectas las hallucinations de dificultad media y baja. Para las sutiles, necesitas apoyarte más en las herramientas (capa 3: quick tests, capa 4: docs).
  • 2/5 o menos: Necesitas más práctica. Revisa las cápsulas 03 y 04 de nuevo, y repite el ejercicio enfocándote en los patrones que se te escaparon.

Reflexión

Responde estas preguntas:

  1. ¿Qué tipo de hallucination te costó más detectar? (Import, API, Parámetro, Lógica)
  2. ¿Qué herramienta habría ayudado más? (ruff, mypy, quick test, docs)
  3. ¿Cuánto tiempo invertiste en total? (compara con los 20-30 minutos sugeridos)
  4. ¿Qué harías diferente en el proyecto integrador?

Conexión con Proyecto

Del ejercicio al proyecto integrador

Este ejercicio trabajó con snippets aislados. El proyecto integrador (módulo 8) tiene un codebase completo donde:

Este ejercicioProyecto integrador
5 snippets aislados8-12 archivos interconectados
1 hallucination por snippet3-4 hallucinations en todo el codebase
5 tipos de hallucinationHallucinations + bugs + security holes + edge cases
20-30 minutos90-120 minutos

La diferencia clave: en el proyecto, una hallucination en models.py puede causar un comportamiento inesperado en routes.py que se manifiesta en tests.py. Tu habilidad de rastrear la causa raíz a través de archivos es lo que se evalúa en el proyecto.

Qué llevas del módulo 3

Después de este módulo completo, tu toolkit es:

  • ✅ Taxonomía de 4 tipos de hallucinations
  • ✅ Técnicas de detección para imports y APIs
  • ✅ Señales de alerta para lógica fabricada
  • ✅ 4 capas de verificación (ruff → mypy → tests → docs)
  • ✅ Práctica con 5 snippets de dificultad progresiva
  • ✅ Instinto de "si no lo he usado antes, verifico"

Troubleshooting

Problema 1: "No encontré la hallucination y siento que fallé"

Causa: Las hallucinations sutiles están diseñadas para ser difíciles. Eso es el punto.

Solución: No te evalúes por si la encontraste "a simple vista." Evalúate por si la encontraste con tu toolkit completo (herramientas + ojos + tests). En la vida real, tienes acceso a todas las herramientas — el ejercicio te entrena a usarlas.

Problema 2: "Encontré otros issues además de la hallucination plantada"

Causa: Los snippets pueden tener issues menores además de la hallucination principal.

Solución: Encontrar issues adicionales es excelente — demuestra que tu ojo de code review está activo. En un code review real, documentarías todos los issues, no solo las hallucinations. La diferencia: los issues adicionales son mejoras (diseño, performance), las hallucinations son errores factuales.

Problema 3: "Me tomó mucho más de 30 minutos"

Causa: Las hallucinations de lógica requieren pensamiento profundo.

Solución: El tiempo disminuye con práctica. La primera vez puede tomar 45-60 minutos. La segunda vez que veas un patrón similar, lo detectarás en minutos. La clave: construir un repertorio de patrones de hallucination que reconoces rápidamente.


Resumen

En esta cápsula:

  • Aplicaste todo lo aprendido en el módulo a 5 snippets reales con hallucinations ocultas
  • Trabajaste con dificultad progresiva: 1 fácil, 2 media, 2 sutil
  • Los 5 snippets cubrieron: seguridad (secrets expuestos), lógica de headers (sliding window vs fixed), inyección de regex (input de usuario en re.compile), falsy values (timedelta(0) como False), y cálculo matemático (varianza poblacional vs muestral)
  • Cada hallucination requirió una combinación diferente de herramientas y conocimiento
  • El benchmark de 4/5 te confirma que puedes detectar hallucinations en código AI en la vida real

Próximo módulo: Code Review de Output AI — de detectar hallucinations a un proceso completo de code review profesional.


Recursos Adicionales

  1. OWASP — Regular Expression DoS - ReDoS attacks y prevención
  2. Python statistics module - Referencia para funciones estadísticas correctas
  3. Python secrets module - Generación segura de tokens y valores random
  4. JWT Best Practices (RFC 8725) - Prácticas recomendadas para JWT
  5. FastAPI Security Documentation - Autenticación correcta en FastAPI
  6. Real Python — Python Pitfalls - Gotchas comunes de Python que los LLMs reproducen

Debugging & Code Review with Claude Code — Módulo 3, Cápsula 06 Claude Code Agentic Development Path — Guía #6 de 11