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,2 @@
schema: spec-driven
created: 2026-08-10
@@ -0,0 +1,215 @@
## Context
Распознавание строит план в два приёма. Сперва разбирается ответ LLM:
`parsePlan` вызывает `sanitizePlan`, и три человекочитаемых поля — `title`,
`original_title`, `provider_hint` — чистятся как недоверенный вход. Потом идёт
сверка с метабазой, и при подтверждённом матче `recognize.go` подменяет поля
плана каноническими значениями:
```go
match, candidates := r.matchMetadata(ctx, plan)
if match != nil {
plan.Title = match.Title // ← значение внешнего сервиса, мимо чистки
...
}
dec := decide(plan, pre, match, ...)
```
Подстановка стоит **после** чистки, поэтому очищено ровно то, что чисткой потом
и перезаписывается. Дальше `plan.Title` уходит в `layout.titleYear` и становится
именем каталога библиотеки.
Ниже по потоку `layout.sanitizeComponent` снимает разделители пути, символы,
недопустимые в SMB/NTFS, и байты `< 0x20`. Категорию Cf (zero-width, BOM,
мягкий перенос) он не трогает и гомоглифы не сворачивает — они не мешают
файловой системе и потому там не при чём.
Гейт авто-раскладки от этого не спасает: `metadata.go` сверяет
`normalize(c.Title) || normalize(c.OriginalTitle)`, то есть кандидат проходит по
**любому** из двух полей, а в план и в путь уезжает только первое.
Инвариант «целевой путь строго под библиотекой» при этом держится — ревью
проверило `Dune/../../etc`, `" .. "`, `"..."` и 400 символов, выхода из
песочницы нет. Речь о предсказуемости имени, а не о песочнице.
## Goals / Non-Goals
**Goals:**
- Название из метабазы получает ту же чистку, что и название от LLM, до того как
попадёт в план.
- Название, схлопнувшееся чисткой в пустое, не порождает каталог с пустым именем
и не роняет раскладку.
- У чистки названий остаётся один дом: второй реализации не заводится.
- Поведение TMDB на нормальных названиях не меняется — регресс на работающем
провайдере дороже самого дефекта.
**Non-Goals:**
- Полная нормализация Unicode (NFC/NFKC, конфузаблы по таблице Unicode). Рамка
задачи это прямо исключает: цель — предсказуемое и сверяемое значение, а не
исчерпывающая защита от визуального совпадения.
- Расширение состава `layout.sanitizeComponent`. Он чистит компонент пути под
требования файловой системы, и категория Cf ему не мешает.
- Чистка `director` в плане. Действующее требование `metadata-match` относит её к
рендеру отображаемого имени, и трогать это изменение не будет.
- Пересмотр гейта матча (`normalize(c.Title) || normalize(c.OriginalTitle)`).
Расхождение «сверяем по двум полям, подставляем одно» после этой правки
перестаёт быть опасным: подставляется очищенное значение.
## Decisions
### Решение 1. Чистить на подстановке, в `recognize`
Санитайзинг применяется к `match.Title` в месте подстановки — там же, где
сегодня стоит `plan.Title = match.Title`. Используется существующая
`sanitizeTitle`, без новых функций.
*Почему здесь.* Место подстановки — единственная точка, где значение метабазы
входит в план; всё, что ниже, работает уже с планом. Чистка здесь означает
инвариант, который можно сформулировать одной фразой и проверить: **план после
матча не содержит несанитизированных человекочитаемых полей**.
*Рассмотрено и отвергнуто:*
- **Закрыть категорию Cf и гомоглифы в `layout.sanitizeComponent`.** Дало бы
второй дом чистке названий и разошлось бы с первым: `sanitizeTitle` сворачивает
гомоглифы потокенно по доминирующему скрипту — правило нетривиальное, и две его
реализации разъедутся молча. Плюс `sanitizeComponent` работает и на
`FolderBase`, прочитанной с диска, — там свёртка гомоглифов сломала бы
сходимость к существующей папке.
- **Чистить в клиентах метабаз (`internal/metadata/*.go`).** Пришлось бы
повторить в трёх клиентах и повторять в каждом следующем; проверка «в плане нет
грязных полей» перестала бы читаться в одном месте.
- **Прогнать `sanitizePlan` ещё раз после матча.** Внешне дешевле всего, но
вторым проходом чистит и то, что уже чисто, а главное — молчит о случае, когда
название базы схлопнулось в пустое: `p.Title` стал бы пустым, и раскладка
упала бы на `layout: empty title after sanitization`.
### Решение 1a. Точек входа две, и закрываются обе
Ревью дизайна показало, что подстановка при авто-матче — не единственный вход
значения метабазы в план. Второй: человек выбирает кандидата на ревью, его
название закрепляется как override и попадает в план мимо распознавания. Спека
`metadata-match` при этом сама называет ручной выбор **основным** путём
подтверждения матча — закрыть только авто-путь значило бы починить менее
употребимую половину и записать в спеку свойство, которого нет.
Закрываются обе одной и той же чисткой, но в трёх местах — по одному на каждую
точку, где значение метабазы входит в домен:
1. **каноническое название матча** — в `buildMatch`, единственной точке сборки
`Match`. Дальше по потоку значение считается чистым: и подстановка в план, и
решение auto/review, и диагностика сухого прогона берут его как есть. Ревью
кода показало, чем плоха чистка на подстановке: условие пригодности
пересчитывалось независимо в двух файлах, и первая же правка одного из них
дала бы молчаливую авто-раскладку;
2. **названия кандидатов** — в момент, когда кандидат копируется в список для
ревью (`matchMetadata`, накопление `candidates`). Оттуда чистое значение
уезжает разом в хранилище, на экран ревью и в карточку Telegram;
3. **набор пинов источника**`sourcePins` в `worker`. Это не дубль пункта 2, а
ответ на два разных вопроса. Во-первых, через `sourcePins` идут **и** превью
источника, **и** его закрепление, поэтому свойство «превью = применение»
держится конструкцией: без чистки здесь экран показывал бы одно название, а
раскладка делала другое. Во-вторых, пункт 2 чистит **на записи**, то есть
гарантия держалась бы на времени записи строки — все кандидаты, сохранённые
до этой правки и стоящие в очереди ревью, обошли бы её. Чистка идемпотентна,
поэтому на новых строках пункт 3 не делает ничего.
*Почему это не двигает гейт матча.* Сильный матч ищется по `cands` — срезу, как
его отдал провайдер, — а в ревью уходит **копия** в `candidates`. Чистка на
копировании до сравнения не доходит. Проверять это пришлось отдельно: сравнение
идёт через `normalize`, и она **не** эквивалентна санитайзингу — zero-width
внутри слова `normalize` превращает в пробел (`Du␀ne``du ne`), а санитайзинг
удаляет (`dune`). То есть чистка всего списка `cands` до сравнения превратила бы
часть нынешних «в review» в «авто», и это был бы сдвиг гейта, которого задача не
заказывала.
*Рассмотрено и отвергнуто:* звать санитайзер прямо в `chooseCandidateLocked`
это закрыло бы закрепление и оставило превью считаться по сырому значению, то
есть развело бы показанное и применённое. Правило живёт в `sourcePins`, потому
что это единственный общий дом набора пинов; запланированный
`review-mapping-editor` придёт туда же, а не заведёт четвёртый вызов.
*Что при этом экспортируется:* `recognize.SanitizeTitle` и `recognize.UsableTitle`
— пара «почисти и проверь пригодность». Экспортировать пришлось обе: предикат без
санитайзера обязывал бы вызывающего помнить порядок, а порядок прозой не
проверяется.
### Решение 2. Пустой результат чистки — прежнее название и уход в review
Если `sanitizeTitle(match.Title)` даёт пустую строку, подстановка не выполняется:
в плане остаётся название от LLM (оно уже прошло чистку и непустое — иначе
`validateSchema` отклонил бы план), а в причины решения добавляется строка, из-за
которой раздача уходит в ревью.
*Почему так.* Из трёх исходов — упасть, подставить пустое, оставить прежнее —
только третий сохраняет работоспособность и при этом не скрывает происшествие.
Название из одних невидимых символов означает, что с записью базы что-то не так,
и это ровно тот случай, ради которого ревью и существует: система не уверена —
зовёт человека, а не заминает.
*Рассмотрено и отвергнуто:* отклонять матч целиком (терялись бы `provider_id`,
год и режиссёр — а они верны); подставлять пустое и ловить это раскладкой (отказ
приходит поздно, терминальным состоянием и текстом чужого слоя — ровно та боль,
которую чинит соседняя задача `long-title-to-review`).
### Решение 3. Год, режиссёр и провайдер не трогаются
`match.Year` — число, чистить нечего. `match.Director` в путь на диске не
попадает, и его очистка по действующему требованию делается при рендере имени
(`naming.sanitize`). `provider`/`provider_id` — идентификаторы, у них своя
валидация.
`original_title` в план из матча **не подставляется вовсе** — сегодня
`recognize.go` берёт из `Match` только название, год и режиссёра. Чистить там
нечего, и требование про «поля плана после матча» это учитывает: значение
`original_title` осталось тем, что пришло от LLM, то есть уже санитизированным.
### Решение 4. Решение auto/review остаётся с одним производителем
`Decision{Auto, Reasons}` для разобранного плана рождается ровно в одном месте —
`decide` в `validate.go`, и её доккомментарий утверждает исчерпывающий перечень
условий авто. Новая причина появляется **внутри** `decide`, а не дописывается к
готовому решению со сбросом `Auto`: иначе у того же решения появляется второй
производитель, и следующая правка гейта (соседняя задача `long-title-to-review`
заводит причину того же класса) будет выбирать между двумя местами.
Дополнительного входа у `decide` при этом не появилось, хотя сперва
предполагался: раз `Match.Title` санитизируется в `buildMatch` (Решение 1a),
`decide` отвечает на вопрос пригодности по уже чистому значению — `match` у неё
и так на руках. Условие считается один раз и читается из одного поля.
### Решение 5. Общая форма «название непригодно как компонент пути» отложена
Пустое-или-вырожденное название и название длиннее лимита файловой системы — один
класс: значение не годится как компонент пути, исход один — review с названной
причиной вместо отказа из слоя раскладки. Общее место для этого класса здесь
**не** заводится: второй его случай — предмет соседней задачи
`long-title-to-review`, и собирать общую форму из одного случая рано. Это
записано, чтобы второй автор не изобретал её параллельно, а достроил.
## Risks / Trade-offs
- **Правка меняет работающий путь TMDB.** → Значение проходит через
идемпотентную чистку, которая на нормальном названии не меняет ничего; критерий
приёмки требует зелёных существующих тестов `internal/recognize` и
`internal/metadata` **без правки ожиданий** — расхождение сразу видно.
- **Свёртка гомоглифов может тронуть честное название.** → Правило потокенное:
одно-скриптовый токен не трогается, и кириллические названия проходят как есть.
Правило уже работает на выводе LLM и покрыто сценариями спеки; изменение лишь
распространяет его на второй источник.
- **Гомоглифы сворачиваются, а конфузаблы шире таблицы — остаются.** →
Осознанный trade-off, названный в рамках задачи. Курируемая кирилло-латинская
таблица закрывает реальный случай (русскоклавиатурные двойники); полная
нормализация Unicode — отдельный разговор.
- **Название базы схлопнулось в пустое — пользователь видит название от LLM.** →
Раздача при этом в ревью, где название правится подсказкой, а причина названа
словами.
- **Уже созданные «грязные» каталоги правкой не чинятся.** → Правило сходимости
базы папки (`file-layout`, «Сходимость базы папки при подтверждённом матче»)
наследует имя от живой папки-якоря и не печатает его заново из распознавания.
Каталог с невидимыми символами, созданный до этой правки, останется якорем, и
следующий сезон ляжет в него. Лечение — переименовать папку руками, после чего
сходимость подхватит новое имя. Изменение закрывает появление новых таких
каталогов, а не существующие.
@@ -0,0 +1,61 @@
## Why
Название, которое система берёт из метабазы (TMDB, TVDB, TVMaze) при
подтверждённом матче, попадает в имя каталога библиотеки Jellyfin дословно —
таким, каким его отдал внешний сервис. Вывод LLM мы чистим и считаем
недоверенным; название из метабазы того же обращения не получает, хотя приходит
ровно так же — из-за периметра.
Итог наблюдаем: название из трёх невидимых символов (zero-width) даёт каталог,
имя которого выглядит пустым; название с кириллической `а` внутри латинского
слова даёт второй каталог, визуально неотличимый от первого; перевод строки
внутри названия доезжает до плана. Ни один из этих случаев авто-раскладку не
останавливает — она проходит, и разбирается это потом руками в библиотеке.
Класс не новый: тем же путём ходит TMDB с самого начала. Разговор поднят
ревью изменения `tvdb-title-locale`, где тот же путь распространили на второго
провайдера.
## What Changes
- Название, взятое из метабазы при подтверждённом матче, проходит ту же чистку,
что и название от LLM, — **до** того, как попасть в план и оттуда в путь на
диске. Дом чистки остаётся один, второго способа чистить названия не заводится.
- Ту же чистку проходят названия кандидатов, уходящих на ревью: выбор кандидата
человеком — полноправный путь подтверждения матча, и закреплённое им название
становится именем каталога так же, как название авто-матча. Условия
подтверждения сильного матча при этом не меняются: сравнение с планом идёт по
значению, как его отдал провайдер.
- Название, от которого после чистки ничего не остаётся, план не перезаписывает:
раздача уходит в ревью с названной причиной, а не раскладывается автоматически
и не падает на пустом имени каталога.
- Режиссёр остаётся как есть — он не участвует в пути на диске, и его чистка по
действующему требованию делается при выводе отображаемого имени.
## Capabilities
### New Capabilities
Новых нет.
### Modified Capabilities
- `metadata-match`: требование «Подтверждение матча и каноническое имя»
дополняется — каноническое название перед подстановкой в план санитизируется,
а название, схлопнувшееся в пустое, подстановку не выполняет и блокирует
авто-раскладку.
- `recognition`: требование «Санитайзинг человекочитаемых полей плана»
перестаёт быть требованием только о выводе LLM — оно называет чистку общей для
обоих источников названия и фиксирует, что после сверки с базой в плане не
остаётся несанитизированных человекочитаемых полей.
## Impact
- `internal/recognize` — порядок подстановки канонического названия
относительно санитайзинга (`recognize.go`), причина ухода в review
(`validate.go`).
- Поведение **обоих** провайдеров: правка меняет уже работающий путь TMDB, и это
главный риск изменения.
- Пути на диске не меняются ни для одной раздачи с нормальным названием:
санитайзинг идемпотентен и на чистом значении не меняет ничего.
- Внешних зависимостей, схемы БД и конфигурации изменение не касается.
@@ -0,0 +1,162 @@
# Отчёт ревью — `metadata-title-sanitize`
Стадия: ревью кода после apply, до archive. Отчёт триажа записан оркестратором
(проход `review-triage` пишет только во временный каталог).
## Сводка
| | |
|---|---|
| Размер / сложность / метка | среднее / незнакомое / `large` (максимум по осям) |
| Обоснование метки | триггер «заводится или меняется правило идентичности, слияния или разбора — построение целевых путей и санитизация имён» (`docs/review.md` → «Триггеры метки») |
| Режим | по графу |
| База диффа | `HEAD~1`, рабочее дерево |
| Гейт | зелёный, прогнан триажом самостоятельно: 14 шагов `OK`, ни одного `SKIP`, `-race` реально выполнился, diff-coverage 46/46 строк (100%) |
| Находок на входе | 16 (+1 замер): specs 4, code 3, architecture 3, adversary 3, ops 3, autotests 0 |
| После дедупликации по причине | 11 причин + 1 найденная самим триажом |
| Итог | 0 блокирующих, 1 «стоит исправить сейчас», 3 гипотезы, 2 promote |
Сигнал о заниженной метке: `review-code` возражений не подал. Второго,
независимого от него корректора на этом прогоне не было — `basics` не
запускался, своих тем проекта не размечено.
## План разметки и исход по каждой теме
| тема | дом | глубина | закрывает | исход |
|---|---|---|---|---|
| requirements | дельты change + `openspec/specs/{metadata-match,recognition}` | разбор | specs | закрыта, 4 находки (minor) |
| autotests | `CLAUDE.md` → «Гейт» | — | autotests | закрыта, 0 находок |
| conventions | `docs/conventions/*` | разбор | code | закрыта, 3 находки (minor), нарушений конвенций 0 |
| architecture | `docs/architecture.md` + источник `docs/passport.md` | доказательство | architecture | закрыта, 3 находки (minor) |
| security | `docs/security.md` | доказательство | adversary | закрыта, 2 построенных пути (major) + 1 свойство |
| operations | `docs/architecture.md` «Эксплуатация» + источник `docs/database.md` | доказательство | ops | закрыта, 3 постмортема (2 major) + 1 замер |
**Тем без отчёта нет.** Тем без дома план не содержал.
## Что стало с каждой причиной
| # | причина | кто нашёл | статус |
|---|---|---|---|
| 1 | превью источника расходится с применением | architecture + adversary | закрыта: общий дом пинов `sourcePins`; `TestBuildSources_PreviewMatchesApply` |
| 2 | кандидат, лежащий в БД грязным, пиннит несанитизированное название | specs + code + adversary | закрыта: чистка в `sourcePins`; `TestChooseCandidate_DirtyLegacyTitleSanitized` |
| 3 | пин, закреплённый до деплоя, читается дословно | ops | закрыта: чистка на чтении в `applyOverrides`; `TestApplyOverrides_LegacyDirtyPinSanitized` |
| 4 | предикат пригодности считается дважды из сырого значения | specs + code | закрыта: `decide` читает уже чистое `Match.Title` |
| 5 | отказ закрепить название молчит | specs + code | закрыта: атрибут `title_pinned` |
| 6 | `Match.Title` остаётся сырым, чистка в двух местах | architecture | закрыта: единственная точка сборки `buildMatch` |
| 7 | `specs/review` обещает закрепление безусловно | specs | закрыта: заведена дельта `review` |
| 8 | грязный каталог-якорь наследуется дальше | ops | не чинится сознательно, см. H1 |
| 9 | название от LLM гейта пригодности не получает | adversary | вне объёма, см. P1 |
| 10 | набор пинов пишется пятью транзакциями | ops | пре-существующее, см. H2 |
| 11 | `resolveFolderBase` даёт `SCAN` по `recognition` | ops (замер) | пре-существующее, см. H3 |
| 12 | режиссёр и название доезжают до карточки с RLO и zero-width | **триаж** | закрыта: `naming.sanitize` снимает Cf |
## Блокирует мердж
Пусто. Обе `major`-находки враждебного прохода и обе `major` эксплуатационного
отработаны, каждая с падающим-без-правки тестом в дереве. Потолок не срабатывал.
## Стоит исправить сейчас — 1 находка (отработана)
### Название и режиссёр из метабазы доезжают до карточки Telegram, шапки веб-UI и имени раздачи с RLO и zero-width
- Файл: `internal/naming/naming.go`, `sanitize`
- Severity: `minor` · Confidence: `high`
- Оракул (прогон триажа): `director = "DenisVilleneuve"`
`label = "Dune (DenisVilleneuve, 2021)"`; `naming.sanitize` снимал только
C0 (`r < 0x20`) и не трогал категорию Cf.
- Последствие: дельта `recognition` этого изменения обосновывает отказ чистить
`director` в плане тем, что «его очистка применяется при рендере отображаемого
имени». Рендер этой гарантии для Cf не давал — то есть изменение записывало в
спеку свойство, которого в коде нет. RLO из credits разворачивает отображение
имени в карточке Telegram, которая для единственного пользователя и есть
интерфейс.
- Найдено проходом: триаж, при добыче оракула на утверждение дельты. Ни один
проход этого не заявлял: тема `requirements` сверяла дельту с кодом изменения,
а обоснование дельты уводило в пакет, которого дифф не касался.
- **Отработано инлайн:** `naming.sanitize` снимает `unicode.Cf`, тест
`TestSanitize_StripsFormatChars`.
## Гипотезы без доказательства
**H1. Грязный каталог-якорь наследует имя всем последующим раздачам того же
матча.** Оракула нет (постмортем-рассуждение), severity снижена до `minor`.
Предложение прохода — чистить базу, извлечённую из якоря, — дало бы вторую папку
рядом с существующей, то есть исход, против которого затевалась задача.
`planBase` и так гоняет `sanitizeComponent` по `FolderBase`, на уровне ФС база
безопасна; речь только о невидимке внутри имени существующей папки. Задача не
заводится: лечение — переименовать папку руками, записано риском в `design.md`.
**H2. Набор пинов пишется пятью отдельными транзакциями.** Пре-существующее,
диффом не заведено (дифф добавил только присваивание переменной ради лог-атрибута).
Окно — миллисекунды между пятью `UPSERT` в локальный SQLite, пути отказа никто не
строил. Дом — цель `state-integrity`.
**H3. `resolveFolderBase` даёт `SCAN` по `recognition` без индекса на
`(provider, provider_id)`.** Замер прохода `ops`: 12.9 мс при 100 загрузках и
20000 `file_link`. Команды замера в выводе прохода нет, триаж его не
воспроизводил — число несётся как заявленное, не как проверенное.
Пре-существующее; дом есть — `tasks/items/scale-100-downloads.md`.
## Promote candidates
**P1. Гейт пригодности названия стоит у источников, а не на границе `layout`.**
Путь построен триажом: `title="-"` → каталог `- (2021)`; `title="…"` → каталог
`… (2021)`; `title="..."``layout: empty title after sanitization`;
`title="etcpasswd"` → каталог `etcpasswd (2021)`. Инвариант «целевой путь
строго под библиотекой» **не нарушен** (`underRoot` держит, полноширинный `` не
разделитель), «существующее не перезаписываем» тоже (коллизия → review). Отсюда
`minor`. Претензия к правилу: `UsableTitle` зовут три вызывающих со стороны
метабазы, а настоящая граница — вход в `layout`. Класс тот же, что у задачи
`long-title-to-review`; расширить её, а не заводить новую.
**P2. `ident.Parse` на входных границах остаётся правилом без механизации.**
Стоячий разрыв из `CLAUDE.md`, кандидат в `internal/archrules`.
## Отсев вкусовщины
По проектному списку «Типовые ложноположительные» не выброшено ничего — ни одна
входящая находка под его пункты не подошла. По общим критериям выброшены две:
«правило разложено по трём пакетам» (после переноса чистки последствия за
пределами чтения не осталось, а отдельный дом правила у `layout` дельта
`recognition` легитимизирует дословно) и «атрибут `title_pinned` конфлатит два
случая» (исход в обоих один, последствия нет).
## Границы покрытия
**Запускалось:** `autotests`, `specs`, `code`, `architecture`, `adversary`, `ops`,
`triage` — метка `large`, режим по графу. Стадия ревью дизайна прогонялась
отдельно составом `specs`, `rubric`, `architecture`; её находки отработаны до
реализации.
**Не запускалось:** `basics` — своих тем проекта не размечено, принимать нечего;
следствие — второго, независимого от `code`, сигнала о заниженной метке не было.
Проход независимой реализации в конвейере отсутствует (снят по стоимости),
`idiom` упразднён 2026-08-04.
**Ни один из шести проходов не сообщил свой потолок и величину среза.** По
молчанию «показал всё» неотличимо от «показал первые N» — это находка о прогоне.
**Правки оркестратора не видел ни один проход.** Все шесть отчётов сняты с
состояния кода до исправлений; семь закрытых причин — правки, по которым ревью не
проводилось. Их проверка сводится к зелёному гейту со 100% diff-coverage, трём
новым падающим-без-правки тестам и чтению кода в точках единственности
(`buildMatch`, `sourcePins`, `applyOverrides`). Находка №12 — прямое следствие:
она живёт в пакете, которого дифф не касался.
**Не проверит ни один проход** (`docs/review.md` → «Недоступно проверке»):
история инцидентов на umbar; поведение SQLite под реальным объёмом и профилем;
завязка внешних потребителей (Jellyfin, закладки) на текущее поведение; суждение
«этой функциональности не должно существовать»; качество распознавания как
такового.
**Перестали проверять сознательно:** идиоматичность Go — с 2026-08-04, различение
«идиоматично против распространено» не спрашивает никто; класс обратимый,
пересмотр — задача `quality-review-agents`.
**Решения и наблюдения проекта прогон не читал:** `docs/adr/` и `docs/research/`
процессные документы. Расхождение изменения с записанным решением ловит сверка
документации (`av-dev-docs:healthcheck`), а не ревью.
**Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.**
На этом изменении — правило разбора и санитизации — именно такой проход на прошлом
прогоне приносил независимый оракул.
@@ -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** отказ закрепить название виден в журнале
@@ -0,0 +1,90 @@
## 1. Код
- [x] 1.1 В `internal/recognize/recognize.go` прогнать `match.Title` через
`sanitizeTitle` перед подстановкой в `plan.Title`; год, режиссёр и провайдер
оставить как есть
- [x] 1.2 Непригодный результат чистки (пусто либо ни одной буквы и цифры):
подстановку не выполнять, прежнее название сохранить, причину передать **в**
`decide`, а не дописывать к готовому решению (см. design, Решение 4)
- [x] 1.3 Логировать на `Debug`, когда чистка реально изменила каноническое
название — тем же способом, что `sanitizeField` (названия не секреты)
- [x] 1.4 Чистить `Title`/`OriginalTitle` кандидата на **копии**, в момент
`append` в `candidates` (`internal/recognize/metadata.go`). Место накопления не
двигать — оно стоит до `if match != nil { continue }`, и перенос обеднил бы
список кандидатов при подтверждённом матче с заблокированным авто. Срез `cands`
не мутировать: `for _, c := range cands` даёт копию структуры (все поля
`metadata.Candidate` скалярные), поэтому сравнение остаётся на значениях
провайдера и гейт матча не сдвигается
- [x] 1.5 Закрепление кандидата с непригодным названием (`chooseCandidateLocked`,
`internal/worker/review.go`): пин названия не ставится, в плане остаётся
название распознавания — тот же исход, что у канонического названия
## 2. Тесты
- [x] 2.1 Табличный тест на четыре входа из «Воспроизведения» задачи: три ZWSP
(`U+200B`), `Dune<RLO>gnp.mkv` (`U+202E`), `Dune\nHACK`, `Dunа` с кириллической
`а` — проверяет `plan.Title` и `Decision.Auto`
- [x] 2.2 Граничные случаи непригодного названия: три ZWSP (пусто после чистки) и
вырожденные `"."`, `"..."`, `"-"`, `" . "` — план сохраняет прежнее название,
`Auto=false`, причина названа, год и провайдер подставлены
- [x] 2.5 Идемпотентность: `sanitizeTitle(sanitizeTitle(x)) == sanitizeTitle(x)`
по тому же набору входов, что и 2.1
- [x] 2.6 Кандидаты, уходящие в ревью, очищены: те же четыре входа в названии
кандидата — в `Result.Candidates` значения санитизированы
- [x] 2.7 Гейт матча не сдвинулся: кандидат, отличающийся от плана только
zero-width внутри слова, по-прежнему **не** даёт подтверждённого матча
(сравнение идёт по значению провайдера)
- [x] 2.8 Список кандидатов не обеднел: при подтверждённом матче от первого
провайдера кандидаты остальных провайдеров того же ключа по-прежнему в списке
- [x] 2.9 Закрепление кандидата с названием `-`: пин названия не поставлен, в
плане остаётся название распознавания, каталог с мусорным именем не строится
- [x] 2.3 Нормальное название матча: значение в плане совпадает с ответом базы,
`Auto=true` при прочих чистых условиях
- [x] 2.4 Прогнать существующие тесты `internal/recognize` и `internal/metadata`
**без правки ожиданий** — регресс на TMDB виден сразу
## 3. Гейт и спеки
- [x] 3.1 `task gate` зелёный
- [x] 3.2 `openspec validate --strict metadata-title-sanitize`
## Критерии приёмки задачи
Приходят из постановки `tasks/items/metadata-title-sanitize.md`, здесь — дословно.
- [x] A1 Все четыре входа из «Воспроизведения» дают либо санитизированный
`plan.Title`, либо уход в review — но не авто-раскладку с исходным значением.
**Оракул:** тот самый падающий тест из отчёта триажа, перенесённый в дерево
- [x] A2 Название, схлопывающееся санитизацией в пустую строку, не порождает
каталог с пустым именем и не роняет раскладку. **Оракул:** табличный тест на
границе, случай «три ZWSP»
- [x] A3 Поведение TMDB на нормальных названиях не изменилось. **Оракул:**
существующие тесты `internal/recognize` и `internal/metadata` зелёные без правок
ожиданий
- [x] A4 Место санитизации названо требованием спеки, а не только кодом.
**Оракул:** `openspec validate --strict` на дельте
## 4. Правки по ревью кода
- [x] 4.1 Санитайзинг канонического названия перенесён в `buildMatch`
единственную точку сборки `Match`; `recognize.go` и `decide` читают уже чистое
значение и не пересчитывают условие каждый по-своему
- [x] 4.2 Чистка и проверка пригодности названия источника перенесены в
`sourcePins` — общий дом набора пинов, через который идут и превью, и
закрепление; «превью = применение» держится конструкцией
- [x] 4.3 Кандидаты, сохранённые прежней версией, чистятся на закреплении:
гарантия не держится на времени записи строки
- [x] 4.4 Отказ закрепить непригодное название виден в журнале — атрибут
`title_pinned` у существующей записи `review candidate chosen`
- [x] 4.5 Дельта `review` — требование «Подтверждение матча обновляет
отображаемое имя» дополнено санитайзингом на закреплении и свойством
«превью = применение»
- [x] 4.6 Тесты: `TestChooseCandidate_DirtyLegacyTitleSanitized`,
`TestBuildSources_PreviewMatchesApply` (оба падают без правки)
- [x] 4.7 Чистка названия и на **чтении** пина (`applyOverrides`): пин,
закреплённый прежней версией, обычным «Применить» не переписывается, и без
этого доезжал до имени каталога дословно (находка эксплуатационного прохода)
- [x] 4.8 `naming.sanitize` снимает категорию Cf: дельта `recognition`
обосновывает отказ чистить `director` в плане тем, что его чистит рендер, — а
рендер снимал только C0, и RLO из credits разворачивал карточку Telegram
(находка триажа)