Распознавание: санитайзинг названий от LLM + безгодовой фолбэк сверки
Кейс «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) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,14 @@ const maxCandidates = 8
|
|||||||
// (original_title → title → provider_hint, см. searchKeys): базы индексированы
|
// (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) {
|
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
|
||||||
if len(r.providers) == 0 {
|
if len(r.providers) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -32,43 +40,65 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []me
|
|||||||
}
|
}
|
||||||
|
|
||||||
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
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 match *Match
|
||||||
var candidates []metadata.Candidate
|
var candidates []metadata.Candidate
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|
||||||
for _, key := range searchKeys(plan) {
|
for _, qYear := range queryYears {
|
||||||
for _, p := range r.providers {
|
// Безгодовой fallback-проход (есть только при известном годе плана).
|
||||||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: plan.Year})
|
// В нём требуем известный год кандидата: off-by-one даёт авто-матч, а
|
||||||
if err != nil {
|
// запись с неизвестным годом — только кандидат в review (год подтвердить
|
||||||
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
|
// нечем, авто было бы недо-подтверждённым). В pass 1 leniency yearMatches
|
||||||
// доменное решение «пропускаем провайдера, пробуем следующий».
|
// к unknown-году сохраняется как прежде.
|
||||||
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
|
fallback := qYear == 0 && plan.Year > 0
|
||||||
continue
|
for _, key := range keys {
|
||||||
}
|
for _, p := range r.providers {
|
||||||
|
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: key, Year: qYear})
|
||||||
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
|
if err != nil {
|
||||||
for _, c := range cands {
|
// Сам вызов провайдера залогирован клиентом (ext.*-ERROR); здесь —
|
||||||
ck := c.Provider + ":" + c.ID
|
// доменное решение «пропускаем провайдера, пробуем следующий».
|
||||||
if seen[ck] || len(candidates) >= maxCandidates {
|
logctx.FromOr(ctx, r.log).Debug("metadata provider skipped", "provider", p.Name())
|
||||||
continue
|
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 {
|
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 {
|
if match != nil {
|
||||||
break
|
break // pass 1 подтвердил матч — безгодовой проход не нужен
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return match, candidates
|
return match, candidates
|
||||||
@@ -129,11 +159,15 @@ func CandidateTag(c metadata.Candidate) (provider, id string) {
|
|||||||
|
|
||||||
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
|
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
|
||||||
// названий плана (после нормализации) и год бьётся (±1 год), дедуплицируя
|
// названий плана (после нормализации) и год бьётся (±1 год), дедуплицируя
|
||||||
// по id.
|
// по id. requireKnownYear (безгодовой fallback-проход) дополнительно отсекает
|
||||||
func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool) []metadata.Candidate {
|
// кандидатов с неизвестным годом: авто-матч там требует подтверждённого года.
|
||||||
|
func strongMatches(cands []metadata.Candidate, year int, titles map[string]bool, requireKnownYear bool) []metadata.Candidate {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var out []metadata.Candidate
|
var out []metadata.Candidate
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
|
if requireKnownYear && c.Year == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !yearMatches(year, c.Year) {
|
if !yearMatches(year, c.Year) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -175,11 +209,14 @@ func normSet(titles ...string) map[string]bool {
|
|||||||
|
|
||||||
// normalize приводит название к сравнимому виду: нижний регистр, только
|
// normalize приводит название к сравнимому виду: нижний регистр, только
|
||||||
// буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое
|
// буквы/цифры (юникод), одиночные пробелы. Букву ё сводим к е (частое
|
||||||
// расхождение написания: «Тёмный» vs «Темный»).
|
// расхождение написания: «Тёмный» vs «Темный»). Перед этим сворачиваем
|
||||||
|
// кирилло-латинские homoglyph-двойники (foldHomoglyphs) — defense-in-depth на
|
||||||
|
// случай двойников со стороны кандидата базы: гейт сильного матча должен быть
|
||||||
|
// устойчив к ним независимо от санитайзинга плана.
|
||||||
func normalize(s string) string {
|
func normalize(s string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
prevSpace := false
|
prevSpace := false
|
||||||
for _, r := range strings.ToLower(s) {
|
for _, r := range strings.ToLower(foldHomoglyphs(s)) {
|
||||||
if r == 'ё' {
|
if r == 'ё' {
|
||||||
r = 'е'
|
r = 'е'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type fakeProvider struct {
|
|||||||
byTitle map[string][]metadata.Candidate // если задано — результат зависит от запроса
|
byTitle map[string][]metadata.Candidate // если задано — результат зависит от запроса
|
||||||
counts map[int]int
|
counts map[int]int
|
||||||
searchErr error
|
searchErr error
|
||||||
|
exactYear bool // имитирует жёсткий year-фильтр базы: q.Year>0 отсекает по точному году
|
||||||
searched int
|
searched int
|
||||||
queries []string // строки запросов в порядке вызова
|
queries []string // строки запросов в порядке вызова
|
||||||
}
|
}
|
||||||
@@ -31,10 +32,20 @@ func (f *fakeProvider) Search(_ context.Context, q metadata.Query) ([]metadata.C
|
|||||||
if f.searchErr != nil {
|
if f.searchErr != nil {
|
||||||
return nil, f.searchErr
|
return nil, f.searchErr
|
||||||
}
|
}
|
||||||
|
res := f.candidates
|
||||||
if f.byTitle != nil {
|
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) {
|
func (f *fakeProvider) SeasonEpisodeCounts(_ context.Context, _ string) (map[int]int, error) {
|
||||||
return f.counts, nil
|
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) {
|
func TestNormalize(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"The Matrix": "the matrix",
|
"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.
|
// Сквозной авто: LLM-план + матч в базе + чистая валидация → Decision.Auto.
|
||||||
func TestRecognize_AutoWithMatch(t *testing.T) {
|
func TestRecognize_AutoWithMatch(t *testing.T) {
|
||||||
in := Input{Name: "The.Matrix.1999", Files: []File{{Path: "m/film.mkv", Size: 1}}}
|
in := Input{Name: "The.Matrix.1999", Files: []File{{Path: "m/film.mkv", Size: 1}}}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
|||||||
}
|
}
|
||||||
raw = resp.Content
|
raw = resp.Content
|
||||||
|
|
||||||
plan, parseErr = parsePlan(raw, in)
|
plan, parseErr = parsePlan(raw, in, log)
|
||||||
if parseErr == nil {
|
if parseErr == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package recognize
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -12,7 +13,11 @@ import (
|
|||||||
// parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему.
|
// parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему.
|
||||||
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные
|
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные
|
||||||
// предупреждения (см. decide) ошибкой не считаются — они уводят в review.
|
// предупреждения (см. 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)
|
jsonStr, err := llm.ExtractJSONObject(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Plan{}, fmt.Errorf("no JSON object in response")
|
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 {
|
if err := validateSchema(&p, in); err != nil {
|
||||||
return Plan{}, err
|
return Plan{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func TestParsePlan_FencedJSON(t *testing.T) {
|
|||||||
in := inputWith("film.mkv")
|
in := inputWith("film.mkv")
|
||||||
raw := "Вот результат:\n```json\n{\"type\":\"movie\",\"title\":\"Film\"," +
|
raw := "Вот результат:\n```json\n{\"type\":\"movie\",\"title\":\"Film\"," +
|
||||||
"\"files\":[{\"src\":\"film.mkv\",\"role\":\"main\"}]}\n```"
|
"\"files\":[{\"src\":\"film.mkv\",\"role\":\"main\"}]}\n```"
|
||||||
p, err := parsePlan(raw, in)
|
p, err := parsePlan(raw, in, testLogger())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parsePlan: %v", err)
|
t.Fatalf("parsePlan: %v", err)
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ func TestParsePlan_UnknownFieldTolerated(t *testing.T) {
|
|||||||
in := inputWith("film.mkv")
|
in := inputWith("film.mkv")
|
||||||
raw := `{"type":"movie","title":"Film","extra_field":123,
|
raw := `{"type":"movie","title":"Film","extra_field":123,
|
||||||
"files":[{"src":"film.mkv","role":"main"}]}`
|
"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)
|
t.Fatalf("unknown field should be tolerated: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-10
|
||||||
@@ -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
|
||||||
|
зафиксированы на этапе груминга.
|
||||||
@@ -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`/источника не затрагиваются.
|
||||||
|
- Зависимостей не добавляется (таблица двойников — небольшой литерал в коде).
|
||||||
+131
@@ -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
|
||||||
+64
@@ -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), а не к
|
||||||
|
«починке» пути
|
||||||
@@ -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`
|
||||||
@@ -12,13 +12,16 @@
|
|||||||
При сверке плана с включёнными базами метаданных система SHALL искать по
|
При сверке плана с включёнными базами метаданных система SHALL искать по
|
||||||
нескольким названиям в порядке убывания силы ключа: сначала по
|
нескольким названиям в порядке убывания силы ключа: сначала по
|
||||||
`original_title`, затем по локализованному `title`, затем по `provider_hint`.
|
`original_title`, затем по локализованному `title`, затем по `provider_hint`.
|
||||||
Поиск SHALL останавливаться, как только очередной запрос дал единичный
|
Этот перебор названий SHALL выполняться в рамках каждого прохода сверки — проходы
|
||||||
сильный матч (ровно один кандидат с совпадением названия и года). Запрос с
|
(с годом и безгодовой fallback) определяет требование «Безгодовой второй проход
|
||||||
названием, нормализованно совпадающим с уже выполненным, система SHALL
|
сверки как fallback». Поиск SHALL останавливаться, как только очередной запрос
|
||||||
пропускать, чтобы не обращаться к базе повторно с тем же ключом.
|
дал единичный сильный матч (ровно один кандидат с совпадением названия и года);
|
||||||
|
ранняя остановка действует в пределах прохода. Запрос с названием, нормализованно
|
||||||
|
совпадающим с уже выполненным, система SHALL пропускать, чтобы не обращаться к
|
||||||
|
базе повторно с тем же ключом.
|
||||||
|
|
||||||
Кандидаты для ручного выбора в review система SHALL собирать из всех
|
Кандидаты для ручного выбора в review система SHALL собирать из всех выполненных
|
||||||
выполненных заходов с дедупликацией по `provider:id` и общим потолком.
|
заходов обоих проходов с дедупликацией по `provider:id` и общим потолком.
|
||||||
|
|
||||||
#### Scenario: Иностранный фильм находится по оригинальному названию
|
#### Scenario: Иностранный фильм находится по оригинальному названию
|
||||||
|
|
||||||
@@ -83,12 +86,27 @@
|
|||||||
Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`,
|
Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`,
|
||||||
чтобы написания, различающиеся только `ё`/`е`, считались одним названием.
|
чтобы написания, различающиеся только `ё`/`е`, считались одним названием.
|
||||||
|
|
||||||
|
Нормализация SHALL дополнительно сворачивать кирилло-латинские homoglyph-двойники
|
||||||
|
по той же курируемой таблице, что применяется при санитайзинге плана
|
||||||
|
(capability `recognition`), чтобы визуально совпадающие название плана и кандидата
|
||||||
|
базы, различающиеся лишь скриптом отдельных букв, считались одним названием. Это
|
||||||
|
defense-in-depth на случай двойников со стороны кандидата: гейт — точка
|
||||||
|
безопасно-критичного решения об авто-матче и SHALL быть устойчив к двойникам
|
||||||
|
независимо от предшествующего санитайзинга.
|
||||||
|
|
||||||
#### Scenario: «Тёмный» и «Темный» совпадают
|
#### Scenario: «Тёмный» и «Темный» совпадают
|
||||||
|
|
||||||
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
|
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
|
||||||
- **WHEN** сравниваются нормализованные названия
|
- **WHEN** сравниваются нормализованные названия
|
||||||
- **THEN** они считаются совпадающими
|
- **THEN** они считаются совпадающими
|
||||||
|
|
||||||
|
#### Scenario: Двойник у кандидата не мешает матчу
|
||||||
|
|
||||||
|
- **GIVEN** план с латинским названием `Harold` и кандидат базы, где то же слово
|
||||||
|
содержит кириллический символ-двойник
|
||||||
|
- **WHEN** сравниваются нормализованные названия
|
||||||
|
- **THEN** они считаются совпадающими
|
||||||
|
|
||||||
### Requirement: Кандидат несёт URL для внешней проверки
|
### Requirement: Кандидат несёт URL для внешней проверки
|
||||||
|
|
||||||
Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести
|
Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести
|
||||||
@@ -130,3 +148,69 @@
|
|||||||
- **AND** при последующей загрузке данных ревью url доступен без повторной
|
- **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
|
||||||
|
|
||||||
|
|||||||
@@ -125,3 +125,66 @@ per-file `season`/`episode` (отдельного скалярного `season`
|
|||||||
- **WHEN** строится план
|
- **WHEN** строится план
|
||||||
- **THEN** этот файл получает роль `ignore` и в раскладку не попадает
|
- **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), а не к
|
||||||
|
«починке» пути
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user