Привет, Хабр! 

Я — Мила Муромцева, системный аналитик в Альфа-Банке. Пишу эту статью, чтобы поделиться нашим опытом, а с кодом и техчастью мне помогает наш разработчик Миша Буторин. Расскажем, как мы научили LLM проверять документацию для платформы Альфа-Онлайн: переписали стандарт, поборолись с токенами и немного с хаосом.

Апдейт — март 2026: обновлены некоторые схемы, часть кода, полностью обновлена глава «Анализ необходимости запуска сценария»

TL;DR (если коротко)

Мы разработали и внедрили AI-агента в процесс ревью документации в Confluence на платформе Альфа-Онлайн (веб-приложение Альфа Банка). Этот инструмент автоматизирует проверку документации, освобождая аналитиков от рутинных задач.

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

AI-агент полезен платформенным аналитикам (ревьюверам) и аналитикам продуктовых команд (авторам документации). Агент помогает быстрее получать обратную связь, фокусируясь на ключевых задачах.

Контекст и боль

Контекст

Чтобы разобраться в проблеме, давайте взглянем на наши процессы. Прежде чем зарелизить фичу, команда готовит поставку. Например, разработчик команды проходит код-ревью, а аналитик — ревью документации. Весь процесс подготовки поставки проходит по определенному workflow в Jira, в котором есть этап Docs review. 

У платформенных аналитиков есть так называемые «дежурства», во время которых они проверяют поставки. Одна из задач платформенных аналитиков — провести ревью документации. Они проверяют, что документация написана в соответствии со стандартом и все новые изменения по продуктам там отражены. В общем, стоят на страже качества документации Банка.

Проблематика

Все бы хорошо, но отсюда вытекают следующие проблемы:

  • На этапе Docs review возникают задержки, т.к. у платформенного аналитика, помимо ревью, есть и множество других задач.

  • Иногда большое количество поставок выбивает из рабочего процесса и утомляет ревьювера. С этим определенно надо что-то делать.

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

Почему они мешали жить?

Данный этап влияет на lead time и замедляет процесс подготовки к релизу. А релизиться часто нужно ASAP. 

Несогласованность в логике ответов разных ревьюверов иногда может сбивать с толку аналитиков продуктовых команд. Не хотелось бы, чтобы аналитики задавались вопросом, а на что обратят внимание ревьюверы в этот раз? 

Какие идеи были ранее и почему не подошли?

№1. Отказаться от ревью совсем. Радикальн��, экономит время всех. Но в таком случае мы очень сильно теряем качество. На документацию смотрят не только участники разных команд, но и саппорты. Отпустив контроль полностью мы теряем важные детали работы функционала, а что еще важнее — место, где лежит документация. Сейчас все располагается в едином пространстве Confluence по всей платформе, что проверяется в том числе. 

Иначе говоря, возвращаемся к тому, от чего ушли раньше. По кругу ходить как-то не хочется :) 

№2. Использовать алгоритмическую автоматизацию (не AI based) для проверки. Можно конечно, но будет куча If’ов. Как ни крути, у документации нет синтаксиса, как в разработке. Все пишут по-разному. Сопровождать алгоритм выйдет сильно дороже ручной проверки.

Зачем здесь AI-агент?

  • Работа с текстом. AI-агент умеет хорошо работать с текстом и понимать суть, работать с разными разметками (HTML, Markdown и т.д.) и представлениями данных (сплошной текст, таблицы). 

  • Гибкость в изменениях. В большинстве случаев для изменения или корректировки логики достаточно обновить промпт. Это может сделать аналитик, не привлекая ресурсы разработчика. 

  • Мгновенная обратная связь. У агента одна задача — провести ревью документации. К задачам он приступает сразу. Не нужно ждать, пока платформенный аналитик проверит документацию.

  • Встраивание в производственный процесс. Агент встраивается в существующие процессы разработки и ревью в Jira (в будущем это будет масштабируемая история). Это делает его невидимым помощником в экосистеме компании.

  • Прозрачность процесса ревью. У агента есть четкий набор инструкций (как проверять) и требований (что проверять). Он будет работать всегда единообразно с минимизацией человеческого фактора.

Его ценность

  • Снижение времени проверки: автоматическое выявление нарушений или несоответствий.

  • Стабилизация качества: единый стандарт проверки вне зависимости от человеческого фактора.

Как всё устроено под капотом

Desmond — task-oriented AI-агент для автоматизации ревью внутренней документации на платформе Альфа-Онлайн.

Его задача — снижать нагрузку на платформенных аналитиков, ускорять фидбек для продуктовых аналитиков и обеспечивать единый стандарт качества.

Особенности

  • Task-oriented cognitive assistant: выполняет одну интеллектуальную задачу — ревью документации.

  • Без диалогового интерфейса: запускается по событию (webhook из Jira), работает реактивно и без состояния.

  • Встроенная персона — Desmond, без взаимодействия с пользователем.

  • LLM-as-a-Service: использует open-source LLM (gpt-oss-120b) через API, без fine-tuning, RAG и обучения (внутренний продукт Банка “AlfaGen”).

Few-shot prompting: логика работы задаётся через промпты с примерами.

Архитектура

  1. Точка входа — задача в Jira, переходящая в нужный статус, триггерит webhook.

  2. Основные шаги:

    • Определение, нужна ли вообще проверка.

    • Поиск релевантной документации по фиче.

    • Проверка соответствия стандартам.

    • Формирование и отправка комментария с результатами в Jira.

Технологии

  • Инфраструктура: Docker + Kubernetes во внутреннем AI-кластере банка.

  • Язык: Java 21 + Spring AI.

  • Модель: gpt-oss-120b, локально развёрнутая Банке.

  • Интеграции: Jira (webhook + REST API + Kafka), Code review Agent (A2A), LibreChat (MCP), LLM API.

Красивая картинка с изображением архитектуры и логика работы:

Разбор: как агент решает задачу

Посмотрим на работу агенты со стороны процесса и распишем детально каждый шаг.

Общий сценарий в коде выглядит как-то так:

```java /**
* Метод проверяет, подходит ли задача для зап��ска сценариев и запускает сценарий
*
* @param webHookEventDto событие на изменение в Jira
*/
public void checkScenarioEligibilityAndRun(WebHookEventDto webHookEventDto) {
log.info("Check scenario for task {} in project {}", webHookEventDto.getIssueKey(), webHookEventDto.getProjectKey());
jiraService.getIssue(webHookEventDto.getIssueKey());
webhookValidator.validateKey(webHookEventDto);
val issueKey = webHookEventDto.getIssueKey();
val classificationRecord = taskClassifierService.getClassificationResultRecord(webHookEventDto);

if (classificationRecord.isTechnical()) {
log.warn("Change description is technical for task {} in project {}",
issueKey,
webHookEventDto.getProjectKey());
jiraChangeStatus(issueKey);
return;
}
//вытаскиваем ссылку из поля
extractDocumentationLink(webHookEventDto);
// проверяем линк на документацию
val versionOfDocs = checkDocumentationLinkAndGetVersion(webHookEventDto);
startScenarios(webHookEventDto, versionOfDocs);
}
```

Теперь по шагам.

№1. Получение сообщения о новой документации на ревью

Здесь не будем надолго останавливаться. Главное, что мы на входе имеем следующую информацию:

  • Описание изменений (фича).

  • Ссылка на документацию.

  • Ссылка на Pull Request разработчика.

№2. Анализ необходимости запуска сценария

Опустим косметические проверки и перейдем к самому интересному. Бот ориентируется на описание изменений и diff PR и определяет, техническая доработка это или нет. Как? Он просто смотрит на примеры и свои “личные” представления, ничего больше.

 Алгоритм такой:

Промпт для проверки выглядит следующим образом.

Ты — аналитик в IT‑компании. Твоя задача — классифицировать доработку как техническую или нет, опираясь на её описание.

Критерий технической доработки

Технической считает��я доработка, если она затрагивает только код и не меняет бизнес‑логику. Такие изменения не требуют обновления документации.

Список типовых технических доработок (полный перечень см. ниже)

- Front‑end изменения без влияния на логику.

- Обновление библиотек и фреймворков.

- Исправление ошибок линтера.

- Написание/рефакторинг тестов.

- Оптимизация кода без изменения поведения.

- Конфигурации среды разработки или деплоя.

- Дизайнерские правки (отступы, шрифты, цвета), не влияющие на UX.

#часть примеров под NDA 🤷🏻‍♀️

Исключения (требуют документирования)

1. Перемещение приложения в новый репозиторий.

