Inicio / Artículos / Deja de dejar que los LLM escriban HTML: componentes JSON semánticos para informes de IA

Deja de dejar que los LLM escriban HTML: componentes JSON semánticos para informes de IA

Por qué un equipo pasó sus informes generados por IA de HTML en bruto a un vocabulario JSON acotado que renderiza código determinista, y por qué los componentes semánticos ganan a las primitivas de maquetación.

2398 palabras

Dejar que un modelo de lenguaje escriba el HTML final de tu producto es una buena forma de empezar y una mala forma de seguir. Pensemos en un equipo que crea informes de insights de clientes generados por IA. La primera versión fue sencilla: pasar el análisis a un LLM y pedirle HTML. Unos meses después, el pipeline tenía decenas de pasos, montañas de marcado y estilos generados y prompts que hasta sus autores mantenían a duras penas.

Un prompt mejor no lo arregló, y un modelo más grande tampoco. Lo que funcionó fue quitarle libertad. El modelo dejó de producir la interfaz y pasó a producir JSON estructurado a partir de un conjunto pequeño de componentes semánticos, mientras el renderizado quedaba en manos de código corriente. El resultado es más predecible, más barato de operar y más fácil de evolucionar.

Por qué existían los informes

El producto empezó como un chat. La gente abría la app, preguntaba por el feedback de sus clientes y el modelo encontraba los insights relevantes. Técnicamente funcionaba. En la práctica cargaba mucho peso sobre el usuario, que tenía que saber qué preguntar, qué datos había, cómo formular una pregunta útil y qué temas merecían atención. Los usuarios avanzados se apañaban. Muchos otros se atascaban, y el problema no eran las respuestas. Simplemente no sabían qué preguntar.

Así que el equipo pasó de pull a push. Un responsable de datos define un análisis una vez y los resultados se entregan automáticamente. Primero fue el correo, que encajaba bien con resúmenes semanales cortos.

Los problemas llegaron cuando los análisis se hicieron más ricos. Los correos largos cuestan de leer en diagonal, las tablas se rompen en pantallas pequeñas y las secciones detalladas hacen que el mensaje parezca abarrotado. A partir de cierto punto, el correo es el contenedor equivocado.

De ahí los informes en HTML. El correo pasó a ser la notificación y el informe, el destino. Una página web tiene sitio para secciones, gráficos, tarjetas, rankings y detalles, y es fácil de compartir. El pipeline era simple:

Customer data
      │
      ▼
AI analysis workflow
      │
      ▼
Generated HTML + CSS
      │
      ▼
Hosted report
      │
      ▼
Email with link

Para los primeros informes funcionó sorprendentemente bien.

El HTML en bruto es un gran prototipo

La primera implementación era básica: pasar datos al modelo, mostrarle un ejemplo del resultado deseado y pedirle HTML. Al principio eso resulta atractivo. Los modelos escriben bien HTML y apenas hace falta infraestructura. Cambiar una maquetación, añadir una sección o probar un nuevo tipo de tarjeta suele significar editar un prompt y no construir componentes de frontend. Para informes pequeños, esa flexibilidad era valiosa.

El crecimiento dejó ver la debilidad. Un flujo de informe grande llegó a unos 40 pasos. Unos 30 obtenían o preparaban datos, y unos 10 existían solo para generar y ensamblar HTML. Un único informe podía contener cientos de kilobytes de marcado y CSS.

A ese tamaño, los problemas se retroalimentaban. El modelo tenía que seguir una especificación visual complicada, escribir respuestas enormes y mantener la coherencia en muchas secciones. Pequeños cambios en el prompt alteraban la maquetación de formas inesperadas, y el volumen de marcado generado encarecía todo sin necesidad.

El apaño evidente fue partir la página en trabajos más pequeños:

Analysis
   │
   ├── Generate sections 1–2 ─┐
   ├── Generate sections 3–4 ─┤
   ├── Generate sections 5–6 ─┼──► Combine HTML ─► Report
   ├── Generate sections 7–8 ─┤
   └── Generate sections 9–10 ┘

