Завёл change review-source-selection: выбор источника и предпросмотр в ревью (openspec)
Переработка экрана ревью: единый список источников (нейронка наравне с кандидатами баз), выбор/переключение/снятие в пользу нейронки, ручное добавление по id/URL, предпросмотр полей и целевых путей до применения. Дизайн отревьюен: единая деривация «источник → overrides» (preview==apply, чинит залипший override title/year). Ограничились существующими capabilities. Беклог: добавил две идеи — «Пересмотр набора capabilities и рефакторинг спек» и «Сила совпадения кандидата / пересмотр распознавания и матчинга». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -191,6 +191,51 @@ qBittorrent, пул LLM-вызовов и запись в SQLite спроект
|
|||||||
[docs/conventions](conventions/README.md),
|
[docs/conventions](conventions/README.md),
|
||||||
[«Словарь единого языка»](#словарь-единого-языка-ubiquitous-language).
|
[«Словарь единого языка»](#словарь-единого-языка-ubiquitous-language).
|
||||||
|
|
||||||
|
### Пересмотр набора capabilities и рефакторинг спек
|
||||||
|
|
||||||
|
Деление capabilities в OpenSpec сложилось по ходу миграции и смешивает
|
||||||
|
разные действия в одной спеке. Пример: `recognition` держит и разбор через
|
||||||
|
LLM, и **сверку с внешними базами** — а «поиск в базе» и «подтверждение
|
||||||
|
матча официальным id» суть разные действия, значит и разные capability.
|
||||||
|
Нужно пересмотреть набор и границы, чтобы имя capability отвечало одному
|
||||||
|
поведению:
|
||||||
|
|
||||||
|
- `recognition` — только разбор сигналов через LLM (план, тип, название,
|
||||||
|
файлы → серии);
|
||||||
|
- отдельные capability под работу с метабазами: поиск записей во внешних
|
||||||
|
базах и сверку/подтверждение матча (разнести пока смешанное в
|
||||||
|
`recognition`);
|
||||||
|
- `web-ui` — только общее оформление, дизайн-система и общие компоненты
|
||||||
|
страниц;
|
||||||
|
- `review` — весь процесс ревью после распознавания и матча (мигрировать из
|
||||||
|
[review-ux.md](specs/review-ux.md); сейчас поведение ревью не в OpenSpec).
|
||||||
|
|
||||||
|
Работа чисто по спекам (границы, RENAMED/MOVED requirements), код не
|
||||||
|
трогаем. Ценно тем, что снимает путаницу «какой capability трогать» на
|
||||||
|
каждой задаче.
|
||||||
|
|
||||||
|
Связано: `CLAUDE.md` (SDD, миграция capabilities), openspec/specs
|
||||||
|
(`recognition`, `web-ui`), [review-ux.md](specs/review-ux.md),
|
||||||
|
[«Словарь единого языка»](#словарь-единого-языка-ubiquitous-language).
|
||||||
|
|
||||||
|
### Сила совпадения кандидата и пересмотр распознавания/матчинга _(идея)_
|
||||||
|
|
||||||
|
Сейчас у кандидата метабазы нет метрики силы совпадения (`metadata_candidate`
|
||||||
|
хранит provider/id/title/year/url), а решение «авто vs review» — по правилу
|
||||||
|
«единственный сильный матч + валидация», не по числовой уверенности. Для
|
||||||
|
ревью это значит: список кандидатов нечем отсортировать/подсветить по
|
||||||
|
уверенности — берём порядок сбора. Идея — ввести на этапе матча **силу
|
||||||
|
совпадения кандидата** (точное совпадение названия+года vs частичное) для
|
||||||
|
сортировки и подсказки в UI. Шире — отдельно продумать **сам процесс
|
||||||
|
распознавания и матчинга**: границы «разбор LLM / поиск в базе / сверка»,
|
||||||
|
что храним у кандидата, как считаем и показываем уверенность. Требует
|
||||||
|
проработки перед реализацией.
|
||||||
|
|
||||||
|
Связано: [recognition.md](specs/recognition.md) (модель уверенности),
|
||||||
|
[ADR-2026-06-13-auto-link-requires-db-match](adr/ADR-2026-06-13-auto-link-requires-db-match.md),
|
||||||
|
[«Ревью: выбор источника совпадения»](#ревью-выбор-источника-совпадения-и-предпросмотр),
|
||||||
|
[«Пересмотр набора capabilities»](#пересмотр-набора-capabilities-и-рефакторинг-спек).
|
||||||
|
|
||||||
### История переходов загрузки
|
### История переходов загрузки
|
||||||
|
|
||||||
Сохранять полную историю переходов состояний загрузки (что/когда/почему/кто
|
Сохранять полную историю переходов состояний загрузки (что/когда/почему/кто
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-03
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
## 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. Клиент только показывает предпосчитанное, не считает пути
|
||||||
|
|
||||||
|
Превью всех известных источников кладём в страницу (data-блоки/скрытые
|
||||||
|
секции), клиентский vanilla-JS лишь переключает видимость по выбору строки.
|
||||||
|
Никакого доменного пересчёта на клиенте — соблюдаем требование `web-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.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Экран ревью сегодня трактует распознавание нейронкой и совпадения из
|
||||||
|
метабаз как **разные режимы**: догадка LLM показана сверху как «текущий
|
||||||
|
план», а кандидаты TMDB/TVDB — отдельным списком ниже; «без базы» —
|
||||||
|
особое состояние. Из-за этого выбор источника непрозрачен: чтобы понять,
|
||||||
|
куда лягут файлы при другом кандидате, приходится сначала его выбрать
|
||||||
|
(запись пиннится в overrides) и только потом увидеть результат. Отменить и
|
||||||
|
попробовать другой — снова вслепую.
|
||||||
|
|
||||||
|
Принцип должен быть иным: **совпадение есть всегда — мы лишь выбираем
|
||||||
|
источник**. Матч нейронки — такая же строка списка, как кандидаты баз.
|
||||||
|
Выбор любого источника должен показывать, что получится (поля и целевые
|
||||||
|
пути), **до применения**.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Единый список источников совпадения** на странице ревью: строка
|
||||||
|
«распознано нейронкой» (без базы) наравне с кандидатами метабаз
|
||||||
|
(TMDB/TVDB/TVMaze) — один список с выбором одного активного источника,
|
||||||
|
а не «план сверху + кандидаты снизу».
|
||||||
|
- **Выбор / переключение / отмена в пользу нейронки** как операции над
|
||||||
|
этим списком: выбрать кандидата базы, переключиться на другого, снять
|
||||||
|
матч с базой обратно на нейронку — единообразно.
|
||||||
|
- **Ручное добавление кандидата** по id или URL записи базы, когда
|
||||||
|
автопоиск промахнулся: добавляется в тот же список как выбираемая строка.
|
||||||
|
- **Предпросмотр до применения**: при наведении/выборе источника показываем
|
||||||
|
**поля** (тип, название, год) и **предпросмотр целевых путей раскладки**,
|
||||||
|
которые получатся при этом источнике, **не пиннит** выбор до явного
|
||||||
|
подтверждения. Место под «режиссёр» в предпросмотре резервируем (источник
|
||||||
|
подключим позже — часто есть в контексте загрузки).
|
||||||
|
- Существующие действия ревью (Применить/Отклонить/Позже/Уточнить/тип/
|
||||||
|
игнор/Undo) сохраняются; переработка касается только блока выбора
|
||||||
|
источника и предпросмотра.
|
||||||
|
|
||||||
|
Вне объёма (осознанно, чтобы не раздувать change):
|
||||||
|
|
||||||
|
- **Детали записи из метабазы** (режиссёр и пр.) через новый вызов
|
||||||
|
«детали по id» — только резервируем место в UI, сам fetch не делаем.
|
||||||
|
- **Полноценный редактор маппинга «файл → серия»** — остаётся Ф5.
|
||||||
|
- Изменения в Telegram — не трогаем (веб = точные правки).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Новых capability не вводим. Отдельный capability `review` (весь процесс
|
||||||
|
ревью) и перекройка домена метабаз — предмет отдельной задачи беклога
|
||||||
|
«Пересмотр набора capabilities и рефакторинг спек»; здесь ограничиваемся
|
||||||
|
существующими capability.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `web-ui`: рендеринг страницы ревью получает **единый список выбора
|
||||||
|
источника** (нейронка + кандидаты баз + ручной ввод) и **предпросмотр
|
||||||
|
полей и целевых путей выбранного источника до применения**. Предпросмотр
|
||||||
|
берётся из единой логики `internal/layout`, как и текущее превью
|
||||||
|
раскладки.
|
||||||
|
|
||||||
|
Сбор кандидатов при сверке с базами (`recognition`) переиспользуется как
|
||||||
|
есть — его поведение не меняется.
|
||||||
|
|
||||||
|
Часть логики этого change доменная (ручное добавление источника,
|
||||||
|
самосогласованный набор overrides при выборе — см. design.md 1a), а не чисто
|
||||||
|
презентационная. До выделения отдельного capability `review` (отложено в
|
||||||
|
беклог) она осознанно живёт под `web-ui`; наблюдаемое поведение выражено
|
||||||
|
требованиями `web-ui`, а `docs/specs/review-ux.md` остаётся источником истины
|
||||||
|
по review-домену и обновляется в этом change.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Код:** `internal/httpapi` (обработчик и шаблон страницы ревью,
|
||||||
|
парсер ручного ввода id/URL, клиентский JS переключения предпросмотра без
|
||||||
|
пиннинга), `internal/worker` (метод расчёта эфемерного плана + предпросмотра
|
||||||
|
путей для источника без записи overrides — переиспользует `applyOverrides` +
|
||||||
|
`layout.BuildLinks`; единая деривация «источник → overrides» с очисткой
|
||||||
|
title/year в `ChooseCandidate`/`ClearProvider` — заодно чинит залипший
|
||||||
|
override). Возможна небольшая правка `internal/store` при персистентности
|
||||||
|
вручную добавленного кандидата.
|
||||||
|
- **Данные:** новый пользовательский путь добавления кандидата вручную
|
||||||
|
(provider+id, опц. url); поля title/year у него могут быть пустыми
|
||||||
|
(деталей из базы пока не тянем).
|
||||||
|
- **Инварианты:** предпросмотр только строит пути через `layout` (та же
|
||||||
|
санитизация и проверка границ библиотеки); выбор источника ничего не
|
||||||
|
раскладывает — хардлинки по-прежнему только по «Применить».
|
||||||
|
- **Совместимость:** ломающих изменений API/схемы не предполагается;
|
||||||
|
существующие действия ревью и их семантика сохраняются.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Единый список источников совпадения на ревью
|
||||||
|
|
||||||
|
Экран ревью (`/review/{id}`) SHALL показывать совпавшие источники **единым
|
||||||
|
списком**, в котором распознавание нейронкой (без базы) — такая же строка,
|
||||||
|
как кандидаты метабаз (TMDB/TVDB/TVMaze), а не отдельный режим сверху.
|
||||||
|
Ровно один источник в списке SHALL быть отмечен активным (эффективный
|
||||||
|
матч). Экран SHALL позволять как операции над этим списком: выбрать
|
||||||
|
кандидата базы, переключиться на другого кандидата и снять матч с базой
|
||||||
|
обратно на нейронку («без базы»). Смена активного источника SHALL
|
||||||
|
выполняться через раундтрип на сервер (форма/htmx), без клиентского
|
||||||
|
пересчёта доменного состояния. Список источников SHALL показываться только
|
||||||
|
при наличии плана распознавания.
|
||||||
|
|
||||||
|
#### Scenario: Нейронка — строка в общем списке
|
||||||
|
|
||||||
|
- **GIVEN** загрузка в `review` с распознаванием нейронкой и одним или
|
||||||
|
несколькими кандидатами метабаз
|
||||||
|
- **WHEN** пользователь открывает `GET /review/{id}`
|
||||||
|
- **THEN** источники показаны единым списком, где строка «распознано
|
||||||
|
нейронкой» стоит наравне с кандидатами баз
|
||||||
|
- **AND** активным отмечен ровно один источник (текущий эффективный матч)
|
||||||
|
|
||||||
|
#### Scenario: Переключение между кандидатами
|
||||||
|
|
||||||
|
- **GIVEN** на экране ревью выбран один кандидат метабазы
|
||||||
|
- **WHEN** пользователь выбирает другого кандидата из списка
|
||||||
|
- **THEN** активным становится выбранный кандидат, прочие — неактивны
|
||||||
|
|
||||||
|
#### 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 показывать для рассматриваемого источника (нейронка,
|
||||||
|
кандидат базы или добавленный вручную) **поля** результата — тип, название,
|
||||||
|
год, с зарезервированным местом под режиссёра. Показ полей источника
|
||||||
|
MUST NOT менять сохранённый матч загрузки и MUST NOT создавать хардлинки:
|
||||||
|
сохранённый матч меняется только явным выбором источника, а раскладка —
|
||||||
|
только действием «Применить». Совпадение целевых путей предпросмотра с
|
||||||
|
результатом применения регулируется требованием «Превью раскладки через
|
||||||
|
единую логику именования».
|
||||||
|
|
||||||
|
#### Scenario: Предпросмотр полей без фиксации выбора
|
||||||
|
|
||||||
|
- **GIVEN** список источников на экране ревью
|
||||||
|
- **WHEN** пользователь рассматривает источник, ещё не выбрав его активным
|
||||||
|
- **THEN** показаны поля результата (тип, название, год) для этого источника
|
||||||
|
- **AND** сохранённый матч загрузки не меняется, хардлинки не создаются
|
||||||
|
|
||||||
|
#### Scenario: Зарезервированное место под режиссёра
|
||||||
|
|
||||||
|
- **GIVEN** режиссёр из метабазы пока не загружается
|
||||||
|
- **WHEN** отображается предпросмотр полей источника
|
||||||
|
- **THEN** в предпросмотре присутствует место под режиссёра, показанное
|
||||||
|
пустым (или прочерком), не ломая вёрстку
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Превью раскладки через единую логику именования
|
||||||
|
|
||||||
|
Превью целевых путей раскладки в веб-UI SHALL вычисляться той же логикой
|
||||||
|
именования, что и реальная раскладка (`internal/naming`/`internal/layout`), а
|
||||||
|
не дублировать правила в шаблоне. На экране ревью превью SHALL строиться **для
|
||||||
|
каждого источника в списке** (нейронка, кандидат базы, добавленный вручную) —
|
||||||
|
эфемерно на сервере, без записи сохранённого матча. Показанные для источника
|
||||||
|
пути MUST совпадать с теми, что создались бы при выборе этого источника и
|
||||||
|
применении.
|
||||||
|
|
||||||
|
#### Scenario: Превью совпадает с реальной раскладкой
|
||||||
|
|
||||||
|
- **WHEN** на экране ревью отображается превью целевых путей для источника
|
||||||
|
- **THEN** эти пути идентичны тем, что создаст применение при выборе этого
|
||||||
|
источника (те же правила имён, спецвыпусков, мультифайла, запрещённых
|
||||||
|
символов, тега провайдера и коллизий)
|
||||||
|
|
||||||
|
#### Scenario: Переключение источника не тянет чужие поля
|
||||||
|
|
||||||
|
- **GIVEN** активен кандидат с запиненными название/год, затем выбран
|
||||||
|
источник без собственных названия/года (нейронка или ручной кандидат)
|
||||||
|
- **WHEN** строится превью и затем выполняется применение выбранного источника
|
||||||
|
- **THEN** и превью, и применение используют название/год этого источника
|
||||||
|
(из плана распознавания), без унаследованных от прежнего кандидата
|
||||||
|
|
||||||
|
### Requirement: Матч с записью метабазы ссылкой
|
||||||
|
|
||||||
|
Веб-UI SHALL показывать подтверждённый матч с записью метабазы (TMDB/TVDB/IMDb)
|
||||||
|
как ссылку на эту запись — на странице просмотра `/download/{id}` (блок
|
||||||
|
распознавания) и в едином списке источников совпадения экрана ревью (у
|
||||||
|
активного источника-кандидата). Ссылка SHALL открываться в новой вкладке с
|
||||||
|
`rel="noopener"`. Рядом со ссылкой SHALL быть видны провайдер, идентификатор
|
||||||
|
записи и (при наличии) год.
|
||||||
|
|
||||||
|
#### Scenario: Матч виден ссылкой на странице просмотра
|
||||||
|
|
||||||
|
- **WHEN** у загрузки подтверждён матч с записью метабазы и известен URL записи
|
||||||
|
- **THEN** в блоке распознавания на `/download/{id}` матч показан ссылкой на
|
||||||
|
запись с провайдером и id
|
||||||
|
|
||||||
|
#### Scenario: URL записи неизвестен
|
||||||
|
|
||||||
|
- **WHEN** матч подтверждён (например, id задан вручную), но канонический URL
|
||||||
|
записи построить нельзя
|
||||||
|
- **THEN** матч показывается текстом (провайдер и id) без ссылки, строка списка
|
||||||
|
не ломается
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
## 1. Ядро: эфемерный предпросмотр источника (worker)
|
||||||
|
|
||||||
|
- [ ] 1.1 Ввести чистую деривацию «источник → полный самосогласованный набор
|
||||||
|
overrides» (кандидат с title/year → пиннит; без — пустая строка в
|
||||||
|
title/year; нейронка → provider=none + пустые title/year) и
|
||||||
|
переиспользовать её в превью и в коммите — гарантия preview == apply
|
||||||
|
(решение 1a design.md)
|
||||||
|
- [ ] 1.2 Привести команды выбора к этой деривации: `ChooseCandidate` у
|
||||||
|
безтайтлового кандидата и `ClearProvider` очищают `ovrTitle`/`ovrYear`
|
||||||
|
(пустой строкой) — чинит латентный залипший override
|
||||||
|
- [ ] 1.3 Выделить чистый расчёт «план + набор overrides источника → поля +
|
||||||
|
`[]layout.Link`» из логики `ReviewData` (переиспользуя `applyOverrides` +
|
||||||
|
`toLayoutPlan` + `layouter.BuildLinks`), без записи overrides в БД
|
||||||
|
- [ ] 1.4 Расширить `ReviewData` срезом источников: нейронка (синтетическая
|
||||||
|
строка `provider=none`) + каждый кандидат; для каждого — эффективные
|
||||||
|
поля (тип/название/год), превью путей и признак «активен»; дедуп
|
||||||
|
источников по `provider:id` (нейронка — отдельная строка)
|
||||||
|
- [ ] 1.5 Тесты worker: превью для неактивного источника не пишет overrides
|
||||||
|
и не создаёт ссылок; пути превью совпадают с результатом применения
|
||||||
|
того же источника; **переключение с титульного кандидата на нейронку/
|
||||||
|
безтайтловый источник не тянет чужие название/год** (preview и apply);
|
||||||
|
нейронка-строка = раскладка без тега провайдера
|
||||||
|
|
||||||
|
## 2. Ручное добавление источника (worker + store + httpapi)
|
||||||
|
|
||||||
|
- [ ] 2.1 Парсер ручного ввода в `httpapi`: id или URL записи метабазы →
|
||||||
|
`(provider, provider_id)` (обратный к `providerURL`: themoviedb.org
|
||||||
|
movie/tv, thetvdb, imdb); валидация на входной границе
|
||||||
|
- [ ] 2.2 Метод worker «добавить источник вручную»: сохранить
|
||||||
|
`metadata_candidate` (provider/id, опц. url; title/year пустые) с
|
||||||
|
дедупом по `provider:id`, затем выбрать его (как `ChooseCandidate`)
|
||||||
|
- [ ] 2.3 Обработчик POST добавления ручного источника + маршрут; отклонять
|
||||||
|
некорректный ввод сообщением, не меняя активный источник
|
||||||
|
- [ ] 2.4 Тесты: URL → provider+id; невалидный ввод отклонён; дубль id не
|
||||||
|
плодит строку, а выбирает существующую
|
||||||
|
|
||||||
|
## 3. Страница ревью: единый список и предпросмотр (httpapi + шаблон)
|
||||||
|
|
||||||
|
- [ ] 3.1 Переработать блок «Источник совпадения» в единый список строк:
|
||||||
|
нейронка + кандидаты баз + ручной ввод; ровно один активный
|
||||||
|
- [ ] 3.2 Рендер строк с предпросмотром полей (тип/название/год + пустое
|
||||||
|
место под режиссёра) и целевых путей для каждого источника
|
||||||
|
- [ ] 3.3 Клиентский vanilla-JS: переключение видимости предпосчитанного
|
||||||
|
превью по выбору строки; без доменного пересчёта на клиенте
|
||||||
|
- [ ] 3.4 Действия строк формами-раундтрипами: выбрать кандидата
|
||||||
|
(`ChooseCandidate`), снять в пользу нейронки (`ClearProvider`),
|
||||||
|
добавить вручную; сохранить прежние действия ревью
|
||||||
|
- [ ] 3.5 Убедиться, что матч-ссылка и заголовок корректны для ручного
|
||||||
|
кандидата с пустыми title/year
|
||||||
|
|
||||||
|
## 4. Проверка и приёмка
|
||||||
|
|
||||||
|
- [ ] 4.1 `task test` и `task lint` зелёные
|
||||||
|
- [ ] 4.2 Ручная проверка сценариев спеки: нейронка-строка, переключение
|
||||||
|
кандидатов, снятие в пользу нейронки, ручной ввод по URL, предпросмотр
|
||||||
|
без фиксации
|
||||||
|
- [ ] 4.3 `openspec validate review-source-selection --strict`
|
||||||
|
- [ ] 4.4 Обновить `docs/specs/review-ux.md` (источник истины по review-домену
|
||||||
|
до миграции): единый список источников, ручное добавление, предпросмотр,
|
||||||
|
объём Ф3/Ф5 — обязательно (поведение экрана меняется); убрать пункт
|
||||||
|
беклога после archive
|
||||||
Reference in New Issue
Block a user