// Package recognize по сигналам торрента определяет фильм/сериал, строит // план раскладки и оценивает уверенность. // // Конвейер (см. docs/specs/recognition.md): // 1. пред-парс имени релиза (go-ptn) — черновые название/год/сезон/серия; // 2. вызов LLM со структурированным выводом → план в нашей схеме; // 3. валидация плана в Go (схема + структура + согласованность сигналов); // 4. решение «авто или review». // // Ф2 не сверяется с метабазами (TMDB/TVDB — Ф4) и ничего не пишет на диск: // без подтверждённого матча в базе авто-раскладка не делается, поэтому в // этой фазе решение всегда «review». Выход LLM недоверенный — план // принимается только если каждый files[].src совпадает с реальным файлом // торрента; итоговая безопасность пути держится на раскладке (Ф3). package recognize import ( "context" "fmt" "log/slog" "git.vakhrushev.me/av/jellybit/internal/llm" ) // MediaType — вид контента. type MediaType string const ( MediaMovie MediaType = "movie" MediaSeries MediaType = "series" ) // FileRole — роль файла в раздаче. type FileRole string const ( RoleMain FileRole = "main" // основной видеофайл фильма RoleEpisode FileRole = "episode" // серия сериала RoleSubtitle FileRole = "subtitle" // внешние субтитры RoleExtra FileRole = "extra" // допматериалы RoleSample FileRole = "sample" // семпл RoleIgnore FileRole = "ignore" // мусор/не нужное ) func (r FileRole) valid() bool { switch r { case RoleMain, RoleEpisode, RoleSubtitle, RoleExtra, RoleSample, RoleIgnore: return true default: return false } } // File — входной файл торрента (путь относительно content_path и размер). type File struct { Path string Size int64 } // Input — сигналы для распознавания одной раздачи. type Input struct { Name string // имя торрента Files []File // список файлов с размерами Context string // текстовый контекст человека (опц.) Hints []string // накопленные подсказки из review (Ф3; в Ф2 обычно пусто) } // PlanFile — файл в плане раскладки. Season/Episode заданы на файле, чтобы // выражать мультисезонные паки и спецвыпуски (см. recognition.md). type PlanFile struct { Src string `json:"src"` Role FileRole `json:"role"` Season *int `json:"season,omitempty"` Episode *int `json:"episode,omitempty"` } // Plan — структурированный результат распознавания (схема ответа LLM). type Plan struct { Type MediaType `json:"type"` Title string `json:"title"` OriginalTitle string `json:"original_title,omitempty"` Year int `json:"year,omitempty"` ProviderHint string `json:"provider_hint,omitempty"` Files []PlanFile `json:"files"` Confidence float64 `json:"confidence"` Notes string `json:"notes,omitempty"` } // PreParse — черновой разбор имени релиза (go-ptn). type PreParse struct { Title string Year int Season int Episode int Quality string } // Decision — решение модели уверенности. type Decision struct { Auto bool // авто-раскладка без review (в Ф2 всегда false) Reasons []string // причины ухода в review / предупреждения валидации } // Result — итог распознавания. type Result struct { Plan Plan PreParse PreParse Decision Decision Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора) Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm) } // LLM — нужная recognize часть провайдера. type LLM interface { Complete(ctx context.Context, req llm.Request) (llm.Response, error) } // Config — параметры распознавания. type Config struct { MaxRetries int // переразбор ответа со схемой-в-промпте ([llm].max_retries) MaxTokens int // лимит ответа модели (0 — дефолт) MaxFiles int // усечение списка файлов в промпте (0 — дефолт) } const ( defaultMaxTokens = 4000 defaultMaxFiles = 100 ) // Recognizer — реализация распознавания. type Recognizer struct { llm LLM maxRetry int maxTokens int maxFiles int log *slog.Logger } // New собирает распознаватель. func New(provider LLM, cfg Config, log *slog.Logger) *Recognizer { maxTokens := cfg.MaxTokens if maxTokens <= 0 { maxTokens = defaultMaxTokens } maxFiles := cfg.MaxFiles if maxFiles <= 0 { maxFiles = defaultMaxFiles } retries := cfg.MaxRetries if retries < 0 { retries = 0 } return &Recognizer{ llm: provider, maxRetry: retries, maxTokens: maxTokens, maxFiles: maxFiles, log: log, } } // Recognize прогоняет конвейер. Транспортная ошибка LLM возвращается как // error (наверху решат retry/failed). Неразобранный после ретраев ответ — // не ошибка, а Result с решением review (см. recognition.md). func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) { pre := preParse(in.Name) msgs := buildMessages(in, pre, r.maxFiles) temp := 0.0 var raw string var plan Plan var parseErr error attempts := 0 for attempt := 0; attempt <= r.maxRetry; attempt++ { attempts++ resp, err := r.llm.Complete(ctx, llm.Request{ Messages: msgs, JSONMode: true, Temperature: &temp, MaxTokens: r.maxTokens, }) if err != nil { return Result{}, fmt.Errorf("recognize: llm complete: %w", err) } raw = resp.Content plan, parseErr = parsePlan(raw, in) if parseErr == nil { break } r.log.Warn("recognize: unparsed llm response", "attempt", attempts, "err", parseErr) // Просим модель исправиться, повторяя схему и ошибку. msgs = append(msgs, llm.Message{Role: llm.RoleAssistant, Content: raw}, llm.Message{Role: llm.RoleUser, Content: correctionMessage(parseErr, in, r.maxFiles)}) } if parseErr != nil { return Result{ PreParse: pre, Attempts: attempts, Raw: raw, Decision: Decision{ Auto: false, Reasons: []string{"ответ LLM не разобран после " + itoa(attempts) + " попыток: " + parseErr.Error()}, }, }, nil } dec := decide(plan, pre) r.log.Info("recognize: done", "type", plan.Type, "title", plan.Title, "year", plan.Year, "files", len(plan.Files), "attempts", attempts, "auto", dec.Auto, "reasons", len(dec.Reasons)) return Result{ Plan: plan, PreParse: pre, Decision: dec, Attempts: attempts, Raw: raw, }, nil }