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

33 KiB
Raw Blame History

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 ни журнал, ни текст отказа не несут значения ключа