- ссылки на tasks/ROADMAP.md переведены на BACKLOG.md и на openspec/specs, упоминания целей — на задачи, которые эту работу делают; - судьи документации нашли восемь расхождений: Purpose спеки pipeline объявлял неописанным то, что уже нормирован пятью требованиями, вид времени в конвенции спорил со схемой, а квоты, шесть часов и отказ от Web Push жили сразу в двух документах без ссылки друг на друга; - два числа получили провенанс: 259 200 запросов в сутки и потолок в шесть часов теперь ведут к записке разведки, а не читаются как замер.
236 lines
24 KiB
Markdown
236 lines
24 KiB
Markdown
# Архитектура
|
||
|
||
Обзор: как сложено и где что работает. **Поведение системы здесь не описывается**
|
||
— нормативно оно живёт в `openspec/specs/`. Места, где оно всё-таки описано,
|
||
помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
|
||
|
||
Документ описывает **сегодняшнее** устройство. Куда проект идёт — в
|
||
[passport.md](passport.md) и в [tasks/BACKLOG.md](../tasks/BACKLOG.md); что из
|
||
этого ещё не решено — в разделе «Открытые вопросы».
|
||
|
||
Заведены пять capability. Четыре первые нормируют **поведение сервиса** для его
|
||
потребителей; пятая — исключение из первого абзаца: она нормирует не сервис, а
|
||
инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает.
|
||
|
||
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: приём и
|
||
опрос за сессией, имя отправителя не доходит ни до хранилища, ни до журнала,
|
||
метка метрики несёт только известное расширение. Задачи
|
||
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
|
||
`pocketbase-storage` и `oidc-login` 2026-08-12. Приём из Telegram здесь не
|
||
описан;
|
||
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
||
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
|
||
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и
|
||
`pocketbase-storage` 2026-08-12. Переходы состояний и отмена контекста посреди шага остаются
|
||
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
||
2026-08-12;
|
||
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||
2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший
|
||
видит всё, что видел прежде аноним;
|
||
- [toolchain](../openspec/specs/toolchain/spec.md) — каким инструментом и какой
|
||
его версии собирается сервис: одно число версии Go во всех местах, где она
|
||
названа, и шаг гейта, который это сверяет. Задача `go-1-26-upgrade`
|
||
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, а не пересказом её требований.
|
||
|
||
<!-- канон: поведение → openspec/specs/intake, delivery -->
|
||
|
||
| Компонент | Где | Что делает |
|
||
| --- | --- | --- |
|
||
| 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), «Владелец видит записи в панели» |
|
||
|
||
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: цепочка переходов состояний -->
|
||
|
||
Конвейер: `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-порт наружу.
|
||
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
||
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
||
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
||
|
||
<!-- канон: поведение → openspec/specs/conversion, recognition -->
|
||
|
||
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
||
| --- | --- | --- | --- | --- |
|
||
| 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](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). Не
|
||
решено, отдельный это шаг конвейера или продолжение шага распознавания.
|