# Как сейчас проходят аудио и диалог chat-agent

Сверка двух пользовательских схем, 06.10.2026. Код chat-agent проверен на
`e4bfe285306fc225c3d445ade16296021bd934f3`, voice - на работающей ревизии
`5d1d9591d57f0b5535884425567c0440b62a91d1`. В Insight в 18:49 UTC подтверждены
режим ASR `streaming`, один экземпляр Core, каждого ingress и Ruby runtime,
их subjects, общий broker и адрес Ollama proxy. Это проверка контрактов и
конфигурации; новые аудиопрогоны и замеры производительности здесь не выполнялись.

Схемы встроены в [обзорную презентацию](</experiments/voice-ai/archive/p/presentations/project-overview.html>)
и [техническую презентацию](</experiments/voice-ai/archive/p/presentations/project-technical.html>).
Числа C3/C4 в них сохраняют прежнюю базу сравнения.

## Что пришлось уточнить

| На присланной схеме | Как устроено сейчас |
|---|---|
| WebRTC/Opus как единственный фиксированный формат | WebRTC передаёт защищённый RTP; codec согласуется в SDP. Opus поддерживается, но код не закрепляет его как единственный codec. |
| WSS сразу даёт тот же PCM 16 kHz | Браузер отправляет mono PCM16LE с фактической частотой AudioContext. Resampler ingress переводит его в 16 kHz. |
| Общий PCM-блок можно принять за отдельный сервис | Это общий код `src_shared/audio.py`, работающий отдельно внутри каждого ingress, со своим FIFO и очередью. |
| Core управляет всем подключением браузера | SDP/ICE обслуживает WebRTC ingress; start/end PCM - WSS ingress. Core выдаёт lease через NATS и принимает ASR final. Настройки микрофона применяет frontend. |
| Voice и GPU объединены одной стрелкой | Streaming bridge делает VAD и отправляет PCM по NATS в inference account; GPU worker возвращает JSON через другой subject. Файловый HTTP API в этот путь не входит. |
| Runtime → LLM: "ответ или tool_calls" | К LLM идёт `LlmRequest` с mode/context/tools. Текст и tool calls возвращаются в `LlmResponse`. Активный runtime - Ruby + AsyncGraph. |
| Все части ответа возвращаются через runtime/Core | У LLM есть отдельный pub-sub канал `chat.agent.stream` к Agent. Итоговый RPC-ответ и UI-события - разные пути. |
| Всё состояние Agent находится в PostgreSQL | История и workflow state сохраняются в БД. Очередь, текущая попытка и накопленный snapshot находятся в памяти Agent. |

## 1. Аудио: сначала транспорт, затем единый PCM-контракт

```mermaid
flowchart LR
  B["Браузер: chat-agent.gis-master.ru"]
  R["webrtc_ingress"]
  W["audio_ws_ingress"]
  C["Core: admission и единственный ASR listener"]
  N["Общий NATS: chat-agent_audio_nats:4222"]
  V["voice: stt_whisper_to_nats / streaming bridge + VAD"]
  G["Внешний inference: GPU ASR worker"]
  A["Agent"]
  B -->|"HTTPS /core/offer: JSON с SDP"| R
  B <-->|"WSS /core/ice/{connection_id}: JSON ICE"| R
  B -->|"DTLS-SRTP: RTP audio, codec из SDP"| R
  B -->|"WSS /audio/ws: start JSON, затем binary PCM16LE"| W
  R -->|"Resampler + FIFO внутри ingress: mono 16 kHz"| N
  W -->|"Resampler + FIFO внутри ingress: mono 16 kHz"| N
  R <-.->|"chat.agent.audio.control: JSON request/reply"| C
  W <-.->|"chat.agent.audio.control: JSON request/reply"| C
  N -->|"webrtc.komaroff.asr.stream.{session_id}: header + PCM"| V
  V -->|"to.inference.webrtc.asr.audio.{stream_id}: header + PCM"| G
  G -->|"from.inference.webrtc.asr.events.{stream_id}: JSON"| V
  V -->|"webrtc.komaroff.asr.text.{session_id}: JSON result"| N
  N -->|"partial / final / rejected / error"| C
  C -->|"Только непустой final: nats.agent.user123 RPC"| A
  C -->|"nats.events.user123: ASR и audio_status"| N
  A -->|"nats.events.user123: ответ и состояние запроса"| N
  N -->|"NATS protocol over WSS /ws"| B
```

