Files
jellybit/openspec/specs/review/spec.md
T
av b9f0929d0c layout: непомещающееся целевое имя уводит задачу в review вместо failed
- предел длины компонента (255 байт) проверяется в BuildLinks до первой
  операции с ФС: ни каталога, ни ссылки при отказе не создаётся
- причина пустого предпросмотра считается на показе (ReviewData.PreviewError)
  и печатается в панели действий и в карточке Telegram: у задачи без
  записанной причины взять её больше неоткуда
2026-08-10 12:16:44 +03:00

40 KiB
Raw Blame History

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), Позже (deferdeferred) и Отклонить (cancelcancelled), — НЕ обязаны свопить экран на месте и 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 панель действий печатает общее предложение подтвердить источник