Skip to main content
Glama
Kirill-FD

llm-analytics-mcp

by Kirill-FD
README.md
# Аналитическая система на базе LLM с интеграцией MCP

MCP-сервер, который даёт языковой модели набор инструментов для анализа
табличных данных: загрузка, очистка, построение графиков, сборка отчёта.
Собственный чат-интерфейс не разрабатывается — используется веб-интерфейс
готовой платформы (Claude как основной клиент, ChatGPT как альтернативный).

Один и тот же реестр инструментов публикуется по двум протоколам сразу:

| Протокол | Эндпоинт | Клиент |
|---|---|---|
| **MCP** (Streamable HTTP) | `/mcp` | Claude — веб, десктоп, любой MCP-клиент |
| **REST + OpenAPI** | `/tools/*`, `/openapi.json` | ChatGPT Custom GPT Action |

---

## Что умеет система

**12 инструментов, 5 скиллов.** Полный список — вызовом `describe_system`
или в [ARCHITECTURE.md](ARCHITECTURE.md).

| Инструмент | Скилл | Назначение |
|---|---|---|
| `list_datasets` | — | Каталог доступных данных |
| `load_data` | DataLoadingSkill | Загрузка CSV/TSV/Excel/JSON/Parquet из каталога, пути или URL |
| `describe_data` | DataLoadingSkill | Структура, типы, пропуски, дубликаты |
| `clean_data` | DataCleaningSkill | Дубликаты, пропуски, нормализация, выбросы |
| `suggest_analysis` | InsightGenerationSkill | Автоподбор плана анализа под структуру данных |
| `plot_trend` | VisualizationSkill | Динамика метрики во времени |
| `plot_distribution` | VisualizationSkill | Гистограмма или бар-чарт (тип выбирается сам) |
| `correlation_analysis` | VisualizationSkill | Тепловая карта корреляций |
| `plot_breakdown` | VisualizationSkill | Разрез метрики по категориям |
| `collect_evidence` | InsightGenerationSkill | Проверяемые числа для текста отчёта |
| `build_report` | ReportingSkill | Отчёт в Markdown, HTML и PDF |
| `describe_system` | — | Интроспекция: состав скиллов и инструментов |

Дополнительные возможности:

1. **Автоподбор анализа** — `suggest_analysis` определяет, какая колонка
   является временной осью, какие метриками, какие разрезами, и возвращает
   готовый план вызовов с обоснованием каждого шага.
2. **Мульти-формат и мульти-источник** — CSV, TSV, Excel, JSON, Parquet;
   каталог, локальный путь или HTTP(S)-ссылка. Последнее принципиально для
   веб-сценария: файл, загруженный в браузерный чат, серверу недоступен.
3. **Сборка отчёта одной командой** — `build_report` сам достраивает
   недостающие графики и выдаёт документ в трёх форматах.

---

## Установка

Требуется Python 3.10 и новее.

```bash
git clone <адрес-репозитория>
cd llm-analytics-mcp

python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate

pip install -r requirements.txt
```

---

## Шаг 1. Тестовые данные

Данные в репозитории **сгенерированы синтетически** по схеме Superstore.
ТЗ прямо это допускает: «вы можете сгенерировать данные самостоятельно
или взять известный датасет».

```bash
python scripts/prepare_dataset.py --synthetic --rows 4000
```

Готовые файлы уже лежат в `data/` — команда нужна, только если вы хотите
пересоздать их или изменить объём.

### Почему синтетика, а не Kaggle

Генератор даёт контроль над тем, что именно демонстрирует система:

- **Заложены проверяемые закономерности** — восходящий тренд, годовая
  сезонность с пиком в конце года и связь «скидка выше 30% → отрицательная
  прибыль». Благодаря этому выводы анализа содержательны, а не случайны.
