Block Generation ADR

Table of Contents

1. Статус и назначение документа

Статус: принят и отражает текущее реализованное поведение кода.

Назначение документа: зафиксировать архитектурные решения для функции AI-генерации одного блока, чтобы отделить её от deterministic rendering, ручного авторинга, prompt slicing и прямой работы с хранилищем.

2. Контекст

В TextFoundry блок является переиспользуемой единицей prompt-структуры. Обычный путь работы с блоком детерминирован:

  • пользователь создаёт или редактирует блок в редакторе;

  • engine валидирует и публикует новую версию;

  • дальнейший render использует уже сохранённые блоки.

При этом в продукте есть отдельный пользовательский сценарий, в котором оператор хочет:

  • быстро получить черновик нового блока по текстовой инструкции;

  • доработать существующий блок через AI как draft suggestion;

  • не смешивать AI-вызов с обычным render path;

  • не терять контроль над финальной публикацией.

По коду это уже реализовано как explicit authoring workflow:

  • контракт задаётся в tf::IBlockGenerator;

  • orchestration выполняет tf::Engine;

  • конкретный provider живёт в textfoundry_ai;

  • GUI загружает результат только в форму редактора блока;

  • сохранение остаётся отдельным ручным действием пользователя.

3. Проблема, которую нужно было решить

Нужно было встроить AI-генерацию в систему так, чтобы одновременно соблюсти несколько требований.

  • Не загрязнять deterministic renderer внешними сетевыми вызовами.

  • Не привязывать core engine к OpenAI-compatible API или конкретному вендору.

  • Не разрешать AI автоматически публиковать новые версии блоков.

  • Не позволять генератору создавать некорректные или конфликтующие block_id без дополнительной проверки.

  • Сохранить единый UX: AI только помогает автору, но не заменяет обычный lifecycle блока.

4. Рассмотренные варианты

Вариант A. Встроить генерацию прямо в renderer или общий render path

Идея: при нехватке данных или по специальным параметрам вызывать LLM прямо из операций render.

Почему отвергнуто:

  • render path перестаёт быть детерминированным;

  • появляются внешние сетевые зависимости там, где сейчас работает локальная композиция блоков;

  • поведение становится сложнее отлаживать и повторять;

  • результат генерации смешивается с пользовательским output, а не с authoring.

Вариант B. Поместить provider-specific вызовы прямо в GUI

Идея: весь HTTP, prompt construction и parsing держать в BlockEditorViewModel без отдельного engine contract.

Почему отвергнуто:

  • бизнес-правила валидации расползаются по UI;

  • TUI, GUI и тесты не смогут использовать единый orchestration layer;

  • core перестаёт контролировать правила идентификаторов, типов и коллизий;

  • появляется жёсткая связка UI с конкретным протоколом провайдера.

Вариант C. Генерировать и автоматически сохранять блок как новую версию

Идея: после успешного ответа модели сразу создавать draft или даже publish.

Почему отвергнуто:

  • пользователь теряет review step;

  • низкокачественный или опасный текст может попасть в хранилище без проверки;

  • становится неясно, кто несёт ответственность за финальный контент: оператор или модель;

  • ломается привычный lifecycle блока, где публикация является отдельным осознанным действием.

Вариант D. Использовать единый интерфейс для single-block generation и batch slicing

Идея: не различать генерацию одного блока и разбор большого prompt на набор блоков.

Почему отвергнуто как основное представление функции:

  • это два близких, но разных пользовательских сценария;

  • для batch slicing нужны отдельные ограничения по сохранению структуры, reuse existing ids и размеру набора;

  • в UI это разные точки входа и разные review workflows.

В результате batch capability сохранена рядом, но оформлена как отдельная функция Prompt Slicing, хотя технически использует тот же provider layer.

5. Принятое решение

Принято следующее архитектурное решение.

  • AI-генерация блока оформляется как явная authoring-операция, а не как часть render execution.

  • Core зависит только от порта IBlockGenerator.

  • Реализация OpenAI-compatible integration живёт в отдельном AI-модуле.

  • Engine принимает на себя orchestration и обязательную пост-валидацию ответа модели.

  • GUI получает структурированный результат и загружает его в форму как suggestion, но не публикует автоматически.

  • Для существующего блока AI-ревизия использует тот же порт, но с усиленными инструкциями на сохранение идентичности блока.

6. Детализация принятого решения

6.1. Архитектурная граница проходит по IBlockGenerator

Причина: engine должен знать только о факте "есть сервис, который по запросу может вернуть `GeneratedBlockData`", но не должен знать:

  • какой именно API используется;

  • как собираются HTTP-запросы;

  • какой prompt format нужен конкретному провайдеру;

  • как устроены вендорские response envelope и JSON-поля.

Это реализовано через интерфейс:

  • GenerateBlock(const BlockGenerationRequest&)

  • GenerateBlocks(const PromptSlicingRequest&)

Следствие: provider можно заменить на другой remote backend, локальную модель или детерминированный test double без переписывания core.

6.2. Engine выполняет не только делегирование, но и нормализацию правил

Причина: нельзя доверять ответу модели как уже готовому доменному объекту.

После вызова генератора engine выполняет обязательную валидацию:

  • проверяет наличие настроенного генератора;

  • обрабатывает ошибки провайдера как обычный Result;

  • валидирует block_id;

  • запрещает пустой template;

  • проверяет коллизии по existing_block_ids;

  • при batch generation дополнительно следит за конфликтами внутри самой пачки.

Это критично, потому что AI может вернуть формально корректный JSON, но доменное содержимое всё равно может быть недопустимым.

6.3. Результат генерации не пишет данные в storage напрямую

Причина: архитектурно генерация и публикация имеют разный уровень доверия.

