Hasta ahora, todo lo que DocuBot "ha respondido" lo hemos escrito nosotros sobre el papel: prompts diseñados en el módulo 2 y respuestas esperadas contra las que evaluar. En esta lección el sistema cobra vida: prepararemos un entorno Python limpio, haremos la primera llamada real a la API de un LLM enviando los roles system/user que diseñamos en 02-01, diseccionaremos la respuesta que devuelve el proveedor (contenido, motivo de parada, consumo de tokens) y conectaremos el parser JSON defensivo de 02-03 a una respuesta de verdad. Ejemplificaremos con el SDK de Anthropic, pero los conceptos —mensajes con roles, límite de salida, objeto de uso— son equivalentes en cualquier proveedor, y cerraremos con una tabla que lo demuestra.

Contenido

  1. Del papel a la API: qué vamos a construir
  2. Preparar el entorno: venv, SDK y clave de API
  3. La primera llamada: system + user con los artefactos de prompts.py
  4. Anatomía de la respuesta: contenido, stop_reason y usage
  5. Parámetros principales: model, max_tokens y temperature
  6. Conectar el parser JSON de 02-03 a una respuesta real
  7. Equivalencia de conceptos entre proveedores

Del papel a la API: qué vamos a construir

En 01-03 tomamos una decisión de arquitectura que ahora se materializa: API gestionada, modelo de gama media y temperature baja. Eso significa que no vamos a instalar ni servir ningún modelo; vamos a enviar peticiones HTTP a un proveedor y recibir respuestas. El SDK del proveedor se encarga del transporte, la autenticación y la serialización, y nosotros nos concentramos en lo que ya tenemos: SYSTEM_DOCUBOT, las plantillas de prompts.py (versión 1.2) y el contrato JSON con su validación defensiva.

El objetivo de esta lección es un módulo cliente.py que, dado una pregunta y un bloque de documentación, devuelva el diccionario validado {"categoria", "respuesta", "fuentes", "confianza"}. Es la primera pieza "viva" de DocuBot.

Preparar el entorno: venv, SDK y clave de API

Trabajaremos en un entorno virtual para aislar dependencias del resto del sistema:

# Crear y activar el entorno virtual
python -m venv .venv

# Linux / macOS
source .venv/bin/activate
# Windows (PowerShell)
.venv\Scripts\Activate.ps1

# Instalar el SDK del proveedor (ejemplo: Anthropic)
pip install anthropic

# Congelar dependencias para reproducibilidad
pip freeze > requirements.txt

La clave de API nunca se escribe en el código ni se sube al repositorio. Se define como variable de entorno, que es donde el SDK la busca automáticamente:

# Linux / macOS
export ANTHROPIC_API_KEY="tu-clave-aqui"

# Windows (PowerShell)
$env:ANTHROPIC_API_KEY = "tu-clave-aqui"

Buenas prácticas desde el primer día:

  • Variable de entorno o gestor de secretos, nunca una cadena en el código. Una clave filtrada en un commit es un incidente de seguridad (y de facturación: quien la tenga gasta tu cuota).
  • Añade .venv/ y cualquier fichero .env a .gitignore.
  • Usa una clave distinta para desarrollo y para producción; así puedes revocar una sin afectar a la otra.

La primera llamada: system + user

Creamos cliente.py reutilizando los artefactos del módulo 2 tal cual. Fíjate en que el system prompt viaja en un parámetro propio, separado de la lista de mensajes — exactamente la separación de roles que estudiamos en 02-01:

# cliente.py
import os
import anthropic

from prompts import SYSTEM_DOCUBOT, prompt_respuesta, PROMPT_VERSION

# El identificador exacto del modelo depende del proveedor y del momento;
# lo leemos de una variable de entorno con un placeholder como valor por defecto.
MODELO = os.environ.get("DOCUBOT_MODELO", "claude-...")

# El cliente lee ANTHROPIC_API_KEY del entorno: no pasamos la clave a mano.
client = anthropic.Anthropic()

