Módulo 8: Proyecto Integrador — Production-Ready AI System

1. Production Checklist

Descripción

El production checklist es la diferencia entre "creo que está listo" y "sé que está listo". No es una lista teórica que lees y archivas — es una herramienta ejecutable: cada item tiene un comando concreto para que verifiques si pasa y un paso concreto para que lo arregles si falla. En esta cápsula vas a construir el checklist completo específico para AI apps que usarás como herramienta de validación en el proyecto integrador del M8.


Por qué un checklist específico para AI

Un checklist genérico de "deploy web app" no cubre lo que puede salir mal en una AI app:

Checklist web app genérico — lo que NO cubre para AI:
❌ "¿Tienes backups?" → No habla de prompts versionados
❌ "¿Está la DB optimizada?" → No habla de cost tracking
❌ "¿Tienes rate limiting?" → No dice nada de token limits
❌ "¿Está la API documentada?" → No menciona guardrails en endpoints

Checklist AI específico — lo que sí necesitas verificar:
✅ ¿Los prompts están versionados en git (no hardcoded en el código)?
✅ ¿Hay guardrails de prompt injection en cada endpoint con user input?
✅ ¿El cost tracking tiene alertas para detectar abuse?
✅ ¿El retry está configurado solo para errores transitorios?
✅ ¿Hay fallback cuando el primary LLM provider falla?
✅ ¿El health check verifica que OpenAI responde, no solo que el servidor HTTP responde?

El checklist completo

Categoría 1: Security

1.1 GUARDRAILS ACTIVOS
    ├── Verificar: ejecutar test de prompt injection
    │   pytest tests/ -k "injection" -v
    │   → Debe fallar el ataque, pasar el test
    ├── Verificar: cada endpoint con user input tiene guardrail
    │   grep -r "guardrail" src/app/routers/  → debe aparecer en cada router
    └── Arreglar: añadir GuardrailsPipeline antes de llamar al domain

1.2 PII NO SE LOGUEA
    ├── Verificar: hacer un request con email, ver que no aparece en logs
    │   curl -X POST /analyze -d '{"text": "email: user@example.com"}'
    │   jq '.message' logs/app.json | grep -i "user@example" → debe retornar vacío
    └── Arreglar: activar sanitize_sensitive_fields en structlog processors

1.3 SECRETS NO EN CÓDIGO
    ├── Verificar: scan del repositorio
    │   grep -r "sk-" src/  → debe retornar vacío (no API keys en código)
    │   grep -r "OPENAI_API_KEY" src/  → solo referencias a env vars, no valores
    └── Arreglar: mover secrets a .env, añadir .env al .gitignore

1.4 .ENV NO EN GIT
    ├── Verificar: git check-ignore .env → debe retornar ".env"
    └── Arreglar: añadir .env a .gitignore, si ya está trackeado: git rm --cached .env

1.5 CONTENT POLICY VERIFICADA
    ├── Verificar: testear con inputs de toxicidad conocida
    │   pytest tests/ -k "content_policy" -v
    └── Arreglar: añadir content policy check en guardrails pipeline

Categoría 2: Testing

2.1 UNIT TESTS PASAN
    ├── Verificar: pytest tests/unit/ -v
    │   → Todos deben pasar. Coverage > 70% en domain y processing
    ├── Verificar: pytest --cov=src/domain --cov-report=term-missing
    └── Arreglar: escribir tests faltantes para domain service y parsers

2.2 INTEGRATION TESTS PASAN (o están correctamente skipped)
    ├── Verificar: pytest tests/integration/ -v
    │   → Pasan con API key real, o skipan correctamente sin ella
    ├── Si no hay API key en CI: pytest tests/integration/ -k "not requires_api"
    └── Arreglar: revisar fixtures de conftest.py, verificar @pytest.mark.integration

2.3 TESTS DE GUARDRAILS PASAN
    ├── Verificar: pytest tests/ -k "guardrail" -v
    │   → Test de injection debe bloquear el ataque
    │   → Test de PII debe redactar correctamente
    └── Arreglar: revisar implementación de guardrails