Eso resolvió los problemas inmediatos del lado del modelo. Las llamadas se hicieron más pequeñas, había menos contexto sobre el que razonar y los límites de salida dejaron de ser una amenaza.

Pero creó otro problema. Ahora alguien tenía que saber qué prompt generaba qué parte de la página, cómo encajaban los fragmentos, qué estilos iban dónde y cómo un cambio en una parte afectaba a las demás. Mantener un informe significaba entender una fábrica de HTML distribuida hecha de prompts.

No era un problema de prompts

Habría sido fácil tratarlo como una cuestión de prompting: instrucciones más precisas, mejores ejemplos, un modelo más nuevo para un marcado más consistente. Nada de eso habría tocado la causa real, que era el nivel de abstracción.

Un extremo da al modelo libertad total para emitir HTML y CSS arbitrarios:

Data → LLM → arbitrary HTML/CSS

Eso es máxima flexibilidad y casi ninguna previsibilidad.

El extremo opuesto es una plantilla fija en la que el modelo solo rellena el contenido:

Data → LLM → fixed fields → hard-coded template

Es eficiente y predecible, pero tampoco encaja, porque cada informe necesita de verdad una estructura distinta. Uno tiene cinco secciones, otro diez. Algunos quieren gráficos, otros rankings, otros texto largo y otros cuadrículas de insights cuantitativos.

Lo que hacía falta estaba en medio. El modelo debía componer un informe sin tener que implementarlo. Esa distinción definió el nuevo diseño.

Lecciones del correo y de Slack

El mismo problema existe fuera de la IA. Los desarrolladores de email llevan años conviviendo con él. Conseguir HTML fiable para correo es famoso por lo difícil, porque los clientes de correo soportan subconjuntos distintos y a menudo antiquísimos de HTML y CSS. Frameworks como MJML responden con un lenguaje de más alto nivel. Describes la estructura y el framework genera el marcado feo:

<mj-section>
  <mj-column>
    <mj-text>Hello world</mj-text>
  </mj-column>
</mj-section>

Block Kit de Slack sigue una idea parecida. Las apps no envían HTML en bruto a Slack. Envían JSON estructurado que describe secciones, texto, imágenes, botones y otros elementos, y Slack decide cómo se ven.

Eso se acercaba a lo que el equipo necesitaba. En lugar de pedirle al modelo algo así:

Create a blue card with a 24px title, two chips below it,
the percentage on the right and 16px padding.

querían que produjera algo así:

{
  "type": "QuantifiedInsight",
  "title": "Delivery complaints increased",
  "memoryCount": 184,
  "share": 0.27
}

La responsabilidad cambia por completo. El modelo describe qué significa el contenido y el renderizador se encarga de la implementación visual.

Por qué JSON encaja con los modelos

Luego surgió la pregunta de cómo debía ser el lenguaje de los informes. Un formato XML al estilo de MJML era posible, pero ganó JSON, y por una razón que tenía menos que ver con los gustos de los desarrolladores que con los modelos. Las APIs de LLM actuales son muy buenas emitiendo JSON estructurado, y JSON Schema puede describir con precisión la forma esperada. Eso da un contrato claro y permite validar la salida antes de renderizar nada.

Una definición simplificada del componente anterior:

{
  "type": "object",
  "properties": {
    "type": {
      "const": "QuantifiedInsight"
    },
    "title": {
      "type": "string"
    },
    "memoryCount": {
      "type": "integer",
      "minimum": 0
    },
    "share": {
      "type": "number",
      "minimum": 0,
      "maximum": 1
    }
  },
  "required": [
    "type",
    "title",
    "memoryCount",
    "share"
  ]
}

La libertad sigue ahí, pero acotada. Para producir este tipo de insight, el modelo debe aportar un título, un recuento y un porcentaje, y el recuento no puede ser negativo, mientras que el porcentaje tiene que estar entre cero y uno.

Antes, el prompt venía a decir «aquí tienes los datos y algunos consejos de diseño, ahora construye la página». El nuevo dice «aquí tienes los datos y estas son las piezas que puedes usar; describe el informe con ellas». Ese sistema es mucho más fácil de controlar.

