Поиск по нескольким названиям при сверке с базой (recognition)

Сверка с метабазой промахивалась на иностранных фильмах с русским
релиз-именем (кейс «Тёмный рыцарь»): поиск шёл по одной строке
provider_hint||title и игнорировал original_title, а базы индексированы
прежде всего по оригинальным названиям.

- matchMetadata ищет по ключам original_title → title → provider_hint с
  ранним стопом на первом единичном сильном матче; пустые и
  нормализованно-дублирующие ключи пропускаются, кандидаты для review
  копятся из всех заходов.
- Промпт требует всегда заполнять title и original_title (дублировать при
  отсутствии оригинала / российском контенте; при неуверенности дублировать,
  не выдумывать). Разбор остаётся мягким к пустому original_title.
- TMDB-поиск передаёт language (по умолчанию ru-RU, настраивается
  [metadata.tmdb].language); original_title не зависит от локали.
- Нормализация названий сводит ё→е.

Инварианты не ослаблены: авто только при подтверждённом единичном матче +
структурной валидации + согласованности сигналов.

Capability recognition впервые перенесена в OpenSpec; change архивирован.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
av
2026-06-29 12:16:47 +03:00
co-authored by Claude Opus 4.8
parent cc7e51b3a4
commit 4fc8c41b3a
15 changed files with 611 additions and 58 deletions
+62 -30
View File
@@ -15,8 +15,13 @@ const maxCandidates = 8
// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
// сильный матч — ровно один кандидат с совпадением названия и года (для него
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
// из всех провайдеров (топ-N, дедуп) — чтобы человек мог выбрать в review,
// когда сильного матча нет. Ошибки провайдера не валят распознавание.
// из всех выполненных заходов (топ-N, дедуп) — чтобы человек мог выбрать в
// review, когда сильного матча нет. Ошибки провайдера не валят распознавание.
//
// Поиск идёт по нескольким названиям в порядке убывания силы ключа
// (original_title → title → provider_hint, см. searchKeys): базы индексированы
// прежде всего по оригинальным названиям. Останавливаемся, как только очередной
// ключ дал единичный сильный матч (ранний стоп — дешевле по обращениям к базе).
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
if len(r.providers) == 0 {
return nil, nil
@@ -26,48 +31,71 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
mt = metadata.Series
}
searchTitle := plan.ProviderHint
if strings.TrimSpace(searchTitle) == "" {
searchTitle = plan.Title
}
matchTitles := normSet(plan.Title, plan.OriginalTitle)
var match *Match
var candidates []metadata.Candidate
seen := map[string]bool{}
for _, p := range r.providers {
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
if err != nil {
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
// доменное решение «пропускаем провайдера, пробуем следующий».
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
continue
}
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
for _, c := range cands {
key := c.Provider + ":" + c.ID
if seen[key] || len(candidates) >= maxCandidates {
for _, key := range searchKeys(plan) {
for _, p := range r.providers {
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: plan.Year})
if err != nil {
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
// доменное решение «пропускаем провайдера, пробуем следующий».
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
continue
}
seen[key] = true
candidates = append(candidates, c)
}
// Единичный сильный матч ищем у первого подходящего провайдера.
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
for _, c := range cands {
ck := c.Provider + ":" + c.ID
if seen[ck] || len(candidates) >= maxCandidates {
continue
}
seen[ck] = true
candidates = append(candidates, c)
}
// Единичный сильный матч ищем у первого подходящего провайдера.
if match != nil {
continue
}
strong := strongMatches(cands, plan.Year, matchTitles)
if len(strong) != 1 {
continue
}
match = r.buildMatch(ctx, p, strong[0], mt)
}
if match != nil {
continue
break
}
strong := strongMatches(cands, plan.Year, matchTitles)
if len(strong) != 1 {
continue
}
match = r.buildMatch(ctx, p, strong[0], mt)
}
return match, candidates
}
// searchKeys строит ключи поиска по базе в порядке убывания силы:
// original_title → title → provider_hint. Пустые и нормализованно совпадающие
// с уже добавленным ключом пропускаем, чтобы не обращаться к базе дважды с тем
// же запросом (частый случай — российский фильм, где original_title дублирует
// title).
func searchKeys(plan Plan) []string {
var keys []string
seen := map[string]bool{}
for _, t := range []string{plan.OriginalTitle, plan.Title, plan.ProviderHint} {
if strings.TrimSpace(t) == "" {
continue
}
n := normalize(t)
if n == "" || seen[n] {
continue
}
seen[n] = true
keys = append(keys, t)
}
return keys
}
// buildMatch тянет число серий (по нативному id) и собирает Match с
// тег-предпочтительным провенансом.
func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c metadata.Candidate, mt metadata.MediaType) *Match {
@@ -146,11 +174,15 @@ func normSet(titles ...string) map[string]bool {
}
// normalize приводит название к сравнимому виду: нижний регистр, только
// буквы/цифры (юникод), одиночные пробелы.
// буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое
// расхождение написания: «Тёмный» vs «Темный»).
func normalize(s string) string {
var b strings.Builder
prevSpace := false
for _, r := range strings.ToLower(s) {
if r == 'ё' {
r = 'е'
}
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
+83 -2
View File
@@ -3,6 +3,7 @@ package recognize
import (
"context"
"errors"
"slices"
"testing"
"git.vakhrushev.me/av/jellybit/internal/metadata"
@@ -11,9 +12,11 @@ import (
type fakeProvider struct {
name string
candidates []metadata.Candidate
byTitle map[string][]metadata.Candidate // если задано — результат зависит от запроса
counts map[int]int
searchErr error
searched int
queries []string // строки запросов в порядке вызова
}
func (f *fakeProvider) Name() string {
@@ -22,9 +25,16 @@ func (f *fakeProvider) Name() string {
}
return f.name
}
func (f *fakeProvider) Search(_ context.Context, _ metadata.Query) ([]metadata.Candidate, error) {
func (f *fakeProvider) Search(_ context.Context, q metadata.Query) ([]metadata.Candidate, error) {
f.searched++
return f.candidates, f.searchErr
f.queries = append(f.queries, q.Title)
if f.searchErr != nil {
return nil, f.searchErr
}
if f.byTitle != nil {
return f.byTitle[q.Title], nil
}
return f.candidates, nil
}
func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) {
return f.counts, nil
@@ -132,6 +142,75 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) {
}
}
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{
@@ -191,6 +270,8 @@ func TestNormalize(t *testing.T) {
"Léon: The Pro!": "léon the pro",
" A B ": "a b",
"Привет, Мир": "привет мир",
"Тёмный рыцарь": "темный рыцарь", // ё → е
"ЁЖ": "еж",
}
for in, want := range cases {
if got := normalize(in); got != want {
+6 -1
View File
@@ -31,7 +31,7 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
{
"type": "movie" | "series",
"title": "каноническое название",
"original_title": "оригинальное название или пустая строка",
"original_title": "оригинальное название (заполняй всегда, см. правила)",
"year": число или 0,
"provider_hint": "строка для поиска в базе (НЕ id)",
"files": [
@@ -47,6 +47,11 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
}
Правила:
- Всегда заполняй И "title", И "original_title". Базы метаданных ищут прежде
всего по оригинальному (обычно английскому) названию, поэтому "original_title"
важен. Если отдельного оригинального названия нет или контент российского
происхождения — продублируй "title" в "original_title". Если НЕ уверен в
оригинальном названии — продублируй "title", но НЕ выдумывай название.
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
- Для фильма ровно один основной видеофайл role "main".