Все нарисованные межсервисные NATS-стрелки проходят через broker, даже когда
ради читаемости он не повторён между каждой парой. Переход к GPU включает
account import/remap и leafnode. Нельзя заменять его HTTP-загрузкой файла
или рисовать direct RPC между ingress и GPU.

### Публичные адреса и кто принимает запрос

База чата: `https://chat-agent.gis-master.ru`. HTTPS и WSS защищены штатным
OAuth ingress; у ICE есть также token конкретного подключения, у audio WSS -
проверка Origin. NATS WebSocket использует отдельную аутентификацию NATS.

| Адрес / метод | Получатель | Что передаётся |
|---|---|---|
| `GET /core/rtc-capabilities` | WebRTC ingress | JSON `{"trickle_ice":1}`. При 404 frontend может использовать legacy offer. |
| `POST /core/offer` | WebRTC ingress | `application/json`: `type:"offer"`, `sdp`, `session_id`, необязательный `trickle:true`. Ответ: `type:"answer"`, `sdp`, `session_id`, `stream_id`; при Trickle также `connection_id`, `ice_endpoint`, `ice_token`. |
| `wss://chat-agent.gis-master.ru/core/ice/{connection_id}` | WebRTC ingress | TEXT JSON: `auth`, `ready`, `candidate`, `end`, `error`; candidate содержит `RTCIceCandidateInit`. Token идёт первым auth-сообщением, не в URL. |
| `DELETE /core/ice/{connection_id}` | WebRTC ingress | Остановка конкретного peer с проверкой header `X-ICE-Token`; успешный ответ HTTP 204. |
| `wss://chat-agent.gis-master.ru/audio/ws` | WSS ingress | TEXT JSON `start` за 3 с; затем binary PCM16LE mono. В конце TEXT JSON `end`. В обратную сторону: `ready`, `ending`, `stopped`, `error`. |
| `wss://chat-agent.gis-master.ru/ws` | Общий NATS, порт 9222 за ingress | Протокол NATS через WSS; payload событий - UTF-8 JSON. Это другой socket, не канал PCM. |
| `POST /core/message` | Core | JSON: `text`, `session_id`, необязательные `request_id`, `turn_id`, `edit`. Ответ - итоговый `AgentInboundResponse` JSON. |
| `GET /core/history?session_id={uuid}` | Core → Agent → PostgreSQL | JSON истории; Core не читает БД напрямую. |
| `GET` / `DELETE /core/requests/{request_id}?session_id={uuid}` | Core → Agent | Snapshot / отмена ответа. DELETE не выключает микрофон. |

Upstreams ingress в общей сети (внутренние адреса, не публичные URL):

| Маршруты | Upstream |
|---|---|
| `/core/offer`, `/core/rtc-capabilities`, `/core/ice/*`, priority 200 | `http://chat-agent_webrtc_ingress:8000` |
| `/audio/ws`, priority 200 | `http://chat-agent_audio_ws_ingress:8000` |
| Остальные `/core/*` | `http://chat-agent_src_core:8000` |
| `/api/*` | `http://chat-agent_src_api_gateway:8100` |
| `/`, `/docs/`, `/presentations/` | `http://chat-agent_src_front:8000` |
| Chat `/ws` и точный voice `/ws` | `chat-agent_audio_nats:9222`, WebSocket без внутреннего TLS; внешний ingress даёт WSS и OAuth |

Сами WebRTC audio packets идут к IP:port выбранной ICE-пары, а не в
`POST /core/offer`. На 06.10 в работающем ingress настроены
`stun:stun.gis-master.ru:3478` и
`turn:stun.gis-master.ru:3478?transport=tcp`.
TURN credentials остаются на сервере. Браузерный default STUN - тот же
`stun:stun.gis-master.ru:3478`. Codec определяется согласованным SDP;
код не вызывает `setCodecPreferences`, поэтому без SDP конкретного peer
нельзя обещать "всегда Opus" или постоянный публичный media port.

WSS start:

```json
{"type":"start","session_id":"<uuid>","format":"pcm16le","channels":1,"sample_rate":48000}
```