- **Дефекты внесены намеренно.** Реальный Superstore практически идеально
  чист: без пропусков и дубликатов `DataCleaningSkill` отчитался бы
  «удалено 0 строк», и продемонстрировать очистку было бы нечем.
- **Воспроизводимость.** Фиксированный `seed=42` — проверяющий получает
  ровно те же данные и те же числа в отчёте, что и в примере.
- **Репозиторий самодостаточен.** Не нужен аккаунт Kaggle, чтобы запустить
  проект.

Загрузка реального Superstore тоже поддерживается — структура колонок
совпадает:

```bash
python scripts/prepare_dataset.py --input ~/Downloads/Sample-Superstore.csv
```

### Что создаёт скрипт

| Файл | Назначение |
|---|---|
| `data/superstore_clean.csv` | Данные, приведённые к колонкам из ТЗ |
| `data/superstore_raw.csv` | Та же таблица с внесёнными дефектами |

Колонки: `Date`, `Product`, `Region`, `Sales`, `Quantity`, `Profit` (из ТЗ)
плюс разрезы `Category`, `Sub-Category`, `Segment`, `Discount`, `Ship Mode`.
Период — 2021–2024, 48 месяцев.

Состав дефектов печатается при запуске и детерминирован:

| Дефект | Объём |
|---|---|
| Пропуски в `Sales` / `Profit` / `Quantity` | ~3.5% / 4.5% / 2% |
| Полные дубликаты строк | ~0.8% |
| Разнобой в написании `Region` (`west`, ` East `, `CENTRAL`) | ~6% строк |
| Экстремальные выбросы в `Sales` | 12 строк |
| Альтернативный формат даты (`15/03/2022`) | ~10% строк |

---

## Шаг 2. Проверка без сервера

Сквозной прогон всей цепочки — от загрузки до PDF-отчёта:

```bash
PYTHONPATH=src python -m analytics_mcp.selfcheck
```

Скрипт повторяет то, что делает LLM в диалоге, но детерминированно.
Полезен как smoke-тест перед демонстрацией: если он проходит, проблема
почти наверняка в интеграции, а не в аналитике.

---

## Шаг 3. Запуск сервера

```bash
PYTHONPATH=src uvicorn analytics_mcp.app:app --host 127.0.0.1 --port 8000
```

Проверка:

```bash
curl http://127.0.0.1:8000/health
```

Полезные адреса:

| Адрес | Что это |
|---|---|
| `http://127.0.0.1:8000/health` | Статус и число зарегистрированных компонентов |
| `http://127.0.0.1:8000/docs` | Swagger UI: все инструменты можно вызвать руками |
| `http://127.0.0.1:8000/openapi.json` | Спецификация для Custom GPT Action |
| `http://127.0.0.1:8000/mcp` | MCP-эндпоинт |

> **Если порт занят.** Стартовавший ранее процесс может продолжать
> отвечать старым кодом — симптом обманчивый: `/health` отвечает, а
> изменения не применяются. Перед перезапуском: `pkill -f uvicorn`.

---

## Шаг 4. Публичный адрес через ngrok

Claude обращается к серверу снаружи, поэтому нужен HTTPS-адрес.

```bash
# 1. Установка и регистрация: https://ngrok.com/download
ngrok config add-authtoken <ваш-токен>

# 2. В личном кабинете ngrok зарезервируйте бесплатный статический домен
#    (Domains -> Create Domain). Без него адрес меняется при каждом
#    перезапуске, и настройку коннектора придётся повторять.

# 3. Запуск туннеля
ngrok http 8000 --domain=ваш-домен.ngrok-free.app
```

Затем пропишите адрес в окружении и перезапустите сервер:

```bash
cp .env.example .env
# в .env укажите:
#   PUBLIC_BASE_URL=https://ваш-домен.ngrok-free.app

export PUBLIC_BASE_URL=https://ваш-домен.ngrok-free.app
export MCP_ALLOWED_HOSTS='127.0.0.1:*,localhost:*,*.ngrok-free.app'
PYTHONPATH=src uvicorn analytics_mcp.app:app --host 127.0.0.1 --port 8000
```

