tomsk-transport
by atBuba
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 точек туда-обратно).
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues