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

- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
av
2026-08-14 20:20:33 +03:00
parent d079f03350
commit 1576d06735
84 changed files with 8973 additions and 2865 deletions
@@ -0,0 +1,345 @@
## Context
Сегодня в хранилище одна строка несёт всё разом: домен записи (владелец, файл),
поля очереди (захват, пауза, попытки) и содержимое (расшифровка целиком, в
колонке). Отсюда четыре следствия, и все они наблюдаемы:
- **захват тянет содержимое.** Запрос захвата перечисляет колонки поимённо и
читает среди них расшифровку; часовая запись едет в память при каждом опросе;
- **ссылка на файл одна, и её переставляет каждый шаг.** У прошедшей конвейер
записи она ведёт на копию во внешнем хранилище, и принятого человеком файла не
найти ничем — задача `play-recording-in-app` упирается именно в это;
- **отказ стирает достигнутое.** Переход в состояние отказа чистит служебные
поля и не оставляет рубежа: продолжить с места остановки не с чего, и владелец
правит состояние в панели наугад;
- **один счётчик несёт две обязанности.** Число попыток ограничивает и повторы
внутри шага, и застревание. Опрос чужой операции обнуляет его переходом в то
же самое состояние — значит зависшая операция опрашивается вечно; перестань он
обнуляться, и здоровая долгая запись умирала бы на шестом опросе.
Замысел выработан разговором 2026-08-14 (запись `record-centric-model`), там же
разобраны и закрыты четыре развилки: где живут темы, дробить ли задачу, как
зовётся конечный рубеж и чем ограничивается застревание. Открытых вопросов
постановка не оставила.
Ограничения, которые эта работа не выбирает: хранилище — встроенная PocketBase,
применённый шаг схемы не переписывается, публичный контракт HTTP API объявлен
необратимым. Записей на сервере при этом нет: сервис остановлен, а прежние
данные удалены решением владельца 2026-08-14 — новая модель заводится с чистого
листа, и переноса данных эта работа не делает.
## Goals / Non-Goals
**Goals:**
- Аудиозапись становится центральной сущностью, приложения к ней живут
отдельными строками, а поля очереди перестают соседствовать с содержимым.
- Рубеж называет достигнутое, остановка становится признаком, и запись
продолжает с места остановки.
- Два сторожа вместо одного: отказы ограничивают повторы, время в рубеже —
застревание.
- Воркеры теряют специализацию, их число задаётся настройкой.
- Схема заводится одним шагом; переноса прежних данных нет.
**Non-Goals:**
- Резать длинную запись на фрагменты — конвейер остаётся цепочкой
(`long-audio-chunking`).
- Выносить доставку из конвейера: ответ в Telegram остаётся хвостом последнего
шага.
- Считать уровни текста: сам шаг обращения к языковой модели делает
`llm-insights-adapter`, вычитанный текст — `literary-text-level`. Здесь
заводятся только места, куда они лягут.
- Размечать говорящих в структуре: связь реплики с разбором говорящего у
провайдера не выяснена.
- Ставить таймауты внешним вызовам — `external-call-timeouts`.
- **Заводить обязательность и необязательность шага.** Ревью дизайна показало,
что носителя у неё нет ни одного: все пять рубежей обязательны, а первый
необязательный шаг приносит `llm-insights-adapter`. Норма без экземпляра
проверяется либо подставным шагом ради теста — что запрещено запретом на
проверки над проверками, — либо галочкой без оракула; вернётся вместе с первым
своим шагом.
- **Давать владельцу записи снимать остановку.** Постановка этого просила, но
поверхности нет: адресов у сервиса два (приём и опрос), экранов нет вовсе, а
открыть правку коллекции запросом запрещает действующая норма `storage`
(«Наружу хранилище отдаёт только то, что заказано»). Сегодня остановку снимает
владелец панели; владельцу записи это даст задача, заводящая экраны.
- Нарастающая пауза опроса и отступ на пустой очереди: первое —
`speechkit-callback-fit`, второе при единицах записей в день не нужно.
- Экраны.
## Decisions
### Разрез сущностей идёт по зависимости от провайдера
Границ для разреза было три, и выбрана одна.
- **По зависимости от провайдера распознавания** — принято. Всё, что перестанет
быть верным при смене провайдера (идентификатор операции, адрес, по которому
провайдер читает аудио, имя модели, сырой ответ), уезжает в строку попытки;
всё остальное остаётся доменом записи. Смена провайдера тогда не трогает
доменную сущность вовсе.
- **По изменчивости** (что правит конвейер против того, что правит человек) —
отвергнуто: граница проходит внутри одной колонки. Рубеж правят оба.
- **По частоте чтения** — отвергнуто как единственная граница: она объясняет,
почему тексты уезжают из записи, но ничего не говорит про идентификатор
операции, который читается ровно так же часто, как рубеж.
Частота чтения при этом остаётся **вторым** разрезом, внутри домена: `title` и
`brief` читаются сотней штук разом и остаются колонками записи, расшифровка и
вычитанный текст читаются по открытию одной записи и уезжают строками `texts`.
### Сырой ответ провайдера хранится вложением, а не колонкой
Ответ на многочасовую запись — мегабайты. Хранилище читает запись целиком, а шаг
опроса читает строку попытки раз в несколько секунд: положенный колонкой, ответ
ехал бы в память при каждом опросе — тот же промах, что расшифровка в перечне
колонок захвата сегодня. Вложение читается только тогда, когда его просят.
Хранится он вообще потому, что **результат операции у провайдера не
переспрашивается**. Отвергнутый вариант — не хранить и разобрать на лету:
дешевле сегодня, но связь реплики с говорящим мы строить пока не умеем, и когда
научимся, архив пересчитать будет не из чего, а повторная операция стоит денег
за каждую запись.
### Остановка — признак, а не рубеж
Прежние `failed` и `dead` схлопываются в признак `halted_at` с причиной, а
`state` не стирается.
- **Признак** — принято: рубеж переживает остановку, продолжение идёт с места
остановки, массовый перезапуск после выкатки правки делается одним обновлением,
а различие «мы рассудили» против «мы перестали пробовать» остаётся причиной,
которую человек читает.
- **Отдельное состояние на каждую причину** — отвергнуто: перечень состояний
закрыт схемой, и каждая новая причина стоила бы необратимого шага.
- **Оставить как есть** — отвергнуто: именно из-за этого перезапись состояния
руками в панели остаётся единственным способом вернуть запись в работу, и
делается он наугад.
### Сторожей двое, и предела времени — два числа
`attempts` считает **отказы** и ограничивает повторы внутри шага;
`state_entered_at` считает **время** и ограничивает застревание.
Пределов времени два, и граница проходит не по рубежам, а по тому, чью работу
ждём: своя (`uploaded`, `normalized`, `transcribed`) и чужая (`submitted`).
- **Одно общее число** — отвергнуто: мерить его пришлось бы по самому долгому, и
застрявшее приведение стояло бы столько же, сколько застрявшая чужая операция.
- **Число на каждый рубеж** — отвергнуто: четыре числа назвали бы разными вещи,
различающиеся только исполнителем, и три из них были бы одинаковы.
Сколько идёт распознавание долгой записи, никто не мерил (`speechkit-limits`,
`intake-limits-measure`), поэтому ошибаемся в сторону долгого: ложная остановка
хуже поздней.
**Час на свою работу меньше самой работы, и это принято сознательно.** Расчётный
потолок записи — шесть часов, приведение такой записи идёт дольше часа по
построению (`docs/review.md`, запись 2026-08-13), а срок захвата шага приведения
стоит сегодня восемью часами. Значит длинная запись, отказавшая один раз и
ждущая повтора дольше часа, будет остановлена сторожем застревания вместо
расшифровки. Ревью дизайна предлагало вывести предел из срока захвата (12 часов
на свою работу); владелец решил 2026-08-14 оставить час, потому что сторож ловит
**зависание**, а живая работа наблюдается по самому процессу конвертера, и
остановка теперь обратима — снятие признака возвращает запись на её рубеж, и
цена ложной остановки равна одному движению владельца. Оба числа в конфиге,
ключи — `[pipeline] own_work_limit` и `[pipeline] foreign_work_limit`.
**Второй сторож пришлось сбрасывать там же, где первые.** Снятие признака
остановки и правка рубежа в панели заново ставят время входа в рубеж: запись,
простоявшая остановленной дольше предела, иначе останавливалась бы снова первым
же захватом, и перезапуск — главное, ради чего заводится признак, — не работал бы
ни для одной записи старше предела.
### Признак захвата — значение, а не занятость
Захват возвращает идентификатор записи **и признак этого захвата**, уникальный
для каждого захвата. Условие записи результата сверяет именно это значение.
Причина проверяемая: панель по требованию `storage` очищает признак захвата,
когда человек снимает остановку. Условие, проверяющее лишь непустоту признака
или срок протухания, пропустило бы обоих — шаг A, потерявший запись, и шаг B,
её подобравший, — и оба записали бы результат и оба ответили бы отправителю.
Гонки часов для этого не нужно: достаточно одного движения человека в панели.
### Имена, которые уезжают необратимым шагом
Коллекция зовётся `audio_records`, а не `audiorecords`: соседи по схеме —
`transcribe_jobs`, `files`, `record_events` — в snake_case, и одно исключение
разошлось бы молча по константе имён, запросу захвата, правилам панели и запрету
удаления.
Вид текста лежит колонкой `kind`, а не `format`: словом `format` в этой же схеме
зовут формат файла (`files.format`), и третий смысл у одного слова проект уже
разводил однажды — комментарий к `location` в `entity/file.go` называет довод.
Текст отказа в журнале событий зовётся `outcome_text`, а не `error_text`:
последнее имя названо поимённо инвариантом проекта о секрете, и две колонки с
этим именем сделали бы инвариант двусмысленным.
### Переход и откладывание — разные операции
Сегодня шаг опроса зовёт переход с **тем же** состоянием, и мнимость этого
перехода обнуляет счётчик. `Postpone(delay)` ставит паузу и снимает захват, а
рубежа и времени входа в него не трогает; отказы обнуляет — ожидание чужой
операции отказом не является.
Без разделения `state_entered_at` сбрасывался бы на каждом опросе и повторил бы
ровно тот промах, ради которого заводится.
### Захват возвращает идентификатор
Сегодня захват перечисляет колонки поимённо в трёх местах сразу, и инвариант
проекта требует править их в четырёх. Захват, возвращающий один `id`, съёживает
инвариант до трёх мест и **перестаёт расти с моделью**: иначе каждая новая
колонка аудиозаписи попадала бы под него, а модель растёт именно сейчас.
Цена названа прямо: захват и чтение записи становятся двумя обращениями к базе
вместо одного. При нагрузке в единицы записей в день это не замеряется, а
неделимость самого захвата не страдает — она держится тем же одним запросом с
`RETURNING`, только возвращает он один столбец.
Отвергнутый вариант — оставить перечень колонок и дописывать его: он ровно тот,
из-за которого инвариант получил серьёзность `major`, и первая же забытая
колонка приезжает нулевой, а первое сохранение пишет этот ноль поверх значения.
### Воркеры теряют специализацию
Пул одинаковых потоков, число из настроек, шаг выбирается по рубежу таблицей
диспетчеризации.
- **Пул** — принято: рубежей станет больше (уровни текста впереди), и каждый
новый рубеж перестаёт требовать своего воркера в `main.go`.
- **Смотритель отдельно от очереди** — отвергнуто для опроса чужой операции:
очередь даёт неделимость захвата и возврат брошенного даром, а единственный
смотритель умирает молча и уносит с собой целый класс записей.
`N = 0` — законное значение: записи принимаются и не двигаются. Это нужный режим
для местного запуска и для выкладки, где конвейер надо остановить, не роняя
приём.
### Темы живут коллекцией со словарём на каждого владельца
Перечень тем человека нужен целиком **перед каждым обращением к модели**: она
получает его в запросе и переиспользует подходящую тему, а новую заводит, только
если не годится ни одна. Собрать такой перечень из наборов строк в записях можно
только перебором всех записей владельца — значит словарь живёт коллекцией.
Потолок — пять тем на запись, и он же уезжает в запрос: без него часовой
разговор даёт два десятка тем, и словарь распухает за неделю.
Отвергнуто: темы набором строк в самой записи (перечень не собрать) и общий
словарь на всех (тема — слепок того, о чём человек говорит, и общий словарь
показал бы одному темы другого).
### Конечный рубеж зовётся `done`
Доставка ответа отправителю в конвейер не входит, и слово описывает пройденный
конвейер, а не полученный человеком текст. Отвергнут `ready`: он обещает «готово
для человека», а человек к этому моменту текста ещё не получил. Прежний довод в
пользу `done` — «переносится с живых записей тождеством» — отпал вместе с
переносом, но само решение он не держал.
### Задача делается одним заходом
Швы у неё есть — сущности со схемой, цепочка рубежей, провайдерская таблица,
обобщение пула, — но резать по ним значит платить **четырьмя** необратимыми
шагами схемы вместо одного и держать на сервере промежуточные раскладки. Решение
владельца 2026-08-14.
## Risks / Trade-offs
- **Шаг схемы уезжает на сервер и не переписывается** → под ним пусто: сервис
остановлен и прежние данные удалены, поэтому цена ошибки в шаге ограничена
повторным пересозданием каталога данных, а не потерей чужого архива.
- **Ложная остановка длинной записи** сторожем застревания принята решением
владельца (см. решение о пределах) → цена ограничена обратимостью остановки;
если класс начнёт всплывать, число правится настройкой без шага схемы.
- **Публичный контракт HTTP API ломается**: перечень значений `status` меняется
целиком → ломка объявлена прямо в спеке `intake`; имена полей сохранены.
Потребителей у контракта сегодня двое — свои же будущие экраны и внешняя
программа, которой ещё нет (`api-tokens`).
- **Таймаутов у внешних вызовов по-прежнему нет** (`external-call-timeouts` не
сделана) → срок захвата остаётся единственным пределом. Молчащий провайдер
держит шаг до конца срока захвата, а протухший захват на шаге отправки даёт
вторую платную операцию. Смягчение частичное: шаг отправки проверяет сделанное
прежде, чем платить, и укладку не повторяет. Полностью закрывается только той
задачей.
- **Захват стал двумя обращениями к базе вместо одного** → между захватом и
чтением запись может измениться. Запись результата остаётся условной по
признаку захвата, поэтому шаг, потерявший захват, ничего не пишет; худший исход
— потерянная работа шага, а не порча записи.
- **Сторожей стало двое, и оба надо сбрасывать в правильных местах** → правило
проверяется тестом на подставных часах: сотня откладываний подряд не двигает
время входа в рубеж и не обнуляет отсчёт.
- **Инвариант проекта о колонках очереди меняет форму** → он съёживается до трёх
мест, и `CLAUDE.md` правится тем же изменением; забыть об этом нельзя, иначе
инвариант станет требовать несуществующего.
- **Класс «перечень, которого не видит компилятор» не исчезает, а переезжает с
колонок на рубежи** → рубеж, забытый в отборе захвата, не выдаётся никому и не
пишет ни строки: пустой прогон по инварианту не логируется. Смягчение —
дескриптор рубежа одним объявлением, из которого выводятся выбор шага, отбор
захвата и оба предела; сканеры `internal/archrules`, стоящие сегодня на
`acquireColumns` и `acquiredRow`, перенацеливаются на этот дескриптор, а не
удаляются.
- **Обрыв процесса между ответом провайдера и записью идентификатора операции**
→ строка попытки заводится **до** обращения, и повторный шаг начинает с
проверки, не заведена ли операция. Жёсткий обрыв (`SIGKILL`, OOM) окна всё
равно не закрывает: это остаточный риск, названный здесь и **не** записанный
нормой — норма, обязывающая к недостижимому, зеленела бы на тесте мягкой
остановки и объявляла бы защиту сделанной.
- **Метка счётчика работы воркера теряет смысл вместе со специализацией** →
метка переводится с имени потока на рубеж, иначе единственный сигнал отказа у
владельца сервиса перестаёт отличать «падает приведение» от «падает
распознавание». `docs/architecture.md`, раздел «Эксплуатация», правится тем же
изменением.
- **Журнал событий `record_events` — второй канал наблюдаемости рядом с
метриками**, а колонки расхода в нём заходят на открытый вопрос «Учёт расхода»
(`usage-accounting`) и на разведку `opentelemetry-fit` → читателя у журнала
сегодня нет: экранов нет, конвейеру читать его запрещено требованием. Колонки
расхода отложены до задачи, которая учёт заводит.
- **`topics` заводится вперёд своего потребителя** → писать и читать темы в этом
изменении не будет ничто. Довод за включение — цена: коллекция, заведённая
позже, стоит второго необратимого шага схемы задаче `llm-insights-adapter`.
Цена включения — имена коллекции и колонок закрепляются раньше, чем известен
запрос потребителя.
## Migration Plan
Переноса данных нет: сервис на сервере остановлен, прежние записи и файлы удалены
решением владельца 2026-08-14. Работа идёт так, будто выкладки не было ни разу.
1. Новый шаг схемы заводит `audio_records` и коллекции приложений, правит `files`
и **удаляет** прежнюю `transcribe_jobs`: данных под ней нет, а оставленная
пустая коллекция висела бы в панели вторым домом для того же понятия.
Применённые шаги при этом не переписываются — изменение идёт новым файлом.
2. Порядок выкладки: сперва образ. Новых обязательных ключей настройки нет — у
числа воркеров и обоих пределов времени есть умолчания.
3. **Откат.** Прежний образ ищет `transcribe_jobs`, которой уже нет, и работать
не будет: откат означает пересоздание каталога данных, и это осознанная цена
пустого старта. Обратного шага схемы нет и не планируется.
4. Резервную копию каталога данных перед выкладкой делает человек — не ради
записей, а ради учётных записей и настроек провайдера входа.
## Open Questions
Четыре развилки постановки разобраны в записи задачи 2026-08-14 и закрыты
решениями владельца. Ревью дизайна открыло ещё несколько мест, и все они вынесены
человеку на чекпоинт; решения, записанные выше, — предложенные, а не принятые:
- **предел «час на свою работу»** — оставлен часом: сторож ловит зависание, а
живая работа наблюдается по процессу; остановка обратима;
- **перенос данных** — отменён целиком: прод остановлен, прежние данные удалены;
- **снятие остановки владельцем записи** — намерение зафиксировано, реализация
приходит с экранами и своим адресом API;
- **`topics`** — оставлены, как решено постановкой: один шаг схемы вместо двух;
- **обязательность шага** — отложена до первого необязательного шага (решение
исполнителя по находке ревью, названо на чекпоинте);
- **имена ключей конфига** объявлены проектом необратимыми и потому названы
дословно: `[pipeline] workers`, `[pipeline] own_work_limit`,
`[pipeline] foreign_work_limit`.
Осталось названным риском, а не вопросом: `external-call-timeouts` в плане
стройки стоит **ниже** этой задачи, хотя «Рамки» постановки называют её
предшествующей. Порядок расставил владелец, и решение о нём принято.