- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
27 KiB
CLAUDE.md
Памятка для работы над transcriber. Перед задачей прочитай также docs/passport.md, docs/architecture.md и docs/conventions/.
Проект ведём по-русски.
Что это
Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, —
конвертирует её ffmpeg в ogg, отдаёт на отложенное распознавание Yandex
SpeechKit и отдаёт текст тому, кто запись загрузил, по опросу готовности.
Состояние записей, метаданные и сами файлы лежат во встроенной PocketBase, и она
же даёт владельцу панель администратора. Вход Telegram убран 2026-08-14 —
временно, до задачи, которая свяжет чат с учётной записью.
Чего не делает: сам речь не распознаёт и своих моделей не держит, текст руками не правит и в форматы документов не экспортирует, учётных записей не заводит, с живым потоком не работает и складом произвольных файлов не служит. Записи и расшифровки хранит бессрочно: решением от 2026-08-11 сервис — архив. Перечень выше — выжимка, границу домена целиком держит docs/passport.md.
Стек
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
панель администратора, — 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. - Принятая запись не теряется молча. Отказ на любом шаге либо оставляет запись пригодной к повтору, либо ставит на неё признак остановки с причиной — и тогда причина видна её владельцу карточкой записи, а владельцу сервиса журналом. Молчаливый выход из шага без записи в лог и без смены состояния запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и отправитель, который не спрашивает, о нём не узнаёт. Адрес, которым он спрашивает, сменился 2026-08-15: опрос готовности убран, и обязанность целиком переехала на карточку. major
NoopJobError— не ошибка. Значение «задач в этом состоянии нет» не логируется, не считается в метрику и не поднимает уровень. Нарушение даёт запись раз в секунду на каждый воркер. major- Миграция, уехавшая на сервер, не переписывается. Изменение — только новым файлом шага. Необратимо: хранилище считает применённое по имени файла. critical
- Имя файла в хранилище задаёт сервис, а в журнал не идёт. Умолчание
PocketBase строит имя из имени, данного отправителем, — оно не применяется.
Само имя — последняя часть ссылки
/api/files/..., поэтому в журнал пишется расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой записи. critical - Колонки записи правятся в двух местах пакета хранилища —
applyOwnedByPipelineвместе сapplyToRecordиrecordToAudioRecord, — плюс шаг схемы. Компилятор не видит ни одного: колонка, забытая в одном из них, теряется молча — запись сохранится без поля либо приедет с нулевым. Мест было четыре, пока захват перечислял колонки поимённо; теперь он возвращает идентификатор и признак своего захвата, и перечень перестал расти с моделью. Сверку держат правилаinternal/archrules. major - Рубеж объявляется одним дескриптором —
internal/entity/stage.go. Из него выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя; перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту ниже не пишется в журнал и не считается в метрику: запись встанет без единого следа. Сверку держат правилаinternal/archrules. major - Результат пишет только держатель захвата, и держатель узнаётся значением. Признак захвата уникален для каждого захвата, и запись результата условна по нему, а не по занятости записи. Шаг, чей захват за время работы достался другому — по протуханию срока или после того, как человек снял признак остановки в панели, — завершается без записи результата. Условие по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись по очереди, портя её результат. major
- У записи есть владелец, и колонка пустого значения не принимает. Ничья запись не заводится ничем — ни приёмом, ни конвейером, ни рукой в панели, — и держит это схема хранилища, а не договорённость. Пока обязательность жила в одном приёме, ничью запись заводили в панели, она уходила в конвейер, стоила денег на распознавание и не доставалась потом никому. Правило со стороны спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной записью, потому что схема запрещает заводить ничью, а это правило — спрашивать ничьим именем. major
- Остановленная запись несёт причину, какой бы та ни была. Причин три — приговор шага, исчерпанные отказы, застревание, — и каждая записывается в саму запись и в её журнал событий. Остановленная запись захвату не выдаётся, значит исход «пригодна к повтору» исключён, и другого следа у неё не будет. Обязанность, записанная у одной причины, у остальных читалась бы как снятая. major
Команды
go build ./... # CGO не нужен
go test ./... # в гейте идёт с -race, и там нужен компилятор C
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.example.toml и заполни; известные прорехи образца перечислены
в docs/conventions/config.md строками
«Расхождение:».
Гейт
- Команда целиком:
task gate. База диффа — переменнаяBASE, по умолчаниюorigin/master; переопределяетсяtask gate BASE=<rev>. - Какое правило чем проверяется — конвенция docs/conventions/go-linters.md. Здесь семантика гейта, там перечень правил, подавлений и место настройки каждого; перечень здесь не повторяется.
- Где логи шагов: вывод команды, отдельного файла нет.
- Что означает каждый исход: ненулевой код любого шага роняет гейт. У
docs.py check,tasks.py check,openspec.py checkиscripts/check-go-version.shсловарь кодов общий: 0 сошлось, 1 дрейф, 2 ошибка употребления, 3 окружение (не корень проекта, каталог или файл не найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно: четвёртый шаг с собственной семантикой сделал бы это утверждение неверным. Тому же словарю следуют обёртки шагов вTaskfile.yml— все, включаяtests,migrations,shell,dockerfileиvulns: недостающий инструмент — отказ окружения, код 3. Уtestsэто отсутствие CGO или компилятора C, без которых не работает детектор гонок — тесты он в этом случае всё равно гоняет, без-race, и краснеет уже после них. Уmigrationsкод 3 — неразрешимая база диффа, отсутствующий каталог шагов и каталог без единого шага; код 1 — переписанный шаг схемы. Сами чужие инструменты (shellcheck,hadolint,govulncheck,golangci-lint) держат свои коды, и гейту от них нужно только «ненулевой». Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: самtaskна любой отказ шага выходит с 201, а код шага печатает строкой («exit status 3»), поэтому словарь читается по коду скрипта. - Что красит безусловно и почему: отказ сборки, тестов,
go vet, гонка, найденная детектором (go test -race), переписанный применённый шаг схемы, неотформатированный файл, находкаgolangci-lint, расхождение объявленных версий Go, дрейф раскладки документов, дрейф каталога задач, формаopenspec/config.yaml, достижимая из кода уязвимость в зависимостях (govulncheck), находкаshellcheckв скриптах оболочки иhadolintвDockerfile. Машина проверяет всё перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с именем недостающего плагина, а не пропускается молча. - Что ловит pre-commit, а что только гейт.
lefthook.ymlгоняет на затронутых файлах дешёвую часть:gofmt(правит на месте и добавляет в коммит),golangci-lintпо пакетам тронутых файлов,shellcheck,hadolint,gitleaksпо индексу. Только гейту остаются сборка,go vet, тесты целиком, сверка версий Go, сверки документов иgovulncheck: они смотрят всё дерево либо требуют сети, а pre-commit обязан быть быстрым. - Шагу
vulnsнужна сеть, и он один такой: база уязвимостей живёт на vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент ставитсяgo install golang.org/x/vuln/cmd/govulncheck@latest. Судит он достижимость из кода: находка в модуле, чей уязвимый символ мы не вызываем, шаг не роняет. Такая сегодня одна —GO-2026-5932вgolang.org/x/crypto/openpgp, исправления у неё нет вовсе. - Чего в гейте намеренно нет и кто тогда обязан это гонять:
- сборка образа — дорога, и отказ от неё сознательный. Дешёвая замена
стоит шагом сверки версий: он сравнивает строки и ловит расхождение, из-за
которого образ перестаёт собираться, но собираемости не проверяет. Собрать
образ по-прежнему может только человек —
task image, и на подъёме версии это обязательно; - собираемость
Dockerfile:hadolintсудит форму, а не сборку, и часть его правил подавлена поимённо —DL3007до задачиpin-runtime-image-baseиDL3018по существу (alpine не держит старые версии пакетов, закрепление ломает сборку через недели). Причины стоят строками вTaskfile.yml; gitleaks— висит на pre-commit вlefthook.ymlи смотрит только индекс коммита. Полную историю никто не проверяет;- согласованность документов между собой и с кодом — её судят агенты, зовёт
их скилл
av-dev:doc-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/<коллекция>/<запись>/). На стройке под ним пусто и сервис остановлен — запрет от этого не снимается: каталог принадлежит серверу, и выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой, его ронять и пересоздавать можно свободно. - Локальный запуск не ходит наружу. Секции
[auth]и[yandex]проверяются на старте, но наружу при этом не обращаются, так что годятся выдуманные непустые значения — адреса[auth]должны лишь разбираться как ссылки. Расшифровка при выдуманных ключах не работает: её подменяютinternal/adapter/recognizer/memory.go. Подробности строками вconfig.example.toml. - Yandex Cloud за деньги. Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
подставляй
internal/adapter/recognizer/memory.go. - Выкладку не запускать.
inv pl -- transcriberизpet-project-serverзапускает человек. - Проверок над проверками не заводить. Уровень проверки один: линтеры и тесты судят код сервиса, а судить их самих незачем. Под запрет попадают тесты на шаги гейта и на свои скрипты проверок, стражи предмета у правил, механизация покрытия изменённого кода, мутационная сверка оракулов и требование мутировать тест, чтобы убедиться в его способности упасть. Решение владельца 2026-08-13; им закрыты четыре задачи — причины и даты в tasks/REJECTED.md, — и тем же решением снесены двадцать сценариев шага сверки версий Go, единственный такой файл в проекте. Исключений у запрета нет.
testdataв проекте нет. Тесты, которым нужен файл, создают его во временном каталоге и убирают за собой.- Временное —
t.TempDir()в тестах,/tmpвне их. Вdata/временное не писать: этот каталог смонтирован на сервере.
Работа
- Стадия проекта — стройка (
[tasks] stage = "build", объявлена в tasks/BACKLOG.md). Приложение строим заново: на сервере данных нет, сервис остановлен, выкладка пойдёт с чистого листа. Совместимость с тем, что уже лежит на сервере, поэтому не требуется — переносить нечего: ни базы, ни файлов записей, ни истории. Что это не отменяет: гейт краснеет на переписанном шаге схемы, как и краснел, и снятие этого запрета — отдельное решение человека; боевой каталог данных остаётся под запретом; выкладку по-прежнему запускает человек. - Основная ветка:
master. Коммиты идут в неё напрямую, веток и PR нет. - Сообщение коммита без трейлера
Co-Authored-By. - Необратимое (спрашивается у человека всегда): применённая миграция, формат файла на диске и раскладка каталога данных, публичный контракт HTTP API, имя ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация секрета.
- Что считается сломанным — новый красный шаг гейта, которого не было до твоей правки. Такое чинится прежде любой другой работы. Исключений из этого правила нет: раздел «Гейт» называет оба прежних долга закрытыми, и списывать красный шаг больше не на что.
- Ориентир по размеру порции: не замерялся.
- Что такое «сделана»:
task gateзелёный и критерии приёмки проверены поимённо.
Язык
-
Документация, комментарии, сообщения коммитов — русский.
-
Код и идентификаторы — английский.
-
Текст, который видит пользователь сервиса, — русский.
-
Точного числа накопленного в документах нет. «Три capability», «пять прогонов ревью», «две типизированные ошибки» расходятся с действительностью на первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина такое не считает, а читатель верит написанному. Ссылаться можно только на конкретную запись (по имени, со ссылкой) либо на весь корпус разом («заведённые capability», «записи журнала ниже»). Само перечисление при этом законно: перечень обновляют вместе с предметом, а число живёт отдельно от него и потому протухает в одиночку.
Изъятие: число, которое не растёт с работой, остаётся числом — количество уровней журнала в библиотеке, ступеней сборки образа, состояний списка на экране. Так же законно историческое число в записи о прошлом: «решением от 2026-08-13 закрыты четыре задачи» описывает событие, а не сегодняшний счёт. Настройки с числовым значением — свой случай, их дом docs/database.md.