Adaptive Cards como base

Inventar todo el formato desde cero no era atractivo. Block Kit de Slack era una referencia útil, pero pertenece al ecosistema de Slack. Las Adaptive Cards de Microsoft encajaban mejor: un modelo JSON abierto y declarativo con ideas ya resueltas para texto, contenedores, columnas, tablas e imágenes, además de librerías de renderizado sobre las que construir. Y, sobre todo, se podía ampliar con elementos propios de más alto nivel.

Las capas quedaron así:

AI-controlled
                             │
                             ▼
Customer data ─► Analysis ─► Report JSON
                             │
─────────────────────────────┼────────────────────────────
                             │
                        Code-controlled
                             ▼
                         Validation
                             │
                             ▼
                    Deterministic renderer
                             │
                             ▼
                            HTML

Esa línea es la decisión de diseño clave. Todo lo que está por encima puede ser generativo. Todo lo que está por debajo debe ser determinista: el mismo JSON tiene que dar siempre el mismo resultado.

Las primitivas genéricas seguían siendo demasiado laxas

Al principio el modelo recibió piezas de UI bastante genéricas. Podía crear un contenedor, añadir texto, insertar columnas y colocar elementos en ellas. Técnicamente funcionaba.

Pero pronto el equipo vio que simplemente había reconstruido el viejo problema un nivel más arriba. Si el modelo sigue eligiendo el número de columnas, la posición de un porcentaje, el orden de los chips y la disposición de las partes, la incoherencia tiene mucho margen para colarse. Un informe puede fallar aunque su JSON sea perfectamente válido.

Imagina una tarjeta que muestra un insight con un recuento absoluto y un porcentaje. Una versión genérica podría ser:

{
  "type": "InsightCard",
  "chips": [
    {
      "label": "27%",
      "style": "secondary"
    },
    {
      "label": "184 memories",
      "style": "primary"
    }
  ]
}

El modelo podría intercambiar los chips, duplicar uno, elegir el estilo equivocado u omitir uno por completo. Ninguna de esas opciones aporta nada a los usuarios.

Así que el componente se volvió semántico:

{
  "type": "QuantifiedInsight",
  "title": "Delivery complaints increased",
  "memoryCount": 184,
  "share": 0.27
}

Adiós al array de chips arbitrarios. Ahora hay un número de menciones y una proporción, y el renderizador sabe cómo presentarlos y en qué orden.

Este pequeño paso resume una lección importante: los componentes semánticos ganan a las primitivas genéricas de maquetación. Un ColumnSet dice cómo se dispone algo. Un QuantifiedInsight dice qué significa. Cuanto más se acerca el vocabulario al lenguaje propio del producto, menos trabajo de implementación le queda al modelo.

El renderizado vuelve a ser software normal

Un efecto secundario agradable es que el renderizado deja de ser un problema de IA. Una versión simplificada en TypeScript:

function renderElement(element: ReportElement) {
  switch (element.type) {
    case 'QuantifiedInsight':
      return renderQuantifiedInsight({
        title: element.title,
        count: element.memoryCount,
        percentage: element.share,
      });
    case 'Timeline':
      return renderTimeline(element);
    case 'Chart':
      return renderChart(element);
    default:
      return renderStandardAdaptiveCardElement(element);
  }
}

Una implementación real podría seguir usando Adaptive Cards por debajo, traduciendo al final el elemento propio a contenedores, bloques de texto y columnas. El modelo no necesita saberlo.

Así se obtiene una frontera de abstracción limpia. Si más adelante todos los insights cuantificados necesitan otro espaciado, otras fuentes u otros colores, cambias el renderizador una vez. No editas varios prompts esperando que el modelo obedezca la nueva instrucción cada vez. El sistema vuelve a comportarse como software normal.

Diseñar el vocabulario

Queda una cuestión de diseño: ¿cuán flexible debe ser cada componente? Un mal extremo es un único componente con decenas de propiedades opcionales:

