Files
jellybit/internal/naming/naming.go
T
avandClaude Opus 4.8 5948f4d219 Веб-UI: режиссёр в блоке «Распознано как» на странице загрузки
На /download/{id} поле «Режиссёр» было захардкожено прочерком, хотя экран
ревью режиссёра уже выводит: слоистое разрешение полей ярлыка было заперто в
неэкспортируемом worker.effectiveDisplayName. Из-за этого билдеры вью видели
только слой распознавания+матч (rd.Plan.Director) без слоя контекста — то же
на экране ревью.

Вынес разрешение в экспортируемую naming.EffectiveFields(parsedContext, plan)
LabelFields с методом Label(): выбор слоя по сырым значениям (как прежде),
выбранные скаляры возвращаются очищенными (sanitize идемпотентен, display_name
побайтно тот же). effectiveDisplayName стал тонкой обёрткой; страница загрузки
и экран ревью берут режиссёра из той же функции — согласованно с заголовком.

OpenSpec: web-ui (ADDED «Режиссёр в блоке распознавания страницы загрузки»),
review (MODIFIED «Инфо и предпросмотр выбранного источника» — слоистое
разрешение с фолбэком на контекст). Change заархивирован. Беклог: закрыта
rezhisser-v-kartochke-zagruzki.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 21:21:27 +03:00

233 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package naming выводит человекочитаемое отображаемое имя торрента из
// текстового контекста загрузки. Имя нужно лишь как ярлык в списке
// qBittorrent (вместо безликого dn вроде rutracker-topic-6514485) и не
// влияет на пути на диске или распознавание.
//
// Стратегия: сначала пробуем LLM (структурированный вывод названия/года/
// режиссёра/сезона), при неудаче — алгоритмический фолбек без сети. Любой
// сбой деградирует к пустой строке: приём загрузки никогда не падает из-за
// вывода имени.
//
// Извлечённая структура ([Fields]) — базовый (наименее доверенный) слой
// источника полей имени: её сохраняют у загрузки (parsed_context) и позже
// переиспользуют при обновлении display_name (поле, которого нет в
// распознавании/матче, — например режиссёр из контекста — не теряется).
package naming
import (
"context"
"encoding/json"
"log/slog"
"strconv"
"strings"
"unicode/utf8"
"git.vakhrushev.me/av/jellybit/internal/llm"
"git.vakhrushev.me/av/jellybit/internal/recognize"
)
// maxNameLen — ограничение длины отображаемого имени (символов/рун).
const maxNameLen = 200
const (
typeMovie = "movie"
typeSeries = "series"
)
// 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"`
}
// LabelFields — эффективные скалярные поля отображаемого ярлыка после слоистого
// разрешения (план → контекст). Значения уже очищены (sanitize): управляющие
// символы вырезаны, пробелы схлопнуты — те же, что попадут внутрь Label, поэтому
// присутствие/отсутствие поля определяется одинаково при прямом выводе (шаблон)
// и внутри Label. Season — готовая строка-сводка сезонов (для фильма пусто).
type LabelFields struct {
Title string
Director string
Year int
Season string
}
// Label собирает полный ярлык display_name из эффективных полей. sanitize внутри
// идемпотентен, поэтому на уже очищенных полях результат тот же.
func (f LabelFields) Label() string {
return Label(f.Title, f.Director, f.Year, f.Season)
}
// EffectiveFields разрешает поля отображаемого ярлыка слоями: план (override →
// распознавание+матч, уже свёрнут вызывающим) с фолбэком на извлечённый из
// контекста слой (parsedContext, схема Fields) для полей, которых план не дал
// (например режиссёр из контекста при распознавании без матча). Сводка сезонов —
// recognize.SeasonSummary(plan) с фолбэком на контекстный скаляр. parsedContext
// недоверен: битый JSON → пустой слой (best-effort). Выбор слоя идёт по сырым
// значениям (как прежде), выбранные скаляры возвращаются очищенными (sanitize),
// чтобы прямой вывод поля во вью совпадал с тем, что даёт Label в шапке.
func EffectiveFields(parsedContext string, plan recognize.Plan) LabelFields {
var ctxf Fields
if s := strings.TrimSpace(parsedContext); s != "" {
_ = json.Unmarshal([]byte(s), &ctxf)
}
title := plan.Title
if title == "" {
title = ctxf.Title
}
director := plan.Director
if director == "" {
director = ctxf.Director
}
year := plan.Year
if year == 0 {
year = ctxf.Year
}
season := recognize.SeasonSummary(plan)
if season == "" && plan.Type == recognize.MediaSeries {
season = ctxf.SeasonLabel()
}
return LabelFields{
Title: sanitize(title),
Director: sanitize(director),
Year: year,
Season: sanitize(season),
}
}
// 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 — тогда работает
// только алгоритмический фолбек.
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}
}
// 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 "", Fields{}, false
}
if n.provider != nil {
if ex, extracted := n.extractViaLLM(ctx, contextText, hint); extracted {
if label := render(ex); label != "" {
return label, ex, true
}
}
}
return fallbackName(contextText, hint), Fields{}, false
}
// 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 ""
}
var paren []string
if d := sanitize(director); d != "" {
paren = append(paren, d)
}
if year > 0 {
paren = append(paren, strconv.Itoa(year))
}
name := title
if len(paren) > 0 {
name += " (" + strings.Join(paren, ", ") + ")"
}
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 {
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
}