feat(dictation): realtime streaming STT (live dictation) #118

Closed
vvzvlad wants to merge 4 commits from feature/streaming-dictation into develop
Owner

Realtime streaming dictation (live STT)

Implements docs/streaming-dictation-plan.md: an optional realtime speech-to-text path layered on top of the existing batch dictation, so transcribed text appears as the user speaks. Batch dictation remains the default and the fallback.

Architecture (plan A2 + B2)

  • Transport A2 — browser ↔ our server (Socket.IO namespace /ai-realtime) ↔ OpenAI Realtime (raw ws). The provider API key never leaves the server; the upstream URL is SSRF-checked before connecting; the gateway enforces the dictation + dictationRealtime gate, cookie-JWT auth, and per-user / per-workspace concurrency caps.
  • Editor B2 — interim (partial) text is shown as a meta-only ProseMirror ghost decoration (no Yjs/history noise); only completed segments are committed to the document. The chat shows interim as a dimmed tail.

⚠️ API contract note

The plan's §3 describes the OpenAI Realtime beta API, which was removed on 2026-05-12. This PR is implemented against the GA (2026) contract instead, verified against the live docs: no OpenAI-Beta header; session.update with session.type:"transcription"; nested audio.input.format:{type:"audio/pcm",rate:24000}; turn_detection inside audio.input. The delta/completed events are unchanged.

Server

  • core/ai-chat/realtime/ai-realtime.service.ts — upstream ws proxy: config resolve, SSRF check, pure parseUpstreamEvent (per-item_id delta accumulation, trimmed final), idle/max-duration timeouts, idempotent teardown, testConnection (reuses openSession). Unit-tested (*.spec.ts, 8 cases).
  • core/ai-chat/realtime/ai-realtime.gateway.ts@WebSocketGateway('/ai-realtime'), cookie-JWT auth, gate before opening upstream, concurrency caps; normalized events ready/interim/final/error/closed.
  • Admin-gated POST /ai-chat/realtime/test connectivity probe.
  • Config: workspace flag settings.ai.dictationRealtime (aiDictationRealtime DTO) + provider sttRealtimeModel / sttRealtimeBaseUrl (realtime key reuses sttApiKey — no new secret).

Client

  • dictation/audio/pcm16-worklet.ts — AudioWorklet resampling to 24 kHz mono PCM16 (Int16 LE), ~150 ms batches.
  • dictation/services/realtime-dictation-client.ts + dictation/hooks/use-realtime-dictation.ts (status/start/stop/cancel + onInterim/onFinal).
  • dictation/components/realtime-mic-button.tsx + editor/extensions/dictation-interim/ ghost decoration; editor (dictation-group) and chat (chat-input) integration; AI settings UI (toggle, realtime model/endpoint, test button) + i18n.

Verification

  • tsc --noEmit clean (server + client); server ESLint clean on changed files; realtime jest spec 8/8.
  • Two adversarial review passes (code-review subagent): Wave 1 had 2 lifecycle bugs (session-close handle reset; client teardown on server-initiated close) — fixed and re-approved; Wave 2 approved (only nits).
  • Not covered: end-to-end runtime (needs a live OpenAI Realtime key + microphone); full client vite build/pnpm lint should run in CI (the isolated worktree lacked client dev tooling). Concurrency caps are per-process.

🤖 Generated with Claude Code

## Realtime streaming dictation (live STT) Implements `docs/streaming-dictation-plan.md`: an **optional** realtime speech-to-text path layered on top of the existing batch dictation, so transcribed text appears **as the user speaks**. Batch dictation remains the default and the fallback. ### Architecture (plan A2 + B2) - **Transport A2** — browser ↔ our server (Socket.IO namespace `/ai-realtime`) ↔ OpenAI Realtime (raw `ws`). The provider API key **never leaves the server**; the upstream URL is **SSRF-checked** before connecting; the gateway enforces the `dictation` + `dictationRealtime` gate, cookie-JWT auth, and per-user / per-workspace concurrency caps. - **Editor B2** — interim (partial) text is shown as a **meta-only ProseMirror ghost decoration** (no Yjs/history noise); only completed segments are committed to the document. The chat shows interim as a dimmed tail. ### ⚠️ API contract note The plan's §3 describes the OpenAI Realtime **beta** API, which was removed on 2026-05-12. This PR is implemented against the **GA (2026)** contract instead, verified against the live docs: no `OpenAI-Beta` header; `session.update` with `session.type:"transcription"`; nested `audio.input.format:{type:"audio/pcm",rate:24000}`; `turn_detection` inside `audio.input`. The `delta`/`completed` events are unchanged. ### Server - `core/ai-chat/realtime/ai-realtime.service.ts` — upstream `ws` proxy: config resolve, SSRF check, pure `parseUpstreamEvent` (per-`item_id` delta accumulation, trimmed final), idle/max-duration timeouts, idempotent teardown, `testConnection` (reuses `openSession`). Unit-tested (`*.spec.ts`, 8 cases). - `core/ai-chat/realtime/ai-realtime.gateway.ts` — `@WebSocketGateway('/ai-realtime')`, cookie-JWT auth, gate before opening upstream, concurrency caps; normalized events `ready/interim/final/error/closed`. - Admin-gated `POST /ai-chat/realtime/test` connectivity probe. - Config: workspace flag `settings.ai.dictationRealtime` (`aiDictationRealtime` DTO) + provider `sttRealtimeModel` / `sttRealtimeBaseUrl` (realtime key reuses `sttApiKey` — no new secret). ### Client - `dictation/audio/pcm16-worklet.ts` — AudioWorklet resampling to 24 kHz mono PCM16 (Int16 LE), ~150 ms batches. - `dictation/services/realtime-dictation-client.ts` + `dictation/hooks/use-realtime-dictation.ts` (`status/start/stop/cancel` + `onInterim/onFinal`). - `dictation/components/realtime-mic-button.tsx` + `editor/extensions/dictation-interim/` ghost decoration; editor (`dictation-group`) and chat (`chat-input`) integration; AI settings UI (toggle, realtime model/endpoint, test button) + i18n. ### Verification - `tsc --noEmit` clean (server + client); server ESLint clean on changed files; realtime jest spec 8/8. - Two adversarial review passes (code-review subagent): Wave 1 had 2 lifecycle bugs (session-close handle reset; client teardown on server-initiated close) — fixed and re-approved; Wave 2 approved (only nits). - Not covered: end-to-end runtime (needs a live OpenAI Realtime key + microphone); full client `vite build`/`pnpm lint` should run in CI (the isolated worktree lacked client dev tooling). Concurrency caps are per-process. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
vvzvlad added 1 commit 2026-06-21 14:48:30 +03:00
Layer an optional realtime speech-to-text path on top of the existing
batch dictation, so transcribed text appears as the user speaks.

Transport A2: browser <-> our server (Socket.IO `/ai-realtime`) <->
OpenAI Realtime (raw ws). The provider API key never leaves the server;
the upstream URL is SSRF-checked before connecting; the gateway enforces
the dictation+dictationRealtime gate, cookie-JWT auth and per-user/
per-workspace concurrency caps. Implemented against the GA (2026) OpenAI
Realtime transcription contract (session.update / audio.input.format /
server_vad), not the now-removed beta shape.

Editor UI B2: interim text is shown as a meta-only ProseMirror ghost
decoration (no Yjs/history noise); only completed segments are committed.
Chat shows interim as a dimmed tail. The mic button switches realtime vs
batch by the workspace flag; batch remains the default and fallback.

Server:
- AiRealtimeService (upstream ws proxy, normalized events, idle/max-
  duration timeouts, idempotent teardown) + parseUpstreamEvent unit tests
- AiRealtimeGateway (Socket.IO `/ai-realtime`) wired into AiChatModule
- admin-gated POST /ai-chat/realtime/test connectivity probe
- config: settings.ai.dictationRealtime + provider sttRealtimeModel/
  sttRealtimeBaseUrl (realtime key reuses sttApiKey; no new secret)

Client:
- pcm16 AudioWorklet (24kHz mono PCM16), RealtimeDictationClient,
  use-realtime-dictation hook (status/start/stop/cancel + onInterim/onFinal)
- RealtimeMicButton + dictation-interim ProseMirror decoration
- editor/chat integration + AI settings UI (toggle, model, test endpoint)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

🤖 Автоматический аудит тест-стратегии (scoped на этот PR). Числа покрытия перепроверены прогоном jest --coverage (V8) и vitest на изолированном worktree @ 0b3d5955. Анализ выполнен 6 субагентами (по одному на модуль). Код PR не изменялся — только анализ.


