Кейс «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>
162 lines
5.9 KiB
Go
162 lines
5.9 KiB
Go
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)
|
||
}
|