внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
@@ -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` в плане
|
||||
стройки стоит **ниже** этой задачи, хотя «Рамки» постановки называют её
|
||||
предшествующей. Порядок расставил владелец, и решение о нём принято.
|
||||
Reference in New Issue
Block a user