- контракт с моделью переводится на адресацию файла номером строки списка, усечение промпта снимается, лимиты уходят в конфиг - раскладка показывает все файлы раздачи, покрытие плана блокирует авто только при непокрытом видеофайле
23 KiB
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), а не выводится расчётом.