def preguntar_docubot_bruto(pregunta: str, documentacion: str):
    """Envía una pregunta a la API y devuelve la respuesta completa (sin procesar)."""
    return client.messages.create(
        model=MODELO,
        max_tokens=1024,
        temperature=0.2,          # baja: respuestas consistentes (decisión de 01-03)
        system=SYSTEM_DOCUBOT,    # el system prompt v1.2 diseñado en 02-01
        messages=[
            {
                "role": "user",
                "content": prompt_respuesta(pregunta, documentacion),
            }
        ],
    )

Desglose línea a línea:

  • anthropic.Anthropic() crea el cliente. No le pasamos la clave: la resuelve desde ANTHROPIC_API_KEY. Si no existe, la llamada fallará con un error de autenticación — mejor eso que una clave escrita en el fichero.
  • model=MODELO indica qué modelo responde. Usamos un placeholder tipo "claude-..." porque los identificadores concretos cambian con el tiempo; en tu proyecto fija el que elegiste en 01-03.
  • system=SYSTEM_DOCUBOT envía la identidad, las reglas anti-alucinación y el tono de DocuBot. En este proveedor es un parámetro de primer nivel; en otros va como un mensaje más con rol system (lo veremos en la tabla final).
  • messages es la lista de turnos de conversación. De momento un único turno user, cuyo contenido genera prompt_respuesta(): la pregunta del empleado más el bloque <documentacion> que exige la regla 2 del system prompt.
  • max_tokens=1024 es el tope de tokens de salida: la respuesta nunca será más larga que eso. Es obligatorio en esta API y es tu primer freno de coste.

Probamos con datos ficticios de Nubelia:

if __name__ == "__main__":
    documentacion = """## Exportar un proyecto
Para exportar un proyecto a CSV: Proyecto > Ajustes > Exportar > CSV.
La exportación incluye tareas, responsables y fechas. Disponible en
los planes Pro y Business."""

    pregunta = "¿Cómo exporto mi proyecto a CSV?"

    respuesta = preguntar_docubot_bruto(pregunta, documentacion)
    print(respuesta)

Si todo está bien configurado, al ejecutar python cliente.py verás por primera vez una respuesta generada por el modelo con tu system prompt. Merece la pena pararse un segundo: las reglas que escribiste en 02-01 están gobernando, ahora mismo, la salida de un modelo real.

Anatomía de la respuesta

El objeto que devuelve la API contiene mucho más que el texto. Sus campos importantes:

respuesta = preguntar_docubot_bruto(pregunta, documentacion)

# 1. El contenido: una LISTA de bloques, no un string directo
for bloque in respuesta.content:
    if bloque.type == "text":
        print(bloque.text)

# 2. Por qué dejó de generar
print(respuesta.stop_reason)      # p. ej. "end_turn"

# 3. Cuántos tokens ha consumido (¡esto es la factura!)
print(respuesta.usage.input_tokens)   # tokens de entrada (system + user)
print(respuesta.usage.output_tokens)  # tokens generados

# 4. Metadatos
print(respuesta.model)            # el modelo que respondió realmente
print(respuesta.id)               # identificador único de la petición

Tres detalles que conviene interiorizar:

  • content es una lista de bloques. Hoy solo contiene bloques de texto, pero el mismo campo transporta otros tipos (llamadas a herramientas en el módulo 5, por ejemplo). Acostúmbrate desde ya a filtrar por bloque.type == "text" en vez de acceder a content[0].text a ciegas.
  • stop_reason te dice por qué terminó la generación. Los valores principales:
stop_reason Significado Qué hacer
end_turn El modelo terminó de forma natural Caso normal, procesar la respuesta
max_tokens Se agotó el tope de salida La respuesta está cortada: súbelo o pide salidas más breves
stop_sequence Apareció una secuencia de parada definida por ti Procesar hasta ese punto
tool_use El modelo quiere llamar a una herramienta Lo veremos en el módulo 5

