Skip to main content
Glama
README.md
# Transport MCP Server (Tomsk)

MCP-сервер, предоставляющий LLM актуальные данные о маршрутах общественного транспорта Томска. Дипломная работа: «Разработка MCP-сервера для интеграции больших языковых моделей в транспортные информационные системы».

## Возможности

- **Tools**
  - `get_stops(route_id)` — упорядоченный список остановок маршрута. Каждая остановка содержит координаты (`lat`/`lon`), расстояние и оценку времени до следующей (`distance_to_next_m`, `estimated_travel_time_s` при средней скорости 20 км/ч), плюс OSM-теги доступности (`wheelchair`/`shelter`/`bench`). Если в БД лежат только конечные, сервер лениво подтягивает полную топологию маршрута из Overpass API (OpenStreetMap) и кэширует — см. ниже.
  - `get_routes_schedules(route_id)` — расписание маршрута в markdown (распознано через OCR из первоисточника); первый вызов скачивает источник, прогоняет через OCR-пайплайн и кэширует, последующие — отдают из БД (TTL по умолчанию 24 ч)
  - `find_nearby_stops(address, radius_m=500)` — геокодит адрес через OSM Nominatim и возвращает остановки в радиусе с дистанцией в метрах
- **Resource** `transport://routes` — список всех активных маршрутов
- **Prompt** `find_route_prompt(from_location, to_location)` — шаблон, инструктирующий LLM последовательно использовать tools/resource для построения маршрута

## Покрытие маршрутов

Поддерживаются **44 маршрута** Томска от трёх источников расписаний:

| Источник | Кол-во | Формат первоисточника | Парсер |
|---|---|---|---|
| **пассажир.online** (xn--80aasi5akda.online) | 3 + 6 | DOCX в cloud.mail.ru / изображения / PDF | `EtvDocxScheduleParser` (для 112С/Б/Д) + OCR-пайплайн (для пригородных) |
| **rasptomsk.ru** | 17 | JPG/PNG/PDF | OCR-пайплайн |
| **tomskavtotrans.ru** | 18 | WordPress-страница с набором `<img>` | HTML-индексатор + vstack + OCR-пайплайн |

Полный список — в `src/transport_mcp/db/seeds/_catalog.py`.

| route_id | Маршрут             | Особенности                                        |
|----------|---------------------|----------------------------------------------------|
| `112S/B/D` | Томск — Серебряный бор / Борики / Дзержинское | DOCX-парсер (точное извлечение таблиц python-docx) |
| `26`, `29` | Кольцевая Алтайская — Авангард, Спичфабрика — Карандашная фабрика | OCR JPG |
| `4`, `5`, `11`, `12`, `13`, `14`, `19`, `20`, `23`, `30`, `33`, `36`, `37`, `38`, `53` | Муниципальные маршруты с rasptomsk.ru | OCR (включая PDF для `19`) |
| `118`–`510` | Пригородные маршруты ТомскАвтоТранса | HTML→vstack→OCR |
| `101`, `133`, `134`, `301`, `401`, `514` | Пригородные маршруты ЕТВ | Multi-file (несколько файлов на маршрут, склейка) |

## Архитектура

```
tools/, resources/, prompts/        # FastMCP обвязка
        ↓
services/                            # бизнес-логика
        ↓
repositories/   parsers/   downloaders/         services/ocr_service
        ↓            ↓           ↓                        ↓
domain/      OCR-table /     httpx →            rapidocr-onnxruntime
             DOCX            cloud.mail.ru,     (CPU, ONNX, RU)
                             rasptomsk.ru,
                             tomskavtotrans.ru
        ↓
db/  (aiosqlite, SQLite)
```

**OCR-пайплайн (для 38 маршрутов):**

```
URL → Downloader → bytes (JPG/PNG/PDF) → OcrEngine.recognise()
                                          │
                                          ▼
                                     list[OcrBox]
                                          │
                                          ▼
                              OcrTableScheduleParser
                              (кластеризация колонок по X,
                               строк по Y → markdown-таблицы)
                                          │
                                          ▼
                                       markdown
```