2. Изменение отображения/контента/дизайна, зависящее от параметров API или deeplink: добавлен/изменён параметр, маппинг значений, дефолты или условия выбора (например, выбор картинки по полю ответа API).

#да, исключения бывают. Некоторые технические нюансы мы сохраняем в документации.

ОПИСАНИЕ Доработки:

%s #сюда мы подставляем описание доработки

План оценки

1. Сравнить описание с перечнем технических доработок.

2. Если совпадения нет, проверить, ограничивается ли изменение только кодом, конфигурациями, инфраструктурой, тестами, стилями — это считается технической доработкой.

3. Проверить наличие пунктов из «Исключений».

4. Если описание соответствует пункту из «Исключений», это не техническая доработка.

5. Выдать окончательный вывод.

Ответ (чётко, без лишних слов):

- Техническая доработка: Да / Нет

- Краткое обоснование (не более 30 слов).

Здесь мы столкнулись с одной из проблем. Нестабильные ответы LLM. Описали подробнее решение этой проблемы в Нестабильность, хаос, баги, токен-хелл.

Промпт для анализа diff выглядит аналогичным образом. Только объясняем, что на вход подставляется текстовое описание diff PR разработчика.

Как выстроена интеграция с агентом Code Review Agent в коде?

Code Review Agent - отдельный агент, который занимается ревью Pull Requests. В данном случае это коллега нашего агента, который помогает разобраться с изменениями в коде :) 

Как же они общаются? 

Это A2A‑интеграция через MCP (Model Context Protocol): 

  1. Агент формирует промпт (“предоставь отчет по PR”) 

  2. Вызывает LLM (через ChatClient), передавая набор toolCallbacks, которые реализуются в code‑review агентом через MCP (SSE‑транспорт).

Код
/**
* Сервис, который использует внешний MCP-инструмент (code-review-api)
* для автоматической генерации отчёта по коду через чат-клиент (ChatClient),
* работающий с LLM (OpenAiChatModel). Класс лениво инициализирует соединение
* к MCP и кэширует провайдера tool-callback'ов в потокобезопасных ссылках.
*/
@Service
@Slf4j
public class CodeReviewServiceImpl implements CodeReviewService {


   // OpenAI chat модель — используем ленивую загрузку, чтобы избежать ранней инициализации
   @Autowired
   private @Lazy OpenAiChatModel chatModel;


   // Свойства конфигурации для MCP (url, таймауты и т.д.)
   @Autowired
   private McpCodeReviewProperties mcpProperties;


   // Потокобезопасные контейнеры для размещения транспорта и провайдера сallback'ов.
   // AtomicReference позволяет атомарно заменять/закрывать ресурсы.
   private final AtomicReference<HttpClientSseClientTransport> transportRef = new AtomicReference<>();
   private final AtomicReference<SyncMcpToolCallbackProvider> providerRef = new AtomicReference<>();


   /**
    * Внешняя точка: генерирует отчёт по коду для PR.
    * Аннотация @Retryable указывает, что при исключении метод будет повторён
    * (настройки берутся из пропертей с дефолтными значениями, указаны ниже).
    *
    * Поведение:
    *  - При исключении логируем, сбрасываем соединение (resetConnection),
    *    и пробрасываем исключение дальше, чтобы механизм retry сработал.
    */
   @Retryable(
           retryFor = Exception.class,
           // maxAttemptsExpression и delayExpression берутся из application properties.
           maxAttemptsExpression = "${app.mcp-connections.code-review-api.retry-max-attempts:2}",
           backoff = @Backoff(delayExpression = "${app.mcp-connections.code-review-api.retry-delay-ms:2000}")
   )
   public String generateReviewReport(String projectName, String serviceName, Integer pullRequestId) {
       try {
           // Делегируем фактической логике вызова MCP
           return callMcpReviewReport(projectName, serviceName, pullRequestId);
       } catch (Exception e) {
           // При ошибке пытаемся сбросить существующее соединение (возможно оно в неконсистентном состоянии)
           log.error("Error during report generation: {}", e.getMessage());
           resetConnection();
           throw e;
       }
   }


   /**
    * Метод-обработчик для случая, когда все попытки retry исчерпаны.
    * Spring вызовет этот метод (аннотированный @Recover) после того, как retry завершится неудачей.
    * Здесь мы логируем и переводим ошибку в RuntimeException с понятным сообщением.
    */
   @Recover
   public String recoverReviewReport(Exception e, String projectName, String serviceName, Integer pullRequestId) {
       log.error("Code review failed after all retries for project={}, service={}, PR={}: {}",
               projectName, serviceName, pullRequestId, e.getMessage());
       resetConnection();
       throw new RuntimeException("Code review failed after retry: " + e.getMessage(), e);
   }


   /**
    * Формирует prompt и вызывает ChatClient (который использует OpenAI модель).
    * .toolCallbacks(...) -- передаём callback'и инструментов MCP, чтобы LLM мог "вызвать" внешний инструмент.
    * Возвращаем только контент ответа.
    */
   private String callMcpReviewReport(String projectName, String serviceName, Integer pullRequestId) {
       return ChatClient.create(chatModel)
               .prompt("""
                               Проведи авто код-ревью Pull Request с ID %d, \
                               проект %s, сервис %s.
                               Используй для этого инструмент code-review-api
                               """.formatted(pullRequestId, projectName, serviceName))
               // Передаём callback-интерфейсы инструментов, которые будут доступны модели
               .toolCallbacks(getOrInitializeCallbacks())
               .call()
               .content();
   }


   /**
    * Ленивая инициализация провайдера tool-callback'ов.
    *
    * Логика:
    *  - Если провайдер уже есть в providerRef, возвращаем его callback'и.
    *  - Иначе синхронизируемся и инициализируем транспорт и MCP-клиент:
    *      - создаём транспорт (SSE клиент),
    *      - создаём синхронный MCP client с таймаутом,
    *      - вызываем client.initialize() асинхронно и ждём завершения в пределах timeout,
    *      - создаём SyncMcpToolCallbackProvider из списка клиентов,
    *      - сохраняем transport и provider в AtomicReference для повторного использования.
    *
    * Важные детали:
    *  - CompletableFuture.runAsync(...).get(timeout) — используется для ожидания инициализации,
    *    чтобы не блокировать поток навсегда.
    *  - Синхронизация предотвращает гонки при параллельной инициализации в нескольких потоках.
    *  - При ошибке инициализации мы сбрасываем коннекшн и пробрасываем RuntimeException.
    */
   private ToolCallback[] getOrInitializeCallbacks() {
       // Быстрая проверка — если уже инициализировано, сразу возвращаем
       SyncMcpToolCallbackProvider provider = providerRef.get();
       if (provider != null) return provider.getToolCallbacks();


       // Double-checked locking: синхронизируем на this для безопасной ленивой инициализации
       synchronized (this) {
           provider = providerRef.get();
           if (provider == null) {
               try {
                   // Создаём транспорт (SSE клиент) к MCP
                   var transport = createTransport();


                   // Конфигурируем синхронный MCP клиент с заданными таймаутами
                   McpSyncClient client = McpClient.sync(transport)
                           .requestTimeout(Duration.ofSeconds(mcpProperties.getRequestTimeoutSec()))
                           .build();


                   // Инициализируем клиент — выполняем init асинхронно и ждём результата с таймаутом
                   CompletableFuture.runAsync(client::initialize)
                           .get(mcpProperties.getInitializationTimeoutSec(), TimeUnit.SECONDS);


                   // Оборачиваем клиент в провайдера callback'ов для Spring AI
                   provider = new SyncMcpToolCallbackProvider(List.of(client));
                   // Сохраняем transport и provider для повторного использования
                   transportRef.set(transport);
                   providerRef.set(provider);
               } catch (Exception e) {
                   // При любой ошибке — логируем, сбрасываем соединение и пробрасываем RuntimeException.
                   log.error("Failed to initialize MCP connection: {}", e.getMessage());
                   resetConnection();
                   throw new RuntimeException("Failed to initialize MCP connection: " + e.getMessage(), e);
               }
           }
       }
       return provider.getToolCallbacks();
   }


   /**
    * Создаёт SSE-транспорт на основе java.net.http.HttpClient.
    * Здесь задаём connectTimeout и указываем sseEndpoint.
    *
    * Пояснение: SSE полезно для получения стриминга/событий от MCP.
    */
   private HttpClientSseClientTransport createTransport() {
       log.info("Creating new MCP transport to: {}", mcpProperties.getUrl());
       // Билдер нативного HttpClient, можно настраивать proxy, TLS и т.п.
       HttpClient.Builder httpClientBuilder = java.net.http.HttpClient.newBuilder()
               .connectTimeout(Duration.ofSeconds(mcpProperties.getConnectTimeoutSec()));


       // Возвращаем сконфигурированный транспорт для MCP
       return HttpClientSseClientTransport.builder(mcpProperties.getUrl())
               .sseEndpoint("/sse")
               .clientBuilder(httpClientBuilder)
               .build();
   }


