Skip to main content
Glama
README.md
# Туда, куда не ищется

Собираем поездку через пересадку, когда прямого билета нет.

Поиск любого сервиса отвечает на вопрос «есть ли билет из А в Б». Если прямого
сообщения нет, выдача пустая — и человек уходит. Хотя доехать можно, и оба
нужных билета продаются там же.

**Живой пример.** Запрос «Москва → Углич» на 20 августа 2026 в MCP Туту:

```
search_multitransport → 0 вариантов
search_bus            → 0 вариантов
search_rail           → 0 вариантов
```

Наш ответ на тот же запрос:

```
Москва → Ярославль   автобус 09:00 → 13:00    863 ₽
пересадка в Ярославле — 40 минут
Ярославль → Углич    автобус 13:40 → 16:25    616 ₽
                                       итого  1 479 ₽, на месте в 16:25
```

Оба билета продаёт Туту. Их поиск этого маршрута не покажет никогда.

## Как это работает

```
запрос «Москва → Плёс»
   ↓
MCP Туту: прямой поиск → 0 вариантов, регион «Ивановская область»
   ↓
узлы-кандидаты: справочник «регион → центр» + предложения агента
   ↓
для каждого узла: сначала плечо «узел → цель», затем «откуда → узел»
   ↓
код сводит стыковки: отбрасывает рейсы, на которые не успеть, считает запас и сумму
   ↓
карточки маршрутов
```

Поиском управляет агент: он сам решает, какие города и даты проверять, и
человек видит ход его работы лентой на экране. Но **стыковки, цены и
ранжирование считает код** — модель к арифметике не допущена, именно там
агенты ошибаются. Тяжёлые ответы Туту через модель не проходят: она получает
идентификаторы найденных плеч и короткие сводки, а полные данные уходят прямо
в интерфейс.

Если уехать в выбранный день нельзя, бэкенд досылает агента за гостиницами:
ищется ближайший день отъезда и подбирается ночлег ровно на столько ночей,
сколько ждать.

Если агент недоступен или упёрся в лимит, интерфейс молча переключается на
детерминированный поиск — экран показывает результат в любом случае.

## Запуск

```bash
npm install
cp .env.example .env          # вписать OPENROUTER_API_KEY (необязательно)
npm run db:up                 # Postgres в Docker
npm run migrate               # схема базы
npm run warm                  # прогрев кэша реальными ответами Туту
npm run dev                   # http://localhost:3100
```

`OPENROUTER_API_KEY` опционален: без него подсказка городов отключается,
поиск работает на справочнике регионов.

## Команды

| Команда | Что делает |
| --- | --- |
| `npm run dev` | приложение на http://localhost:3100 |
| `npm run route -- Москва Углич 2026-08-20` | сборка маршрута в консоли |
| `npm run warm` | прогрев кэша из `src/modules/tutu/fixtures/` |
| `npm run cache` | что сейчас в кэше |
| `npm run smoke` | проверка живой связки с MCP Туту |
| `npm run db:up` / `db:down` | Postgres в Docker |
| `npm run migrate` | миграции Prisma |
| `npm run lint` | ESLint |

## Структура

Весь код в `src/`, в корне только конфигурация. Интерфейс собран одной папкой,
логика разложена по модулям — по смыслу, а не по типу файла:

```
src/
  app/                роутинг Next: страница и три API — тонкие, только вызовы модулей
    api/agent/          агентный поиск потоком
    api/route/          детерминированный поиск (страховка)
    api/stay/           гостиницы

  frontend/           всё, что видно в браузере
    components/route/   форма, календарь, подсказки, лента, карточки, гостиницы
    hooks/              чтение потока агента, определение города
    design.ts           палитра и подписи из макета
    format.ts, geo.ts   форматирование и расстояния
    config.ts           константы интерфейса

  modules/            логика, зависимости однонаправленные: agent → routing → tutu
    tutu/               клиент MCP, кэш, типы, прокси, снятые ответы (fixtures)
    routing/            сборка маршрутов, справочники регионов и городов
    agent/              цикл, инструменты, промпт, ошибки, ретраи, состояние

  lib/                клиент Prisma
```

**Почему внутри модулей нет слоёв** `domain/`, `infra/`, `api/`. Такая нарезка
оправдана на десятках тысяч строк и полутора десятках доменов. Здесь один
сценарий: слои дали бы папки по одному файлу и пустой `contract/`. Когда модуль
перерастёт тысячу строк, они появятся вокруг существующих файлов.

## Работа с MCP Туту

Подключение: `https://mcp.tutu.ru/mcp`, streamable HTTP, авторизация не нужна.

Два инженерных решения, без которых это не работает:

**Ответы весят ~26 КБ.** Инструменты обёрнуты так, что полный JSON уходит в
интерфейс и рисуется карточками, а в языковую модель — сжатая выжимка через
`toModelOutput`. Замер на реальном поиске: 26 305 → 1 971 символ, экономия 93 %.

**У сервера жёсткий rate limit.** Около полусотни запросов подряд закрыли нам
доступ ко всему домену на уровне TCP на несколько часов. Поэтому: `view: 'compact'`,
кэш ответов в Postgres, не больше двух узлов на поиск, а перед демо — прогрев
кэша из `fixtures/` командой `npm run warm`. Если сервер недоступен, экран
показывает то, что есть в кэше, и не падает.

## Стек

Next.js 16 (App Router) · TypeScript · `@ai-sdk/mcp` · Prisma 7 + Postgres 18 ·
Tailwind 4 + shadcn/ui · OpenRouter (опционально)

## Документы

- `SPECIFICATION.md` — продукт целиком: проблема, доказательства, техника, регламент
- `SCREEN-SPEC.md` — спецификация экрана для вёрстки
- `USER-GUIDE.md` — руководство пользователя
- `fixtures/README.md` — что за сохранённые ответы и зачем

Maintenance

ActivityMaintained
ResponsivenessNo issues