`48000` - пример фактической частоты браузера, не фиксированное требование.
Допустимы 8000-96000 Hz. Один binary chunk содержит чётное число байтов,
не более 8192. ASR и ответ чата возвращаются через NATS `/ws`, а не через
`/audio/ws`.

### Что именно ingress публикует в NATS

Subject: `webrtc.komaroff.asr.stream.{session_id}`. Формат binary payload:

```text
[4 байта: длина JSON, uint32 big-endian]
[JSON metadata, UTF-8]
[PCM16 little-endian, mono, 16000 Hz]
```

Один обычный frame - 20 ms: 320 samples / 640 PCM bytes, не считая header.
Metadata: `type:"frame"`, `seq`, `sample_rate:16000`, `channels:1`,
`sample_width:2`, `session_id`, `stream_id` и безопасный trace carrier.
Для `type:"end"` / `"abort"` PCM отсутствует. FIFO сохраняет остатки,
padding только у последнего хвоста и меньше 20 ms. Общий модуль не означает
общую очередь между двумя ingress.

### Voice и граница inference

На стороне chat/voice broker:

- К GPU: `to.inference.webrtc.asr.audio.{stream_id}`, тот же binary envelope
  с PCM16LE 16 kHz; bridge добавляет `phrase_id`, свою `seq` и `speech_samples`.
- От GPU: `from.inference.webrtc.asr.events.{stream_id}`, UTF-8 JSON.
- На inference account эти subjects называются соответственно
  `inference.webrtc.asr.audio.{stream_id}` и
  `inference.webrtc.asr.events.{stream_id}`. Prefix меняет broker import/remap.
- Желаемый leafnode в DRS: `nats://192.168.28.134:37422`.
  В этой сверке не снималась свежая конфигурация удалённого H100;
  адрес leafnode подтверждён source, не отдельной сетевой пробой.

Bridge возвращает в `webrtc.komaroff.asr.text.{session_id}`:

```json
{"version":1,"result_type":"final","session_id":"<uuid>","stream_id":"<uuid>","phrase_id":"<id>","revision":2,"text":"<распознанный текст>"}
```

`result_type`: `partial`, `final`, `rejected`, `error`; возможен `code`,
`traceparent`, `tracestate`. Core проверяет session/stream/phrase/revision,
отбрасывает дубликаты и результаты закрытого потока. Только непустой final
запускает диалог. Live-диаризации на этом пути нет. Файловые transcription /
diarization UI на `https://voice.gis-master.ru/whisper-trasncription/` и других
сохранённых путях работают отдельно.