   /**
    * Сбрасывает текущее соединение и провайдер. Вызывается при ошибках и при завершении приложения.
    *
    * Логика:
    *  - Обнуляем providerRef, чтобы при следующем вызове произошла новая инициализация.
    *  - Берём и закрываем текущий transport (если он есть) — освобождаем сетевые ресурсы.
    */
   public void resetConnection() {
       // Убираем ссылку на провайдера, чтобы при следующем запросе произошла реинициализация
       providerRef.set(null);


       // Атомарно забираем и обнуляем транспорт; если он был — корректно закрываем
       HttpClientSseClientTransport transport = transportRef.getAndSet(null);
       if (transport != null) {
           try {
               log.info("Closing MCP transport connection...");
               transport.close();
           } catch (Exception e) {
               // Закрытие транспорта — не критическая операция, логируем предупреждение
               log.warn("Non-critical error closing MCP transport: {}", e.getMessage());
           }
       }
   }


   /**
    * Гарантированно вызывается при завершении контекста Spring (PreDestroy).
    * Здесь просто делегируем сброс соединения.
    */
   @PreDestroy
   public void onShutdown() {
       resetConnection();
   }
}

Пример вызова:

String report = codeReviewService.generateReviewReport(projectKey, repositorySlug, pullRequestId);


@GetMapping("/docs/review")
public String doCodeReview(@RequestParam String projectName,
                          @RequestParam String serviceName,
                          @RequestParam Integer pullRequestId) {
   return codeReviewService.generateReviewReport(projectName, serviceName, pullRequestId);
}

Если доработка техническая 

Если платформенному аналитику проверять нечего, так и пишем в комментарии к задаче и переводим ее в следующий статус:

Если это тот самый случай с бизнес-фичей, идем дальше. 

Насколько это точно? Не рискованно ли завязываться на «мнение» LLM? Рискованно. Однако LLM, также как и люди, совершают ошибки. Процент этих ошибок может быть допустимым, а может быть и нет. 

Мы протестировали на реальных задачах: 27 из 30 обработалось успешно (89,7%). В целом, нас устраивает для старта. Понятно, что всего разнообразия не учесть. Будем тюнить со временем :)

№3. Проверка наличия документации по фиче

На этом этапе всё достаточно просто: необходимо найти фрагменты документации, которые описывают интересующую нас фичу, и ответить на два вопроса:

  1. Найдено ли описание?

  2. Если да — где оно находится и какие фрагменты текста были найдены?

Схема выглядит так:

Для поиска релевантных фрагментов мы используем следующий промпт:

Промпт

Ты – эксперт по автоматическому анализу технической документации.

Задача: извлечь из предоставленного текста все фрагменты, релевантные описанию доработки, и подтвердить их присутствие в разделе «7. История изменений».

## INPUT

- Документ:

```

%s

```

- Описание доработки: "%s"

## План действий (PLAN)

1. Extract keywords & synonyms из описания доработки.

2. Perform contextual search по документу, учитывая варианты формулировок, UI‑элементы и технические термины.

3. Map fragments to sections – определить заголовок раздела для каждого найденного фрагмента.

4. Verify history* – проверить, упомянуты ли найденные фрагменты в разделе «7. История изменений».

5. Generate human‑readable output – оформить результат согласно шаблону ниже.

## Пример (few‑shot)

Input:

Описание изменений: "Добавлен диплинк /xxxx"

Output (человекочитаемый):

Нашел описание доработки в разделе(-ах):

* Deeplink — "/xxxxxx – открытие формы xxxxx; xxxxxx – полная форма; xxxxx – поп‑ап."

* 6. Детальное описание алгоритма — "Система открывает форму xxxxx по диплинку с xxx = true, переход к шагу 1 без отображения пуша."

* 1.2. Система отображает форму xxxx — "Открытие формы происходит по диплинку с xxxx = true."

Раздел "7. История изменений": указано

## Rules

1. Для выделения жирным используй одинарные символы, вместо двойных.

## Человекочитаемый шаблон (OUTPUT TEMPLATE), адаптированный для комментария в Jira:

- Если найдено:

Нашел описание доработки в разделе(-ах):

* <section_title> — "<excerpt>";

* ...

Раздел "7. История изменений": [указано / не указано]

- Если не найдено:

Информация о доработке не найдена в предоставленной документации. Проверь, описаны ли изменения в документации? Возможно, стоит поправить формулировку описания изменений.

Safety note: Выводить только запрошенные данные, без лишних пояснений.

Единственная проблема — лимит токенов. Тогда ситуация становится чуть сложнее. Что с этим делать? Разделить документацию на чанки и анализировать «по кусочкам». Так мы и поступили. 

Как правильно разбивать документацию на чанки?

Возникает логичный вопрос: какую стратегию разбиения (chunking strategy) выбрать?

На первый взгляд может показаться, что нужно логически делить текст на смысловые блоки — заголовки, параграфы, разделы. Однако в нашей задаче это не критично. Мы ищем одни и те же фрагменты информации вне зависимости от их положения в структуре документа. Поэтому оптимальной стала стратегия равномерного разбиения по количеству символов или токенов — она простая, быстрая и легко масштабируется.

Как не упереться снова в лимит?

Допустим, у нас уже есть текст и мы знаем лимит токенов. Как убедиться, что при разбиении документа на чанки мы не превысим лимит и при этом не потеряем контекст?

Ключ — в правильном расчёте размера чанков и перекрытии (overlap) между ними. Мы использовали простую формулу:

C_{chunk} = (T_{max} - T_{prompt})* r - C_{dup} - C_{header}

где:
T_{max} — максимальное количество токенов (например, 32 000)
T_{prompt} — количество токенов, зарезервированных под промпт (например, 1000)
r — среднее количество символов на токен (например, 5)
C_{dup} — количество дублируемых символов (например, 500)
C_{header} — длина заголовка в символа (например, 67)
C_{chunk} — максимальное количество символов в чанке

Смысл её в том, чтобы заранее рассчитать максимально допустимое количество символов на чанк (C_{chunk}), включая резерв под перекрытие (C_{dup}) и заголовок. Коэффициент rбыл подобран эмпирически — с запасом, чтобы учесть возможные колебания в длине токенов.

Визуализация алгоритма чанкования
Визуализация алгоритма чанкования

При нарезке текста на чанки мы:

  • делим его на куски длиной C_{chunk} символов;

  • добавляем к каждому i-му чанку C_{dup} символов из конца предыдущего, чтобы сохранить связный контекст;

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

Эта стратегия позволяет не только нарезать документы эффективно, но и минимизировать потерю смысла на границах чанков.

Финальный этап — анализ и саммари

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

Промпт для саммари:

Ты — эксперт по анализу текста. Задача состоит в том, чтобы агрегировать и проанализировать результаты, полученные от LLM, после того как текст документации был разделён на чанки и каждый чанк был проанализирован отдельно.

## Контекст задачи:

1. На предыдущем шаге LLM проводила поиск описания фичи в документации. Текст документации был разделён на чанки, и LLM уже обработала каждый чанк, выполнив поиск релевантных фрагментов.

2. Ответы, полученные от LLM, содержат результаты анализа каждого чанка документации.

3. Т��перь необходимо агрегировать все эти ответы и составить итоговое саммари.

## Что нужно сделать:

1. Проанализируй все ответы LLM, полученные по чанкам.

2. Найди, в каких чанках были найдены релевантные фрагменты, соответствующие описанию фичи (это описание фичи передано в виде переменной <changeDescription>. Содержимое changeDescription: %s ).

3. В ответах, где такие фрагменты найдены, укажи название разделов, в которых они содержатся, и приведите текст этих фрагментов.

4. Также проверь, указаны ли соответствующие изменения или описание найденных фрагментов в разделе "7. История изменений" хотя бы в одном из этих чанков.

   - Ответь строго "указано" или "не указано". Если информации недостаточно, запроси уточнение.

## Правила

1. Для выделения жирным используй одинарные символы, вместо двойных.

Содержимое всех ответов LLM, полученных по чанкам:

%s

## Форматы итогового саммари, адаптированное для комментария в Jira:

