Files
transcriber/docs/architecture.md
T
av df65eb5e32 docs: решено оставить очередь своей таблицей коллекцией PocketBase
- заведены записка разведки job-queue-choice и ADR: готовые библиотеки River и
  goqite отвергнуты, захват сворачивается в один запрос с RETURNING
- в architecture.md уточнён принцип «очередь таблицей» и закрыт открытый вопрос
  «Очередь», кроме холостого опроса
- задача pocketbase-storage забрала очередь себе: границы, счётчик попыток,
  состояние «мертва» и оракулы
2026-08-11 14:13:27 +03:00

188 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура
Обзор: как сложено и где что работает. **Поведение системы здесь не описывается**
— нормативно оно живёт в `openspec/specs/`. Места, где оно всё-таки описано,
помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
Документ описывает **сегодняшнее** устройство. Куда проект идёт — в
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
этого ещё не решено — в разделе «Открытые вопросы».
Заведена одна capability — [intake](../openspec/specs/intake/spec.md), и в ней
описан **только приём по HTTP**: его нормируют проверки, написанные задачей
`http-handler-tests-never-green` 2026-08-11. Поведение прочих узлов, включая
приём из Telegram, по-прежнему живёт только в коде. Задача, которая его трогает,
дописывает спеку своей capability.
## Принципы
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит в SQLite, воркер забирает работу
запросом с захватом. Внешний брокер не заводим: нагрузка — единицы записей в
день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим —
решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
кандидатов в [research/job-queue.md](research/job-queue.md).
<!-- канон: поведение → openspec/specs/pipeline -->
- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине,
достаётся снова по истечении срока захвата и проходит шаг заново.
- **Ядро зависит от интерфейсов.** `internal/service` знает только
`internal/contract`; ffmpeg, Yandex, Telegram и SQLite подставляются в
`main.go`.
## Компоненты
Каждый — строкой со ссылкой на 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/sqlite` | Задачи и файлы, запросы через goqu |
<!-- канон: поведение → openspec/specs/pipeline -->
Конвейер: `created``converted``transcribe``done` либо `failed`. Три
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду.
## Внешние границы и форматы
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
Файл скачивается по ссылке `file.Link(token)` обычным `http.Get`. 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 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| SQLite (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
или по сообщению об ошибке. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
Отдельного оповещения нет.
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
три воркера опрашивают базу вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением».
## Единые точки проекта
| Что | Где |
| --- | --- |
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` |
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
| Разбор конфигурации | `internal/config.LoadConfig` |
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()`
по месту, отображения доменной ошибки в код HTTP-ответа нет — обработчик решает
сам.
## Деплой
Образ собирается по контракту роли `app_image`: `task image` даёт
`transcriber:$BUILD_ID`, по умолчанию `transcriber:dev`. Реестр не участвует —
образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой
`inv pl -- transcriber` из `pet-project-server`.
Сборка двухступенчатая, финальный слой — alpine с `ca-certificates` и `ffmpeg`,
процесс работает под непривилегированным пользователем `transcriber`.
## Открытые вопросы
- **Хранилище.** PocketBase заменяет SQLite с goqu и goose, файлы переезжают в её
раскладку на диске — решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md), замер панели
в [research/pocketbase.md](research/pocketbase.md). Требование CGO этим
снимается. Чем становится конвейер задач, решено 2026-08-11 — см. «Очередь»
ниже. Данные не переносим — начинаем с чистого листа.
- **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера
обрабатывает PocketBase, а не наш код (тот же ADR). Не решено, где живёт сессия
и как связываются пользователь Telegram и пользователь веба. Панель
администратора при этом Authelia не закрывает: у неё свой пароль
суперпользователя.
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
устанавливаемое на телефон; фреймворк выбирает разведка
`spa-framework-choice`, и до её итога
[conventions/web-ui.md](conventions/web-ui.md) стоит почти пустой. Шаг сборки
фронтенда меняет требования к машине разработчика и к образу — решение уровня
ADR.
- **Уведомления.** Пользователь веба узнаёт о готовности только опросом.
Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
текст расшифровки начинает уходить на сторону — сдвиг периметра
[security.md](security.md).
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётный
потолок проекта — шесть часов, и он взят с запасом, а не замером.
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
задан числом в [database.md](database.md), «Настройки с числовым значением»;
обрыв начинает загрузку заново.
Загрузку частями разбирает разведка `chunked-upload-choice`; её выбор меняет
публичный контракт приёма и потому идёт через решение в `adr/`.
- **Учёт расхода.** Распознавание и языковая модель оплачиваются по факту, а
учёта по пользователям нет: метрики считают сервис целиком. Что именно
копится — записи о потреблении или счётчики — решает задача
`usage-accounting`.
- **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт,
2026-08-11), а рост каталога `data/files` ничем не ограничен и не наблюдается.
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
ничего не знает. После переезда на PocketBase не решено, хватит ли копировать
её каталог файлами, или приложению нужна команда выгрузки: база под нагрузкой
копируется файлом не всегда целой. Своё копирование по расписанию у PocketBase
есть — берём мы его или нет, тоже не решено.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом.
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
конвертер этот случай не проверялся.
- **Очередь.** Модель очереди решена 2026-08-11: остаётся своей таблицей и
становится коллекцией PocketBase, захват сворачивается в один запрос с
`RETURNING`, число попыток ложится колонкой, а исчерпавшая их задача переходит
в состояние «мертва» вместо `is_error = 1`
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)). Пишет это
`pocketbase-storage` тем же заходом, что и хранилище. Не решено, отказываться
ли от холостого опроса: три воркера дают 259 200 запросов в сутки при нагрузке
в единицы записей в день, и во что это обходится, никто не мерил.
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
выкладке сегодня нет.
- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает
уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не
решено, отдельный это шаг конвейера или продолжение шага распознавания.