- чистка стоит на каждой точке входа значения метабазы в план — сборка матча, копия кандидата для ревью, набор закреплённых значений источника и его чтение: гарантия, поставленная только на запись, обходится данными, сохранёнными прежними версиями - название, непригодное как имя каталога (пустое или без единой буквы и цифры), не подставляется — раздача уходит в review с названной причиной - гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям провайдера, чистится только копия, уходящая дальше
216 lines
19 KiB
Markdown
216 lines
19 KiB
Markdown
## 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`, «Сходимость базы папки при подтверждённом матче»)
|
||
наследует имя от живой папки-якоря и не печатает его заново из распознавания.
|
||
Каталог с невидимыми символами, созданный до этой правки, останется якорем, и
|
||
следующий сезон ляжет в него. Лечение — переименовать папку руками, после чего
|
||
сходимость подхватит новое имя. Изменение закрывает появление новых таких
|
||
каталогов, а не существующие.
|