Files
jellybit/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/design.md
T
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

14 KiB
Raw Blame History

Context

Распознавание разбирает недоверенный ответ LLM в структурированный план и сверяет его с включёнными базами метаданных (internal/recognize). Ключевые точки:

  • matchMetadata (internal/recognize/metadata.go) перебирает названия (searchKeys: original_titletitleprovider_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 зафиксированы на этапе груминга.