Для multi-file источников (пассажир.online 101/133/134/...) и tomskavtotrans (несколько `<img>` на странице) скачанные изображения склеиваются вертикально через `utils.image_join.vstack_images` до подачи в OCR — парсеру это выглядит как одна высокая картинка.

**Lazy-обогащение остановками из OSM.** OCR-расписания публикуют только конечные остановки маршрута, поэтому для не-legacy маршрутов в БД по умолчанию лежит лишь 2 точки (по seed-у из `terminal_coordinates.json`). При первом вызове `get_stops(route_id)` для такого маршрута `StopsService` идёт в Overpass API (OpenStreetMap), достаёт `relation` маршрута по `ref` и упорядоченный список stop-нод, делает upsert в таблицы `stops`+`route_stops` и записывает метку в `overpass_sync`. На последующих вызовах данные отдаются из БД мгновенно. Логика:

```
get_stops(route_id)
        ↓
StopsService.list_for_route
        ├── route exists? → нет → ToolError
        ├── stops в БД ≥ OVERPASS_MIN_STOPS (=3)? → вернуть
        ├── route в LEGACY_SEED_ONLY (112С/Б/Д/26/29)? → вернуть seed как есть
        ├── overpass_sync свежий (TTL=168ч)? → вернуть существующий
        └── per-route asyncio.Lock + double-check
                ↓
            OverpassRouteFetcher.fetch_route_stops(ref)
                ├── OK     → upsert stops + route_stops, mark_ok
                ├── empty  → mark_skipped (нет в OSM)
                └── error  → mark_failed (back-off через TTL)
        ↓
StopsRepository.list_by_route
        ↓ (distance/eta считаются налету через haversine)
list[Stop]
```

**Расширяемость:** добавление нового маршрута — это одна запись в `db/seeds/_catalog.py` (URL источника, конечные, source_kind). Координаты конечных подтягиваются из `data/terminal_coordinates.json` (заполняется однократно через `scripts/seed_coordinates.py`). Промежуточные остановки подтянутся из OSM автоматически при первом запросе. Сервисы и tools не меняются.

## Запуск

```powershell
uv sync --extra dev                                  # установка зависимостей (включая OCR)
Copy-Item .env.example .env                          # вписать переменные окружения (опционально)
uv run transport-mcp-seed                            # инициализация БД + seed всех маршрутов
uv run transport-mcp                                 # сервер на http://127.0.0.1:8000/mcp
```

Первый вызов `get_routes_schedules` для OCR-маршрута займёт ~10-30 секунд: rapidocr-onnxruntime загружает свои модели (~50 МБ) при первом инференсе, далее — мгновенно из кэша.

## Подключение к Claude Desktop

В `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "tomsk-transport": {
      "url": "http://127.0.0.1:8000/mcp"
    }
  }
}
```

## Подключение к Claude Code

```powershell
claude mcp add --transport http tomsk-transport http://127.0.0.1:8000/mcp
```

## Проверка через MCP Inspector

```powershell
npx @modelcontextprotocol/inspector
# Подключиться к http://127.0.0.1:8000/mcp transport=Streamable HTTP
# Вызовите: get_stops("112S")  — seed (промежуточные внесены вручную),
#           get_stops("12")    — lazy-fetch из Overpass (1й вызов ~5-10с, далее мгновенно),
#           get_stops("442")   — пригородный Северск, тоже из Overpass,
#           get_routes_schedules("4"|"19"|"119"|"133"),
#           find_nearby_stops("проспект Ленина 30", 600);
# прочитайте transport://routes; вызовите prompt find_route_prompt.
```

## Тесты

```powershell
uv run pytest                                       # 116 unit/E2E тестов, ~25 сек
uv run pytest -m ocr_live                           # 4 live OCR-теста на реальных изображениях, ~30 сек
```

