# 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` → перераспознавание с ручным подтверждением). Экран ревью MUST NOT содержать команду переключения типа movie↔series: тип показывается read-only, а его корректировка выполняется мягкой подсказкой через **Уточнить**. Команды из любого транспорта SHALL сериализоваться worker'ом под единой блокировкой; применяется последняя валидная команда. Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать заново**, **Привязать заново**, а также фиксация типа), 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** пользователь открывает экран ревью - **THEN** отдельной команды/кнопки переключения movie↔series на экране нет - **AND** тип показан read-only в инфо-части выбранного источника #### 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** пользователь зафиксировал тип `series` как override - **WHEN** запускается перераспознавание по новой подсказке - **THEN** в новом эффективном плане тип остаётся `series` ### 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 best-effort получить режиссёра этого источника из метабазы (credits по `provider:id`, см. `metadata-match`) и закрепить его как override, чтобы он попал в эффективные поля и в ярлык. Недоступность credits или отсутствие режиссёра SHALL NOT проваливать выбор источника: режиссёр остаётся из более низкого слоя (сохранённый контекст) или пустым. Так режиссёр из метабазы появляется и на **основном** пути подтверждения — ручном выборе кандидата, а не только при авто-матче. Обновление SHALL выполняться после успешного закрепления выбора кандидата и SHALL быть best-effort по отношению к qBittorrent: недоступность клиента SHALL NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие только при подтверждённом матче». #### Scenario: Выбор кандидата обновляет имя - **GIVEN** загрузка в ревью с пустым или неинформативным `display_name` (например, «Unknown») и списком кандидатов - **WHEN** пользователь выбирает кандидата, подтверждая матч - **THEN** выбор кандидата закрепляется как и прежде - **AND** `download.display_name` обновляется полным ярлыком «Название (режиссёр, год)» (для сериала — со сводкой сезонов) - **AND** раздача в qBittorrent переименовывается в то же имя #### Scenario: Ручное добавление источника обновляет имя - **GIVEN** загрузка в ревью без совпадений в списке - **WHEN** пользователь вручную добавляет источник по id/URL, подтверждая матч - **THEN** источник закрепляется как и прежде - **AND** `download.display_name` и имя раздачи в qBittorrent обновляются полным ярлыком подтверждённого источника #### Scenario: Выбор кандидата подтягивает режиссёра в ярлык - **GIVEN** загрузка в ревью, у выбранного кандидата в credits метабазы указан режиссёр - **WHEN** пользователь выбирает кандидата, подтверждая матч - **THEN** режиссёр best-effort извлекается из метабазы и закрепляется override - **AND** `download.display_name` получает полный ярлык с этим режиссёром #### Scenario: Режиссёр кандидата недоступен — выбор не ломается - **GIVEN** выбор кандидата, для которого credits недоступны или режиссёра нет - **WHEN** пользователь подтверждает матч - **THEN** выбор источника выполнен, режиссёр берётся из сохранённого контекста или остаётся пустым - **AND** команда ревью не возвращает ошибку #### Scenario: Недоступность qBittorrent не ломает выбор кандидата - **GIVEN** выбор кандидата в ревью - **WHEN** переименование раздачи в qBittorrent завершается ошибкой - **THEN** выбор кандидата и обновление `download.display_name` выполнены - **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 быть поверхностью точных правок (маппинг файлов, выбор/ввод источника, предпросмотр). Telegram SHALL давать быстрые действия (одобрить, подсказать, переключить тип, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом на ту же страницу; точечные правки, не помещающиеся в чат, SHALL делаться в вебе. #### Scenario: Эскалация из Telegram в веб - **GIVEN** загрузка в `review`, требующая точечного маппинга файлов - **WHEN** пользователь в Telegram выбирает «В вебе» - **THEN** бот даёт deep-link на страницу ревью той же загрузки ### 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 не ломается