2.4 TESTS DE RELIABILITY PASAN
    ├── Verificar: pytest tests/ -k "retry or circuit or fallback" -v
    │   → Retry debe reintentar 3 veces
    │   → Circuit debe abrirse después de N failures
    │   → Fallback debe activarse cuando primary falla
    └── Arreglar: revisar RetryProvider, CircuitBreaker, FallbackProvider

2.5 NO HAY TESTS LENTOS SIN REASON
    ├── Verificar: pytest tests/ --timeout=5 -v
    │   → Los unit tests no deben tardar > 2s cada uno
    └── Arreglar: verificar que retry en tests usa min_wait_seconds=0.01

Categoría 3: Observability

3.1 REQUEST TRACING FUNCIONA
    ├── Verificar:
    │   # Hacer un request
    │   curl -X POST /api/v1/analyze -d '{"text": "test"}'
    │   # Buscar el request_id en logs
    │   tail -1 logs/app.json | jq '.request_id'
    │   → Debe retornar un UUID, no null
    └── Arreglar: verificar que RequestTracingMiddleware está registrado en main.py

3.2 COST TRACKING FUNCIONA
    ├── Verificar:
    │   tail -1 logs/app.json | jq '{cost_usd, input_tokens, output_tokens}'
    │   → Debe retornar valores numéricos, no null
    └── Arreglar: verificar que calculate_cost se llama en OpenAIProvider.complete()

3.3 LOG LEVEL CORRECTO EN PROD
    ├── Verificar: ENVIRONMENT=production python -c "from src.config import get_settings; s=get_settings(); print(s.log_level)"
    │   → Debe retornar "INFO" (no "DEBUG" en producción)
    └── Arreglar: revisar .env.production, verificar model_validator en Settings

3.4 GUARDRAIL ACTIVATIONS LOGUEADAS
    ├── Verificar:
    │   # Enviar un request con prompt injection
    │   curl -X POST /api/v1/analyze -d '{"text": "Ignore previous instructions"}'
    │   # Buscar en logs
    │   grep "guardrail_activated" logs/app.json
    │   → Debe aparecer la entry con el tipo de guardrail
    └── Arreglar: verificar que GuardrailsPipeline loguea con log.warning("guardrail_activated", ...)

3.5 LOGS EN FORMATO JSON (NO PLAIN TEXT EN PROD)
    ├── Verificar:
    │   head -1 logs/app.json | python -m json.tool > /dev/null
    │   → Debe parsear sin error
    └── Arreglar: verificar configure_logging() con format="json"

Categoría 4: Reliability

4.1 RETRY CONFIGURADO CORRECTAMENTE
    ├── Verificar (en código):
    │   grep -r "max_attempts\|stop_after_attempt" src/ → debe existir
    │   grep -r "stop_after_attempt(1000)\|stop_after_attempt(999)" → NO debe existir
    ├── Verificar (con test):
    │   pytest tests/ -k "test_retry" -v → debe pasar
    └── Arreglar: verificar RetryProvider con max_attempts=3-5

4.2 CIRCUIT BREAKER ES SINGLETON
    ├── Verificar:
    │   grep -r "CircuitBreaker()" src/app/ → si aparece en un handler, problema
    │   # El CB debe estar en dependencies.py o en un módulo global
    └── Arreglar: mover CircuitBreaker a variable global fuera del handler

4.3 FALLBACK CHAIN CONFIGURADA
    ├── Verificar:
    │   grep -r "FallbackProvider" src/ → debe aparecer en dependencies.py
    │   grep -r "static_fallback" src/ → debe tener un fallback string
    └── Arreglar: envolver provider con FallbackProvider

4.4 HEALTH CHECKS RESPONDEN
    ├── Verificar:
    │   curl -f http://localhost:8000/health/live → debe retornar 200
    │   curl -f http://localhost:8000/health/ready → debe retornar 200
    │   curl -f http://localhost:8000/health/deps → debe retornar 200
    └── Arreglar: verificar que health router está registrado en main.py

