Распознавание: санитайзинг названий от 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:
@@ -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
|
||||
|
||||
|
||||
@@ -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), а не к
|
||||
«починке» пути
|
||||
|
||||
|
||||
Reference in New Issue
Block a user