Las lecciones anteriores terminaban siempre en texto: una respuesta redactada, una etiqueta de categoría. Suficiente mientras el lector sea un humano. Pero DocuBot no vivirá en una terminal: irá incrustado en la web de soporte de Nubelia, y esa web es código — necesita saber en qué categoría cayó la pregunta para pintar el icono correcto, qué fuentes citar como enlaces, y qué confianza tiene la respuesta para decidir si la muestra o la deriva a un humano. Es decir: el consumidor de la salida del LLM será un programa, y los programas no parsean prosa. Esta lección enseña a conseguir salidas estructuradas y parseables — sobre todo JSON — de un sistema que, como sabemos desde 01-04, es no determinista por naturaleza: cómo pedirlas, cómo validarlas en Python, cómo robustecerlas y cómo controlar otros aspectos del formato (markdown, longitud, idioma). Seguimos sin llamar a ninguna API: trabajamos con el texto de los prompts, las respuestas esperadas y el código de validación que las recibirá.

Contenido

  1. Por qué un desarrollador necesita salidas parseables
  2. Pedir JSON con un esquema en el prompt
  3. Validar en Python: json.loads y programación defensiva
  4. Técnicas para robustecer la salida
  5. Modos JSON nativos: qué te espera en el módulo 3
  6. Control de otros formatos: markdown, longitud e idioma

Por qué un desarrollador necesita salidas parseables

Imagina la integración de DocuBot en la web de soporte de Nubelia con salida en texto libre:

La pregunta parece de permisos. Para invitar a un usuario externo
necesitas rol de Administrador... Fuente: sección "Roles y permisos".
Estoy bastante seguro de esta respuesta.

¿Cómo extrae el código la categoría? ¿Con una expresión regular que busque "parece de X"? ¿Y si mañana el modelo escribe "Se trata de una cuestión de permisos"? Cada respuesta redactada de forma distinta (no determinismo, 01-04) rompería el parser. La solución es invertir el contrato: en lugar de adaptar el código a la prosa del modelo, obligamos al modelo a hablar el idioma del código. El contrato de DocuBot con la web de soporte será:

{
  "categoria": "permisos",
  "respuesta": "Para invitar a un usuario externo necesitas rol de Administrador. Ve a Espacio de trabajo → Miembros → Invitar.",
  "fuentes": ["Roles y permisos"],
  "confianza": "alta"
}

Cuatro campos, cada uno con un consumidor claro en la web:

Campo Tipo Quién lo consume
categoria string (enum de 6 valores) Enrutado e icono en la interfaz
respuesta string Se muestra al empleado
fuentes lista de strings Se pintan como enlaces a la wiki
confianza "alta" | "media" | "baja" Si es "baja", la web ofrece escalar a un humano en lugar de fiarse

El campo confianza merece una pausa: es la versión estructurada de la regla 3 del system prompt de 02-01 ("di que no lo sabes"). Un texto libre que admite ignorancia es útil; un campo "confianza": "baja" es accionable: el código puede ramificar sobre él. Estructurar la salida no es solo comodidad de parseo — es convertir los comportamientos del asistente en datos sobre los que programar. (Que la decisión final de escalar la tome un flujo con humanos, y no el modelo por su cuenta, es un principio de diseño al que volveremos en el módulo 6.)

Pedir JSON con un esquema en el prompt

La técnica base es describir el esquema en el prompt y — recordando el few-shot de 02-02 — mostrar un ejemplo, porque el formato se enseña mejor con ejemplos que con descripciones. Añadimos a las instrucciones de DocuBot:

FORMATO DE RESPUESTA:
Responde ÚNICAMENTE con un objeto JSON válido, sin texto antes ni
después, sin comillas de bloque de código. El esquema es:

