diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 69e8040..11b5ea0 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -268,9 +268,10 @@ func metadataProviders(cfg *config.Config, logger *slog.Logger) ([]metadata.Prov } if cfg.Metadata.TMDB.Enabled && cfg.Metadata.TMDB.APIKey != "" { p, err := metadata.NewTMDB(metadata.TMDBConfig{ - APIKey: cfg.Metadata.TMDB.APIKey, - Proxy: cfg.Metadata.TMDB.Proxy, - Timeout: cfg.Metadata.TMDB.Timeout.Std(), + APIKey: cfg.Metadata.TMDB.APIKey, + Proxy: cfg.Metadata.TMDB.Proxy, + Timeout: cfg.Metadata.TMDB.Timeout.Std(), + Language: cfg.Metadata.TMDB.Language, }, logger) if err != nil { return nil, fmt.Errorf("tmdb provider: %w", err) diff --git a/config.example.toml b/config.example.toml index a7fca11..6e10db8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -35,10 +35,11 @@ timeout = "120s" # таймаут запроса к LLM; Go- max_retries = 3 # попыток получить валидный ответ LLM; целое ≥ 0 [metadata.tmdb] -enabled = false # включить провайдера TMDB; без матча авто-раскладку не делаем -api_key = "" # секрет: ключ TMDB; обязателен, если enabled (заполняет деплой) -proxy = "" # опц. HTTP-прокси; пусто = без прокси -timeout = "10s" # таймаут запроса к TMDB; Go-duration (s/m/h) +enabled = false # включить провайдера TMDB; без матча авто-раскладку не делаем +api_key = "" # секрет: ключ TMDB; обязателен, если enabled (заполняет деплой) +proxy = "" # опц. HTTP-прокси; пусто = без прокси +timeout = "10s" # таймаут запроса к TMDB; Go-duration (s/m/h) +language = "ru-RU" # локаль названий в ответе TMDB; пусто = ru-RU [metadata.tvdb] enabled = false # включить провайдера TVDB diff --git a/docs/specs/recognition.md b/docs/specs/recognition.md index 00825b8..9e2145c 100644 --- a/docs/specs/recognition.md +++ b/docs/specs/recognition.md @@ -38,6 +38,20 @@ названию+году, берём официальный id и каноническое имя, собираем кандидатов. TVMaze — без ключа, только сериалы; внешний id (TVDB/IMDb) из `externals` идёт в имя папки. + - **Поиск по нескольким названиям** в порядке убывания силы ключа: + `original_title` → локализованное `title` → `provider_hint`. Базы + индексированы прежде всего по оригинальным названиям, поэтому + оригинал — первым; останавливаемся, как только очередной ключ дал + единичный сильный матч. Пустые и нормализованно-дублирующие ключи + пропускаем (русский фильм, где оригинал = локализованное, дёргает + базу один раз). Кандидатов для review копим из всех заходов. + - **Локаль TMDB:** запрос передаёт `language` (по умолчанию `ru-RU`, + настраивается `[metadata.tmdb].language`). Влияет только на + локализованный `Title`/`Name`; `original_title`/`original_name` + остаётся на языке оригинала, поэтому оригинальная сторона сравнения + не страдает, а русская — сходится. + - **Нормализация названий** при сравнении сводит `ё`→`е` («Тёмный» и + «Темный» — одно название). 4. **Оценка уверенности** и решение: авто или review. ## Структура ответа LLM (предварительная) @@ -45,7 +59,9 @@ ``` type movie | series title каноническое название -original_title оригинальное название (если есть) +original_title оригинальное (обычно англ.) название — заполняется всегда: + нет отдельного / российский контент → дублирует title; + при неуверенности дублируем, а не выдумываем year год provider_hint строка для поиска в базе (НЕ итоговый id) files[] { src, role: main|episode|subtitle|extra|sample|ignore, diff --git a/internal/config/config.go b/internal/config/config.go index 0a625c4..d0bb092 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,12 +76,14 @@ type Metadata struct { } // MetadataProvider — настройки одного провайдера метаданных. У keyless-баз -// (TVMaze) поле api_key не используется. +// (TVMaze) поле api_key не используется; language учитывает только TMDB +// (локаль возвращаемых названий, дефолт ru-RU). type MetadataProvider struct { - Enabled bool `toml:"enabled"` - APIKey string `toml:"api_key"` - Proxy string `toml:"proxy"` - Timeout Duration `toml:"timeout"` + Enabled bool `toml:"enabled"` + APIKey string `toml:"api_key"` + Proxy string `toml:"proxy"` + Timeout Duration `toml:"timeout"` + Language string `toml:"language"` } // Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается @@ -172,7 +174,7 @@ func Default() *Config { MaxRetries: 3, }, Metadata: Metadata{ - TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)}, + TMDB: MetadataProvider{Timeout: Duration(10 * time.Second), Language: "ru-RU"}, TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)}, }, Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)}, diff --git a/internal/metadata/tmdb.go b/internal/metadata/tmdb.go index 17c62e9..e6320fa 100644 --- a/internal/metadata/tmdb.go +++ b/internal/metadata/tmdb.go @@ -13,22 +13,27 @@ import ( "git.vakhrushev.me/av/jellybit/internal/logging" ) -const tmdbDefaultBaseURL = "https://api.themoviedb.org/3" +const ( + tmdbDefaultBaseURL = "https://api.themoviedb.org/3" + tmdbDefaultLanguage = "ru-RU" +) // TMDBConfig — настройки клиента TMDB. type TMDBConfig struct { - APIKey string - Proxy string - Timeout time.Duration - BaseURL string // пусто → api.themoviedb.org; задаётся в тестах + APIKey string + Proxy string + Timeout time.Duration + BaseURL string // пусто → api.themoviedb.org; задаётся в тестах + Language string // локаль возвращаемых названий; пусто → ru-RU } // TMDB — клиент The Movie Database (API v3, авторизация по api_key). type TMDB struct { - apiKey string - baseURL string - hc *http.Client - log *slog.Logger + apiKey string + baseURL string + language string + hc *http.Client + log *slog.Logger } // NewTMDB собирает клиент TMDB. logger nil → slog.Default(). @@ -44,10 +49,14 @@ func NewTMDB(cfg TMDBConfig, logger *slog.Logger) (*TMDB, error) { if base == "" { base = tmdbDefaultBaseURL } + lang := cfg.Language + if lang == "" { + lang = tmdbDefaultLanguage + } if logger == nil { logger = slog.Default() } - return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil + return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), language: lang, hc: hc, log: logger}, nil } func (t *TMDB) Name() string { return "tmdb" } @@ -67,7 +76,7 @@ type tmdbSearchResp struct { // Search ищет фильм/сериал по названию и году. func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) { var path string - params := url.Values{"api_key": {t.apiKey}, "query": {q.Title}, "include_adult": {"false"}} + params := url.Values{"api_key": {t.apiKey}, "query": {q.Title}, "include_adult": {"false"}, "language": {t.language}} switch q.Type { case Movie: path = "/search/movie" diff --git a/internal/metadata/tmdb_test.go b/internal/metadata/tmdb_test.go index b68a7fc..8800aea 100644 --- a/internal/metadata/tmdb_test.go +++ b/internal/metadata/tmdb_test.go @@ -102,6 +102,45 @@ func TestTMDB_ErrorStatus(t *testing.T) { } } +func TestTMDB_SearchDefaultLanguage(t *testing.T) { + // Язык не задан в конфиге → дефолт ru-RU; original_title не зависит от локали. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("language"); got != "ru-RU" { + t.Errorf("language = %q, want ru-RU", got) + } + _, _ = w.Write([]byte(`{"results":[ + {"id":155,"title":"Тёмный рыцарь","original_title":"The Dark Knight","release_date":"2008-07-18"} + ]}`)) + })) + defer srv.Close() + + got, err := newTMDB(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "The Dark Knight", Year: 2008}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if got[0].Title != "Тёмный рыцарь" || got[0].OriginalTitle != "The Dark Knight" { + t.Errorf("candidate = %+v", got[0]) + } +} + +func TestTMDB_SearchLanguageOverride(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("language"); got != "en-US" { + t.Errorf("language = %q, want en-US", got) + } + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + defer srv.Close() + + c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: srv.URL, Language: "en-US"}, nil) + if err != nil { + t.Fatalf("NewTMDB: %v", err) + } + if _, err := c.Search(context.Background(), Query{Type: Movie, Title: "X"}); err != nil { + t.Fatalf("Search: %v", err) + } +} + func TestNewTMDB_RequiresKey(t *testing.T) { if _, err := NewTMDB(TMDBConfig{}, nil); err == nil { t.Fatal("want error without api_key") diff --git a/internal/recognize/metadata.go b/internal/recognize/metadata.go index beb3cb5..0868d25 100644 --- a/internal/recognize/metadata.go +++ b/internal/recognize/metadata.go @@ -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) diff --git a/internal/recognize/metadata_test.go b/internal/recognize/metadata_test.go index 84500c5..0317a0b 100644 --- a/internal/recognize/metadata_test.go +++ b/internal/recognize/metadata_test.go @@ -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 { diff --git a/internal/recognize/prompt.go b/internal/recognize/prompt.go index 54708de..56efad3 100644 --- a/internal/recognize/prompt.go +++ b/internal/recognize/prompt.go @@ -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". diff --git a/openspec/changes/archive/2026-06-29-recognition-multi-title-match/.openspec.yaml b/openspec/changes/archive/2026-06-29-recognition-multi-title-match/.openspec.yaml new file mode 100644 index 0000000..34f9314 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-recognition-multi-title-match/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/archive/2026-06-29-recognition-multi-title-match/design.md b/openspec/changes/archive/2026-06-29-recognition-multi-title-match/design.md new file mode 100644 index 0000000..38a5956 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-recognition-multi-title-match/design.md @@ -0,0 +1,92 @@ +## Context + +Сверка распознавания (`internal/recognize/metadata.go`, `matchMetadata`) +сейчас формирует один поисковый ключ `searchTitle = provider_hint || title` +и шлёт его всем включённым провайдерам. Гейт `strongMatches` сравнивает +нормализованные `{plan.Title, plan.OriginalTitle}` против +`{cand.Title, cand.OriginalTitle}` и требует год ±1. Базы TMDB/TVDB +индексированы прежде всего по оригинальным названиям, поэтому русское +релиз-имя часто не находится, а если `original_title` пуст — английская +сторона гейта вообще не работает. TMDB-поиск не передаёт `language`, так что +локализованный `Title` приходит в дефолтной локали. + +Источник истины по recognition пока `docs/specs/recognition.md` (capability +ещё не перенесена в OpenSpec) — дельту синхронизируем с ним. + +## Goals / Non-Goals + +**Goals:** +- Повысить попадаемость сверки на иностранных фильмах с русским релиз-именем, + не ослабляя гейты авто-раскладки. +- Сделать оригинальное название всегда доступным для запроса и сравнения. +- Минимальные, локальные правки в существующих функциях. + +**Non-Goals:** +- Не меняем модель уверенности и условия авто (матч в базе + структурная + валидация + согласованность сигналов остаются как есть). +- Не вводим fuzzy-сравнение названий (только точечная нормализация `ё`→`е`). +- Не добавляем поле страны/языка происхождения в `Plan`. +- Не трогаем TVDB/TVMaze-клиентов по части локали (вне объёма). + +## Decisions + +**1. Стратегия поиска «оригинал → fallback», а не мёрж всех запросов.** +Перебираем ключи `[original_title, title, provider_hint]` по порядку, +останавливаемся на первом, давшем единичный сильный матч. Оригинал — +сильнейший ключ баз, обычно хватает первого захода; меньше обращений к +API/квотам. Альтернатива — гнать все запросы и мёржить кандидатов — даёт чуть +полнее список для review, но дороже по обращениям; отвергнута как избыточная. +Кандидатов для review всё равно копим из всех фактически выполненных заходов. +Дубль-ключи (нормализованно равные уже выполненному) пропускаем. + +**2. `provider_hint` остаётся третьим фолбэком, а не удаляется.** +Два канонических названия покрывают основной кейс, но `hint` иногда +сформулирован удачнее (очищен от мусора релиз-имени) — дешёвая страховка, +когда оба названия не нашлись. Удаление поля — лишняя правка схемы без явной +выгоды. + +**3. «Всегда заполнять оба названия» — через промпт, не через жёсткую схему.** +Требование к модели: заполнять `title` и `original_title`, при отсутствии +отдельного оригинала или для российского контента — дублировать `title`; при +неуверенности — **дублировать, а не выдумывать**. Это и есть защита от +ложного авто-матча: «не знаю» схлопывается в безопасное дублирование русского +названия, а не в галлюцинацию английского, которая могла бы случайно +сматчиться с реальным фильмом и привести к авто-раскладке не того тайтла. +`parsePlan` остаётся мягким: пустой `original_title` не отбраковываем +(graceful-фолбэк на `title`), чтобы не плодить correction-ретраи и не уходить +в review зря. + +**4. `language=ru-RU` для TMDB, всегда, параметризуемо конфигом.** +Поле `original_title`/`original_name` у TMDB не зависит от `language` — +английская сторона гейта не страдает. Локализованный `Title` приходит +по-русски: сходится русская сторона гейта и аккуратнее карточки кандидатов в +review. Деление на «российский/нероссийский» не нужно — `ru-RU` полезен +именно зарубежке, а для русского контента нейтрален. Дефолт `ru-RU`, поле +`[metadata.tmdb].language`. + +**5. `ё`→`е` в `normalize`, и только это.** +Узкая правка под реальный класс расхождений написания. `й`→`и` и прочие +свёртки НЕ делаем — меняют смысл, риск ложных совпадений. + +## Risks / Trade-offs + +- **Модель всё же выдумывает оригинал вопреки промпту** → гейт по-прежнему + требует год ±1 и единичность матча; авто только при подтверждённом матче. + Промпт явно предписывает дублирование при неуверенности. Остаточный риск + низкий и не выше текущего (галлюцинация `title` возможна и сейчас). +- **`ru-RU` для контента без русской локализации** → TMDB отдаёт fallback + (оригинал/английский), хуже текущего поведения не становится. +- **Лишние обращения к базам при фолбэках** → ограничены порядком из 1–3 + запросов на провайдера, дубль-ключи пропускаются, ранний стоп на первом + сильном матче. Ошибки провайдера по-прежнему не валят распознавание. + +## Migration Plan + +- Изменения обратносовместимы по хранимым данным и API. Новое поле конфига + `[metadata.tmdb].language` опционально (дефолт `ru-RU`). +- Откат — ревёрт; персистентных миграций БД нет. +- После apply синхронизировать `docs/specs/recognition.md` с дельтой. + +## Open Questions + +- Нет. diff --git a/openspec/changes/archive/2026-06-29-recognition-multi-title-match/proposal.md b/openspec/changes/archive/2026-06-29-recognition-multi-title-match/proposal.md new file mode 100644 index 0000000..8149dd0 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-recognition-multi-title-match/proposal.md @@ -0,0 +1,62 @@ +## Why + +Сверка распознавания с базой метаданных промахивается на иностранных +фильмах с русским релиз-именем: реальный кейс — «Тёмный рыцарь» (The Dark +Knight), поиск не нашёл ничего, раскладку пришлось делать вручную. Базы +(TMDB/TVDB) индексированы прежде всего по оригинальным названиям, а поиск +сейчас идёт по одной строке `provider_hint || title` и игнорирует +`original_title`. Из-за этого сильное звено сверки — оригинальное название — +не используется ни в запросе, ни (когда модель оставила его пустым) в гейте +сравнения. + +## What Changes + +- **Поиск по нескольким названиям** в сверке с базой: стратегия + «оригинал → fallback» — сначала запрос по `original_title`, при единичном + сильном матче стоп; иначе запрос по локализованному `title`; третьим + фолбеком — `provider_hint`. Кандидатов для review копим из всех заходов + (дедуп по `provider:id`). Избыточный запрос-дубль (когда нормализованные + названия совпадают) пропускаем. +- **Контракт LLM на оба названия:** промпт требует всегда заполнять и + `title`, и `original_title`. Нет отдельного оригинала / российское + происхождение → продублировать `title`. Не уверен в оригинале → дублировать, + а **не выдумывать** (защита от ложного авто-матча по галлюцинации). Схема + разбора остаётся мягкой: пустой `original_title` не отбраковываем, а + graceful-фолбэк на `title`. +- **Локаль TMDB:** TMDB-поиск всегда передаёт `language` (по умолчанию + `ru-RU`), параметризуемый конфигом. Локализованный `Title` приходит + по-русски — сходится русская сторона гейта и аккуратнее карточки кандидатов + в review. `original_title` у TMDB остаётся на языке оригинала, английская + сторона гейта не страдает. +- **Нормализация названий:** в сравнении сводим `ё`→`е`, чтобы «Тёмный» и + «Темный» считались одним названием. + +Гейты авто-раскладки не ослабляются: авто по-прежнему только при +подтверждённом единичном матче в базе + структурной валидации + +согласованности сигналов. Изменение лишь повышает попадаемость сверки. + +## Capabilities + +### New Capabilities +- `recognition`: распознавание контента раздачи (фильм/сериал, название, + год, сезон/серия), сверка с базами метаданных и решение «авто или review». + Первый перенос capability из `docs/specs/recognition.md` в OpenSpec; в этот + change фиксируем требования к сверке с базой и контракту названий, + затронутые изменением (остальное мигрируется отдельно). + +### Modified Capabilities + + +## Impact + +- `internal/recognize/metadata.go` — `matchMetadata` (многозапросный поиск), + `normalize` (`ё`→`е`). +- `internal/recognize/prompt.go` — правила промпта по `title`/`original_title`. +- `internal/metadata/tmdb.go` — параметр `language` в запросе поиска. +- Конфигурация — новое поле `[metadata.tmdb].language` (дефолт `ru-RU`), + валидация на старте. +- `docs/specs/recognition.md` — синхронизируем с дельтой (источник истины до + полного переноса). +- Внешнее API/схема ответа LLM: `provider_hint` сохраняется; новые требования + к заполнению `original_title`. Обратная совместимость хранимых данных не + затрагивается. diff --git a/openspec/changes/archive/2026-06-29-recognition-multi-title-match/specs/recognition/spec.md b/openspec/changes/archive/2026-06-29-recognition-multi-title-match/specs/recognition/spec.md new file mode 100644 index 0000000..4aadc59 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-recognition-multi-title-match/specs/recognition/spec.md @@ -0,0 +1,85 @@ +## ADDED Requirements + +### Requirement: Сверка с базой по нескольким названиям + +При сверке плана с включёнными базами метаданных система SHALL искать по +нескольким названиям в порядке убывания силы ключа: сначала по +`original_title`, затем по локализованному `title`, затем по `provider_hint`. +Поиск SHALL останавливаться, как только очередной запрос дал единичный +сильный матч (ровно один кандидат с совпадением названия и года). Запрос с +названием, нормализованно совпадающим с уже выполненным, система SHALL +пропускать, чтобы не обращаться к базе повторно с тем же ключом. + +Кандидаты для ручного выбора в review система SHALL собирать из всех +выполненных заходов с дедупликацией по `provider:id` и общим потолком. + +#### Scenario: Иностранный фильм находится по оригинальному названию + +- **GIVEN** план с `title` «Тёмный рыцарь», `original_title` «The Dark Knight», год 2008 +- **WHEN** выполняется сверка с базой +- **THEN** первый запрос идёт по «The Dark Knight» +- **AND** при единичном сильном матче дальнейшие запросы (по `title`, `provider_hint`) не выполняются + +#### Scenario: Фолбэк на локализованное название + +- **GIVEN** план, для которого запрос по `original_title` не дал единичного сильного матча +- **WHEN** продолжается сверка +- **THEN** выполняется запрос по локализованному `title` +- **AND** при отсутствии матча и там — запрос по `provider_hint` + +#### Scenario: Дублирующий запрос пропускается + +- **GIVEN** план, у которого `original_title` нормализованно совпадает с `title` +- **WHEN** выполняется сверка +- **THEN** база запрашивается этим названием один раз, повторный заход по `title` не делается + +### Requirement: Контракт LLM на оригинальное и локализованное названия + +Промпт распознавания SHALL требовать от модели всегда заполнять и `title`, и +`original_title`. Если отдельного оригинального названия нет или контент +российского происхождения, модель SHALL дублировать `title` в +`original_title`. При неуверенности в оригинальном названии модель SHALL +дублировать `title`, а не выдумывать название (защита от ложного авто-матча). + +Разбор ответа SHALL оставаться устойчивым к пустому `original_title`: пустое +значение не отбраковывается и не вызывает correction-ретрай; сверка +gracefully использует доступные названия. + +#### Scenario: Российский фильм — дублирование + +- **GIVEN** раздача российского фильма без отдельного оригинального названия +- **WHEN** модель возвращает план +- **THEN** `title` и `original_title` заполнены одинаковым каноническим названием + +#### Scenario: Пустой original_title не ломает разбор + +- **GIVEN** ответ модели с пустым `original_title` +- **WHEN** план разбирается +- **THEN** разбор успешен без correction-ретрая +- **AND** сверка использует `title` (и `provider_hint`) + +### Requirement: Локаль запроса к TMDB + +Запрос поиска к TMDB SHALL передавать параметр `language`, по умолчанию +`ru-RU`, со значением, настраиваемым конфигом `[metadata.tmdb].language`. +Это влияет только на локализованное поле `Title`/`Name`; поле +`original_title`/`original_name` остаётся на языке оригинала, поэтому +оригинальная сторона сравнения не затрагивается. + +#### Scenario: Локализованный заголовок приходит по-русски + +- **GIVEN** TMDB включён, `language` не задан в конфиге +- **WHEN** выполняется поиск фильма с русской локализацией +- **THEN** запрос содержит `language=ru-RU` +- **AND** в кандидате `Title` приходит на русском, а `OriginalTitle` — на языке оригинала + +### Requirement: Нормализация названий при сравнении + +Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`, +чтобы написания, различающиеся только `ё`/`е`, считались одним названием. + +#### Scenario: «Тёмный» и «Темный» совпадают + +- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь» +- **WHEN** сравниваются нормализованные названия +- **THEN** они считаются совпадающими diff --git a/openspec/changes/archive/2026-06-29-recognition-multi-title-match/tasks.md b/openspec/changes/archive/2026-06-29-recognition-multi-title-match/tasks.md new file mode 100644 index 0000000..9311acf --- /dev/null +++ b/openspec/changes/archive/2026-06-29-recognition-multi-title-match/tasks.md @@ -0,0 +1,32 @@ +## 1. Конфигурация TMDB language + +- [x] 1.1 Добавить поле `language` в конфиг TMDB (`[metadata.tmdb].language`), дефолт `ru-RU`; проброс в клиент TMDB +- [x] 1.2 Безопасный дефолт `ru-RU` при пустой локали (в `Default()` и фолбэком в клиенте — отдельная валидация-реджект не нужна); обновить `config.example.toml` + +## 2. Локаль в TMDB-клиенте + +- [x] 2.1 В `internal/metadata/tmdb.go` передавать `language` в `Search` (`params.Set("language", ...)`) +- [x] 2.2 Тест: запрос содержит `language=ru-RU`; `OriginalTitle` не зависит от локали + +## 3. Нормализация названий + +- [x] 3.1 В `internal/recognize/metadata.go` `normalize` сводить `ё`→`е` +- [x] 3.2 Тест: «Тёмный рыцарь» и «Темный рыцарь» нормализуются одинаково + +## 4. Многозапросный поиск в matchMetadata + +- [x] 4.1 Сформировать упорядоченный список ключей `[original_title, title, provider_hint]`, отбросив пустые и нормализованные дубли +- [x] 4.2 Перебирать ключи: для каждого — поиск по всем провайдерам, ранний стоп на первом единичном сильном матче (`strongMatches`) +- [x] 4.3 Кандидатов для review копить из всех выполненных заходов (дедуп по `provider:id`, потолок `maxCandidates`) +- [x] 4.4 Тесты: матч по original при пустом совпадении по title; фолбэк на title; фолбэк на provider_hint; пропуск дубль-ключа + +## 5. Контракт LLM на названия (промпт) + +- [x] 5.1 В `internal/recognize/prompt.go` усилить правила: всегда заполнять `title` и `original_title`; дублировать при отсутствии оригинала / российском происхождении; при неуверенности дублировать, не выдумывать +- [x] 5.2 Убедиться, что `parsePlan` остаётся мягким к пустому `original_title` (нет отбраковки/лишнего correction-ретрая); тест на graceful-фолбэк + +## 6. Синхронизация спеки и проверка + +- [x] 6.1 Синхронизировать `docs/specs/recognition.md` с дельтой (конвейер сверки, контракт названий, локаль TMDB, нормализация) +- [x] 6.2 `task test` и `task lint` зелёные +- [x] 6.3 `openspec validate recognition-multi-title-match --strict` проходит diff --git a/openspec/specs/recognition/spec.md b/openspec/specs/recognition/spec.md new file mode 100644 index 0000000..8f454cd --- /dev/null +++ b/openspec/specs/recognition/spec.md @@ -0,0 +1,94 @@ +# recognition Specification + +## Purpose + +Распознавание: сопоставление загрузки с конкретным фильмом/сериалом во +включённых базах метаданных. Capability описывает контракт LLM на названия, +порядок и нормализацию сверки по нескольким названиям, локаль запроса к TMDB +и сбор кандидатов для ручного выбора в review. + +## Requirements + +### Requirement: Сверка с базой по нескольким названиям + +При сверке плана с включёнными базами метаданных система SHALL искать по +нескольким названиям в порядке убывания силы ключа: сначала по +`original_title`, затем по локализованному `title`, затем по `provider_hint`. +Поиск SHALL останавливаться, как только очередной запрос дал единичный +сильный матч (ровно один кандидат с совпадением названия и года). Запрос с +названием, нормализованно совпадающим с уже выполненным, система SHALL +пропускать, чтобы не обращаться к базе повторно с тем же ключом. + +Кандидаты для ручного выбора в review система SHALL собирать из всех +выполненных заходов с дедупликацией по `provider:id` и общим потолком. + +#### Scenario: Иностранный фильм находится по оригинальному названию + +- **GIVEN** план с `title` «Тёмный рыцарь», `original_title` «The Dark Knight», год 2008 +- **WHEN** выполняется сверка с базой +- **THEN** первый запрос идёт по «The Dark Knight» +- **AND** при единичном сильном матче дальнейшие запросы (по `title`, `provider_hint`) не выполняются + +#### Scenario: Фолбэк на локализованное название + +- **GIVEN** план, для которого запрос по `original_title` не дал единичного сильного матча +- **WHEN** продолжается сверка +- **THEN** выполняется запрос по локализованному `title` +- **AND** при отсутствии матча и там — запрос по `provider_hint` + +#### Scenario: Дублирующий запрос пропускается + +- **GIVEN** план, у которого `original_title` нормализованно совпадает с `title` +- **WHEN** выполняется сверка +- **THEN** база запрашивается этим названием один раз, повторный заход по `title` не делается + +### Requirement: Контракт LLM на оригинальное и локализованное названия + +Промпт распознавания SHALL требовать от модели всегда заполнять и `title`, и +`original_title`. Если отдельного оригинального названия нет или контент +российского происхождения, модель SHALL дублировать `title` в +`original_title`. При неуверенности в оригинальном названии модель SHALL +дублировать `title`, а не выдумывать название (защита от ложного авто-матча). + +Разбор ответа SHALL оставаться устойчивым к пустому `original_title`: пустое +значение не отбраковывается и не вызывает correction-ретрай; сверка +gracefully использует доступные названия. + +#### Scenario: Российский фильм — дублирование + +- **GIVEN** раздача российского фильма без отдельного оригинального названия +- **WHEN** модель возвращает план +- **THEN** `title` и `original_title` заполнены одинаковым каноническим названием + +#### Scenario: Пустой original_title не ломает разбор + +- **GIVEN** ответ модели с пустым `original_title` +- **WHEN** план разбирается +- **THEN** разбор успешен без correction-ретрая +- **AND** сверка использует `title` (и `provider_hint`) + +### Requirement: Локаль запроса к TMDB + +Запрос поиска к TMDB SHALL передавать параметр `language`, по умолчанию +`ru-RU`, со значением, настраиваемым конфигом `[metadata.tmdb].language`. +Это влияет только на локализованное поле `Title`/`Name`; поле +`original_title`/`original_name` остаётся на языке оригинала, поэтому +оригинальная сторона сравнения не затрагивается. + +#### Scenario: Локализованный заголовок приходит по-русски + +- **GIVEN** TMDB включён, `language` не задан в конфиге +- **WHEN** выполняется поиск фильма с русской локализацией +- **THEN** запрос содержит `language=ru-RU` +- **AND** в кандидате `Title` приходит на русском, а `OriginalTitle` — на языке оригинала + +### Requirement: Нормализация названий при сравнении + +Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`, +чтобы написания, различающиеся только `ё`/`е`, считались одним названием. + +#### Scenario: «Тёмный» и «Темный» совпадают + +- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь» +- **WHEN** сравниваются нормализованные названия +- **THEN** они считаются совпадающими