1. Если описание фичи найдено в ответах LLM:

    Нашел описание доработки в разделе(-ах):

    * Название раздела 1 — "Текст описания 1";

    * Название раздела 2 — "Текст описания 2"

    ...

    Раздел "7. История изменений": [указано / не указано]

2. Если описание фичи НЕ найдено в ответах LLM:

   Информация о доработке не найдена в предоставленной документации. Проверь, описаны ли изменения в документации? Возможно, стоит поправить формулировку описания изменений.

ВАЖНО! Верни только Итоговое саммари четко в одном из указанных форматов.

Пример ответа, когда описани фичи найдено:

Пример ответа, описание фичи не найдено:

4. Проверка документации на соответствие стандарту 

Это, пожалуй, самая непростая задача в нашем сервисе — со звёздочкой. Объёмная документация, не менее объёмный стандарт — и всё это нужно сверить. Задача: пройтись по каждому разделу документа и определить, насколько он соответствует требованиям стандарта.

Что из себя представляет документация?

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

  • Таблица (без названия) — «шапка» документации. 

  • Основная информация: таблица с глоссарием, ограничениями, ссылками (в том числе на документации микросервисов).

  • Предусловия: что должно быть выполнено до запуска фичи.

  • Постусловия: что происходит после выполнения фичи.

  • Сценарии использования — UML Use Case диаграмма.

  • Алгоритм: описание алгоритма и интеграций в UML Sequence или других нотациях.

  • Детальное описание алгоритма — табличное описание шагов: действия клиента, поведение системы, логика интеграций и макеты интерфейсов.

  • История изменений: список доработок и авторы изменений.

Здесь нам очень повезло в том, что в нашем случае мы имеем регламентированную структуру, на которую можем смело опираться. 

Что такое стандарт?

Ст��ндарт — это набор требований к структуре и содержанию документации.

Он определяет, какие разделы должны быть в документе, какие данные обязательны в каждом из них. Например, в «шапке» должны быть указаны: команда, описание фичи, диплинки, репозитории, фича-тогглы и т.п.

Стандарт требует согласованности между разделами: если в одном разделе упоминается микросервис, то в другом должна быть ссылка на его документацию.

Как мы решали эту задачу? Вот так:

  1. Сначала мы попробовали запустить проверку на небольшой документации.
    На входе: ссылка на стандарт и ссылка на сам документ. Задача — сравнить и понять, насколько документ соответствует требованиям.

  2. Проблема проявилась сразу: LLM интерпретировала требования по-разному. Нам они казались очевидными, но только потому, что мы в контексте.

  3. Быстро стало понятно, что текущий стандарт не подходит. Он слишком «человеческий»: много пояснений, добавленных для аналитиков, которые не имеют отношения к технической проверке.

  4. Решение — переписали стандарт в «LLM-читаемом формате». Подробнее описано ниже.

  5. Запустили тест снова — стало лучше. Но часть требований всё равно игнорировалась, результаты каждый раз отличались, и для обычной (не огромной!) документации быстро возникал лимит токенов  — и от стандарта, и от текста.

  6. Мы решили делить документ на чанки — но уже логически, по структуре. Подробнее об этом расскажем ниже.

  7. Сейчас требования практически не теряются, а документы среднего размера обрабатываются целиком — ура!

  8. Оставалась одна проблема — слишком объёмный итоговый отчёт (200-250 строк).
    Чтобы с ним было удобно работать, мы сделали отдельный формат финального саммари, который сократил саммари до 50 строк без потери качества.

Как переписать стандарт так, чтобы LLM-понимала, что именно нужно проверять?

Возьмем кусочек стандарта, описывающий шапку страницы:

Нам, как аналитикам, которые понимают, про какие репозитории идет речь, как выглядит ссылка на Epic в Jira и т.д. все понятно. А LLM в этот момент:

Для LLM этого недостаточно. Она не может «догадаться», если не задать ей рамки явно.
Примеры вопросов, которые остаются без ответа:

  • Что считается ссылкой на команду — Confluence? Notion? Jira?

  • Jira — это макрос или обычный URL?

  • Как проверить, что ссылка на репозиторий корректная? О каком репозитории идет речь?

  • Какие требования к неймингу? Доп. Страницы для LLM - это только ссылка. Доступа к контенту, доступному по этой ссылке, нет.

  • Всегда ли именно эта таблица обязательна, или бывают исключения?

На все эти вопросы нужно ответить при составлении LLM-читаемого стандарта. Вот пример того, что у нас получилось. Стандарт очень большой, поэтому разместим здесь 2 небольших кусочка оттуда.

Кусочек 1

# Таблица

## Требования к таблице

Таблица должна содержать ровно 8 строк и 2 непустых столбца.

### Структура первого столбца (строго по порядку):
1. Команда 
2. Бизнес-ценность 
3. Epic 
4. Макеты 
5. Git Front 
6. Git Middle 
7. Feature-Toggle 
8. Deeplink 

> ⚠️ Если в первом столбце присутствуют любые другие строки или отсутствуют перечисленные -- требование не выполнено.

---

### Заполнение второго столбца

Каждая строка второго столбца должна соответствовать значению из первого столбца и содержать валидные данные следующего вида:

#### 1. Команда - Должно содержать ссылку(и) на страницу(ы) команды в Confluence.
- ✅ Пример: 
  <ac:link><ri:page ri:content-title="XXXX" /></ac:link> 
  или 
  <ac:link><ri:page ri:content-title="XXXX" /></ac:link><ac:link><ri:page ri:space-key="XXXXX" ri:content-title="Команда XXXXX" /></ac:link>
  или
  https://confluence.com/pages/viewpage.action?pageId=1234567
-
❌ Запрещено: текст без ссылки (например, "XXXXX"), пустое поле.

#### 2. Бизнес-ценность - Краткое описание бизнес-функции.
- ✅ Пример: 
  "Сбор обратной связи XXX XXXXX" 
  "Выгрузка XXXXXX в формате Excel/CSV"
- ❌ Запрещено: пустое поле, прочерк (-), технические комментарии.

#### 3. Epic - Ссылка на задачу в Jira.
- ✅ Пример: 
  <ac:structured-macro ac:name="jira" ac:schema-version="1" ac:macro-id="..."><ac:parameter ac:name="key">TEAM-123</ac:parameter></ac:structured-macro> 
  или 
  <a href="https://jira.com/browse/TASK-1234">https://jira.com/browse/TASK-1234</a>
-
❌ Запрещено: "См. задачи в Jira", пустое поле.

#### 4. Макеты - Ссылка(и) на макеты в Figma.
- ✅ Пример: 
  <a href="https://www.figma.com/file/...">Макеты figma</a> 
  или несколько ссылок через пробел.
Если ссылка указана в макросе виджета, то добавляй рекомендацию привести к формату ссылки.
- ❌ Запрещено: "Дизайн в разработке", пустое поле.

Кусочек 2

# 3. Постусловия

## Требования к разделу

### Общие требования
1. Раздел должен быть представлен в виде таблицы.
2. Каждая строка таблицы описывает конкретный результат, который клиент получает после выполнения функциональности.
3. Раздел может быть пустым.
4. Запрещены:
  - Общие формулировки (например, "клиент может продолжить пользоваться приложением").
  - Технические детали реализации (например, "отправка данных на сервер").

---  

## Формат таблицы
- Таблица должна содержать нумерованные шаги:
  - Первый столбец: номер шага (1, 2, 3 и т.д.).
Нумерация может начинаться с 0 и содержать обозначения подпунктов шага (например, 1a или 1б).
  - Второй столбец: описание действия клиента.

### ✅ Пример корректного оформления:
<table>
  <tr>
    <th>1</th>
    <td>Клиент видит аналитику в разбивке по XXXXX.</td>
  </tr>
  <tr>
    <th>2</th>
    <td>Клиент имеет возможность посмотреть операции по конкретному XXXXX.</td>
  </tr>
</table>

---

## ❌ Запрещенные случаи
1. Слишком общие формулировки
  - Некорректный пример: "Клиент может продолжить пользоваться приложением". 
  - Корректный пример: "Клиент видит список операций за выбранный период".

2. Технические детали
  - Некорректный пример: "Данные передаются через API /api/v1/something". 
  - Корректный пример: "Клиент получает отчет с категоризацией XXXXX".

Для составления мы использовали few-shot prompting, так как возможность обучения на момент разработки и написания статьи отсутствуют. Для каждого пункта, для каждого поля мы привели примеры правильного и неправильного заполнения

И так для всех разделов. В итоге LLM-читаемый стандарт получился внушительных размеров. Зато все расписано до мелочей. 

А как быть с кросс-раздельными проверками?

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

