Добавил поиск метаданных по каталогам
This commit is contained in:
@@ -44,7 +44,7 @@ func TestIntegration_RecognizeSeries(t *testing.T) {
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r := recognize.New(provider, recognize.Config{MaxRetries: 2}, log)
|
||||
r := recognize.New(provider, nil, recognize.Config{MaxRetries: 2}, log)
|
||||
|
||||
const dir = "Аватар Легенда об Аанге.Книга 2.Земля(Avatar The Last Airbender The book 2.Earth)/"
|
||||
in := recognize.Input{
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package recognize
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||
)
|
||||
|
||||
// matchMetadata сверяет план с включёнными базами: ищет по названию+году и,
|
||||
// если ровно один кандидат уверенно совпадает (название и год), возвращает
|
||||
// матч с официальным id и каноническим именем. Несколько кандидатов или их
|
||||
// отсутствие → nil (тогда авто-раскладки не будет). Ошибки провайдера не
|
||||
// валят распознавание — просто нет матча.
|
||||
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
|
||||
if len(r.providers) == 0 {
|
||||
return nil
|
||||
}
|
||||
mt := metadata.Movie
|
||||
if plan.Type == MediaSeries {
|
||||
mt = metadata.Series
|
||||
}
|
||||
|
||||
searchTitle := plan.ProviderHint
|
||||
if strings.TrimSpace(searchTitle) == "" {
|
||||
searchTitle = plan.Title
|
||||
}
|
||||
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
||||
|
||||
for _, p := range r.providers {
|
||||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
|
||||
if err != nil {
|
||||
r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err)
|
||||
continue
|
||||
}
|
||||
strong := strongMatches(cands, plan.Year, matchTitles)
|
||||
if len(strong) != 1 {
|
||||
continue
|
||||
}
|
||||
c := strong[0]
|
||||
match := &Match{Provider: c.Provider, ProviderID: c.ID, Title: c.Title, Year: c.Year}
|
||||
if mt == metadata.Series {
|
||||
if counts, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil {
|
||||
match.SeasonEpisodeCounts = counts
|
||||
} else {
|
||||
r.log.Warn("recognize: episode counts failed",
|
||||
"provider", p.Name(), "id", c.ID, "err", cerr)
|
||||
}
|
||||
}
|
||||
return match
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
|
||||
// названий плана (после нормализации) и год бьётся (±1 год), дедуплицируя
|
||||
// по id.
|
||||
func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool) []metadata.Candidate {
|
||||
seen := map[string]bool{}
|
||||
var out []metadata.Candidate
|
||||
for _, c := range cands {
|
||||
if !yearMatches(year, c.Year) {
|
||||
continue
|
||||
}
|
||||
if !titles[normalize(c.Title)] && !titles[normalize(c.OriginalTitle)] {
|
||||
continue
|
||||
}
|
||||
if seen[c.ID] {
|
||||
continue
|
||||
}
|
||||
seen[c.ID] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// yearMatches: год известен у обоих и расходится не больше чем на 1 (разные
|
||||
// базы по-разному датируют релиз), либо где-то год неизвестен.
|
||||
func yearMatches(a, b int) bool {
|
||||
if a == 0 || b == 0 {
|
||||
return true
|
||||
}
|
||||
d := a - b
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
return d <= 1
|
||||
}
|
||||
|
||||
// normSet — множество нормализованных непустых названий.
|
||||
func normSet(titles ...string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, t := range titles {
|
||||
if n := normalize(t); n != "" {
|
||||
out[n] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalize приводит название к сравнимому виду: нижний регистр, только
|
||||
// буквы/цифры (юникод), одиночные пробелы.
|
||||
func normalize(s string) string {
|
||||
var b strings.Builder
|
||||
prevSpace := false
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch {
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
prevSpace = false
|
||||
case !prevSpace:
|
||||
b.WriteByte(' ')
|
||||
prevSpace = true
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package recognize
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||
)
|
||||
|
||||
type fakeProvider struct {
|
||||
name string
|
||||
candidates []metadata.Candidate
|
||||
counts map[int]int
|
||||
searchErr error
|
||||
searched int
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Name() string {
|
||||
if f.name == "" {
|
||||
return "tmdb"
|
||||
}
|
||||
return f.name
|
||||
}
|
||||
func (f *fakeProvider) Search(_ context.Context, _ metadata.Query) ([]metadata.Candidate, error) {
|
||||
f.searched++
|
||||
return f.candidates, f.searchErr
|
||||
}
|
||||
func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) {
|
||||
return f.counts, nil
|
||||
}
|
||||
|
||||
func recognizerWith(p metadata.Provider) *Recognizer {
|
||||
var providers []metadata.Provider
|
||||
if p != nil {
|
||||
providers = []metadata.Provider{p}
|
||||
}
|
||||
return New(&fakeLLM{}, providers, Config{}, testLogger())
|
||||
}
|
||||
|
||||
func TestMatchMetadata_SingleStrong(t *testing.T) {
|
||||
p := &fakeProvider{candidates: []metadata.Candidate{
|
||||
{Provider: "tmdb", ID: "603", Title: "The Matrix", Year: 1999},
|
||||
{Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999})
|
||||
if m == nil {
|
||||
t.Fatal("expected match")
|
||||
}
|
||||
if m.ProviderID != "603" || m.Provider != "tmdb" {
|
||||
t.Errorf("match = %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_AmbiguousNoMatch(t *testing.T) {
|
||||
// Два кандидата с тем же названием и годом — неоднозначно.
|
||||
p := &fakeProvider{candidates: []metadata.Candidate{
|
||||
{ID: "1", Title: "Fargo", Year: 2014},
|
||||
{ID: "2", Title: "Fargo", Year: 2014},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
if m := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil {
|
||||
t.Errorf("ambiguous must not match, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_YearMismatch(t *testing.T) {
|
||||
p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}}
|
||||
r := recognizerWith(p)
|
||||
if m := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil {
|
||||
t.Errorf("year mismatch must not match, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_OriginalTitle(t *testing.T) {
|
||||
p := &fakeProvider{candidates: []metadata.Candidate{
|
||||
{ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "Léon", Year: 1994})
|
||||
if m == nil || m.ProviderID != "1" {
|
||||
t.Errorf("should match by original title, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
|
||||
p := &fakeProvider{
|
||||
candidates: []metadata.Candidate{{ID: "60622", Title: "Fargo", Year: 2014}},
|
||||
counts: map[int]int{1: 10, 2: 10},
|
||||
}
|
||||
r := recognizerWith(p)
|
||||
m := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
||||
if m == nil || m.SeasonEpisodeCounts[1] != 10 {
|
||||
t.Errorf("counts not fetched: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
|
||||
p := &fakeProvider{searchErr: errors.New("upstream down")}
|
||||
r := recognizerWith(p)
|
||||
if m := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil {
|
||||
t.Errorf("provider error must yield no match, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchMetadata_Disabled(t *testing.T) {
|
||||
r := recognizerWith(nil)
|
||||
if m := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil {
|
||||
t.Errorf("no providers → no match, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"The Matrix": "the matrix",
|
||||
"Léon: The Pro!": "léon the pro",
|
||||
" A B ": "a b",
|
||||
"Привет, Мир": "привет мир",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalize(in); got != want {
|
||||
t.Errorf("normalize(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сквозной авто: LLM-план + матч в базе + чистая валидация → Decision.Auto.
|
||||
func TestRecognize_AutoWithMatch(t *testing.T) {
|
||||
in := Input{Name: "The.Matrix.1999", Files: []File{{Path: "m/film.mkv", Size: 1}}}
|
||||
resp := `{"type":"movie","title":"The Matrix","year":1999,"confidence":0.95,
|
||||
"provider_hint":"The Matrix","files":[{"src":"m/film.mkv","role":"main"}]}`
|
||||
llmFake := &fakeLLM{responses: []string{resp}}
|
||||
p := &fakeProvider{candidates: []metadata.Candidate{
|
||||
{Provider: "tmdb", ID: "603", Title: "The Matrix", Year: 1999},
|
||||
}}
|
||||
r := New(llmFake, []metadata.Provider{p}, Config{}, testLogger())
|
||||
|
||||
res, err := r.Recognize(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("Recognize: %v", err)
|
||||
}
|
||||
if !res.Decision.Auto {
|
||||
t.Errorf("expected auto, reasons: %v", res.Decision.Reasons)
|
||||
}
|
||||
if res.Match == nil || res.Match.ProviderID != "603" {
|
||||
t.Errorf("match = %+v", res.Match)
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,16 @@
|
||||
// Конвейер (см. docs/specs/recognition.md):
|
||||
// 1. пред-парс имени релиза (go-ptn) — черновые название/год/сезон/серия;
|
||||
// 2. вызов LLM со структурированным выводом → план в нашей схеме;
|
||||
// 3. валидация плана в Go (схема + структура + согласованность сигналов);
|
||||
// 4. решение «авто или review».
|
||||
// 3. сверка с базами метаданных (TMDB/TVDB, опц.) — единичный сильный матч
|
||||
// по названию+году даёт официальный id и каноническое имя;
|
||||
// 4. решение «авто или review»: авто только при подтверждённом матче,
|
||||
// чистой структурной валидации (для сериала — число серий бьётся с
|
||||
// базой), согласованности с пред-парсом и уверенности не ниже порога.
|
||||
//
|
||||
// Ф2 не сверяется с метабазами (TMDB/TVDB — Ф4) и ничего не пишет на диск:
|
||||
// без подтверждённого матча в базе авто-раскладка не делается, поэтому в
|
||||
// этой фазе решение всегда «review». Выход LLM недоверенный — план
|
||||
// принимается только если каждый files[].src совпадает с реальным файлом
|
||||
// торрента; итоговая безопасность пути держится на раскладке (Ф3).
|
||||
// Без включённых баз (или без матча) авто-раскладка не делается — задача
|
||||
// уходит в review. Выход LLM недоверенный: план принимается только если
|
||||
// каждый files[].src совпадает с реальным файлом торрента; итоговая
|
||||
// безопасность пути держится на раскладке (layout).
|
||||
package recognize
|
||||
|
||||
import (
|
||||
@@ -20,6 +22,7 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||
)
|
||||
|
||||
// MediaType — вид контента.
|
||||
@@ -101,11 +104,21 @@ type Decision struct {
|
||||
Reasons []string // причины ухода в review / предупреждения валидации
|
||||
}
|
||||
|
||||
// Match — подтверждение распознавания базой метаданных.
|
||||
type Match struct {
|
||||
Provider string // "tmdb" | "tvdb"
|
||||
ProviderID string // официальный id
|
||||
Title string // каноническое название
|
||||
Year int // каноничный год
|
||||
SeasonEpisodeCounts map[int]int // число серий по сезонам (для сериала)
|
||||
}
|
||||
|
||||
// Result — итог распознавания.
|
||||
type Result struct {
|
||||
Plan Plan
|
||||
PreParse PreParse
|
||||
Decision Decision
|
||||
Match *Match // подтверждённый матч в базе (nil — нет)
|
||||
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора)
|
||||
Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm)
|
||||
}
|
||||
@@ -117,27 +130,32 @@ type LLM interface {
|
||||
|
||||
// Config — параметры распознавания.
|
||||
type Config struct {
|
||||
MaxRetries int // переразбор ответа со схемой-в-промпте ([llm].max_retries)
|
||||
MaxTokens int // лимит ответа модели (0 — дефолт)
|
||||
MaxFiles int // усечение списка файлов в промпте (0 — дефолт)
|
||||
MaxRetries int // переразбор ответа со схемой-в-промпте ([llm].max_retries)
|
||||
MaxTokens int // лимит ответа модели (0 — дефолт)
|
||||
MaxFiles int // усечение списка файлов в промпте (0 — дефолт)
|
||||
AutoThreshold float64 // порог уверенности для авто (0 — дефолт 0.85)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultMaxTokens = 4000
|
||||
defaultMaxFiles = 100
|
||||
defaultMaxTokens = 4000
|
||||
defaultMaxFiles = 100
|
||||
defaultAutoThreshold = 0.85
|
||||
)
|
||||
|
||||
// Recognizer — реализация распознавания.
|
||||
type Recognizer struct {
|
||||
llm LLM
|
||||
providers []metadata.Provider
|
||||
maxRetry int
|
||||
maxTokens int
|
||||
maxFiles int
|
||||
threshold float64
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New собирает распознаватель.
|
||||
func New(provider LLM, cfg Config, log *slog.Logger) *Recognizer {
|
||||
// New собирает распознаватель. providers — включённые базы метаданных
|
||||
// (пусто → сверки нет, авто-раскладка не делается).
|
||||
func New(provider LLM, providers []metadata.Provider, cfg Config, log *slog.Logger) *Recognizer {
|
||||
maxTokens := cfg.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = defaultMaxTokens
|
||||
@@ -150,11 +168,17 @@ func New(provider LLM, cfg Config, log *slog.Logger) *Recognizer {
|
||||
if retries < 0 {
|
||||
retries = 0
|
||||
}
|
||||
threshold := cfg.AutoThreshold
|
||||
if threshold <= 0 {
|
||||
threshold = defaultAutoThreshold
|
||||
}
|
||||
return &Recognizer{
|
||||
llm: provider,
|
||||
providers: providers,
|
||||
maxRetry: retries,
|
||||
maxTokens: maxTokens,
|
||||
maxFiles: maxFiles,
|
||||
threshold: threshold,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
@@ -209,15 +233,26 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
dec := decide(plan, pre)
|
||||
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
|
||||
// в плане заменяем на каноничные.
|
||||
match := r.matchMetadata(ctx, plan)
|
||||
if match != nil {
|
||||
plan.Title = match.Title
|
||||
if match.Year != 0 {
|
||||
plan.Year = match.Year
|
||||
}
|
||||
}
|
||||
|
||||
dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold)
|
||||
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))
|
||||
"matched", match != nil, "auto", dec.Auto, "reasons", len(dec.Reasons))
|
||||
return Result{
|
||||
Plan: plan,
|
||||
PreParse: pre,
|
||||
Decision: dec,
|
||||
Match: match,
|
||||
Attempts: attempts,
|
||||
Raw: raw,
|
||||
}, nil
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestRecognize_Movie(t *testing.T) {
|
||||
{"src":"The.Matrix.1999/sample.mkv","role":"sample","season":null,"episode":null}
|
||||
]}`
|
||||
f := &fakeLLM{responses: []string{resp}}
|
||||
r := New(f, Config{MaxRetries: 2}, testLogger())
|
||||
r := New(f, nil, Config{MaxRetries: 2}, testLogger())
|
||||
|
||||
res, err := r.Recognize(context.Background(), in)
|
||||
if err != nil {
|
||||
@@ -74,9 +74,10 @@ func TestRecognize_Movie(t *testing.T) {
|
||||
if len(res.Decision.Reasons) == 0 {
|
||||
t.Error("expected at least the no-DB-match reason")
|
||||
}
|
||||
// Чистая структура: единственная причина — отсутствие матча в базе.
|
||||
if len(res.Decision.Reasons) != 1 {
|
||||
t.Errorf("unexpected extra warnings: %v", res.Decision.Reasons)
|
||||
// Чистая структура + уверенность 0.9 ≥ порога: единственная причина —
|
||||
// отсутствие матча в базе.
|
||||
if len(res.Decision.Reasons) != 1 || !hasReason(res.Decision.Reasons, "метабазы отключены") {
|
||||
t.Errorf("unexpected reasons: %v", res.Decision.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +97,7 @@ func TestRecognize_Series(t *testing.T) {
|
||||
{"src":"Avatar/03.mkv","role":"episode","season":2,"episode":3}
|
||||
]}`
|
||||
f := &fakeLLM{responses: []string{resp}}
|
||||
r := New(f, Config{}, testLogger())
|
||||
r := New(f, nil, Config{}, testLogger())
|
||||
|
||||
res, err := r.Recognize(context.Background(), in)
|
||||
if err != nil {
|
||||
@@ -105,9 +106,22 @@ func TestRecognize_Series(t *testing.T) {
|
||||
if res.Plan.Type != MediaSeries || len(res.Plan.Files) != 3 {
|
||||
t.Errorf("plan = %+v", res.Plan)
|
||||
}
|
||||
if len(res.Decision.Reasons) != 1 {
|
||||
t.Errorf("clean series should warn only about DB match, got: %v", res.Decision.Reasons)
|
||||
// Метабазы выключены → авто нет; причина про базу обязательна.
|
||||
if res.Decision.Auto {
|
||||
t.Error("auto must be false without metadata providers")
|
||||
}
|
||||
if !hasReason(res.Decision.Reasons, "метабазы отключены") {
|
||||
t.Errorf("expected metadata-off reason, got: %v", res.Decision.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func hasReason(reasons []string, substr string) bool {
|
||||
for _, r := range reasons {
|
||||
if strings.Contains(r, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestRecognize_RetriesOnBadSrcThenSucceeds(t *testing.T) {
|
||||
@@ -120,7 +134,7 @@ func TestRecognize_RetriesOnBadSrcThenSucceeds(t *testing.T) {
|
||||
good := `{"type":"movie","title":"Some Movie","year":2020,"files":[
|
||||
{"src":"movie/film.mkv","role":"main"}]}`
|
||||
f := &fakeLLM{responses: []string{bad, good}}
|
||||
r := New(f, Config{MaxRetries: 2}, testLogger())
|
||||
r := New(f, nil, Config{MaxRetries: 2}, testLogger())
|
||||
|
||||
res, err := r.Recognize(context.Background(), in)
|
||||
if err != nil {
|
||||
@@ -143,7 +157,7 @@ func TestRecognize_ExhaustedRetriesGoesToReview(t *testing.T) {
|
||||
in := Input{Name: "x", Files: []File{{Path: "a.mkv", Size: 1}}}
|
||||
bad := `not a json at all`
|
||||
f := &fakeLLM{responses: []string{bad}}
|
||||
r := New(f, Config{MaxRetries: 2}, testLogger())
|
||||
r := New(f, nil, Config{MaxRetries: 2}, testLogger())
|
||||
|
||||
res, err := r.Recognize(context.Background(), in)
|
||||
if err != nil {
|
||||
@@ -167,7 +181,7 @@ func TestRecognize_TransportErrorPropagates(t *testing.T) {
|
||||
in := Input{Name: "x", Files: []File{{Path: "a.mkv", Size: 1}}}
|
||||
wantErr := errors.New("connection refused")
|
||||
f := &fakeLLM{errs: []error{wantErr}}
|
||||
r := New(f, Config{MaxRetries: 2}, testLogger())
|
||||
r := New(f, nil, Config{MaxRetries: 2}, testLogger())
|
||||
|
||||
_, err := r.Recognize(context.Background(), in)
|
||||
if err == nil || !errors.Is(err, wantErr) {
|
||||
@@ -188,7 +202,7 @@ func TestRecognize_PromptCarriesSignals(t *testing.T) {
|
||||
resp := `{"type":"series","title":"Some Show","files":[
|
||||
{"src":"ep1.mkv","role":"episode","season":1,"episode":1}]}`
|
||||
f := &fakeLLM{responses: []string{resp}}
|
||||
r := New(f, Config{}, testLogger())
|
||||
r := New(f, nil, Config{}, testLogger())
|
||||
if _, err := r.Recognize(context.Background(), in); err != nil {
|
||||
t.Fatalf("Recognize: %v", err)
|
||||
}
|
||||
@@ -219,7 +233,7 @@ func TestRecognize_FileListTruncated(t *testing.T) {
|
||||
resp := `{"type":"series","title":"Big","files":[{"src":"` + pathOf(0) +
|
||||
`","role":"episode","season":1,"episode":1}]}`
|
||||
f := &fakeLLM{responses: []string{resp}}
|
||||
r := New(f, Config{MaxFiles: 100}, testLogger())
|
||||
r := New(f, nil, Config{MaxFiles: 100}, testLogger())
|
||||
if _, err := r.Recognize(context.Background(), in); err != nil {
|
||||
t.Fatalf("Recognize: %v", err)
|
||||
}
|
||||
|
||||
@@ -76,15 +76,72 @@ func validateSchema(p *Plan, in Input) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// decide считает решение модели уверенности. В Ф2 метабазы выключены, а без
|
||||
// подтверждённого матча в базе авто-раскладка не делается (recognition.md),
|
||||
// поэтому Auto всегда false; здесь же копим структурные предупреждения и
|
||||
// расхождения с пред-парсом — они объясняют ревью человеку.
|
||||
func decide(p Plan, pre PreParse) Decision {
|
||||
reasons := []string{"матч в базе не подтверждён (метабазы отключены в Ф2) → review"}
|
||||
// decide считает решение модели уверенности (см. recognition.md). Авто —
|
||||
// только если выполнено всё: подтверждённый единичный матч в базе; чистая
|
||||
// структурная валидация (для сериала — число серий бьётся с базой);
|
||||
// согласованность с пред-парсом; самооценка LLM не ниже порога. Любая
|
||||
// невыполненная — причина ухода в review.
|
||||
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision {
|
||||
var reasons []string
|
||||
|
||||
switch {
|
||||
case !metadataEnabled:
|
||||
reasons = append(reasons, "метабазы отключены → авто-раскладка недоступна")
|
||||
case match == nil:
|
||||
reasons = append(reasons, "не найдено в базе или несколько кандидатов")
|
||||
}
|
||||
|
||||
reasons = append(reasons, structuralWarnings(p)...)
|
||||
|
||||
if match != nil && p.Type == MediaSeries {
|
||||
reasons = append(reasons, episodeCountWarnings(p, match.SeasonEpisodeCounts)...)
|
||||
}
|
||||
|
||||
reasons = append(reasons, consistencyWarnings(p, pre)...)
|
||||
return Decision{Auto: false, Reasons: reasons}
|
||||
|
||||
if p.Confidence < threshold {
|
||||
reasons = append(reasons,
|
||||
fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold))
|
||||
}
|
||||
|
||||
return Decision{Auto: len(reasons) == 0, Reasons: reasons}
|
||||
}
|
||||
|
||||
// episodeCountWarnings сверяет число распознанных серий по сезонам с базой.
|
||||
// Нет данных по сезону → блокируем авто (полноту пака не подтвердить).
|
||||
func episodeCountWarnings(p Plan, counts map[int]int) []string {
|
||||
recognized := map[int]int{}
|
||||
for _, f := range p.Files {
|
||||
if f.Role == RoleEpisode && f.Episode != nil {
|
||||
season := 0
|
||||
if f.Season != nil {
|
||||
season = *f.Season
|
||||
}
|
||||
recognized[season]++
|
||||
}
|
||||
}
|
||||
var w []string
|
||||
for _, season := range sortedKeys(toSlices(recognized)) {
|
||||
rc := recognized[season]
|
||||
dbc, ok := counts[season]
|
||||
switch {
|
||||
case !ok || dbc == 0:
|
||||
w = append(w, fmt.Sprintf("сезон %d: в базе нет данных о числе серий", season))
|
||||
case rc != dbc:
|
||||
w = append(w, fmt.Sprintf("сезон %d: распознано серий %d, в базе %d", season, rc, dbc))
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// toSlices превращает map[int]int в map[int][]int для sortedKeys (нужны
|
||||
// только ключи).
|
||||
func toSlices(m map[int]int) map[int][]int {
|
||||
out := make(map[int][]int, len(m))
|
||||
for k := range m {
|
||||
out[k] = nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// structuralWarnings — нарушения структуры плана (мягкие, не блокируют разбор).
|
||||
|
||||
@@ -155,17 +155,71 @@ func TestConsistencyWarnings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_AlwaysReview(t *testing.T) {
|
||||
p := Plan{Type: MediaMovie, Title: "X", Files: []PlanFile{{Role: RoleMain}}}
|
||||
d := decide(p, PreParse{})
|
||||
func TestDecide_MetadataDisabled(t *testing.T) {
|
||||
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
|
||||
d := decide(p, PreParse{}, nil, false, 0.85)
|
||||
if d.Auto {
|
||||
t.Error("Ф2 decision must never be auto")
|
||||
t.Error("без метабаз авто недопустимо")
|
||||
}
|
||||
if len(d.Reasons) == 0 || !strings.Contains(d.Reasons[0], "метабазы отключены") {
|
||||
t.Errorf("first reason should be DB match, got %v", d.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_NoMatch(t *testing.T) {
|
||||
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
|
||||
d := decide(p, PreParse{}, nil, true, 0.85)
|
||||
if d.Auto || !strings.Contains(d.Reasons[0], "не найдено в базе") {
|
||||
t.Errorf("reasons = %v", d.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_AutoMovie(t *testing.T) {
|
||||
p := Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999, Confidence: 0.95,
|
||||
Files: []PlanFile{{Role: RoleMain}, {Role: RoleSample}}}
|
||||
match := &Match{Provider: "tmdb", ProviderID: "603", Title: "The Matrix", Year: 1999}
|
||||
d := decide(p, PreParse{Year: 1999}, match, true, 0.85)
|
||||
if !d.Auto {
|
||||
t.Errorf("clean movie with match must be auto, reasons: %v", d.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_LowConfidenceBlocksAuto(t *testing.T) {
|
||||
p := Plan{Type: MediaMovie, Title: "X", Year: 2000, Confidence: 0.5,
|
||||
Files: []PlanFile{{Role: RoleMain}}}
|
||||
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "X", Year: 2000}
|
||||
d := decide(p, PreParse{}, match, true, 0.85)
|
||||
if d.Auto || !hasReason(d.Reasons, "уверенность") {
|
||||
t.Errorf("low confidence must block auto, reasons: %v", d.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_AutoSeriesEpisodeCount(t *testing.T) {
|
||||
s := 2
|
||||
mk := func(e int) PlanFile { ep := e; return PlanFile{Role: RoleEpisode, Season: &s, Episode: &ep} }
|
||||
p := Plan{Type: MediaSeries, Title: "Fargo", Year: 2014, Confidence: 0.9,
|
||||
Files: []PlanFile{mk(1), mk(2), mk(3)}}
|
||||
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Fargo", Year: 2014,
|
||||
SeasonEpisodeCounts: map[int]int{2: 3}}
|
||||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); !d.Auto {
|
||||
t.Errorf("full season must be auto, reasons: %v", d.Reasons)
|
||||
}
|
||||
|
||||
// Неполный пак (в базе 10) — авто блокируется.
|
||||
match.SeasonEpisodeCounts = map[int]int{2: 10}
|
||||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto ||
|
||||
!hasReason(d.Reasons, "распознано серий 3, в базе 10") {
|
||||
t.Errorf("partial pack must block auto, reasons: %v", d.Reasons)
|
||||
}
|
||||
|
||||
// Нет данных о числе серий — авто блокируется.
|
||||
match.SeasonEpisodeCounts = nil
|
||||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto ||
|
||||
!hasReason(d.Reasons, "нет данных о числе серий") {
|
||||
t.Errorf("missing counts must block auto, reasons: %v", d.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreParse(t *testing.T) {
|
||||
pre := preParse("The.Matrix.1999.1080p.BluRay.x264")
|
||||
if pre.Year != 1999 {
|
||||
|
||||
Reference in New Issue
Block a user