Канон документов, каталог задач и OpenSpec
docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью. Конвенции перенесены из jellybit; места, где код им не следует, помечены строкой «Расхождение» как объявленный долг. tasks/ с роадмапом: две достигнутые цели, две запланированные (веб и многопользовательский режим), два направления (все форматы, долгие записи) и пять задач в беклоге. openspec/config.yaml — маршрутизатор с адресами документов, спек пока нет. CLAUDE.md переписан по форме канона: инварианты с severity, семантика гейта, запреты с путями. Taskfile получил task gate.
This commit is contained in:
@@ -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` на пустом прогоне не растёт. Оракул —
|
||||
тот же тест, проверка значения счётчика до и после.
|
||||
|
||||
## Рамки
|
||||
|
||||
Поведение снаружи не меняется; конвейер не трогаем.
|
||||
@@ -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` на негодный файл — неверный ответ, это отдельная задача про трансляцию
|
||||
доменной ошибки.
|
||||
@@ -0,0 +1,23 @@
|
||||
# 🎯 Запись длиной в несколько часов доходит до текста
|
||||
|
||||
- **Тип:** goal
|
||||
- **Секция:** Направления
|
||||
- **Зачем:** Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, а границы модели deferred-general неизвестны.
|
||||
- **Теги:** decomposed
|
||||
|
||||
Лекция, созвон и интервью целиком превращаются в текст. Сегодня неизвестно даже,
|
||||
на каком звене такая запись отваливается, — цель начинается с замера, а не с
|
||||
переделки.
|
||||
|
||||
## Завершение
|
||||
|
||||
1. Потолки каждого звена замерены и записаны в `research/` с командой замера:
|
||||
приём из Telegram, приём по HTTP, конвертация, заливка в Object Storage,
|
||||
модель `deferred-general`.
|
||||
2. Запись, превышающая потолок, отклоняется на приёме понятным текстом, а не
|
||||
висит в конвейере до истечения захвата.
|
||||
3. Запись в пределах потолка доходит до текста и не теряет его хвост.
|
||||
4. Текст в несколько сотен килобайт доходит до получателя: и в браузере, и в
|
||||
Telegram, где предел сообщения — 4000 символов.
|
||||
5. Долгая задача не блокирует короткие: запись на три часа не останавливает
|
||||
конвейер для голосового на десять секунд.
|
||||
@@ -0,0 +1,21 @@
|
||||
# 🎯 Сервисом пользуются несколько человек, и записи одного не видны другому
|
||||
|
||||
- **Тип:** goal
|
||||
- **Секция:** Запланировано
|
||||
- **Зачем:** У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
|
||||
|
||||
Приложение узнаёт, кто к нему пришёл, и показывает каждому только его записи.
|
||||
Учётные записи заводит и проверяет внешний провайдер — Authelia по OIDC; своей
|
||||
регистрации и своих паролей не делаем, это граница из
|
||||
[паспорта](../../docs/passport.md).
|
||||
|
||||
## Завершение
|
||||
|
||||
1. Неаутентифицированный запрос к записям не проходит: ни к странице, ни к API.
|
||||
2. У задачи и файла есть владелец, и выборка чужой записи по её
|
||||
идентификатору возвращает «не найдено», а не содержимое.
|
||||
3. Вход идёт через OIDC у Authelia; выход из сессии работает.
|
||||
4. Пользователь Telegram сопоставлен с учётной записью, и записи, пришедшие
|
||||
ботом, видны ему же в браузере.
|
||||
5. Белый список Telegram перестаёт быть отдельным механизмом: право писать боту
|
||||
выводится из учётной записи.
|
||||
@@ -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`.
|
||||
|
||||
## Рамки
|
||||
|
||||
Данные прежней базы не переносим и не пытаемся сохранить; выкладку не запускаем;
|
||||
смена формата хранения на сервере необратима, и момент перехода назначает
|
||||
человек.
|
||||
@@ -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 оплачиваются по
|
||||
факту. Число прогонов и длину пробных записей назначает человек. Боевые записи
|
||||
пользователей для замера не берём.
|
||||
@@ -0,0 +1,19 @@
|
||||
# 🎯 Записи загружаются и читаются в браузере
|
||||
|
||||
- **Тип:** goal
|
||||
- **Секция:** Запланировано
|
||||
- **Зачем:** Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем.
|
||||
|
||||
Приложение получает поверхность, на которой запись загружают и забирают текст,
|
||||
не открывая Telegram и не вызывая API руками. Конвейер обработки при этом
|
||||
остаётся прежним — меняется только вход и способ показать результат.
|
||||
|
||||
## Завершение
|
||||
|
||||
1. Страница принимает файл формой и заводит задачу — ту же, что заводит бот.
|
||||
2. Состояние задачи видно на странице и обновляется само, пока задача не дошла
|
||||
до `done` или `failed`; отказ показывается человекочитаемым текстом.
|
||||
3. Готовый текст читается и копируется со страницы целиком, без деления на
|
||||
части.
|
||||
4. Список своих записей открывается и листается.
|
||||
5. Всё перечисленное работает без JavaScript — формой и переходом по ссылке.
|
||||
Reference in New Issue
Block a user