"Все ссылки на документацию сервисов, описанных в таблице, должны быть указаны в разделе 'Основная информация'."

Для таких случаев мы ввели отдельный тип требований — кросс-проверки. Они оформлены как отдельные правила, в которых явно указано, какие разделы участвуют и как должна выглядеть связь между ними.

Один из примеров кросс-проверок:

# Таблица; 1. Основная информация

## Дано
- таблица, в которой в колонке «Git Middle» указаны ссылки на репозитории сервисов.
- раздел «1. Основная информация», в котором указаны ссылки на документацию сервисов

## Обязательные проверки
### Полнота сервисов в разделе «1. Основная информация»
- для каждого сервиса из этой колонки убедиться, что в разделе «1. Основная информация» присутствует ссылка на официальную документацию.

Подробнее о реализации — в следующем разделе, посвящённом стратегии чанкования

4.1. Chunking strategy 

В этой задаче нам важно обеспечить максимально точную проверку по всем требованиям, поэтому мы стараемся снизить «шум» в данных и структурировать информацию так, чтобы LLM было легче анализировать:

  • чёткие требования,

  • разбитые по разделам,

  • никаких «всё сразу в одной куче».

А это значит, что мы будем здесь использовать стратегию логического разделения по разделам. Выглядит это как-то так:

У нас есть LLM-читаемый формат, разбитый по разделам и документация, также структурированная по разделам.

Решение:

  1. Разделяем на чанки документацию: 1 чанк — 1 раздел. 

  2. Разделяем на чанки стандарт: 1 чанк — 1 требование.

  3. Сопоставляем чанки стандарта и документации между собой. Проверка оформления: 1 чанк (раздел) документации ~ 1 чанку стандарта (требование к разделу). Кросс-проверки: 1 чанк кросс-проверки (отдельный раздел с кросс-проверками) ~ n чанкам (разделам) документации. 

  4. Отправляем в LLM запросы на проверку по каждому соответствию

  5. Собираем в единый отчет.

Как это работает покажу на примерах.

Проверка оформления

1 чанк стандарта

# Таблица

## Требования к таблице

Таблица должна содержать ровно 8 строк и 2 непустых столбца.

### Структура первого столбца (строго по порядку):
1. Команда 
2. Бизнес-ценность 
3. Epic 
4. Макеты 
5. Git Front 
6. Git Middle 
7. Feature-Toggle 
8. Deeplink 

> ⚠️ Если в первом столбце присутствуют любые другие строки или отсутствуют перечисленные -- требование не выполнено.

---

### Заполнение второго столбца

Каждая строка второго столбца должна соответствовать значению из первого столбца и содержать валидные данные следующего вида:

#### 1. Команда - Должно содержать ссылку(и) на страницу(ы) команды в Confluence.
- ✅ Пример: 
  <ac:link><ri:page ri:content-title="XXXX" /></ac:link> 
  или 
  <ac:link><ri:page ri:content-title="XXXX" /></ac:link><ac:link><ri:page ri:space-key="XXXXX" ri:content-title="Команда XXXXX" /></ac:link>
  или
  https://confluence.com/pages/viewpage.action?pageId=1234567
-
❌ Запрещено: текст без ссылки (например, "XXXXX"), пустое поле.

#### 2. Бизнес-ценность - Краткое описание бизнес-функции.
- ✅ Пример: 
  "Сбор обратной связи XXX XXXXX" 
  "Выгрузка XXXXXX в формате Excel/CSV"
- ❌ Запрещено: пустое поле, прочерк (-), технические комментарии.

#### 3. Epic - Ссылка на задачу в Jira.
- ✅ Пример: 
  <ac:structured-macro ac:name="jira" ac:schema-version="1" ac:macro-id="..."><ac:parameter ac:name="key">TEAM-123</ac:parameter></ac:structured-macro> 
  или 
  <a href="https://jira.com/browse/TASK-1234">https://jira.com/browse/TASK-1234</a>
-
❌ Запрещено: "См. задачи в Jira", пустое поле.

#### 4. Макеты - Ссылка(и) на макеты в Figma.
- ✅ Пример: 
  <a href="https://www.figma.com/file/...">Макеты figma</a> 
  или несколько ссылок через пробел.
Если ссылка указана в макросе виджета, то добавляй рекомендацию привести к формату ссылки.
- ❌ Запрещено: "Дизайн в разработке", пустое поле.

1 чанк документации

Представлено графически для наглядности, в реальности данные передаются в LLM в HTML-формате.

Кросс-проверка

1 чанк стандарта

# Таблица; 1. Основная информация

## Дано
- таблица, в которой в колонке «Git Middle» указаны ссылки на репозитории сервисов.
- раздел «1. Основная информация», в котором указаны ссылки на документацию сервисов

## Обязательные проверки
### Полнота сервисов в разделе «1. Основная информация»
- для каждого сервиса из этой колонки убедиться, что в разделе «1. Основная информация» присутствует ссылка на официальную документацию.

2 чанка документации

Чанк 1:

Чанк 2:

Теперь нужно сложить воедино все ответы LLM.

Итоговый промпт:

# SYSTEM ROLE:

Ты — системный аналитик-валидатор корпоративной документации, эксперт по стандартам Confluence и продуктовым требованиям.

Твоя главная функция — ПОЛНАЯ автоматическая проверка документации в HTML на соответствие каждому пункту стандарта, переданному в виде Markdown.

---

# TASK:

Проведи детальную проверку документа (doc_html) по стандарту (standard_md).
Твоя задача — извлечь правила из standard_md и проверить их в документе.

---

## INPUT FORMAT

```json

{
 "standard_name": "%s",
 "standard_md": "%s",
 "doc_html": "%s"
}
```

# ALGORITHM

## Шаг 1. Извлечение правил

1. Проанализируй standard_md и извлеки требования.
2. Каждое требование формализуй в виде:

{
   "rule_id": "<генерируемый ID>",
   "rule_text": "<текст требования>",
   "check_method": "<как проверить>"
}

## Шаг 2. Проверка документа

1. Для каждого правила:
1.1 Найди соответствующий фрагмент в doc_html.
1.2. Определи статус:
- "ok" — выполнено
- "partial" — частично выполнено
- "fail" — нарушено или отсутствует (для желательных, но не обязательных требований указывается только partial)
1.3. Определи, в чем ошибка и что нужно исправить:
- "what_wrong" - описание, какой фрагмент не соответствует стандарту и почему
- "fix" - рекомендация по исправлению ошибок в конкретных найденных фрагментах документации
1.4. Человекоориентированные правки (USER FIXES):
— Формулируй fix как инструкцию для редактора в Confluence.
— Не вставляй HTML или XML-фрагменты, кроме имён макросов (макрос "Фигма", макрос "Вкладки" и т.д.).
— Используй формат:

  1. Опиши, где именно нужно внести правку (в какой колонке, строке, ячейке).
  2. Укажи, какой макрос или элемент интерфейса нужно добавить, удалить или заменить.
  3. Если нужно поправить параметры (например, размеры изображений), укажи значения словами.
— Примеры формулировок:
  • «В колонке “Дизайн” заменить текущий блок изображений на макрос группы вкладок (tabs-group) с вкладками “Десктоп” и “Мобильная версия”.»
  • «Убедиться, что высота изображений: 600 px для десктопа, 300 px для мобильной версии.»
  • «Если уже используется tabs-group — не заменять, только обновить размеры изображений и подписи вкладок.»
— Избегай прямых вставок кода (<ac:structured-macro ...>); говори словами, что нужно сделать.
— Если в документе уже есть подходящий макрос, уточни: “проверить настройки, обновить параметры”.

2. Для каждого правила верни один компактный объект:

{
   "rule_text": "...",
   "status": "...",
   "what_wrong": "<описание нарушения или пусто>",
   "fix": "<рекомендация по исправлению или пусто>"
}

## Шаг 3. Вывод
1. Финальный JSON должен быть:
{
   "standard_name": "<название>",
   "rules": [
   {...}, {...}, ...
   ],
   "summary": {
       "total": <число>,
       "ok": <число>,
       "partial": <число>,
       "fail": <число>,
       "recommendations": ["..."]
   }
}

# RULES OF INTERPRETATION
- Не додумывай требований, которых нет в standard_md
- Не изменяй факты: если нет явного нарушения — статус “ok”
- Если правило неоднозначно — укажи это в what_wrong
- Анализируй содержимое ac:structured-macro и ac:image по атрибутам и тексту
- Для желательных, не обязательных требований (указывается в standard_md), запрещён итоговый fail — только ok или partial.

# OUTPUT FORMAT

