Files
av 01ac0430a9 спека: заведён change large-release-recognition для больших раздач
- контракт с моделью переводится на адресацию файла номером строки списка,
  усечение промпта снимается, лимиты уходят в конфиг
- раскладка показывает все файлы раздачи, покрытие плана блокирует авто
  только при непокрытом видеофайле
2026-09-02 08:54:32 +03:00

232 lines
23 KiB
Markdown
Raw Permalink 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.
## 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), а не выводится расчётом.