4.5 RATE LIMITING CONFIGURADO
    ├── Verificar:
    │   grep -r "RateLimitedProvider\|TokenBucket" src/ → debe aparecer
    │   # Verificar que rate < 80% del límite del API
    └── Arreglar: envolver con RateLimitedProvider(rpm=int(api_limit * 0.8))

Categoría 5: Performance

5.1 LATENCY TARGET DOCUMENTADO
    ├── Verificar: cat docs/BASELINES.md → debe existir con valores numéricos
    └── Arreglar: ejecutar benchmark, documentar en BASELINES.md

5.2 TIMEOUTS CONFIGURADOS
    ├── Verificar:
    │   grep -r "timeout" src/ → debe aparecer en openai client init
    └── Arreglar: OpenAI(timeout=30.0) — no dejar el default infinito

5.3 COST PER REQUEST ESTIMADO
    ├── Verificar:
    │   # De los logs de desarrollo:
    │   jq '.cost_usd' logs/app.json | awk '{s+=$1} END {print s/NR}' → costo promedio
    └── Documentar en BASELINES.md

5.4 PROMPTS OPTIMIZADOS PARA COSTO
    ├── Verificar: el system prompt no tiene bloques de texto repetitivos innecesarios
    │   # Usar tiktoken para estimar tokens del prompt:
    │   python -c "import tiktoken; enc = tiktoken.encoding_for_model('gpt-4o'); print(len(enc.encode(open('prompts/sentiment/v1.yaml').read())))"
    └── Arreglar: eliminar padding innecesario en prompts

Categoría 6: Documentation

6.1 README COMPLETO
    ├── Verificar: README.md tiene → setup instructions, env vars, cómo correr tests, cómo deployar
    └── Arreglar: completar README con secciones faltantes

6.2 API DOCS ACCESIBLES
    ├── Verificar: curl http://localhost:8000/docs → debe retornar 200
    └── Arreglar: FastAPI incluye Swagger automáticamente

6.3 .ENV.EXAMPLE EXISTE Y ESTÁ ACTUALIZADO
    ├── Verificar:
    │   cat .env.example → debe tener todas las variables
    │   diff <(grep -o '^[A-Z_]*' .env) <(grep -o '^[A-Z_]*' .env.example) → debe ser vacío
    └── Arreglar: añadir variables faltantes en .env.example

6.4 RUNBOOK CON MÍNIMO 3 INCIDENTES
    ├── Verificar: cat docs/RUNBOOK.md → debe tener secciones de alta latencia, errores 5xx, costo alto
    └── Arreglar: completar runbook con diagnóstico y resolución para cada incidente

6.5 PROMPTS VERSIONADOS
    ├── Verificar:
    │   ls prompts/  → debe tener versión en nombre de directorio (v1, v2, ...)
    │   grep "version" prompts/sentiment/v1.yaml → debe tener metadata de versión
    └── Arreglar: mover prompts a archivos YAML con metadata de versión

Script de checklist automatizado

# scripts/run_checklist.py
"""
Ejecuta el production checklist de forma automatizada.
Los items que se pueden automatizar, se verifican con código.
Los items manuales, se marcan como "requires human verification".
"""
import subprocess
import json
import os
import sys
from pathlib import Path
from typing import Tuple

def check(name: str, fn) -> Tuple[str, bool, str]:
    """Ejecuta un check y retorna (name, passed, detail)."""
    try:
        result = fn()
        if isinstance(result, tuple):
            passed, detail = result
        else:
            passed, detail = bool(result), ""
        return name, passed, detail
    except Exception as e:
        return name, False, str(e)[:200]

def check_no_secrets_in_code() -> Tuple[bool, str]:
    """Verifica que no hay API keys en el código."""
    result = subprocess.run(
        ["grep", "-r", "sk-", "src/"],
        capture_output=True, text=True
    )
    if result.stdout.strip():
        return False, f"Found potential secrets: {result.stdout[:200]}"
    return True, "No secrets found in src/"

