закрыта задача record-centric-model
This commit is contained in:
@@ -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) — Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user