Files
transcriber/docs/architecture.md
T
av 91138dd39a точки входа переехали в cmd/, заведена заглушка OIDC для локального входа
- main.go и journal_route_test.go переехали в cmd/transcriber без правок
  содержимого; образ собирает ./cmd/transcriber поимённо
- cmd/oidcstub отвечает на /authorize, /token и /userinfo, проверок не делает
  и слушает петлевой адрес: войти без Authelia стало чем
- ступень сборки приложения переехала на node:24 с alpine — musl ждёт ответа
  на AAAA, которого нет, и npm ci висел вместо отказа
2026-08-15 20:15:57 +03:00

35 KiB
Raw Blame History

Архитектура

Обзор: как сложено и где что работает. Поведение системы здесь не описывается — нормативно оно живёт в 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-log 2026-08-11, pocketbase-storage и oidc-login 2026-08-12, local-run-without-telegram-token 2026-08-13, remove-telegram-intake 2026-08-14;
  • pipeline — пустой прогон воркера, захват задачи и срок его протухания, число попыток, остановка признаком, пауза перед повтором и молчание конвейера наружу: задачи errors-as-instead-of-typecast 2026-08-11, pocketbase-storage 2026-08-12, local-run-without-telegram-token 2026-08-13 и remove-telegram-intake 2026-08-14. Переходы состояний и отмена контекста посреди шага остаются долгом; что именно не описано, перечисляет раздел Purpose самой спеки;
  • storage — где живут запись, её метаданные и её файл, как файл отдаётся и что видит владелец: задача pocketbase-storage 2026-08-12;
  • recognitionпопытка распознавания у внешнего провайдера: что о ней хранится, почему сырой ответ сохраняется целиком и вложением, как из сохранённого строится структура реплик без повторной оплаты и почему разбор формата провайдера не доходит до конвейера. Задача record-centric-model 2026-08-14;
  • archiveархив своих записей глазами приложения: пространство адресов /app/ и единая форма отказа с машиночитаемым кодом, пределы, которыми сервис ограничивает загрузку, и само чтение — страница записей ключом, карточка без текста и текст названного вида. Здесь же обязанность, переехавшая с убранного опроса готовности: причину остановки владелец записи узнаёт карточкой. Задача json-api-for-spa 2026-08-15;
  • webappприложение в браузере: чем сервис его отдаёт, каким адресом оно открывается, что делает обновление страницы посреди него и что человек видит, открыв его. Здесь же правило неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения ответов и то, что раздача пишет в журнал. Задача spa-skeleton 2026-08-15;
  • access — кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются открытыми. Задача oidc-login 2026-08-12. Здесь же разграничение записей по владельцу: принятая запись принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей записи не бывает вовсе — колонка владельца пустого значения не принимает. Задачи record-ownership и remove-telegram-intake 2026-08-14.

Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде. Задача, которая его трогает, дописывает спеку своей capability.

Принципы

  • Один процесс. HTTP-сервер и фоновые воркеры живут в одном бинарнике и делят одну базу. Отдельного воркер-процесса нет намеренно.
  • Очередь таблицей. Состояние задачи лежит коллекцией хранилища; неделимость захвата и порядок выборки нормирует pipeline, «Захват задачи неделим». Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11, ADR, сравнение кандидатов в research/job-queue.md.
  • Шаг конвейера идемпотентен по повтору. Что делает срок захвата и когда задача возвращается в работу, нормирует pipeline, «Брошенная задача возвращается в работу»; здесь это принцип письма шага, а не описание поведения.
  • Ядро зависит от интерфейсов. internal/service знает только internal/contract; ffmpeg, Yandex и хранилище подставляются в точке входа cmd/transcriber. Правило механизировано тестами-сканерами 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, «Владелец видит записи в панели»
Приложение web/ Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в web/embed/dist и вшивается в бинарник; в git его нет
Раздача приложения internal/controller/http, webapp.go Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика

Цепочка рубежей — uploadednormalizedsubmittedtranscribeddone; рубеж называет достигнутое, а не предстоящее, и нормирует его 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.
  • Node и его установщик пакетов. Нужны только сборке приложения и на машину не ставятся: шаг зовёт их контейнером, а образ берёт из ступени Dockerfile. Требованием к машине разработчика поэтому становится docker. Реестр пакетов — сетезависимый адрес набора проверок; все такие перечислены в CLAUDE.md, «Гейт».

Эксплуатация

  • Где работает, что рядом, кто перезапускает: один контейнер на личном сервере, разворачивает и перезапускает 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/clockNow даёт метку в 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 — режет по пределу и убирает управляющие знаки; зовёт её приём
Адресное пространство сервиса internal/controller/http.ServiceMounts — перечень корней и адресов наблюдения. Он порождает регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути и уровень журнала

Единых точек, которых нет и которые ожидались бы: идентификаторы генерируются вызовом 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.

Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение собирается первым — вшивание требует готового каталога, — а в рабочий слой Node не попадает. Финальный слой — alpine с ca-certificates и ffmpeg, процесс работает под непривилегированным пользователем transcriber.

Ступень бинарника собирает одну точку входа — ./cmd/transcriber, а не весь пакет: рядом в cmd/ живёт oidcstub, подставной провайдер OIDC для локального входа, и в образе ему делать нечего.

Ступень приложения стоит на образе с glibc, а не на alpine, и решает это не вес: у musl запрос имени идёт A и AAAA разом и ждёт оба ответа, поэтому DNS-сервер, молчащий на AAAA, оставляет установщика пакетов без адреса при живом A. Установщик уходит в повторы с нарастающей паузой на каждом пакете, и сборка не краснеет, а висит — исход хуже красного. Слои этой ступени в рабочий слой не едут, поэтому её вес остаётся ценой одной сборки.

По весу финальный образ от ступени приложения не растёт вовсе: она отдаёт следующей только собранное, а сама в рабочий слой не копируется. Вшитое приложение прибавляет к бинарнику 86 072 байта. Время сборки образа не замерялось и замеряться не будет — решение владельца от 2026-08-15.

Открытые вопросы

  • Учётные записи. Вход через 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 не закрывает: у неё свой пароль суперпользователя.
  • Приложение. Каркас поставлен spa-skeleton 2026-08-15: приложение открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и списка нет — их делают upload-and-status-screen и records-list-screen. Решено делать SPA, устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и сборкой Vite — 2026-08-11, ADR, сравнение кандидатов в research/spa-framework.md. Тем же решением Node входит в гейт и слоем в сборку образа; как именно он зовётся — решением ADR 2026-08-15. Не решено, брать ли готовый набор компонентов.
  • Уведомления. Пользователь веба узнаёт о готовности только опросом карточки. Доставку решено брать внешнюю — 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-storage 2026-08-12 (ADR), перестроена вокруг аудиозаписи задачей record-centric-model 2026-08-14 и нормирована спекой pipeline. Не решено, отказываться ли от холостого опроса: он даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их паузы, а не замер (research/job-queue.md, «Как снималось»), — при нагрузке в единицы записей в день, и во что это обходится, никто не мерил.
  • Наблюдаемость. /metrics остаётся и развивается. Чем — дописывать счётчики через client_golang или перейти на OpenTelemetry с трассировкой — решает разведка opentelemetry-fit. Коллектор был бы процессом, которого в выкладке сегодня нет.
  • Выводы из текста. Литературный текст, заголовок, темы и пересказ решено считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost. Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает уходить ещё на одну сторону — сдвиг периметра security.md. Не решено, отдельный это шаг конвейера или продолжение шага распознавания.