Распознавание: санитайзинг названий от 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
+161
View File
@@ -0,0 +1,161 @@
package recognize
import (
"log/slog"
"strings"
"unicode"
)
// homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских
// пар (двойников). Реальная боль — русскоклавиатурные двойники в англоязычных
// названиях (кейс «Hаrold» с кир. `а`). Таблица используется в обе стороны:
// направление свёртки выбирает foldHomoglyphs по доминирующему скрипту токена.
// Единственный источник правды и для санитайзинга плана, и для нормализации в
// гейте матча (normalize).
var homoglyphPairs = []struct{ cyr, lat rune }{
// строчные
{'а', 'a'}, {'е', 'e'}, {'о', 'o'}, {'р', 'p'}, {'с', 'c'},
{'у', 'y'}, {'х', 'x'}, {'к', 'k'},
// заглавные
{'А', 'A'}, {'В', 'B'}, {'Е', 'E'}, {'К', 'K'}, {'М', 'M'},
{'Н', 'H'}, {'О', 'O'}, {'Р', 'P'}, {'С', 'C'}, {'Т', 'T'},
{'Х', 'X'},
}
var (
cyr2lat = map[rune]rune{}
lat2cyr = map[rune]rune{}
)
func init() {
for _, p := range homoglyphPairs {
cyr2lat[p.cyr] = p.lat
lat2cyr[p.lat] = p.cyr
}
}
// sanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM:
// (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и
// обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем
// до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов.
func sanitizeTitle(s string) string {
s = stripControl(s)
s = collapseSpaces(s)
s = foldHomoglyphs(s)
return s
}
// sanitizePlan применяет санитайзинг к человекочитаемым полям плана. files[].src
// НЕ трогаем: они обязаны байт-в-байт совпадать с реальными файлами торрента, и
// homoglyph там — настоящий mismatch (отклоняется валидацией, уходит в review),
// а не повод «чинить» путь. Логируем на Debug, когда значение реально изменилось
// (названия не относятся к секретам).
func sanitizePlan(p *Plan, log *slog.Logger) {
p.Title = sanitizeField(p.Title, "title", log)
p.OriginalTitle = sanitizeField(p.OriginalTitle, "original_title", log)
p.ProviderHint = sanitizeField(p.ProviderHint, "provider_hint", log)
}
func sanitizeField(v, field string, log *slog.Logger) string {
clean := sanitizeTitle(v)
if clean != v && log != nil {
log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean)
}
return clean
}
// stripControl удаляет format-символы (zero-width, BOM, soft-hyphen — категория
// Cf) и управляющие C0/C1, кроме пробельных (\t\n\r и пр. остаются — их сведёт
// collapseSpaces).
func stripControl(s string) string {
return strings.Map(func(r rune) rune {
switch {
case unicode.Is(unicode.Cf, r):
return -1
case unicode.IsControl(r) && !unicode.IsSpace(r):
return -1
default:
return r
}
}, s)
}
// collapseSpaces сводит последовательности пробельных к одиночному пробелу и
// обрезает края.
func collapseSpaces(s string) string {
var b strings.Builder
b.Grow(len(s))
pendingSpace := false
for _, r := range s {
if unicode.IsSpace(r) {
pendingSpace = true
continue
}
if pendingSpace && b.Len() > 0 {
b.WriteByte(' ')
}
pendingSpace = false
b.WriteRune(r)
}
return b.String()
}
// foldHomoglyphs сворачивает кирилло-латинские homoglyph-двойники потокенно.
// Токен (максимальная последовательность букв) из одного скрипта не трогаем —
// билингвальность реальна, честная кириллица неприкосновенна. В смешанном токене
// определяем доминирующий скрипт по числу буквенных рун и заменяем
// буквы-меньшинство их двойниками из доминирующего скрипта; при равенстве
// скриптов токен не меняем. Не-буквенные символы (пробелы, цифры, пунктуация)
// разделяют токены и копируются как есть.
func foldHomoglyphs(s string) string {
runes := []rune(s)
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(runes); {
if !unicode.IsLetter(runes[i]) {
b.WriteRune(runes[i])
i++
continue
}
j := i
for j < len(runes) && unicode.IsLetter(runes[j]) {
j++
}
b.WriteString(foldToken(runes[i:j]))
i = j
}
return b.String()
}
func foldToken(tok []rune) string {
var cyr, lat int
for _, r := range tok {
switch {
case unicode.Is(unicode.Cyrillic, r):
cyr++
case unicode.Is(unicode.Latin, r):
lat++
}
}
if cyr == 0 || lat == 0 {
return string(tok) // одно-скриптовый токен — не трогаем
}
var m map[rune]rune
switch {
case lat > cyr:
m = cyr2lat // доминирует латиница — сворачиваем кириллические двойники
case cyr > lat:
m = lat2cyr
default:
return string(tok) // нет доминирующего скрипта — не трогаем
}
out := make([]rune, len(tok))
for i, r := range tok {
if repl, ok := m[r]; ok {
out[i] = repl
} else {
out[i] = r
}
}
return string(out)
}