внутренняя модель перестроена вокруг аудиозаписи

- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
av
2026-08-14 20:20:33 +03:00
parent d079f03350
commit 1576d06735
84 changed files with 8973 additions and 2865 deletions
+49 -23
View File
@@ -32,6 +32,11 @@
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
2026-08-12;
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
целиком и вложением, как из сохранённого строится структура реплик без
повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
Задача `record-centric-model` 2026-08-14;
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
@@ -78,22 +83,22 @@
| --- | --- | --- |
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
| Воркеры | `internal/controller/worker` | Крутят по одному шагу конвейера, опрашивая базу |
| Сервис расшифровки | `internal/service` | Конвейер: приём, конвертация, распознавание, отдача результата |
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки задачи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: цепочка переходов состояний -->
Конвейер: `created``converted``transcribe``done` либо `failed`. Каждый
переход двигает свой воркер, и каждый опрашивает базу раз в секунду. Что
делает задача, исчерпавшая попытки, нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
«мертва»».
Цепочка рубежей — `uploaded``normalized``submitted``transcribed`
`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его
[pipeline](../openspec/specs/pipeline/spec.md), «Рубеж записи называет
достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж
сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается
по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит
настройкой.
## Внешние границы и форматы
@@ -136,6 +141,17 @@
поделят один длинный опрос и часть ответов до людей не дойдёт.
Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней.
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об
этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после
чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном
двух бинарей на одном каталоге данных.
Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с
этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа;
обратного шага схемы нет и не планируется. Порог перехода назван прямо: до
выкладки `record-centric-model` откат образа работает, после — нет.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
наружу — [database.md](database.md), «Настройки с числовым значением»:
@@ -145,17 +161,23 @@
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- |
| Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись завершается заглушкой «на записи нет текста» |
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
или по сообщению об ошибке. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
Отдельного оповещения нет.
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
«падает приведение» отличается от «падает распознавание». Плюс логи
контейнера. Отдельного оповещения нет.
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
на смену рубежа, на остановку и на снятие остановки; читает его человек в
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет.
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
воркеры опрашивают базу вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением».
@@ -164,12 +186,15 @@
| Что | Где |
| --- | --- |
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
| Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` |
| Приём аудио и заведение записи | `TranscribeService.createRecord` — через него идут оба входа |
| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру |
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
| Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа |
| Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая |
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; ответ отправителю — `TranscribeService.halt`, одно место на все причины |
| Разбор конфигурации | `internal/config.LoadConfig` |
| Чтение времени | `internal/clock``Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
@@ -243,7 +268,8 @@
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
конвертер этот случай не проверялся.
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена
вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он
даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их
паузы, а не замер