- Инструментарий проекта спеками не нормируется: capability toolchain удалена, норму шага сверки версий Go держат его проверки в scripts. - Перечень capability в архитектуре и ревью сокращён до четырёх, решение ADR-2026-08-12-spec-norms-build-toolchain помечено устаревшим.
24 KiB
Архитектура
Обзор: как сложено и где что работает. Поведение системы здесь не описывается
— нормативно оно живёт в openspec/specs/. Места, где оно всё-таки описано,
помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
Документ описывает сегодняшнее устройство. Куда проект идёт — в passport.md и в tasks/BACKLOG.md; что из этого ещё не решено — в разделе «Открытые вопросы».
Заведены четыре capability, и все нормируют поведение сервиса для его потребителей. Инструмент, которым сервис собирают, спеками не нормируется вовсе: у набора проверок и сборки другой потребитель — тот, кто собирает, — и решением от 2026-08-13 его нормы живут в самих шагах, их проверках и conventions/go-linters.md.
- intake — только приём по HTTP: приём и
опрос за сессией, имя отправителя не доходит ни до хранилища, ни до журнала,
метка метрики несёт только известное расширение. Задачи
http-handler-tests-never-greenиno-user-filename-in-log2026-08-11,pocketbase-storageиoidc-login2026-08-12. Приём из Telegram здесь не описан; - pipeline — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
повтором: задачи
errors-as-instead-of-typecast2026-08-11 иpocketbase-storage2026-08-12. Переходы состояний и отмена контекста посреди шага остаются долгом; что именно не описано, перечисляет разделPurposeсамой спеки; - storage — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача
pocketbase-storage2026-08-12; - access — кто пришёл в сервис и пускают ли
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача
oidc-login2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший видит всё, что видел прежде аноним.
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в коде. Задача, которая его трогает, дописывает спеку своей capability.
Принципы
- Один процесс. Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и делят одну базу. Отдельного воркер-процесса нет намеренно.
- Очередь таблицей. Состояние задачи лежит коллекцией хранилища; неделимость захвата и порядок выборки нормирует pipeline, «Захват задачи неделим». Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11, ADR, сравнение кандидатов в research/job-queue.md.
- Шаг конвейера идемпотентен по повтору. Что делает срок захвата и когда задача возвращается в работу, нормирует pipeline, «Брошенная задача возвращается в работу»; здесь это принцип письма шага, а не описание поведения.
- Ядро зависит от интерфейсов.
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, «Владелец видит записи в панели» |
Конвейер: created → converted → transcribe → done либо failed. Три
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду. Что
делает задача, исчерпавшая попытки, нормирует
pipeline, «Число попыток и состояние
«мертва»».
Внешние границы и форматы
- Telegram Bot API. Вход — обновления длинным опросом, выход — сообщения.
Файл скачивается по ссылке
file.Link(token)запросом с контекстом, клиентом самого бота. Клиента заводит единая точкаinternal/adapter/telegram: токен стоит в пути каждого обращения, и снятие адреса с отказа живёт там — 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-порт наружу. -
Внешние зависимости поимённо и чем каждая отказывает. Столбец «отвечает медленно» читается вместе с тем, что таймаута нет ни у одного обращения наружу — database.md, «Настройки с числовым значением»:
Зависимость Падает Отвечает медленно Молчит Отдаёт мусор Telegram Bot API Бот не стартует, приложение продолжает работу без него Скачивание файла висит бесконечно Длинный опрос пуст, новые задачи не заводятся Файл скачался битым, отказ вылезет на конвертации 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, «Настройки с числовым значением».
Единые точки проекта
| Что | Где |
|---|---|
| Приём аудио и заведение задачи | 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), сессия
живёт кукой
transcriber_sessionи сама себя не продлевает. Норма — access, решения — ADR-2026-08-12-session-without-refresh и ADR-2026-08-12-oidc-exchange-via-own-route. Не решено одно: как связываются пользователь Telegram и пользователь веба. Панель администратора при этом Authelia не закрывает: у неё свой пароль суперпользователя. - Приложение. Экранов нет вовсе, есть только API. Решено делать SPA,
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
сборкой Vite — 2026-08-11,
ADR, сравнение кандидатов в
research/spa-framework.md. Тем же решением Node
входит в гейт и слоем в сборку образа. Пишет это
spa-skeleton; во что обходится слой Node в образе, не замерялось. Не решено, брать ли готовый набор компонентов. - Уведомления. Пользователь веба узнаёт о готовности только опросом. Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и текст расшифровки начинает уходить на сторону — сдвиг периметра security.md.
- Долгие записи. Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
из Telegram — точно, ограничения
deferred-generalпо длине — нет. Расчётные шесть часов нормирует storage, «Файл записи живёт в хранилище»; откуда взято число — research/pocketbase-defaults.md, «Чего эта записка не узнала». - Приём большого файла. Форма читается целиком, предел памяти под multipart
задан числом в database.md, «Настройки с числовым значением»;
обрыв начинает загрузку заново.
Загрузку частями разбирает разведка
chunked-upload-choice; её выбор меняет публичный контракт приёма и потому идёт через решение вadr/. - Учёт расхода. Распознавание и языковая модель оплачиваются по факту, а
учёта по пользователям нет: метрики считают сервис целиком. Что именно
копится — записи о потреблении или счётчики — решает задача
usage-accounting. - Срок хранения. Записи и тексты решено хранить бессрочно (паспорт, 2026-08-11), а рост каталога данных ничем не ограничен и не наблюдается.
- Резервные копии. Копии делает сервер своими средствами, и приложение о них ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или приложению нужна команда выгрузки: база под нагрузкой копируется файлом не всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его или нет, тоже не решено.
- Формат для распознавания. Конвертер отдаёт ogg/vorbis (
libvorbis), а SpeechKit получаетContainerAudio_OGG_OPUS. Расхождение не разобрано: то ли сервис определяет содержимое сам, то ли часть записей теряется на этом. - Видео. Дорожку из видеофайла бот принимает по MIME-типу
video/, но конвертер этот случай не проверялся. - Очередь. Модель очереди сделана задачей
pocketbase-storage2026-08-12 (ADR) и нормирована спекойpipeline. Не решено, отказываться ли от холостого опроса: три воркера дают 259 200 запросов к базе в сутки — расчёт из паузы воркера, а не замер (research/job-queue.md, «Как снималось»), — при нагрузке в единицы записей в день, и во что это обходится, никто не мерил. - Наблюдаемость.
/metricsостаётся и развивается. Чем — дописывать счётчики черезclient_golangили перейти на OpenTelemetry с трассировкой — решает разведкаopentelemetry-fit. Коллектор был бы процессом, которого в выкладке сегодня нет. - Выводы из текста. Литературный текст, заголовок, темы и пересказ решено считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost. Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает уходить ещё на одну сторону — сдвиг периметра security.md. Не решено, отдельный это шаг конвейера или продолжение шага распознавания.