## 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 зафиксированы на этапе груминга.