спека: заведён change large-release-recognition для больших раздач

- контракт с моделью переводится на адресацию файла номером строки списка,
  усечение промпта снимается, лимиты уходят в конфиг
- раскладка показывает все файлы раздачи, покрытие плана блокирует авто
  только при непокрытом видеофайле
This commit is contained in:
av
2026-09-02 08:54:32 +03:00
parent 2ac26bf8ed
commit 01ac0430a9
6 changed files with 685 additions and 0 deletions
@@ -0,0 +1,231 @@
## Context
Распознавание собирает промпт в `internal/recognize/prompt.go` и разбирает ответ в
`validate.go`. Список файлов печатается нумерованным (`1. путь (размер)`), но номер
служит только человеку: модель обязана вернуть `files[].src` дословной копией пути, а
`validateSchema` ищет эту строку среди реальных файлов торрента (`validate.go:75`). Это и
есть главный инвариант безопасности недоверенного выхода: сослаться на посторонний путь
нельзя.
Цена контракта видна на больших сериальных паках. Путь вида
`Отчаянные домохозяйки (Сезон 1-8 из 8) Дубляж/Сезон 8/Desperate.Housewives.S08E13.WEB-DLRip.Rutracker.org.avi`
— около 70 токенов на кириллице; с ролью и номерами элемент плана стоит ~85 выходных
токенов. Раздача на 180 файлов требует ~15k токенов ответа при зашитом
`defaultMaxTokens = 4000`, а список в промпте заранее подрезан `defaultMaxFiles = 100`.
Ни то, ни другое в конфиг не выведено. Отсюда все наблюдаемые симптомы: ровно 100 файлов
в плане, невидимые 80, сорок строк причин про пропуски серий.
Вход при этом не жмёт: 180 путей — это 12–15k токенов промпта, для рабочей модели
(`deepseek-v4-flash` через bothub) это не проблема. Дорог и хрупок именно ответ, и дорог
он ровно тем, что модель переписывает наши же строки.
Дополнительное ограничение задаёт следующий этап: разметку файлов будет проверять
детерминированный разбор имён, а для этого источники должны остаться независимыми. Всё,
что похоже на «подсказать модели наш ответ», в этом change недопустимо.
## Goals / Non-Goals
**Goals:**
- Раздача на сотни файлов распознаётся целиком, без усечения списка и без обрыва ответа.
- Ошибка модели видна: непокрытые файлы показаны, покрытие названо числом.
- Лимиты распознавания настраиваются на проде без пересборки.
- Инвариант «`files[].src` — реальный файл торрента» не ослабевает.
- Цена неудачной попытки не растёт вместе с размером раздачи.
**Non-Goals:**
- Детерминированный разбор сезона/серии из имён файлов и сверка с моделью — этап B.
- Батчирование запроса по пачкам файлов: при компактном формате ответа нужды нет, а
склейка шапки между пачками добавляет свой класс расхождений.
- Групповка раскладки по сезонам со свёрткой в UI: раздача на 180 строк читается, если
строки упорядочены; свёртка — отдельная задача интерфейса.
- Пересмотр модели уверенности и гейта авто-раскладки. Единственное исключение — новая
причина покрытия, и она заведена так, чтобы гейт не двигать (см. решение ниже);
всё остальное в решении auto/review остаётся как есть.
## Decisions
### Файл адресуется номером строки нашего списка
`files[]` несёт `i` — номер из напечатанного нами перечня (1-based, в порядке печати).
`src` заполняет резолв на разборе: `in.Files[i-1].Path`. Валидация вырождается в проверку
диапазона, и посторонний путь становится невыразимым — инвариант усиливается, а не
слабеет. Выход падает до ~14 токенов на файл: 180 файлов укладываются в 2.5k, то есть в
нынешний потолок даже без его подъёма.
Альтернативы:
- *Оставить дословный `src`, подняв лимиты.* Отвергнуто: 15k выходных токенов — это минуты
генерации при `[llm].timeout = 120s`, риск упереться в потолок ответа провайдера и
сохранение главного источника отбраковки (одна опечатка в одном из 180 путей роняет
весь план, дальше correction-ретрай целиком).
- *Индекс файла из qBittorrent.* Отвергнуто: `qbt.File` отдаёт только `name` и `size`
(`internal/qbt/qbt.go:86`), собственного индекса у нас нет, а порядок ответа API как
контракт нигде не зафиксирован. Номер строки собственного списка не зависит от чужого
API вовсе.
- *Короткий хеш пути.* Отвергнуто: длиннее индекса в токенах и хуже читается человеком в
логах и сыром ответе.
### Негодный элемент отбрасывается, план живёт
Номер вне диапазона, повторная адресация одного файла, элемент без номера и без пути —
всё это отбраковывает **элемент**, а не план: остальные файлы остаются, задача уходит в
`review` с причиной, называющей отброшенное. Первоначальный вариант «отклонять план
целиком» отвергнут: он воспроизводит ровно тот отказ, против которого затевается change
— одна ошибка в одном из 180 элементов роняет всю работу и запускает correction-ретрай.
Решение приведено к действующей норме `recognition` («Роли файлов на краях раздачи»):
неоднозначность нумерации эскалируется в `review`, а не разрешается молча и не роняет
разбор. Порог здравого смысла — план, где не осталось ни одного годного файла, считается
неразобранным.
При повторной адресации побеждает первый элемент: иначе два элемента претендуют на один
файл, а раскладка сделала бы два хардлинка с одного источника на две серии.
### Номер главенствует над путём, а расхождение — сигнал
Элемент, несущий и `i`, и `src`, резолвится по номеру. Несовпадение присланного пути с
резолвом даёт причину ухода в `review`, а не молчаливое затирание: модель, назвавшая обе
стороны, бесплатно предъявляет нам сверку против главного риска этого change — сдвига
индекса. Одна ветка в разборе покупает оракул, который иначе появился бы только в этапе B.
### Повторная попытка не переприсылает список файлов
Сегодня `Recognize` дописывает в диалог сырой ответ и `correctionMessage`, а тот печатает
список файлов заново (`recognize.go:243`, `prompt.go:180`). При зашитом лимите в 100
файлов это терпимо; со снятым лимитом четвёртая попытка несла бы четыре копии списка —
50–60k входных токенов, то есть изменение, чья цель «раздача на сотни файлов
распознаётся целиком», удорожало бы её неудачу. Индексная адресация это и снимает: номера
названы в первом сообщении диалога и сохраняют смысл на всех попытках, поэтому
correction-сообщение несёт только ошибку и схему.
### Порядок списка — свойство узла, а не вызывающего
`Recognize` приводит полученный список файлов к детерминированному порядку сам (сортировка
по пути), и печать промпта с резолвом индексов идут по одному и тому же срезу. Сортировка
у вызывающего отвергнута: сборка `recognize.Input` не единственная — кроме воркера
(`internal/worker/review.go:126`) её делает CLI `jellybit recognize --dry-run`
(`cmd/jellybit/recognize.go:101`), и путь, не получивший сортировки, печатал бы номера,
означающие другие файлы. Свойство нормировано на узле — значит и механизируется в узле, а
тест зовёт `Recognize`, а не его вызывающего.
Внутри торрента путь уникален, так что порядок воспроизводим между вызовами: повторное
распознавание из ревью не сдвинет привязку. Полагаться на порядок ответа qBittorrent
нельзя — он не обещан. Побочный эффект приятный: файлы одного сезона идут в промпте
подряд.
### Старый формат принимается как запасной
Пришёл `i` — резолвим; пришёл `src` — валидируем как сегодня. Модель, повторившая формат
из прежних примеров, не должна стоить нам correction-ретрая. Ветка с `src` остаётся и как
страховка на случай, если индексная разметка окажется хуже на каких-то раздачах: откат —
это смена схемы в промпте, без правки разбора.
### Обрыв генерации — отдельная причина, а не мусор
`llm.Response` доводит `finish_reason` до `recognize` (сейчас поле разбирается в
`internal/llm/openai.go:97` и выбрасывается). Обрыв по длине даёт review с причиной
«ответ модели обрезан» и не повторяется ни тем же промптом, ни его вариантом: обрыв
означает, что ответ не поместился, а не что модель ошиблась.
### Лимиты — в конфиг, дефолты соразмерны промпту
`[recognition].max_files` и `[recognition].max_tokens`, дефолты 500 и 8000. Прежний
`max_files` остаётся предохранителем от аномальной раздачи, а не рабочим ограничением, но
его значение обязано соответствовать той же арифметике, которой обоснована безопасность
изменения: 180 файлов — 12–15k входных токенов, значит 500 файлов — порядка 35k, что
модель принимает. Дефолт 1000 отвергнут именно поэтому: предохранитель, пропускающий
раздачу, которая упрётся в контекст, не предохраняет, а лишь меняет внятную причину
«список усечён» на 4xx с текстом провайдера. Отказ по размеру запроса называется
отдельной причиной.
### Покрытие плана показывается всегда, блокирует — только по видеофайлу
`Decision.Auto` — это `len(reasons) == 0` (`validate.go:124`), поэтому любая новая причина
автоматически становится блокирующей. Промпт при этом требует покрыть «каждый значимый
файл» (`prompt.go:55`): модель вправе опустить `.nfo`, скриншоты и `.txt`, и на типовой
фильмовой раздаче покрытие штатно меньше сотни процентов. Причина покрытия в общем виде
выключила бы авто-раскладку по всей системе — притом что её пересмотр объявлен Non-Goal.
Поэтому покрытие разделено: число «в плане N из M» показывается человеку всегда, а
блокирует только непокрытый **видеофайл** — он означает потерянную серию или фильм.
Файлы-спутники в блокирующую часть не входят. Отсюда требуется различать видео по
расширению — в проекте такого места сегодня нет (`grep` по `internal` не находит ни
одного упоминания `.srt`/`.mkv` как класса), поэтому перечень заводится один раз и рядом
с ролями файлов, а не расползается по вызывающим.
Альтернатива «потребовать от модели полного покрытия, мусор — ролью `ignore`» отвергнута:
она перекладывает на выход модели ещё сотню элементов на больших паках — ровно ту
стоимость, которую change снимает.
### Файлы вне плана показывает раскладка
`buildFileRows` (`internal/httpapi/files.go:25`) обходит `plan.Files`; файл, который
модель не включила в план, не отображается нигде. Обход разворачивается на файлы
торрента, план подмешивается по `src`. Файл без записи в плане получает явную строку «не
в плане». Порядок строк определён полностью: разложенные (сезон, серия, путь) → файлы
плана без цели (`extra`/`sample`/`ignore`, по пути) → файлы вне плана (по пути). Пустые
`season`/`episode` — указатели, и свёрнутые в ноль они поставили бы семплы и скриншоты
перед первым сезоном; поэтому место таких файлов задано явно, а не выводится из
умолчания.
Колонка `#` виджета сейчас печатает порядковый номер строки
(`web/templates/partials/layout_widget.html:8`). После изменения номер файла несёт
контракт: он попадает в сырой ответ модели и в логи, и человек, разбирающий «модель
адресовала файл 13», обязан увидеть в раскладке тот же файл. Значит колонка либо
показывает номер из списка распознавания, либо не показывается вовсе.
Отсюда зависимость по данным: странице нужен список файлов торрента, а не только
сохранённый план. Живой запрос в qBittorrent на рендере — лишний поход в чужой сервис на
каждый показ; список файлов приходит вместе с распознаванием и сохраняется рядом с
планом (миграция `0012`, колонка в `recognition`). Записи, созданные раньше, списка не
имеют — и виджет обязан это **сказать**, а не молча показать план как полный перечень:
иначе неполнота неотличима от честного «файлов вне плана нет». Раздача, ради которой
change делается, уже лежит в `review`, и полный список она получит при повторном
распознавании из ревью.
### Причины ревью сворачиваются
`seriesWarnings` (`validate.go:186`) даёт строку на каждый разрыв нумерации — на паке из
8 сезонов это 40+ строк. Вместо этого: одна строка на сезон с перечнем недостающих серий
и одна сводная строка покрытия. Смысл причин не меняется, меняется форма: причина должна
читаться человеком в ревью с одного взгляда.
## Risks / Trade-offs
- **Ошибка модели в индексе становится тихой.** Перепутанный путь сегодня чаще всего не
совпадёт ни с одним файлом и ответ будет отбракован громко; сдвиг индекса на единицу
даёт валидный, но не тот файл → серия уедет на соседнее место.
*Смягчение:* отбраковка повторной адресации, сверка присланного `src` с резолвом номера,
когда модель прислала оба, сводка покрытия в причинах и сверка числа серий с метабазой.
Полное смягчение — перекрёстная проверка с разбором имени файла в этапе B, ради которой
источники и держатся независимыми.
- **Разделение покрытия по типу файла держится на перечне расширений.** Незнакомое
расширение видео попадёт в спутники, и потерянная серия не заблокирует авто.
*Смягчение:* перечень заводится в одном месте и покрывается тестом; сверка числа серий
с метабазой ловит потерю независимо от расширения.
- **Список файлов теперь хранится рядом с планом.** Рост объёма БД на больших раздачах и
ещё одно место, которое может разойтись с реальностью: раздача, переименованная в
qBittorrent после распознавания, даст раскладку по устаревшему снимку.
*Смягчение:* хранится то, что и так получено при распознавании; это снимок момента
распознавания, и реальная раскладка всё равно идёт от плана. Записи без снимка виджет
называет явно.
- **Схема в промпте меняется — качество разметки может просесть.** Модель разметит 180
файлов хуже, чем 100, просто потому что их больше.
*Смягчение:* мерить нечем — размеченного корпуса нет и не будет (`tasks/REJECTED.md`,
2026-08-06), поэтому проверка ручная на той самой раздаче, а откат дешёвый (ветка `src`
сохранена).
- **Дефолт `max_tokens = 8000` может не поддержаться эндпоинтом.** Потолок ответа у
провайдера ниже нашего дефолта — получим обрыв.
*Смягчение:* обрыв теперь называется явной причиной, а лимит крутится в конфиге без
пересборки.
- **Множитель стоимости попыток остаётся.** `correctionMessage` снял рост промпта
*внутри* диалога, но полный промпт всё равно уходит заново на каждой **транспортной**
попытке: `internal/llm` держит зашитые `maxAttempts = 3` (сетевой сбой, 429, 5xx), и
они умножаются на бюджет переразбора `[llm].max_retries` (дефолт 3, то есть 1 + 3
запроса). В худшем случае — до 12 отправок, а промпт после подъёма `max_files` со 100
до 500 стал впятеро тяжелее. Плюс `[llm].timeout` (120 s) при удвоенном `max_tokens`
(4000 → 8000) не пересматривался: генерация вдвое длиннее ответа может не уложиться в
прежнее окно, и тогда таймаут читается как транспортный сбой и уходит в ретрай.
*Смягчение:* оба предела в конфиге и крутятся без пересборки; согласование `timeout` с
`max_tokens` проверяется на живом кейсе (задача 9.3), а не выводится расчётом.