☀️ 🌙
Artefacto 4 · AI Engineering · Concepto 03 (Fase 4)

Una API con portero

El agente deja de vivir en tu terminal y se vuelve un servicio web al que se llama por HTTP — protegido por tu token.

Hasta ahora el agente solo hablaba contigo, en tu Mac. La Fase 4 le da una puerta HTTP: cualquiera (con el token correcto) puede mandarle una oferta desde internet y recibir la respuesta. Son dos piezas: dejar el motor reutilizable, y ponerle delante una API con un portero que protege tu API key de OpenAI. Todo el ejemplo real, ejecutado, está al final.

Lo que hay dentro
  1. Las dos piezas de esta fase
  2. El motor ahora devuelve datos, no solo texto
  3. Qué es una API HTTP (recordando el Art.2)
  4. El token como portero (fail-closed)
  5. lifespan: preparar el motor una sola vez
  6. Servir el frontend (adelanto de la Fase 6)
  7. El ejemplo real, paso a paso ✦
  8. Secretos: .env hoy, Secret Manager al desplegar
  9. Diccionario del mundo real (para tu CV)

01 Las dos piezas de esta fase

El principio es el mismo que ya usaste en el Artefacto 2 (RAG): un motor, muchas interfaces. El motor no cambia; solo le añadimos una boca nueva.

┌──────────────────────────────────────┐ │ MOTOR · agente.py (no se toca) │ │ procesar_oferta(texto) → dict │ └──────┬─────────────────────┬──────────┘ │ │ ┌──────▼──────┐ ┌─────▼───────────┐ │ cli.py │ │ api.py │ ← pieza nueva │ (terminal) │ │ (HTTP + token) │ └─────────────┘ └─────────────────┘

02 El motor ahora devuelve datos

El CLI se contentaba con un texto. Pero una API que alimenta a una web quiere estructura: no solo la respuesta, también qué pasos dio el agente, cuántos turnos y si terminó bien. Por eso refactorizamos el bucle para que devuelva un dict — igual que rag_core.py devolvía un dict en el Art.2.

# agente.py — el bucle (ahora núcleo reutilizable) devuelve estructura
def _correr_bucle(peticion, max_turnos=6, verboso=True) -> dict:
    ...
    if not msg.tool_calls:                       # terminó
        return {"respuesta": msg.content, "pasos": pasos,
                "turnos": turno, "completado": True}
    ...

def agente(peticion, ...) -> str:                # envoltorio para el CLI
    return _correr_bucle(peticion, ...)["respuesta"]

def procesar_oferta(texto, max_turnos=6) -> dict:  # envoltorio para la API
    peticion = f"Analiza esta oferta y prepárame la candidatura:\n{texto}"
    return _correr_bucle(peticion, max_turnos, verboso=False)

Una sola definición del bucle, dos puertas de salida. El CLI toma ["respuesta"]; la API toma el dict entero. Ese pasos es la traza que la web pintará como barra de progreso.

03 Qué es una API HTTP

Ya montaste una en el Art.2, así que esto es memoria muscular. Una API HTTP es un programa que escucha peticiones por la red y devuelve respuestas. Aquí, con FastAPI, definimos dos rutas y una sonda:

RutaMétodoTokenQué hace
/saludGETno¿está vivo? Para monitorización (Cloud Run la usa)
/preguntarPOSTsírecibe {"oferta": "..."} y corre el agente
/GETnosirve el frontend (Fase 6)

La oferta viaja en el cuerpo (body) de un POST como JSON, y el token en una cabecera (header) llamada X-Token. FastAPI valida la forma del body con un modelo Pydantic — si falta oferta, rechaza solo.

04 El token como portero

Este es el corazón de la fase. Cada llamada al agente cuesta ~4 peticiones a OpenAI (el bucle), así que dejarlo abierto sería regalar tu dinero a cualquier bot. El token es un portero: si no coincide con APP_TOKEN, la respuesta es 401 y —clave— el agente ni se ejecuta.

@app.post("/preguntar")
def preguntar(peticion: Peticion, x_token: str = Header(default="")):
    if x_token != APP_TOKEN or not APP_TOKEN:        # portero
        raise HTTPException(status_code=401, detail="Token inválido o ausente.")
    if not peticion.oferta.strip():
        raise HTTPException(status_code=400, detail="La oferta está vacía.")
    return procesar_oferta(peticion.oferta)          # ← solo se llega aquí con token OK

Fail-closed: fíjate en or not APP_TOKEN. Si por un despiste el token quedara vacío, la puerta se cierra para todos en vez de abrirse para todos. En seguridad, el fallo por defecto debe ser "denegar", nunca "permitir". Es una decisión de una línea que separa un juguete de algo serio.

05 lifespan: preparar una vez

Arrancar el servidor por cada petición sería absurdo. FastAPI tiene lifespan: un bloque que corre una vez al levantar el servicio (para "calentar" el motor: crear el cliente OpenAI, cachear el CV) y otra al apagarlo. En el Art.2 cargabas ahí el índice del RAG; aquí basta con avisar de que el token está puesto.