def check_env_example_exists() -> Tuple[bool, str]:
    """Verifica que .env.example existe."""
    exists = Path(".env.example").exists()
    return exists, ".env.example found" if exists else ".env.example missing"

def check_env_gitignored() -> Tuple[bool, str]:
    """Verifica que .env está en .gitignore."""
    result = subprocess.run(
        ["git", "check-ignore", ".env"],
        capture_output=True, text=True
    )
    ignored = ".env" in result.stdout
    return ignored, ".env gitignored" if ignored else ".env NOT in .gitignore — SECURITY RISK"

def check_unit_tests_pass() -> Tuple[bool, str]:
    """Ejecuta unit tests y verifica que pasan."""
    result = subprocess.run(
        ["python", "-m", "pytest", "tests/unit/", "-v", "--tb=short"],
        capture_output=True, text=True
    )
    passed = result.returncode == 0
    lines = result.stdout.split("\n")
    summary = next((l for l in reversed(lines) if "passed" in l or "failed" in l), "")
    return passed, summary

def check_health_endpoint(base_url: str = "http://localhost:8000") -> Tuple[bool, str]:
    """Verifica que /health/live responde 200."""
    import urllib.request
    try:
        req = urllib.request.urlopen(f"{base_url}/health/live", timeout=5)
        return req.status == 200, f"HTTP {req.status}"
    except Exception as e:
        return False, str(e)

def check_baselines_doc_exists() -> Tuple[bool, str]:
    """Verifica que BASELINES.md existe con contenido."""
    path = Path("docs/BASELINES.md")
    if not path.exists():
        return False, "docs/BASELINES.md missing"
    content = path.read_text()
    has_latency = "latency" in content.lower()
    has_cost = "cost" in content.lower()
    if not (has_latency and has_cost):
        return False, "BASELINES.md exists but missing latency/cost sections"
    return True, "BASELINES.md has latency and cost baselines"

def check_runbook_exists() -> Tuple[bool, str]:
    """Verifica que el runbook existe con incidentes documentados."""
    path = Path("docs/RUNBOOK.md")
    if not path.exists():
        return False, "docs/RUNBOOK.md missing"
    content = path.read_text()
    incidents = ["latencia", "5xx", "costo", "latency", "error", "cost"]
    found = sum(1 for i in incidents if i.lower() in content.lower())
    if found < 2:
        return False, f"RUNBOOK.md exists but only {found} incident types documented"
    return True, f"RUNBOOK.md has {found} incident types"

def run_production_checklist(skip_server_checks: bool = False):
    """Ejecuta el checklist completo y reporta resultados."""
    checks = [
        ("Security: no secrets in code", check_no_secrets_in_code),
        ("Security: .env gitignored", check_env_gitignored),
        ("Security: .env.example exists", check_env_example_exists),
        ("Testing: unit tests pass", check_unit_tests_pass),
        ("Documentation: BASELINES.md", check_baselines_doc_exists),
        ("Documentation: RUNBOOK.md", check_runbook_exists),
    ]
    
    if not skip_server_checks:
        checks.append(("Reliability: health/live", check_health_endpoint))
    
    results = []
    for name, fn in checks:
        name, passed, detail = check(name, fn)
        results.append((name, passed, detail))
        status = "✅" if passed else "❌"
        print(f"  {status} {name}: {detail}")
    
    passed = sum(1 for _, p, _ in results if p)
    total = len(results)
    print(f"\n{'=' * 50}")
    print(f"CHECKLIST: {passed}/{total} passed")
    
    if passed < total:
        failed = [n for n, p, _ in results if not p]
        print(f"FAILED: {', '.join(failed)}")
        return False
    
    print("ALL CHECKS PASSED — ready for production")
    return True

if __name__ == "__main__":
    skip_server = "--no-server" in sys.argv
    success = run_production_checklist(skip_server_checks=skip_server)
    sys.exit(0 if success else 1)

Ejercicios

Ejercicio 1: Identificar el check más crítico

