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

165 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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
зафиксированы на этапе груминга.