Головна / Статті / Досить дозволяти LLM писати HTML: семантичні JSON-компоненти для AI-звітів

Досить дозволяти LLM писати HTML: семантичні JSON-компоненти для AI-звітів

Чому команда перевела AI-звіти із сирого HTML на обмежений словник JSON, який рендерить детермінований код, і чим семантичні компоненти кращі за примітиви верстки.

2398 слів

Дозволити мовній моделі писати фінальний HTML для продукту — нормальний спосіб почати й поганий спосіб продовжувати. Візьмімо команду, яка робить згенеровані AI звіти з інсайтами про клієнтів. Перша версія далася легко: віддати аналіз LLM і попросити HTML. За кілька місяців у пайплайні були десятки кроків, гори згенерованої розмітки та стилів і промпти, у яких ледве розбиралися навіть їхні автори.

Кращий промпт проблему не розв'язав, більша модель — теж. Спрацювало інше: у моделі забрали свободу. Вона перестала видавати інтерфейс і почала видавати структурований JSON із невеликого набору семантичних компонентів, а рендерингом зайнявся звичайний код. Результат став передбачуванішим, дешевшим в експлуатації та простішим у розвитку.

Навіщо взагалі знадобилися звіти

Продукт починався як чат. Люди відкривали застосунок, питали про відгуки клієнтів, і модель знаходила доречні інсайти. Технічно все працювало. На практиці це сильно навантажувало користувача: йому треба було розуміти, що питати, які дані є, як сформулювати корисне запитання і на які теми варто дивитися. Досвідчені користувачі давали раду. Багато інших буксували, і річ була не у відповідях. Вони просто не знали, про що спитати.

Тож команда перейшла від моделі pull до push. Власник даних один раз описує аналіз, а результати надходять автоматично. Спершу це була пошта, і для коротких щотижневих зведень вона пасувала.

Проблеми почалися, коли аналізи стали багатшими. Довгі листи важко переглядати, таблиці ламаються на маленьких екранах, а детальні розділи перевантажують повідомлення. Після певного моменту пошта — невідповідний контейнер.

Звідси HTML-звіти. Лист став сповіщенням, а звіт — місцем призначення. На вебсторінці вистачає місця для розділів, графіків, карток, рейтингів і деталей, і нею легко поділитися. Пайплайн був простим:

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

Для перших кількох звітів це працювало напрочуд добре.

Сирий HTML — чудовий прототип

Перша реалізація була примітивною: передати моделі дані, показати приклад бажаного результату й попросити HTML. На ранньому етапі це привабливо. Моделі добре пишуть HTML, а інфраструктури майже не потрібно. Змінити верстку, додати розділ чи спробувати новий тип картки часто означає підправити промпт, а не писати фронтенд-компоненти. Для невеликих звітів така гнучкість була цінною.

Із ростом виявилася слабкість. Один великий процес генерації звіту доріс приблизно до 40 кроків. Близько 30 із них отримували чи готували дані, і ще приблизно 10 існували лише для того, щоб згенерувати й зібрати HTML. Один звіт міг містити сотні кілобайтів розмітки та CSS.

На такому обсязі проблеми підживлювали одна одну. Моделі доводилося дотримуватися складної візуальної специфікації, писати величезні відповіді й зберігати одноманітність у багатьох розділах. Дрібні правки промпту несподівано змінювали верстку, а сам обсяг згенерованої розмітки робив усе невиправдано дорогим.

Очевидний обхідний шлях — розрізати сторінку на менші завдання:

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

Це зняло безпосередні проблеми на боці моделі. Виклики стали меншими, контексту для міркувань — менше, а ліміти на вивід перестали загрожувати.

Але з'явилася нова проблема. Тепер хтось мав знати, який промпт відповідає за яку частину сторінки, як фрагменти складаються докупи, які стилі куди належать і як зміна в одній частині впливає на інші. Підтримувати звіт означало розбиратися в розподіленій фабриці HTML із промптів.

Річ була не в промптах

Найпростіше було б вважати це проблемою промптингу: чіткіші інструкції, кращі приклади, свіжа модель для стабільнішої розмітки. Ніщо з цього не зачепило б справжньої причини — рівня абстракції.

Одна крайність дає моделі повну свободу видавати довільні HTML і CSS:

Data → LLM → arbitrary HTML/CSS

Це максимум гнучкості й майже жодної передбачуваності.

