diff --git a/openspec/changes/large-release-recognition/.openspec.yaml b/openspec/changes/large-release-recognition/.openspec.yaml new file mode 100644 index 0000000..032461f --- /dev/null +++ b/openspec/changes/large-release-recognition/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-02 diff --git a/openspec/changes/large-release-recognition/design.md b/openspec/changes/large-release-recognition/design.md new file mode 100644 index 0000000..206001b --- /dev/null +++ b/openspec/changes/large-release-recognition/design.md @@ -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), а не выводится расчётом. diff --git a/openspec/changes/large-release-recognition/proposal.md b/openspec/changes/large-release-recognition/proposal.md new file mode 100644 index 0000000..bb0a614 --- /dev/null +++ b/openspec/changes/large-release-recognition/proposal.md @@ -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. diff --git a/openspec/changes/large-release-recognition/specs/recognition/spec.md b/openspec/changes/large-release-recognition/specs/recognition/spec.md new file mode 100644 index 0000000..8fff8db --- /dev/null +++ b/openspec/changes/large-release-recognition/specs/recognition/spec.md @@ -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 не является diff --git a/openspec/changes/large-release-recognition/specs/web-ui/spec.md b/openspec/changes/large-release-recognition/specs/web-ui/spec.md new file mode 100644 index 0000000..c585c0c --- /dev/null +++ b/openspec/changes/large-release-recognition/specs/web-ui/spec.md @@ -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** виджет называет, что список файлов раздачи недоступен и полнота не гарантирована diff --git a/openspec/changes/large-release-recognition/tasks.md b/openspec/changes/large-release-recognition/tasks.md new file mode 100644 index 0000000..0e88aae --- /dev/null +++ b/openspec/changes/large-release-recognition/tasks.md @@ -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`), а отброшенные элементы и файлы вне плана видны числом в причинах