Files
jellybit/internal/recognize/recognize.go
T

225 lines
7.6 KiB
Go

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