- модель адресует файл номером строки нашего списка вместо копии пути: ответ на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято, max_files и max_tokens ушли в [recognition], correction-ретрай больше не переприсылает список - негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и отказ по размеру запроса названы своими причинами, покрытие плана блокирует авто только при непокрытом видеофайле - раскладка показывает все файлы раздачи со строками «не в плане» и полным порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
438 lines
22 KiB
Go
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
|
|
}
|