Распознавание: санитайзинг названий от 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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user