Заархивировал review-source-selection: дельта web-ui влита в спеки (openspec)
Влил 3 ADDED (единый список источников, ручное добавление по id/URL, предпросмотр полей до фиксации) и 2 MODIFIED (превью для каждого источника; матч ссылкой в списке источников) требования в openspec/specs/web-ui. Change перенесён в changes/archive. Убрал реализованный пункт из беклога, перецелил ссылки на review-ux.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
## 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`/`ovrYear` (в `applyOverrides` пустая строка
|
||||
трактуется как «нет 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.
|
||||
Reference in New Issue
Block a user