{
  "categoria": string, una de: "facturacion", "permisos",
               "integraciones", "api", "incidencias", "otros",
  "respuesta": string, la respuesta al usuario en el idioma de la
               pregunta, máximo 150 palabras,
  "fuentes": array de strings, títulos de las secciones de
             <documentacion> usadas; [] si no usaste ninguna,
  "confianza": "alta" | "media" | "baja". Usa "baja" si la
               documentación no cubre la pregunta; en ese caso
               "respuesta" debe ser "No encuentro esa información en
               la documentación disponible." y "fuentes" debe ser [].

Ejemplo de respuesta válida:
{"categoria": "permisos", "respuesta": "Solo los Administradores pueden invitar usuarios externos. Ve a Espacio de trabajo → Miembros.", "fuentes": ["Roles y permisos"], "confianza": "alta"}

Decisiones de diseño que conviene interiorizar:

  • "ÚNICAMENTE… sin texto antes ni después". Sin esto, los modelos tienden a envolver el JSON en cortesía ("¡Claro! Aquí tienes: …") o en un bloque markdown ```json. Ambas cosas rompen un json.loads ingenuo.
  • Cada campo lleva tipo, valores permitidos y criterio de uso. No solo "un campo confianza": cuándo es baja y qué implica para los demás campos. Los campos se definen juntos porque se rellenan juntos.
  • Los valores cerrados se enumeran ("alta" | "media" | "baja"). Volveremos sobre esto en la sección de robustez.
  • El ejemplo va en una sola línea deliberadamente: también enseña que no hace falta pretty-printing (menos tokens de salida = menos coste y latencia, 01-04).
  • El caso "no lo sé" está integrado en el esquema. La regla anti-alucinación de 02-01 ahora produce datos coherentes: confianza: "baja" + respuesta fija + fuentes vacías. Los tres campos cuentan la misma historia y el código puede verificarlo.

Con la pregunta "¿Nubelia permite exportar un proyecto a PDF?" (sin cobertura en la documentación), la respuesta esperada del DocuBot estructurado es:

{"categoria": "otros", "respuesta": "No encuentro esa información en la documentación disponible.", "fuentes": [], "confianza": "baja"}

Compárala con el DocuBot ingenuo de 01-04, que inventaba la exportación a PDF con total fluidez. Mismo modelo; el prompt y el contrato de salida marcan la diferencia.

Validar en Python: json.loads y programación defensiva

Regla de oro: la salida de un LLM es entrada no confiable, exactamente igual que un formulario web. El modelo casi siempre devolverá JSON válido con este prompt… y "casi siempre" en producción significa "a veces no". Tu código debe asumirlo. Validación defensiva paso a paso:

import json

CATEGORIAS_VALIDAS = {"facturacion", "permisos", "integraciones",
                      "api", "incidencias", "otros"}
NIVELES_CONFIANZA = {"alta", "media", "baja"}

RESPUESTA_FALLBACK = {
    "categoria": "otros",
    "respuesta": "No he podido procesar la respuesta. "
                 "Un agente humano revisará tu consulta.",
    "fuentes": [],
    "confianza": "baja",
}

def limpiar_json(texto: str) -> str:
    """Elimina envoltorios habituales: bloques markdown y texto extra."""
    texto = texto.strip()
    if texto.startswith("```"):
        # Quita la primera línea (``` o ```json) y el cierre final
        texto = texto.split("\n", 1)[1]
        texto = texto.rsplit("```", 1)[0]
    # Si aún hay prosa alrededor, quédate con el primer {...} completo
    inicio, fin = texto.find("{"), texto.rfind("}")
    if inicio != -1 and fin > inicio:
        texto = texto[inicio:fin + 1]
    return texto.strip()

def parsear_respuesta_docubot(texto_modelo: str) -> dict:
    """Convierte la salida del modelo en un dict seguro para la web."""
    # Paso 1: parsear (puede fallar → JSON malformado)
    try:
        datos = json.loads(limpiar_json(texto_modelo))
    except json.JSONDecodeError:
        return RESPUESTA_FALLBACK

    # Paso 2: ¿es un objeto? (el modelo podría devolver una lista o un string)
    if not isinstance(datos, dict):
        return RESPUESTA_FALLBACK

    # Paso 3: campos con valores por defecto seguros
    resultado = {
        "categoria": datos.get("categoria", "otros"),
        "respuesta": datos.get("respuesta", ""),
        "fuentes": datos.get("fuentes", []),
        "confianza": datos.get("confianza", "baja"),
    }

    # Paso 4: normalizar valores fuera del enum
    if resultado["categoria"] not in CATEGORIAS_VALIDAS:
        resultado["categoria"] = "otros"
    if resultado["confianza"] not in NIVELES_CONFIANZA:
        resultado["confianza"] = "baja"
    if not isinstance(resultado["fuentes"], list):
        resultado["fuentes"] = []

    # Paso 5: reglas de coherencia del dominio
    if not resultado["respuesta"].strip():
        return RESPUESTA_FALLBACK
    if resultado["confianza"] == "baja":
        resultado["fuentes"] = []   # sin confianza no hay fuentes que citar

    return resultado

Explicación de cada capa de defensa:

  • limpiar_json ataca los dos fallos más frecuentes en la práctica: el envoltorio ```json … ``` y la frase de cortesía antes del objeto. Buscar del primer { al último } es un truco pragmático que rescata la mayoría de los casos (no todos: un JSON truncado a media cadena seguirá fallando, y está bien que falle — para eso está el paso 1).
  • try/except json.JSONDecodeError es innegociable: es el único modo seguro de saber si un texto es JSON válido. Nunca valides "a ojo" con startswith("{").
  • datos.get(campo, valor_por_defecto) en lugar de datos["campo"]: si el modelo omite un campo, obtienes un valor seguro en vez de un KeyError a las 3 de la mañana. Nota que cada defecto es el conservador: categoría otros, confianza baja — ante la duda, el sistema desconfía.
  • Normalización de enums: si el modelo devuelve "Permisos" (mayúscula) o "billing" (se le cruzó el inglés), el valor cae a otros/baja en lugar de propagarse como categoría fantasma por tu base de datos. (Podrías ser más indulgente y normalizar minúsculas antes de comparar; decide y documenta.)
  • Reglas de coherencia: la validación estructural (¿es JSON?, ¿tipos correctos?) no garantiza coherencia de dominio (¿confianza baja con tres fuentes citadas?). Esa última capa es tuya.
  • RESPUESTA_FALLBACK cierra el sistema: pase lo que pase, la web de soporte recibe un dict con la forma correcta y un mensaje digno. El fallo del modelo se degrada con elegancia en lugar de convertirse en una excepción para el usuario. En el módulo 3 añadiremos otra opción: reintentar la llamada cuando el parseo falla.

En proyectos reales, esta validación manual suele delegarse en librerías de esquemas como Pydantic (defines la clase, ella valida tipos y enums); el principio es idéntico y conviene haber escrito la versión manual una vez para entender qué te regala la librería.

Técnicas para robustecer la salida

Validar es la red de seguridad; estas técnicas reducen cuántas veces hace falta usarla:

  • Instrucción "solo JSON", explícita y repetida. "Responde ÚNICAMENTE con el objeto JSON, sin explicaciones, sin markdown" — y repítela al final del prompt si el contexto es largo (el "lost in the middle" de 01-04 también afecta a las instrucciones de formato).
  • Esquema + ejemplo, siempre juntos. La descripción define el contrato; el ejemplo lo demuestra. Si observas errores de formato recurrentes, añade un segundo ejemplo que cubra el caso conflictivo (few-shot aplicado a formato): por ejemplo, un ejemplo con confianza: "baja" para que el modelo vea también la forma del caso "no lo sé".
  • Campos enum mejor que campos libres. "confianza": "alta"|"media"|"baja" es robusto; "confianza": 0.87 invita al modelo a inventar una precisión decimal que no tiene (¿en qué se diferencia 0.87 de 0.79? En nada medible). Regla general: cuantos menos grados de libertad tenga un campo, más fiable será. Los enums, además, se validan con un simple in.
  • Estructuras planas mejor que anidamientos profundos. Un objeto de 4 campos falla poco; uno con tres niveles de anidamiento y arrays de objetos falla más. Si necesitas mucha estructura, plantéate si son varias llamadas encadenadas (02-02) con salidas simples.
  • Pide los campos "difíciles" en un orden que ayude. Colocar respuesta antes que confianza deja que la autoevaluación se apoye en la respuesta ya generada — es el principio de la cadena de razonamiento (02-02) aplicado dentro del JSON: primero el trabajo, después el juicio sobre el trabajo.
  • Temperatura baja. Para salidas estructuradas quieres el extremo determinista de la tabla de temperaturas de 01-02. La creatividad es enemiga del parser.

Y la buena noticia: no estarás solo en esto. Los principales proveedores ofrecen modos JSON y structured outputs nativos — parámetros de la API con los que el propio motor garantiza JSON sintácticamente válido o incluso conforme a un esquema formal que tú declaras. Los usaremos en cuanto lleguemos a la API en 03-01. ¿Por qué aprender entonces la versión "artesanal"? Tres razones: no todos los modelos/proveedores la ofrecen; los modos nativos garantizan sintaxis, no coherencia de dominio (la validación de los pasos 4-5 sigue siendo tuya); y entender el mecanismo te permite depurar cuando algo falla. Más adelante verás que el function calling (05-01) es la evolución natural de esta misma idea: en lugar de pedir JSON por las buenas, la API formaliza el esquema como la firma de una función que el modelo "rellena".

Control de otros formatos: markdown, longitud e idioma

JSON es el caso estrella, pero el control de formato es más amplio. Tres frentes que DocuBot necesita:

Markdown. El campo respuesta se renderizará en la web de soporte, que admite markdown básico. Conviene decir exactamente cuál:

El campo "respuesta" puede usar markdown básico: **negrita**, listas con
"-" y pasos numerados. No uses encabezados (#), tablas ni bloques de
código salvo que la respuesta incluya un ejemplo de la API.

Sin esta acotación, el modelo puede devolver encabezados ## que en un panel pequeño de la web se ven desproporcionados. El formato de salida se especifica para el consumidor real: un panel de soporte, un email, una celda de tabla admiten cosas distintas.

Longitud. Ya lo vimos en 02-01: los modelos no cuentan palabras con exactitud, pero las instrucciones de longitud desplazan la media de forma fiable. Funciona mejor especificar unidades estructurales que palabras: "máximo 3 pasos", "una frase por fuente", "2 párrafos". Las unidades discretas se respetan mejor que los recuentos ("máximo 147 palabras" no va a pasar de guía). Si la longitud es un límite duro (una columna de base de datos, un SMS), trúncala en código: la instrucción reduce los casos, la validación garantiza el límite.

Idioma. Nubelia opera en España y tiene equipos que escriben en castellano, catalán e inglés. La regla de DocuBot ya lo prevé ("responde en el idioma de la pregunta"), pero con salida JSON hay una sutileza que sorprende la primera vez: hay que separar el idioma del contenido del idioma de la estructura:

El campo "respuesta" debe estar en el idioma de la pregunta del usuario.
Las claves del JSON y los valores de "categoria" y "confianza" se
mantienen SIEMPRE en los valores exactos del esquema, sin traducir.

Sin la segunda frase, una pregunta en inglés puede producir "confianza": "high" — JSON perfectamente válido que tu enum rechazará. El esquema es código; el contenido es lenguaje. (Y fíjate en que la validación del apartado anterior ya te protegía: "high" habría caído a "baja". Las capas se apoyan entre sí.)

Errores Comunes y Consejos

  • Parsear con eval() en lugar de json.loads. Nunca: eval ejecuta código arbitrario, y estás procesando texto generado por un sistema no confiable. Es la diferencia entre parsear y ejecutar.
  • Asumir que "el modelo casi siempre devuelve JSON válido" equivale a "siempre". El 1-2 % de fallos de formato, con miles de peticiones, son decenas de errores diarios. El try/except y el fallback no son paranoia: son el coste de admisión.
  • Validar solo la sintaxis y no el dominio. {"categoria": "recetas", "confianza": "altisima"} es JSON impecable y basura para tu sistema. Enums y reglas de coherencia siempre.
  • Pedir números de confianza con decimales. Invitan a una falsa precisión. Enums de 3 niveles: menos expresivos, mucho más honestos y validables.
  • Esquemas kilométricos con 15 campos anidados. Cada campo extra es una oportunidad de error y más tokens. Pide lo que la aplicación consume hoy; añade campos cuando exista su consumidor.
  • Olvidar el caso de error en el esquema. Si el esquema solo contempla respuestas exitosas, el modelo meterá el "no lo sé" con calzador en campos pensados para otra cosa. Diseña el esquema del fallo (nuestra combinación confianza: "baja" + respuesta fija + fuentes: []) con el mismo cariño que el del éxito.
  • Consejo: guarda las salidas del modelo que rompan tu parser. Cada una es un caso de prueba gratuito para la lección 02-04 y un candidato a ejemplo few-shot de formato.

Ejercicios

Ejercicio 1. El equipo de soporte de Nubelia quiere que DocuBot detecte además la urgencia de cada pregunta para priorizar la cola. Amplía el esquema JSON del prompt de DocuBot con un campo urgencia bien diseñado (valores, criterio de asignación, ejemplo) y justifica tus decisiones frente a la alternativa "urgencia": 7 (escala numérica 1-10).

Ejercicio 2. Escribe qué devuelve parsear_respuesta_docubot (la función de la lección) para cada una de estas tres salidas del modelo, y qué paso de la función actúa en cada caso:

a) ```json\n{"categoria": "api", "respuesta": "El límite es de 100 peticiones/min.", "fuentes": ["Límites de la API"], "confianza": "alta"}\n``` b) {"categoria": "Facturación", "respuesta": "Puedes descargarla en Ajustes → Facturación.", "confianza": "media"} c) Lo siento, no puedo generar la respuesta en este momento.

