# Архитектура Обзор: как сложено и где что работает. **Поведение системы здесь не описывается** — нормативно оно живёт в `openspec/specs/`. Места, где оно всё-таки описано, помечены маркером долга и переезжают туда первой же задачей, которая их трогает. Документ описывает **сегодняшнее** устройство. Куда проект идёт — в [passport.md](passport.md) и в [tasks/BACKLOG.md](../tasks/BACKLOG.md); что из этого ещё не решено — в разделе «Открытые вопросы». Заведены четыре capability, и все нормируют **поведение сервиса** для его потребителей. Инструмент, которым сервис собирают, спеками не нормируется вовсе: у набора проверок и сборки другой потребитель — тот, кто собирает, — и решением от 2026-08-13 его нормы живут в самих шагах, их проверках и [conventions/go-linters.md](conventions/go-linters.md). - [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие входов**: приём и опрос за сессией, имя отправителя не доходит ни до хранилища, ни до журнала, метка метрики несёт только известное расширение, а незаданный вход Telegram не мешает подъёму. Задачи `http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11, `pocketbase-storage` и `oidc-login` 2026-08-12, `local-run-without-telegram-token` 2026-08-13. Приём из Telegram по существу — кто допущен и как забирается запись — здесь по-прежнему не описан; - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват задачи и срок его протухания, число попыток, состояние «мертва», пауза перед повтором и недоставленный ответ отправителю: задачи `errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12 и `local-run-without-telegram-token` 2026-08-13. Переходы состояний и отмена контекста посреди шага остаются долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки; - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` 2026-08-12; - [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются открытыми. Задача `oidc-login` 2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший видит всё, что видел прежде аноним. Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в коде. Задача, которая его трогает, дописывает спеку своей capability. ## Принципы - **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и делят одну базу. Отдельного воркер-процесса нет намеренно. - **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость захвата и порядок выборки нормирует [pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим». Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11, [ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение кандидатов в [research/job-queue.md](research/job-queue.md). - **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда задача возвращается в работу, нормирует [pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается в работу»; здесь это принцип письма шага, а не описание поведения. - **Ядро зависит от интерфейсов.** `internal/service` знает только `internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в `main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и они же держат обратные направления: транспорты не знают друг о друге, адаптер не знает ни ядра, ни транспортов. *Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http` импортирует `adapter/repo/pocketbase`, потому что HTTP-поверхность и есть роутер этого хранилища, а не наш сервер поверх него. Правила на это направление нет намеренно. ## Компоненты Каждый — строкой со ссылкой на capability, а не пересказом её требований. | Компонент | Где | Что делает | | --- | --- | --- | | Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу | | HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи | | Воркеры | `internal/controller/worker` | Крутят по одному шагу конвейера, опрашивая базу | | Сервис расшифровки | `internal/service` | Конвейер: приём, конвертация, распознавание, отдача результата | | Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности | | Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit | | Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам | | Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом | | Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций | | Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки задачи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» | Конвейер: `created` → `converted` → `transcribe` → `done` либо `failed`. Три воркера двигают по одному переходу, каждый опрашивает базу раз в секунду. Что делает задача, исчерпавшая попытки, нормирует [pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние «мертва»». ## Внешние границы и форматы - **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения. Файл скачивается по ссылке `file.Link(token)` запросом с контекстом, клиентом самого бота. Клиента заводит единая точка `internal/adapter/telegram`: токен стоит в пути каждого обращения, и снятие адреса с отказа живёт там — [conventions/logging.md](conventions/logging.md), «Безопасность: что не логируем». Telegram не отдаёт файлы больше 20 МиБ — это потолок приёма из бота. - **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с `UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением. - **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель `deferred-general`, авторизация заголовком `Api-Key`. Распознавание асинхронное: запрос возвращает идентификатор операции, готовность опрашивается через `operation.api.cloud.yandex.net:443`, текст читается потоком. - **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`. ## Эксплуатация - **Где работает, что рядом, кто перезапускает:** один контейнер на личном сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом — обратный прокси, который публикует HTTP-порт наружу. - **Пустой токен бота нельзя разворачивать раньше образа, который его понимает.** Пустое значение стало объявленным режимом 2026-08-13; версии до неё роняли на нём старт с кодом 1 **до** открытия порта. Значит, откат образа при уже применённом пустом токене останавливает не бот, а весь сервис — вместе с HTTP и панелью. Порядок: сперва образ, потом конфиг; при откате — наоборот. Воспроизведено ревью кода на прежней версии. - **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает медленно» читается вместе с тем, что таймаута нет ни у одного обращения наружу — [database.md](database.md), «Настройки с числовым значением»: | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | --- | --- | --- | --- | --- | | Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняет только ответ «такого бота нет». Норму держит [intake](../openspec/specs/intake/spec.md), «Недоступный или незаданный вход Telegram не мешает подъёму» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | | Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» | | ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — | | Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — | - **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота или по сообщению об ошибке. Владелец — по метрике `transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера. Отдельного оповещения нет. - **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос, три воркера опрашивают базу вхолостую с паузой из [database.md](database.md), «Настройки с числовым значением». ## Единые точки проекта | Что | Где | | --- | --- | | Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа | | Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает | | Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` | | Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг | | Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния | | Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю | | Разбор конфигурации | `internal/config.LoadConfig` | | Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера | | Метрики | `internal/metrics`, префикс имени `transcriber_` | | Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` | Единых точек, которых **нет** и которые ожидались бы: идентификаторы генерируются вызовом `uuid.NewString()` по месту, отображения доменной ошибки в код HTTP-ответа нет — обработчик решает сам. Время из этого перечня ушло 2026-08-13: его читает `internal/clock`, и запрет держит линтер. ## Деплой Образ собирается по контракту роли `app_image`: `task image` даёт `transcriber:$BUILD_ID`, по умолчанию `transcriber:dev`. Реестр не участвует — образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой `inv pl -- transcriber` из `pet-project-server`. Сборка двухступенчатая, финальный слой — alpine с `ca-certificates` и `ffmpeg`, процесс работает под непривилегированным пользователем `transcriber`. ## Открытые вопросы - **Учётные записи.** Вход через OIDC решён и развёрнут 2026-08-12: провайдер — Authelia, ответ провайдера обрабатывает PocketBase, а не наш код ([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)), сессия живёт кукой `transcriber_session` и сама себя не продлевает. Норма — [access](../openspec/specs/access/spec.md), решения — [ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md) и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md). **Не решено одно:** как связываются пользователь Telegram и пользователь веба. Панель администратора при этом Authelia не закрывает: у неё свой пароль суперпользователя. - **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA, устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и сборкой Vite — 2026-08-11, [ADR](adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в [research/spa-framework.md](research/spa-framework.md). Тем же решением Node входит в гейт и слоем в сборку образа. Пишет это `spa-skeleton`; во что обходится слой Node в образе, не замерялось. Не решено, брать ли готовый набор компонентов. - **Уведомления.** Пользователь веба узнаёт о готовности только опросом. Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и текст расшифровки начинает уходить на сторону — сдвиг периметра [security.md](security.md). - **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётные шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл записи живёт в хранилище»; откуда взято число — [research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта записка не узнала». - **Приём большого файла.** Форма читается целиком, предел памяти под multipart задан числом в [database.md](database.md), «Настройки с числовым значением»; обрыв начинает загрузку заново. Загрузку частями разбирает разведка `chunked-upload-choice`; её выбор меняет публичный контракт приёма и потому идёт через решение в `adr/`. - **Учёт расхода.** Распознавание и языковая модель оплачиваются по факту, а учёта по пользователям нет: метрики считают сервис целиком. Что именно копится — записи о потреблении или счётчики — решает задача `usage-accounting`. - **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт, 2026-08-11), а рост каталога данных ничем не ограничен и не наблюдается. - **Резервные копии.** Копии делает сервер своими средствами, и приложение о них ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или приложению нужна команда выгрузки: база под нагрузкой копируется файлом не всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его или нет, тоже не решено. - **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли сервис определяет содержимое сам, то ли часть записей теряется на этом. - **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но конвертер этот случай не проверялся. - **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12 ([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована спекой `pipeline`. Не решено, отказываться ли от холостого опроса: три воркера дают 259 200 запросов к базе в сутки — расчёт из паузы воркера, а не замер ([research/job-queue.md](research/job-queue.md), «Как снималось»), — при нагрузке в единицы записей в день, и во что это обходится, никто не мерил. - **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой — решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в выкладке сегодня нет. - **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost. Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не решено, отдельный это шаг конвейера или продолжение шага распознавания.