# review Specification ## Purpose Ревью раскладки человеком после распознавания и матча: петля «догадка → подсказка → перераспознавание», команды (Применить/Уточнить/Распознать заново/ Игнор/Позже/Отклонить/Undo/Привязать заново; тип — read-only, корректируется через «Уточнить»), мягкие подсказки vs жёсткие `override`, единый список источников совпадения с ручным добавлением и предпросмотром (превью = применение), разделение труда транспортов (веб — точные правки, Telegram — быстрые действия и эскалация в веб). ## Requirements ### Requirement: Вход в review с явной причиной Когда модель уверенности не разрешает авто-раскладку, система SHALL переводить загрузку в `review` и SHALL показывать **конкретную причину** (низкая самооценка LLM; нет матча в базе или несколько кандидатов; предупреждение структурной валидации; неразобранный ответ LLM), а не обобщённое «не уверен». Поверхность решения SHALL быть единой для всех транспортов и содержать источник (имя, контекст, дерево файлов), догадку системы (тип, название, год, матч) и превью целевой раскладки. #### Scenario: Причина видна в интерфейсе - **GIVEN** загрузка ушла в `review` из-за отсутствия матча в базе - **WHEN** пользователь открывает ревью - **THEN** показана конкретная причина (напр. «нет в TMDB · уверенность 0.46») ### Requirement: Команды ревью и их эффекты Экран ревью SHALL предоставлять команды: **Применить** (создать хардлинки по эффективному плану), **Уточнить** (добавить подсказку → перераспознать), **Распознать заново** (повторный прогон без новой подсказки), **Игнор файла**, **Позже** (`deferred`), **Отклонить** (`cancelled`), **Undo** (снять созданные ссылки → `reverted`) и **Привязать заново** (из `reverted`/`cancelled`/`target_missing` → перераспознавание с ручным подтверждением). Никакая поверхность ревью — ни веб-UI, ни Telegram — MUST NOT содержать команду переключения типа movie↔series: тип показывается read-only, а его корректировка выполняется мягкой подсказкой через **Уточнить** (перераспознавание, где пользователь явно указывает тип). Команды из любого транспорта SHALL сериализоваться worker'ом под единой блокировкой; применяется последняя валидная команда. Команда **Позже** (`Defer`) SHALL парковать задачу в `deferred` из любого не-терминального состояния, у которого уже есть раздача в qBittorrent, и SHALL отклонять её из **пре-источникового** состояния `catched` (торрент ещё НЕ добавлен в qBittorrent) — конфликтом (`ErrConflict`) с понятным пользователю сообщением, НЕ меняя состояние загрузки. Пре-источниковое `catched` — единственное состояние без раздачи среди не-терминальных: откладывать в нём нечего (задача ещё не дошла до ревью), а `catched → deferred` уводил бы задачу в лимбо — `processCatched` листает только `catched` и больше её не подхватит, а последующие команды через отсутствие источника выводят необратимый `deleted`. Терминальные состояния Defer SHALL отклонять как и прежде (`ErrConflict`). Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать заново**, **Привязать заново**), SHALL синхронно (без дебаунса) проверять перед действием, что источник не только присутствует в qBittorrent, но и **готов к раскладке** — раздача в готовом классе состояния (`uploading`/`stalledUP`/`pausedUP`/… с учётом различий имён qBit v4/v5), т.е. файлы докачаны. Если источник ещё качается (любое `downloading`-подобное или переходное `moving`/`checking` состояние), команда SHALL отказывать с конфликтом и причиной «торрент ещё качается», НЕ создавая хардлинки и НЕ меняя состояние загрузки (её нахождение в `review`/`deferred`/… легитимно, приводить к реальности нечего). Отсутствие источника в qBittorrent SHALL по-прежнему приводить состояние к реальности (`orphaned`/`deleted`) и отказывать. Так недокачанная задача не может пройти через перераспознавание в авто-раскладку или ручное применение и захардлинкать неполные файлы, обойдя финальность состояния `completed`. #### Scenario: Применение создаёт раскладку - **GIVEN** загрузка в `review` с эффективным планом - **WHEN** пользователь выбирает «Применить» - **THEN** создаются хардлинки по плану, задача переходит к раскладке #### Scenario: Отклонить и привязать заново - **GIVEN** загрузка в `review` - **WHEN** пользователь «Отклонить», затем «Привязать заново» - **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным подтверждением (авто-раскладка не делается) #### Scenario: Тип не переключается командой ни в одном транспорте - **GIVEN** загрузка в `review` с распознанным типом - **WHEN** пользователь открывает ревью в вебе или в Telegram - **THEN** отдельной команды/кнопки переключения movie↔series нет ни на одной поверхности - **AND** тип показан read-only; для смены типа пользователь уточняет распознавание («Уточнить», явно указав тип) #### Scenario: Позже паркует задачу из ревью - **GIVEN** загрузка в `review` (раздача в qBittorrent уже есть) - **WHEN** пользователь выбирает «Позже» - **THEN** задача переходит в `deferred` и возвращается на поверхность ревью по любому последующему действию #### Scenario: Позже отклоняется для пре-источникового catched - **GIVEN** загрузка в `catched` (торрент ещё не добавлен в qBittorrent) - **WHEN** приходит команда «Позже» (`Defer`, напр. прямым POST на `/ui/downloads/{id}/defer`) - **THEN** команда отклоняется конфликтом с понятным сообщением, что отложить можно только после добавления торрента - **AND** загрузка остаётся в `catched` и штатно доходит до `downloading` через `processCatched` #### Scenario: Недокачанный источник отклоняет перераспознавание - **GIVEN** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё качается (`downloading`, файлы не докачаны) - **WHEN** пользователь выбирает «Распознать заново» (или «Уточнить»/«Привязать заново») - **THEN** команда отклоняется с конфликтом и причиной «торрент ещё качается» - **AND** загрузка остаётся в `deferred`, хардлинки не создаются, авто-раскладка не запускается #### Scenario: Недокачанный источник отклоняет ручное применение - **GIVEN** загрузка в `review`, чья раздача в qBittorrent ещё качается - **WHEN** пользователь выбирает «Применить» - **THEN** команда отклоняется с конфликтом «торрент ещё качается», хардлинки на неполные файлы не создаются, состояние загрузки не меняется ### Requirement: Подсказка мягкая, override жёсткий Подсказка (`hint`) SHALL быть мягким сигналом — её интерпретирует LLM при перераспознавании. Ручная правка поля SHALL быть жёстким **override**: система берёт значение как есть и «пиннит» его; перераспознавание НЕ SHALL затирать уже поправленное поле. Накопленные подсказки и правки SHALL переживать перераспознавание и накладываться на новый план. #### Scenario: Override переживает перераспознавание - **GIVEN** пользователь закрепил источник (кандидат метабазы) как эффективный матч - **WHEN** запускается перераспознавание по новой подсказке - **THEN** в новом эффективном плане закреплённые название/год/провайдер остаются ### Requirement: Единый список источников совпадения на ревью Экран ревью (`/review/{id}`) SHALL показывать совпавшие источники **единым списком**, в котором распознавание нейронкой (без базы) — такая же строка, как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху. Ровно один источник в списке SHALL быть отмечен активным (эффективный матч). Активный источник SHALL выбираться **одним кликом/тапом по строке варианта** (радиокнопка), без отдельной кнопки подтверждения выбора. Выбор источника SHALL сохранять его как эффективный матч (persist) и SHALL выполняться через раундтрип на сервер (форма/htmx), без клиентского пересчёта доменного состояния; при этом инфо-часть и предпросмотр раскладки SHALL немедленно обновляться под выбранный источник (частичный своп блока, без полной перезагрузки страницы). Тем же ответом свопа SHALL синхронно обновляться панель действий — в частности доступность команды **Применить**, зависящая от наличия предпросмотра раскладки, — через out-of-band-фрагмент, чтобы кнопка не рассинхронизировалась с блоком источника (например при пустом предпросмотре из-за коллизии путей). Экран SHALL позволять операции над этим списком: выбрать кандидата базы, переключиться на другого кандидата и снять матч с базы обратно на нейронку («без базы»). Список источников SHALL показываться только при наличии плана распознавания. #### Scenario: Нейронка — строка в общем списке - **GIVEN** загрузка в `review` с распознаванием нейронкой и одним или несколькими кандидатами метабаз - **WHEN** пользователь открывает `GET /review/{id}` - **THEN** источники показаны единым списком, где строка «распознано нейронкой» стоит наравне с кандидатами баз - **AND** активным отмечен ровно один источник (текущий эффективный матч) #### Scenario: Выбор кандидата одним кликом - **GIVEN** на экране ревью выбран один кандидат метабазы - **WHEN** пользователь кликает/тапает строку другого кандидата - **THEN** выбранный кандидат сохраняется активным, прочие — неактивны, без отдельного нажатия кнопки «выбрать» - **AND** инфо-часть и предпросмотр раскладки сразу обновляются под выбранного кандидата без полной перезагрузки страницы - **AND** панель действий обновляется тем же ответом (out-of-band): доступность «Применить» синхронна наличию предпросмотра раскладки #### Scenario: Снятие матча в пользу нейронки - **GIVEN** на экране ревью активен кандидат метабазы с названием «Fargo» - **WHEN** пользователь кликает строку «распознано нейронкой» - **THEN** матч с базой снимается (источник — нейронка, «без базы»), тег папки провайдера не проставляется - **AND** поля источника — из распознавания нейронкой, без унаследованных от прежнего кандидата название/год ### Requirement: Ручное добавление источника по id или URL Когда автопоиск по базам промахнулся, экран ревью SHALL позволять добавить источник вручную — по идентификатору записи метабазы или, где применимо, по её URL. Ввод SHALL разбираться и валидироваться в пару `(provider, provider_id)` на входной границе (`internal/httpapi`); допустимые провайдеры — `tmdb`, `tvdb`, `imdb`. Добавленный источник SHALL появляться в списке как выбираемая строка; при совпадении `provider:id` с уже присутствующим источником новая строка NOT создаётся, а выбирается существующая. Некорректный ввод SHALL отклоняться с сообщением, не меняя текущий активный источник. #### Scenario: Добавление кандидата по URL TMDB - **GIVEN** загрузка в `review`, где нужной записи нет среди автокандидатов - **WHEN** пользователь вводит URL записи TMDB и подтверждает добавление - **THEN** из URL извлекаются провайдер и id, источник добавляется в список выбираемой строкой #### Scenario: Дубль id выбирает существующую строку - **GIVEN** в списке уже есть кандидат с данным `provider:id` - **WHEN** пользователь добавляет вручную тот же `provider:id` - **THEN** новая строка не создаётся, активным становится существующий кандидат #### Scenario: Некорректный ввод отклонён - **WHEN** пользователь вводит нераспознаваемый id/URL - **THEN** экран показывает сообщение об ошибке и не меняет текущий активный источник ### Requirement: Подтверждение матча обновляет отображаемое имя Система SHALL при подтверждении матча в ревью запускать обновление отображаемого имени загрузки по подтверждённому распознаванию (см. capability `ingest`): переливать **полный ярлык** имени — «Название (режиссёр, год)», для сериала со сводкой сезонов — в `download.display_name` и в имя раздачи qBittorrent, без нового вызова LLM. Имя строится из эффективных полей (override → распознавание с вложенным матчем → сохранённый контекст). Подтверждением матча SHALL считаться как выбор кандидата из списка совпадений, так и ручное добавление источника по id/URL (оба закрепляют провайдера и каноническое название). Закрепляемое название источника SHALL проходить санитайзинг человекочитаемых полей (см. `recognition`) и проверку пригодности как компонента пути (см. `metadata-match`) — на **общей** точке сборки набора пинов источника, той же, через которую строится предпросмотр. Отсюда следует свойство, на которое опирается экран ревью: показанное для источника название и путь совпадают с тем, что закрепится и разложится по выбору этого источника. Название, непригодное как имя каталога, пином SHALL NOT становиться — в плане остаётся название распознавания, а факт отказа SHALL быть наблюдаем в журнале. Санитайзинг на закреплении SHALL применяться независимо от того, было ли значение очищено при сохранении кандидата: гарантия чистоты не может держаться на времени записи строки, иначе кандидаты, сохранённые прежними версиями, обходят её. Тот же санитайзинг идемпотентен, поэтому на уже очищенном значении он ничего не меняет. При закреплении выбранного/добавленного источника система SHALL best-effort получить режиссёра этого источника из метабазы (credits по `provider:id`, см. `metadata-match`) и закрепить его как override, чтобы он попал в эффективные поля и в ярлык. Недоступность credits или отсутствие режиссёра SHALL NOT проваливать выбор источника: режиссёр остаётся из более низкого слоя (сохранённый контекст) или пустым. Так режиссёр из метабазы появляется и на **основном** пути подтверждения — ручном выборе кандидата, а не только при авто-матче. Обновление SHALL выполняться после успешного закрепления выбора кандидата и SHALL быть best-effort по отношению к qBittorrent: недоступность клиента SHALL NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие только при подтверждённом матче». #### Scenario: Выбор кандидата переливает каноническое имя - **GIVEN** загрузка в ревью с кандидатами метабазы - **WHEN** человек выбирает кандидата - **THEN** провайдер, id и каноническое название закрепляются как override - **AND** отображаемое имя загрузки обновляется полным ярлыком #### Scenario: Название источника показано ровно таким, каким закрепится - **GIVEN** кандидат, название которого содержит zero-width символ или кириллический двойник внутри латинского слова - **WHEN** строится список источников для экрана ревью - **THEN** в строке источника и в его предпросмотре стоит очищенное название - **AND** выбор этого источника закрепляет то же самое значение #### Scenario: Кандидат, сохранённый прежней версией, чистится на закреплении - **GIVEN** кандидат, чьё название записано в хранилище без санитайзинга - **WHEN** человек выбирает этого кандидата - **THEN** закрепляется санитизированное значение, а не то, что лежит в хранилище #### Scenario: Непригодное название источника пином не становится - **GIVEN** кандидат, название которого не содержит ни одной буквы и ни одной цифры - **WHEN** человек выбирает этого кандидата - **THEN** название пином не становится, в плане остаётся название распознавания - **AND** провайдер, id и год закрепляются как обычно - **AND** отказ закрепить название виден в журнале ### Requirement: Инфо и предпросмотр выбранного источника В едином блоке выбора источника экран ревью SHALL показывать для **выбранного (активного)** источника две части: **инфо** — тип (read-only, movie/series), название, оригинальное название, год, режиссёра эффективного источника, разрешённого слоями (`override`/подтверждённый матч+кандидат → сохранённый при приёме контекст раздачи, `parsed_context`; когда режиссёр недоступен ни в одном слое — пусто/прочерк, не ломая вёрстку), для сериала — сводку сезонов (один сезон, диапазон/список для многосезонного пака или «Спецвыпуски»); и **предпросмотр раскладки** — целевые пути хардлинков этого источника. Обе части SHALL относиться именно к активному источнику и SHALL обновляться при смене выбора. Отрисовка блока (показ инфо и предпросмотра) MUST NOT создавать хардлинки: раскладка создаётся только явным действием «Применить». Совпадение целевых путей предпросмотра с результатом применения регулируется требованием «Превью раскладки через единую логику именования» (`web-ui`). #### Scenario: Инфо и предпросмотр относятся к активному источнику - **GIVEN** в списке активен кандидат метабазы - **WHEN** пользователь смотрит инфо-часть и предпросмотр раскладки - **THEN** показаны тип, название, ориг. название, год (и сводка сезонов для сериала) именно этого источника и предпросмотр его целевых путей #### Scenario: Просмотр блока не создаёт раскладку - **GIVEN** экран ревью с показанным блоком выбора источника - **WHEN** пользователь только просматривает инфо и предпросмотр, не нажимая «Применить» - **THEN** хардлинки не создаются, файлы под `paths.movies`/`series` не меняются #### Scenario: Режиссёр показан, когда доступен - **GIVEN** активный источник — подтверждённый матч, несущий режиссёра - **WHEN** отображается инфо-часть выбранного источника - **THEN** в ней показан режиссёр этого источника - **AND** при отсутствии режиссёра во всех слоях место остаётся пустым (или прочерком), не ломая вёрстку #### Scenario: Режиссёр берётся из контекста, когда матч его не даёт - **GIVEN** активный источник без режиссёра в плане, но с режиссёром в сохранённом контексте (`parsed_context`) - **WHEN** отображается инфо-часть выбранного источника - **THEN** в ней показан режиссёр из контекста (нижний слой разрешения) ### Requirement: Разделение труда транспортов в ревью Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL быть поверхностью точных правок (маппинг файлов, ручной ввод/выбор источника по id или URL, «без базы», предпросмотр). Telegram SHALL давать быстрые действия (одобрить, подсказать, **быстрый выбор источника из готового списка кандидатов метабазы**, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом на ту же страницу; точечные правки, не помещающиеся в чат (ручной ввод id/URL, маппинг файлов), SHALL делаться в вебе. #### Scenario: Эскалация из Telegram в веб - **GIVEN** загрузка в `review`, требующая точечного маппинга файлов - **WHEN** пользователь в Telegram выбирает «В вебе» - **THEN** бот даёт deep-link на страницу ревью той же загрузки #### Scenario: Быстрый выбор кандидата в Telegram, точный ввод — в вебе - **GIVEN** загрузка в `review` с сохранёнными кандидатами метабазы - **WHEN** пользователь выбирает кандидата inline-кнопкой в Telegram - **THEN** кандидат закрепляется как источник (тот же единый выбор источника, что и в вебе), а ручной ввод id/URL и «без базы» остаются точными правками веба ### Requirement: Петлевые действия ревью обновляют экран на месте Петлевые действия распознавания на экране ревью — **Распознать заново** (`rerecognize`) и **Уточнить** (`refine`) — SHALL выполняться htmx-запросом и обновлять тело экрана ревью на месте (partial swap), без полной перезагрузки страницы и без сброса позиции прокрутки. Поскольку эти действия асинхронны (переводят загрузку в `recognizing`, распознавание доделывает воркер), своп SHALL отражать актуальное состояние — состояние `recognizing` с индикацией «идёт распознавание», а не мгновенно готовый план. Накопленные подсказки и ручные override MUST переживать перераспознавание. Это согласуется с уже действующим частичным свопом при смене выбранного источника (см. «Единый список источников совпадения на ревью»). Пока загрузка в `recognizing`, экран ревью SHALL сам обновляться поллингом htmx-фрагмента (`GET /fragments/downloads/{id}/review`) и по завершении распознавания SHALL автоматически смениться на готовый план (список источников, инфо и предпросмотр активного источника), без ручного обновления страницы. Как только состояние вышло из `recognizing`, фрагмент SHALL возвращаться без поллера, и опрос прекращается. Без htmx экран SHALL деградировать до ручной ссылки «Обновить». Выходы из ревью, после которых загрузка покидает `review` — **Применить** (`apply` → раскладка/`done`), **Позже** (`defer` → `deferred`) и **Отклонить** (`cancel` → `cancelled`), — НЕ обязаны свопить экран на месте и MAY уводить с экрана ревью навигацией (редирект/`HX-Redirect`), поскольку загрузка перестаёт быть предметом этого экрана. Поведение петлевых действий MUST деградировать без htmx: без заголовка `HX-Request` обработчик SHALL исполнять то же доменное действие и отвечать редиректом на `/review/{id}`, как раньше. #### Scenario: Перераспознавание свопит экран в состояние recognizing - **GIVEN** загрузка в `review`, экран ревью открыт - **WHEN** пользователь нажимает «Распознать заново» или «Уточнить» с подсказкой (htmx активен) - **THEN** тело экрана ревью обновляется на месте в состояние `recognizing` с индикацией «идёт распознавание», без полной перезагрузки и без прыжка прокрутки наверх - **AND** накопленные подсказки и ручные override сохраняются #### Scenario: Экран сам обновляется до готового плана - **GIVEN** экран ревью показывает состояние `recognizing` после петлевого действия - **WHEN** воркер завершает распознавание и загрузка снова в `review` - **THEN** экран автоматически (поллингом фрагмента) сменяется на готовый план (источники, инфо, предпросмотр), без ручного обновления - **AND** после выхода из `recognizing` фрагмент возвращается без поллера и опрос прекращается #### Scenario: Выход из ревью уводит с экрана - **GIVEN** загрузка в `review` с готовым превью - **WHEN** пользователь нажимает «Применить», «Позже» или «Отклонить» - **THEN** загрузка покидает `review` (соответственно `done`/`deferred`/ `cancelled`), а интерфейс уводит пользователя с экрана ревью навигацией #### Scenario: Деградация петлевого действия без htmx - **WHEN** «Распознать заново» или «Уточнить» приходит POST-запросом без заголовка `HX-Request` - **THEN** обработчик исполняет то же доменное действие и отвечает редиректом на `/review/{id}`, поведение без JavaScript не ломается ### Requirement: Панель действий при пустом предпросмотре называет причину Панель действий SHALL называть причину, когда предпросмотр раскладки пуст, а не печатать общее «Подтверди источник, чтобы получить превью раскладки». Первой SHALL идти причина, посчитанная **на показе** — отказ построения этого предпросмотра: она относится к текущему эффективному плану, тогда как записанная при последнем переходе после смены источника устаревает, а у задачи, пришедшей в `review` без записанной причины, её нет вовсе. Записанная причина SHALL использоваться, когда посчитанной нет. Общий текст SHALL оставаться только там, где нет ни той, ни другой — источник действительно ещё не подтверждён. Построение предпросмотра НЕ SHALL двигать состояние задачи: причина считается на чтении и наружу отдаётся значением, а не записью. Те же две причины в том же порядке SHALL показываться и в карточке Telegram, когда плана в ней нет: обе поверхности ревью объясняют отсутствие команды «Применить» одинаково. Команда, упершаяся в непомещающееся имя, SHALL отвечать конфликтом, а не сбоем сервера. Требование не трогает доступность команды «Применить»: она по-прежнему следует наличию предпросмотра. Речь о том, что человеку говорят, когда предпросмотра нет: пустой предпросмотр наступает и от коллизии путей, и от непомещающегося имени, и от невалидного плана, а текст сегодня во всех случаях один и в трёх из четырёх неверен. #### Scenario: Непомещающееся имя названо в панели действий - **GIVEN** загрузка в `review` с причиной «имя не помещается», источник подтверждён, предпросмотр пуст - **WHEN** человек открывает экран ревью - **THEN** панель действий печатает причину отказа, а не предложение подтвердить источник, и команда «Применить» недоступна #### Scenario: Причина не записана в состоянии — считается на показе - **GIVEN** загрузка пришла в `review` без записанной причины (нет матча), а её название не помещается в имя файла - **WHEN** человек открывает экран ревью - **THEN** панель действий называет длину имени, хотя в состоянии причины нет, и состояние при этом не меняется #### Scenario: После смены источника показывается свежая причина - **GIVEN** загрузка в `review` с записанной причиной «имя не помещается», и человек выбрал другой источник - **WHEN** экран перестраивается - **THEN** показывается причина, посчитанная для нового плана, а не записанная при прошлом переходе #### Scenario: Карточка Telegram называет ту же причину - **GIVEN** загрузка в `review`, плана в карточке нет - **WHEN** карточка отправляется или обновляется - **THEN** в ней есть строка с причиной, по которой план не построился #### Scenario: Источник не подтверждён — текст прежний - **GIVEN** загрузка в `review` без записанной причины, без посчитанной и без предпросмотра - **WHEN** человек открывает экран ревью - **THEN** панель действий печатает общее предложение подтвердить источник