Распознавание: санитайзинг названий от 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,164 @@
|
||||
## Context
|
||||
|
||||
Распознавание разбирает недоверенный ответ LLM в структурированный план и сверяет
|
||||
его с включёнными базами метаданных (`internal/recognize`). Ключевые точки:
|
||||
|
||||
- `matchMetadata` (`internal/recognize/metadata.go`) перебирает названия
|
||||
(`searchKeys`: `original_title` → `title` → `provider_hint`) × провайдеров,
|
||||
вызывая `Provider.Search(Query{Title, Year})`; год уходит в query-параметр
|
||||
провайдера как жёсткий фильтр (`tvdb.go:160`, `tmdb.go:79`).
|
||||
- Гейт сильного матча `strongMatches` сравнивает названия через `normalize`
|
||||
(нижний регистр, только буквы/цифры, `ё→е`) и год ±1; авто-раскладка — только
|
||||
при ровно одном сильном кандидате (инвариант проекта).
|
||||
|
||||
Реальный сбой: LLM вернул `title` с кириллической буквой-двойником вместо
|
||||
латинской. Сырое название ушло в запрос TVDB дословно → база не нашла (пустой
|
||||
список кандидатов); и даже вернись кандидат, `normalize` кир/лат двойники не
|
||||
сворачивает (это разные руны, обе `unicode.IsLetter`) → гейт бы промахнулся.
|
||||
Отдельно: ошибка модели в годе делает жёсткий year-фильтр запроса причиной
|
||||
промаха по записи, которая в базе есть.
|
||||
|
||||
Инвариант, который НЕ двигаем: авто-раскладка только при подтверждённом единичном
|
||||
сильном матче; выход LLM недоверенный (безопасность на валидации, не на промпте);
|
||||
`files[].src` неприкосновенны (обязаны совпадать с реальными файлами торрента).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Чистить человекочитаемые поля плана (`title`, `original_title`, `provider_hint`)
|
||||
на границе разбора, чтобы в запрос к базе и в имя папки Jellyfin шло вменяемое
|
||||
название.
|
||||
- Сворачивать кирилло-латинские homoglyph-двойники так, чтобы «Hаrold» (с кир. `а`)
|
||||
и «Harold» считались одним названием — и в запросе, и в гейте сравнения.
|
||||
- Ловить кривой год от LLM безгодовым вторым проходом сверки, не ослабляя гейт.
|
||||
- Поднять hit-rate кандидатов метабазы → меньше ручного ввода id в review, чаще
|
||||
корректный provider-id для Jellyfin.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Fuzzy/edit-distance сравнение названий (гейт остаётся exact-normalized).
|
||||
- Транслитерация ru↔en как дополнительный ключ поиска.
|
||||
- Срез подзаголовка после «:» и прочие эвристики разбиения названия.
|
||||
- Санитайзинг входного `context` и накопленных `hints` (другой класс входа).
|
||||
- Трогать `files[].src` или пути раскладки.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Решение 1: санитайзинг — на границе разбора плана, до валидации и сверки
|
||||
|
||||
Санитайзинг применяется к `title`/`original_title`/`provider_hint` сразу после
|
||||
получения структурированного плана из ответа LLM и ДО структурной валидации и
|
||||
сверки с базой. Так очищенное название попадает и в запрос к базе, и (при
|
||||
отсутствии матча) в имя папки Jellyfin — единая точка очистки.
|
||||
|
||||
Состав (в порядке применения):
|
||||
|
||||
1. **Strip control/zero-width** — удаляем управляющие C0/C1, zero-width (`U+200B`
|
||||
и родственные), BOM (`U+FEFF`).
|
||||
2. **Collapse whitespace + trim** — внутренние последовательности пробельных →
|
||||
один пробел, обрезка краёв.
|
||||
3. **Homoglyph-fold смешанных токенов** (см. Решение 2).
|
||||
|
||||
`files[].src` НЕ санитизируем: они обязаны байт-в-байт биться с файлами торрента;
|
||||
homoglyph там — настоящий mismatch, который корректно отклоняется валидацией и
|
||||
уходит в review. «Чинить» пути значило бы подгонять план под несуществующий файл.
|
||||
|
||||
_Альтернатива (отклонено):_ чистить только перед запросом к базе (в `searchKeys`).
|
||||
Тогда имя папки в no-match-ветке осталось бы грязным, а гейт сравнения — уязвимым.
|
||||
Очистка канонического плана один раз покрывает оба пути.
|
||||
|
||||
### Решение 2: homoglyph-fold — курируемая таблица кир↔лат, потокенно
|
||||
|
||||
Свёртка работает по словам (токенам, разделённым не-буквенными символами):
|
||||
|
||||
- Токен, все буквы которого одного скрипта (весь Latin или весь Cyrillic), не
|
||||
трогаем — билингвальность реальна: русские названия по-настоящему кириллические,
|
||||
и подменять их латиницей нельзя.
|
||||
- Токен **смешанного** скрипта → определяем доминирующий скрипт по числу буквенных
|
||||
рун и мапим буквы-меньшинство в доминирующий скрипт через курируемую таблицу
|
||||
двойников (~15–20 пар: строчные `а е о р с у х к`, заглавные `А В Е К М Н О Р С Т Х`
|
||||
и латинские аналоги `a e o p c y x k / A B E K M H O P C T X`). При равенстве
|
||||
скриптов в токене (нет доминирующего) оставляем как есть.
|
||||
|
||||
_Почему курируемая таблица, а не UTS#39 skeleton:_ реальная боль — русскоклавиатурные
|
||||
двойники в англоязычных названиях; дюжина пар её закрывает без зависимости и без
|
||||
переусложнения под наш билингвальный домен. Полный юникодный confusables — оверкилл.
|
||||
|
||||
_Почему потокенно, а не по всей строке:_ решение о скрипте на уровне слова не путает
|
||||
двуязычные названия («Название [English]») и не ломает честную кириллицу.
|
||||
|
||||
Та же fold-функция переиспользуется в `normalize` (Решение 3), поэтому таблица
|
||||
двойников — единственный источник правды.
|
||||
|
||||
### Решение 3: fold в гейте `normalize` как defense-in-depth
|
||||
|
||||
`normalize` (гейт сильного матча) получает тот же homoglyph-fold после `ё→е`.
|
||||
Поскольку план уже очищен на границе разбора, а официальные базы отдают чистые
|
||||
названия, это подстраховка на случай двойников со стороны кандидата — но именно
|
||||
гейт принимает безопасно-критичное решение об авто-матче, поэтому делаем его
|
||||
устойчивым независимо от шага санитайзинга. Стоимость около нулевая (общая fold-
|
||||
функция).
|
||||
|
||||
### Решение 4: безгодовой второй проход сверки как fallback
|
||||
|
||||
`matchMetadata` оборачивается в два прохода:
|
||||
|
||||
- **pass 1** — как сейчас: `searchKeys × providers`, `Search(Query{Title, Year})`,
|
||||
ранний стоп на единичном сильном матче.
|
||||
- **pass 2** — только если `plan.Year > 0` И pass 1 не дал подтверждённого матча:
|
||||
тот же перебор, но `Search` с `Year = 0`.
|
||||
|
||||
Гейт `strongMatches` (год ±1 + exact-normalized название, ровно один кандидат)
|
||||
в основе не меняется — precision держится тем же механизмом, инвариант авто-матча
|
||||
не двигается. Одна прицельная строгость добавлена **только для fallback-прохода**:
|
||||
там требуется известный год кандидата. Причина — безгодовой проход делает
|
||||
достижимыми записи, которые exact-year фильтр прежде прятал, включая записи с
|
||||
неизвестным годом; авто-матч по такой записи означал бы «год подтвердить нечем, но
|
||||
всё равно авто». Поэтому в pass 2 запись с `year == 0` уходит кандидатом в review,
|
||||
а не в авто (off-by-one с известным годом — по-прежнему авто). В pass 1 прежняя
|
||||
leniency `yearMatches` к неизвестному году сохранена (поведение не регрессирует).
|
||||
Кандидаты копятся через оба прохода с той же дедупликацией по `provider:id` и общим
|
||||
потолком `maxCandidates`.
|
||||
|
||||
_Обоснование:_ exact-year фильтр запроса **строже** гейта — запрос требует точный
|
||||
год, а гейт принимает год ±1. Поэтому безгодовой проход даёт две разные выгоды:
|
||||
(а) **восстанавливает авто-матч для граничных off-by-one расхождений года** —
|
||||
запись, которую точный фильтр отсёк, но гейт ±1 принял бы (разные базы датируют
|
||||
релиз по-разному, off-by-1 частый); (б) при бо́льших ошибках года **пополняет
|
||||
список кандидатов для review** — гейт по году такую запись отклонит (авто-матча
|
||||
не будет), но человек получит кандидата вместо пустого списка. Год держим в первом
|
||||
проходе (дешёвое сужение при верном годе — меньше мусора в выдаче), безгодовой —
|
||||
фолбэк только когда первый ничего не подтвердил.
|
||||
|
||||
_Альтернатива (отклонено):_ вообще убрать год из запроса. Потеряли бы дешёвое
|
||||
сужение для частых названий, где год у модели верный (общий случай).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Over-fold: свёртка поломает легитимное смешанное название** → снижаем риск
|
||||
потокенной логикой (честный одно-скриптовый токен неприкосновенен) и `slog.Debug`
|
||||
с before/after при каждой переписи — перекос будет виден в логах.
|
||||
- **Ошибочная свёртка «меньшинства» в редком двуязычном слове** → таблица только из
|
||||
визуально-неотличимых пар; символы без двойника не трогаются, длина/структура
|
||||
строки сохраняется.
|
||||
- **pass 2 добавляет обращения к базе** → только в ветке «pass 1 не подтвердил
|
||||
матч» и только при известном годе; в типовом успешном случае лишних запросов нет.
|
||||
- **Безгодовой поиск шумит кандидатами для частых названий** → гейт с exact-title и
|
||||
годом ±1 не пропустит их в авто; в review это просто более полный список для
|
||||
выбора, не регрессия.
|
||||
- **Общий потолок `maxCandidates` делится на оба прохода** → если pass 1 заполнил
|
||||
лимит «мусором», реальный кандидат из pass 2 может не попасть в список review.
|
||||
Крайний случай; потолок общий по требованию спеки, отдельного механизма не
|
||||
вводим — при необходимости поднять лимит отдельной задачей.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Изменение чисто поведенческое, без миграций БД и конфига. Уже лежащие в review
|
||||
загрузки не трогаются; эффект проявляется на новых распознаваниях и при
|
||||
«Распознать заново» из review. Откат — ревертом коммита.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Нет — решения по составу санитайзинга, стратегии fold и границам scope
|
||||
зафиксированы на этапе груминга.
|
||||
Reference in New Issue
Block a user