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

- 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
+72 -40
View File
@@ -18,18 +18,22 @@ Telegram, дописывает его сюда.
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
сохранена и получить заведённую под неё задачу расшифровки в состоянии
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
полем `status`.
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести
идентификатор записи полем `job_id` и её рубеж полем `status`.
Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень
состояний назван проектом необратимым, и ломка объявлена прямо — состояние
теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет
вовсе.
Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API
объявлен проектом необратимым, и переименование поля ломает внешнюю программу
молча. Меняются значения поля рубежа, а не его имя.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
и переименование поля ломает внешнюю программу молча. Появление отказа без
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
@@ -61,30 +65,30 @@ Telegram, дописывает его сюда.
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created`
со значением `uploaded`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой задачи стоит предъявитель сессии
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
#### Scenario: Сессия не даёт учётной записи пользователя
- **GIVEN** предъявлена сессия владельца панели
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни задачи не заводится
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни задачи не заводится
- **AND** тело ответа не несёт данных задачи
- **AND** ни файла, ни аудиозаписи не заводится
- **AND** тело ответа не несёт данных записи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни задачи не заводится
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Размеру записи приём не судья
@@ -237,58 +241,86 @@ Telegram, дописывает его сюда.
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать состояние задачи расшифровки по запросу
`GET /api/status/:id` **только её владельцу**. Запрос без сессии MUST получать
код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни текста
расшифровки. Ответ владельцу MUST нести идентификатор полем `job_id`, состояние
полем `status` и время заведения полем `created_at`, а текст расшифровки полем
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет:
пустая строка на месте отсутствующего текста читается как «расшифровка пуста».
Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id`
**только её владельцу**. Запрос без сессии MUST получать код `401`, и тело
такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ
владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время
заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и
это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте
отсутствующего текста читается как «расшифровка пуста».
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
кодам ответа перебирается список заведённых задач.
Видов текста у записи больше одного, поэтому ответ MUST называть вид, который
отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она.
Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы
у одной и той же записи от того, успел ли отработать необязательный шаг, а
контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не
применяться: она делает ответ функцией порядка записи, а не состояния записи.
Задача, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом. То же 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: Задача найдена
#### Scenario: Запись найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает состояние своей задачи
- **WHEN** он спрашивает рубеж своей записи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
- **AND** значение `status` принадлежит перечню рубежей конвейера
#### Scenario: Запись остановлена
- **GIVEN** запись остановлена признаком на рубеже приведения
- **WHEN** владелец спрашивает её рубеж
- **THEN** поле `status` несёт рубеж приведения
- **AND** поле `halted` несёт истину
- **AND** машинного текста отказа в ответе нет
#### Scenario: Сессии нет
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
- **WHEN** программа спрашивает рубеж заведённой записи без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
состояние по неизвестному идентификатору
- **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем
рубеж по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Чужая задача неотличима от неизвестной
#### Scenario: Чужая запись неотличима от неизвестной
- **GIVEN** задача заведена одним вошедшим
- **WHEN** её состояние спрашивает другой вошедший
- **GIVEN** запись заведена одним вошедшим
- **WHEN** её рубеж спрашивает другой вошедший
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
идентификатору
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает состояние своей задачи, которая ещё не дошла до текста
- **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Задачи с таким идентификатором нет
#### Scenario: Записи с таким идентификатором нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
- **WHEN** программа спрашивает рубеж по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной записи
### Requirement: Поднятые входы видны наблюдателю
+516 -162
View File
@@ -2,28 +2,36 @@
## Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
Конвейер расшифровки: как аудиозапись движется по рубежам, что делает воркер,
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
когда доставить его некуда.
Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число
попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи
результата держателем захвата и недоставка ответа при неподнятом входе.
Сознательно не описаны: цепочка переходов `created → converted → transcribe →
done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
требования на него не написаны, потому что требование без проверки —
предположение, а не норма. Первая задача, которая трогает любое из
перечисленного, дописывает его сюда.
Описаны цепочка рубежей и смысл рубежа, остановка признаком и её причины, оба
сторожа — число отказов и время в рубеже, — откладывание работы отдельно от
перехода, неделимость захвата и срок его протухания, условие записи результата
держателем захвата, нарастающая пауза перед повтором, число воркеров настройкой,
журнал событий записи и недоставка ответа при неподнятом входе.
Сознательно не описаны: освобождение ресурсов внешних клиентов и **какие отказы
считаются приговором записи, а какие поводом к повтору**. Второе — не пробел
формулировки, а неразобранный вопрос: сегодня отказ приведения останавливает
запись с первой попытки, и предел отказов на нём не работает никогда. Это не
значит, что поведения нет: оно живёт в коде, а требования на него не написаны,
потому что требование без проверки — предположение, а не норма. Первая задача,
которая трогает любое из перечисленного, дописывает его сюда.
## Requirements
### Requirement: Пустой прогон воркера — не отказ
Воркер SHALL отличать «работы в этом состоянии сейчас нет» от отказа шага. На
Воркер SHALL отличать «пригодной к работе записи сейчас нет» от отказа шага. На
пустом прогоне он MUST не считать прогон отказом: не увеличивать счётчик работы
и не писать о нём на уровне владельца сервиса. Признак пустого прогона MUST
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
пояснения, добавленные к этому значению на любом промежуточном шаге пути.
Формулировка сменилась вместе с моделью: воркер больше не привязан к рубежу и
опрашивает не «своё состояние», а очередь целиком, поэтому пустой прогон значит
«работы нет ни на одном рубеже», а не «работы нет в этом состоянии».
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: воркеры
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт от
каждого запись отказа в секунду и столько же засчитанных сбоев, которых не было.
@@ -31,12 +39,12 @@ done | failed`, отмена контекста посреди шага и ос
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
в цепочке своей ошибки. Воркер узнаёт признак по смыслу на любой глубине, поэтому
отказ, к которому признак примешался, тоже зачёл бы пустым прогоном: задача
осталась бы в своём состоянии и переопрашивалась раз в секунду без единой записи
отказ, к которому признак примешался, тоже зачёл бы пустым прогоном: запись
осталась бы на своём рубеже и переопрашивалась раз в секунду без единой записи
— ровно то, что запрещает инвариант «Принятая запись не теряется молча».
Отказ шага, наоборот, MUST быть виден владельцу сервиса записью в журнале и MUST
быть засчитан в счётчик работы с пометкой отказа.
быть засчитан в счётчик работы с пометкой отказа и с меткой рубежа.
**Сколько раз он записывается и каким уровнем — это требование не нормирует, и
умолчанием тут считать нечего.** Сегодня один отказ даёт две записи: пишет шаг
@@ -48,16 +56,16 @@ done | failed`, отмена контекста посреди шага и ос
двойную, — закрыло бы долг контрактом. Задача, которая возьмётся за этот долг,
дописывает норму сюда.
#### Scenario: Работы в состоянии нет
#### Scenario: Пригодной к работе записи нет
- **GIVEN** ни одной задачи в опрашиваемом состоянии нет
- **GIVEN** ни одной записи, пригодной к работе, нет ни на одном рубеже
- **WHEN** воркер делает свой прогон
- **THEN** на уровне владельца сервиса об этом прогоне не пишется ничего
- **AND** счётчик работы воркера не растёт
#### Scenario: Признак пустого прогона дошёл с пояснением
- **GIVEN** работы в опрашиваемом состоянии нет
- **GIVEN** пригодной к работе записи нет
- **AND** промежуточный шаг добавил к этому признаку своё пояснение
- **WHEN** воркер делает свой прогон
- **THEN** прогон по-прежнему считается пустым: счётчик не растёт, записи на
@@ -68,29 +76,45 @@ done | failed`, отмена контекста посреди шага и ос
- **GIVEN** шаг конвейера вернул отказ
- **WHEN** воркер завершает прогон
- **THEN** отказ виден владельцу сервиса записью в журнале
- **AND** счётчик работы воркера растёт с пометкой отказа
- **AND** счётчик работы воркера растёт с пометкой отказа и меткой рубежа
#### Scenario: Шаг сделал работу
- **GIVEN** шаг конвейера отработал задачу без отказа
- **GIVEN** шаг конвейера отработал запись без отказа
- **WHEN** воркер завершает прогон
- **THEN** счётчик работы воркера растёт с пометкой успеха
- **AND** записи об отказе в журнале нет
### Requirement: Захват задачи неделим
Захват задачи воркером SHALL быть одним неделимым шагом хранилища: выбор
подходящей задачи и пометка её захваченной MUST происходить вместе, и захваченная
задача MUST возвращаться тем же шагом.
Захват записи воркером SHALL быть одним неделимым шагом хранилища: выбор
подходящей записи и пометка её захваченной MUST происходить вместе.
Одна и та же задача MUST доставаться ровно одному захватившему. Двум вызывающим,
пришедшим за одним состоянием одновременно, запись MUST достаться одному, а
второй MUST получить признак «работы в этом состоянии нет».
Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не
перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе
всякая новая колонка аудиозаписи попадала бы под инвариант проекта о колонках
очереди, и забытая в захвате колонка приезжала бы нулевой, а первое же
сохранение писало бы этот ноль поверх сохранённого значения.
**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не
признаком занятости. Условие записи результата сверяет именно это значение:
захват, перевыданный другому — по протуханию срока или после того, как человек
снял признак остановки в панели, — обязан обращать запись первого в отказ.
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
два шага записали бы в одну запись и оба ответили бы отправителю.
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
получить признак «работы сейчас нет».
Срок протухания захвата MUST ехать с рубежом записи, а не с воркером: воркер не
привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом
при самом захвате.
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
обработки невоспроизводим, а проверка, опирающаяся на «следующую» задачу,
зелена через раз.
обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена
через раз.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
@@ -99,41 +123,57 @@ done | failed`, отмена контекста посреди шага и ос
Признак «работы нет» этим требованием не переопределяется — его нормирует
требование «Пустой прогон воркера — не отказ».
#### Scenario: За задачей пришли трое разом
#### Scenario: За работой пришли трое разом
- **GIVEN** в опрашиваемом состоянии лежит ровно одна задача
- **WHEN** три захвата этого состояния идут одновременно
- **GIVEN** к работе пригодна ровно одна запись
- **WHEN** три захвата идут одновременно
- **THEN** запись получает ровно один из них
- **AND** двое остальных получают признак «работы в этом состоянии нет»
- **AND** двое остальных получают признак «работы сейчас нет»
#### Scenario: Захваченная задача не выдаётся второй раз
#### Scenario: Захваченная запись не выдаётся второй раз
- **GIVEN** задача захвачена и срок захвата не истёк
- **WHEN** за тем же состоянием приходит следующий захват
- **THEN** эта задача ему не выдаётся
- **GIVEN** запись захвачена и срок захвата не истёк
- **WHEN** приходит следующий захват
- **THEN** эта запись ему не выдаётся
#### Scenario: Захват отдаёт идентификатор и свой признак
- **GIVEN** к работе пригодна запись
- **WHEN** воркер её захватывает
- **THEN** захват возвращает идентификатор записи и признак этого захвата
- **AND** колонки записи шаг читает отдельным чтением
#### Scenario: Признак перевыданного захвата отличается от прежнего
- **GIVEN** запись захвачена, и признак первого захвата известен
- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер
- **THEN** признак нового захвата отличается от признака первого
### Requirement: Результат пишет только держатель захвата
Шаг конвейера SHALL записывать свой результат только тогда, когда захват задачи
всё ещё принадлежит ему. Запись MUST быть условна по признаку захвата, а шаг,
чей захват за время работы достался другому, MUST завершиться без записи
Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата**
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
результата и без ответа отправителю.
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
теряет задачу, продолжая работать. Без этого условия два воркера пишут в одну
задачу по очереди, счётчик попыток сбрасывает тот, кто уже не владелец, а
отправитель получает два ответа на одну запись.
теряет запись, продолжая работать. Снять захват может и человек, вернувший
остановленную запись в работу. Без условия по уникальному признаку два воркера
пишут в одну запись по очереди, счётчик отказов сбрасывает тот, кто уже не
владелец, а отправитель получает два ответа на одну запись.
Шаг MUST записывать только те поля, которыми распоряжается сам. Задачу он держит
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
уверен, что правка на месте.
уверен, что правка на месте. Владелец записи, заголовок, краткое описание и темы
конвейер MUST не трогать.
#### Scenario: Правка владельца пережила сохранение шага
- **GIVEN** шаг держит захваченную задачу
- **GIVEN** шаг держит захваченную запись
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
- **WHEN** шаг записывает свой результат
- **THEN** результат шага записан
@@ -141,157 +181,121 @@ done | failed`, отмена контекста посреди шага и ос
#### Scenario: Захват ушёл под работающим шагом
- **GIVEN** шаг работает над захваченной задачей
- **AND** за это время та же задача досталась другому захвату
- **GIVEN** шаг работает над захваченной записью
- **AND** за это время та же запись досталась другому захвату
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
- **AND** отправителю ничего не отправляется
#### Scenario: Человек снял остановку под работающим шагом
- **GIVEN** шаг работает над захваченной записью
- **AND** человек за это время снял с неё признак остановки, освободив захват
- **AND** запись досталась другому воркеру
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
### Requirement: Брошенная задача возвращается в работу
Задача, захваченная и брошенная на середине, SHALL доставаться снова по
Запись, захваченная и брошенная на середине, SHALL доставаться снова по
истечении срока захвата. Срок MUST считаться от времени захвата, а истёкший
захват MUST не мешать выдать задачу следующему.
захват MUST не мешать выдать запись следующему.
Срок задаётся шагом конвейера и MUST быть не меньше того времени, которое этот
шаг может занять на самом длинном допустимом входе. Срок короче делает
протухание штатным событием живого шага, а не признаком беды.
Срок задаётся рубежом, с которого запись взята, и MUST быть не меньше того
времени, которое шаг этого рубежа может занять на самом длинном допустимом
входе. Срок короче делает протухание штатным событием живого шага, а не
признаком беды. Срок MUST записываться в саму запись при захвате: воркер шага не
знает и вывести срок из себя не может.
Все значения времени, по которым идёт этот отбор, MUST записываться и сравниваться
в одном виде — том же, в каком хранилище пишет собственные времена записи.
Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем, обращает
условие в постоянную истину или постоянную ложь, причём молча.
Все значения времени, по которым идёт этот отбор, MUST записываться и
сравниваться в одном виде — том же, в каком хранилище пишет собственные времена
записи. Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем,
обращает условие в постоянную истину или постоянную ложь, причём молча.
#### Scenario: Захват протух
- **GIVEN** задача захвачена, а время захвата отстоит дальше срока
- **WHEN** за её состоянием приходит захват
- **THEN** задача выдаётся ему
- **GIVEN** запись захвачена, а время захвата отстоит дальше срока
- **WHEN** приходит захват
- **THEN** запись выдаётся ему
#### Scenario: Срок сравнивается с временем, записанным хранилищем
- **GIVEN** задача захвачена, и время захвата записано в том же виде, в каком
- **GIVEN** запись захвачена, и время захвата записано в том же виде, в каком
хранилище пишет время изменения записи
- **WHEN** за её состоянием приходит захват до истечения срока
- **THEN** задача ему не выдаётся
- **WHEN** приходит захват до истечения срока
- **THEN** запись ему не выдаётся
### Requirement: Число попыток и состояние «мертва»
#### Scenario: Срок протухания приехал с рубежом
У задачи SHALL быть число попыток. Оно MUST расти при каждом захвате и MUST
возвращаться к нулю, когда шаг завершился без отказа. Рост при захвате, а не при
отказе, засчитывает попытку и задаче, брошенной на середине: шаг, уносящий с
собой процесс, до объявления отказа не доходит никогда, и без этого такая задача
крутилась бы вечно.
Задача, захваченная с числом попыток сверх заданного предела, MUST переводиться в
состояние «мертва» тем, кто её захватил, и MUST не отдаваться шагу в работу. Перевод
принадлежит одному месту: условие отбора, молча пропускающее задачу мимо выборки,
оставило бы её без состояния и без следа.
Мёртвая задача MUST отбираться владельцем по своему состоянию и MUST
возвращаться в работу правкой этого состояния — без запроса в консоли сервера.
Переход в «мертва» MUST сообщать отправителю о неудаче ровно так же, как
сообщает о ней отказ шага. Иначе он становится третьим исходом там, где инвариант
проекта «Принятая запись не теряется молча» допускает два: задача не пригодна к
повтору и об отказе никто не сказал.
От состояния отказа «мертва» отличается тем, чей это приговор. В `failed` задачу
переводит шаг, рассудивший об этой записи окончательно: конвертация не удалась,
распознавание вернуло ошибку. В «мертва» задача уходит без такого суждения — мы
повторяли и перестали. Ни один шаг конвейера в «мертва» не переводит сам.
Прежний признак «задача с ошибкой», исключавший задачу из выборки навсегда и
отдельный от перечня состояний, MUST не заводиться заново: два способа вывести
задачу из выборки расходятся, и молчаливо теряется тот, который забыли проверить.
#### Scenario: Задача падает на каждой попытке
- **GIVEN** шаг конвейера отказывает на каждой попытке
- **WHEN** задача проходит заданное число попыток
- **THEN** она переходит в состояние «мертва»
- **AND** следующий захват её не выдаёт
- **AND** отправитель получает сообщение о неудаче
#### Scenario: Шаг уносит процесс, не объявив отказа
- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке
- **WHEN** задача захватывается снова заданное число раз
- **THEN** она переходит в состояние «мертва»
#### Scenario: Прошедшая задача попыток не копит
- **GIVEN** задача прошла подряд несколько состояний без единого отказа
- **WHEN** смотрят её число попыток
- **THEN** оно не приблизилось к пределу
#### Scenario: Мёртвая задача возвращена в работу
- **GIVEN** задача в состоянии «мертва»
- **WHEN** её состояние сменили на то, с которого она отказывала
- **THEN** следующий захват выдаёт её снова
- **GIVEN** записи двух рубежей с разными сроками захвата пригодны к работе
- **WHEN** их захватывает один и тот же воркер
- **THEN** у каждой записан срок её рубежа
### Requirement: Пауза перед повтором нарастает
Перед повтором **отказавшей** задачи сервис SHALL выдерживать паузу, и пауза
MUST расти с числом её попыток до объявленного потолка. Задача MUST не
Перед повтором **отказавшей** записи сервис SHALL выдерживать паузу, и пауза
MUST расти с числом её отказов до объявленного потолка. Запись MUST не
выдаваться захвату, пока пауза не кончилась.
Ожидание чужой операции этой паузой MUST не выражаться. Шаг, увидевший, что
внешняя операция ещё идёт, отработал без отказа: он назначает **свою** задержку
опроса, заданную числом, и попытки при этом не тратит. Пауза, выведенная из
числа попыток, на таком шаге вырождается в наименьшее своё значение и учащает
опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее секунды.
внешняя операция ещё идёт, отработал без отказа: он **откладывает** работу своей
задержкой, заданной числом, и отказов при этом не тратит. Пауза, выведенная из
числа отказов, на таком шаге вырождается в наименьшее своё значение и учащает
опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее
секунды.
#### Scenario: Отказавшая задача ждёт
#### Scenario: Отказавшая запись ждёт
- **GIVEN** задача отказала на шаге конвейера
- **GIVEN** запись отказала на шаге конвейера
- **WHEN** захват приходит раньше конца её паузы
- **THEN** задача ему не выдаётся
- **THEN** запись ему не выдаётся
#### Scenario: Вторая пауза длиннее первой
- **GIVEN** задача отказала дважды подряд
- **GIVEN** запись отказала дважды подряд
- **WHEN** сравнивают паузу после второго отказа с паузой после первого
- **THEN** вторая длиннее
#### Scenario: Ожидание операции не учащается и не тратит попыток
#### Scenario: Ожидание операции не учащается и не тратит отказов
- **GIVEN** внешняя операция распознавания ещё идёт
- **WHEN** шаг проверки отрабатывает подряд несколько раз
- **WHEN** шаг опроса отрабатывает подряд несколько раз
- **THEN** задержка до следующей проверки каждый раз одна и та же
- **AND** число попыток задачи не растёт
- **AND** число отказов записи не растёт
### Requirement: Недоставленный ответ не роняет шаг
Шаг конвейера SHALL доводить задачу до достигнутого состояния, когда ответ
Шаг конвейера SHALL доводить запись до достигнутого рубежа, когда ответ
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
задачи, MUST называть причину и MUST считаться отдельной метрикой с причиной
записи, MUST называть причину и MUST считаться отдельной метрикой с причиной
меткой.
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят**
задача заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
у задачи не назван** — источником значится Telegram, а чата в задаче нет.
запись заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
у записи не назван** — источником значится Telegram, а чата в записи нет.
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
записи: у задачи из Telegram чат есть всегда, и пропасть он может только от
записи: у записи из Telegram чат есть всегда, и пропасть он может только от
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
записей о ненастроенном боте.
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
достигнутое состояние сохранено. Работа к этой минуте сделана, и объявленный
отказ засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть
соврал бы про исход дважды. Повтор делу не помогает: ни бот, ни адресат от
ожидания не появятся. Поэтому задача остаётся в достигнутом состоянии, в повтор
не уходит и в `failed` не переводится, а причина недоставки живёт в записи
журнала, а не в состоянии задачи.
достигнутый рубеж сохранён. Работа к этой минуте сделана, и объявленный отказ
засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть соврал бы
про исход дважды. Повтор делу не помогает: ни бот, ни адресат от ожидания не
появятся. Поэтому запись остаётся на достигнутом рубеже, в повтор не уходит и
**признака остановки не получает**, а причина недоставки живёт в записи журнала,
а не в рубеже записи.
Идентификатор задачи в записи обязателен: без него владелец видит, что ответ не
ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя в эту
запись MUST не попадать — приватность содержимого записи требование не
То же MUST относиться к недоставке сообщения об **остановке**: остановка уже
сохранена, и недоставка её MUST не отменять.
Идентификатор записи в этой строке обязателен: без него владелец видит, что
ответ не ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя
в эту запись MUST не попадать — приватность содержимого записи требование не
ослабляет.
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
@@ -299,60 +303,410 @@ MUST расти с числом её попыток до объявленног
#### Scenario: Вход отправителя не поднят
- **GIVEN** задача принята входом Telegram прошлым запуском сервиса
- **GIVEN** запись принята входом Telegram прошлым запуском сервиса
- **AND** сервис поднялся без этого входа
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии, в повтор не уходит и в
`failed` не переводится
- **AND** запись остаётся на достигнутом рубеже, в повтор не уходит и признака
остановки не получает
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
задачи и причиной
записи и причиной
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
#### Scenario: Адресат у задачи не назван
#### Scenario: Адресат у записи не назван
- **GIVEN** у задачи источником значится Telegram, а чат не назван
- **GIVEN** у записи источником значится Telegram, а чат не назван
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии
- **AND** запись остаётся на достигнутом рубеже
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
задачи и причиной: неназванный адресат — симптом порчи записи
записи и причиной: неназванный адресат — симптом порчи записи
#### Scenario: Не доехало сообщение об остановке
- **GIVEN** запись остановлена признаком
- **AND** вход отправителя не поднят
- **WHEN** шаг доходит до ответа отправителю
- **THEN** признак остановки у записи остаётся
- **AND** в журнале есть запись о недоставке с идентификатором записи и причиной
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
- **GIVEN** задача принята по HTTP
- **GIVEN** запись принята по HTTP
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа и без записи о недоставке
### Requirement: Выборка воркера владельцем не сужается
Воркер SHALL брать задачи всех владельцев подряд и MUST не учитывать владельца
при выборе очередной задачи. Задача без владельца — принятая ботом — MUST
Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца
при выборе очередной записи. Запись без владельца — принятая ботом — MUST
обрабатываться наравне с прочими.
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных
поставило бы в зависимость от того, кто первым завёл учётную запись.
Владелец задачи MUST переживать работу конвейера: шаг, сохраняющий свой
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
результат, владельца не трогает и не затирает.
#### Scenario: Задачи двух владельцев проходят одним воркером
#### Scenario: Записи двух владельцев проходят одним воркером
- **GIVEN** заведены задачи двух разных владельцев в одном состоянии
- **WHEN** воркер забирает задачи этого состояния
- **GIVEN** заведены записи двух разных владельцев на одном рубеже
- **WHEN** воркер забирает работу
- **THEN** ему достаются обе, в порядке заведения
#### Scenario: Задача без владельца обрабатывается
#### Scenario: Запись без владельца обрабатывается
- **GIVEN** заведена задача, принятая ботом, — без владельца
- **WHEN** воркер забирает задачи её состояния
- **GIVEN** заведена запись, принятая ботом, — без владельца
- **WHEN** воркер забирает работу
- **THEN** она достаётся ему наравне с прочими
#### Scenario: Шаг конвейера владельца не затирает
- **GIVEN** задача с владельцем прошла шаг конвейера
- **GIVEN** запись с владельцем прошла шаг конвейера
- **WHEN** шаг сохраняет свой результат
- **THEN** владелец задачи остаётся прежним
- **THEN** владелец записи остаётся прежним
### Requirement: Рубеж записи называет достигнутое
Аудиозапись SHALL нести рубеж — состояние, называющее **достигнутое**, а не
предстоящее. Цепочка рубежей: `uploaded`, `normalized`, `submitted`,
`transcribed`, `done`. Какой шаг делать дальше, сервис MUST выбирать по рубежу
одним общим местом, а не тем, какой воркер пришёл за записью.
Прежние состояния называли предстоящую работу (`created`, `converted`,
`transcribe`), и потому по состоянию нельзя было сказать, что с записью уже
сделано: продолжить с места остановки было не с чего.
Конечный рубеж MUST зваться `done`. Доставка ответа отправителю в конвейер не
входит, и слово описывает пройденный конвейер, а не полученный человеком текст.
Перечень рубежей, из которых запись берётся в работу, MUST выводиться из одного
объявления рубежа, а не перечисляться отдельно каждым потребителем. Рубеж,
забытый в отборе захвата, не выдаётся ни одному воркеру никогда, а пустой прогон
по инварианту проекта не пишется в журнал и не считается в метрику: запись
встала бы без единого следа. Потребителей у перечня больше двух — выбор шага,
отбор захвата, срок захвата, предел времени, закрытый перечень значений в схеме,
— и человеческая сверка между ними не механизируема.
Записи на конечном рубеже MUST не браться в работу и MUST не подпадать под
предел времени в рубеже: `done` не ждёт работы, и стоять в нём запись будет
вечно по построению.
#### Scenario: Рубеж называет сделанное
- **GIVEN** запись прошла приведение к рабочему формату
- **WHEN** смотрят её рубеж
- **THEN** он называет приведение сделанным, а не предстоящим
#### Scenario: Следующий шаг выбирается по рубежу
- **GIVEN** запись стоит на рубеже приведения
- **WHEN** её берёт воркер
- **THEN** идёт отправка на распознавание, а не повторное приведение
#### Scenario: Запись на конечном рубеже не берут и не останавливают
- **GIVEN** запись стоит на конечном рубеже дольше любого предела
- **WHEN** приходит захват
- **THEN** запись ему не выдаётся
- **AND** признака остановки у неё не появляется
### Requirement: Остановка записи — признак, а не рубеж
Сервис SHALL останавливать запись отдельным признаком с причиной и MUST не
стирать при этом достигнутый рубеж. Признак MUST нести время остановки, причину
и машинный текст отказа.
Снятие признака SHALL возвращать запись в работу **с того рубежа, где она
стояла**, и MUST сбрасывать число отказов, паузу **и время входа в рубеж**.
Время входа сбрасывается по той же причине, что и остальные сторожа: запись,
простоявшая остановленной дольше предела, иначе останавливалась бы снова первым
же захватом, и перезапуск не работал бы вовсе.
Прежние состояния отказа и смерти MUST не заводиться заново: обе причины
восстанавливаются одинаково — снятием признака, — и различие между ними
перестаёт быть структурным, оставаясь причиной остановки. Состояние, называющее
отказ, стирает достигнутый рубеж, и продолжение с места остановки становится
невозможным.
**Способ вывести запись из выборки MUST быть один — этот признак.** Второго
признака, исключающего запись из работы помимо рубежа и паузы, MUST не
заводиться: два способа расходятся, и молчаливо теряется тот, который забыли
проверить. Условие отбора MUST не выводить запись из выборки молча — запись,
переставшая браться в работу, обязана нести признак остановки с причиной.
Остановку MUST ставить тот, кто запись захватил. Перевод принадлежит одному
месту: условие отбора, молча пропускающее запись мимо выборки, оставило бы её
без следа.
Остановленная запись MUST не выдаваться захвату.
#### Scenario: Остановленная запись продолжает с места остановки
- **GIVEN** шаг остановил запись на рубеже приведения
- **WHEN** признак остановки снимают
- **THEN** следующим идёт отправка на распознавание, а не повторное приведение
#### Scenario: Остановленная запись не выдаётся захвату
- **GIVEN** у записи стоит признак остановки
- **WHEN** за её рубежом приходит захват
- **THEN** запись ему не выдаётся
#### Scenario: Снятие признака сбрасывает всех сторожей
- **GIVEN** запись остановлена с накопленными отказами и паузой
- **AND** остановленной она простояла дольше предела времени в рубеже
- **WHEN** признак остановки снимают
- **THEN** число отказов, пауза и время входа в рубеж сброшены
- **AND** ближайший захват выдаёт запись, а не останавливает её снова
### Requirement: Всякая остановка сообщает отправителю
Остановка записи по любой причине SHALL сообщать отправителю о неудаче ровно
так же, как сообщает о ней отказ шага, и MUST быть видна владельцу сервиса
записью в журнале.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
инвариант допускает два исхода — запись пригодна к повтору либо об отказе
сказано, — а остановленная запись захвату не выдаётся, значит первый исход
исключён.
Причин остановки больше одной, и обязанность общая для всех: исчерпанные
отказы, застревание в рубеже, приговор шага. Обязанность, записанная у одной
причины, у остальных читалась бы как снятая.
Ответ уходит **после** того, как признак остановки сохранён, и недоставка этого
ответа MUST не отменять остановку: её нормирует требование «Недоставленный ответ
не роняет шаг».
#### Scenario: Остановка по отказам сообщает отправителю
- **GIVEN** запись остановлена по исчерпании отказов
- **WHEN** шаг доходит до ответа отправителю
- **THEN** отправитель получает сообщение о неудаче
#### Scenario: Остановка по времени сообщает отправителю
- **GIVEN** запись остановлена по пределу времени в рубеже
- **WHEN** шаг доходит до ответа отправителю
- **THEN** отправитель получает сообщение о неудаче
- **AND** в журнале владельца есть запись об остановке с причиной
### Requirement: Время в рубеже ограничено
У аудиозаписи SHALL быть время входа в рубеж, и оно MUST ставиться только при
смене рубежа и при возврате записи в работу. Запись, простоявшая в рубеже дольше
предела, MUST останавливаться признаком с причиной «застряла».
Пределов MUST быть два, и граница проходит по тому, **чью работу ждём**: своя
работа — час, ожидание чужой операции — сутки. Одно общее число пришлось бы
мерить по самому долгому, и застрявшее приведение стояло бы сутки; число на
каждый рубеж назвало бы разными вещи, различающиеся только исполнителем. Сколько
идёт распознавание долгой записи, сервис не мерил, поэтому у чужой работы ошибка
идёт в сторону долгого: ложная остановка хуже поздней. Оба числа MUST лежать в
настройках.
Сторож этот ловит **зависание**, а не долгую работу, и час у своей работы меньше
времени, которое многочасовая запись занимает на приведении. Цена решения
названа прямо: длинная запись, отказавшая один раз и ждущая повтора дольше часа,
будет остановлена как застрявшая. Цена ограничена тем, что остановка обратима —
снятие признака возвращает запись на её рубеж, — и тем, что живой шаг проверяется
по самому процессу. Решение владельца 2026-08-14.
Откладывание опроса MUST не двигать время входа в рубеж и MUST не сдвигать этот
предел. Иначе запись, чью чужую операцию опрашивают раз в несколько секунд,
никогда не достигнет предела, и застревание останется незамеченным.
Остановка по этому пределу MUST ничего не терять: идентификатор чужой операции
остаётся в строке попытки распознавания, и снятие признака возобновляет опрос
той же операции, а не заводит вторую.
#### Scenario: Сотня откладываний не двигает отсчёт
- **GIVEN** запись стоит на рубеже отправки, и чужая операция ещё идёт
- **WHEN** опрос откладывается сотню раз подряд
- **THEN** время входа в рубеж не изменилось
- **AND** отсчёт до предела не обнулился
#### Scenario: Предел достигнут
- **GIVEN** запись простояла в рубеже дольше своего предела
- **WHEN** за ней приходит захват
- **THEN** запись получает признак остановки с причиной «застряла»
#### Scenario: Возобновление опроса не заводит вторую операцию
- **GIVEN** запись остановлена по пределу на рубеже отправки
- **WHEN** признак остановки снимают
- **THEN** опрос идёт по прежнему идентификатору операции
- **AND** новая операция у провайдера не заводится
### Requirement: Откладывание не является переходом
Сервис SHALL различать переход на новый рубеж и откладывание работы над
записью. Откладывание MUST ставить паузу и снимать захват, MUST не трогать ни
рубеж, ни время входа в него, и MUST не считаться отказом: ожидание чужой
операции отказом не является, поэтому число отказов оно MUST обнулять.
Сегодня шаг опроса зовёт переход с **тем же** состоянием, и мнимость этого
перехода обнуляет счётчик. Без разделения время входа в рубеж сбрасывалось бы на
каждом опросе и повторило бы ровно тот промах, ради которого заводится.
#### Scenario: Откладывание не двигает рубеж
- **GIVEN** шаг опроса увидел, что чужая операция ещё идёт
- **WHEN** он откладывает работу
- **THEN** рубеж записи прежний, и время входа в него прежнее
- **AND** захват с записи снят, а пауза поставлена
### Requirement: Шаг с внешней оплатой проверяет сделанное
Шаг, чьё повторение оплачивается наружу, SHALL проверять, не сделана ли работа
уже, и MUST не делать её второй раз. Проверка MUST идти по наблюдаемому признаку
присутствия результата, а не по сверке содержимого хешем: у составного объекта
во внешнем хранилище признак целостности не равен отпечатку содержимого.
Признак MUST записываться прежде, чем оплачиваемое обращение считается
состоявшимся: строка попытки распознавания заводится до обращения к провайдеру,
и повторный шаг начинает с проверки, не заведена ли операция.
Полной защиты от обрыва процесса между ответом провайдера и записью признака
требование не даёт и дать не может: жёсткая остановка контейнера не оставляет
места ни одной записи. Это остаточный риск, названный в дизайне, а не норма:
норма, обязывающая к недостижимому, не пишется.
#### Scenario: Работа уже сделана
- **GIVEN** результат оплачиваемого шага уже на месте, и признак его записан
- **WHEN** шаг повторяется
- **THEN** внешнее обращение не повторяется
### Requirement: Число воркеров задаётся настройкой
Сервис SHALL брать число рабочих потоков конвейера из настроек, а сами потоки
MUST не быть привязаны к отдельному шагу: каждый берёт любую подходящую запись и
выбирает шаг по её рубежу. Поведение записи MUST не зависеть от числа потоков.
Ноль MUST быть законным значением: сервис поднимается, записи принимаются и не
двигаются. Это режим, а не поломка.
Счётчик работы воркера MUST различать шаги: метка счётчика MUST нести рубеж, с
которого запись взята, а не имя или номер потока. У одинаковых потоков имя
перестаёт что-либо значить, а счётчик отказов — единственный сигнал, по которому
владелец сервиса замечает поломку; без разреза по шагу «падает приведение» и
«падает распознавание» становятся неразличимы.
Опрос чужой операции MUST оставаться работой очереди, а не отдельного
смотрителя: очередь даёт ему неделимость захвата и возврат брошенного даром, а
единственный смотритель умирает молча и уносит с собой целый класс записей.
#### Scenario: Запись доходит при одном потоке и при нескольких
- **GIVEN** число потоков конвейера равно одному
- **WHEN** запись проходит конвейер
- **THEN** она доходит до конечного рубежа
- **AND** при числе потоков больше одного исход тот же
#### Scenario: Потоков нет вовсе
- **GIVEN** число потоков конвейера равно нулю
- **WHEN** запись принимают
- **THEN** сервис принимает её и не теряет
- **AND** запись остаётся на первом рубеже
#### Scenario: Отказ виден с разрезом по шагу
- **GIVEN** шаг приведения отказал
- **WHEN** наблюдатель читает счётчик работы воркера
- **THEN** отказ засчитан с меткой рубежа приведения
### Requirement: Журнал событий записи пишется на смену рубежа
Сервис SHALL вести журнал событий аудиозаписи и MUST писать в него строку на
смену рубежа, на остановку и на снятие остановки. Строка MUST называть источник
события — шаг конвейера или человека, — сам шаг, исход и длительность.
Журнал MUST не писаться на каждое откладывание опроса: часовая запись дала бы
сотни строк ни о чём.
Ни один шаг конвейера MUST не читать этот журнал, чтобы решить, что делать
дальше: решение принимается по рубежу записи, и второй источник решения
разошёлся бы с первым молча.
Содержимое записи в журнал событий MUST не попадать — инвариант приватности
действует здесь наравне с журналом сервиса.
#### Scenario: Смена рубежа записана
- **GIVEN** шаг конвейера довёл запись до нового рубежа
- **WHEN** смотрят журнал событий этой записи
- **THEN** в нём есть строка с шагом, исходом и длительностью
#### Scenario: Откладывание строки не пишет
- **GIVEN** опрос чужой операции откладывается многократно
- **WHEN** смотрят журнал событий записи
- **THEN** строк об откладываниях в нём нет
#### Scenario: Перезапуск человеком виден в журнале
- **GIVEN** запись остановлена признаком
- **WHEN** человек снимает признак
- **THEN** в журнале событий есть строка с указанием, что это сделал человек
### Requirement: Число отказов ограничивает повторы шага
У аудиозаписи SHALL быть число отказов. Оно MUST расти при каждом захвате и MUST
возвращаться к нулю, когда шаг завершился без отказа либо отложил работу. Рост
при захвате, а не при отказе, засчитывает попытку и записи, брошенной на
середине: шаг, уносящий с собой процесс, до объявления отказа не доходит
никогда.
**Остановка сервиса отказом не считается.** Шаг, прерванный отменой по
собственной остановке сервиса, MUST возвращать число отказов назад и MUST не
выносить записи приговора: запись не виновата в том, что нас перезапустили, и
несколько выкладок подряд иначе останавливают здоровую многочасовую запись с
приговором «отказы исчерпаны». Всякая другая причина, по которой шаг не дошёл до
объявления исхода, отказ тратит.
Запись, захваченная с числом отказов сверх заданного предела, MUST
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
работу. Об этой остановке отправителю сообщается наравне с прочими — норму
держит требование «Всякая остановка сообщает отправителю».
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
с одной из двух обязанностей — опрос, вернувший «ещё в работе», обнуляет его, и
зависшая чужая операция опрашивается вечно, а не обнулял бы — убивал бы здоровую
запись.
#### Scenario: Запись отказывает на каждой попытке
- **GIVEN** шаг конвейера отказывает на каждой попытке
- **WHEN** запись проходит заданное число отказов
- **THEN** у неё появляется признак остановки
- **AND** следующий захват её не выдаёт
- **AND** отправитель получает сообщение о неудаче
#### Scenario: Шаг уносит процесс, не объявив отказа
- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке
- **WHEN** запись захватывается снова заданное число раз
- **THEN** у неё появляется признак остановки
#### Scenario: Остановка сервиса отказа не тратит
- **GIVEN** шаг работает над записью
- **WHEN** сервис останавливают, и шаг прерывается отменой
- **THEN** число отказов записи прежнее
- **AND** признака остановки у записи не появляется
#### Scenario: Прошедшая запись отказов не копит
- **GIVEN** запись прошла подряд несколько рубежей без единого отказа
- **WHEN** смотрят её число отказов
- **THEN** оно не приблизилось к пределу
+151
View File
@@ -0,0 +1,151 @@
# recognition Specification
## Purpose
TBD - created by archiving change record-centric-model. Update Purpose after archive.
## Requirements
### Requirement: Попытка распознавания хранится отдельно от записи
Сервис SHALL держать всё, что принадлежит внешнему распознавателю, отдельной
строкой, связанной с аудиозаписью, и MUST не хранить это колонками самой записи.
К попытке относятся имя провайдера, имя модели, идентификатор операции у
провайдера, адрес, по которому провайдер читал аудио, время начала и время
завершения.
Разрез проходит по одной границе: **зависит ли вещь от провайдера
распознавания**. Идентификатор операции — самое провайдерское, что есть в
модели, а копия аудио во внешнем хранилище существует только потому, что
сегодняшний провайдер читает запись по адресу; другой провайдер её не потребует.
Оставленные колонками записи, они делают смену провайдера правкой доменной
сущности.
Копия аудио во внешнем хранилище MUST не считаться файлом записи: у записи
остаётся ровно две своих копии — принятая и приведённая, — а ключ объекта живёт
в строке попытки.
#### Scenario: Идентификатор операции лежит в попытке
- **GIVEN** запись отправлена на распознавание
- **WHEN** смотрят, где лежит идентификатор операции у провайдера
- **THEN** он лежит в строке попытки распознавания
- **AND** колонки с ним у самой записи нет
#### Scenario: Копия во внешнем хранилище не подменяет файл записи
- **GIVEN** запись прошла отправку на распознавание
- **WHEN** смотрят ссылки записи на файлы
- **THEN** они ведут на принятую и на приведённую копии
- **AND** ключ объекта во внешнем хранилище лежит в строке попытки
### Requirement: Сырой ответ провайдера сохраняется целиком
Сервис SHALL сохранять ответ распознавателя целиком, в том виде, в каком он
пришёл, и MUST хранить его вложением, а не колонкой строки попытки.
Хранится он потому, что **результат операции у провайдера не переспрашивается**:
связь реплики с говорящим сервис строить пока не умеет, и когда научится, архив
пересчитается из сохранённого без повторной оплаты.
Вложением, а не колонкой, — потому что шаг опроса читает строку попытки часто, а
хранилище читает запись целиком: ответ на многочасовую запись, положенный
колонкой, ехал бы в память при каждом опросе.
Чтение строки попытки шагом опроса MUST не тянуть за собой сохранённый ответ.
Сохранённый ответ — это полный текст речи, и закрыт он MUST быть наравне с самой
записью: поле вложения помечено защищённым, правило просмотра пускает только
владельца связанной записи, ссылка не попадает ни в журнал, ни в метку метрики.
Норму держит capability `storage`, требование «Содержимое записи закрыто во всех
коллекциях, где лежит»; здесь она названа потому, что коллекция попыток — то
место, куда содержимое приезжает впервые.
#### Scenario: Ответ сохранён и читается позже
- **GIVEN** распознавание завершилось и ответ провайдера получен
- **WHEN** запись доходит до конечного рубежа
- **THEN** сохранённый ответ доступен по строке попытки целиком
#### Scenario: Опрос не тянет сохранённый ответ
- **GIVEN** у попытки распознавания есть сохранённый ответ
- **WHEN** шаг опроса читает строку попытки
- **THEN** сохранённый ответ в память при этом не читается
### Requirement: Структура реплик строится из сохранённого ответа
Сервис SHALL строить структуру реплик записи из сохранённого ответа провайдера и
MUST не обращаться к провайдеру повторно ради неё. Структура MUST хранить время
каждой реплики и MUST лежать отдельной строкой со ссылкой с записи, а не
колонкой записи.
У структуры MUST быть номер версии её вида: разбор сохранённого ответа изменится
раньше, чем архив пересчитают, и по номеру видно, какой разбор её построил.
Говорящих структура сегодня не размечает: связь реплики с разбором говорящего у
провайдера не выяснена. Требование этого и не заказывает — оно заказывает
источник, из которого разметка станет возможной без повторной оплаты.
#### Scenario: Структура собрана без обращения к провайдеру
- **GIVEN** ответ провайдера сохранён
- **WHEN** сервис строит структуру реплик
- **THEN** структура собрана с временем каждой реплики
- **AND** к провайдеру не уходит ни одного обращения
### Requirement: Разбор формата провайдера не выходит за адаптер
Распознаватель SHALL отдавать сервису доменный результат — реплики со временем,
плоский текст и байты ответа на хранение, — и MUST не отдавать сырой формат
провайдера. Ни один шаг конвейера MUST не знать, каким потоком и какими полями
провайдер отвечает.
Сегодня разбор потока лежит в шаге: адаптер отдаёт строку, склеенную из
альтернатив, и всё, что провайдер сказал сверх текста, теряется на границе
контракта.
#### Scenario: Шаг получает реплики, а не поток провайдера
- **WHEN** шаг конвейера забирает результат распознавания
- **THEN** он получает реплики со временем, плоский текст и байты на хранение
- **AND** формата провайдера в этом результате нет
### Requirement: Заливка и отправка на распознавание разделены
Сервис SHALL разделять укладку аудио туда, откуда провайдер его прочитает, и
отправку операции распознавания: это два разных обращения с разной ценой
повтора. Повтор укладки MUST быть бесплатен и класть объект под тем же ключом;
повтор отправки оплачивается наружу и MUST не происходить, когда операция уже
заведена.
Разделение нужно затем, чтобы шаг мог проверить сделанное прежде, чем платить:
объект нужного размера на месте — укладку MUST не повторять; идентификатор
операции в строке попытки есть — отправку MUST не повторять.
Строка попытки MUST заводиться **до** обращения к провайдеру: окно между ответом
провайдера и записью идентификатора — то место, где теряется оплаченное. Мягкую
остановку сервиса отправка MUST переживать своим пределом по времени; полной
защиты от жёсткого обрыва процесса требование не даёт и дать не может — это
остаточный риск, названный в дизайне, а не норма.
#### Scenario: Объект уже лежит, а операции ещё нет
- **GIVEN** аудио уже уложено туда, откуда провайдер его читает, и размер совпадает
- **AND** идентификатора операции в строке попытки нет
- **WHEN** шаг повторяется
- **THEN** укладка не повторяется
- **AND** операция отправляется
#### Scenario: Операция уже заведена
- **GIVEN** в строке попытки есть идентификатор операции
- **WHEN** шаг повторяется
- **THEN** отправка не повторяется
- **AND** шаг переходит к опросу этой операции
#### Scenario: Операция принята, а сервис мягко останавливают
- **GIVEN** отправка операции ушла провайдеру
- **AND** сервис в эту минуту останавливают мягко
- **WHEN** провайдер отвечает идентификатором операции
- **THEN** идентификатор сохраняется в строке попытки
- **AND** повторная отправка той же записи не заводится
+219 -59
View File
@@ -2,14 +2,17 @@
## Purpose
Где живут запись, её метаданные и её файл: раскладка каталога данных, приведение
схемы при подъёме, отдача файла ссылкой по токену, собственная поверхность
хранилища и панель владельца.
Где живут аудиозапись, её приложения и её файлы: раскладка каталога данных,
приведение схемы при подъёме, отдача файла ссылкой по токену, собственная
поверхность хранилища и панель владельца.
Приём и опрос готовности нормирует `intake`, вход и сессию — `access`.
Сознательно не описаны: перенос прежних данных — его нет по решению задачи
`pocketbase-storage`; удаление записей и файлов — сервис объявлен архивом
2026-08-11, а удаление приносит задача `delete-record`.
Приём и опрос готовности нормирует `intake`, вход и сессию — `access`, попытку
распознавания у внешнего провайдера — `recognition`.
Сознательно не описаны: перенос прежних данных — его нет ни по решению задачи
`pocketbase-storage`, ни по решению владельца 2026-08-14, которым прежние записи
удалены вместе с остановкой сервиса; удаление записей и файлов — сервис объявлен
архивом 2026-08-11, а удаление приносит задача `delete-record`.
## Requirements
### Requirement: Сервис поднимается на чистом каталоге данных
@@ -208,22 +211,24 @@ MUST завести свою схему и принимать записи об
### Requirement: Владелец видит записи в панели
Сервис SHALL давать владельцу панель, где задача видна строкой, отбирается по
своему идентификатору и правится, а её файл слушается и скачивается.
Сервис SHALL давать владельцу панель, где аудиозапись видна строкой, отбирается
по своему идентификатору и правится, а её файлы слушаются и скачиваются.
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
второго процесса.
Панель — вход в задачу наравне с конвейером, а не окно просмотра, и правка
состояния задачи в ней MUST подчиняться тем же правилам перехода, что и правка
из кода: служебные поля прошлого состояния — признак захвата, время захвата,
пауза, число попыток — MUST очищаться. Иначе владелец, вернувший мёртвую задачу в
работу, получит задачу, которая не выдаётся захвату до конца прежнего срока и
умирает от первого же отказа, — и не узнает об этом.
Панель — вход в запись наравне с конвейером, а не окно просмотра. Снятие
признака остановки в панели MUST возвращать запись в работу с сохранённого
рубежа и MUST очищать служебные поля прошлого захвата — признак захвата, срок
его протухания, паузу, число отказов и MUST заново ставить время входа в
рубеж. Правка рубежа руками MUST делать то же самое. Иначе владелец, вернувший
запись в работу, получит запись, которая не выдаётся захвату до конца прежнего
срока, останавливается от первого же отказа или останавливается снова первым же
захватом по пределу времени, — и не узнает об этом.
Задача, заведённая в панели руками, MUST не уносить сервис: поля, без которых
Запись, заведённая в панели руками, MUST не уносить сервис: поля, без которых
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
перечень состояний — закрытым.
перечень рубежей — закрытым.
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
@@ -231,18 +236,20 @@ MUST завести свою схему и принимать записи об
#### Scenario: Принятая запись видна владельцу
- **GIVEN** запись принята и её задача заведена
- **WHEN** владелец отбирает задачи по идентификатору принятой
- **THEN** он видит её строкой со своим состоянием
- **AND** файл этой записи скачивается из той же строки
- **GIVEN** запись принята и заведена
- **WHEN** владелец отбирает записи по идентификатору принятой
- **THEN** он видит её строкой со своим рубежом
- **AND** её файл скачивается из той же строки
#### Scenario: Мёртвую задачу вернули в работу правкой в панели
#### Scenario: Остановленную запись вернули в работу правкой в панели
- **GIVEN** задача в состоянии «мертва» с исчерпанными попытками и признаком
- **GIVEN** запись остановлена признаком, с накопленными отказами и признаком
прежнего захвата
- **WHEN** владелец меняет её состояние на рабочее
- **THEN** признак захвата, время захвата, пауза и число попыток очищены
- **AND** ближайший захват выдаёт задачу
- **AND** остановленной она простояла дольше предела времени в рубеже
- **WHEN** владелец снимает признак остановки
- **THEN** признак захвата, срок его протухания, пауза и число отказов очищены
- **AND** время входа в рубеж поставлено заново
- **AND** ближайший захват выдаёт запись с сохранённого рубежа
### Requirement: Пароль владельца от панели не лежит в конфигурации
@@ -278,8 +285,8 @@ MUST завести свою схему и принимать записи об
### Requirement: Владелец задачи лежит связью с учётной записью
Хранилище SHALL держать владельца задачи расшифровки отдельной колонкой — связью
с учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
не назван, не достаётся никому по недосмотру схемы.
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи,
@@ -287,35 +294,33 @@ MUST завести свою схему и принимать записи об
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама
capability `intake`, а не схема.
Колонка приезжает **новым шагом схемы**: применённый шаг не переписывается.
Записей, заведённых до этого шага, сервис не переносит — проект заводится с
чистого листа.
Владелец MUST не назначаться и не меняться конвейером.
#### Scenario: Колонка появляется на пустой базе
- **WHEN** сервис поднимается на чистом каталоге данных
- **THEN** у таблицы задач есть колонка владельца
- **THEN** у аудиозаписи есть колонка владельца
- **AND** умолчания у неё нет
#### Scenario: Конвейер владельца не назначает
- **GIVEN** запись с владельцем прошла шаг конвейера
- **WHEN** смотрят её владельца
- **THEN** он прежний
### Requirement: Файл записи сужается владельцем наравне с задачей
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, тем же шагом схемы, — и правило просмотра файлов MUST пускать к файлу
только его владельца. Прежнее правило пускало всякого узнанного, и знание
идентификатора файловой записи равнялось праву скачать чужое аудио.
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
Без этого требования разграничение закрывает метаданные задачи и оставляет
открытым содержимое — то самое, что оно и заведено прятать. Хуже самой дыры была
бы отметка о закрытии: паспорт и модель угроз называют исполнителем этой работы
именно эту задачу, и слово «закрыто» скрыло бы открытый путь.
Владелец файла MUST назначаться там же, где владелец задачи, — при приёме, из
Владелец файла MUST назначаться там же, где владелец записи, — при приёме, из
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером
для записи без владельца.
Ссылка на файл в задаче переставляется каждым шагом конвейера, поэтому владелец
файла MUST лежать своей колонкой, а не выводиться через задачу: исходная копия
после конвертации не связана с задачей ничем.
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
выводиться через запись: файл переживает свою запись, и заведённый шагом до
сохранения записи он остаётся с владельцем и без ссылки.
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
@@ -343,15 +348,21 @@ capability `intake`, а не схема.
### Requirement: Учётная запись с записями не удаляется
Хранилище SHALL отвергать удаление учётной записи, у которой остались задачи
расшифровки **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
Хранилище SHALL отвергать удаление учётной записи, у которой остались
аудиозаписи **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
другую подменяет сообщением про обязательную связь — подсказкой, по которой
владелец панели пойдёт удалять записи руками.
Считаются обе коллекции с владельцем. Файл переживает свою задачу: шаг конвейера
заводит его до сохранения задачи, и потерянный захват оставляет файл с владельцем
и без ссылки.
Считаются **все** коллекции с колонкой владельца, и перечень их MUST жить одним
местом: коллекция, пропущенная в счёте, пропускает удаление вперёд, и наружу
приезжает не наш отказ с причиной, а подсказка библиотеки про обязательную связь
— та самая, по которой владелец панели пойдёт удалять записи руками. Сегодня их
три: аудиозаписи, файлы и словарь тем.
Файл переживает свою запись: шаг конвейера заводит его до сохранения записи, и
потерянный захват оставляет файл с владельцем и без ссылки. Тема переживает её
так же: словарь принадлежит человеку, а не записи.
Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его
позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой
@@ -361,12 +372,6 @@ capability `intake`, а не схема.
удалить **свою** учётную запись запросом, так что запрет закрывает и публичную
поверхность.
Требование заведено вместо прежнего «удаление не уносит задачи следом»: оно
выглядело выполненным, а на деле хранилище при выключенном каскаде **снимает
ссылку** — задачи остаются, но становятся ничьими, а ничья задача не достаётся
по API никому. Архив человека исчезал бы молча и восстановлению не подлежал:
прежнего владельца не остаётся нигде.
Цена требования названа прямо: владелец панели упирается в отказ, а способа
удалить записи в сервисе пока нет вовсе — его приносит задача про удаление
записи. До неё удаление учётной записи с записями невозможно, и это осознанный
@@ -374,20 +379,175 @@ capability `intake`, а не схема.
#### Scenario: Удаление учётной записи с записями отвергается
- **GIVEN** у учётной записи есть задачи расшифровки
- **GIVEN** у учётной записи есть аудиозаписи
- **WHEN** её удаляют
- **THEN** удаление не проходит, а отказ называет причину
- **AND** задачи и их владелец остаются прежними
- **AND** записи и их владелец остаются прежними
#### Scenario: Учётная запись с одними файлами тоже не удаляется
- **GIVEN** у учётной записи остались файлы, но задач нет
- **GIVEN** у учётной записи остались файлы, но записей нет
- **WHEN** её удаляют
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
#### Scenario: Учётная запись с одними темами тоже не удаляется
- **GIVEN** у учётной записи остались темы словаря, но ни записей, ни файлов нет
- **WHEN** её удаляют
- **THEN** удаление не проходит, а отказ называет причину нашими словами
#### Scenario: Учётная запись без записей удаляется
- **GIVEN** у учётной записи нет ни задач, ни файлов
- **GIVEN** у учётной записи нет ни аудиозаписей, ни файлов, ни тем
- **WHEN** её удаляют
- **THEN** удаление проходит
### Requirement: Аудиозапись — центральная сущность хранилища
Хранилище SHALL держать аудиозапись отдельной сущностью, а всё, что к ней
приложено, — отдельными строками со ссылками с записи. Приложениями считаются
файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания.
Поля, которыми распоряжается очередь — признак захвата, срок его протухания,
пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым
записи в одной строке настолько, чтобы чтение очереди тянуло содержимое: сегодня
расшифровка лежит колонкой той же строки и читается при каждом захвате.
Запись MUST нести заголовок и краткое описание своими колонками: они читаются
вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать
отдельными строками: они читаются по открытию одной записи.
#### Scenario: Список читается без содержимого
- **GIVEN** у записи есть расшифровка
- **WHEN** читают запись ради её рубежа и заголовка
- **THEN** текст расшифровки при этом не читается
### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит
Всякая коллекция, куда переезжает содержимое аудиозаписи, SHALL быть закрыта
наравне с самой записью: её правило просмотра MUST не открывать содержимое
никому, кроме владельца связанной записи, а поле, хранящее файл или вложение,
MUST быть помечено защищённым.
Пока содержимое отдаётся собственным адресом сервиса, а не поверхностью
хранилища, правило просмотра MUST оставаться незаданным — то есть «только
владелец панели». Непустое правило открывает перечисление коллекции, и заводить
его раньше, чем появится потребитель, значит открывать поверхность впрок:
норму держит требование «Наружу хранилище отдаёт только то, что заказано».
Требование распространяется на все коллекции приложений — тексты, структуру
реплик, попытку распознавания с её сохранённым ответом, журнал событий и темы, —
и заводится потому, что содержимое **переезжает** из одной строки в шесть. Норма
о защищённом поле файла сегодня написана про файл записи, а сырой ответ
распознавателя — это полный текст речи в другой коллекции: реализация, следующая
только прежней норме, завела бы поле с умолчанием библиотеки, и ссылка на него
отдавала бы расшифровку любому, кто её знает, без сессии.
Ссылка на такое вложение MUST не попадать ни в журнал, ни в метку метрики, ни в
ответ отправителю — теми же словами, какими это нормировано для файла записи.
Умолчание библиотеки здесь не годится ни в одном месте: незаданное правило
просмотра значит «только владелец панели» и отнимает содержимое у самого
владельца записи, а незащищённое поле файла отдаёт его всем.
#### Scenario: Чужой сохранённый ответ не отдаётся
- **GIVEN** запись принята одним вошедшим и прошла распознавание
- **WHEN** другой вошедший идёт по ссылке на сохранённый ответ провайдера
- **THEN** содержимого он не получает
#### Scenario: Без сессии содержимое не отдаётся
- **WHEN** ссылку на сохранённый ответ провайдера запрашивают без сессии
- **THEN** приходит отказ, а содержимого в ответе нет
#### Scenario: Перечисление приложений закрыто
- **WHEN** запрос без прав владельца просит список записей коллекции текстов
- **THEN** приходит отказ
### Requirement: Ссылки на исходник и приведённую копию живут порознь
Аудиозапись SHALL нести две отдельные ссылки на файлы — на принятую копию и на
копию, приведённую к рабочему формату, — и шаг конвейера MUST не переставлять
одну ссылку на свой результат.
Сегодня ссылка одна, и её переставляет каждый шаг: у прошедшей конвейер записи
она ведёт на копию во внешнем хранилище, а принятого человеком файла не найти
ничем. Послушать загруженное нечем именно поэтому.
Обе копии MUST оставаться доступными после того, как запись прошла конвейер.
#### Scenario: После конвейера доступны обе копии
- **GIVEN** запись прошла конвейер целиком
- **WHEN** смотрят её ссылки на файлы
- **THEN** ссылка на принятую копию и ссылка на приведённую заполнены
- **AND** обе открываются
### Requirement: Тексты и структура лежат отдельно от записи
Хранилище SHALL держать тексты записи отдельными строками, каждая со своим видом
текста, и структуру реплик — своей строкой. Запись MUST ссылаться на них, а не
хранить их колонками.
Видов текста больше одного: сырая расшифровка и вычитанный текст. Колонкой на
каждый вид схема росла бы с каждым новым видом, а необратимый шаг схемы платится
за каждую такую колонку отдельно.
**Приложение MUST быть уникально по паре «запись и вид»**, а структура — по паре
«запись и версия разбора». Шаг завершения пишет текст, структуру и сохранённый
ответ несколькими операциями и только потом двигает рубеж: прерванный на середине
и повторённый с прежнего рубежа, он завёл бы второй комплект строк, и вопрос
«какой текст отдавать человеку» стал бы вопросом порядка записи, а не состояния.
Потребитель текста MUST называть **вид**, который берёт, а не брать последний
записанный: иначе исход зависит от порядка записи. Ответ опроса готовности берёт
сырую расшифровку — норму держит capability `intake`.
#### Scenario: Расшифровка лежит своей строкой
- **GIVEN** запись прошла распознавание
- **WHEN** смотрят, где лежит текст расшифровки
- **THEN** он лежит отдельной строкой, на которую запись ссылается
#### Scenario: Повтор шага не заводит второй расшифровки
- **GIVEN** шаг завершения записал расшифровку и оборвался до смены рубежа
- **WHEN** шаг повторяется с прежнего рубежа
- **THEN** строка расшифровки у записи одна
### Requirement: Словарь тем ведётся по владельцу
Хранилище SHALL держать темы отдельной коллекцией, и тема MUST быть уникальна в
паре «владелец и название»: словарь тем свой у каждого человека. У записи MUST
быть не больше пяти тем.
Коллекцией, а не набором строк в записи, — потому что перечень тем человека
нужен целиком перед каждым обращением к модели, а собрать его из наборов строк
можно только перебором всех его записей.
Потолок в пять тем MUST быть у самой записи: без него часовой разговор даёт два
десятка тем, и словарь распухает за неделю.
Название темы выведено из содержимого записи, а перечень тем человека — слепок
того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с
текстом расшифровки.
Ни один шаг этого изменения тем не пишет и не читает: место заводится вперёд,
чтобы задача, считающая темы языковой моделью, не платила вторым необратимым
шагом схемы. Цена решения названа прямо — имена коллекции и её колонок
закрепляются раньше, чем известен их потребитель.
#### Scenario: Тема одного человека не мешает теме другого
- **GIVEN** у двух владельцев заведена тема с одинаковым названием
- **WHEN** смотрят словарь тем
- **THEN** это две разные темы, каждая своего владельца
#### Scenario: Шестая тема не заводится
- **WHEN** записи назначают шестую тему
- **THEN** назначение не проходит