спека: заведён change large-release-recognition для больших раздач

- контракт с моделью переводится на адресацию файла номером строки списка,
  усечение промпта снимается, лимиты уходят в конфиг
- раскладка показывает все файлы раздачи, покрытие плана блокирует авто
  только при непокрытом видеофайле
This commit is contained in:
av
2026-09-02 08:54:32 +03:00
parent 2ac26bf8ed
commit 01ac0430a9
6 changed files with 685 additions and 0 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-02
@@ -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), а не выводится расчётом.
@@ -0,0 +1,92 @@
## Why
Раздача «Отчаянные домохозяйки» — один торрент на 8 сезонов, 180 файлов — распозналась
негодно: в план попало ровно 100 файлов, потому что промпт усекает список файлов жёсткой
константой `defaultMaxFiles = 100`. Остальные 80 файлов модель не видела, в интерфейсе они
не показаны никак, а причины ревью распухли до сорока строк «пропуск серий в сезоне N».
Порядок строк раскладки в UI — тот, в каком их вернула модель, то есть вперемешку по
сезонам.
Просто поднять лимит нельзя: каждый элемент плана обязан нести дословную копию пути
(~70 токенов на кириллице), 180 файлов дают ~15k выходных токенов при нашем же потолке
`defaultMaxTokens = 4000`. Обрыв генерации при этом неотличим от мусора — `finish_reason`
из ответа разбирается, но не проверяется, и обрыв уходит в три бессмысленных ретрая
с полным промптом. Вход тут не проблема (12–15k токенов промпта — ничто для современной
модели), проблема целиком на стороне ответа.
## What Changes
- **BREAKING (контракт с моделью, не с пользователем)**: элемент `files[]` в ответе LLM
адресует файл номером из напечатанного нами списка (`i`), а не дословной копией пути.
`src` заполняем сами резолвом индекса. Выход падает с ~85 до ~14 токенов на файл; путь,
которого нет в торренте, становится невыразимым. Старый формат с `src` принимается как
запасной, чтобы привычка модели не давала ретрая на пустом месте.
- Негодный элемент ответа (номер вне диапазона, повторная адресация файла, элемент без
номера и пути) отбрасывается поимённой причиной, а не роняет весь план: иначе одна
ошибка в одном из 180 элементов воспроизводит ровно тот отказ, против которого change и
затевается. Элемент, несущий и номер, и путь, резолвится по номеру, а расхождение между
ними становится причиной ревью — бесплатной сверкой против сдвига адресации.
- Жёсткое усечение списка файлов снимается: модели показываются все файлы раздачи.
`max_files` и `max_tokens` выводятся в `[recognition]` с дефолтами 500 и 8000 — сейчас
оба зашиты в код и на проде не крутятся.
- Повторная попытка перестаёт переприсылать список файлов: номера названы в первом
сообщении диалога, и цена неудачного распознавания больше не растёт вместе с числом
попыток.
- Порядок файлов, напечатанных в промпте, детерминирован и совпадает с порядком резолва
индексов — иначе повторное распознавание из ревью сдвинет привязку.
- Обрыв генерации (`finish_reason` = `length`) распознаётся как отдельная причина ухода
в review и не ретраится тем же промптом.
- В раскладке видны все файлы торрента: попавшие в план — с ролью и целью, не попавшие —
отдельной строкой. Молчаливый пропуск файла моделью перестаёт быть невидимым. Загрузки,
распознанные до этого изменения, списка файлов не имеют — и виджет говорит об этом
прямо, вместо того чтобы показать план как полный перечень.
- Строки раскладки упорядочены полностью — разложенные по (сезон, серия, путь), затем
файлы плана без цели, затем файлы вне плана, — а не по порядку ответа модели.
- Причины ревью по нумерации серий сворачиваются: перечисление недостающих серий по
сезону одной строкой и сводка покрытия плана вместо строки на каждый разрыв. Покрытие
показывается, когда покрыты не все файлы, но блокирует авто-раскладку только
непокрытый видеофайл: иначе опущенный моделью `.nfo` выключил бы авто-путь на типовых
раздачах. Отсюда же следствие для решения: `Reasons` перестают быть однородным
списком «причин не-авто» — в них появляются информационные строки, и решение
считается по блокирующим причинам, а не по длине списка.
Вне объёма: детерминированный разбор сезона/серии из имён файлов и сверка с ответом
модели — следующий change (этап B). Здесь модель по-прежнему единственный источник
разметки файлов.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `recognition`: контракт ответа LLM (адресация файла индексом вместо дословного пути),
отказ от усечения списка файлов и вывод лимитов в конфиг, обрыв генерации как
отдельная причина review, форма причин по нумерации серий, решение auto/review
считается по блокирующим причинам (в `Reasons` появились информационные строки).
- `web-ui`: раскладка показывает все файлы раздачи, включая не попавшие в план, и
упорядочена по сезону и серии.
## Impact
- `internal/recognize`: `prompt.go` (нумерация и печать списка, схема в промпте,
correction-сообщение без повторной печати списка), `validate.go` (резолв индекса вместо
поиска пути, отбраковка негодных элементов, свёртка предупреждений, покрытие по
видеофайлам), `recognize.go` (упорядочивание входного списка в самом узле, лимиты,
обработка обрыва).
- `internal/llm`: `finish_reason` доводится до вызывающего в `Response`.
- `internal/config`: `[recognition].max_files`, `[recognition].max_tokens`;
`config.example.toml` и `docs/database.md` (настройки с числовым значением).
- `internal/httpapi/files.go` и `web/templates/partials/layout_widget.html`: сортировка,
файлы вне плана, колонка `#` (номер строки виджета обязан совпасть с номером адресации
либо исчезнуть), строка о недоступном списке файлов.
- Хранилище: миграция `0012` — колонка со списком файлов раздачи в таблице `recognition`,
и правка ER-схемы в `docs/database.md` (без неё краснеет шаг `canon` гейта).
- Совместимость: сохранённые планы в БД хранят `src` путями и не меняются. Записи,
созданные до миграции, списка файлов не имеют — виджет называет это явно. Уже
применённые раскладки не затрагиваются.
- Риск: ошибка модели в индексе становится тихой (валидный, но не тот файл). Смягчение —
требование уникальности индексов и сводка покрытия в причинах; перекрёстная проверка
разметки приходит в этапе B.
@@ -0,0 +1,249 @@
## MODIFIED Requirements
### Requirement: Разбор сигналов LLM в структурированный план
Система SHALL передавать LLM недоверенные сигналы (имя торрента, дерево файлов с
размерами, текстовый контекст и накопленные подсказки, пред-парс) и получать
структурированный план в схеме: `type` (`movie`|`series`), `title`,
`original_title`, `year`, `provider_hint`, `files[]` и `confidence`. Каждый
элемент `files[]` SHALL адресовать файл раздачи номером строки из напечатанного
системой списка файлов, а не копией пути, и нести `role`
(`main`|`episode`|`subtitle`|`extra`|`sample`|`ignore`) и, для сериала,
per-file `season`/`episode` (отдельного скалярного `season` быть SHALL NOT — так
выражаются мультисезонные паки и спецвыпуски). Путь файла (`src`) SHALL
подставлять сама система резолвом номера по своему списку. Ответ, где элемент
`files[]` несёт путь вместо номера, система SHALL принимать как запасной формат,
проверяя совпадение пути с реальным файлом торрента. Элемент, несущий и номер, и
путь, система SHALL резолвить по номеру; несовпадение присланного пути с
резолвом номера SHALL давать причину ухода в `review` (сигнал сдвига адресации),
а не молчаливое затирание.
Негодный элемент `files[]` — номер вне диапазона списка, повторная адресация уже
адресованного файла, отсутствие и номера, и пути — система SHALL отбрасывать с
причиной ухода в `review`, сохраняя остальной план; при повторной адресации в
плане SHALL оставаться первый элемент. Отбраковка отдельных элементов SHALL NOT
считаться ошибкой разбора и SHALL NOT порождать повторный запрос к модели. План,
в котором после отбраковки не осталось ни одного файла, система SHALL считать
неразобранным.
Порядок списка файлов SHALL быть свойством самого распознавания, а не
дисциплиной вызывающего: распознавание SHALL приводить полученный список к
детерминированному порядку само, печатать промпт и резолвить номера по одному и
тому же упорядоченному списку. Повторное распознавание той же раздачи SHALL
давать ту же нумерацию.
План MAY дополнительно нести опциональное скалярное поле `director` (режиссёр).
Это поле НЕ требуется от LLM и НЕ участвует в структурной валидации или гейте
авто-раскладки; его заполняют подтверждённый матч метабазы (авто) или закреплённый
в ревью выбранный источник (через override, см. `metadata-match`/`review`) как
недоверенное косметическое значение для вывода отображаемого имени. Как недоверенное
человекочитаемое поле, `director` SHALL NOT входить в plan-санитайзинг (он чистит
`title`/`original_title`/`provider_hint`); очистка режиссёра применяется при рендере
имени. Пустой `director` SHALL быть штатным (режиссёр неизвестен).
#### Scenario: План сериала с per-file нумерацией
- **GIVEN** сезон-пак из 10 видеофайлов
- **WHEN** LLM возвращает план
- **THEN** `type` = `series`, а каждый видеофайл несёт свои `season`/`episode`
#### Scenario: Номер файла резолвится в путь
- **GIVEN** список из 180 файлов, напечатанный в промпте
- **WHEN** модель возвращает элемент плана с номером 13
- **THEN** система подставляет в `src` путь тринадцатого файла своего списка
#### Scenario: Номер вне диапазона отбрасывает элемент, а не план
- **GIVEN** раздача из 180 файлов и ответ модели, где один элемент несёт номер 181,
а остальные 179 корректны
- **WHEN** план разбирается
- **THEN** негодный элемент отброшен, остальные 179 файлов остаются в плане
- **AND** задача уходит в `review` с причиной, называющей отброшенный элемент
- **AND** повторный запрос к модели не выполняется
#### Scenario: Повторная адресация одного файла
- **GIVEN** ответ модели, где два элемента адресуют один и тот же файл
- **WHEN** план разбирается
- **THEN** в плане остаётся первый элемент, второй отброшен
- **AND** задача уходит в `review` с причиной, называющей повторную адресацию
#### Scenario: Все элементы негодны — план не разобран
- **GIVEN** ответ модели, где ни один элемент `files[]` не адресует реальный файл
- **WHEN** план разбирается
- **THEN** план не принимается как валидный
#### Scenario: Путь вместо номера принимается как запасной формат
- **GIVEN** ответ модели, где элемент `files[]` несёт путь файла вместо номера
- **WHEN** план разбирается
- **THEN** разбор успешен, если путь совпадает с реальным файлом торрента
- **AND** correction-ретрай не выполняется
#### Scenario: Номер и путь одновременно — главенствует номер
- **GIVEN** элемент `files[]`, несущий и номер, и путь, которые указывают на разные файлы
- **WHEN** план разбирается
- **THEN** `src` берётся резолвом номера
- **AND** задача уходит в `review` с причиной о несовпадении присланного пути с резолвом
#### Scenario: Несуществующий src отклоняется
- **GIVEN** ответ LLM, где `files[].src` не совпадает ни с одним файлом торрента
- **WHEN** план разбирается
- **THEN** такой элемент отбрасывается с причиной, а посторонний путь в план не попадает
#### Scenario: Нумерация не зависит от вызывающего
- **GIVEN** два вызова распознавания одной раздачи, получившие список файлов в разном порядке
- **WHEN** собирается промпт
- **THEN** напечатанная нумерация в обоих вызовах одинакова
#### Scenario: Режиссёр не требуется от LLM и не влияет на гейт
- **GIVEN** ответ LLM без поля `director`
- **WHEN** план разбирается и оценивается
- **THEN** разбор успешен, `director` пуст
- **AND** отсутствие режиссёра не влияет на структурную валидацию и решение
auto/review
### Requirement: Провайдер LLM за абстракцией со структурированным выводом
Доступ к LLM SHALL быть за интерфейсом с выбором реализации по полю `[llm].type`
(первый тип — `openai-compat`). Система SHALL запрашивать JSON-режим
(`response_format: {"type":"json_object"}`), срезать ```-ограждения и
валидировать ответ в Go против схемы плана. При ошибке разбора система SHALL
ретраить до `[llm].max_retries`, передавая модели саму ошибку и схему. Если после
ретраев ответ не разобран, задача SHALL уходить в `review` (НЕ в `failed`) с
причиной «ответ LLM не разобран».
Повторный запрос SHALL NOT переприсылать список файлов раздачи: список назван в
первом сообщении диалога, и его номера сохраняют смысл на всех попытках. Размер
запроса SHALL NOT расти пропорционально числу попыток.
Признак завершения генерации (`finish_reason`) SHALL доходить до распознавания.
Ответ, оборванный по длине, система SHALL уводить в `review` с отдельной
причиной «ответ модели обрезан» и SHALL NOT повторять запрос ни тем же промптом,
ни его вариантом: обрыв означает, что ответ не поместился, а не что модель
ошиблась.
#### Scenario: Неразобранный ответ уходит в review
- **GIVEN** LLM, чей ответ не проходит валидацию схемы после всех ретраев
- **WHEN** завершается распознавание
- **THEN** задача переходит в `review` с причиной «ответ LLM не разобран»
- **AND** задача НЕ переходит в `failed`
#### Scenario: Повторная попытка не переприсылает список файлов
- **GIVEN** раздача из 180 файлов и ответ модели с ошибкой разбора
- **WHEN** выполняется correction-ретрай
- **THEN** список файлов раздачи повторно не печатается
- **AND** размер запроса второй попытки сопоставим с размером первой
#### Scenario: Обрыв по длине не ретраится
- **GIVEN** ответ модели с признаком обрыва генерации по длине
- **WHEN** завершается распознавание
- **THEN** задача переходит в `review` с причиной «ответ модели обрезан»
- **AND** повторных запросов к модели не выполняется
### Requirement: Модель уверенности и решение auto/review
Система SHALL раскладывать автоматически (без review) только при выполнении
ВСЕГО: (1) подтверждённый единичный сильный матч в базе (`metadata-match`) с
`provider_id`; (2) структурная валидация без предупреждений (фильм — ровно один
основной видеофайл; сериал — число серий бьётся с базой, нумерация S·E
консистентна); (3) согласованность пред-парса и LLM по типу/названию/году. Иначе
задача SHALL уходить в `review` с явной причиной. Самооценку LLM (`confidence`)
система SHALL учитывать лишь как вспомогательный сигнал, НЕ как единственный гейт.
Список причин распознавания SHALL быть неоднородным: кроме блокирующих причин он
MAY содержать информационные строки, которые показываются человеку и сохраняются
вместе с распознаванием, но авто-раскладку НЕ отменяют — такова сводка покрытия
плана (см. «Причины по нумерации серий и покрытию плана»). Поэтому решение
auto/review система SHALL считать по **блокирующим** причинам, а НЕ по длине
списка причин: непустой список сам по себе SHALL NOT означать `review`.
#### Scenario: Нет матча в базе — всегда review
- **GIVEN** план без подтверждённого матча в базе (база выключена или матча нет)
- **WHEN** принимается решение auto/review
- **THEN** задача уходит в `review`, авто-раскладка не делается
#### Scenario: Матч и чистая валидация — авто
- **GIVEN** подтверждённый единичный матч, чистая структурная валидация и
согласованность сигналов
- **WHEN** принимается решение
- **THEN** допускается авто-раскладка (при отсутствии `force_review`)
#### Scenario: Информационная причина авто не отменяет
- **GIVEN** подтверждённый матч и чистая валидация, но покрытие плана неполно
только за счёт файлов-спутников
- **WHEN** принимается решение auto/review
- **THEN** список причин непуст — в нём сводка покрытия
- **AND** авто-раскладка допускается
## ADDED Requirements
### Requirement: Полный список файлов раздачи в промпте
Промпт распознавания SHALL включать все файлы раздачи, а не фиксированную их
часть. Предельное число файлов и предельный размер ответа модели SHALL задаваться
настройками `[recognition].max_files` и `[recognition].max_tokens`; предел числа
файлов служит предохранителем от аномальной раздачи, а не рабочим ограничением, и
его значение по умолчанию SHALL соответствовать размеру промпта, который заведомо
принимает модель. Если список всё же усечён пределом, распознавание SHALL называть
усечение отдельной причиной ухода в `review`. Отказ модели, вызванный размером
запроса, распознавание SHALL называть причиной о размере запроса, а не текстом
провайдера.
#### Scenario: Раздача на сотни файлов показана модели целиком
- **GIVEN** раздача из 180 файлов и `max_files` = 500
- **WHEN** собирается промпт распознавания
- **THEN** в списке файлов промпта присутствуют все 180 файлов
#### Scenario: Усечение названо причиной
- **GIVEN** раздача, число файлов которой превышает `max_files`
- **WHEN** завершается распознавание
- **THEN** задача уходит в `review`, и среди причин названо усечение списка файлов
### Requirement: Причины по нумерации серий и покрытию плана компактны
Причины ухода в `review`, порождённые нумерацией серий, SHALL быть свёрнуты: на
сезон приходится не более одной причины, перечисляющей недостающие серии.
Распознавание SHALL называть покрытие плана — сколько файлов раздачи попало в
план из общего числа, — когда покрыты не все файлы. Блокировать авто-раскладку
SHALL только непокрытый видеофайл: файл, который модель не адресовала и который
по расширению является видео, означает потерянную серию или фильм. Непокрытые
файлы-спутники (субтитры, изображения, тексты, служебные файлы) SHALL
показываться в покрытии, но SHALL NOT входить в структурную валидацию и SHALL NOT
влиять на решение auto/review: модель вправе не перечислять то, что не
раскладывается.
#### Scenario: Пропуски сезона свёрнуты в одну причину
- **GIVEN** план сезона, где недостают серии E05, E07 и E11
- **WHEN** формируются причины ухода в review
- **THEN** этому сезону соответствует одна причина, перечисляющая недостающие серии
#### Scenario: Непокрытый видеофайл блокирует авто
- **GIVEN** раздача, где один видеофайл не адресован ни одним элементом плана
- **WHEN** принимается решение auto/review
- **THEN** задача уходит в `review` с причиной о непокрытом видеофайле
#### Scenario: Непокрытые файлы-спутники авто не блокируют
- **GIVEN** раздача, где не адресованы только `.nfo`, скриншоты и текстовый файл,
а все видеофайлы покрыты, матч подтверждён и валидация чиста
- **WHEN** принимается решение auto/review
- **THEN** авто-раскладка допускается
- **AND** покрытие плана показано человеку, но причиной ухода в review не является
@@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: Раскладка показывает все файлы раздачи
Виджет «файл источника → раскладка» на экранах загрузки и ревью SHALL показывать
все файлы раздачи, а не только попавшие в план распознавания. Файл, которого нет
в плане, SHALL отображаться отдельной строкой с явной пометкой «не в плане» и без
целевого пути.
Порядок строк SHALL быть определён полностью и не зависеть от порядка файлов в
плане: сперва разложенные файлы — по сезону, затем по номеру серии, затем по пути
источника; за ними файлы плана без целевого пути (`extra`, `sample`, `ignore`) —
по пути источника; последними файлы вне плана — по пути источника. Файлы без
сезона и серии SHALL NOT попадать в начало списка.
Если список файлов раздачи для загрузки неизвестен (запись создана до того, как
система стала его сохранять), виджет SHALL строить строки по плану и SHALL прямо
называть, что список файлов раздачи недоступен и полнота не гарантирована.
Молчаливый показ неполного списка как полного SHALL NOT допускаться.
Номер строки, показанный в виджете, SHALL совпадать с номером, которым файл
адресуется в ответе модели, либо не показываться вовсе: расхождение сделало бы
разбор ошибок адресации ложным.
#### Scenario: Файл, пропущенный моделью, виден
- **GIVEN** раздача из 180 файлов, из которых модель разметила 100
- **WHEN** открывается раскладка загрузки
- **THEN** показаны все 180 файлов
- **AND** 80 неразмеченных помечены как «не в плане» и не имеют целевого пути
#### Scenario: Порядок строк — по сезону и серии
- **GIVEN** план мультисезонного пака, где модель вернула файлы в произвольном порядке
- **WHEN** отображается раскладка
- **THEN** строки идут по возрастанию сезона, внутри сезона — по номеру серии
- **AND** семплы, допматериалы и игнорируемые файлы стоят после разложенных серий,
а не перед первым сезоном
#### Scenario: Список файлов раздачи неизвестен
- **GIVEN** загрузка, распознанная до появления сохранённого списка файлов
- **WHEN** открывается её раскладка
- **THEN** строки построены по плану
- **AND** виджет называет, что список файлов раздачи недоступен и полнота не гарантирована
@@ -0,0 +1,66 @@
## 1. Признак обрыва генерации из LLM
- [x] 1.1 Довести `finish_reason` до вызывающего: добавить поле в `llm.Response`, заполнить его в `internal/llm/openai.go` (сейчас разбирается и выбрасывается)
- [x] 1.2 Тест `internal/llm`: ответ с `finish_reason: "length"` доносит признак обрыва до вызывающего
## 2. Компактный формат ответа модели
- [x] 2.1 Добавить `Index` в `recognize.PlanFile` (`i` в JSON), `Src` оставить для запасного формата
- [x] 2.2 Переписать схему в `schemaText` (`prompt.go`) под адресацию номером: `i` вместо `src`, явно оговорить, что для ролей `sample`/`ignore`/`extra` номера сезона и серии не нужны, а `notes` — не более пары предложений
- [x] 2.3 Поправить шапку списка файлов в `writeFileList`: номер строки — это то, что нужно вернуть в `i`
- [x] 2.4 Резолв номера в `validate.go`: при `i` в диапазоне `1..len(in.Files)` заполнять `Src` из своего списка
- [x] 2.5 Сохранить приём старого формата: пришёл `src` без `i` — проверять совпадение с реальным файлом торрента, как сегодня
- [x] 2.6 Элемент с обоими полями резолвить по `i`; несовпадение присланного `src` с резолвом — причина ревью, а не тихое затирание
- [x] 2.7 Отбраковка негодных элементов вместо отказа от плана: номер вне диапазона, повторная адресация файла (побеждает первый элемент), элемент без номера и пути — элемент отбрасывается, план живёт, каждая отбраковка названа причиной; план без единого годного файла считается неразобранным
- [x] 2.8 Убедиться, что отбраковка элементов не порождает correction-ретрая (это не ошибка разбора)
- [x] 2.9 Тесты `internal/recognize`: резолв номера в путь; номер вне диапазона; дубль адресации; элемент без `i` и `src`; запасной формат с `src`; `i` и `src` вместе — совпадающие и расходящиеся; план, где годных элементов не осталось. Отдельно: резолв не паникует ни на одном входе (`i` = 0, отрицательное, `len+1`, дробное или строковое в JSON, пустой `files[]`, `files[]` длиннее списка раздачи)
## 3. Порядок списка файлов — свойство узла
- [x] 3.1 Упорядочивать список файлов по пути внутри `Recognize`, а не у вызывающего: сборки `Input` две — `internal/worker/review.go:126` и `cmd/jellybit/recognize.go:101`
- [x] 3.2 Печать промпта и резолв номеров идут по одному и тому же упорядоченному срезу на всех попытках, включая correction-ретрай
- [x] 3.3 Тест: `Recognize` с перемешанным `Input` даёт ту же нумерацию, что и с упорядоченным (тест зовёт узел, а не воркер)
## 4. Лимиты в конфиг
- [x] 4.1 Добавить `[recognition].max_files` и `[recognition].max_tokens` в `internal/config` с валидацией и дефолтами 500 и 8000
- [x] 4.2 Пробросить их в `recognize.Config` на сборке зависимостей (воркер и CLI)
- [x] 4.3 Описать обе настройки в `config.example.toml` (назначение, диапазон, единицы) и в `docs/database.md` (настройки с числовым значением)
- [x] 4.4 Тест конфига: дефолты применяются, невалидные значения отвергаются
## 5. Цена попытки не растёт с размером раздачи
- [x] 5.1 Убрать повторную печать списка файлов из `correctionMessage` (`prompt.go:180`): номера названы в первом сообщении диалога
- [x] 5.2 Обрыв ответа по длине — review с причиной «ответ модели обрезан», без повторного запроса ни тем же промптом, ни его вариантом
- [x] 5.3 Усечение списка пределом `max_files` — отдельная причина ухода в review; знаменатель покрытия считается по полному числу файлов раздачи, а не по усечённому списку
- [x] 5.4 Отказ модели по размеру запроса называть отдельной причиной, а не текстом провайдера
- [x] 5.5 Тесты: correction-ретрай не содержит списка файлов и сопоставим по размеру с первой попыткой; обрыв не порождает ни одного повторного запроса (тест считает вызовы фейкового клиента); снятие лимита не умножает число попыток сверх `[llm].max_retries`
## 6. Свёртка причин и покрытие плана
- [x] 6.1 Переписать `seriesWarnings` (`validate.go:186`): одна причина на сезон с перечнем недостающих серий вместо строки на каждый разрыв
- [x] 6.2 Завести перечень видеорасширений в одном месте рядом с ролями файлов (в проекте такого места сегодня нет) и покрыть его тестом
- [x] 6.3 Покрытие плана: число «в плане N из M» показывается, когда покрыты не все файлы; блокирующей причиной становится только непокрытый видеофайл, файлы-спутники в решение auto/review не входят
- [x] 6.4 Тесты: сезон с тремя дырами даёт одну причину; непокрытый видеофайл уводит в review; раздача, где не покрыты только `.nfo` и скриншоты, авто-раскладку не теряет
## 7. Список файлов раздачи рядом с планом
- [x] 7.1 Миграция `0012`: колонка со списком файлов раздачи (JSON: путь и размер) в таблице `recognition`
- [x] 7.2 Обновить ER-схему в `docs/database.md` — иначе краснеет шаг `canon` гейта
- [x] 7.3 Заполнять колонку при создании записи распознавания из того же `Input`, из которого построен план (снимок и `src` плана обязаны происходить из одного среза); читать при рендере страниц загрузки и ревью
- [x] 7.4 Записи, созданные до миграции: рендер строит строки по плану и явно называет, что список файлов недоступен и полнота не гарантирована — молчаливой деградации быть не должно
## 8. Раскладка показывает все файлы
- [x] 8.1 Развернуть `buildFileRows` (`internal/httpapi/files.go`) на файлы раздачи: план подмешивается по `src`, файл без записи получает пометку «не в плане»; ровно одна строка на файл раздачи
- [x] 8.2 Полный ключ сортировки: разложенные (сезон, серия, путь) → файлы плана без цели (`extra`/`sample`/`ignore`, по пути) → файлы вне плана (по пути); пустые `season`/`episode` не поднимают файл в начало списка
- [x] 8.3 Отобразить пометку «не в плане» и строку о недоступном списке файлов в шаблонах раскладки в единой дизайн-системе
- [x] 8.4 Колонка `#` в `web/templates/partials/layout_widget.html:8` печатает порядковый номер строки — привести её к номеру адресации из списка распознавания либо убрать: расхождение сделает разбор ошибок адресации ложным
- [x] 8.5 Тесты `internal/httpapi`: файл вне плана присутствует в строках без целевого пути; порядок строк не зависит от порядка `plan.Files` (тест с перемешанным планом); запись без сохранённого списка файлов даёт строку о недоступности
## 9. Проверка на живом кейсе
- [ ] 9.1 `task gate` зелёный
- [ ] 9.2 Прогнать распознавание раздачи «Отчаянные домохозяйки» (180 файлов) на рабочем эндпоинте: план покрывает все файлы, причины ревью читаются с одного взгляда, раскладка упорядочена по сезонам
- [ ] 9.3 Сверить фактический размер ответа модели с `max_tokens`: подтвердить, что запаса хватает, и при необходимости поправить дефолт
- [ ] 9.4 Проверить, что сырой ответ модели и ключ LLM не попадают в лог (`docs/conventions/logging.md`), а отброшенные элементы и файлы вне плана видны числом в причинах