К содержимому
subtl

Журнал изменений

Собран из журнала изменений проекта при сборке сайта. Записи приведены на языке оригинала.

  1. Stripe: в живом аккаунте Arcada LLC заведены три продукта Subtl (Subtl Pro, Subtl Pro BYOK, Subtl Unlimited, метки app=subtl) и шесть цен по прайсу из 01-product.md §6 — $19/$159, $9/$76, $39/$328. Идентификаторы цен прописаны в конфигурации, тест проверяет, что каждый тариф резолвится в обе стороны. Stripe Tax в аккаунте уже активен, адрес компании задан, автоналог включается в Checkout кодом. Не сделано: секретный ключ API нельзя получить программно — его нужно скопировать из дашборда; конфигурацию портала коннектор создать не даёт (нет права PostBillingPortalConfigurations), пока используется дефолтная конфигурация аккаунта; вебхук создаётся после появления публичного адреса.

  2. BYOK: ключ пользователя запечатывается на клиенте в X25519-конверт, открывается только в памяти на время запроса, не логируется и не хранится в открытом виде; при отклонении ключа стадия падает с byok_provider_rejected и не переключается на наш ключ (проверено вживую: 10/10 сегментов отклонены при заведомо неверном ключе, при рабочем — все стадии прошли на ключе пользователя). Учёт токенов: каждый вызов модели пишет subtl_tokens_total{provider,model,stage,kind}. Первое измерение (46 с, пресет с документом): 22 475 prompt и 2 014 completion токенов, из них 68 % prompt приходится на суфлёра; cached равен нулю везде — кеширование промпта, на которое опирается расчёт себестоимости в docs/05 §6.4, фактически не срабатывает. Причина выяснена замером и оказалась не той, что предполагалась: префикс стабилен (1 740 символов, одинаковые байты во всех вызовах), но меньше минимального порога кеша Gemini. Разбор обоих сценариев — в docs/02 §4.

  3. Phase 3, Stream B: BYOK (byok_proxy). Сервер публикует X25519-ключ (GET /byok/pubkey, alg = x25519-hkdf-sha256-aes256gcm, конверт SBK1 || epk || nonce || AES-256-GCM), клиент шифрует свой ключ провайдера и присылает шифротекст (PUT /byok/keys), сервер расшифровывает на каждый запрос, только в памяти; в таблице byok_keys нет колонки для plaintext, наружу отдаются имя провайдера и hint — последние 4 символа, посчитанные при записи. Приватный ключ сервера — BYOK_SERVER_PRIVKEY (без него в dev генерируется эфемерный, с громким предупреждением). Маршрутизация по стадиям, а не по сессии: у пользователя с одним ключом Gemini на его счёт уходят STT, перевод и классификация, суфлёр остаётся на нашем Claude. Ошибка на ключе пользователя (401/402/403/429 и 400 c упоминанием ключа — Gemini отвечает именно так) → Error{byok_provider_rejected, retryable:false} с именем провайдера, без фолбэка на наш ключ; прочая ошибка допускает фолбэк только на другой ключ того же пользователя. pro_byok без единого ключа получает byok_key_required на Hello вместо бесплатного доступа к нашим провайдерам. Desktop: Settings → Свои ключи (password-поле, маска, удаление), ключ уходит в Keychain и шифротекстом на сервер, в settings.json и в логи не попадает; веб-кабинет — только статус и удаление. Тесты, которые сломались бы при утечке: plaintext не появляется в захваченном выводе zerolog (TestAPILogsNeverContainTheKey, TestKeyNeverReachesLogsOrMessages), ни в одной колонке byok_keys (to_jsonb-обход строки), ни в сообщении Error (провайдер-фейк возвращает ключ в теле ошибки), ни в telemetry allow-листах (Rust и TS); шифротекст не открывается другим ключом сервера, повреждённый — не открывается вовсе; конверт, запечатанный Rust-клиентом, открывается Go-сервером (зафиксирован как фикстура). Сквозная проверка через реальный API: с заведомо неверным «ключом пользователя» 10/10 сегментов дали byok_provider_rejected (HTTP 400, retryable=false) и ни одного транскрипта — фолбэка на наш ключ не произошло; с рабочим ключом в логе видно byok: stage running on the user's own key для stt/translate/classify/suggest, транскрипты и переводы вернулись, subtl_byok_stages_total{stage,provider,outcome} посчитал стадии; grep по всему логу сервера: ни ключа, ни его хвоста, ни строки AIzaSy. byok_direct остаётся запланированным (docs/05 §7) — ключ уже лежит в keychain клиента, список провайдеров и таблица общие, ничто из сделанного ему не мешает.

  4. локальное распознавание (Phase 3, Stream A): whisper.cpp с Metal в core-rs под фичей local-stt, маршрутизация во время выполнения (вместо аудио уходит готовый LocalTranscript), загрузка моделей с проверкой SHA-256 и политика auto (локально только на Apple Silicon с 16 ГБ и скачанной моделью). Замер на M1/8 ГБ, та же 46-секундная фикстура, 40 сегментов за 4 прогона: p50 334 мс, p90 422 мс, p95 707 мс против 1453 мс p50 у облачного пути при равной точности — примерно вчетверо быстрее и $0 за час. large-v3-turbo на этой машине медленнее реального времени (5.3 с на сегмент), поэтому по умолчанию предлагается small-q5_1; на машине с 16 ГБ turbo не измерялся.

  5. Phase 3, Stream A: распознавание речи на устройстве. packages/core-rs, feature local-stt (по умолчанию выключена) — whisper-rs 0.16 с Metal на macOS: LocalStt::load(&Path) + transcribe(&[f32], lang_hint) -> Transcription{text, lang}, 16 кГц моно на входе, один экземпляр на весь звонок, потоков = физических ядер (максимум 8). Маршрутизация рантаймовая (Engine::set_local_stt), не compile-time: с распознавателем сегмент уходит одним LocalTranscript, кадры Audio и SegmentEnd не отправляются вообще; без него поведение прежнее (тесты packages/core-rs/tests/local_stt_routing.rs). Распознавание идёт в отдельном потоке на канал, чтобы не блокировать колбэк захвата. Хост: команды models_status / models_download{id} (события model_download с прогрессом) / models_delete{id}, скачивание официальных ggml-весов в app data dir с проверкой размера и SHA-256, в инсталлер модель не кладётся; sttMode = auto | cloud | local (по умолчанию auto: локально только на Apple Silicon с ≥ 16 ГБ и скачанной моделью), политика — чистая функция stt_route::resolve с тестами; Settings → AI показывает режим, что он даёт на этой машине, список моделей с загрузкой и фразу о том, что остаётся на устройстве (EN/RU). Измерено (spikes/03-stt-providers/README.md, M1 / 8 ГБ, та же 46-секундная фикстура и тот же сервер, WER — методом wer.go, сверено с ним до шестого знака): small-q5_1 — p50 1188–1228 мс, p95 1290–1616 мс, WER 0.127; облако (gemini-2.5-flash-lite) — p50 1453–1489 мс, p95 1775–2125 мс, WER 0.119; large-v3-turbo-q5_0 на этой машине 5.3 с на фразу, то есть медленнее реального времени, поэтому по умолчанию предлагается small. Цель «p95 < 1 с» не достигнута: она берётся только с урезанным окном энкодера (with_fit_audio_ctx, p50 313–345 мс, p95 716–1091 мс), а на large-v3-turbo это же урезание заставляет модель повторять фразы и удваивает WER (0.127 → 0.288) — поэтому опция выключена по умолчанию. Себестоимость в 02 §4 не пересчитана: нет измеренных счётчиков токенов, оценка помечена как оценка. Не проверено: large-v3-turbo на машине с ≥ 16 ГБ, Windows (Vulkan/CUDA), реальный звонок в приложении.

  6. качество суфлёра: в промпт добавлено правило против заполнителей — модель предлагала произнести «we have worked together for X years», когда факта нет в документах; теперь она обязана сказать, что уточнит, или задать вопрос. Разброс числа автоподсказок (от 1 до 6 на одном и том же аудио) перестал быть догадкой: каждое решение классификатора пишется в лог и в метрику subtl_classify_verdicts_total{kind,suggested}. Замер на фикстуре: 5 подсказок из 9 реплик, пропуски обоснованы — смолток и утверждения без вопроса.

  7. наблюдаемость: события воронки в PostHog (десктоп и веб) с allow-list полей — свободный текст и содержимое реплик отбрасываются билдером, а не дисциплиной; Sentry на паники и 5xx; счётчики воронки и один JSON-лог на запрос с request_id; профиль monitoring в compose (Prometheus + Loki + Grafana + алерты). Всё выключено без ключей. Форма waitlist на лендинге подключена к POST /waitlist (проверено: 202 с сохранением источника и языка, 400 invalid_email на неверный адрес).

  8. Наблюдаемость (закрывает измеримую часть Phase 2): канонический список событий воронки определён по одному разу на платформу — apps/desktop/src-tauri/src/telemetry/events.rs и apps/web/src/lib/analytics.ts, таблица «событие → где срабатывает → свойства» в 10-go-to-market.md §3. Оба билдера payload отбрасывают ключи вне allow-листа и строки, не похожие на машинный токен, поэтому транскрипт/перевод/подсказка/e-mail в событие не попадают (тесты unknown_and_free_text_fields_are_stripped, strips unknown keys and free text). Desktop: модуль telemetry батчит события в PostHog /capture (флаш каждые 30 с и на выходе, очередь 500, сброс при privacy.telemetry=false), distinct_id — id пользователя после входа, иначе анонимный из стора настроек (перегенерируется при выходе); Sentry без встроенной panic-интеграции — свой хук шлёт только место и тип паники, before_send вычищает message/breadcrumbs/hostname; React error boundary → команда report_error. Сервер: sentry-go на паники и 5xx, счётчики subtl_sessions_total{plan}, subtl_session_seconds, subtl_active_sessions, subtl_suggestions_total{trigger}, subtl_provider_requests_total/subtl_provider_errors_total{provider,stage}, subtl_quota_exceeded_total, subtl_stripe_events_total{type}, subtl_http_requests_total{route,status}; zerolog переведён на один JSON-объект в строке с request_id в каждой записи запроса. Web: снипет PostHog за NEXT_PUBLIC_POSTHOG_KEY, до согласия persistence: "memory" (без cookie), минимальная полоса согласия, на юридических страницах аналитики нет. Infra: профиль monitoring в compose (Prometheus + node-exporter, Alertmanager, Loki, promtail, Grafana с провиженингом и дашбордом subtl-overview), правила алертов из 02 §9 и runbook docs/runbooks/observability.md. Всё инертно без ключей: без POSTHOG_KEY/SENTRY_DSN нет ни очереди, ни клиента, ни сетевых вызовов — одна INFO-строка на старте (проверено запуском сервера без ключей и с POSTHOG_KEY=test SENTRY_DSN=). Не проверено вживую: реальная доставка в PostHog и Sentry (нет ключей), Telegram-алерты (нет бота), стек мониторинга на VPS (машины нет).

  9. Приёмка Phase 2 (subtl-cli send --preset … --summary, 46 с фикстура, документ financial_model.txt 1271 Б → 1 чанк, ready за < 4 с): подсказки перестали быть общими и отвечают цифрами из документа — «$140K/мес, runway 22 месяца», «команда 12: 7 инженеров, 2 продажи, 1 CS, 2 основателя, найм 4+2»; на реплику «три фаундера» суфлёр поправил по документу («сооснователей двое»); на вопрос о ценах Enterprise ответил «уточню после звонка» вместо выдуманной цифры (правило из spike 05 работает). /meetings/:id/end вернул саммари и minutes_billed: 1. Латентность: перевод (первый фрагмент) p50 561 / p95 670 мс, суфлёр TTFT p50 721 / p95 753 мс, STT p50 1721 / p95 3503 мс (разброс вырос — Gemini делит квоту с эмбеддингами и суфлёром; ключ Groq остаётся главным способом это починить). Не проверено вживую: Stripe (нет ключей), Windows (нет машины), обновления (нет ключа подписи).

  10. Phase 2, день 1: сервер — файлы контекста (PDF/DOCX/TXT/MD/CSV → чанки 1600/240 → эмбеддинги Gemini 768d в pgvector), RAG top-5 в суфлёре, саммари встречи на POST /meetings/:id/end, Stripe Checkout/Portal/webhooks с идемпотентностью, /waitlist, /me/export, DELETE /me, /devices; desktop — история в SQLite с FTS и экспортом, стор настроек, авто-детект звонка, updater (ждёт ключ подписи владельца), Windows-адаптер WASAPI (кросс-компиляция под x86_64-pc-windows-gnu + CI-джоб, вживую не запускался); web — 31 статическая страница: лендинг EN/RU, программные /for/{app} (пока noindex до реальных скриншотов), сравнение с Cluely, /pricing, /security, /changelog, юридические страницы с баннером «черновик», кабинет с входом по коду, подпиской, пресетами, файлами, устройствами и удалением аккаунта.

  11. Phase 1 (macOS MVP), день 1: сервер — auth по 6-значному коду (magic code, Resend; dev: код в логе), JWT access/refresh с ротацией, Postgres-хранилища (users, entitlements, devices, presets, meetings), миграции при старте, пайплайн STT → перевод (Gemini Flash, стрим по предложениям) → классификатор (Flash-Lite, таймаут 1200 мс по замеру) → суфлёр (Claude Haiku с кешем, fallback Gemini), квоты Free 40 мин с Quota/quota_exceeded; desktop — Swift static lib SubtlCapture (SCK) + cpal-микрофон + тест звука + разрешения, все экраны 06-ux-spec (онбординг, Home, контекст, Live, история, настройки) на EN/RU, оверлей stacked/bar/sidebar со всеми состояниями, JWT в Keychain, трей, ⌥⌘S/Space/L/P; core-rs — pause/resume/regenerate. Сквозной прогон (subtl-cli → WS → сервер, 46 с фикстура, JWT): 10/10 транскриптов, 10/10 переводов, 6 подсказок по автоклассификации; STT p50 1594 мс (Gemini flash-lite, узкое место), перевод первый фрагмент p50 621 / p95 865 мс, суфлёр TTFT p50 695 / p95 839 мс. Исправлено: лимит полосы 320 кбит/с срезал pre-roll и догоняющие буферы (теперь 4 Мбит/с как защита от abuse); JWT_SECRET обязателен для стабильных сессий между рестартами. Не проверено в этой сессии: реальный захват звука в приложении (нужен клик TCC у владельца), звонки с людьми, 5 внешних тестеров.

  12. spike 06 выполнен на реальной оболочке apps/desktop: transparent + alwaysOnTop + click-through + set_content_protected работают вместе; оверлей не виден в screencapture (контроль без защиты — виден), CPU 0.25 % при стриме текста, 0.14 % в простое (критерий ≤ 3 %). Шаринг в Zoom/Meet и click-through — проверка владельцем.

  13. Phase 0 (день 2): Rust собран локально, 15 тестов зелёные; DoD-прогон выполнен: 10/10 сегментов транскрибированы через core-rs → WS → Go → Gemini STT, gemini-2.5-flash-lite p50 ≈ 1.56 с, p95 1.77–2.03 с (на границе критерия 2 с, см. spikes/03); spike 07 — Silero F1 0.987 (PASS), энергетический VAD 0.75; в сегментатор добавлен pre-roll 300 мс (без него терялись первые слова фраз); исправлен контракт ws_url (сервер отдаёт полный адрес); промпт Gemini STT без таймстампов.

  14. CI зелёный на main (run 34140599194): rust fmt/clippy/test, go, ts, tauri-macos debug build с иконками из brand/mark.svg; stealth через WebviewWindow::set_content_protected.

  15. v0.4 Phase 0 (день 1): монорепо arturayupov/subtl (pnpm + cargo + go), CI; packages/protocol (session.proto + SegmentEnd/Latency, TS/Go/Rust codegen, flags); apps/server Go (health, dev-auth, /meetings, WS-сессия, whisper_http + gemini_stt, тесты зелёные); packages/core-rs (VAD-сегментатор, ресемплер, WS-клиент с буфером 30 с, Engine), tools/subtl-cli, spikes/07-vad — собраны и протестированы в CI (GitHub Actions, macOS-15), локально не собирались (диск); apps/desktop Tauri 2 (main + overlay, click-through, sharingType=.none, ⌥⌘S/⌥⌘Space, demo-режим), apps/web заглушка; packages/ui токены, packages/prompts v1 со snapshot-тестами; infra compose + миграция. Spikes: 01 собран, TCC-блок без интерактива; 03 Gemini STT WER 0 на 5/7, 1.1–2.0 с/фраза; 04 Gemini Flash thinkingBudget:0 p50 718 мс, Haiku 1182 мс; 05 Haiku TTFT p95 922 мс без кеша, 3/30 производных цифр. Не измерено: Groq/Deepgram/DeepL (нет ключей), OpenAI (нет кредитов), DoD p95 (Rust не собран).

  16. v0.3: развилки README закрыты (гибридная монетизация, macOS первым, транскрипты локально, Stealth в Pro); пакет перенесён в docs/ монорепо subtl, CLAUDE.md в корне; убраны дубли; 01 §6 приведён к решению по Stripe.

  17. v0.2: имя Subtl / subtl.me; дизайн A (тёмное стекло), Manrope, mint; Free 40 мин, годовые −30 %; языки лендинга 3 волны; добавлены 08–11, brand/.

  18. v0.1 пакет спецификаций создан.