From 1d6f8f34497c72f0988b92cb57c0c43e3107a73e Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 18 Jul 2026 16:50:43 +0300 Subject: [PATCH] =?UTF-8?q?review:=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20=D1=8F=D0=B2=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=20=D1=82?= =?UTF-8?q?=D0=B8=D0=BF=D0=B0=20movie=E2=86=94series=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=D1=85=20=D1=82=D1=80=D0=B0=D0=BD=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кнопка «Тип» была только в 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) --- docs/specs/review-ux.md | 7 +- internal/httpapi/httpapi_test.go | 2 +- internal/httpapi/render_test.go | 1 - internal/tgbot/bot.go | 4 - internal/tgbot/bot_test.go | 20 +-- internal/tgbot/render.go | 8 - internal/worker/review.go | 45 +----- internal/worker/review_test.go | 32 +--- internal/worker/worker.go | 2 +- .../proposal.md | 39 +++++ .../specs/review/spec.md | 137 ++++++++++++++++++ .../tasks.md | 35 +++++ openspec/specs/review/spec.md | 43 +++--- 13 files changed, 252 insertions(+), 123 deletions(-) create mode 100644 openspec/changes/archive/2026-07-18-review-remove-type-switch/proposal.md create mode 100644 openspec/changes/archive/2026-07-18-review-remove-type-switch/specs/review/spec.md create mode 100644 openspec/changes/archive/2026-07-18-review-remove-type-switch/tasks.md diff --git a/docs/specs/review-ux.md b/docs/specs/review-ux.md index eaf2c22..7d05608 100644 --- a/docs/specs/review-ux.md +++ b/docs/specs/review-ux.md @@ -121,7 +121,8 @@ Telegram = одобрить / подсказать / выбрать кандид - **База пустая (рус/аниме)** → «без базы» или ручной id/url. Аниме с абсолютной нумерацией → веб-хелпер «absolute → S·E» ([задача «Аниме с абсолютной нумерацией»](../backlog/anime-absolyutnaya-numeraciya.md)). -- **Не тот тип (movie↔series)** → переключатель пересобирает форму плана. +- **Не тот тип (movie↔series)** → «Уточнить» с явным указанием типа + перераспознаёт план (отдельного переключателя типа нет — тип read-only). - **Мусор (sample/extra/дубли дорожек)** → роль «игнор». - **Полный провал** (LLM ничего не вытащил) → веб-«ручной режим»: выбрать тип, ввести название/год, разложить файлы руками; в Telegram — сразу @@ -152,14 +153,14 @@ Telegram = одобрить / подсказать / выбрать кандид ## Объём по версиям - **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать - заново», переключатель типа, **единый список источников совпадения** + заново», **единый список источников совпадения** (нейронка наравне с кандидатами баз; выбор/переключение/снятие в пользу нейронки), **ручное добавление источника по id или URL** (TMDB/IMDb — по URL, TVDB — по числовому id), **предпросмотр полей и целевых путей каждого источника до применения** (место под режиссёра зарезервировано), пометка файла «игнор», «Применить»/«Отклонить»/«Позже», Undo и «Привязать заново». В Telegram — подтверждение с reply-подсказкой - («Уточнить»), переключатель типа, «Позже»/«Отклонить» и эскалация в веб; + («Уточнить»), «Позже»/«Отклонить» и эскалация в веб; пинги о входе в review и готовности. - **Ф5 (на будущее):** полный редактор маппинга «файл → серия» (правка S·E, «нумеровать подряд»), ручной режим при полном провале LLM, diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index e390852..4c5f0dd 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -265,7 +265,7 @@ func TestAPICommandNotReady(t *testing.T) { func TestAPICommandInvalidInput(t *testing.T) { // Промах пользователя (worker.ErrInvalidInput) → 400, не 500. - cmd := &fakeCommander{err: fmt.Errorf("set type: invalid type %q: %w", "foo", worker.ErrInvalidInput)} + cmd := &fakeCommander{err: fmt.Errorf("choose candidate: invalid id %q: %w", "foo", worker.ErrInvalidInput)} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}}) resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil) diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index e64cf15..8fdac32 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -46,7 +46,6 @@ func (s stubReviewer) ReviewData(context.Context, string) (*worker.ReviewData, e } func (stubReviewer) Apply(context.Context, string) error { return nil } func (stubReviewer) Refine(context.Context, string, string) error { return nil } -func (stubReviewer) SetType(context.Context, string, string) error { return nil } func (stubReviewer) IgnoreFile(context.Context, string, string) error { return nil } func (stubReviewer) Defer(context.Context, string) error { return nil } func (stubReviewer) Undo(context.Context, string) error { return nil } diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go index 02e5660..8088a54 100644 --- a/internal/tgbot/bot.go +++ b/internal/tgbot/bot.go @@ -44,7 +44,6 @@ type Reviewer interface { Apply(ctx context.Context, id string) error Refine(ctx context.Context, id string, hint string) error ChooseCandidate(ctx context.Context, id, candidateID string) error - SetType(ctx context.Context, id string, mediaType string) error Defer(ctx context.Context, id string) error Cancel(ctx context.Context, id string) error Retry(ctx context.Context, id string) error @@ -355,9 +354,6 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) { case "dismiss_confirm": err = b.reviewer.Dismiss(ctx, id) note = "Закрываю…" - case "type": - err = b.reviewer.SetType(ctx, id, val) - note = "Меняю тип…" case "refine": b.setPending(chatID, id) b.answer(cq.ID, "Жду подсказку") diff --git a/internal/tgbot/bot_test.go b/internal/tgbot/bot_test.go index 8d5ab63..24d9c0d 100644 --- a/internal/tgbot/bot_test.go +++ b/internal/tgbot/bot_test.go @@ -67,7 +67,6 @@ type fakeReviewer struct { data *worker.ReviewData applied []string refined map[string]string - typed map[string]string deferred []string canceled []string retried []string @@ -97,13 +96,6 @@ func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID string f.chosen[id] = candidateID return nil } -func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error { - if f.typed == nil { - f.typed = map[string]string{} - } - f.typed[id] = t - return nil -} func (f *fakeReviewer) Defer(_ context.Context, id string) error { f.deferred = append(f.deferred, id) return nil @@ -270,14 +262,6 @@ func TestBot_CallbackApply(t *testing.T) { } } -func TestBot_CallbackType(t *testing.T) { - b, _, _, rev := newTestBot(t, []int64{7}) - b.handleCallback(context.Background(), cbFrom(7, "type:"+tid+":movie")) - if rev.typed[tid] != "movie" { - t.Errorf("typed = %v", rev.typed) - } -} - func TestBot_CallbackRefineSetsPending(t *testing.T) { b, api, _, _ := newTestBot(t, []int64{7}) b.handleCallback(context.Background(), cbFrom(7, "refine:"+tid)) @@ -457,8 +441,8 @@ func TestBot_CallbackRetry(t *testing.T) { } func TestParseCallback(t *testing.T) { - a, id, v := parseCallback("type:" + tid + ":series") - if a != "type" || id != tid || v != "series" { + a, id, v := parseCallback("pick:" + tid + ":" + tid) + if a != "pick" || id != tid || v != tid { t.Errorf("got %q %q %q", a, id, v) } a, id, v = parseCallback("apply:" + tid) diff --git a/internal/tgbot/render.go b/internal/tgbot/render.go index 0b9df88..426b73c 100644 --- a/internal/tgbot/render.go +++ b/internal/tgbot/render.go @@ -106,7 +106,6 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark if n := len(dedupCandidates(rd.Candidates)); n > 0 { row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("🗂 База (%d)", n), "sources:"+sid)) } - row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("📺↔🎬 Тип", "type:"+sid+":"+oppositeType(string(rd.Plan.Type)))) row2 := tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("🔁 Уточнить", "refine:"+sid), @@ -382,13 +381,6 @@ func contextOrSource(rd *worker.ReviewData) string { return rd.Download.SourceRef } -func oppositeType(t string) string { - if t == "series" { - return "movie" - } - return "series" -} - func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] diff --git a/internal/worker/review.go b/internal/worker/review.go index 471e9c3..939a9c5 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -23,7 +23,6 @@ import ( // Поля override. const ( - ovrMediaType = "media_type" ovrIgnoredFiles = "ignored_files" ovrProvider = "provider" // выбранная база ("none" = без базы) ovrProviderID = "provider_id" // id в выбранной базе @@ -475,38 +474,6 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) (err error) return nil } -// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой -// — чтобы LLM пересобрал роли файлов под новый тип. -func (w *Worker) SetType(ctx context.Context, id string, mediaType string) (err error) { - defer func() { w.logCmd(ctx, "set_type", id, err) }() - if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) { - return fmt.Errorf("set type: invalid type %q: %w", mediaType, ErrInvalidInput) - } - w.mu.Lock() - defer w.mu.Unlock() - - d, err := w.requireReviewable(ctx, id, "set type") - if err != nil { - return err - } - if err := w.ensureSourceReady(ctx, d, "set type"); err != nil { - return err - } - ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash()) - if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil { - return fmt.Errorf("set type: %w", err) - } - label := "фильм" - if mediaType == string(recognize.MediaSeries) { - label = "сериал" - } - if err := w.store.AddHint(ctx, id, "Тип точно: "+label+"."); err != nil { - return fmt.Errorf("set type: %w", err) - } - w.transition(ctx, *d, store.StateRecognizing, "", "") - return nil -} - // IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review; // превью пересчитается с учётом правки. func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) (err error) { @@ -1148,8 +1115,7 @@ func (w *Worker) sourceOption(ctx context.Context, downloadID string, kind Sourc } // mergeSourceOverrides накладывает пины источника (provider/id/title/year/ -// director) на неисточниковые overrides (media_type, ignored_files, force_review, -// ...). +// director) на неисточниковые overrides (ignored_files, force_review, ...). func mergeSourceOverrides(base, pins map[string]string) map[string]string { m := make(map[string]string, len(base)+len(pins)) for k, v := range base { @@ -1307,13 +1273,10 @@ func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, pr // --- Хелперы преобразования --- -// applyOverrides применяет ручные правки к плану: форсит тип, каноническое -// имя/год (из выбранного кандидата базы) и помечает игнорируемые файлы ролью -// ignore (их раскладка пропустит). +// applyOverrides применяет ручные правки к плану: каноническое имя/год (из +// выбранного кандидата базы) и помечает игнорируемые файлы ролью ignore (их +// раскладка пропустит). func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan { - if mt := overrides[ovrMediaType]; mt == string(recognize.MediaMovie) || mt == string(recognize.MediaSeries) { - plan.Type = recognize.MediaType(mt) - } if t := overrides[ovrTitle]; t != "" { plan.Title = t } diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 5e0b8ea..ba63ede 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -300,7 +300,6 @@ func TestReviewCommands_RejectNotReadySource(t *testing.T) { }{ {"rerecognize", store.StateDeferred, func(w *Worker) error { return w.Rerecognize(context.Background(), "1") }}, {"refine", store.StateReview, func(w *Worker) error { return w.Refine(context.Background(), "1", "подсказка") }}, - {"set type", store.StateReview, func(w *Worker) error { return w.SetType(context.Background(), "1", "series") }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -949,28 +948,6 @@ func TestRefine_AddsHintAndRerecognizes(t *testing.T) { } } -func TestSetType(t *testing.T) { - st := newMemStore() - d := completedDownload("1") - d.State = store.StateReview - st.put(d) - qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, State: "uploading"}}} - w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) - - if err := w.SetType(context.Background(), "1", "series"); err != nil { - t.Fatalf("SetType: %v", err) - } - if st.overrides["1"][ovrMediaType] != "series" { - t.Errorf("override = %v", st.overrides["1"]) - } - if st.downloads["1"].State != store.StateRecognizing { - t.Errorf("state = %q, want recognizing", st.downloads["1"].State) - } - if err := w.SetType(context.Background(), "1", "cartoon"); err == nil { - t.Error("invalid type must be rejected") - } -} - func TestIgnoreFile(t *testing.T) { st := newMemStore() d := completedDownload("1") @@ -1582,18 +1559,19 @@ func TestReviewData(t *testing.T) { func TestApplyOverrides(t *testing.T) { plan := recognize.Plan{ - Type: recognize.MediaMovie, + Type: recognize.MediaMovie, + Title: "Сырое название", Files: []recognize.PlanFile{ {Src: "a.mkv", Role: recognize.RoleMain}, {Src: "b.mkv", Role: recognize.RoleEpisode}, }, } out := applyOverrides(plan, map[string]string{ - ovrMediaType: "series", + ovrTitle: "Каноническое название", ovrIgnoredFiles: `["a.mkv"]`, }) - if out.Type != recognize.MediaSeries { - t.Errorf("type = %q, want series", out.Type) + if out.Title != "Каноническое название" { + t.Errorf("title = %q, want override", out.Title) } if out.Files[0].Role != "ignore" { t.Errorf("a.mkv role = %q, want ignore", out.Files[0].Role) diff --git a/internal/worker/worker.go b/internal/worker/worker.go index b6e5df2..9537407 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -6,7 +6,7 @@ // Ф1 ведёт задачу downloading → completed, плюс stuck/failed по таймаутам и // ошибкам qBittorrent. Ф3 продолжает: completed → recognizing (вызов // recognize) → review; команды ревью (apply/refine/reject/defer/undo, -// переключение типа, пометка «игнор») раскладывают файлы хардлинками через +// пометка «игнор») раскладывают файлы хардлинками через // layout. Распознавание зовётся в поллинг-цикле, команды — из транспортов; // всё под per-download блокировкой w.mu. package worker diff --git a/openspec/changes/archive/2026-07-18-review-remove-type-switch/proposal.md b/openspec/changes/archive/2026-07-18-review-remove-type-switch/proposal.md new file mode 100644 index 0000000..eafea05 --- /dev/null +++ b/openspec/changes/archive/2026-07-18-review-remove-type-switch/proposal.md @@ -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` в проде не + использовался в вебе; если где-то записан — просто перестаёт применяться + (перераспознавание вернёт распознанный тип), поведение безопасно. +- Миграций БД нет. diff --git a/openspec/changes/archive/2026-07-18-review-remove-type-switch/specs/review/spec.md b/openspec/changes/archive/2026-07-18-review-remove-type-switch/specs/review/spec.md new file mode 100644 index 0000000..94a98f1 --- /dev/null +++ b/openspec/changes/archive/2026-07-18-review-remove-type-switch/specs/review/spec.md @@ -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 и «без базы» остаются точными правками веба diff --git a/openspec/changes/archive/2026-07-18-review-remove-type-switch/tasks.md b/openspec/changes/archive/2026-07-18-review-remove-type-switch/tasks.md new file mode 100644 index 0000000..73ff5ed --- /dev/null +++ b/openspec/changes/archive/2026-07-18-review-remove-type-switch/tasks.md @@ -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. diff --git a/openspec/specs/review/spec.md b/openspec/specs/review/spec.md index ed8c8ea..209e45f 100644 --- a/openspec/specs/review/spec.md +++ b/openspec/specs/review/spec.md @@ -33,11 +33,12 @@ LLM; нет матча в базе или несколько кандидато **Позже** (`deferred`), **Отклонить** (`cancelled`), **Undo** (снять созданные ссылки → `reverted`) и **Привязать заново** (из `reverted`/`cancelled`/`target_missing` → перераспознавание с ручным -подтверждением). Экран ревью MUST NOT содержать команду переключения типа -movie↔series: тип показывается read-only, а его корректировка выполняется -мягкой подсказкой через **Уточнить**. Команды из любого транспорта SHALL -сериализоваться worker'ом под единой блокировкой; применяется последняя валидная -команда. +подтверждением). Никакая поверхность ревью — ни веб-UI, ни Telegram — MUST NOT +содержать команду переключения типа movie↔series: тип показывается read-only, а +его корректировка выполняется мягкой подсказкой через **Уточнить** +(перераспознавание, где пользователь явно указывает тип). Команды из любого +транспорта SHALL сериализоваться worker'ом под единой блокировкой; применяется +последняя валидная команда. Команда **Позже** (`Defer`) SHALL парковать задачу в `deferred` из любого не-терминального состояния, у которого уже есть раздача в qBittorrent, и SHALL @@ -51,9 +52,9 @@ movie↔series: тип показывается read-only, а его корре Терминальные состояния Defer SHALL отклонять как и прежде (`ErrConflict`). Команды, которым нужен источник (**Применить**, **Уточнить**, **Распознать -заново**, **Привязать заново**, а также фиксация типа), SHALL синхронно (без -дебаунса) проверять перед действием, что источник не только присутствует в -qBittorrent, но и **готов к раскладке** — раздача в готовом классе состояния +заново**, **Привязать заново**), SHALL синхронно (без дебаунса) проверять перед +действием, что источник не только присутствует в qBittorrent, но и **готов к +раскладке** — раздача в готовом классе состояния (`uploading`/`stalledUP`/`pausedUP`/… с учётом различий имён qBit v4/v5), т.е. файлы докачаны. Если источник ещё качается (любое `downloading`-подобное или переходное `moving`/`checking` состояние), команда SHALL отказывать с @@ -78,12 +79,14 @@ qBittorrent, но и **готов к раскладке** — раздача в - **THEN** задача уходит в `cancelled`, а затем снова на распознавание с ручным подтверждением (авто-раскладка не делается) -#### Scenario: Тип не переключается кнопкой +#### Scenario: Тип не переключается командой ни в одном транспорте - **GIVEN** загрузка в `review` с распознанным типом -- **WHEN** пользователь открывает экран ревью -- **THEN** отдельной команды/кнопки переключения movie↔series на экране нет -- **AND** тип показан read-only в инфо-части выбранного источника +- **WHEN** пользователь открывает ревью в вебе или в Telegram +- **THEN** отдельной команды/кнопки переключения movie↔series нет ни на одной + поверхности +- **AND** тип показан read-only; для смены типа пользователь уточняет + распознавание («Уточнить», явно указав тип) #### Scenario: Позже паркует задачу из ревью @@ -107,7 +110,7 @@ qBittorrent, но и **готов к раскладке** — раздача в - **GIVEN** загрузка припаркована в `deferred`, а её раздача в qBittorrent ещё качается (`downloading`, файлы не докачаны) - **WHEN** пользователь выбирает «Распознать заново» (или «Уточнить»/«Привязать - заново»/фиксацию типа) + заново») - **THEN** команда отклоняется с конфликтом и причиной «торрент ещё качается» - **AND** загрузка остаётся в `deferred`, хардлинки не создаются, авто-раскладка не запускается @@ -129,9 +132,11 @@ qBittorrent, но и **готов к раскладке** — раздача в #### Scenario: Override переживает перераспознавание -- **GIVEN** пользователь зафиксировал тип `series` как override +- **GIVEN** пользователь закрепил источник (кандидат метабазы) как эффективный + матч - **WHEN** запускается перераспознавание по новой подсказке -- **THEN** в новом эффективном плане тип остаётся `series` +- **THEN** в новом эффективном плане закреплённые название/год/провайдер + остаются ### Requirement: Единый список источников совпадения на ревью @@ -331,10 +336,10 @@ SHALL относиться именно к активному источнику Состояние ревью SHALL быть единым (в SQLite) для всех транспортов. Веб-UI SHALL быть поверхностью точных правок (маппинг файлов, ручной ввод/выбор источника по id или URL, «без базы», предпросмотр). Telegram SHALL давать быстрые действия -(одобрить, подсказать, переключить тип, **быстрый выбор источника из готового -списка кандидатов метабазы**, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом -на ту же страницу; точечные правки, не помещающиеся в чат (ручной ввод id/URL, -маппинг файлов), SHALL делаться в вебе. +(одобрить, подсказать, **быстрый выбор источника из готового списка кандидатов +метабазы**, «Позже»/«Отклонить») и эскалацию в веб deep-link'ом на ту же +страницу; точечные правки, не помещающиеся в чат (ручной ввод id/URL, маппинг +файлов), SHALL делаться в вебе. #### Scenario: Эскалация из Telegram в веб