# webrtc-komaroff: от реплики до изменения карты

Архитектурный снимок от 16 сентября 2026 года. Исследован удалённый `main` **772de4a714ae6c5abea91946e85a1953ee919ec3** в отдельном чистом worktree. Локальный checkout, в котором лежит этот документ, оставлен на своей исходной версии. Существующие презентации и сгенерированный OpenWiki не использовались как доказательства.

[Техническая презентация](</experiments/voice-ai/archive/c/presentations/technical-2026-09-16.html>) · [Реестр доказательств](</experiments/voice-ai/archive/c/DOCS/architecture-2026-09-16/evidence.md>) · [Восемь предложений по развитию](</experiments/voice-ai/archive/c/DOCS/architecture-2026-09-16/improvements/README.md>) · [Проверка материалов](</experiments/voice-ai/archive/c/DOCS/architecture-2026-09-16/verification.md>)

## Что делает система

Браузер принимает текст или голос. Core завершает WebRTC-соединение, передаёт звук соседнему ASR-сервису и отправляет готовую реплику Agent. Agent загружает состояние разговора из Postgres и вызывает выбранный workflow. Workflow обращается к LLM и инструментам, после чего Agent сохраняет результат и публикует команды интерфейсу. Пользователь видит ответ и, для авиационных сценариев, аэропорты или маршрут на карте.

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

Везде ниже **VERIFIED / SOURCE** означает подтверждение кодом, тестом или декларацией. **INFERRED** — вывод из реализации без нагрузочного эксперимента. **UNKNOWN** — слой, который в этом исследовании не проверялся. Развёрнутые образы, число реплик и live-поведение не установлены.

## Граница проходит между диалогом и распознаванием

![Компоненты и владельцы](</experiments/voice-ai/archive/c/DOCS/architecture-2026-09-16/diagrams/context.svg>)

Браузер владеет микрофоном и состоянием отображения. Core владеет живым peer connection; его нельзя восстановить из записи диалога. Agent владеет постоянным состоянием сессии. LLM gateway изолирует вызов модели, а API gateway исполняет инструменты.

`py_faster_whisper` — соседний репозиторий: он получает PCM и возвращает текст. Его же образ обслуживает файловый WebUI, но публичные маршруты этого UI объявлены в `stack/webrtc.drs`. GPU backends и принимающая сторона межстековых proxy принадлежат внешнему `rag-stack`; его код в этот аудит не включён.

Источники: [Core routes](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/772de4a714ae6c5abea91946e85a1953ee919ec3/src_core/main.py>), [AgentServer](</experiments/voice-ai/archive/w/src_agent/service.py>), [владение ingress](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/772de4a714ae6c5abea91946e85a1953ee919ec3/stack/webrtc.drs>).

## Где находится реализация

![Структура модулей](</experiments/voice-ai/archive/c/DOCS/architecture-2026-09-16/diagrams/modules.svg>)

| Модуль | Ответственность и вход | Состояние / выход |
|---|---|---|
| `src_front/src` | TypeScript UI, WebRTC, HTTP-команды, NATS WebSocket | sessionStorage, сообщения, MapLibre |
| `src_core` | aiohttp: `/core/offer`, `/core/message`, `/core/history`, `/core/init_map` | peer connections и audio tasks в памяти |
| `src_agent` | NATS `nats.agent.<user_id>`, история сессии | `sessions`, `runtime_state`, `events` в Postgres |
| `src_langgraph` | Python workflow, LangGraph | runtime передаётся в запросе и ответе |
| `src_langgraph_rb` | Ruby workflow через собственный DSL/адаптер | альтернативная реализация сценариев |
| `src_langgraph_rb_node` | Ruby workflow на AsyncGraph 0.1.2 | граф, память и результаты внешних шагов |
| `src_llm` | NATS LLM request → Ollama или локальная llama.cpp-модель | ответ нормализуется под режим запроса |
| `src_api_gateway` | FastAPI и NATS tools | каталог аэропортов, демонстрационные данные |
| `src_shared/contracts` | Pydantic DTO и subjects Python-сервисов | граница совместимости JSON |
| `src_postgres`, `docker`, `stack` | schema/migrations, build, желаемое развёртывание | образы, volume, stack contract |

