Распознавание: санитайзинг названий от 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:
av
2026-07-10 15:45:19 +03:00
co-authored by Claude Opus 4.8
parent 7d8a455e47
commit e2ea1840c9
15 changed files with 1076 additions and 41 deletions
+66 -29
View File
@@ -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 = 'е'
}