распознавание: большие раздачи размечаются целиком, файлы вне плана видны

- модель адресует файл номером строки нашего списка вместо копии пути: ответ
  на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято,
  max_files и max_tokens ушли в [recognition], correction-ретрай больше не
  переприсылает список
- негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и
  отказ по размеру запроса названы своими причинами, покрытие плана блокирует
  авто только при непокрытом видеофайле
- раскладка показывает все файлы раздачи со строками «не в плане» и полным
  порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
This commit is contained in:
av
2026-09-02 08:54:58 +03:00
parent 01ac0430a9
commit fc9a3b4066
26 changed files with 1978 additions and 201 deletions
+279 -15
View File
@@ -1,12 +1,15 @@
package recognize
import (
"runtime"
"strings"
"testing"
)
func intp(n int) *int { return &n }
func idxp(n int) *FileIndex { i := FileIndex(n); return &i }
func inputWith(paths ...string) Input {
files := make([]File, len(paths))
for i, p := range paths {
@@ -25,9 +28,17 @@ func TestValidateSchema_OK(t *testing.T) {
{Src: "b.mkv", Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
},
}
if err := validateSchema(&p, in); err != nil {
problems, err := validateSchema(&p, in)
if err != nil {
t.Fatalf("validateSchema: %v", err)
}
if len(problems) != 0 {
t.Errorf("чистый план не должен давать претензий: %v", problems)
}
// Запасной формат (src без номера) резолвится в свой же номер.
if idxOf(p.Files[0]) != 1 || idxOf(p.Files[1]) != 2 {
t.Errorf("индексы не проставлены: %+v", p.Files)
}
}
func TestValidateSchema_Errors(t *testing.T) {
@@ -42,13 +53,13 @@ func TestValidateSchema_Errors(t *testing.T) {
{"empty title", Plan{Type: MediaMovie, Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "title is empty"},
{"no files", Plan{Type: MediaMovie, Title: "x"}, "files list is empty"},
{"bad role", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: "boss"}}}, "unknown role"},
{"empty src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "empty src"},
{"unknown src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "not found among torrent files"},
{"no addressing at all", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "no files[] element addresses"},
{"unknown src only", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "no files[] element addresses"},
{"episode no num", Plan{Type: MediaSeries, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleEpisode, Season: intp(1)}}}, "has no episode number"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateSchema(&tt.p, in)
_, err := validateSchema(&tt.p, in)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Errorf("err = %v, want contains %q", err, tt.want)
}
@@ -60,7 +71,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, testLogger())
p, _, err := parsePlan(raw, in, testLogger())
if err != nil {
t.Fatalf("parsePlan: %v", err)
}
@@ -73,7 +84,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, testLogger()); err != nil {
if _, _, err := parsePlan(raw, in, testLogger()); err != nil {
t.Fatalf("unknown field should be tolerated: %v", err)
}
}
@@ -106,10 +117,10 @@ func TestSeriesWarnings_GapAndDup(t *testing.T) {
w := seriesWarnings(files)
var dup, gap bool
for _, s := range w {
if strings.Contains(s, "дубль") {
if strings.Contains(s, "дубли серий") {
dup = true
}
if strings.Contains(s, "пропуск") {
if strings.Contains(s, "не хватает серий") {
gap = true
}
}
@@ -157,7 +168,7 @@ func TestConsistencyWarnings(t *testing.T) {
func TestDecide_MetadataDisabled(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
d := decide(p, PreParse{}, nil, false, 0.85)
d := decide(p, PreParse{}, nil, false, 0.85, planIssues{})
if d.Auto {
t.Error("без метабаз авто недопустимо")
}
@@ -168,7 +179,7 @@ func TestDecide_MetadataDisabled(t *testing.T) {
func TestDecide_NoMatch(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
d := decide(p, PreParse{}, nil, true, 0.85)
d := decide(p, PreParse{}, nil, true, 0.85, planIssues{})
if d.Auto || !strings.Contains(d.Reasons[0], "не найдено в базе") {
t.Errorf("reasons = %v", d.Reasons)
}
@@ -178,7 +189,7 @@ func TestDecide_AutoMovie(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999, Confidence: 0.95,
Files: []PlanFile{{Role: RoleMain}, {Role: RoleSample}}}
match := &Match{Provider: "tmdb", ProviderID: "603", Title: "The Matrix", Year: 1999}
d := decide(p, PreParse{Year: 1999}, match, true, 0.85)
d := decide(p, PreParse{Year: 1999}, match, true, 0.85, planIssues{})
if !d.Auto {
t.Errorf("clean movie with match must be auto, reasons: %v", d.Reasons)
}
@@ -188,7 +199,7 @@ func TestDecide_LowConfidenceBlocksAuto(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "X", Year: 2000, Confidence: 0.5,
Files: []PlanFile{{Role: RoleMain}}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "X", Year: 2000}
d := decide(p, PreParse{}, match, true, 0.85)
d := decide(p, PreParse{}, match, true, 0.85, planIssues{})
if d.Auto || !hasReason(d.Reasons, "уверенность") {
t.Errorf("low confidence must block auto, reasons: %v", d.Reasons)
}
@@ -201,20 +212,20 @@ func TestDecide_AutoSeriesEpisodeCount(t *testing.T) {
Files: []PlanFile{mk(1), mk(2), mk(3)}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Fargo", Year: 2014,
SeasonEpisodeCounts: map[int]int{2: 3}}
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); !d.Auto {
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); !d.Auto {
t.Errorf("full season must be auto, reasons: %v", d.Reasons)
}
// Неполный пак (в базе 10) — авто блокируется.
match.SeasonEpisodeCounts = map[int]int{2: 10}
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto ||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); d.Auto ||
!hasReason(d.Reasons, "распознано серий 3, в базе 10") {
t.Errorf("partial pack must block auto, reasons: %v", d.Reasons)
}
// Нет данных о числе серий — авто блокируется.
match.SeasonEpisodeCounts = nil
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto ||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); d.Auto ||
!hasReason(d.Reasons, "нет данных о числе серий") {
t.Errorf("missing counts must block auto, reasons: %v", d.Reasons)
}
@@ -234,3 +245,256 @@ func TestPreParse(t *testing.T) {
t.Errorf("season/episode = %d/%d, want 2/5", series.Season, series.Episode)
}
}
// Перечень видеорасширений — единственное место, где решается, что считать
// потерянной серией: незнакомое расширение уедет в спутники и авто не заблокирует.
func TestIsVideoFile(t *testing.T) {
video := []string{
"a.mkv", "a.MKV", "s1/e01.avi", "film.mp4", "x.m4v", "x.mov", "x.wmv",
"x.mpg", "x.mpeg", "BDMV/STREAM/00001.m2ts", "x.mts", "x.ts", "x.vob",
"x.flv", "x.webm", "x.ogm", "x.divx", "x.rmvb", "x.3gp", "disc.iso", "disc.img",
}
for _, p := range video {
if !IsVideoFile(p) {
t.Errorf("IsVideoFile(%q) = false, want true", p)
}
}
other := []string{
"a.srt", "a.ass", "a.sub", "a.idx", "a.nfo", "a.txt", "a.jpg", "a.png",
"a.md5", "a.sfv", "cover", "a.mkv.txt", "",
}
for _, p := range other {
if IsVideoFile(p) {
t.Errorf("IsVideoFile(%q) = true, want false", p)
}
}
}
// Сезон с тремя дырами даёт ровно одну причину, перечисляющую недостающие серии.
func TestSeriesWarnings_GapsCollapsedPerSeason(t *testing.T) {
var files []PlanFile
for _, e := range []int{1, 2, 3, 4, 6, 8, 9, 10, 12} { // нет 5, 7, 11
files = append(files, PlanFile{Role: RoleEpisode, Season: intp(1), Episode: intp(e)})
}
w := seriesWarnings(files)
if len(w) != 1 {
t.Fatalf("ожидалась одна причина на сезон, получено %d: %v", len(w), w)
}
if !strings.Contains(w[0], "E05") || !strings.Contains(w[0], "E07") || !strings.Contains(w[0], "E11") {
t.Errorf("причина не перечисляет недостающие серии: %q", w[0])
}
}
// Мультисезонный пак: на сезон — не больше одной причины о пропусках.
func TestSeriesWarnings_OneReasonPerSeason(t *testing.T) {
var files []PlanFile
for s := 1; s <= 8; s++ {
for _, e := range []int{1, 3, 5} { // дыры в каждом сезоне
files = append(files, PlanFile{Role: RoleEpisode, Season: intp(s), Episode: intp(e)})
}
}
if w := seriesWarnings(files); len(w) != 8 {
t.Errorf("причин %d при 8 сезонах, ожидалось 8: %v", len(w), w)
}
}
// Непокрытый видеофайл означает потерянную серию — авто блокируется.
func TestDecide_UncoveredVideoBlocksAuto(t *testing.T) {
in := inputWith("s1/e01.mkv", "s1/e02.mkv")
p := Plan{Type: MediaSeries, Title: "Show", Year: 2020, Confidence: 0.95,
Files: []PlanFile{{Src: "s1/e01.mkv", Role: RoleEpisode, Season: intp(1), Episode: intp(1)}}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Show", Year: 2020,
SeasonEpisodeCounts: map[int]int{1: 1}}
d := decide(p, PreParse{}, match, true, 0.85,
planIssues{files: in.Files, all: in.Files})
if d.Auto {
t.Errorf("непокрытый видеофайл обязан блокировать авто: %v", d.Reasons)
}
if !hasReason(d.Reasons, "вне плана осталось видеофайлов: 1") {
t.Errorf("reasons = %v", d.Reasons)
}
if !hasReason(d.Reasons, "в план попало 1 файлов из 2") {
t.Errorf("сводка покрытия обязана быть названа: %v", d.Reasons)
}
}
// Непокрытые спутники (.nfo, скриншоты, тексты) видны в покрытии, но авто не
// отменяют: модель вправе не перечислять то, что не раскладывается.
func TestDecide_UncoveredCompanionsKeepAuto(t *testing.T) {
in := inputWith("film.mkv", "film.nfo", "screens/01.jpg", "readme.txt")
p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95,
Files: []PlanFile{{Src: "film.mkv", Role: RoleMain}}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999}
d := decide(p, PreParse{Year: 1999}, match, true, 0.85,
planIssues{files: in.Files, all: in.Files})
if !d.Auto {
t.Errorf("непокрытые спутники не должны отменять авто: %v", d.Reasons)
}
if !hasReason(d.Reasons, "в план попало 1 файлов из 4") {
t.Errorf("покрытие обязано быть показано человеку: %v", d.Reasons)
}
}
// Отброшенный элемент — блокирующая причина, а полное покрытие сводкой не шумит.
func TestDecide_FullCoverageIsSilent(t *testing.T) {
in := inputWith("film.mkv")
p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95,
Files: []PlanFile{{Src: "film.mkv", Role: RoleMain}}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999}
d := decide(p, PreParse{Year: 1999}, match, true, 0.85,
planIssues{files: in.Files, all: in.Files})
if !d.Auto || len(d.Reasons) != 0 {
t.Errorf("полное покрытие не должно давать причин: %+v", d)
}
withDrop := decide(p, PreParse{Year: 1999}, match, true, 0.85,
planIssues{files: in.Files, all: in.Files, dropped: []string{"элемент отброшен"}})
if withDrop.Auto || !hasReason(withDrop.Reasons, "элемент отброшен") {
t.Errorf("отбраковка обязана блокировать авто: %+v", withDrop)
}
}
// Датовая нумерация серий (обычная для ежедневных шоу) рядом с обычной даёт
// разрыв в миллионы номеров. Перечисление такого разрыва материализовало бы
// сотни мегабайт int при пределе печати в 12 номеров, поэтому пропуски
// считаются арифметикой, а не перечислением.
func TestSeriesWarnings_HugeGapNotMaterialized(t *testing.T) {
files := []PlanFile{
{Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
{Role: RoleEpisode, Season: intp(1), Episode: intp(20260902)},
}
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
w := seriesWarnings(files)
runtime.ReadMemStats(&after)
// Бюджет — на строки причины, а не на 20 млн номеров: старая реализация
// брала здесь ~160 МиБ.
if grew := after.TotalAlloc - before.TotalAlloc; grew > 1<<20 {
t.Errorf("разрыв материализован: выделено %d байт", grew)
}
if len(w) != 1 {
t.Fatalf("ожидалась одна причина на сезон, получено %v", w)
}
if !strings.Contains(w[0], "E02, E03") || !strings.Contains(w[0], "E13") {
t.Errorf("причина обязана перечислять начало разрыва: %q", w[0])
}
if !strings.Contains(w[0], "и ещё 20260888") {
t.Errorf("хвост обязан быть назван числом: %q", w[0])
}
}
// Длинный хвост пропусков сворачивается: печатается предел, остальное — счётчик.
func TestSeriesWarnings_LongTailFolded(t *testing.T) {
files := []PlanFile{
{Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
{Role: RoleEpisode, Season: intp(1), Episode: intp(20)}, // нет 2..19 — 18 штук
}
w := seriesWarnings(files)
if len(w) != 1 {
t.Fatalf("причины = %v", w)
}
if strings.Count(w[0], "E") != episodeListMax {
t.Errorf("напечатано номеров: %d, ожидался предел %d: %q",
strings.Count(w[0], "E"), episodeListMax, w[0])
}
if !strings.Contains(w[0], "E13") || strings.Contains(w[0], "E14") {
t.Errorf("предел печати сдвинулся: %q", w[0])
}
if !strings.Contains(w[0], "и ещё 6") {
t.Errorf("свёрнутый хвост не назван числом: %q", w[0])
}
}
// Претензии к элементам не растут без предела, а внешние значения в них
// усечены: причины оседают в БД навсегда и уезжают в карточку Telegram.
func TestValidateSchema_ProblemsCappedAndValuesShortened(t *testing.T) {
long := "сезон 1/" + strings.Repeat("длинное-имя-", 30) + "серия.mkv"
in := inputWith(long, "b.mkv")
p := Plan{Type: MediaSeries, Title: "Show", Files: []PlanFile{
{Src: long, Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
}}
// Тридцать элементов, каждый повторно адресующий первый файл.
for range 30 {
p.Files = append(p.Files,
PlanFile{Src: long, Role: RoleEpisode, Season: intp(1), Episode: intp(2)})
}
problems, err := validateSchema(&p, in)
if err != nil {
t.Fatalf("validateSchema: %v", err)
}
if len(problems) != maxProblems+1 {
t.Fatalf("претензий %d, ожидались %d поимённых плюс свёрнутый хвост: %v",
len(problems), maxProblems, problems)
}
if !strings.Contains(problems[len(problems)-1], "и ещё 18") {
t.Errorf("хвост претензий не назван числом: %q", problems[len(problems)-1])
}
for _, s := range problems {
if strings.Contains(s, long) {
t.Errorf("внешнее значение не усечено: %q", s)
}
}
}
// Отказ по неизвестной роли называет файл так, как его адресовала модель:
// проверка стоит до резолва, и pf.Src там ещё пуст.
func TestValidateSchema_UnknownRoleNamesAddressing(t *testing.T) {
in := inputWith("a.mkv", "b.mkv")
p := Plan{Type: MediaMovie, Title: "Film", Files: []PlanFile{
{Index: idxp(2), Role: "trailer"},
}}
_, err := validateSchema(&p, in)
if err == nil || !strings.Contains(err.Error(), "#2") {
t.Errorf("err = %v, ожидался номер адресации", err)
}
}
// Ноль — не «номера нет»: так адресует модель, посчитавшая список с нуля, и
// остальные её номера уезжают на файл. Диагноз обязан быть самоописывающимся.
func TestResolveFile_ZeroIndexIsItsOwnCase(t *testing.T) {
in := inputWith("a.mkv", "b.mkv")
byPath := map[string]int{"a.mkv": 0, "b.mkv": 1}
_, problem, ok := resolveFile(PlanFile{Index: idxp(0)}, in.Files, byPath)
if ok || !strings.Contains(problem, "нумеруется с 1") {
t.Errorf("явный ноль: ok=%v, problem=%q", ok, problem)
}
_, problem, ok = resolveFile(PlanFile{}, in.Files, byPath)
if ok || !strings.Contains(problem, "без номера файла и без пути") {
t.Errorf("поля нет вовсе: ok=%v, problem=%q", ok, problem)
}
// Ноль рядом с путём остаётся запасным форматом: путь резолвится.
idx, problem, ok := resolveFile(PlanFile{Index: idxp(0), Src: "b.mkv"}, in.Files, byPath)
if !ok || idx != 1 || problem != "" {
t.Errorf("путь при нулевом номере обязан резолвиться: idx=%d ok=%v %q", idx, ok, problem)
}
}
// Структурное предупреждение и рассинхрон года — блокирующие причины, а не
// информационные заметки: развод «блокирует / не блокирует» обязан исполняться.
func TestDecide_StructuralAndConsistencyBlockAuto(t *testing.T) {
in := inputWith("a.mkv", "b.mkv")
p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95,
Files: []PlanFile{
{Src: "a.mkv", Role: RoleMain},
{Src: "b.mkv", Role: RoleMain}, // два main у фильма
}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999}
d := decide(p, PreParse{Year: 1998}, match, true, 0.85,
planIssues{files: in.Files, all: in.Files})
if d.Auto {
t.Errorf("структурное предупреждение обязано блокировать авто: %v", d.Reasons)
}
if !hasReason(d.Reasons, "основных видеофайлов 2") {
t.Errorf("структурная причина не названа: %v", d.Reasons)
}
if !hasReason(d.Reasons, "год расходится: пред-парс=1998, LLM=1999") {
t.Errorf("рассинхрон года не назван: %v", d.Reasons)
}
}