> **Самая частая причина «не подключается».** MCP SDK по умолчанию
> включает защиту от DNS rebinding и принимает только заголовок `Host`
> вида `localhost`. За туннелем `Host` содержит домен ngrok, и запрос
> отклоняется **на этапе подключения коннектора**, без внятной ошибки в
> интерфейсе. Переменная `MCP_ALLOWED_HOSTS` решает ровно эту проблему.

---

## Шаг 5. Подключение к Claude (основной сценарий)

1. Откройте **Settings → Connectors → Add custom connector**.
2. Укажите адрес: `https://ваш-домен.ngrok-free.app/mcp`
   (обратите внимание на суффикс `/mcp`).
3. Сохраните и убедитесь, что коннектор перешёл в состояние подключённого.
4. В новом диалоге включите коннектор `analytics_mcp` через меню
   инструментов.
5. Скопируйте содержимое [`prompts/system_prompt.md`](prompts/system_prompt.md)
   в описание проекта (Project instructions) — это задаёт порядок вызовов.

Проверочный запрос: **«Какие датасеты доступны?»** — модель должна вызвать
`list_datasets` и показать содержимое каталога.

---

## Шаг 6. Подключение к ChatGPT (альтернативный сценарий)

1. Выгрузите спецификацию с публичным адресом:

   ```bash
   PUBLIC_BASE_URL=https://ваш-домен.ngrok-free.app \
     PYTHONPATH=src python scripts/export_openapi.py
   ```

2. Создайте Custom GPT: **Explore GPTs → Create → Configure**.
3. **Create new action → Schema** — вставьте содержимое `openapi.json`.
4. Authentication: **None**.
5. В поле Instructions вставьте `prompts/system_prompt.md`.

Подробности и особенности отображения графиков —
в [`prompts/gpt_action_setup.md`](prompts/gpt_action_setup.md).

---

## Демонстрационный сценарий

Порядок запросов подобран так, чтобы на скриншотах была видна **цепочка
вызовов**, а не один запрос. Ключевой кадр — шаг 4: видно, что планирует
модель, а не хардкод.

| # | Запрос пользователю | Ожидаемые вызовы |
|---|---|---|
| 1 | Какие датасеты доступны? | `list_datasets` |
| 2 | Загрузи superstore_raw и опиши структуру | `load_data`, `describe_data` |
| 3 | Почисти данные | `clean_data` |
| 4 | Что здесь стоит проанализировать? | `suggest_analysis` |
| 5 | Построй эти графики | `plot_trend`, `plot_breakdown`, `plot_distribution`, `correlation_analysis` |
| 6 | Сделай отчёт с выводами и рекомендациями | `collect_evidence`, `build_report` |

Пример результата — [`docs/report_example.md`](docs/report_example.md),
графики — в [`docs/plots/`](docs/plots/).

---

### Скриншоты работы

Материалы демонстрации лежат в `docs/screenshots/`:

| Файл | Что показано |
|---|---|
| [`01-list-datasets.png`](docs/screenshots/01-list-datasets.png) | Claude вызывает `list_datasets` и показывает каталог сервера |
| [`02-clean.png`](docs/screenshots/02-clean.png) | Отчёт `clean_data`: нормализация Region, 30 дубликатов, 817 выбросов |
| [`02.2-clean.png`](docs/screenshots/02.2-clean.png) | Сравнение версий датасета «с заполнением пропусков» и «без» |
| [`03-suggest-analysis.png`](docs/screenshots/03-suggest-analysis.png) | Модель проверяет гипотезы из отчёта новыми вызовами инструментов |
| [`03.2-suggest-analysis.png`](docs/screenshots/03.2-suggest-analysis.png) | Приоритизированный список направлений дальнейшего анализа |
| [`04-plots.png`](docs/screenshots/04-plots.png) | Построение графиков; модель явно отмечает, чего инструменты не умеют |

