- architecture.md: раздел «Слои и модель домена» — таблица слоёв с колонкой «чего не знает», шаблоны со своим сегодняшним предметом в коде, запрет анемичной модели - CLAUDE.md: раздел «Проектирование» с подходом и ссылкой на раздел архитектуры - названо, что чистоту домена не держит ни одно правило archrules
331 lines
31 KiB
Markdown
331 lines
31 KiB
Markdown
# 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 и отдаёт текст тому, кто запись загрузил, по опросу готовности.
|
||
Состояние записей, метаданные и сами файлы лежат во встроенной PocketBase, и она
|
||
же даёт владельцу панель администратора. Вход Telegram убран 2026-08-14 —
|
||
временно, до задачи, которая свяжет чат с учётной записью.
|
||
|
||
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
|
||
руками не правит и в форматы документов не экспортирует, учётных записей не
|
||
заводит, с живым потоком не работает и складом произвольных файлов не служит.
|
||
Записи и расшифровки хранит бессрочно: решением от 2026-08-11 сервис — архив.
|
||
Перечень выше — выжимка, границу домена целиком держит
|
||
[docs/passport.md](docs/passport.md).
|
||
|
||
## Стек
|
||
|
||
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
|
||
панель администратора, — `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**
|
||
- **Имя файла в хранилище задаёт сервис, а в журнал не идёт.** Умолчание
|
||
PocketBase строит имя из имени, данного отправителем, — оно не применяется.
|
||
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется
|
||
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
|
||
записи. **critical**
|
||
- **Колонки записи правятся в двух местах** пакета хранилища —
|
||
`applyOwnedByPipeline` вместе с `applyToRecord` и `recordToAudioRecord`, —
|
||
плюс шаг схемы. Компилятор не видит ни одного: колонка, забытая в одном из
|
||
них, теряется молча — запись сохранится без поля либо приедет с нулевым.
|
||
Мест было четыре, пока захват перечислял колонки поимённо; теперь он
|
||
возвращает идентификатор и признак своего захвата, и перечень перестал расти
|
||
с моделью. Сверку держат правила `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 proxy # подставной прокси: ставит заголовок входа локально
|
||
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/data.db`), и записи живых людей
|
||
(`data/storage/<коллекция>/<запись>/`). На стройке под ним пусто и сервис
|
||
остановлен — запрет от этого не снимается: каталог принадлежит серверу, и
|
||
выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой,
|
||
его ронять и пересоздавать можно свободно.
|
||
- **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]`
|
||
проверяются на старте, но наружу при этом не обращаются. У `[auth]` остался
|
||
один ключ — перечень доверенных адресов, — и он проверяется на читаемость, а не
|
||
на достижимость. Расшифровка при выдуманных ключах не работает: её подменяют
|
||
`internal/adapter/recognizer/memory.go`. Подробности строками в
|
||
`config.example.toml`.
|
||
**На машине без прокси представиться нечем**: сервис узнаёт
|
||
пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков
|
||
не ставит. На место контура встаёт подставной прокси —
|
||
`go run ./cmd/devtools proxy`: он слушает свой порт, ставит заголовок и
|
||
переправляет запрос сервису, а проверок не делает никаких. Приложение при этом
|
||
открывают **по адресу прокси**, а не по адресу сервиса. Ключей боевого
|
||
провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге.
|
||
- **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).
|