Протилежна крайність — фіксований шаблон, у якому модель лише заповнює вміст:

Data → LLM → fixed fields → hard-coded template

Це ефективно й передбачувано, але теж не пасує: різним звітам справді потрібна різна структура. В одному п'ять розділів, в іншому десять. Десь потрібні графіки, десь рейтинги, десь довгий текст, десь сітки кількісних інсайтів.

Потрібне лежало посередині. Модель має складати звіт, але не реалізовувати його. Саме ця відмінність визначила новий дизайн.

Уроки email і Slack

Та сама проблема існує й поза AI. Розробники листів живуть із нею роками. Надійний HTML для email сумнозвісно складний, бо поштові клієнти підтримують різні й часто давні підмножини HTML і CSS. Фреймворки на кшталт MJML відповідають на це мовою вищого рівня. Ви описуєте структуру, а фреймворк генерує потворну розмітку:

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

Block Kit у Slack побудований на схожій ідеї. Застосунки не надсилають у Slack сирий HTML. Вони надсилають структурований JSON з описом розділів, тексту, зображень, кнопок та інших елементів, а Slack сам вирішує, як це виглядатиме.

Це було близько до того, чого потребувала команда. Замість того щоб просити в моделі щось таке:

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

вони хотіли отримувати щось таке:

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

Відповідальність повністю зміщується. Модель описує, що означає вміст, а рендерер вирішує, як це реалізувати візуально.

Чому JSON пасує моделям

Далі постало питання, якою має бути мова звітів. Можна було зробити XML-формат у дусі MJML, але переміг JSON, і причина стосувалася не так смаків розробників, як моделей. Сучасні API для LLM чудово видають структурований JSON, а JSON Schema дає змогу точно описати очікувану форму. Це дає чіткий контракт і можливість перевірити вивід до того, як щось буде відрендерено.

Спрощене визначення компонента вище:

{
  "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"
  ]
}

Свобода лишається, але вона обмежена. Щоб видати такий інсайт, модель мусить вказати заголовок, кількість і відсоток, причому кількість не може бути від'ємною, а відсоток має лежати між нулем і одиницею.

Раніше промпт зводився до «ось дані й трохи порад щодо дизайну, тепер збери сторінку». Новий звучить так: «ось дані, а ось будівельні блоки, якими тобі дозволено користуватися; опиши звіт за їхньою допомогою». Такою системою керувати значно простіше.

Adaptive Cards як основа

Винаходити весь формат з нуля не хотілося. Block Kit від Slack був корисним орієнтиром, але належить екосистемі Slack. Microsoft Adaptive Cards пасували краще: відкрита декларативна модель на JSON із готовими рішеннями для тексту, контейнерів, колонок, таблиць і зображень, плюс бібліотеки рендерингу, на які можна спертися. Найголовніше — її можна було розширити власними елементами вищого рівня.

Шари зрештою виглядали так:

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

Ця межа — ключове архітектурне рішення. Усе, що над нею, може бути генеративним. Усе, що під нею, має бути детермінованим: однаковий JSON щоразу має давати однаковий результат.

Загальні примітиви все ще давали забагато свободи

Спершу модель отримала доволі загальні будівельні блоки UI. Вона могла створити контейнер, додати текст, вставити колонки й розмістити в них елементи. Технічно це працювало.

Проте невдовзі команда зрозуміла, що просто відтворила стару проблему рівнем вище. Коли модель і далі обирає кількість колонок, місце для відсотка, порядок чипів і розташування частин, у непослідовності лишається безліч шпарин. Звіт може провалитися, навіть якщо його JSON цілком валідний.

Уявіть картку з інсайтом, абсолютною кількістю та відсотком. Загальна версія могла б виглядати так:

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

Модель могла поміняти чипи місцями, продублювати один, обрати не той стиль чи взагалі загубити один із них. Жоден із цих варіантів нічого не дає користувачам.

Тому компонент став семантичним:

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

Масиву довільних чипів більше немає. Є кількість згадок і частка, і рендерер знає, як і в якому порядку їх показати.

Цей невеликий крок відображає важливий урок: семантичні компоненти кращі за загальні примітиви верстки. ColumnSet каже, як щось розташовано. QuantifiedInsight каже, що це означає. Що ближчий словник до мови самого продукту, то менше роботи з реалізації лишається моделі.

Рендеринг стає звичайним софтом

Приємний побічний ефект: рендеринг перестає бути AI-задачею. Спрощена версія на 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);
  }
}