Para DocuBot, max_tokens es especialmente peligroso: un JSON cortado a la mitad nunca validará. Comprobar stop_reason antes de parsear evita perder tiempo depurando "JSON inválido" cuando el problema real era el tope de salida.

  • usage es tu contador de coste. Entrada y salida se facturan por separado y a precios distintos. En 03-04 construiremos la estimación de costes sobre estos dos números; de momento, imprímelos en cada prueba para ir desarrollando intuición de cuánto "pesa" cada llamada.

Una función auxiliar que usaremos en todo el curso:

def extraer_texto(respuesta) -> str:
    """Concatena los bloques de texto de una respuesta de la API."""
    return "".join(
        bloque.text for bloque in respuesta.content if bloque.type == "text"
    )

Parámetros principales

Los tres parámetros que ajustarás constantemente:

Parámetro Qué controla Guía para DocuBot
model Qué modelo responde (capacidad, velocidad, precio) Gama media para respuestas; en 03-04 veremos que el clasificador puede usar uno menor
max_tokens Tope duro de tokens de salida 1024 sobra para respuestas de <150 palabras + JSON; es freno de coste y de descontrol
temperature Aleatoriedad del muestreo (0 = muy determinista, 1 = muy variado) Baja (0.0–0.3): DocuBot debe ser consistente, no creativo

Sobre temperature, dos matices honestos:

  • Temperature baja no es determinismo total. Como vimos en 01-04 (no determinismo), dos llamadas idénticas pueden diferir ligeramente incluso a temperature 0. Reduce la varianza; no la elimina.
  • Algunos modelos recientes de algunos proveedores fijan el muestreo internamente y rechazan el parámetro temperature con un error de petición inválida. Consulta la documentación del modelo concreto que uses; si lo rechaza, simplemente omite el parámetro y controla el estilo mediante el prompt.

Conectar el parser JSON de 02-03 a una respuesta real

En 02-03 diseñamos el contrato {"categoria", "respuesta", "fuentes", "confianza"} y la validación defensiva (limpiar_json, json.loads con try/except, defaults conservadores, RESPUESTA_FALLBACK). Todo aquello se escribió contra respuestas hipotéticas; ahora lo enchufamos a la salida real:

import json
from prompts import RESPUESTA_FALLBACK
from validacion import limpiar_json, validar_contrato  # el módulo escrito en 02-03

def preguntar_docubot(pregunta: str, documentacion: str) -> dict:
    """Llamada completa: API -> texto -> JSON validado con el contrato de 02-03."""
    respuesta = preguntar_docubot_bruto(pregunta, documentacion)

    # Un JSON cortado por max_tokens jamás parseará: detectarlo pronto y claro.
    if respuesta.stop_reason == "max_tokens":
        return RESPUESTA_FALLBACK

    texto = extraer_texto(respuesta)
    try:
        datos = json.loads(limpiar_json(texto))   # quita ```json ... ``` y ruido
    except (json.JSONDecodeError, TypeError):
        return RESPUESTA_FALLBACK

    # Defaults conservadores para campos ausentes o inválidos (02-03)
    return validar_contrato(datos)

