diff --git a/tasks/BACKLOG.md b/tasks/BACKLOG.md index 5564fc2..56ba182 100644 --- a/tasks/BACKLOG.md +++ b/tasks/BACKLOG.md @@ -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) — Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину. diff --git a/tasks/items/record-centric-model.md b/tasks/items/record-centric-model.md new file mode 100644 index 0000000..ca926b3 --- /dev/null +++ b/tasks/items/record-centric-model.md @@ -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.