Имя: восстановление display_name после распознавания + гард пустого входа

Голый magnet без dn/контекста заставлял namer звать LLM на пустом входе,
и модель галлюцинировала мусорное имя («Unknown»), которое писалось и в
display_name, и в rename qBittorrent, а заодно ломало UI-фолбэк на
распознанное название. Верное каноническое имя, вычисляемое позже при
распознавании, никуда не переливалось.

- naming: гард пустого входа в DeriveName (нет контекста и подсказки → ""
  без вызова LLM) + детерминированный форматтер FormatTitleYear.
- qbt: операция RenameTorrent (переименование существующей раздачи).
- store: SetDisplayName — обновление имени постфактум без гарда состояния.
- worker: refreshDisplayNameLocked/RefreshDisplayName — перелив канонического
  имени (эффективный план) в display_name + best-effort rename раздачи по
  реальному t.Hash; авто-триггер при подтверждении матча (choose/manual add).
- web-ui: кнопка «Обновить имя» на странице загрузки (htmx-своп заголовка,
  деградация без JS), видимая при наличии распознавания (вкл. done/orphaned).

Спека: дельты ingest/review/web-ui влиты в openspec/specs; change
refresh-display-name заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-10 16:51:04 +03:00
co-authored by Claude Opus 4.8
parent e2ea1840c9
commit 0c9421f4c1
26 changed files with 1179 additions and 21 deletions
+15
View File
@@ -719,6 +719,21 @@ WHERE id = ? AND state = ?`,
return nil
}
// SetDisplayName обновляет отображаемое имя загрузки постфактум — перелив
// канонического имени после распознавания (см. capability ingest). В отличие от
// PromoteCatched не двигает FSM и не завязан на состояние (гарда state нет):
// обновление имени валидно и в review, и в терминальных done/orphaned. Имя —
// косметика (заголовок в UI + ярлык раздачи), пути на диске не затрагивает.
func (s *Store) SetDisplayName(ctx context.Context, id, name string) error {
if _, err := s.DB.ExecContext(ctx, `
UPDATE download
SET display_name = ?, updated_at = ?
WHERE id = ?`, name, FormatTime(Now()), id); err != nil {
return fmt.Errorf("set display name %s: %w", id, err)
}
return nil
}
// setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
+28
View File
@@ -37,6 +37,34 @@ func newCatched() *Download {
}
}
func TestSetDisplayNamePostHoc(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
d := newDownloading()
d.DisplayName = "Unknown"
if existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(1)}, nil); err != nil || existing != nil {
t.Fatalf("create: existing=%v err=%v", existing, err)
}
// Терминальное состояние — SetDisplayName не завязан на state (в отличие от
// PromoteCatched) и должен обновлять имя и на done.
forceState(t, st, d.ID, StateDone)
if err := st.SetDisplayName(ctx, d.ID, "Harold and the Purple Crayon (2024)"); err != nil {
t.Fatalf("SetDisplayName: %v", err)
}
got, err := st.GetDownload(ctx, d.ID)
if err != nil {
t.Fatalf("GetDownload: %v", err)
}
if got.DisplayName != "Harold and the Purple Crayon (2024)" {
t.Errorf("display_name = %q", got.DisplayName)
}
if got.State != StateDone {
t.Errorf("state = %q, SetDisplayName не должен двигать FSM", got.State)
}
}
// catched — нетерминальное активное состояние: его наличие блокирует повторный
// приём того же infohash (инвариант «≤1 активная на infohash»).
func TestCatchedIsActiveForDedup(t *testing.T) {