From 9a964f2efc7b9d89026a246b5c02c25a018e9306 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Fri, 14 Aug 2026 20:20:58 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=20record-centric-model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tasks/BACKLOG.md | 1 - tasks/items/record-centric-model.md | 227 ---------------------------- 2 files changed, 228 deletions(-) delete mode 100644 tasks/items/record-centric-model.md diff --git a/tasks/BACKLOG.md b/tasks/BACKLOG.md index 56ba182..5564fc2 100644 --- a/tasks/BACKLOG.md +++ b/tasks/BACKLOG.md @@ -43,7 +43,6 @@ ## Очередь -- [✨ Перестроить внутреннюю модель вокруг аудиозаписи](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 deleted file mode 100644 index ca926b3..0000000 --- a/tasks/items/record-centric-model.md +++ /dev/null @@ -1,227 +0,0 @@ -# ✨ Перестроить внутреннюю модель вокруг аудиозаписи - -- **Тип:** 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.