- центральная сущность — аудиозапись: файлы, тексты, структура реплик и темы живут отдельными строками, поля очереди перестают соседствовать с содержимым, а провайдерское уезжает в свою таблицу - конвейер становится цепочкой рубежей с остановкой признаком: рубеж не стирается, и запись перезапускается с места остановки - воркеры теряют специализацию, их число задаётся конфигом
20 KiB
✨ Перестроить внутреннюю модель вокруг аудиозаписи
- Тип: feature
- Категория: Очередь — Модель — основание: поверх неё строятся экраны, уровни текста, учёт и метрики, и всякая задача, взятая раньше, будет переписана вместе с моделью. Решение владельца 2026-08-14.
- Зачем: Задача очереди и запись — одна строка: поля захвата лежат рядом с расшифровкой, указатель на файл переставляет каждый шаг, а перевод в failed стирает рубеж и делает перезапуск невозможным.
Центральная сущность — аудиозапись, а не задача конвейера. Приложения к ней (файлы, тексты, структура) живут отдельными строками и ссылками, поля очереди перестают соседствовать с содержимым, воркеры теряют специализацию, а их число задаётся конфигом.
Замысел выработан в разговоре 2026-08-14, там же разобраны четыре развилки: где живут темы, дробить ли задачу, как зовётся конечный рубеж и чем ограничивается застревание. Ниже — принятые решения целиком; открытых вопросов не осталось.
Задача делается одним заходом и не дробится — решение владельца 2026-08-14. Швы у неё есть (сущности со схемой, цепочка рубежей, провайдерская таблица, обобщение пула), но резать по ним значит платить четырьмя необратимыми шагами схемы вместо одного и держать на сервере промежуточные раскладки. Один заход — один шаг схемы и один перенос живых записей.
Сущности
Разрез проведён по одной границе: зависит ли вещь от провайдера распознавания.
audiorecords ← домен
id, owner, title, brief
state, state_entered_at рубеж конвейера
halted_at, halt_reason, error_text остановка
acquisition_id, acquire_expires_at, очередь
delay_time, attempts
original_file_id ────▶ files
normalized_file_id ──▶ files
structure_id ────────▶ structures
transcript_text_id ──▶ texts
literary_text_id ────▶ texts
topics ──────────────▶ topics, до 5 значений
recognition_id ──────▶ recognitions
created, updated
files location, size, format, duration_ms ← только исходник и opus
texts format, contents
structures version, contents (реплики с временем, JSON)
topics owner, name — уникально по паре
record_events origin, step, outcome, duration, model, tokens, error_text
recognitions ← провайдерское
record_id, provider, model, external_id,
source_uri, payload (вложением), started_at, finished_at
Что следует из разреза:
- копия в Object Storage — не файл записи. Она существует только потому, что
SpeechKit читает аудио по URI; другой провайдер её не потребует. Ключ объекта
переезжает в
recognitions.source_uri, и уfilesостаётся ровно двое членов на запись; recognition_op_idуезжает с записи туда же: идентификатор операции Yandex — самое провайдерское, что есть в модели, а сегодня он лежит колонкой в доменной сущности;- сырой ответ SpeechKit сохраняется целиком, вложением, а не колонкой. Шаг
опроса читает эту строку раз в пять секунд, а PocketBase читает запись целиком
(
SELECT *): восьмимегабайтный JSON в колонке ехал бы в память при каждом опросе — тот же промах, чтоtranscription_textвacquireColumnsсегодня. Хранится он затем, что результат операции нельзя переспросить: связь реплики с говорящим мы строить пока не умеем, и когда научимся, архив пересчитается из сохранённого без единого рубля; - разбор потока — обязанность адаптера, а не шага. Контракт
AudioRecognizerотдаёт доменный результат (реплики, говорящие, плоский текст, байты на хранение) вместо строки, и ни один шаг конвейера не знает формата провайдера.
Текст расщеплён по тому, читается ли он вместе со списком: title и brief
идут сотней штук разом и лежат колонками записи, transcript и literary
читаются по открытию и лежат строками texts со ссылкой с записи.
Темы — не текст, и словарь у них свой на каждого человека. Модель называет их свободно, но получает в запросе темы, которые у этого владельца уже есть, и переиспользует подходящую; новую заводит, только если не годится ни одна. Отсюда и хранение: перечень тем нужен перед каждым обращением к модели, а собрать его из массивов строк можно только перебором всех записей — значит словарь живёт коллекцией. Потолок — 5 тем на запись, и он же уезжает в запрос: без него часовой разговор даёт два десятка тем, и словарь распухает за неделю. Название темы выведено из содержимого записи, а перечень тем человека — слепок того, о чём он вообще говорит: в журнал они не идут наравне с расшифровкой.
Конвейер
Цепочка рубежей: состояние называет достигнутое, а следующий шаг выбирается
таблицей диспетчеризации. Конечный рубеж остаётся done: доставка вне
конвейера, и слово описывает пройденный конвейер, а не полученный человеком
текст, — плюс переносится с живых записей тождеством, не стоя ни строки в
необратимом шаге схемы.
uploaded ──▶ normalized ──▶ submitted ──▶ transcribed ──▶ done
│ ▲
└──┘ delay_time, опрос
- остановка — признак, а не состояние.
halted_at+halt_reason+error_text;stateпри этом не стирается. Иначе рубеж теряется, и «продолжить с места остановки» становится невозможным. Перезапуск — снятие признака со сбросом попыток и паузы, доступен и владельцу записи; массовый после выкатки правки — однимUPDATE. Прежниеfailedиdeadсхлопываются вhalt_reason: обе восстанавливаются одинаково, и различие перестаёт быть структурным; - шаг бывает обязательным и необязательным. Отказ уровней текста не роняет запись: расшифровка уже есть, и отбирать её из-за надстройки нельзя — запись переходит к следующему рубежу, причина уходит в журнал;
- сторожей два, и обязанности у них разные.
attemptsсчитает отказы и ограничивает повторы внутри шага;state_entered_atсчитает время и ограничивает застревание. Сегодня обе роли навешаны наattempts, и потому он не справляется ни с одной: опрос, вернувший «ещё в работе», обнуляет его — и зависшая в SpeechKit операция опрашивается вечно, — а не обнулял бы, убивал бы здоровую запись; - предел времени — два числа, а не одно и не четыре. Граница проходит не по
рубежам, а по тому, чью работу ждём: своя (
uploaded,normalized,transcribed) — час, чужая (submitted) — сутки. Одно общее число пришлось бы мерить по самому долгому, и застрявшая нормализация стояла бы сутки. Сколько идёт распознавание долгой записи, никто не мерил (speechkit-limits,intake-limits-measure), поэтому ошибаемся в сторону долгого: ложная остановка хуже поздней. Оба числа — в конфиг и строкой вdocs/database.md. Достигнут предел — остановка признаком с причиной «застряла»; наsubmittedона ничего не теряет, операция в Yandex остаётся вrecognitions.external_id, и перезапуск возобновляет опрос той же; MoveToStateиPostponeразводятся. Сегодня опрос зовётMoveToStateAndDelayс тем же состоянием — переходом это никогда не было, и именно фиктивность перехода обнуляет попытки.Postpone(delay)ставит паузу и снимает захват, аstateиstate_entered_atне трогает; попытки обнуляет по прежнему доводу — ожидание чужой операции отказом не является. Без этого разделения новая колонка сбрасывалась бы на каждом опросе и повторила бы ровно тот промах, ради которого заводится;- срок захвата едет с состоянием, а не с воркером: обобщённый воркер не
знает заранее, что вытянет. Пишется числом при захвате в
acquire_expires_at; - захват возвращает
id, а не перечень колонок. Инвариант «колонки очереди правятся в четырёх местах» съёживается до трёх и перестаёт расти с моделью — иначе каждая новая колонка записи попадала бы под него; - шаг с внешней оплатой проверяет сделанное. Объект в Object Storage есть
нужного размера — не заливаем; строка текста для уровня есть — не считаем.
Сверка по хешу ненадёжна:
ETagу multipart-объекта не MD5 содержимого; - журнал событий пишется на смену рубежа, не на каждую петлю опроса, и никто не читает его, чтобы решить, что делать. Строку пишет и человек — перезапуск виден в журнале с указанием, кто нажал.
Воркеры
Специализация снимается, число уезжает в конфиг, N = 0 — законное значение
(записи принимаются и не двигаются). Первые три условия масштабируемости в коде
уже есть — неделимый захват, запись только держателем, шаг не предполагает
единственности; недостающие два названы в «Рамках» и в «Вопросах».
Опрос остаётся задачей очереди, а не отдельным смотрителем: очередь даёт ему устойчивость даром, а единственный смотритель умирает молча и уносит с собой целый класс записей.
Затрагивает
- шаг схемы: коллекции
texts,structures,recognitions,record_events,topics; переработкаtranscribe_jobsвaudiorecords; правкаfiles. Применённые шаги не переписываются — только новым файлом; - перенос живых записей на сервере:
created→uploaded,converted→normalized,transcribe→submitted,done→done; уfailedиdeadрубеж утрачен и восстанавливается по заполненности полей; internal/entity/job.go— сущность записи, перечень рубежей, переходы,Fail/Die/MoveToState, новыйPostpone;internal/contract/contract.go—AudioRecognizerотдаёт доменный результат вместо строки; заливка и отправка разделены;internal/contract/repository.go— контракты репозиториев записи, файлов, текстов, структуры, попыток распознавания, журнала;internal/adapter/recognizer/yandex/— разбор потокаGetRecognitionв реплики, раздельные заливка и отправка, отдача сырых байтов;internal/adapter/repo/pocketbase/— запрос захвата,acquireColumnsиacquiredRow,applyToRecord,recordToJob;internal/service/transcribe.go— шаги, таблица диспетчеризации по рубежу, остановка признаком;internal/controller/worker/worker.goиmain.go— пул вместо трёх именованных воркеров;config.example.tomlиinternal/config— число воркеров, срок захвата по шагу, два предела времени в рубеже;- публичный контракт HTTP API — перечень состояний в ответе о записи;
docs/architecture.md,docs/database.md,openspec/specs/, инвариантыCLAUDE.mdо колонках очереди и о держателе захвата.
Критерии приёмки
- Запись, остановленная на шаге, перезапускается снятием признака и продолжает
с того рубежа, где стояла. Оракул — тест: шаг останавливает запись на
normalized, снятиеhalted_atвозвращает её в работу, и следующим идёт отправка на распознавание, а не повторная нормализация. - У прошедшей конвейер записи ссылки на исходник и на opus ведут на разные существующие копии. Оракул — тест полного прохода: обе ссылки заполнены и обе открываются.
- Число воркеров задаётся конфигом, и поведение от него не зависит. Оракул —
прогон теста конвейера при
N=1иN=4: запись доходит доdoneв обоих; приN=0она остаётся вuploadedи не теряется. - Структура реплик строится из сохранённого ответа провайдера без обращения к нему. Оракул — тест на сохранённом вложении: структура собрана, клиент SpeechKit не позван ни разу.
- Живые записи переносятся шагом схемы без потери. Оракул — тест шага на слепке прежних данных: у каждой задачи появляется запись с тем же рубежом, владельцем, файлом и текстом.
- Запись, застрявшая в рубеже дольше предела, останавливается, а откладывание
опроса предела не сдвигает. Оракул — тест на подставных часах: сотня
откладываний подряд не двигает
state_entered_atи не обнуляет отсчёт, а по истечении предела запись получает признак остановки с причиной «застряла».
Рамки
Резку длинной записи на фрагменты не делаем — конвейер остаётся цепочкой.
Доставку из конвейера не выносим и не переделываем: ответ в Telegram остаётся
хвостом последнего шага. Уровни текста модель готовит, но сам шаг обращения к
языковой модели делает llm-insights-adapter, а вычитанный текст —
literary-text-level. Говорящих в структуре сегодня не размечаем: связь реплики
с SpeakerAnalysis не выяснена. Таймауты внешним вызовам ставит
external-call-timeouts, и она этой задаче предшествует — без предела по времени
у шага срок захвата не может его превысить, и протухший захват даёт вторую
платную операцию в SpeechKit. Нарастающую паузу опроса и отступ на пустой
очереди не делаем: первое — speechkit-callback-fit, второе при единицах записей
в день не нужно. Экранов не трогаем.
Необратимое: шаг схемы, уехавший на сервер, перенос живых записей и изменение перечня состояний в публичном контракте API.