Распознавание: санитайзинг названий от 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,57 @@
## Why
LLM возвращает названия с «грязью», из-за которой сверка с метабазой промахивается
и загрузка уходит в review без единого кандидата (реальный кейс — «Harold and the
Purple Crayon» с кириллической буквой-двойником вместо латинской: сырое название
ушло в query-параметр TVDB дословно, база не нашла, а гейт сильного матча кир/лат
двойники не сворачивает). Плюс год из плана уходит в запрос как жёсткий фильтр —
при ошибке модели в годе база не находит запись, которая на деле есть. Итог:
Jellyfin остаётся без официального id, а человек вбивает id руками. Цель — поднять
hit-rate кандидатов метабазы, не трогая инвариант авто-матча.
## What Changes
- **Санитайзинг названий от LLM** (`recognition`): на границе разбора плана, после
unmarshal и ДО структурной валидации/сверки, чистим человекочитаемые поля
`title`, `original_title`, `provider_hint`: (1) strip control/zero-width
символов, (2) collapse пробелов + trim, (3) свёртка кирилло-латинских
homoglyph-двойников по курируемой таблице (потокенно, только для смешанных
токенов). `files[].src` не трогаем — они обязаны байт-в-байт совпадать с файлами
торрента.
- **Homoglyph-fold в гейте матча** (`metadata-match`): нормализация названий при
сравнении получает ту же свёртку двойников как defense-in-depth на случай грязи
со стороны кандидата базы.
- **Ретрай сверки без года** (`metadata-match`): если поиск с годом не дал
подтверждённого матча, второй проход тем же перебором названий×провайдеров, но
без года. Гейт (exact-normalized название + год ±1) остаётся прежним — precision
не падает, кандидаты копятся через оба прохода.
- Наблюдаемость: `slog.Debug`, когда санитайзинг реально переписал поле.
Не в scope (отдельные задачи при необходимости): fuzzy/edit-distance матч,
транслитерация ru↔en, срез подзаголовка после «:», санитайзинг входного контекста
и накопленных подсказок.
## Capabilities
### New Capabilities
_Нет._
### Modified Capabilities
- `recognition`: добавляется требование санитайзинга человекочитаемых полей плана
(`title`/`original_title`/`provider_hint`) на границе разбора ответа LLM.
- `metadata-match`: нормализация названий при сравнении расширяется свёрткой
кирилло-латинских homoglyph-двойников; сверка получает безгодовой второй проход
как fallback, когда поиск с годом не дал подтверждённого матча.
## Impact
- Код: `internal/recognize` (новый шаг санитайзинга при разборе плана, `normalize`
и `matchMetadata` в `metadata.go`). Провайдеры `internal/metadata/*` не меняются
(год уже опционален в `Query`).
- Поведение: ранее промахивавшиеся из-за homoglyph/кривого года раздачи теперь
находят кандидата (авто-матч при единичном сильном совпадении, иначе — заполненный
список для выбора в review). Инвариант «авто только при подтверждённом единичном
сильном матче» и неприкосновенность `files[].src`/источника не затрагиваются.
- Зависимостей не добавляется (таблица двойников — небольшой литерал в коде).