Распознавание: санитайзинг названий от 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
+102
View File
@@ -0,0 +1,102 @@
package recognize
import (
"bytes"
"log/slog"
"strings"
"testing"
)
func TestFoldHomoglyphs(t *testing.T) {
cases := map[string]string{
// Latin-доминантный токен: кириллический двойник 'а' (U+0430) → 'a'.
"Hаrold and the Purple Crayon": "Harold and the Purple Crayon",
// Честная кириллица — не трогаем.
"Тёмный рыцарь": "Тёмный рыцарь",
// Честная латиница — не трогаем.
"The Dark Knight": "The Dark Knight",
// Cyrillic-доминантный токен (М р з) с латинскими 'o' (U+006F) → 'о'.
"Мoрoз": "Мороз",
// Равенство скриптов в токене (кир. О U+041E + лат. k) — не трогаем.
"Оk": "Оk",
// Двуязычные, но одно-скриптовые токены — каждый нетронут.
"Fargo Фарго": "Fargo Фарго",
// Буква-меньшинство без двойника в таблице (кир. 'б' U+0431) остаётся.
"Harбld": "Harбld",
// Пустая строка.
"": "",
}
for in, want := range cases {
if got := foldHomoglyphs(in); got != want {
t.Errorf("foldHomoglyphs(%q) = %q, want %q", in, got, want)
}
}
}
func TestSanitizeTitle(t *testing.T) {
cases := map[string]string{
// Zero-width (U+200B) внутри слова удаляется, слово склеивается.
"a\u200bb": "ab",
// Zero-width + двойник + лишние пробелы → strip + fold + collapse + trim.
" Hаrold\u200b Crayon ": "Harold Crayon",
// Табы/переводы строк как пробелы, сведены к одиночным.
"Harold\tand the": "Harold and the",
// Управляющий символ (BEL U+0007) удаляется.
"Harold\u0007": "Harold",
// BOM (U+FEFF) удаляется.
"\ufeffFargo": "Fargo",
// Уже чистое — без изменений.
"The Matrix": "The Matrix",
}
for in, want := range cases {
if got := sanitizeTitle(in); got != want {
t.Errorf("sanitizeTitle(%q) = %q, want %q", in, got, want)
}
}
}
func TestSanitizePlan_FieldsCleanedSrcUntouched(t *testing.T) {
// src намеренно содержит кириллический двойник — sanitizePlan его не трогает.
dirtySrc := "Hаrold.mkv"
p := Plan{
Title: "Hаrold",
OriginalTitle: " spaced ",
ProviderHint: "clean\u200b",
Files: []PlanFile{{Src: dirtySrc, Role: RoleMain}},
}
sanitizePlan(&p, nil)
if p.Title != "Harold" {
t.Errorf("Title = %q, want %q", p.Title, "Harold")
}
if p.OriginalTitle != "spaced" {
t.Errorf("OriginalTitle = %q, want %q", p.OriginalTitle, "spaced")
}
if p.ProviderHint != "clean" {
t.Errorf("ProviderHint = %q, want %q", p.ProviderHint, "clean")
}
if p.Files[0].Src != dirtySrc {
t.Errorf("files[].src изменён санитайзингом: %q != %q", p.Files[0].Src, dirtySrc)
}
}
func TestSanitizePlan_LogsOnChange(t *testing.T) {
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
p := Plan{Title: "Hаrold"} // 'а' — кириллица, санитайзинг перепишет
sanitizePlan(&p, log)
out := buf.String()
if !strings.Contains(out, "recognition plan field sanitized") || !strings.Contains(out, "field=title") {
t.Errorf("ожидался Debug-лог о санитайзинге title, got: %q", out)
}
}
func TestSanitizePlan_NoLogWhenClean(t *testing.T) {
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
p := Plan{Title: "Harold", OriginalTitle: "The Matrix"}
sanitizePlan(&p, log)
if buf.Len() != 0 {
t.Errorf("для чистых полей лога быть не должно, got: %q", buf.String())
}
}