Распознавание: санитайзинг названий от 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>
This commit is contained in:
@@ -15,6 +15,7 @@ type fakeProvider struct {
|
||||
byTitle map[string][]metadata.Candidate // если задано — результат зависит от запроса
|
||||
counts map[int]int
|
||||
searchErr error
|
||||
exactYear bool // имитирует жёсткий year-фильтр базы: q.Year>0 отсекает по точному году
|
||||
searched int
|
||||
queries []string // строки запросов в порядке вызова
|
||||
}
|
||||
@@ -31,10 +32,20 @@ func (f *fakeProvider) Search(_ context.Context, q metadata.Query) ([]metadata.C
|
||||
if f.searchErr != nil {
|
||||
return nil, f.searchErr
|
||||
}
|
||||
res := f.candidates
|
||||
if f.byTitle != nil {
|
||||
return f.byTitle[q.Title], nil
|
||||
res = f.byTitle[q.Title]
|
||||
}
|
||||
return f.candidates, nil
|
||||
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
|
||||
@@ -264,6 +275,105 @@ func TestMatchMetadata_Disabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -280,6 +390,17 @@ func TestNormalize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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}}}
|
||||
|
||||
Reference in New Issue
Block a user