Files
jellybit/openspec/changes/archive/2026-08-10-metadata-title-sanitize/design.md
T
av 9aecf757e0 recognize: название из метабазы санитизируется перед попаданием в план
- чистка стоит на каждой точке входа значения метабазы в план — сборка матча,
  копия кандидата для ревью, набор закреплённых значений источника и его
  чтение: гарантия, поставленная только на запись, обходится данными,
  сохранёнными прежними версиями
- название, непригодное как имя каталога (пустое или без единой буквы и
  цифры), не подставляется — раздача уходит в review с названной причиной
- гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям
  провайдера, чистится только копия, уходящая дальше
2026-08-10 10:41:16 +03:00

216 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
Распознавание строит план в два приёма. Сперва разбирается ответ LLM:
`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`, «Сходимость базы папки при подтверждённом матче»)
наследует имя от живой папки-якоря и не печатает его заново из распознавания.
Каталог с невидимыми символами, созданный до этой правки, останется якорем, и
следующий сезон ляжет в него. Лечение — переименовать папку руками, после чего
сходимость подхватит новое имя. Изменение закрывает появление новых таких
каталогов, а не существующие.