De toda la lista, ¿cuál es el check que, si falla, haría que NO lanzarías bajo ninguna circunstancia?

Ver guía

Depende del contexto, pero los candidatos más fuertes son:

  • Security 1.3 (secrets no en código): un API key en producción es una brecha de seguridad inmediata y un costo no controlado
  • Security 1.1 (guardrails activos): si tu app acepta user input sin guardrails, es vulnerable a prompt injection desde el día 1
  • Testing 2.1 (unit tests pasan): si los tests fallan, el código puede estar roto de formas que no conoces

En la práctica: secrets en código y guardrails sin activar son los que más se pasan por alto y tienen consecuencias más directas.


Ejercicio 2: Ampliar el checklist

Para una app de análisis de documentos legales (alta precisión requerida, datos sensibles), ¿qué 3 items específicos añadirías al checklist?

Ver guía
  1. PII/datos sensibles: verificar que números de caso, nombres de partes, y datos de contratos no aparecen en logs ni se almacenan sin encriptación
  2. Confidence threshold: el sistema no debe devolver resultados con baja confianza sin advertencia explícita — añadir check de que el umbral de confianza mínima está configurado
  3. Audit trail: para cumplimiento legal, verificar que hay un log inmutable de qué documentos fueron procesados, cuándo, y por quién — no solo los logs estructurados normales

Ejercicio 3: Automatizar un check nuevo

Escribe una función check_guardrails_in_all_routers() para el script run_checklist.py que verifique que cada archivo dentro de src/app/routers/ contiene al menos una referencia a guardrails o GuardrailsPipeline. Debe retornar (bool, str) indicando si pasó y el detalle.

Ver solución
def check_guardrails_in_all_routers() -> Tuple[bool, str]:
    """Verifica que cada router tiene guardrails configurados."""
    from pathlib import Path

    router_dir = Path("src/app/routers")
    if not router_dir.exists():
        return False, "src/app/routers/ directory not found"

    router_files = list(router_dir.glob("*.py"))
    router_files = [f for f in router_files if f.name != "__init__.py"]

    if not router_files:
        return False, "No router files found"

    missing_guardrails = []
    for router_file in router_files:
        content = router_file.read_text()
        if "guardrail" not in content.lower() and "GuardrailsPipeline" not in content:
            missing_guardrails.append(router_file.name)

    if missing_guardrails:
        return False, f"Routers sin guardrails: {', '.join(missing_guardrails)}"

    return True, f"Todos los {len(router_files)} routers tienen guardrails"

Ejercicio 4: Checklist para un sistema multi-modelo

Tu aplicación ahora usa dos modelos: gpt-4o para análisis complejo y gpt-4o-mini para clasificación rápida. Escribe 4 items de checklist adicionales específicos para esta configuración multi-modelo, siguiendo el formato del checklist (verificar + arreglar).

Ver solución
MULTI-MODEL 1: AMBOS MODELOS CONFIGURADOS
    ├── Verificar:
    │   python -c "from src.config import get_settings; s=get_settings(); print(s.openai_model, s.secondary_model)"
    │   → Debe retornar ambos modelos, no None
    └── Arreglar: añadir secondary_model en .env y en Settings

MULTI-MODEL 2: COST TRACKING POR MODELO
    ├── Verificar:
    │   jq 'select(.model) | {model, cost_usd}' logs/app.json | sort | uniq -c
    │   → Cada modelo debe tener su propio registro de costos
    └── Arreglar: verificar que calculate_cost recibe el model name del provider

MULTI-MODEL 3: FALLBACK ENTRE MODELOS
    ├── Verificar:
    │   pytest tests/ -k "test_model_fallback" -v
    │   → Si gpt-4o falla, gpt-4o-mini debe tomar el request
    └── Arreglar: configurar FallbackProvider con ambos modelos

MULTI-MODEL 4: RATE LIMITS INDEPENDIENTES
    ├── Verificar:
    │   grep -r "RateLimitedProvider" src/ → debe haber uno por modelo
    │   → Cada modelo tiene su propio rate limit (gpt-4o: 500 RPM, mini: 2000 RPM)
    └── Arreglar: crear RateLimitedProvider separado por modelo en dependencies.py

