Files
jellybit/openspec/changes/review-source-selection/design.md
T
avandClaude Opus 4.8 3d3448d050 Уточнил детали реализации review-source-selection по итогам разбора (openspec)
Зафиксировал в design/tasks: []SourceOption в ReviewData (нейронка первой),
общая деривация overridesForSource, предпросмотр всех источников на сервере
с раскрытием по клику, секция «Раскладка» остаётся отдельной для активного
источника.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 09:57:13 +03:00

17 KiB
Raw Blame History

Context

Экран ревью уже умеет много: выбор кандидата (ChooseCandidate), ручной ввод id (SetProviderID), «без базы» (ClearProvider), превью текущего плана (ReviewData.Preview через layout.BuildLinks). Механика выбора источника — это overrides: выбор кандидата пиннит provider, provider_id и (если есть) title/year, после чего эффективный план пересобирается applyOverrides и печатается превью путей.

Проблемы текущего экрана — не в отсутствии операций, а в подаче:

  • нейронка и кандидаты баз показаны как разные сущности (план сверху, кандидаты снизу; «без базы» — особое состояние);
  • превью есть только для уже выбранного источника: чтобы увидеть пути для другого кандидата, его надо сначала выбрать (запись пиннится), т.е. «примерить вслепую».

Ограничение capability web-ui: клиент не пересчитывает доменное состояние — все доменные величины (пути, поля) приходят с сервера (требование «Клиентские взаимодействия без сборки»).

Goals / Non-Goals

Goals:

  • Единый список источников: нейронка + кандидаты баз + добавленные вручную, один активный.
  • Предпросмотр полей (тип/название/год) и целевых путей для любого источника до его выбора, посчитанный на сервере.
  • Ручное добавление источника по id/URL как строки списка.
  • Переиспользовать существующую логику (applyOverrides + layout.BuildLinks), не дублируя правила именования.

Non-Goals:

  • Fetch деталей записи из метабазы (режиссёр и пр.) — только резервируем место в UI.
  • Сила совпадения кандидата (per-candidate score) и сортировка/подсветка списка по ней — сегодня у кандидата такого поля нет; список берём в порядке сбора. Отдельная идея беклога (там же — пересмотр процесса распознавания/матчинга).
  • Изменение решения «авто vs review» и модели уверенности — не трогаем: авто по-прежнему только при единственном сильном матче + валидации, иначе review; фича работает внутри уже наступившего review.
  • Отдельный capability review и перекройка домена метабаз — отдельная задача беклога.
  • Редактор маппинга «файл → серия» и правки в Telegram.

Decisions

0. Двухуровневая модель ревью сохраняется, нового подтверждения нет

В ревью два независимых шага, оба остаются как есть:

  1. Выбор источника — кнопки «выбрать» / «Задать id» / «Без базы» (POST → ChooseCandidate/SetProviderID/ClearProvider) фиксируют матч, записывая overrides. Файлы не раскладываются, лишь пересобирается план.
  2. «Применить» (POST → Apply) — единственный шаг, создающий хардлинки.

Фича добавляет только предпросмотр до шага 1: увидеть поля и целевые пути каждого источника, не нажимая «выбрать» (не трогая БД). Семантику «выбрать» и «Применить» не меняем, новых кнопок-подтверждений не вводим.

1. Предпросмотр считается на сервере эфемерно, без записи overrides

Добавляем в worker чистый расчёт: по текущему плану и гипотетическому источнику (provider, provider_id, опц. title/year) собрать эффективные поля и []layout.Link, не записывая overrides в БД. Технически — тот же applyOverrides + layouter.BuildLinks(toLayoutPlan(...)), что и в ReviewData, но поверх копии плана с временными пинами источника; в БД ничего не пишем.

ReviewData расширяется срезом «источник → (поля, превью путей, активен ли)» для нейронки и каждого кандидата. Транспорт рендерит их строками списка.

  • Почему так, а не «выбрать → посмотреть → отменить»: выбор сейчас пиннит запись (пишет overrides) и меняет сохранённый матч; «примерка» не должна трогать состояние (инвариант «предпросмотр ничего не раскладывает и не фиксирует»).
  • Альтернатива — отдельный эндпоинт GET /review/{id}/preview?source=…, отдающий htmx-фрагмент: отвергнута для известных источников — превью считается быстро и без сети (BuildLinks — чистая работа с путями), их дешевле посчитать сразу и вложить в страницу; лишний раундтрип на каждое наведение не нужен.

1a. Единая деривация «источник → набор overrides» (гарантия preview == apply)

Проблема, найденная на ревью дизайна. Пины ovrTitle/ovrYear пишутся SetOverride и не удаляются (в store нет DeleteOverride). ChooseCandidate пиннит title/year только при непустых полях кандидата (review.go:573-579), а ClearProvider их вообще не трогает (review.go:624-640). Значит после выбора кандидата «Fargo/2014» и последующего переключения на нейронку или на ручной кандидат без title запиненный ovrTitle=Fargo остаётся. Тогда applyOverrides (review.go:742) при Apply возьмёт Fargo, а эфемерное превью источника без собственного title покажет title из плана → пути превью ≠ пути применения. Это и латентный баг текущего кода (переключение кандидатов тянет чужой title).

Решение. Ввести одну чистую функцию «источник → полный самосогласованный набор overrides» и использовать её и в превью, и в коммите:

  • источник-кандидат с title/year → пиннит их; без title/year → пишет пустую строку в ovrTitle/ovrYearapplyOverrides пустая строка трактуется как «нет override», review.go:742,745 — DeleteOverride не нужен, берётся значение плана);
  • источник-нейронка (ClearProvider) → provider=none и пустые ovrTitle/ovrYear (поля из плана распознавания).

