Перейти к основному содержимому

Техническая справка: AI-генерация меты

Документ для разработчиков. Описывает устройство фичи генерации меты и её применения к товарам (apply-meta).

Идея​

Генерация — это сохранённый переиспользуемый payload тех же групп, что и копирование между товарами: описание, meta description, теги, характеристики. Поэтому генерация применяется к товарам через тот же пайплайн apply-meta, что и «копирование из другого товара». Внешний AI изолирован интерфейсом.

Жизненный цикл асинхронный: POST /api/v1/generations сразу сохраняет строку со статусом PENDING (входы резолвятся в input_snapshot в этот момент) и после коммита запускает воркер (generation/GenerationWorker, @Async("aiGenerationExecutor") — свой пул на 2 потока, чтобы двухминутные job'ы не толкались с пайплайном). Воркер пишет output + DONE либо ERROR + error_message. Фронт поллит GET /{id}. Превью-режима «в памяти» нет — каждая генерация сохранена с момента запуска. generation/StaleGenerationSweeper на старте приложения помечает ERROR'ом PENDING-строки старше ai.generation.stale-pending-minutes (env AI_GENERATION_STALE_PENDING_MINUTES, дефолт 10) — их убил перезапуск сервера.

metaTitle не генерируется никогда — он заполняется SEO-шаблоном из app_config; при применении SEO-группы из генерации фронт передаёт текущий metaTitle товара, чтобы не затереть его. Контракт для реального AI-клиента: docs/ai/generation-contract.md.

Модель данных​

Таблица meta_generations — миграции back/.../db/changelog/changes/084-create-meta-generations.xml и 085-add-generation-error-message.xml (зарегистрированы в db.changelog-master.xml).

Сущность entity/MetaGenerationEntity.java:

ПолеТипНазначение
titlevarcharАвтозаголовок AI · {товар}; переименовывается в таблице генераций
statusvarchar(20)PENDING (воркер работает) → DONE | ERROR
errorMessagetextПричина при ERROR; чистится при regenerate
modelvarchar(100)Выбранная модель (значение с фронта; логики маршрутизации нет)
commenttextДоп. инструкция для AI
sourceProductIdbigint FKТовар-основа; FK ON DELETE SET NULL
genDescription/genSeo/genTags/genAttributesbooleanЧто было запрошено
inputSnapshotjsonb (Map<String,String>)Резолвленные значения товара, переданные AI (метка → значение)
outputjsonb (GenerationOutput)Результат

model/generation/GenerationOutput.java (JSONB): descriptionDoc (DescriptionDoc), metaDescription, tagNames: List<String> (теги — имена, не id), attributes: List<GenerationAttribute(name,value)>. Поля metaTitle нет.

JSONB маппится через @JdbcTypeCode(SqlTypes.JSON) (как descriptionDoc у товара).

Граница внешнего AI​

  • generation/MetaGenerationClient.java — интерфейс: GenerationOutput generate(GenerationCommand)
    • List<String> models() (алиасы для выпадающего списка в UI).
  • generation/GenerationCommand.java — вход: model, comment, description, seo, tags, attributes, inputs: Map<String,String>.
  • generation/StubMetaGenerationClient.java — синхронная заглушка (активна при ai.platform.enabled=false, по умолчанию): детерминированный плейсхолдер, заполняет только запрошенные группы, вплетает комментарий.
  • generation/AiPlatformMetaGenerationClient.java — реальный клиент MBC AI Platform (активен при ai.platform.enabled=true; см. ai-platform-integration.md). Создаёт job на канале seo_product_meta (POST /v1/generations?wait=N с обязательным Idempotency-Key), при 202 опрашивает GET /v1/jobs/{id} до терминального статуса; FAILED/REJECTED/таймаут → AiGenerationException (502 в GlobalExceptionHandler). Маппинг входов — §2.5 ai-platform-integration.md (Название → name, Характеристики → specs и т.д.). models() читает каталог GET /v1/channels с кэшем 5 минут.
  • generation/platform/ — AiPlatformProperties (@ConfigurationProperties("ai.platform"): enabled, baseUrl, apiKey, channel, waitSeconds, pollIntervalMs, pollTimeoutMs; env-переменные AI_PLATFORM_*), AiPlatformConfig (бин aiPlatformRestClient с заголовком X-Api-Key), DTO ответа платформы.

Платформа сама приводит result к схеме generation-contract.md (§2.2 интеграционного документа), поэтому дополнительной валидации в клиенте нет — результат десериализуется прямо в GenerationOutput.

Сервис и эндпоинты​

service/MetaGenerationService + service/impl/MetaGenerationServiceImpl:

  • start(StartGenerationRequest) — грузит товар, buildInputs() собирает выбранные поля в Map<метка,значение>, сохраняет PENDING-строку и после коммита (через TransactionSynchronization.afterCommit) запускает GenerationWorker.run(id).
  • list(productId) — все генерации или только по товару-основе; sourceProductName резолвится батчем для отображения/поиска.
  • get / update (только title) / delete / regenerate. regenerate возвращает строку в PENDING (ошибка чистится, старый output живёт до перезаписи) и заново запускает воркер; для уже PENDING — no-op.
  • buildInputs() — маппинг ключей полей (name, category, brand, country, price, description, attributes, tags) в русские метки (Название, Категория, Бренд, Страна, Цена, Текущее описание, Характеристики, Теги); эти метки читает клиент.

Контроллер controller/MetaGenerationController — /api/v1/generations:

МетодПутьНазначение
POST/Запуск: сохраняет PENDING и возвращает её (201); результат — поллингом
GET/?productId=Список (по createdAt desc), опционально по товару-основе
GET/modelsАлиасы моделей (каталог платформы или заглушка)
GET/{id}Одна генерация (этим же поллится статус)
PUT/{id}Переименование
DELETE/{id}Удалить (204)
POST/{id}/regenerateПерезапуск из сохранённого inputSnapshot

Доступ: /api/v1/generations/** → ADMIN, SUPER_ADMIN (в config/SecurityConfig). Исключение GenerationNotFoundException зарегистрировано в GlobalExceptionHandler.

Применение: apply-meta​

POST /api/v1/products/{id}/apply-meta (controller/ProductController), DTO dto/product/ApplyMetaRequest:

applyDescription + descriptionDoc
applySeo + metaTitle + metaDescription
applyTags + tagIds
applyAttributes + attributes

Каждый флаг applyX включает свою группу (false = не трогать, true = применить, в т.ч. пусто). ProductServiceImpl.applyMeta(...) — один @Transactional: описание/SEO выставляются, теги и MANUAL-характеристики полностью замещаются. Общая логика вынесена в приватные replaceTags(...) / replaceManualAttributes(...), которые переиспользуют setTags и setManualAttributes (валидация неизвестного тега откатывает транзакцию).

apply-meta не знает о генерациях — фронт резолвит генерацию в тот же черновик.

Фронтенд​

  • admin/products/[id]/generate/page.tsx — экран запуска: форма слева («Текущее описание» по умолчанию не выбрано), справа история генераций товара (GET /?productId=). После запуска — анимация ожидания с поллингом GET /{id}: до 60 с каждые 2 с, до 180 с каждые 10 с с таймером «следующее обновление через N с», дальше автополлинг останавливается (кнопка «Проверить ещё раз»). DONE → автредирект в /copy-from?generationId=; ERROR → текст ошибки + «Повторить». Открытие страницы с уже идущей генерацией подхватывает её поллинг.
  • admin/generations/page.tsx — таблица всех генераций: клиентский поиск (заголовок / модель / товар-основа), фильтр по статусу, ?productId= (предфильтр из истории товара), действия: применить / повторить / переименовать / удалить. Пока в списке есть PENDING — список сам обновляется раз в 5 с.
  • admin/products/[id]/copy-from/page.tsx — мастер. Источник (товар или генерация) приводится к общему ResolvedSource; вкладки источника; резолв tagNames → tagIds (несовпавшие → блок «создать/пропустить» через POST /api/v1/tags); поддержка ?generationId= (обёрнут в Suspense из-за useSearchParams в Next 16).
  • components/Admin/DescriptionEditor.tsx — добавлены необязательные onChange (живой поток значения, onChange держится в ref во избежание цикла ре-рендера) и hideSave.
  • Типы: types/generation.ts, types/product.ts (ApplyMetaRequest, ProductAttribute).

Тесты​

  • controller/ProductApplyMetaIT — apply-meta (все группы, applyX=false, замена MANUAL с сохранением AUTO, неизвестный тег → 404, 401).
  • controller/MetaGenerationControllerIT — async-цикл (start → поллинг до DONE), фильтр productId, rename/delete/regenerate, 401, 404. Тестовая транзакция отключена (@Transactional(NOT_SUPPORTED)): поток опирается на afterCommit и отдельный поток воркера, которые не срабатывают внутри откатываемой транзакции; данные чистятся в @AfterEach.
  • generation/StubMetaGenerationClientTest — unit на заглушку.
  • generation/AiPlatformMetaGenerationClientTest — unit на клиент платформы (MockRestServiceServer).

Запуск: cd back && ./gradlew test --tests "*MetaGeneration*" --tests "*ApplyMeta*" (нужен Docker для Testcontainers).