Фикс повторного распознавания

This commit is contained in:
2026-06-15 07:57:22 +03:00
parent 16a82572e7
commit e297f0fb84
9 changed files with 145 additions and 44 deletions
+8 -5
View File
@@ -54,7 +54,7 @@ ingest → downloading → completed → recognizing ──┬─ авто ─
└─ failed ⇄ retry
done → undo → reverted
reverted → «Привязать заново» → recognizing (ручная перепривязка, всегда через review)
reverted/cancelled → «Привязать заново» → recognizing (ручная перепривязка, всегда через review)
review → «Позже» → deferred → review
любой → «Отклонить» → cancelled
```
@@ -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 блокировкой —
два транспорта не гонятся за одно состояние. Состояние персистентно в
+9 -4
View File
@@ -123,10 +123,15 @@ Telegram = одобрить / подсказать / выбрать кандид
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
после применения → `reverted` (удаляет только ссылки своего батча, под
`media`). Полная карта состояний — в [architecture.md](architecture.md).
- После отката доступна **«Привязать заново»**: перезапускает распознавание
для той же раздачи (`reverted → recognizing`) и снова приводит в review —
раскладка всегда требует ручного подтверждения, авто не делаем. Нужна,
когда распознали неверно: откатил, перепривязал, поправил и применил.
- После отката или отклонения доступна **«Привязать заново»**: перезапускает
распознавание для той же раздачи (`reverted`/`cancelled → recognizing`) и
снова приводит в review — раскладка всегда требует ручного подтверждения,
авто не делаем. Нужна, когда распознали неверно: откатил/отклонил,
перепривязал, поправил и применил.
- В самом ревью, помимо **«Уточнить»** (подсказка + перераспознавание), есть
**«Распознать заново»** — повторный прогон распознавания без новой подсказки
(контекст и прежние подсказки уже учтены). Полезно, когда модель один раз
споткнулась на разовой ошибке.
## Объём по версиям
+3 -2
View File
@@ -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,
}
}
+19
View File
@@ -194,6 +194,7 @@ type fakeReviewer struct {
deferred []int64
undone []int64
relinked []int64
rerecognized []int64
cleared []int64
}
@@ -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)
}
}
+7
View File
@@ -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()
+9 -7
View File
@@ -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("… и ещё ")
+24 -8
View File
@@ -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
}
+47 -3
View File
@@ -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")
}
}
+4
View File
@@ -150,6 +150,10 @@
<p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p>
<button type="submit">🔁 Уточнить</button>
</form>
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:.4rem">
<button type="submit">🔄 Распознать заново</button>
<small>(без новой подсказки — по уже накопленному контексту)</small>
</form>
{{if .Hints}}
<p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p>
{{end}}