Название `ruby_node` не означает отдельный процесс Node.js. Здесь «node» — узел графа; командой запуска служит Ruby, а зависимостью — `async-graph`.

Корни `src_*` соответствуют выбранному корпоративному профилю. Их перенос в `api-gateway/src` или `front/src` не сокращает legacy. Framework-каталог `src_front/src` имеет другую роль и остаётся внутри service root.

## Текстовый запрос проходит через два уровня оркестрации

![Последовательность текстового запроса](</experiments/voice-ai/archive/c/DOCS/architecture-2026-09-16/diagrams/sequence-text.svg>)

Core преобразует HTTP payload в сообщение с `trace_id`, `request_id`, `session_id` и `turn_id`. Agent создаёт или обновляет строку сессии, читает runtime и передаёт его workflow. Возвращённый `next_runtime` записывается по ожидаемой версии; история записывается отдельной транзакцией. После этого Agent публикует UI-события и отвечает Core.

Workflow не выбирается автоматически по готовности соседей. В DRS `WORKFLOW_RUNTIME` по умолчанию равен `ruby_node`, а Agent направляет запросы на `nats.workflow.run.ruby.node`. Альтернативы используют другие subjects. При отсутствии responder Agent делает ограниченные повторы, затем возвращает `workflow_unavailable`; перехода на другой движок в этой ветке нет.

Новый запрос в сценарии аэропортов обычно вызывает модель дважды: `routing_decision`, затем `tool_params`. Далее инструменты возвращают позицию и аэропорты. `build_route` вызывается только при запросе маршрута; координаты и финальная фраза уже формируются без дополнительных LLM-вызовов. Это закреплено в [тестах сценария](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/772de4a714ae6c5abea91946e85a1953ee919ec3/src_langgraph_rb_node/spec/scenarios/find_nearest_airport_spec.rb>).

## Голос добавляет поток с собственным жизненным циклом

![Голосовой путь](</experiments/voice-ai/archive/c/DOCS/architecture-2026-09-16/diagrams/sequence-voice.svg>)

После SDP offer Core создаёт `RTCPeerConnection`. Полученный audio track проходит `TrackSourceNode`: выход — mono PCM s16, 16 кГц, кадры по 20 мс. `WhisperStreamNode` публикует их на `inference.whisper.stream.<token>` и слушает `inference.whisper.text.<token>`.

Граница между репозиториями — бинарный пакет:

```text
[длина metadata: u32 big-endian][metadata JSON UTF-8][PCM int16 little-endian]
metadata: type=frame|end, seq, sample_rate, sample_width, channels, session_id
```

Whisper делает VAD — выделение речи среди тишины — и отдаёт `pending`, затем текст с `phrase_id`. Core преобразует этот идентификатор в стабильный `turn_id` и вызывает тот же обработчик диалога. Наличие поля `seq` у отправителя не означает проверки последовательности у получателя: в исследованном bridge эта проверка не найдена.

PCM при этих настройках занимает **32 000 байт/с на поток**, без metadata и транспортных накладных расходов; каждый 20-мс кадр несёт 640 байт звука. Это расчёт формата, а не измеренная пропускная способность.

## Как пройти код до побочного эффекта

![Выборочное дерево вызовов](</experiments/voice-ai/archive/c/DOCS/architecture-2026-09-16/diagrams/call-tree.svg>)

`message_handler` вызывает `handle_transcription`; тот переходит через NATS к `AgentServer.handle_request`. `AgentRunner.run` читает состояние, а `_run_with_optimistic_lock` вызывает workflow и сохраняет ответ. В выбранном Ruby runtime `Engine.run` выбирает сценарий, `GraphRunner.run` доходит до внешнего await, а `RequestExecutor.call` направляет его в `RuntimeIo.call_llm` или `call_tool`.

