Канон документов, каталог задач и OpenSpec
docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью. Конвенции перенесены из jellybit; места, где код им не следует, помечены строкой «Расхождение» как объявленный долг. tasks/ с роадмапом: две достигнутые цели, две запланированные (веб и многопользовательский режим), два направления (все форматы, долгие записи) и пять задач в беклоге. openspec/config.yaml — маршрутизатор с адресами документов, спек пока нет. CLAUDE.md переписан по форме канона: инварианты с severity, семантика гейта, запреты с путями. Taskfile получил task gate.
This commit is contained in:
@@ -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, — русский.
|
||||
|
||||
Reference in New Issue
Block a user