Ejercicio 3. Escribe la instrucción de formato (el bloque FORMATO DE RESPUESTA del prompt) para otro caso de Nubelia: un prompt que recibe el texto de un ticket y debe devolver JSON con resumen (string, máximo 2 frases, en el idioma del ticket), producto_afectado (enum: "tableros", "api", "informes", "otros") y requiere_ingeniero (booleano true/false con criterio explícito). Incluye la instrucción "solo JSON" y un ejemplo válido.

Soluciones

Solución 1. Ampliación posible del esquema:

  "urgencia": "critica" | "alta" | "normal". Usa "critica" solo si el
              usuario describe un servicio caído o pérdida de datos en
              curso; "alta" si hay un bloqueo de trabajo sin alternativa;
              "normal" en el resto de casos (dudas, mejoras, consultas).

Ejemplo: {"categoria": "incidencias", "respuesta": "...", "fuentes":
["Estado del servicio"], "confianza": "alta", "urgencia": "critica"}

Justificación: un enum de 3 niveles con criterios observables ("servicio caído", "bloqueo sin alternativa") es asignable de forma consistente y validable con un in. Una escala 1-10 obliga al modelo a inventar distinciones que nadie ha definido (¿qué separa un 6 de un 7?), produce valores incomparables entre ejecuciones (no determinismo, 01-04) y complica el código consumidor (¿el umbral de prioridad es ≥6 o ≥7?). Regla: la granularidad del campo debe ser la que el consumidor realmente usa — y una cola de soporte distingue tres niveles, no diez.

