display_name: слоистое разрешение полей + сохранение режиссёра из контекста

Единый источник полей отображаемого имени и один рендер полного ярлыка на
всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный
«Название (режиссёр, год). Сезон N» но выбрасывал структуру, а перелив по
распознаванию — усечённый «Title (Year)».

- Слоистое разрешение скаляров имени: override → recognition(+match) →
  новый базовый слой «контекст» (download.parsed_context, JSON naming.Fields).
- naming: публичные Fields/Label/Derive, вынесен единый рендер; удалён
  FormatTitleYear. Сводка сезонов вынесена в recognize.SeasonSummary.
- Режиссёр из метабазы (решение A2): TMDB/TVDB credits через опциональный
  metadata.DirectorProvider; авто-матч кладёт в plan.Director, ручной выбор
  кандидата тянет credits и пиннит ovrDirector. Метабаза бьёт контекст.
- refreshDisplayNameLocked строит полный ярлык из эффективных полей;
  инфо-панель ревью показывает загруженного режиссёра.
- Миграция 0011_parsed_context + ER-схема. Всё косметика: на пути/раскладку
  не влияет, приём/вывод имени не валятся (best-effort).

Закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат ярлыка».
OpenSpec: archive/2026-07-11-field-resolution-display-name (ingest,
recognition, metadata-match, review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-11 11:50:08 +03:00
co-authored by Claude Opus 4.8
parent 9472bfdd83
commit 02d4ecc2aa
40 changed files with 1536 additions and 311 deletions
+66 -52
View File
@@ -7,6 +7,11 @@
// режиссёра/сезона), при неудаче — алгоритмический фолбек без сети. Любой
// сбой деградирует к пустой строке: приём загрузки никогда не падает из-за
// вывода имени.
//
// Извлечённая структура ([Fields]) — базовый (наименее доверенный) слой
// источника полей имени: её сохраняют у загрузки (parsed_context) и позже
// переиспользуют при обновлении display_name (поле, которого нет в
// распознавании/матче, — например режиссёр из контекста — не теряется).
package naming
import (
@@ -22,24 +27,34 @@ import (
// maxNameLen — ограничение длины отображаемого имени (символов/рун).
const maxNameLen = 200
// mediaType — вид контента в извлечённой структуре.
type mediaType string
const (
typeMovie mediaType = "movie"
typeSeries mediaType = "series"
typeMovie = "movie"
typeSeries = "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"`
// Fields — скалярные поля имени, извлечённые из одного источника (контекст).
// Это же схема ответа LLM и схема хранения parsed_context. Year/Director/Season
// опциональны (пустые в ярлык не попадают).
type Fields struct {
Type string `json:"type"` // "movie"|"series"
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"`
}
// SeasonLabel — сводка сезона из контекстного скаляра: «Сезон N» для сериала с
// заданным номером, иначе пусто. Для контекста сезон скалярный (в отличие от
// плана распознавания, где он per-file и сводится recognize.SeasonSummary).
// Используется как fallback-слой сводки сезонов, когда план распознавания её не
// даёт.
func (f Fields) SeasonLabel() string {
if f.Type == typeSeries && f.Season != nil && *f.Season > 0 {
return "Сезон " + strconv.Itoa(*f.Season)
}
return ""
}
// Namer выводит отображаемое имя. provider может быть nil — тогда работает
@@ -65,72 +80,71 @@ func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer {
return &Namer{provider: provider, attempts: attempts, log: logger}
}
// DeriveName выводит отображаемое имя из контекста. hint — подсказка из
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает
// "" если имя получить не удалось (тогда вызывающий не задаёт rename).
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
// Derive выводит отображаемое имя и извлечённую структуру. hint — подсказка из
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает имя
// ("" если вывести не удалось вызывающий не задаёт rename) и извлечённую
// структуру с ok=true, если LLM дал валидные поля (тогда их JSON сохраняют как
// parsed_context). Фолбек полей не даёт (ok=false): его выход — только строка.
func (n *Namer) Derive(ctx context.Context, contextText, hint string) (name string, fields Fields, ok bool) {
// Нет ни контекста, ни подсказки — выводить имя не из чего. LLM на пустом
// входе способен лишь галлюцинировать (наблюдалось «Unknown»), поэтому его не
// зовём: имя считается не выведенным, вызывающий добавит загрузку без rename.
if strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint) == "" {
return ""
return "", Fields{}, false
}
if n.provider != nil {
if ex, ok := n.extractViaLLM(ctx, contextText, hint); ok {
if name := render(ex); name != "" {
return name
if ex, extracted := n.extractViaLLM(ctx, contextText, hint); extracted {
if label := render(ex); label != "" {
return label, ex, true
}
}
}
return fallbackName(contextText, hint)
return fallbackName(contextText, hint), Fields{}, false
}
// FormatTitleYear собирает детерминированный ярлык «Title (Year)» из уже
// распознанного названия — без сети и LLM (перелив канонического имени после
// распознавания). Год опционален; имя очищается от управляющих символов и
// обрезается по длине. Пустой title → пустая строка. В отличие от render это
// НЕ формат add-шага (там ещё режиссёр и сезон): у перелива их нет.
func FormatTitleYear(title string, year int) string {
// DeriveName — тонкая обёртка над Derive, когда структура не нужна (возвращает
// только имя).
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
name, _, _ := n.Derive(ctx, contextText, hint)
return name
}
// Label собирает полный детерминированный ярлык display_name из эффективных
// полей: «Название (режиссёр, год)», для сериала — хвост «. <сводка сезонов>».
// Все части, кроме названия, опциональны и выпадают, если пусты. seasonSummary —
// уже готовая строка сводки (для плана — recognize.SeasonSummary; для контекста —
// «Сезон N»). Имя очищается от управляющих символов и обрезается по длине.
// Пустое название → пустая строка. Единый рендер для шага добавления и перелива
// имени по распознаванию, чтобы формат ярлыка совпадал на всех путях.
func Label(title, director string, year int, seasonSummary string) 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 != "" {
if d := sanitize(director); d != "" {
paren = append(paren, d)
}
if ex.Year > 0 {
paren = append(paren, strconv.Itoa(ex.Year))
if year > 0 {
paren = append(paren, strconv.Itoa(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)
if s := sanitize(seasonSummary); s != "" {
name += ". " + s
}
return truncate(name, maxNameLen)
}
// render собирает ярлык из извлечённой структуры (контекст) через общий Label.
func render(f Fields) string {
return Label(f.Title, f.Director, f.Year, f.SeasonLabel())
}
// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы.
func sanitize(s string) string {
s = strings.Map(func(r rune) rune {
+22 -12
View File
@@ -77,22 +77,32 @@ func TestDeriveNameHintPresentSkipsGuard(t *testing.T) {
}
}
func TestFormatTitleYear(t *testing.T) {
func TestLabel(t *testing.T) {
cases := []struct {
title string
year int
want string
name string
title string
director string
year int
season string
want string
}{
{"Harold and the Purple Crayon", 2024, "Harold and the Purple Crayon (2024)"},
{"Брат", 0, "Брат"},
{" Fargo\n", 2015, "Fargo (2015)"},
{"", 2024, ""},
{" ", 0, ""},
{"title only", "Брат", "", 0, "", "Брат"},
{"title+year", "Fargo", "", 2015, "", "Fargo (2015)"},
{"title+director+year", "Дюна", "Дени Вильнёв", 2024, "", "Дюна (Дени Вильнёв, 2024)"},
{"director only", "Брат", "Алексей Балабанов", 0, "", "Брат (Алексей Балабанов)"},
{"series one season", "Сёгун", "", 2024, "Сезон 2", "Сёгун (2024). Сезон 2"},
{"series multi season", "Фарго", "", 0, "Сезоны 13", "Фарго. Сезоны 13"},
{"series specials", "Рик и Морти", "", 0, "Спецвыпуски", "Рик и Морти. Спецвыпуски"},
{"empty title", " ", "Кто-то", 2024, "Сезон 1", ""},
{"sanitized", " Fargo\n", "", 2015, "", "Fargo (2015)"},
}
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)
}
t.Run(c.name, func(t *testing.T) {
if got := Label(c.title, c.director, c.year, c.season); got != c.want {
t.Errorf("Label(%q, %q, %d, %q) = %q, want %q",
c.title, c.director, c.year, c.season, got, c.want)
}
})
}
}
+9 -9
View File
@@ -40,10 +40,10 @@ const systemPrompt = `Ты извлекаешь из текста о торре
// Транспортные ошибки провайдера (сеть/429/5xx) гасятся внутри llm.Provider;
// здесь повторяем только переразбор невалидного ответа. ok=false, если за
// все попытки валидного результата не получено.
func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (extracted, bool) {
func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (Fields, bool) {
contextText = strings.TrimSpace(contextText)
if contextText == "" {
return extracted{}, false
return Fields{}, false
}
user := "Контекст раздачи:\n" + contextText
@@ -68,7 +68,7 @@ func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (ex
// Транспортная ошибка/таймаут залогирована клиентом LLM (ext.*);
// здесь — доменное решение «уходим в фолбек, приём не валим».
log.Debug("naming llm failed, using fallback")
return extracted{}, false
return Fields{}, false
}
ex, perr := parseExtracted(resp.Content)
@@ -81,22 +81,22 @@ func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (ex
llm.Message{Role: llm.RoleUser, Content: "Ответ невалиден: " + perr.Error() +
". Верни строго один JSON-объект по схеме, без пояснений."})
}
return extracted{}, false
return Fields{}, false
}
// parseExtracted вытаскивает JSON-объект из ответа и валидирует минимум:
// непустой title. Тип по умолчанию — movie.
func parseExtracted(raw string) (extracted, error) {
func parseExtracted(raw string) (Fields, error) {
jsonStr, err := llm.ExtractJSONObject(raw)
if err != nil {
return extracted{}, err
return Fields{}, err
}
var ex extracted
var ex Fields
if err := json.Unmarshal([]byte(jsonStr), &ex); err != nil {
return extracted{}, fmt.Errorf("naming: unmarshal: %w", err)
return Fields{}, fmt.Errorf("naming: unmarshal: %w", err)
}
if strings.TrimSpace(ex.Title) == "" {
return extracted{}, fmt.Errorf("naming: empty title")
return Fields{}, fmt.Errorf("naming: empty title")
}
if ex.Type != typeMovie && ex.Type != typeSeries {
ex.Type = typeMovie
+11 -11
View File
@@ -10,52 +10,52 @@ func ptr(i int) *int { return &i }
func TestRender(t *testing.T) {
tests := []struct {
name string
in extracted
in Fields
want string
}{
{
name: "movie with director and year",
in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 2024},
in: Fields{Type: typeMovie, Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 2024},
want: "Дюна: Часть вторая (Дени Вильнёв, 2024)",
},
{
name: "movie with year only",
in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Year: 2024},
in: Fields{Type: typeMovie, Title: "Дюна: Часть вторая", Year: 2024},
want: "Дюна: Часть вторая (2024)",
},
{
name: "movie with director only",
in: extracted{Type: typeMovie, Title: "Брат", Director: "Алексей Балабанов"},
in: Fields{Type: typeMovie, Title: "Брат", Director: "Алексей Балабанов"},
want: "Брат (Алексей Балабанов)",
},
{
name: "movie without director and year",
in: extracted{Type: typeMovie, Title: "Брат"},
in: Fields{Type: typeMovie, Title: "Брат"},
want: "Брат",
},
{
name: "series with season",
in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024, Season: ptr(2)},
in: Fields{Type: typeSeries, Title: "Сёгун", Year: 2024, Season: ptr(2)},
want: "Сёгун (2024). Сезон 2",
},
{
name: "series without season",
in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024},
in: Fields{Type: typeSeries, Title: "Сёгун", Year: 2024},
want: "Сёгун (2024)",
},
{
name: "series season zero is omitted",
in: extracted{Type: typeSeries, Title: "Сёгун", Season: ptr(0)},
in: Fields{Type: typeSeries, Title: "Сёгун", Season: ptr(0)},
want: "Сёгун",
},
{
name: "empty title yields empty name",
in: extracted{Type: typeMovie, Title: " ", Year: 2024},
in: Fields{Type: typeMovie, Title: " ", Year: 2024},
want: "",
},
{
name: "control chars and newlines are sanitized",
in: extracted{Type: typeMovie, Title: "Дюна\n\tЧасть\x00 вторая", Year: 2024},
in: Fields{Type: typeMovie, Title: "Дюна\n\tЧасть\x00 вторая", Year: 2024},
want: "Дюна Часть вторая (2024)",
},
}
@@ -70,7 +70,7 @@ func TestRender(t *testing.T) {
func TestRenderTruncates(t *testing.T) {
long := strings.Repeat("я", maxNameLen+50)
got := render(extracted{Type: typeMovie, Title: long})
got := render(Fields{Type: typeMovie, Title: long})
if n := len([]rune(got)); n > maxNameLen {
t.Errorf("длина имени = %d рун, want <= %d", n, maxNameLen)
}