review: убрать явный переключатель типа movie↔series из всех транспортов

Кнопка «Тип» была только в Telegram (доменная SetType = жёсткий override
media_type + хинт + перераспознавание), в вебе её нет. Это создавало
расхождение поверхностей и внутреннюю противоречивость спеки review.
Решение: смена типа — редкий случай, для него достаточно «Уточнить»
(перераспознавание с явным указанием типа). Явный переключатель не нужен
ни на одной поверхности.

Снято: Telegram-кнопка и callback type:, worker.SetType, ставший мёртвым
override-плумбинг media_type (ovrMediaType, ветка applyOverrides, хелпер
oppositeType) и стейл-хвосты в тестах httpapi. Спека review — три MODIFIED
требования (запрет на все поверхности, «фиксация типа» убрана из команд и
из быстрых действий Telegram, иллюстрация override заменена на закрепление
источника). Синхронизирован docs/specs/review-ux.md.

Change заархивирован: openspec/changes/archive/2026-07-18-review-remove-type-switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-18 16:50:43 +03:00
co-authored by Claude Opus 4.8
parent 023fdacda5
commit 1d6f8f3449
13 changed files with 252 additions and 123 deletions
@@ -0,0 +1,39 @@
# Убрать явный переключатель типа movie↔series из ревью
## Зачем
В ревью тип контента (movie/series) не имеет явного переключателя в вебе, но
Telegram-бот давал кнопку «📺↔🎬 Тип» (доменная команда `SetType`: жёсткий
override `media_type` + хинт + перераспознавание). Это создавало расхождение
поверхностей и внутреннюю противоречивость спеки `review`: требование «Команды
ревью и их эффекты» запрещает переключатель типа на «экране ревью» (веб), но там
же упоминает «фиксацию типа» как команду, а «Разделение труда транспортов» даёт
боту «переключить тип».
Решение (принято пользователем): смена типа — редкий случай, для него достаточно
повторного распознавания с явным указанием типа через «Уточнить» (мягкая
подсказка). Явный переключатель типа не нужен **ни на одной поверхности**.
## Что меняется
- **Код:** снять кнопку «Тип» и её callback из Telegram-бота, убрать `SetType`
из интерфейса `Reviewer` бота и доменную команду `worker.SetType`, а также
ставший мёртвым override-плумбинг `media_type` (константа `ovrMediaType`,
ветка в `applyOverrides`, хелпер `oppositeType`). Путь коррекции типа —
существующий `Refine` («Уточнить»).
- **Спека `review`:** переформулировать запрет переключателя типа на **все**
поверхности (веб + Telegram); убрать «фиксацию типа» из списка команд,
которым нужен источник, и из списка быстрых действий Telegram; заменить в
сценарии «Override переживает перераспознавание» иллюстрацию с типа на
закрепление источника (тип больше не override).
## Влияние
- Capability: **review** (MODIFIED, 3 требования). Косвенно — тексты/клавиатура
`notifications` (реализация бота), без изменения его требований.
- Обратная совместимость: устаревшая callback-кнопка `type:` в старых
сообщениях перестанет действовать (как любая устаревшая кнопка — «Кнопка
устарела»). Данные не мигрируют: override `media_type` в проде не
использовался в вебе; если где-то записан — просто перестаёт применяться
(перераспознавание вернёт распознанный тип), поведение безопасно.
- Миграций БД нет.
@@ -0,0 +1,137 @@
## MODIFIED Requirements
### 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: Разделение труда транспортов в ревью
Состояние ревью 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 и «без базы» остаются точными правками веба
@@ -0,0 +1,35 @@
# Задачи
## 1. Спека review (дельта)
- [x] MODIFIED «Команды ревью и их эффекты»: запрет переключателя типа — на все
поверхности; убрать «фиксацию типа» из команд, которым нужен источник;
обобщить сценарий «Тип не переключается кнопкой»; убрать «/фиксацию типа»
из сценария недокачанного источника.
- [x] MODIFIED «Разделение труда транспортов в ревью»: убрать «переключить тип»
из быстрых действий Telegram.
- [x] MODIFIED «Подсказка мягкая, override жёсткий»: сценарий «Override
переживает перераспознавание» — иллюстрация закреплением источника вместо
типа.
## 2. Код
- [x] `internal/tgbot/render.go`: убрать кнопку «📺↔🎬 Тип» из `reviewKeyboard`
и неиспользуемый хелпер `oppositeType`.
- [x] `internal/tgbot/bot.go`: убрать ветку callback `type:` и `SetType` из
интерфейса `Reviewer`.
- [x] `internal/worker/review.go`: удалить `SetType`, константу `ovrMediaType`,
ветку `media_type` в `applyOverrides`; поправить комментарии,
упоминающие переключение/фиксацию типа.
## 3. Тесты
- [x] `internal/tgbot/bot_test.go`: убрать `SetType` из `fakeReviewer`, тест
callback `type:`; в `TestParseCallback` заменить action `type` на живой.
- [x] `internal/worker/review_test.go`: удалить `TestSetType` и ветку «set type»
из табличных тестов.
## 4. Проверка
- [x] `task test`, `task lint` — зелёные.
- [x] `openspec validate --strict` — valid.