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

228 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ✨ Перестроить внутреннюю модель вокруг аудиозаписи
- **Тип:** 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.