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

212 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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.