diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 554aaaf..d30de34 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -53,10 +53,10 @@ ingest → downloading → completed → recognizing ──┬─ авто ─ │ └─ stuck (не качается дольше таймаута) └─ failed ⇄ retry -done → undo → reverted -reverted → «Привязать заново» → recognizing (ручная перепривязка, всегда через review) -review → «Позже» → deferred → review -любой → «Отклонить» → cancelled +done → undo → reverted +reverted/cancelled → «Привязать заново» → recognizing (ручная перепривязка, всегда через review) +review → «Позже» → deferred → review +любой → «Отклонить» → cancelled ``` - **ingest** — приняли источник + контекст, отдали в qBittorrent @@ -75,10 +75,13 @@ review → «Позже» → deferred → review созданные ссылки). - **deferred / cancelled / failed / stuck** — «Позже», «Отклонить», ошибка (ретраибельна), не качается дольше таймаута. -- **reverted → recognizing** — «Привязать заново»: после отката можно - перезапустить распознавание для той же раздачи. Перепривязка всегда идёт - через review с ручным подтверждением (авто-раскладку не делаем), и требует, - чтобы раздача всё ещё была в qBittorrent. +- **reverted / cancelled → recognizing** — «Привязать заново»: после отката + или отклонения можно перезапустить распознавание для той же раздачи. + Перепривязка всегда идёт через review с ручным подтверждением (авто-раскладку + не делаем), и требует, чтобы раздача всё ещё была в qBittorrent. +- **review → recognizing** — кроме «Уточнить» (подсказка + перераспознавание) + есть «Распознать заново»: повторный прогон распознавания без новой подсказки, + по уже накопленному контексту и подсказкам. Все переходы и команды идут через `worker` под per-download блокировкой — два транспорта не гонятся за одно состояние. Состояние персистентно в diff --git a/docs/specs/review-ux.md b/docs/specs/review-ux.md index 8e413c5..ea6088e 100644 --- a/docs/specs/review-ux.md +++ b/docs/specs/review-ux.md @@ -123,10 +123,15 @@ Telegram = одобрить / подсказать / выбрать кандид действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo** после применения → `reverted` (удаляет только ссылки своего батча, под `media`). Полная карта состояний — в [architecture.md](architecture.md). -- После отката доступна **«Привязать заново»**: перезапускает распознавание - для той же раздачи (`reverted → recognizing`) и снова приводит в review — - раскладка всегда требует ручного подтверждения, авто не делаем. Нужна, - когда распознали неверно: откатил, перепривязал, поправил и применил. +- После отката или отклонения доступна **«Привязать заново»**: перезапускает + распознавание для той же раздачи (`reverted`/`cancelled → recognizing`) и + снова приводит в review — раскладка всегда требует ручного подтверждения, + авто не делаем. Нужна, когда распознали неверно: откатил/отклонил, + перепривязал, поправил и применил. +- В самом ревью, помимо **«Уточнить»** (подсказка + перераспознавание), есть + **«Распознать заново»** — повторный прогон распознавания без новой подсказки + (контекст и прежние подсказки уже учтены). Полезно, когда модель один раз + споткнулась на разовой ошибке. ## Объём по версиям diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 9f67cfa..82f825e 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -85,6 +85,7 @@ func NewRouter(d Deps) (http.Handler, error) { r.Get("/review/{id}", s.handleReview) r.Post("/ui/downloads/{id}/apply", s.handleApply) r.Post("/ui/downloads/{id}/refine", s.handleRefine) + r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize) r.Post("/ui/downloads/{id}/type", s.handleSetType) r.Post("/ui/downloads/{id}/ignore", s.handleIgnore) r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate) @@ -127,7 +128,7 @@ type downloadView struct { Terminal bool Reviewable bool // review/deferred — есть экран ревью Undoable bool // done — можно откатить раскладку - Relinkable bool // reverted — можно перепривязать заново + Relinkable bool // reverted/cancelled — можно перепривязать заново } func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { @@ -306,7 +307,7 @@ func toView(d store.Download) downloadView { Terminal: d.State.IsTerminal(), Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Undoable: d.State == store.StateDone, - Relinkable: d.State == store.StateReverted, + Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled, } } diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 028302d..f17f822 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -183,18 +183,19 @@ func (e ingestErr) Error() string { return string(e) } // --- Ревью --- type fakeReviewer struct { - data *worker.ReviewData - applyErr error - refined map[int64]string - typed map[int64]string - ignored map[int64]string - chosen map[int64]int64 - providerSet map[int64]string - applied []int64 - deferred []int64 - undone []int64 - relinked []int64 - cleared []int64 + data *worker.ReviewData + applyErr error + refined map[int64]string + typed map[int64]string + ignored map[int64]string + chosen map[int64]int64 + providerSet map[int64]string + applied []int64 + deferred []int64 + undone []int64 + relinked []int64 + rerecognized []int64 + cleared []int64 } func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) { @@ -240,6 +241,10 @@ func (f *fakeReviewer) Relink(_ context.Context, id int64) error { f.relinked = append(f.relinked, id) return nil } +func (f *fakeReviewer) Rerecognize(_ context.Context, id int64) error { + f.rerecognized = append(f.rerecognized, id) + return nil +} func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error { if f.chosen == nil { f.chosen = map[int64]int64{} @@ -463,3 +468,17 @@ func TestRelink(t *testing.T) { t.Errorf("relinked = %v, want [1]", rv.relinked) } } + +func TestRerecognize(t *testing.T) { + rv := &fakeReviewer{data: seriesReviewData()} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + cl := noRedirectClient() + + if _, err := cl.Post(srv.URL+"/ui/downloads/1/rerecognize", "", nil); err != nil { + t.Fatal(err) + } + if len(rv.rerecognized) != 1 || rv.rerecognized[0] != 1 { + t.Errorf("rerecognized = %v, want [1]", rv.rerecognized) + } +} diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index 32a7781..11ff477 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -21,6 +21,7 @@ type Reviewer interface { Defer(ctx context.Context, id int64) error Undo(ctx context.Context, id int64) error Relink(ctx context.Context, id int64) error + Rerecognize(ctx context.Context, id int64) error ChooseCandidate(ctx context.Context, id, candidateID int64) error SetProviderID(ctx context.Context, id int64, provider, providerID string) error ClearProvider(ctx context.Context, id int64) error @@ -166,6 +167,12 @@ func (s *server) handleRefine(w http.ResponseWriter, r *http.Request) { }) } +func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) { + s.reviewAction(w, r, func(ctx context.Context, id int64) error { + return s.deps.Reviewer.Rerecognize(ctx, id) + }) +} + func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) { s.reviewAction(w, r, func(ctx context.Context, id int64) error { _ = r.ParseForm() diff --git a/internal/recognize/prompt.go b/internal/recognize/prompt.go index 04834ef..54708de 100644 --- a/internal/recognize/prompt.go +++ b/internal/recognize/prompt.go @@ -36,7 +36,7 @@ const schemaText = `Схема ответа (строгий JSON, без markdow "provider_hint": "строка для поиска в базе (НЕ id)", "files": [ { - "src": "путь файла РОВНО как в списке ниже", + "src": "путь файла из списка ниже, БЕЗ размера в скобках в конце строки", "role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore", "season": число или null, "episode": число или null @@ -50,7 +50,8 @@ const schemaText = `Схема ответа (строгий JSON, без markdow - "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore". - Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode. - Для фильма ровно один основной видеофайл role "main". -- Поле src копируй ДОСЛОВНО из списка файлов; не выдумывай и не нормализуй пути. +- Поле src — это путь файла из списка, скопированный дословно, но БЕЗ размера + «(…)» в конце строки; не выдумывай и не нормализуй пути. - Внешние субтитры — role "subtitle".` const systemPrompt = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента, @@ -133,14 +134,15 @@ func writeFileList(b *strings.Builder, files []File, maxFiles int) { } b.WriteString("Файлы (") b.WriteString(strconv.Itoa(n)) - b.WriteString(", поле src — это точные пути отсюда):\n") + b.WriteString("). В src копируй ТОЛЬКО путь — текст после номера и до размера ") + b.WriteString("в скобках; размер «(…)» в конце строки в src НЕ включай:\n") for i := 0; i < shown; i++ { b.WriteString(strconv.Itoa(i + 1)) - b.WriteString(". [") - b.WriteString(humanSize(files[i].Size)) - b.WriteString("] ") + b.WriteString(". ") b.WriteString(files[i].Path) - b.WriteByte('\n') + b.WriteString(" (") + b.WriteString(humanSize(files[i].Size)) + b.WriteString(")\n") } if shown < n { b.WriteString("… и ещё ") diff --git a/internal/worker/review.go b/internal/worker/review.go index 8a1c434..dac315e 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -286,11 +286,11 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize return nil } -// Relink повторно привязывает откатанную задачу (reverted): возвращает её на -// распознавание, и поллинг-цикл перезапустит recognize. Авто-раскладку при -// этом не делаем — ручная перепривязка всегда проходит через ревью с -// подтверждением (force_review). Источник (раздача в qBittorrent) для этого -// должен быть на месте. +// Relink повторно привязывает откатанную (reverted) или отклонённую +// (cancelled) задачу: возвращает её на распознавание, и поллинг-цикл +// перезапустит recognize. Авто-раскладку при этом не делаем — ручная +// перепривязка всегда проходит через ревью с подтверждением (force_review). +// Источник (раздача в qBittorrent) для этого должен быть на месте. func (w *Worker) Relink(ctx context.Context, id int64) error { w.mu.Lock() defer w.mu.Unlock() @@ -299,8 +299,8 @@ func (w *Worker) Relink(ctx context.Context, id int64) error { if err != nil { return fmt.Errorf("relink: %w", err) } - if d.State != store.StateReverted { - return fmt.Errorf("relink: download %d is in state %s (expected reverted)", id, d.State) + if d.State != store.StateReverted && d.State != store.StateCancelled { + return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled)", id, d.State) } if !d.Infohash.Valid { return fmt.Errorf("relink: download %d has no infohash", id) @@ -325,7 +325,23 @@ func (w *Worker) Relink(ctx context.Context, id int64) error { return fmt.Errorf("relink: %w", err) } w.transition(ctx, *d, store.StateRecognizing, "", "") - w.log.Info("relink: re-recognizing reverted download", "download_id", id) + w.log.Info("relink: re-recognizing download", "download_id", id, "from", d.State) + return nil +} + +// Rerecognize перезапускает распознавание для задачи в review/deferred без +// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг- +// цикл проведёт задачу recognizing → review заново. +func (w *Worker) Rerecognize(ctx context.Context, id int64) error { + w.mu.Lock() + defer w.mu.Unlock() + + d, err := w.requireReviewable(ctx, id, "rerecognize") + if err != nil { + return err + } + w.log.Info("review: re-recognizing without hint", "download_id", id) + w.transition(ctx, *d, store.StateRecognizing, "", "") return nil } diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index eada4f4..03028b9 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -120,14 +120,58 @@ func TestRelink_RevertedToRecognizing(t *testing.T) { } } -func TestRelink_RejectsNonReverted(t *testing.T) { +func TestRelink_CancelledToRecognizing(t *testing.T) { st := newMemStore() - st.put(completedDownload(1)) // не reverted + d := revertedDownload(1) + d.State = store.StateCancelled + st.put(d) + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}} + w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil) + + if err := w.Relink(context.Background(), 1); err != nil { + t.Fatalf("Relink: %v", err) + } + if st.downloads[1].State != store.StateRecognizing { + t.Fatalf("state = %q, want recognizing", st.downloads[1].State) + } + if st.overrides[1][ovrForceReview] != "1" { + t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview]) + } +} + +func TestRelink_RejectsActiveState(t *testing.T) { + st := newMemStore() + st.put(completedDownload(1)) // не reverted/cancelled qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) if err := w.Relink(context.Background(), 1); err == nil { - t.Fatal("ожидали ошибку для не-reverted задачи, получили nil") + t.Fatal("ожидали ошибку для не-reverted/cancelled задачи, получили nil") + } +} + +func TestRerecognize_ReviewToRecognizing(t *testing.T) { + st := newMemStore() + d := completedDownload(1) + d.State = store.StateReview + st.put(d) + w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) + + if err := w.Rerecognize(context.Background(), 1); err != nil { + t.Fatalf("Rerecognize: %v", err) + } + if st.downloads[1].State != store.StateRecognizing { + t.Fatalf("state = %q, want recognizing", st.downloads[1].State) + } +} + +func TestRerecognize_RejectsNonReview(t *testing.T) { + st := newMemStore() + st.put(completedDownload(1)) // completed, не review/deferred + w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) + + if err := w.Rerecognize(context.Background(), 1); err == nil { + t.Fatal("ожидали ошибку для не-review задачи, получили nil") } } diff --git a/web/templates/review.html b/web/templates/review.html index 09fff53..93a55a5 100644 --- a/web/templates/review.html +++ b/web/templates/review.html @@ -150,6 +150,10 @@

+
+ + (без новой подсказки — по уже накопленному контексту) +
{{if .Hints}}

Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}

{{end}}