Это дерево описывает конкретную реализацию, а не обещание durable execution. `GraphRunner` хранит `request_results` в локальном hash на время вызова. Python `StateGraph` компилируется без checkpointer. Сохранённый между репликами runtime не равен восстановлению незавершённого внешнего шага после падения процесса.

## Постоянная история и runtime записываются раздельно

![Данные и точки фиксации](</experiments/voice-ai/archive/c/DOCS/architecture-2026-09-16/diagrams/data.svg>)

`sessions` связывает диалог с пользователем. `runtime_state` хранит версию, активный сценарий, pending и JSON-контекст. `events` хранит сообщения, а уникальность `(session_id, turn_id)` защищает строки истории от одинаковых ключей.

Agent сначала фиксирует runtime условным UPDATE, затем вызывает `persist_chat_turn`. Две записи истории внутри этой функции атомарны между собой, но не с предшествующим UPDATE runtime. Если история не запишется после успешного UPDATE, общий retry повторит workflow. Внешние обращения уже могли выполниться. **INFERRED:** при конфликте или отказе в этой точке возможны лишние LLM/tool-вызовы и расхождение runtime с историей. Частота в production неизвестна.

Редактирование реплики удаляет историю от найденного `event_id` и сбрасывает активный pending workflow перед пересчётом. Это контракт ветвления разговора, который нельзя потерять при замене движка.

Источники: [AgentRunner](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/772de4a714ae6c5abea91946e85a1953ee919ec3/src_agent/agent.py>), [runtime repository](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/772de4a714ae6c5abea91946e85a1953ee919ec3/src_agent/repositories/runtime_state.py>), [история и транзакции](</experiments/voice-ai/archive/w/src_agent/utils/db.py>), [schema](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/772de4a714ae6c5abea91946e85a1953ee919ec3/src_postgres/data/init/010_core_schema.sql>).

## Контракты, которые связывают сервисы

| Контракт | Производитель → потребитель | Что нельзя менять отдельно |
|---|---|---|
| `POST /core/offer` | браузер → Core | SDP и session identity |
| `POST /core/message` | браузер → Core | text, session_id, turn_id, edit |
| `GET /core/history` | браузер → Core → Agent | порядок сообщений и runtime_context |
| `nats.agent.<user_id>` | Core → Agent | envelope и Agent response |
| `nats.workflow.run.{python,ruby,ruby.node}` | Agent → выбранный runtime | runtime version, status, errors, client_events |
| `nats.llm.<user_id>` | workflow → LLM | mode и нормализованный data/error |
| `nats.tools.<tool_name>` | workflow → API gateway | args и tool result |
| `nats.events.<user_id>` | Agent/Core → браузер | command, artifacts, trace, порядок UI |
| `inference.whisper.stream/text.<token>` | Core ↔ Whisper | бинарный PCM и JSON результата |

Pydantic использует `extra="forbid"`, а Ruby поддерживает свои validators. Даже добавление поля в JSON может потребовать согласованного rollout. Общей автоматически проверяемой схемы для Python, Ruby и TypeScript в исследованном наборе нет.

В браузере `eventsSubject` зафиксирован как `nats.events.user123`. Это не доказательство межпользовательской изоляции. Для многопользовательского режима нужны отдельная модель identity, server-side разрешения на сессию и проверка subscriptions.

## Инструменты пока ограничивают смысл продукта

API gateway использует локальный список аэропортов, фиксированную текущую позицию, демонстрационные сведения о рейсах и погоде. Маршрут строится геометрически по дуге большого круга. Он не подтверждает дорожный маршрут, время проезда или актуальную авиационную информацию.

Поэтому архитектуру разумно оценивать как стенд голосового интерфейса и сценарной оркестрации. Вынос инструментов в отдельный сервис оправдан будущей интеграцией с реальными источниками, но сами эти источники пока не реализованы в проверенных handlers. Перед продуктовым использованием нужны явные provenance, freshness и обработка отсутствующих данных.

