recognize: название из метабазы санитизируется перед попаданием в план

- чистка стоит на каждой точке входа значения метабазы в план — сборка матча,
  копия кандидата для ревью, набор закреплённых значений источника и его
  чтение: гарантия, поставленная только на запись, обходится данными,
  сохранёнными прежними версиями
- название, непригодное как имя каталога (пустое или без единой буквы и
  цифры), не подставляется — раздача уходит в review с названной причиной
- гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям
  провайдера, чистится только копия, уходящая дальше
This commit is contained in:
av
2026-08-10 10:41:16 +03:00
parent bb278e8744
commit 9aecf757e0
26 changed files with 1653 additions and 53 deletions
@@ -0,0 +1,157 @@
## ADDED Requirements
### Requirement: Санитайзинг названий кандидатов, уходящих в ревью
Система SHALL санитизировать названия кандидата (`Title`, `OriginalTitle`) тем же
санитайзингом человекочитаемых полей плана (см. `recognition`) перед тем, как
унести их из сверки дальше: в список кандидатов для ревью, в хранилище, на экран
и в закрепляемое человеком значение. Причина та же, что у
канонического названия: выбор кандидата человеком — полноправный путь
подтверждения матча, и закреплённое им название становится именем каталога
библиотеки в тех же условиях, что и название авто-матча.
Условия подтверждения сильного матча система SHALL проверять на значениях, как их
отдал провайдер: санитайзинг кандидатов SHALL NOT влиять на эти значения. Порядок
операций требование не нормирует — нормирует исход: гейт матча этой правкой не
двигается, иначе кандидат, чьё название отличается от плана невидимым символом,
начал бы совпадать там, где прежде уходил в review. Полнота списка кандидатов
тоже SHALL остаться прежней.
Если название кандидата после санитайзинга непригодно как компонент пути (пусто
либо без единой буквы и цифры), система SHALL сохранить кандидата в списке — он
остаётся выбором человека и несёт `provider_id` и URL для внешней проверки, — но
закрепление такого названия SHALL приводить к тому же исходу, что и у
канонического: подстановки не происходит, в плане остаётся название
распознавания.
`OriginalTitle` кандидата чистится наравне с `Title`, хотя в хранилище и на экран
сегодня доходит только второе: первое уезжает в результат распознавания и в
диагностику сухого прогона, и держать в одной структуре одно чистое поле и одно
грязное — источник будущей ошибки.
#### Scenario: Название кандидата чистится перед показом и закреплением
- **GIVEN** провайдер вернул кандидата, название которого содержит zero-width
символ и кириллический двойник внутри латинского слова
- **WHEN** кандидат попадает в список для ревью
- **THEN** его название очищено тем же санитайзингом, что и поля плана
- **AND** человек, выбравший этого кандидата, закрепляет очищенное название
- **AND** в имя каталога библиотеки оно уходит в тех же условиях, что и название
авто-матча (живой папки-якоря того же тайтла нет — см. `file-layout`,
«Сходимость базы папки при подтверждённом матче»)
#### Scenario: Сравнение с планом идёт по значению провайдера
- **GIVEN** кандидат, название которого отличается от названия плана только
невидимым символом внутри слова
- **WHEN** проверяются условия подтверждения сильного матча
- **THEN** сравнение идёт по значению, как его отдал провайдер
- **AND** исход подтверждения матча тот же, что был до этого изменения
## MODIFIED Requirements
### Requirement: Подтверждение матча и каноническое имя
При единичном сильном матче система SHALL брать из записи базы официальный
`provider` (`tmdb`|`tvdb`|`tvmaze`) и `provider_id`, а также каноническое название
и год, и подменять ими соответствующие поля плана (для сериала — с учётом внешнего
тега TVDB/IMDb из `externals`, идущего в имя папки). Матч SHALL считаться
подтверждённым только при ровно одном сильном кандидате; при нуле или нескольких
кандидатах подтверждённого матча быть SHALL NOT (авто-раскладка не разрешается,
кандидаты уходят в review). Работа с базами опциональна: при выключенных базах
сверка не выполняется и подтверждённого матча нет.
Каноническое название — **недоверенное значение внешнего сервиса**, и перед
подстановкой в план система SHALL применять к нему тот же санитайзинг
человекочитаемых полей, что и к выводу LLM (см. `recognition`, требование
«Санитайзинг человекочитаемых полей плана»). Подстановка несанитизированного
значения SHALL NOT выполняться: план — источник имени каталога библиотеки, и
значение, не прошедшее чистку, уходит в путь на диске.
Если после санитайзинга каноническое название оказывается **непригодным как
компонент пути** — пустым либо вырожденным, то есть не содержащим ни одной буквы
и ни одной цифры (`.`, `..`, `-`, только пунктуация), — система SHALL сохранить в
плане прежнее название и SHALL NOT разрешать авто-раскладку: причина уходит в
перечень причин решения, раздача попадает в review. Проверка пригодности SHALL
стоять **после** санитайзинга, а не до него. Ронять распознавание или раскладку
такой матч SHALL NOT — год, провайдер и `provider_id` при этом подставляются как
обычно.
Санитайзинг, **изменивший** каноническое название, но оставивший его пригодным,
авто-раскладку SHALL NOT блокировать: в план идёт очищенное значение, и оно
предсказуемо — ради этого чистка и стоит.
При подтверждённом матче система SHALL дополнительно попытаться получить из базы
**режиссёра** (TMDB/TVDB credits) и вложить его в план (`director`) как
недоверенное косметическое значение для вывода отображаемого имени. Тот же способ
выборки режиссёра по `provider:id` SHALL быть доступен при закреплении вручную
выбранного в ревью кандидата (см. `review`), т.к. основной путь подтверждения
матча — ручной выбор, а не авто. Выборка режиссёра SHALL быть best-effort: её
недоступность, отсутствие в базе или провайдер без режиссёра (напр. TVMaze) SHALL
NOT проваливать распознавание/матч/выбор — `director` остаётся пустым, а имя
выводится без режиссёра или из более низкого слоя (сохранённый контекст). Режиссёр
из метабазы SHALL иметь приоритет над режиссёром из контекста (более проверенный
источник).
Режиссёр — недоверенное человекочитаемое поле: он SHALL NOT участвовать в
структурной валидации/гейте авто-раскладки, а его очистка (управляющие символы,
пробелы, лимит длины) применяется при рендере отображаемого имени, а не в
plan-санитайзинге.
#### Scenario: Единичный матч даёт id и каноническое имя
- **GIVEN** поиск вернул ровно одного сильного кандидата TMDB для фильма
- **WHEN** матч подтверждается
- **THEN** план получает `provider`=`tmdb`, `provider_id`, каноническое название и год
#### Scenario: Каноническое название чистится перед подстановкой
- **GIVEN** подтверждённый единичный матч, каноническое название которого содержит
zero-width символ, перевод строки или кириллический двойник внутри латинского слова
- **WHEN** каноническое название подставляется в план
- **THEN** в плане оказывается санитизированное значение
- **AND** авто-раскладка остаётся разрешённой, если прочие условия выполнены
- **AND** когда база имени папки печатается из распознавания (живой папки-якоря
того же тайтла нет — см. `file-layout`, «Сходимость базы папки при подтверждённом
матче»), в имя каталога уходит очищенное значение
#### Scenario: Название базы непригодно как компонент пути — раздача уходит в review
- **GIVEN** подтверждённый единичный матч, каноническое название которого после
санитайзинга пусто (целиком состояло из zero-width символов) либо не содержит ни
одной буквы и ни одной цифры (`.`, `...`, `-`)
- **WHEN** каноническое название подставляется в план
- **THEN** название плана остаётся прежним (подстановки не происходит)
- **AND** решение auto/review содержит причину «название из базы непригодно как имя
каталога», авто-раскладка не разрешается
- **AND** распознавание не проваливается, год и провайдер подставлены
#### Scenario: Нормальное название матча не меняется
- **GIVEN** подтверждённый единичный матч с обычным каноническим названием
- **WHEN** каноническое название подставляется в план
- **THEN** значение в плане совпадает с тем, что отдала база (санитайзинг на чистом
значении ничего не меняет)
- **AND** авто-раскладка остаётся разрешённой, если прочие условия выполнены
#### Scenario: Матч подтягивает режиссёра
- **GIVEN** подтверждённый единичный матч TMDB для фильма, у которого в credits
указан режиссёр
- **WHEN** матч подтверждается
- **THEN** в план вкладывается `director` из credits
- **AND** отображаемое имя может использовать этого режиссёра
#### Scenario: Режиссёр недоступен — матч не ломается
- **GIVEN** подтверждённый матч, для которого выборка режиссёра недоступна или
провайдер режиссёра не отдаёт
- **WHEN** матч подтверждается
- **THEN** `director` остаётся пустым
- **AND** матч подтверждён, распознавание не проваливается
#### Scenario: Несколько кандидатов — матч не подтверждён
- **GIVEN** поиск вернул более одного подходящего кандидата
- **WHEN** оценивается матч
- **THEN** подтверждённого матча нет, кандидаты собираются для выбора в review
@@ -0,0 +1,111 @@
## MODIFIED Requirements
### Requirement: Санитайзинг человекочитаемых полей плана
Перед структурной валидацией плана и сверкой с базами система SHALL санитизировать
человекочитаемые поля плана — `title`, `original_title`, `provider_hint` — как
недоверенный вывод LLM. Санитайзинг SHALL: (1) удалять управляющие и zero-width
символы (C0/C1, `U+200B` и родственные, BOM `U+FEFF`); (2) сводить внутренние
последовательности пробельных к одиночному пробелу и обрезать края; (3) сворачивать
кирилло-латинские homoglyph-двойники (см. ниже).
Санитайзинг SHALL быть **общим для обоих источников названия** — вывода LLM и
записи метабазы. Значение, пришедшее из метабазы и заменяющее поле плана при
подтверждённом матче, SHALL проходить тот же санитайзинг перед подстановкой (см.
`metadata-match`, требование «Подтверждение матча и каноническое имя»). Второго
способа чистить **названия плана** система SHALL NOT заводить: одна реализация
обслуживает оба источника. Речь только о полях плана — очистка компонента пути
под требования файловой системы (`layout`) и очистка отображаемого ярлыка
(`naming`) остаются своими, отдельными и законными.
Санитайзинг SHALL быть **идемпотентным**: повторное применение к уже
санитизированному значению SHALL NOT изменять его. На этом свойстве стоит
утверждение, что правка не меняет путей на диске для раздач с нормальным
названием, и оно проверяется тестом, а не подразумевается.
Следствие, на которое опирается раскладка: к моменту, когда план уходит в решение
auto/review, каждое из полей `title`, `original_title`, `provider_hint` совпадает
с тем, что дал бы санитайзинг этого значения — независимо от того, пришло оно от
LLM или из метабазы.
Свёртка двойников SHALL работать потокенно (по словам, разделённым не-буквенными
символами): токен, все буквы которого принадлежат одному скрипту, система SHALL
оставлять без изменений (билингвальность реальна — кириллические названия
неприкосновенны); в токене смешанного скрипта система SHALL определять доминирующий
скрипт по числу буквенных рун и заменять буквы-меньшинство их визуальными
двойниками из доминирующего скрипта по курируемой таблице. При отсутствии
доминирующего скрипта (равенство) токен SHALL оставаться без изменений.
Санитайзинг SHALL применяться ТОЛЬКО к перечисленным человекочитаемым полям
**плана**. Применение того же санитайзинга к значениям, пришедшим из метабазы —
каноническому названию и названиям кандидатов, — заказано отдельно, см.
`metadata-match`.
`files[].src` система SHALL NOT санитизировать — эти значения обязаны совпадать с
реальными файлами торрента, и расхождение (в т.ч. homoglyph) SHALL оставаться
основанием отклонить план, а не поводом «чинить» путь. `director` система SHALL
NOT санитизировать в плане — он не участвует в пути на диске, и его очистка
применяется при рендере отображаемого имени (см. `metadata-match`).
Когда санитайзинг реально изменил значение поля, система 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), а не к
«починке» пути
#### Scenario: Название из метабазы чистится тем же санитайзингом
- **GIVEN** подтверждённый единичный матч, каноническое название которого содержит
zero-width символ и кириллический двойник внутри латинского слова
- **WHEN** каноническое название подставляется в план
- **THEN** в плане оказывается значение, прошедшее тот же санитайзинг, что и вывод
LLM
#### Scenario: После сверки с базой несанитизированных полей в плане не остаётся
- **GIVEN** план после подтверждённого матча с базой
- **WHEN** оценивается решение auto/review
- **THEN** каждое из полей `title`, `original_title`, `provider_hint` совпадает с
тем, что дал бы санитайзинг этого значения
- **AND** `director` под это требование не подпадает: он чистится при рендере
отображаемого имени
#### Scenario: Повторный санитайзинг ничего не меняет
- **GIVEN** значение, уже прошедшее санитайзинг
- **WHEN** санитайзинг применяется к нему второй раз
- **THEN** значение не изменяется
@@ -0,0 +1,69 @@
## MODIFIED Requirements
### Requirement: Подтверждение матча обновляет отображаемое имя
Система SHALL при подтверждении матча в ревью запускать обновление отображаемого
имени загрузки по подтверждённому распознаванию (см. capability `ingest`):
переливать **полный ярлык** имени — «Название (режиссёр, год)», для сериала со
сводкой сезонов — в `download.display_name` и в имя раздачи qBittorrent, без
нового вызова LLM. Имя строится из эффективных полей (override → распознавание с
вложенным матчем → сохранённый контекст). Подтверждением матча SHALL
считаться как выбор кандидата из списка совпадений, так и ручное добавление
источника по id/URL (оба закрепляют провайдера и каноническое название).
Закрепляемое название источника SHALL проходить санитайзинг человекочитаемых
полей (см. `recognition`) и проверку пригодности как компонента пути (см.
`metadata-match`) — на **общей** точке сборки набора пинов источника, той же,
через которую строится предпросмотр. Отсюда следует свойство, на которое
опирается экран ревью: показанное для источника название и путь совпадают с тем,
что закрепится и разложится по выбору этого источника. Название, непригодное как
имя каталога, пином SHALL NOT становиться — в плане остаётся название
распознавания, а факт отказа SHALL быть наблюдаем в журнале.
Санитайзинг на закреплении SHALL применяться независимо от того, было ли значение
очищено при сохранении кандидата: гарантия чистоты не может держаться на времени
записи строки, иначе кандидаты, сохранённые прежними версиями, обходят её. Тот же
санитайзинг идемпотентен, поэтому на уже очищенном значении он ничего не меняет.
При закреплении выбранного/добавленного источника система SHALL best-effort
получить режиссёра этого источника из метабазы (credits по `provider:id`, см.
`metadata-match`) и закрепить его как override, чтобы он попал в эффективные поля
и в ярлык. Недоступность credits или отсутствие режиссёра SHALL NOT проваливать
выбор источника: режиссёр остаётся из более низкого слоя (сохранённый контекст)
или пустым. Так режиссёр из метабазы появляется и на **основном** пути
подтверждения — ручном выборе кандидата, а не только при авто-матче.
Обновление SHALL выполняться после успешного закрепления выбора кандидата и
SHALL быть best-effort по отношению к qBittorrent: недоступность клиента SHALL
NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие
только при подтверждённом матче».
#### Scenario: Выбор кандидата переливает каноническое имя
- **GIVEN** загрузка в ревью с кандидатами метабазы
- **WHEN** человек выбирает кандидата
- **THEN** провайдер, id и каноническое название закрепляются как override
- **AND** отображаемое имя загрузки обновляется полным ярлыком
#### Scenario: Название источника показано ровно таким, каким закрепится
- **GIVEN** кандидат, название которого содержит zero-width символ или
кириллический двойник внутри латинского слова
- **WHEN** строится список источников для экрана ревью
- **THEN** в строке источника и в его предпросмотре стоит очищенное название
- **AND** выбор этого источника закрепляет то же самое значение
#### Scenario: Кандидат, сохранённый прежней версией, чистится на закреплении
- **GIVEN** кандидат, чьё название записано в хранилище без санитайзинга
- **WHEN** человек выбирает этого кандидата
- **THEN** закрепляется санитизированное значение, а не то, что лежит в хранилище
#### Scenario: Непригодное название источника пином не становится
- **GIVEN** кандидат, название которого не содержит ни одной буквы и ни одной
цифры
- **WHEN** человек выбирает этого кандидата
- **THEN** название пином не становится, в плане остаётся название распознавания
- **AND** провайдер, id и год закрепляются как обычно
- **AND** отказ закрепить название виден в журнале