# ✨ Перестроить внутреннюю модель вокруг аудиозаписи - **Тип:** 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.