Troubleshooting

Problema: El script del checklist falla con ModuleNotFoundError

Síntoma: Al ejecutar python scripts/run_checklist.py obtienes ModuleNotFoundError: No module named 'src'.

Causa: El script se ejecuta desde un directorio donde Python no encuentra el paquete src.

Solución:

# Opción 1: ejecutar desde la raíz del proyecto
cd /path/to/project
python scripts/run_checklist.py

# Opción 2: añadir el path al inicio del script
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

# Opción 3: instalar el paquete en modo editable
pip install -e .

Problema: grep -r "sk-" src/ da falsos positivos

Síntoma: El check de secrets reporta encontrar sk- pero son nombres de variables como skip, task-id, o comentarios.

Causa: El patrón sk- es demasiado genérico.

Solución:

# Usar un patrón más específico para API keys de OpenAI
grep -rP "sk-[a-zA-Z0-9]{20,}" src/

# O excluir palabras comunes
grep -r "sk-" src/ | grep -v "skip\|task\|mask\|desk"

# Mejor aún: usar herramientas dedicadas como gitleaks o trufflehog
pip install trufflehog
trufflehog filesystem --directory src/

Problema: Los tests de guardrails pasan en local pero fallan en CI

Síntoma: pytest tests/ -k "guardrail" pasa en tu máquina pero falla en GitHub Actions con timeout o errores de conexión.

Causa: Los tests de guardrails hacen llamadas reales al LLM en tu máquina (con tu API key) pero en CI no hay API key o hay rate limiting.

Solución:

# En conftest.py: usar mock para CI
import pytest
import os

@pytest.fixture
def guardrails_pipeline():
    """Guardrails pipeline que no necesita LLM real."""
    return GuardrailsPipeline(
        injection_enabled=True,
        content_policy_enabled=True,
        pii_redaction_enabled=True,
    )

@pytest.mark.skipif(
    not os.environ.get("OPENAI_API_KEY"),
    reason="Requires OPENAI_API_KEY"
)
def test_guardrail_with_real_llm():
    ...

Problema: El health check dice 200 pero la app no procesa requests

Síntoma: curl /health/live retorna 200, pero los requests a /analyze fallan con 500.

Causa: El health check de liveness solo verifica que el proceso HTTP está corriendo. No verifica dependencias externas como OpenAI.

Solución:

# Usar /health/ready en vez de /health/live para validar dependencias
# curl http://localhost:8000/health/ready

@router.get("/health/ready")
async def readiness(provider: LLMProvider = Depends(get_llm_provider)):
    try:
        provider.complete([{"role": "user", "content": "ping"}])
        return {"status": "ready"}
    except Exception as e:
        raise HTTPException(status_code=503, detail=str(e))

La distinción clave entre los tres health endpoints:

  • /health/live — ¿el proceso está corriendo? (liveness probe)
  • /health/ready — ¿puede aceptar tráfico? (readiness probe, incluye dependencias)
  • /health/deps — ¿qué dependencias están activas? (diagnóstico detallado)

Resumen

  • Específico para AI: el checklist cubre lo que los checklists genéricos ignoran (prompts versionados, guardrails, cost tracking, fallbacks)
  • Ejecutable: cada item tiene un comando concreto para verificar y un paso concreto para arreglar
  • 6 categorías: Security, Testing, Observability, Reliability, Performance, Documentation
  • Automatizable: los items repetibles se codifican en scripts/run_checklist.py
  • Antes de cada deploy: el checklist no es un one-time thing — se ejecuta antes de cada lanzamiento

Recursos adicionales

  1. Google SRE Book — Production Readiness Review — El framework de Google para validar apps antes de producción
  2. 12-Factor App — Metodología de configuración y deployment
  3. OWASP LLM Top 10 — Las 10 vulnerabilidades más críticas en apps LLM
  4. Snyk — Herramienta de scan de secrets y vulnerabilidades