Распознавание: санитайзинг названий от 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:
av
2026-07-10 15:45:19 +03:00
co-authored by Claude Opus 4.8
parent 7d8a455e47
commit e2ea1840c9
15 changed files with 1076 additions and 41 deletions
+66 -29
View File
@@ -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 = 'е'
}
+123 -2
View File
@@ -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}}}
+1 -1
View File
@@ -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
}
+161
View File
@@ -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)
}
+102
View File
@@ -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())
}
}
+8 -1
View File
@@ -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
}
+2 -2
View File
@@ -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)
}
}