Введение (проблематика)
В типовом BI-проекте данные проходят некоторый путь от источника данных до аналитического отчёта:
Источник данных → ETL-процессы → DWH (витрины) → OLAP-cube → Меры
На выходе получаем множество мер — ключевых показателей бизнеса
вроде: "Выручка", "Средний чек", "Конверсия"; каждая из которых это результат
цепочки трансформаций данных через SQL-процедуры, представления
и DAX-формулы.
Когда в проекте более 200 мер, удержать все детали в голове сложно, и при вопросе пользователя: - "Откуда берётся значение в мере [Долг поставщика]?", разработчик вынужден:
Открыть .bim файл, найти DAX-формулу меры;
Определить, какие OLAP-таблицы она использует;
Найти соответствующие DWH views;
Проследить до stored procedures и понять логику расчета;
Сформулировать ответ.
В зависимости от характера вопроса и сложности самого показателя на одну меру может потребоваться 15-20 минут.
Архитектура решения
Первоначальный подход и его особенности

Первоначальное решение было реализовано в виде MCP-сервера, основная задача которого — создавать однотипные промпты для списка переданных мер, с указанием расположения составных частей проекта (.bim-файла, процедур и представлений). Ответ MCP-сервера возвращается Cursor-агенту и тот действует в соответствии с полученной инструкцией - создает новое описание, вставляет в документацию.
В этой статье не рассматриваются вопросы подключения MCP-server к Cursor, поскольку на данную тему уже есть публикации, в том числе на Хабре.
Плюсы:
- автоматизируем значительную часть "ручного труда", связанного с составлением описания мер, учитывающего особенности всего процесса трансформации данных внутри проекта.
Недостатки: выявленные при эксплуатации
- при единовременной передаче обширного списка мер может не хватить контекстного окна модели, выбранной при запуске Cursor-агента, для составления документации по всему перечню мер (агент падает);
- при обработке списка мер агент может либо зацикливаться, либо частично переписывать ранее созданные блоки для уже обработанных мер (приводит к перерасходу токенов);
- вышеперечисленные пункты требуют ручного управления процессом - разбивать список мер на небольшие части (2-3 меры), следить за ходом выполнения;
- при обработке даже 2-3 мер за один запуск Cursor-агента желателен выбор "дорогих" моделей для успешного завершения задачи.
Пример вызова MCP-server tech-spec (в ветке main находится версия MCP-сервера, вызов которого показан на скрине).

