- центральная сущность — аудиозапись: файлы, тексты, структура реплик и темы живут отдельными строками, поля очереди перестают соседствовать с содержимым, а провайдерское уезжает в свою таблицу - конвейер становится цепочкой рубежей с остановкой признаком: рубеж не стирается, и запись перезапускается с места остановки - воркеры теряют специализацию, их число задаётся конфигом
228 lines
20 KiB
Markdown
228 lines
20 KiB
Markdown
# ✨ Перестроить внутреннюю модель вокруг аудиозаписи
|
||
|
||
- **Тип:** 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.
|