Распознавание: санитайзинг названий от 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
@@ -0,0 +1,42 @@
## 1. Homoglyph-таблица и fold
- [x] 1.1 В `internal/recognize` завести курируемую таблицу кирилло-латинских
двойников (~15–20 пар, строчные и заглавные) как единственный источник правды
- [x] 1.2 Реализовать потокенную `foldHomoglyphs(s string) string`: одно-скриптовый
токен не трогаем, в смешанном — доминирующий скрипт по числу буквенных рун,
меньшинство мапим по таблице; при равенстве оставляем как есть
- [x] 1.3 Юнит-тесты `foldHomoglyphs`: «Hаrold»→«Harold», честная кириллица
нетронута, смешанное двуязычное название, равенство скриптов, пустой ввод
## 2. Санитайзинг полей плана (recognition)
- [x] 2.1 Реализовать `sanitizeTitle`: strip control/zero-width (C0/C1, U+200B и
родственные, BOM), collapse пробелов + trim, затем `foldHomoglyphs`
- [x] 2.2 Применить санитайзинг к `title`, `original_title`, `provider_hint` на
границе разбора плана — после unmarshal, ДО структурной валидации и сверки;
`files[].src` не трогать
- [x] 2.3 `slog.Debug` when поле реально переписано (before/after), логгер из ctx
- [x] 2.4 Тесты: поля чистятся, `files[].src` неприкосновенны, лог при изменении
## 3. Fold в гейте нормализации (metadata-match)
- [x] 3.1 Добавить `foldHomoglyphs` в `normalize` (`internal/recognize/metadata.go`)
после `ё→е`, переиспользуя ту же таблицу
- [x] 3.2 Тест: план и кандидат, различающиеся лишь скриптом буквы, нормализованно
совпадают
## 4. Безгодовой второй проход сверки (metadata-match)
- [x] 4.1 Обернуть перебор в `matchMetadata` в два прохода: pass 1 — как сейчас
(`Search` с годом), pass 2 — только при `plan.Year > 0` и отсутствии матча в
pass 1, тот же перебор `searchKeys × providers` с `Year = 0`
- [x] 4.2 Кандидаты копятся через оба прохода с существующей дедупликацией по
`provider:id` и потолком `maxCandidates`; гейт `strongMatches` не менять
- [x] 4.3 Тесты: кривой год находится без года; при матче в pass 1 второго прохода
нет; при `year = 0` второго прохода нет; несколько кандидатов из pass 2 → матч
не подтверждён
## 5. Проверка и вычитка
- [x] 5.1 `task test` и `task lint` — зелёные
- [x] 5.2 `openspec validate --strict sanitize-llm-titles-yearless-retry`