La primera vez que ejecutes esto contra el modelo real es probable que descubras cosas que el papel no mostraba: respuestas envueltas en ```json, un campo confianza con un valor fuera del enumerado, una lista fuentes que llega como string. Exactamente para eso construimos la validación defensiva — y por eso limpiar_json y los defaults no eran paranoia, sino ingeniería.

Salida estructurada nativa. Como anunciamos en 02-03, los proveedores ofrecen modos que garantizan que la salida cumple un esquema JSON: en Anthropic se declara un esquema en la petición (output_config con json_schema), en OpenAI existe el equivalente con response_format/esquemas estrictos y en Gemini con response_schema. Cuando tu proveedor y modelo lo soporten, actívalo: convierte el "casi siempre es JSON válido" en "siempre lo es". Aun así, mantén la validación defensiva: el esquema garantiza la forma, no el contenido (una confianza: "alta" en una respuesta inventada sigue siendo un problema de evaluación, no de formato), y tu código seguirá siendo portable entre proveedores.

Equivalencia de conceptos entre proveedores

Todo lo anterior existe, con otros nombres, en cualquier proveedor serio. A nivel de conceptos (no de cifras ni de identificadores concretos):

Concepto Anthropic OpenAI Google Gemini
SDK Python anthropic openai google-genai
Clave de API ANTHROPIC_API_KEY (env) OPENAI_API_KEY (env) GOOGLE_API_KEY (env)
System prompt Parámetro system separado Mensaje con rol system/developer en la lista system_instruction en la configuración
Turnos de conversación messages con roles user/assistant messages con roles user/assistant contents con roles user/model
Tope de salida max_tokens Parámetro de límite de tokens de salida max_output_tokens
Consumo de tokens usage.input_tokens / usage.output_tokens Objeto usage (prompt / completion) usage_metadata
Motivo de parada stop_reason finish_reason finish_reason
Salida estructurada Esquema JSON en la petición response_format / esquemas estrictos response_schema

La lección de esta tabla: aprende los conceptos, no los nombres. Si mañana Nubelia cambia de proveedor, el 90% de tu código (plantillas, validación, evaluación, gestión de errores) no cambia; solo la fina capa que habla con el SDK. Por eso concentramos toda la interacción con la API en cliente.py — un único fichero que reescribir si llega ese día.

Lo que no hemos tocado hoy, deliberadamente: la respuesta llega de golpe tras varios segundos (streaming, 03-02), si la red falla la excepción explota sin control (errores y reintentos, 03-02), cada llamada empieza de cero sin recordar nada (conversaciones, 03-03) y no sabemos cuánto cuesta esto al mes (costes, 03-04).

Errores Comunes y Consejos

  • Hardcodear la clave de API "solo para probar". Ese "solo para probar" acaba en un commit. Variable de entorno desde el minuto uno, sin excepciones.
  • Acceder a respuesta.content[0].text sin comprobar el tipo de bloque. Funciona hoy; se rompe en cuanto el modelo devuelva otro tipo de bloque. Usa siempre el filtrado por bloque.type.
  • Ignorar stop_reason. El síntoma típico: "el parser JSON falla aleatoriamente". La causa real: respuestas cortadas por max_tokens. Comprueba el motivo de parada antes de parsear.
  • Olvidar que max_tokens limita la salida, no la entrada. La entrada está limitada por la ventana de contexto del modelo (01-04); son límites distintos con errores distintos.
  • Reinventar el parser en cada script. Los artefactos de 02-03 (limpiar_json, validar_contrato, RESPUESTA_FALLBACK) son módulos importables. Un único punto de validación, un único sitio que corregir.
  • Consejo: imprime usage en cada prueba durante el desarrollo. En dos días tendrás una intuición sólida de cuántos tokens consume cada tipo de pregunta — y 03-04 te resultará mucho más natural.
  • Consejo: ejecuta contra la API el conjunto de evaluación de 02-04 (o una muestra de 10 preguntas) en cuanto tengas preguntar_docubot() funcionando. Es tu primera evaluación con respuestas reales, y la base de comparación para todo lo que optimices después.

Ejercicios

Ejercicio 1

Escribe una función diagnostico_llamada(respuesta) que reciba el objeto de respuesta de la API y devuelva un diccionario con: texto (contenido concatenado), cortada (booleano: True si stop_reason == "max_tokens"), tokens_entrada, tokens_salida y modelo. Pruébala con una llamada real y con max_tokens=20 para forzar el corte.

Ejercicio 2

Modifica preguntar_docubot_bruto() para aceptar un parámetro opcional temperature (por defecto 0.2) y haz 3 llamadas con la misma pregunta a temperature 0.0 y otras 3 a 0.9. Compara las salidas: ¿se cumple lo que estudiamos en 02-04 sobre la necesidad de varias ejecuciones por caso al comparar?

Ejercicio 3

El clasificador de soporte de 02-02 (prompt_clasificacion() con EJEMPLOS_CLASIFICACION) todavía no ha tocado la API. Escribe clasificar_consulta(texto: str) -> str que lo envíe con max_tokens=20 y temperature 0.0, y devuelva una de las 6 categorías (facturacion, permisos, integraciones, api, incidencias, otros). Si la salida del modelo no coincide exactamente con ninguna categoría, devuelve "otros" (default conservador, como en 02-03).

Soluciones

Solución 1:

def diagnostico_llamada(respuesta) -> dict:
    return {
        "texto": "".join(
            b.text for b in respuesta.content if b.type == "text"
        ),
        "cortada": respuesta.stop_reason == "max_tokens",
        "tokens_entrada": respuesta.usage.input_tokens,
        "tokens_salida": respuesta.usage.output_tokens,
        "modelo": respuesta.model,
    }

Con max_tokens=20 verás cortada: True y un texto interrumpido a media frase: exactamente el caso que preguntar_docubot() intercepta antes de parsear.

Solución 2:

def preguntar_docubot_bruto(pregunta, documentacion, temperature=0.2):
    return client.messages.create(
        model=MODELO,
        max_tokens=1024,
        temperature=temperature,
        system=SYSTEM_DOCUBOT,
        messages=[{"role": "user", "content": prompt_respuesta(pregunta, documentacion)}],
    )

for t in (0.0, 0.9):
    print(f"--- temperature={t} ---")
    for i in range(3):
        r = preguntar_docubot_bruto(pregunta, documentacion, temperature=t)
        print(f"[{i+1}]", extraer_texto(r)[:120])

A 0.0 las tres salidas serán casi idénticas (casi: no determinismo de 01-04); a 0.9 variarán visiblemente. Conclusión práctica: comparar prompts con una sola ejecución por caso es ruido, no medición — por eso 02-04 fijó 3 ejecuciones por caso.

Solución 3:

from prompts import prompt_clasificacion

CATEGORIAS = {"facturacion", "permisos", "integraciones", "api", "incidencias", "otros"}

def clasificar_consulta(texto: str) -> str:
    respuesta = client.messages.create(
        model=MODELO,
        max_tokens=20,
        temperature=0.0,
        messages=[{"role": "user", "content": prompt_clasificacion(texto)}],
    )
    salida = extraer_texto(respuesta).strip().lower()
    return salida if salida in CATEGORIAS else "otros"

Dos decisiones heredadas del módulo 2: temperature 0.0 porque clasificar exige consistencia, y default "otros" porque ante la duda es mejor una categoría genérica que una equivocada con apariencia de segura.

Conclusión

DocuBot ya no es papel: tienes un entorno reproducible con el SDK instalado y la clave fuera del código, una primera llamada real que envía el SYSTEM_DOCUBOT de 02-01 y las plantillas de prompts.py, y el criterio para leer lo que vuelve — el contenido como lista de bloques, stop_reason como centinela (especialmente max_tokens antes de parsear) y usage como contador de la factura. El parser defensivo de 02-03 procesa ahora respuestas de verdad, reforzado con los modos de salida estructurada nativos cuando el proveedor los ofrezca, y la tabla de equivalencias te garantiza que nada de esto te ata a un proveedor concreto. Pero nuestra integración es ingenua en un aspecto que el usuario nota al instante y en otro que notará el peor día del año: la respuesta tarda varios segundos en aparecer de golpe, y si la API devuelve un error 429 o un 500, DocuBot simplemente explota. En la próxima lección resolvemos ambos: streaming para que las palabras fluyan según se generan, y una estrategia seria de errores y reintentos para que DocuBot se degrade con elegancia en vez de romperse.

© Copyright 2026. Todos los derechos reservados