Только валидный JSON, без исходного HTML, Markdown и без комментариев.

Таким образом, мы проверяем каждый раздел и каждую кросс-проверку отдельно. После прохождения всех проверок собираем итоговое саммари, чтобы зафиксировать результат анализа по всему документу.

Как выглядит код:

public Map<String, String> compareChunkStandardAndDocumentation(ChunkDocResult chunkDocResult) {
   /**
    * Сравнивает чанки (разделы) эталонной документации со
    * соответствующими чанками пользовательской документации.
    *
    * Алгоритм:
    *  - Берёт prompt-шаблон сравнения.
    *  - Итерирует набор стандартных чанков (standardMap).
    *  - Для каждого стандарта:
    *      - Если название стандарта содержит несколько имён разделов (разделены "; "),
    *        собирает соответствующие чанки документации и помечает ключ как "Кросс проверка".
    *      - Формирует текст запроса (prompt) и отправляет в LLM (chatModel).
    *      - Если размер запроса превышает лимит токенов, разбивает текст документации
    *        на меньшие чанки и выполняет повторные запросы, затем агрегирует ответы.
    *  - Выполняет вызовы параллельно с ограничением через Semaphore и виртуальные потоки,
    *    собирает результаты в потокобезопасную карту.
    *
    * Особенности и обработка ошибок:
    *  - Параллелизм контролируется полем `concurrentLimit` и `Semaphore`.
    *  - При `ResourceAccessException` используется `retryService.retryLlmCall`.
    *  - Пустые ответы от LLM заменяются результатом `retryService.retryLlmCall`.
    *  - Для больших текстов применяется разбиение (split) и последующая агрегация.
    *
    * Вход:
    *  - chunkDocResult: объект с полями:
    *      - standard: Map<String, String> — эталонные разделы (ключ — название раздела, значение — содержимое).
    *      - documentation: Map<String, String> — разбитая документация по названиям разделов.
    *
    * Выход:
    *  - Map<String, String> — сопоставление название_стандарта -> комментарий/результат сравнения LLM.
    */


   // Шаблон запроса, используемый для сравнения одного раздела стандарта с соответствующими разделами документации
   val requestPrompt = docsPrompts.getCompareStandardAndDocumentationPrompt();
   val standardMap = chunkDocResult.getStandard();
   val answerMap = new ConcurrentHashMap<String, String>();
   List<Future<Map.Entry<String, String>>> futures = new LinkedList<>();
   val semaphore = new Semaphore(concurrentLimit);


   standardMap.forEach((standardName, standardValue) -> {
       // Блокируем слот для соблюдения ограничения параллелизма
       semaphoreAcquire(semaphore);
       var feature = virtualThreadExecutorService.getVirtualExecutor().submit(() -> {
           // Разрешаем ситуации, когда один стандарт соответствует нескольким разделам документации
           // (имена разделов в ключе стандарта разделены "; ")
           val documentationNames = standardName.split("; ");
           // Формируем список строк вида "Имя_раздела: содержимое_чанка"
           val documentationValues = Arrays.stream(documentationNames)
                   .map(docsName -> docsName + ": " + chunkDocResult.getDocumentation().get(docsName))
                   .toList();
           val standardNameGeneral = documentationValues.size() > 1 ? CROSS_CHECK + standardName : standardName;
           val documentationValuesString = String.join(NEWLINE, documentationValues);
           val request = String.format(requestPrompt, standardNameGeneral, standardValue, documentationValuesString);
           try {
               // Если сформированный запрос слишком большой по токенам — разбиваем документацию на чанки
               if (isTokenLimit(request)) {
                   log.info("Start compare standard chunks {} with documentation", standardNameGeneral);
                   val chunkResult = getSplitLlmChunkResult(documentationValuesString,
                           this::getChunkCheckStandardRequest,
                           List.of(standardNameGeneral, standardValue));


                   val summaryAgeRequest = docsPrompts.getAggregateStandardChunkDocumentationSummaryPrompt()
                           .formatted(chunkResult.toString());
                   var chunkAgrResult = chatModel.call(summaryAgeRequest);
                   if (StringUtils.isEmpty(chunkAgrResult)) {
                       chunkAgrResult = retryService.retryLlmCall(request);
                   }
                   log.info("Finish compare standard chunks {} with documentation", standardNameGeneral);


                   return Map.entry(standardNameGeneral, chunkAgrResult);
               }


               // Обычный путь: единичный вызов LLM для одного стандарта
               log.info("Start compare standard {} with documentation", standardNameGeneral);
               var chunkResult = chatModel.call(request);
               if (StringUtils.isEmpty(chunkResult)) {
                   chunkResult = retryService.retryLlmCall(request);
               }
               log.info("Finish compare standard {} with documentation", standardNameGeneral);


               return Map.entry(standardNameGeneral, chunkResult);
           } catch (ResourceAccessException e) {
               // При сетевой/IO ошибке вызываем retry-сервис и возвращаем его результат
               log.error("Error compare standard chunks during call llm with ex class for standard {}", standardName);
               return Map.entry(standardNameGeneral, retryService.retryLlmCall(request));
           } finally {
               // Освобождаем слот семафора в любом случае
               semaphore.release();
           }
       });
       futures.add(feature);
   });
   futures.forEach(future -> virtualThreadExecutorService.fillMapOfVirtualThreadResult(future, answerMap));


   return answerMap;
}

Мы видим, что при использовании чанков LLM перестала пропускать требования, проверки стали стабильнее, ответы — содержательнее. 

Пример ответа LLM до чанкования в свободном формате:

Документация по стандарту:

*Структура документа*: Документ имеет правильную структуру, включающую все необходимые разделы: Table, Table of Contents, Основная информация, Предусловия, Постусловия, Сценарии использования, Алгоритм, Детальное описание алгоритма и История изменений.

*Таблица*: Таблица имеет правильную структуру и содержит все необходимые поля: Команда, Бизнес-ценность, Epic, Макеты, Git Front, Git Middle, Feature-Toggle и Deeplink.


*Основная информация*: Раздел содержит необходимые таблицы: Словарь, Ограничения и Полезные ссылки.


*Предусловия*: Раздел содержит необходимые шаги, которые пользователь должен выполнить, чтобы доступ к функциональности страницы.


*Постусловия*: Раздел содержит результаты, которые пользователь получает после выполнения функциональности.


*Сценарии использования*: Раздел содержит диаграмму, которая иллюстрирует взаимодействие между клиентом и системой.


*Алгоритм*: Раздел содержит диаграмму, которая иллюстрирует взаимодействие между клиентом и системой.


*Детальное описание алгоритма*: Раздел содержит необходимые шаги, которые система выполняет в ответ на действия клиента.


*История изменений*: Раздел содержит информацию об изменениях, внесенных в документацию.

//вывод на уровне “вроде норм”. Сложно понять, что LLM реально проанализировала, а что - нет.

В целом, документация соответствует стандарту, но необходимо проверить следующее:
* Правильность ссылок в таблице и разделе "Полезные ссылки".
* Правильность дизайна в разделе "Детальное описание алгоритма".
* Правильность информации в разделе "История изменений".
//никакой конкретики 😭

Рекомендации:

* Проверить правильность ссылок в таблице и разделе "Полезные ссылки".
* Проверить правильность дизайна в разделе "Детальное описание алгоритма".
* Проверить правильность информации в разделе "История изменений".
* Убедиться, что документация соответствует стандарту и содержит все необходимые разделы и информацию.
//ну спасибо, а тебя мы зачем попросили все это проверить? 😭

Пример ответа LLM после чанкования в JSON-формате. Приведу пример анализа только одного чанка (раздел Алгоритм), т.к. в сумме по чанкам получается тот самый объёмный текст длиной в 200-250 символов.

Ответ после чанкования

