Канон документов, каталог задач и OpenSpec

docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего
устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью.
Конвенции перенесены из jellybit; места, где код им не следует, помечены
строкой «Расхождение» как объявленный долг.

tasks/ с роадмапом: две достигнутые цели, две запланированные (веб и
многопользовательский режим), два направления (все форматы, долгие
записи) и пять задач в беклоге.

openspec/config.yaml — маршрутизатор с адресами документов, спек пока нет.

CLAUDE.md переписан по форме канона: инварианты с severity, семантика
гейта, запреты с путями. Taskfile получил task gate.
This commit is contained in:
av
2026-08-10 21:19:07 +03:00
parent a4646c0930
commit 4d1c2bf44c
40 changed files with 3656 additions and 71 deletions
+66
View File
@@ -0,0 +1,66 @@
# Конвенции кода
Как мы пишем код — в отличие от `openspec/specs/`, который описывает, что
система делает, и от [../architecture.md](../architecture.md), который описывает,
как она сложена.
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
перечень «Механизировано» ниже. Причина: файл на несколько сотен строк
размазывает внимание по тривиальному — и модель, и человек добросовестно
проверят именование и не дойдут до формы решения.
Обоснование «почему именно так» живёт в [../adr/](../adr/README.md); инварианты с
severity — в [CLAUDE.md](../../CLAUDE.md).
## Откуда взяты и что с расхождениями
Все пять записей перенесены из проекта jellybit — тот же Go, тот же автор, те же
задачи. Код transcriber написан раньше и **части правил не следует**: ключи —
UUID вместо ULID, время берётся `time.Now()` по месту, лог пишется на каждом
шаге и дублируется воркером, доменные ошибки проверяются приведением типа.
Каждое такое место названо в своей записи строкой «*Расхождение:*». Читается оно
как **долг, а не как нарушение**: правила действуют на новый код, переписывание
существующего — отдельная работа. Проходу ревью строка «Расхождение» говорит, что
находка на этом месте уже известна и новой не считается.
## Записи
- [logging.md](logging.md) — логирование: уровень по адресату, единая логирующая
точка на доменной границе, словарь полей, `ext.*`, что не логируем.
- [errors.md](errors.md) — ошибки: stdlib, обёртка `%w`, `errors.Is` и
`errors.As`, трансляция доменной ошибки на внешней границе, sentinel против
типизированной.
- [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл
`0600`, самодокументируемый `config.dist.toml`, проверка на старте.
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
ULID, разбор на входной границе, естественные ключи у деталей.
- [web-ui.md](web-ui.md) — веб-UI на htmx: партиал равен странице равен
фрагменту, ветвление по `HX-Request`, деградация без JS, ошибка на htmx-пути
как 200 плюс фрагмент, самозавершающийся опрос, вендоринг статики. **Записана
наперёд: веб-UI ещё нет.**
## Механизировано
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в
промптах ревью не пересказывается.
| Правило | Где механизировано |
| --- | --- |
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck` (кроме `defer Close` и `send`) |
| Форматирование исходников | `.golangci.yml``gofmt` |
| Подозрительные конструкции языка | `.golangci.yml``govet`, `staticcheck`, `ineffassign`, `unused` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
| Раскладка документов, битые ссылки, миграция без правки `database.md` | `docs.py check` |
Не названное здесь место механизации означает, что проход по конвенциям будет
добросовестно проверять уже проверенное.
**Из перечисленного в записях правилом выражено одно** — сравнение ошибок через
`errors.Is` и `errors.As` (`errorlint`, строка таблицы выше). Прозой остаётся всё
прочее: ни константный `msg` лога (`sloglint`), ни запрет `fmt.Print*` и
`os.Getenv` (`forbidigo`), ни запрет сторонних пакетов ошибок (`depguard`), ни
архитектурные тесты-сканеры. Это следующий шаг переноса в правило: свойство,
оставшееся прозой, проверяет человек на каждом ревью заново.
+124
View File
@@ -0,0 +1,124 @@
# Конфигурация
Конвенция: *как* устроена и грузится конфигурация transcriber (TOML).
Правила оформления кода (How), не спецификация поведения.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: образец называется `config.dist.toml`, а не `config.example.toml`;
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме
проверки пустых ключей внутри адаптеров.
**Механизировано:** ничего. Запрет `os.Getenv` для конфигурации правилом линтера
не выражен, и `godotenv` в `main.go` загружает `.env` — то есть окружение сейчас
участвует.
## Принципы
- **Конфигурация — только TOML.** Переменные окружения для конфигурации **не
используем**: окружение наследуется дочерними процессами и видно через
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
*Расхождение:* `main.go` зовёт `godotenv.Load()` и молча продолжает без файла.
- Грузим **один раз при старте** в одну типизированную структуру `Config`
(под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
прикладном коде нет, только загрузчик `internal/config`.
- Конфиг **неизменяем** после старта; смена параметров — перезапуск процесса.
## Файл и поиск
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге**
процесса.
- Путь переопределяется опцией **`-c path`** или **`--config=path`**.
- Образец в репозитории — **`config.dist.toml`** (см. ниже); реальный
`config.toml` не коммитится.
## config.dist.toml — самодокументируемый образец
`config.dist.toml` коммитим как единый справочник по конфигу: все секции и все
поля. **Каждое поле снабжаем комментарием**, из которого ясно:
- **зачем** поле — что оно меняет в поведении;
- **допустимые значения** — перечисление или границы;
- **единицы измерения**, если применимы — секунды, байты, доля `01`.
```toml
[server]
port = <N> # порт HTTP-сервера
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
users_while_list = ["<@name>"] # кому отвечает бот; строка автора Telegram
```
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и
начинают врать молча при смене умолчания. Действующие умолчания и их смысл живут
одним домом — таблица «Настройки с числовым значением» в
[../database.md](../database.md); `config.dist.toml` — источник истины по составу
полей.
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
## Поля по дискриминатору `type`
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
бекендов или внешних сервисов), обязательность и опциональность полей определяет
значение `type`, а не фиксированный список секции.
- **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор
обязательных полей; поля других значений не требуются. Неизвестное значение —
ошибка на старте с перечислением поддерживаемых.
- **Образец — по `type`.** В `config.dist.toml`:
- основное (умолчательное) значение **предзаполнено** рабочими значениями;
- альтернативные — **блоками-комментариями ниже**, каждый со своим описанием
полей (зачем, границы, единицы — как у обычных полей);
- так из примера видны все варианты и поля каждого, не открывая код.
Дискриминатора в transcriber пока нет; правило записано на случай второго
распознавателя.
## Секреты
Секреты доставляет **выкладка**, рендеря их прямо в `config.toml` (transcriber:
Ansible из `pet-project-server`). Приложение просто читает TOML — отдельного слоя
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
репозиторий и не окружение.
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
`yandex.object_storage_access_key_id`,
`yandex.object_storage_secret_access_key`.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
владелец — пользователь процесса (`1000:1000`).
- В `config.dist.toml` секретные поля — пустые строки.
*Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые
строки, и загрузчик их не отличает от настоящего значения.
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво
отрендеренный файл) — см. «Проверка и остановка на старте».
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
## Проверка и остановка на старте
Конфиг проверяем **на старте, до приёма трафика**. Негодный конфиг — лог `ERROR`
и выход с ненулевым кодом, не стартуем наполовину.
Что проверяем:
- обязательные поля заданы;
- каталоги хранилища существуют и доступны на запись;
- границы числовых полей соблюдены;
- ключи внешних сервисов не пусты.
*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
TOML. Пустой токен бота ловится в `NewTelegramController` уже после старта, и
приложение продолжает работу без бота; пустые ключи Yandex ловятся в
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
места проверки нет.
## Структура в коде
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
- Одна корневая структура `Config` с под-структурами по секциям (`Server`,
`Database`, `Storage`, `Yandex`, `Telegram`).
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
требует правки обоих мест.
+56
View File
@@ -0,0 +1,56 @@
# Конвенция: база данных и идентификаторы
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber этому не
следует ни в одном пункте: ключи — UUID v4, а не ULID; время — `time.Now()` по
месту вызова в локальной зоне, а не единой точкой в UTC; единой точки генерации
и разбора нет. Правила действуют на новый код; переписывание существующего —
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
**Механизировано:** ничего. Ни правила линтера, ни теста-сканера под эти пункты
в transcriber нет.
## Первичные ключи — ULID, не автоинкремент
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален между таблицами — поиск по голому id находит все
записи сущности в логах.
- **Точка генерации и разбора одна**: создание — при вставке записи в
репозитории, разбор — на входных границах. Самодельных генераторов по месту
вызова не заводим.
## Канонический вид — lowercase
- Генерим и храним id в **нижнем регистре**. Сравнение строк в SQLite
побайтовое, поэтому любой внешний id (URL, форма, поле запроса) обязательно
проходит разбор до запроса к БД — разбор проверяет формат и нормализует
регистр (base32 ULID нечувствителен к регистру при декодировании).
- Синтаксически неверный id считаем несуществующей сущностью (404), без похода
в БД.
## Естественные и составные ключи — для деталей
- У таблиц-деталей и связей допустим естественный или составной ключ вместо
ULID, когда он есть по природе данных. Отдельный ULID там — мёртвый вес.
- Прочие генерируемые идентификаторы — тем же способом, что и ключи сущностей:
единый формат, сортируемость, корреляция в логах.
## Прочее
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени.
- Миграции — goose (`migrations/`): SQL-файлы для DDL; Go-миграции
(`goose.AddMigrationContext`) — когда нужен код (генерация id, заполнение
задним числом). При изменении структуры обновляем схему
[../database.md](../database.md) тем же изменением.
- Добавляя колонку, соблюдай инвариант «Новая колонка правится во всех четырёх
местах» — [CLAUDE.md](../../CLAUDE.md), «Инварианты».
+145
View File
@@ -0,0 +1,145 @@
# Ошибки
Конвенция: *как* устроены и передаются ошибки в transcriber. Правила оформления
кода (How). Где и когда ошибку **логировать** — в [logging.md](logging.md),
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как ошибки
строятся, оборачиваются и проверяются.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
решают сами, а доменные ошибки проверяются приведением типа, а не `errors.As`.
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint` в
`.golangci.yml`. Запрета сторонних пакетов ошибок (`depguard`) нет — сторонних
пакетов ошибок в проекте и так нет.
## Базовая идиома: stdlib
- Только стандартный `errors` плюс `fmt.Errorf`: контекст ошибки несёт `slog`, а
не стек — стек-трейсы и внешний сборщик избыточны для домашнего сервиса.
- Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал
пересмотреть, а не умолчание.
## Обёртка и контекст
transcriber — **приложение, а не библиотека**: внешнего Go-API нет, весь код наш.
Поэтому внутри приложения обёртка `%w`**умолчание**, чтобы `errors.Is` и
`errors.As` работали сквозь слои.
- Добавляем контекст обёрткой: `fmt.Errorf("convert audio: %w", err)`.
- `%w` — когда вызывающий может смотреть причину (наш обычный случай). `%v`
когда причину сознательно **не** раскрываем.
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в цепочке, а
трансляцией на внешней границе (см. ниже).
Стиль сообщения:
- со строчной, без точки в конце, без «failed to» и «error» — обёртка и так
читается как «контекст: причина»;
- контекст — операция или субъект: `"acquire job: %w"`, а не
`"something failed"`;
- без заикания: каждый слой добавляет **свой** смысл, не повторяет нижний.
*Расхождение:* в коде преобладает форма `"failed to <действие>: %w"`.
## Проверка ошибок
- Граничные ошибки зависимостей **транслируем в доменные у источника**:
`sql.ErrNoRows` превращается в доменную ошибку в слое репозитория, чтобы выше
по коду не торчал `database/sql`.
- Проверяем `errors.Is` и `errors.As`, а не сравнением и не приведением типа.
*Расхождение, и оно опасно:* `NoopJobError` и `JobNotFoundError` проверяются
приведением типа — `err.(*contract.NoopJobError)` в
`internal/controller/worker/worker.go` и `err.(*contract.JobNotFoundError)` в
`internal/service/transcribe.go`. Работает это только потому, что на этом пути
ошибку никто не оборачивает. Первый же `fmt.Errorf("…: %w")` между ними сломает
проверку молча: воркер перестанет отличать «задач нет» от отказа и начнёт
считать пустой прогон ошибкой раз в секунду.
## Sentinel и типизированные
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий, на
которые ветвится код. Проверяем `errors.Is`.
- **Типизированная ошибка** (тип с полями плюс метод `Error()`) — когда
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
где хватает sentinel.
Сегодня в проекте три типизированные ошибки, и данные несёт только одна:
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
(состояние), `tg.EmptyBotTokenError` (без полей — уместнее sentinel).
## Граница и трансляция: приватный и публичный канал
Внутри — богатые обёрнутые ошибки. На внешней границе ошибку **транслируем**, и
форма зависит от канала и от того, кто его видит:
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
`%w` и контекстом. Пишется один раз на доменной границе — см.
[logging.md](logging.md).
- **Публичный канал — пользовательские поверхности** (Telegram, веб-UI, HTTP
API). Сюда отдаём:
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
найти полную ошибку в логах. «При обработке задачи произошла ошибка, job_id
= …», а не «произошла ошибка» и не сырой текст.
**Ключ есть не у всякого транспорта, и это называется вслух.** Отказ приёма
случается до заведения задачи, и ключа у него нет вовсе — тогда сообщение
остаётся без якоря, а диагностика ищется по записи доменной границы.
Заводить транспорту собственный идентификатор запроса ради ключа — решение
уровня спеки, а не умолчание;
- **отображение доменной ошибки в статус и сообщение** — единой точкой для
HTTP и веба:
| Доменная ошибка | Статус | Сообщение |
| --- | --- | --- |
| задача не найдена | 404 | «задача не найдена» |
| файл не приложен, формат не распознан | 400 | «некорректный ввод» |
| задача ещё выполняется, действие сейчас недопустимо | 409 | «действие недоступно в текущем состоянии» |
| прочее | 500 | «внутренняя ошибка» |
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
*Расхождение:* такой точки нет. `internal/controller/http/transcribe.go`
отвечает 404 на **любую** ошибку `GetByID`, включая сбой базы, и 500 на
любую ошибку заведения задачи.
### Разовый ответ и сохранённая диагностика
У публичной границы две поверхности, и правило сырого текста для них разное.
- **Разовый ответ на действие** (тело HTTP-ответа, сообщение бота по результату
команды) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
полная ошибка остаётся в логах по идентификатору задачи.
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
и полезен. Но:
- **секреты запрещены** — токены, ключи, пароли, заголовок
авторизации. Ошибка транспорта может нести URL с токеном внутри, и её
вычищают на границе клиента;
- это **не** канал для разовых отказов — те остаются нейтральными;
- **внешнее значение в тексте усекается на границе, а его размер называется
числом рядом**: без этого непонятно, насколько сокращать.
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения, а
пользователь Telegram видит отдельный человекочитаемый текст — это часть
правила соблюдена.
## panic
- `panic` — только для невосстановимого: нарушенный инвариант, ошибка
инициализации, из которой нельзя стартовать.
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
ронял процесс. В transcriber это `gin.Recovery()`; у воркеров и у бота такой
границы **нет**: паника в шаге конвейера роняет процесс целиком.
## Несколько ошибок
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
+248
View File
@@ -0,0 +1,248 @@
# Логирование
Конвенция: *как* и *когда* писать логи в transcriber. Это правила оформления
кода (How), а не спецификация поведения — наблюдаемые требования к логам (что
система обязана залогировать как часть контракта capability) живут в спеках
OpenSpec.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: обработчик текстовый, а не JSON; уровень зашит `INFO` и не
настраивается; `msg` — предложение с заглавной буквы, а не константная
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
ошибку выше, где её логируют снова.
**Механизировано:** ничего. Ни `sloglint`, ни `forbidigo` в `.golangci.yml` не
включены, поэтому правилами не выражено ни одно из перечисленного ниже.
## Принципы
- Структурированный JSON (`slog.JSONHandler`), один формат для разработки и для
продакшена.
- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
отдельный ключ с типизированным значением: это даёт отбор и сведение через
`jq` без регулярных выражений.
```json
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"job accepted","capability":"intake","job_id":"…","source":"telegram","duration_seconds":137}
```
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
## Сообщение
- `msg` — короткая константа в нижнем регистре: `job accepted`,
`recognition done`, `conversion failed`. Данные — в атрибутах:
`log.Info("job accepted", "job_id", id, "source", "telegram")`.
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
- **Смена состояния задачи — единая категория `state transition`** с полями
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
жизненный цикл собирался одним отбором:
`jq 'select(.msg=="state transition" and .job_id=="…")'`. Физический эффект
сверх перехода — отдельная запись своей категории (`file converted`,
`text delivered`), она запись перехода не подменяет.
*Расхождение:* сегодня `msg` — предложение вида `Starting conversion job`,
поля `capability` нет, отдельной категории перехода нет.
## Уровни
Принцип: уровень — это **адресат** («кому сообщение»), а не «насколько громко
сломалось». `slog` даёт четыре уровня; их и используем.
| Уровень | Кому и когда | Примеры в transcriber |
| --- | --- | --- |
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, задача ушла в `failed`, необработанная ошибка |
Правила:
- Уровень **не зависит от capability**: `ERROR` в приёме и в распознавании
одинаково серьёзны.
- `WARN` не значит «ничего страшного». `WARN` значит «может стать проблемой».
Если это не «может» — это `INFO`.
- Меняется адресат — меняется уровень. Негодный ввод от пользователя — это
`DEBUG` (норма, владельцу разбирать нечего), а не `ERROR`.
- **Событийное — `INFO`, рутинно-частое — `DEBUG`.** Операция по реальному
действию (приём записи, запуск распознавания, отправка текста) идёт на `INFO`.
Повторяющаяся служебная операция, которую запускает таймер или опрос и которая
сама по себе события не несёт (проверка здоровья, пустой прогон воркера,
опрос готовности операции), — на `DEBUG`: на `INFO` она зашумляет разбор.
- `slog` не разделяет CRITICAL и FATAL — сбой на старте логируем `ERROR` и
завершаем процесс с ненулевым кодом.
*Расхождение:* уровень зашит константой в `main.go`, `DEBUG` включить нечем.
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
## Время
- Поле — `time` (ключ `slog` по умолчанию).
- UTC, RFC 3339 с долями секунды, суффикс `Z`.
- Логи — **в UTC**, как и хранение в БД: это даёт однозначный порядок событий и
лексикографическую сортировку. Часовой пояс есть только у **отображения**.
## Поля: словарь имён
Главное условие — **единый словарь**: одно поле, одно имя по всему коду.
- Доменные поля — плоский `snake_case`.
- Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`,
`ext.*`.
- JSON плоский: все поля на верхнем уровне, без вложенности.
| Когда добавляем | Поля |
| --- | --- |
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`; пока их нет, поле не заполняется), `job_id`, `file_id`, `source` |
| на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
*Расхождение:* в коде встречаются `job_id`, `file_id`, `operation_id`,
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
пересечён с этим лишь частично.
## Корреляция по id сущности
Отдельный случайный `trace_id` не заводим — у сущностей уже есть стабильные
осмысленные ключи: идентификаторы задачи и файла, они лежат в базе.
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
Для задачи — логгер с уже подставленным ключом, протаскиваемый сквозь стадии,
чтобы ключ дописывался на каждую запись сам:
```go
log := log.With("job_id", job.Id, "capability", "conversion")
```
- Все записи одной задачи собираются одним отбором:
`jq 'select(.job_id=="…")' app.jsonl`.
## Ошибки
Ошибки Go логируем как атрибут, а не как текст сообщения:
`log.Error("conversion failed", "error", err, "job_id", id)`. Ключ — `error`.
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
накапливается в цепочке `%w`.
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
исход операции. Логирует эта единая точка, а не каждый транспорт — так
транспорты остаются тонкими. Границы в transcriber:
- приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`);
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
- завершение и отказ задачи (`completeJob`, `failJob`).
- Транспорты переводят возвращённую ошибку в свой ответ и **не логируют** её
повторно — иначе один сбой даёт дубли.
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой доменной
ошибки ровно один логирующий; уровень выбирает он.
| Класс отказа | Кому | Уровень |
| --- | --- | --- |
| негодный ввод, задача не найдена, действие сейчас недопустимо | пользователю (он уже получил ответ на поверхности) | `DEBUG` |
| запись распознана пустой, задача досталась повторно | владельцу, «может стать проблемой» | `WARN` |
| сбой БД, диска, недоступность внешнего сервиса | владельцу, в разбор | `ERROR` |
- **Повторяющийся сбой фонового цикла — `WARN`, а не `ERROR`.** Одиночный
промах шага временный: задача останется в своём состоянии, и следующий тик
повторит. Тот же класс сбоя в синхронной операции приёма — `ERROR`, потому что
операция провалилась целиком и повтора нет. Уровень задаёт не текст ошибки, а
наличие штатного повтора.
- Телеметрия внешнего вызова (`ext.*`, см. ниже) — отдельная запись о поведении
зависимости, а не дубль доменной ошибки.
- Глушить ошибку без лога — только с однострочным комментарием «почему».
*Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
записи. Плюс `internal/controller/http/transcribe.go` пишет через `log.Printf`
мимо `slog` целиком.
## Внешние сервисы: логируем все вызовы
**Каждый** вызов внешнего сервиса логируется. Поля:
- `ext.service``telegram`, `speechkit`, `object-storage`, `ffmpeg`;
- `ext.operation` — логическая операция (`getFile`, `sendMessage`,
`RecognizeFile`, `GetOperation`, `PutObject`, `convert`);
- `ext.status_code` — код ответа, если применим;
- `duration_ms` — длительность вызова;
- `retry` — номер попытки, если повторы были.
Уровни вызова:
- `INFO` — успешный **событийный** вызов (заливка объекта, запуск распознавания,
отправка сообщения, конвертация);
- `DEBUG` — успешный **рутинно-частый** вызов (опрос готовности операции,
длинный опрос обновлений);
- `WARN` — попытка не удалась, делаем повтор;
- `ERROR` — повторы исчерпаны либо сервис недоступен. Завершённый ответ с 4xx —
это успех на транспортном уровне; решение «это ошибка» принимает доменный
вызывающий.
Тело запроса и ответа — только на `DEBUG` и **после** вычистки секретов.
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
конвертация (через метрику длительности) и запуск распознавания; заливка в
Object Storage, скачивание файла из Telegram и опрос операции не логируются
никак.
## HTTP и проверка здоровья
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
`http.status_code`, `duration_ms`, `transport`.
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
потребитель молча оставит одно из значений. Правило проверяется чтением,
линтером не выражается.
- **`GET /health` и `GET /metrics` логируем на `DEBUG`** — их дёргают
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
`INFO` они не пишутся.
*Расхождение:* `sloggin` пишет все запросы одинаково, `/health` и `/metrics`
попадают в лог наравне с остальными.
## Безопасность: что не логируем
Никаких секретов в полях и сообщениях. Под запретом:
- токен бота Telegram;
- ключ SpeechKit и заголовок `Authorization`;
- пара ключей Object Storage;
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
переписки. Логируем длину текста, а не текст.
Дополнительно:
- Тела запросов и ответов внешних сервисов — только на `DEBUG`, с вычисткой
секретов и обрезкой по длине.
- При сомнении не логируем значение, логируем факт его наличия
(`"has_api_key", true`).
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
`*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram
живёт прямо в пути (`…/bot<TOKEN>/…`). Такую ошибку разворачивают в
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
*Расхождение:* вычистки нет. Скачивание файла из Telegram идёт обычным
`http.Get(file.Link(token))`, и ошибка этого вызова содержит токен бота. Сегодня
она не логируется — то есть утечки нет, но защищает от неё только отсутствие
строки лога.
## Куда пишем и уровень
- Пишем JSON в `stdout` одним потоком; сбор и ротацию делает окружение. Не
раскладываем по файлам.
- Базовый уровень в продакшене — `INFO`; `DEBUG` включается конфигом при
необходимости. При разработке — `DEBUG`.
*Расхождение:* поля конфигурации под уровень лога нет.
## Анализ
- Повседневно — `jq`: `jq 'select(.job_id=="…")' app.jsonl`.
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
+146
View File
@@ -0,0 +1,146 @@
# Веб-UI (htmx)
Конвенция: *как* мы пишем код веб-UI — частичная замена фрагментов, опрос живых
обновлений, обработчики действий, деградация без JS, ошибки. Это правила
оформления кода (How), а не спецификация поведения — что именно UI показывает и
какие действия обязан поддерживать, живёт в спеке OpenSpec.
**Взято из проекта jellybit и записано наперёд: веб-UI в transcriber нет вовсе.**
Есть только HTTP API на gin. Ни одного расхождения назвать нельзя — нечему
расходиться; правила действуют с первой страницы, которую заведём.
Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок
наружу — [errors.md](errors.md). Здесь — только особенности htmx-транспорта, без
повторения.
## Стек и границы
htmx-first: `gin` плюс `html/template` (рендер на сервере) плюс htmx. Ничего
сверх этого: **без шага сборки, без Node и сборщика, без реактивных
фреймворков**. htmx вендорится и раздаётся с нашего же хоста (`go:embed`,
`/static/vendor/`), без CDN.
- Свой JS сведён к минимуму: только то, чего серверу знать не нужно.
**Клиентского пересчёта доменного состояния нет** — состояние считает сервер,
клиент лишь подменяет присланную разметку.
- Alpine.js и SPA сознательно **не вводим**. Понадобится реактивный клиентский
виджет — вводим отдельным изменением и записываем решение, не раньше.
## Единый источник разметки: партиал равен странице равен фрагменту
Переиспользуемый кусок — это `{{define "name"}}` в каталоге партиалов. Тот же
`{{define}}` рендерится **и** внутри страницы (`{{template "name" .}}`), **и**
как ответ-фрагмент того же обработчика. Отдельной разметки под фрагмент не
заводим — иначе она разъедется со страницей.
**Инвариант: корень `{{define}}` — это элемент с целевым `id`**, например
`#job-{id}`. `hx-swap="outerHTML"` заменяет весь корневой узел; если ответный
фрагмент не несёт тот же корневой `id`, следующее действие или опрос не найдёт
цель. Разметку и `id` держим в одном партиале.
Сборку данных для шаблона выносим в отдельную функцию и зовём её и на полной
странице, и во фрагменте — чтобы htmx-ветка не копировала сборку.
## Обработчик действия: ветвление htmx и редирект
htmx-запрос определяем по заголовку `HX-Request: true`.
Обработчик действия зовёт доменную операцию **одинаково** в обеих ветках, а
дальше ветвится: без htmx — привычный редирект после POST (303); с htmx —
перечитать актуальное состояние, собрать данные тем же сборщиком и отдать
фрагмент.
Рендер именованного шаблона идёт **в буфер** и только затем пишется в ответ: при
ошибке шаблона клиент не получит полстраницы.
## Деградация без JS обязательна
Формы действий остаются обычными `<form method="post" action="...">`, а
`hx-post`, `hx-target` и `hx-swap` лишь **накладываются сверху** на ту же форму.
Без JS всё работает через POST и редирект. Атрибут `action` — рабочий запасной
путь, а не украшение.
Фильтр, поиск и разбиение списка на страницы — **серверные**, параметрами
запроса, тоже без JS. Клиентской фильтрации нет намеренно.
## Ошибки на htmx-пути: HTTP 200 и фрагмент
htmx по умолчанию **не подменяет DOM на ответы 4xx и 5xx**. Поэтому при ошибке
действия обработчик отвечает **200 с фрагментом**, несущим сообщение. Доменную
ошибку на htmx-пути **не** транслируем в HTTP-статус — в отличие от API и от
пути без JS.
- Сообщение — нейтральный текст публичного канала (см. [errors.md](errors.md));
сырой `err.Error()` наружу не идёт.
- Ошибку кладём в **отдельное поле** под ошибку действия, не перегружая доменные
поля: непустое доменное поле перекрыло бы сообщение.
- **При ошибке активное состояние не меняем** — перечитанные данные показывают
прежний выбор плюс сообщение.
## Живой опрос
Приём живого обновления: эндпоинт фрагмента плюс в разметке `hx-get`,
`hx-trigger="every Ns"` и `hx-swap="outerHTML"`. Прямой предмет опроса в
transcriber — карточка задачи, пока та не дошла до `done` или `failed`.
- **Один опросчик на обновляемый корень.** Опрашивает себя корень поверхности, а
вложенные живые области своего `hx-get` **не несут**: подмена корня уносит их
вместе с таймером, и два опроса подменяли бы разметку друг друга.
- **Опросчик самозавершается.** Опрос идёт, пока предмет может измениться без
участия браузера; перестал — фрагмент возвращается **без `hx-*`**, и htmx
больше не опрашивает. Для задачи это значит: `created`, `converted` и
`transcribe` наблюдаемы, `done` и `failed` — нет.
- **Отказ тика тоже самозавершается.** Не сумев прочитать задачу, тик отвечает
`200` и фрагментом с объяснением **без `hx-*`**: htmx не подменяет DOM на
`4xx` и `5xx`, поэтому статус ошибки оставил бы поверхность навсегда прежней, а
опрос — бесконечным. Фрагмент отказа обязан нести корневой `id` того узла,
который он собой заменяет.
- **Уровень лога у тика — `WARN`.** У повторяющегося опроса есть штатный повтор;
`ERROR` оставляем разовому действию человека (см. [logging.md](logging.md)).
- **Подмена всего фрагмента через `outerHTML`** удаляет старый узел вместе с его
опросчиком, и htmx заново размечает новый — двойного опроса нет **при условии
совпадения корневого `id`**. Эфемерное состояние разметки подмену не
переживает: то, что должно пережить тик (раскрытый `<details>`), помечается
`hx-preserve`.
- **Частота — по цене тика, и она называется числом** в таблице настроек
[../database.md](../database.md) в тот же момент, когда заводится первый
опрашиваемый экран; сегодня такой настройки нет. Опрашивать чаще, чем меняется
источник, бессмысленно: задачу двигает воркер с шагом в секунду.
- **Тик ходит в БД, и это цена решения.** Читать состояние задачи дешевле, чем
держать снимок в памяти, но каждый открытый браузер добавляет запросов.
## Подмена сохраняет контекст; выход — навигация
`hx-swap="outerHTML"` не сбрасывает прокрутку и не трогает серверные фильтр,
поиск и страницу (они в параметрах запроса). Действие **не должно уводить**
пользователя со страницы, если предмет остаётся на ней.
Действие, после которого предмет **покидает** страницу (удаление записи),
остаётся **обычной POST-формой без `hx-*`**, то есть полной навигацией. Признак
«это выход» — форма без htmx-атрибутов; так не нужен `HX-Redirect`, а «уйти с
экрана» выражено самой навигацией.
**Асинхронные действия.** Доменное действие асинхронно почти всегда: загрузка
записи только заводит задачу, работу доделывают воркеры. Подмена отдаёт
**промежуточное** состояние, а не мнимый результат; готовый итог догоняем
самозавершающимся опросом. Мгновенный итог в UI не обещаем.
## Различение поверхности одного действия
Один и тот же роут действия, вызванный с разных страниц, отдаёт разные фрагменты.
Различаем **явным скрытым полем формы** `surface=list|detail`, а не догадкой по
`HX-Target` или `Referer`: поле самодокументируемо и не зависит от разрешения
цели.
## Статика, вендоринг, кэш
- Ресурсы встроены через `go:embed`, отдаются под `/static/` с длинным
неизменяемым кэшем (`Cache-Control: public, max-age=31536000, immutable`).
- Меняемые ресурсы (css, js) версионируются параметром `?v=<версия>` — коротким
sha256 их содержимого, URL строит помощник шаблона. Свежая выкладка не отдаёт
устаревший файл.
- Вендор (htmx, шрифты) адресуется по **неизменному имени файла**, и параметр
версии ему не нужен. В git его **не коммитим**; задача сборки идемпотентно
добывает его по манифесту со сверкой sha256.
- Шрифты и скрипты — **со своего хоста**, без внешних. Бинарник самодостаточен,
внешних ресурсов времени выполнения нет.