Files
jellybit/internal/naming/naming.go
T
avandClaude Opus 4.8 0c9421f4c1 Имя: восстановление 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>
2026-07-10 16:51:04 +03:00

162 lines
6.3 KiB
Go

// Package naming выводит человекочитаемое отображаемое имя торрента из
// текстового контекста загрузки. Имя нужно лишь как ярлык в списке
// qBittorrent (вместо безликого dn вроде rutracker-topic-6514485) и не
// влияет на пути на диске или распознавание.
//
// Стратегия: сначала пробуем LLM (структурированный вывод названия/года/
// режиссёра/сезона), при неудаче — алгоритмический фолбек без сети. Любой
// сбой деградирует к пустой строке: приём загрузки никогда не падает из-за
// вывода имени.
package naming
import (
"context"
"log/slog"
"strconv"
"strings"
"unicode/utf8"
"git.vakhrushev.me/av/jellybit/internal/llm"
)
// maxNameLen — ограничение длины отображаемого имени (символов/рун).
const maxNameLen = 200
// mediaType — вид контента в извлечённой структуре.
type mediaType string
const (
typeMovie mediaType = "movie"
typeSeries mediaType = "series"
)
// extracted — структура имени, извлечённая из контекста (схема ответа LLM).
// Year и Director опциональны (пустое значение → в ярлык не попадают).
type extracted struct {
Type mediaType `json:"type"`
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
Year int `json:"year"`
Director string `json:"director"`
Season *int `json:"season"`
IsRussian bool `json:"is_russian"`
}
// Namer выводит отображаемое имя. provider может быть nil — тогда работает
// только алгоритмический фолбек.
type Namer struct {
provider llm.Provider
// attempts — число попыток получить валидный ответ LLM ([llm].max_retries).
// Здесь это ровно столько вызовов модели (в отличие от recognize, где
// max_retries — это число ПЕРЕразборов, т.е. max_retries+1 вызовов).
attempts int
log *slog.Logger
}
// New собирает Namer. provider nil → только фолбек. attempts < 1 → 1.
// logger nil → slog.Default().
func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer {
if attempts < 1 {
attempts = 1
}
if logger == nil {
logger = slog.Default()
}
return &Namer{provider: provider, attempts: attempts, log: logger}
}
// DeriveName выводит отображаемое имя из контекста. hint — подсказка из
// 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 != "" {
return name
}
}
}
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", если сезон задан.
//
// Имя очищается от управляющих символов/переводов строк и обрезается по
// длине. Пустой Title → пустая строка.
func render(ex extracted) string {
title := sanitize(ex.Title)
if title == "" {
return ""
}
var paren []string
if d := sanitize(ex.Director); d != "" {
paren = append(paren, d)
}
if ex.Year > 0 {
paren = append(paren, strconv.Itoa(ex.Year))
}
name := title
if len(paren) > 0 {
name += " (" + strings.Join(paren, ", ") + ")"
}
if ex.Type == typeSeries && ex.Season != nil && *ex.Season > 0 {
name += ". Сезон " + strconv.Itoa(*ex.Season)
}
return truncate(name, maxNameLen)
}
// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы.
func sanitize(s string) string {
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || r == '\r' {
return ' '
}
if r < 0x20 {
return -1
}
return r
}, s)
return strings.Join(strings.Fields(s), " ")
}
// truncate обрезает строку до n рун (без разрыва символа), отбрасывая хвост.
func truncate(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
count := 0
for i := range s {
if count == n {
return strings.TrimRight(s[:i], " ")
}
count++
}
return s
}