Скриншоты показывают ключевое свойство системы: **цепочкой вызовов
управляет LLM**. Модель сама решает, какие инструменты вызвать, находит
ограничения набора (например, отсутствие фильтрации строк) и сообщает
о них вместо того, чтобы подгонять вывод.

## Проверка интеграции

```bash
# Полный цикл по обоим транспортам: initialize, tools/list, tools/call,
# возврат изображения, обработка ошибочных аргументов
python scripts/integration_test.py
```

---

## Структура репозитория

```
llm-analytics-mcp/
├── README.md                    инструкция (этот файл)
├── ARCHITECTURE.md              архитектура и роль MCP/скиллов
├── openapi.json                 спецификация для Custom GPT Action
├── requirements.txt
├── .env.example
├── data/                        тестовые данные
├── docs/
│   ├── report_example.md/html/pdf   пример сгенерированного отчёта
│   ├── plots/                       примеры графиков
│   └── screenshots/                 скриншоты диалога
├── prompts/
│   ├── system_prompt.md         инструкция для LLM
│   └── gpt_action_setup.md      настройка Custom GPT Action
├── scripts/
│   ├── prepare_dataset.py       подготовка данных
│   ├── export_openapi.py        выгрузка спецификации
│   └── integration_test.py      проверка обоих транспортов
└── src/analytics_mcp/
    ├── core/                    реестр инструментов, хранилище, модели
    ├── skills/                  бизнес-логика этапов анализа
    ├── tools/                   инструменты, публикуемые наружу
    ├── transports/              адаптеры MCP и REST
    ├── rendering/               оформление графиков, артефакты
    ├── app.py                   сборка ASGI-приложения
    └── selfcheck.py             сквозная самопроверка
```

---

## Как добавить свой инструмент

Ядро при этом не меняется. Создайте файл `src/analytics_mcp/tools/my_tools.py`:

```python
from __future__ import annotations

from analytics_mcp.core.datasets import store
from analytics_mcp.core.registry import tool


@tool(tags=("stats",), skill="DataLoadingSkill", title="Топ значений")
def top_values(column: str, dataset_id: str | None = None, limit: int = 10) -> dict:
    """Возвращает самые частые значения колонки.

    Args:
        column: Имя колонки.
        dataset_id: Датасет. По умолчанию — последний использованный.
        limit: Сколько значений вернуть.
    """
    record = store.get(dataset_id)
    record.require_column(column)
    counts = record.df[column].value_counts().head(limit)
    return {str(k): int(v) for k, v in counts.items()}
```

Перезапустите сервер. Инструмент появится **сразу в обоих протоколах**:
в `tools/list` у MCP и в `/openapi.json` у REST. Пакет `tools` импортирует
свои модули автоматически, JSON-схема выводится из сигнатуры, описание —
из докстринга.

---

## Известные ограничения

Названы сознательно — это границы прототипа, а не недоделки:

- **Хранилище датасетов в памяти.** При перезапуске сервера загруженные
  данные теряются. Для прототипа приемлемо; в продакшене — Redis или диск.
- **Нет авторизации.** Демонстрационный стенд за временным туннелем.
  Для продакшена — API-ключ в заголовке и проверка на стороне FastAPI.
- **Нет фильтрации строк.** Инструменты работают с датасетом целиком:
  срез «только регион West за 2024 год» построить нельзя. Это заметно
  в демонстрации — модель честно сообщает, чего не может посчитать,
  вместо подгонки вывода.
- **Пять скиллов, а не больше.** Сознательный выбор: лучше пять
  работающих, чем десять формальных.
- **Юнит-тестов нет** — только сквозная самопроверка `selfcheck.py`
  и интеграционный тест обоих транспортов.