Введение (проблематика)

В типовом 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-сервера, вызов которого показан на скрине).

Пример вызова MCP-сервера tech-spec
Пример вызова MCP-сервера tech-spec
Пример полученного описания меры [Кол-во]

1. Общая информация

Название меры: Кол-во
Тип: Мера (Measure)
Источник: OLAP.bim
Папка отображения: Базовые меры
Формат отображения: #,0

2. Назначение (Цель/задача)

Мера "Кол-во" предназначена для подсчета общего количества товаров/услуг в заказах клиентов с учетом определенных фильтров. Данная мера является базовой и используется для расчета других производных показателей в аналитических отчетах.ionos

3. Исходные данные

Основная таблица: М001 Заказы Клиентов
Основное поле: [Количество]
Дополнительные поля для фильтрации:

  • [ВГО булево] - булево поле для исключения ВГО (внутригрупповые операции)

  • [Отмененный] - булево поле для исключения отмененных заказов

4. Логика обработки

4.1. Выражение DAX

CALCULATE (
SUM ( 'М001 Заказы Клиентов'[Количество] ),
NOT 'М001 Заказы Клиентов'[ВГО булево],
NOT ( 'М001 Заказы Клиентов'[Отмененный] )
)

4.2. Описание логики

  1. Основная агрегация: Суммирование значений поля [Количество] из таблицы 'М001 Заказы Клиентов'.

  2. Фильтрация по ВГО: Исключение записей, где [ВГО булево] = TRUE.

  3. Фильтрация по статусу: Исключение отмененных заказов, где [Отмененный] = TRUE.

  4. Контекстная фильтрация: Мера автоматически применяет фильтры из срезов и других измерений.

5. Результат

5.1. Выходные данные

  • Тип данных: Числовой (Decimal)

  • Формат отображения: Целое число с разделителями тысяч (#,0)

  • Единица измерения: Штуки/единицы товара

5.2. Примеры использования

  1. Общее количество товаров в заказах - без дополнительных фильтров.

  2. Количество по клиентам - с фильтром по конкретному клиенту.

  3. Количество по периодам - с фильтром по дате заказа.

  4. Количество по товарным группам - с фильтром по категории товара.

6. Зависимости

6.1. Таблицы

  • М001 Заказы Клиентов - основная таблица фактов.

  • Связанные справочные таблицы через внешние ключи.

6.2. Поля

  • [Количество] - основное поле для агрегации.

  • [ВГО булево] - поле для фильтрации.

  • [Отмененный] - поле для фильтрации.

6.3. Связанные меры

Мера может использоваться в качестве основы для:

  • Производных мер (проценты, коэффициенты).

  • Расчетных полей.

  • Других агрегированных показателей.

6.4. Поток данных

  1. OLAP мера: [Кол-во] в кубе OLAP.

  2. Представление DWH: Данные для меры поступают из представления [uni].[vt_order].

  3. Таблицы фактов DWH: Представление [uni].[vt_order] агрегирует данные из следующих таблиц:
    [dbo].[fact_order]
    [dbo].[fact_cancelled_order]
    [dbo].[fact_order_characteristics_received]

  4. Хранимые процедуры: Эти таблицы фактов загружаются и обрабатываются следующими хранимыми процедурами:
    [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

Спасибо за внимание!

Комментарии (0)