Отчёт по тест-стратегии — gitmost PR #118 «feat(dictation): realtime streaming STT» — 2026-06-21

Область анализа — только изменения PR #118 (25 файлов, +2111/−19): серверный realtime-шлюз/сервис,
клиентский realtime-движок диктовки, ghost-декорация интерима в редакторе, UI AI-настроек и интеграция в чат.
Анализ выполнен на изолированном git-worktree, привязанном к HEAD ветки feature/streaming-dictation
(commit 0b3d5955). Все «уже покрыто»-утверждения перепроверены прогоном jest/vitest.

Таксономия слоёв (зафиксирована для бюджета пирамиды):

  • unit — в процессе, один субъект (функция/класс), коллабораторы замоканы/фейковые, без I/O. Сюда отнесены
    тесты сервиса с инъецированным фейковым WebSocket (сетевого I/O нет).
  • integration — реальный рантайм фреймворка: живой ProseMirror EditorView, gateway со связкой нескольких
    реальных коллабораторов + module-singleton счётчики + жизненный цикл сокета, транзакция БД.
  • contract — проверка формы API/wire-границы.
  • e2e — полный стек, ручной (микрофон + secure context + живой STT-ключ).

1. Исполнительное резюме

  • Проанализировано модулей: 6 (3 server + 3 client), по одному module-testability-analyst на модуль.
  • Предложено тестов (unit / integration / e2e / contract): 32 / 8 / 1 / 3 = 44.
  • Отклонено как малоценные / дедуплицировано: 8 (см. §7).
  • Покрытие сейчас (изменённый код PR, проверено V8/jest):10 % строк диффа.
    Серверная новизна покрыта частично только в parseUpstreamEvent; весь клиентский код — 0 %.
  • Прогноз после внедрения плана:80 % тестируемой поверхности (за вычетом skip-list:
    браузерный AudioWorklet/getUserMedia, DI-обвязка, типы, тонкие react-query-обёртки).
  • Блокеры до написания тестов: инъекция фабрики WebSocket в сервис; экспорт/выделение чистых счётчиков
    и canConnect в шлюзе; вынос DSP-математики из AudioWorklet; добавление @vitest/coverage-v8 в клиент
    (иначе клиентское покрытие в принципе не измеряется).

Сверка «уже покрыто» (прогон инструментом)

