Распознавание: санитайзинг названий от 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,2 @@
schema: spec-driven
created: 2026-07-10
@@ -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
зафиксированы на этапе груминга.
@@ -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`/источника не затрагиваются.
- Зависимостей не добавляется (таблица двойников — небольшой литерал в коде).
@@ -0,0 +1,131 @@
## MODIFIED Requirements
### Requirement: Сверка с базой по нескольким названиям
При сверке плана с включёнными базами метаданных система SHALL искать по
нескольким названиям в порядке убывания силы ключа: сначала по
`original_title`, затем по локализованному `title`, затем по `provider_hint`.
Этот перебор названий SHALL выполняться в рамках каждого прохода сверки — проходы
(с годом и безгодовой fallback) определяет требование «Безгодовой второй проход
сверки как fallback». Поиск SHALL останавливаться, как только очередной запрос
дал единичный сильный матч (ровно один кандидат с совпадением названия и года);
ранняя остановка действует в пределах прохода. Запрос с названием, нормализованно
совпадающим с уже выполненным, система SHALL пропускать, чтобы не обращаться к
базе повторно с тем же ключом.
Кандидаты для ручного выбора в review система SHALL собирать из всех выполненных
заходов обоих проходов с дедупликацией по `provider:id` и общим потолком.
#### Scenario: Иностранный фильм находится по оригинальному названию
- **GIVEN** план с `title` «Тёмный рыцарь», `original_title` «The Dark Knight», год 2008
- **WHEN** выполняется сверка с базой
- **THEN** первый запрос идёт по «The Dark Knight»
- **AND** при единичном сильном матче дальнейшие запросы (по `title`, `provider_hint`) не выполняются
#### Scenario: Фолбэк на локализованное название
- **GIVEN** план, для которого запрос по `original_title` не дал единичного сильного матча
- **WHEN** продолжается сверка
- **THEN** выполняется запрос по локализованному `title`
- **AND** при отсутствии матча и там — запрос по `provider_hint`
#### Scenario: Дублирующий запрос пропускается
- **GIVEN** план, у которого `original_title` нормализованно совпадает с `title`
- **WHEN** выполняется сверка
- **THEN** база запрашивается этим названием один раз, повторный заход по `title` не делается
### Requirement: Нормализация названий при сравнении
Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`,
чтобы написания, различающиеся только `ё`/`е`, считались одним названием.
Нормализация SHALL дополнительно сворачивать кирилло-латинские homoglyph-двойники
по той же курируемой таблице, что применяется при санитайзинге плана
(capability `recognition`), чтобы визуально совпадающие название плана и кандидата
базы, различающиеся лишь скриптом отдельных букв, считались одним названием. Это
defense-in-depth на случай двойников со стороны кандидата: гейт — точка
безопасно-критичного решения об авто-матче и SHALL быть устойчив к двойникам
независимо от предшествующего санитайзинга.
#### Scenario: «Тёмный» и «Темный» совпадают
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
- **WHEN** сравниваются нормализованные названия
- **THEN** они считаются совпадающими
#### Scenario: Двойник у кандидата не мешает матчу
- **GIVEN** план с латинским названием `Harold` и кандидат базы, где то же слово
содержит кириллический символ-двойник
- **WHEN** сравниваются нормализованные названия
- **THEN** они считаются совпадающими
## ADDED Requirements
### Requirement: Безгодовой второй проход сверки как fallback
Система SHALL выполнять второй проход сверки без года как fallback: когда поиск с
годом не дал подтверждённого единичного сильного матча, а год плана известен
(`year > 0`), выполняется тот же перебор названий (в порядке `original_title`
`title``provider_hint`) и провайдеров, но с запросом БЕЗ года. Второй проход
SHALL выполняться только как
fallback: если первый проход подтвердил матч или год плана неизвестен, второго
прохода быть SHALL NOT.
Гейт сильного матча (нормализованное совпадение названия и год кандидата в пределах
±1, ровно один кандидат) при этом SHALL оставаться неизменным — безгодовой проход
расширяет только выдачу запроса, но не ослабляет условие подтверждения, поэтому
инвариант «авто-раскладка только при подтверждённом единичном сильном матче» не
затрагивается. Дополнительно в безгодовом проходе система SHALL требовать
известный год кандидата: запись с неизвестным годом (год подтвердить нечем) во
втором проходе подтверждённым матчем быть SHALL NOT и уходит кандидатом в review
(в первом, с-годом, проходе прежняя leniency к неизвестному году сохраняется).
Кандидаты для ручного выбора в review система SHALL собирать из обоих проходов с
той же дедупликацией по `provider:id` и общим потолком.
#### Scenario: Год плана off-by-one — авто-матч восстанавливается без года
- **GIVEN** запись есть в базе, но год плана отличается от её года ровно на 1
(жёсткий exact-year фильтр запроса отсёк её в первом проходе)
- **WHEN** первый проход (с годом) не дал сильного матча
- **THEN** выполняется второй проход без года
- **AND** единичный кандидат проходит гейт (название совпадает, год бьётся ±1) —
матч подтверждается
#### Scenario: Год плана расходится больше чем на 1 — кандидат только в review
- **GIVEN** запись есть в базе, но год плана отличается от её года больше чем на 1
- **WHEN** второй проход без года возвращает эту запись единственным кандидатом
- **THEN** гейт отклоняет её по году (расхождение больше ±1) — подтверждённого
матча нет
- **AND** кандидат собирается для выбора в review (человек получает кандидата
вместо пустого списка)
#### Scenario: Первый проход подтвердил матч — второго нет
- **GIVEN** первый проход (с годом) дал единичный сильный матч
- **WHEN** завершается сверка
- **THEN** второй (безгодовой) проход не выполняется
#### Scenario: Год неизвестен — второго прохода нет
- **GIVEN** план без года (`year` = 0)
- **WHEN** первый проход не дал матча
- **THEN** второй проход не выполняется (он был бы идентичен первому)
#### Scenario: Кандидат с неизвестным годом в безгодовом проходе — только review
- **GIVEN** год плана известен, а pass 1 (с годом) не дал матча
- **WHEN** безгодовой проход возвращает единственного кандидата с совпадающим
названием, но неизвестным годом
- **THEN** подтверждённого матча нет (год кандидата не подтверждён) — авто-раскладка
не делается
- **AND** кандидат собирается для выбора в review
#### Scenario: Несколько кандидатов из безгодового прохода — матч не подтверждён
- **GIVEN** безгодовой проход вернул более одного кандидата, проходящего гейт
- **WHEN** оценивается матч
- **THEN** подтверждённого матча нет, кандидаты собираются для выбора в review
@@ -0,0 +1,64 @@
## ADDED Requirements
### Requirement: Санитайзинг человекочитаемых полей плана
Перед структурной валидацией плана и сверкой с базами система SHALL санитизировать
человекочитаемые поля плана — `title`, `original_title`, `provider_hint` — как
недоверенный вывод LLM. Санитайзинг SHALL: (1) удалять управляющие и zero-width
символы (C0/C1, `U+200B` и родственные, BOM `U+FEFF`); (2) сводить внутренние
последовательности пробельных к одиночному пробелу и обрезать края; (3) сворачивать
кирилло-латинские homoglyph-двойники (см. ниже).
Свёртка двойников SHALL работать потокенно (по словам, разделённым не-буквенными
символами): токен, все буквы которого принадлежат одному скрипту, система SHALL
оставлять без изменений (билингвальность реальна — кириллические названия
неприкосновенны); в токене смешанного скрипта система SHALL определять доминирующий
скрипт по числу буквенных рун и заменять буквы-меньшинство их визуальными
двойниками из доминирующего скрипта по курируемой таблице. При отсутствии
доминирующего скрипта (равенство) токен SHALL оставаться без изменений.
Санитайзинг SHALL применяться ТОЛЬКО к перечисленным человекочитаемым полям.
`files[].src` система SHALL NOT санитизировать — эти значения обязаны совпадать с
реальными файлами торрента, и расхождение (в т.ч. homoglyph) SHALL оставаться
основанием отклонить план, а не поводом «чинить» путь.
Когда санитайзинг реально изменил значение поля, система SHALL логировать это на
уровне `Debug` (названия не относятся к секретам).
#### Scenario: Кириллический двойник в англоязычном названии сворачивается
- **GIVEN** план, где `title` = `Hаrold and the Purple Crayon` (буква `а` в первом
слове — кириллическая `U+0430`)
- **WHEN** план санитизируется
- **THEN** первое слово становится `Harold` (все буквы латинские)
- **AND** в запрос к базе и в сравнение уходит латинское название
#### Scenario: Честное кириллическое название не трогается
- **GIVEN** план российского фильма с `title` = `Тёмный рыцарь`, где все буквы
каждого слова кириллические
- **WHEN** план санитизируется
- **THEN** название остаётся кириллическим без замены букв
#### Scenario: Токен без доминирующего скрипта не трогается
- **GIVEN** план, где короткий токен содержит поровну латинских и кириллических
букв (доминирующего скрипта нет)
- **WHEN** план санитизируется
- **THEN** этот токен остаётся без замены букв (осознанный trade-off: двухбуквенный
homoglyph-typo не сворачивается)
#### Scenario: Zero-width и лишние пробелы вычищаются
- **GIVEN** план, где `title` содержит zero-width символ и сдвоенные пробелы
- **WHEN** план санитизируется
- **THEN** zero-width удалён, внутренние пробелы сведены к одиночным, края обрезаны
#### Scenario: files[].src не санитизируется
- **GIVEN** ответ LLM, где `files[].src` содержит символ-двойник и не совпадает ни
с одним реальным файлом торрента
- **WHEN** план обрабатывается
- **THEN** `files[].src` НЕ изменяется санитайзингом
- **AND** несовпадение src приводит к отклонению плана (эскалация в review), а не к
«починке» пути
@@ -0,0 +1,42 @@
## 1. Homoglyph-таблица и fold
- [x] 1.1 В `internal/recognize` завести курируемую таблицу кирилло-латинских
двойников (~15–20 пар, строчные и заглавные) как единственный источник правды
- [x] 1.2 Реализовать потокенную `foldHomoglyphs(s string) string`: одно-скриптовый
токен не трогаем, в смешанном — доминирующий скрипт по числу буквенных рун,
меньшинство мапим по таблице; при равенстве оставляем как есть
- [x] 1.3 Юнит-тесты `foldHomoglyphs`: «Hаrold»→«Harold», честная кириллица
нетронута, смешанное двуязычное название, равенство скриптов, пустой ввод
## 2. Санитайзинг полей плана (recognition)
- [x] 2.1 Реализовать `sanitizeTitle`: strip control/zero-width (C0/C1, U+200B и
родственные, BOM), collapse пробелов + trim, затем `foldHomoglyphs`
- [x] 2.2 Применить санитайзинг к `title`, `original_title`, `provider_hint` на
границе разбора плана — после unmarshal, ДО структурной валидации и сверки;
`files[].src` не трогать
- [x] 2.3 `slog.Debug` when поле реально переписано (before/after), логгер из ctx
- [x] 2.4 Тесты: поля чистятся, `files[].src` неприкосновенны, лог при изменении
## 3. Fold в гейте нормализации (metadata-match)
- [x] 3.1 Добавить `foldHomoglyphs` в `normalize` (`internal/recognize/metadata.go`)
после `ё→е`, переиспользуя ту же таблицу
- [x] 3.2 Тест: план и кандидат, различающиеся лишь скриптом буквы, нормализованно
совпадают
## 4. Безгодовой второй проход сверки (metadata-match)
- [x] 4.1 Обернуть перебор в `matchMetadata` в два прохода: pass 1 — как сейчас
(`Search` с годом), pass 2 — только при `plan.Year > 0` и отсутствии матча в
pass 1, тот же перебор `searchKeys × providers` с `Year = 0`
- [x] 4.2 Кандидаты копятся через оба прохода с существующей дедупликацией по
`provider:id` и потолком `maxCandidates`; гейт `strongMatches` не менять
- [x] 4.3 Тесты: кривой год находится без года; при матче в pass 1 второго прохода
нет; при `year = 0` второго прохода нет; несколько кандидатов из pass 2 → матч
не подтверждён
## 5. Проверка и вычитка
- [x] 5.1 `task test` и `task lint` — зелёные
- [x] 5.2 `openspec validate --strict sanitize-llm-titles-yearless-retry`
+90 -6
View File
@@ -12,13 +12,16 @@
При сверке плана с включёнными базами метаданных система SHALL искать по
нескольким названиям в порядке убывания силы ключа: сначала по
`original_title`, затем по локализованному `title`, затем по `provider_hint`.
Поиск SHALL останавливаться, как только очередной запрос дал единичный
сильный матч (ровно один кандидат с совпадением названия и года). Запрос с
названием, нормализованно совпадающим с уже выполненным, система SHALL
пропускать, чтобы не обращаться к базе повторно с тем же ключом.
Этот перебор названий SHALL выполняться в рамках каждого прохода сверки — проходы
(с годом и безгодовой fallback) определяет требование «Безгодовой второй проход
сверки как fallback». Поиск SHALL останавливаться, как только очередной запрос
дал единичный сильный матч (ровно один кандидат с совпадением названия и года);
ранняя остановка действует в пределах прохода. Запрос с названием, нормализованно
совпадающим с уже выполненным, система SHALL пропускать, чтобы не обращаться к
базе повторно с тем же ключом.
Кандидаты для ручного выбора в review система SHALL собирать из всех
выполненных заходов с дедупликацией по `provider:id` и общим потолком.
Кандидаты для ручного выбора в review система SHALL собирать из всех выполненных
заходов обоих проходов с дедупликацией по `provider:id` и общим потолком.
#### Scenario: Иностранный фильм находится по оригинальному названию
@@ -83,12 +86,27 @@
Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`,
чтобы написания, различающиеся только `ё`/`е`, считались одним названием.
Нормализация SHALL дополнительно сворачивать кирилло-латинские homoglyph-двойники
по той же курируемой таблице, что применяется при санитайзинге плана
(capability `recognition`), чтобы визуально совпадающие название плана и кандидата
базы, различающиеся лишь скриптом отдельных букв, считались одним названием. Это
defense-in-depth на случай двойников со стороны кандидата: гейт — точка
безопасно-критичного решения об авто-матче и SHALL быть устойчив к двойникам
независимо от предшествующего санитайзинга.
#### Scenario: «Тёмный» и «Темный» совпадают
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
- **WHEN** сравниваются нормализованные названия
- **THEN** они считаются совпадающими
#### Scenario: Двойник у кандидата не мешает матчу
- **GIVEN** план с латинским названием `Harold` и кандидат базы, где то же слово
содержит кириллический символ-двойник
- **WHEN** сравниваются нормализованные названия
- **THEN** они считаются совпадающими
### Requirement: Кандидат несёт URL для внешней проверки
Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести
@@ -130,3 +148,69 @@
- **AND** при последующей загрузке данных ревью url доступен без повторной
генерации
### Requirement: Безгодовой второй проход сверки как fallback
Система SHALL выполнять второй проход сверки без года как fallback: когда поиск с
годом не дал подтверждённого единичного сильного матча, а год плана известен
(`year > 0`), выполняется тот же перебор названий (в порядке `original_title`
`title``provider_hint`) и провайдеров, но с запросом БЕЗ года. Второй проход
SHALL выполняться только как
fallback: если первый проход подтвердил матч или год плана неизвестен, второго
прохода быть SHALL NOT.
Гейт сильного матча (нормализованное совпадение названия и год кандидата в пределах
±1, ровно один кандидат) при этом SHALL оставаться неизменным — безгодовой проход
расширяет только выдачу запроса, но не ослабляет условие подтверждения, поэтому
инвариант «авто-раскладка только при подтверждённом единичном сильном матче» не
затрагивается. Дополнительно в безгодовом проходе система SHALL требовать
известный год кандидата: запись с неизвестным годом (год подтвердить нечем) во
втором проходе подтверждённым матчем быть SHALL NOT и уходит кандидатом в review
(в первом, с-годом, проходе прежняя leniency к неизвестному году сохраняется).
Кандидаты для ручного выбора в review система SHALL собирать из обоих проходов с
той же дедупликацией по `provider:id` и общим потолком.
#### Scenario: Год плана off-by-one — авто-матч восстанавливается без года
- **GIVEN** запись есть в базе, но год плана отличается от её года ровно на 1
(жёсткий exact-year фильтр запроса отсёк её в первом проходе)
- **WHEN** первый проход (с годом) не дал сильного матча
- **THEN** выполняется второй проход без года
- **AND** единичный кандидат проходит гейт (название совпадает, год бьётся ±1) —
матч подтверждается
#### Scenario: Год плана расходится больше чем на 1 — кандидат только в review
- **GIVEN** запись есть в базе, но год плана отличается от её года больше чем на 1
- **WHEN** второй проход без года возвращает эту запись единственным кандидатом
- **THEN** гейт отклоняет её по году (расхождение больше ±1) — подтверждённого
матча нет
- **AND** кандидат собирается для выбора в review (человек получает кандидата
вместо пустого списка)
#### Scenario: Первый проход подтвердил матч — второго нет
- **GIVEN** первый проход (с годом) дал единичный сильный матч
- **WHEN** завершается сверка
- **THEN** второй (безгодовой) проход не выполняется
#### Scenario: Год неизвестен — второго прохода нет
- **GIVEN** план без года (`year` = 0)
- **WHEN** первый проход не дал матча
- **THEN** второй проход не выполняется (он был бы идентичен первому)
#### Scenario: Кандидат с неизвестным годом в безгодовом проходе — только review
- **GIVEN** год плана известен, а pass 1 (с годом) не дал матча
- **WHEN** безгодовой проход возвращает единственного кандидата с совпадающим
названием, но неизвестным годом
- **THEN** подтверждённого матча нет (год кандидата не подтверждён) — авто-раскладка
не делается
- **AND** кандидат собирается для выбора в review
#### Scenario: Несколько кандидатов из безгодового прохода — матч не подтверждён
- **GIVEN** безгодовой проход вернул более одного кандидата, проходящего гейт
- **WHEN** оценивается матч
- **THEN** подтверждённого матча нет, кандидаты собираются для выбора в review
+63
View File
@@ -125,3 +125,66 @@ per-file `season`/`episode` (отдельного скалярного `season`
- **WHEN** строится план
- **THEN** этот файл получает роль `ignore` и в раскладку не попадает
### Requirement: Санитайзинг человекочитаемых полей плана
Перед структурной валидацией плана и сверкой с базами система SHALL санитизировать
человекочитаемые поля плана — `title`, `original_title`, `provider_hint` — как
недоверенный вывод LLM. Санитайзинг SHALL: (1) удалять управляющие и zero-width
символы (C0/C1, `U+200B` и родственные, BOM `U+FEFF`); (2) сводить внутренние
последовательности пробельных к одиночному пробелу и обрезать края; (3) сворачивать
кирилло-латинские homoglyph-двойники (см. ниже).
Свёртка двойников SHALL работать потокенно (по словам, разделённым не-буквенными
символами): токен, все буквы которого принадлежат одному скрипту, система SHALL
оставлять без изменений (билингвальность реальна — кириллические названия
неприкосновенны); в токене смешанного скрипта система SHALL определять доминирующий
скрипт по числу буквенных рун и заменять буквы-меньшинство их визуальными
двойниками из доминирующего скрипта по курируемой таблице. При отсутствии
доминирующего скрипта (равенство) токен SHALL оставаться без изменений.
Санитайзинг SHALL применяться ТОЛЬКО к перечисленным человекочитаемым полям.
`files[].src` система SHALL NOT санитизировать — эти значения обязаны совпадать с
реальными файлами торрента, и расхождение (в т.ч. homoglyph) SHALL оставаться
основанием отклонить план, а не поводом «чинить» путь.
Когда санитайзинг реально изменил значение поля, система SHALL логировать это на
уровне `Debug` (названия не относятся к секретам).
#### Scenario: Кириллический двойник в англоязычном названии сворачивается
- **GIVEN** план, где `title` = `Hаrold and the Purple Crayon` (буква `а` в первом
слове — кириллическая `U+0430`)
- **WHEN** план санитизируется
- **THEN** первое слово становится `Harold` (все буквы латинские)
- **AND** в запрос к базе и в сравнение уходит латинское название
#### Scenario: Честное кириллическое название не трогается
- **GIVEN** план российского фильма с `title` = `Тёмный рыцарь`, где все буквы
каждого слова кириллические
- **WHEN** план санитизируется
- **THEN** название остаётся кириллическим без замены букв
#### Scenario: Токен без доминирующего скрипта не трогается
- **GIVEN** план, где короткий токен содержит поровну латинских и кириллических
букв (доминирующего скрипта нет)
- **WHEN** план санитизируется
- **THEN** этот токен остаётся без замены букв (осознанный trade-off: двухбуквенный
homoglyph-typo не сворачивается)
#### Scenario: Zero-width и лишние пробелы вычищаются
- **GIVEN** план, где `title` содержит zero-width символ и сдвоенные пробелы
- **WHEN** план санитизируется
- **THEN** zero-width удалён, внутренние пробелы сведены к одиночным, края обрезаны
#### Scenario: files[].src не санитизируется
- **GIVEN** ответ LLM, где `files[].src` содержит символ-двойник и не совпадает ни
с одним реальным файлом торрента
- **WHEN** план обрабатывается
- **THEN** `files[].src` НЕ изменяется санитайзингом
- **AND** несовпадение src приводит к отклонению плана (эскалация в review), а не к
«починке» пути