Live OCR-тесты по умолчанию отключены (`addopts = ["-m", "not ocr_live"]`). Они скачивают модели rapidocr и стучатся в карьерные сайты — запускать вручную для регрессии. Запросы к Overpass API в тестах не делаются — `tests/conftest.py` выставляет `OVERPASS_ENABLED=false`, реальный сетевой клиент проверяется через `pytest-httpx` с записанной фикстурой (`tests/fixtures/overpass_route_12.json`).

## Ручная сверка OCR

```powershell
uv run python scripts\manual_ocr_check.py
```

Прогоняет OCR-пайплайн по списку маршрутов из `ROUTES_TO_CHECK`, складывает распознанный markdown в `data/ocr_manual_check/route_<id>.md`. Удобно для сверки с оригинальными скриншотами.

## Структура

```
src/transport_mcp/
├── server.py                    # composition root, регистрация tools/resources/prompts
├── config.py                    # pydantic-settings (.env)
├── exceptions.py
├── logging_setup.py             # лог в stderr
├── domain/                      # Pydantic-модели (Route, Stop, Schedule, ...)
├── db/
│   ├── connection.py
│   ├── migrations.py
│   ├── schema.sql
│   ├── seed.py                  # composition root для transport-mcp-seed
│   └── seeds/
│       ├── route_112s/112b/112d/26/29.py   # ручные seed-модули (legacy)
│       └── _catalog.py                      # каталог 39 OCR-маршрутов
├── repositories/                # routes_repo, stops_repo, schedule_repo, cache_repo, overpass_sync_repo
├── services/
│   ├── routes_service.py
│   ├── stops_service.py          # БД-first, fallback на Overpass под per-route Lock
│   ├── schedule_service.py       # TTL+lock, поддержка multi-URL источников
│   ├── geocoding_service.py      # OSM Nominatim + Overpass (для find_nearby_stops)
│   ├── overpass_client.py        # OverpassRouteFetcher: relation+stop-nodes по ref
│   ├── route_osm_ref.py          # mapping route_id → OSM ref + LEGACY_SEED_ONLY
│   └── ocr_service.py            # rapidocr-onnxruntime + PDF через pypdfium2
├── parsers/
│   ├── base.py                  # ScheduleParser ABC
│   ├── etv_docx_parser.py       # DOCX-парсер для 112С/Б/Д (python-docx)
│   ├── ocr_table_parser.py      # универсальный OCR-парсер
│   ├── rasptomsk_ocr_parser.py  # тонкий wrapper для обратной совместимости тестов
│   ├── rasptomsk_specs.py       # DayBlockSpec для 26 и 29
│   ├── registry.py              # ScheduleSource, SourceRegistry
│   └── route_registry.py        # массовая регистрация из _catalog.py
├── downloaders/
│   ├── base.py                  # FileDownloader ABC
│   ├── cloud_mail_ru.py         # cloud.mail.ru public weblinks (пассажир.online)
│   ├── http_direct.py           # обычный GET (rasptomsk.ru)
│   ├── tomskavtotrans.py        # HTML-индексатор страницы + vstack
│   └── local_cache.py           # дисковый кэш SHA-256 (декоратор)
├── tools/                       # get_stops, get_routes_schedules, find_nearby_stops
├── resources/                   # transport://routes
├── prompts/                     # find_route_prompt
└── utils/
    ├── geo.py                   # haversine
    ├── transport_constants.py   # AVG_BUS_SPEED_MPS (для ETA до следующей остановки)
    └── image_join.py            # vstack_images для multi-file/HTML-источников

scripts/
├── seed_coordinates.py          # one-time массовое геокодирование через Nominatim
└── manual_ocr_check.py          # сверка OCR с оригиналами

data/
├── transport.db                 # SQLite БД
├── cache/                       # дисковый кэш скачанных файлов (SHA-256(url))
├── terminal_coordinates.json    # координаты конечных, заполняется seed_coordinates.py
└── ocr_manual_check/            # выводы ручной сверки
```

## Замечания

