Files
avandClaude Opus 4.8 e2ea1840c9 Распознавание: санитайзинг названий от 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>
2026-07-10 15:45:19 +03:00

162 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}