Источник: [API gateway](</experiments/voice-ai/archive/w/src_api_gateway/main.py>), функции `_current_position`, `_tool_get_weather`, `_lookup_flight`, `_build_route_data`.

## Сборка и развёртывание описаны отдельно от исполнения

![Слои поставки](</experiments/voice-ai/archive/c/DOCS/architecture-2026-09-16/diagrams/deployment.svg>)

Compose содержит производимые образы и локальные runtime-настройки. Python build contexts указывают на `src_*`; Ruby contexts сейчас охватывают корень репозитория. DRS объявляет все три workflow-сервиса, а значение `WORKFLOW_RUNTIME` определяет путь Agent. Объявленный невыбранный движок нельзя автоматически считать удаляемым: сначала нужен учёт его consumers.

В корневом CI отдельная job `tests` закомментирована. В этом исследовании **не подтверждён обязательный gate всех тестов перед публикацией**. Успех build/deploy job сам по себе это не восполняет. Pipeline `#4640` ранее прошёл для этого же commit; это историческое build/deploy evidence, не новый live smoke.

| Слой | Результат этого исследования |
|---|---|
| Source | VERIFIED: точный commit, source anchors, clean worktree |
| Локальные tests | 25 Ruby examples и 7 Python tests прошли 16.09.2026 |
| Desired stack | VERIFIED: декларации DRS; не исполнялись |
| Running image / replicas / health | UNKNOWN: runtime не перепроверялся |
| Голос/карта в live environment | UNKNOWN: новый end-to-end прогон не выполнялся |

## Проверки покрывают сценарии, но оставляют транспортные пробелы

В `src_langgraph_rb_node/spec` есть проверки registry, engine, memory, графов и сценариев. Они подтверждают, например, что вопрос о названии аэропорта не строит маршрут и явный радиус сохраняется при неудаче модели. `tests/test_llm_routing.py` проверяет логику нормализации маршрутизации. Входы LLM/tools в Ruby suite подменены: эти результаты не измеряют GPU, NATS или браузер.

Для принятия архитектурных изменений нужны ещё испытания двух реплик workflow, одновременных turn одной сессии, обрыва клиента, перегруженного ASR и reconnect UI. Предлагаемые метрики и условия приёмки приведены [отдельно для каждого изменения](</experiments/voice-ai/archive/c/DOCS/architecture-2026-09-16/improvements/README.md>). Сейчас p95, стоимость реплики, частота повторов и максимальное число сессий **не измерены**.

## Куда идти при изменении поведения

| Задача | Реализация | Что проверить вместе |
|---|---|---|
| Новый инструмент | API gateway + tool DTO + RuntimeIo | schema, NATS subject, ошибки, fixtures |
| Новый сценарий | registry, definition, graph, nodes | routing entries, pending, history, UI events |
| Изменить звук | TrackSourceNode + WhisperStreamNode | wire packet, VAD bridge, end, отмена |
| Заменить workflow | Agent subjects + DRS + Compose | parity всех сценариев, edit/replay, время ожидания |
| Добавить реплики | subscriptions + state ownership | queue groups, идемпотентность, порядок сессии |
| Изменить UI-события | UiEventPublisher + frontend handlers | session scope, reconnect, дубли, snapshot |
| Поменять хранение | runtime repository + db.py + migrations | атомарность turn и совместимость истории |

## Оценка подхода

Разделение медиапотока, GPU и диалога стоит сохранить: у них разные требования к задержке, состоянию и ресурсам. Самое спорное место — два последовательных оркестратора и три поддерживаемых реализации одних сценариев. Они увеличивают объём согласуемых contracts, не давая доказанного выигрыша на текущей нагрузке.

Начать стоит с исправления повторного исполнения и ограничения очередей, затем сокращать обращения к модели и выбирать один runtime по измеренному parity. Объединение Agent с выбранным workflow — обоснованный кандидат на упрощение. Полное переписывание нужно рассматривать только при подтверждённой потребности в восстановлении длительных процессов, многопользовательской изоляции или другой модели исполнения; уменьшение числа файлов само по себе его не оправдывает.