Solución 2.

a) limpiar_json elimina el envoltorio ```json … ```, el parseo del paso 1 funciona y todos los valores pasan las validaciones: se devuelve el dict completo tal cual, con categoria: "api" y confianza: "alta". b) El JSON parsea bien (pasos 1-2), pero: falta fuentes → el paso 3 lo rellena con []; "Facturación" no está en el enum (mayúscula y tilde) → el paso 4 lo normaliza a "otros". Resultado: {"categoria": "otros", "respuesta": "Puedes descargarla en Ajustes → Facturación.", "fuentes": [], "confianza": "media"}. (Ilustra el coste de una normalización estricta: una variante trivial de mayúsculas pierde la categoría real; sería razonable aplicar .lower() y quitar tildes antes de comparar.) c) No hay { ni }: limpiar_json devuelve el texto tal cual, json.loads lanza JSONDecodeError y el paso 1 devuelve RESPUESTA_FALLBACK. La web muestra el mensaje neutro y ofrece escalado humano: degradación elegante.

Solución 3. Una solución posible:

FORMATO DE RESPUESTA:
Responde ÚNICAMENTE con un objeto JSON válido, sin texto antes ni
después y sin bloques de código. Esquema:

{
  "resumen": string, máximo 2 frases, en el idioma del ticket,
  "producto_afectado": "tableros" | "api" | "informes" | "otros",
  "requiere_ingeniero": booleano. true solo si el ticket describe un
      error reproducible del producto (fallo, dato incorrecto, caída);
      false si es una duda de uso, una petición de mejora o un problema
      de configuración del propio usuario.
}

