Канон документов, каталог задач и 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
+122 -71
View File
@@ -1,99 +1,150 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Памятка для работы над transcriber. Перед задачей прочитай также
[docs/passport.md](docs/passport.md),
[docs/architecture.md](docs/architecture.md) и
[docs/conventions/](docs/conventions/README.md).
Проект и общение по нему ведём по-русски.
Проект ведём по-русски.
## Что это
Сервис расшифровки аудио. Два входа — Telegram-бот и HTTP API, один общий конвейер
обработки. Распознавание асинхронное, через Yandex SpeechKit; файл едет в Yandex
Object Storage, оттуда его забирает SpeechKit.
Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и
HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач
и метаданные файлов лежат в SQLite, файлы — на диске.
Чего **не** делает: не редактирует и не пересказывает текст, не хранит записи как
архив, не распознаёт речь сам, не заводит учётные записи и не работает с живым
потоком. Границу домена целиком держит [docs/passport.md](docs/passport.md).
## Стек
Go 1.24 (нужен CGO из-за `mattn/go-sqlite3`), gin, goqu, goose, SQLite,
`go-telegram-bot-api`, `aws-sdk-go-v2` для Object Storage, gRPC-клиент Yandex
SpeechKit v3, Prometheus, `slog`. Сборка — Taskfile, образ — Docker,
выкладка — Ansible из `pet-project-server`.
## Инварианты
Что нарушать нельзя.
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit и пара ключей Object
Storage не попадают в git, в лог, в ответ пользователю и в колонку
`error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во
всех местах выкладки. **critical**
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
**critical**
- **Бот отвечает только тем, кто в белом списке.** Бот проверяет отправителя до
любой работы, включая скачивание файла. Нарушение обратимо правкой конфига, но
чужие записи к тому моменту уже обработаны за наши деньги. **critical**
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
задачу пригодной к повтору, либо переводит её в `failed` и сообщает
пользователю. Молчаливый выход из шага без записи в лог и без смены состояния
запрещён. Обратимо повторной отправкой, но пользователь об этом не узнает.
**major**
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
запись раз в секунду на каждый воркер. **major**
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
файлом. Необратимо: goose считает применённую версию по номеру. **critical**
- **Новая колонка правится во всех четырёх местах** репозитория SQLite —
`Create`, `Save`, `GetByID`, `FindAndAcquire`. Компилятор расхождение не
поймает, а проявится оно как потерянное при сохранении поле. **major**
## Команды
```bash
go build ./... # нужен CGO: mattn/go-sqlite3
go build ./... # нужен CGO
go test ./...
go vet ./...
gofmt -l .
golangci-lint run
go run . -c config.toml # флаг -c или --config, по умолчанию config.toml
task image # docker-образ, тег из $BUILD_ID (по умолчанию dev)
task image # docker-образ; тег и раскладка — docs/architecture.md
task gate # весь набор проверок разом
```
`golangci-lint run` на чистом `master` даёт 4 замечания в существующем коде (два
непроверенных `Close`, два сравнения ошибок через приведение типа вместо
`errors.As`). Их пока не чинили — новые замечания отличай от этих.
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml`
скопируй `config.dist.toml` и заполни; известные прорехи образца перечислены в
[docs/conventions/config.md](docs/conventions/config.md) строками
«*Расхождение:*».
Деплой запускается не отсюда, а из `pet-project-server`: `inv pl -- transcriber`.
Плейбук сам зовёт `task image` по контракту роли `app_image`, образ едет на сервер
через `docker save`/`load`, реестр не участвует.
## Гейт
`lefthook` на pre-commit гоняет `gitleaks git --staged`.
- **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию
`origin/master`; переопределяется `task gate BASE=<rev>`.
- **Где логи шагов:** вывод команды, отдельного файла нет.
- **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У
`docs.py check` коды свои: 0 сошлось, 1 дрейф раскладки, 2 ошибка
употребления, 3 не корень проекта, 4 внутренний сбой.
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
неотформатированный файл, находка `golangci-lint`, дрейф раскладки документов.
Всё перечисленное проверяется машиной и потому не обсуждается.
- **Чего в гейте намеренно нет и кто тогда обязан это гонять:**
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
коммита. Полную историю никто не проверяет;
- согласованность документов между собой и с кодом — её судят агенты, зовёт
их скилл `av-dev-docs:healthcheck`, и звать его надо руками;
- проверка каталога задач и формы `openspec/config.yaml` — соответствующих
плагинов в проекте нет, шагов в гейте нет, и не проверяет их **никто**;
- покрытие изменённых строк не считается ничем.
## Конфиг
**Гейт на `master` сегодня красный, и это объявленный долг, а не поломка дня.**
Два известных отказа:
`config.toml` в gitignore, образец — `config.dist.toml`. Разбирается в
`internal/config`; значения по умолчанию заданы в `defaultConfig()`, файл их
перекрывает. Правишь поле — правь оба места.
- `go test ./...` падает в `internal/controller/http`: тесты требуют
`testdata/sample.m4a`, которого в репозитории нет и не было (`*.m4a` стоит в
`.gitignore`), а остальные скармливают строку `test audio content` реальному
`ffprobe` и ждут 201. Заведено задачей `http-handler-tests-never-green`;
- `golangci-lint run` даёт 4 замечания в существующем коде: два непроверенных
`Close` (`adapter/recognizer/yandex/speechkit.go:55`, `main.go:124`) и два
сравнения ошибок приведением типа (`controller/worker/worker.go:51`,
`service/transcribe.go:394`). Долг записан в
[docs/conventions/errors.md](docs/conventions/errors.md), заведён задачей
`errors-as-instead-of-typecast`.
Два подвоха:
Новые отказы отличай от этих. Пока они живы, «зелёный гейт» в определении
сделанного означает «не добавилось ничего сверх перечисленного».
- Ключ белого списка пользователей Telegram называется `users_while_list` (опечатка
в теге toml), лежит в секции `[server]`, и в `config.dist.toml` его нет вообще.
Без него бот отвечает отказом всем.
- Список сверяется с `update.Message.From.String()` из tgbotapi — это `@username`
либо имя с фамилией, а не числовой id.
## Запреты
## Конвейер задач
- **Рабочую БД не трогать.** `data/transcriber.db` на сервере и его копии.
Локальная база в `./data/` — своя, её ронять и пересоздавать можно свободно.
- **Боевой каталог записей не трогать.** `data/files` на сервере: там лежат
голосовые сообщения живых людей.
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
перехватывает обновления у работающего, и пользователь теряет ответы.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
подставляй `internal/adapter/recognizer/memory.go`.
- **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server`
запускает человек.
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
временном каталоге и убирают за собой.
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
писать: этот каталог смонтирован на сервере.
Состояния `TranscribeJob`: `created``converted``transcribe``done` / `failed`
(константы в `internal/entity/job.go`).
## Работа
Три воркера в `main.go` крутят по одному шагу каждый, опрашивая базу раз в секунду:
`conversion_worker` (created → converted, ffmpeg в ogg), `transcribe_worker`
(converted → transcribe, заливка в S3 и старт распознавания), `check_worker`
(transcribe → done/failed, опрос операции Yandex).
- **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет.
- **Сообщение коммита** без трейлера `Co-Authored-By`.
- **Необратимое** (спрашивается у человека всегда): применённая миграция, формат
файла на диске и раскладка `data/files`, публичный контракт HTTP API, имя
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
секрета.
- **Что считается сломанным** — новый красный шаг гейта, которого не было до
твоей правки. Такое чинится прежде любой другой работы. Два объявленных долга
из раздела «Гейт» сломанным состоянием **не** считаются, пока их не закрыли
задачами.
- **Ориентир по размеру порции:** не замерялся.
- **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены
поимённо.
Задача захватывается через `FindAndAcquire`: один `UPDATE` проставляет
`acquisition_id`, дальше строка читается по нему. Задача с истёкшим `acquire_time`
достаётся заново — час для конвертации и распознавания, сутки для проверки.
`delay_time` откладывает следующую попытку.
## Язык
`contract.NoopJobError` — не ошибка, а «задач в этом состоянии нет». Воркер такой
случай не логирует и не считает в метрику. Возвращая ошибку из шага конвейера, не
теряй эту семантику.
Ошибку, после которой задачу нет смысла повторять, оформляй через `failJob`: он
переводит задачу в `failed` и сам шлёт человеку понятный текст в Telegram. Возврат
обычной ошибки оставляет задачу в текущем состоянии на повтор.
## Слои
`internal/entity` — модели, `internal/contract` — интерфейсы и типы ошибок,
`internal/service` — конвейер, `internal/controller/{http,tg,worker}` — входы,
`internal/adapter/*` — реализации contract (ffmpeg, yandex, telegram, sqlite).
Сервис зависит только от интерфейсов `contract`; конкретные адаптеры собираются
в `main.go`.
## База
Миграции goose в `migrations/`, вшиты в бинарник через `//go:embed migrations/*.sql`
в `main.go` и накатываются при старте. Новый файл достаточно положить в каталог,
регистрировать нигде не надо. Диалект — `sqlite3`.
Добавляя колонку, правь четыре места: миграцию, структуру в `internal/entity`,
и в `internal/adapter/repo/sqlite` — списки колонок в `Create`, `Save`, `GetByID` и
`FindAndAcquire`. Списки продублированы, компилятор расхождение не поймает.
## Конвенции
- Коммиты прямо в `master`, без веток и PR.
- В сообщении коммита не ставить трейлер `Co-Authored-By`.
- Логи — `log/slog`, структурные пары ключ-значение, логгер прокидывается
конструктором. `log.Printf` в `internal/controller/http/transcribe.go` — остаток,
на него не равняться.
- Текст, который увидит пользователь Telegram, — по-русски. Логи и комментарии в
коде — как в соседних файлах.
- Метрики Prometheus объявляются в `internal/metrics` с префиксом `transcriber_`.
- Документация, комментарии, сообщения коммитов — русский.
- Код и идентификаторы — английский.
- Текст, который видит пользователь Telegram, — русский.