Так каждый источник даёт детерминированный эффективный план; превью считается тем же набором overrides, что запишет выбор → preview == apply по построению.

  • Затрагивает поведение worker (ChooseCandidate/ClearProvider теперь очищают title/year), а не только web-ui. Это осознанно: спека остаётся в web-ui (наблюдаемое — «превью == применение» и «переключение не тянет чужие поля»), а правка команд ревью — реализация. Заодно чиним латентный баг.
  • Альтернатива — превью «симулирует» унаследованные пины (показывать чужой title у нейронки): отвергнута — противоречит принципу «нейронка = поля распознавания» и путает пользователя.

2. Клиент только показывает предпосчитанное, не считает пути

Превью всех источников предпосчитываем на сервере и вкладываем в страницу (данных немного — кандидатов мало, порога/ленивой загрузки не вводим). Клиент лишь раскрывает/скрывает предпосчитанный блок строки по клику (нативный <details> или минимальный vanilla-JS) — никакого доменного пересчёта на клиенте, соблюдаем требование web-ui.

UI-модель: секция «Раскладка» остаётся отдельной и показывает пути активного источника (как сейчас); раскрываемое инлайн-превью в строке — для сравнения неактивных источников до выбора. Фактическая смена активного источника (пиннинг) — по явному действию формой (раундтрип), как сейчас.

3. Нейронка — синтетическая строка, а не особый режим

Строку «распознано нейронкой» синтезируем из сырого плана распознавания (provider = none): её поля — то, что дал LLM без базы, её превью — раскладка без тега провайдера. Выбор этой строки = существующий ClearProvider. Так «без базы» перестаёт быть отдельным состоянием UI и становится обычной строкой списка.

4. Ручной источник — кандидат в том же списке

Ручной ввод (id или URL) на входной границе httpapi парсим в (provider, provider_id). Допустимые провайдеры — tmdb, tvdb, imdb (набор согласован с providerTag/providerURL; tvmaze — только источник автопоиска, вручную не вводится). Добавляем как строку списка: сохраняем metadata_candidate с этим provider/provider_id и (если дан) url; title/year — пустые (деталей не тянем). Дедуп по provider:id: если такой источник уже в списке — не плодим строку, а выбираем существующую. Такой кандидат участвует в выборе и предпросмотре наравне с автонайденными; выбор — тот же ChooseCandidate (с очисткой title/year по решению 1a).

Парсинг URL реалистичен не для всех баз. providerURL строит TVDB как thetvdb.com/dereferrer/series/{numeric_id}, но с сайта пользователь копирует thetvdb.com/series/{slug} — без числового id. Поэтому обещаем: URL — для TMDB/IMDb, для TVDB — ручной ввод числового id (slug из URL не распознаём). Список принимаемых паттернов фиксируем в реализации как обратный к providerURL.

  • Превью ручного источника корректно и без title/year из базы: имя папки берётся из title плана, а от источника меняется лишь тег провайдера в пути. Значит «предпросмотр путей» для ручного кандидата полноценен.
  • Альтернатива — просто пиннить SetProviderID без строки в списке: отвергнута — тогда ручной источник не «переключаемый» наравне с остальными, что противоречит принципу единого списка.

5. Режиссёр — зарезервированное место, источник позже

В предпросмотре полей выводим строку «Режиссёр» пустой (прочерк). Fetch деталей из метабазы — отдельная задача; при появлении источника (детали по id или парсинг из контекста загрузки) заполняем это же место.

Risks / Trade-offs

  • [Рассинхрон превью и применения] Превью и реальная раскладка должны идти одной логикой. → Оба используют layout.BuildLinks/naming и одну деривацию «источник → overrides» (решение 1a); в тестах проверяем равенство путей превью и применения (требование web-ui «Превью совпадает с реальной раскладкой», сценарий «Переключение источника не тянет чужие поля»).
  • [Залипший override title/year] Пины title/year не удаляются и могут утечь между источниками (латентный баг). → Решение 1a: выбор источника пишет полный самосогласованный набор (пустая строка = сброс к плану).
  • [Раздувание страницы] Предпосчёт превью для всех кандидатов кладёт N наборов путей в HTML. → Кандидатов немного (потолок сбора уже есть в recognition); BuildLinks без сети. Приемлемо; если станет тяжело — ленивый htmx-фрагмент (решение 1, альтернатива) как эволюция.
  • [Ручной кандидат с пустыми title/year] Строки списка и матч-ссылка должны переживать пустые поля. → matchURL уже строит URL из provider/id; заголовок берём из плана. Проверить рендер строки без title/year.
  • [Дубль ручного и автокандидата] Пользователь может ввести id, уже присутствующий в списке. → Дедуп по provider:id при добавлении (как в сборе кандидатов): не плодим строку, просто выбираем существующую.

Migration Plan

  • Данные: миграций схемы не требуется — ручной кандидат ложится в существующую metadata_candidate (title/year nullable уже так). Новый источник строки — пользовательское действие, обратная совместимость полная.
  • Откат: изменения ограничены страницей ревью и добавочным методом предпросмотра в worker; откат — возврат прежнего шаблона/обработчика, данные не затрагиваются.

Open Questions

Блокирующих открытых вопросов нет. Оставшийся детерминированный на реализацию пункт — точный список принимаемых URL-паттернов ручного ввода (обратный к providerURL: themoviedb.org/{movie,tv}/{id}, imdb.com/title/{id}; TVDB — числовой id, не slug), см. решение 4.