Источники аудио: [WebRTC ingress](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/e4bfe285306fc225c3d445ade16296021bd934f3/src_webrtc_ingress/main.py>),
[ICE signaling](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/e4bfe285306fc225c3d445ade16296021bd934f3/src_webrtc_ingress/ice/signaling.py>),
[WSS ingress](</experiments/voice-ai/archive/w/src_audio_ws_ingress/main.py>),
[PCM module](</experiments/voice-ai/archive/w/src_shared/audio.py>),
[Core ASR handler](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/e4bfe285306fc225c3d445ade16296021bd934f3/src_core/handlers/handle_transcription.py>),
[voice bridge](<https://git.giscloud.ru/voice-chat/py_faster_whisper/-/blob/5d1d9591d57f0b5535884425567c0440b62a91d1/src/streaming_asr/bridge.py>),
[GPU contract](<https://git.giscloud.ru/voice-chat/py_faster_whisper/-/blob/5d1d9591d57f0b5535884425567c0440b62a91d1/src/streaming_asr/protocol.py>),
[worker source](<https://git.giscloud.ru/voice-chat/py_faster_whisper/-/blob/5d1d9591d57f0b5535884425567c0440b62a91d1/src/streaming_asr/worker.py>).

## 2. Диалог: RPC-итог и части ответа идут разными путями

```mermaid
flowchart LR
  B["Браузер"]
  C["Core"]
  A["Agent: очередь и текущая попытка в памяти"]
  P[("PostgreSQL: история, entities, artifacts, workflow state")]
  R["Ruby + AsyncGraph: src_langgraph_rb_node"]
  L["LLM adapter"]
  T["API gateway: пять fixture tools"]
  O["Ollama-compatible proxy"]
  N["Общий NATS"]
  B -->|"POST /core/message: JSON; либо ASR final в Core"| C
  C -->|"nats.agent.user123: AgentInboundRequest"| A
  A -->|"AgentInboundResponse: JSON reply"| C
  C -->|"Итог HTTP JSON для текстового POST"| B
  A <-->|"PostgreSQL wire protocol: TCP 5432"| P
  A -->|"nats.workflow.run.ruby.node: WorkflowRunRequest"| R
  R -->|"WorkflowRunResponse: result + next_runtime"| A
  R -->|"nats.llm.user123: LlmRequest mode/input/constraints"| L
  L -->|"LlmResponse: response_text / tool_calls / error"| R
  R -->|"nats.tools.discover и nats.tools.{name}: JSON RPC"| T
  T -->|"Каталог или ToolCallResponse: JSON reply"| R
  L -->|"HTTPS POST /api/chat: JSON messages/tools/format"| O
  O -->|"NDJSON stream; structured mode - JSON"| L
  L -->|"chat.agent.stream: delta + request/attempt/generation/sequence"| A
  A -->|"nats.events.user123: response_fragment/request_state/message/client"| N
  N -->|"wss://chat-agent.gis-master.ru/ws: NATS + JSON payload"| B
```

На схеме broker опущен между RPC-парами; прямых HTTP-вызовов Agent → Ruby
или Ruby → API gateway в этом пути нет. Ответ на RPC отправляется в
одноразовый reply inbox NATS, а не обратно в subject запроса.

### Внутренние адреса и request/reply payload

Общий адрес для наших NATS clients: `nats://chat-agent_audio_nats:4222`.
Неизменённый voice использует совместимый alias
`nats://web-rtc-komaroff_audio_nats:4222` того же broker. Это не старый внешний
application host. Во всех адресах намеренно опущены credentials.

| Кто → кто | Subject / адрес | Запрос → ответ |
|---|---|---|
| Core → Agent | `nats.agent.user123` | `AgentInboundRequest`: text, turn_id, edit, user_id → `AgentInboundResponse`: ok, result, status, client_handler/client_events, errors. |
| Core → Agent, история | `nats.agent.history.user123` | `HistoryGetRequest` → `HistoryGetResponse`. |
| Agent → Ruby | `nats.workflow.run.ruby.node` | `WorkflowRunRequest`: text, runtime(active_workflow_id/pending/context/version) → `WorkflowRunResponse`: status/result/next_runtime/client events/errors. |
| Ruby → LLM | `nats.llm.user123` | `LlmRequest`: mode, input, constraints → `LlmResponse`: ok, data, error. Для dialog_step data содержит response_text и tool_calls(id/name/arguments). |
| Ruby → tools | `nats.tools.discover`, `nats.tools.{tool_name}` | `{}` → каталог JSON-схем; `ToolCallRequest`(tool_name/args) → `ToolCallResponse`(ok/artifact_key/data/error). |
| Ingress → Core | `chat.agent.audio.control` | JSON admit/heartbeat/ending/ended/abort → JSON lease/ACK/error. Lease token и epoch - только backend. |
| Core → Agent, snapshot/cancel | `chat.agent.requests` | JSON action/request_id/session_id → состояние/результат/отказ. |
| Agent → downstream, cancel | `chat.agent.cancel.workflow`, `.llm`, `.tools` | JSON с идентификаторами попытки → acknowledgement. |
| Agent ↔ PostgreSQL | `chat-agent_src_postgres:5432`, database `mcp` | PostgreSQL wire protocol, не JSON RPC. |

Typed RPC payload - UTF-8 JSON, с общим envelope
`trace_id`, `correlation_id`, `request_id`, `session_id`, `ts_ms`.
NATS headers несут traceparent и execution carrier; это не поля NATS subject.
У control/stream сообщений свои компактные схемы, они не обязаны повторять
весь TraceEnvelope. Дочерний `request_id` LLM-вызова и внешний request_id хода
не следует считать одним ID; поток дополнительно связывается attempt/generation.

### Что LLM передаёт провайдеру

Адрес: `https://nats-proxy-ollama.gis-master.ru/api/chat`, HTTPS POST.
`application/json`: `model:"qwen3:30b"`, `messages`, `tools`, `think:false`,
`options:{num_ctx:8192,num_predict:2048,temperature:0}`.

- `dialog_step`: native tools и `stream:true`; HTTP-ответ разбирается как NDJSON,
  с `message.content`, `message.tool_calls`, завершением и usage.
- Структурированные режимы: `format:<JSON Schema>`, `stream:false`; один JSON.
- Полный набор tool calls проверяется сервером до исполнения. Потом Ruby
  выполняет инструменты последовательно и возвращает результаты модели.
  Обычный разговор обходится одной генерацией, инструментальный - может несколькими.
- Внутренний маршрут proxy → Ollama не исследован заново и не представлен
  как прямое соединение к конкретному inference host.

Пять tools: `search_airports_nearby`, `get_weather`, `get_flight_status`,
`get_current_position`, `build_route`. Сейчас API gateway отдаёт демонстрационные
fixtures; схема не означает подключение к настоящим погодным/авиационным API.

### Как ответ доходит до браузера

`chat.agent.stream` - внутренний pub-sub LLM → Agent, UTF-8 JSON:
`request_id`, `session_id`, `attempt_id`, `generation_id`, `generation_index`,
`sequence`, `delta`. Agent проверяет владельца/попытку/порядок, накапливает текст
и публикует UI-события в `nats.events.user123`:

- `response_fragment`: принятый delta и идентификаторы;
- `request_state`: накопленный text, state и идентификаторы; активный snapshot раз в секунду;
- `message`: итоговый текст и trace;
- `client`: проверяемая frontend команда, например изменения карты;
- Core публикует там же `asr_result` и `audio_status`.

Envelope UI-события: `time`, `service`, `type`, `kind`, `data`, `name`, `uid`.
Это JSON payload внутри протокола NATS через WSS, не произвольный JSON WebSocket.
Frontend проверяет session/stream либо request/attempt/generation/sequence
до отображения и выполнения команды. Subject `user123` всё ещё общий;
фильтрация UI не заменяет отдельные NATS ACL для пользователей.

RPC сохраняет полный результат; частичные события не являются его заменой.
После reconnect frontend запрашивает snapshot через Core или историю через
Agent. Общий Core NATS не даёт replay этих UI-событий.

Источники диалога: [Agent service](</experiments/voice-ai/archive/w/src_agent/service.py>),
[request state](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/e4bfe285306fc225c3d445ade16296021bd934f3/src_agent/request_state.py>),
[RPC contracts](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/tree/e4bfe285306fc225c3d445ade16296021bd934f3/src_shared/contracts>),
[Ruby runtime IO](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/e4bfe285306fc225c3d445ade16296021bd934f3/src_langgraph_rb_node/lib/src_langgraph_rb_node/runtime/runtime_io.rb>),
[dialog tool loop](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/e4bfe285306fc225c3d445ade16296021bd934f3/src_langgraph_rb_node/lib/src_langgraph_rb_node/runtime/dialog.rb>),
[LLM service](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/e4bfe285306fc225c3d445ade16296021bd934f3/src_llm/service.py>),
[HTTP provider](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/e4bfe285306fc225c3d445ade16296021bd934f3/src_llm/clients/provider.py>),
[API gateway](</experiments/voice-ai/archive/w/src_api_gateway/main.py>),
[frontend event filter](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/e4bfe285306fc225c3d445ade16296021bd934f3/src_front/src/assistant/assistantApp.ts>),
[DRS routes, names, account remap](<https://git.giscloud.ru/voice-chat/webrtc-komaroff/-/blob/e4bfe285306fc225c3d445ade16296021bd934f3/stack/webrtc.drs>).

## Граница подтверждения

| Слой | Подтверждено этой сверкой |
|---|---|
| Source | Producers/consumers, обе формы аудио, binary header, JSON contracts, directions, RPC и stream. Git diff e9122e0..e4bfe28 для Agent/LLM/API/Ruby/contracts пуст. |
| Runtime | Insight 06.10.2026: Core и ingress `0c403307`, Agent/LLM/API `e9122e0`, Ruby/Frontend `e4bfe28`; voice bridge `5d1d959`, ASR_MODE=streaming. Также проверены действующие router rules, priorities, upstream ports и OAuth middleware. Не ожидается одинаковый SHA у выборочно собранных сервисов. |
| Внешний inference | Worker source и account remap подтверждены; свежий inspect H100 и внутренних hops Ollama proxy не выполнялся. |
| Поведение / скорость | Эта правка меняет документацию. Результаты C3/C4 относятся к прежним прогонам, а не к повторной приёмке аудио сегодня. |
