Имя: восстановление 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:
@@ -69,6 +69,12 @@ func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer {
|
||||
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает
|
||||
// "" если имя получить не удалось (тогда вызывающий не задаёт rename).
|
||||
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
|
||||
// Нет ни контекста, ни подсказки — выводить имя не из чего. LLM на пустом
|
||||
// входе способен лишь галлюцинировать (наблюдалось «Unknown»), поэтому его не
|
||||
// зовём: имя считается не выведенным, вызывающий добавит загрузку без rename.
|
||||
if strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint) == "" {
|
||||
return ""
|
||||
}
|
||||
if n.provider != nil {
|
||||
if ex, ok := n.extractViaLLM(ctx, contextText, hint); ok {
|
||||
if name := render(ex); name != "" {
|
||||
@@ -79,6 +85,22 @@ func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string
|
||||
return fallbackName(contextText, hint)
|
||||
}
|
||||
|
||||
// FormatTitleYear собирает детерминированный ярлык «Title (Year)» из уже
|
||||
// распознанного названия — без сети и LLM (перелив канонического имени после
|
||||
// распознавания). Год опционален; имя очищается от управляющих символов и
|
||||
// обрезается по длине. Пустой title → пустая строка. В отличие от render это
|
||||
// НЕ формат add-шага (там ещё режиссёр и сезон): у перелива их нет.
|
||||
func FormatTitleYear(title string, year int) string {
|
||||
title = sanitize(title)
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
if year > 0 {
|
||||
title += " (" + strconv.Itoa(year) + ")"
|
||||
}
|
||||
return truncate(title, maxNameLen)
|
||||
}
|
||||
|
||||
// render собирает из структуры короткий ярлык:
|
||||
// - movie: "Title (Director, Year)" — режиссёр и год опциональны;
|
||||
// - series: то же + ". Сезон N", если сезон задан.
|
||||
|
||||
@@ -49,6 +49,53 @@ func TestDeriveNameViaLLM(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameEmptyInputSkipsLLM(t *testing.T) {
|
||||
// Голый magnet без dn/контекста: выводить имя не из чего — LLM не зовём.
|
||||
fp := &fakeProvider{responses: []string{
|
||||
`{"type":"movie","title":"Unknown"}`, // как раз мусор, который был бы записан
|
||||
}}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), " ", " ")
|
||||
if got != "" {
|
||||
t.Errorf("DeriveName() = %q, want empty", got)
|
||||
}
|
||||
if fp.calls != 0 {
|
||||
t.Errorf("вызовов LLM = %d, want 0 (пустой вход не зовёт модель)", fp.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameHintPresentSkipsGuard(t *testing.T) {
|
||||
// Контекст пуст, но есть подсказка (dn) — гард пустого входа НЕ срабатывает:
|
||||
// имя выводится фолбеком из подсказки. LLM здесь не зовётся (он работает
|
||||
// только по контексту, а его нет) — это прежнее поведение, гард его не меняет.
|
||||
fp := &fakeProvider{responses: []string{`{"type":"movie","title":"x"}`}}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), "", "Dune.Part.Two.2024")
|
||||
if got == "" {
|
||||
t.Error("при наличии подсказки имя не должно быть пустым (гард не должен глотать hint)")
|
||||
}
|
||||
if fp.calls != 0 {
|
||||
t.Errorf("вызовов LLM = %d, want 0 (LLM выводит по контексту, которого нет)", fp.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTitleYear(t *testing.T) {
|
||||
cases := []struct {
|
||||
title string
|
||||
year int
|
||||
want string
|
||||
}{
|
||||
{"Harold and the Purple Crayon", 2024, "Harold and the Purple Crayon (2024)"},
|
||||
{"Брат", 0, "Брат"},
|
||||
{" Fargo\n", 2015, "Fargo (2015)"},
|
||||
{"", 2024, ""},
|
||||
{" ", 0, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := FormatTitleYear(c.title, c.year); got != c.want {
|
||||
t.Errorf("FormatTitleYear(%q, %d) = %q, want %q", c.title, c.year, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameRussianTitle(t *testing.T) {
|
||||
fp := &fakeProvider{responses: []string{
|
||||
`{"type":"movie","title":"Брат","original_title":"Brat","year":1997,"is_russian":true}`,
|
||||
|
||||
Reference in New Issue
Block a user