From 9aecf757e0742490e92e51d65712fd342fd90d27 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 10 Aug 2026 10:41:16 +0300 Subject: [PATCH] =?UTF-8?q?recognize:=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B8=D0=B7=20=D0=BC=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D1=8B=20=D1=81=D0=B0=D0=BD=D0=B8=D1=82=D0=B8?= =?UTF-8?q?=D0=B7=D0=B8=D1=80=D1=83=D0=B5=D1=82=D1=81=D1=8F=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=20=D0=BF=D0=BE=D0=BF=D0=B0=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=D0=BC=20=D0=B2=20=D0=BF=D0=BB=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - чистка стоит на каждой точке входа значения метабазы в план — сборка матча, копия кандидата для ревью, набор закреплённых значений источника и его чтение: гарантия, поставленная только на запись, обходится данными, сохранёнными прежними версиями - название, непригодное как имя каталога (пустое или без единой буквы и цифры), не подставляется — раздача уходит в review с названной причиной - гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям провайдера, чистится только копия, уходящая дальше --- .../ADR-2026-08-10-sanitize-at-every-entry.md | 82 +++++++ docs/adr/README.md | 1 + docs/architecture.md | 1 + docs/review.md | 42 ++++ docs/security.md | 2 +- internal/naming/naming.go | 10 +- internal/naming/naming_test.go | 22 ++ internal/recognize/metadata.go | 19 +- internal/recognize/metadata_sanitize_test.go | 220 ++++++++++++++++++ internal/recognize/recognize.go | 8 +- internal/recognize/sanitize.go | 40 +++- internal/recognize/sanitize_test.go | 4 +- internal/recognize/validate.go | 18 +- internal/worker/review.go | 31 ++- internal/worker/review_test.go | 121 ++++++++++ .../.openspec.yaml | 2 + .../design.md | 215 +++++++++++++++++ .../proposal.md | 61 +++++ .../review/report.md | 162 +++++++++++++ .../specs/metadata-match/spec.md | 157 +++++++++++++ .../specs/recognition/spec.md | 111 +++++++++ .../specs/review/spec.md | 69 ++++++ .../tasks.md | 90 +++++++ openspec/specs/metadata-match/spec.md | 98 ++++++++ openspec/specs/recognition/spec.md | 51 +++- openspec/specs/review/spec.md | 69 +++--- 26 files changed, 1653 insertions(+), 53 deletions(-) create mode 100644 docs/adr/ADR-2026-08-10-sanitize-at-every-entry.md create mode 100644 internal/recognize/metadata_sanitize_test.go create mode 100644 openspec/changes/archive/2026-08-10-metadata-title-sanitize/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-10-metadata-title-sanitize/design.md create mode 100644 openspec/changes/archive/2026-08-10-metadata-title-sanitize/proposal.md create mode 100644 openspec/changes/archive/2026-08-10-metadata-title-sanitize/review/report.md create mode 100644 openspec/changes/archive/2026-08-10-metadata-title-sanitize/specs/metadata-match/spec.md create mode 100644 openspec/changes/archive/2026-08-10-metadata-title-sanitize/specs/recognition/spec.md create mode 100644 openspec/changes/archive/2026-08-10-metadata-title-sanitize/specs/review/spec.md create mode 100644 openspec/changes/archive/2026-08-10-metadata-title-sanitize/tasks.md diff --git a/docs/adr/ADR-2026-08-10-sanitize-at-every-entry.md b/docs/adr/ADR-2026-08-10-sanitize-at-every-entry.md new file mode 100644 index 0000000..9566998 --- /dev/null +++ b/docs/adr/ADR-2026-08-10-sanitize-at-every-entry.md @@ -0,0 +1,82 @@ +# Значение метабазы чистится на каждой точке входа в план, а три санитайзера не сводятся в один + +- **Дата:** 2026-08-10 +- **Источник:** + [openspec/changes/archive/2026-08-10-metadata-title-sanitize/design.md](../../openspec/changes/archive/2026-08-10-metadata-title-sanitize/design.md), + разделы `Decisions` (Решения 1, 1a, 3) и `Non-Goals` + +## Контекст + +Название, приходящее из TMDB/TVDB/TVMaze, попадает в имя каталога библиотеки +Jellyfin. Выход LLM мы чистим и считаем недоверенным; название из метабазы того +же обращения не получало, хотя приходит так же — из-за периметра. Наблюдаемый +исход: каталог из невидимых символов выглядит пустым, кириллическая буква внутри +латинского слова даёт вторую папку, неотличимую от первой, и авто-раскладка это +пропускала. + +Разбор показал, что точка входа не одна. Их четыре, и каждая ведёт в имя +каталога: сборка подтверждённого матча, копия кандидата, уходящая на экран +ревью и в хранилище, набор закреплённых значений выбранного человеком +источника и **чтение** уже закреплённого значения. + +## Решение + +**Чистка стоит на каждой из четырёх точек, а не в одной «правильной».** + +Цитата из `design.md`, Решение 1a: + +> Закрываются обе одной и той же чисткой, но в трёх местах — по одному на +> каждую точку, где значение метабазы входит в домен. + +Плюс четвёртая, добавленная по находке эксплуатационного прохода: чистка **на +чтении** закреплённого значения. Гарантия чистоты не может держаться на времени записи строки — +кандидаты и закреплённые значения, сохранённые прежними версиями, обходят её, +а обычное +«Применить» ничего не перезаписывает. Санитайзинг идемпотентен, поэтому лишние +точки на уже чистом значении не делают ничего; это же свойство сделано +нормативным и покрыто тестом. + +Отдельно: **гейт подтверждения матча чистка не двигает.** Сравнение кандидата с +планом идёт по значениям провайдера, чистится только копия, уходящая дальше. +Причина в том, что `normalize` и санитайзинг не эквивалентны: невидимый символ +внутри слова `normalize` превращает в пробел, а санитайзинг удаляет — чистка до +сравнения превратила бы часть нынешних «в review» в «авто». + +## Рассмотренные варианты + +- **Свести три санитайзера проекта в один.** Отвергнуто: у них разный предмет — + `recognize.SanitizeTitle` чистит значение, `layout.sanitizeComponent` — + компонент пути под требования файловой системы, `naming.sanitize` — + отображаемый ярлык. Свёртка гомоглифов — визуально неотличимых букв из разных алфавитов — внутри + `sanitizeComponent` сломала бы + правило сходимости базы папки: она гоняется и по имени, прочитанному с диска. +- **Закрыть только авто-путь, ручной отдать отдельной задаче.** Отвергнуто на + чекпоинте: спека `metadata-match` сама называет ручной выбор **основным** + путём подтверждения матча — починка коснулась бы менее употребимой половины, + а спека утверждала бы свойство, которого нет. +- **Чистить в клиентах метабаз.** Отвергнуто: пришлось бы повторять в трёх + клиентах и в каждом следующем, а проверка «в плане нет грязных полей» + перестала бы читаться в одном месте. +- **Разовая правка данных вместо чистки на чтении.** Отвергнута как более + дорогая и не закрывающая следующего читателя. +- **Полная нормализация Unicode** (NFC/NFKC плюс полная таблица визуально + совпадающих символов Unicode) — Non-Goal. Цель — предсказуемое и сверяемое значение, а не исчерпывающая защита + от визуального совпадения; курируемая кирилло-латинская таблица закрывает + реальный случай. + +## Что осталось нерешённым намеренно + +**Каталог с невидимым символом, уже созданный в библиотеке, кодом не лечится.** +Правило сходимости базы папки наследует имя от живой папки-якоря, и очистка +извлечённой базы напечатала бы рядом вторую, чистую папку — то есть ровно тот +исход с двумя каталогами, против которого затевалось изменение. Лечение — +переименовать папку руками, после чего сходимость подхватит новое имя. +Изменение закрывает появление новых таких каталогов, а не существующие. + +## Цена + +Точек чистки четыре вместо одной, и правило «значение метабазы чистится на +входе в домен» держится на ревью, а не на линтере. Взамен свойство «показанное +на экране совпадает с тем, что ляжет на диск» держится устройством кода: чистка +стоит в `sourcePins` — общем доме набора закреплённых значений, через который +идут и предпросмотр, и закрепление. diff --git a/docs/adr/README.md b/docs/adr/README.md index efb233d..954bbf6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -42,6 +42,7 @@ | Дата | Запись | Статус | | --- | --- | --- | +| 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — | | 2026-08-07 | [Локаль TVDB читается из ответа поиска, а не передаётся в запрос](ADR-2026-08-07-tvdb-locale-reads-response.md) | — | | 2026-08-06 | [Спека следует за кодом, когда гарантия недостижима, а окно узкое](ADR-2026-08-06-spec-follows-code-on-narrow-window.md) | — | | 2026-08-04 | [Конвейер ревью и пайплайн задачи переезжают в плагины](ADR-2026-08-04-review-pipeline-to-plugin.md) | — | diff --git a/docs/architecture.md b/docs/architecture.md index 9638b8f..34acfdf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,6 +111,7 @@ | Время | `store.Now()` — единственный источник меток времени в данных, всегда UTC; формат хранения — RFC 3339. Вторая санкционированная точка wall-clock — timestamp-часть ULID в `ident.NewID` (исключение `^internal/(ident\|store)/` в `.golangci.yml`). Отдельно от меток в данных стоят замеры длительности: `cmd/jellybit` исключён из `forbidigo` целиком (правило `^cmd/`), плюс точечные `//nolint:forbidigo` в `internal/logging/ext.go` и `internal/httpapi/httpapi.go` | | Идентификаторы | `internal/ident` — генерация и нормализация ULID; `ident.Parse` на каждой входной границе | | Целевые имена и превью раскладки | `internal/naming` — одна логика для превью в UI и для реального применения | +| Чистка человекочитаемых значений | три санитайзера с разным предметом, сводить их в один нельзя: `recognize.SanitizeTitle` — значение (недоверенный вход: LLM и метабазы), `layout.sanitizeComponent` — компонент пути под требования ФС, `naming.sanitize` — отображаемый ярлык. Значение метабазы чистится **на каждой** точке входа в план: сборка матча, копия кандидата для ревью, набор закреплённых значений источника и его чтение — [ADR-2026-08-10-sanitize-at-every-entry](adr/ADR-2026-08-10-sanitize-at-every-entry.md) | | Разбор источника | `internal/magnet` и `internal/torrent`; инфохэш извлекается только здесь | | Приём | use-case `ingest` — общий путь для HTTP, веб-UI, Telegram и CLI | | Переходы состояний | `worker` под per-download блокировкой; легальность перехода задаётся декларативным графом | diff --git a/docs/review.md b/docs/review.md index 63ba255..3dfacc0 100644 --- a/docs/review.md +++ b/docs/review.md @@ -181,6 +181,10 @@ Go-сервиса и что здесь уже проскакивало. Устр - `security`: читается ли тело ответа внешнего сервиса целиком без предела — лимита на размер ответа LLM в проекте нет, и это единственный недоверенный канал, где предел не стоит ([security.md](security.md) → «Что вне модели») +- `operations`: гарантия, которую вводит изменение, поставлена на запись или на + чтение — и что будет с данными, записанными до деплоя, которые обычный путь + не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и + очередь ревью её обходила) - `operations`: не удваивает ли новая ветка расход лимита метабаз и платного LLM — повтор, ретрай, «распознать заново» на том же входе? (кэша ответов нет, задача `metadata-cache`) @@ -316,6 +320,44 @@ Go-сервиса и что здесь уже проскакивало. Устр случаи до этой даты не восстанавливались — восстановленная постфактум причина непоймания недостоверна, а именно она и нужна. +## 2026-08-10 — чистка названия метабазы стояла только на записи, и очередь ревью её обходила [пойман] + +- **Где:** `internal/worker/review.go` — `sourcePins`, `applyOverrides`, + `buildSources`. Норма — `openspec/specs/metadata-match/spec.md`, требование + «Санитайзинг названий кандидатов, уходящих в ревью», и + `openspec/specs/review/spec.md`, «Подтверждение матча обновляет отображаемое + имя». +- **Симптом:** найден на ревью самой задачи `metadata-title-sanitize`, до + мерджа. В эксплуатации не всплывал. Сошлись независимо четыре прохода: + `adversary` (построенный путь с падающим тестом), `ops` (постмортем), + `specs` и `code`. +- **Причина:** правка чистила значение метабазы **в момент записи** — при + копировании кандидата в список для ревью. Из этого следовали три дыры разом. + (1) Кандидаты, сохранённые прежними версиями, лежат в хранилище грязными, а + их выбор человеком закреплял название дословно. (2) `applyOverrides` читал + значение, закреплённое до деплоя, дословно, и обычное «Применить» без + повторного выбора источника создавало ровно тот каталог, ради которого + правка затевалась. (3) Предпросмотр источника на экране считался из сырого + названия, а гейт пригодности стоял только на закреплении — экран показывал + одно, раскладка делала другое, при том что «превью = применение» записано + требованием `web-ui`. +- **Чем воспроизведён:** тремя тестами, каждый падает без правки (проверено + прогоном с временно снятой правкой): `TestBuildSources_PreviewMatchesApply`, + `TestChooseCandidate_DirtyLegacyTitleSanitized`, + `TestApplyOverrides_LegacyDirtyPinSanitized`. Плюс прогон `adversary`: + превью `- (2014) [tvdbid-269613]/…` против применяемого + `Догадка (2014) [tvdbid-269613]/…` на одном экране. +- **Почему не поймали:** ловить было нечему — дефект поймали на этом же ревью, + до мерджа. Записывается ради причины его появления: **гарантия, поставленная + на запись, молчаливо не распространяется на данные, записанные раньше.** + Ревью дизайна дошло до «закрыть оба пути подтверждения матча», но точкой + закрытия выбрало запись, а не чтение; вопрос «а что с тем, что уже лежит в + хранилище» не задал никто из трёх проходов стадии дизайна. Его задал + эксплуатационный проход — на оси времени, где он и живёт. +- **Что меняем:** вопрос темы `operations` (ниже) — про гарантию, поставленную + на запись. Решение по существу — ADR-2026-08-10-sanitize-at-every-entry: + чистка стоит на каждой точке входа, включая чтение. + ## 2026-08-06 — уборка своего торрента после отмены сносит чужие файлы [проскочил] - **Где:** `internal/worker/worker.go:501-556` — гард `:501-509`, удаление diff --git a/docs/security.md b/docs/security.md index c4f01dd..06435e2 100644 --- a/docs/security.md +++ b/docs/security.md @@ -29,7 +29,7 @@ REST API работают **без авторизации** осознанно; | Текстовый контекст человека | все транспорты | попадает в промпт LLM целиком | | Сообщение торрент-бота | Telegram (пересылка) | чужой формат, парсер, ссылки; текст автора бота, а не отправителя | | **Ответ LLM** | HTTP к эндпоинту | целиком под влиянием входа выше; названия, годы, номера сезонов и серий, из которых строится целевой путь | -| Ответы метабаз | HTTP к TMDB/TVDB/TVMaze | канонические названия, из которых тоже строится путь | +| Ответы метабаз | HTTP к TMDB/TVDB/TVMaze | канонические названия, из которых тоже строится путь; чистятся наравне с выходом LLM на каждой точке входа в план ([ADR-2026-08-10-sanitize-at-every-entry](adr/ADR-2026-08-10-sanitize-at-every-entry.md)) | | Ответы qBittorrent | HTTP | пути, состояния, размеры | | Запросы веб-UI и REST | LAN | идентификаторы, параметры действий | diff --git a/internal/naming/naming.go b/internal/naming/naming.go index 5c5ef9c..397df78 100644 --- a/internal/naming/naming.go +++ b/internal/naming/naming.go @@ -20,6 +20,7 @@ import ( "log/slog" "strconv" "strings" + "unicode" "unicode/utf8" "git.vakhrushev.me/av/jellybit/internal/llm" @@ -202,13 +203,18 @@ func render(f Fields) string { return Label(f.Title, f.Director, f.Year, f.SeasonLabel()) } -// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы. +// sanitize убирает управляющие и невидимые символы, переводы строк, схлопывает +// пробелы. Категорию Cf (zero-width, BOM, RLO) снимаем наравне с C0: значения +// приходят из метабазы и из сохранённого контекста, то есть снаружи, а ярлык +// уезжает в карточку Telegram, в шапку веб-UI и в имя раздачи qBittorrent — +// RLO там переворачивает отображение. Это же обещает требование metadata-match: +// режиссёр в плане не чистится именно потому, что его чистит рендер. func sanitize(s string) string { s = strings.Map(func(r rune) rune { if r == '\n' || r == '\t' || r == '\r' { return ' ' } - if r < 0x20 { + if r < 0x20 || unicode.Is(unicode.Cf, r) { return -1 } return r diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go index 4cfa247..f28e354 100644 --- a/internal/naming/naming_test.go +++ b/internal/naming/naming_test.go @@ -158,3 +158,25 @@ func TestDeriveNameNilProviderUsesFallback(t *testing.T) { t.Errorf("DeriveName() = %q (ожидался фолбек без LLM)", got) } } + +// Значения ярлыка приходят снаружи — из credits метабазы и из сохранённого +// контекста. Категория Cf (zero-width, BOM, RLO) обязана сниматься здесь: +// требование metadata-match не чистит режиссёра в плане именно потому, что его +// чистит рендер. RLO в карточке Telegram переворачивает отображение имени. +func TestSanitize_StripsFormatChars(t *testing.T) { + cases := []struct{ in, want string }{ + {"Denis\u202eVilleneuve", "DenisVilleneuve"}, + {"Denis\u200bVilleneuve", "DenisVilleneuve"}, + {"Denis\ufeffVilleneuve", "DenisVilleneuve"}, + {"Denis\nVilleneuve", "Denis Villeneuve"}, + {"Дени Вильнёв", "Дени Вильнёв"}, + } + for _, c := range cases { + if got := sanitize(c.in); got != c.want { + t.Errorf("sanitize(%q) = %q, want %q", c.in, got, c.want) + } + } + if got := Label("Dune", "Denis\u202eVilleneuve", 2021, ""); got != "Dune (DenisVilleneuve, 2021)" { + t.Errorf("Label = %q", got) + } +} diff --git a/internal/recognize/metadata.go b/internal/recognize/metadata.go index e668a39..8fc2d0e 100644 --- a/internal/recognize/metadata.go +++ b/internal/recognize/metadata.go @@ -72,13 +72,17 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me } // Копим кандидатов для выбора (дедуп по провайдеру+id, потолок). + // Названия чистим на КОПИИ: выбор кандидата человеком — такой же + // путь подтверждения матча, как авто, и закреплённое название так + // же уезжает в имя каталога. Исходный cands не трогаем — по нему + // ниже ищется сильный матч, и сдвигать его гейт задача не заказывала. for _, c := range cands { ck := c.Provider + ":" + c.ID if seen[ck] || len(candidates) >= maxCandidates { continue } seen[ck] = true - candidates = append(candidates, c) + candidates = append(candidates, sanitizeCandidate(c)) } // Единичный сильный матч ищем у первого подходящего провайдера. @@ -139,10 +143,21 @@ func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c meta } } prov, pid := CandidateTag(c) + // Каноническое название чистим здесь — в единственной точке сборки Match, и + // уже ПОСЛЕ гейта сильного матча (strongMatches сравнивает по значениям + // провайдера). Дальше по потоку значение считается чистым: и подстановка в + // план, и решение auto/review, и диагностика сухого прогона берут его как + // есть, а не чистят каждый заново. + title := c.Title + if clean := SanitizeTitle(title); clean != title { + logctx.FromOr(ctx, r.log).Debug("metadata match title sanitized", + "before", title, "after", clean) + title = clean + } return &Match{ Provider: prov, ProviderID: pid, - Title: c.Title, + Title: title, Year: c.Year, Director: directorOf(ctx, r.log, p, mt, c.ID), SeasonEpisodeCounts: counts, diff --git a/internal/recognize/metadata_sanitize_test.go b/internal/recognize/metadata_sanitize_test.go new file mode 100644 index 0000000..b2b4453 --- /dev/null +++ b/internal/recognize/metadata_sanitize_test.go @@ -0,0 +1,220 @@ +package recognize + +import ( + "context" + "testing" + + "git.vakhrushev.me/av/jellybit/internal/metadata" +) + +// Провенанс набора входов — отчёт триажа ревью tvdb-title-locale (находка 2) и +// раздел «Воспроизведение» задачи metadata-title-sanitize. До правки каждый из +// них давал auto=true и уезжал в имя каталога библиотеки дословно. +const dunePlanResp = `{"type":"movie","title":"Dune","original_title":"Dune","year":2021, + "confidence":0.9,"files":[ + {"src":"Dune.2021/movie.mkv","role":"main","season":null,"episode":null} + ]}` + +func duneInput() Input { + return Input{ + Name: "Dune.2021.2160p.BluRay.x265", + Files: []File{{Path: "Dune.2021/movie.mkv", Size: 20 << 30}}, + } +} + +// recognizeWithMatchTitle прогоняет полный Recognize против базы, единственная +// запись которой названа dbTitle. Год и оригинальное название совпадают с планом, +// поэтому матч подтверждается и все прочие условия авто чисты — судим ровно +// подстановку названия. +func recognizeWithMatchTitle(t *testing.T, dbTitle string) Result { + t.Helper() + p := &fakeProvider{candidates: []metadata.Candidate{ + {Provider: "tmdb", ID: "438631", Title: dbTitle, OriginalTitle: "Dune", Year: 2021}, + }} + r := New(&fakeLLM{responses: []string{dunePlanResp}}, []metadata.Provider{p}, + Config{MaxRetries: 2}, testLogger()) + res, err := r.Recognize(context.Background(), duneInput()) + if err != nil { + t.Fatalf("Recognize: %v", err) + } + return res +} + +// Четыре входа из «Воспроизведения»: значение в плане обязано совпадать с тем, +// что дал бы санитайзер, а не с тем, что отдала база. +func TestRecognize_MatchTitleSanitized(t *testing.T) { + cases := []struct { + name string + db string + want string + auto bool + about string + }{ + { + name: "zero-width внутри слова", db: "Du\u200bne", want: "Dune", auto: true, + about: "невидимка снимается, авто остаётся", + }, + { + name: "RLO переворачивает отображение", db: "Dune\u202egnp.mkv", + want: "Dunegnp.mkv", auto: true, + about: "управляющий символ снимается", + }, + { + name: "перевод строки", db: "Dune\nHACK", want: "Dune HACK", auto: true, + about: "перевод строки сводится к пробелу", + }, + { + name: "кириллический двойник", db: "Dunа", want: "Duna", auto: true, + about: "двойник сворачивается в доминирующий скрипт", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + res := recognizeWithMatchTitle(t, c.db) + if res.Match == nil { + t.Fatalf("матч должен быть подтверждён (%s)", c.about) + } + if res.Plan.Title != c.want { + t.Errorf("plan.Title = %q, want %q", res.Plan.Title, c.want) + } + if res.Plan.Title == c.db { + t.Errorf("значение базы уехало в план дословно: %q", res.Plan.Title) + } + if res.Decision.Auto != c.auto { + t.Errorf("auto = %v, want %v; reasons=%v", + res.Decision.Auto, c.auto, res.Decision.Reasons) + } + }) + } +} + +// Название, непригодное как имя каталога: пустое после чистки либо голая +// пунктуация. Подстановки не происходит, авто заблокирована причиной, но матч +// остаётся подтверждённым — id и год верны, распознавание не падает. +func TestRecognize_MatchTitleUnusable(t *testing.T) { + for _, db := range []string{"\u200b\u200b\u200b", ".", "...", "-", " . "} { + t.Run(db, func(t *testing.T) { + res := recognizeWithMatchTitle(t, db) + if res.Plan.Title != "Dune" { + t.Errorf("plan.Title = %q, want %q (название распознавания)", + res.Plan.Title, "Dune") + } + if res.Decision.Auto { + t.Error("авто-раскладка должна быть заблокирована") + } + if !hasReason(res.Decision.Reasons, "непригодно как имя каталога") { + t.Errorf("причина не названа: %v", res.Decision.Reasons) + } + if res.Match == nil || res.Match.ProviderID != "438631" { + t.Errorf("матч обязан остаться подтверждённым, got %+v", res.Match) + } + if res.Plan.Year != 2021 { + t.Errorf("год должен быть подставлен, got %d", res.Plan.Year) + } + }) + } +} + +// Нормальное название: значение базы доезжает как есть, авто разрешена. Это +// защита от регресса на TMDB — санитайзинг на чистом значении не делает ничего. +func TestRecognize_MatchTitleNormalUnchanged(t *testing.T) { + res := recognizeWithMatchTitle(t, "Dune: Part One") + if res.Plan.Title != "Dune: Part One" { + t.Errorf("plan.Title = %q, want %q", res.Plan.Title, "Dune: Part One") + } + if !res.Decision.Auto { + t.Errorf("auto = false, reasons=%v", res.Decision.Reasons) + } +} + +// Кандидаты, уходящие в review, очищены: их названия закрепляет человек, и они +// становятся именем каталога так же, как каноническое. +func TestMatchMetadata_CandidatesSanitized(t *testing.T) { + p := &fakeProvider{candidates: []metadata.Candidate{ + {Provider: "tmdb", ID: "1", Title: "Du\u200bne", OriginalTitle: "Dun\u200be", Year: 2021}, + {Provider: "tmdb", ID: "2", Title: "Dunа Part Two", Year: 2024}, + }} + r := recognizerWith(p) + _, cands := r.matchMetadata(context.Background(), + Plan{Type: MediaMovie, Title: "Nothing Matches Here", Year: 1900}) + if len(cands) != 2 { + t.Fatalf("candidates = %d, want 2", len(cands)) + } + if cands[0].Title != "Dune" || cands[0].OriginalTitle != "Dune" { + t.Errorf("кандидат не очищен: %+v", cands[0]) + } + if cands[1].Title != "Duna Part Two" { + t.Errorf("двойник у кандидата не свёрнут: %q", cands[1].Title) + } +} + +// Гейт матча не сдвинулся: кандидат, отличающийся от плана только невидимым +// символом внутри слова, сильным не считается. Сравнение идёт по значению +// провайдера — санитизируется только копия, уходящая в review. +func TestMatchMetadata_SanitizeDoesNotMoveGate(t *testing.T) { + p := &fakeProvider{candidates: []metadata.Candidate{ + {Provider: "tmdb", ID: "1", Title: "Du\u200bne", Year: 2021}, + }} + r := recognizerWith(p) + m, cands := r.matchMetadata(context.Background(), + Plan{Type: MediaMovie, Title: "Dune", Year: 2021}) + if m != nil { + t.Errorf("невидимка внутри слова не должна давать сильный матч, got %+v", m) + } + if len(cands) != 1 || cands[0].Title != "Dune" { + t.Errorf("кандидат обязан уйти в review очищенным, got %+v", cands) + } +} + +// Список кандидатов не обеднел: после подтверждённого матча у первого провайдера +// остальные продолжают пополнять список для review. +func TestMatchMetadata_CandidatesFromAllProvidersKept(t *testing.T) { + a := &fakeProvider{name: "tmdb", candidates: []metadata.Candidate{ + {Provider: "tmdb", ID: "1", Title: "Dune", Year: 2021}, + }} + b := &fakeProvider{name: "tvdb", candidates: []metadata.Candidate{ + {Provider: "tvdb", ID: "9", Title: "Dune Other", Year: 2021}, + }} + r := New(&fakeLLM{}, []metadata.Provider{a, b}, Config{}, testLogger()) + m, cands := r.matchMetadata(context.Background(), + Plan{Type: MediaMovie, Title: "Dune", Year: 2021}) + if m == nil { + t.Fatal("матч у первого провайдера должен подтвердиться") + } + if len(cands) != 2 { + t.Errorf("candidates = %d, want 2 (кандидаты второго провайдера не теряются)", + len(cands)) + } +} + +func TestUsableTitle(t *testing.T) { + usable := []string{"Dune", "2001", "Ne Zha", "Брат", "«Дюна»"} + unusable := []string{"", ".", "..", "...", "-", " . ", "—", "!?"} + for _, s := range usable { + if !UsableTitle(s) { + t.Errorf("UsableTitle(%q) = false, want true", s) + } + } + for _, s := range unusable { + if UsableTitle(s) { + t.Errorf("UsableTitle(%q) = true, want false", s) + } + } +} + +// Идемпотентность несёт два утверждения сразу: что правка не двигает путей у +// раздач с нормальным названием и что «в плане нет несанитизированных полей» +// вообще проверяемо. Без теста она подразумевалась. +func TestSanitizeTitle_Idempotent(t *testing.T) { + inputs := []string{ + "Du\u200bne", "Dune\u202egnp.mkv", "Dune\nHACK", "Dunа", + "Dune: Part One", " spaced out ", "\u200b\u200b\u200b", ".", "-", + "Тёмный рыцарь", "哪吒之魔童降世", + } + for _, in := range inputs { + once := SanitizeTitle(in) + if twice := SanitizeTitle(once); twice != once { + t.Errorf("sanitizeTitle не идемпотентен на %q: %q → %q", in, once, twice) + } + } +} diff --git a/internal/recognize/recognize.go b/internal/recognize/recognize.go index 68273e8..edb1578 100644 --- a/internal/recognize/recognize.go +++ b/internal/recognize/recognize.go @@ -260,7 +260,13 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) { // review, когда единичного сильного матча нет. match, candidates := r.matchMetadata(ctx, plan) if match != nil { - plan.Title = match.Title + // Match.Title пришёл санитизированным (buildMatch чистит его в единственной + // точке сборки матча). Здесь остаётся только вопрос пригодности: название, + // не годное как имя каталога, не подставляем вовсе — в плане остаётся + // название распознавания, а decide уводит раздачу в review. + if UsableTitle(match.Title) { + plan.Title = match.Title + } if match.Year != 0 { plan.Year = match.Year } diff --git a/internal/recognize/sanitize.go b/internal/recognize/sanitize.go index 117a7d8..67f11aa 100644 --- a/internal/recognize/sanitize.go +++ b/internal/recognize/sanitize.go @@ -4,6 +4,8 @@ import ( "log/slog" "strings" "unicode" + + "git.vakhrushev.me/av/jellybit/internal/metadata" ) // homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских @@ -34,17 +36,49 @@ func init() { } } -// sanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM: +// SanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM: // (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и // обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем // до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов. -func sanitizeTitle(s string) string { +func SanitizeTitle(s string) string { s = stripControl(s) s = collapseSpaces(s) s = foldHomoglyphs(s) return s } +// UsableTitle отвечает, годится ли название как компонент пути к файлу. Годится +// то, в чём после санитайзинга осталась хотя бы одна буква или цифра: layout +// снимает разделители и обрезает края от точек и пробелов, поэтому «.», «...», +// «-» и прочая голая пунктуация схлопываются там в пустое имя каталога, а +// ведущая точка вдобавок делает каталог скрытым для Jellyfin. +// +// Проверять этим предикатом надо УЖЕ санитизированное значение: невидимые +// символы буквами не являются, но и не мешают — их снимает SanitizeTitle. +// Экспортирована вместе с SanitizeTitle, потому что тот же вопрос задаёт worker +// при закреплении выбранного человеком источника: правило одно, домов у него +// быть не должно. +func UsableTitle(s string) bool { + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return true + } + } + return false +} + +// sanitizeCandidate чистит человекочитаемые названия кандидата метабазы. Зовётся +// на КОПИИ кандидата в момент, когда он уходит из сверки дальше — в список для +// review, в хранилище, на экран и в закрепляемое человеком значение. Значения, +// по которым ищется сильный матч, при этом не меняются: сравнение обязано идти +// по тому, что отдал провайдер (см. metadata-match), иначе кандидат, отличающийся +// от плана невидимым символом, начал бы совпадать там, где прежде уходил в review. +func sanitizeCandidate(c metadata.Candidate) metadata.Candidate { + c.Title = SanitizeTitle(c.Title) + c.OriginalTitle = SanitizeTitle(c.OriginalTitle) + return c +} + // sanitizePlan применяет санитайзинг к человекочитаемым полям плана. files[].src // НЕ трогаем: они обязаны байт-в-байт совпадать с реальными файлами торрента, и // homoglyph там — настоящий mismatch (отклоняется валидацией, уходит в review), @@ -57,7 +91,7 @@ func sanitizePlan(p *Plan, log *slog.Logger) { } func sanitizeField(v, field string, log *slog.Logger) string { - clean := sanitizeTitle(v) + clean := SanitizeTitle(v) if clean != v && log != nil { log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean) } diff --git a/internal/recognize/sanitize_test.go b/internal/recognize/sanitize_test.go index 738bd98..0b7da94 100644 --- a/internal/recognize/sanitize_test.go +++ b/internal/recognize/sanitize_test.go @@ -49,8 +49,8 @@ func TestSanitizeTitle(t *testing.T) { "The Matrix": "The Matrix", } for in, want := range cases { - if got := sanitizeTitle(in); got != want { - t.Errorf("sanitizeTitle(%q) = %q, want %q", in, got, want) + if got := SanitizeTitle(in); got != want { + t.Errorf("SanitizeTitle(%q) = %q, want %q", in, got, want) } } } diff --git a/internal/recognize/validate.go b/internal/recognize/validate.go index 5057be6..3ca854a 100644 --- a/internal/recognize/validate.go +++ b/internal/recognize/validate.go @@ -84,10 +84,10 @@ func validateSchema(p *Plan, in Input) error { } // decide считает решение модели уверенности (см. recognition.md). Авто — -// только если выполнено всё: подтверждённый единичный матч в базе; чистая -// структурная валидация (для сериала — число серий бьётся с базой); -// согласованность с пред-парсом; самооценка LLM не ниже порога. Любая -// невыполненная — причина ухода в review. +// только если выполнено всё: подтверждённый единичный матч в базе; каноническое +// название матча пригодно как имя каталога; чистая структурная валидация (для +// сериала — число серий бьётся с базой); согласованность с пред-парсом; +// самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review. func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision { var reasons []string @@ -98,6 +98,16 @@ func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold reasons = append(reasons, "не найдено в базе или несколько кандидатов") } + // Каноническое название базы, непригодное как имя каталога (пустое или без + // единой буквы и цифры), в план не подставлено — там осталось название + // распознавания. Матч при этом верен: id, год и режиссёр на месте, поэтому + // отклоняем не матч, а авто-раскладку. Match.Title уже санитизирован + // (buildMatch), второй раз не чистим: два независимых пересчёта одного + // условия разъедутся на первой же правке одного из них. + if match != nil && !UsableTitle(match.Title) { + reasons = append(reasons, "название из базы непригодно как имя каталога") + } + reasons = append(reasons, structuralWarnings(p)...) if match != nil && p.Type == MediaSeries { diff --git a/internal/worker/review.go b/internal/worker/review.go index 1d8c6c0..72b953a 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -812,7 +812,8 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store. if w.recognizer != nil { director = w.recognizer.Director(ctx, candMediaType(rec), cand.Provider, cand.ProviderID) } - for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year, director) { + pins := sourcePins(cand.Provider, cand.ProviderID, title, year, director) + for field, value := range pins { if err := w.store.SetOverride(ctx, id, field, value); err != nil { return fmt.Errorf("choose candidate: %w", err) } @@ -820,8 +821,13 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store. if err := w.store.SetCandidateChosen(ctx, rec.ID, cand.ID); err != nil { return fmt.Errorf("choose candidate: %w", err) } + // title_pinned=false означает, что название кандидата не годится как имя + // каталога и в плане осталось название распознавания. Без этого атрибута + // вопрос «почему папка названа догадкой, а не как в базе» по логам не + // разбирается: на авто-пути такой отказ несёт причина решения, здесь её нет. logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen", - "provider", cand.Provider, "provider_id", cand.ProviderID) + "provider", cand.Provider, "provider_id", cand.ProviderID, + "title_pinned", pins[ovrTitle] != "") // Подтверждённый матч — переливаем каноническое имя в display_name и в ярлык // раздачи (best-effort, косметика). Сбой обновления имени не должен ронять // выбор кандидата: логируем и продолжаем. @@ -911,6 +917,18 @@ func sourcePins(provider, providerID, title string, year int, director string) m if year > 0 { yr = strconv.Itoa(year) } + // Название источника — недоверенное значение метабазы, и чистится оно здесь, + // на единственном общем доме набора пинов: через sourcePins идут и превью + // источника, и его закрепление, поэтому «превью = применение» держится + // конструкцией, а не памятью. Чистка идемпотентна — на кандидате, записанном + // уже с чисткой, она ничего не меняет, а строку из БД, сохранённую прежней + // версией, приводит в порядок. Непригодное как имя каталога название пином не + // становится: пустое значение очищает пин, и в плане остаётся название + // распознавания. + title = recognize.SanitizeTitle(title) + if !recognize.UsableTitle(title) { + title = "" + } return map[string]string{ ovrProvider: provider, ovrProviderID: providerID, @@ -1297,8 +1315,15 @@ func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, pr // applyOverrides применяет ручные правки к плану: каноническое имя/год (из // выбранного кандидата базы) и помечает игнорируемые файлы ролью ignore (их // раскладка пропустит). +// +// Название чистится здесь, НА ЧТЕНИИ, и это не дубль чистки в sourcePins: та +// держит хранилище чистым, а эта защищает от значений, записанных прежними +// версиями. Пин, закреплённый до появления санитайзинга, иначе доезжает до имени +// каталога дословно при обычном «Применить» — без повторного выбора источника +// запись в хранилище никто не перепишет. Чистка идемпотентна, так что на пинах, +// записанных уже с ней, обе не делают ничего. func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan { - if t := overrides[ovrTitle]; t != "" { + if t := recognize.SanitizeTitle(overrides[ovrTitle]); recognize.UsableTitle(t) { plan.Title = t } if y := overrides[ovrYear]; y != "" { diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 415887f..b722a36 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -2285,3 +2285,124 @@ func TestSweepLinking_LeavesOtherStates(t *testing.T) { t.Errorf("review task moved to %q", st.downloads["2"].State) } } + +// Кандидат с непригодным названием (голая пунктуация — TVDB правится +// сообществом, мусорные записи там штатны): пин названия не ставится, и в +// эффективном плане остаётся название распознавания. Иначе в библиотеке +// появился бы каталог «- (2000)», а на «.» раскладка упала бы ошибкой слоя +// layout — тот же исход, что и у канонического названия на авто-пути. +func TestChooseCandidate_UnusableTitleNotPinned(t *testing.T) { + for _, title := range []string{"-", ".", "..."} { + t.Run(title, func(t *testing.T) { + w, st := reviewWithCandidate(t, store.MetadataCandidate{ + Provider: "tvdb", ProviderID: "269613", + Title: store.NullString(title), + Year: sql.NullInt64{Int64: 2014, Valid: true}, + }) + if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil { + t.Fatalf("ChooseCandidate: %v", err) + } + if ov := st.overrides["1"]; ov[ovrTitle] != "" { + t.Errorf("непригодное название закреплено: %q", ov[ovrTitle]) + } + // Провайдер, id и год закрепляются как обычно — матч верен. + if ov := st.overrides["1"]; ov[ovrProviderID] != "269613" || ov[ovrYear] != "2014" { + t.Errorf("overrides = %v", st.overrides["1"]) + } + plan, _, _, err := w.effectivePlan(context.Background(), "1") + if err != nil { + t.Fatalf("effectivePlan: %v", err) + } + if plan.Title != "Догадка" { + t.Errorf("plan.Title = %q, want название распознавания", plan.Title) + } + }) + } +} + +// Кандидат, сохранённый ПРЕЖНЕЙ версией (до чистки на входе в список), лежит в +// БД грязным. Гарантия чистоты не может держаться на времени записи: чистка +// стоит на закреплении, в sourcePins, и идемпотентна — на новых строках это +// no-op. Без неё невидимка доезжает до имени каталога, потому что +// layout.sanitizeComponent категорию Cf не трогает. +func TestChooseCandidate_DirtyLegacyTitleSanitized(t *testing.T) { + w, st := reviewWithCandidate(t, store.MetadataCandidate{ + Provider: "tvdb", ProviderID: "269613", + Title: store.NullString("Far\u200bgo"), + Year: sql.NullInt64{Int64: 2014, Valid: true}, + }) + if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil { + t.Fatalf("ChooseCandidate: %v", err) + } + if got := st.overrides["1"][ovrTitle]; got != "Fargo" { + t.Errorf("закреплено %q, want %q — грязная строка из БД не очищена", got, "Fargo") + } + plan, _, _, err := w.effectivePlan(context.Background(), "1") + if err != nil { + t.Fatalf("effectivePlan: %v", err) + } + if plan.Title != "Fargo" { + t.Errorf("plan.Title = %q", plan.Title) + } +} + +// Превью = применение: строка источника показывает ровно то название, которое +// закрепится по клику. Прежде превью считалось из сырого названия кандидата, а +// гейт пригодности стоял только на закреплении — экран обещал одно, раскладка +// делала другое. +func TestBuildSources_PreviewMatchesApply(t *testing.T) { + for _, c := range []struct{ name, dbTitle, want string }{ + {"непригодное название", "-", "Догадка"}, + {"грязное название", "Far\u200bgo", "Fargo"}, + } { + t.Run(c.name, func(t *testing.T) { + w, st := reviewWithCandidate(t, store.MetadataCandidate{ + Provider: "tvdb", ProviderID: "269613", + Title: store.NullString(c.dbTitle), + Year: sql.NullInt64{Int64: 2014, Valid: true}, + }) + rd, err := w.ReviewData(context.Background(), "1") + if err != nil { + t.Fatalf("ReviewData: %v", err) + } + var src *SourceOption + for i := range rd.Sources { + if rd.Sources[i].Kind == SourceCandidate { + src = &rd.Sources[i] + } + } + if src == nil { + t.Fatal("кандидат не попал в источники") + } + if src.Title != c.want { + t.Errorf("превью показывает %q, want %q", src.Title, c.want) + } + if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil { + t.Fatalf("ChooseCandidate: %v", err) + } + plan, _, _, err := w.effectivePlan(context.Background(), "1") + if err != nil { + t.Fatalf("effectivePlan: %v", err) + } + if plan.Title != src.Title { + t.Errorf("превью %q, применилось %q — расхождение", src.Title, plan.Title) + } + }) + } +} + +// Пин, закреплённый ПРЕЖНЕЙ версией, лежит в overrides грязным, и обычное +// «Применить» его не переписывает — источник заново не выбирают. Поэтому чистка +// стоит и на чтении: иначе раздача, стоящая в ревью на момент выката, создаёт +// ровно тот каталог, ради которого затевалась правка. +func TestApplyOverrides_LegacyDirtyPinSanitized(t *testing.T) { + plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Догадка", Year: 2000} + got := applyOverrides(plan, map[string]string{ovrTitle: "Fa\u200brgo"}) + if got.Title != "Fargo" { + t.Errorf("plan.Title = %q, want %q — грязный пин доехал до раскладки", got.Title, "Fargo") + } + // Непригодный пин названием не становится: остаётся название распознавания. + if got := applyOverrides(plan, map[string]string{ovrTitle: "-"}); got.Title != "Догадка" { + t.Errorf("plan.Title = %q, want название распознавания", got.Title) + } +} diff --git a/openspec/changes/archive/2026-08-10-metadata-title-sanitize/.openspec.yaml b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/.openspec.yaml new file mode 100644 index 0000000..d7bc011 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-10 diff --git a/openspec/changes/archive/2026-08-10-metadata-title-sanitize/design.md b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/design.md new file mode 100644 index 0000000..de2f359 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/design.md @@ -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`, «Сходимость базы папки при подтверждённом матче») + наследует имя от живой папки-якоря и не печатает его заново из распознавания. + Каталог с невидимыми символами, созданный до этой правки, останется якорем, и + следующий сезон ляжет в него. Лечение — переименовать папку руками, после чего + сходимость подхватит новое имя. Изменение закрывает появление новых таких + каталогов, а не существующие. diff --git a/openspec/changes/archive/2026-08-10-metadata-title-sanitize/proposal.md b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/proposal.md new file mode 100644 index 0000000..de8c651 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/proposal.md @@ -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, и это + главный риск изменения. +- Пути на диске не меняются ни для одной раздачи с нормальным названием: + санитайзинг идемпотентен и на чистом значении не меняет ничего. +- Внешних зависимостей, схемы БД и конфигурации изменение не касается. diff --git a/openspec/changes/archive/2026-08-10-metadata-title-sanitize/review/report.md b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/review/report.md new file mode 100644 index 0000000..33121b5 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/review/report.md @@ -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 = "Denis‮Villeneuve"` → + `label = "Dune (Denis‮Villeneuve, 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="/etc/passwd"` → каталог `/etc/passwd (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`), а не ревью. + +**Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.** +На этом изменении — правило разбора и санитизации — именно такой проход на прошлом +прогоне приносил независимый оракул. diff --git a/openspec/changes/archive/2026-08-10-metadata-title-sanitize/specs/metadata-match/spec.md b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/specs/metadata-match/spec.md new file mode 100644 index 0000000..229e079 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/specs/metadata-match/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-08-10-metadata-title-sanitize/specs/recognition/spec.md b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/specs/recognition/spec.md new file mode 100644 index 0000000..1d8c43c --- /dev/null +++ b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/specs/recognition/spec.md @@ -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** значение не изменяется diff --git a/openspec/changes/archive/2026-08-10-metadata-title-sanitize/specs/review/spec.md b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/specs/review/spec.md new file mode 100644 index 0000000..b127563 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/specs/review/spec.md @@ -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** отказ закрепить название виден в журнале diff --git a/openspec/changes/archive/2026-08-10-metadata-title-sanitize/tasks.md b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/tasks.md new file mode 100644 index 0000000..6125a7b --- /dev/null +++ b/openspec/changes/archive/2026-08-10-metadata-title-sanitize/tasks.md @@ -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`), `Dunegnp.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 + (находка триажа) diff --git a/openspec/specs/metadata-match/spec.md b/openspec/specs/metadata-match/spec.md index ab11df4..a3b86ac 100644 --- a/openspec/specs/metadata-match/spec.md +++ b/openspec/specs/metadata-match/spec.md @@ -54,6 +54,26 @@ кандидаты уходят в review). Работа с базами опциональна: при выключенных базах сверка не выполняется и подтверждённого матча нет. +Каноническое название — **недоверенное значение внешнего сервиса**, и перед +подстановкой в план система SHALL применять к нему тот же санитайзинг +человекочитаемых полей, что и к выводу LLM (см. `recognition`, требование +«Санитайзинг человекочитаемых полей плана»). Подстановка несанитизированного +значения SHALL NOT выполняться: план — источник имени каталога библиотеки, и +значение, не прошедшее чистку, уходит в путь на диске. + +Если после санитайзинга каноническое название оказывается **непригодным как +компонент пути** — пустым либо вырожденным, то есть не содержащим ни одной буквы +и ни одной цифры (`.`, `..`, `-`, только пунктуация), — система SHALL сохранить в +плане прежнее название и SHALL NOT разрешать авто-раскладку: причина уходит в +перечень причин решения, раздача попадает в review. Проверка пригодности SHALL +стоять **после** санитайзинга, а не до него. Ронять распознавание или раскладку +такой матч SHALL NOT — год, провайдер и `provider_id` при этом подставляются как +обычно. + +Санитайзинг, **изменивший** каноническое название, но оставивший его пригодным, +авто-раскладку SHALL NOT блокировать: в план идёт очищенное значение, и оно +предсказуемо — ради этого чистка и стоит. + При подтверждённом матче система SHALL дополнительно попытаться получить из базы **режиссёра** (TMDB/TVDB credits) и вложить его в план (`director`) как недоверенное косметическое значение для вывода отображаемого имени. Тот же способ @@ -77,6 +97,36 @@ plan-санитайзинге. - **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 @@ -386,3 +436,51 @@ fallback: если первый проход подтвердил матч ил - **THEN** гейт даёт двух сильных кандидатов вместо одного - **AND** подтверждённого матча нет, обе записи уходят кандидатами в review +### 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** исход подтверждения матча тот же, что был до этого изменения + diff --git a/openspec/specs/recognition/spec.md b/openspec/specs/recognition/spec.md index 9ef5fd4..9bf7b90 100644 --- a/openspec/specs/recognition/spec.md +++ b/openspec/specs/recognition/spec.md @@ -179,6 +179,25 @@ per-file `season`/`episode` (отдельного скалярного `season` последовательности пробельных к одиночному пробелу и обрезать края; (3) сворачивать кирилло-латинские homoglyph-двойники (см. ниже). +Санитайзинг SHALL быть **общим для обоих источников названия** — вывода LLM и +записи метабазы. Значение, пришедшее из метабазы и заменяющее поле плана при +подтверждённом матче, SHALL проходить тот же санитайзинг перед подстановкой (см. +`metadata-match`, требование «Подтверждение матча и каноническое имя»). Второго +способа чистить **названия плана** система SHALL NOT заводить: одна реализация +обслуживает оба источника. Речь только о полях плана — очистка компонента пути +под требования файловой системы (`layout`) и очистка отображаемого ярлыка +(`naming`) остаются своими, отдельными и законными. + +Санитайзинг SHALL быть **идемпотентным**: повторное применение к уже +санитизированному значению SHALL NOT изменять его. На этом свойстве стоит +утверждение, что правка не меняет путей на диске для раздач с нормальным +названием, и оно проверяется тестом, а не подразумевается. + +Следствие, на которое опирается раскладка: к моменту, когда план уходит в решение +auto/review, каждое из полей `title`, `original_title`, `provider_hint` совпадает +с тем, что дал бы санитайзинг этого значения — независимо от того, пришло оно от +LLM или из метабазы. + Свёртка двойников SHALL работать потокенно (по словам, разделённым не-буквенными символами): токен, все буквы которого принадлежат одному скрипту, система SHALL оставлять без изменений (билингвальность реальна — кириллические названия @@ -187,10 +206,15 @@ per-file `season`/`episode` (отдельного скалярного `season` двойниками из доминирующего скрипта по курируемой таблице. При отсутствии доминирующего скрипта (равенство) токен SHALL оставаться без изменений. -Санитайзинг SHALL применяться ТОЛЬКО к перечисленным человекочитаемым полям. +Санитайзинг SHALL применяться ТОЛЬКО к перечисленным человекочитаемым полям +**плана**. Применение того же санитайзинга к значениям, пришедшим из метабазы — +каноническому названию и названиям кандидатов, — заказано отдельно, см. +`metadata-match`. `files[].src` система SHALL NOT санитизировать — эти значения обязаны совпадать с реальными файлами торрента, и расхождение (в т.ч. homoglyph) SHALL оставаться -основанием отклонить план, а не поводом «чинить» путь. +основанием отклонить план, а не поводом «чинить» путь. `director` система SHALL +NOT санитизировать в плане — он не участвует в пути на диске, и его очистка +применяется при рендере отображаемого имени (см. `metadata-match`). Когда санитайзинг реально изменил значение поля, система SHALL логировать это на уровне `Debug` (названия не относятся к секретам). @@ -233,3 +257,26 @@ per-file `season`/`episode` (отдельного скалярного `season` - **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** значение не изменяется + diff --git a/openspec/specs/review/spec.md b/openspec/specs/review/spec.md index 209e45f..ccf9a86 100644 --- a/openspec/specs/review/spec.md +++ b/openspec/specs/review/spec.md @@ -231,6 +231,20 @@ LLM; нет матча в базе или несколько кандидато считаться как выбор кандидата из списка совпадений, так и ручное добавление источника по id/URL (оба закрепляют провайдера и каноническое название). +Закрепляемое название источника SHALL проходить санитайзинг человекочитаемых +полей (см. `recognition`) и проверку пригодности как компонента пути (см. +`metadata-match`) — на **общей** точке сборки набора пинов источника, той же, +через которую строится предпросмотр. Отсюда следует свойство, на которое +опирается экран ревью: показанное для источника название и путь совпадают с тем, +что закрепится и разложится по выбору этого источника. Название, непригодное как +имя каталога, пином SHALL NOT становиться — в плане остаётся название +распознавания, а факт отказа SHALL быть наблюдаем в журнале. + +Санитайзинг на закреплении SHALL применяться независимо от того, было ли значение +очищено при сохранении кандидата: гарантия чистоты не может держаться на времени +записи строки, иначе кандидаты, сохранённые прежними версиями, обходят её. Тот же +санитайзинг идемпотентен, поэтому на уже очищенном значении он ничего не меняет. + При закреплении выбранного/добавленного источника система SHALL best-effort получить режиссёра этого источника из метабазы (credits по `provider:id`, см. `metadata-match`) и закрепить его как override, чтобы он попал в эффективные поля @@ -244,46 +258,35 @@ SHALL быть best-effort по отношению к qBittorrent: недост NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие только при подтверждённом матче». -#### Scenario: Выбор кандидата обновляет имя +#### Scenario: Выбор кандидата переливает каноническое имя -- **GIVEN** загрузка в ревью с пустым или неинформативным `display_name` - (например, «Unknown») и списком кандидатов -- **WHEN** пользователь выбирает кандидата, подтверждая матч -- **THEN** выбор кандидата закрепляется как и прежде -- **AND** `download.display_name` обновляется полным ярлыком - «Название (режиссёр, год)» (для сериала — со сводкой сезонов) -- **AND** раздача в qBittorrent переименовывается в то же имя +- **GIVEN** загрузка в ревью с кандидатами метабазы +- **WHEN** человек выбирает кандидата +- **THEN** провайдер, id и каноническое название закрепляются как override +- **AND** отображаемое имя загрузки обновляется полным ярлыком -#### Scenario: Ручное добавление источника обновляет имя +#### Scenario: Название источника показано ровно таким, каким закрепится -- **GIVEN** загрузка в ревью без совпадений в списке -- **WHEN** пользователь вручную добавляет источник по id/URL, подтверждая матч -- **THEN** источник закрепляется как и прежде -- **AND** `download.display_name` и имя раздачи в qBittorrent обновляются - полным ярлыком подтверждённого источника +- **GIVEN** кандидат, название которого содержит zero-width символ или + кириллический двойник внутри латинского слова +- **WHEN** строится список источников для экрана ревью +- **THEN** в строке источника и в его предпросмотре стоит очищенное название +- **AND** выбор этого источника закрепляет то же самое значение -#### Scenario: Выбор кандидата подтягивает режиссёра в ярлык +#### Scenario: Кандидат, сохранённый прежней версией, чистится на закреплении -- **GIVEN** загрузка в ревью, у выбранного кандидата в credits метабазы указан - режиссёр -- **WHEN** пользователь выбирает кандидата, подтверждая матч -- **THEN** режиссёр best-effort извлекается из метабазы и закрепляется override -- **AND** `download.display_name` получает полный ярлык с этим режиссёром +- **GIVEN** кандидат, чьё название записано в хранилище без санитайзинга +- **WHEN** человек выбирает этого кандидата +- **THEN** закрепляется санитизированное значение, а не то, что лежит в хранилище -#### Scenario: Режиссёр кандидата недоступен — выбор не ломается +#### Scenario: Непригодное название источника пином не становится -- **GIVEN** выбор кандидата, для которого credits недоступны или режиссёра нет -- **WHEN** пользователь подтверждает матч -- **THEN** выбор источника выполнен, режиссёр берётся из сохранённого контекста - или остаётся пустым -- **AND** команда ревью не возвращает ошибку - -#### Scenario: Недоступность qBittorrent не ломает выбор кандидата - -- **GIVEN** выбор кандидата в ревью -- **WHEN** переименование раздачи в qBittorrent завершается ошибкой -- **THEN** выбор кандидата и обновление `download.display_name` выполнены -- **AND** команда ревью не возвращает ошибку +- **GIVEN** кандидат, название которого не содержит ни одной буквы и ни одной + цифры +- **WHEN** человек выбирает этого кандидата +- **THEN** название пином не становится, в плане остаётся название распознавания +- **AND** провайдер, id и год закрепляются как обычно +- **AND** отказ закрепить название виден в журнале ### Requirement: Инфо и предпросмотр выбранного источника