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