Files
transcriber/tasks/items/record-centric-model.md
T
av d079f03350 заведена задача на перестройку модели вокруг аудиозаписи
- центральная сущность — аудиозапись: файлы, тексты, структура реплик и темы
  живут отдельными строками, поля очереди перестают соседствовать с содержимым,
  а провайдерское уезжает в свою таблицу
- конвейер становится цепочкой рубежей с остановкой признаком: рубеж не
  стирается, и запись перезапускается с места остановки
- воркеры теряют специализацию, их число задаётся конфигом
2026-08-14 16:46:40 +03:00

20 KiB
Raw Blame History

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

  • Тип: 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.goAudioRecognizer отдаёт доменный результат вместо строки; заливка и отправка разделены;
  • 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.