Las claves y los valores de "producto_afectado" no se traducen nunca.

Ejemplo de respuesta válida:
{"resumen": "El usuario no puede exportar el informe semanal; la descarga falla con error.", "producto_afectado": "informes", "requiere_ingeniero": true}

Claves: instrucción "solo JSON" al principio, enum cerrado, booleano con criterio explícito de asignación (sin él, requiere_ingeniero sería una moneda al aire), nota de no-traducción y ejemplo en una línea que demuestra el contrato.

Conclusión

DocuBot ya habla dos idiomas: el de las personas (el campo respuesta, en el idioma del usuario, con su markdown acotado) y el de los programas (el contrato JSON categoria/respuesta/fuentes/confianza, con enums cerrados y su caso de fallo diseñado). Has aprendido las dos mitades inseparables de la disciplina: pedir bien (esquema + ejemplo, "solo JSON", campos con pocos grados de libertad) y validar siempre (json.loads protegido, valores por defecto conservadores, coherencia de dominio y un fallback digno), sabiendo que los modos structured outputs nativos que veremos en 03-01 te quitarán trabajo sintáctico pero nunca la validación de dominio, y que el function calling de 05-01 llevará esta idea un paso más allá. Pero queda una pregunta incómoda que este módulo aún no ha respondido: el prompt de DocuBot ya tiene identidad, reglas, ejemplos few-shot y un esquema de salida… ¿cómo sabemos que funciona? ¿Y cómo sabremos que la próxima "mejora" del prompt no empeora lo que ya funcionaba? La última lección del módulo convierte el prompting en ingeniería de verdad: versionado, conjuntos de casos de prueba y evaluación sistemática de prompts.

© Copyright 2026. Todos los derechos reservados