{
  "type": "EverythingCard",
  "icon": "...",
  "rank": 1,
  "percentage": 0.27,
  "image": "...",
  "timeline": true,
  "compact": false
}

El otro es un componente nuevo para cada pequeña variación visual.

El equilibrio se parece al diseño de componentes de React. A veces dos vistas son un mismo componente con unas pocas props. Otras veces son conceptos distintos y conviene mantenerlos separados. El significado es el mejor criterio de desempate: si dos bloques transmiten la misma idea, puede bastar una propiedad acotada, y si transmiten cosas distintas, probablemente merecen componentes separados. El objetivo no es tener el menor número de componentes, sino un vocabulario que el modelo pueda usar de forma fiable.

Menos libertad, más flexibilidad útil

Al principio suena al revés. El modelo ya no puede generar HTML o CSS arbitrarios, no puede decidir libremente cómo se dispone cada elemento y algunos componentes tienen formas que no puede alterar. Y aun así el sistema entero se volvió más flexible.

La razón es que la flexibilidad útil no es lo mismo que la libertad de implementación. A los usuarios les importa si un informe puede albergar las secciones, insights, datos y formas visuales adecuados. Les da igual si un porcentaje se construye con un <div> o con tres elementos <span> anidados. Ese tipo de libertad añade variabilidad y ningún valor.

Al eliminarla, se le puede dar al modelo con seguridad más voz en las decisiones que importan: qué secciones son relevantes, qué componentes expresan mejor un insight, qué contenido va dónde y cuántos componentes necesita una sección. Lo que no puede hacer es reinventar la implementación de cada componente en cada ejecución. Es un reparto de responsabilidades más sano.

El patrón se generaliza

En realidad, la lección no va de HTML. Va de dónde encaja la IA generativa dentro de un sistema de software.

Para un prototipo, dejar que el modelo emita el artefacto final es muy potente. Avanzas rápido, te ahorras infraestructura y aprendes qué necesita el producto. En esa fase, la generación en bruto era lo correcto. El problema empieza cuando el prototipo pasa a formar parte de un sistema repetible, porque la generación directa se vuelve más difícil de controlar de lo que compensa. Un buen siguiente paso es subir al modelo un nivel en la arquitectura:

  • No le pidas que construya la interfaz. Pídele que la describa.
  • No le pidas que ensamble el documento final. Pídele la estructura semántica del documento.
  • No le pidas que resuelva los detalles de bajo nivel. Dale un pequeño lenguaje que cubra las decisiones que quieres que tome.

El patrón general:

Natural language
      │
      ▼
      AI
      │
      ▼
Small semantic language
      │
      ▼
Deterministic software
      │
      ▼
Final artifact

Cuando trabajas así, muchas preguntas dejan de ser prompt engineering y vuelven a ser diseño de software. ¿Qué conceptos debe poder expresar el modelo? ¿Dónde hace falta de verdad la flexibilidad? ¿Qué se puede quitar? ¿Cómo puede el esquema descartar estados inválidos o indeseados? ¿Dónde está la frontera entre las decisiones del modelo y las del código? Son problemas de ingeniería conocidos, y la IA generativa no los borra. Hace que elegir buenas abstracciones importe todavía más.

Qué viene ahora

El vocabulario aún se está puliendo. El primer informe ya se ha pasado al nuevo enfoque y se está comparando con el original antes de migrar más flujos. El ritmo gradual es deliberado: un sistema de componentes construido alrededor de un solo informe probablemente esté sobreajustado, mientras que uno que funciona en varios informes reales empieza a convertirse en una plataforma. Es probable que algunos componentes desaparezcan, otros se simplifiquen y surjan nuevos elementos semánticos.

La dirección, eso sí, está clara. El modelo decide qué decir y qué piezas usar. El software decide exactamente cómo se comportan y se renderizan esas piezas. Esa frontera convirtió una maraña creciente de prompts de HTML en algo muy parecido al software de siempre. Una buena regla práctica: usa el modelo para las decisiones que necesitan inteligencia y el código para las que necesitan certeza.