{
 "standard_name": "5. Алгоритм",
 "rules": [
   {
     "rule_text": "Если раздел не пуст, диаграмма должна включать действия клиента и интеграцию между фронтендом и миддл‑слоем.",
     "status": "ok",
     "what_wrong": "",
     "fix": ""
   },
   {
     "rule_text": "Интеграции внутри миддл‑слоя (исключение: xxxxxxxx) допускаются, но нежелательны – лучше удалить их из документа.",
     "status": "fail",
     "what_wrong": "В диаграмме присутствует интеграция service1 → service2 (миддл‑слой → миддл‑слой) без xxxxx.",
     "fix": "Удалить или заменить интеграцию между сервисами миддл‑слоя на отдельную диаграмму, если она не относится к xxxxx."
},

   {
  "rule_text": "Интеграции миддл‑слоя с внешними системами допускаются, но нежелательны – лучше удалить их из документа.",
     "status": "fail",
     "what_wrong": "В диаграмме присутствуют интеграции service1 → system1 и service1 → system2 (миддл‑слой → внешние системы).",
     "fix": "Удалить или вынести интеграции миддл‑слоя с внешними системами в отдельный раздел, если они не являются критическими для текущей диаграммы."
   },
   {
     "rule_text": "Методы API сервисов миддл‑слоя должны быть указаны в формате: HTTP‑метод /endpoint/params (исключение – внешние системы).",
     "status": "ok",
     "what_wrong": "",
     "fix": ""
   },
   {
     "rule_text": "Если в диаграмме упоминается xxxxxxxxxxx, должна быть отображена интеграция с x-yyyyyyyyyyy (где X – произвольное значение).",
     "status": "ok",
     "what_wrong": "",
     "fix": ""
   }
 ],
 "summary": {
   "total": 5,
   "ok": 3,
   "partial": 0,
   "fail": 2,
   "recommendations": [
     "Удалить или заменить интеграцию между сервисами миддл‑слоя (serivce1 → service2), так как интеграции внутри миддл‑слоя нежелательны.",
     "Удалить или вынести интеграции миддл‑слоя с внешними системами (service1 → system1, service1 → system2), так как такие интеграции нежелательны."
   ]
 }
}

Ну совсем другое дело! 

А что если раздел слишком большой?

В таком случае мы используем chunking strategy из п. 3 Проверка документации по фиче и агрегируем результаты с помощью немного сумасшедшего 💊, но работающего промпта.

Промпт

# SYSTEM ROLE
Ты — аналитик-агрегатор корпоративной документации.
Твоя задача — объединить текстовые результаты проверки чанков одного раздела, чтобы восстановить целостное выполнение требований.

# TASK

На вход подаётся JSON-массив chunk_results — результаты проверки чанков одного и того же раздела по одному стандарту.
Определи итоговый статус каждого требования, учитывая искусственное разбиение документа на части.
Если разные чанки покрывают разные части одного требования, в сумме → статус ok.

# INPUT FORMAT
[{
 "standard_name": "<название стандарта>",
 "rules": [ { "rule_text":"...", "status":"ok|partial|fail", "what_wrong":"...", "fix":"..." }, ... ],
 "summary": { ... }
}, ... ]

# INPUT DATA
%s

# ALGORITHM

0) PRE-NORMALIZATION (важно)

- Не воспринимай формулировки из одного чанка как истину о всём документе.
- Если status ∈ {fail, partial} и в what_wrong есть маркеры локальности
 (например: «в данном фрагменте», «в предоставленном фрагменте», «не найдено здесь», «таблица отсутствует»), трактуй это как локальное отсутствие (LOCAL) — на уровне документа это unknown, пока нет других свидетельств.
- Только явные глобальные формулировки («в документе нет», «по всему документу отсутствует», «во всех разделах нет») считаются GLOBAL-отрицанием. Их сила уступает любому положительному свидетельству из других чанков.

1) ГРУППИРОВКА ПО СМЫСЛУ

- Объедини записи по rule_text, считая «одним правилом» близкие по смыслу формулировки (даже если текст слегка отличается).
- Внутри каждой группы собери:
 - все статусы отдельных чанков,
 - все тексты what_wrong/fix.

2) СЕМАНТИЧЕСКОЕ «АСПЕКТНОЕ» СКЛЕИВАНИЕ (без заранее заданных списков)

- Для каждой группы выдели аспекты требования абстрактно: извлеки ключевые фразы/существительные и их дополнения из rule_text, what_wrong, fix (например: «таблица», «первая строка», «заголовок», «описание доступа», «диплинк», «назначение», «эффект» и т.п.).
- Для каждой записи пометь:
 - present — что подтверждается (прямо или косвенно) в любом чанке,
 - missing — что отмечено как отсутствующее,
 - unknown — не упомянуто.
- Если для какого-то аспекта есть и present, и missing из разных чанков, считай, что аспект присутствует (presence > absence), а «missing» локально/устарело.

3) РАЗРЕШЕНИЕ ПРОТИВОРЕЧИЙ

- Если в группе есть хоть одно положительное свидетельство (прямой ok или явное наличие аспекта в тексте) → аспект считается покрытым.
- Локальные отрицания из отдельных чанков не создают глобального fail.
- Глобальное отрицание учитывай только если:
 - положительных свидетельств нет ВООБЩЕ, и
 - таких глобальных отрицаний из разных чанков ≥ 2.

4) ИТОГОВЫЙ СТАТУС ГРУППЫ

- Если все существенные аспекты требования покрыты в сумме разных чанков → ok.
- Если часть аспектов покрыта, а часть остаётся неизвестной/непокрытой → partial.
- Если аспекты в целом не покрыты и есть согласованное отрицание (см. п.3) → fail.
- Если есть хотя бы один ok по группе — итог ok.
- Если во всех чанках fail/partial, но их what_wrong описывают разные недостающие части и вместе они закрывают требование → итог ok.

5) ФИЛЬТР ТЕКСТОВ ДЛЯ ИТОГОВОГО ПРАВИЛА

- what_wrong: объедини уникальные замечания ТОЛЬКО из записей со статусом partial/fail и только те, что по смыслу относятся к текущему rule_text.
 - Удали локальные маркеры («в этом фрагменте…»), устаревшие и противоречащие положительным свидетельствам.
 - Если итоговый статус группы = ok → оставь what_wrong пустым.
- fix: объедини уникальные рекомендации ТОЛЬКО из записей со статусом partial/fail, которые по смыслу относятся к текущему rule_text.
 - Если рекомендация по смыслу относится к другому требованию (по ключевым словам не пересекается с rule_text) — игнорируй её как «шум».
 - Если итоговый статус группы = ok → оставь fix пустым.

6) SUMMARY

- total — число уникальных групп правил.
- ok/partial/fail — подсчёт по итоговым статусам групп.
- recommendations — объединённый список всех уникальных fix ТОЛЬКО из итоговых правил, где statusok.
 - Не добавляй рекомендации из правил со статусом ok.
 - Удали повторы и рекомендации, противоречащие итоговому статусу.

7) АНТИ-ОБОБЩЕНИЕ

Если в what_wrong или fix встречаются слова "добавить/указать/исправить" без примера тега/строки/URL, переформулируй с конкретной вставкой. Общие рекомендации запрещены.

# RULES

- Рассматривай чанки как части одного целого: локальное отсутствие ≠ глобальное.
- При противоречии «есть» vs «нет» побеждает «есть» (presence > absence).
- Если совокупно требование выполнено — статус ok, а what_wrong/fix должны быть пустыми.
- Рекомендации формируй только по оставшимся несоответствиям (partial/fail), по смысловому совпадению с правилом.
- При сомнениях между fail и partial выбирай partial.
- Верни только валидный JSON и ничего больше.

# OUTPUT

Только валидный JSON:

```

{
 "standard_name": "<название>",
 "rules": [
   {
     "rule_text": "...",
     "status": "...",
     "what_wrong": "...",
     "fix": "..."
   }
 ],
 "summary": {
   "total": <число>,
   "ok": <число>,
   "partial": <число>,
   "fail": <число>,
   "recommendations": ["..."]
 }

}

```

Формирование итогового отчета (саммари)

На этом этапе у нас уже есть детализированные ответы от LLM по каждому требованию стандарта.

Однако при сборке итогового саммари возникла новая проблема:
❗️ В одном большом ответе LLM не всегда может корректно агрегировать замечания, даже если они явно присутствуют в предыдущих ответах.

Причина очевидна — слишком большой объём текста, высокий уровень детализации, а значит, большой риск потери важных фрагментов при свёртке. Чтобы избежать этого, мы решили ввести строгую структуру и формализованный формат отчета:

Промпт

# TASK:

Собери результаты частичных проверок документации в единый отчет Jira-формата.

# INPUT STRUCTURE:
{
"standard_name": "<раздел>",
"rules": [
 {"rule_text": "<проверка>", "status": "ok|partial|failed", "what_wrong": "<ошибка>", "fix": "<рекомендация>"}
],
"summary": {"total": n, "ok": n, "partial": n, "fail": n, "recommendations": ["..."]}
}, ...

# INPUT DATA:

%s

# OUTPUT FORMAT:

{
 "answer" : "<тут твой проведенный анализ в формате, казанном ниже>"
 "recommendation": "<тут рекомендации по устранению недостатков, если есть>"
}

## OUTPUT FORMAT for "answer"

%s

# RULES:

