Привет! Я Алексей из команды Fiji, которая занимается внутренним продуктом для хранения и редактирования геоданных. Мы уже немного рассказывали о нем на Хабре: раз, два, три, четыре.
Наш проект активно развивается уже 10 лет, недавно ещё и команда выросла вдвое. Соответственно, почти вдвое увеличилось количество задач, а вместе с ним — и сложность интеграций с другими командами. Требования часто дополняются и меняются по ходу реализации, статьи в Confluence не всегда актуализируются, а часть информации оседает в чатах и на созвонах. Только код в мастере стабильно отражает то, что реально работает на продакшне.
Не так давно у нас случился триггер на одном из созвонов — технолог задал вопрос про задачу, которую делали пару месяцев назад, а мы все сидим и глазами хлопаем, ничего не помним. Ни заказчики, ни аналитики, ни разработчики. Кого-то из тех, кто мог бы ответить, на встрече не было. Тут и подумалось: в коде-то эта вся логика есть, нужно её только достать и переварить обратно в текст.
Так и появилась идея сделать помощника как для новых ребят, так и для старичков, так как весь контекст держать в головах уже проблематично: основной солюшн — это почти 15 тысяч файлов на C# и около 1.5 млн строк кода, плюс утилиты и пара сервисов на Java. В статье — история о пройденном пути создания командного ассистента, который помогает отвечать на любые вопросы о проекте.
Инструменты на старте
Мы могли бы использовать GitHub Copilot, в том числе в режиме агента — иногда он отвечает нормально, особенно если знать какие файлы подкинуть ему и сделать это явно. Однако в целом он не находит весь нужный контекст, так как имеет ограничения по индексированию большого числа файлов. При этом доступ к Copilot есть только у разработки и тестирования, а у аналитиков и менеджеров — нет. Другие инструменты, вроде Cursor, недоступны нам для рабочего кода, поэтому решили сделать своё решение.
Первый эксперимент
В 2ГИС есть команда, которая занимается AI-направлением. Ребята выкатили удобный сервис для работы с RAG (Retrieval Augmented Generation): можно заливать свои текстовые файлы и задавать к ним вопросы через простой API. Под капотом — LangChain Python, Qdrant и GPT-4.1 Модели OpenAI доступны нам и напрямую, не только через RAG API.
C начала проекта Fiji мы старались писать саммари к публичным классам и методам, чтобы было чуть проще ориентироваться. Но дальше комментариев в IDE дело не заходило — генерации документации по саммари у нас не было. Кажется, время пришло!
Идея простая: сгенерировать XML-файл по саммари, закинуть его в RAG и попробовать позадавать вопросы. Для тестов решено было взять один проект с серверной бизнес-логикой — всего же в солюшне уже почти сотня проектов разной степени сложности и размеров.
Типичный XML для одного класса
<members>
<member name="T:BusinessLogic.Server.ArchLogic">
<summary>
Логика создания/удаления арок
</summary>
</member>
<member name="M:BusinessLogic.Server.ArchLogic.#ctor(DomainModel.IMetadataSnapshot,DataAccess.IFeatureRepository,DataAccess.IBusinessLogicRepository)">
<summary>
Создает экземпляр объекта
</summary>
<param name="metadataSnapshot">Источник метаданных</param>
<param name="featureRepository">Репозиторий по работе с фичами</param>
</member>
<member name="P:BusinessLogic.Server.ArchLogic.ArchClassId">
<summary>
Идентфикатор фичекласса "Арка"
</summary>
</member>
<member name="M:BusinessLogic.Server.ArchLogic.RecreateArches(DomainModel.Feature[],DataAccess.IRepositorySession,DomainModel.Audit.AuditChangeSource)">
<summary>
При изменении пересоздать арки при изменении стиля или геометрии дорог
</summary>
<param name="createdRoads">Измененные дороги</param>
<param name="session">Сессия работы с БД</param>
<param name="auditChangeSource">Источник изменения фич для аудита</param>
</member>
<member name="M:BusinessLogic.Server.ArchLogic.CreateArches(System.Collections.Generic.IReadOnlyCollection{DomainModel.Feature},DataAccess.IRepositorySession,DomainModel.Audit.AuditChangeSource)">
<summary>
При создании дорог есоздать арки
</summary>
<param name="createdRoads">Измененные дороги</param>
<param name="session">Сессия работы с БД</param>
<param name="auditChangeSource">Источник изменения фич для аудита</param>
</member>
…
Буквально несколько запросов для настройки RAG-ассистента и можно кидать вопрос.

