Files
transcriber/CLAUDE.md
T
av 1576d06735 внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
2026-08-14 20:20:33 +03:00

25 KiB
Raw Blame History

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
  • Колонки записи правятся в двух местах пакета хранилища — applyOwnedByPipeline вместе с applyToRecord и recordToAudioRecord, — плюс шаг схемы. Компилятор не видит ни одного: колонка, забытая в одном из них, теряется молча — запись сохранится без поля либо приедет с нулевым. Мест было четыре, пока захват перечислял колонки поимённо; теперь он возвращает идентификатор и признак своего захвата, и перечень перестал расти с моделью. Сверку держат правила internal/archrules. major
  • Рубеж объявляется одним дескрипторомinternal/entity/stage.go. Из него выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя; перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту ниже не пишется в журнал и не считается в метрику: запись встанет без единого следа. Сверку держат правила internal/archrules. 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-typecast 2026-08-11; тогда же у errcheck включена настройка check-blank, поэтому _ = x.Close() больше не снимает замечание: отказ, который решено не проверять, объявляют в exclude-functions поимённо;
  • go test ./... чинила задача http-handler-tests-never-green.

Запреты

  • Боевой каталог данных не трогать. data/ на сервере целиком: под ним и база (data/data.db), и записи живых людей (data/storage/<коллекция>/<запись>/). Локальный каталог данных — свой, его ронять и пересоздавать можно свободно.
  • Боевым токеном бота не запускаться. Второй процесс с тем же токеном перехватывает обновления у работающего, и пользователь теряет ответы. Запускай с telegram.enabled = false: сервис поднимается без Telegram, к нему не уходит ни одного обращения, и работает он одним входом, по HTTP. Пустого bot_token для этого мало и больше не значит ничего: включён вход или нет, решает отдельный признак telegram.enabled, а пустой ключ при enabled = true роняет старт. Выключенного входа для подъёма тоже мало: секции [auth] и [yandex] проверяются на старте, но наружу при этом не ходят, так что годятся выдуманные непустые значения; подробности строками в 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/ временное не писать: этот каталог смонтирован на сервере.

Работа

  • Основная ветка: master. Коммиты идут в неё напрямую, веток и PR нет.
  • Сообщение коммита без трейлера Co-Authored-By.
  • Необратимое (спрашивается у человека всегда): применённая миграция, формат файла на диске и раскладка каталога данных, публичный контракт HTTP API, имя ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация секрета.
  • Что считается сломанным — новый красный шаг гейта, которого не было до твоей правки. Такое чинится прежде любой другой работы. Исключений из этого правила нет: раздел «Гейт» называет оба прежних долга закрытыми, и списывать красный шаг больше не на что.
  • Ориентир по размеру порции: не замерялся.
  • Что такое «сделана»: task gate зелёный и критерии приёмки проверены поимённо.

Язык

  • Документация, комментарии, сообщения коммитов — русский.

  • Код и идентификаторы — английский.

  • Текст, который видит пользователь Telegram, — русский.

  • Точного числа накопленного в документах нет. «Три capability», «пять прогонов ревью», «две типизированные ошибки» расходятся с действительностью на первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина такое не считает, а читатель верит написанному. Ссылаться можно только на конкретную запись (по имени, со ссылкой) либо на весь корпус разом («заведённые capability», «записи журнала ниже»). Само перечисление при этом законно: перечень обновляют вместе с предметом, а число живёт отдельно от него и потому протухает в одиночку.

    Изъятие: число, которое не растёт с работой, остаётся числом — количество уровней журнала в библиотеке, ступеней сборки образа, состояний списка на экране. Так же законно историческое число в записи о прошлом: «решением от 2026-08-13 закрыты четыре задачи» описывает событие, а не сегодняшний счёт. Настройки с числовым значением — свой случай, их дом docs/database.md.