From e2ea1840c93a5bad6acc8adf239cd05c63fddd0e Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Fri, 10 Jul 2026 15:45:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D0=BF=D0=BE=D0=B7=D0=BD?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5:=20=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D0=B9=D0=B7=D0=B8=D0=BD=D0=B3=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B9=20=D0=BE=D1=82=20LLM=20+?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=D0=B3=D0=BE=D0=B4=D0=BE=D0=B2=D0=BE=D0=B9?= =?UTF-8?q?=20=D1=84=D0=BE=D0=BB=D0=B1=D1=8D=D0=BA=20=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кейс «Harold and the Purple Crayon»: LLM отдал title с кириллической буквой-двойником, сырое название ушло в запрос TVDB дословно (не нашлось), а гейт нормализации кир/лат двойники не сворачивал — двойной промах, пустой список кандидатов, ручной ввод id. - recognition: санитайзинг человекочитаемых полей плана (title/original_title/ provider_hint) на границе разбора, до валидации: strip control/zero-width, collapse пробелов, потокенная свёртка homoglyph-двойников по курируемой кир↔лат таблице. files[].src не трогаем (обязаны биться с торрентом). - metadata-match: тот же fold в normalize (гейт) как defense-in-depth; безгодовой второй проход сверки как fallback при известном годе и промахе первого — восстанавливает off-by-one авто-матчи и пополняет кандидатов review. В fallback требуем известный год кандидата (год-unknown → review, не авто); гейт год ±1 и инвариант авто-матча не двигаются. Спеки recognition/metadata-match обновлены, change заархивирован. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/recognize/metadata.go | 95 ++++++---- internal/recognize/metadata_test.go | 125 ++++++++++++- internal/recognize/recognize.go | 2 +- internal/recognize/sanitize.go | 161 +++++++++++++++++ internal/recognize/sanitize_test.go | 102 +++++++++++ internal/recognize/validate.go | 9 +- internal/recognize/validate_test.go | 4 +- .../.openspec.yaml | 2 + .../design.md | 164 ++++++++++++++++++ .../proposal.md | 57 ++++++ .../specs/metadata-match/spec.md | 131 ++++++++++++++ .../specs/recognition/spec.md | 64 +++++++ .../tasks.md | 42 +++++ openspec/specs/metadata-match/spec.md | 96 +++++++++- openspec/specs/recognition/spec.md | 63 +++++++ 15 files changed, 1076 insertions(+), 41 deletions(-) create mode 100644 internal/recognize/sanitize.go create mode 100644 internal/recognize/sanitize_test.go create mode 100644 openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/design.md create mode 100644 openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/proposal.md create mode 100644 openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/specs/metadata-match/spec.md create mode 100644 openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/specs/recognition/spec.md create mode 100644 openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/tasks.md diff --git a/internal/recognize/metadata.go b/internal/recognize/metadata.go index 0868d25..7e53908 100644 --- a/internal/recognize/metadata.go +++ b/internal/recognize/metadata.go @@ -22,6 +22,14 @@ const maxCandidates = 8 // (original_title → title → provider_hint, см. searchKeys): базы индексированы // прежде всего по оригинальным названиям. Останавливаемся, как только очередной // ключ дал единичный сильный матч (ранний стоп — дешевле по обращениям к базе). +// +// Проходов сверки два: pass 1 — с годом плана (дешёвое сужение при верном годе), +// pass 2 — fallback без года, только если год известен и pass 1 не подтвердил +// матч. Exact-year фильтр запроса строже гейта strongMatches (год ±1): безгодовой +// проход восстанавливает off-by-one авто-матчи (запись, которую точный фильтр +// отсёк, а гейт принял бы) и пополняет кандидатов для review при бо́льших ошибках +// года. Гейт при этом не меняется (год ±1 по plan.Year), инвариант авто-матча не +// двигается. Кандидаты копятся через оба прохода (дедуп по provider:id, потолок). func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) { if len(r.providers) == 0 { return nil, nil @@ -32,43 +40,65 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me } matchTitles := normSet(plan.Title, plan.OriginalTitle) + keys := searchKeys(plan) + + // Год запроса по проходам: сначала год плана, затем 0 (без года) как fallback. + // При неизвестном годе второй проход был бы идентичен первому — не делаем. + queryYears := []int{plan.Year} + if plan.Year > 0 { + queryYears = append(queryYears, 0) + } var match *Match var candidates []metadata.Candidate seen := map[string]bool{} - for _, key := range searchKeys(plan) { - for _, p := range r.providers { - cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: plan.Year}) - if err != nil { - // Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь — - // доменное решение «пропускаем провайдера, пробуем следующий». - logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name()) - continue - } - - // Копим кандидатов для выбора (дедуп по провайдеру+id, потолок). - for _, c := range cands { - ck := c.Provider + ":" + c.ID - if seen[ck] || len(candidates) >= maxCandidates { + for _, qYear := range queryYears { + // Безгодовой fallback-проход (есть только при известном годе плана). + // В нём требуем известный год кандидата: off-by-one даёт авто-матч, а + // запись с неизвестным годом — только кандидат в review (год подтвердить + // нечем, авто было бы недо-подтверждённым). В pass 1 leniency yearMatches + // к unknown-году сохраняется как прежде. + fallback := qYear == 0 && plan.Year > 0 + for _, key := range keys { + for _, p := range r.providers { + cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: qYear}) + if err != nil { + // Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь — + // доменное решение «пропускаем провайдера, пробуем следующий». + logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name()) continue } - seen[ck] = true - candidates = append(candidates, c) - } - // Единичный сильный матч ищем у первого подходящего провайдера. + // Копим кандидатов для выбора (дедуп по провайдеру+id, потолок). + for _, c := range cands { + ck := c.Provider + ":" + c.ID + if seen[ck] || len(candidates) >= maxCandidates { + continue + } + seen[ck] = true + candidates = append(candidates, c) + } + + // Единичный сильный матч ищем у первого подходящего провайдера. + // Гейт по plan.Year (не qYear): безгодовой проход расширяет только + // выдачу запроса, но требует год кандидата ±1 (и известный — в + // fallback), поэтому условие подтверждения не ослабляется. + if match != nil { + continue + } + strong := strongMatches(cands, plan.Year, matchTitles, fallback) + if len(strong) != 1 { + continue + } + match = r.buildMatch(ctx, p, strong[0], mt) + } if match != nil { - continue + break } - strong := strongMatches(cands, plan.Year, matchTitles) - if len(strong) != 1 { - continue - } - match = r.buildMatch(ctx, p, strong[0], mt) } if match != nil { - break + break // pass 1 подтвердил матч — безгодовой проход не нужен } } return match, candidates @@ -129,11 +159,15 @@ func CandidateTag(c metadata.Candidate) (provider, id string) { // strongMatches оставляет кандидатов, чьё название совпадает с одним из // названий плана (после нормализации) и год бьётся (±1 год), дедуплицируя -// по id. -func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool) []metadata.Candidate { +// по id. requireKnownYear (безгодовой fallback-проход) дополнительно отсекает +// кандидатов с неизвестным годом: авто-матч там требует подтверждённого года. +func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool, requireKnownYear bool) []metadata.Candidate { seen := map[string]bool{} var out []metadata.Candidate for _, c := range cands { + if requireKnownYear && c.Year == 0 { + continue + } if !yearMatches(year, c.Year) { continue } @@ -175,11 +209,14 @@ func normSet(titles ...string) map[string]bool { // normalize приводит название к сравнимому виду: нижний регистр, только // буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое -// расхождение написания: «Тёмный» vs «Темный»). +// расхождение написания: «Тёмный» vs «Темный»). Перед этим сворачиваем +// кирилло-латинские homoglyph-двойники (foldHomoglyphs) — defense-in-depth на +// случай двойников со стороны кандидата базы: гейт сильного матча должен быть +// устойчив к ним независимо от санитайзинга плана. func normalize(s string) string { var b strings.Builder prevSpace := false - for _, r := range strings.ToLower(s) { + for _, r := range strings.ToLower(foldHomoglyphs(s)) { if r == 'ё' { r = 'е' } diff --git a/internal/recognize/metadata_test.go b/internal/recognize/metadata_test.go index 0317a0b..7802054 100644 --- a/internal/recognize/metadata_test.go +++ b/internal/recognize/metadata_test.go @@ -15,6 +15,7 @@ type fakeProvider struct { byTitle map[string][]metadata.Candidate // если задано — результат зависит от запроса counts map[int]int searchErr error + exactYear bool // имитирует жёсткий year-фильтр базы: q.Year>0 отсекает по точному году searched int queries []string // строки запросов в порядке вызова } @@ -31,10 +32,20 @@ func (f *fakeProvider) Search(_ context.Context, q metadata.Query) ([]metadata.C if f.searchErr != nil { return nil, f.searchErr } + res := f.candidates if f.byTitle != nil { - return f.byTitle[q.Title], nil + res = f.byTitle[q.Title] } - return f.candidates, nil + if f.exactYear && q.Year > 0 { + var filtered []metadata.Candidate + for _, c := range res { + if c.Year == q.Year { + filtered = append(filtered, c) + } + } + return filtered, nil + } + return res, nil } func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) { return f.counts, nil @@ -264,6 +275,105 @@ func TestMatchMetadata_Disabled(t *testing.T) { } } +func TestMatchMetadata_YearlessFallbackOffByOne(t *testing.T) { + // Год плана off-by-one: exact-year фильтр запроса отсекает запись в pass 1, + // безгодовой pass 2 её возвращает, гейт ±1 принимает → авто-матч. + p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{ + {Provider: "tmdb", ID: "42", Title: "Harold and the Purple Crayon", Year: 2024}, + }} + r := recognizerWith(p) + m, _ := r.matchMetadata(context.Background(), + Plan{Type: MediaMovie, Title: "Harold and the Purple Crayon", Year: 2023}) + if m == nil || m.ProviderID != "42" { + t.Fatalf("off-by-one год: ожидался матч через безгодовой проход, got %+v", m) + } + if p.searched != 2 { + t.Errorf("searched = %d, want 2 (pass1 с годом + pass2 без года)", p.searched) + } +} + +func TestMatchMetadata_YearlessFallbackBigGapReviewOnly(t *testing.T) { + // Год расходится больше чем на 1: pass 2 вернёт запись, но гейт по году её + // отклонит — подтверждённого матча нет, кандидат уходит в review. + p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{ + {Provider: "tmdb", ID: "7", Title: "X", Year: 2005}, + }} + r := recognizerWith(p) + m, cands := r.matchMetadata(context.Background(), + Plan{Type: MediaMovie, Title: "X", Year: 2010}) + if m != nil { + t.Errorf("расхождение года >1: авто-матч не ожидался, got %+v", m) + } + if len(cands) != 1 || cands[0].ID != "7" { + t.Errorf("кандидат должен собраться для review: %+v", cands) + } +} + +func TestMatchMetadata_YearlessUnknownYearReviewOnly(t *testing.T) { + // Год плана известен, но у записи в базе год неизвестен: pass 1 (с годом) её + // не находит, pass 2 (без года) находит, но в fallback требуется известный + // год → авто-матча нет, кандидат уходит в review. + p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{ + {Provider: "tmdb", ID: "9", Title: "X", Year: 0}, + }} + r := recognizerWith(p) + m, cands := r.matchMetadata(context.Background(), + Plan{Type: MediaMovie, Title: "X", Year: 2020}) + if m != nil { + t.Errorf("год кандидата неизвестен: авто-матч не ожидался, got %+v", m) + } + if len(cands) != 1 || cands[0].ID != "9" { + t.Errorf("кандидат должен собраться для review: %+v", cands) + } +} + +func TestMatchMetadata_NoSecondPassWhenPass1Matches(t *testing.T) { + // pass 1 подтвердил матч — безгодовой проход не выполняется. + p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{ + {Provider: "tmdb", ID: "1", Title: "X", Year: 2000}, + }} + r := recognizerWith(p) + m, _ := r.matchMetadata(context.Background(), + Plan{Type: MediaMovie, Title: "X", Year: 2000}) + if m == nil { + t.Fatal("ожидался матч в pass 1") + } + if p.searched != 1 { + t.Errorf("searched = %d, want 1 (второго прохода быть не должно)", p.searched) + } +} + +func TestMatchMetadata_NoSecondPassWhenYearUnknown(t *testing.T) { + // Год неизвестен (0): второй проход был бы идентичен первому — не делаем. + p := &fakeProvider{candidates: []metadata.Candidate{ + {Provider: "tmdb", ID: "1", Title: "Y", Year: 2000}, + }} + r := recognizerWith(p) + // Название кандидата совпадает, но нет матча по названию плана — гейт не пройдёт; + // проверяем именно число запросов. + r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "Совсем другое"}) + if p.searched != 1 { + t.Errorf("searched = %d, want 1 (год неизвестен → одного прохода достаточно)", p.searched) + } +} + +func TestMatchMetadata_YearlessAmbiguousNoMatch(t *testing.T) { + // Несколько кандидатов из безгодового прохода, проходящих гейт → не подтверждён. + p := &fakeProvider{exactYear: true, candidates: []metadata.Candidate{ + {Provider: "tmdb", ID: "1", Title: "Twin", Year: 2001}, + {Provider: "tmdb", ID: "2", Title: "Twin", Year: 2001}, + }} + r := recognizerWith(p) + m, cands := r.matchMetadata(context.Background(), + Plan{Type: MediaMovie, Title: "Twin", Year: 2000}) + if m != nil { + t.Errorf("неоднозначность из pass 2: матч не ожидался, got %+v", m) + } + if len(cands) != 2 { + t.Errorf("оба кандидата должны собраться для review: %+v", cands) + } +} + func TestNormalize(t *testing.T) { cases := map[string]string{ "The Matrix": "the matrix", @@ -280,6 +390,17 @@ func TestNormalize(t *testing.T) { } } +func TestNormalize_FoldsHomoglyph(t *testing.T) { + // «Harold» с кириллической буквой-двойником 'а' (U+0430) в первом слове + // нормализуется к тому же виду, что и чистая латиница (defense-in-depth на + // стороне кандидата базы). + dirty := "Hаrold and the Purple Crayon" // 'а' — кириллица + clean := "Harold and the Purple Crayon" + if normalize(dirty) != normalize(clean) { + t.Errorf("normalize(%q)=%q != normalize(%q)=%q", dirty, normalize(dirty), clean, normalize(clean)) + } +} + // Сквозной авто: LLM-план + матч в базе + чистая валидация → Decision.Auto. func TestRecognize_AutoWithMatch(t *testing.T) { in := Input{Name: "The.Matrix.1999", Files: []File{{Path: "m/film.mkv", Size: 1}}} diff --git a/internal/recognize/recognize.go b/internal/recognize/recognize.go index da45aaf..1dfa29c 100644 --- a/internal/recognize/recognize.go +++ b/internal/recognize/recognize.go @@ -212,7 +212,7 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) { } raw = resp.Content - plan, parseErr = parsePlan(raw, in) + plan, parseErr = parsePlan(raw, in, log) if parseErr == nil { break } diff --git a/internal/recognize/sanitize.go b/internal/recognize/sanitize.go new file mode 100644 index 0000000..117a7d8 --- /dev/null +++ b/internal/recognize/sanitize.go @@ -0,0 +1,161 @@ +package recognize + +import ( + "log/slog" + "strings" + "unicode" +) + +// homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских +// пар (двойников). Реальная боль — русскоклавиатурные двойники в англоязычных +// названиях (кейс «Hаrold» с кир. `а`). Таблица используется в обе стороны: +// направление свёртки выбирает foldHomoglyphs по доминирующему скрипту токена. +// Единственный источник правды и для санитайзинга плана, и для нормализации в +// гейте матча (normalize). +var homoglyphPairs = []struct{ cyr, lat rune }{ + // строчные + {'а', 'a'}, {'е', 'e'}, {'о', 'o'}, {'р', 'p'}, {'с', 'c'}, + {'у', 'y'}, {'х', 'x'}, {'к', 'k'}, + // заглавные + {'А', 'A'}, {'В', 'B'}, {'Е', 'E'}, {'К', 'K'}, {'М', 'M'}, + {'Н', 'H'}, {'О', 'O'}, {'Р', 'P'}, {'С', 'C'}, {'Т', 'T'}, + {'Х', 'X'}, +} + +var ( + cyr2lat = map[rune]rune{} + lat2cyr = map[rune]rune{} +) + +func init() { + for _, p := range homoglyphPairs { + cyr2lat[p.cyr] = p.lat + lat2cyr[p.lat] = p.cyr + } +} + +// sanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM: +// (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и +// обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем +// до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов. +func sanitizeTitle(s string) string { + s = stripControl(s) + s = collapseSpaces(s) + s = foldHomoglyphs(s) + return s +} + +// sanitizePlan применяет санитайзинг к человекочитаемым полям плана. files[].src +// НЕ трогаем: они обязаны байт-в-байт совпадать с реальными файлами торрента, и +// homoglyph там — настоящий mismatch (отклоняется валидацией, уходит в review), +// а не повод «чинить» путь. Логируем на Debug, когда значение реально изменилось +// (названия не относятся к секретам). +func sanitizePlan(p *Plan, log *slog.Logger) { + p.Title = sanitizeField(p.Title, "title", log) + p.OriginalTitle = sanitizeField(p.OriginalTitle, "original_title", log) + p.ProviderHint = sanitizeField(p.ProviderHint, "provider_hint", log) +} + +func sanitizeField(v, field string, log *slog.Logger) string { + clean := sanitizeTitle(v) + if clean != v && log != nil { + log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean) + } + return clean +} + +// stripControl удаляет format-символы (zero-width, BOM, soft-hyphen — категория +// Cf) и управляющие C0/C1, кроме пробельных (\t\n\r и пр. остаются — их сведёт +// collapseSpaces). +func stripControl(s string) string { + return strings.Map(func(r rune) rune { + switch { + case unicode.Is(unicode.Cf, r): + return -1 + case unicode.IsControl(r) && !unicode.IsSpace(r): + return -1 + default: + return r + } + }, s) +} + +// collapseSpaces сводит последовательности пробельных к одиночному пробелу и +// обрезает края. +func collapseSpaces(s string) string { + var b strings.Builder + b.Grow(len(s)) + pendingSpace := false + for _, r := range s { + if unicode.IsSpace(r) { + pendingSpace = true + continue + } + if pendingSpace && b.Len() > 0 { + b.WriteByte(' ') + } + pendingSpace = false + b.WriteRune(r) + } + return b.String() +} + +// foldHomoglyphs сворачивает кирилло-латинские homoglyph-двойники потокенно. +// Токен (максимальная последовательность букв) из одного скрипта не трогаем — +// билингвальность реальна, честная кириллица неприкосновенна. В смешанном токене +// определяем доминирующий скрипт по числу буквенных рун и заменяем +// буквы-меньшинство их двойниками из доминирующего скрипта; при равенстве +// скриптов токен не меняем. Не-буквенные символы (пробелы, цифры, пунктуация) +// разделяют токены и копируются как есть. +func foldHomoglyphs(s string) string { + runes := []rune(s) + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(runes); { + if !unicode.IsLetter(runes[i]) { + b.WriteRune(runes[i]) + i++ + continue + } + j := i + for j < len(runes) && unicode.IsLetter(runes[j]) { + j++ + } + b.WriteString(foldToken(runes[i:j])) + i = j + } + return b.String() +} + +func foldToken(tok []rune) string { + var cyr, lat int + for _, r := range tok { + switch { + case unicode.Is(unicode.Cyrillic, r): + cyr++ + case unicode.Is(unicode.Latin, r): + lat++ + } + } + if cyr == 0 || lat == 0 { + return string(tok) // одно-скриптовый токен — не трогаем + } + var m map[rune]rune + switch { + case lat > cyr: + m = cyr2lat // доминирует латиница — сворачиваем кириллические двойники + case cyr > lat: + m = lat2cyr + default: + return string(tok) // нет доминирующего скрипта — не трогаем + } + out := make([]rune, len(tok)) + for i, r := range tok { + if repl, ok := m[r]; ok { + out[i] = repl + } else { + out[i] = r + } + } + return string(out) +} diff --git a/internal/recognize/sanitize_test.go b/internal/recognize/sanitize_test.go new file mode 100644 index 0000000..738bd98 --- /dev/null +++ b/internal/recognize/sanitize_test.go @@ -0,0 +1,102 @@ +package recognize + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestFoldHomoglyphs(t *testing.T) { + cases := map[string]string{ + // Latin-доминантный токен: кириллический двойник 'а' (U+0430) → 'a'. + "Hаrold and the Purple Crayon": "Harold and the Purple Crayon", + // Честная кириллица — не трогаем. + "Тёмный рыцарь": "Тёмный рыцарь", + // Честная латиница — не трогаем. + "The Dark Knight": "The Dark Knight", + // Cyrillic-доминантный токен (М р з) с латинскими 'o' (U+006F) → 'о'. + "Мoрoз": "Мороз", + // Равенство скриптов в токене (кир. О U+041E + лат. k) — не трогаем. + "Оk": "Оk", + // Двуязычные, но одно-скриптовые токены — каждый нетронут. + "Fargo Фарго": "Fargo Фарго", + // Буква-меньшинство без двойника в таблице (кир. 'б' U+0431) остаётся. + "Harбld": "Harбld", + // Пустая строка. + "": "", + } + for in, want := range cases { + if got := foldHomoglyphs(in); got != want { + t.Errorf("foldHomoglyphs(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSanitizeTitle(t *testing.T) { + cases := map[string]string{ + // Zero-width (U+200B) внутри слова удаляется, слово склеивается. + "a\u200bb": "ab", + // Zero-width + двойник + лишние пробелы → strip + fold + collapse + trim. + " Hаrold\u200b Crayon ": "Harold Crayon", + // Табы/переводы строк как пробелы, сведены к одиночным. + "Harold\tand the": "Harold and the", + // Управляющий символ (BEL U+0007) удаляется. + "Harold\u0007": "Harold", + // BOM (U+FEFF) удаляется. + "\ufeffFargo": "Fargo", + // Уже чистое — без изменений. + "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) + } + } +} + +func TestSanitizePlan_FieldsCleanedSrcUntouched(t *testing.T) { + // src намеренно содержит кириллический двойник — sanitizePlan его не трогает. + dirtySrc := "Hаrold.mkv" + p := Plan{ + Title: "Hаrold", + OriginalTitle: " spaced ", + ProviderHint: "clean\u200b", + Files: []PlanFile{{Src: dirtySrc, Role: RoleMain}}, + } + sanitizePlan(&p, nil) + + if p.Title != "Harold" { + t.Errorf("Title = %q, want %q", p.Title, "Harold") + } + if p.OriginalTitle != "spaced" { + t.Errorf("OriginalTitle = %q, want %q", p.OriginalTitle, "spaced") + } + if p.ProviderHint != "clean" { + t.Errorf("ProviderHint = %q, want %q", p.ProviderHint, "clean") + } + if p.Files[0].Src != dirtySrc { + t.Errorf("files[].src изменён санитайзингом: %q != %q", p.Files[0].Src, dirtySrc) + } +} + +func TestSanitizePlan_LogsOnChange(t *testing.T) { + var buf bytes.Buffer + log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + p := Plan{Title: "Hаrold"} // 'а' — кириллица, санитайзинг перепишет + sanitizePlan(&p, log) + out := buf.String() + if !strings.Contains(out, "recognition plan field sanitized") || !strings.Contains(out, "field=title") { + t.Errorf("ожидался Debug-лог о санитайзинге title, got: %q", out) + } +} + +func TestSanitizePlan_NoLogWhenClean(t *testing.T) { + var buf bytes.Buffer + log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + p := Plan{Title: "Harold", OriginalTitle: "The Matrix"} + sanitizePlan(&p, log) + if buf.Len() != 0 { + t.Errorf("для чистых полей лога быть не должно, got: %q", buf.String()) + } +} diff --git a/internal/recognize/validate.go b/internal/recognize/validate.go index 234b372..5057be6 100644 --- a/internal/recognize/validate.go +++ b/internal/recognize/validate.go @@ -3,6 +3,7 @@ package recognize import ( "encoding/json" "fmt" + "log/slog" "sort" "strings" @@ -12,7 +13,11 @@ import ( // parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему. // Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные // предупреждения (см. decide) ошибкой не считаются — они уводят в review. -func parsePlan(raw string, in Input) (Plan, error) { +// +// Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см. +// sanitizePlan) ДО структурной валидации: так, например, title из одних +// zero-width символов схлопывается в пустой и корректно уводит в ретрай. +func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) { jsonStr, err := llm.ExtractJSONObject(raw) if err != nil { return Plan{}, fmt.Errorf("no JSON object in response") @@ -29,6 +34,8 @@ func parsePlan(raw string, in Input) (Plan, error) { } } + sanitizePlan(&p, log) + if err := validateSchema(&p, in); err != nil { return Plan{}, err } diff --git a/internal/recognize/validate_test.go b/internal/recognize/validate_test.go index 1e55fa1..44f2232 100644 --- a/internal/recognize/validate_test.go +++ b/internal/recognize/validate_test.go @@ -60,7 +60,7 @@ func TestParsePlan_FencedJSON(t *testing.T) { in := inputWith("film.mkv") raw := "Вот результат:\n```json\n{\"type\":\"movie\",\"title\":\"Film\"," + "\"files\":[{\"src\":\"film.mkv\",\"role\":\"main\"}]}\n```" - p, err := parsePlan(raw, in) + p, err := parsePlan(raw, in, testLogger()) if err != nil { t.Fatalf("parsePlan: %v", err) } @@ -73,7 +73,7 @@ func TestParsePlan_UnknownFieldTolerated(t *testing.T) { in := inputWith("film.mkv") raw := `{"type":"movie","title":"Film","extra_field":123, "files":[{"src":"film.mkv","role":"main"}]}` - if _, err := parsePlan(raw, in); err != nil { + if _, err := parsePlan(raw, in, testLogger()); err != nil { t.Fatalf("unknown field should be tolerated: %v", err) } } diff --git a/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/.openspec.yaml b/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/.openspec.yaml new file mode 100644 index 0000000..eb5fa80 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-10 diff --git a/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/design.md b/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/design.md new file mode 100644 index 0000000..61e67bc --- /dev/null +++ b/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/design.md @@ -0,0 +1,164 @@ +## Context + +Распознавание разбирает недоверенный ответ LLM в структурированный план и сверяет +его с включёнными базами метаданных (`internal/recognize`). Ключевые точки: + +- `matchMetadata` (`internal/recognize/metadata.go`) перебирает названия + (`searchKeys`: `original_title` → `title` → `provider_hint`) × провайдеров, + вызывая `Provider.Search(Query{Title, Year})`; год уходит в query-параметр + провайдера как жёсткий фильтр (`tvdb.go:160`, `tmdb.go:79`). +- Гейт сильного матча `strongMatches` сравнивает названия через `normalize` + (нижний регистр, только буквы/цифры, `ё→е`) и год ±1; авто-раскладка — только + при ровно одном сильном кандидате (инвариант проекта). + +Реальный сбой: LLM вернул `title` с кириллической буквой-двойником вместо +латинской. Сырое название ушло в запрос TVDB дословно → база не нашла (пустой +список кандидатов); и даже вернись кандидат, `normalize` кир/лат двойники не +сворачивает (это разные руны, обе `unicode.IsLetter`) → гейт бы промахнулся. +Отдельно: ошибка модели в годе делает жёсткий year-фильтр запроса причиной +промаха по записи, которая в базе есть. + +Инвариант, который НЕ двигаем: авто-раскладка только при подтверждённом единичном +сильном матче; выход LLM недоверенный (безопасность на валидации, не на промпте); +`files[].src` неприкосновенны (обязаны совпадать с реальными файлами торрента). + +## Goals / Non-Goals + +**Goals:** + +- Чистить человекочитаемые поля плана (`title`, `original_title`, `provider_hint`) + на границе разбора, чтобы в запрос к базе и в имя папки Jellyfin шло вменяемое + название. +- Сворачивать кирилло-латинские homoglyph-двойники так, чтобы «Hаrold» (с кир. `а`) + и «Harold» считались одним названием — и в запросе, и в гейте сравнения. +- Ловить кривой год от LLM безгодовым вторым проходом сверки, не ослабляя гейт. +- Поднять hit-rate кандидатов метабазы → меньше ручного ввода id в review, чаще + корректный provider-id для Jellyfin. + +**Non-Goals:** + +- Fuzzy/edit-distance сравнение названий (гейт остаётся exact-normalized). +- Транслитерация ru↔en как дополнительный ключ поиска. +- Срез подзаголовка после «:» и прочие эвристики разбиения названия. +- Санитайзинг входного `context` и накопленных `hints` (другой класс входа). +- Трогать `files[].src` или пути раскладки. + +## Decisions + +### Решение 1: санитайзинг — на границе разбора плана, до валидации и сверки + +Санитайзинг применяется к `title`/`original_title`/`provider_hint` сразу после +получения структурированного плана из ответа LLM и ДО структурной валидации и +сверки с базой. Так очищенное название попадает и в запрос к базе, и (при +отсутствии матча) в имя папки Jellyfin — единая точка очистки. + +Состав (в порядке применения): + +1. **Strip control/zero-width** — удаляем управляющие C0/C1, zero-width (`U+200B` + и родственные), BOM (`U+FEFF`). +2. **Collapse whitespace + trim** — внутренние последовательности пробельных → + один пробел, обрезка краёв. +3. **Homoglyph-fold смешанных токенов** (см. Решение 2). + +`files[].src` НЕ санитизируем: они обязаны байт-в-байт биться с файлами торрента; +homoglyph там — настоящий mismatch, который корректно отклоняется валидацией и +уходит в review. «Чинить» пути значило бы подгонять план под несуществующий файл. + +_Альтернатива (отклонено):_ чистить только перед запросом к базе (в `searchKeys`). +Тогда имя папки в no-match-ветке осталось бы грязным, а гейт сравнения — уязвимым. +Очистка канонического плана один раз покрывает оба пути. + +### Решение 2: homoglyph-fold — курируемая таблица кир↔лат, потокенно + +Свёртка работает по словам (токенам, разделённым не-буквенными символами): + +- Токен, все буквы которого одного скрипта (весь Latin или весь Cyrillic), не + трогаем — билингвальность реальна: русские названия по-настоящему кириллические, + и подменять их латиницей нельзя. +- Токен **смешанного** скрипта → определяем доминирующий скрипт по числу буквенных + рун и мапим буквы-меньшинство в доминирующий скрипт через курируемую таблицу + двойников (~15–20 пар: строчные `а е о р с у х к`, заглавные `А В Е К М Н О Р С Т Х` + и латинские аналоги `a e o p c y x k / A B E K M H O P C T X`). При равенстве + скриптов в токене (нет доминирующего) оставляем как есть. + +_Почему курируемая таблица, а не UTS#39 skeleton:_ реальная боль — русскоклавиатурные +двойники в англоязычных названиях; дюжина пар её закрывает без зависимости и без +переусложнения под наш билингвальный домен. Полный юникодный confusables — оверкилл. + +_Почему потокенно, а не по всей строке:_ решение о скрипте на уровне слова не путает +двуязычные названия («Название [English]») и не ломает честную кириллицу. + +Та же fold-функция переиспользуется в `normalize` (Решение 3), поэтому таблица +двойников — единственный источник правды. + +### Решение 3: fold в гейте `normalize` как defense-in-depth + +`normalize` (гейт сильного матча) получает тот же homoglyph-fold после `ё→е`. +Поскольку план уже очищен на границе разбора, а официальные базы отдают чистые +названия, это подстраховка на случай двойников со стороны кандидата — но именно +гейт принимает безопасно-критичное решение об авто-матче, поэтому делаем его +устойчивым независимо от шага санитайзинга. Стоимость около нулевая (общая fold- +функция). + +### Решение 4: безгодовой второй проход сверки как fallback + +`matchMetadata` оборачивается в два прохода: + +- **pass 1** — как сейчас: `searchKeys × providers`, `Search(Query{Title, Year})`, + ранний стоп на единичном сильном матче. +- **pass 2** — только если `plan.Year > 0` И pass 1 не дал подтверждённого матча: + тот же перебор, но `Search` с `Year = 0`. + +Гейт `strongMatches` (год ±1 + exact-normalized название, ровно один кандидат) +в основе не меняется — precision держится тем же механизмом, инвариант авто-матча +не двигается. Одна прицельная строгость добавлена **только для fallback-прохода**: +там требуется известный год кандидата. Причина — безгодовой проход делает +достижимыми записи, которые exact-year фильтр прежде прятал, включая записи с +неизвестным годом; авто-матч по такой записи означал бы «год подтвердить нечем, но +всё равно авто». Поэтому в pass 2 запись с `year == 0` уходит кандидатом в review, +а не в авто (off-by-one с известным годом — по-прежнему авто). В pass 1 прежняя +leniency `yearMatches` к неизвестному году сохранена (поведение не регрессирует). +Кандидаты копятся через оба прохода с той же дедупликацией по `provider:id` и общим +потолком `maxCandidates`. + +_Обоснование:_ exact-year фильтр запроса **строже** гейта — запрос требует точный +год, а гейт принимает год ±1. Поэтому безгодовой проход даёт две разные выгоды: +(а) **восстанавливает авто-матч для граничных off-by-one расхождений года** — +запись, которую точный фильтр отсёк, но гейт ±1 принял бы (разные базы датируют +релиз по-разному, off-by-1 частый); (б) при бо́льших ошибках года **пополняет +список кандидатов для review** — гейт по году такую запись отклонит (авто-матча +не будет), но человек получит кандидата вместо пустого списка. Год держим в первом +проходе (дешёвое сужение при верном годе — меньше мусора в выдаче), безгодовой — +фолбэк только когда первый ничего не подтвердил. + +_Альтернатива (отклонено):_ вообще убрать год из запроса. Потеряли бы дешёвое +сужение для частых названий, где год у модели верный (общий случай). + +## Risks / Trade-offs + +- **Over-fold: свёртка поломает легитимное смешанное название** → снижаем риск + потокенной логикой (честный одно-скриптовый токен неприкосновенен) и `slog.Debug` + с before/after при каждой переписи — перекос будет виден в логах. +- **Ошибочная свёртка «меньшинства» в редком двуязычном слове** → таблица только из + визуально-неотличимых пар; символы без двойника не трогаются, длина/структура + строки сохраняется. +- **pass 2 добавляет обращения к базе** → только в ветке «pass 1 не подтвердил + матч» и только при известном годе; в типовом успешном случае лишних запросов нет. +- **Безгодовой поиск шумит кандидатами для частых названий** → гейт с exact-title и + годом ±1 не пропустит их в авто; в review это просто более полный список для + выбора, не регрессия. +- **Общий потолок `maxCandidates` делится на оба прохода** → если pass 1 заполнил + лимит «мусором», реальный кандидат из pass 2 может не попасть в список review. + Крайний случай; потолок общий по требованию спеки, отдельного механизма не + вводим — при необходимости поднять лимит отдельной задачей. + +## Migration Plan + +Изменение чисто поведенческое, без миграций БД и конфига. Уже лежащие в review +загрузки не трогаются; эффект проявляется на новых распознаваниях и при +«Распознать заново» из review. Откат — ревертом коммита. + +## Open Questions + +Нет — решения по составу санитайзинга, стратегии fold и границам scope +зафиксированы на этапе груминга. diff --git a/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/proposal.md b/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/proposal.md new file mode 100644 index 0000000..dbc86eb --- /dev/null +++ b/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/proposal.md @@ -0,0 +1,57 @@ +## Why + +LLM возвращает названия с «грязью», из-за которой сверка с метабазой промахивается +и загрузка уходит в review без единого кандидата (реальный кейс — «Harold and the +Purple Crayon» с кириллической буквой-двойником вместо латинской: сырое название +ушло в query-параметр TVDB дословно, база не нашла, а гейт сильного матча кир/лат +двойники не сворачивает). Плюс год из плана уходит в запрос как жёсткий фильтр — +при ошибке модели в годе база не находит запись, которая на деле есть. Итог: +Jellyfin остаётся без официального id, а человек вбивает id руками. Цель — поднять +hit-rate кандидатов метабазы, не трогая инвариант авто-матча. + +## What Changes + +- **Санитайзинг названий от LLM** (`recognition`): на границе разбора плана, после + unmarshal и ДО структурной валидации/сверки, чистим человекочитаемые поля + `title`, `original_title`, `provider_hint`: (1) strip control/zero-width + символов, (2) collapse пробелов + trim, (3) свёртка кирилло-латинских + homoglyph-двойников по курируемой таблице (потокенно, только для смешанных + токенов). `files[].src` не трогаем — они обязаны байт-в-байт совпадать с файлами + торрента. +- **Homoglyph-fold в гейте матча** (`metadata-match`): нормализация названий при + сравнении получает ту же свёртку двойников как defense-in-depth на случай грязи + со стороны кандидата базы. +- **Ретрай сверки без года** (`metadata-match`): если поиск с годом не дал + подтверждённого матча, второй проход тем же перебором названий×провайдеров, но + без года. Гейт (exact-normalized название + год ±1) остаётся прежним — precision + не падает, кандидаты копятся через оба прохода. +- Наблюдаемость: `slog.Debug`, когда санитайзинг реально переписал поле. + +Не в scope (отдельные задачи при необходимости): fuzzy/edit-distance матч, +транслитерация ru↔en, срез подзаголовка после «:», санитайзинг входного контекста +и накопленных подсказок. + +## Capabilities + +### New Capabilities + +_Нет._ + +### Modified Capabilities + +- `recognition`: добавляется требование санитайзинга человекочитаемых полей плана + (`title`/`original_title`/`provider_hint`) на границе разбора ответа LLM. +- `metadata-match`: нормализация названий при сравнении расширяется свёрткой + кирилло-латинских homoglyph-двойников; сверка получает безгодовой второй проход + как fallback, когда поиск с годом не дал подтверждённого матча. + +## Impact + +- Код: `internal/recognize` (новый шаг санитайзинга при разборе плана, `normalize` + и `matchMetadata` в `metadata.go`). Провайдеры `internal/metadata/*` не меняются + (год уже опционален в `Query`). +- Поведение: ранее промахивавшиеся из-за homoglyph/кривого года раздачи теперь + находят кандидата (авто-матч при единичном сильном совпадении, иначе — заполненный + список для выбора в review). Инвариант «авто только при подтверждённом единичном + сильном матче» и неприкосновенность `files[].src`/источника не затрагиваются. +- Зависимостей не добавляется (таблица двойников — небольшой литерал в коде). diff --git a/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/specs/metadata-match/spec.md b/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/specs/metadata-match/spec.md new file mode 100644 index 0000000..081da32 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/specs/metadata-match/spec.md @@ -0,0 +1,131 @@ +## MODIFIED Requirements + +### Requirement: Сверка с базой по нескольким названиям + +При сверке плана с включёнными базами метаданных система SHALL искать по +нескольким названиям в порядке убывания силы ключа: сначала по +`original_title`, затем по локализованному `title`, затем по `provider_hint`. +Этот перебор названий SHALL выполняться в рамках каждого прохода сверки — проходы +(с годом и безгодовой fallback) определяет требование «Безгодовой второй проход +сверки как fallback». Поиск SHALL останавливаться, как только очередной запрос +дал единичный сильный матч (ровно один кандидат с совпадением названия и года); +ранняя остановка действует в пределах прохода. Запрос с названием, нормализованно +совпадающим с уже выполненным, система SHALL пропускать, чтобы не обращаться к +базе повторно с тем же ключом. + +Кандидаты для ручного выбора в review система SHALL собирать из всех выполненных +заходов обоих проходов с дедупликацией по `provider:id` и общим потолком. + +#### Scenario: Иностранный фильм находится по оригинальному названию + +- **GIVEN** план с `title` «Тёмный рыцарь», `original_title` «The Dark Knight», год 2008 +- **WHEN** выполняется сверка с базой +- **THEN** первый запрос идёт по «The Dark Knight» +- **AND** при единичном сильном матче дальнейшие запросы (по `title`, `provider_hint`) не выполняются + +#### Scenario: Фолбэк на локализованное название + +- **GIVEN** план, для которого запрос по `original_title` не дал единичного сильного матча +- **WHEN** продолжается сверка +- **THEN** выполняется запрос по локализованному `title` +- **AND** при отсутствии матча и там — запрос по `provider_hint` + +#### Scenario: Дублирующий запрос пропускается + +- **GIVEN** план, у которого `original_title` нормализованно совпадает с `title` +- **WHEN** выполняется сверка +- **THEN** база запрашивается этим названием один раз, повторный заход по `title` не делается + +### Requirement: Нормализация названий при сравнении + +Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`, +чтобы написания, различающиеся только `ё`/`е`, считались одним названием. + +Нормализация SHALL дополнительно сворачивать кирилло-латинские homoglyph-двойники +по той же курируемой таблице, что применяется при санитайзинге плана +(capability `recognition`), чтобы визуально совпадающие название плана и кандидата +базы, различающиеся лишь скриптом отдельных букв, считались одним названием. Это +defense-in-depth на случай двойников со стороны кандидата: гейт — точка +безопасно-критичного решения об авто-матче и SHALL быть устойчив к двойникам +независимо от предшествующего санитайзинга. + +#### Scenario: «Тёмный» и «Темный» совпадают + +- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь» +- **WHEN** сравниваются нормализованные названия +- **THEN** они считаются совпадающими + +#### Scenario: Двойник у кандидата не мешает матчу + +- **GIVEN** план с латинским названием `Harold` и кандидат базы, где то же слово + содержит кириллический символ-двойник +- **WHEN** сравниваются нормализованные названия +- **THEN** они считаются совпадающими + +## ADDED Requirements + +### Requirement: Безгодовой второй проход сверки как fallback + +Система SHALL выполнять второй проход сверки без года как fallback: когда поиск с +годом не дал подтверждённого единичного сильного матча, а год плана известен +(`year > 0`), выполняется тот же перебор названий (в порядке `original_title` → +`title` → `provider_hint`) и провайдеров, но с запросом БЕЗ года. Второй проход +SHALL выполняться только как +fallback: если первый проход подтвердил матч или год плана неизвестен, второго +прохода быть SHALL NOT. + +Гейт сильного матча (нормализованное совпадение названия и год кандидата в пределах +±1, ровно один кандидат) при этом SHALL оставаться неизменным — безгодовой проход +расширяет только выдачу запроса, но не ослабляет условие подтверждения, поэтому +инвариант «авто-раскладка только при подтверждённом единичном сильном матче» не +затрагивается. Дополнительно в безгодовом проходе система SHALL требовать +известный год кандидата: запись с неизвестным годом (год подтвердить нечем) во +втором проходе подтверждённым матчем быть SHALL NOT и уходит кандидатом в review +(в первом, с-годом, проходе прежняя leniency к неизвестному году сохраняется). +Кандидаты для ручного выбора в review система SHALL собирать из обоих проходов с +той же дедупликацией по `provider:id` и общим потолком. + +#### Scenario: Год плана off-by-one — авто-матч восстанавливается без года + +- **GIVEN** запись есть в базе, но год плана отличается от её года ровно на 1 + (жёсткий exact-year фильтр запроса отсёк её в первом проходе) +- **WHEN** первый проход (с годом) не дал сильного матча +- **THEN** выполняется второй проход без года +- **AND** единичный кандидат проходит гейт (название совпадает, год бьётся ±1) — + матч подтверждается + +#### Scenario: Год плана расходится больше чем на 1 — кандидат только в review + +- **GIVEN** запись есть в базе, но год плана отличается от её года больше чем на 1 +- **WHEN** второй проход без года возвращает эту запись единственным кандидатом +- **THEN** гейт отклоняет её по году (расхождение больше ±1) — подтверждённого + матча нет +- **AND** кандидат собирается для выбора в review (человек получает кандидата + вместо пустого списка) + +#### Scenario: Первый проход подтвердил матч — второго нет + +- **GIVEN** первый проход (с годом) дал единичный сильный матч +- **WHEN** завершается сверка +- **THEN** второй (безгодовой) проход не выполняется + +#### Scenario: Год неизвестен — второго прохода нет + +- **GIVEN** план без года (`year` = 0) +- **WHEN** первый проход не дал матча +- **THEN** второй проход не выполняется (он был бы идентичен первому) + +#### Scenario: Кандидат с неизвестным годом в безгодовом проходе — только review + +- **GIVEN** год плана известен, а pass 1 (с годом) не дал матча +- **WHEN** безгодовой проход возвращает единственного кандидата с совпадающим + названием, но неизвестным годом +- **THEN** подтверждённого матча нет (год кандидата не подтверждён) — авто-раскладка + не делается +- **AND** кандидат собирается для выбора в review + +#### Scenario: Несколько кандидатов из безгодового прохода — матч не подтверждён + +- **GIVEN** безгодовой проход вернул более одного кандидата, проходящего гейт +- **WHEN** оценивается матч +- **THEN** подтверждённого матча нет, кандидаты собираются для выбора в review diff --git a/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/specs/recognition/spec.md b/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/specs/recognition/spec.md new file mode 100644 index 0000000..a702ae0 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/specs/recognition/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: Санитайзинг человекочитаемых полей плана + +Перед структурной валидацией плана и сверкой с базами система SHALL санитизировать +человекочитаемые поля плана — `title`, `original_title`, `provider_hint` — как +недоверенный вывод LLM. Санитайзинг SHALL: (1) удалять управляющие и zero-width +символы (C0/C1, `U+200B` и родственные, BOM `U+FEFF`); (2) сводить внутренние +последовательности пробельных к одиночному пробелу и обрезать края; (3) сворачивать +кирилло-латинские homoglyph-двойники (см. ниже). + +Свёртка двойников SHALL работать потокенно (по словам, разделённым не-буквенными +символами): токен, все буквы которого принадлежат одному скрипту, система SHALL +оставлять без изменений (билингвальность реальна — кириллические названия +неприкосновенны); в токене смешанного скрипта система SHALL определять доминирующий +скрипт по числу буквенных рун и заменять буквы-меньшинство их визуальными +двойниками из доминирующего скрипта по курируемой таблице. При отсутствии +доминирующего скрипта (равенство) токен SHALL оставаться без изменений. + +Санитайзинг SHALL применяться ТОЛЬКО к перечисленным человекочитаемым полям. +`files[].src` система SHALL NOT санитизировать — эти значения обязаны совпадать с +реальными файлами торрента, и расхождение (в т.ч. homoglyph) SHALL оставаться +основанием отклонить план, а не поводом «чинить» путь. + +Когда санитайзинг реально изменил значение поля, система 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), а не к + «починке» пути diff --git a/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/tasks.md b/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/tasks.md new file mode 100644 index 0000000..92d66cf --- /dev/null +++ b/openspec/changes/archive/2026-07-10-sanitize-llm-titles-yearless-retry/tasks.md @@ -0,0 +1,42 @@ +## 1. Homoglyph-таблица и fold + +- [x] 1.1 В `internal/recognize` завести курируемую таблицу кирилло-латинских + двойников (~15–20 пар, строчные и заглавные) как единственный источник правды +- [x] 1.2 Реализовать потокенную `foldHomoglyphs(s string) string`: одно-скриптовый + токен не трогаем, в смешанном — доминирующий скрипт по числу буквенных рун, + меньшинство мапим по таблице; при равенстве оставляем как есть +- [x] 1.3 Юнит-тесты `foldHomoglyphs`: «Hаrold»→«Harold», честная кириллица + нетронута, смешанное двуязычное название, равенство скриптов, пустой ввод + +## 2. Санитайзинг полей плана (recognition) + +- [x] 2.1 Реализовать `sanitizeTitle`: strip control/zero-width (C0/C1, U+200B и + родственные, BOM), collapse пробелов + trim, затем `foldHomoglyphs` +- [x] 2.2 Применить санитайзинг к `title`, `original_title`, `provider_hint` на + границе разбора плана — после unmarshal, ДО структурной валидации и сверки; + `files[].src` не трогать +- [x] 2.3 `slog.Debug` when поле реально переписано (before/after), логгер из ctx +- [x] 2.4 Тесты: поля чистятся, `files[].src` неприкосновенны, лог при изменении + +## 3. Fold в гейте нормализации (metadata-match) + +- [x] 3.1 Добавить `foldHomoglyphs` в `normalize` (`internal/recognize/metadata.go`) + после `ё→е`, переиспользуя ту же таблицу +- [x] 3.2 Тест: план и кандидат, различающиеся лишь скриптом буквы, нормализованно + совпадают + +## 4. Безгодовой второй проход сверки (metadata-match) + +- [x] 4.1 Обернуть перебор в `matchMetadata` в два прохода: pass 1 — как сейчас + (`Search` с годом), pass 2 — только при `plan.Year > 0` и отсутствии матча в + pass 1, тот же перебор `searchKeys × providers` с `Year = 0` +- [x] 4.2 Кандидаты копятся через оба прохода с существующей дедупликацией по + `provider:id` и потолком `maxCandidates`; гейт `strongMatches` не менять +- [x] 4.3 Тесты: кривой год находится без года; при матче в pass 1 второго прохода + нет; при `year = 0` второго прохода нет; несколько кандидатов из pass 2 → матч + не подтверждён + +## 5. Проверка и вычитка + +- [x] 5.1 `task test` и `task lint` — зелёные +- [x] 5.2 `openspec validate --strict sanitize-llm-titles-yearless-retry` diff --git a/openspec/specs/metadata-match/spec.md b/openspec/specs/metadata-match/spec.md index 04d41ae..6e30436 100644 --- a/openspec/specs/metadata-match/spec.md +++ b/openspec/specs/metadata-match/spec.md @@ -12,13 +12,16 @@ При сверке плана с включёнными базами метаданных система SHALL искать по нескольким названиям в порядке убывания силы ключа: сначала по `original_title`, затем по локализованному `title`, затем по `provider_hint`. -Поиск SHALL останавливаться, как только очередной запрос дал единичный -сильный матч (ровно один кандидат с совпадением названия и года). Запрос с -названием, нормализованно совпадающим с уже выполненным, система SHALL -пропускать, чтобы не обращаться к базе повторно с тем же ключом. +Этот перебор названий SHALL выполняться в рамках каждого прохода сверки — проходы +(с годом и безгодовой fallback) определяет требование «Безгодовой второй проход +сверки как fallback». Поиск SHALL останавливаться, как только очередной запрос +дал единичный сильный матч (ровно один кандидат с совпадением названия и года); +ранняя остановка действует в пределах прохода. Запрос с названием, нормализованно +совпадающим с уже выполненным, система SHALL пропускать, чтобы не обращаться к +базе повторно с тем же ключом. -Кандидаты для ручного выбора в review система SHALL собирать из всех -выполненных заходов с дедупликацией по `provider:id` и общим потолком. +Кандидаты для ручного выбора в review система SHALL собирать из всех выполненных +заходов обоих проходов с дедупликацией по `provider:id` и общим потолком. #### Scenario: Иностранный фильм находится по оригинальному названию @@ -83,12 +86,27 @@ Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`, чтобы написания, различающиеся только `ё`/`е`, считались одним названием. +Нормализация SHALL дополнительно сворачивать кирилло-латинские homoglyph-двойники +по той же курируемой таблице, что применяется при санитайзинге плана +(capability `recognition`), чтобы визуально совпадающие название плана и кандидата +базы, различающиеся лишь скриптом отдельных букв, считались одним названием. Это +defense-in-depth на случай двойников со стороны кандидата: гейт — точка +безопасно-критичного решения об авто-матче и SHALL быть устойчив к двойникам +независимо от предшествующего санитайзинга. + #### Scenario: «Тёмный» и «Темный» совпадают - **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь» - **WHEN** сравниваются нормализованные названия - **THEN** они считаются совпадающими +#### Scenario: Двойник у кандидата не мешает матчу + +- **GIVEN** план с латинским названием `Harold` и кандидат базы, где то же слово + содержит кириллический символ-двойник +- **WHEN** сравниваются нормализованные названия +- **THEN** они считаются совпадающими + ### Requirement: Кандидат несёт URL для внешней проверки Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести @@ -130,3 +148,69 @@ - **AND** при последующей загрузке данных ревью url доступен без повторной генерации +### Requirement: Безгодовой второй проход сверки как fallback + +Система SHALL выполнять второй проход сверки без года как fallback: когда поиск с +годом не дал подтверждённого единичного сильного матча, а год плана известен +(`year > 0`), выполняется тот же перебор названий (в порядке `original_title` → +`title` → `provider_hint`) и провайдеров, но с запросом БЕЗ года. Второй проход +SHALL выполняться только как +fallback: если первый проход подтвердил матч или год плана неизвестен, второго +прохода быть SHALL NOT. + +Гейт сильного матча (нормализованное совпадение названия и год кандидата в пределах +±1, ровно один кандидат) при этом SHALL оставаться неизменным — безгодовой проход +расширяет только выдачу запроса, но не ослабляет условие подтверждения, поэтому +инвариант «авто-раскладка только при подтверждённом единичном сильном матче» не +затрагивается. Дополнительно в безгодовом проходе система SHALL требовать +известный год кандидата: запись с неизвестным годом (год подтвердить нечем) во +втором проходе подтверждённым матчем быть SHALL NOT и уходит кандидатом в review +(в первом, с-годом, проходе прежняя leniency к неизвестному году сохраняется). +Кандидаты для ручного выбора в review система SHALL собирать из обоих проходов с +той же дедупликацией по `provider:id` и общим потолком. + +#### Scenario: Год плана off-by-one — авто-матч восстанавливается без года + +- **GIVEN** запись есть в базе, но год плана отличается от её года ровно на 1 + (жёсткий exact-year фильтр запроса отсёк её в первом проходе) +- **WHEN** первый проход (с годом) не дал сильного матча +- **THEN** выполняется второй проход без года +- **AND** единичный кандидат проходит гейт (название совпадает, год бьётся ±1) — + матч подтверждается + +#### Scenario: Год плана расходится больше чем на 1 — кандидат только в review + +- **GIVEN** запись есть в базе, но год плана отличается от её года больше чем на 1 +- **WHEN** второй проход без года возвращает эту запись единственным кандидатом +- **THEN** гейт отклоняет её по году (расхождение больше ±1) — подтверждённого + матча нет +- **AND** кандидат собирается для выбора в review (человек получает кандидата + вместо пустого списка) + +#### Scenario: Первый проход подтвердил матч — второго нет + +- **GIVEN** первый проход (с годом) дал единичный сильный матч +- **WHEN** завершается сверка +- **THEN** второй (безгодовой) проход не выполняется + +#### Scenario: Год неизвестен — второго прохода нет + +- **GIVEN** план без года (`year` = 0) +- **WHEN** первый проход не дал матча +- **THEN** второй проход не выполняется (он был бы идентичен первому) + +#### Scenario: Кандидат с неизвестным годом в безгодовом проходе — только review + +- **GIVEN** год плана известен, а pass 1 (с годом) не дал матча +- **WHEN** безгодовой проход возвращает единственного кандидата с совпадающим + названием, но неизвестным годом +- **THEN** подтверждённого матча нет (год кандидата не подтверждён) — авто-раскладка + не делается +- **AND** кандидат собирается для выбора в review + +#### Scenario: Несколько кандидатов из безгодового прохода — матч не подтверждён + +- **GIVEN** безгодовой проход вернул более одного кандидата, проходящего гейт +- **WHEN** оценивается матч +- **THEN** подтверждённого матча нет, кандидаты собираются для выбора в review + diff --git a/openspec/specs/recognition/spec.md b/openspec/specs/recognition/spec.md index 7e158f8..186faa5 100644 --- a/openspec/specs/recognition/spec.md +++ b/openspec/specs/recognition/spec.md @@ -125,3 +125,66 @@ per-file `season`/`episode` (отдельного скалярного `season` - **WHEN** строится план - **THEN** этот файл получает роль `ignore` и в раскладку не попадает +### Requirement: Санитайзинг человекочитаемых полей плана + +Перед структурной валидацией плана и сверкой с базами система SHALL санитизировать +человекочитаемые поля плана — `title`, `original_title`, `provider_hint` — как +недоверенный вывод LLM. Санитайзинг SHALL: (1) удалять управляющие и zero-width +символы (C0/C1, `U+200B` и родственные, BOM `U+FEFF`); (2) сводить внутренние +последовательности пробельных к одиночному пробелу и обрезать края; (3) сворачивать +кирилло-латинские homoglyph-двойники (см. ниже). + +Свёртка двойников SHALL работать потокенно (по словам, разделённым не-буквенными +символами): токен, все буквы которого принадлежат одному скрипту, система SHALL +оставлять без изменений (билингвальность реальна — кириллические названия +неприкосновенны); в токене смешанного скрипта система SHALL определять доминирующий +скрипт по числу буквенных рун и заменять буквы-меньшинство их визуальными +двойниками из доминирующего скрипта по курируемой таблице. При отсутствии +доминирующего скрипта (равенство) токен SHALL оставаться без изменений. + +Санитайзинг SHALL применяться ТОЛЬКО к перечисленным человекочитаемым полям. +`files[].src` система SHALL NOT санитизировать — эти значения обязаны совпадать с +реальными файлами торрента, и расхождение (в т.ч. homoglyph) SHALL оставаться +основанием отклонить план, а не поводом «чинить» путь. + +Когда санитайзинг реально изменил значение поля, система 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), а не к + «починке» пути +