заведена задача на перестройку модели вокруг аудиозаписи

- центральная сущность — аудиозапись: файлы, тексты, структура реплик и темы
  живут отдельными строками, поля очереди перестают соседствовать с содержимым,
  а провайдерское уезжает в свою таблицу
- конвейер становится цепочкой рубежей с остановкой признаком: рубеж не
  стирается, и запись перезапускается с места остановки
- воркеры теряют специализацию, их число задаётся конфигом
This commit is contained in:
av
2026-08-14 16:46:40 +03:00
parent a67cdee382
commit d079f03350
2 changed files with 228 additions and 0 deletions
+1
View File
@@ -43,6 +43,7 @@
## Очередь
- [✨ Перестроить внутреннюю модель вокруг аудиозаписи](items/record-centric-model.md) — Задача очереди и запись — одна строка: поля захвата лежат рядом с расшифровкой, указатель на файл переставляет каждый шаг, а перевод в failed стирает рубеж и делает перезапуск невозможным.
- [🐞 Убрать код провайдера из журнала запросов хранилища](items/provider-code-out-of-storage-log.md) — Строка запроса с кодом входа целиком уезжает в таблицу _logs и лежит там пять суток, хотя спека access требует, чтобы код в журнал не попадал.
- [🐞 Вести учёт употреблённых состояний входа на сервере](items/server-side-login-state.md) — Одноразовость возврата держится на уборке куки, то есть на браузере: сервер не помнит, какие состояния уже потрачены.
- [✨ Строить адрес входа из настроек коллекции, а не из конфига](items/login-url-from-collection-settings.md) — Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
+227
View File
@@ -0,0 +1,227 @@
# ✨ Перестроить внутреннюю модель вокруг аудиозаписи
- **Тип:** 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.