- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
32 KiB
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. Работа идёт так, будто выкладки не было ни разу.
- Новый шаг схемы заводит
audio_recordsи коллекции приложений, правитfilesи удаляет прежнююtranscribe_jobs: данных под ней нет, а оставленная пустая коллекция висела бы в панели вторым домом для того же понятия. Применённые шаги при этом не переписываются — изменение идёт новым файлом. - Порядок выкладки: сперва образ. Новых обязательных ключей настройки нет — у числа воркеров и обоих пределов времени есть умолчания.
- Откат. Прежний образ ищет
transcribe_jobs, которой уже нет, и работать не будет: откат означает пересоздание каталога данных, и это осознанная цена пустого старта. Обратного шага схемы нет и не планируется. - Резервную копию каталога данных перед выкладкой делает человек — не ради записей, а ради учётных записей и настроек провайдера входа.
Open Questions
Четыре развилки постановки разобраны в записи задачи 2026-08-14 и закрыты решениями владельца. Ревью дизайна открыло ещё несколько мест, и все они вынесены человеку на чекпоинт; решения, записанные выше, — предложенные, а не принятые:
- предел «час на свою работу» — оставлен часом: сторож ловит зависание, а живая работа наблюдается по процессу; остановка обратима;
- перенос данных — отменён целиком: прод остановлен, прежние данные удалены;
- снятие остановки владельцем записи — намерение зафиксировано, реализация приходит с экранами и своим адресом API;
topics— оставлены, как решено постановкой: один шаг схемы вместо двух;- обязательность шага — отложена до первого необязательного шага (решение исполнителя по находке ревью, названо на чекпоинте);
- имена ключей конфига объявлены проектом необратимыми и потому названы
дословно:
[pipeline] workers,[pipeline] own_work_limit,[pipeline] foreign_work_limit.
Осталось названным риском, а не вопросом: external-call-timeouts в плане
стройки стоит ниже этой задачи, хотя «Рамки» постановки называют её
предшествующей. Порядок расставил владелец, и решение о нём принято.