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

23 KiB
Raw Permalink Blame History

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), а не выводится расчётом.