Files
jellybit/internal/recognize/recognize.go
T
av fc9a3b4066 распознавание: большие раздачи размечаются целиком, файлы вне плана видны
- модель адресует файл номером строки нашего списка вместо копии пути: ответ
  на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято,
  max_files и max_tokens ушли в [recognition], correction-ретрай больше не
  переприсылает список
- негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и
  отказ по размеру запроса названы своими причинами, покрытие плана блокирует
  авто только при непокрытом видеофайле
- раскладка показывает все файлы раздачи со строками «не в плане» и полным
  порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
2026-09-02 08:54:58 +03:00

438 lines
22 KiB
Go

// 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
}