Files
jellybit/internal/recognize/metadata_test.go
T
avandClaude Opus 4.8 e2ea1840c9 Распознавание: санитайзинг названий от LLM + безгодовой фолбэк сверки
Кейс «Harold and the Purple Crayon»: LLM отдал title с кириллической
буквой-двойником, сырое название ушло в запрос TVDB дословно (не нашлось),
а гейт нормализации кир/лат двойники не сворачивал — двойной промах, пустой
список кандидатов, ручной ввод id.

- recognition: санитайзинг человекочитаемых полей плана (title/original_title/
  provider_hint) на границе разбора, до валидации: strip control/zero-width,
  collapse пробелов, потокенная свёртка homoglyph-двойников по курируемой
  кир↔лат таблице. files[].src не трогаем (обязаны биться с торрентом).
- metadata-match: тот же fold в normalize (гейт) как defense-in-depth;
  безгодовой второй проход сверки как fallback при известном годе и промахе
  первого — восстанавливает off-by-one авто-матчи и пополняет кандидатов
  review. В fallback требуем известный год кандидата (год-unknown → review,
  не авто); гейт год ±1 и инвариант авто-матча не двигаются.

Спеки recognition/metadata-match обновлены, change заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:45:19 +03:00

426 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package recognize
import (
"context"
"errors"
"slices"
"testing"
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
type fakeProvider struct {
name string
candidates []metadata.Candidate
byTitle map[string][]metadata.Candidate // если задано — результат зависит от запроса
counts map[int]int
searchErr error
exactYear bool // имитирует жёсткий year-фильтр базы: q.Year>0 отсекает по точному году
searched int
queries []string // строки запросов в порядке вызова
}
func (f *fakeProvider) Name() string {
if f.name == "" {
return "tmdb"
}
return f.name
}
func (f *fakeProvider) Search(_ context.Context, q metadata.Query) ([]metadata.Candidate, error) {
f.searched++
f.queries = append(f.queries, q.Title)
if f.searchErr != nil {
return nil, f.searchErr
}
res := f.candidates
if f.byTitle != nil {
res = f.byTitle[q.Title]
}
if f.exactYear && q.Year > 0 {
var filtered []metadata.Candidate
for _, c := range res {
if c.Year == q.Year {
filtered = append(filtered, c)
}
}
return filtered, nil
}
return res, nil
}
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_ReturnsCandidates(t *testing.T) {
// Нет сильного матча (разные названия), но кандидаты собраны для выбора.
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
{Provider: "tvmaze", ID: "2", Title: "Fargo Idaho", Year: 2010},
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014}, // дубль по id
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Совсем другое", Year: 2014})
if m != nil {
t.Errorf("strong match не ожидался: %+v", m)
}
if len(cands) != 2 { // дубль отброшен
t.Fatalf("candidates = %d, want 2: %+v", len(cands), cands)
}
// CandidateTag даёт внешний TVDB-id для первого.
prov, id := CandidateTag(cands[0])
if prov != "tvdb" || id != "269613" {
t.Errorf("tag = %s/%s", prov, id)
}
}
func TestRecognize_PopulatesCandidates(t *testing.T) {
in := Input{Name: "Show.S01", Files: []File{{Path: "e1.mkv", Size: 1}}}
resp := `{"type":"series","title":"Show","year":2020,"confidence":0.9,
"files":[{"src":"e1.mkv","role":"episode","season":1,"episode":1}]}`
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
{Provider: "tvmaze", ID: "1", Title: "Show One", Year: 2020},
{Provider: "tvmaze", ID: "2", Title: "Show Two", Year: 2019},
}}
r := New(&fakeLLM{responses: []string{resp}}, []metadata.Provider{p}, Config{}, testLogger())
res, err := r.Recognize(context.Background(), in)
if err != nil {
t.Fatalf("Recognize: %v", err)
}
if res.Match != nil {
t.Errorf("strong match не ожидался")
}
if len(res.Candidates) != 2 {
t.Errorf("Result.Candidates = %d, want 2", len(res.Candidates))
}
}
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_MatchByOriginalFirst(t *testing.T) {
// Реальный кейс: русское релиз-имя, матч по оригинальному названию.
// При сильном матче по первому ключу дальнейшие запросы не делаются.
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
"The Dark Knight": {{Provider: "tmdb", ID: "155", Title: "The Dark Knight", Year: 2008}},
}}
r := recognizerWith(p)
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Тёмный рыцарь", OriginalTitle: "The Dark Knight", Year: 2008})
if m == nil || m.ProviderID != "155" {
t.Fatalf("should match by original title, got %+v", m)
}
if want := []string{"The Dark Knight"}; !slices.Equal(p.queries, want) {
t.Errorf("queries = %v, want %v (ранний стоп на оригинале)", p.queries, want)
}
}
func TestMatchMetadata_FallbackToTitle(t *testing.T) {
// Запрос по original ничего не дал — фолбэк на локализованный title.
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
"Wrong Original": {},
"Fargo": {{Provider: "tmdb", ID: "1", Title: "Fargo", Year: 2014}},
}}
r := recognizerWith(p)
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Fargo", OriginalTitle: "Wrong Original", Year: 2014})
if m == nil || m.ProviderID != "1" {
t.Fatalf("should fall back to title, got %+v", m)
}
if want := []string{"Wrong Original", "Fargo"}; !slices.Equal(p.queries, want) {
t.Errorf("queries = %v, want %v", p.queries, want)
}
}
func TestMatchMetadata_FallbackToProviderHint(t *testing.T) {
// Ни original, ни title не нашли — третий фолбэк по provider_hint.
// Гейт по-прежнему требует совпадения названия кандидата с title/original.
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
"Orig": {},
"Loc": {},
"Hint": {{Provider: "tmdb", ID: "7", Title: "Loc", Year: 2000}},
}}
r := recognizerWith(p)
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Loc", OriginalTitle: "Orig", ProviderHint: "Hint", Year: 2000})
if m == nil || m.ProviderID != "7" {
t.Fatalf("should fall back to provider_hint, got %+v", m)
}
if want := []string{"Orig", "Loc", "Hint"}; !slices.Equal(p.queries, want) {
t.Errorf("queries = %v, want %v", p.queries, want)
}
}
func TestMatchMetadata_SkipsDuplicateKey(t *testing.T) {
// Российский фильм: original_title дублирует title — база дёргается раз.
p := &fakeProvider{byTitle: map[string][]metadata.Candidate{
"Брат": {{Provider: "tmdb", ID: "1", Title: "Брат", Year: 1997}},
}}
r := recognizerWith(p)
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Брат", OriginalTitle: "Брат", Year: 1997})
if m == nil || m.ProviderID != "1" {
t.Fatalf("expected match, got %+v", m)
}
if p.searched != 1 {
t.Errorf("searched = %d, want 1 (дубль-ключ пропущен)", p.searched)
}
}
func TestMatchMetadata_TagFromExternal(t *testing.T) {
// TVMaze-стиль: нативный id для счёта серий, внешний TVDB-id для тега.
p := &fakeProvider{
name: "tvmaze",
candidates: []metadata.Candidate{
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
},
counts: map[int]int{1: 10},
}
r := recognizerWith(p)
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
if m == nil {
t.Fatal("expected match")
}
// Провенанс/тег — внешний TVDB-id, а не нативный tvmaze.
if m.Provider != "tvdb" || m.ProviderID != "269613" {
t.Errorf("match provider = %s/%s, want tvdb/269613", m.Provider, m.ProviderID)
}
if m.SeasonEpisodeCounts[1] != 10 {
t.Errorf("counts not fetched by native id: %+v", m.SeasonEpisodeCounts)
}
}
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 TestMatchMetadata_YearlessFallbackOffByOne(t *testing.T) {
// Год плана off-by-one: exact-year фильтр запроса отсекает запись в pass 1,
// безгодовой pass 2 её возвращает, гейт ±1 принимает → авто-матч.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "42", Title: "Harold and the Purple Crayon", Year: 2024},
}}
r := recognizerWith(p)
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Harold and the Purple Crayon", Year: 2023})
if m == nil || m.ProviderID != "42" {
t.Fatalf("off-by-one год: ожидался матч через безгодовой проход, got %+v", m)
}
if p.searched != 2 {
t.Errorf("searched = %d, want 2 (pass1 с годом + pass2 без года)", p.searched)
}
}
func TestMatchMetadata_YearlessFallbackBigGapReviewOnly(t *testing.T) {
// Год расходится больше чем на 1: pass 2 вернёт запись, но гейт по году её
// отклонит — подтверждённого матча нет, кандидат уходит в review.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "7", Title: "X", Year: 2005},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2010})
if m != nil {
t.Errorf("расхождение года >1: авто-матч не ожидался, got %+v", m)
}
if len(cands) != 1 || cands[0].ID != "7" {
t.Errorf("кандидат должен собраться для review: %+v", cands)
}
}
func TestMatchMetadata_YearlessUnknownYearReviewOnly(t *testing.T) {
// Год плана известен, но у записи в базе год неизвестен: pass 1 (с годом) её
// не находит, pass 2 (без года) находит, но в fallback требуется известный
// год → авто-матча нет, кандидат уходит в review.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "9", Title: "X", Year: 0},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2020})
if m != nil {
t.Errorf("год кандидата неизвестен: авто-матч не ожидался, got %+v", m)
}
if len(cands) != 1 || cands[0].ID != "9" {
t.Errorf("кандидат должен собраться для review: %+v", cands)
}
}
func TestMatchMetadata_NoSecondPassWhenPass1Matches(t *testing.T) {
// pass 1 подтвердил матч — безгодовой проход не выполняется.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "X", Year: 2000},
}}
r := recognizerWith(p)
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2000})
if m == nil {
t.Fatal("ожидался матч в pass 1")
}
if p.searched != 1 {
t.Errorf("searched = %d, want 1 (второго прохода быть не должно)", p.searched)
}
}
func TestMatchMetadata_NoSecondPassWhenYearUnknown(t *testing.T) {
// Год неизвестен (0): второй проход был бы идентичен первому — не делаем.
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Y", Year: 2000},
}}
r := recognizerWith(p)
// Название кандидата совпадает, но нет матча по названию плана — гейт не пройдёт;
// проверяем именно число запросов.
r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "Совсем другое"})
if p.searched != 1 {
t.Errorf("searched = %d, want 1 (год неизвестен → одного прохода достаточно)", p.searched)
}
}
func TestMatchMetadata_YearlessAmbiguousNoMatch(t *testing.T) {
// Несколько кандидатов из безгодового прохода, проходящих гейт → не подтверждён.
p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Twin", Year: 2001},
{Provider: "tmdb", ID: "2", Title: "Twin", Year: 2001},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Twin", Year: 2000})
if m != nil {
t.Errorf("неоднозначность из pass 2: матч не ожидался, got %+v", m)
}
if len(cands) != 2 {
t.Errorf("оба кандидата должны собраться для review: %+v", cands)
}
}
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)
}
}
}
func TestNormalize_FoldsHomoglyph(t *testing.T) {
// «Harold» с кириллической буквой-двойником 'а' (U+0430) в первом слове
// нормализуется к тому же виду, что и чистая латиница (defense-in-depth на
// стороне кандидата базы).
dirty := "Hаrold and the Purple Crayon" // 'а' — кириллица
clean := "Harold and the Purple Crayon"
if normalize(dirty) != normalize(clean) {
t.Errorf("normalize(%q)=%q != normalize(%q)=%q", dirty, normalize(dirty), clean, normalize(clean))
}
}
// Сквозной авто: 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)
}
}