Канон документов, каталог задач и 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
+19
View File
@@ -0,0 +1,19 @@
# 🎯 Принимается запись любого формата, включая дорожку из видео
- **Тип:** goal
- **Секция:** Направления
- **Зачем:** Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
Человек отдаёт файл, не думая о том, что внутри: аудио любого распространённого
контейнера или видео, из которого нужна только речь. Подготовка на стороне
пользователя не требуется.
## Завершение
1. Перечень принимаемых форматов замерен и записан в `research/`, а не выведен
из документации ffmpeg.
2. Видеофайл принимается, и из него берётся звуковая дорожка.
3. Формат, который принять нельзя, отклоняется на приёме — с текстом, из
которого понятно почему, а не отказом на конвертации через минуту.
4. Расхождение ogg/vorbis против заявленного SpeechKit `OGG_OPUS` разобрано:
либо устранено, либо записано как проверенно безвредное.
@@ -0,0 +1,35 @@
# 🧹 Сравнивать доменные ошибки через errors.As
- **Тип:** chore
- **Категория:** Ядро
- **Зачем:** NoopJobError и JobNotFoundError проверяются приведением типа: первая же обёртка %w между слоями сломает проверку молча.
Сегодня это работает только потому, что ошибку на этом пути никто не
оборачивает. Сломается тихо: воркер перестанет отличать «задач нет» от отказа и
начнёт писать `ERROR` раз в секунду на каждый из трёх воркеров, а `NoopJobError`
попадёт в метрику отказов.
Правило записано в [conventions/errors.md](../../docs/conventions/errors.md),
линтер на этих двух местах уже красный.
## Затрагивает
- `internal/controller/worker/worker.go`, проверка `NoopJobError`;
- `internal/service/transcribe.go`, метод `findJob`, проверка
`JobNotFoundError`;
- `internal/contract/error.go` — оба типа полей не несут и годятся в sentinel;
- `.golangci.yml` — после правки `errorlint` на этих местах должен молчать.
## Критерии приёмки
- Обе проверки идут через `errors.As` либо через `errors.Is` по sentinel.
Оракул — `golangci-lint run` не даёт замечаний `errorlint`.
- Обёртка `fmt.Errorf("…: %w", err)` в середине пути не ломает распознавание.
Оракул — тест: обёрнутый `NoopJobError` воркер по-прежнему считает пустым
прогоном и не пишет ни лога, ни метрики.
- Метрика `transcriber_worker_job_count` на пустом прогоне не растёт. Оракул —
тот же тест, проверка значения счётчика до и после.
## Рамки
Поведение снаружи не меняется; конвейер не трогаем.
+46
View File
@@ -0,0 +1,46 @@
# 🧹 Задать таймауты обращениям к внешним сервисам
- **Тип:** chore
- **Категория:** Инфра
- **Зачем:** Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата.
Все обращения наружу идут с `context.Background()` либо через `http.Get` без
клиента со своим таймаутом. Отказ такого рода в
[architecture.md](../../docs/architecture.md) назван условием: собеседник,
который «отвечает медленно», сегодня останавливает воркер целиком, и заметить
это можно только по тому, что задачи перестали двигаться.
Сюда же контекст: воркеры получают `ctx` и отменяют его при остановке, но ни
один шаг конвейера внутрь его не передаёт — мягкая остановка ждёт таймаута
вместо того, чтобы прервать вызов.
## Затрагивает
- `internal/adapter/recognizer/yandex/s3.go` — заливка объекта;
- `internal/adapter/recognizer/yandex/speechkit.go` — запуск распознавания,
чтение потока, опрос операции;
- `internal/controller/tg/tg.go` — скачивание файла через `http.Get`;
- `internal/adapter/telegram/sender.go` — отправка сообщения;
- `internal/contract/contract.go` — интерфейсам нужен `context.Context` первым
аргументом;
- `internal/controller/worker/worker.go` и `internal/service/transcribe.go`
протаскивание контекста в шаг;
- `config.dist.toml` и `internal/config` — числа таймаутов;
- `docs/database.md`, таблица настроек с числовым значением.
## Критерии приёмки
- У каждого обращения наружу есть таймаут, и его значение задаётся конфигом.
Оракул — тест на подставном сервере, который не отвечает: вызов
возвращается с ошибкой за назначенное время, а не висит.
- Отмена контекста при остановке приложения прерывает шаг конвейера. Оракул —
тест: отменённый контекст возвращает управление из шага, задача остаётся в
прежнем состоянии.
- Прерванная по таймауту задача достаётся повторно и доходит до текста. Оракул
— тест на повторный прогон шага после отказа по таймауту.
- Числа таймаутов записаны в `docs/database.md`. Оракул — `task gate`.
## Рамки
Повторов с нарастающей паузой не заводим — это отдельная работа; здесь только
таймаут и отмена.
@@ -0,0 +1,57 @@
# 🐞 Починить тесты http-обработчика, ни разу не бывшие зелёными
- **Тип:** fix
- **Категория:** Ядро
- **Зачем:** go test ./... падает на master: тесты требуют файла, которого нет в репозитории, и ждут 201 от ffprobe, которому скормили строку.
Тесты заведены коммитом `87d8b05` и с тех пор ни разу не проходили: файл
`internal/controller/http/testdata/sample.m4a` в git не попадал никогда, а
`.gitignore` строкой `*.m4a` и не даст его закоммитить. Остальные случаи
записывают в файл строку `test audio content` и ждут `201`, тогда как обработчик
зовёт настоящий `ffprobe`, который такой вход отвергает, — получается `500`.
Отсюда следствие важнее самих тестов: **красный `go test` перестал что-либо
значить**, и любой настоящий отказ в этом пакете теперь неотличим от привычного
шума.
Заодно тесты зовут `os.Chdir`, то есть меняют состояние всего процесса: гонять
их параллельно нельзя, а `errcheck` на этих вызовах молчит только потому, что
`_test.go` вынесен в исключения `.golangci.yml`.
## Воспроизведение
```
go test ./internal/controller/http/
```
Отказов четыре: `TestCreateTranscribeJob_Success` не находит
`testdata/sample.m4a`; `TestCreateTranscribeJob_EmptyFile` и три случая
`TestCreateTranscribeJob_DifferentFileExtensions` получают `500` вместо `201` с
`ffprobe execution failed: exit status 1` в логе.
## Затрагивает
- `internal/controller/http/transcribe_test.go` целиком;
- `.gitignore`, строка `*.m4a` — она же мешает положить настоящую запись в
`testdata`;
- `internal/contract`, `AudioMetaViewer` — подставной вместо настоящего
`ffprobe` в тестах;
- `.golangci.yml`, исключение `errcheck` для `_test.go`, если `os.Chdir` уйдёт.
## Критерии приёмки
- `go test ./...` зелёный на чистом клоне без ручной подготовки файлов. Оракул —
`git clone` во временный каталог и `go test ./...`.
- Тест приёма не зависит от установленного `ffprobe`: метаданные даёт подставной
`AudioMetaViewer`. Оракул — прогон с временно переименованным `ffprobe` в
`PATH`.
- Отказ разбора метаданных проверяется отдельным случаем и ожидает `500`, а не
`201`. Оракул — тот же тест на подставном, возвращающем ошибку.
- Тесты не меняют рабочий каталог процесса. Оракул — `grep -n 'os.Chdir'
internal/controller/http/transcribe_test.go` пуст.
## Рамки
Поведение обработчика не меняем: задача про тесты. Если по ходу выяснится, что
`500` на негодный файл — неверный ответ, это отдельная задача про трансляцию
доменной ошибки.
+23
View File
@@ -0,0 +1,23 @@
# 🎯 Запись длиной в несколько часов доходит до текста
- **Тип:** goal
- **Секция:** Направления
- **Зачем:** Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, а границы модели deferred-general неизвестны.
- **Теги:** decomposed
Лекция, созвон и интервью целиком превращаются в текст. Сегодня неизвестно даже,
на каком звене такая запись отваливается, — цель начинается с замера, а не с
переделки.
## Завершение
1. Потолки каждого звена замерены и записаны в `research/` с командой замера:
приём из Telegram, приём по HTTP, конвертация, заливка в Object Storage,
модель `deferred-general`.
2. Запись, превышающая потолок, отклоняется на приёме понятным текстом, а не
висит в конвейере до истечения захвата.
3. Запись в пределах потолка доходит до текста и не теряет его хвост.
4. Текст в несколько сотен килобайт доходит до получателя: и в браузере, и в
Telegram, где предел сообщения — 4000 символов.
5. Долгая задача не блокирует короткие: запись на три часа не останавливает
конвейер для голосового на десять секунд.
+21
View File
@@ -0,0 +1,21 @@
# 🎯 Сервисом пользуются несколько человек, и записи одного не видны другому
- **Тип:** goal
- **Секция:** Запланировано
- **Зачем:** У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
Приложение узнаёт, кто к нему пришёл, и показывает каждому только его записи.
Учётные записи заводит и проверяет внешний провайдер — Authelia по OIDC; своей
регистрации и своих паролей не делаем, это граница из
[паспорта](../../docs/passport.md).
## Завершение
1. Неаутентифицированный запрос к записям не проходит: ни к странице, ни к API.
2. У задачи и файла есть владелец, и выборка чужой записи по её
идентификатору возвращает «не найдено», а не содержимое.
3. Вход идёт через OIDC у Authelia; выход из сессии работает.
4. Пользователь Telegram сопоставлен с учётной записью, и записи, пришедшие
ботом, видны ему же в браузере.
5. Белый список Telegram перестаёт быть отдельным механизмом: право писать боту
выводится из учётной записи.
+44
View File
@@ -0,0 +1,44 @@
# 🧹 Перевести хранилище на встроенный PocketBase
- **Тип:** chore
- **Категория:** Инфра
- **Зачем:** Хранилище, учётные записи и веб-панель нужны все три, и SQLite с goqu не даёт ни второго, ни третьего.
PocketBase встраивается библиотекой в тот же бинарник и приносит хранилище,
учётные записи и панель администратора разом. Наблюдаемое поведение сервиса
после перевода не меняется: те же два входа, тот же конвейер, тот же текст на
выходе.
Данные не переносим — база заводится с чистого листа, и это решение принято
сознательно.
## Затрагивает
- таблицы `files` и `transcribe_jobs`, каталог `migrations/` и весь механизм
goose;
- `internal/adapter/repo/sqlite` целиком, включая захват задачи через
`FindAndAcquire`;
- `internal/contract`, интерфейсы `FileRepository` и `TranscriptJobRepository`;
- ключ конфигурации `[database] path` и раскладка каталога `data/`;
- сборка образа: PocketBase тянет свой набор зависимостей, а `mattn/go-sqlite3`
с его требованием CGO может уйти;
- `docs/database.md` — схема, представление данных и таблица настроек.
## Критерии приёмки
- Сервис поднимается на чистом каталоге данных, накатывает свою схему сам и
принимает запись обоими входами. Оракул — запуск на пустом `data/` и прогон
записи из Telegram и через `POST /api/audio` до состояния `done`.
- Захват задачи воркером не выдаёт одну запись двум вызывающим. Оракул — тест
на трёх параллельных вызовах захвата по одному состоянию: ровно один
получает запись.
- Задача, брошенная на середине, достаётся снова по истечении срока захвата.
Оракул — тест с проставленным задним числом `acquire_time`.
- `docs/database.md` описывает новую схему, а старые упоминания goose и goqu из
документов канона убраны. Оракул — `task gate`, шаг `docs.py check`.
## Рамки
Данные прежней базы не переносим и не пытаемся сохранить; выкладку не запускаем;
смена формата хранения на сервере необратима, и момент перехода назначает
человек.
+32
View File
@@ -0,0 +1,32 @@
# 🔬 Потолки SpeechKit по длине записи и по формату
- **Тип:** research
- **Категория:** Ядро
- **Зачем:** Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается.
- **Теги:** goal:long-recordings
Цель «запись длиной в несколько часов доходит до текста» упирается в то, что
неизвестно, на каком звене такая запись отваливается. Начинать с переделки
приёма или с деления записи на куски — разные работы, и выбор между ними
определяет замер, а не рассуждение.
Отдельно висит расхождение: конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает заявку `ContainerAudio_OGG_OPUS`. Работает ли это и что
происходит с записью — не разбиралось.
## Вопрос
Какую самую длинную запись модель `deferred-general` доводит до текста, что
происходит при превышении и какие контейнеры она принимает на самом деле.
## Куда ляжет ответ
`docs/research/speechkit.md` — числами и с командой замера. Ответ на вопрос про
`OGG_OPUS` уходит туда же и снимает открытый вопрос из
[architecture.md](../../docs/architecture.md).
## Рамки
Замер идёт за деньги: распознавание и хранение в Object Storage оплачиваются по
факту. Число прогонов и длину пробных записей назначает человек. Боевые записи
пользователей для замера не берём.
+19
View File
@@ -0,0 +1,19 @@
# 🎯 Записи загружаются и читаются в браузере
- **Тип:** goal
- **Секция:** Запланировано
- **Зачем:** Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем.
Приложение получает поверхность, на которой запись загружают и забирают текст,
не открывая Telegram и не вызывая API руками. Конвейер обработки при этом
остаётся прежним — меняется только вход и способ показать результат.
## Завершение
1. Страница принимает файл формой и заводит задачу — ту же, что заводит бот.
2. Состояние задачи видно на странице и обновляется само, пока задача не дошла
до `done` или `failed`; отказ показывается человекочитаемым текстом.
3. Готовый текст читается и копируется со страницы целиком, без деления на
части.
4. Список своих записей открывается и листается.
5. Всё перечисленное работает без JavaScript — формой и переходом по ссылке.