Пример полученного описания меры [Кол-во]
1. Общая информация
Название меры: Кол-во
Тип: Мера (Measure)
Источник: OLAP.bim
Папка отображения: Базовые меры
Формат отображения: #,0
2. Назначение (Цель/задача)
Мера "Кол-во" предназначена для подсчета общего количества товаров/услуг в заказах клиентов с учетом определенных фильтров. Данная мера является базовой и используется для расчета других производных показателей в аналитических отчетах.ionos
3. Исходные данные
Основная таблица: М001 Заказы Клиентов
Основное поле: [Количество]
Дополнительные поля для фильтрации:
[ВГО булево] - булево поле для исключения ВГО (внутригрупповые операции)
[Отмененный] - булево поле для исключения отмененных заказов
4. Логика обработки
4.1. Выражение DAX
CALCULATE (
SUM ( 'М001 Заказы Клиентов'[Количество] ),
NOT 'М001 Заказы Клиентов'[ВГО булево],
NOT ( 'М001 Заказы Клиентов'[Отмененный] )
)
4.2. Описание логики
Основная агрегация: Суммирование значений поля [Количество] из таблицы 'М001 Заказы Клиентов'.
Фильтрация по ВГО: Исключение записей, где [ВГО булево] = TRUE.
Фильтрация по статусу: Исключение отмененных заказов, где [Отмененный] = TRUE.
Контекстная фильтрация: Мера автоматически применяет фильтры из срезов и других измерений.
5. Результат
5.1. Выходные данные
Тип данных: Числовой (Decimal)
Формат отображения: Целое число с разделителями тысяч (#,0)
Единица измерения: Штуки/единицы товара
5.2. Примеры использования
Общее количество товаров в заказах - без дополнительных фильтров.
Количество по клиентам - с фильтром по конкретному клиенту.
Количество по периодам - с фильтром по дате заказа.
Количество по товарным группам - с фильтром по категории товара.
6. Зависимости
6.1. Таблицы
М001 Заказы Клиентов - основная таблица фактов.
Связанные справочные таблицы через внешние ключи.
6.2. Поля
[Количество] - основное поле для агрегации.
[ВГО булево] - поле для фильтрации.
[Отмененный] - поле для фильтрации.
6.3. Связанные меры
Мера может использоваться в качестве основы для:
Производных мер (проценты, коэффициенты).
Расчетных полей.
Других агрегированных показателей.
6.4. Поток данных
OLAP мера: [Кол-во] в кубе OLAP.
Представление DWH: Данные для меры поступают из представления [uni].[vt_order].
Таблицы фактов DWH: Представление [uni].[vt_order] агрегирует данные из следующих таблиц:
[dbo].[fact_order]
[dbo].[fact_cancelled_order]
[dbo].[fact_order_characteristics_received]Хранимые процедуры: Эти таблицы фактов загружаются и обрабатываются следующими хранимыми процедурами:
[dbo].[sp_load_fact_order]
[dbo].[sp_load_fact_cancelled_order]
[dbo].[sp_load_fact_order_characteristics_received]
[dbo].[sp_load_fact_order]
Назначение: Процедура формирует основной набор данных для анализа продаж.
Источники и логика:
Основные данные: Берутся из документа "Заказ клиента" из системы 1С. Для каждой товарной позиции в заказе извлекается информация о количестве, цене, сумме, предоставленных скидках и ставке НДС.
Обогащение данных: Информация о заказе дополняется сведениями из связанных справочников 1С:
Клиент и партнер: Кто является покупателем.
Менеджер: Кто ответственный за заказ.
Номенклатура: Какой товар заказан.
Организация: От лица какой нашей организации оформлен заказ.
Склад, подразделение, сезон: Дополнительные аналитические разрезы.
Финансовые показатели: Процедура также рассчитывает состояние оплаты по заказу, обращаясь к регистру "Расчеты с клиентами", и определяет наличие просроченной задолженности.
Бизнес-ценность: Позволяет получить полную и обогащенную картину по каждому заказу для построения отчетов по продажам, маржинальности и эффективности работы менеджеров.
[dbo].[sp_load_fact_cancelled_order]
Назначение: Процедура собирает данные по отмененным позициям в заказах клиентов.
Источники и логика:
Основные данные: Как и в предыдущей процедуре, источником является документ "Заказ клиента" из 1С. Ключевое отличие — отбираются только те строки товаров, у которых установлен признак "Отменено".
Дата отмены: Для понимания, когда именно произошла отмена, процедура обращается к регистру "История изменений заказа клиента".
Контекст заказа: Вся остальная информация (клиент, менеджер, суммы, скидки) подтягивается аналогично процедуре загрузки основных заказов, чтобы сохранить полный контекст на момент отмены.
Бизнес-ценность: Дает возможность анализировать объем и причины отмен, оценивать упущенную выручку и выявлять тенденции, связанные с отказами от товаров.
[dbo].[sp_load_fact_order_characteristics_received]
Назначение: Процедура отслеживает поступление товаров по заказам, у которых в процессе обработки менялись характеристики (например, цвет, размер).
Источники и логика:
Основные данные: Процедура отталкивается от данных, где зафиксированы факты поступления товаров по заказам, в которых были изменения (fact_order_characteristics_changes).
Связь с заказом: Используя номер заказа, процедура обращается к исходному документу "Заказ клиента" в 1С, чтобы получить полную информацию о клиенте, менеджере, дате и других атрибутах заказа.
Фильтр: Отбираются только те записи, которые отражают фактическое поступление товара (quantity_received > 0), а не просто резервирование.
Бизнес-ценность: Позволяет контролировать и анализировать исполнение "сложных" заказов, где характеристики товара были изменены. Это помогает понять, как такие изменения влияют на сроки и полноту выполнения заказа.
Эволюция проекта

Для устранения недостатков, выявленных во время эксплуатации, было решено использовать вызов Cursor CLI из скрипта, и управлять процессом при помощи LangGraph.
Получаем агента на базе LangGraph, который:
- INPUT NODE — инициализирует состояние графа, принимая запрос пользователя и пути к рабочим директориям (процедуры, OLAP, документация);
- PROCESS MEASURES LIST — обработка полученного сообщения от пользователя (структурирование данных). Задача - извлечь из текста структуру JSON с топиками (бизнес-разделами) и мерами;
- CURSOR CLI — итерируется по извлеченному списку мер. Для каждой меры вызываем cursor-agent с инструкцией использовать MCP Server tech-spec.
Документация Cursor CLI.
Репозиторий LangGraph c учебными материалами и инструкциями по установке.
Плюсы:
Итеративный вызов Cursor CLI, с передачей в качестве аргумента лишь одной меры, обеспечивает:
- возможность использовать более дешевые модели, так как снижаются требования к размеру окна контекста;
- логирование результатов выполнения узла;
- сохранение описания в отдельный документ (решается проблема с зацикливанием Cursor-агента, и перерасходом токенов).
Недостатки:
- так как циклический вызов Cursor CLI выполняется внутри одного узла, то логируется общий итог выполнения "ноды", без декомпозиции в графе обработки каждой, отдельно взятой, меры.
Для наглядности обратимся к скринам, описывающим пример обработки такого промпта:
# Продажи ## [Кол-во], [Рейтинг Потенциал]


На первом скрине видно, что в measure_list_node входной промпт был переложен в структуру, описанную для атрибута input_mrs класса OverallState.
class OverallState(TypedDict): """This is the general graph state""" question: str input_mrs: List[Dict[str, List[str]]] processed_mrs: List[Dict[str, List[str]]] missed_mrs: List[Dict[str, List[str]]] created_docs: List[str] directories: DirectoryList
Каждая из перечисленных мер ('Кол-во', 'Рейтинг Потенциал') была отработана циклическими вызовами Cursor CLI внутри cursor_cli_node. Однако, в Waterfall трассировки мы не видим деталей по каждому отдельному вызову Cursor CLI. Это стало поводом переработки графа в его итоговую структуру.
Итоговое решение

Главное отличие новой версии — переход от линейной обработки к циклическому графу состояний (Cyclic State Graph).
Ключевые изменения:
- введен узел process_router_node, который управляет итерациями. Он выбирает одну текущую меру для обработки и передаёт её исполнителю (Cursor CLI);
- узел Cursor CLI теперь обрабатывает ровно одну задачу за раз и возвращает управление роутеру;
- добавлен новый узел combine_doc_node - теперь, после завершения всех итераций, агент автоматически собирает все созданные фрагменты в единый документ combined_manual.md.
Финальная версия агента находится в репозитории lc-manual-creator-agent .
Рассмотрим пример работы агента, передав ему следующий промпт:
# Продажи ## [Кол-во], [Рейтинг Потенциал] # Транспорт ## [Кол-во рейсов]


Отлично! Теперь мы видим последовательные вызовы cursor_cli_node, с фиксацией в state результатов работы каждого шага.

Напомним, что перед агентом стояла задача составить описание трёх мер из двух бизнес-разделов (Продажи: "Кол-во" и "Рейтинг Потенциал"; Транспорт: "Кол-во рейсов").
Общая продолжительность работы агента составила ~ 10 мин. (625 сек.).
Вызов Cursor CLI осуществлялся с хинтом --model "auto".
Как это работает: полный цикл выполнения
Рассмотрим детально, что происходит внутри агента от момента получения запроса до создания финальной документации.
Шаг 1: Input Node — инициализация состояния
Что происходит:
Агент получает пользовательский запрос в произвольном формате (желательно подавать в markdown);
Инициализирует граф состояний (
OverallState) с путями к рабочим директориям;Проверяет наличие необходимых компонентов: .bim-файла, SQL-процедур, представлений.
Пример входных данных:
# Продажи ## [Кол-во], [Рейтинг Потенциал] # Транспорт ## [Кол-во рейсов]
Состояние графа после выполнения:
{ "question": "# Продажи\n## [Кол-во]...", "input_mrs": [], # пока пусто, заполнится на следующем шаге "directories": { "stored_procedures_dir": "/path/to/procedures", "views_dir": "/path/to/views", "olap_dir": "/path/to/olap", "store_doc_dir": "/path/to/output" } }
Шаг 2: Measure List Node — интеллектуальный парсинг
Что происходит:
Узел отправляет запрос к локальной LLM (через LM Studio) для извлечения структурированного списка мер в формате JSON.
Промпт для LLM:
Извлеки из текста структуру: каждая тема (заголовок #) и связанные с ней меры (в квадратных скобках). Верни JSON в формате: [{"topic": "Продажи", "measures": ["Кол-во", "Цена"]}, ...]
Механизм фалбэка:
Если LLM недоступна или возвращает ошибку (таймаут, неправильный формат), агент автоматически переключается на regex-парсинг:
# Упрощённая логика фалбэка try: response = llm_call(user_input) measures = parse_json(response) except Exception: # Откат на regex measures = regex_parse(user_input)
Результат парсинга:
"input_mrs": [ {"topic": "Продажи", "measures": ["Кол-во", "Рейтинг Потенциал"]}, {"topic": "Транспорт", "measures": ["Кол-во рейсов"]} ]
Этот шаг критически важен: от качества структурирования зависит корректность всех последующих операций.
Шаг 3: Process Router Node — управление итерациями
Что происходит:
Роутер определяет следующий шаг выполнения, отвечая на вопрос: "Остались ли необработанные меры?"
Логика маршрутизации:
# Псевдокод логики def router(state: OverallState): # Собираем все обработанные меры (успешные + пропущенные) processed_measures = set() for item in state["processed_mrs"] + state["missed_mrs"]: for measure in item["measures"]: processed_measures.add((item["topic"], measure)) # Ищем необработанные меры в input_mrs for topic_dict in state["input_mrs"]: for measure in topic_dict["measures"]: if (topic_dict["topic"], measure) not in processed_measures: # Нашли необработанную → передаём в CLI return "cursor_cli_node" # Все меры обработаны → объединение документов return "combine_doc_node"
Важная деталь: Роутер не обрабатывает все меры сразу, а выбирает одну текущую меру и передаёт её в исполнитель. Это обеспечивает гранулярность логирования.
Шаг 4: Cursor CLI Node — генерация документации
Что происходит:
Для выбранной меры формируется промпт и вызывается Cursor CLI с инструкцией использовать MCP-сервер tech-spec.
Формирование промпта:
prompt = f""" Используя MCP-сервер tech-spec, создай техническую спецификацию для меры: - Топик: {current_topic} - Мера: {current_measure} Пути к компонентам проекта: - OLAP: {state.directories.olap_dir} - Процедуры: {state.directories.stored_procedures_dir} - Представления: {state.directories.views_dir} Сохрани результат в файл: {store_doc_dir}/{current_topic}_{current_measure}.md """
Вызов CLI:
cursor-agent --print --force --output-format text \ --model auto "{prompt}"
Обработка результата:
Парсинг вывода CLI для извлечения пути к созданному файлу;
Обновление состояния:
Если успешно → добавить в
processed_mrs, сохранить путь вcreated_docs;Если ошибка/таймаут (10 мин) → добавить в
missed_mrs;
input_mrsостаётся неизменным — сохраняется исходный список для отчётности.
Таймаут и обработка ошибок:
try: result = subprocess.run( ["cursor-agent", ...], timeout=600, # 10 минут на меру capture_output=True ) # Извлечение пути к файлу из stdout file_path = extract_file_path(result.stdout) # Добавляем в processed, НО НЕ удаляем из input_mrs state["processed_mrs"].append({ "topic": topic, "measures": [measure] }) state["created_docs"].append(file_path) except subprocess.TimeoutExpired: state["missed_mrs"].append({ "topic": topic, "measures": [measure], "reason": "timeout" })
Шаг 5: Возврат к Router — циклическая проверка
Что происходит:
После обработки одной меры управление возвращается в Process Router Node (см. Шаг 3).
Граф состояний:
cursor_cli_node → process_router_node → [есть меры?] ↙ ↘ Да Нет ↓ ↓ cursor_cli_node combine_doc_node
Цикл продолжается до тех пор, пока все меры не будут обработаны или добавлены в missed_mrs.
Шаг 6: Combine Doc Node — объединение документации
Что происходит:
Когда все меры обработаны, агент собирает отдельные файлы в единый документ.
Логика объединения:
def combine_documents(state: OverallState): combined_content = "# Техническая документация по мерам\n\n" # Группировка по топикам for topic_dict in state["processed_mrs"]: combined_content += f"## {topic_dict['topic']}\n\n" # Чтение каждого созданного документа for doc_path in state["created_docs"]: if topic_dict["topic"] in doc_path: with open(doc_path, 'r') as f: combined_content += f.read() + "\n\n" # Сохранение объединённого файла output_path = f"{state.directories.store_doc_dir}/combined_manual.md" with open(output_path, 'w') as f: f.write(combined_content)
Результат:
Создаётся файл combined_manual.md со структурой:
# Техническая документация по мерам ## Продажи ### Мера: [Кол-во] [полное описание] ### Мера: [Рейтинг Потенциал] [полное описание] ## Транспорт ### Мера: [Кол-во рейсов] [полное описание]
Шаг 7: END — завершение и отчёт
Отчёт о выполнении:
{ "input_mrs": [ # ИСХОДНЫЙ список, неизменный {"topic": "Продажи", "measures": ["Кол-во", "Рейтинг Потенциал"]}, {"topic": "Транспорт", "measures": ["Кол-во рейсов"]} ], "processed_mrs": [ # Успешно обработанные {"topic": "Продажи", "measures": ["Кол-во", "Рейтинг Потенциал"]}, {"topic": "Транспорт", "measures": ["Кол-во рейсов"]} ], "missed_mrs": [], # Пропущенные (пусто, если всё успешно) "created_docs": [ "/path/to/output/Продажи_Кол-во.md", "/path/to/output/Продажи_Рейтинг_Потенциал.md", "/path/to/output/Транспорт_Кол-во_рейсов.md" ], "execution_time": "625 секунд (~10 минут)" }
Преимущества архитектуры
Аспект | Реализация | Комментарий |
|---|---|---|
Гранулярность | Одна мера за итерацию | Детальное логирование каждого шага |
Отказоустойчивость | Таймауты + missed_mrs | Продолжение работы при ошибках |
Масштабируемость | Циклический граф | Обработка 10, 100, 1000 мер без изменения кода |
Прозрачность | LangGraph Studio | Визуализация в реальном времени |
Гибкость | LLM + regex фалбэк | Работа даже при недоступности AI |
Спасибо за внимание!