Первый ответ получен, немного радуемся наступившему будущему и встраиваем вызов запросов к RAG в командного бота, чтобы поделиться со всеми.
Что за бот?
Чат-бот в Mattermost существует уже несколько лет, но до этого умел выполнять только строгие команды для автоматизации рутины — например, подготовка релизов или ротация дежурных. А теперь может отвечать на вопросы по проекту в свободной форме. Бот тоже написан на C#, как и большая часть Fiji.

Вроде бы успех, но здесь нет никакой информации о коде — наш RAG обрабатывает XML так, что выкидывает из него все тэги и значения атрибутов. В ответе только то, что попало в текст саммари — без имён классов, методов и их параметров. Возникает мысль, что саммари должно быть чуть подробнее, чтобы точнее отражать то, что происходит в коде. А мы, как правило, ленимся — в лучшем случае это однострочные комментарии, а иногда их и совсем нет.
Раз саммари не хватает, давайте сгенерируем? Конечно же, с помощью LLM.
Получение документации
Генерация саммари
Copilot умеет генерировать саммари, но текст получается не сильно подробный и управлять этим нельзя, а хочется настраивать промпт, чтобы влиять на детальность и стиль комментариев. Для этого у нас появился roslyn-анализатор с генерацией саммари по коду.
Анализатор прямо в IDE подсвечивает классы и методы с отсутствующими саммари и позволяет генерировать их. Работает так: отдаём модели код нужного класса, в ответ получаем текст саммари, который вставляем перед классом или методом. Если что-то не устраивает, то подкручиваем промпт либо правим получившийся текст сами. Это куда быстрее, чем набирать его с нуля руками.
Важная часть промпта — глоссарий. Чтобы лучше попадать в командный язык: принятые внутри термины доменной модели, сокращения, историческое наследие и т.п.
Часть промпта с глоссарием
## ГЛОССАРИЙ
Используй следующие соответствия терминов в коде для повышения понятности и точности:
- branch - проект
- CargoFrame - грузовой каркас
- CargoGraph - грузовой граф
- city - наспункт
- clarification - уточнение
- crossroad - перекресток
- CutFeatures - разрезание фич
- edge - ребро
- elevation - этажность
- embankment - насыпь
- FareZone - тарифная зона
- gateway - проход-проезд
- HousingEstate - жилмассив
- hydrography - гидрография
- ManagerTerritory - территория работы менеджеров
- multilingual - мультиязычный
- ParkingArea - площадные парковки
- POI - достопримечательности
…
Пример получающегося саммари
/// <summary>
/// generated by gpt-4.1
/// Класс для работы с объектами типа "арка", обеспечивающий расчет и обработку арок на пересечениях дорожных звеньев при их создании и изменении.
/// Реализует:
/// - Вычисление идентификатора фичекласса для арок.
/// - Создание новых арок при добавлении дорожных звеньев.
/// - Пересчет и актуализацию арок при изменении стиля или геометрии дорожных звеньев, включая удаление устаревших и добавление новых арок.
/// - Поиск необходимых соседних дорожных звеньев для корректного формирования арок на соответствующих точках стыка.
/// Обеспечивает согласованность между дорожными звеньями и их пересечениями посредством арок, а также контроль актуальности информации при редактировании данных.
/// </summary>
public class ArchLogic
{
…
Саммари добавили, что дальше?
Преобразование в JSON
Нам хотелось, чтобы в ответе ассистента были упоминания классов, из которых он добыл ответ, а в идеале ссылки на исходный код.
Простой эксперимент с конвертацией XML в JSON и загрузкой полученного файла показал, что в таком случае из него ничего не теряется. Ещё можно заметить, что в исходном XML куча однотипного текста с описанием параметров методов, которые никак не помогают модели при ответах на наши вопросы — из названий переменных очень часто и так понятно их предназначение. Другие эксперименты показывают, что если иметь подробное саммари для класса, то RAG будет по вопросу попадать в него и описания методов тоже не нужны. А чтобы модель могла указывать ссылки на используемые классы надо ей об этом как-то сообщить.
Ну что ж, напишем немного кода по преобразованию XML в JSON, оставив только самое необходимое — саммари от классов, их имена и пути до них.
Часть JSON на примере пары классов
[
{
"filePath": "BusinessLogic.Server/ArchLogic.cs",
"className": "ArchLogic",
"summary": "Класс для работы с объектами типа "арка", обеспечивающий расчет и обработку арок на пересечениях дорожных звеньев при их\nсоздании и изменении.\nРеализует:\n- Вычисление идентификатора фичекласса для арок.\n- Создание новых арок при добавлении дорожных звеньев.\n- Пересчет и актуализацию арок при изменении стиля или геометрии дорожных звеньев, включая удаление устаревших и\nдобавление новых арок.\n- Поиск необходимых соседних дорожных звеньев для корректного формирования арок на соответствующих точках стыка.\nОбеспечивает согласованность между дорожными звеньями и их пересечениями посредством арок, а также контроль актуальности\nинформации при редактировании данных."
},
{
"filePath": "BusinessLogic.Server/BuildingAddressLogic/BuildingAddressSearcher.cs",
"className": "BuildingAddressSearcher",
"summary": "Класс предназначен для поиска и анализа адресов зданий, связанных с изменениями в базе данных. Предоставляет методы для:\n- Получения актуальных ссылок на адреса, к которым привязаны здания, с учетом изменений текущей сессии — ссылки на\nадреса, связанные со зданиями до их изменений, не возвращаются.\n- Извлечения адресов из заданного перечня зданий, формируя список пар «здание-адрес».\n- Определения новых или изменённых адресов здания по сравнению с его предыдущим состоянием на основе сравнения атрибутов\nадреса (название улицы и номер дома).\n- Выявления адресов, которые были у здания до изменений, но отсутствуют в его новой версии, также на основании сравнения\nатрибутов адреса.\n- Поиска адресов, которые полностью удаляются из зданий (и не перемещаются в другие здания), на основании\nидентификаторов адресов в справочнике.\nСпециализирован для работы с геообъектами типа \"здание\" и связанными с ними адресами, учитывает изменения данных в\nрамках одной сессии работы с базой данных."
},
...
Заливаем в RAG, кидаем вопрос. Уже лучше, к тому же есть ссылки на исходный код, чтобы можно было поразбираться. Явно указываем в промпте, чтобы модель при упоминании класса добавляла к нему ссылку на наш GitLab.

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

Нужно больше контекста
Идём в GitLab
О, у нас есть ссылки на исходники. Что если следующим шагом полностью передать их в LLM? Ведь как-то так и работают агенты в Copilot или Cursor – находят подходящий к нашему запросу исходный код и отдают модели. Чем мы хуже??
Берём исходники найденных через RAG классов (в промпте указываем, чтобы возвращались только пути до файлов, а не сам ответ), достаём их из GitLab, передаём в модель вместе с заданным вопросом. Тут уже попахивает магией, и мы получаем очень детальные ответы по актуальному коду, если попали с вопросом на саммари из проектов, загруженных в RAG.

Строим граф зависимостей кода
Если дочитать ответ бота до конца, то видно, что он явно попросил дать ещё исходников, которые могут помочь с ответом. Действительно, бывает, что часть логики находится в базовых классах, хэлперах или в других классах-зависимостях.
Поможем модели и накинем ещё больше контекста. Для этого построим граф зависимостей классов по всему нашему солюшну. И к тем файлам, что вернул RAG на первом шаге, добавим ещё и связанные с ними. Так модель дает ещё более точные ответы, даже если RAG вернул не всё, что хотелось бы. Для компиляции и получения семантической модели нашего кода используем nuget-пакет Microsoft.CodeAnalysis.Workspaces.MSBuild.
var workspace = MSBuildWorkspace.Create();
var solution = await workspace.OpenSolutionAsync(Path.Combine(RootPath, "Fiji.sln"));
foreach (var project in solution.Projects)
{
var compilation = await project.GetCompilationAsync();
foreach (var syntaxTree in compilation.SyntaxTrees)
{
var semanticModel = compilation.GetSemanticModel(syntaxTree);
var root = await syntaxTree.GetRootAsync();
var classDeclarations = root.DescendantNodes().OfType<ClassDeclarationSyntax>();
foreach (var classDeclaration in classDeclarations)
{
// добываем саммари из класса и ищем его зависимости
}
}
}
Нюансы реализации:
Чтобы сборка проекта прошла без ошибок нужно явно добавить пакеты Microsoft.CodeAnalysis.CSharp, Microsoft.CodeAnalysis.CSharp.Workspaces и Microsoft.CodeAnalysis.Workspaces.MSBuild, даже если IDE говорит, что они уже неявно подтянуты.
Берём только зависимости, связанные с нашим кодом. Классы самого dotnet и пакетов нас не интересуют: модель и так знает как они работают и зачем нужны.
Если зависимость пришла через интерфейс, то нам нужны его реализации, так как логика находится в них.
Иногда зависимостей может быть много и чтобы не перегружать контекст, приоритезируем зависимости и ограничиваем количество исходников в запросе к модели. Для экспериментов можно прикинуть сколько символов и токенов занимает типичный класс вашего кода, воспользовавшись этим инструментом от OpenAI — https://platform.openai.com/tokenizer
Приоритеты (вверху самые важные типы, их кидаем в контекст в первую очередь):
то, что вернул RAG,
базовые классы,
нэймспэйсы с бизнес-логикой,
остальные зависимости, кроме репозиториев и вспомогательных проектов (утилиты, логирование и т.п.),
репозитории,
классы-утилиты.
Храним найденные зависимости по всем классам солюшна в БД, чтобы не тратить время на их анализ при каждом запросе к ассистенту. Наличие графа зависимостей позволяет идти по нему и в обратную сторону — не только добавлять в контекст код, от которого зависит тот или иной класс, но и искать места использования конкретного класса и показывать модели, как и где он используется. Это поможет получить весь нужный контекст и отвечать вопросы типа: «Как обычно используется X?» Обычно ретриверы не могут дать полный ответ на такие общие вопросы, так как находят не все документы.
Также на этапе обхода всех классов проекта сразу создаём JSON с саммари этих классов, чтобы не генерировать XML при сборке солюшна как мы делали в начале. Таким образом шаг преобразования XML в JSON убирается. Актуализируем граф зависимостей и документы для RAG раз в сутки с ветки мастера.
После всех манипуляций ответы получаются уже такими.

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

Теперь наш пайплайн выглядит так:

Использование tool'ов
Итак, на текущий момент мы сами предоставляем модели исходный код некоторых классов для ответов на задаваемые вопросы. Но ведь она и сама может иметь доступ ко всем исходникам проекта и решать, какие именно классы ей нужны.
Мы добавили два своих тула для того, чтобы модель могла принять решение, какие файлы ей нужны, и запрашивала их:
По названию проекта модель получает его описание из README.md и структуру всех файлов в этом проекте.
По пути файла модель получает его исходный код.
А ещё, чтобы усилить контекст и дать модели первоначальные знания о солюшне, добавили в промпт краткое описание проектов — буквально по одному предложению на каждый. Теперь модель может отвечать на вопросы, даже если RAG не нашёл никаких подходящих классов, например, потому что саммари для этих классов просто нет. Но по наблюдениям, лучшие ответы получаются, когда модель получает для рассуждений хоть какой-то подходящий код.
Интеграция с IDE
Общение в Mattermost — это хорошо, и аналитики с менеджерами говорят нам спасибо. Но мы уже начали плотно взаимодействовать с кодом: отсылаем его модели и в ответ тоже получаем куски кода. Почему бы не делать это внутри IDE?
Давайте поможем Copilot с формированием контекста для наших запросов, а дальше он сам всё сделает. Как раз не так давно у него появилась поддержка MCP-протокола. И nuget-пакет для написания своего MCP-сервера уже есть, пусть пока и в preview-версии.
Идея простая — делаем tool, который с помощью нашего ассистента находит нужные файлы и отдает их в контекст Copilot.
Детали про SDK для dotnet можно найти на GitHub, а про сам протокол MCP в официальной документации. Там же есть ссылки и примеры работы с SDK на разных языках.
Формирование ответа внутри тула напоминает формирование обычного запроса к ChatGPT
var toolResponse = new CallToolResponse
{
Content =
[
new Content
{
Annotations = new Annotations { Audience = [Role.Assistant], Priority = 1 },
Text = Prompts.DocumentationToolPrompt
}
],
IsError = false
};
var sources = ragClient.GetSources(message, token);
await foreach (var (sourcePath, sourceCode) in sources)
{
var text = $"{sourcePath}\n{sourceCode}";
var content = new Content { Resource = new TextResourceContents { Text = text, Uri = sourcePath, MimeType = "text/plain" }, Type = "resource" };
toolResponse.Content.Add(content);
}
Есть аналог системного промпта, в нём указываем роль assistant и максимальный приоритет priority 1. В этом промпте указываем, что дальше Copilot должен опираться на предоставленные файлы и в ответах использовать ссылки на классы и методы, чтобы сразу можно было переходить к ним внутри IDE.
Дальше в качестве ресурсов передаем подходящие файлы, которые вернул RAG.
Ниже пример ответа на один и тот же вопрос без использования нашего MCP tool и с ним.


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

Результаты
В итоге пайплайн работы ассистента выглядит следующим образом:

-
Подготовка данных
Генерация саммари с помощью roslyn-анализатора — пока ручной процесс из-за вычитки результатов и мелких правок, но есть возможность генерации по проекту или сразу всему солюшну, если набраться смелости.
Получение JSON с саммари.
Заливка полученного JSON в RAG-ассистента.
Построение графа зависимостей файлов по всему солюшну.
-
Ответ на вопрос
Поиск в RAG подходящих для ответа файлов.
Поиск в дереве нужных зависимостей.
Получение исходников всех этих файлов из GitLab.
Формирование контекста из полученных исходников с приоритезацией зависимостей.
Отправка в LLM исходников и заданного вопроса.
Диалог с моделью с помощью тулов для получения еще большего контекста для ответа на вопрос.
Мелочи по форматированию ответа.
Так как вся логика формирования контекста происходит из нашего собственного кода, то мы можем и дальше совершенствовать её, добавляя новые шаги. Например, поиск в Jira подходящих под вопрос тикетов, поиск в git по истории изменений с объяснением что и зачем было сделано и т.д.
Если ваш проект не такой большой, то шагов с генераций саммари и использования RAG можно и не делать. Достаточно дать модели доступ к описаниям проектов и отдельным файлам, дальше она сама разберётся, что делать. Но нужны детальные описания солюшна, проектов, папок, структуры, каких-то основных моментов и командных договоренностей. Здесь ничего нового: чем детальнее описан проект, тем проще в него погрузиться не только новому члену команды, но и LLM.
Бот уже помогает с ответами всем участникам команды не только во внутренних чатах, но и при обращениях от других команд.

Чтобы оценить качество работы ассистента и его полезность, пару недель назад мы добавили в бота счётчики запросов и просьбу оценить ответ — пара кнопок прямо в конце ответа. 45% ответов отмечаются как полезные, а в 15% случаев, что они никак не помогли разобраться с вопросом. Результаты радуют, но есть куда расти.
Что ещё не сделано
Подробные саммари пока ещё есть не для всего проекта. Из-за этого ассистент дает хорошие ответы, если знать, что он может на них ответить. Если спрашивать всё подряд и не получать точных ответов, то быстро отпадёт желание им пользоваться.
Нужна интеграция с Open API/Swagger, схемой БД — это нужно, чтобы ассистент знал не только бизнес-логику, но и взаимодействия на других уровнях.
Ещё у нас много автотестов и тест-кейсов в Allure, было бы неплохо иметь интеграцию с ними.
Это ближайшие планы, и скоро мы это обязательно сделаем.
Несмотря на то, что не всё ещё доведено до идеала, команда уже отмечает полезность такого ассистента и начинает все активнее им пользоваться. А наличие собственного решения открывает много возможностей для дальнейших улучшений и интеграций. Например, ребята из QA уже используют ответы ассистента для генерации тест-кейсов по новым задачам.
Следующим шагом хочется «воспитать» в ассистенте джуна, способного решать мелкие тикеты. Назначаем в Jira тикет на нашего бота (кстати, часть тикетов по тредам мы создаём с помощью этого же бота), если понимаем, что он может справиться. После этого бот вносит правки в код и создает merge request. Дальше уже все как с обычным человеком — либо принимаем MR, либо отправляем доделывать, либо доделываем сами, если что-то пошло не так?
propell-ant
Если не секрет, какой на этом этапе получился расход токенов на всю систему?
rumyash Автор
Не секрет, в среднем на одну обработку запроса из чата модель во время общения с нашим кодом тратит 0.5М входящих токенов (из них большая часть в процессе этого общения будут кэшированы) и 1.5К исходящих.