Распознавание: санитайзинг названий от LLM + безгодовой фолбэк сверки
Кейс «Harold and the Purple Crayon»: LLM отдал title с кириллической буквой-двойником, сырое название ушло в запрос TVDB дословно (не нашлось), а гейт нормализации кир/лат двойники не сворачивал — двойной промах, пустой список кандидатов, ручной ввод id. - recognition: санитайзинг человекочитаемых полей плана (title/original_title/ provider_hint) на границе разбора, до валидации: strip control/zero-width, collapse пробелов, потокенная свёртка homoglyph-двойников по курируемой кир↔лат таблице. files[].src не трогаем (обязаны биться с торрентом). - metadata-match: тот же fold в normalize (гейт) как defense-in-depth; безгодовой второй проход сверки как fallback при известном годе и промахе первого — восстанавливает off-by-one авто-матчи и пополняет кандидатов review. В fallback требуем известный год кандидата (год-unknown → review, не авто); гейт год ±1 и инвариант авто-матча не двигаются. Спеки recognition/metadata-match обновлены, change заархивирован. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,14 @@ const maxCandidates = 8
|
||||
// (original_title → title → provider_hint, см. searchKeys): базы индексированы
|
||||
// прежде всего по оригинальным названиям. Останавливаемся, как только очередной
|
||||
// ключ дал единичный сильный матч (ранний стоп — дешевле по обращениям к базе).
|
||||
//
|
||||
// Проходов сверки два: pass 1 — с годом плана (дешёвое сужение при верном годе),
|
||||
// pass 2 — fallback без года, только если год известен и pass 1 не подтвердил
|
||||
// матч. Exact-year фильтр запроса строже гейта strongMatches (год ±1): безгодовой
|
||||
// проход восстанавливает off-by-one авто-матчи (запись, которую точный фильтр
|
||||
// отсёк, а гейт принял бы) и пополняет кандидатов для review при бо́льших ошибках
|
||||
// года. Гейт при этом не меняется (год ±1 по plan.Year), инвариант авто-матча не
|
||||
// двигается. Кандидаты копятся через оба прохода (дедуп по provider:id, потолок).
|
||||
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
|
||||
if len(r.providers) == 0 {
|
||||
return nil, nil
|
||||
@@ -32,43 +40,65 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
|
||||
}
|
||||
|
||||
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
||||
keys := searchKeys(plan)
|
||||
|
||||
// Год запроса по проходам: сначала год плана, затем 0 (без года) как fallback.
|
||||
// При неизвестном годе второй проход был бы идентичен первому — не делаем.
|
||||
queryYears := []int{plan.Year}
|
||||
if plan.Year > 0 {
|
||||
queryYears = append(queryYears, 0)
|
||||
}
|
||||
|
||||
var match *Match
|
||||
var candidates []metadata.Candidate
|
||||
seen := map[string]bool{}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
||||
for _, c := range cands {
|
||||
ck := c.Provider + ":" + c.ID
|
||||
if seen[ck] || len(candidates) >= maxCandidates {
|
||||
for _, qYear := range queryYears {
|
||||
// Безгодовой fallback-проход (есть только при известном годе плана).
|
||||
// В нём требуем известный год кандидата: off-by-one даёт авто-матч, а
|
||||
// запись с неизвестным годом — только кандидат в review (год подтвердить
|
||||
// нечем, авто было бы недо-подтверждённым). В pass 1 leniency yearMatches
|
||||
// к unknown-году сохраняется как прежде.
|
||||
fallback := qYear == 0 && plan.Year > 0
|
||||
for _, key := range keys {
|
||||
for _, p := range r.providers {
|
||||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: qYear})
|
||||
if err != nil {
|
||||
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
|
||||
// доменное решение «пропускаем провайдера, пробуем следующий».
|
||||
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
|
||||
continue
|
||||
}
|
||||
seen[ck] = 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)
|
||||
}
|
||||
|
||||
// Единичный сильный матч ищем у первого подходящего провайдера.
|
||||
// Гейт по plan.Year (не qYear): безгодовой проход расширяет только
|
||||
// выдачу запроса, но требует год кандидата ±1 (и известный — в
|
||||
// fallback), поэтому условие подтверждения не ослабляется.
|
||||
if match != nil {
|
||||
continue
|
||||
}
|
||||
strong := strongMatches(cands, plan.Year, matchTitles, fallback)
|
||||
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)
|
||||
}
|
||||
if match != nil {
|
||||
break
|
||||
break // pass 1 подтвердил матч — безгодовой проход не нужен
|
||||
}
|
||||
}
|
||||
return match, candidates
|
||||
@@ -129,11 +159,15 @@ func CandidateTag(c metadata.Candidate) (provider, id string) {
|
||||
|
||||
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
|
||||
// названий плана (после нормализации) и год бьётся (±1 год), дедуплицируя
|
||||
// по id.
|
||||
func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool) []metadata.Candidate {
|
||||
// по id. requireKnownYear (безгодовой fallback-проход) дополнительно отсекает
|
||||
// кандидатов с неизвестным годом: авто-матч там требует подтверждённого года.
|
||||
func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool, requireKnownYear bool) []metadata.Candidate {
|
||||
seen := map[string]bool{}
|
||||
var out []metadata.Candidate
|
||||
for _, c := range cands {
|
||||
if requireKnownYear && c.Year == 0 {
|
||||
continue
|
||||
}
|
||||
if !yearMatches(year, c.Year) {
|
||||
continue
|
||||
}
|
||||
@@ -175,11 +209,14 @@ func normSet(titles ...string) map[string]bool {
|
||||
|
||||
// normalize приводит название к сравнимому виду: нижний регистр, только
|
||||
// буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое
|
||||
// расхождение написания: «Тёмный» vs «Темный»).
|
||||
// расхождение написания: «Тёмный» vs «Темный»). Перед этим сворачиваем
|
||||
// кирилло-латинские homoglyph-двойники (foldHomoglyphs) — defense-in-depth на
|
||||
// случай двойников со стороны кандидата базы: гейт сильного матча должен быть
|
||||
// устойчив к ним независимо от санитайзинга плана.
|
||||
func normalize(s string) string {
|
||||
var b strings.Builder
|
||||
prevSpace := false
|
||||
for _, r := range strings.ToLower(s) {
|
||||
for _, r := range strings.ToLower(foldHomoglyphs(s)) {
|
||||
if r == 'ё' {
|
||||
r = 'е'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user