Files
transcriber/CLAUDE.md
T
av 52fe31319a локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
2026-08-23 13:12:47 +03:00

350 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
Памятка для работы над transcriber. Перед задачей прочитай также
[docs/passport.md](docs/passport.md),
[docs/architecture.md](docs/architecture.md) и
[docs/conventions/](docs/conventions/README.md).
Проект ведём по-русски.
## Что это
Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, —
конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex
SpeechKit и отдаёт текст тому, кто запись загрузил, карточкой записи.
Состояние записей и метаданные лежат в SQLite, файлы записей — своим каталогом
рядом с базой. Панели администратора у сервиса нет: встроенное хранилище,
дававшее её, убрано 2026-08-22. Вход Telegram убран 2026-08-14 — временно, до
задачи, которая свяжет чат с учётной записью.
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
руками не правит и в форматы документов не экспортирует, учётных записей не
заводит, с живым потоком не работает и складом произвольных файлов не служит.
Записи и расшифровки хранит бессрочно: решением от 2026-08-11 сервис — архив.
Перечень выше — выжимка, границу домена целиком держит
[docs/passport.md](docs/passport.md).
## Стек
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), SQLite через
`modernc.org/sqlite` — база, — шаги схемы библиотекой `pressly/goose/v3`,
маршруты и слои на `net/http`, файлы записей своим каталогом,
`aws-sdk-go-v2` для Object
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Приложение — Vue 3
с роутером пятой версии и сборкой Vite; собранное вшито в бинарник, проверяют
его Biome и юнит-тесты Vue. Сборка —
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
## Проектирование
**Чистая архитектура.** Зависимости направлены внутрь, к домену: домен не знает
ни хранилища, ни транспорта, знание о внешнем мире приходит интерфейсом порта, а
реализацию подставляет точка входа. Направления держат тесты-сканеры
`internal/archrules`, а не договорённость.
**Модель домена ведётся тактическими шаблонами DDD** — сущность и корень
агрегата, объект-значение, доменное событие, репозиторий, служба домена,
фабрика. Анемичной модели не заводим: поведение записи живёт в домене, а
прикладной слой назначает порядок шагов, а не правила.
Слои, их дома, что каждому знать нельзя и чем шаблон занят сегодня —
[docs/architecture.md](docs/architecture.md), «Слои и модель домена». Здесь это
не повторяется: перечень растёт вместе с моделью, и вторая копия разошлась бы с
ним молча.
## Инварианты
Что нарушать нельзя.
- **Секрет не покидает конфиг.** Ключ SpeechKit и пара ключей Object
Storage не попадают в git, в лог, в ответ пользователю и
в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
вручную во всех местах выкладки. **critical**
Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в
настройках коллекции пользователей хранилища, — и снято 2026-08-22 вместе с
самим секретом: вход переехал на доверенный заголовок, обменивать код стало не
на что. Чтение файла базы больше не равносильно чтению секрета. Секретов в
базе не осталось вовсе: пароль владельца от панели ушёл вместе с панелью.
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
**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).
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
запись пригодной к повтору, либо ставит на неё признак остановки с причиной —
и тогда причина видна её владельцу **карточкой записи**, а владельцу сервиса
журналом. Молчаливый выход из шага без записи в лог и без смены состояния
запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом
Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и
отправитель, который не спрашивает, о нём не узнаёт. Адрес, которым он
спрашивает, сменился 2026-08-15: опрос готовности убран, и обязанность целиком
переехала на карточку. **major**
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
запись раз в секунду на каждый воркер. **major**
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
файлом шага. Необратимо: учёт применённого ведёт сама база.
**critical**
*Снятие было, разовое:* 2026-08-22 решением владельца весь каталог шагов
встроенного хранилища удалён и заменён одним шагом начальной схемы. Причина —
стройка: на сервере данных нет, сервис остановлен, выкладка идёт с чистого
листа, а новая база ведёт учёт применённого своей таблицей, которой отметки
прежнего каталога не годятся вовсе. Граница названа: снятие кончилось этим
изменением, и шаг начальной схемы подпадает под инвариант как всякий прежний.
- **Имя файла на диске задаёт сервис, а в журнал не идёт.** Ни имя файла, ни имя
подкаталога записи не строятся из имени, данного отправителем: подкаталог зовётся
идентификатором записи, файл — идентификатором с расширением. В журнал пишется
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
записи. **critical**
- **Колонки записи правятся в трёх местах** пакета хранилища —
`writeOwnedByPipeline` вместе с `writeRecord`, `readRecordColumns` и
`rowToAudioRecord`, — плюс шаг схемы. Компилятор не видит ни одного: колонка,
забытая в одном из них, теряется молча — запись сохранится без поля, приедет с
нулевым либо доедет до сущности пустой, и ближайшее сохранение запишет этот
ноль поверх сохранённого.
Мест было четыре, пока захват перечислял колонки поимённо; теперь он
возвращает идентификатор и признак своего захвата, и перечень перестал расти
с моделью. Отображение при этом идёт **по имени колонки**: именованные
параметры запроса и место назначения, найденное по имени, — позиционный список
дал бы сдвиг на одно поле, который компилируется молча. Сверку держат правила
`internal/archrules`. **major**
- **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него
выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя;
перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в
отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
ниже не пишется в журнал и не считается в метрику: запись встанет без единого
следа. Сверку держат правила `internal/archrules`. **major**
- **Результат пишет только держатель захвата, и держатель узнаётся значением.**
Признак захвата уникален для каждого захвата, и запись результата условна по
нему, а не по занятости записи. Шаг, чей захват за время работы достался
другому — по протуханию срока или после того, как человек вернул запись в
работу подкомандой оснастки, — завершается без записи результата. Условие
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
по очереди, портя её результат. **major**
- **У записи есть владелец, и колонка пустого значения не принимает.** Ничья
запись не заводится ничем — ни приёмом, ни конвейером, ни запросом к базе, — и
держит это схема, а не договорённость: колонка объявлена внешним ключом на
учётную запись и обязательна. Пока обязательность жила в одном приёме, ничью
запись заводили руками мимо него, она уходила в конвейер, стоила денег на
распознавание и не доставалась потом никому. Правило со стороны
спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной
записью, потому что схема запрещает **заводить** ничью, а это правило —
**спрашивать** ничьим именем. **major**
- **Остановленная запись несёт причину, какой бы та ни была.** Причин три —
приговор шага, исчерпанные отказы, застревание, — и каждая записывается в саму
запись и в её журнал событий. Остановленная запись захвату не выдаётся, значит
исход «пригодна к повтору» исключён, и другого следа у неё не будет.
Обязанность, записанная у одной причины, у остальных читалась бы как снятая.
**major**
## Команды
```bash
go build ./... # CGO не нужен
go test ./... # в гейте идёт с -race, и там нужен компилятор C
go vet ./...
gofmt -l .
golangci-lint run
go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml
go run ./cmd/devtools resume -c config.toml <id> # вернуть остановленную запись в работу
task front # приложение: зависимости, Biome, юнит-тесты, сборка
task image # docker-образ; тег и раскладка — docs/architecture.md
task gate # весь набор проверок разом
```
Node на машину **не ставится**: шаг сборки приложения зовёт его контейнером, а
образ берёт из ступени `Dockerfile`. Требованием к машине разработчика поэтому
становится docker — тот же, которым собирается образ.
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml`
скопируй `config.example.toml` и заполни; известные прорехи образца перечислены
в [docs/conventions/config.md](docs/conventions/config.md) строками
«*Расхождение:*».
## Гейт
- **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию
`origin/master`; переопределяется `task gate BASE=<rev>`.
- **Какое правило чем проверяется** — конвенция
[docs/conventions/go-linters.md](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»), поэтому словарь читается по коду скрипта.
- **Что красит безусловно и почему:** красная сборка приложения, находка Biome,
красный юнит-тест приложения, отказ сборки, тестов, `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 обязан быть быстрым.
- **Сетезависимые шаги названы здесь поимённо, и перечень этот закрыт.** Один из
них — `front`: он ставит зависимости приложения из реестра пакетов, а docker до
того тянет образ сборочного окружения. Отказ сети и реестра там — отказ окружения, код 3,
отдельно от красной сборки, у которой код 1; полный кэш установщика снимает
поход в сеть вовсе. Без короткого обращения-пробы шаг **висел** бы вместо
отказа: установщик уходит в повторы с нарастающей паузой на каждом пакете, а
гейт, который висит, хуже красного.
- **Шагу `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` сегодня зелёный целиком, и объявленных долгов у него нет.**
Красный шаг означает поломку — свою или чужую, но поломку, а не наследство.
Списывать отказ на долг больше нельзя: списывать не на что.
Три прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
новый:
- `hadolint` давал `DL3066` на строке `USER transcriber` — «Non-numeric user-id
may not be resolvable by host system». Отказ пришёл с обновлением `hadolint`,
а не с правкой репозитория, и жил на `master` незамеченным. Закрыто решением
владельца 2026-08-22: пользователь называется числом — `USER 1000:1000`.
Владельца файлов в смонтированном каталоге это не двигает, потому что те же
числа уже стояли при заведении пользователя (`-u 1000`, `-g 1000`);
- `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/transcriber.db`), и записи живых людей
(`data/records/<запись>/`). На стройке под ним пусто и сервис
остановлен — запрет от этого не снимается: каталог принадлежит серверу, и
выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой,
его ронять и пересоздавать можно свободно.
- **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]`
проверяются на старте, но наружу при этом не обращаются. У `[auth]` остался
один ключ — перечень доверенных адресов, — и он проверяется на читаемость, а не
на достижимость. Расшифровка при выдуманных ключах не работает: её подменяют
`internal/adapter/recognizer/memory.go`. Подробности строками в
`config.example.toml`.
**На машине без прокси представиться нечем**: сервис узнаёт
пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков
не ставит. Заголовок подставляет сам сервис — настройками, а не вторым
процессом: рецепт из трёх правок записан связным блоком в
`config.example.toml`, под перечнем доверенных адресов. Коротко: пара петлевых
адресов в перечень, `[server] debug = true`, раскомментированная секция
`[auth.test_headers]` с ключом `Remote-User`. Приложение при этом открывают по
адресу сервиса, второго порта нет. Заполненная имитация при выключенном
предохранителе роняет старт с именем ключа. Ключей боевого
провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
подставляй `internal/adapter/recognizer/memory.go`.
- **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server`
запускает человек.
- **Проверок над проверками не заводить.** Уровень проверки один: линтеры и
тесты судят код сервиса, а судить их самих незачем. Под запрет попадают тесты
на шаги гейта и на свои скрипты проверок, стражи предмета у правил,
механизация покрытия изменённого кода, мутационная сверка оракулов и
требование мутировать тест, чтобы убедиться в его способности упасть. Решение
владельца 2026-08-13; им закрыты четыре задачи — причины и даты в
[tasks/REJECTED.md](tasks/REJECTED.md), — и тем же решением снесены двадцать
сценариев шага сверки версий Go, единственный такой файл в проекте.
Исключений у запрета нет.
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
временном каталоге и убирают за собой.
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
писать: этот каталог смонтирован на сервере.
## Работа
- **Стадия проекта — стройка** (`[tasks] stage = "build"`, объявлена в
[tasks/BACKLOG.md](tasks/BACKLOG.md)). Приложение строим заново: на сервере
данных нет, сервис остановлен, выкладка пойдёт с чистого листа. Совместимость
с тем, что уже лежит на сервере, поэтому не требуется — переносить нечего: ни
базы, ни файлов записей, ни истории. Что это **не** отменяет: гейт краснеет на
переписанном шаге схемы, как и краснел, и снятие этого запрета — отдельное
решение человека; боевой каталог данных остаётся под запретом; выкладку
по-прежнему запускает человек.
- **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет.
- **Сообщение коммита** без трейлера `Co-Authored-By`.
- **Необратимое** (спрашивается у человека всегда): применённая миграция, формат
файла на диске и раскладка каталога данных, публичный контракт HTTP API, имя
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
секрета.
- **Что считается сломанным** — новый красный шаг гейта, которого не было до
твоей правки. Такое чинится прежде любой другой работы. Исключений из этого
правила нет: раздел «Гейт» называет оба прежних долга закрытыми, и списывать
красный шаг больше не на что.
- **Ориентир по размеру порции:** не замерялся.
- **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены
поимённо.
## Язык
- Документация, комментарии, сообщения коммитов — русский.
- Код и идентификаторы — английский.
- Текст, который видит пользователь сервиса, — русский.
- **Точного числа накопленного в документах нет.** «Три capability», «пять
прогонов ревью», «две типизированные ошибки» расходятся с действительностью на
первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина
такое не считает, а читатель верит написанному. Ссылаться можно только на
**конкретную запись** (по имени, со ссылкой) либо на **весь корпус разом**
(«заведённые capability», «записи журнала ниже»). Само перечисление при этом
законно: перечень обновляют вместе с предметом, а число живёт отдельно от него
и потому протухает в одиночку.
*Изъятие:* число, которое не растёт с работой, остаётся числом — количество
уровней журнала в библиотеке, ступеней сборки образа, состояний списка на
экране. Так же законно **историческое** число в записи о прошлом: «решением от
2026-08-13 закрыты четыре задачи» описывает событие, а не сегодняшний счёт.
Настройки с числовым значением — свой случай, их дом
[docs/database.md](docs/database.md).