- шаг схемы закрывает поверхность, которую хранилище приносит открытой: собственную регистрацию, вход по паролю и одноразовый код — без этого закрытие приёма обходилось двумя запросами - продление сессии выключено, срок семь суток: иначе отзыв доступа у провайдера до сервиса не доходит никогда - файл записи отдаётся вошедшему по токену файла — пересмотр ADR-2026-08-12-file-link-open-but-not-logged
16 KiB
CLAUDE.md
Памятка для работы над transcriber. Перед задачей прочитай также docs/passport.md, docs/architecture.md и docs/conventions/.
Проект ведём по-русски.
Что это
Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и
HTTP API, — конвертирует её ffmpeg в ogg, отдаёт на отложенное распознавание
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач,
метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу
панель администратора.
Чего не делает: сам речь не распознаёт и своих моделей не держит, текст руками не правит и в форматы документов не экспортирует, учётных записей не заводит, с живым потоком не работает и складом произвольных файлов не служит. Записи и расшифровки хранит бессрочно: решением от 2026-08-11 сервис — архив. Перечень выше — выжимка, границу домена целиком держит 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 и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и
в колонку
error_text. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во всех местах выкладки. critical Изъятие: секрет клиента OIDC живёт ещё и в настройках коллекции пользователей хранилища — туда его кладёт приведение настроек при каждом подъёме, потому что применённый шаг схемы не переписывается и не пережил бы ротации. Чтение файла базы равносильно чтению этого секрета; перечисленные места запрета это не отменяет. - Содержимое записи остаётся приватным. Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
critical
Изъятие: расширение — хвост после последней точки — в журнал попадает
собственным полем: по нему прослеживается путь записи. Имени файла в журнале
нет вовсе (инвариант ниже). Изъятие узкое
и кончается журналом: наружу, меткой метрики, расширение выходит только
приведённым к перечню известных форматов. Границу держит спека
intake, цена — adr/ADR-2026-08-11-known-format-label.md, остаток — docs/security.md. - Бот отвечает только тем, кто в белом списке. Бот проверяет отправителя до любой работы, включая скачивание файла. Нарушение обратимо правкой конфига, но чужие записи к тому моменту уже обработаны за наши деньги. critical
- Принятая запись не теряется молча. Отказ на любом шаге либо оставляет
задачу пригодной к повтору, либо переводит её в
failedи сообщает пользователю. Молчаливый выход из шага без записи в лог и без смены состояния запрещён. Обратимо повторной отправкой, но пользователь об этом не узнает. major NoopJobError— не ошибка. Значение «задач в этом состоянии нет» не логируется, не считается в метрику и не поднимает уровень. Нарушение даёт запись раз в секунду на каждый воркер. major- Миграция, уехавшая на сервер, не переписывается. Изменение — только новым файлом шага. Необратимо: хранилище считает применённое по имени файла. critical
- Имя файла в хранилище задаёт сервис, а в журнал не идёт. Умолчание
PocketBase строит имя из имени, данного отправителем, — оно не применяется.
Само имя — последняя часть ссылки
/api/files/..., поэтому в журнал пишется расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой записи. critical - Колонки очереди правятся в четырёх местах пакета хранилища —
applyToRecord,recordToJob, константаacquireColumnsи структураacquiredRowс еёtoJob, — плюс шаг схемы. Компилятор видит два из них. Колонка, забытая в пареacquireColumns/acquiredRow, приезжает из захвата нулевой, и первый жеSaveпишет этот ноль поверх сохранённого значения: поле теряется только у задачи, попавшей к воркеру. major - Результат пишет только держатель захвата. Шаг, чей захват за время работы достался другому, завершается без записи и без ответа отправителю. Иначе два воркера пишут в одну задачу по очереди, а отправитель получает два ответа. major
Команды
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 строками
«Расхождение:».
Гейт
- Команда целиком:
task gate. База диффа — переменнаяBASE, по умолчаниюorigin/master; переопределяетсяtask gate BASE=<rev>. - Где логи шагов: вывод команды, отдельного файла нет.
- Что означает каждый исход: ненулевой код любого шага роняет гейт. У
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-typecast2026-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, — русский.