// Package recognize по сигналам торрента определяет фильм/сериал, строит // план раскладки и оценивает уверенность. // // Конвейер (см. openspec/specs/recognition/spec.md): // 1. пред-парс имени релиза (go-ptn) — черновые название/год/сезон/серия; // 2. вызов LLM со структурированным выводом → план в нашей схеме; // 3. сверка с базами метаданных (TMDB/TVDB, опц.) — единичный сильный матч // по названию+году даёт официальный id и каноническое имя; // 4. решение «авто или review»: авто только при подтверждённом матче, // чистой структурной валидации (для сериала — число серий бьётся с // базой), согласованности с пред-парсом и уверенности не ниже порога. // // Без включённых баз (или без матча) авто-раскладка не делается — задача // уходит в review. Выход LLM недоверенный: файл адресуется НОМЕРОМ строки в // напечатанном нами списке, а files[].src подставляет резолв номера по этому // же списку — посторонний путь невыразим. Присланный моделью путь принимается // лишь как запасной формат и только при точном совпадении с файлом торрента; // итоговая безопасность пути держится на раскладке (layout). package recognize import ( "context" "errors" "fmt" "log/slog" "math" "path/filepath" "sort" "strconv" "strings" "git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/logctx" "git.vakhrushev.me/av/jellybit/internal/metadata" ) // 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 — входной файл торрента (путь относительно save_path и размер). // JSON-теги — для снимка списка файлов рядом с планом (store.Recognition, // миграция 0012): раскладка обязана показывать и те файлы, которых нет в плане. type File struct { Path string `json:"path"` Size int64 `json:"size"` } // videoExts — расширения, по которым файл считается видео (нижний регистр, с // точкой). Единственное место перечня: на нём держится разделение покрытия // плана — непокрытый видеофайл означает потерянную серию или фильм и блокирует // авто-раскладку, непокрытый файл-спутник (субтитры, картинки, тексты) — нет. // Незнакомое расширение видео попадёт в спутники, поэтому список пополняем // здесь, а не по месту вызова. var videoExts = map[string]bool{ ".mkv": true, ".mp4": true, ".avi": true, ".m4v": true, ".mov": true, ".wmv": true, ".mpg": true, ".mpeg": true, ".m2ts": true, ".mts": true, ".ts": true, ".vob": true, ".flv": true, ".webm": true, ".ogm": true, ".divx": true, ".rmvb": true, ".3gp": true, ".iso": true, ".img": true, } // IsVideoFile — видео ли это по расширению пути. Регистр расширения не важен. func IsVideoFile(path string) bool { return videoExts[strings.ToLower(filepath.Ext(path))] } // Input — сигналы для распознавания одной раздачи. type Input struct { Name string // имя торрента Files []File // список файлов с размерами Context string // текстовый контекст человека (опц.) Hints []string // накопленные подсказки из review (Ф3; в Ф2 обычно пусто) } // FileIndex — номер файла в списке, напечатанном в промпте (1-based). Модель // иногда возвращает его строкой или дробным числом; разбор это терпит и // сводит к целому, потому что негодный номер обязан отбраковывать элемент, а // не весь план (см. validate.go). Неразбираемое значение даёт 0 — и элемент // отбраковывается той же причиной, что и явно присланный ноль. type FileIndex int // UnmarshalJSON разбирает номер терпимо: число, строка с числом, дробное с // нулевой дробной частью, null. Ошибку не возвращает НИКОГДА — иначе один // кривой элемент ронял бы разбор всего плана. func (n *FileIndex) UnmarshalJSON(b []byte) error { s := strings.Trim(strings.TrimSpace(string(b)), `"`) if s == "" || s == "null" { *n = 0 return nil } if v, err := strconv.Atoi(s); err == nil { *n = FileIndex(v) return nil } if f, err := strconv.ParseFloat(s, 64); err == nil && f == math.Trunc(f) && f > math.MinInt32 && f < math.MaxInt32 { *n = FileIndex(int(f)) return nil } *n = 0 return nil } // PlanFile — файл в плане раскладки. Season/Episode заданы на файле, чтобы // выражать мультисезонные паки и спецвыпуски (см. recognition.md). type PlanFile struct { // Index — чем файл адресован в ответе модели: номер строки нашего списка. // Src заполняет резолвом сама система, поэтому посторонний путь невыразим. // Указатель, а не значение: «поле не прислано» и «прислан 0» — разные // диагнозы. Ноль означает модель, посчитавшую список с нуля, и тогда все // остальные её номера резолвятся со сдвигом на файл; отсутствие поля — // запасной формат с путём либо элемент, не адресующий ничего. Index *FileIndex `json:"i,omitempty"` 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"` // Director — режиссёр (опц.). LLM его НЕ заполняет и не валидируется по нему; // его вкладывает подтверждённый матч метабазы (buildMatch) или закреплённый в // ревью источник (override). Недоверенное косметическое поле для вывода // отображаемого имени; в plan-санитайзинг не входит (чистится на рендере). Director string `json:"director,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 / предупреждения валидации } // Match — подтверждение распознавания базой метаданных. type Match struct { Provider string // "tmdb" | "tvdb" ProviderID string // официальный id Title string // каноническое название Year int // каноничный год Director string // режиссёр (best-effort из credits; пусто — нет) SeasonEpisodeCounts map[int]int // число серий по сезонам (для сериала) } // Result — итог распознавания. type Result struct { Plan Plan PreParse PreParse Decision Decision Match *Match // подтверждённый единичный матч (nil — нет) Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи) Raw string // сырой ответ LLM последней попытки // Files — ПОЛНЫЙ список файлов раздачи в том порядке, в каком печатается // промпт (и в каком резолвятся номера). Снимок момента распознавания: его // сохраняют рядом с планом, чтобы раскладка показывала и файлы вне плана. // Усечение пределом max_files сюда не распространяется — иначе снимок выдал // бы показанный модели срез за весь перечень раздачи. Заполнен на всех // исходах, включая review без разобранного плана. Files []File } // 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 — дефолт) AutoThreshold float64 // порог уверенности для авто (0 — дефолт 0.85) // Language — язык локализованного `title` в промпте ("ru" | "en"); пусто → // "en". `original_title` от него не зависит (см. prompt.go). Language string } const ( // Дефолты — предохранители для прямых вызовов конструктора (тесты, CLI без // конфига). Канонические значения задаёт [recognition] (config.Default); // равенство им сторожит TestDefaultsAgreeWithConfig — иначе прод и тест // разъедутся молча. defaultMaxTokens = 8000 defaultMaxFiles = 500 defaultAutoThreshold = 0.85 defaultLanguage = "en" ) // Recognizer — реализация распознавания. type Recognizer struct { llm LLM providers []metadata.Provider maxRetry int maxTokens int maxFiles int threshold float64 language string log *slog.Logger } // New собирает распознаватель. providers — включённые базы метаданных // (пусто → сверки нет, авто-раскладка не делается). func New(provider LLM, providers []metadata.Provider, 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 } threshold := cfg.AutoThreshold if threshold <= 0 { threshold = defaultAutoThreshold } // Канонический дефолт языка — config.ContentLanguage() (единственный питатель // на проде). Здесь defensive-нормализация для прямых вызовов конструктора // (тесты); держать равным этому дефолту. language := cfg.Language if language == "" { language = defaultLanguage } return &Recognizer{ llm: provider, providers: providers, maxRetry: retries, maxTokens: maxTokens, maxFiles: maxFiles, threshold: threshold, language: language, log: log, } } // Recognize прогоняет конвейер. Транспортная ошибка LLM возвращается как // error (наверху решат retry/failed). Неразобранный после ретраев ответ — // не ошибка, а Result с решением review (см. recognition.md). func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) { log := logctx.FromOr(ctx, r.log) pre := preParse(in.Name) // Порядок списка — свойство узла, а не дисциплина вызывающего (сборок // Input две: воркер и CLI). Печать промпта и резолв номеров идут дальше по // одному и тому же срезу, поэтому повторное распознавание той же раздачи // даёт ту же нумерацию. // // Усечение пределом max_files — свойство ПРОМПТА и резолва номеров, а не // снимка: снимок хранит полный список раздачи, иначе виджет раскладки выдаёт // показанный модели срез за весь перечень («в план попало 100 из 100», когда // в торренте 250). Показанный список — префикс полного, поэтому номера // адресации от этого не меняются. all := sortedFiles(in.Files) shown := all if r.maxFiles > 0 && len(shown) > r.maxFiles { shown = all[:r.maxFiles] } in.Files = shown issues := planIssues{files: shown, all: all} msgs := buildMessages(in, pre, len(all), r.language) 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 { // Отказ по размеру запроса называем своей причиной: текст провайдера // человеку в ревью ничего не говорит, а лечится это [recognition].max_files. if errors.Is(err, llm.ErrRequestTooLarge) { log.Warn("recognition request rejected as too large", "source_files", len(in.Files), "max_files", r.maxFiles, "error", err) return reviewResult(pre, issues, attempts, "", []string{fmt.Sprintf( "модель отвергла запрос по его размеру: файлов в списке %d"+ " (уменьшите [recognition].max_files)", len(in.Files))}), nil } return Result{Files: all}, fmt.Errorf("recognize: llm complete: %w", err) } raw = resp.Content // Обрыв генерации по длине — не ошибка модели: ответ не поместился. // Повтор тем же промптом (и его вариантом) дал бы тот же обрыв. if resp.FinishReason == llm.FinishLength { log.Warn("recognition llm response truncated", "attempt", attempts, "max_tokens", r.maxTokens) return reviewResult(pre, issues, attempts, raw, []string{fmt.Sprintf( "ответ модели обрезан по пределу длины (max_tokens = %d):"+ " план неполон, повтор тем же запросом не поможет", r.maxTokens)}), nil } plan, issues.dropped, parseErr = parsePlan(raw, in, log) if parseErr == nil { break } log.Warn("recognition llm response unparsed", "attempt", attempts, "error", parseErr) // Просим модель исправиться, повторяя схему и ошибку. Список файлов // НЕ переприсылаем: его номера названы в первом сообщении диалога и // сохраняют смысл на всех попытках (иначе цена неудачи росла бы вместе // с размером раздачи). msgs = append(msgs, llm.Message{Role: llm.RoleAssistant, Content: raw}, llm.Message{Role: llm.RoleUser, Content: correctionMessage(parseErr)}) } if parseErr != nil { // Претензии последней попытки — единственное поэлементное объяснение // («номер 181 вне диапазона 1..2»); без них человек читает голое «ответ // LLM не разобран». decide на успешной ветке добавляет их так же. reasons := []string{ "ответ LLM не разобран после " + itoa(attempts) + " попыток: " + parseErr.Error(), } reasons = append(reasons, issues.dropped...) return reviewResult(pre, issues, attempts, raw, reasons), nil } // Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год // в плане заменяем на каноничные. Кандидаты копим для ручного выбора в // review, когда единичного сильного матча нет. match, candidates := r.matchMetadata(ctx, plan) if match != nil { // Match.Title пришёл санитизированным (buildMatch чистит его в единственной // точке сборки матча). Здесь остаётся только вопрос пригодности: название, // не годное как имя каталога, не подставляем вовсе — в плане остаётся // название распознавания, а decide уводит раздачу в review. if UsableTitle(match.Title) { plan.Title = match.Title } if match.Year != 0 { plan.Year = match.Year } if match.Director != "" { plan.Director = match.Director } } dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold, issues) log.Info("recognition done", "media_type", plan.Type, "title", plan.Title, "year", plan.Year, "files", len(plan.Files), "source_files", len(in.Files), "dropped", len(issues.dropped), "attempts", attempts, "matched", match != nil, "candidates", len(candidates), "auto", dec.Auto, "reasons", len(dec.Reasons)) return Result{ Plan: plan, PreParse: pre, Decision: dec, Match: match, Candidates: candidates, Attempts: attempts, Raw: raw, Files: all, }, nil } // reviewResult — исход без пригодного плана: задача уходит в review с // названными причинами. Снимок списка файлов отдаём и здесь: он нужен // раскладке и повторному распознаванию из ревью. func reviewResult(pre PreParse, issues planIssues, attempts int, raw string, reasons []string) Result { if issues.truncated() > 0 { reasons = append(reasons, truncationReason(issues)) } return Result{ PreParse: pre, Attempts: attempts, Raw: raw, Files: issues.all, Decision: Decision{Auto: false, Reasons: reasons}, } } // sortedFiles — детерминированный порядок списка файлов (по пути). Возвращает // НОВЫЙ срез: входной принадлежит вызывающему, менять его порядок нельзя. // Путь внутри торрента уникален, поэтому порядок воспроизводим между вызовами. func sortedFiles(files []File) []File { out := make([]File, len(files)) copy(out, files) sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) return out }