Канон документов, каталог задач и OpenSpec
docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью. Конвенции перенесены из jellybit; места, где код им не следует, помечены строкой «Расхождение» как объявленный долг. tasks/ с роадмапом: две достигнутые цели, две запланированные (веб и многопользовательский режим), два направления (все форматы, долгие записи) и пять задач в беклоге. openspec/config.yaml — маршрутизатор с адресами документов, спек пока нет. CLAUDE.md переписан по форме канона: инварианты с severity, семантика гейта, запреты с путями. Taskfile получил task gate.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"canon": 12,
|
||||
"migrations": "migrations"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# Журнал решений
|
||||
|
||||
Одна запись — одно решение. **ADR переносит решение из архивного `design.md`**, а
|
||||
не сочиняет его заново: запись цитирует решение и ссылается на
|
||||
`openspec/changes/archive/<id>/design.md`.
|
||||
|
||||
## Когда заводить
|
||||
|
||||
Верно одно из трёх:
|
||||
|
||||
- **дорогой откат** — переделка стоит дороже переписывания одного файла;
|
||||
- **намеренный отказ** от очевидного подхода;
|
||||
- **пересмотр прежнего решения** — тогда у старой записи обязателен статус
|
||||
«заменено на».
|
||||
|
||||
Не заводить для рутины и для того, что видно из кода и `git log`.
|
||||
|
||||
## Соглашения
|
||||
|
||||
- Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, дата — когда решение реально принято.
|
||||
Слаг **английский по сути, а не транслитом**: `queue-as-table`, не
|
||||
`ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`.
|
||||
- Записи неизменяемы: передумали — новая запись, старой ставится статус.
|
||||
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
|
||||
`устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и
|
||||
источником, а не абзацем в теле.
|
||||
|
||||
## Записи
|
||||
|
||||
Новые сверху.
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
|
||||
Записей нет: канон заведён 2026-08-10, а решения, принятые до него, источника в
|
||||
архиве изменений не имеют — сочинять их задним числом правило запрещает.
|
||||
Ближайшие кандидаты назовёт первое же изменение, которое тронет хранилище или
|
||||
вход: замена SQLite на PocketBase и вход через OIDC оба проходят триггер
|
||||
«дорогой откат».
|
||||
@@ -0,0 +1,22 @@
|
||||
# Краткий заголовок решения
|
||||
|
||||
- **Дата:** ГГГГ-ММ-ДД
|
||||
- **Источник:** openspec/changes/archive/<id>/design.md
|
||||
|
||||
Статус ставится тем же полем и только при пересмотре:
|
||||
`- **Статус:** заменено на ADR-…` либо `- **Статус:** устарело`.
|
||||
У активной записи поля нет.
|
||||
|
||||
## Решение
|
||||
|
||||
Что именно решено — одной фразой.
|
||||
|
||||
## Почему
|
||||
|
||||
Намерение и причина. Цитата из источника, а не пересказ. Пиши так, чтобы через
|
||||
год было понятно без чтения переписки.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` что стало лучше.
|
||||
- `−` чем платим: ограничения, риски, нагрузка на сопровождение.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Архитектура
|
||||
|
||||
Обзор: как сложено и где что работает. **Поведение системы здесь не описывается**
|
||||
— нормативно оно живёт в `openspec/specs/`. Места, где оно всё-таки описано,
|
||||
помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
|
||||
|
||||
Документ описывает **сегодняшнее** устройство. Куда проект идёт — в
|
||||
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
|
||||
этого ещё не решено — в разделе «Открытые вопросы».
|
||||
|
||||
Спеки ещё не заведены: capability ни одной, поведение живёт только в коде.
|
||||
Первая задача, которая трогает поведение, заводит спеку — до тех пор у темы
|
||||
`requirements` нормативного документа нет.
|
||||
|
||||
## Принципы
|
||||
|
||||
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
||||
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
||||
- **Очередь таблицей.** Состояние задачи лежит в SQLite, воркер забирает работу
|
||||
запросом с захватом. Внешний брокер не заводим: нагрузка — единицы записей в
|
||||
день (оценка владельца, не замер: `research/` пуст).
|
||||
- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине,
|
||||
достаётся снова по истечении срока захвата и проходит шаг заново.
|
||||
- **Ядро зависит от интерфейсов.** `internal/service` знает только
|
||||
`internal/contract`; ffmpeg, Yandex, Telegram и SQLite подставляются в
|
||||
`main.go`.
|
||||
|
||||
## Компоненты
|
||||
|
||||
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
|
||||
|
||||
<!-- канон: поведение → openspec/specs/intake, delivery -->
|
||||
|
||||
| Компонент | Где | Что делает |
|
||||
| --- | --- | --- |
|
||||
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
|
||||
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
|
||||
| Воркеры | `internal/controller/worker` | Крутят по одному шагу конвейера, опрашивая базу |
|
||||
| Сервис расшифровки | `internal/service` | Конвейер: приём, конвертация, распознавание, отдача результата |
|
||||
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
||||
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
|
||||
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
||||
| Репозитории | `internal/adapter/repo/sqlite` | Задачи и файлы, запросы через goqu |
|
||||
|
||||
<!-- канон: поведение → openspec/specs/pipeline -->
|
||||
|
||||
Конвейер: `created` → `converted` → `transcribe` → `done` либо `failed`. Три
|
||||
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду.
|
||||
|
||||
## Внешние границы и форматы
|
||||
|
||||
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
|
||||
Файл скачивается по ссылке `file.Link(token)` обычным `http.Get`. Telegram не
|
||||
отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
|
||||
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
|
||||
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
|
||||
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
|
||||
`deferred-general`, авторизация заголовком `Api-Key`. Распознавание
|
||||
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
|
||||
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
|
||||
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
|
||||
|
||||
## Эксплуатация
|
||||
|
||||
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
|
||||
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
|
||||
обратный прокси, который публикует HTTP-порт наружу.
|
||||
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
||||
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
||||
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
||||
|
||||
<!-- канон: поведение → openspec/specs/conversion, recognition -->
|
||||
|
||||
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
|
||||
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
|
||||
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||
| SQLite (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||
|
||||
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
|
||||
или по сообщению об ошибке. Владелец — по метрике
|
||||
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
|
||||
Отдельного оповещения нет.
|
||||
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
|
||||
три воркера опрашивают базу раз в секунду вхолостую.
|
||||
|
||||
## Единые точки проекта
|
||||
|
||||
| Что | Где |
|
||||
| --- | --- |
|
||||
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
|
||||
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` |
|
||||
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
|
||||
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
|
||||
| Разбор конфигурации | `internal/config.LoadConfig` |
|
||||
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
||||
|
||||
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
||||
генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()`
|
||||
по месту, отображения доменной ошибки в код HTTP-ответа нет — обработчик решает
|
||||
сам.
|
||||
|
||||
## Деплой
|
||||
|
||||
Образ собирается по контракту роли `app_image`: `task image` даёт
|
||||
`transcriber:$BUILD_ID`, по умолчанию `transcriber:dev`. Реестр не участвует —
|
||||
образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой
|
||||
`inv pl -- transcriber` из `pet-project-server`.
|
||||
|
||||
Сборка двухступенчатая, финальный слой — alpine с `ca-certificates` и `ffmpeg`,
|
||||
процесс работает под непривилегированным пользователем `transcriber`.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- **Хранилище.** Пробуем PocketBase взамен SQLite с goqu и goose. Не решено, чем
|
||||
становится конвейер задач: таблицей PocketBase с тем же захватом или чем-то
|
||||
другим. Данные не переносим — начинаем с чистого листа.
|
||||
- **Учётные записи.** Вход через OIDC, провайдер — Authelia. Не решено, где
|
||||
живёт сессия и как связываются пользователь Telegram и пользователь веба.
|
||||
- **Веб-интерфейс.** Формы нет вовсе, есть только API. Конвенция веб-UI на htmx
|
||||
описана в [conventions/web-ui.md](conventions/web-ui.md) заранее.
|
||||
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём
|
||||
из Telegram — точно, ограничения `deferred-general` по длине — нет.
|
||||
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
|
||||
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
|
||||
сервис определяет содержимое сам, то ли часть записей теряется на этом.
|
||||
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
|
||||
конвертер этот случай не проверялся.
|
||||
@@ -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`), ни
|
||||
архитектурные тесты-сканеры. Это следующий шаг переноса в правило: свойство,
|
||||
оставшееся прозой, проверяет человек на каждом ревью заново.
|
||||
@@ -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` коммитим как единый справочник по конфигу: все секции и все
|
||||
поля. **Каждое поле снабжаем комментарием**, из которого ясно:
|
||||
|
||||
- **зачем** поле — что оно меняет в поведении;
|
||||
- **допустимые значения** — перечисление или границы;
|
||||
- **единицы измерения**, если применимы — секунды, байты, доля `0–1`.
|
||||
|
||||
```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()`, файл их перекрывает. Новое поле
|
||||
требует правки обоих мест.
|
||||
@@ -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), «Инварианты».
|
||||
@@ -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`.
|
||||
@@ -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 прямо из файла.
|
||||
@@ -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.
|
||||
- Шрифты и скрипты — **со своего хоста**, без внешних. Бинарник самодостаточен,
|
||||
внешних ресурсов времени выполнения нет.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Схема хранилища
|
||||
|
||||
СУБД, миграции, правило времени и идентификаторов.
|
||||
|
||||
СУБД — SQLite, драйвер `mattn/go-sqlite3` (нужен CGO). Запросы строит
|
||||
`doug-martin/goqu` с диалектом `sqlite3`. Миграции — `pressly/goose`, каталог
|
||||
`migrations/`, вшит в бинарник через `//go:embed migrations/*.sql` в `main.go` и
|
||||
накатывается при старте. Новый файл достаточно положить в каталог.
|
||||
|
||||
**Идентификаторы** — UUID v4 строкой.
|
||||
|
||||
**Время** — локальная зона процесса, UTC не навязан. Колонки `created_at` и
|
||||
`updated_at` проставляет приложение, а не СУБД; умолчание `CURRENT_TIMESTAMP`
|
||||
стоит только у `files.created_at`.
|
||||
|
||||
Того, что единой точки генерации идентификатора и времени нет, здесь не
|
||||
повторяем: перечень единых точек и их отсутствий держит
|
||||
[architecture.md](architecture.md), «Единые точки проекта».
|
||||
|
||||
Переход на PocketBase запланирован, но не начат — здесь описано сегодняшнее
|
||||
состояние. Открытые вопросы перехода — в
|
||||
[architecture.md](architecture.md), раздел «Открытые вопросы».
|
||||
|
||||
## Таблицы
|
||||
|
||||
### `files`
|
||||
|
||||
Один файл на одну физическую копию: исходник, результат конвертации и копия в
|
||||
Object Storage — три разные записи.
|
||||
|
||||
| Колонка | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | UUID файла |
|
||||
| `storage` | TEXT | `local` или `s3` |
|
||||
| `file_name` | TEXT | Имя в хранилище: UUID с расширением |
|
||||
| `size` | INTEGER | Размер в байтах |
|
||||
| `created_at` | DATETIME | Умолчание `CURRENT_TIMESTAMP` |
|
||||
|
||||
### `transcribe_jobs`
|
||||
|
||||
Задача расшифровки и она же очередь.
|
||||
|
||||
| Колонка | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | UUID задачи |
|
||||
| `state` | TEXT | `created`, `converted`, `transcribe`, `done`, `failed` |
|
||||
| `source` | TEXT | `api`, `telegram`, `unknown`; умолчание `unknown` |
|
||||
| `file_id` | TEXT FK → `files.id` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
|
||||
| `delay_time` | DATETIME | Не брать задачу раньше этого времени |
|
||||
| `acquisition_id` | TEXT | Кто захватил задачу |
|
||||
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание |
|
||||
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
|
||||
| `transcription_text` | TEXT | Результат распознавания |
|
||||
| `is_error` | BOOLEAN | Задача с `1` из выборки исключена навсегда |
|
||||
| `error_text` | TEXT | Текст ошибки, машинный |
|
||||
| `tg_chat_id` | INTEGER | Куда отправить результат |
|
||||
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
|
||||
| `created_at`, `updated_at` | DATETIME | Проставляет приложение |
|
||||
|
||||
Индексов, кроме первичных ключей, нет. Выборка воркера идёт полным перебором по
|
||||
`state`, `is_error`, `delay_time` и `acquire_time`.
|
||||
|
||||
## Представление данных
|
||||
|
||||
Чем физически лежит запись и что происходит при чтении и записи.
|
||||
|
||||
- **Расшифровка лежит целиком в колонке `transcription_text`** одной строкой.
|
||||
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она
|
||||
целиком при каждом `GetByID` и при каждом захвате задачи воркером.
|
||||
- **Аудио в базе не лежит.** На диске — каталог `data/files`, плоский, имя файла
|
||||
равно UUID с расширением. Ни файлы, ни объекты в Object Storage не удаляются
|
||||
после завершения задачи: каталог и бакет растут неограниченно.
|
||||
- **Захват задачи — два запроса подряд, не транзакция.** Сперва `UPDATE …
|
||||
WHERE id = (SELECT … LIMIT 1)` проставляет `acquisition_id`, затем отдельный
|
||||
`SELECT … WHERE acquisition_id = ?` читает строку. Репозиторий сверяет число
|
||||
затронутых строк с ожидаемым, но между запросами задачу может перехватить
|
||||
другой воркер с тем же значением — на одном процессе это не наблюдалось.
|
||||
- **Список колонок задан не одним местом** — четырьмя запросами файла
|
||||
`internal/adapter/repo/sqlite/transcript_job_repo.go`. Правило правки всех
|
||||
четырёх и его severity — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
|
||||
|
||||
## Настройки с числовым значением
|
||||
|
||||
| Настройка | Значение | Где |
|
||||
| --- | --- | --- |
|
||||
| Срок захвата, конвертация и распознавание | 1 час | `service/transcribe.go`, вызовы `findJob` |
|
||||
| Срок захвата, проверка операции | 24 часа | там же |
|
||||
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` |
|
||||
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` |
|
||||
| Задержка между проверками операции | 5 секунд | там же |
|
||||
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` |
|
||||
| Память под multipart-загрузку | 32 МиБ | `main.go`, `router.MaxMultipartMemory` |
|
||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` |
|
||||
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` |
|
||||
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` |
|
||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` |
|
||||
|
||||
Чего среди настроек **нет**: режим журналирования SQLite не задан (значение по
|
||||
умолчанию, не WAL), таймаут занятости не задан, размер пула соединений не задан,
|
||||
срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к Telegram, S3 и
|
||||
SpeechKit тоже нет — ни одного.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Паспорт проекта
|
||||
|
||||
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
|
||||
устроено», [tasks/ROADMAP.md](../tasks/ROADMAP.md) — «в каком порядке», паспорт —
|
||||
«зачем и для кого».
|
||||
|
||||
## Цель
|
||||
|
||||
Превращать записанную речь в текст, который можно читать и искать.
|
||||
|
||||
**Потребители** — список закрытый: он определяет, что считать нужным, а что
|
||||
интересным.
|
||||
|
||||
| Кто | Что ему нужно от нас |
|
||||
| --- | --- |
|
||||
| Владелец сервиса | Отправить голосовое сообщение из Telegram и получить текст ответом. Работает сегодня |
|
||||
| Приглашённый пользователь | Войти в веб через свою учётную запись, загрузить запись, забрать текст. Каждый видит только свои записи |
|
||||
| Внешняя программа | Отдать файл по HTTP и опросить готовность. Работает сегодня, без разграничения доступа |
|
||||
|
||||
Цель достигнута, когда:
|
||||
|
||||
- запись любого распространённого формата принимается без предварительной
|
||||
подготовки, включая дорожку из видео;
|
||||
- запись длиной в несколько часов доходит до текста, а не прерывается ошибкой при
|
||||
достижении предела;
|
||||
- сервисом пользуются несколько человек, и записи одного не видны другому;
|
||||
- текст доступен там же, где загружали, — в вебе и в Telegram.
|
||||
|
||||
## Что целью не является
|
||||
|
||||
Граница домена. По ней в теме `architecture` судят, не перенесено ли понятие
|
||||
через границу.
|
||||
|
||||
- **Редактор текста.** Расшифровку отдаём как есть; правка, разметка, экспорт в
|
||||
форматы документов — не наша работа.
|
||||
- **Хранилище записей.** Отдаём текст и на этом заканчиваем: библиотекой, архивом
|
||||
и поиском по прошлым записям сервис не становится. Сколько запись лежит на
|
||||
диске после обработки — вопрос срока хранения, а его нет вовсе:
|
||||
[database.md](database.md), «Представление данных».
|
||||
- **Понимание сказанного.** Пересказ, выжимка, ответы на вопросы по записи, поиск
|
||||
по смыслу — за границей: мы отдаём текст, а не выводы из него.
|
||||
- **Собственное распознавание.** Модель не обучаем и не держим у себя, речь
|
||||
распознаёт внешний сервис.
|
||||
- **Управление учётными записями.** Пользователей заводит и проверяет внешний
|
||||
провайдер, свою регистрацию и свои пароли не делаем.
|
||||
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
|
||||
обрабатываем.
|
||||
|
||||
## Типовые сценарии
|
||||
|
||||
1. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот
|
||||
отвечает «обрабатываю», через минуту приходит текст ответом на то же
|
||||
сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят
|
||||
несколькими частями.
|
||||
2. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот
|
||||
отличает их по MIME-типу и расширению.
|
||||
3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio`, получает идентификатор
|
||||
задачи и опрашивает `GET /api/status/:id`, пока не увидит `done` и текст.
|
||||
4. **Отказ на середине.** Конвертация или распознавание не удались — задача
|
||||
переходит в `failed`, а пользователь Telegram получает сообщение о том, что
|
||||
именно не вышло, и предложение повторить.
|
||||
|
||||
## Референсы
|
||||
|
||||
Где смотреть prior art, когда упёрлись.
|
||||
|
||||
- **Yandex SpeechKit, отложенное распознавание** — модель `deferred-general`,
|
||||
которой пользуемся: она и задаёт потолок по длине записи и формату.
|
||||
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
|
||||
перестанет устраивать по цене или по качеству русской речи.
|
||||
- **PocketBase** — кандидат в хранилище взамен сегодняшнего SQLite. Источником
|
||||
учётных записей его не рассматриваем: вход решено делать через OIDC у Authelia
|
||||
([architecture.md](architecture.md), «Открытые вопросы»).
|
||||
@@ -0,0 +1,22 @@
|
||||
# Разведка
|
||||
|
||||
Наблюдения за внешним миром: что реально шлёт источник, чем документация формата
|
||||
расходится с практикой. Источник истины — этот каталог, а не чужая документация.
|
||||
|
||||
**Каждый вывод — с числами и командой, которой получен**, чтобы его можно было
|
||||
перепроверить.
|
||||
|
||||
## Как снималось
|
||||
|
||||
Ничего не снималось. Записей нет: замеров на живом потоке не делали, поведение
|
||||
внешних сервисов на границах не проверяли.
|
||||
|
||||
Внешних источников, о которых разведка нужна, четыре — Telegram Bot API, Yandex
|
||||
SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, что стоит
|
||||
открытыми вопросами в [../architecture.md](../architecture.md) — «Долгие
|
||||
записи», «Формат для распознавания», «Видео». Первый же ответ на любой из них
|
||||
заводит здесь запись с командой и условиями замера.
|
||||
|
||||
## Записи
|
||||
|
||||
Записей нет.
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
# Ревью: настройка и журнал
|
||||
|
||||
## Как настроен конвейер
|
||||
|
||||
Конвейера ревью в проекте пока нет: плагин не подключён, ни одного прогона не
|
||||
было. Раздел заполнен наперёд по коду — он и служит настройкой первому прогону.
|
||||
|
||||
### Типовые узлы
|
||||
|
||||
Рода узлов проекта и проверяемые свойства к каждому.
|
||||
|
||||
**Шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
|
||||
`FindAndRunTranscribeCheckJob`):
|
||||
|
||||
- отличает «задач нет» от отказа и не считает первое ошибкой;
|
||||
- при отказе на середине оставляет задачу в состоянии, из которого повтор
|
||||
корректен, либо переводит в `failed` осознанно;
|
||||
- не теряет ссылку на файл: `job.FileID` переставляется только после того, как
|
||||
запись о новом файле создана;
|
||||
- повтор шага на той же задаче не создаёт лишних файлов и записей;
|
||||
- отвечает пользователю ровно один раз.
|
||||
|
||||
**Транспорт** (`internal/controller/tg`, `internal/controller/http`):
|
||||
|
||||
- проверяет право отправителя до всякой работы;
|
||||
- не логирует ошибку, которую уже залогировал доменный слой;
|
||||
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
|
||||
- закрывает то, что открыл, на всех ветках выхода.
|
||||
|
||||
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`):
|
||||
|
||||
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
|
||||
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
|
||||
- различает «сервис ответил отказом» и «сервис недоступен»;
|
||||
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
|
||||
успех молча.
|
||||
|
||||
**Репозиторий SQLite** (`adapter/repo/sqlite`):
|
||||
|
||||
- список колонок совпадает во всех четырёх запросах файла;
|
||||
- `NULL` в колонке разбирается в указатель, а не роняет `Scan`;
|
||||
- захват задачи не выдаёт одну строку двум вызывающим;
|
||||
- ошибка драйвера транслируется в доменную у источника.
|
||||
|
||||
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
|
||||
`adapter/metaviewer/ffmpeg`):
|
||||
|
||||
- отсутствие программы в `PATH` отличается от отказа обработки;
|
||||
- вход, пришедший от пользователя, не попадает в аргументы командной строки
|
||||
неразобранным;
|
||||
- пустой или частично записанный выходной файл считается отказом;
|
||||
- процесс не висит вечно.
|
||||
|
||||
**Любой узел** — сверх свойств своего рода:
|
||||
|
||||
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
|
||||
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
|
||||
становится неотличим от привычного шума (журнал, запись 2026-08-10).
|
||||
|
||||
### Типовые ложноположительные
|
||||
|
||||
- **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач
|
||||
в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не
|
||||
логирует его и не считает в метрику. Настоящий дефект рядом другой — проверка
|
||||
идёт приведением типа и сломается при первой же обёртке; он уже записан в
|
||||
[conventions/errors.md](conventions/errors.md).
|
||||
- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её
|
||||
нет: три воркера читают три разных состояния, и одну строку они не делят.
|
||||
Находка становится настоящей ровно тогда, когда появится второй экземпляр
|
||||
процесса или второй воркер на то же состояние.
|
||||
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
||||
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
||||
Новой находкой это не считается, пока не измерен рост.
|
||||
- **«HTTP API открыт без аутентификации».** Известно и записано первой строкой
|
||||
[security.md](security.md). Находкой считается только новая поверхность,
|
||||
выставленная наружу, а не повторение этого факта.
|
||||
|
||||
### Вопросы по темам
|
||||
|
||||
Форма: `<тема>: <вопрос> (<провенанс>)`.
|
||||
|
||||
- `operations`: пережил ли шаг конвейера отмену контекста на середине — воркеры
|
||||
получают `ctx`, но ни один шаг его внутрь не передаёт (чтение `worker.go` и
|
||||
`transcribe.go`, 2026-08-10).
|
||||
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
||||
одного из них таймаута нет (чтение `tg.go`, `s3.go`, `speechkit.go`,
|
||||
2026-08-10).
|
||||
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
|
||||
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
|
||||
2026-08-10).
|
||||
- `security`: не попал ли в лог текст расшифровки, имя файла пользователя или
|
||||
URL с токеном бота (запрет в [security.md](security.md) и
|
||||
[conventions/logging.md](conventions/logging.md)).
|
||||
- `security`: не строится ли путь на диске или ключ объекта из значения,
|
||||
пришедшего снаружи, — расширение файла сегодня берётся из имени отправителя
|
||||
(чтение `service/transcribe.go`, 2026-08-10).
|
||||
- `architecture`: не появился ли второй путь приёма мимо
|
||||
`createTranscribeJob` — сегодня через него идут оба входа
|
||||
([architecture.md](architecture.md), «Единые точки проекта»).
|
||||
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
|
||||
спек ещё нет, и соблазн описать поведение в обзоре максимальный.
|
||||
- `conventions`: новая колонка правится во всех четырёх местах репозитория
|
||||
(CLAUDE.md, «Инварианты»).
|
||||
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня
|
||||
тестов два файла, и оба мимо конвейера.
|
||||
|
||||
### Триггеры метки
|
||||
|
||||
Проектная конкретизация правила выбора метки. Умолчание — `medium`.
|
||||
|
||||
**Крупное здесь** (поднимает до `large`, ось объёма):
|
||||
|
||||
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
|
||||
колонку разом;
|
||||
- замена хранилища или переход на PocketBase — любой её кусок;
|
||||
- введение веб-UI: транспорт, шаблоны и статика одновременно;
|
||||
- изменение, трогающее оба входа сразу — Telegram и HTTP.
|
||||
|
||||
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
|
||||
|
||||
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
|
||||
пользователь веба, до начала работы назвать нельзя;
|
||||
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
|
||||
форма решения зависит от замера;
|
||||
- приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой;
|
||||
- всё, что требует записи в `research/` прежде, чем начать.
|
||||
|
||||
**Мелкое здесь** (опускает до `small`):
|
||||
|
||||
- правка текста, который видит пользователь Telegram;
|
||||
- новая метрика в `internal/metrics`;
|
||||
- правка `config.dist.toml` и умолчаний `defaultConfig()` без нового поля;
|
||||
- правка документов канона.
|
||||
|
||||
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
|
||||
API и имя не откатываются обратной правкой после мерджа — какими бы маленькими
|
||||
ни были, они не `small`.
|
||||
|
||||
### Недоступно проверке
|
||||
|
||||
**Не проверит ни один проход:**
|
||||
|
||||
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
|
||||
SpeechKit и Object Storage поднять в тесте нечем;
|
||||
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
|
||||
день, и утверждения о росте остаются условиями, а не замерами;
|
||||
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
||||
отдан внешней программе, и она вне нашей границы.
|
||||
|
||||
**Перестали проверять сознательно:**
|
||||
|
||||
Ничего не отключали — проверять пока и не начинали.
|
||||
|
||||
## Журнал дефектов
|
||||
|
||||
Первая запись найдена прогоном гейта при заведении канона 2026-08-10, две
|
||||
нижние восстановлены по истории git тогда же. Все три помечены `проскочил`:
|
||||
ревью тогда не было, и поймать их было некому. У восстановленных нет поля «Чем
|
||||
воспроизведён», и выдумывать его задним числом нельзя.
|
||||
|
||||
## 2026-08-10 — тесты http-обработчика ни разу не были зелёными [проскочил]
|
||||
|
||||
- **Где:** `internal/controller/http/transcribe_test.go`
|
||||
- **Симптом:** `go test ./...` падает четырьмя случаями; обнаружено первым же
|
||||
прогоном гейта при заведении канона
|
||||
- **Причина:** тест требует `testdata/sample.m4a`, которого в репозитории нет и
|
||||
не могло быть — `.gitignore` содержит `*.m4a`. Остальные случаи записывают в
|
||||
файл строку `test audio content` и ждут `201`, а обработчик зовёт настоящий
|
||||
`ffprobe`, который такой вход отвергает
|
||||
- **Чем воспроизведён:** `go test ./internal/controller/http/` — четыре отказа,
|
||||
из них один по отсутствию файла и три по коду `500` вместо `201`
|
||||
- **Почему не поймали:** гейта не было вовсе, а `go test` руками, судя по
|
||||
результату, не гоняли ни разу с коммита `87d8b05`
|
||||
- **Что меняем:** заведена задача `http-handler-tests-never-green`; в гейт
|
||||
добавлен шаг `go test ./...`, и красный тест теперь виден. Настоящий остаток
|
||||
шире: **тест, который никогда не проходил, обнуляет сигнал всего пакета** — в
|
||||
типовые узлы добавлено свойство «покрыт хоть одним проходящим тестом», а в
|
||||
вопросы темы `autotests` — вопрос про изменённый шаг конвейера
|
||||
|
||||
## 2025-10-23 — пустой ответ вместо текста расшифровки [проскочил]
|
||||
|
||||
- **Где:** `internal/service/transcribe.go`, ветка завершения задачи
|
||||
- **Симптом:** пользователь Telegram получал пустое сообщение вместо текста
|
||||
- **Причина:** SpeechKit возвращал операцию успешной, но с пустым текстом, и
|
||||
задача завершалась этим пустым значением
|
||||
- **Чем воспроизведён:** восстановлено по коммиту `ec637c0`, оракула нет
|
||||
- **Почему не поймали:** конвейера ревью не существовало
|
||||
- **Что меняем:** уже сделано — пустой текст подменяется фразой «на записи нет
|
||||
текста». Настоящий остаток в другом: свойство «вырожденный ответ внешнего
|
||||
сервиса не превращается в успех молча» вынесено в типовой узел «клиент
|
||||
внешнего сервиса» выше
|
||||
|
||||
## 2025-08-17 — длинная расшифровка не доходила до пользователя [проскочил]
|
||||
|
||||
- **Где:** `internal/adapter/telegram/sender.go`
|
||||
- **Симптом:** отправка текста длиннее предела сообщения Telegram завершалась ошибкой
|
||||
целиком, пользователь не получал ничего
|
||||
- **Причина:** предел длины сообщения на стороне Telegram не учитывался
|
||||
- **Чем воспроизведён:** восстановлено по коммиту `822e168`, который тем же
|
||||
заходом завёл `internal/adapter/telegram/split_test.go`
|
||||
- **Почему не поймали:** конвейера ревью не существовало
|
||||
- **Что меняем:** уже сделано — деление по словам с пределом 4000 символов,
|
||||
число записано в [database.md](database.md)
|
||||
@@ -0,0 +1,91 @@
|
||||
# Модель угроз
|
||||
|
||||
## Периметр
|
||||
|
||||
**Сервис открыт наружу: HTTP-порт опубликован в интернет через обратный прокси, и
|
||||
аутентификации не делает ни прокси, ни само приложение.** Находки строятся против
|
||||
этого — сегодняшнего — периметра.
|
||||
|
||||
Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, и каждый
|
||||
пользователь видит только свои записи. Он **не** развёрнут; описанное ниже
|
||||
разграничение доступа относится только к Telegram.
|
||||
|
||||
Отсюда главное следствие, из которого читается всё остальное: **`POST /api/audio`
|
||||
доступен кому угодно из интернета**. Отправитель не назван, не ограничен по числу
|
||||
запросов и не ограничен по размеру файла.
|
||||
|
||||
## Недоверенный вход
|
||||
|
||||
Что приходит извне и каким каналом.
|
||||
|
||||
| Вход | Канал | Кто может слать |
|
||||
| --- | --- | --- |
|
||||
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой из интернета |
|
||||
| Идентификатор задачи | `GET /api/status/:id` | Любой из интернета |
|
||||
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка |
|
||||
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
|
||||
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
|
||||
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
|
||||
|
||||
## Из чего строятся пути и ключи
|
||||
|
||||
Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога.
|
||||
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
|
||||
путь не предполагался.
|
||||
|
||||
- **Путь на диске** — `filepath.Join(cfg.Storage.Path, fileId + ext)`, где
|
||||
`fileId` наш UUID, а **`ext` берётся из имени файла отправителя** через
|
||||
`filepath.Ext`. Расширение в путь попадает без проверки списком; `filepath.Ext`
|
||||
режет по последней точке и не пропускает разделитель каталогов, но это
|
||||
единственное, что стоит между входом и именем файла.
|
||||
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
||||
расширением. Бакет один на все записи, префикса по пользователю нет.
|
||||
- **Каталог** один и плоский: `data/files` целиком, вложенности нет.
|
||||
- **Идентификатор задачи** — UUID v4. Он же единственное, что защищает
|
||||
`GET /api/status/:id`.
|
||||
|
||||
## Что разграничивает доступ
|
||||
|
||||
- **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой
|
||||
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
|
||||
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
|
||||
меняется владельцем в любой момент: список привязан к изменяемому значению.
|
||||
- **HTTP API** — ничего. Ни ключа, ни сессии, ни ограничения по адресу.
|
||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты вместе с
|
||||
остальным.
|
||||
|
||||
Владения записью в модели данных нет: у задачи нет пользователя. Пока API
|
||||
анонимен, знание UUID задачи и есть право её читать.
|
||||
|
||||
## Что чувствительнее чего
|
||||
|
||||
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
|
||||
это самое чувствительное, что здесь есть.
|
||||
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним.
|
||||
3. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||||
Утечка оплачивается деньгами и доступом к бакету.
|
||||
4. **Белый список пользователей** — сам по себе перечень имён.
|
||||
|
||||
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
|
||||
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
|
||||
|
||||
Тексты расшифровок и имена файлов в логи не пишутся — логируется длина текста и
|
||||
идентификаторы. Токен бота попадает в URL скачивания файла (`file.Link(token)`),
|
||||
и этот URL нигде не логируется.
|
||||
|
||||
## Что вне модели
|
||||
|
||||
Перечислить явно.
|
||||
|
||||
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
|
||||
Ansible — не наша граница.
|
||||
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
|
||||
доверяем полностью.
|
||||
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
|
||||
SpeechKit не рассматривается.
|
||||
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
|
||||
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
|
||||
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
|
||||
внешней программе, своей песочницы вокруг неё нет.
|
||||
- **Удаление данных по требованию.** Ни файлы, ни расшифровки не удаляются
|
||||
вовсе; забвение не реализовано и в задачах не стоит.
|
||||
Reference in New Issue
Block a user