@asynccontextmanager
async def lifespan(app):
    if not APP_TOKEN:
        print("⚠️  APP_TOKEN vacío: la API rechazará TODO (fail-closed).")
    print("✅ Agente listo. Esperando peticiones.")
    yield          # ← aquí el servidor vive y atiende; lo de abajo corre al apagar

06 Servir el frontend

El frontend web llega en la Fase 6, pero la API ya deja el hueco: monta la carpeta static/ en /… pero solo si existe, para no romper hoy que aún no está. Va al final del fichero, para que /salud y /preguntar tengan prioridad sobre el montaje.

if RUTA_STATIC.is_dir():                    # solo si ya hay frontend
    app.mount("/", StaticFiles(directory=RUTA_STATIC, html=True), name="static")

07 El ejemplo real, paso a paso ✦

Esto es lo que salió al arrancar la API en local (uvicorn api:app --port 8099) y llamarla con curl. Las cuatro pruebas, tal cual:

Prueba 1 · /salud sin token → vivo

$ curl -s http://127.0.0.1:8099/salud {"estado":"ok","protegido":true} # vivo, y con token configurado

Pruebas 2 y 3 · el portero rechaza 401

# sin cabecera de token $ curl -X POST .../preguntar -d '{"oferta":"Data Scientist Python"}' HTTP 401 # con un token equivocado $ curl -X POST .../preguntar -H "X-Token: malo" -d '{"oferta":"..."}' HTTP 401 # el agente NI se ejecutó → 0 gasto de API

Prueba 4 · token correcto 200 → el agente gira

$ curl -X POST .../preguntar \ -H "X-Token: ****" \ -d '{"oferta":"Buscamos AI Engineer Mid en Madrid. Imprescindible: Python, RAG, Docker, GCP o AWS, ingles B2. Se valora FastAPI."}' turnos: 4 pasos: ['analizar_oferta', 'match_cv', 'redactar_borrador'] completado: True --- respuesta --- He analizado la oferta para el puesto de AI Engineer Mid... Imprescindibles: Python, RAG, Docker, Cloud (GCP o AWS), Inglés B2 Encaje Global: Medio ... (+ borrador de candidatura)

El campo pasos deja ver el bucle uno a uno — el agente decidió, en este orden:

1
analizar_oferta
texto crudo → requisitos estructurados (imprescindibles, deseables, modalidad)
2
match_cv
esos requisitos vs tu CV → encaje "Medio", qué cumples, qué falta
3
redactar_borrador
oferta + encaje → borrador de candidatura (reglas de estilo horneadas)
4
respuesta final (sin herramientas)
el modelo redacta el resumen para ti y el bucle para → completado: True

Cuatro turnos, tres herramientas, orden elegido por el modelo. Exactamente la misma danza del CLI, ahora servida por HTTP y devuelta como JSON para que una web la pueda pintar.

08 Secretos: hoy y al desplegar

El token y la API key son secretos: nunca en el código, nunca en git. Hoy viven en .env (blindado en .gitignore). Al desplegar en la Fase 5 pasarán a Secret Manager de GCP, exactamente como en el Art.2. El código no cambia: os.environ los lee igual venga de un fichero o de la nube.

Recordatorio para el deploy: el CV (cv_denys.md) no es un secreto de entorno, es un fichero — y se horneará dentro de la imagen Docker. Revisaremos .gcloudignore antes del push, como acordamos.

09 Diccionario del mundo real

ConceptoQué es aquíDónde vive fuera
API REST / HTTPapi.py con FastAPIel 90% de los backends del mundo hablan así
Endpoint/salud, /preguntarcada "puerta" con la que interactúa un cliente
Header / bodytoken en X-Token, oferta en JSONautenticación en cabeceras, datos en el cuerpo: convención universal
401 Unauthorizedtoken malo o ausenteel código HTTP estándar de "identifícate"
Fail-closedor not APP_TOKENprincipio de seguridad: ante la duda, denegar
Pydantic modelclass Peticion(BaseModel)validación de datos de entrada en casi todo Python moderno
lifespan / warm-uppreparar el motor 1 vezcargar modelos/conexiones al arrancar, no por petición
Secret management.env → Secret Managerhigiene obligatoria: credenciales fuera del código

Para tu CV, en una frase: «Expuse el agente como una API REST con FastAPI, autenticada por token con política fail-closed, reutilizando el motor sin cambios y devolviendo la traza de pasos en JSON para el frontend.» Eso es lenguaje de backend real, no de notebook.

→ Y ahora, la Fase 5

La API vive y protege. El siguiente paso es empaquetarla en Docker y desplegarla en Cloud Run con los secretos en Secret Manager — el mismo camino del Art.2, que ya recorriste una vez. Luego, el frontend (Fase 6) y lucirlo en el portfolio.