Files
transcriber/openspec/specs/recognition/spec.md
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

183 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# recognition Specification
## Purpose
Что принадлежит внешнему распознавателю и как это лежит у нас: попытка
распознавания отдельной строкой, сырой ответ провайдера целиком, структура
реплик, построенная из сохранённого ответа, и граница, за которую разбор чужого
формата не выходит.
Движение записи по рубежам нормирует `pipeline`, хранение текста и структуры —
`storage`, закрытость содержимого — `storage` же.
Сознательно не описан формат ответа конкретного провайдера: он живёт в адаптере
и в записках разведки, а не в норме поведения.
## Requirements
### Requirement: Попытка распознавания хранится отдельно от записи
Сервис SHALL держать всё, что принадлежит внешнему распознавателю, отдельной
строкой, связанной с аудиозаписью, и MUST не хранить это колонками самой записи.
К попытке относятся имя провайдера, имя модели, идентификатор операции у
провайдера, адрес, по которому провайдер читал аудио, время начала и время
завершения.
Разрез проходит по одной границе: **зависит ли вещь от провайдера
распознавания**. Идентификатор операции — самое провайдерское, что есть в
модели, а копия аудио во внешнем хранилище существует только потому, что
сегодняшний провайдер читает запись по адресу; другой провайдер её не потребует.
Оставленные колонками записи, они делают смену провайдера правкой доменной
сущности.
Копия аудио во внешнем хранилище MUST не считаться файлом записи: у записи
остаётся ровно две своих копии — принятая и приведённая, — а ключ объекта живёт
в строке попытки.
#### Scenario: Идентификатор операции лежит в попытке
- **GIVEN** запись отправлена на распознавание
- **WHEN** смотрят, где лежит идентификатор операции у провайдера
- **THEN** он лежит в строке попытки распознавания
- **AND** колонки с ним у самой записи нет
#### Scenario: Копия во внешнем хранилище не подменяет файл записи
- **GIVEN** запись прошла отправку на распознавание
- **WHEN** смотрят ссылки записи на файлы
- **THEN** они ведут на принятую и на приведённую копии
- **AND** ключ объекта во внешнем хранилище лежит в строке попытки
### Requirement: Сырой ответ провайдера сохраняется целиком
Сервис SHALL сохранять ответ распознавателя целиком, в том виде, в каком он
пришёл, и MUST хранить его **отдельным файлом в каталоге данных**, а не колонкой
строки попытки.
Хранится он потому, что **результат операции у провайдера не переспрашивается**:
связь реплики с говорящим сервис строить пока не умеет, и когда научится, архив
пересчитается из сохранённого без повторной оплаты.
Файлом, а не колонкой, — потому что шаг опроса читает строку попытки часто, а
репозиторий читает строку целиком: ответ на многочасовую запись, положенный
колонкой, ехал бы в память при каждом опросе. Где именно этот файл лежит,
нормирует capability `storage`, требование «Файл записи живёт в хранилище»:
третьим файлом в подкаталоге записи, наравне с копиями аудио. Копией аудио он при
этом не считается — их у записи по-прежнему две.
Чтение строки попытки шагом опроса MUST не тянуть за собой сохранённый ответ.
Сохранённый ответ — это полный текст речи, и закрыт он MUST быть наравне с самой
записью. Закрытость MUST держаться **проверкой владельца в обработчике сервиса**:
адреса, которым сохранённый ответ читают снаружи, сервис MUST не заводить вовсе, а
всякий адрес, отдающий содержимое записи, MUST судить владельца связанной
аудиозаписи сам, при каждом обращении. Пометка поля защищённым и правило
просмотра коллекции, которыми закрытость держалась прежде, — механизмы
встроенного хранилища, и их не остаётся; норма от этого не ослабла, а перестала
зависеть от настройки, которую мы не писали.
Путь к файлу сохранённого ответа MUST не попадать ни в журнал, ни в метку
метрики, ни в ответ отправителю.
Норму держит capability `storage`, требование «Содержимое записи закрыто везде,
где лежит»; здесь она названа потому, что попытка распознавания — то место, куда
содержимое приезжает впервые.
#### Scenario: Ответ сохранён и читается позже
- **GIVEN** распознавание завершилось и ответ провайдера получен
- **WHEN** запись доходит до конечного рубежа
- **THEN** сохранённый ответ доступен по строке попытки целиком
#### Scenario: Опрос не тянет сохранённый ответ
- **GIVEN** у попытки распознавания есть сохранённый ответ
- **WHEN** шаг опроса читает строку попытки
- **THEN** сохранённый ответ в память при этом не читается
#### Scenario: Адреса чтения сохранённого ответа у сервиса нет
- **GIVEN** запись принята одним узнанным и прошла распознавание
- **WHEN** другой узнанный ищет адрес, которым читается сохранённый ответ этой
записи
- **THEN** такого адреса у сервиса нет
- **AND** содержимого он не получает
### 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** повторная отправка той же записи не заводится