zaezd
README.md
# Заезд
Движок поездок, который начинается с повода. Человек не знает, куда ехать, он знает, зачем:
хочет на конференцию по своей теме. Заезд находит ближайшее офлайн-событие, считает дорогу
туда и обратно, отель рядом с площадкой и полную цену участия, и показывает до трёх
объяснимых пакетов на одном экране.
Первая вертикаль - ИТ-конференции. Каталог событий даёт confcal MCP, транспорт и жильё - Tutu
MCP, а собирает всё детерминированный композитор, а не модель.
## Что где
| Что | Где |
|---|---|
| Экран поездки | `/` на том адресе, где вы запустили Заезд |
| Поездка по ссылке | `/t/<trip_id>`, ссылку отдаёт экран и любой из инструментов |
| MCP-эндпоинт | `/mcp`, streamable HTTP, без ключа |
| Проверка живости | `/healthz`, отвечает режимом и опорной датой |
| Руководство пользователя | [docs/user-guide.md](docs/user-guide.md) |
| Двухминутная запись | [docs/demo.mp4](docs/demo.mp4), сервер, экран и агент |
## Сквозной сценарий
Открываете корень. На экране уже собранная поездка, а не пустая форма.
Сверху событие, дата, город, площадка и время пешком до неё от ближайшего отеля. Ниже до трёх
карточек: дорога туда с номером поезда или рейса, отель с ценой за всё проживание и
расстоянием до площадки, дорога обратно, итог одной цифрой и строка арифметики под ним,
которая сходится. Дальше карта города с
площадкой и отелями, погода на даты поездки, другие события по теме и список того, что не
вошло и почему.
Нажимаете "Собрать ссылки на оплату" - появляется чек-лист из двух-трёх ссылок Туту. Подпись
каждой берётся из того, что Туту реально вернул: "Открыть корзину" там, где откроется
корзина, и "Открыть страницу выбора" там, где корзины не будет.
То же самое агенту: `find_event_trips` с темой и городом отправления возвращает ту же поездку
структурой, `get_trip_details` раскрывает пакет, `create_trip_checkout` собирает чек-лист.
Хост, который умеет рисовать, получает тот же экран как MCP App.
## Запуск из исходников
```bash
npm install
ZAEZD_MODE=replay npm run dev
```
Откроется http://localhost:8080. В режиме `replay` продукт работает на записанных ответах из
`fixtures/` и не ходит в сеть: с ним можно жить без интернета и без ключей, а опорной датой
служит день записи. Живой режим - `ZAEZD_MODE=live`.
Проверка целиком:
```bash
npm run verify
```
Это синхронность правил, сверка документированных команд, типизация, линтер, сборка
браузерного дерева, исполняемые сценарии на Gherkin и юнит-тесты. Всё офлайн.
Браузерное дерево собирается до сценариев не для красоты. Один сценарий запрашивает у сервера
тот самый `boot.js`, который уезжает в хост, и на чистом клоне этого файла ещё нет.
## Запуск в контейнере
Образ собирается из репозитория и внутри содержит и код, и фикстуры, поэтому в режиме
`replay` контейнеру не нужен ни интернет, ни секреты.
```bash
cp .env.example .env # и отредактируйте
docker compose up -d --build
```
Экран поднимется на http://127.0.0.1:8080. Без файла `.env` compose не стартует: это
намеренно, чтобы никто не запустил продукт на чужих значениях по умолчанию.
Без compose то же самое одной командой:
```bash
docker build -t zaezd .
docker run -d --name zaezd -p 127.0.0.1:8080:8080 --env-file .env zaezd
```
Разово, без файла окружения, чтобы просто посмотреть:
```bash
docker run --rm -p 127.0.0.1:8080:8080 \
-e ZAEZD_MODE=replay -e ZAEZD_PUBLIC_URL=http://localhost:8080 zaezd
```
### Переменные окружения
Полный список с комментариями лежит в [.env.example](.env.example). Коротко:
| Переменная | Зачем | По умолчанию |
|---|---|---|
| `ZAEZD_MODE` | `live` ходит в источники, `replay` читает `fixtures/` | `live` |
| `PORT` | порт внутри контейнера | `8080` |
| `ZAEZD_PUBLIC_URL` | адрес, по которому продукт виден снаружи. Из него строятся ссылки на поездку и CSP виджета | `http://localhost:8080` |
| `ZAEZD_CONTACT_EMAIL` | контакт в `User-Agent`; Nominatim банит анонимные запросы | `zaezd@example.com` |
| `ZAEZD_CONFCAL_URL` | MCP каталога событий | публичный confcal |
| `ZAEZD_TUTU_URL` | MCP Туту | `https://mcp.tutu.ru/mcp` |
Ключей и токенов нет ни одного: оба источника открытые. Значение `ZAEZD_PUBLIC_URL` важно
задать честно - именно этот адрес уезжает агенту как ссылка на экран и попадает в CSP
виджета, и если он не совпадает с реальным, доска внутри хоста останется пустой.
Контейнер несёт `HEALTHCHECK` на `/healthz`, так что `docker ps` показывает `healthy` только
когда приложение действительно отвечает.
### За обратным прокси
Сам `docker-compose.yaml` ничего не знает про прокси, домены и сети - это свойства площадки,
а не продукта. Всё это кладётся в `docker-compose.override.yaml`, он в гитигноре и живёт
только на той машине, которая разворачивает сервис. Пример для Traefik:
```yaml
services:
zaezd:
ports: !reset [] # наружу светит прокси, а не контейнер
environment:
ZAEZD_PUBLIC_URL: https://zaezd.example.com
labels:
traefik.enable: 'true'
traefik.http.routers.zaezd.rule: Host(`zaezd.example.com`)
traefik.http.routers.zaezd.entrypoints: https
traefik.http.routers.zaezd.tls: 'true'
traefik.http.routers.zaezd.tls.certresolver: letsencrypt
traefik.http.services.zaezd.loadbalancer.server.port: '8080'
networks: [proxy]
networks:
proxy:
external: true
```
Имена точки входа, резолвера сертификатов и сети у всех разные, поэтому в примере они
подставные. Для nginx или Caddy override будет другим, продукт от этого не меняется: он
слушает `PORT` и отдаёт `/healthz`.
## Подключить к агенту
Эндпоинт `<адрес>/mcp` работает по streamable HTTP и не требует ключа. Локально это
`http://localhost:8080/mcp`, на развёрнутом сервисе - ваш адрес из `ZAEZD_PUBLIC_URL`.
Claude Desktop не принимает удалённые серверы прямо в `claude_desktop_config.json`, там живут
только stdio-команды, поэтому либо добавьте Заезд через Settings, Connectors, Add custom
connector, либо пропишите проксирующий запуск:
```json
{
"mcpServers": {
"zaezd": { "command": "npx", "args": ["-y", "mcp-remote", "http://localhost:8080/mcp"] }
}
}
```
Claude Code и Codex CLI:
```bash
claude mcp add --transport http zaezd http://localhost:8080/mcp
codex mcp add zaezd --url http://localhost:8080/mcp
```
Qwen Code:
```bash
qwen mcp add --transport http zaezd http://localhost:8080/mcp
```
Хост, который умеет рисовать виджеты, получит тот же экран через ресурс
`ui://zaezd/trip-board`. Хост без виджетов получит тот же ответ текстом.
## Архитектура
Зависимости идут только вниз.
| Слой | Где | Что делает |
|---|---|---|
| L0 | `src/composer/{types,dates,selection,feasibility,pricing,hotels,packages,checkout-labels}.ts` | чистые правила: даты, выполнимость, цена, отбор пакетов. Без ввода-вывода и без часов |
| L1 | `src/sources/` | клиенты confcal и Tutu, нормализация, кэш, режим replay |
| L2 | `src/enrich/` | геокодинг, производственный календарь, погода. Каждый с таймаутом и запасным ответом |
| L3 | `src/composer/{build-trip,build-checkout,trip-id}.ts` | сборка поездки, бюджеты, живые ссылки на оплату, запрос в ссылке |
| L4 | `src/web/`, `src/mcp/` | экран и три инструмента. Бизнес-логики здесь нет |
Схема и две последовательности - в [docs/architecture.md](docs/architecture.md), решения с
обоснованиями - в [docs/decisions.md](docs/decisions.md).
Ключевое: даты, цены и выполнимость считает код, а не модель. Три одинаковых живых прогона
через модель дали три разных числа ночей и разброс цены в полтора раза. Поэтому алгоритм
живёт в `src/composer/dates.ts` и покрыт таблицей сценариев.
## Ограничения
Список честный, читайте его как часть продукта.
- Онлайн-событие поездку не строит, и событие в вашем же городе тоже. В обоих случаях
Заезд говорит об этом прямо, а не показывает пустой экран;
- живой каталог confcal знает 21 город, а живые офлайн-события есть примерно в 15 из них.
Пустые города не прячутся, счётчик показан на экране;
- за один запрос считается одна поездка. Ещё до пяти событий по теме показываются списком,
но не считаются: веер из пяти сборок - это спиннер, а не продукт;
- прогноз погоды доступен только на 16 дней вперёд. Дальше блок погоды просто исчезает;
- адрес площадки берётся из каталога и никогда не додумывается. Если каталог его не дал, экран
говорит об этом и не ставит метку на карте, а агента ответ просит поискать адрес в открытых
источниках и сказать человеку, что адрес не из каталога;
- мультитранспорт Туту считает взрослых, детских и льготных тарифов в поездке нет;
- ссылка на авиабилет в холодном браузере открывает поиск, а не корзину: корзина заводится
только в браузере с живой сессией Туту. Поэтому подпись такой кнопки об этом и говорит;
- схемы мест в вагоне нет;
- цена участия в событии складывается в итог только если каталог написал её числом. Текстовую
цену вроде "бесплатно для студентов" Заезд показывает как есть и в сумму не берёт;
- в режиме `replay` ссылки на оплату собраны из записи и, скорее всего, протухли. Экран и
ответ агенту об этом предупреждают;
- у каталога за раз запрашивается восемь событий: на большем числе он перестаёт отдавать поток
посреди ответа. Замер и обоснование в [журнале решений](docs/decisions.md).
## Измерено
| Метрика | Значение |
|---|---|
| Инструментов у Tutu MCP | 16 |
| Манифест Tutu MCP | 102 143 символа, около 25,5 тыс. токенов |
| Инструментов у шлюза Заезда | 3 |
| Манифест шлюза Заезда | 10 181 символ, около 2,5 тыс. токенов |
| Живая сборка поездки, холодная | 12,1 с |
| Живая сборка поездки, повторная | 0,2 с |
| Сборка поездки из записи | 36 мс |
| Внешних источников | 6 |
| Исполняемых сценариев | 217 |
| Юнит-тестов | 336 |
| Проверено в агентах | Claude Code, Qwen Code |
## Карта репозитория
```
specs/ спецификация продукта, она же источник правды
features/ исполняемые сценарии на Gherkin и шаги к ним
tests/ юнит-тесты чистого слоя
src/ код продукта по слоям
fixtures/ записанные ответы источников
scripts/ record.ts и вспомогательные утилиты
docs/ руководство пользователя, архитектура, журнал решений
```
## Лицензия
MIT, см. [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues