спека: заведён change large-release-recognition для больших раздач
- контракт с моделью переводится на адресацию файла номером строки списка, усечение промпта снимается, лимиты уходят в конфиг - раскладка показывает все файлы раздачи, покрытие плана блокирует авто только при непокрытом видеофайле
This commit is contained in:
@@ -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), а не выводится расчётом.
|
||||
Reference in New Issue
Block a user