Канон документов, каталог задач и OpenSpec

docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего
устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью.
Конвенции перенесены из jellybit; места, где код им не следует, помечены
строкой «Расхождение» как объявленный долг.

tasks/ с роадмапом: две достигнутые цели, две запланированные (веб и
многопользовательский режим), два направления (все форматы, долгие
записи) и пять задач в беклоге.

openspec/config.yaml — маршрутизатор с адресами документов, спек пока нет.

CLAUDE.md переписан по форме канона: инварианты с severity, семантика
гейта, запреты с путями. Taskfile получил task gate.
This commit is contained in:
av
2026-08-10 21:19:07 +03:00
parent a4646c0930
commit 4d1c2bf44c
40 changed files with 3656 additions and 71 deletions
+131
View File
@@ -0,0 +1,131 @@
# Архитектура
Обзор: как сложено и где что работает. **Поведение системы здесь не описывается**
— нормативно оно живёт в `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, а не пересказом её требований.
<!-- канон: поведение → 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"` и по логам контейнера.
Отдельного оповещения нет.
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
три воркера опрашивают базу раз в секунду вхолостую.
## Единые точки проекта
| Что | Где |
| --- | --- |
| Приём аудио и заведение задачи | `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. Конвенция веб-UI на htmx
описана в [conventions/web-ui.md](conventions/web-ui.md) заранее.
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
из Telegram — точно, ограничения `deferred-general` по длине — нет.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом.
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
конвертер этот случай не проверялся.