Реальна реалізація може й далі використовувати Adaptive Cards під капотом, і власний елемент зрештою перетворюється на контейнери, текстові блоки та колонки. Моделі про це знати не треба.

Так виходить чиста межа абстракції. Якщо згодом усім кількісним інсайтам знадобляться інші відступи, шрифти чи кольори, ви змінюєте рендерер один раз. Не треба правити кілька промптів і сподіватися, що модель щоразу виконає нову інструкцію. Система знову поводиться як звичайне ПЗ.

Проєктування словника

Лишається питання дизайну: наскільки гнучким має бути кожен компонент? Одна погана крайність — єдиний компонент із десятками необов'язкових властивостей:

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

Інша — новий компонент на кожну крихітну візуальну варіацію.

Цей компроміс схожий на проєктування React-компонентів. Іноді два подання — це один компонент із парою пропсів. Іноді це різні поняття, і їх варто тримати окремо. Найкращий арбітр — зміст: якщо два блоки передають одну ідею, вистачить обмеженої властивості, а якщо різні — їм, найпевніше, потрібні окремі компоненти. Мета не в мінімумі компонентів, а в словнику, яким модель може надійно користуватися.

Менше свободи, більше корисної гнучкості

Спершу це звучить парадоксально. Модель більше не може генерувати довільні HTML чи CSS, не може вільно вирішувати, як розташувати кожен елемент, а деякі компоненти мають форму, яку вона не здатна змінити. І водночас система загалом стала гнучкішою.

Річ у тім, що корисна гнучкість — не те саме, що свобода реалізації. Користувачам важливо, чи може звіт вмістити потрібні розділи, інсайти, дані й візуальні форми. Їм байдуже, чи зібрано відсоток з одного <div> чи з трьох вкладених <span>. Така свобода додає варіативності й не додає цінності.

Коли її прибрали, моделі можна безпечно довірити більше рішень, які справді важать: які розділи доречні, які компоненти найкраще виражають інсайт, який вміст куди ставити і скільки компонентів потрібно розділу. Чого вона не може — то це щоразу заново винаходити реалізацію кожного компонента. Це здоровіший розподіл обов'язків.

Патерн узагальнюється

Урок насправді не про HTML. Він про те, де генеративному AI місце всередині програмної системи.

Для прототипу дозволити моделі видавати фінальний артефакт — потужний прийом. Ви рухаєтеся швидко, обходитеся без інфраструктури й дізнаєтеся, що потрібно продукту. На тому етапі сира генерація була правильним вибором. Проблеми починаються, коли прототип стає частиною відтворюваної системи, бо прямою генерацією стає важче керувати, ніж вона того варта. Добрий наступний крок — підняти модель на рівень вище в архітектурі:

  • Не просіть її будувати інтерфейс. Просіть описати інтерфейс.
  • Не просіть збирати фінальний документ. Просіть семантичну структуру документа.
  • Не просіть вирішувати низькорівневі деталі. Дайте їй невелику мову, що покриває ті рішення, які ви хочете їй довірити.

Загальний патерн:

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

Коли працюєш так, багато питань перестають бути промпт-інжинірингом і знову стають проєктуванням ПЗ. Які поняття модель має вміти виражати? Де гнучкість справді потрібна? Що можна прибрати? Як схема може виключити невалідні чи небажані стани? Де проходить межа між рішеннями моделі й рішеннями коду? Це знайомі інженерні задачі, і генеративний AI їх не скасовує. Він лише робить вибір добрих абстракцій ще важливішим.

Що далі

Словник досі доопрацьовується. Перший звіт уже переведено на новий підхід, і його порівнюють з початковим, перш ніж переносити інші процеси. Поступовий темп обрано свідомо: система компонентів, побудована навколо одного звіту, найімовірніше, перенавчена під нього, а та, що працює на кількох реальних звітах, починає перетворюватися на платформу. Якісь компоненти, ймовірно, зникнуть, інші спростяться, з'являться нові семантичні елементи.

Напрям при цьому зрозумілий. Модель вирішує, що сказати і які будівельні блоки використати. Софт точно визначає, як ці блоки поводяться й рендеряться. Ця межа перетворила дедалі більший клубок HTML-промптів на щось близьке до звичайного ПЗ. Добре емпіричне правило: витрачайте модель на рішення, яким потрібен інтелект, а код — на рішення, яким потрібна визначеність.