1. **Гибридная архитектура парсеров.** 112С/Б/Д используют точный DOCX-парсер (`python-docx` → таблицы напрямую, 100% точность). Все остальные маршруты — OCR-пайплайн поверх `rapidocr-onnxruntime` (CPU, ONNX, поддержка русского). Это компромисс между качеством (DOCX даёт идеальные таблицы) и охватом (OCR покрывает любые форматы первоисточника).

2. **Координаты остановок.** Конечные 38 OCR-маршрутов геокодируются один раз через OSM Nominatim (`scripts/seed_coordinates.py`); результат лежит в `data/terminal_coordinates.json`. Точки, которые Nominatim не нашёл, заполнены вручную как fallback. Для 112С/Б/Д координаты всех промежуточных остановок внесены вручную в seed-модулях. Для остальных маршрутов промежуточные остановки подтягиваются автоматически из OpenStreetMap через Overpass API при первом обращении к `get_stops` (см. замечание 7).

3. **Кэш.** Скачанные документы хранятся в `data/cache/` по SHA-256(url). Сгенерированный markdown — в таблице `schedule_documents` с TTL 24 ч (поле `cache_meta.last_fetched_at`).

4. **rapidocr-onnxruntime вместо PaddleOCR.** PaddlePaddle 3.x имеет известный баг с oneDNN на Windows (`OneDnnContext does not have the input Filter`), который не выключается ни флагами, ни `enable_mkldnn=False`. ONNX Runtime упаковка тех же моделей PaddleOCR работает стабильно, занимает ~50 МБ вместо ~700 МБ paddlepaddle и даёт сравнимое качество распознавания.

5. **Дополнительные распознанные колонки.** На длинных расписаниях (например, маршрут 26 на rasptomsk.ru) первоисточник физически разбит на несколько столбцов на странице. OCR-парсер ожидает 6 колонок по спецификации DayBlockSpec (3 блока × 2 направления), а распознаёт 7-8. Лишние колонки выводятся в секцию `## Дополнительные распознанные колонки` со списком времён — это страховка против молчаливой потери данных. LLM-клиент видит и основные блоки, и дополнительные, и трактует их в контексте запроса пользователя.

6. **Маршрут 19** на rasptomsk.ru опубликован в виде PDF. OCR-движок определяет PDF по магическим байтам и рендерит каждую страницу через `pypdfium2` в изображение перед распознаванием.

7. **Overpass API как источник остановок.** Yandex Schedules API не покрывает городской транспорт Томска (только междугороднее автобусное и ж/д сообщение), Yandex Maps публичный API остановок маршрута не отдаёт. Поэтому источник промежуточных остановок — **Overpass API (OpenStreetMap)**: бесплатный, без ключа. Запрос идёт по `area["name"="Томская область"]["admin_level"="4"]` (не `["name"="Томск"]["admin_level"="6"]` — в OSM городские маршруты Томска относятся к области, а не к городу). Сам Overpass-запрос отсекает рекурсию по way-геометрии и явно резолвит только stop-nodes через `node(r.routes); out;` — иначе запрос для admin_level=4 не укладывается в server-side timeout. Метки синхронизации хранятся в таблице `overpass_sync` (TTL=168 ч / 7 дней), при недоступности OSM `get_stops` тихо возвращает имеющиеся в БД остановки. Поведение настраивается через env-переменные `OVERPASS_ENABLED`, `OVERPASS_URL`, `OVERPASS_AREA_NAME`, `OVERPASS_ADMIN_LEVEL`, `OVERPASS_TIMEOUT_S`, `OVERPASS_SYNC_TTL_HOURS`, `OVERPASS_MIN_STOPS`.

8. **Пригородные маршруты, не размеченные в OSM.** Маршруты `118`, `131`, `141`, `308`, `514` в OpenStreetMap не размечены — для них `get_stops` всегда возвращает только 2 конечные остановки из seed. Это ограничение источника данных, не проекта. По состоянию на дату аудита **34 из 39 не-legacy маршрутов** реально находятся в OSM и через Overpass отдают полный список остановок (для маршрута 12, например, 42+37 точек туда-обратно).