# CLAUDE.md Памятка для работы над transcriber. Перед задачей прочитай также [docs/passport.md](docs/passport.md), [docs/architecture.md](docs/architecture.md) и [docs/conventions/](docs/conventions/README.md). Проект ведём по-русски. ## Что это Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач, метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу панель администратора. Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст руками не правит и в форматы документов не экспортирует, учётных записей не заводит, с живым потоком не работает и складом произвольных файлов не служит. Записи и расшифровки хранит бессрочно: решением от 2026-08-11 сервис — архив. Перечень выше — выжимка, границу домена целиком держит [docs/passport.md](docs/passport.md). ## Стек Go 1.26 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и панель администратора, — `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** *Изъятие:* расширение — хвост после последней точки — в журнал попадает собственным полем: по нему прослеживается путь записи. Имени файла в журнале нет вовсе (инвариант ниже). Изъятие узкое и кончается журналом: наружу, меткой метрики, расширение выходит только приведённым к перечню известных форматов. Границу держит спека `intake`, цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md), остаток — [docs/security.md](docs/security.md). - **Бот отвечает только тем, кто в белом списке.** Бот проверяет отправителя до любой работы, включая скачивание файла. Нарушение обратимо правкой конфига, но чужие записи к тому моменту уже обработаны за наши деньги. **critical** - **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет задачу пригодной к повтору, либо переводит её в `failed` и сообщает пользователю. Молчаливый выход из шага без записи в лог и без смены состояния запрещён. Обратимо повторной отправкой, но пользователь об этом не узнает. **major** - **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не логируется, не считается в метрику и не поднимает уровень. Нарушение даёт запись раз в секунду на каждый воркер. **major** - **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым файлом шага. Необратимо: хранилище считает применённое по имени файла. **critical** - **Имя файла в хранилище задаёт сервис, а в журнал не идёт.** Умолчание PocketBase строит имя из имени, данного отправителем, — оно не применяется. Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой записи. **critical** - **Колонки очереди правятся в четырёх местах** пакета хранилища — `applyToRecord`, `recordToJob`, константа `acquireColumns` и структура `acquiredRow` с её `toJob`, — плюс шаг схемы. Компилятор видит два из них. Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения: поле теряется **только у задачи, попавшей к воркеру**. **major** - **Результат пишет только держатель захвата.** Шаг, чей захват за время работы достался другому, завершается без записи и без ответа отправителю. Иначе два воркера пишут в одну задачу по очереди, а отправитель получает два ответа. **major** ## Команды ```bash go build ./... # CGO не нужен go test ./... go vet ./... gofmt -l . golangci-lint run go run . -c config.toml # флаг -c или --config, по умолчанию config.toml task image # docker-образ; тег и раскладка — docs/architecture.md task gate # весь набор проверок разом ``` Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml` — скопируй `config.dist.toml` и заполни; известные прорехи образца перечислены в [docs/conventions/config.md](docs/conventions/config.md) строками «*Расхождение:*». ## Гейт - **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию `origin/master`; переопределяется `task gate BASE=`. - **Где логи шагов:** вывод команды, отдельного файла нет. - **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У `docs.py check`, `tasks.py check`, `openspec.py check` и `scripts/check-go-version.sh` словарь кодов общий: 0 сошлось, 1 дрейф, 2 ошибка употребления, 3 окружение (не корень проекта, каталог или файл не найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно: четвёртый шаг с собственной семантикой сделал бы это утверждение неверным. - **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`, неотформатированный файл, находка `golangci-lint`, расхождение объявленных версий Go, дрейф раскладки документов, дрейф каталога задач, форма `openspec/config.yaml`. Машина проверяет всё перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с именем недостающего плагина, а не пропускается молча. - **Чего в гейте намеренно нет и кто тогда обязан это гонять:** - **сборка образа** — дорога, и отказ от неё сознательный. Дешёвая замена стоит шагом сверки версий: он сравнивает строки и ловит расхождение, из-за которого образ перестаёт собираться, но собираемости не проверяет. Собрать образ по-прежнему может только человек — `task image`, и на подъёме версии это обязательно; - `shellcheck` — shell-скрипт в гейте один, `scripts/check-go-version.sh`; три соседних шага это Python в плагинах, и линтер оболочки к ним неприменим. Линтер не заведён, и цена ему одна строка шага плюс одно подавление ложного `SC1007` на идиому `CDPATH= cd`. Его не проверяет ничто, а это единственный исполняемый файл проекта, которого не видят ни `go vet`, ни `golangci-lint`; - `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс коммита. Полную историю никто не проверяет; - согласованность документов между собой и с кодом — её судят агенты, зовёт их скилл `av-dev-docs:healthcheck`, и звать его надо руками; - покрытие изменённых строк не считается ничем. **Гейт на `master` сегодня зелёный целиком, и объявленных долгов у него нет.** Красный шаг означает поломку — свою или чужую, но поломку, а не наследство. Списывать отказ на долг больше нельзя: списывать не на что. Два прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как новый: - `golangci-lint run` давал 4 замечания — два непроверенных `Close` и два сравнения ошибок приведением типа. Закрыто задачей `errors-as-instead-of-typecast` 2026-08-11; тогда же у `errcheck` включена настройка `check-blank`, поэтому `_ = x.Close()` больше не снимает замечание: отказ, который решено не проверять, объявляют в `exclude-functions` поимённо; - `go test ./...` чинила задача `http-handler-tests-never-green`. ## Запреты - **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и база (`data/data.db`), и записи живых людей (`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его ронять и пересоздавать можно свободно. - **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном перехватывает обновления у работающего, и пользователь теряет ответы. - **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён — подставляй `internal/adapter/recognizer/memory.go`. - **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server` запускает человек. - **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во временном каталоге и убирают за собой. - **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не писать: этот каталог смонтирован на сервере. ## Работа - **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет. - **Сообщение коммита** без трейлера `Co-Authored-By`. - **Необратимое** (спрашивается у человека всегда): применённая миграция, формат файла на диске и раскладка каталога данных, публичный контракт HTTP API, имя ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация секрета. - **Что считается сломанным** — новый красный шаг гейта, которого не было до твоей правки. Такое чинится прежде любой другой работы. Два объявленных долга из раздела «Гейт» сломанным состоянием **не** считаются, пока их не закрыли задачами. - **Ориентир по размеру порции:** не замерялся. - **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены поимённо. ## Язык - Документация, комментарии, сообщения коммитов — русский. - Код и идентификаторы — английский. - Текст, который видит пользователь Telegram, — русский.