Files
transcriber/openspec/specs/intake/spec.md
T
av 1576d06735 внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
2026-08-14 20:20:33 +03:00

442 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# intake Specification
## Purpose
Приём записи и опрос готовности задачи расшифровки: что считается принятой
записью, что уезжает в ответ и что происходит, когда запись не удалось
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
Приём по существу описан пока **только для HTTP** — того, что нормируют
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
требованиями по-прежнему не описано — требование, написанное без проверки, это
предположение, а не норма. Первая задача, которая трогает поведение приёма из
Telegram, дописывает его сюда.
## 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 сохранять принятую запись под собственным именем — идентификатором,
к которому приписано расширение из имени файла отправителя. Имя, данное
отправителем, MUST не попадать в хранилище: оно приходит извне и содержимым
своим приёму не подконтрольно.
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
файла в хранилище расширение было всегда.
Требование переживает смену раскладки. Умолчание хранилища, строящее имя из
имени отправителя, MUST не применяться: имя отправителя в журнал не пишется по
инварианту приватности, а изъятие из него кончается расширением — хвостом после
последней точки.
#### Scenario: Расширение взято из имени отправителя
- **WHEN** программа шлёт запись с именем `test.mp3`
- **THEN** имя файла в хранилище оканчивается на `.mp3`
#### Scenario: Имени без расширения назначено своё
- **WHEN** программа шлёт запись с именем `test` без расширения
- **THEN** имя файла в хранилище оканчивается на `.audio`
#### Scenario: Имя отправителя в хранилище не попало
- **WHEN** программа шлёт запись с именем `секретное-слово.mp3`
- **THEN** имя файла в хранилище не содержит `секретное-слово`
- **AND** путь к этому файлу не содержит его тоже
### Requirement: Отказ чтения метаданных
Сервис SHALL отвечать отказом, когда источник метаданных не смог прочитать
принятую запись. Ответ MUST иметь код `500`, а причина отказа MUST не попадать в
тело ответа: она принадлежит журналу, а не отправителю.
#### Scenario: Источник метаданных вернул ошибку
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с этой записью
- **THEN** ответ имеет код `500`
- **AND** задача расшифровки не заводится
### Requirement: Имя файла, данное отправителем, не попадает в журнал
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать.
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
нормирует требование ниже; наружу расширение выходит только приведённым к
известному виду — этому отдано отдельное требование.
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
имя человека. Правка при этом ложится на общий шаг заведения задачи, через
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
её напишет задача, которая тронет его поведение.
#### Scenario: Имя записи не видно в журнале принятой записи
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку при обычном расширении `.mp3`
- **THEN** ни одна журнальная запись приёма этой строки не содержит
- **AND** расширение `.mp3` в журнале допустимо
#### Scenario: Имя записи не видно в журнале при отказе приёма
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
строки не содержит
### Requirement: Журнал приёма прослеживает запись
Приём SHALL писать в журнал идентификатор заведённого файла, расширение принятой
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
удаление имени отправителя прослеживаемости не отнимает.
Расширение засчитывается собственным полем журнальной строки. Имя, под которым
файл лёг в хранилище, приём MUST в журнал не писать: это имя — последняя часть
ссылки на скачивание, и записанное вместе с идентификатором записи оно собирает
ссылку целиком. Норму держит capability `storage`.
#### Scenario: Идентификатор, расширение и размер на месте
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
принятой записи и её размер в байтах
#### Scenario: Имени файла в хранилище в журнале нет
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет
### Requirement: Метка метрики несёт только известное расширение
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
употребить его меткой метрики: расширение приводится к нижнему регистру и
сверяется с закрытым перечнем; совпавшее идёт приведённым, всякое другое MUST
заменяться единым значением `other`. Перечень — `mp3`, `wav`, `ogg`, `oga`,
`opus`, `flac`, `m4a`, `aac`, `wma`, `mp4`, `mkv`, `mov`, `avi`, `webm`, плюс
`audio`: последнее не формат, а собственное умолчание сервиса на случай имени
без расширения, и различать его от чужого хвоста метка обязана.
Страница метрик отдаётся без проверки отправителя, поэтому метка — поверхность
пошире журнала: её читает кто угодно. Тем же ограничением снимается и рост числа
временных рядов, которым иначе распоряжается анонимный отправитель.
Требование намеренно шире приёма: под него подпадает и метка шага конвертации.
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
с ней.
Имя файла в хранилище это требование не трогает: там расширение остаётся тем,
каким пришло, — это уже нормировано требованием «Имя файла в хранилище».
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
`other` в метке означает «расширение не из перечня», а само оно стоит полем
журнальной строки приёма и полем формата строки конвертации.
#### Scenario: Незнакомое расширение наружу не выходит
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
принадлежит перечню известных форматов
- **THEN** метка метрики принимает значение `other`
- **AND** имя файла в хранилище сохраняет пришедшее расширение
#### Scenario: Известное расширение идёт как есть
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем `sample.MP3`
- **THEN** метка метрики принимает значение `mp3`
### 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` и сообщение о ненайденной записи
### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый
вход и неподнятый.
Требование стоит на том, что иначе потерянный вход не виден ничем: проба
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика —
единственный канал наблюдения, который у владельца автоматизирован.
#### Scenario: Вход Telegram не поднят
- **GIVEN** сервис поднялся без Telegram
- **WHEN** наблюдатель читает метрики
- **THEN** признак поднятости входа Telegram равен нулю
- **AND** признак поднятости входа HTTP равен единице
### Requirement: Признак включения решает, поднимается ли вход Telegram
Намерение владельца SHALL объявляться отдельным признаком включения входа
Telegram, а ключ доступа MUST означать только доступ. При выключенном входе
сервис MUST подниматься без Telegram и MUST не смотреть на ключ доступа вовсе.
При включённом входе пустой ключ MUST быть отказом старта: сообщение называет имя
незаполненного ключа и MUST не нести его значения.
Признак включения MUST быть в настройках задан. Умолчания у него нет: файл, где
признака нет вовсе, негоден, и сервис MUST выходить с ошибкой настройки, назвав
недостающий ключ. Умолчание здесь было бы угаданным намерением, а признак заведён
затем, чтобы намерение объявляли: любое умолчание делает одну из двух ошибок
тихой — либо бот молча пропадает, либо файл без признака молча работает.
Выключенный вход MUST быть назван в журнале **ровно одной** записью уровня `INFO`
при старте. Это выбор владельца, а не отклонение, и предупреждать о нём не о чем;
предупреждение остаётся за тем, чего владелец не выбирал.
При включённом входе сервис SHALL подниматься, когда вход поднять не удалось, и
MUST продолжать работу оставшимся входом: приём по HTTP, опрос готовности и
конвейер расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в
журнале **ровно одной** записью уровня `WARN` при старте — с причиной и без
значения ключа.
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
бота нет» — ошибка настройки: бот по этому ключу не появится ни от ожидания, ни
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
опечатки в ключе, перестаёт отвечать своим отправителям, и узнать об этом было бы
неоткуда.
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
здоровья.
Требование нормирует **наличие входа**, а не приём из него.
#### Scenario: Вход выключен
- **GIVEN** в настройках сервиса вход Telegram выключен
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** конвейер расшифровки работает
- **AND** бот не заведён, а в журнале ровно одна запись уровня `INFO` о том, что
вход выключен настройкой
#### Scenario: Вход выключен, а ключ доступа задан
- **GIVEN** в настройках сервиса вход Telegram выключен
- **AND** ключ доступа при этом заполнен
- **WHEN** сервис запускается
- **THEN** он поднимается без Telegram, и бот не заводится
- **AND** к Telegram не уходит ни одного обращения
#### Scenario: Вход включён, а ключа доступа нет
- **GIVEN** в настройках сервиса вход Telegram включён
- **AND** ключ доступа пуст
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** сообщение об отказе называет имя незаполненного ключа
#### Scenario: Признака включения в настройках нет
- **GIVEN** в настройках сервиса нет признака включения входа Telegram
- **AND** ключ доступа заполнен и Telegram признаёт по нему бота
- **WHEN** сервис запускается
- **THEN** старт кончается отказом настройки
- **AND** сообщение об отказе называет недостающий ключ
#### Scenario: Вход включён и ключ годен
- **GIVEN** в настройках сервиса вход Telegram включён
- **AND** стоит ключ, по которому Telegram признаёт бота
- **WHEN** сервис запускается
- **THEN** он поднимается и работает обоими входами
#### Scenario: Telegram не отвечает
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
- **AND** запись не несёт значения ключа
#### Scenario: Telegram ответил, что такого бота нет
- **GIVEN** в настройках сервиса вход Telegram включён и ключ непуст
- **AND** Telegram отвечает отказом на этот ключ
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** ни журнал, ни текст отказа не несут значения ключа