Файл (изменён в PR) Stmts Branch Факт
ai-realtime.service.ts 36.9 % 68.4 % покрыт только parseUpstreamEvent (8 тестов ); openSession/testConnection/deriveRealtimeUrl — 0 %
ai-realtime.gateway.ts 0 % 0 % теста нет вообще (строки 1–236)
ai-settings.service.ts 32.7 % 100 % update/resolve для realtime — 0 % funcs
workspace.service.ts 10.8 % 100 % только смоук expect(service).toBeDefined()
update-workspace.dto.ts 100 %* 100 %* *line-coverage обманчив: новое поле aiDictationRealtime поведенчески НЕ проверено
client dictation/**, dictation-interim/** 0 % 0 % тестов нет; @vitest/coverage-v8 отсутствует

2. Рекомендации по модулям

Модуль A — server-realtime-service (core/ai-chat/realtime/ai-realtime.service.ts)

  • Кандидаты на чистые функции: deriveRealtimeUrl (стат., :447) и parseUpstreamEvent (:75) — уже чистые;
    rawDataToString (:478, private static) — опц. экспорт.
  • Unit-тесты добавить:
    • parseUpstreamEvent — добор веток: delta/completed без item_id → ignore; error без message
      fallback на describeProviderError; completed без transcript и без acc → text:''; не-объектный JSON
      ("42", "null") → ignore. Ловит: падение/неверную ветку на битых upstream-кадрах. Рефактор не нужен.
    • deriveRealtimeUrl — default при пустом base; https→wss/http→ws; нет дублирования /v1; снятие
      суффикса /realtime; непарсимый base → тихий fallback на api.openai.com (фиксируем как умышленное —
      см. §4 риск). Ловит: дубль /v1/v1, потерю порта, понижение схемы. Рефактор не нужен.
    • openSession handshake [R-SVC1]: один session.update с type:'transcription', audio.input.format
      PCM 24k, server_vad; заголовок Authorization: Bearer, нет OpenAI-Beta; language только если задан;
      onReady один раз. Ловит: поломку контракта upstream-рукопожатия.
    • openSession config/SSRF-перила [R-SVC1]: нет драйвера → AiSttNotConfiguredException и сокет НЕ создан;
      isUrlAllowed=false → throw и сокет НЕ создан; в guard уходит http-эквивалент ws-URL. Ловит: обход SSRF
      (сокет до проверки), ложный 503 при fallback sttModel.
    • openSession маршрутизация сообщений [R-SVC1]: interim→onInterim; completed→onFinal; error→teardown;
      ignore→без колбэков; сообщение после close — no-op. Ловит: use-after-teardown, путаницу interim/final.
    • appendAudio [R-SVC1]: сокет не OPEN → тихий no-op (PCM не уходит); OPEN → корректный base64; send throw
      onError+teardown; успешный append пере-взводит idle-таймер. Ловит: потерю/утечку аудио, утечку сессии.
    • stop/close идемпотентность [R-SVC1]: commit→teardown; ошибка commit не мешает teardown;
      двойной close → onClosed ровно один раз. Ловит: двойной onClosed, утечку сокетов/листенеров.
    • Таймеры (fake timers) [R-SVC1]: idle 15s, max 120s, неожиданный close (code≠1000) → onError; close 1000
      → без error; после teardown поздние тайм��ры не срабатывают. Ловит: утечку таймеров и невозврат слота.
    • testConnection settle-once [R-SVC1]: ready→{ok:true}; error→{ok:false}; таймаут 8s; not-configured;
      «поздний» handle всё равно закрыт. Ловит: двойной settle и утечку «late-handle».
  • Integration: нет (всё закрывается unit-тестами с фейковым ws).
  • E2E: нет (дублировал бы замоканный openSession; недетерминирован).
  • НЕ тестировать: isUrlAllowed/IP-диапазоны (покрыто ssrf-guard.spec.ts на нижнем слое — только факт
    вызова guard); AiSettingsService.resolve, describeProviderError (чужие модули); типы/интерфейсы.

Модуль B — server-realtime-gateway (realtime/ai-realtime.gateway.ts + controller/module)

  • Кандидаты на чистые функции: incr/decr (:45/:51, module-private), canConnect(...) (логика :101–142),
    toBuffer (:230).
  • Unit-тесты добавить:
    • decr/incr [R-GW1]: delete-at-zero; decr по отсутствующему ключу НЕ уходит в минус и не создаёт
      фантомный слот. Ловит: утечку/недосчёт слота concurrency. Высший ROI.
    • canConnect [R-GW2]: оба флага → allow; dictation xor dictationRealtime → deny; нет settings.ai
      deny; user-cap(1)/workspace-cap(5) с приоритетом сообщений; >= без off-by-one. Ловит: обход гейта, off-by-one.
    • toBuffer: Buffer/Uint8Array/ArrayBuffer → Buffer; строка/null → null. Ловит: краш/пересылку не-бинарных данных.
    • handleStart lifecycle (прямой new шлюза, мок сервиса/сокета): релей onReady→ready,
      onInterim→interim{itemId,text}, onFinal, onClosed чистит handle; double-start guard; AiSttNotConfigured
      vs describeProviderError (без утечки ключа/стека). Ловит: регрессии формы событий, двойной старт, утечку секрета.
    • handleAudio/handleStop guards: нет handle → нет appendAudio; Buffer → один append; не-бинарь → нет append.
  • Integration-тесты добавить (gateway + реальные TokenService/WorkspaceRepo + фейковый socket + module-singleton):
    • handleConnection auth: невалидный/просроченный/отсутствующий cookie-JWT → error:Unauthorized+disconnect,
      счётчик НЕ инкрементнут. Ловит: анонимный сокет, утечку деталей, занятый слот на отклонённой авторизации.
    • handleConnection gate+cap: гейт выкл → disconnect, счётчики чисты; cap превышен → отказ; оба cap проверены
      до инкремента любого
      . Ловит: «грязный» счётчик при отказе, асимметричный инкремент.
    • handleDisconnect no-leak: декремент обоих map; no-op если соединение отклонено до инкремента; идемпотентность
      двойного disconnect (не в минус). Ловит: классическую утечку слота / уход счётчика в отрицательное. Высший ROI.
  • Contract: POST ai-chat/realtime/test → admin-gate (CASL Manage Settings; не-админ → Forbidden, testConnection
    не вызван) + неизменная форма {ok:true}|{ok:false,error}. Ловит: обход admin-гейта, дрейф формы ответа.
  • НЕ тестировать: ai-chat.module.ts провайдеры, @WebSocketGateway({...}) опции (DI/framework wiring);
    socket.io/cookie/ws сами по себе; logger.error.

Модуль C — server-ai-settings & workspace (integrations/ai/*, core/workspace/*)

  • Кандидаты на чистые функции: deriveRealtimeUrl (дедуплицировано с модулем A — здесь НЕ дублируем);
    опц. вынос applyAiSettingPatches(dto, before) из WorkspaceService.update.
  • Unit-тесты добавить:
    • AiSettingsService.update merge/partial: DTO только с realtime-полями → в patch ровно эти ключи; DTO с
      chatModel → realtime-поля НЕ затёрты; пустой patch → repo не вызван. Ловит: выпадение поля из allowlist
      (тихо не сохранится), затирание чужих полей. Рефактор не нужен.
    • AiSettingsService.resolve fallback ключа: sttApiKeyEnc есть → дешифр; нет → fallback на chat apiKey.
      Ловит: регрессию «realtime-ключ переиспользует sttApiKey» (поломка диктовки у workspace с одним chat-ключом).
    • UpdateWorkspaceDto.aiDictationRealtime: true/false ок; не-boolean → ошибка; пропуск ок. Ловит: опечатку
      декоратора (@IsString вместо @IsBoolean). ⚠️ Это закрывает обманчивый «100 %» line-coverage — сейчас
      поведение поля не проверено. ROI средний (регресс-пин).
  • Integration: WorkspaceService.update ветка aiDictationRealtime [R-WS1]: верный ключ настройки
    'dictationRealtime'; audit-diff; поле удалено из dto до generic-update (иначе запись несуществующей колонки).
  • Contract (security): консистентность SSRF — UpdateAiSettingsDto.sttRealtimeBaseUrl намеренно только
    @IsString() (нет @IsUrl()), а блокировка приватных/loopback адресов — в connect-time isUrlAllowed.
    Тест прогоняет http://169.254.169.254/v1 → http-эквивалент → isUrlAllowedok:false. Ловит: самый
    тяжёлый класс — «закалили» DTO с иной allow/deny-семантикой ИЛИ убрали connect-time проверку ⇒ живой SSRF
    (metadata-endpoint/внутренние сервисы). Высший ROI.
  • НЕ тестировать: allowlist WorkspaceRepo.updateAiProviderSettings (покрыт unit-тестом update сверху);
    тривиальные @IsString поля sttRealtimeModel/sttRealtimeBaseUrl; field-agnostic admin-гейт (уже покрыт
    workspace-update-gate.spec.ts); типы ai.types.ts; ORM-хелперы Kysely.

Модуль D — client-realtime-dictation-core (features/dictation/**)

  • Кандидаты на чистые функции (главное — DSP заперт в браузерном классе):
    floatToPcm16LE (:104–120) [R-DSP1]; resampler-ядро (:54–89) [R-DSP2]; frame-аккумулятор
    (:16/:93–99) [R-DSP3]; reducer interim/final/cancel/stop (:320–357) [R-HOOK4];
    baseLanguageSubtag (:374) [R-HOOK5]; mapGetUserMediaError (:207–219, дублирует use-dictation.ts) [R-HOOK6].
  • Unit-тесты добавить:
    • floatToPcm16LE [R-DSP1]: +1→32767, −1→−32768, 0→0; clamp +2/−2 без переполнения; LE-порядок байт;
      property-тест «выход всегда в [−32768,32767]»; NaN/Inf. Ловит: переполнение/wrap, endianness, off-by-one. Высший ROI.
    • resampler-ядро [R-DSP2]: 48k→24k ≈ половина; ratio=1 ≈ passthrough; 44.1k→24k дробный; бесшовность
      cross-quantum
      (разбиение на два process() == целому); инвариант «нет OOB-чтения». Ловит: щелчки на стыках, OOB, дрейф.
    • frame-аккумулятор [R-DSP3]: ровно 3600 сэмплов → один кадр 7200 байт; 3599 → ничего; остаток переносится.
      Ловит: неверную границу ~150 мс, потерю/дубль хвоста.
    • reducer [R-HOOK4]: interim замещает interim; final trim + отброс пустых; cancel отбрасывает pending и
      игнорирует последующее; аудио буферизуется до ready и сливается по порядку; closed после stop — no-op.
      Ловит: потерю/дубль final, утечку interim после cancel, нарушение порядка.
    • baseLanguageSubtag [R-HOOK5]: en-US→en, en→en, ''→undefined. Ловит: отправку region-locale, который STT отвергает.
    • mapGetUserMediaError [R-HOOK6]: NotAllowed/NotFound/NotReadable/fallback. Ловит: неверный UX-текст; чинит дубль с batch-хуком.
  • Integration-тесты добавить (jsdom + мок socket.io-client):
    • RealtimeDictationClient (unit-класс): декодирование ready/interim/final/error/connect_error с ?? '';
      error-once guard; disconnect снимает листенеры и сбрасывает флаг; ?.-гварды при отсутствии сокета.
    • RealtimeMicButton (component, мок хука): status recording→stop, иначе→start; переход recording→idle вызывает
      onInterim("") ровно раз. Ловит: «зависший» partial-текст в редакторе после остановки.
  • E2E (ручной, 1 шт.): клик-микрофон → речь → interim стримится → пауза → final коммитится → стоп → сессия
    закрыта, индикатор off. Покрывает: реальный AudioWorklet+getUserMedia+живой upstream — не воспроизводимо мок-ами.
  • НЕ тестировать: рантайм AudioWorkletProcessor/process()/registerProcessor; реальные getUserMedia/
    MediaStream/AudioContext/AudioWorkletNode; socket.io-client; getAudioContextCtor webkit-проба;
    audio-worklet.d.ts; пред-существующая batch-диктовка.

Модуль E — client-editor-interim-decoration (editor/extensions/dictation-interim/**)

  • Кандидаты на чистые функции: applyInterimMeta(meta, prev) (:33–44) [R-EXT1]; clampRange(from,to,size)
    в dictation-group.tsx (:34–35) [R-EXT3].
  • Integration-тесты добавить (headless ProseMirror/Tiptap в jsdom):
    • REGRESSION GUARD (высший ROI): после setDictationInterim/clearDictationInterim транзакция имеет
      docChanged===false, steps.length===0, doc равен прежнему. Ловит: «полезную» вставку interim в документ —
      тот самый класс дефектов, ради предотвращения которого PR и существует (загрязнение Yjs/history).
    • History guard: с @tiptap/extension-history — серия interim-обновлений не добавляет undo-шагов; undo
      откатывает напечатанный текст, не interim. Ловит: interim как undoable-шаг.
    • decorations: пустой текст → null (нет виджета); непустой → один widget contenteditable=false у каретки;
      декорация маппится при правках (трекает selection.head, а не устаревшую позицию).
  • Unit-тесты добавить: applyInterimMeta [R-EXT1] (merge/passthrough); DictationGroupclampRange
    off-by-one [R-EXT3] + гварды editor.isDestroyed (set/clear только на живом редакторе; realtime vs batch gating).
  • E2E: нет (доминируется integration-гвардами).
  • НЕ тестировать: регистрация в extensions.ts; boilerplate DictationInterim.create; внутренности
    Decoration.widget/DecorationSet (ProseMirror); инлайн-стили виджета (кроме contenteditable=false);
    dictationInterimKey.

Модуль F — client-ai-settings-ui & chat (workspace/.../ai-provider-settings.tsx, ai-chat/chat-input.tsx)

  • Кандидаты на чистые функции: resolveCardStatus/isEndpointConfigured/resolveKeyFieldуже экспортированы;
    resolveUrl (:89) [R-UI1 экспорт]; merge interim/final в chat-input.tsx (:84–93) [R-UI2 вынос].
  • Unit-тесты добавить:
    • resolveKeyField: buffer→{set,value}; cleared+пусто→{set:'' }; нетронуто→{set:false}; buffer+cleared→buffer.
      Ловит: утечку/потерю write-only секрета (sttApiKey). Высший ROI (security). Рефактор не нужен.
    • isEndpointConfigured: model+base→true; наследование chat-base; whitespace-base как пустой. Ловит: ошибки trim/предиката.
    • resolveCardStatus: 4 исхода, особенно «enabled но не configured → warning». Ловит: скрытие реального мисконфига.
    • resolveUrl [R-UI1]: trailing-slash, fallback-цепочка. Ловит: двойной/потерянный слеш в hint.
    • appendFinalToDraft(draft, final) [R-UI2]: пусто+final→final; непусто→"draft final" (один разделитель);
      накопление слева-направо. Ловит: лидирующий/двойной пробел/потерю текста. Высший ROI (ядро chat-диктовки).
    • interim-transition [R-UI2]: onInterim ставит dimmed-tail; onFinal/send/cancel чистят interim.
      Ловит: «зависший» ghost-хвост или двойной коммит.
  • Contract: testRealtimeConnection (мок api): POST на верный маршрут /ai-chat/realtime/test (не префикс
    /workspace/ai-settings), распаковка {ok}-конверта, без тела запроса. Ловит: регрессию неверного URL (легко
    скопировать соседний маршрут) и дрейф формы.
  • НЕ тестировать: workspace.types.ts/типы сервиса; тонкие react-query-обёртки (useTest...Mutation,
    useAiSettingsQuery); неизменённые passthrough-api.post; handleToggle* (оптимистичная обвязка + Mantine/jotai);
    StatusDot/полный render компонентов (tautological snapshot); translation.json.

3. Сквозные аспекты

  • Contract-тесты сервис↔сервис: (1) SSRF-консистентность DTO↔connect-time guard (модуль C); (2) admin-gate +
    форма {ok} realtime-test (модуль B/F, серверная сторона владеет формой — клиентский контракт дедуплицирован сюда).
  • Property-based тесты: идеально ложатся на вынесенный DSP — floatToPcm16LE (выход всегда в int16-диапазоне),
    длина ресемпла, отсутствие OOB при случайных размерах квантов/частотах.
  • Дымовые/нагрузочные: микро-нагрузка на concurrency-caps шлюза (N>cap соединений отклоняются и НЕ держат слот) —
    покрывается integration-тестом disconnect-no-leak; отдельный нагрузочный не требуется.
  • Test-data factories: фабрика RealtimeSettings/ResolvedAiConfig (модули A/C); фейковый WebSocketLike
    (EventEmitter с readyState/send/close/on) для модуля A; фейковый Socket (data/emit/disconnect как jest-моки)
    для модуля B; стабы getUserMedia/AudioContext/AudioWorkletNode для модуля D.

4. Обнаруженные антипаттерны

  • Скрытые побочные эффекты, мешающие unit-тестам: DSP-математика заперта в AudioWorkletProcessor
    (pcm16-worklet.ts); state-machine диктовки размазан по ref/effect (use-realtime-dictation.ts); merge-логика
    чата в JSX-замыканиях (chat-input.tsx). → разблокируется выносом (см. §5).
  • God-объекты: ai-provider-settings.tsx — 1109 строк; WorkspaceService — 17 зависимостей в конструкторе
    (workspace.service.ts:57).
  • Нетестируемое module-singleton состояние: sessionsPerUser/sessionsPerWorkspace (Maps, ai-realtime.gateway.ts:45+)
    без reset-хука → риск порядко-зависимых тестов между файлами. → требует [R-GW1].
  • Тавтологический смоук: workspace.service.spec.ts:33 (expect(service).toBeDefined()) даёт ложную уверенность
    в покрытии — переопределяется integration-тестом ветки.
  • Обманчивое покрытие: update-workspace.dto.ts 100 % line, но валидация нового boolean не утверждена (V8 считает
    строки декларации покрытыми при инстанцировании).
  • Security-запахи (нужны решения автора, см. §5):
    • sttRealtimeBaseUrl не валидируется как URL при сохранении (только connect-time guard) — расходится с MCP-путём
      (mcp-servers.service.ts:138, где есть save-time гейт).
    • deriveRealtimeUrl при непарсимом base молча уходит на api.openai.com → опечатка в self-hosted endpoint
      отправит аудио и workspace-ключ на дефолтный публичный endpoint.
    • appendAudio молча отбрасывает PCM до открытия сокета (потеря данных, не баг по дизайну — подтвердить).
  • Нестабильные/порядко-зависимые тесты: существующих не обнаружено — текущие спеки чистые (свежий acc в
    beforeEach, без sleeps и общего состояния). Для новых хук-тестов — только fake timers, без реальных sleep.

5. Необходимые рефакторинги перед написанием тестов

Реф. Где Блокирует
R-SVC1 ai-realtime.service.ts:175 — инъекция wsFactory вместо хардкода new WebSocket(...) все unit openSession/appendAudio/stop/таймеры/testConnection (7 тестов)
R-SVC2 (опц.) экспорт rawDataToString прямой unit (иначе через маршрутизацию)
R-GW1 экспорт incr/decr или SessionCounters с reset unit счётчиков; устранение порядко-зависимости cap-тестов
R-GW2 вынос чистого canConnect(userId, wsId, settings, counts) unit гейта/cap (7 сценариев)
R-WS1 вынос applyAiSettingPatches / thin-slice WorkspaceService.update integration ветки aiDictationRealtime
R-DSP1/2/3 вынос floatToPcm16LE / resampler / framing из pcm16-worklet.ts в инлайнимый бандлером модуль unit DSP (3) — высший ROI; ⚠️ worklet-scope запрещает import, нужен инлайн
R-HOOK4/5/6 вынос reducer / baseLanguageSubtag / общий mapGetUserMediaError unit логики хука (3); дедуп с batch
R-EXT1/3 вынос applyInterimMeta / clampRange unit (2)
R-UI1/2 экспорт resolveUrl; вынос merge-хелперов чата unit (3)
Инфра добавить @vitest/coverage-v8 в apps/client измеримость клиентского покрытия (сейчас невозможно)

6. План внедрения (по фазам)

  • Фаза 1 — security & утечки (наивысший ROI, минимум рефактора): SSRF-контракт (C); gateway auth/cap/disconnect-
    no-leak (B, рефактор не нужен) + decr после R-GW1; resolveKeyField (F, без рефактора); parseUpstreamEvent
    добор + deriveRealtimeUrl (A, без рефактора). Ловит обход авторизации, утечку слотов, утечку ключа, SSRF.
  • Фаза 2 — ядро realtime-сервиса: R-SVC1, затем 7 unit openSession/lifecycle/таймеры/testConnection (A).
    Фиксирует контракт upstream и отсутствие утечек сессий/таймеров.
  • Фаза 3 — клиентский DSP + reducer: добавить @vitest/coverage-v8; R-DSP1/2/3 + R-HOOK4 → property/edge-тесты
    PCM/resample/framing/reducer (D). Самый дешёвый и детерминированный выигрыш покрытия на клиенте.
  • Фаза 4 — инварианты редактора и чата: headless-ProseMirror regression/ history guards (E); R-UI2 →
    appendFinalToDraft/interim-transition (F); RealtimeDictationClient/RealtimeMicButton (D).
  • Фаза 5 — настройки/workspace + ручной e2e: R-WS1 ветка aiDictationRealtime, AiSettingsService.update/resolve,
    DTO-boolean (C); один ручной mic-smoke (D).

7. Источники

  • Отчёты 6 аналитиков module-testability-analyst (по одному на модуль A–F).
  • Независимая сверка покрытия: jest --coverage --coverageProvider=v8 (server, изолированный worktree @ 0b3d5955)
    и vitest run (client); подтверждено 8/8 тестов parseUpstreamEvent, 0 % gateway, 0 % клиентской диктовки,
    отсутствие @vitest/coverage-v8.
  • Фильтрация: дедуп (deriveRealtimeUrl оставлен только в A; клиентский payload-контракт сведён к серверному;
    mapGetUserMediaError — один общий тест) — снято ~4; skip-list (DI-wiring, типы, react-query-обёртки, i18n,
    AudioWorklet/getUserMedia, snapshot) — отсеяно большинство публичных символов; отклонено как малоценное:
    cardStatusLabel-i18n, shouldShowInterim, buildPayload omit-vs-send, toggle-revert — ~4. Итог отклонено/дедуп: 8.
  • Бюджет пирамиды соблюдён: unit 32/44 = 73 % (≥70 %); integration 8/44 = 18 % (≤20 %); e2e 1 (≤10);
    contract 3 (отдельный бакет). Каждый тест называет слой, цель (файл:строка), сценарии и класс дефекта.
> 🤖 Автоматический аудит тест-стратегии (scoped на этот PR). Числа покрытия перепроверены прогоном `jest --coverage` (V8) и `vitest` на изолированном worktree @ `0b3d5955`. Анализ выполнен 6 субагентами (по одному на модуль). Код PR не изменялся — только анализ. --- # Отчёт по тест-стратегии — gitmost PR #118 «feat(dictation): realtime streaming STT» — 2026-06-21 > Область анализа — **только изменения PR #118** (25 файлов, +2111/−19): серверный realtime-шлюз/сервис, > клиентский realtime-движок диктовки, ghost-декорация интерима в редакторе, UI AI-настроек и интеграция в чат. > Анализ выполнен на изолированном git-worktree, привязанном к HEAD ветки `feature/streaming-dictation` > (commit `0b3d5955`). Все «уже покрыто»-утверждения перепроверены прогоном `jest`/`vitest`. **Таксономия слоёв (зафиксирована для бюджета пирамиды):** - **unit** — в процессе, один субъект (функция/класс), коллабораторы замоканы/фейковые, без I/O. Сюда отнесены тесты сервиса с инъецированным фейковым `WebSocket` (сетевого I/O нет). - **integration** — реальный рантайм фреймворка: живой ProseMirror `EditorView`, gateway со связкой нескольких реальных коллабораторов + module-singleton счётчики + жизненный цикл сокета, транзакция БД. - **contract** — проверка формы API/wire-границы. - **e2e** — полный стек, ручной (микрофон + secure context + живой STT-ключ). --- ## 1. Исполнительное резюме - **Проанализировано модулей:** 6 (3 server + 3 client), по одному `module-testability-analyst` на модуль. - **Предложено тестов (unit / integration / e2e / contract):** **32 / 8 / 1 / 3** = 44. - **Отклонено как малоценные / дедуплицировано:** **8** (см. §7). - **Покрытие сейчас (изменённый код PR, проверено V8/jest):** ≈ **10 %** строк диффа. Серверная новизна покрыта частично только в `parseUpstreamEvent`; весь клиентский код — **0 %**. - **Прогноз после внедрения плана:** ≈ **80 %** *тестируемой* поверхности (за вычетом skip-list: браузерный AudioWorklet/getUserMedia, DI-обвязка, типы, тонкие react-query-обёртки). - **Блокеры до написания тестов:** инъекция фабрики `WebSocket` в сервис; экспорт/выделение чистых счётчиков и `canConnect` в шлюзе; вынос DSP-математики из AudioWorklet; **добавление `@vitest/coverage-v8`** в клиент (иначе клиентское покрытие в принципе не измеряется). ### Сверка «уже покрыто» (прогон инструментом) | Файл (изменён в PR) | Stmts | Branch | Факт | |---|---|---|---| | `ai-realtime.service.ts` | 36.9 % | 68.4 % | покрыт только `parseUpstreamEvent` (8 тестов ✅); `openSession`/`testConnection`/`deriveRealtimeUrl` — 0 % | | `ai-realtime.gateway.ts` | **0 %** | 0 % | теста нет вообще (строки 1–236) | | `ai-settings.service.ts` | 32.7 % | 100 % | `update`/`resolve` для realtime — 0 % funcs | | `workspace.service.ts` | **10.8 %** | 100 % | только смоук `expect(service).toBeDefined()` | | `update-workspace.dto.ts` | 100 %* | 100 %* | *line-coverage обманчив: новое поле `aiDictationRealtime` поведенчески НЕ проверено | | client `dictation/**`, `dictation-interim/**` | **0 %** | 0 % | тестов нет; `@vitest/coverage-v8` отсутствует | --- ## 2. Рекомендации по модулям ### Модуль A — server-realtime-service (`core/ai-chat/realtime/ai-realtime.service.ts`) - **Кандидаты на чистые функции:** `deriveRealtimeUrl` (стат., :447) и `parseUpstreamEvent` (:75) — уже чистые; `rawDataToString` (:478, private static) — опц. экспорт. - **Unit-тесты добавить:** - `parseUpstreamEvent` — добор веток: `delta`/`completed` без `item_id` → ignore; `error` без `message` → fallback на `describeProviderError`; `completed` без transcript и без acc → `text:''`; не-объектный JSON (`"42"`, `"null"`) → ignore. *Ловит:* падение/неверную ветку на битых upstream-кадрах. **Рефактор не нужен.** - `deriveRealtimeUrl` — default при пустом base; `https→wss`/`http→ws`; нет дублирования `/v1`; снятие суффикса `/realtime`; **непарсимый base → тихий fallback на `api.openai.com`** (фиксируем как умышленное — см. §4 риск). *Ловит:* дубль `/v1/v1`, потерю порта, понижение схемы. **Рефактор не нужен.** - `openSession` handshake **[R-SVC1]**: один `session.update` с `type:'transcription'`, `audio.input.format` PCM 24k, `server_vad`; заголовок `Authorization: Bearer`, **нет** `OpenAI-Beta`; `language` только если задан; `onReady` один раз. *Ловит:* поломку контракта upstream-рукопожатия. - `openSession` config/SSRF-перила **[R-SVC1]**: нет драйвера → `AiSttNotConfiguredException` и сокет НЕ создан; `isUrlAllowed`=false → throw и сокет НЕ создан; в guard уходит **http-эквивалент** ws-URL. *Ловит:* обход SSRF (сокет до проверки), ложный 503 при fallback `sttModel`. - `openSession` маршрутизация сообщений **[R-SVC1]**: interim→`onInterim`; completed→`onFinal`; error→teardown; ignore→без колбэков; сообщение **после** close — no-op. *Ловит:* use-after-teardown, путаницу interim/final. - `appendAudio` **[R-SVC1]**: сокет не OPEN → тихий no-op (PCM не уходит); OPEN → корректный base64; `send` throw → `onError`+teardown; успешный append пере-взводит idle-таймер. *Ловит:* потерю/утечку аудио, утечку сессии. - `stop`/`close` идемпотентность **[R-SVC1]**: commit→teardown; ошибка commit не мешает teardown; двойной close → `onClosed` ровно один раз. *Ловит:* двойной `onClosed`, утечку сокетов/листенеров. - Таймеры (fake timers) **[R-SVC1]**: idle 15s, max 120s, неожиданный close (code≠1000) → `onError`; close 1000 → без error; после teardown поздние тайм��ры не срабатывают. *Ловит:* утечку таймеров и невозврат слота. - `testConnection` settle-once **[R-SVC1]**: ready→`{ok:true}`; error→`{ok:false}`; таймаут 8s; not-configured; «поздний» handle всё равно закрыт. *Ловит:* двойной settle и утечку «late-handle». - **Integration:** нет (всё закрывается unit-тестами с фейковым `ws`). - **E2E:** нет (дублировал бы замоканный `openSession`; недетерминирован). - **НЕ тестировать:** `isUrlAllowed`/IP-диапазоны (покрыто `ssrf-guard.spec.ts` на нижнем слое — только факт вызова guard); `AiSettingsService.resolve`, `describeProviderError` (чужие модули); типы/интерфейсы. ### Модуль B — server-realtime-gateway (`realtime/ai-realtime.gateway.ts` + controller/module) - **Кандидаты на чистые функции:** `incr`/`decr` (:45/:51, module-private), `canConnect(...)` (логика :101–142), `toBuffer` (:230). - **Unit-тесты добавить:** - `decr`/`incr` **[R-GW1]**: delete-at-zero; `decr` по отсутствующему ключу НЕ уходит в минус и не создаёт фантомный слот. *Ловит:* утечку/недосчёт слота concurrency. **Высший ROI.** - `canConnect` **[R-GW2]**: оба флага → allow; `dictation` xor `dictationRealtime` → deny; нет `settings.ai` → deny; user-cap(1)/workspace-cap(5) с приоритетом сообщений; `>=` без off-by-one. *Ловит:* обход гейта, off-by-one. - `toBuffer`: Buffer/Uint8Array/ArrayBuffer → Buffer; строка/null → null. *Ловит:* краш/пересылку не-бинарных данных. - `handleStart` lifecycle (прямой `new` шлюза, мок сервиса/сокета): релей `onReady→ready`, `onInterim→interim{itemId,text}`, `onFinal`, `onClosed` чистит handle; double-start guard; `AiSttNotConfigured` vs `describeProviderError` (без утечки ключа/стека). *Ловит:* регрессии формы событий, двойной старт, утечку секрета. - `handleAudio`/`handleStop` guards: нет handle → нет `appendAudio`; Buffer → один append; не-бинарь → нет append. - **Integration-тесты добавить** (gateway + реальные TokenService/WorkspaceRepo + фейковый socket + module-singleton): - `handleConnection` auth: невалидный/просроченный/отсутствующий cookie-JWT → `error:Unauthorized`+`disconnect`, счётчик НЕ инкрементнут. *Ловит:* анонимный сокет, утечку деталей, занятый слот на отклонённой авторизации. - `handleConnection` gate+cap: гейт выкл → disconnect, счётчики чисты; cap превышен → отказ; **оба cap проверены до инкремента любого**. *Ловит:* «грязный» счётчик при отказе, асимметричный инкремент. - `handleDisconnect` no-leak: декремент обоих map; no-op если соединение отклонено до инкремента; идемпотентность двойного disconnect (не в минус). *Ловит:* классическую утечку слота / уход счётчика в отрицательное. **Высший ROI.** - **Contract:** `POST ai-chat/realtime/test` → admin-gate (CASL Manage Settings; не-админ → Forbidden, `testConnection` не вызван) + неизменная форма `{ok:true}|{ok:false,error}`. *Ловит:* обход admin-гейта, дрейф формы ответа. - **НЕ тестировать:** `ai-chat.module.ts` провайдеры, `@WebSocketGateway({...})` опции (DI/framework wiring); `socket.io`/`cookie`/`ws` сами по себе; `logger.error`. ### Модуль C — server-ai-settings & workspace (`integrations/ai/*`, `core/workspace/*`) - **Кандидаты на чистые функции:** `deriveRealtimeUrl` (дедуплицировано с модулем A — здесь НЕ дублируем); опц. вынос `applyAiSettingPatches(dto, before)` из `WorkspaceService.update`. - **Unit-тесты добавить:** - `AiSettingsService.update` merge/partial: DTO только с realtime-полями → в patch ровно эти ключи; DTO с `chatModel` → realtime-поля НЕ затёрты; пустой patch → repo не вызван. *Ловит:* выпадение поля из allowlist (тихо не сохранится), затирание чужих полей. **Рефактор не нужен.** - `AiSettingsService.resolve` fallback ключа: `sttApiKeyEnc` есть → дешифр; нет → fallback на chat `apiKey`. *Ловит:* регрессию «realtime-ключ переиспользует sttApiKey» (поломка диктовки у workspace с одним chat-ключом). - `UpdateWorkspaceDto.aiDictationRealtime`: `true`/`false` ок; не-boolean → ошибка; пропуск ок. *Ловит:* опечатку декоратора (`@IsString` вместо `@IsBoolean`). ⚠️ **Это закрывает обманчивый «100 %» line-coverage** — сейчас поведение поля не проверено. ROI средний (регресс-пин). - **Integration:** `WorkspaceService.update` ветка `aiDictationRealtime` **[R-WS1]**: верный ключ настройки `'dictationRealtime'`; audit-diff; поле удалено из dto до generic-update (иначе запись несуществующей колонки). - **Contract (security):** консистентность SSRF — `UpdateAiSettingsDto.sttRealtimeBaseUrl` **намеренно** только `@IsString()` (нет `@IsUrl()`), а блокировка приватных/loopback адресов — в connect-time `isUrlAllowed`. Тест прогоняет `http://169.254.169.254/v1` → http-эквивалент → `isUrlAllowed` ⇒ `ok:false`. *Ловит:* самый тяжёлый класс — «закалили» DTO с иной allow/deny-семантикой ИЛИ убрали connect-time проверку ⇒ живой SSRF (metadata-endpoint/внутренние сервисы). **Высший ROI.** - **НЕ тестировать:** allowlist `WorkspaceRepo.updateAiProviderSettings` (покрыт unit-тестом `update` сверху); тривиальные `@IsString` поля `sttRealtimeModel`/`sttRealtimeBaseUrl`; field-agnostic admin-гейт (уже покрыт `workspace-update-gate.spec.ts`); типы `ai.types.ts`; ORM-хелперы Kysely. ### Модуль D — client-realtime-dictation-core (`features/dictation/**`) - **Кандидаты на чистые функции (главное — DSP заперт в браузерном классе):** `floatToPcm16LE` (:104–120) **[R-DSP1]**; resampler-ядро (:54–89) **[R-DSP2]**; frame-аккумулятор (:16/:93–99) **[R-DSP3]**; reducer interim/final/cancel/stop (:320–357) **[R-HOOK4]**; `baseLanguageSubtag` (:374) **[R-HOOK5]**; `mapGetUserMediaError` (:207–219, дублирует `use-dictation.ts`) **[R-HOOK6]**. - **Unit-тесты добавить:** - `floatToPcm16LE` **[R-DSP1]**: +1→32767, −1→−32768, 0→0; clamp +2/−2 без переполнения; LE-порядок байт; property-тест «выход всегда в [−32768,32767]»; NaN/Inf. *Ловит:* переполнение/wrap, endianness, off-by-one. **Высший ROI.** - resampler-ядро **[R-DSP2]**: 48k→24k ≈ половина; ratio=1 ≈ passthrough; 44.1k→24k дробный; **бесшовность cross-quantum** (разбиение на два `process()` == целому); инвариант «нет OOB-чтения». *Ловит:* щелчки на стыках, OOB, дрейф. - frame-аккумулятор **[R-DSP3]**: ровно 3600 сэмплов → один кадр 7200 байт; 3599 → ничего; остаток переносится. *Ловит:* неверную границу ~150 мс, потерю/дубль хвоста. - reducer **[R-HOOK4]**: interim замещает interim; final trim + отброс пустых; cancel отбрасывает pending и игнорирует последующее; аудио буферизуется до `ready` и сливается по порядку; `closed` после `stop` — no-op. *Ловит:* потерю/дубль final, утечку interim после cancel, нарушение порядка. - `baseLanguageSubtag` **[R-HOOK5]**: `en-US→en`, `en→en`, `''→undefined`. *Ловит:* отправку region-locale, который STT отвергает. - `mapGetUserMediaError` **[R-HOOK6]**: NotAllowed/NotFound/NotReadable/fallback. *Ловит:* неверный UX-текст; чинит дубль с batch-хуком. - **Integration-тесты добавить** (jsdom + мок `socket.io-client`): - `RealtimeDictationClient` (unit-класс): декодирование `ready/interim/final/error/connect_error` с `?? ''`; error-once guard; `disconnect` снимает листенеры и сбрасывает флаг; `?.`-гварды при отсутствии сокета. - `RealtimeMicButton` (component, мок хука): status `recording`→stop, иначе→start; переход recording→idle вызывает `onInterim("")` ровно раз. *Ловит:* «зависший» partial-текст в редакторе после остановки. - **E2E (ручной, 1 шт.):** клик-микрофон → речь → interim стримится → пауза → final коммитится → стоп → сессия закрыта, индикатор off. *Покрывает:* реальный AudioWorklet+getUserMedia+живой upstream — не воспроизводимо мок-ами. - **НЕ тестировать:** рантайм `AudioWorkletProcessor`/`process()`/`registerProcessor`; реальные `getUserMedia`/ `MediaStream`/`AudioContext`/`AudioWorkletNode`; `socket.io-client`; `getAudioContextCtor` webkit-проба; `audio-worklet.d.ts`; пред-существующая batch-диктовка. ### Модуль E — client-editor-interim-decoration (`editor/extensions/dictation-interim/**`) - **Кандидаты на чистые функции:** `applyInterimMeta(meta, prev)` (:33–44) **[R-EXT1]**; `clampRange(from,to,size)` в `dictation-group.tsx` (:34–35) **[R-EXT3]**. - **Integration-тесты добавить** (headless ProseMirror/Tiptap в jsdom): - **REGRESSION GUARD (высший ROI):** после `setDictationInterim`/`clearDictationInterim` транзакция имеет `docChanged===false`, `steps.length===0`, `doc` равен прежнему. *Ловит:* «полезную» вставку interim в документ — тот самый класс дефектов, ради предотвращения которого PR и существует (загрязнение Yjs/history). - **History guard:** с `@tiptap/extension-history` — серия interim-обновлений не добавляет undo-шагов; undo откатывает напечатанный текст, не interim. *Ловит:* interim как undoable-шаг. - `decorations`: пустой текст → `null` (нет виджета); непустой → один widget `contenteditable=false` у каретки; декорация маппится при правках (трекает `selection.head`, а не устаревшую позицию). - **Unit-тесты добавить:** `applyInterimMeta` **[R-EXT1]** (merge/passthrough); `DictationGroup` — `clampRange` off-by-one **[R-EXT3]** + гварды `editor.isDestroyed` (set/clear только на живом редакторе; realtime vs batch gating). - **E2E:** нет (доминируется integration-гвардами). - **НЕ тестировать:** регистрация в `extensions.ts`; boilerplate `DictationInterim.create`; внутренности `Decoration.widget`/`DecorationSet` (ProseMirror); инлайн-стили виджета (кроме `contenteditable=false`); `dictationInterimKey`. ### Модуль F — client-ai-settings-ui & chat (`workspace/.../ai-provider-settings.tsx`, `ai-chat/chat-input.tsx`) - **Кандидаты на чистые функции:** `resolveCardStatus`/`isEndpointConfigured`/`resolveKeyField` — **уже экспортированы**; `resolveUrl` (:89) **[R-UI1 экспорт]**; merge interim/final в `chat-input.tsx` (:84–93) **[R-UI2 вынос]**. - **Unit-тесты добавить:** - `resolveKeyField`: buffer→`{set,value}`; cleared+пусто→`{set:'' }`; нетронуто→`{set:false}`; buffer+cleared→buffer. *Ловит:* утечку/потерю write-only секрета (`sttApiKey`). **Высший ROI (security).** **Рефактор не нужен.** - `isEndpointConfigured`: model+base→true; наследование chat-base; whitespace-base как пустой. *Ловит:* ошибки trim/предиката. - `resolveCardStatus`: 4 исхода, особенно «enabled но не configured → warning». *Ловит:* скрытие реального мисконфига. - `resolveUrl` **[R-UI1]**: trailing-slash, fallback-цепочка. *Ловит:* двойной/потерянный слеш в hint. - `appendFinalToDraft(draft, final)` **[R-UI2]**: пусто+final→final; непусто→`"draft final"` (один разделитель); накопление слева-направо. *Ловит:* лидирующий/двойной пробел/потерю текста. **Высший ROI (ядро chat-диктовки).** - interim-transition **[R-UI2]**: `onInterim` ставит dimmed-tail; `onFinal`/`send`/`cancel` чистят interim. *Ловит:* «зависший» ghost-хвост или двойной коммит. - **Contract:** `testRealtimeConnection` (мок `api`): POST на **верный** маршрут `/ai-chat/realtime/test` (не префикс `/workspace/ai-settings`), распаковка `{ok}`-конверта, без тела запроса. *Ловит:* регрессию неверного URL (легко скопировать соседний маршрут) и дрейф формы. - **НЕ тестировать:** `workspace.types.ts`/типы сервиса; тонкие react-query-обёртки (`useTest...Mutation`, `useAiSettingsQuery`); неизменённые passthrough-`api.post`; `handleToggle*` (оптимистичная обвязка + Mantine/jotai); `StatusDot`/полный render компонентов (tautological snapshot); `translation.json`. --- ## 3. Сквозные аспекты - **Contract-тесты сервис↔сервис:** (1) SSRF-консистентность DTO↔connect-time guard (модуль C); (2) admin-gate + форма `{ok}` realtime-test (модуль B/F, серверная сторона владеет формой — клиентский контракт дедуплицирован сюда). - **Property-based тесты:** идеально ложатся на вынесенный DSP — `floatToPcm16LE` (выход всегда в int16-диапазоне), длина ресемпла, отсутствие OOB при случайных размерах квантов/частотах. - **Дымовые/нагрузочные:** микро-нагрузка на concurrency-caps шлюза (N>cap соединений отклоняются и НЕ держат слот) — покрывается integration-тестом disconnect-no-leak; отдельный нагрузочный не требуется. - **Test-data factories:** фабрика `RealtimeSettings`/`ResolvedAiConfig` (модули A/C); фейковый `WebSocketLike` (EventEmitter с `readyState/send/close/on`) для модуля A; фейковый `Socket` (`data/emit/disconnect` как jest-моки) для модуля B; стабы `getUserMedia/AudioContext/AudioWorkletNode` для модуля D. ## 4. Обнаруженные антипаттерны - **Скрытые побочные эффекты, мешающие unit-тестам:** DSP-математика заперта в `AudioWorkletProcessor` (`pcm16-worklet.ts`); state-machine диктовки размазан по ref/effect (`use-realtime-dictation.ts`); merge-логика чата в JSX-замыканиях (`chat-input.tsx`). → разблокируется выносом (см. §5). - **God-объекты:** `ai-provider-settings.tsx` — 1109 строк; `WorkspaceService` — 17 зависимостей в конструкторе (`workspace.service.ts:57`). - **Нетестируемое module-singleton состояние:** `sessionsPerUser`/`sessionsPerWorkspace` (Maps, `ai-realtime.gateway.ts:45+`) без reset-хука → риск порядко-зависимых тестов между файлами. → требует **[R-GW1]**. - **Тавтологический смоук:** `workspace.service.spec.ts:33` (`expect(service).toBeDefined()`) даёт ложную уверенность в покрытии — переопределяется integration-тестом ветки. - **Обманчивое покрытие:** `update-workspace.dto.ts` 100 % line, но валидация нового boolean не утверждена (V8 считает строки декларации покрытыми при инстанцировании). - **Security-запахи (нужны решения автора, см. §5):** - `sttRealtimeBaseUrl` не валидируется как URL при сохранении (только connect-time guard) — расходится с MCP-путём (`mcp-servers.service.ts:138`, где есть save-time гейт). - `deriveRealtimeUrl` при непарсимом base **молча** уходит на `api.openai.com` → опечатка в self-hosted endpoint отправит аудио и workspace-ключ на дефолтный публичный endpoint. - `appendAudio` молча отбрасывает PCM до открытия сокета (потеря данных, не баг по дизайну — подтвердить). - **Нестабильные/порядко-зависимые тесты:** существующих не обнаружено — текущие спеки чистые (свежий `acc` в `beforeEach`, без sleeps и общего состояния). Для новых хук-тестов — только fake timers, без реальных `sleep`. ## 5. Необходимые рефакторинги перед написанием тестов | Реф. | Где | Блокирует | |---|---|---| | **R-SVC1** | `ai-realtime.service.ts:175` — инъекция `wsFactory` вместо хардкода `new WebSocket(...)` | все unit `openSession`/`appendAudio`/`stop`/таймеры/`testConnection` (7 тестов) | | R-SVC2 (опц.) | экспорт `rawDataToString` | прямой unit (иначе через маршрутизацию) | | **R-GW1** | экспорт `incr`/`decr` или `SessionCounters` с reset | unit счётчиков; устранение порядко-зависимости cap-тестов | | **R-GW2** | вынос чистого `canConnect(userId, wsId, settings, counts)` | unit гейта/cap (7 сценариев) | | R-WS1 | вынос `applyAiSettingPatches` / thin-slice `WorkspaceService.update` | integration ветки `aiDictationRealtime` | | **R-DSP1/2/3** | вынос `floatToPcm16LE` / resampler / framing из `pcm16-worklet.ts` в инлайнимый бандлером модуль | unit DSP (3) — высший ROI; ⚠️ worklet-scope запрещает import, нужен инлайн | | R-HOOK4/5/6 | вынос reducer / `baseLanguageSubtag` / общий `mapGetUserMediaError` | unit логики хука (3); дедуп с batch | | R-EXT1/3 | вынос `applyInterimMeta` / `clampRange` | unit (2) | | R-UI1/2 | экспорт `resolveUrl`; вынос merge-хелперов чата | unit (3) | | **Инфра** | добавить `@vitest/coverage-v8` в `apps/client` | измеримость клиентского покрытия (сейчас невозможно) | ## 6. План внедрения (по фазам) - **Фаза 1 — security & утечки (наивысший ROI, минимум рефактора):** SSRF-контракт (C); gateway auth/cap/disconnect- no-leak (B, рефактор не нужен) + `decr` после R-GW1; `resolveKeyField` (F, без рефактора); `parseUpstreamEvent` добор + `deriveRealtimeUrl` (A, без рефактора). Ловит обход авторизации, утечку слотов, утечку ключа, SSRF. - **Фаза 2 — ядро realtime-сервиса:** R-SVC1, затем 7 unit `openSession`/lifecycle/таймеры/`testConnection` (A). Фиксирует контракт upstream и отсутствие утечек сессий/таймеров. - **Фаза 3 — клиентский DSP + reducer:** добавить `@vitest/coverage-v8`; R-DSP1/2/3 + R-HOOK4 → property/edge-тесты PCM/resample/framing/reducer (D). Самый дешёвый и детерминированный выигрыш покрытия на клиенте. - **Фаза 4 — инварианты редактора и чата:** headless-ProseMirror regression/ history guards (E); R-UI2 → `appendFinalToDraft`/interim-transition (F); `RealtimeDictationClient`/`RealtimeMicButton` (D). - **Фаза 5 — настройки/workspace + ручной e2e:** R-WS1 ветка `aiDictationRealtime`, `AiSettingsService.update/resolve`, DTO-boolean (C); один ручной mic-smoke (D). ## 7. Источники - Отчёты **6** аналитиков `module-testability-analyst` (по одному на модуль A–F). - Независимая сверка покрытия: `jest --coverage --coverageProvider=v8` (server, изолированный worktree @ `0b3d5955`) и `vitest run` (client); подтверждено 8/8 тестов `parseUpstreamEvent`, 0 % gateway, 0 % клиентской диктовки, отсутствие `@vitest/coverage-v8`. - **Фильтрация:** дедуп (`deriveRealtimeUrl` оставлен только в A; клиентский payload-контракт сведён к серверному; `mapGetUserMediaError` — один общий тест) — снято ~4; skip-list (DI-wiring, типы, react-query-обёртки, i18n, AudioWorklet/getUserMedia, snapshot) — отсеяно большинство публичных символов; отклонено как малоценное: `cardStatusLabel`-i18n, `shouldShowInterim`, `buildPayload` omit-vs-send, toggle-revert — ~4. Итог отклонено/дедуп: **8**. - **Бюджет пирамиды соблюдён:** unit 32/44 = 73 % (≥70 %); integration 8/44 = 18 % (≤20 %); e2e 1 (≤10); contract 3 (отдельный бакет). Каждый тест называет слой, цель (`файл:строка`), сценарии и класс дефекта.
Ghost added 1 commit 2026-06-21 17:15:53 +03:00
Implements all reviewer comments (code-review, red-team, and test-strategy
audit), accepting the recommended variants.

Server — realtime service (ai-realtime.service.ts):
- SSRF: pin the validated IP via a WebSocket `lookup` hook that re-checks every
  resolved address with isIpAllowed (mirrors external-mcp buildPinnedDispatcher),
  closing the TOCTOU/DNS-rebinding window; fix the misleading comment.
- no-silent-loss: on Stop, drain the in-flight segment (bounded 2.5s) and deliver
  the final via onFinal before closing instead of dropping the tail.
- fail-closed deriveRealtimeUrl: a non-empty unparseable base now THROWS (no
  silent api.openai.com fallback that would leak a self-hosted key); http://ws://
  bases rejected (plaintext key). Path normalization preserved.
- parseUpstreamEvent keys the accumulator by item_id+content_index so GA segments
  don't concatenate.
- inject a wsFactory seam for testing; also fix a latent bug — `import WebSocket
  from 'ws'` resolved to undefined at runtime (no esModuleInterop) -> import=require.
- unref idle/max/drain timers.

Server — realtime gateway (ai-realtime.gateway.ts, session-limits.ts):
- reject revoked/disabled users and inactive sessions (mirror jwt.strategy:
  findById+isUserDisabled + findActiveById) with NO counter increment.
- CSWSH: Origin allowlist (matching APP_URL, or no Origin for native clients)
  before auth, no increment.
- extract SessionCounters (delete-at-zero, never negative) + pure canConnect
  (both caps >= checked before any increment); document the per-process/in-memory
  cap caveat (single-replica only).

Client:
- dictation-group: realtime final now inserts at the captured rangeRef SNAPSHOT
  (not the live caret) and guards editor.isEditable; single-space separator.
- use-realtime-dictation/realtime-dictation-client: stop-during-acquisition tears
  down the mic (no leak / button reset); reconnect re-emits start (double-start
  guarded); interim ghost cleared on teardown; io() options de-duplicated.
- pcm16-worklet: flush the partial sub-frame tail on stop; one-pole anti-aliasing
  low-pass before 48k->24k.
- extract shared mic-capture (acquireMicStream/mapGetUserMediaError, used by batch
  + realtime), pure DSP (pcm16-dsp.ts), and the session reducer/baseLanguageSubtag;
  extract applyInterimMeta/clampRange/resolveUrl/appendFinalToDraft.

Tests + infra: +~150 server tests (deriveRealtimeUrl, parseUpstreamEvent branches,
openSession/lifecycle/timers/testConnection via fake ws, gateway auth/caps/no-leak,
realtime-test admin contract, AiSettings update/resolve, DTO boolean, SSRF deny)
and +~140 client tests (DSP property/edge, resampler continuity, framing, reducer,
mic-capture, RealtimeDictationClient/MicButton, ProseMirror interim regression +
history guards, appendFinalToDraft, resolveKeyField, route contract). Added
@vitest/coverage-v8. CHANGELOG [Unreleased] entry incl. the single-replica caveat.

Review: APPROVE WITH SUGGESTIONS (no critical/regression); applied the drain-timer
unref. Server tsc clean + 358 tests; client tsc clean + 201 tests; vite build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost force-pushed feature/streaming-dictation from 1095c5679f to 310b54a6da 2026-06-21 18:43:17 +03:00 Compare
Author
Owner
image.png вы там все дебилы что ли? нахуй отдельные поля для реалтайм диктовки? просто блядь сделайте переключатель, который включает реалтайм диктовку на существующих модели и эндпоинте и блокирует выбор протокола на openai, дизейблет селектор. нахуй вы два поля сделали, у вас что, может быть две диктовки в одной инсталляции? долбоебы.
<img width="893" alt="image.png" src="attachments/8d66766a-15db-4543-8350-d04c80fb6358"> вы там все дебилы что ли? нахуй отдельные поля для реалтайм диктовки? просто блядь сделайте переключатель, который включает реалтайм диктовку на существующих модели и эндпоинте и блокирует выбор протокола на openai, дизейблет селектор. нахуй вы два поля сделали, у вас что, может быть две диктовки в одной инсталляции? долбоебы.
202 KiB
Ghost added 1 commit 2026-06-21 20:07:28 +03:00
Found while live-testing the realtime dictation:

- 'already active' lockout (real bug): the per-user slot was tied to the
  connected socket lifetime and a stale/racing socket could leave the counter
  stuck, so a fresh mic start was rejected. Now per-user single-session is
  enforced purely by LATEST-WINS EVICTION — a new connect disconnects the user's
  prior socket and frees its slot synchronously — and the user counter no longer
  participates in the cap decision (it could only cause false lockouts). Also
  free the slot when a start fails to open. The per-workspace cap is unchanged.

- #737: drop the separate sttRealtimeModel / sttRealtimeBaseUrl settings — realtime
  dictation now reuses the existing STT model + base URL (the realtime WS endpoint
  is derived from it server-side). Removed the fields from the DTO, types, settings
  service, repo allowlist, and the settings UI. The STT 'Test endpoint' button is
  now a single context-aware button (probes the realtime WS endpoint when realtime
  is on, the batch endpoint otherwise), and the 'Request format' selector is
  disabled while realtime is on (realtime always uses the OpenAI Realtime protocol).

- no-silent-loss: parse the OpenAI
  conversation.item.input_audio_transcription.failed event (e.g. insufficient_quota,
  bad model) and surface its concrete reason to the client instead of dropping it
  silently — previously a per-item transcription failure produced 'no words' with
  no explanation.

Tests: realtime suites green (gateway latest-wins eviction, parser .failed surfacing,
ai-settings reuse-STT-model); server + client tsc clean; workspace vitest 37 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-21 20:47:57 +03:00
The live partial transcript was shown as a dimmed line under the chat textarea,
but each segment only flickers there for an instant before its final lands in
the draft — the preview was unreadable noise. Remove it: realtime partials are
no longer rendered separately; finalized segments are appended straight into the
draft (where the user actually reads them). Editor dictation (inline ghost at the
caret) is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost force-pushed feature/streaming-dictation from 1b9cf7a30c to c70dac79ad 2026-06-22 01:10:36 +03:00 Compare
vvzvlad closed this pull request 2026-06-22 21:07:29 +03:00

Pull request closed

Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#118