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

- 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,193 @@
# Критерии приёмки
Дословно из записи задачи `record-centric-model`. Файл задачи закрытие удалит —
критерии обязаны его пережить.
1. Запись, остановленная на шаге, перезапускается снятием признака и продолжает
с того рубежа, где стояла. Оракул — тест: шаг останавливает запись на
`normalized`, снятие `halted_at` возвращает её в работу, и следующим идёт
отправка на распознавание, а не повторная нормализация.
2. У прошедшей конвейер записи ссылки на исходник и на opus ведут на разные
существующие копии. Оракул — тест полного прохода: обе ссылки заполнены и обе
открываются.
3. Число воркеров задаётся конфигом, и поведение от него не зависит. Оракул —
прогон теста конвейера при `N=1` и `N=4`: запись доходит до `done` в обоих;
при `N=0` она остаётся в `uploaded` и не теряется.
4. Структура реплик строится из сохранённого ответа провайдера без обращения к
нему. Оракул — тест на сохранённом вложении: структура собрана, клиент
SpeechKit не позван ни разу.
5. Запись, застрявшая в рубеже дольше предела, останавливается, а откладывание
опроса предела не сдвигает. Оракул — тест на подставных часах: сотня
откладываний подряд не двигает `state_entered_at` и не обнуляет отсчёт, а по
истечении предела запись получает признак остановки с причиной «застряла».
## Приёмочные критерии из ревью дизайна
Рубрика прохода `review-rubric`, пункты, не покрытые критериями выше. Приёмка
судится по одному списку.
6. Держатель захвата отличим **значением**, а не занятостью записи. Оракул —
тест: шаг A держит запись, признак остановки снимает человек, запись
захватывает шаг B; запись результата шагом A не проходит, и отправителю от
него ничего не уходит.
7. Всякий способ вывести запись из работы сообщает отправителю. Оракул — тест по
перечню причин остановки: у каждой отправитель получает сообщение.
8. Повтор шага не создаёт второго приложения. Оракул — тест: шаг завершения
оборван после записи текста и повторён с прежнего рубежа; строка расшифровки
у записи одна.
9. Штатная остановка сервиса не тратит отказ. Оракул — тест: шаг прерван
отменой контекста, число отказов записи прежнее, признака остановки нет.
10. Содержимое записи закрыто во всех коллекциях, где лежит. Оракул — тест:
ссылка на сохранённый ответ провайдера без сессии отдаёт отказ, с чужой
сессией — тоже.
11. Отказ виден с разрезом по шагу. Оракул — тест: отказ шага приведения растит
счётчик с меткой своего рубежа.
## 1. Сущности и рубежи
- [x] 1.1 Завести `entity.AudioRecord` с рубежом, временем входа в рубеж,
признаком остановки и её причиной, полями очереди и ссылками на приложения
- [x] 1.2 Дескриптор рубежа одним объявлением: имя, шаг, чья работа, срок захвата,
предел времени, берётся ли в работу. Таблица выбора шага, список отбора
захвата и пределы **выводятся** из него, а не перечисляются порознь
- [x] 1.3 Развести `MoveToState` и `Postpone`: первый двигает рубеж и время входа
в него, второй ставит паузу и снимает захват, рубежа не трогая
- [x] 1.4 Заменить `Fail` и `Die` на `Halt(причина, текст)` и `Resume()`;
`Resume` сбрасывает отказы, паузу **и время входа в рубеж**, рубеж сохраняет
- [x] 1.5 Завести сущности приложения: файл, текст с видом, структура реплик с
версией, тема, событие журнала, попытка распознавания
- [x] 1.6 Тест: `Postpone` не двигает время входа в рубеж и не сбрасывает отсчёт
- [x] 1.7 Тест: `Halt` сохраняет рубеж, `Resume` возвращает на него же и заново
ставит время входа **(критерий 1)**
## 2. Шаг схемы
- [x] 2.1 Новым файлом шага завести коллекции `audio_records`, `texts`,
`structures`, `recognitions`, `record_events`, `topics`
- [x] 2.2 Дописать `files` полями формата и длительности
- [x] 2.3 Уникальность: тема по паре «владелец и название», текст по паре
«запись и вид» (`texts.kind`), структура по паре «запись и версия»
- [x] 2.4 Индексы под выборку захвата: рубеж, пауза, срок захвата, признак
остановки
- [x] 2.5 Правила доступа новых коллекций: просмотр только владельцем связанной
записи; поле вложения в `recognitions` помечено защищённым
- [x] 2.6 Тот же шаг удаляет прежнюю коллекцию `transcribe_jobs`: данных под ней
нет, а пустая коллекция висела бы в панели вторым домом для того же понятия
- [x] 2.7 Тест шага: на чистом каталоге поднимаются все коллекции новой модели, и
принятая следом запись доходит до конечного рубежа
- [x] 2.8 Тест: ссылка на сохранённый ответ без сессии и с чужой сессией даёт
отказ **(критерий 10)**
- [x] 2.9 Обновить `docs/database.md` тем же изменением: гейт сверяет шаг схемы
с правкой этого документа
## 3. Контракты и репозитории
- [x] 3.1 `AudioRecognizer` отдаёт доменный результат — реплики со временем,
плоский текст, байты на хранение — вместо строки; заливка и отправка
разделены
- [x] 3.2 Контракты репозиториев: запись, файлы, тексты, структура, попытки
распознавания, журнал событий, темы
- [x] 3.3 Захват возвращает **идентификатор записи и признак этого захвата**;
признак уникален для каждого захвата; `acquireColumns` и `acquiredRow`
уходят, срок протухания пишется числом при захвате
- [x] 3.4 Запрос захвата: отбор по рубежам из дескриптора, паузе, сроку захвата и
отсутствию признака остановки, порядок по времени заведения и ключу
- [x] 3.5 Запись результата условна по признаку **этого** захвата, а не по
занятости; владелец, заголовок, краткое описание и темы шагом не
переписываются
- [x] 3.6 Перенацелить сканеры `internal/archrules` с колонок захвата на перечень
рубежей: дескриптор против списка отбора против значений шага схемы
- [x] 3.7 Тест: захват отдаёт идентификатор и признак, троим одновременным
достаётся одному
- [x] 3.8 Тест: остановленная запись захвату не выдаётся
- [x] 3.9 Тест: шаг A теряет захват после снятия остановки человеком и записи не
проводит **(критерий 6)**
## 4. Адаптер распознавания
- [x] 4.1 Разбор потока `GetRecognition` в реплики со временем внутри адаптера
- [x] 4.2 Раздельные заливка в Object Storage и отправка операции; строка попытки
заводится до обращения к провайдеру
- [x] 4.3 Заливка проверяет объект нужного размера и не повторяется; отправка не
повторяется при заведённом идентификаторе операции
- [x] 4.4 Адаптер отдаёт сырые байты ответа на хранение вложением
- [x] 4.5 Тест: структура собрана из сохранённого вложения, клиент SpeechKit не
позван ни разу **(критерий 4)**
- [x] 4.6 Тест: объект на месте — заливка не повторяется; идентификатор операции
на месте — отправка не повторяется
## 5. Конвейер
- [x] 5.1 Выбор шага по рубежу из дескриптора; шаги перестают быть привязаны к
воркеру
- [x] 5.2 Шаг приведения: две отдельные ссылки на файлы вместо одной
переставляемой
- [x] 5.3 Шаг отправки: строка попытки распознавания, идентификатор операции и
ключ объекта уезжают туда
- [x] 5.4 Шаг опроса зовёт `Postpone`, а не переход в то же состояние
- [x] 5.5 Шаг завершения: текст строкой `texts`, структура строкой `structures`,
сырой ответ вложением; повтор не заводит второго комплекта
- [x] 5.6 Предел времени в рубеже из дескриптора: остановка с причиной «застряла»,
идентификатор операции при этом сохраняется
- [x] 5.7 Единое место ответа отправителю на всякую остановку, независимо от
причины
- [x] 5.8 Отмена по остановке сервиса возвращает число отказов назад и приговора
не выносит
- [x] 5.9 Журнал событий пишется на смену рубежа, на остановку и на снятие
остановки; на откладывание — нет; колонка текста отказа зовётся
`outcome_text`, чтобы `error_text` осталось именем одной колонки
- [x] 5.10 Тест полного прохода: обе ссылки на файлы заполнены и обе открываются
**(критерий 2)**
- [x] 5.11 Тест: остановка на `normalized`, снятие признака, следующим идёт
отправка **(критерий 1)**
- [x] 5.12 Тест на подставных часах: сотня откладываний не двигает
`state_entered_at`, по истечении предела запись останавливается с причиной
«застряла» **(критерий 5)**
- [x] 5.13 Тест по перечню причин остановки: у каждой отправитель получает
сообщение **(критерий 7)**
- [x] 5.14 Тест: повтор шага завершения не заводит второй расшифровки
**(критерий 8)**
- [x] 5.15 Тест: отмена контекста не тратит отказ **(критерий 9)**
## 6. Воркеры, метрики и настройки
- [x] 6.1 Пул одинаковых воркеров вместо трёх именованных в `main.go`
- [x] 6.2 Метка счётчика `transcriber_worker_job_count` — рубеж, а не имя потока
- [x] 6.3 Число воркеров и два предела времени — в `internal/config` и
`config.example.toml` с умолчаниями; имена ключей названы в дизайне
- [x] 6.4 `N = 0` поднимает сервис без движения записей
- [x] 6.5 Тест конвейера при `N=1` и `N=4` — запись доходит до `done`; при `N=0`
остаётся в `uploaded` **(критерий 3)**
- [x] 6.6 Тест: отказ шага приведения растит счётчик с меткой своего рубежа
**(критерий 11)**
## 7. Поверхность и панель
- [x] 7.1 Ответ приёма отдаёт рубеж `uploaded`; ответ опроса — рубеж из нового
перечня плюс поле `halted`
- [x] 7.2 Текст расшифровки в ответе опроса читается из `texts` по **виду**
«сырая расшифровка», а не по последней записи
- [x] 7.3 Правила панели: снятие признака остановки и правка рубежа очищают
захват, срок, паузу и отказы и заново ставят время входа в рубеж
- [x] 7.4 Запрет удаления учётной записи считает `audio_records` и `files`
- [x] 7.5 Тесты транспорта под новые значения `status` и поле `halted`
## 8. Документы и гейт
- [x] 8.1 Инвариант `CLAUDE.md` о колонках очереди: перечень колонок съёживается,
предмет правила переезжает на перечень рубежей
- [x] 8.2 Инвариант `CLAUDE.md` о держателе захвата: держатель отличим значением
признака захвата, а не занятостью записи
- [x] 8.3 `docs/architecture.md`: компоненты, цепочка рубежей, единые точки,
таблица внешних зависимостей, раздел «Эксплуатация» — чем владелец теперь
замечает отказ
- [x] 8.4 `docs/database.md`: коллекции, представление данных, настройки с
числовым значением (число воркеров, два предела времени, сроки захвата)
- [x] 8.5 `docs/review.md`: снять ложноположительное «гонка захвата по построению
невозможна» — построение снято пулом одинаковых воркеров
- [x] 8.6 Разделы `Purpose` спек `pipeline` и `storage` при архивации: цепочка
рубежей перестала быть «сознательно не описанной», а строка про непереносимые
прежние данные — верной
- [x] 8.7 `task gate` зелёный целиком
- [x] 8.8 Поведенческая проверка живым запуском: подъём с `telegram.enabled =
false` и подставным распознавателем, запись доходит до `done`