AI-агент для ревью документации в Confluence: инструкция как сделать также
Пишу эту статью, чтобы поделиться нашим опытом. В Альфе документация на фичи живёт в Confluence и перед каждым релизом проходит обязательное ревью. Платформенные аналитики проверяют её вручную — и это регулярно тормозит процесс. Мы решили разобраться с этим с помощью AI-агента. Рассказываем, как он устроен, с чем пришлось повозиться и что получилось в итоге.
Мы разработали и внедрили AI-агента в процесс ревью документации в Confluence. Агент является частью экосистемы агентов Desmond, а под капотом у него Java 21, Spring AI, gpt-oss-120b, Docker + Kubernetes во внутреннем AI-кластере банка, Jira (webhook + REST API + Kafka), LibreChat (MCP). Он автоматически проверяет документацию в Confluence на соответствие стандарту. Агент встроен в Jira: получает webhook при смене статуса задачи, находит нужную документацию, проверяет её и оставляет комментарий с результатами ревью.
Кому это будет нужно:
Платформенным аналитикам — меньше рутинного ревью, больше времени на рабочие задачи.
Продуктовым аналитикам и разработчикам — быстрая обратная связь, понятные и шаблонные требования для проекта.
Откуда растёт проблема
Прежде чем описывать решение, давайте поймем контекст.
Перед релизом любой фичи команда готовит поставку: у разработчика — code review, у аналитика — ревью документации. Весь процесс идёт по workflow в Jira, и один из обязательных этапов — Docs review.
За этот этап отвечают платформенные аналитики: они дежурят по очереди и проверяют поставки. Их задача — убедиться, что документация написана по стандарту и отражает все новые изменения по продуктам. Хранится всё в едином Confluence-пространстве платформы, и порядок там очень нужен: документацию читают не только аналитики разных команд, но и саппорт.
Звучит адекватно. Но на практике вылезают три проблемы:
Задержки. У платформенного аналитика помимо ревью куча других задач. Когда поставок много — Docs review начинает тормозить весь процесс подготовки к релизу.
Усталость ревьювера. Большой поток однотипных проверок выматывает даже самого внимательного, поэтому риск нужно было сократить.
Человеческий фактор. Несмотря на наличие стандарта, разные ревьюверы могут расставлять акценты по-разному. Аналитик продуктовой команды никогда не знает наверняка, на что обратят внимание в этот раз.
Всё это бьёт по времени поставки и непонятно растягивает подготовку к релизу — особенно когда релизиться нужно быстро.
Что пробовали раньше
Прежде чем идти в сторону AI, рассмотрели два очевидных варианта.
Вариант 1: убрать ревью совсем.
Радикально, экономит время всех участников, но тогда мы теряем контроль над качеством документации — а это дорого. Документацию читают не только аналитики, но и саппорты. Без проверки начнут теряться важные детали о работе функционала, по сути, возвращаемся к тому хаосу, от которого уже уходили раньше. Не вариант.
Вариант 2: алгоритмическая автоматизация без AI.
Теоретически можно написать набор проверок на if-else. Но у документации нет синтаксиса, как у кода — каждый аналитик пишет немного по-своему. Поддержка такого алгоритма со временем станет дороже, чем просто делать ревью руками.
Почему AI-агент
После того как стандартные подходы отпали, мы стали думать в сторону LLM. И чем дольше разбирались, тем яснее становилось: LLM под это хорошо ложится.
Вот конкретные факты, которые мы собрали внутри команды:
LLM умеет понимать суть написанного, а не только искать точные совпадения, если документация написана по-разному — для нейронки это не проблема.
Когда требования меняются, в большинстве случаев достаточно поправить промпт. Аналитик справится сам, без участия разработчика.
У агента одна задача — ревью, он делает её сразу, не надо ждать, пока освободится дежурный аналитик.
Агент интегрируется в существующий Jira-workflow. Для команд ничего не меняется — агент работает в фоне.
У агента понятный набор инструкций.
Архитектура агента
Desmond — экосистема агентов. Desmond Docs Reviewer — task-oriented AI-агент для автоматизации ревью внутренней документации на платформе Альфа-Онлайн.
Стек:
Язык: Java 21 + Spring AI
Инфраструктура: Docker + Kubernetes во внутреннем AI-кластере банка
Модель: gpt-oss-120b — внутренний продукт банка AlfaGen, развёрнутый локально
Интеграции: Jira (webhook + REST API + Kafka), Code Review Agent (A2A через MCP), LLM API
Точка входа — webhook из Jira. Задача переходит в нужный статус → триггерится webhook → агент получает событие и начинает работу.
На входе агент имеет три сущности:
описание изменений (фича)
ссылку на документацию в Confluence
ссылку на Pull Request разработчика
Дальше — четыре шага.
Как агент решает задачу пошагово
Посмотрим на работу агента со стороны процесса и распишем детально каждый.
Шаг 1. Нужна ли вообще проверка?
Первое, что делает агент — определяет, техническая это доработка или бизнесовая. Если изменения затрагивают только код и не меняют бизнес-логику — документацию обновлять не нужно, а значит и проверять нечего.
Агент анализирует описание задачи и diff PR. Смотрит на примеры из промпта и выдаёт решение: техническая доработка или нет, плюс короткое обоснование — не больше 30 слов.
Типовые технические доработки, которые не требуют проверки: обновление библиотек, рефакторинг тестов, исправление линтера, дизайнерские правки без влияния на UX. Есть и исключения — например, изменение deeplink-параметров или перемещение приложения в новый репозиторий. Всё это прописано в промпте явно.
На реальных задачах получили 89,7% корректных ответов (27 из 30). Для старта приемлемо, но будем улучшать.
Если доработка техническая — агент пишет комментарий в задачу и переводит её в следующий статус. Дальше не идёт.
Общий сценарий в коде выглядит так:
```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);
}
```
Шаг 2. Есть ли в документации описание фичи?
Если доработка бизнесовая — агент ищет в документации фрагменты, которые описывают конкретную фичу. Задача на этом шаге простая: найдено ли описание, и если да — в каких разделах и что именно написано.
Отдельно проверяется раздел «7. История изменений» — изменение должно быть там зафиксировано.
Проблема с токенами. Документация бывает большой — и она не влезает в контекст целиком. Решение: разбить на чанки и анализировать по частям.
Стратегия нарезки простая — равномерно по количеству символов, семантическое деление по разделам здесь не критично: мы ищем одни и те же фрагменты вне зависимости от их положения в структуре.
Чтобы не терять контекст на границах, используем перекрытие (overlap): к каждому чанку добавляем хвост предыдущего и заголовок его раздела. Размер чанка рассчитывается по формуле с коэффициентом, подобранным эмпирически — с запасом на колебания длины токенов.
Смысл её в том, чтобы заранее рассчитать максимально допустимое количество символов на чанк (C_chunk), включая резерв под перекрытие (C_dup) и заголовок. Коэффициент r был подобран эмпирически — с запасом, чтобы учесть возможные колебания в длине токенов.
После того как все чанки обработаны, агент собирает сводное саммари: в каких разделах нашлось описание фичи, указано ли изменение в истории. Если ничего не найдено — просит проверить формулировку описания в задаче.
Пример ответа, когда описание фичи найдено:
Пример ответа, когда описание фичи не найдено:
Шаг 3. Соответствует ли документация стандарту?
Это самая сложная часть — со звёздочкой. Нужно пройтись по каждому разделу документа и сверить его с требованиями стандарта. Документация структурирована: шапка, основная информация, предусловия, постусловия, сценарии использования, алгоритм, подробное описание, история изменений.
Стандарт для LLM — это не то же самое, что стандарт для людей
Когда мы первый раз запустили проверку, скормили агенту существующий стандарт как есть. Результат нам не понравился: LLM интерпретировала одни и те же требования по-разному, часть пунктов игнорировала, часть выполняла непредсказуемо.
Причина в том, что стандарт писался для аналитиков, которые понимают контекст и в нём много неявных допущений — «вставьте ссылку на команду», «укажите репозиторий» — и ни слова о том, как именно это должно выглядеть.
Вот типичные вопросы, на которые у стандарта не было ответа:
Ссылка на команду — это Confluence, Notion или Jira?
Jira-ссылка — это макрос или обычный URL?
Как проверить корректность ссылки на репозиторий?
Всегда ли обязательна эта таблица, или бывают исключения?
Переписали стандарт в LLM-читаемый формат, руководствуясь простыми принципами:
Каждое требование сформулировано явно и однозначно.
Для каждого поля приведены примеры правильного и неправильного заполнения.
Никаких «подразумевается» и «как правило».
Вот как выглядит фрагмент переписанного стандарта для шапки документа:
# Таблица
## Требования к таблице
Таблица должна содержать *ровно 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".
Кросс-раздельные проверки
Часть требований касается не одного раздела, а согласованности между несколькими. Например: все сервисы, упомянутые в колонке Git Middle шапки, должны иметь ссылку на документацию в разделе «Основная информация».
Для таких случаев мы выделили отдельный тип — кросс-проверки. Они оформлены как отдельные правила, где явно указано, какие разделы участвуют и что между ними должно быть согласовано. В промпт к агенту передаются сразу несколько чанков документации — те разделы, которые участвуют в проверке.
Один из примеров кросс-проверок:
# Таблица; 1. Основная информация
## Дано
- таблица, в которой в колонке «Git Middle» указаны ссылки на репозитории сервисов.
- раздел «1. Основная информация», в котором указаны ссылки на документацию сервисов
## Обязательные проверки
### Полнота сервисов в разделе «1. Основная информация»
- для каждого сервиса из этой колонки убедиться, что в разделе «1. Основная информация» присутствует ссылка на официальную документацию.
Chunking strategy
В этой задаче нам важно обеспечить максимально точную проверку по всем требованиям, поэтому мы будем здесь использовать стратегию логического разделения по разделам. Выглядит это как-то так:
Дано:
LLM-читаемый формат, разбитый по разделам
Документация, также структурированная по разделам
Решение:
1. Разделяем на чанки документацию. 1 чанк = 1 раздел.
2. Разделяем на чанки стандарт. 1 чанк = 1 требование.
3. Сопоставляем чанки стандарта и документации между собой:
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
Итоговый промпт:
# 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 и без комментариев.
Таким образом, мы проверяем каждый раздел и каждую кросс-проверку отдельно. После прохождения всех проверок собираем итоговое саммари, чтобы зафиксировать результат анализа по всему документу.
Как выглядит код:
``` java
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 из шага 2 и агрегируем результаты с помощью немного сумасшедшего 💊, но работающего промпта.
# 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` ТОЛЬКО из итоговых правил, где `status` ≠ `ok`.
- Не добавляй рекомендации из правил со статусом `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": ["..."]
}
}
Агент отправляет запрос на проверку по каждому соответствию, затем собирает всё в нормальный отчёт. Изначально отчёт выходил на 200–250 строк — неудобно читать, мы добавили отдельный шаг финального саммари — сократили до 50 строк без потери содержания.
Технические грабли
Нестабильность ответов LLM
Одна из первых проблем — LLM отвечала в произвольном формате. Иногда JSON, иногда текст, иногда что-то среднее. Решение: Structured Output в Spring AI. Ответ всегда приходит в заданной структуре — никакой самодеятельности.
``` java
// отправляем запрос в LLM и мапим его в нужную нам entity
private YesNoAnswerRecord isChangeDescriptionTechnical(String question) {
return ChatClient.create(openAiChatModel).prompt(new Prompt(question)).call().entity(YesNoAnswerRecord.class);
}
```
Лимит токенов
Встречается на обоих шагах, но по-разному. На шаге поиска фичи — документация слишком большая целиком. На шаге проверки стандарту — в контекст не влезают и документ, и стандарт одновременно. В обоих случаях решение — chunking с правильно рассчитанным размером и overlap.
Скрытые элементы Confluence
Confluence отдаёт HTML, и часть контента скрыта в элементах, которые не видны при обычном просмотре страницы. Агент работает напрямую с HTML, в котором остаются только нужные для обработки теги — это позволяет не терять данные, которые визуально не отображаются
Долгое время обработки
Первые версии работали медленно: несколько запросов к LLM шли последовательно. Переключились на Java Virtual Threads — параллельная обработка чанков сократила медианное время ревью с 1:20 до 0:26.