внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-14
|
||||
@@ -0,0 +1,345 @@
|
||||
## Context
|
||||
|
||||
Сегодня в хранилище одна строка несёт всё разом: домен записи (владелец, файл),
|
||||
поля очереди (захват, пауза, попытки) и содержимое (расшифровка целиком, в
|
||||
колонке). Отсюда четыре следствия, и все они наблюдаемы:
|
||||
|
||||
- **захват тянет содержимое.** Запрос захвата перечисляет колонки поимённо и
|
||||
читает среди них расшифровку; часовая запись едет в память при каждом опросе;
|
||||
- **ссылка на файл одна, и её переставляет каждый шаг.** У прошедшей конвейер
|
||||
записи она ведёт на копию во внешнем хранилище, и принятого человеком файла не
|
||||
найти ничем — задача `play-recording-in-app` упирается именно в это;
|
||||
- **отказ стирает достигнутое.** Переход в состояние отказа чистит служебные
|
||||
поля и не оставляет рубежа: продолжить с места остановки не с чего, и владелец
|
||||
правит состояние в панели наугад;
|
||||
- **один счётчик несёт две обязанности.** Число попыток ограничивает и повторы
|
||||
внутри шага, и застревание. Опрос чужой операции обнуляет его переходом в то
|
||||
же самое состояние — значит зависшая операция опрашивается вечно; перестань он
|
||||
обнуляться, и здоровая долгая запись умирала бы на шестом опросе.
|
||||
|
||||
Замысел выработан разговором 2026-08-14 (запись `record-centric-model`), там же
|
||||
разобраны и закрыты четыре развилки: где живут темы, дробить ли задачу, как
|
||||
зовётся конечный рубеж и чем ограничивается застревание. Открытых вопросов
|
||||
постановка не оставила.
|
||||
|
||||
Ограничения, которые эта работа не выбирает: хранилище — встроенная PocketBase,
|
||||
применённый шаг схемы не переписывается, публичный контракт HTTP API объявлен
|
||||
необратимым. Записей на сервере при этом нет: сервис остановлен, а прежние
|
||||
данные удалены решением владельца 2026-08-14 — новая модель заводится с чистого
|
||||
листа, и переноса данных эта работа не делает.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Аудиозапись становится центральной сущностью, приложения к ней живут
|
||||
отдельными строками, а поля очереди перестают соседствовать с содержимым.
|
||||
- Рубеж называет достигнутое, остановка становится признаком, и запись
|
||||
продолжает с места остановки.
|
||||
- Два сторожа вместо одного: отказы ограничивают повторы, время в рубеже —
|
||||
застревание.
|
||||
- Воркеры теряют специализацию, их число задаётся настройкой.
|
||||
- Схема заводится одним шагом; переноса прежних данных нет.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Резать длинную запись на фрагменты — конвейер остаётся цепочкой
|
||||
(`long-audio-chunking`).
|
||||
- Выносить доставку из конвейера: ответ в Telegram остаётся хвостом последнего
|
||||
шага.
|
||||
- Считать уровни текста: сам шаг обращения к языковой модели делает
|
||||
`llm-insights-adapter`, вычитанный текст — `literary-text-level`. Здесь
|
||||
заводятся только места, куда они лягут.
|
||||
- Размечать говорящих в структуре: связь реплики с разбором говорящего у
|
||||
провайдера не выяснена.
|
||||
- Ставить таймауты внешним вызовам — `external-call-timeouts`.
|
||||
- **Заводить обязательность и необязательность шага.** Ревью дизайна показало,
|
||||
что носителя у неё нет ни одного: все пять рубежей обязательны, а первый
|
||||
необязательный шаг приносит `llm-insights-adapter`. Норма без экземпляра
|
||||
проверяется либо подставным шагом ради теста — что запрещено запретом на
|
||||
проверки над проверками, — либо галочкой без оракула; вернётся вместе с первым
|
||||
своим шагом.
|
||||
- **Давать владельцу записи снимать остановку.** Постановка этого просила, но
|
||||
поверхности нет: адресов у сервиса два (приём и опрос), экранов нет вовсе, а
|
||||
открыть правку коллекции запросом запрещает действующая норма `storage`
|
||||
(«Наружу хранилище отдаёт только то, что заказано»). Сегодня остановку снимает
|
||||
владелец панели; владельцу записи это даст задача, заводящая экраны.
|
||||
- Нарастающая пауза опроса и отступ на пустой очереди: первое —
|
||||
`speechkit-callback-fit`, второе при единицах записей в день не нужно.
|
||||
- Экраны.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Разрез сущностей идёт по зависимости от провайдера
|
||||
|
||||
Границ для разреза было три, и выбрана одна.
|
||||
|
||||
- **По зависимости от провайдера распознавания** — принято. Всё, что перестанет
|
||||
быть верным при смене провайдера (идентификатор операции, адрес, по которому
|
||||
провайдер читает аудио, имя модели, сырой ответ), уезжает в строку попытки;
|
||||
всё остальное остаётся доменом записи. Смена провайдера тогда не трогает
|
||||
доменную сущность вовсе.
|
||||
- **По изменчивости** (что правит конвейер против того, что правит человек) —
|
||||
отвергнуто: граница проходит внутри одной колонки. Рубеж правят оба.
|
||||
- **По частоте чтения** — отвергнуто как единственная граница: она объясняет,
|
||||
почему тексты уезжают из записи, но ничего не говорит про идентификатор
|
||||
операции, который читается ровно так же часто, как рубеж.
|
||||
|
||||
Частота чтения при этом остаётся **вторым** разрезом, внутри домена: `title` и
|
||||
`brief` читаются сотней штук разом и остаются колонками записи, расшифровка и
|
||||
вычитанный текст читаются по открытию одной записи и уезжают строками `texts`.
|
||||
|
||||
### Сырой ответ провайдера хранится вложением, а не колонкой
|
||||
|
||||
Ответ на многочасовую запись — мегабайты. Хранилище читает запись целиком, а шаг
|
||||
опроса читает строку попытки раз в несколько секунд: положенный колонкой, ответ
|
||||
ехал бы в память при каждом опросе — тот же промах, что расшифровка в перечне
|
||||
колонок захвата сегодня. Вложение читается только тогда, когда его просят.
|
||||
|
||||
Хранится он вообще потому, что **результат операции у провайдера не
|
||||
переспрашивается**. Отвергнутый вариант — не хранить и разобрать на лету:
|
||||
дешевле сегодня, но связь реплики с говорящим мы строить пока не умеем, и когда
|
||||
научимся, архив пересчитать будет не из чего, а повторная операция стоит денег
|
||||
за каждую запись.
|
||||
|
||||
### Остановка — признак, а не рубеж
|
||||
|
||||
Прежние `failed` и `dead` схлопываются в признак `halted_at` с причиной, а
|
||||
`state` не стирается.
|
||||
|
||||
- **Признак** — принято: рубеж переживает остановку, продолжение идёт с места
|
||||
остановки, массовый перезапуск после выкатки правки делается одним обновлением,
|
||||
а различие «мы рассудили» против «мы перестали пробовать» остаётся причиной,
|
||||
которую человек читает.
|
||||
- **Отдельное состояние на каждую причину** — отвергнуто: перечень состояний
|
||||
закрыт схемой, и каждая новая причина стоила бы необратимого шага.
|
||||
- **Оставить как есть** — отвергнуто: именно из-за этого перезапись состояния
|
||||
руками в панели остаётся единственным способом вернуть запись в работу, и
|
||||
делается он наугад.
|
||||
|
||||
### Сторожей двое, и предела времени — два числа
|
||||
|
||||
`attempts` считает **отказы** и ограничивает повторы внутри шага;
|
||||
`state_entered_at` считает **время** и ограничивает застревание.
|
||||
|
||||
Пределов времени два, и граница проходит не по рубежам, а по тому, чью работу
|
||||
ждём: своя (`uploaded`, `normalized`, `transcribed`) и чужая (`submitted`).
|
||||
|
||||
- **Одно общее число** — отвергнуто: мерить его пришлось бы по самому долгому, и
|
||||
застрявшее приведение стояло бы столько же, сколько застрявшая чужая операция.
|
||||
- **Число на каждый рубеж** — отвергнуто: четыре числа назвали бы разными вещи,
|
||||
различающиеся только исполнителем, и три из них были бы одинаковы.
|
||||
|
||||
Сколько идёт распознавание долгой записи, никто не мерил (`speechkit-limits`,
|
||||
`intake-limits-measure`), поэтому ошибаемся в сторону долгого: ложная остановка
|
||||
хуже поздней.
|
||||
|
||||
**Час на свою работу меньше самой работы, и это принято сознательно.** Расчётный
|
||||
потолок записи — шесть часов, приведение такой записи идёт дольше часа по
|
||||
построению (`docs/review.md`, запись 2026-08-13), а срок захвата шага приведения
|
||||
стоит сегодня восемью часами. Значит длинная запись, отказавшая один раз и
|
||||
ждущая повтора дольше часа, будет остановлена сторожем застревания вместо
|
||||
расшифровки. Ревью дизайна предлагало вывести предел из срока захвата (12 часов
|
||||
на свою работу); владелец решил 2026-08-14 оставить час, потому что сторож ловит
|
||||
**зависание**, а живая работа наблюдается по самому процессу конвертера, и
|
||||
остановка теперь обратима — снятие признака возвращает запись на её рубеж, и
|
||||
цена ложной остановки равна одному движению владельца. Оба числа в конфиге,
|
||||
ключи — `[pipeline] own_work_limit` и `[pipeline] foreign_work_limit`.
|
||||
|
||||
**Второй сторож пришлось сбрасывать там же, где первые.** Снятие признака
|
||||
остановки и правка рубежа в панели заново ставят время входа в рубеж: запись,
|
||||
простоявшая остановленной дольше предела, иначе останавливалась бы снова первым
|
||||
же захватом, и перезапуск — главное, ради чего заводится признак, — не работал бы
|
||||
ни для одной записи старше предела.
|
||||
|
||||
### Признак захвата — значение, а не занятость
|
||||
|
||||
Захват возвращает идентификатор записи **и признак этого захвата**, уникальный
|
||||
для каждого захвата. Условие записи результата сверяет именно это значение.
|
||||
|
||||
Причина проверяемая: панель по требованию `storage` очищает признак захвата,
|
||||
когда человек снимает остановку. Условие, проверяющее лишь непустоту признака
|
||||
или срок протухания, пропустило бы обоих — шаг A, потерявший запись, и шаг B,
|
||||
её подобравший, — и оба записали бы результат и оба ответили бы отправителю.
|
||||
Гонки часов для этого не нужно: достаточно одного движения человека в панели.
|
||||
|
||||
### Имена, которые уезжают необратимым шагом
|
||||
|
||||
Коллекция зовётся `audio_records`, а не `audiorecords`: соседи по схеме —
|
||||
`transcribe_jobs`, `files`, `record_events` — в snake_case, и одно исключение
|
||||
разошлось бы молча по константе имён, запросу захвата, правилам панели и запрету
|
||||
удаления.
|
||||
|
||||
Вид текста лежит колонкой `kind`, а не `format`: словом `format` в этой же схеме
|
||||
зовут формат файла (`files.format`), и третий смысл у одного слова проект уже
|
||||
разводил однажды — комментарий к `location` в `entity/file.go` называет довод.
|
||||
|
||||
Текст отказа в журнале событий зовётся `outcome_text`, а не `error_text`:
|
||||
последнее имя названо поимённо инвариантом проекта о секрете, и две колонки с
|
||||
этим именем сделали бы инвариант двусмысленным.
|
||||
|
||||
### Переход и откладывание — разные операции
|
||||
|
||||
Сегодня шаг опроса зовёт переход с **тем же** состоянием, и мнимость этого
|
||||
перехода обнуляет счётчик. `Postpone(delay)` ставит паузу и снимает захват, а
|
||||
рубежа и времени входа в него не трогает; отказы обнуляет — ожидание чужой
|
||||
операции отказом не является.
|
||||
|
||||
Без разделения `state_entered_at` сбрасывался бы на каждом опросе и повторил бы
|
||||
ровно тот промах, ради которого заводится.
|
||||
|
||||
### Захват возвращает идентификатор
|
||||
|
||||
Сегодня захват перечисляет колонки поимённо в трёх местах сразу, и инвариант
|
||||
проекта требует править их в четырёх. Захват, возвращающий один `id`, съёживает
|
||||
инвариант до трёх мест и **перестаёт расти с моделью**: иначе каждая новая
|
||||
колонка аудиозаписи попадала бы под него, а модель растёт именно сейчас.
|
||||
|
||||
Цена названа прямо: захват и чтение записи становятся двумя обращениями к базе
|
||||
вместо одного. При нагрузке в единицы записей в день это не замеряется, а
|
||||
неделимость самого захвата не страдает — она держится тем же одним запросом с
|
||||
`RETURNING`, только возвращает он один столбец.
|
||||
|
||||
Отвергнутый вариант — оставить перечень колонок и дописывать его: он ровно тот,
|
||||
из-за которого инвариант получил серьёзность `major`, и первая же забытая
|
||||
колонка приезжает нулевой, а первое сохранение пишет этот ноль поверх значения.
|
||||
|
||||
### Воркеры теряют специализацию
|
||||
|
||||
Пул одинаковых потоков, число из настроек, шаг выбирается по рубежу таблицей
|
||||
диспетчеризации.
|
||||
|
||||
- **Пул** — принято: рубежей станет больше (уровни текста впереди), и каждый
|
||||
новый рубеж перестаёт требовать своего воркера в `main.go`.
|
||||
- **Смотритель отдельно от очереди** — отвергнуто для опроса чужой операции:
|
||||
очередь даёт неделимость захвата и возврат брошенного даром, а единственный
|
||||
смотритель умирает молча и уносит с собой целый класс записей.
|
||||
|
||||
`N = 0` — законное значение: записи принимаются и не двигаются. Это нужный режим
|
||||
для местного запуска и для выкладки, где конвейер надо остановить, не роняя
|
||||
приём.
|
||||
|
||||
### Темы живут коллекцией со словарём на каждого владельца
|
||||
|
||||
Перечень тем человека нужен целиком **перед каждым обращением к модели**: она
|
||||
получает его в запросе и переиспользует подходящую тему, а новую заводит, только
|
||||
если не годится ни одна. Собрать такой перечень из наборов строк в записях можно
|
||||
только перебором всех записей владельца — значит словарь живёт коллекцией.
|
||||
|
||||
Потолок — пять тем на запись, и он же уезжает в запрос: без него часовой
|
||||
разговор даёт два десятка тем, и словарь распухает за неделю.
|
||||
|
||||
Отвергнуто: темы набором строк в самой записи (перечень не собрать) и общий
|
||||
словарь на всех (тема — слепок того, о чём человек говорит, и общий словарь
|
||||
показал бы одному темы другого).
|
||||
|
||||
### Конечный рубеж зовётся `done`
|
||||
|
||||
Доставка ответа отправителю в конвейер не входит, и слово описывает пройденный
|
||||
конвейер, а не полученный человеком текст. Отвергнут `ready`: он обещает «готово
|
||||
для человека», а человек к этому моменту текста ещё не получил. Прежний довод в
|
||||
пользу `done` — «переносится с живых записей тождеством» — отпал вместе с
|
||||
переносом, но само решение он не держал.
|
||||
|
||||
### Задача делается одним заходом
|
||||
|
||||
Швы у неё есть — сущности со схемой, цепочка рубежей, провайдерская таблица,
|
||||
обобщение пула, — но резать по ним значит платить **четырьмя** необратимыми
|
||||
шагами схемы вместо одного и держать на сервере промежуточные раскладки. Решение
|
||||
владельца 2026-08-14.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Шаг схемы уезжает на сервер и не переписывается** → под ним пусто: сервис
|
||||
остановлен и прежние данные удалены, поэтому цена ошибки в шаге ограничена
|
||||
повторным пересозданием каталога данных, а не потерей чужого архива.
|
||||
- **Ложная остановка длинной записи** сторожем застревания принята решением
|
||||
владельца (см. решение о пределах) → цена ограничена обратимостью остановки;
|
||||
если класс начнёт всплывать, число правится настройкой без шага схемы.
|
||||
- **Публичный контракт HTTP API ломается**: перечень значений `status` меняется
|
||||
целиком → ломка объявлена прямо в спеке `intake`; имена полей сохранены.
|
||||
Потребителей у контракта сегодня двое — свои же будущие экраны и внешняя
|
||||
программа, которой ещё нет (`api-tokens`).
|
||||
- **Таймаутов у внешних вызовов по-прежнему нет** (`external-call-timeouts` не
|
||||
сделана) → срок захвата остаётся единственным пределом. Молчащий провайдер
|
||||
держит шаг до конца срока захвата, а протухший захват на шаге отправки даёт
|
||||
вторую платную операцию. Смягчение частичное: шаг отправки проверяет сделанное
|
||||
прежде, чем платить, и укладку не повторяет. Полностью закрывается только той
|
||||
задачей.
|
||||
- **Захват стал двумя обращениями к базе вместо одного** → между захватом и
|
||||
чтением запись может измениться. Запись результата остаётся условной по
|
||||
признаку захвата, поэтому шаг, потерявший захват, ничего не пишет; худший исход
|
||||
— потерянная работа шага, а не порча записи.
|
||||
- **Сторожей стало двое, и оба надо сбрасывать в правильных местах** → правило
|
||||
проверяется тестом на подставных часах: сотня откладываний подряд не двигает
|
||||
время входа в рубеж и не обнуляет отсчёт.
|
||||
- **Инвариант проекта о колонках очереди меняет форму** → он съёживается до трёх
|
||||
мест, и `CLAUDE.md` правится тем же изменением; забыть об этом нельзя, иначе
|
||||
инвариант станет требовать несуществующего.
|
||||
- **Класс «перечень, которого не видит компилятор» не исчезает, а переезжает с
|
||||
колонок на рубежи** → рубеж, забытый в отборе захвата, не выдаётся никому и не
|
||||
пишет ни строки: пустой прогон по инварианту не логируется. Смягчение —
|
||||
дескриптор рубежа одним объявлением, из которого выводятся выбор шага, отбор
|
||||
захвата и оба предела; сканеры `internal/archrules`, стоящие сегодня на
|
||||
`acquireColumns` и `acquiredRow`, перенацеливаются на этот дескриптор, а не
|
||||
удаляются.
|
||||
- **Обрыв процесса между ответом провайдера и записью идентификатора операции**
|
||||
→ строка попытки заводится **до** обращения, и повторный шаг начинает с
|
||||
проверки, не заведена ли операция. Жёсткий обрыв (`SIGKILL`, OOM) окна всё
|
||||
равно не закрывает: это остаточный риск, названный здесь и **не** записанный
|
||||
нормой — норма, обязывающая к недостижимому, зеленела бы на тесте мягкой
|
||||
остановки и объявляла бы защиту сделанной.
|
||||
- **Метка счётчика работы воркера теряет смысл вместе со специализацией** →
|
||||
метка переводится с имени потока на рубеж, иначе единственный сигнал отказа у
|
||||
владельца сервиса перестаёт отличать «падает приведение» от «падает
|
||||
распознавание». `docs/architecture.md`, раздел «Эксплуатация», правится тем же
|
||||
изменением.
|
||||
- **Журнал событий `record_events` — второй канал наблюдаемости рядом с
|
||||
метриками**, а колонки расхода в нём заходят на открытый вопрос «Учёт расхода»
|
||||
(`usage-accounting`) и на разведку `opentelemetry-fit` → читателя у журнала
|
||||
сегодня нет: экранов нет, конвейеру читать его запрещено требованием. Колонки
|
||||
расхода отложены до задачи, которая учёт заводит.
|
||||
- **`topics` заводится вперёд своего потребителя** → писать и читать темы в этом
|
||||
изменении не будет ничто. Довод за включение — цена: коллекция, заведённая
|
||||
позже, стоит второго необратимого шага схемы задаче `llm-insights-adapter`.
|
||||
Цена включения — имена коллекции и колонок закрепляются раньше, чем известен
|
||||
запрос потребителя.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Переноса данных нет: сервис на сервере остановлен, прежние записи и файлы удалены
|
||||
решением владельца 2026-08-14. Работа идёт так, будто выкладки не было ни разу.
|
||||
|
||||
1. Новый шаг схемы заводит `audio_records` и коллекции приложений, правит `files`
|
||||
и **удаляет** прежнюю `transcribe_jobs`: данных под ней нет, а оставленная
|
||||
пустая коллекция висела бы в панели вторым домом для того же понятия.
|
||||
Применённые шаги при этом не переписываются — изменение идёт новым файлом.
|
||||
2. Порядок выкладки: сперва образ. Новых обязательных ключей настройки нет — у
|
||||
числа воркеров и обоих пределов времени есть умолчания.
|
||||
3. **Откат.** Прежний образ ищет `transcribe_jobs`, которой уже нет, и работать
|
||||
не будет: откат означает пересоздание каталога данных, и это осознанная цена
|
||||
пустого старта. Обратного шага схемы нет и не планируется.
|
||||
4. Резервную копию каталога данных перед выкладкой делает человек — не ради
|
||||
записей, а ради учётных записей и настроек провайдера входа.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Четыре развилки постановки разобраны в записи задачи 2026-08-14 и закрыты
|
||||
решениями владельца. Ревью дизайна открыло ещё несколько мест, и все они вынесены
|
||||
человеку на чекпоинт; решения, записанные выше, — предложенные, а не принятые:
|
||||
|
||||
- **предел «час на свою работу»** — оставлен часом: сторож ловит зависание, а
|
||||
живая работа наблюдается по процессу; остановка обратима;
|
||||
- **перенос данных** — отменён целиком: прод остановлен, прежние данные удалены;
|
||||
- **снятие остановки владельцем записи** — намерение зафиксировано, реализация
|
||||
приходит с экранами и своим адресом API;
|
||||
- **`topics`** — оставлены, как решено постановкой: один шаг схемы вместо двух;
|
||||
- **обязательность шага** — отложена до первого необязательного шага (решение
|
||||
исполнителя по находке ревью, названо на чекпоинте);
|
||||
- **имена ключей конфига** объявлены проектом необратимыми и потому названы
|
||||
дословно: `[pipeline] workers`, `[pipeline] own_work_limit`,
|
||||
`[pipeline] foreign_work_limit`.
|
||||
|
||||
Осталось названным риском, а не вопросом: `external-call-timeouts` в плане
|
||||
стройки стоит **ниже** этой задачи, хотя «Рамки» постановки называют её
|
||||
предшествующей. Порядок расставил владелец, и решение о нём принято.
|
||||
@@ -0,0 +1,102 @@
|
||||
## Why
|
||||
|
||||
Сервис объявлен архивом: записи и расшифровки лежат бессрочно, к ним
|
||||
возвращаются через месяцы, а поверх них строятся список, темы, уровни текста и
|
||||
учёт расхода. Держать всё это негде — центральной сущности «аудиозапись» в
|
||||
сервисе нет вовсе: есть задача конвейера, у которой поля захвата лежат в одной
|
||||
строке с расшифровкой, указатель на файл переставляет каждый шаг, а отказ стирает
|
||||
достигнутый рубеж и делает продолжение с места остановки невозможным.
|
||||
|
||||
Всякая задача, взятая раньше этой, будет переписана вместе с моделью — потому
|
||||
владелец 2026-08-14 поставил её первой в план стройки.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Центральная сущность — аудиозапись.** Домен записи (владелец, заголовок,
|
||||
краткое описание, рубеж, ссылки на приложения) отделяется от того, что нужно
|
||||
только конвейеру, и от того, что принадлежит провайдеру распознавания.
|
||||
- **Приложения к записи живут отдельными строками.** Файлы, тексты, структура
|
||||
реплик, темы, журнал событий и попытка распознавания перестают быть колонками
|
||||
одной строки и адресуются ссылками с записи.
|
||||
- **Ссылки на файлы перестают переставляться.** У записи две отдельные ссылки —
|
||||
на исходник и на приведённую копию, — и обе живут до конца. Сегодня их одна, и
|
||||
прошедшая конвейер запись ведёт на объект во внешнем хранилище: послушать
|
||||
загруженное нечем.
|
||||
- **Копия во внешнем хранилище перестаёт быть файлом записи.** Она существует
|
||||
только потому, что распознаватель читает аудио по адресу, и переезжает в
|
||||
строку о попытке распознавания вместе с идентификатором операции.
|
||||
- **Сырой ответ распознавателя сохраняется целиком** — вложением, а не колонкой.
|
||||
Результат операции у провайдера не переспрашивается, а связь реплики с
|
||||
говорящим сервис строить пока не умеет: когда научится, архив пересчитается из
|
||||
сохранённого без единого рубля.
|
||||
- **Состояние называет достигнутое, а не предстоящее.** Цепочка рубежей:
|
||||
`uploaded → normalized → submitted → transcribed → done`. **BREAKING**: перечень
|
||||
состояний в ответе о записи меняется целиком — публичный контракт HTTP API
|
||||
объявлен проектом необратимым.
|
||||
- **Остановка становится признаком, а не состоянием.** Прежние `failed` и `dead`
|
||||
схлопываются в признак остановки с причиной; достигнутый рубеж при этом
|
||||
сохраняется, и снятие признака продолжает работу с места остановки, а не с
|
||||
начала. Снимает признак владелец панели — поверхности для владельца записи у
|
||||
сервиса пока нет, её заводит задача с экранами.
|
||||
- **Сторожей становится двое.** Число отказов ограничивает повторы внутри шага,
|
||||
время в рубеже — застревание. Сегодня обе роли навешаны на счётчик попыток, и
|
||||
он не справляется ни с одной: операция, зависшая у провайдера, опрашивается
|
||||
вечно.
|
||||
- **Предел времени в рубеже** — два числа: час на свою работу, сутки на чужую.
|
||||
Достигнут предел — запись останавливается с причиной «застряла».
|
||||
- **Переход и откладывание разводятся.** Шаг опроса перестаёт изображать переход
|
||||
в то же самое состояние: откладывание ставит паузу и снимает захват, а рубежа и
|
||||
времени входа в него не трогает.
|
||||
- **Шаг с внешней оплатой проверяет сделанное** прежде, чем платить второй раз.
|
||||
- **Воркеры теряют специализацию**, а их число задаётся настройкой; ноль —
|
||||
законное значение: записи принимаются и не двигаются.
|
||||
- **Переноса данных нет.** Сервис на сервере остановлен, прежние записи удалены
|
||||
решением владельца 2026-08-14, и новая модель заводится с чистого листа.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `recognition`: попытка распознавания у внешнего провайдера — что о ней
|
||||
хранится, почему сырой ответ сохраняется целиком, как из сохранённого строится
|
||||
структура реплик и почему разбор формата провайдера не доходит до конвейера.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `pipeline`: цепочка рубежей и смысл состояния; остановка признаком вместо
|
||||
состояний отказа и смерти; два сторожа вместо одного; предел времени в рубеже;
|
||||
разведение перехода и откладывания; необязательный шаг, чей отказ не роняет
|
||||
запись; захват, возвращающий один идентификатор; воркер без специализации и его
|
||||
число настройкой.
|
||||
- `storage`: аудиозапись центральной сущностью и её приложения отдельными
|
||||
коллекциями; две отдельные ссылки на файлы вместо одной переставляемой;
|
||||
словарь тем на каждого владельца; журнал событий записи; перенос живых записей
|
||||
шагом схемы.
|
||||
- `intake`: перечень состояний в ответе о приёме и об опросе готовности.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Схема хранилища**: новый шаг — коллекции `audio_records`, `texts`,
|
||||
`structures`, `recognitions`, `record_events`, `topics`; прежняя
|
||||
`transcribe_jobs` уходит; правка `files`. Применённые шаги не переписываются.
|
||||
- **Публичный контракт HTTP API**: значения поля состояния. Необратимо.
|
||||
- `internal/entity` — сущность записи, перечень рубежей, переходы, откладывание,
|
||||
остановка признаком.
|
||||
- `internal/contract` — распознаватель отдаёт доменный результат вместо строки,
|
||||
заливка и отправка разделены; контракты репозиториев записи, файлов, текстов,
|
||||
структуры, попыток распознавания и журнала.
|
||||
- `internal/adapter/recognizer/yandex` — разбор потока результата в реплики,
|
||||
раздельные заливка и отправка, отдача сырых байтов на хранение.
|
||||
- `internal/adapter/repo/pocketbase` — запрос захвата, отображение записи,
|
||||
правила панели.
|
||||
- `internal/service` — шаги, таблица выбора следующего шага по рубежу, остановка
|
||||
признаком.
|
||||
- `internal/controller/worker` и `main.go` — пул вместо трёх именованных
|
||||
воркеров.
|
||||
- `internal/config` и `config.example.toml` — число воркеров, срок захвата по
|
||||
шагу, два предела времени в рубеже.
|
||||
- `docs/architecture.md`, `docs/database.md`, инварианты `CLAUDE.md` о колонках
|
||||
очереди и о держателе захвата.
|
||||
- **Предшествующая задача**: `external-call-timeouts` в плане стройки стоит
|
||||
ниже, а по «Рамкам» постановки предшествует — без предела по времени у шага
|
||||
срок захвата не может его превысить.
|
||||
@@ -0,0 +1,443 @@
|
||||
# Триаж ревью: record-centric-model
|
||||
|
||||
## Сводка
|
||||
|
||||
- **Режим прогона:** по графу. **Метка:** `large`, обоснование разметки — «крупное ×
|
||||
незнакомое» (смена модели очереди: захват, повторы и воркеры разом; конвейер задач
|
||||
трогается целиком). Размер и сложность числом в переданном плане не названы; свой
|
||||
замер объёма — 61 путь в рабочем дереве (`git status --short | wc -l`), изменение
|
||||
лежит некоммитнутым поверх `d079f03`.
|
||||
- **Состояние гейта:** зелёный, `task gate` exit 0 (проход `autotests`, лог в
|
||||
`scratchpad/gate_run.log`).
|
||||
- **Находок на входе:** 28 пронумерованных находок шести проходов плюс 14 пунктов в их
|
||||
дополнительных секциях (specs — 9 «поведение вне спеки», architecture — 3 «дешевле
|
||||
переделать до мерджа», ops — 2 ответа сверх перечня). После дедупликации по причине
|
||||
осталось 24 различимые причины; в первых двух секциях — **7**.
|
||||
- **Сверка с «Типовыми ложноположительными»** (`docs/review.md`) выполнена: под пункт
|
||||
«файлы и объекты не удаляются, диск растёт» формально попадали две находки, обе
|
||||
оставлены — у обеих есть замер, которого пункт и требует. Пункт про молчание воркера
|
||||
на `NoopJobError` не выбрасывает находку №2, но **ограничивает её починку** — см.
|
||||
внутри находки. Пункты про гонку захвата и про запись без владельца отменены
|
||||
редакциями 2026-08-14 и к находкам этого прогона не применялись.
|
||||
|
||||
### План с исходом по каждой теме
|
||||
|
||||
| тема | дом | глубина | кто закрывает | исход |
|
||||
|---|---|---|---|---|
|
||||
| requirements | `openspec/specs/` + дельты change | разбор | specs | закрыта, 6 находок + секция из 9 пунктов |
|
||||
| autotests | CLAUDE.md, «Гейт» | — | autotests | закрыта, 3 находки; гейт зелёный, флаки не найдены (3 прогона `-race`) |
|
||||
| conventions | `docs/conventions/` | разбор | code | закрыта, 8 находок (4 техника + 4 конвенции) |
|
||||
| architecture | `docs/architecture.md` + источник `passport.md` | доказательство | architecture | закрыта, 3 находки + секция из 3 пунктов |
|
||||
| security | `docs/security.md` | доказательство | adversary | закрыта, 2 построенных пути + 1 свойство без пути |
|
||||
| operations | `docs/architecture.md` «Эксплуатация» + источник `database.md` | доказательство | ops | закрыта, 5 находок |
|
||||
| темы проекта | дома нет | — | basics | **не запускался**: своих тем у проекта нет, все документы `docs/` разошлись по шести темам ядра |
|
||||
|
||||
- **Тем без отчёта нет.** Каждая заявленная тема отчиталась; единственная строка «дома
|
||||
нет» — `темы проекта`, и она заявлена такой в самом плане, а не потеряна на прогоне.
|
||||
- **Сигнал о заниженной метке не пришёл ни от одного прохода.** `review-code`
|
||||
отработал и возражений по метке не подал; `review-basics` на этом прогоне не
|
||||
запускался, то есть его половина корректора не работала вовсе. Метку выбирал
|
||||
`review-scope`, и независимая проверка метки прошла в одном лице из двух.
|
||||
|
||||
---
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
### Архив хранит не то, что пришло от провайдера: неизвестные поля ответа исчезают молча
|
||||
|
||||
- Файл: `internal/adapter/recognizer/yandex/speechkit.go:200-235`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: зонд прохода `specs` — `proto.Marshal` сохраняет неизвестное поле (4 байта),
|
||||
пара `encodeResponses`/`decodeResponses` через `protojson` отдаёт 0 байт. Сверено
|
||||
чтением на месте: `encodeResponses` зовёт `protojson.Marshal(resp)`, а комментарий
|
||||
над ней обещает «ответ провайдера целиком, в том виде, в каком он пришёл».
|
||||
Норма — дельта `recognition`, `openspec/changes/record-centric-model/specs/recognition/spec.md:36-38`:
|
||||
«Сервис SHALL сохранять ответ распознавателя целиком, в том виде, в каком он пришёл».
|
||||
- Последствие: `protojson` выбрасывает поля, которых нет в вендоренной схеме. Всё, что
|
||||
SpeechKit добавит в ответ (и всё, что уже есть в версии сервиса новее нашей
|
||||
go-genproto), в сохранённой попытке отсутствует, и узнать об этом нечем: разбор
|
||||
проходит успешно. Ради этого архива и заведена коллекция `recognitions` — «станут
|
||||
доступны, когда мы научимся их читать». Не станут. Повторное распознавание стоит
|
||||
денег (`CLAUDE.md`, «Запреты», Yandex Cloud за деньги), а исходное аудио к тому
|
||||
моменту может быть уже единственным, что осталось.
|
||||
- Предложение: развилка, потому что решается формат файла на диске, а он в проекте
|
||||
необратим (`CLAUDE.md`, «Работа»: «формат файла на диске» спрашивается у человека
|
||||
всегда). Варианты: **(а)** хранить `proto.Marshal` — неизвестные поля переживают
|
||||
цикл, цена: содержимое перестаёт читаться глазами и в панели; **(б)** оставить
|
||||
`protojson` и переписать требование дельты, назвав цену прямо («храним разобранное
|
||||
нашей схемой, а не пришедшее»); **(в)** хранить оба представления — цена в объёме
|
||||
файла, вдвое.
|
||||
- Найдено проходом: specs
|
||||
- Действие: развилка
|
||||
|
||||
### Шаг сообщает исход одним `nil`, и остановка приговором засчитывается успехом наравне с откладыванием опроса
|
||||
|
||||
- Файл: `internal/service/transcribe.go:274-285`, `:634-644`, `:734-762`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: три независимых замера на живом хранилище (specs, code, ops) плюс сверка
|
||||
чтением на месте. `failStep` (`:735-739`) возвращает `nil` после `halt`, ветка
|
||||
«операция ещё идёт» (`:634-644`) тоже возвращает `nil`, а `RunStep` судит исход
|
||||
только по `stepErr != nil` (`:274-285`). Замеры: остановленная запись даёт два
|
||||
события — `halted`, следом `done`; счётчик `error=false` 1→2, `error=true` 0→0;
|
||||
halt по `stuck` — `error=true` 0→0; 3 откладывания дают 3 строки `poll/done`,
|
||||
50 циклов опроса — +50 строк `record_events`. Константа `EventOutcomeFailed`
|
||||
объявлена (`internal/entity/record_event.go:15`) и не пишется ни одной строкой кода.
|
||||
Нормы: `docs/architecture.md:156-161` — «Владелец — по метрике
|
||||
`transcriber_worker_job_count` с меткой `error="true"` … Отдельного оповещения нет»;
|
||||
дельта `pipeline`, `spec.md:264` — «Журнал MUST не писаться на каждое откладывание
|
||||
опроса» и сценарий `spec.md:280-284` «Откладывание строки не пишет».
|
||||
- Последствие: три причины остановки — исчерпанные попытки, застревание, приговор шага
|
||||
— не двигают единственный канал владельца. Запись умерла, метрика показывает успех,
|
||||
журнал записи утверждает `done`. Отправителю сообщение уходит (`halt` зовёт
|
||||
`notify`), то есть инвариант «Принятая запись не теряется молча» формально держится
|
||||
ровно наполовину: пользователь знает, владелец — нет. Второй половиной та же причина
|
||||
забивает журнал: часовое распознавание кладёт ≈720 строк `done`, суточное — до ~17000
|
||||
на одну запись, и настоящие события в нём тонут.
|
||||
- Предложение: исход шага должен называться, а не выводиться из `nil` — отдельным
|
||||
значением («сделано» / «отложено» / «остановлено»), и `RunStep` пишет `done` только
|
||||
на первом. Остановка пишет `EventOutcomeFailed` (либо оставляет один `halted`) и
|
||||
двигает `transcriber_worker_job_count{error="true"}` — тот, который назван каналом
|
||||
владельца. **Ограничение починки, нарушить его нельзя:** считать в метрику сам
|
||||
`NoopJobError`, которым `acquire()` возвращает остановленную запись воркеру,
|
||||
запрещено инвариантом `CLAUDE.md` («`NoopJobError` — не ошибка», major) и записано
|
||||
ложноположительным в `docs/review.md`. Значит счёт и событие ставит сам `halt`, а не
|
||||
воркер.
|
||||
- Найдено проходом: specs (2 находки), code (2), ops (2) — шесть формулировок одной
|
||||
причины; оракулы независимые, `Confidence` от совпадения не растёт
|
||||
- Действие: инлайн
|
||||
|
||||
### Единственный объявленный способ убрать запись оставляет полный текст речи на диске, а удаление учётной записи отвергается чужим сообщением
|
||||
|
||||
- Файл: `internal/adapter/repo/pocketbase/owner_guard.go:36-80`,
|
||||
`internal/adapter/repo/pocketbase/migrations/202608140002_record_centric_model.go:225-302`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: прогон прохода `adversary` на живом хранилище — удаление строки
|
||||
`audio_records` отвергается (связи приложений `Required:true` без каскада), удаление
|
||||
строки `files` проходит молча, на диске остаётся
|
||||
`storage/<recognitions>/<запись>/<имя>.payload` с текстом речи. Второй прогон:
|
||||
удаление учётной записи с архивом даёт наш отказ с причиной, с одной темой —
|
||||
«Make sure that the record is not part of a required relation reference». Сверено
|
||||
чтением: `countOwned` перебирает `RecordsCollection` и `FilesCollection`, а
|
||||
коллекций с колонкой `owner` в шаге схемы **три** — `topics` заводится с полем
|
||||
`owner` и уникальным индексом `idx_topics_owner_name`. Комментарий над функцией
|
||||
обещает «по обеим коллекциям». Нормы: `docs/security.md:335-341` — «единственный
|
||||
способ убрать запись — руками в базе и в каталоге на сервере», а задача
|
||||
`delete-record` обязана убирать «все уровни текста»; дельта `storage` требует, чтобы
|
||||
отказ называл причину.
|
||||
- Последствие: владелец, выполнивший единственную записанную процедуру удаления,
|
||||
получает отказ на строке записи и удаляет файл — после чего считает данные
|
||||
удалёнными, а расшифровка речи человека остаётся на диске бессрочно. Второй путь:
|
||||
собственный страж, заведённый ровно ради того, чтобы владелец не пошёл удалять
|
||||
связи руками, на учётной записи с темой молчит и пропускает вперёд «ведущую»
|
||||
подсказку библиотеки — то есть ведёт владельца делать необратимое. `docs/security.md`
|
||||
этим изменением не тронут вовсе, хотя содержимое переехало в шесть коллекций и
|
||||
завелась вторая раскладка файла на диске.
|
||||
- Предложение: перечень коллекций с владельцем — одно место, выводимое из шага схемы
|
||||
(инлайн-часть, `topics` добавляется сразу). Дальше развилка по удалению: **(а)**
|
||||
завести каскад/процедуру, убирающую запись со всеми уровнями текста и файлами, до
|
||||
мерджа; **(б)** оставить как есть, но переписать `docs/security.md` под новую
|
||||
раскладку и назвать процедуру поимённо, включая `recognitions/*.payload`, и
|
||||
дополнить «Затрагивает» задачи `delete-record`; **(в)** признать удаление
|
||||
недоступным до `delete-record` и сказать это в `security.md` прямо.
|
||||
- Найдено проходом: adversary (2 пути), code (1 находка о `topics`) — один корень
|
||||
- Действие: развилка
|
||||
|
||||
---
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### Откат образа поверх применённого шага схемы не диагностируется: старый бинарь встаёт молча и ломает 100% очереди
|
||||
|
||||
- Файл: `internal/adapter/repo/pocketbase/migrations/migrations.go`,
|
||||
шаг `202608140002_record_centric_model.go`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: замер прохода `ops` двумя реальными бинарями — старый бинарь поднимается на
|
||||
каталоге новой схемы **без ошибки**, затем 100% обращений к очереди дают
|
||||
`failed to find collection transcribe_jobs`. `RunAllMigrations` накатывает
|
||||
недостающие из своего списка и шагов новее не видит; `down` коллекцию не
|
||||
восстанавливает.
|
||||
- Последствие: это первый шаг схемы проекта, который убирает коллекцию, а не
|
||||
добавляет. Штатное средство владельца на инциденте — откатить образ — с этого
|
||||
момента делает хуже и не говорит об этом: сервис стартует зелёным и отказывает на
|
||||
каждой записи. Обратно чинится повторной выкладкой нового образа, то есть ущерб
|
||||
обратим, но обнаруживается он в худший момент и не тем сообщением. Шаг схемы после
|
||||
выкладки не переписывается (`CLAUDE.md`, инвариант, **critical**), поэтому дешёвая
|
||||
минута — сейчас.
|
||||
- Предложение: развилка. **(а)** старт отказывается работать на схеме новее своего
|
||||
списка — явным сообщением «база новее бинаря, откат образа не поддержан»; цена:
|
||||
проверка версии схемы на подъёме, ~десяток строк плюс её норма;
|
||||
**(б)** записать в `docs/architecture.md`, «Эксплуатация», что откат образа через
|
||||
этот шаг невозможен и что делать вместо него; цена: только текст, ловушка остаётся;
|
||||
**(в)** признать осознанным и не делать ничего.
|
||||
- Найдено проходом: ops
|
||||
- Действие: развилка
|
||||
|
||||
### После жёсткого падения воркера запись невидима до восьми часов, а `own_work_limit_minutes` этим не управляет
|
||||
|
||||
- Файл: `internal/service/transcribe.go:296-339`,
|
||||
`internal/adapter/repo/pocketbase/record_repo.go:190-215`, `internal/entity/stage.go`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: замер прохода `ops` — после захвата без `Save` повторный захват записи не
|
||||
выдаёт; halt по `stuck` наступает только по истечении `acquire_expires_at`. Сверено
|
||||
чтением: сторож простоя `isStuck` проверяется **после** захвата (`:328`), а захват
|
||||
фильтрует по `acquire_expires_at` (`record_repo.go:215`), чей срок для приведения и
|
||||
отправки — 8 часов (`docs/database.md:274-275`).
|
||||
- Последствие: настройка продана владельцу как сторож зависания — «предел простоя, своя
|
||||
работа, 60 минут, сторож ловит зависание» (`docs/database.md:279`), — но для
|
||||
крашнутого или убитого держателя реальный предел вчетверо с лишним больше и задаётся
|
||||
другим числом из другого файла. Запись человека молча стоит до восьми часов, и ни
|
||||
один документ этого расхождения не называет. Класс — «молчание»: владелец узнает
|
||||
только по отсутствию ответа.
|
||||
- Предложение: свести к одному числу либо назвать оба и их роли — в
|
||||
`docs/database.md` и в дельте `pipeline`. Развилка: **(а)** срок захвата опустить до
|
||||
предела простоя (цена: многочасовое приведение начнёт терять захват и перезапускаться
|
||||
— именно та цена, ради которой 8 часов и стоят); **(б)** оставить два числа, но
|
||||
сделать протухший захват видимым (сторож простоя судит и по времени захвата);
|
||||
**(в)** оставить как есть и записать в `database.md` прямо, что для крашнутого
|
||||
держателя предел — срок захвата, а не `own_work_limit_minutes`.
|
||||
- Найдено проходом: ops. **Понижено при триаже:** половина находки прохода
|
||||
`architecture` — «имя ключа в коде разошлось с `design.md`» — из основного списка
|
||||
снята: `config.example.toml`, `internal/config/config.go:35` и канон
|
||||
`docs/database.md:279` называют ключ `own_work_limit_minutes` согласованно, разошёлся
|
||||
один `design.md` изменения. Необратимости здесь нет, это дрейф документа — его дом
|
||||
`av-dev:doc-healthcheck`, не ревью.
|
||||
- Действие: развилка
|
||||
|
||||
### Переписанный конвейер уехал без проверок: пять тестов снесено, четыре узла с нулевым покрытием
|
||||
|
||||
- Файл: `internal/controller/worker/worker.go:100-147`,
|
||||
`internal/adapter/recognizer/yandex/speechkit.go:206-280`,
|
||||
`internal/controller/http/transcribe.go:140-166`,
|
||||
`internal/adapter/repo/pocketbase/panel.go:33-99`,
|
||||
`internal/adapter/repo/pocketbase/owner_guard.go:36-80`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: `go test ./... -coverpkg=./...` (проход `autotests`) — `Pool`/`NewPool`/
|
||||
`Size`/`Start` 0.0%, `encodeResponses`/`decodeResponses`/`outcomeFromResponses` 0.0%,
|
||||
`GetTranscribeJobStatus` 52.9% (ветка выдачи готовой расшифровки, ветка 500 при
|
||||
отказе `textRepo` и новое поле `halted` без единого assert). `git status`: удалены
|
||||
`owner_test.go`, `transcript_job_repo_test.go`, `file_repo_test.go` — пять проверок
|
||||
снесено, а не переписано. Норма: `docs/review.md`, «Типовые узлы», «Любой узел» —
|
||||
«изменённое место покрыто хоть одним **проходящим** тестом»; журнал 2026-08-10
|
||||
показывает, чем это кончается.
|
||||
- Последствие: пул одинаковых воркеров — предмет всего изменения — не поднимается ни
|
||||
одним тестом; разбор реального ответа SpeechKit не проверен ничем, хотя `Parse` —
|
||||
чистая функция и сети не требует; панельный возврат записи в работу и страж удаления
|
||||
учётной записи держатся на чтении глазами, и находка №3 показывает, что чтение уже
|
||||
один раз промахнулось. Зелёный гейт здесь не означает проверенного кода.
|
||||
- Предложение: инлайн, четыре адреса. Приоритет — `encodeResponses`/`decodeResponses`
|
||||
(дешевле всех, чистые функции, и это оракул находки №1), `owner_guard` с тремя
|
||||
коллекциями, ветки `GetTranscribeJobStatus` включая `halted`, подъём пула.
|
||||
Панельный возврат — тестом против настоящего хранилища, как это делают
|
||||
`schema_test.go` и `ownership_test.go`.
|
||||
- Найдено проходом: autotests (3), specs (1)
|
||||
- Действие: инлайн
|
||||
|
||||
### Колонка `source_uri` уезжает необратимым шагом схемы, и читателя у неё нет
|
||||
|
||||
- Файл: `internal/contract/repository.go`, `internal/contract/contract.go`,
|
||||
шаг `202608140002_record_centric_model.go`
|
||||
- Severity: minor
|
||||
- Confidence: high
|
||||
- Оракул: проход `architecture` — `grep` по рабочим путям: колонка пишется и не
|
||||
читается ни одним, адрес объекта пересчитывается `SourceURI(objectKey)` на месте
|
||||
употребления. Контракт распознавателя вырос с 3 методов до 9: `Upload`,
|
||||
`ObjectExists`, `SourceURI` вынесли в ядро ключ объекта и идемпотентность заливки.
|
||||
- Последствие: шаг схемы после выкладки не переписывается (`CLAUDE.md`, инвариант,
|
||||
**critical**), поэтому снять колонку потом можно только новым шагом. Пока она есть,
|
||||
следующий читатель обязан гадать, что из двух — колонка или пересчёт — правда, а
|
||||
ядро обязано знать про объектное хранилище провайдера, чего оно знать не должно.
|
||||
Цена сегодня — минуты, после мерджа — новый шаг схемы и вопрос человеку.
|
||||
- Предложение: развилка. **(а)** свернуть заливку в один метод `EnsureUploaded` и
|
||||
убрать `SourceURI`/`ObjectExists` из контракта; **(б)** оставить контракт и начать
|
||||
читать `attempt.SourceURI` вместо пересчёта — тогда у колонки появляется читатель;
|
||||
**(в)** признать колонку заделом осознанно и снять её из шага схемы **до** мерджа,
|
||||
вернув отдельной задачей.
|
||||
- Найдено проходом: architecture
|
||||
- Действие: развилка
|
||||
|
||||
---
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
Понижены: оракула нет либо путь не построен. Все — `major` и ниже по контракту.
|
||||
|
||||
- **Отказ чтения в `Put` подменяется заведением новой строки** (`text_repo.go:33-42,
|
||||
92-101`, было minor/high у specs и code). Код не различает `sql.ErrNoRows` от отказа
|
||||
базы: на кратком отказе хранилища вместо обновления заводится вторая строка текста.
|
||||
Зонда никто не снял — понижено до гипотезы, но починка дешёвая и очевидная
|
||||
(`errors.Is(err, sql.ErrNoRows)`).
|
||||
- **Приговор шага выносится с первой попытки, и `maxAttempts` не работает никогда**
|
||||
(secция «поведение вне спеки» прохода specs). Отказ `ffmpeg` идёт через `failStep`
|
||||
сразу, то есть счётчик попыток не доживает до сторожа. Замера нет, дельтой вопрос
|
||||
«какие отказы приговор, а какие повтор» не решён вовсе — это **самый весомый из
|
||||
отложенного**: если гипотеза верна, кратковременный отказ внешней программы хоронит
|
||||
запись человека с первой попытки. Стоит зонда в следующем прогоне либо строки в
|
||||
дельте `pipeline`.
|
||||
- **Признак «работы нет» сервис выдаёт сам за прогоны, в которых работа была**
|
||||
(`transcribe.go:244,258,319,332`). Дельта требует, чтобы признак рождался только
|
||||
ответом хранилища на опрос. Последствие — искажение наблюдаемости, не поведения;
|
||||
зонда нет.
|
||||
- **Пул без верхней границы** (замер ops: 12.6k оп/с не растёт с N, латентность
|
||||
78.7µs → 7.93ms при 1→100 воркерах; `Validate()` проверяет только `Workers<0`).
|
||||
Замер настоящий, но `docs/review.md`, «Недоступно проверке», прямо говорит: реального
|
||||
профиля нагрузки у проекта нет, «утверждения о росте остаются условиями». Проект
|
||||
работает на единицах записей в день, ущерб сегодня нулевой — гипотеза, не находка.
|
||||
- **Ручная правка рубежа в панели у остановленной записи не снимает признак остановки**
|
||||
(секция specs). Путь не построен, поведение панели проверено только чтением.
|
||||
- **Пауза при переходе на `submitted` нигде не нормирована** и **мёртвая пара
|
||||
`location`/`object_key`** (секция specs). Расхождения без названного последствия.
|
||||
- **Таймаутов у Telegram, S3 и SpeechKit по-прежнему нет ни одного, `FindAndAcquire` не
|
||||
принимает контекст** (ops). Не находка этого изменения: отсутствие таймаутов
|
||||
записано в `docs/review.md`, «Вопросы по темам», чтением от 2026-08-13 и старше
|
||||
задачи. Названо, чтобы не читалось как новое.
|
||||
|
||||
## Promote candidates
|
||||
|
||||
- **Сканер на пару «правило домена ↔ его повтор в адаптере».** `panel.go:33-99`
|
||||
повторяет `entity.AudioRecord.Resume()` колонками, а `grep '\.Resume()'` даёт
|
||||
единственное вхождение — в тесте. `internal/archrules` такую пару не держит, и
|
||||
разойдутся они молча. Правило механизируемо — значит это кандидат в сканер, а не
|
||||
находка ревью (контракт находок, `nit`).
|
||||
- **Правило конвенции для новых `select`-перечислений.** Пять новых перечислений
|
||||
закрыты схемой, `docs/conventions/database.md` даёт изъятие только для `state`, ни
|
||||
одной строки «*Расхождение:*» не добавлено. Либо изъятие расширяется, либо каждая
|
||||
новая строка объявляется — сегодня не сказано ни то, ни другое.
|
||||
- **`schemaFieldNames` в `internal/archrules` собирает поля из всех коллекций каталога
|
||||
шагов, включая снесённую `transcribe_jobs`.** Правило остаётся зелёным и при колонке,
|
||||
объявленной в чужой коллекции, — то есть страж инварианта про колонки ослаб. Это
|
||||
правка самого правила (не «проверка над проверкой», запрет `CLAUDE.md` сюда не
|
||||
достаёт), но она сама себе кандидат в конвенцию: перечень схемы читается по текущей
|
||||
схеме, а не по истории каталога.
|
||||
|
||||
## Урожай (отложено, сработал потолок)
|
||||
|
||||
Ни один пункт ниже не выброшен — они не поместились в семь и ждут своей задачи.
|
||||
|
||||
- **Опрос чужой операции пишется на `INFO` двумя строками за цикл**
|
||||
(`transcribe.go:623,637`). `docs/conventions/logging.md:60,77,183` называет «проверку
|
||||
готовности операции распознавания» уровнем `DEBUG` поимённо; ≈1440 строк `INFO` на
|
||||
часовую запись. Плюс записанное там же *Расхождение*: `DEBUG` включить нечем, уровень
|
||||
зашит в `main.go`. Починка — одна замена уровня, но она без предмета, пока уровень не
|
||||
настраивается.
|
||||
- **Правило возврата записи в работу написано дважды** (`panel.go:33-99` против
|
||||
`entity.AudioRecord.Resume()`), и событие журнала пишется двумя способами —
|
||||
`appendEvent` через контракт и `appendResumeEvent` вручную через `core.NewRecord` в
|
||||
адаптере. Механизируемая часть ушла в promote выше; остаток — решение, какой из двух
|
||||
путей настоящий.
|
||||
- **Поверхность без вызывающих:** `contract.Clock` (реализаций нет), `entity.Stages()`,
|
||||
`entity.WorkingStates()`, поле `recordRepo` в `TelegramController`, интерфейс `Worker`
|
||||
с единственной реализацией.
|
||||
- **`docs/conventions/logging.md:103` называет поле `job_id`, код перешёл на
|
||||
`record_id`.** Записанная конвенция разошлась с кодом — дрейф документа.
|
||||
- **`design.md` изменения называет ключи `own_work_limit`/`foreign_work_limit`, код и
|
||||
канон — `own_work_limit_minutes`/`foreign_work_limit_minutes`.** Дрейф документа,
|
||||
дом — `av-dev:doc-healthcheck`.
|
||||
- **Словарь «job» пережил понятие** (`JobNotFoundError`, `CreateJobFromApi`,
|
||||
`TranscribeHandler.CreateTranscribeJob`). **Выброшено как вкусовщина**, а не отложено:
|
||||
поведения не меняет, стоимости следующего изменения не меняет заметно, записанной
|
||||
конвенции не нарушает; публичные имена полей API трогать всё равно нельзя. Названо,
|
||||
чтобы не всплыло третьим прогоном как новое.
|
||||
|
||||
---
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
**План: темы, дома, глубины.** requirements (`openspec/specs/` + дельты, разбор),
|
||||
autotests (`CLAUDE.md` «Гейт», глубины нет), conventions (`docs/conventions/`, разбор),
|
||||
architecture (`docs/architecture.md` + `passport.md`, доказательство), security
|
||||
(`docs/security.md`, доказательство), operations (`docs/architecture.md`
|
||||
«Эксплуатация» + `database.md`, доказательство), темы проекта — **дома нет**.
|
||||
|
||||
**Что запускалось.** Шесть проходов на метке `large`, режим «по графу»: specs,
|
||||
autotests, code, architecture, adversary, ops. **Не запускался** `basics` — своих тем
|
||||
у проекта нет, все документы `docs/` разошлись по шести темам ядра; это решение плана,
|
||||
а не отказ прогона. Пятая строка про метку `small` неприменима: метка `large`, дома
|
||||
`security`, `operations` и `architecture` открывались.
|
||||
|
||||
**Что не мог проверить каждый проход.** Уставы проходов мне дословно не переданы —
|
||||
границы ниже выведены из их же выводов, и это деградация строкой: `autotests` судит
|
||||
наличие и способность проверок падать, но не правильность самой нормы; `specs` судит
|
||||
код против дельт и молчит там, где дельта молчит (раздел «поведение вне спеки» — ровно
|
||||
этот остаток); `code` читает и не запускает боевых сценариев; `architecture` судит
|
||||
форму решения и не мерит; `adversary` строит пути в границах прогона — настоящих
|
||||
Telegram, SpeechKit и Object Storage в прогоне нет; `ops` мерит на своей машине и
|
||||
одном каталоге данных, а не на сервере.
|
||||
|
||||
**Что осталось целиком на человеке** (`docs/review.md`, «Недоступно проверке»; списки
|
||||
раздельные и не сливаются).
|
||||
|
||||
*Не проверит ни один проход:*
|
||||
|
||||
- `operations`: поведение внешних сервисов под нагрузкой и на границах — SpeechKit и
|
||||
Object Storage поднять в тесте нечем;
|
||||
- `operations`: реальный профиль нагрузки; проект работает на единицах записей в день,
|
||||
и утверждения о росте остаются условиями, а не замерами;
|
||||
- `security`: стойкость `ffmpeg` к вредоносному входу;
|
||||
- `security`: поведение настоящей Authelia и её правило на нашего клиента;
|
||||
- `security`: поведение браузера с куками — `SameSite`, приём `Set-Cookie` при переходе
|
||||
с чужого сайта.
|
||||
|
||||
*Перестали проверять сознательно:*
|
||||
|
||||
- `autotests`: разбор вывода настоящего `ffprobe` — длительность даёт подставной
|
||||
источник (ADR-2026-08-11-stub-adapters-in-tests);
|
||||
- работа сервиса с настоящими внешними собеседниками: живой прогон отвечает за подъём,
|
||||
отказ старта, маршруты и остановку; приём из Telegram, расшифровку и заливку он не
|
||||
проверяет — боевым токеном запускаться запрещено, ключи Yandex выдуманы,
|
||||
распознавание подменяется в коде.
|
||||
|
||||
Сверх этого никем не проверено: история инцидентов этого сервиса, поведение под
|
||||
реальным потоком, поведение внешних систем в их сегодняшних версиях, завязка
|
||||
потребителей на текущее поведение и вопрос «а нужна ли эта функциональность вообще».
|
||||
|
||||
**Каких документов не хватило** — строкой на каждый, с причиной:
|
||||
|
||||
- `docs/security.md` **есть, но не тронут этим изменением**: периметр в нём описан по
|
||||
прежней модели (задачи и файлы), про шесть коллекций и вторую раскладку файла на
|
||||
диске он не знает. Проход `adversary` судил новый периметр по старому документу;
|
||||
- `design.md` изменения расходится с каноном и кодом по именам ключей конфига —
|
||||
документ был, но как источник имён недостоверен;
|
||||
- `docs/conventions/database.md` даёт изъятие только для `state`: как объявлять новые
|
||||
`select`-перечисления, не сказано ни в одну сторону, и проход `code` судил по
|
||||
умолчанию;
|
||||
- дельта `pipeline` не решает, какие отказы приговор, а какие повтор, — из-за этого
|
||||
самая весомая гипотеза осталась гипотезой;
|
||||
- `docs/review.md`, «Типовые ложноположительные», **был и использован**; раздел не
|
||||
пуст, отсев шёл не вслепую.
|
||||
|
||||
**Сработавшие потолки — по проходу.** `code` объявил свой: конвенций 4 из 4, потолок
|
||||
сработал ровно, что осталось за срезом — не названо. `architecture` объявил: 3 находки
|
||||
плюс секция «дешевле переделать до мерджа». `autotests` (3), `specs` (6 + 9), `ops`
|
||||
(5 + ответы на 9 вопросов) и `adversary` (2 пути + 1 свойство) **своего потолка не
|
||||
сообщили** — то есть «находок больше нет» у них неотличимо от «больше не поместилось».
|
||||
Это ровно то, что записано в `docs/review.md` от 2026-08-13, и класс всплыл снова.
|
||||
|
||||
**Потолок триажа.** В первые две секции не влезло семь причин; все они выписаны в
|
||||
«Урожай» и «Гипотезы» поимённо, молча не выброшено ничего. Одна выброшена как
|
||||
вкусовщина и названа там же.
|
||||
|
||||
**Четыре строки, которые не принёс ни один проход:**
|
||||
|
||||
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон его не
|
||||
открывает. Расхождение изменения с записанным решением (в том числе с
|
||||
ADR-2026-08-11 про архив и ADR-2026-08-12 про сессию) ловит скилл
|
||||
`av-dev:doc-healthcheck`, а не ревью.
|
||||
2. **Записанные наблюдения проекта не использовались.** `docs/research/` не
|
||||
открывался. Всякое число в этом отчёте снято проходом на этом прогоне или прочитано
|
||||
в коде и каноне со ссылкой на строку.
|
||||
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.**
|
||||
Различение «идиоматично против распространено» не спрашивал никто.
|
||||
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.**
|
||||
«Не знаю, чего не знаю» здесь не достаёт никто — на изменении с меткой «незнакомое»
|
||||
это самый дорогой пробел прогона.
|
||||
|
||||
Формулировка «критичных проблем не обнаружено» к этому отчёту неприменима: `critical`
|
||||
в нём нет потому, что ни одна находка не получила оракула, поднимающего её до
|
||||
нарушения инварианта необратимого класса, — а не потому, что таких свойств не искали
|
||||
и не нашли.
|
||||
@@ -0,0 +1,168 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Приём записи по HTTP
|
||||
|
||||
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
|
||||
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
|
||||
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
||||
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
|
||||
получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести
|
||||
идентификатор записи полем `job_id` и её рубеж полем `status`.
|
||||
|
||||
Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень
|
||||
состояний назван проектом необратимым, и ломка объявлена прямо — состояние
|
||||
теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет
|
||||
вовсе.
|
||||
|
||||
Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API
|
||||
объявлен проектом необратимым, и переименование поля ломает внешнюю программу
|
||||
молча. Меняются значения поля рубежа, а не его имя.
|
||||
|
||||
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
|
||||
не заплатит узнанный отправитель, не должна попасть даже в память.
|
||||
|
||||
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||
пригодность содержимого узнаёт у источника метаданных.
|
||||
|
||||
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
||||
хранилище, и нормирует её capability `storage`.
|
||||
|
||||
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Проверка
|
||||
стоит здесь, а не только в схеме хранилища: колонка владельца допускает пустое
|
||||
значение ради записей из Telegram, и приём по HTTP — то место, где
|
||||
обязательность держится.
|
||||
|
||||
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
|
||||
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
|
||||
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
|
||||
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
|
||||
стать не может.
|
||||
|
||||
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
|
||||
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
|
||||
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
|
||||
|
||||
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
|
||||
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
|
||||
пишется.
|
||||
|
||||
#### Scenario: Запись принята
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
||||
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
||||
со значением `uploaded`
|
||||
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
||||
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
|
||||
|
||||
#### Scenario: Сессия не даёт учётной записи пользователя
|
||||
|
||||
- **GIVEN** предъявлена сессия владельца панели
|
||||
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
|
||||
- **THEN** ответ имеет код `403`
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
|
||||
#### Scenario: Сессии нет
|
||||
|
||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
- **AND** тело ответа не несёт данных записи
|
||||
|
||||
#### Scenario: Поля с записью нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
|
||||
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
|
||||
#### Scenario: Размеру записи приём не судья
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт запись нулевой длины
|
||||
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
|
||||
|
||||
### Requirement: Опрос готовности задачи
|
||||
|
||||
Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id`
|
||||
**только её владельцу**. Запрос без сессии MUST получать код `401`, и тело
|
||||
такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ
|
||||
владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время
|
||||
заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и
|
||||
это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте
|
||||
отсутствующего текста читается как «расшифровка пуста».
|
||||
|
||||
Видов текста у записи больше одного, поэтому ответ MUST называть вид, который
|
||||
отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она.
|
||||
Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы
|
||||
у одной и той же записи от того, успел ли отработать необязательный шаг, а
|
||||
контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не
|
||||
применяться: она делает ответ функцией порядка записи, а не состояния записи.
|
||||
|
||||
Перечень значений поля `status` MUST совпадать с перечнем рубежей конвейера:
|
||||
`uploaded`, `normalized`, `submitted`, `transcribed`, `done`. Прежних значений
|
||||
`created`, `converted`, `transcribe`, `failed` и `dead` в ответе MUST не быть.
|
||||
Это объявленная ломка публичного контракта: рубеж называет достигнутое, а отказ
|
||||
перестал быть состоянием.
|
||||
|
||||
Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST
|
||||
нести признак остановки отдельным полем `halted` со значением истины. Машинный
|
||||
текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса,
|
||||
а не отправителю. Отправитель узнаёт о неудаче ответом там, откуда пришла
|
||||
запись, — это нормирует capability `pipeline`.
|
||||
|
||||
Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по
|
||||
кодам ответа перебирается список заведённых записей.
|
||||
|
||||
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
|
||||
идентификатор, — кодом `404` и тем же телом. То же MUST относиться к записи без
|
||||
владельца: запись, принятая ботом, по этому адресу не достаётся никому.
|
||||
|
||||
#### Scenario: Запись найдена
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** он спрашивает рубеж своей записи
|
||||
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
||||
- **AND** значение `status` принадлежит перечню рубежей конвейера
|
||||
|
||||
#### Scenario: Запись остановлена
|
||||
|
||||
- **GIVEN** запись остановлена признаком на рубеже приведения
|
||||
- **WHEN** владелец спрашивает её рубеж
|
||||
- **THEN** поле `status` несёт рубеж приведения
|
||||
- **AND** поле `halted` несёт истину
|
||||
- **AND** машинного текста отказа в ответе нет
|
||||
|
||||
#### Scenario: Сессии нет
|
||||
|
||||
- **WHEN** программа спрашивает рубеж заведённой записи без сессии
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
|
||||
|
||||
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
|
||||
|
||||
- **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем
|
||||
рубеж по неизвестному идентификатору
|
||||
- **THEN** оба ответа имеют код `401`
|
||||
|
||||
#### Scenario: Чужая запись неотличима от неизвестной
|
||||
|
||||
- **GIVEN** запись заведена одним вошедшим
|
||||
- **WHEN** её рубеж спрашивает другой вошедший
|
||||
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
|
||||
идентификатору
|
||||
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
|
||||
|
||||
#### Scenario: Расшифровки ещё нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста
|
||||
- **THEN** поля `transcription_text` в ответе нет вовсе
|
||||
|
||||
#### Scenario: Записи с таким идентификатором нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа спрашивает рубеж по неизвестному идентификатору
|
||||
- **THEN** ответ имеет код `404` и сообщение о ненайденной записи
|
||||
@@ -0,0 +1,714 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### 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** оно не приблизилось к пределу
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Захват задачи неделим
|
||||
|
||||
Захват записи воркером SHALL быть одним неделимым шагом хранилища: выбор
|
||||
подходящей записи и пометка её захваченной MUST происходить вместе.
|
||||
|
||||
Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не
|
||||
перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе
|
||||
всякая новая колонка аудиозаписи попадала бы под инвариант проекта о колонках
|
||||
очереди, и забытая в захвате колонка приезжала бы нулевой, а первое же
|
||||
сохранение писало бы этот ноль поверх сохранённого значения.
|
||||
|
||||
**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не
|
||||
признаком занятости. Условие записи результата сверяет именно это значение:
|
||||
захват, перевыданный другому — по протуханию срока или после того, как человек
|
||||
снял признак остановки в панели, — обязан обращать запись первого в отказ.
|
||||
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
|
||||
два шага записали бы в одну запись и оба ответили бы отправителю.
|
||||
|
||||
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
|
||||
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
|
||||
получить признак «работы сейчас нет».
|
||||
|
||||
Срок протухания захвата MUST ехать с рубежом записи, а не с воркером: воркер не
|
||||
привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом
|
||||
при самом захвате.
|
||||
|
||||
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
|
||||
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
|
||||
обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена
|
||||
через раз.
|
||||
|
||||
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
|
||||
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
|
||||
одного из них теряется без следа.
|
||||
|
||||
Признак «работы нет» этим требованием не переопределяется — его нормирует
|
||||
требование «Пустой прогон воркера — не отказ».
|
||||
|
||||
#### Scenario: За работой пришли трое разом
|
||||
|
||||
- **GIVEN** к работе пригодна ровно одна запись
|
||||
- **WHEN** три захвата идут одновременно
|
||||
- **THEN** запись получает ровно один из них
|
||||
- **AND** двое остальных получают признак «работы сейчас нет»
|
||||
|
||||
#### Scenario: Захваченная запись не выдаётся второй раз
|
||||
|
||||
- **GIVEN** запись захвачена и срок захвата не истёк
|
||||
- **WHEN** приходит следующий захват
|
||||
- **THEN** эта запись ему не выдаётся
|
||||
|
||||
#### Scenario: Захват отдаёт идентификатор и свой признак
|
||||
|
||||
- **GIVEN** к работе пригодна запись
|
||||
- **WHEN** воркер её захватывает
|
||||
- **THEN** захват возвращает идентификатор записи и признак этого захвата
|
||||
- **AND** колонки записи шаг читает отдельным чтением
|
||||
|
||||
#### Scenario: Признак перевыданного захвата отличается от прежнего
|
||||
|
||||
- **GIVEN** запись захвачена, и признак первого захвата известен
|
||||
- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер
|
||||
- **THEN** признак нового захвата отличается от признака первого
|
||||
|
||||
### Requirement: Результат пишет только держатель захвата
|
||||
|
||||
Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи
|
||||
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата** —
|
||||
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
|
||||
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
|
||||
результата и без ответа отправителю.
|
||||
|
||||
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
|
||||
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
|
||||
теряет запись, продолжая работать. Снять захват может и человек, вернувший
|
||||
остановленную запись в работу. Без условия по уникальному признаку два воркера
|
||||
пишут в одну запись по очереди, счётчик отказов сбрасывает тот, кто уже не
|
||||
владелец, а отправитель получает два ответа на одну запись.
|
||||
|
||||
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
|
||||
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
|
||||
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
|
||||
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
|
||||
уверен, что правка на месте. Владелец записи, заголовок, краткое описание и темы
|
||||
конвейер MUST не трогать.
|
||||
|
||||
#### Scenario: Правка владельца пережила сохранение шага
|
||||
|
||||
- **GIVEN** шаг держит захваченную запись
|
||||
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
|
||||
- **WHEN** шаг записывает свой результат
|
||||
- **THEN** результат шага записан
|
||||
- **AND** правка владельца на месте
|
||||
|
||||
#### Scenario: Захват ушёл под работающим шагом
|
||||
|
||||
- **GIVEN** шаг работает над захваченной записью
|
||||
- **AND** за это время та же запись досталась другому захвату
|
||||
- **WHEN** первый шаг доходит до записи результата
|
||||
- **THEN** результат не записывается
|
||||
- **AND** отправителю ничего не отправляется
|
||||
|
||||
#### Scenario: Человек снял остановку под работающим шагом
|
||||
|
||||
- **GIVEN** шаг работает над захваченной записью
|
||||
- **AND** человек за это время снял с неё признак остановки, освободив захват
|
||||
- **AND** запись досталась другому воркеру
|
||||
- **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 не выражаться. Шаг, увидевший, что
|
||||
внешняя операция ещё идёт, отработал без отказа: он **откладывает** работу своей
|
||||
задержкой, заданной числом, и отказов при этом не тратит. Пауза, выведенная из
|
||||
числа отказов, на таком шаге вырождается в наименьшее своё значение и учащает
|
||||
опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее
|
||||
секунды.
|
||||
|
||||
#### Scenario: Отказавшая запись ждёт
|
||||
|
||||
- **GIVEN** запись отказала на шаге конвейера
|
||||
- **WHEN** захват приходит раньше конца её паузы
|
||||
- **THEN** запись ему не выдаётся
|
||||
|
||||
#### Scenario: Вторая пауза длиннее первой
|
||||
|
||||
- **GIVEN** запись отказала дважды подряд
|
||||
- **WHEN** сравнивают паузу после второго отказа с паузой после первого
|
||||
- **THEN** вторая длиннее
|
||||
|
||||
#### Scenario: Ожидание операции не учащается и не тратит отказов
|
||||
|
||||
- **GIVEN** внешняя операция распознавания ещё идёт
|
||||
- **WHEN** шаг опроса отрабатывает подряд несколько раз
|
||||
- **THEN** задержка до следующей проверки каждый раз одна и та же
|
||||
- **AND** число отказов записи не растёт
|
||||
|
||||
### Requirement: Пустой прогон воркера — не отказ
|
||||
|
||||
Воркер SHALL отличать «пригодной к работе записи сейчас нет» от отказа шага. На
|
||||
пустом прогоне он MUST не считать прогон отказом: не увеличивать счётчик работы
|
||||
и не писать о нём на уровне владельца сервиса. Признак пустого прогона MUST
|
||||
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
|
||||
пояснения, добавленные к этому значению на любом промежуточном шаге пути.
|
||||
|
||||
Формулировка сменилась вместе с моделью: воркер больше не привязан к рубежу и
|
||||
опрашивает не «своё состояние», а очередь целиком, поэтому пустой прогон значит
|
||||
«работы нет ни на одном рубеже», а не «работы нет в этом состоянии».
|
||||
|
||||
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: воркеры
|
||||
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт от
|
||||
каждого запись отказа в секунду и столько же засчитанных сбоев, которых не было.
|
||||
|
||||
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
|
||||
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
|
||||
в цепочке своей ошибки. Воркер узнаёт признак по смыслу на любой глубине, поэтому
|
||||
отказ, к которому признак примешался, тоже зачёл бы пустым прогоном: запись
|
||||
осталась бы на своём рубеже и переопрашивалась раз в секунду без единой записи
|
||||
— ровно то, что запрещает инвариант «Принятая запись не теряется молча».
|
||||
|
||||
Отказ шага, наоборот, MUST быть виден владельцу сервиса записью в журнале и MUST
|
||||
быть засчитан в счётчик работы с пометкой отказа и с меткой рубежа.
|
||||
|
||||
**Сколько раз он записывается и каким уровнем — это требование не нормирует, и
|
||||
умолчанием тут считать нечего.** Сегодня один отказ даёт две записи: пишет шаг
|
||||
конвейера и следом воркер, — а уровень стоит `ERROR` там, где конвенция просит
|
||||
`WARN` для повторяющегося сбоя фонового цикла. И то и другое записано долгом в
|
||||
`docs/conventions/logging.md`, раздел «Ошибки», строкой «Расхождение, и оно
|
||||
системное». Долгом оно и остаётся: требование, объявившее одиночную запись
|
||||
нормой, сделало бы недостижимое обязательным, а требование, объявившее нормой
|
||||
двойную, — закрыло бы долг контрактом. Задача, которая возьмётся за этот долг,
|
||||
дописывает норму сюда.
|
||||
|
||||
#### Scenario: Пригодной к работе записи нет
|
||||
|
||||
- **GIVEN** ни одной записи, пригодной к работе, нет ни на одном рубеже
|
||||
- **WHEN** воркер делает свой прогон
|
||||
- **THEN** на уровне владельца сервиса об этом прогоне не пишется ничего
|
||||
- **AND** счётчик работы воркера не растёт
|
||||
|
||||
#### Scenario: Признак пустого прогона дошёл с пояснением
|
||||
|
||||
- **GIVEN** пригодной к работе записи нет
|
||||
- **AND** промежуточный шаг добавил к этому признаку своё пояснение
|
||||
- **WHEN** воркер делает свой прогон
|
||||
- **THEN** прогон по-прежнему считается пустым: счётчик не растёт, записи на
|
||||
уровне владельца нет
|
||||
|
||||
#### Scenario: Шаг отказал
|
||||
|
||||
- **GIVEN** шаг конвейера вернул отказ
|
||||
- **WHEN** воркер завершает прогон
|
||||
- **THEN** отказ виден владельцу сервиса записью в журнале
|
||||
- **AND** счётчик работы воркера растёт с пометкой отказа и меткой рубежа
|
||||
|
||||
#### Scenario: Шаг сделал работу
|
||||
|
||||
- **GIVEN** шаг конвейера отработал запись без отказа
|
||||
- **WHEN** воркер завершает прогон
|
||||
- **THEN** счётчик работы воркера растёт с пометкой успеха
|
||||
- **AND** записи об отказе в журнале нет
|
||||
|
||||
### Requirement: Недоставленный ответ не роняет шаг
|
||||
|
||||
Шаг конвейера SHALL доводить запись до достигнутого рубежа, когда ответ
|
||||
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
|
||||
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
|
||||
записи, MUST называть причину и MUST считаться отдельной метрикой с причиной
|
||||
меткой.
|
||||
|
||||
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят** —
|
||||
запись заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
|
||||
у записи не назван** — источником значится Telegram, а чата в записи нет.
|
||||
|
||||
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
|
||||
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
|
||||
записи: у записи из Telegram чат есть всегда, и пропасть он может только от
|
||||
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
|
||||
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
|
||||
записей о ненастроенном боте.
|
||||
|
||||
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
|
||||
достигнутый рубеж сохранён. Работа к этой минуте сделана, и объявленный отказ
|
||||
засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть соврал бы
|
||||
про исход дважды. Повтор делу не помогает: ни бот, ни адресат от ожидания не
|
||||
появятся. Поэтому запись остаётся на достигнутом рубеже, в повтор не уходит и
|
||||
**признака остановки не получает**, а причина недоставки живёт в записи журнала,
|
||||
а не в рубеже записи.
|
||||
|
||||
То же MUST относиться к недоставке сообщения об **остановке**: остановка уже
|
||||
сохранена, и недоставка её MUST не отменять.
|
||||
|
||||
Идентификатор записи в этой строке обязателен: без него владелец видит, что
|
||||
ответ не ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя
|
||||
в эту запись MUST не попадать — приватность содержимого записи требование не
|
||||
ослабляет.
|
||||
|
||||
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
|
||||
уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
|
||||
|
||||
#### Scenario: Вход отправителя не поднят
|
||||
|
||||
- **GIVEN** запись принята входом Telegram прошлым запуском сервиса
|
||||
- **AND** сервис поднялся без этого входа
|
||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
||||
- **AND** запись остаётся на достигнутом рубеже, в повтор не уходит и признака
|
||||
остановки не получает
|
||||
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
|
||||
записи и причиной
|
||||
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
|
||||
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
|
||||
|
||||
#### Scenario: Адресат у записи не назван
|
||||
|
||||
- **GIVEN** у записи источником значится Telegram, а чат не назван
|
||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
|
||||
- **AND** запись остаётся на достигнутом рубеже
|
||||
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
|
||||
записи и причиной: неназванный адресат — симптом порчи записи
|
||||
|
||||
#### Scenario: Не доехало сообщение об остановке
|
||||
|
||||
- **GIVEN** запись остановлена признаком
|
||||
- **AND** вход отправителя не поднят
|
||||
- **WHEN** шаг доходит до ответа отправителю
|
||||
- **THEN** признак остановки у записи остаётся
|
||||
- **AND** в журнале есть запись о недоставке с идентификатором записи и причиной
|
||||
|
||||
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
|
||||
|
||||
- **GIVEN** запись принята по HTTP
|
||||
- **WHEN** шаг конвейера доходит до ответа отправителю
|
||||
- **THEN** шаг завершается без отказа и без записи о недоставке
|
||||
|
||||
### Requirement: Выборка воркера владельцем не сужается
|
||||
|
||||
Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца
|
||||
при выборе очередной записи. Запись без владельца — принятая ботом — MUST
|
||||
обрабатываться наравне с прочими.
|
||||
|
||||
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
|
||||
владельцем остановило бы расшифровку записей бота вовсе, а записи остальных
|
||||
поставило бы в зависимость от того, кто первым завёл учётную запись.
|
||||
|
||||
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
|
||||
результат, владельца не трогает и не затирает.
|
||||
|
||||
#### Scenario: Записи двух владельцев проходят одним воркером
|
||||
|
||||
- **GIVEN** заведены записи двух разных владельцев на одном рубеже
|
||||
- **WHEN** воркер забирает работу
|
||||
- **THEN** ему достаются обе, в порядке заведения
|
||||
|
||||
#### Scenario: Запись без владельца обрабатывается
|
||||
|
||||
- **GIVEN** заведена запись, принятая ботом, — без владельца
|
||||
- **WHEN** воркер забирает работу
|
||||
- **THEN** она достаётся ему наравне с прочими
|
||||
|
||||
#### Scenario: Шаг конвейера владельца не затирает
|
||||
|
||||
- **GIVEN** запись с владельцем прошла шаг конвейера
|
||||
- **WHEN** шаг сохраняет свой результат
|
||||
- **THEN** владелец записи остаётся прежним
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Число попыток и состояние «мертва»
|
||||
|
||||
**Reason**: Одно число несло две обязанности сразу — ограничивать повторы внутри
|
||||
шага и ограничивать застревание, — и не справлялось ни с одной: опрос, вернувший
|
||||
«ещё в работе», обнулял его, и зависшая чужая операция опрашивалась вечно.
|
||||
Состояние «мертва», как и состояние отказа, стирало достигнутый рубеж, и
|
||||
продолжить с места остановки было не с чего.
|
||||
|
||||
**Migration**: Обязанности разведены по двум требованиям — «Число отказов
|
||||
ограничивает повторы шага» и «Время в рубеже ограничено». Состояния «мертва» и
|
||||
отказа заменены признаком остановки с причиной, который рубежа не стирает:
|
||||
требование «Остановка записи — признак, а не рубеж»; туда же дословно перенесены
|
||||
запрет на второй способ вывести запись из выборки и правило «перевод принадлежит
|
||||
одному месту». Обязанность сообщить отправителю вынесена в общее требование
|
||||
«Всякая остановка сообщает отправителю»: причин остановки стало больше одной, и
|
||||
обязанность, записанная у одной из них, у остальных читалась бы как снятая.
|
||||
Возврат в работу по-прежнему делает владелец, но снятием признака, а не правкой
|
||||
состояния.
|
||||
@@ -0,0 +1,147 @@
|
||||
## ADDED 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** повторная отправка той же записи не заводится
|
||||
@@ -0,0 +1,313 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### 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** назначение не проходит
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Владелец задачи лежит связью с учётной записью
|
||||
|
||||
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
|
||||
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
|
||||
не назван, не достаётся никому по недосмотру схемы.
|
||||
|
||||
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи,
|
||||
принятые ботом, владельца не имеют, потому что связи чата Telegram с учётной
|
||||
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама
|
||||
capability `intake`, а не схема.
|
||||
|
||||
Владелец MUST не назначаться и не меняться конвейером.
|
||||
|
||||
#### Scenario: Колонка появляется на пустой базе
|
||||
|
||||
- **WHEN** сервис поднимается на чистом каталоге данных
|
||||
- **THEN** у аудиозаписи есть колонка владельца
|
||||
- **AND** умолчания у неё нет
|
||||
|
||||
#### Scenario: Конвейер владельца не назначает
|
||||
|
||||
- **GIVEN** запись с владельцем прошла шаг конвейера
|
||||
- **WHEN** смотрят её владельца
|
||||
- **THEN** он прежний
|
||||
|
||||
### Requirement: Файл записи сужается владельцем наравне с задачей
|
||||
|
||||
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
||||
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
|
||||
|
||||
Владелец файла MUST назначаться там же, где владелец записи, — при приёме, из
|
||||
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером
|
||||
для записи без владельца.
|
||||
|
||||
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
|
||||
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
|
||||
выводиться через запись: файл переживает свою запись, и заведённый шагом до
|
||||
сохранения записи он остаётся с владельцем и без ссылки.
|
||||
|
||||
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
|
||||
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
|
||||
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
|
||||
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
|
||||
касаясь пути, по которому аудио и уходит.
|
||||
|
||||
#### Scenario: Чужой файл не отдаётся
|
||||
|
||||
- **GIVEN** запись принята одним вошедшим
|
||||
- **WHEN** другой вошедший идёт по ссылке на файл этой записи со своим токеном
|
||||
- **THEN** содержимого он не получает
|
||||
|
||||
#### Scenario: Свой файл отдаётся
|
||||
|
||||
- **GIVEN** человек принял запись
|
||||
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
|
||||
- **THEN** содержимое отдаётся
|
||||
|
||||
#### Scenario: Файл записи из Telegram не отдаётся по API
|
||||
|
||||
- **GIVEN** запись принята ботом, и владельца у неё нет
|
||||
- **WHEN** вошедший человек идёт по ссылке на её файл со своим токеном
|
||||
- **THEN** содержимого он не получает
|
||||
|
||||
### Requirement: Владелец видит записи в панели
|
||||
|
||||
Сервис SHALL давать владельцу панель, где аудиозапись видна строкой, отбирается
|
||||
по своему идентификатору и правится, а её файлы слушаются и скачиваются.
|
||||
|
||||
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
|
||||
второго процесса.
|
||||
|
||||
Панель — вход в запись наравне с конвейером, а не окно просмотра. Снятие
|
||||
признака остановки в панели MUST возвращать запись в работу с сохранённого
|
||||
рубежа и MUST очищать служебные поля прошлого захвата — признак захвата, срок
|
||||
его протухания, паузу, число отказов — и MUST заново ставить время входа в
|
||||
рубеж. Правка рубежа руками MUST делать то же самое. Иначе владелец, вернувший
|
||||
запись в работу, получит запись, которая не выдаётся захвату до конца прежнего
|
||||
срока, останавливается от первого же отказа или останавливается снова первым же
|
||||
захватом по пределу времени, — и не узнает об этом.
|
||||
|
||||
Запись, заведённая в панели руками, MUST не уносить сервис: поля, без которых
|
||||
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
|
||||
перечень рубежей — закрытым.
|
||||
|
||||
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
|
||||
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
|
||||
сервис — это записано моделью угроз проекта.
|
||||
|
||||
#### Scenario: Принятая запись видна владельцу
|
||||
|
||||
- **GIVEN** запись принята и заведена
|
||||
- **WHEN** владелец отбирает записи по идентификатору принятой
|
||||
- **THEN** он видит её строкой со своим рубежом
|
||||
- **AND** её файл скачивается из той же строки
|
||||
|
||||
#### Scenario: Остановленную запись вернули в работу правкой в панели
|
||||
|
||||
- **GIVEN** запись остановлена признаком, с накопленными отказами и признаком
|
||||
прежнего захвата
|
||||
- **AND** остановленной она простояла дольше предела времени в рубеже
|
||||
- **WHEN** владелец снимает признак остановки
|
||||
- **THEN** признак захвата, срок его протухания, пауза и число отказов очищены
|
||||
- **AND** время входа в рубеж поставлено заново
|
||||
- **AND** ближайший захват выдаёт запись с сохранённого рубежа
|
||||
|
||||
### Requirement: Учётная запись с записями не удаляется
|
||||
|
||||
Хранилище SHALL отвергать удаление учётной записи, у которой остались
|
||||
аудиозаписи **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
|
||||
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
|
||||
другую подменяет сообщением про обязательную связь — подсказкой, по которой
|
||||
владелец панели пойдёт удалять записи руками.
|
||||
|
||||
Считаются **все** коллекции с колонкой владельца, и перечень их MUST жить одним
|
||||
местом: коллекция, пропущенная в счёте, пропускает удаление вперёд, и наружу
|
||||
приезжает не наш отказ с причиной, а подсказка библиотеки про обязательную связь
|
||||
— та самая, по которой владелец панели пойдёт удалять записи руками. Сегодня их
|
||||
три: аудиозаписи, файлы и словарь тем.
|
||||
|
||||
Файл переживает свою запись: шаг конвейера заводит его до сохранения записи, и
|
||||
потерянный захват оставляет файл с владельцем и без ссылки. Тема переживает её
|
||||
так же: словарь принадлежит человеку, а не записи.
|
||||
|
||||
Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его
|
||||
позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой
|
||||
запуска, а окружение проверок его не ставило вовсе.
|
||||
|
||||
Удаление при этом не только панельное: умолчание библиотеки разрешает вошедшему
|
||||
удалить **свою** учётную запись запросом, так что запрет закрывает и публичную
|
||||
поверхность.
|
||||
|
||||
Цена требования названа прямо: владелец панели упирается в отказ, а способа
|
||||
удалить записи в сервисе пока нет вовсе — его приносит задача про удаление
|
||||
записи. До неё удаление учётной записи с записями невозможно, и это осознанный
|
||||
тупик, а не недосмотр.
|
||||
|
||||
#### Scenario: Удаление учётной записи с записями отвергается
|
||||
|
||||
- **GIVEN** у учётной записи есть аудиозаписи
|
||||
- **WHEN** её удаляют
|
||||
- **THEN** удаление не проходит, а отказ называет причину
|
||||
- **AND** записи и их владелец остаются прежними
|
||||
|
||||
#### Scenario: Учётная запись с одними файлами тоже не удаляется
|
||||
|
||||
- **GIVEN** у учётной записи остались файлы, но записей нет
|
||||
- **WHEN** её удаляют
|
||||
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
|
||||
|
||||
#### Scenario: Учётная запись с одними темами тоже не удаляется
|
||||
|
||||
- **GIVEN** у учётной записи остались темы словаря, но ни записей, ни файлов нет
|
||||
- **WHEN** её удаляют
|
||||
- **THEN** удаление не проходит, а отказ называет причину нашими словами
|
||||
|
||||
#### Scenario: Учётная запись без записей удаляется
|
||||
|
||||
- **GIVEN** у учётной записи нет ни аудиозаписей, ни файлов, ни тем
|
||||
- **WHEN** её удаляют
|
||||
- **THEN** удаление проходит
|
||||
@@ -0,0 +1,193 @@
|
||||
# Критерии приёмки
|
||||
|
||||
Дословно из записи задачи `record-centric-model`. Файл задачи закрытие удалит —
|
||||
критерии обязаны его пережить.
|
||||
|
||||
1. Запись, остановленная на шаге, перезапускается снятием признака и продолжает
|
||||
с того рубежа, где стояла. Оракул — тест: шаг останавливает запись на
|
||||
`normalized`, снятие `halted_at` возвращает её в работу, и следующим идёт
|
||||
отправка на распознавание, а не повторная нормализация.
|
||||
2. У прошедшей конвейер записи ссылки на исходник и на opus ведут на разные
|
||||
существующие копии. Оракул — тест полного прохода: обе ссылки заполнены и обе
|
||||
открываются.
|
||||
3. Число воркеров задаётся конфигом, и поведение от него не зависит. Оракул —
|
||||
прогон теста конвейера при `N=1` и `N=4`: запись доходит до `done` в обоих;
|
||||
при `N=0` она остаётся в `uploaded` и не теряется.
|
||||
4. Структура реплик строится из сохранённого ответа провайдера без обращения к
|
||||
нему. Оракул — тест на сохранённом вложении: структура собрана, клиент
|
||||
SpeechKit не позван ни разу.
|
||||
5. Запись, застрявшая в рубеже дольше предела, останавливается, а откладывание
|
||||
опроса предела не сдвигает. Оракул — тест на подставных часах: сотня
|
||||
откладываний подряд не двигает `state_entered_at` и не обнуляет отсчёт, а по
|
||||
истечении предела запись получает признак остановки с причиной «застряла».
|
||||
|
||||
## Приёмочные критерии из ревью дизайна
|
||||
|
||||
Рубрика прохода `review-rubric`, пункты, не покрытые критериями выше. Приёмка
|
||||
судится по одному списку.
|
||||
|
||||
6. Держатель захвата отличим **значением**, а не занятостью записи. Оракул —
|
||||
тест: шаг A держит запись, признак остановки снимает человек, запись
|
||||
захватывает шаг B; запись результата шагом A не проходит, и отправителю от
|
||||
него ничего не уходит.
|
||||
7. Всякий способ вывести запись из работы сообщает отправителю. Оракул — тест по
|
||||
перечню причин остановки: у каждой отправитель получает сообщение.
|
||||
8. Повтор шага не создаёт второго приложения. Оракул — тест: шаг завершения
|
||||
оборван после записи текста и повторён с прежнего рубежа; строка расшифровки
|
||||
у записи одна.
|
||||
9. Штатная остановка сервиса не тратит отказ. Оракул — тест: шаг прерван
|
||||
отменой контекста, число отказов записи прежнее, признака остановки нет.
|
||||
10. Содержимое записи закрыто во всех коллекциях, где лежит. Оракул — тест:
|
||||
ссылка на сохранённый ответ провайдера без сессии отдаёт отказ, с чужой
|
||||
сессией — тоже.
|
||||
11. Отказ виден с разрезом по шагу. Оракул — тест: отказ шага приведения растит
|
||||
счётчик с меткой своего рубежа.
|
||||
|
||||
## 1. Сущности и рубежи
|
||||
|
||||
- [x] 1.1 Завести `entity.AudioRecord` с рубежом, временем входа в рубеж,
|
||||
признаком остановки и её причиной, полями очереди и ссылками на приложения
|
||||
- [x] 1.2 Дескриптор рубежа одним объявлением: имя, шаг, чья работа, срок захвата,
|
||||
предел времени, берётся ли в работу. Таблица выбора шага, список отбора
|
||||
захвата и пределы **выводятся** из него, а не перечисляются порознь
|
||||
- [x] 1.3 Развести `MoveToState` и `Postpone`: первый двигает рубеж и время входа
|
||||
в него, второй ставит паузу и снимает захват, рубежа не трогая
|
||||
- [x] 1.4 Заменить `Fail` и `Die` на `Halt(причина, текст)` и `Resume()`;
|
||||
`Resume` сбрасывает отказы, паузу **и время входа в рубеж**, рубеж сохраняет
|
||||
- [x] 1.5 Завести сущности приложения: файл, текст с видом, структура реплик с
|
||||
версией, тема, событие журнала, попытка распознавания
|
||||
- [x] 1.6 Тест: `Postpone` не двигает время входа в рубеж и не сбрасывает отсчёт
|
||||
- [x] 1.7 Тест: `Halt` сохраняет рубеж, `Resume` возвращает на него же и заново
|
||||
ставит время входа **(критерий 1)**
|
||||
|
||||
## 2. Шаг схемы
|
||||
|
||||
- [x] 2.1 Новым файлом шага завести коллекции `audio_records`, `texts`,
|
||||
`structures`, `recognitions`, `record_events`, `topics`
|
||||
- [x] 2.2 Дописать `files` полями формата и длительности
|
||||
- [x] 2.3 Уникальность: тема по паре «владелец и название», текст по паре
|
||||
«запись и вид» (`texts.kind`), структура по паре «запись и версия»
|
||||
- [x] 2.4 Индексы под выборку захвата: рубеж, пауза, срок захвата, признак
|
||||
остановки
|
||||
- [x] 2.5 Правила доступа новых коллекций: просмотр только владельцем связанной
|
||||
записи; поле вложения в `recognitions` помечено защищённым
|
||||
- [x] 2.6 Тот же шаг удаляет прежнюю коллекцию `transcribe_jobs`: данных под ней
|
||||
нет, а пустая коллекция висела бы в панели вторым домом для того же понятия
|
||||
- [x] 2.7 Тест шага: на чистом каталоге поднимаются все коллекции новой модели, и
|
||||
принятая следом запись доходит до конечного рубежа
|
||||
- [x] 2.8 Тест: ссылка на сохранённый ответ без сессии и с чужой сессией даёт
|
||||
отказ **(критерий 10)**
|
||||
- [x] 2.9 Обновить `docs/database.md` тем же изменением: гейт сверяет шаг схемы
|
||||
с правкой этого документа
|
||||
|
||||
## 3. Контракты и репозитории
|
||||
|
||||
- [x] 3.1 `AudioRecognizer` отдаёт доменный результат — реплики со временем,
|
||||
плоский текст, байты на хранение — вместо строки; заливка и отправка
|
||||
разделены
|
||||
- [x] 3.2 Контракты репозиториев: запись, файлы, тексты, структура, попытки
|
||||
распознавания, журнал событий, темы
|
||||
- [x] 3.3 Захват возвращает **идентификатор записи и признак этого захвата**;
|
||||
признак уникален для каждого захвата; `acquireColumns` и `acquiredRow`
|
||||
уходят, срок протухания пишется числом при захвате
|
||||
- [x] 3.4 Запрос захвата: отбор по рубежам из дескриптора, паузе, сроку захвата и
|
||||
отсутствию признака остановки, порядок по времени заведения и ключу
|
||||
- [x] 3.5 Запись результата условна по признаку **этого** захвата, а не по
|
||||
занятости; владелец, заголовок, краткое описание и темы шагом не
|
||||
переписываются
|
||||
- [x] 3.6 Перенацелить сканеры `internal/archrules` с колонок захвата на перечень
|
||||
рубежей: дескриптор против списка отбора против значений шага схемы
|
||||
- [x] 3.7 Тест: захват отдаёт идентификатор и признак, троим одновременным
|
||||
достаётся одному
|
||||
- [x] 3.8 Тест: остановленная запись захвату не выдаётся
|
||||
- [x] 3.9 Тест: шаг A теряет захват после снятия остановки человеком и записи не
|
||||
проводит **(критерий 6)**
|
||||
|
||||
## 4. Адаптер распознавания
|
||||
|
||||
- [x] 4.1 Разбор потока `GetRecognition` в реплики со временем внутри адаптера
|
||||
- [x] 4.2 Раздельные заливка в Object Storage и отправка операции; строка попытки
|
||||
заводится до обращения к провайдеру
|
||||
- [x] 4.3 Заливка проверяет объект нужного размера и не повторяется; отправка не
|
||||
повторяется при заведённом идентификаторе операции
|
||||
- [x] 4.4 Адаптер отдаёт сырые байты ответа на хранение вложением
|
||||
- [x] 4.5 Тест: структура собрана из сохранённого вложения, клиент SpeechKit не
|
||||
позван ни разу **(критерий 4)**
|
||||
- [x] 4.6 Тест: объект на месте — заливка не повторяется; идентификатор операции
|
||||
на месте — отправка не повторяется
|
||||
|
||||
## 5. Конвейер
|
||||
|
||||
- [x] 5.1 Выбор шага по рубежу из дескриптора; шаги перестают быть привязаны к
|
||||
воркеру
|
||||
- [x] 5.2 Шаг приведения: две отдельные ссылки на файлы вместо одной
|
||||
переставляемой
|
||||
- [x] 5.3 Шаг отправки: строка попытки распознавания, идентификатор операции и
|
||||
ключ объекта уезжают туда
|
||||
- [x] 5.4 Шаг опроса зовёт `Postpone`, а не переход в то же состояние
|
||||
- [x] 5.5 Шаг завершения: текст строкой `texts`, структура строкой `structures`,
|
||||
сырой ответ вложением; повтор не заводит второго комплекта
|
||||
- [x] 5.6 Предел времени в рубеже из дескриптора: остановка с причиной «застряла»,
|
||||
идентификатор операции при этом сохраняется
|
||||
- [x] 5.7 Единое место ответа отправителю на всякую остановку, независимо от
|
||||
причины
|
||||
- [x] 5.8 Отмена по остановке сервиса возвращает число отказов назад и приговора
|
||||
не выносит
|
||||
- [x] 5.9 Журнал событий пишется на смену рубежа, на остановку и на снятие
|
||||
остановки; на откладывание — нет; колонка текста отказа зовётся
|
||||
`outcome_text`, чтобы `error_text` осталось именем одной колонки
|
||||
- [x] 5.10 Тест полного прохода: обе ссылки на файлы заполнены и обе открываются
|
||||
**(критерий 2)**
|
||||
- [x] 5.11 Тест: остановка на `normalized`, снятие признака, следующим идёт
|
||||
отправка **(критерий 1)**
|
||||
- [x] 5.12 Тест на подставных часах: сотня откладываний не двигает
|
||||
`state_entered_at`, по истечении предела запись останавливается с причиной
|
||||
«застряла» **(критерий 5)**
|
||||
- [x] 5.13 Тест по перечню причин остановки: у каждой отправитель получает
|
||||
сообщение **(критерий 7)**
|
||||
- [x] 5.14 Тест: повтор шага завершения не заводит второй расшифровки
|
||||
**(критерий 8)**
|
||||
- [x] 5.15 Тест: отмена контекста не тратит отказ **(критерий 9)**
|
||||
|
||||
## 6. Воркеры, метрики и настройки
|
||||
|
||||
- [x] 6.1 Пул одинаковых воркеров вместо трёх именованных в `main.go`
|
||||
- [x] 6.2 Метка счётчика `transcriber_worker_job_count` — рубеж, а не имя потока
|
||||
- [x] 6.3 Число воркеров и два предела времени — в `internal/config` и
|
||||
`config.example.toml` с умолчаниями; имена ключей названы в дизайне
|
||||
- [x] 6.4 `N = 0` поднимает сервис без движения записей
|
||||
- [x] 6.5 Тест конвейера при `N=1` и `N=4` — запись доходит до `done`; при `N=0`
|
||||
остаётся в `uploaded` **(критерий 3)**
|
||||
- [x] 6.6 Тест: отказ шага приведения растит счётчик с меткой своего рубежа
|
||||
**(критерий 11)**
|
||||
|
||||
## 7. Поверхность и панель
|
||||
|
||||
- [x] 7.1 Ответ приёма отдаёт рубеж `uploaded`; ответ опроса — рубеж из нового
|
||||
перечня плюс поле `halted`
|
||||
- [x] 7.2 Текст расшифровки в ответе опроса читается из `texts` по **виду**
|
||||
«сырая расшифровка», а не по последней записи
|
||||
- [x] 7.3 Правила панели: снятие признака остановки и правка рубежа очищают
|
||||
захват, срок, паузу и отказы и заново ставят время входа в рубеж
|
||||
- [x] 7.4 Запрет удаления учётной записи считает `audio_records` и `files`
|
||||
- [x] 7.5 Тесты транспорта под новые значения `status` и поле `halted`
|
||||
|
||||
## 8. Документы и гейт
|
||||
|
||||
- [x] 8.1 Инвариант `CLAUDE.md` о колонках очереди: перечень колонок съёживается,
|
||||
предмет правила переезжает на перечень рубежей
|
||||
- [x] 8.2 Инвариант `CLAUDE.md` о держателе захвата: держатель отличим значением
|
||||
признака захвата, а не занятостью записи
|
||||
- [x] 8.3 `docs/architecture.md`: компоненты, цепочка рубежей, единые точки,
|
||||
таблица внешних зависимостей, раздел «Эксплуатация» — чем владелец теперь
|
||||
замечает отказ
|
||||
- [x] 8.4 `docs/database.md`: коллекции, представление данных, настройки с
|
||||
числовым значением (число воркеров, два предела времени, сроки захвата)
|
||||
- [x] 8.5 `docs/review.md`: снять ложноположительное «гонка захвата по построению
|
||||
невозможна» — построение снято пулом одинаковых воркеров
|
||||
- [x] 8.6 Разделы `Purpose` спек `pipeline` и `storage` при архивации: цепочка
|
||||
рубежей перестала быть «сознательно не описанной», а строка про непереносимые
|
||||
прежние данные — верной
|
||||
- [x] 8.7 `task gate` зелёный целиком
|
||||
- [x] 8.8 Поведенческая проверка живым запуском: подъём с `telegram.enabled =
|
||||
false` и подставным распознавателем, запись доходит до `done`
|
||||
@@ -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
@@ -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** оно не приблизилось к пределу
|
||||
|
||||
|
||||
@@ -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
@@ -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** назначение не проходит
|
||||
|
||||
|
||||
Reference in New Issue
Block a user