Генерация:

  • создаёт suggestion;

  • может ошибаться по смыслу;

  • зависит от внешнего провайдера и prompt policy;

  • не является достаточным основанием для изменения canonical project state.

Публикация:

  • создаёт новую версию блока;

  • влияет на последующий render;

  • должна оставаться контролируемой и обозримой операцией.

Поэтому BlockEditorViewModel::generate() и revise() только заполняют поля формы, а сохранение происходит позже через обычный save/publish workflow.

6.4. Для generate и revise используется одна capability, но с разными ограничениями

Причина: создание нового блока и доработка существующего блока похожи по механике, но различаются по допустимым изменениям.

Для нового блока:

  • можно предложить новый id;

  • коллизии запрещены;

  • AI может заполнить все основные поля блока.

Для ревизии:

  • preferred_id, preferred_type и preferred_language задаются из текущего блока;

  • в prompt явно добавляется требование не менять identity блока;

  • включается allow_id_collision = true, потому что редактируется тот же блок;

  • UI загружает только изменённые содержательные поля в форму и требует вручную сохранить новую версию.

Это позволяет не плодить отдельный интерфейс для revision, но при этом сохранить другой policy.

6.5. Prompt policy хранится в AI-модуле, а не в UI и не в core domain

Причина: prompt constants, schema expectations и вендорская сериализация относятся к слою интеграции, а не к доменной модели блока.

Там фиксируются важные поведенческие требования:

  • вернуть только структурированный payload;

  • использовать templ как имя поля шаблона;

  • ограничиться допустимыми типами блока;

  • сохранять язык исходного содержания, если перевод явно не запрошен;

  • предпочитать полный, пригодный к использованию контент, а не краткую сводку.

Это защищает core от "prompt leakage" и даёт возможность менять prompt policy независимо от модели хранения блоков.

6.6. Block Generation и Prompt Slicing разделены в документации и UX

Причина: несмотря на общий provider contract, это разные продуктовые возможности.

Block Generation:

  • генерирует один блок;

  • инициируется из редактора блока;

  • ориентирован на authoring одного reusable unit.

Prompt Slicing:

  • раскладывает длинный текст на набор блоков;

  • инициируется из отдельного сценария slicing;

  • требует batch validation и дополнительного review набора.

Такое разделение делает ADR точнее: решение про single-block generation не размывается batch-сценариями.

7. Последствия решения

Положительные

  • deterministic render path остаётся чистым и воспроизводимым;

  • core engine не зависит от конкретного LLM API;

  • UI сохраняет human-in-the-loop модель;

  • качество guardrails выше, потому что engine валидирует результат после AI;

  • проще тестировать доменные ограничения отдельно от HTTP и prompt policy;

  • можно отключить AI-конфигурацию без потери обычного authoring workflow.

Нейтральные или компромиссные

  • для пользователя появляется дополнительный шаг review-before-save;

  • часть логики revision prompt живёт в GUI, а не полностью в engine;

  • single-block generation и batch slicing используют общий порт, что требует дисциплины в документации и именовании сценариев.

Отрицательные

  • latency и надёжность зависят от внешнего провайдера;

  • даже валидный payload может быть слабым по качеству и потребовать ручной переработки;

  • при неверной prompt policy возможна деградация качества без изменения доменных контрактов.

8. Риски и меры снижения

Риск: модель вернёт хороший JSON, но плохой по смыслу блок

Снижение:

  • обязательный review в редакторе;

  • отсутствие автопубликации;

  • статусные сообщения в UI;

  • последующее ручное сохранение только после проверки пользователем.

Риск: модель создаст конфликтующий или нестабильный block_id

Снижение:

  • post-validation в engine;

  • проверка существующих id;

  • строгие правила формата идентификатора;

  • отдельный режим allow_id_collision только для revision того же блока.

Риск: AI-функция случайно станет зависимостью обычного render path

Снижение:

  • отдельные API GenerateBlockData / GenerateBlockDraft;

  • отсутствие скрытых вызовов генератора из Render;

  • отдельная документация на authoring capability.

Риск: provider lock-in

Снижение:

  • зависимость core только от IBlockGenerator;

  • провайдерская реализация вынесена в adapter layer;

  • конфигурация AI-подсистемы проходит через session layer.

Риск: пользователь примет AI-черновик как готовый production-content

Снижение:

  • в UX результат явно обозначается как suggestion/revision;

  • сохранение новой версии не происходит автоматически;

  • workflow редактора требует осознанного Save.

9. Что решение сознательно не покрывает

Данный ADR не фиксирует:

  • multi-provider routing policy;

  • автоматический quality scoring generated blocks;

  • историю prompt/response как отдельный аудит-объект;

  • массовую пакетную генерацию новых блоков вне slicing-сценария;

  • server-side orchestration для удалённого prompt service.

Эти темы могут быть оформлены отдельными ADR при появлении соответствующей реализации.

10. Влияние на связанные артефакты

Решение влияет на следующие документы и кодовые границы:

  • PRD для описания user-facing сценария draft generation;

  • System Design для component/data flow;

  • Service Spec для описания BlockGenerationRequest и ответа;

  • Runbook для отказов провайдера и деградации;

  • Security Note для правил работы с внешним AI endpoint;

  • GUI BlockEditorViewModel;

  • core tf::Engine;

  • AI adapter OpenAiCompatibleBlockGenerator.

11. Открытые вопросы

  • Нужно ли со временем вынести revision prompt assembly из GUI в отдельный сервисный слой, чтобы вся prompt policy собиралась консистентно в одном месте.

  • Нужно ли добавить quality gates поверх структурной валидации, например минимальные проверки на содержательность description/template.

  • Нужен ли отдельный режим "generate as draft object" с явным сохранением в draft-хранилище без немедленной публикации.

Связанные документы