## Для поля answer

1. Для каждого критерия формируй строку в таблице:

  status:
  ✅ Выполнено (детали) | ⚠️ Частично выполнено (пояснение) | ❌ Не выполнено (ошибка)
  Если критерий не проверен → ❌ Нет данных (пропусти при оценке).
2. Соблюдай формат таблиц Jira: | Критерий | Статус |.
3. Обеспечь полноту: ни один критерий не пропускай, даже если пустой.
4. Запрещено изменять столбцы и строки таблицы из output format. .

## Для поля recommendations

1. Если все критерии выполнены, оставь поле пустым.
2. Заполняй recommendations рекомендациями по устранению не выполненных требований.
2.1. Рекомендации должны быть в формате 1 предложения без доп. символов, нумерации, буллетов и т.д.

## ПРАВИЛА ОБРАБОТКИ INPUT DATA

1. Правила нужно распределять по конкретным критериям этого раздела по смыслу rule_text:
  - 1 критерий = группа правил одной тематики;
  - правил всегда больше, чем критериев, поэтому несколько правил объединяются в одну группу;
  - если в группе нет правил, подходящих под конкретный критерий → ставится “❌ Нет данных”;
  - запрещено дублировать одно и то же правило в разные критерии;
  - запрещено копировать общее нарушение на все критерии раздела.

2. Итоговый статус по каждому критерию определяется по всем правилам, отнесённым именно к этому критерию:
  - если среди них есть fail → итог "❌ Не выполнено";
  - иначе если есть partial → "⚠️ Частично выполнено";
  - иначе если все ok → "✅ Выполнено";
  - если не найдено ни одного правила → "❌ Нет данных".

5. Итоговый текст в скобках — краткое (1–2 предложения) summary по этому критерию: объединённая суть what's_wrong + fix для правил, относящихся к этому критерию.

6. Если what's_wrong и fix пустые — выводится только статус.

Что делает LLM на этом этапе:

  1. Объединяет все результаты проверок (по разделам и кросс-проверкам).

  2. Формирует таблицы по каждому разделу с указанием всех критериев.

  3. Отмечает статус каждого пункта: выполнен/не выполнен/частично выполнен.

  4. Добавляет детали ошибок в скобках рядом со статусом — чтобы пользователь ничего не упустил.

  5. В финале — генерирует краткий вывод и общую оценку*.

*Оценка является субъективной и основывается на “мнении” LLM (по крайней мере, пока что)

Так выглядит отчёт о проверке на соответствие стандарту в комментарии к задаче в Jira:

И ниже такие же таблички для каждого раздела. А после — краткая выжимка:

 

Что пошло не так (и как мы это починили)

Нестабильность, хаос, баги, токен-хелл

Спрятанные элементы

Многие элементы документации в Confluence «прячутся» внутри макросов, тегов и других визуальных обёрток. При попытке анализа через обычный текст LLM просто не видит, например, ссылки — они выглядят как обычный текст.

🔧 Решение: обрабатываем HTML напрямую. Так мы можем вытащить реальный контент, включая ссылки, форматирование и скрытые блоки. Это обязательный шаг, если хотим точный анализ.

Лимит токенов

Классика. Самая неудобная проблема на текущий момент. 

🔧 Решение:

  • «Чанкование». Разделение на чанки, последовательная проверка документации по кусочкам и суммаризация результатов.

  • Очистка текста. Определили whitelist тегов, которые действительно несут смысл (заголовки, ссылки, таблицы и т.д.), остальные — в мусор. Это уменьшает шум и экономит токены.

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

Многобукв → LLM не справляется

Когда в промпте слишком много требований и данных, модель начинает путаться: формулировки становятся размытыми, какие-то пункты игнорируются. Здесь нас выручают:

  • «Чанкование». При возможности лучше разбивать текст на логические блоки и обрабатывать отдельно. Чем меньше контекст, тем выше точность.

  • Чёткие структура и формат для саммари. Вместо «ну вроде всё ок» — детальный разбор по каждому пункту стандарта. Формат саммари должен быть строгим и однообразным, тогда ответы проще обрабатывать.

Нестабильные ответы

Когда от LLM требуется бинарное решение (например, «Да» или «Нет»), просто хорошо написанного промпта может быть недостаточно — модель может «фантазировать» или давать слишком расплывчатые ответы.

🔧 Решение. Для таких случаев мы используем Structured Output — форматируем запросы и просим возвращать результат в строго заданной структуре (JSON, таблицы, булевы значения и т.д.). Это сильно повышает стабильность и предсказуемость результата.

// отправляем запрос в LLM и мапим его в нужную нам entity
private YesNoAnswerRecord isChangeDescriptionTechnical(String question) {
   return ChatClient.create(openAiChatModel).prompt(new Prompt(question)).call().entity(YesNoAnswerRecord.class);
}

Долгое выполнение запросов

LLM — штука небыстрая. Пока «подумает», пока вернёт ответ, проходит ощутимое количество времени. А если у нас десятки чанков — привет очередь из запросов и вечность в ожидании. 

Чтобы не наблюдать, как летят секунды, мы подключили Java Virtual Threads и распараллелили запросы. Вызов чанков и проверок происходит параллельно, далее результаты собираются в один список. Вместо конвейера асинхронных запросов выполняем все запросы сразу, а длительность будет зависеть от самого долгого запроса в LLM. 

Результат на одном из примеров: вместо 1 мин 20 сек — 26 секунд. И никаких жертв — всё стабильно, аккуратно и параллельно.

Как это выглядит в коде: 

List<Future<String>> futures = new LinkedList<>();
// Вызываем виртуал тред
docsLinkChunks.forEach((chunkIndex, chunk) -> {
   Future<String> future = virtualThreadExecutorService.getVirtualExecutor().submit(() -> {
       String request = String.format(requestPrompt, chunk, changeDescription);
       return chatModel.call(request);
   });
   futures.add(future);
});


// Заполняем список ответов из многопоточности
List<String> answerList = new ArrayList<>();
futures.forEach(future -> virtualThreadExecutorService.fillListOfVirtualThreadResult(future, answerList));

Что получилось в итоге?

Реальные результаты: метрики, UX, отзывы

Взяли медианное время проведения ревью документации и разбили по месяцам. Результаты получились такие:

Что упростилось/ускорилось?

  • Проверка стала менее рутинной. Теперь не нужно вручную вычитывать корректность ссылок и формат — агент справляется с этим.

  • У аналитиков появилась «шпаргалка» в виде отчёта с результатами проверок. Это сокращает время на уточнение формулировок и требований.

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

  • Нет необходимости ждать или проводить повторное ревью изменений, можно перезапустить агента и получить результат уже с учетом правок.

Ограничения

  • Макеты — проверяется только наличие, не контент.

  • Нестандартные кейсы — если структура документации сильно отличается от шаблонной, автоматически проверить её корректность не получится.

Что ещё осталось нерешённым?

Ничего.

О чём было бы круто услышать мнение читателей

Мы не специализируется на разработке агентов и не посвящаем этому все наше рабочее время. Данный агент - side проект группы энтузиастов в рамках нашего технического развития по одному из направлений :) 

  • Возможно, мы где-то «переизобрели велосипед».

  • Могли упустить best practices.

  • Или есть более простые подходы, о которых мы не знали.

Если ты дочитал(а) до этого места — будем очень благодарны за обратную связь 💜

Благодарности

Семен Корольков и Влад Курчин (@simple_analytics) — спасибо за идеи, внимательность и то, что помогали довести всё до ума. За обсуждения, уточнения и споры, без которых финальная версия точно была бы слабее. 

Мише Буторину (@MoshkaBortmanXXL) — за код, терпение и то, что поддержал идею, когда она была ещё просто гипотезой. 

Артёму Гринько — за поддержку нашего проекта и наставления на верный путь. 


Мы активно развиваем MLSecOps для защиты ИИ-систем. У нас открыта вакансия Специалист по защите искусственного интеллекта (MLSecOps). Будете анализировать безопасность систем, разрабатывать защиту от атак, проводить аудиты и тестирование AI-систем и разрабатывать политики и процессы безопасности. Присылайте отклики!

Также читайте:

Три варианта решения задачи распределения бюджета в категорийном кэшбэке
Уровень «Хард». Часто нам нужно распределить бюджет какой-то акции/программы так, чтобы… Это «чтобы»...
habr.com
Почему ваш новый «гениальный» флоу вызывает у команды панику? Разбираем психологию сопротивления изменениям
Привет, Хабр, меня зовут Станислав, я Product manager! Представьте ситуацию: вы, как продакт, нескол...
habr.com