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
+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