внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
+184
-52
@@ -38,85 +38,191 @@ CGO сборке не нужен.
|
||||
|
||||
### `files`
|
||||
|
||||
Один файл на одну физическую копию: исходник, результат конвертации и копия в
|
||||
Object Storage — каждая своей записью.
|
||||
Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
|
||||
приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
|
||||
считается — она существует только потому, что провайдер распознавания читает
|
||||
аудио по адресу, и её ключ живёт в строке попытки распознавания.
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `file` | file | Сам файл; пусто у копии в Object Storage |
|
||||
| `file` | file | Сам файл |
|
||||
| `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом |
|
||||
| `location` | select | `local` или `s3` |
|
||||
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
|
||||
| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
|
||||
| `size` | INTEGER | Размер в байтах |
|
||||
| `format` | TEXT | Расширение без точки, в нижнем регистре |
|
||||
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
|
||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||
|
||||
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
|
||||
capability, и третий смысл развёл бы одно слово по разным вещам.
|
||||
|
||||
### `transcribe_jobs`
|
||||
### `audio_records`
|
||||
|
||||
Задача расшифровки и она же очередь.
|
||||
Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на
|
||||
приложения лежат здесь; содержимое — по ссылкам, отдельными строками.
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом |
|
||||
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой |
|
||||
| `source` | select | `api`, `telegram`, `unknown` |
|
||||
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
|
||||
| `delay_time` | DATETIME | Не брать задачу раньше этого времени |
|
||||
| `acquisition_id` | TEXT | Кто захватил задачу |
|
||||
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание |
|
||||
| `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа |
|
||||
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
|
||||
| `transcription_text` | editor | Результат распознавания |
|
||||
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
|
||||
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
|
||||
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
|
||||
| `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается |
|
||||
| `halt_reason` | select | `step_failed`, `attempts_exhausted`, `stuck` |
|
||||
| `error_text` | TEXT | Текст ошибки, машинный |
|
||||
| `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого |
|
||||
| `acquire_expires_at` | DATETIME | Срок протухания захвата; приезжает с рубежом |
|
||||
| `delay_time` | DATETIME | Не брать запись раньше этого времени |
|
||||
| `attempts` | INTEGER ≥ 0 | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании |
|
||||
| `original_file` | relation → `files` | Принятая копия |
|
||||
| `normalized_file` | relation → `files` | Копия, приведённая к рабочему формату |
|
||||
| `transcript_text`, `literary_text` | relation → `texts` | Тексты записи |
|
||||
| `structure` | relation → `structures` | Структура реплик |
|
||||
| `recognition` | relation → `recognitions` | Попытка распознавания |
|
||||
| `topics` | relation → `topics`, до 5 | Темы записи |
|
||||
| `tg_chat_id` | INTEGER | Куда отправить результат |
|
||||
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
|
||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||
|
||||
Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата.
|
||||
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
|
||||
этот один.
|
||||
Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним,
|
||||
паузе и сроку протухания.
|
||||
|
||||
**Состояния `failed` и `dead` — разные приговоры**, и чей это приговор, нормирует
|
||||
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
|
||||
«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние
|
||||
потребует нового шага.
|
||||
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
|
||||
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
|
||||
хранилище, и принятого человеком файла не найти было ничем.
|
||||
|
||||
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
|
||||
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
|
||||
признака, — и различие между ними перестало быть структурным. Рубеж при
|
||||
остановке сохраняется, поэтому запись продолжает с места остановки.
|
||||
|
||||
**Сторожей двое.** `attempts` ограничивает повторы внутри шага,
|
||||
`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не
|
||||
справлялось ни с одной.
|
||||
|
||||
### `texts`
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record` | relation → `audio_records` | Чья это расшифровка |
|
||||
| `kind` | select | `transcript` или `literary` |
|
||||
| `contents` | editor | Сам текст |
|
||||
|
||||
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
|
||||
Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат
|
||||
файла.
|
||||
|
||||
### `structures`
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record` | relation → `audio_records` | Чья это структура |
|
||||
| `version` | INTEGER | Версия вида разбора |
|
||||
| `contents` | JSON | Реплики со временем |
|
||||
|
||||
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
|
||||
сохранённого ответа изменится раньше, чем архив пересчитают.
|
||||
|
||||
### `recognitions`
|
||||
|
||||
Попытка распознавания у внешнего провайдера — всё, что зависит от него.
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record` | relation → `audio_records` | Чья это попытка |
|
||||
| `provider`, `model` | TEXT | Кем и какой моделью считано |
|
||||
| `external_id` | TEXT | Идентификатор операции у провайдера |
|
||||
| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио |
|
||||
| `payload` | file, **защищённое** | Сырой ответ провайдера целиком |
|
||||
| `started_at`, `finished_at` | DATETIME | Границы операции |
|
||||
|
||||
**Сырой ответ лежит вложением, а не колонкой.** Шаг опроса читает эту строку раз
|
||||
в несколько секунд, а хранилище читает запись целиком: ответ на многочасовую
|
||||
запись ехал бы в память при каждом опросе. Хранится он потому, что результат
|
||||
операции у провайдера не переспрашивается.
|
||||
|
||||
Поле вложения помечено защищённым: сырой ответ — это полный текст речи, и
|
||||
умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
|
||||
|
||||
### `record_events`
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record` | relation → `audio_records` | Чьё это событие |
|
||||
| `origin` | select | `pipeline` или `human` |
|
||||
| `step` | TEXT | Имя шага |
|
||||
| `outcome` | select | `done`, `failed`, `halted`, `resumed` |
|
||||
| `outcome_text` | TEXT | Причина, если она есть |
|
||||
| `duration_ms` | INTEGER | Сколько шаг занял |
|
||||
|
||||
Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо
|
||||
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
|
||||
двусмысленным.
|
||||
|
||||
Журнал пишется на смену рубежа, на остановку и на снятие остановки — не на
|
||||
каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить,
|
||||
что делать дальше.
|
||||
|
||||
### `topics`
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `owner` | relation → `users` | Чей это словарь |
|
||||
| `name` | TEXT | Название темы |
|
||||
|
||||
Пара «владелец и название» уникальна: словарь тем свой у каждого человека.
|
||||
Коллекцией, а не набором строк в записи, потому что перечень тем нужен целиком
|
||||
перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем
|
||||
не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не
|
||||
платила вторым необратимым шагом схемы.
|
||||
|
||||
### Чего в схеме больше нет
|
||||
|
||||
Коллекция `transcribe_jobs` удалена шагом `202608140002`. Данных под ней не было:
|
||||
сервис на сервере остановлен, а прежние записи удалены решением владельца
|
||||
2026-08-14 — переноса это изменение не делало. Оставленная пустая коллекция
|
||||
висела бы в панели вторым домом для понятия, которого больше нет.
|
||||
|
||||
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
|
||||
обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи,
|
||||
принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с
|
||||
учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит
|
||||
поэтому сам приём, а не схема.
|
||||
учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит поэтому
|
||||
сам приём, а не схема.
|
||||
|
||||
Выборка по владельцу сужает **чтение задачи**: чужая, ничья и несуществующая
|
||||
Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая
|
||||
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
|
||||
обрабатывает записи всех. Тот же шаг сужает правило просмотра коллекции
|
||||
`files` владельцем: прежнее правило пускало всякого вошедшего, и знание
|
||||
идентификатора файловой записи равнялось праву скачать чужое аудио.
|
||||
обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции `files`
|
||||
владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора
|
||||
файловой записи равнялось праву скачать чужое аудио.
|
||||
|
||||
**Учётная запись с задачами не удаляется.** Каскадное удаление у связи выключено,
|
||||
**Учётная запись с записями не удаляется.** Каскадное удаление у связи выключено,
|
||||
но одного этого мало: при выключенном каскаде хранилище снимает ссылку и
|
||||
сохраняет запись без проверок — задачи остались бы, но стали бы ничьими, а ничья
|
||||
задача не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
|
||||
сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья
|
||||
запись не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
|
||||
а не правило коллекции: панель ходит правами суперпользователя, и правило её не
|
||||
судит. Цена названа прямо — владелец панели упирается в отказ, а удаления
|
||||
записей в сервисе пока нет вовсе.
|
||||
судит. Считаются все коллекции с колонкой владельца — `audio_records`, `files` и
|
||||
`topics`, — и перечень живёт одним местом: пропущенная коллекция пропускает
|
||||
удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь
|
||||
вместо нашего отказа с причиной.
|
||||
|
||||
**Правила доступа задач пусты**, то есть перечислять и читать их может только
|
||||
владелец панели. Проверено прогоном: анонимный запрос к
|
||||
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`,
|
||||
`/api/settings` и `/api/crons` — `401`.
|
||||
**Правила доступа новых коллекций пусты**, то есть перечислять и читать их может
|
||||
только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не
|
||||
поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок.
|
||||
Проверено прогоном: анонимный запрос к `/api/collections/*/records` отвечает
|
||||
`403`, к `/api/logs`, `/api/backups`, `/api/settings` и `/api/crons` — `401`.
|
||||
|
||||
## Представление данных
|
||||
|
||||
Чем физически лежит запись и что происходит при чтении и записи.
|
||||
|
||||
- **Расшифровка лежит целиком в поле `transcription_text`** одной строкой.
|
||||
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она
|
||||
целиком при каждом чтении задачи и при каждом захвате.
|
||||
- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
|
||||
её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
|
||||
колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той
|
||||
же строки и читалась при каждом опросе очереди.
|
||||
- **Аудио лежит в раскладке хранилища:**
|
||||
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
|
||||
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
|
||||
@@ -135,19 +241,28 @@ capability, и третий смысл развёл бы одно слово п
|
||||
Без этого сужения закрытие API обходится двумя запросами — завести себе
|
||||
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
|
||||
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
|
||||
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
|
||||
- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
|
||||
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
||||
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
|
||||
невоспроизводим.
|
||||
невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания
|
||||
захвата и отсутствию признака остановки; срок протухания выбирается по рубежу
|
||||
самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить
|
||||
его не может.
|
||||
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
|
||||
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
|
||||
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
|
||||
что условие проверяется тем же запросом, что и сам захват.
|
||||
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
|
||||
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
|
||||
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
|
||||
серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
|
||||
- **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с
|
||||
`applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре,
|
||||
пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и
|
||||
перечень перестал расти с моделью. Правило правки и его серьёзность —
|
||||
инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила
|
||||
`internal/archrules`.
|
||||
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
|
||||
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
|
||||
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
|
||||
по инварианту проекта не пишется в журнал и не считается в метрику.
|
||||
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
|
||||
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
|
||||
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
|
||||
@@ -157,14 +272,22 @@ capability, и третий смысл развёл бы одно слово п
|
||||
|
||||
| Настройка | Значение | Где | Откуда число |
|
||||
| --- | --- | --- | --- |
|
||||
| Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер |
|
||||
| Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же |
|
||||
| Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас |
|
||||
| Срок захвата, распознавание | 8 часов | там же | то же |
|
||||
| Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды |
|
||||
| Задержка перед первой проверкой операции | 10 секунд | там же | как было |
|
||||
| Предел отказов | 5 | `service/transcribe.go` | обычное умолчание, не замер |
|
||||
| Пауза перед повтором | `2^(отказ−1)` с, потолок 5 минут | там же | то же |
|
||||
| Срок захвата, приведение | 8 часов | `entity/stage.go` | потолок записи 6 часов плюс запас |
|
||||
| Срок захвата, отправка на распознавание | 8 часов | там же | то же |
|
||||
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
|
||||
| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
|
||||
| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение |
|
||||
| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
|
||||
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
|
||||
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая |
|
||||
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
|
||||
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
|
||||
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
|
||||
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
|
||||
| Задержка между проверками операции | 5 секунд | там же | как было |
|
||||
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | как было |
|
||||
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
|
||||
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
|
||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
||||
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
|
||||
@@ -177,6 +300,15 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
|
||||
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
|
||||
|
||||
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
|
||||
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
|
||||
протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит
|
||||
по нехватке памяти или `docker kill`, — запись невидима сторожу до истечения
|
||||
**срока захвата** её рубежа, то есть восьми часов у приведения и отправки.
|
||||
Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и
|
||||
остановка «застряла» наступает только после него. Мягкая остановка сюда не
|
||||
подпадает: она снимает захват сама.
|
||||
|
||||
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
||||
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
||||
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
|
||||
|
||||
Reference in New Issue
Block a user