# Архитектура Обзор: как сложено и где что работает. **Поведение системы здесь не описывается** — нормативно оно живёт в `openspec/specs/`. Места, где оно всё-таки описано, помечены маркером долга и переезжают туда первой же задачей, которая их трогает. Документ описывает **сегодняшнее** устройство. Куда проект идёт — в [passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из этого ещё не решено — в разделе «Открытые вопросы». Спеки ещё не заведены: capability ни одной, поведение живёт только в коде. Первая задача, которая трогает поведение, заводит спеку — до тех пор у темы `requirements` нормативного документа нет. ## Принципы - **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и делят одну базу. Отдельного воркер-процесса нет намеренно. - **Очередь таблицей.** Состояние задачи лежит в SQLite, воркер забирает работу запросом с захватом. Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца, не замер: `research/` пуст). - **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине, достаётся снова по истечении срока захвата и проходит шаг заново. - **Ядро зависит от интерфейсов.** `internal/service` знает только `internal/contract`; ffmpeg, Yandex, Telegram и SQLite подставляются в `main.go`. ## Компоненты Каждый — строкой со ссылкой на 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/sqlite` | Задачи и файлы, запросы через goqu | Конвейер: `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), «Настройки с числовым значением»: | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | --- | --- | --- | --- | --- | | Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | | Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» | | Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | SQLite (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — | - **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота или по сообщению об ошибке. Владелец — по метрике `transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера. Отдельного оповещения нет. - **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос, три воркера опрашивают базу раз в секунду вхолостую. ## Единые точки проекта | Что | Где | | --- | --- | | Приём аудио и заведение задачи | `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. Не решено, чем становится конвейер задач: таблицей PocketBase с тем же захватом или чем-то другим. Данные не переносим — начинаем с чистого листа. - **Учётные записи.** Вход через OIDC, провайдер — Authelia. Не решено, где живёт сессия и как связываются пользователь Telegram и пользователь веба. - **Приложение.** Экранов нет вовсе, есть только 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` по длине — нет. - **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли сервис определяет содержимое сам, то ли часть записей теряется на этом. - **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но конвертер этот случай не проверялся. - **Очередь.** Принцип «очередь таблицей» и захват `FindAndAcquire` написаны вручную: захват не транзакционен, повторов с нарастающей паузой нет, число попыток не считается, очереди мёртвых задач нет. Пересматривается разведкой `job-queue-choice` — раньше смены хранилища, чтобы не переписывать захват дважды. - **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой — решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в выкладке сегодня нет. - **Выводы из текста.** Заголовок, пересказ и темы решено считать внешним сервисом с OpenAI-совместимым интерфейсом. Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает уходить ещё на одну сторону — сдвиг периметра [security.md](security.md).