- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
31 KiB
Архитектура
Обзор: как сложено и где что работает. Поведение системы здесь не описывается
— нормативно оно живёт в openspec/specs/. Места, где оно всё-таки описано,
помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
Документ описывает сегодняшнее устройство. Куда проект идёт — в passport.md и в tasks/BACKLOG.md; что из этого ещё не решено — в разделе «Открытые вопросы».
Заведённые capability нормируют поведение сервиса для его потребителей — все до одной. Инструмент, которым сервис собирают, спеками не нормируется вовсе: у набора проверок и сборки другой потребитель — тот, кто собирает, — и решением от 2026-08-13 его нормы живут в самих шагах, их проверках и conventions/go-linters.md.
- intake — приём по HTTP плюс наличие
входов: приём за сессией, имя отправителя не доходит ни до
хранилища, ни до журнала, метка метрики несёт только известное расширение, а
наблюдатель видит единственный поднятый вход. Задачи
http-handler-tests-never-greenиno-user-filename-in-log2026-08-11,pocketbase-storageиoidc-login2026-08-12,local-run-without-telegram-token2026-08-13,remove-telegram-intake2026-08-14; - pipeline — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, остановка признаком, пауза перед
повтором и молчание конвейера наружу: задачи
errors-as-instead-of-typecast2026-08-11,pocketbase-storage2026-08-12,local-run-without-telegram-token2026-08-13 иremove-telegram-intake2026-08-14. Переходы состояний и отмена контекста посреди шага остаются долгом; что именно не описано, перечисляет разделPurposeсамой спеки; - storage — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача
pocketbase-storage2026-08-12; - recognition — попытка распознавания
у внешнего провайдера: что о ней хранится, почему сырой ответ сохраняется
целиком и вложением, как из сохранённого строится структура реплик без
повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
Задача
record-centric-model2026-08-14; - archive — архив своих записей глазами
приложения: пространство адресов
/app/и единая форма отказа с машиночитаемым кодом, пределы, которыми сервис ограничивает загрузку, и само чтение — страница записей ключом, карточка без текста и текст названного вида. Здесь же обязанность, переехавшая с убранного опроса готовности: причину остановки владелец записи узнаёт карточкой. Задачаjson-api-for-spa2026-08-15; - access — кто пришёл в сервис и пускают ли
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача
oidc-login2026-08-12. Здесь же разграничение записей по владельцу: принятая запись принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей записи не бывает вовсе — колонка владельца пустого значения не принимает. Задачиrecord-ownershipиremove-telegram-intake2026-08-14.
Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде. Задача, которая его трогает, дописывает спеку своей capability.
Принципы
- Один процесс. HTTP-сервер и фоновые воркеры живут в одном бинарнике и делят одну базу. Отдельного воркер-процесса нет намеренно.
- Очередь таблицей. Состояние задачи лежит коллекцией хранилища; неделимость захвата и порядок выборки нормирует pipeline, «Захват задачи неделим». Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11, ADR, сравнение кандидатов в research/job-queue.md.
- Шаг конвейера идемпотентен по повтору. Что делает срок захвата и когда задача возвращается в работу, нормирует pipeline, «Брошенная задача возвращается в работу»; здесь это принцип письма шага, а не описание поведения.
- Ядро зависит от интерфейсов.
internal/serviceзнает толькоinternal/contract; ffmpeg, Yandex и хранилище подставляются вmain.go. Правило механизировано тестами-сканерамиinternal/archrules, и они же держат обратные направления: транспорты не знают друг о друге, адаптер не знает ни ядра, ни транспортов. Изъятие: транспорт вправе знать адаптер хранилища —controller/httpимпортируетadapter/repo/pocketbase, потому что HTTP-поверхность и есть роутер этого хранилища, а не наш сервер поверх него. Правила на это направление нет намеренно.
Компоненты
Каждый — строкой со ссылкой на capability, а не пересказом её требований.
| Компонент | Где | Что делает |
|---|---|---|
| HTTP API | internal/controller/http |
Адреса приложения под корнем /app/: приём записи, страница своих записей, карточка, текст названного вида, пределы сервера и «кто вошёл» |
| Воркеры | internal/controller/worker |
Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Сервис расшифровки | internal/service |
Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Конвертер и метаданные | internal/adapter/{converter,metaviewer}/ffmpeg |
ffmpeg в ogg/vorbis, ffprobe для длительности |
| Распознаватель | internal/adapter/recognizer/yandex |
Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
| Репозитории | internal/adapter/repo/pocketbase |
Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
| Шаги схемы | internal/adapter/repo/pocketbase/migrations |
Файл на шаг, имя файла — имя шага; там же имена коллекций |
| Панель владельца | internal/adapter/repo/pocketbase, panel.go |
Панель хранилища; правила правки записи нормирует storage, «Владелец видит записи в панели» |
Цепочка рубежей — uploaded → normalized → submitted → transcribed →
done; рубеж называет достигнутое, а не предстоящее, и нормирует его
pipeline, «Рубеж записи называет
достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж
сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается
по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит
настройкой.
Внешние границы и форматы
- 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-порт наружу. -
Порядок выкладки: конфиг после образа. Прежде здесь стояло правило, разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть. Секцию
[telegram]и ключserver.users_while_listчеловек убирает из боевого файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними сервис поднимает молча. -
Откат образа через шаг схемы
202608140002не работает и не говорит об этом. Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только те шаги, которые знает сам бинарь: прежний образ шагов новее не видит, поднимается без единой ошибки и отвечает зелёной пробой здоровья — после чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном двух бинарей на одном каталоге данных.Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа; обратного шага схемы нет и не планируется. Порог перехода назван прямо: до выкладки
record-centric-modelоткат образа работает, после — нет. -
Внешние зависимости поимённо и чем каждая отказывает. Столбец «отвечает медленно» читается вместе с тем, что таймаута нет ни у одного обращения наружу — database.md, «Настройки с числовым значением»:
Зависимость Падает Отвечает медленно Молчит Отдаёт мусор Yandex SpeechKit Шаг возвращает ошибку, запись остаётся на повтор Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции Операция вечно in progress, повтор каждые 5 секунд — до предела простоя в суткиПустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; карточка записи отдаёт рубеж doneс пустым перечнем доступных видов текста↳ остановка сервиса Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом — — — Yandex Object Storage Заливка падает, запись остаётся на рубеже normalizedТо же, что падение: висит до конца захвата — SpeechKit не прочитает объект и вернёт отказ операции ffmpeg, ffprobe Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит Конвейер стоит: вызов синхронный — Выходной файл пуст, отказ вылезет на распознавании Хранилище (файл на диске) Приложение не стартует либо шаг падает на каждом запросе Блокировка записи держит воркеры — — Диск Запись файла падает, задача не заводится — — — -
Кто заметит отказ и когда: тот, кто загрузил запись, — карточкой записи: остановленная запись отдаёт признак остановки и её причину. Владелец — по метрике
transcriber_worker_job_countс меткойerror="true", и меткаstageназывает рубеж, с которого запись взята: с появлением пула одинаковых воркеров имя потока перестало что-либо значить, а разрез по шагу — единственное, чем «падает приведение» отличается от «падает распознавание». Плюс логи контейнера. Отдельного оповещения нет. -
Журнал событий записи — второй канал наблюдения,
record_events. Пишется на смену рубежа, на остановку и на снятие остановки; читает его человек в панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет. -
Характер потока: непрерывный, но разреженный. Воркеры опрашивают базу вхолостую с паузой из database.md, «Настройки с числовым значением».
Единые точки проекта
| Что | Где |
|---|---|
| Приём аудио и заведение записи | TranscribeService.createRecord — единственный путь, которым запись появляется в хранилище |
| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (pocketbase.BindPanelRules), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват записи воркером | AudioRecordRepository.FindAndAcquire — один запрос с RETURNING, отдаёт идентификатор и признак захвата |
| Объявление рубежа | internal/entity/stage.go — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
| Выбор шага по рубежу | TranscribeService.stepFor — таблица, а не привязка к воркеру |
| Рабочая копия файла на диске | FileRepository.Localize, Stage, StageEmpty — они же дают единственный способ её убрать (WorkFile.Close); зовёт его шаг |
| Переход записи на рубеж | 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_ |
| Значения метки формата | internal/metrics.FormatLabel — приводит расширение к закрытому перечню, прочее заменяет на other; нормирует спека intake |
| Отображение доменной ошибки в ответ | internal/controller/http.mapDomainError — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой OneErrorForm, стоящий снаружи всех прочих |
| Состояния отбора списка | internal/entity.ListFilter вместе с WorkingStages и TerminalStages — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
| Уборка имени файла отправителя | internal/entity.SanitizeOriginalFilename — режет по пределу и убирает управляющие знаки; зовёт её приём |
Единых точек, которых нет и которые ожидались бы: идентификаторы
генерируются вызовом uuid.NewString() по месту. Время из этого перечня ушло
2026-08-13: его читает internal/clock, и запрет держит линтер; отображение
доменной ошибки — 2026-08-15 задачей json-api-for-spa, и до неё обработчик
решал сам: опрос отвечал 404 на упавшую базу, а приём — 500 на негодный файл.
Деплой
Образ собирается по контракту роли app_image: task image даёт
transcriber:$BUILD_ID, по умолчанию transcriber:dev. Реестр не участвует —
образ едет на сервер через docker save/load. Выкладку целиком запускает человек командой
inv pl -- transcriber из pet-project-server.
Сборка двухступенчатая, финальный слой — alpine с ca-certificates и ffmpeg,
процесс работает под непривилегированным пользователем transcriber.
Открытые вопросы
- Учётные записи. Вход через OIDC решён и развёрнут 2026-08-12: провайдер —
Authelia, ответ провайдера обрабатывает PocketBase, а не наш код
(ADR), сессия
живёт кукой
transcriber_sessionи сама себя не продлевает. Норма — access, решения — ADR-2026-08-12-session-without-refresh и ADR-2026-08-12-oidc-exchange-via-own-route. Не решено одно: как связать чат Telegram с учётной записью — от этого зависит возвращение убранного входа. Панель администратора при этом Authelia не закрывает: у неё свой пароль суперпользователя. - Приложение. Экранов нет вовсе, есть только API. Решено делать SPA,
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
сборкой Vite — 2026-08-11,
ADR, сравнение кандидатов в
research/spa-framework.md. Тем же решением Node
входит в гейт и слоем в сборку образа. Пишет это
spa-skeleton; во что обходится слой Node в образе, не замерялось. Не решено, брать ли готовый набор компонентов. - Уведомления. Пользователь веба узнаёт о готовности только опросом карточки. Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и текст расшифровки начинает уходить на сторону — сдвиг периметра security.md.
- Долгие записи. Потолок сегодня неизвестен и не замерялся: ограничения
deferred-generalпо длине не выяснены. Расчётные шесть часов нормирует storage, «Файл записи живёт в хранилище»; откуда взято число — research/pocketbase-defaults.md, «Чего эта записка не узнала». - Приём большого файла. Форма читается целиком, предел памяти под multipart
задан числом в database.md, «Настройки с числовым значением»;
обрыв начинает загрузку заново.
Загрузку частями разбирает разведка
chunked-upload-choice; её выбор меняет публичный контракт приёма и потому идёт через решение вadr/. - Учёт расхода. Распознавание и языковая модель оплачиваются по факту, а
учёта по пользователям нет: метрики считают сервис целиком. Что именно
копится — записи о потреблении или счётчики — решает задача
usage-accounting. - Срок хранения. Записи и тексты решено хранить бессрочно (паспорт, 2026-08-11), а рост каталога данных ничем не ограничен и не наблюдается.
- Резервные копии. Копии делает сервер своими средствами, и приложение о них ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или приложению нужна команда выгрузки: база под нагрузкой копируется файлом не всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его или нет, тоже не решено.
- Формат для распознавания. Конвертер отдаёт ogg/vorbis (
libvorbis), а SpeechKit получаетContainerAudio_OGG_OPUS. Расхождение не разобрано: то ли сервис определяет содержимое сам, то ли часть записей теряется на этом. - Видео. Дорожка из видеофайла к приёму допускается — расширение он берёт из имени и о годности содержимого спрашивает источник метаданных, — но конвертер на этом случае не проверялся.
- Очередь. Модель очереди сделана задачей
pocketbase-storage2026-08-12 (ADR), перестроена вокруг аудиозаписи задачейrecord-centric-model2026-08-14 и нормирована спекойpipeline. Не решено, отказываться ли от холостого опроса: он даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их паузы, а не замер (research/job-queue.md, «Как снималось»), — при нагрузке в единицы записей в день, и во что это обходится, никто не мерил. - Наблюдаемость.
/metricsостаётся и развивается. Чем — дописывать счётчики черезclient_golangили перейти на OpenTelemetry с трассировкой — решает разведкаopentelemetry-fit. Коллектор был бы процессом, которого в выкладке сегодня нет. - Выводы из текста. Литературный текст, заголовок, темы и пересказ решено считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost. Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает уходить ещё на одну сторону — сдвиг периметра security.md. Не решено, отдельный это шаг конвейера или продолжение шага распознавания.