Skip to main content
Glama
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).