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

- 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,168 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести
идентификатор записи полем `job_id` и её рубеж полем `status`.
Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень
состояний назван проектом необратимым, и ломка объявлена прямо — состояние
теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет
вовсе.
Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API
объявлен проектом необратимым, и переименование поля ломает внешнюю программу
молча. Меняются значения поля рубежа, а не его имя.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Проверка
стоит здесь, а не только в схеме хранилища: колонка владельца допускает пустое
значение ради записей из Telegram, и приём по HTTP — то место, где
обязательность держится.
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
стать не может.
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
пишется.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `uploaded`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
#### Scenario: Сессия не даёт учётной записи пользователя
- **GIVEN** предъявлена сессия владельца панели
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни аудиозаписи не заводится
- **AND** тело ответа не несёт данных записи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id`
**только её владельцу**. Запрос без сессии MUST получать код `401`, и тело
такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ
владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время
заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и
это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте
отсутствующего текста читается как «расшифровка пуста».
Видов текста у записи больше одного, поэтому ответ MUST называть вид, который
отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она.
Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы
у одной и той же записи от того, успел ли отработать необязательный шаг, а
контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не
применяться: она делает ответ функцией порядка записи, а не состояния записи.
Перечень значений поля `status` MUST совпадать с перечнем рубежей конвейера:
`uploaded`, `normalized`, `submitted`, `transcribed`, `done`. Прежних значений
`created`, `converted`, `transcribe`, `failed` и `dead` в ответе MUST не быть.
Это объявленная ломка публичного контракта: рубеж называет достигнутое, а отказ
перестал быть состоянием.
Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST
нести признак остановки отдельным полем `halted` со значением истины. Машинный
текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса,
а не отправителю. Отправитель узнаёт о неудаче ответом там, откуда пришла
запись, — это нормирует capability `pipeline`.
Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по
кодам ответа перебирается список заведённых записей.
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом. То же MUST относиться к записи без
владельца: запись, принятая ботом, по этому адресу не достаётся никому.
#### Scenario: Запись найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает рубеж своей записи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
- **AND** значение `status` принадлежит перечню рубежей конвейера
#### Scenario: Запись остановлена
- **GIVEN** запись остановлена признаком на рубеже приведения
- **WHEN** владелец спрашивает её рубеж
- **THEN** поле `status` несёт рубеж приведения
- **AND** поле `halted` несёт истину
- **AND** машинного текста отказа в ответе нет
#### Scenario: Сессии нет
- **WHEN** программа спрашивает рубеж заведённой записи без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
- **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем
рубеж по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Чужая запись неотличима от неизвестной
- **GIVEN** запись заведена одним вошедшим
- **WHEN** её рубеж спрашивает другой вошедший
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
идентификатору
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Записи с таким идентификатором нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает рубеж по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной записи