Поиск по нескольким названиям при сверке с базой (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:
@@ -268,9 +268,10 @@ func metadataProviders(cfg *config.Config, logger *slog.Logger) ([]metadata.Prov
|
|||||||
}
|
}
|
||||||
if cfg.Metadata.TMDB.Enabled && cfg.Metadata.TMDB.APIKey != "" {
|
if cfg.Metadata.TMDB.Enabled && cfg.Metadata.TMDB.APIKey != "" {
|
||||||
p, err := metadata.NewTMDB(metadata.TMDBConfig{
|
p, err := metadata.NewTMDB(metadata.TMDBConfig{
|
||||||
APIKey: cfg.Metadata.TMDB.APIKey,
|
APIKey: cfg.Metadata.TMDB.APIKey,
|
||||||
Proxy: cfg.Metadata.TMDB.Proxy,
|
Proxy: cfg.Metadata.TMDB.Proxy,
|
||||||
Timeout: cfg.Metadata.TMDB.Timeout.Std(),
|
Timeout: cfg.Metadata.TMDB.Timeout.Std(),
|
||||||
|
Language: cfg.Metadata.TMDB.Language,
|
||||||
}, logger)
|
}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("tmdb provider: %w", err)
|
return nil, fmt.Errorf("tmdb provider: %w", err)
|
||||||
|
|||||||
+5
-4
@@ -35,10 +35,11 @@ timeout = "120s" # таймаут запроса к LLM; Go-
|
|||||||
max_retries = 3 # попыток получить валидный ответ LLM; целое ≥ 0
|
max_retries = 3 # попыток получить валидный ответ LLM; целое ≥ 0
|
||||||
|
|
||||||
[metadata.tmdb]
|
[metadata.tmdb]
|
||||||
enabled = false # включить провайдера TMDB; без матча авто-раскладку не делаем
|
enabled = false # включить провайдера TMDB; без матча авто-раскладку не делаем
|
||||||
api_key = "" # секрет: ключ TMDB; обязателен, если enabled (заполняет деплой)
|
api_key = "" # секрет: ключ TMDB; обязателен, если enabled (заполняет деплой)
|
||||||
proxy = "" # опц. HTTP-прокси; пусто = без прокси
|
proxy = "" # опц. HTTP-прокси; пусто = без прокси
|
||||||
timeout = "10s" # таймаут запроса к TMDB; Go-duration (s/m/h)
|
timeout = "10s" # таймаут запроса к TMDB; Go-duration (s/m/h)
|
||||||
|
language = "ru-RU" # локаль названий в ответе TMDB; пусто = ru-RU
|
||||||
|
|
||||||
[metadata.tvdb]
|
[metadata.tvdb]
|
||||||
enabled = false # включить провайдера TVDB
|
enabled = false # включить провайдера TVDB
|
||||||
|
|||||||
@@ -38,6 +38,20 @@
|
|||||||
названию+году, берём официальный id и каноническое имя, собираем
|
названию+году, берём официальный id и каноническое имя, собираем
|
||||||
кандидатов. TVMaze — без ключа, только сериалы; внешний id
|
кандидатов. TVMaze — без ключа, только сериалы; внешний id
|
||||||
(TVDB/IMDb) из `externals` идёт в имя папки.
|
(TVDB/IMDb) из `externals` идёт в имя папки.
|
||||||
|
- **Поиск по нескольким названиям** в порядке убывания силы ключа:
|
||||||
|
`original_title` → локализованное `title` → `provider_hint`. Базы
|
||||||
|
индексированы прежде всего по оригинальным названиям, поэтому
|
||||||
|
оригинал — первым; останавливаемся, как только очередной ключ дал
|
||||||
|
единичный сильный матч. Пустые и нормализованно-дублирующие ключи
|
||||||
|
пропускаем (русский фильм, где оригинал = локализованное, дёргает
|
||||||
|
базу один раз). Кандидатов для review копим из всех заходов.
|
||||||
|
- **Локаль TMDB:** запрос передаёт `language` (по умолчанию `ru-RU`,
|
||||||
|
настраивается `[metadata.tmdb].language`). Влияет только на
|
||||||
|
локализованный `Title`/`Name`; `original_title`/`original_name`
|
||||||
|
остаётся на языке оригинала, поэтому оригинальная сторона сравнения
|
||||||
|
не страдает, а русская — сходится.
|
||||||
|
- **Нормализация названий** при сравнении сводит `ё`→`е` («Тёмный» и
|
||||||
|
«Темный» — одно название).
|
||||||
4. **Оценка уверенности** и решение: авто или review.
|
4. **Оценка уверенности** и решение: авто или review.
|
||||||
|
|
||||||
## Структура ответа LLM (предварительная)
|
## Структура ответа LLM (предварительная)
|
||||||
@@ -45,7 +59,9 @@
|
|||||||
```
|
```
|
||||||
type movie | series
|
type movie | series
|
||||||
title каноническое название
|
title каноническое название
|
||||||
original_title оригинальное название (если есть)
|
original_title оригинальное (обычно англ.) название — заполняется всегда:
|
||||||
|
нет отдельного / российский контент → дублирует title;
|
||||||
|
при неуверенности дублируем, а не выдумываем
|
||||||
year год
|
year год
|
||||||
provider_hint строка для поиска в базе (НЕ итоговый id)
|
provider_hint строка для поиска в базе (НЕ итоговый id)
|
||||||
files[] { src, role: main|episode|subtitle|extra|sample|ignore,
|
files[] { src, role: main|episode|subtitle|extra|sample|ignore,
|
||||||
|
|||||||
@@ -76,12 +76,14 @@ type Metadata struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MetadataProvider — настройки одного провайдера метаданных. У keyless-баз
|
// MetadataProvider — настройки одного провайдера метаданных. У keyless-баз
|
||||||
// (TVMaze) поле api_key не используется.
|
// (TVMaze) поле api_key не используется; language учитывает только TMDB
|
||||||
|
// (локаль возвращаемых названий, дефолт ru-RU).
|
||||||
type MetadataProvider struct {
|
type MetadataProvider struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
APIKey string `toml:"api_key"`
|
APIKey string `toml:"api_key"`
|
||||||
Proxy string `toml:"proxy"`
|
Proxy string `toml:"proxy"`
|
||||||
Timeout Duration `toml:"timeout"`
|
Timeout Duration `toml:"timeout"`
|
||||||
|
Language string `toml:"language"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается
|
// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается
|
||||||
@@ -172,7 +174,7 @@ func Default() *Config {
|
|||||||
MaxRetries: 3,
|
MaxRetries: 3,
|
||||||
},
|
},
|
||||||
Metadata: Metadata{
|
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)},
|
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
|
||||||
},
|
},
|
||||||
Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
|
Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
|
||||||
|
|||||||
+20
-11
@@ -13,22 +13,27 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
"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.
|
// TMDBConfig — настройки клиента TMDB.
|
||||||
type TMDBConfig struct {
|
type TMDBConfig struct {
|
||||||
APIKey string
|
APIKey string
|
||||||
Proxy string
|
Proxy string
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
BaseURL string // пусто → api.themoviedb.org; задаётся в тестах
|
BaseURL string // пусто → api.themoviedb.org; задаётся в тестах
|
||||||
|
Language string // локаль возвращаемых названий; пусто → ru-RU
|
||||||
}
|
}
|
||||||
|
|
||||||
// TMDB — клиент The Movie Database (API v3, авторизация по api_key).
|
// TMDB — клиент The Movie Database (API v3, авторизация по api_key).
|
||||||
type TMDB struct {
|
type TMDB struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
baseURL string
|
baseURL string
|
||||||
hc *http.Client
|
language string
|
||||||
log *slog.Logger
|
hc *http.Client
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTMDB собирает клиент TMDB. logger nil → slog.Default().
|
// NewTMDB собирает клиент TMDB. logger nil → slog.Default().
|
||||||
@@ -44,10 +49,14 @@ func NewTMDB(cfg TMDBConfig, logger *slog.Logger) (*TMDB, error) {
|
|||||||
if base == "" {
|
if base == "" {
|
||||||
base = tmdbDefaultBaseURL
|
base = tmdbDefaultBaseURL
|
||||||
}
|
}
|
||||||
|
lang := cfg.Language
|
||||||
|
if lang == "" {
|
||||||
|
lang = tmdbDefaultLanguage
|
||||||
|
}
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
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" }
|
func (t *TMDB) Name() string { return "tmdb" }
|
||||||
@@ -67,7 +76,7 @@ type tmdbSearchResp struct {
|
|||||||
// Search ищет фильм/сериал по названию и году.
|
// Search ищет фильм/сериал по названию и году.
|
||||||
func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||||
var path string
|
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 {
|
switch q.Type {
|
||||||
case Movie:
|
case Movie:
|
||||||
path = "/search/movie"
|
path = "/search/movie"
|
||||||
|
|||||||
@@ -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) {
|
func TestNewTMDB_RequiresKey(t *testing.T) {
|
||||||
if _, err := NewTMDB(TMDBConfig{}, nil); err == nil {
|
if _, err := NewTMDB(TMDBConfig{}, nil); err == nil {
|
||||||
t.Fatal("want error without api_key")
|
t.Fatal("want error without api_key")
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ const maxCandidates = 8
|
|||||||
// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
|
// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
|
||||||
// сильный матч — ровно один кандидат с совпадением названия и года (для него
|
// сильный матч — ровно один кандидат с совпадением названия и года (для него
|
||||||
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
|
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
|
||||||
// из всех провайдеров (топ-N, дедуп) — чтобы человек мог выбрать в review,
|
// из всех выполненных заходов (топ-N, дедуп) — чтобы человек мог выбрать в
|
||||||
// когда сильного матча нет. Ошибки провайдера не валят распознавание.
|
// review, когда сильного матча нет. Ошибки провайдера не валят распознавание.
|
||||||
|
//
|
||||||
|
// Поиск идёт по нескольким названиям в порядке убывания силы ключа
|
||||||
|
// (original_title → title → provider_hint, см. searchKeys): базы индексированы
|
||||||
|
// прежде всего по оригинальным названиям. Останавливаемся, как только очередной
|
||||||
|
// ключ дал единичный сильный матч (ранний стоп — дешевле по обращениям к базе).
|
||||||
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
|
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
|
||||||
if len(r.providers) == 0 {
|
if len(r.providers) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -26,48 +31,71 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
|
|||||||
mt = metadata.Series
|
mt = metadata.Series
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTitle := plan.ProviderHint
|
|
||||||
if strings.TrimSpace(searchTitle) == "" {
|
|
||||||
searchTitle = plan.Title
|
|
||||||
}
|
|
||||||
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
||||||
|
|
||||||
var match *Match
|
var match *Match
|
||||||
var candidates []metadata.Candidate
|
var candidates []metadata.Candidate
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|
||||||
for _, p := range r.providers {
|
for _, key := range searchKeys(plan) {
|
||||||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
|
for _, p := range r.providers {
|
||||||
if err != nil {
|
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: plan.Year})
|
||||||
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
|
if err != nil {
|
||||||
// доменное решение «пропускаем провайдера, пробуем следующий».
|
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
|
||||||
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
|
// доменное решение «пропускаем провайдера, пробуем следующий».
|
||||||
continue
|
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
|
||||||
}
|
|
||||||
|
|
||||||
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
|
||||||
for _, c := range cands {
|
|
||||||
key := c.Provider + ":" + c.ID
|
|
||||||
if seen[key] || len(candidates) >= maxCandidates {
|
|
||||||
continue
|
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 {
|
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
|
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 с
|
// buildMatch тянет число серий (по нативному id) и собирает Match с
|
||||||
// тег-предпочтительным провенансом.
|
// тег-предпочтительным провенансом.
|
||||||
func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c metadata.Candidate, mt metadata.MediaType) *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 приводит название к сравнимому виду: нижний регистр, только
|
// normalize приводит название к сравнимому виду: нижний регистр, только
|
||||||
// буквы/цифры (юникод), одиночные пробелы.
|
// буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое
|
||||||
|
// расхождение написания: «Тёмный» vs «Темный»).
|
||||||
func normalize(s string) string {
|
func normalize(s string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
prevSpace := false
|
prevSpace := false
|
||||||
for _, r := range strings.ToLower(s) {
|
for _, r := range strings.ToLower(s) {
|
||||||
|
if r == 'ё' {
|
||||||
|
r = 'е'
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package recognize
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||||
@@ -11,9 +12,11 @@ import (
|
|||||||
type fakeProvider struct {
|
type fakeProvider struct {
|
||||||
name string
|
name string
|
||||||
candidates []metadata.Candidate
|
candidates []metadata.Candidate
|
||||||
|
byTitle map[string][]metadata.Candidate // если задано — результат зависит от запроса
|
||||||
counts map[int]int
|
counts map[int]int
|
||||||
searchErr error
|
searchErr error
|
||||||
searched int
|
searched int
|
||||||
|
queries []string // строки запросов в порядке вызова
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeProvider) Name() string {
|
func (f *fakeProvider) Name() string {
|
||||||
@@ -22,9 +25,16 @@ func (f *fakeProvider) Name() string {
|
|||||||
}
|
}
|
||||||
return f.name
|
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++
|
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) {
|
func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) {
|
||||||
return f.counts, nil
|
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) {
|
func TestMatchMetadata_TagFromExternal(t *testing.T) {
|
||||||
// TVMaze-стиль: нативный id для счёта серий, внешний TVDB-id для тега.
|
// TVMaze-стиль: нативный id для счёта серий, внешний TVDB-id для тега.
|
||||||
p := &fakeProvider{
|
p := &fakeProvider{
|
||||||
@@ -191,6 +270,8 @@ func TestNormalize(t *testing.T) {
|
|||||||
"Léon: The Pro!": "léon the pro",
|
"Léon: The Pro!": "léon the pro",
|
||||||
" A B ": "a b",
|
" A B ": "a b",
|
||||||
"Привет, Мир": "привет мир",
|
"Привет, Мир": "привет мир",
|
||||||
|
"Тёмный рыцарь": "темный рыцарь", // ё → е
|
||||||
|
"ЁЖ": "еж",
|
||||||
}
|
}
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
if got := normalize(in); got != want {
|
if got := normalize(in); got != want {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
|||||||
{
|
{
|
||||||
"type": "movie" | "series",
|
"type": "movie" | "series",
|
||||||
"title": "каноническое название",
|
"title": "каноническое название",
|
||||||
"original_title": "оригинальное название или пустая строка",
|
"original_title": "оригинальное название (заполняй всегда, см. правила)",
|
||||||
"year": число или 0,
|
"year": число или 0,
|
||||||
"provider_hint": "строка для поиска в базе (НЕ id)",
|
"provider_hint": "строка для поиска в базе (НЕ id)",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -47,6 +47,11 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
|||||||
}
|
}
|
||||||
|
|
||||||
Правила:
|
Правила:
|
||||||
|
- Всегда заполняй И "title", И "original_title". Базы метаданных ищут прежде
|
||||||
|
всего по оригинальному (обычно английскому) названию, поэтому "original_title"
|
||||||
|
важен. Если отдельного оригинального названия нет или контент российского
|
||||||
|
происхождения — продублируй "title" в "original_title". Если НЕ уверен в
|
||||||
|
оригинальном названии — продублируй "title", но НЕ выдумывай название.
|
||||||
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
|
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
|
||||||
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
|
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
|
||||||
- Для фильма ровно один основной видеофайл role "main".
|
- Для фильма ровно один основной видеофайл role "main".
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-29
|
||||||
@@ -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
|
||||||
|
|
||||||
|
- Нет.
|
||||||
@@ -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
|
||||||
|
<!-- Нет: recognition в OpenSpec ещё не было, заводим как новую. -->
|
||||||
|
|
||||||
|
## 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`. Обратная совместимость хранимых данных не
|
||||||
|
затрагивается.
|
||||||
+85
@@ -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** они считаются совпадающими
|
||||||
@@ -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` проходит
|
||||||
@@ -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** они считаются совпадающими
|
||||||
Reference in New Issue
Block a user