diff --git a/cmd/jellybit/recognize.go b/cmd/jellybit/recognize.go index c9f2546..a988a56 100644 --- a/cmd/jellybit/recognize.go +++ b/cmd/jellybit/recognize.go @@ -94,6 +94,8 @@ func runRecognize(args []string) error { } rec := recognize.New(provider, providers, recognize.Config{ MaxRetries: cfg.LLM.MaxRetries, + MaxFiles: cfg.Recognition.MaxFiles, + MaxTokens: cfg.Recognition.MaxTokens, AutoThreshold: cfg.Recognition.AutoConfidenceThreshold, Language: cfg.ContentLanguage(), }, logger) @@ -169,13 +171,20 @@ func printDryRun(t qbt.Torrent, files []qbt.File, res recognize.Result, lay *lay } fmt.Printf("\n──── Решение ────\n") + // Reasons печатаем на обеих ветках: на авто-ветке там лежит сводка покрытия + // («в план попало N из M») — информационные строки, не отменяющие авто. + // Ровно тем прогоном, которым эту сводку и проверяют руками, прятать её + // нельзя. if res.Decision.Auto { fmt.Printf("АВТО-раскладка (review не нужен)\n") + if len(res.Decision.Reasons) > 0 { + fmt.Printf("замечания:\n") + } } else { fmt.Printf("REVIEW — причины:\n") - for _, reason := range res.Decision.Reasons { - fmt.Printf(" · %s\n", reason) - } + } + for _, reason := range res.Decision.Reasons { + fmt.Printf(" · %s\n", reason) } fmt.Printf("\n──── Превью раскладки (хардлинки НЕ создаются) ────\n") diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 58bf47c..184f98d 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -103,6 +103,8 @@ func runServe(args []string) error { if llmProvider != nil { recognizer = recognize.New(llmProvider, providers, recognize.Config{ MaxRetries: cfg.LLM.MaxRetries, + MaxFiles: cfg.Recognition.MaxFiles, + MaxTokens: cfg.Recognition.MaxTokens, AutoThreshold: cfg.Recognition.AutoConfidenceThreshold, Language: cfg.ContentLanguage(), }, logger) diff --git a/config.example.toml b/config.example.toml index 031e663..c496d7a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -72,6 +72,8 @@ source_missing_threshold = 3 # подряд тиков сверки бе [recognition] auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0 +max_files = 500 # сколько файлов раздачи максимум показывать модели; целое ≥ 1. Предохранитель от аномальной раздачи, а не рабочее ограничение: усечение уводит задачу в review отдельной причиной +max_tokens = 8000 # предел длины ответа модели, токены; целое ≥ 1. Не хватило — ответ обрывается, и задача уходит в review с причиной «ответ модели обрезан» [telegram] enabled = false # включить Telegram-бота diff --git a/docs/architecture.md b/docs/architecture.md index 04ead93..452091a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -191,4 +191,4 @@ Jellyfin указывают на `movies`/`series`, а не на корень | Идентичность раздачи | split v1/v2-хеши не связаны, паре `xt` из магнета доверяем | `infohash-identity-integrity` | | Расход внешних лимитов | кэша ответов метабаз нет, повтор распознавания бьёт провайдера заново | `metadata-cache` | | История переходов | хранится только текущее состояние, «как сюда попали» восстанавливается по логам | `download-transition-history` | -| Предел ответа LLM | лимита на размер ответа нет — единственный недоверенный канал без предела; задачей пока не заведено | — | +| Предел ответа LLM | предел размера ответа есть (`[recognition].max_tokens`), обрыв по нему распознаётся и уводит в review; открытым остаётся согласование предела с потолком провайдера и с `[llm].timeout` | — | diff --git a/docs/database.md b/docs/database.md index d620c7b..efb00b4 100644 --- a/docs/database.md +++ b/docs/database.md @@ -14,7 +14,8 @@ > `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`), > `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC, > `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла), -> `0010_retried_at`, `0011_parsed_context` (структура имени из контекста, JSON). +> `0010_retried_at`, `0011_parsed_context` (структура имени из контекста, JSON), +> `0012_recognition_source_files` (снимок списка файлов раздачи рядом с планом). Назначение таблиц и роль компонентов — [architecture.md](architecture.md). Значения `state` и легальные переходы — нормативно в @@ -78,9 +79,10 @@ erDiagram TEXT provider "nullable; tmdb|tvdb|tvmaze|none" TEXT provider_id "nullable" REAL confidence "nullable" - TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто" + TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины ухода в review + информационные заметки (сводка покрытия), не отменяющие авто" TEXT raw_llm "nullable; сырой ответ LLM" TEXT plan "nullable; JSON recognize.Plan (миграция 0002)" + TEXT source_files "nullable; JSON [{path,size}] — снимок файлов раздачи в порядке нумерации промпта (миграция 0012). NULL — запись старше миграции: перечень неполон, UI говорит об этом прямо" TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } @@ -165,8 +167,11 @@ erDiagram - **Всё, кроме одного поля, — плоские колонки.** Никакого сжатия, никаких внешних файлов: строка читается и пишется целиком обычным запросом. - **JSON-строками в TEXT** лежат три поля: `recognition.plan` (канонический - `recognize.Plan` — файл → роль/сезон/серия), `recognition.reasons` (список - причин не-авто) и `download.parsed_context` (структура имени из контекста). + `recognize.Plan` — файл → роль/сезон/серия), `recognition.reasons` (диагностика + распознавания: блокирующие причины ухода в review **и** информационные + заметки вроде сводки покрытия, которые авто-раскладку не отменяют, — само + решение auto/review из длины списка не выводится) и `download.parsed_context` + (структура имени из контекста). Читаются целиком и разбираются в Go; частичного чтения и обновления поля внутри JSON нет, SQL по содержимому этих полей не делается. - **`recognition.raw_llm`** — сырой ответ модели как есть, **несжатый**. Это @@ -204,6 +209,8 @@ erDiagram | `[worker].catch_timeout` | `10m` | предел для пойманной задачи, не добавившейся в qBittorrent | | `[worker].source_missing_threshold` | `3` тика | дебаунс пропажи источника | | `[recognition].auto_confidence_threshold` | `0.85` | порог авто-раскладки (доп. проверка к матчу в базе) | +| `[recognition].max_files` | `500` файлов | сколько файлов раздачи максимум показывать модели в промпте. Предохранитель от аномальной раздачи, а не рабочее ограничение: 500 путей — порядка 35k токенов промпта, что модель принимает. Усечение уводит задачу в `review` отдельной причиной, а знаменатель покрытия остаётся полным числом файлов | +| `[recognition].max_tokens` | `8000` токенов | предел длины ответа модели. При индексной адресации файла (`files[].i` вместо копии пути) элемент плана стоит ~14 токенов, так что 500 файлов укладываются с запасом. Не хватило — `finish_reason = length`, и задача уходит в `review` с причиной «ответ модели обрезан», без повторного запроса | | `[llm].timeout` / `max_retries` | `120s` / `3` | каждая попытка порождает строку `recognition` с сырым ответом | | `[metadata.*].timeout` | `10s` | таймаут запроса к метабазе | diff --git a/internal/archrules/arch_test.go b/internal/archrules/arch_test.go index e047da0..cfd6a06 100644 --- a/internal/archrules/arch_test.go +++ b/internal/archrules/arch_test.go @@ -96,16 +96,36 @@ func TestМиграцииБезAutoincrementИСерверногоВремени // docs/conventions/errors.md: сравнение ошибок — errors.Is/errors.As, никогда // по тексту. errorlint ловит `err == ErrX` и приведение типа, но не матчинг // подстрокой — его ловим здесь. +// +// Прямой формы мало: `msg := strings.ToLower(err.Error())` и следом +// `strings.Contains(msg, …)` — то же сравнение по тексту, просто через +// промежуточную переменную (так оно и просочилось в recognize). Поэтому вторым +// проходом собираем имена, которым присвоен `.Error()`, и ищем их в тех же +// сравнениях. func TestОшибкиНеМатчатсяПоТексту(t *testing.T) { - re := regexp.MustCompile(`(strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\([^)]*\.Error\(\)|\.Error\(\)\s*==)`) + direct := regexp.MustCompile(`(strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\([^)]*\.Error\(\)|\.Error\(\)\s*==)`) + // Присваивание текста ошибки переменной, в т.ч. завёрнутое в вызовы + // (strings.ToLower, strings.TrimSpace и подобные). + assigned := regexp.MustCompile(`(?m)^\s*(\w+)\s*:?=\s*(?:[\w.]+\()*\s*[\w.]+\.Error\(\)`) + const why = "ошибку матчим через errors.Is/errors.As, а не по тексту сообщения" for _, path := range goFiles(t) { body, err := os.ReadFile(path) if err != nil { t.Fatalf("читаю %s: %v", path, err) } - if loc := re.FindIndex(body); loc != nil { - rel, _ := filepath.Rel(repoRoot, path) - t.Errorf("%s:%d — ошибку матчим через errors.Is/errors.As, а не по тексту сообщения", rel, lineOf(body, loc[0])) + rel, _ := filepath.Rel(repoRoot, path) + if loc := direct.FindIndex(body); loc != nil { + t.Errorf("%s:%d — %s", rel, lineOf(body, loc[0]), why) + } + for _, m := range assigned.FindAllSubmatch(body, -1) { + name := regexp.QuoteMeta(string(m[1])) + used := regexp.MustCompile( + `strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\(\s*` + name + `\b` + + `|\b` + name + `\s*==\s*"` + `|"\s*==\s*` + name + `\b`) + if loc := used.FindIndex(body); loc != nil { + t.Errorf("%s:%d — %s (текст ошибки положен в %s)", + rel, lineOf(body, loc[0]), why, m[1]) + } } } } diff --git a/internal/config/config.go b/internal/config/config.go index f6ad91e..96a41e8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -127,9 +127,17 @@ type Worker struct { SourceMissingThreshold int `toml:"source_missing_threshold"` } -// Recognition — пороги распознавания. +// Recognition — пороги и пределы распознавания. type Recognition struct { AutoConfidenceThreshold float64 `toml:"auto_confidence_threshold"` + // MaxFiles — предохранитель от аномальной раздачи: сколько файлов + // максимум печатать модели в промпте. Рабочим ограничением быть не должен + // (усечение уводит задачу в review отдельной причиной), поэтому значение + // держим на уровне промпта, который модель заведомо принимает. + MaxFiles int `toml:"max_files"` + // MaxTokens — предел длины ответа модели. Обрыв по нему распознаётся + // отдельно (finish_reason = length) и уводит в review без повтора запроса. + MaxTokens int `toml:"max_tokens"` } // Telegram — настройки бота (Ф5). @@ -232,9 +240,17 @@ func Default() *Config { CatchTimeout: Duration(10 * time.Minute), SourceMissingThreshold: 3, }, - Recognition: Recognition{AutoConfidenceThreshold: 0.85}, - HTTP: HTTP{Listen: ":8080"}, - Log: Log{Level: "info", Format: "json"}, + Recognition: Recognition{ + AutoConfidenceThreshold: 0.85, + // 500 файлов — порядка 35k токенов промпта: столько модель принимает, + // а раздача крупнее уже требует разбираться руками. Согласие с + // предохранителями конструктора (recognize.defaultMaxFiles / + // defaultMaxTokens) сторожит TestDefaultsAgreeWithConfig. + MaxFiles: 500, + MaxTokens: 8000, + }, + HTTP: HTTP{Listen: ":8080"}, + Log: Log{Level: "info", Format: "json"}, } } @@ -307,6 +323,12 @@ func (c *Config) validate() error { if t := c.Recognition.AutoConfidenceThreshold; t < 0 || t > 1 { errs = append(errs, fmt.Errorf("recognition.auto_confidence_threshold %.3f is out of range [0, 1]", t)) } + if c.Recognition.MaxFiles < 1 { + errs = append(errs, fmt.Errorf("recognition.max_files %d must be >= 1", c.Recognition.MaxFiles)) + } + if c.Recognition.MaxTokens < 1 { + errs = append(errs, fmt.Errorf("recognition.max_tokens %d must be >= 1", c.Recognition.MaxTokens)) + } if c.LLM.MaxRetries < 0 { errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries)) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 731f6b1..47475f3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -131,3 +131,54 @@ func TestValidate_Errors(t *testing.T) { }) } } + +// Пределы распознавания: дефолты применяются к конфигу, где секции нет вовсе, +// а нулевые/отрицательные значения отвергаются — молча дефолтить их нельзя, +// иначе выключенный предохранитель неотличим от настроенного. +func TestValidate_RecognitionLimits(t *testing.T) { + if d := Default().Recognition; d.MaxFiles != 500 || d.MaxTokens != 8000 { + t.Errorf("дефолты = %+v, want max_files 500 / max_tokens 8000", d) + } + + dir := t.TempDir() + for _, p := range []string{"downloads", "movies", "series"} { + if err := os.MkdirAll(filepath.Join(dir, p), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + } + path := filepath.Join(dir, "config.toml") + body := "[qbittorrent]\nurl = \"http://qbit:8080\"\n" + + "[paths]\ndownloads = \"" + filepath.Join(dir, "downloads") + "\"\n" + + "movies = \"" + filepath.Join(dir, "movies") + "\"\n" + + "series = \"" + filepath.Join(dir, "series") + "\"\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Recognition.MaxFiles != 500 || cfg.Recognition.MaxTokens != 8000 { + t.Errorf("конфиг без секции не получил дефолты: %+v", cfg.Recognition) + } + + for _, tt := range []struct { + name string + mut func(*Config) + want string + }{ + {"max_files 0", func(c *Config) { c.Recognition.MaxFiles = 0 }, "recognition.max_files"}, + {"max_files negative", func(c *Config) { c.Recognition.MaxFiles = -1 }, "recognition.max_files"}, + {"max_tokens 0", func(c *Config) { c.Recognition.MaxTokens = 0 }, "recognition.max_tokens"}, + {"max_tokens negative", func(c *Config) { c.Recognition.MaxTokens = -10 }, "recognition.max_tokens"}, + } { + t.Run(tt.name, func(t *testing.T) { + c := validCfg(t) + tt.mut(c) + err := c.validate() + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Errorf("err = %v, want contains %q", err, tt.want) + } + }) + } +} diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index 0cc4c60..cb0e598 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -45,7 +45,7 @@ type downloadDetailView struct { MatchURL string // ссылка на запись метабазы (пусто — показываем текстом) NoBase bool Confidence string - Files []fileRow + Files layoutWidget // Живая статистика раздачи (заполняется из снимка воркера). Seeding seedingView @@ -178,7 +178,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet } // Файл источника → целевой путь из превью (единая логика layout). - view.Files = buildFileRows(rd.Plan, rd.Preview) + view.Files = buildFileRows(rd.Plan, rd.Preview, rd.SourceFiles, rd.SourceFilesKnown) } // Живая статистика раздачи — со значениями уже в первом кадре; секция diff --git a/internal/httpapi/files.go b/internal/httpapi/files.go index 5bf2503..d33b939 100644 --- a/internal/httpapi/files.go +++ b/internal/httpapi/files.go @@ -1,6 +1,8 @@ package httpapi import ( + "sort" + "git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/recognize" ) @@ -9,31 +11,165 @@ import ( // ревью и просмотра загрузки. Целевой путь берётся из превью (единая логика // internal/layout), а не вычисляется в шаблоне. type fileRow struct { + // Num — номер файла в списке распознавания: тот самый, которым его + // адресует ответ модели. 0 — номер неизвестен (снимка списка нет), и тогда + // он не показывается вовсе: расхождение сделало бы разбор ошибок адресации + // ложным. + Num int Src string Dst string // целевой путь; пусто — файл не раскладывается RoleLabel string Linked bool Ignored bool + // Unplanned — файла нет в плане распознавания: модель его не разметила. + // Молчаливый пропуск файла моделью не должен быть невидимым. + Unplanned bool + + season int // ключи сортировки; 0 у файлов без нумерации + episode int + group int // 0 — разложенные, 1 — план без цели, 2 — вне плана } -// buildFileRows сшивает файлы плана с целевыми путями из превью раскладки. -func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow { - dstBySrc := make(map[string]string, len(preview)) - for _, l := range preview { - dstBySrc[l.Src] = l.Dst +// layoutWidget — данные виджета раскладки: строки плюс честность о полноте. +type layoutWidget struct { + Rows []fileRow + // SourceUnknown — списка файлов раздачи для этой загрузки нет (запись + // создана до того, как система стала его сохранять). Строки построены по + // плану, и виджет обязан назвать это прямо. + SourceUnknown bool + Planned int // файлов в плане + Total int // всего файлов раздачи (0, когда список неизвестен) +} + +// buildFileRows сшивает файлы РАЗДАЧИ с планом и целевыми путями из превью. +// files — снимок списка файлов раздачи момента распознавания (в порядке +// нумерации промпта); nil означает, что снимка нет: тогда строки строятся по +// плану, а виджет сообщает о неполноте. +func buildFileRows(plan recognize.Plan, preview []layout.Link, files []recognize.File, known bool) layoutWidget { + dsts := planDsts(plan, preview) + + // Строки строятся по ЭЛЕМЕНТАМ плана, а не по путям источников: план из БД + // вправе разметить один файл двумя элементами (сдвоенный эпизод в записях, + // сделанных до индексной адресации). Схлопывание таких элементов по пути + // показало бы одну цель там, где Apply создаст два хардлинка, — предпросмотр + // перестал бы быть равен применению (см. требование web-ui). + planBySrc := make(map[string][]int, len(plan.Files)) + distinct := make(map[string]bool, len(plan.Files)) + for i, f := range plan.Files { + planBySrc[f.Src] = append(planBySrc[f.Src], i) + distinct[f.Src] = true } - rows := make([]fileRow, 0, len(plan.Files)) - for _, f := range plan.Files { - dst := dstBySrc[f.Src] - rows = append(rows, fileRow{ - Src: f.Src, - Dst: dst, - RoleLabel: roleLabel(string(f.Role)), - Linked: dst != "", - Ignored: f.Role == "ignore", - }) + + w := layoutWidget{ + SourceUnknown: !known, + Planned: len(distinct), + Rows: make([]fileRow, 0, len(files)+len(plan.Files)), } - return rows + if known { + w.Total = len(files) + } + + seen := make(map[string]bool, len(files)) + for i, f := range files { + seen[f.Path] = true + idxs := planBySrc[f.Path] + if len(idxs) == 0 { + w.Rows = append(w.Rows, newFileRow(i+1, f.Path, "", recognize.PlanFile{}, false)) + continue + } + for _, j := range idxs { + w.Rows = append(w.Rows, newFileRow(i+1, f.Path, dsts[j], plan.Files[j], true)) + } + } + // План может ссылаться на файл, которого в снимке нет (снимка нет вовсе + // либо раздачу переименовали после распознавания) — такую строку всё равно + // показываем, но без номера: он ничего не адресует. + for j, f := range plan.Files { + if seen[f.Src] { + continue + } + w.Rows = append(w.Rows, newFileRow(0, f.Src, dsts[j], f, true)) + } + + // Порядок задан полностью и не зависит от порядка файлов в плане: + // разложенные (сезон, серия, путь) → файлы плана без цели → файлы вне + // плана. Пустые season/episode — указатели, и свёрнутые в ноль они + // поставили бы семплы и скриншоты перед первым сезоном, поэтому место + // таких файлов задаёт group, а не умолчание нумерации. + sort.SliceStable(w.Rows, func(i, j int) bool { + a, b := w.Rows[i], w.Rows[j] + switch { + case a.group != b.group: + return a.group < b.group + case a.group == 0 && a.season != b.season: + return a.season < b.season + case a.group == 0 && a.episode != b.episode: + return a.episode < b.episode + default: + return a.Src < b.Src + } + }) + return w +} + +// planDsts — целевой путь КАЖДОГО элемента плана (пусто — элемент не +// раскладывается). Карта «путь → цель» тут не годится: у двух элементов с одним +// источником цели разные. Превью строится по этому же плану, в том же порядке и +// только по раскладываемым ролям, поэтому элементы и ссылки сопоставляются +// проходом в два указателя; расхождение (превью от другого плана) оставляет +// строку без цели, но не сдвигает остальные. +func planDsts(plan recognize.Plan, preview []layout.Link) []string { + out := make([]string, len(plan.Files)) + k := 0 + for i, f := range plan.Files { + if !linkableRole(f.Role) || k >= len(preview) || preview[k].Src != f.Src { + continue + } + out[i] = preview[k].Dst + k++ + } + return out +} + +// linkableRole — роль, которую раскладка превращает в ссылку (зеркало +// worker.mapRole; здесь нужна лишь для сопоставления строк с превью, и промах +// стоит пустой цели в строке, а не неверной раскладки). +func linkableRole(r recognize.FileRole) bool { + switch r { + case recognize.RoleMain, recognize.RoleEpisode, recognize.RoleSubtitle: + return true + default: + return false + } +} + +// newFileRow собирает строку виджета по файлу раздачи и его записи в плане. +func newFileRow(num int, src, dst string, pf recognize.PlanFile, inPlan bool) fileRow { + row := fileRow{ + Num: num, + Src: src, + Dst: dst, + Linked: dst != "", + Unplanned: !inPlan, + group: 2, + } + if !inPlan { + row.RoleLabel = "не в плане" + return row + } + row.RoleLabel = roleLabel(string(pf.Role)) + row.Ignored = pf.Role == "ignore" + row.group = 1 + if row.Linked { + row.group = 0 + } + if pf.Season != nil { + row.season = *pf.Season + } + if pf.Episode != nil { + row.episode = *pf.Episode + } + return row } // roleLabel — человекочитаемая роль файла раскладки. diff --git a/internal/httpapi/files_test.go b/internal/httpapi/files_test.go new file mode 100644 index 0000000..a8022b4 --- /dev/null +++ b/internal/httpapi/files_test.go @@ -0,0 +1,195 @@ +package httpapi + +import ( + "net/http" + "strings" + "testing" + + "git.vakhrushev.me/av/jellybit/internal/layout" + "git.vakhrushev.me/av/jellybit/internal/recognize" + "git.vakhrushev.me/av/jellybit/internal/store" + "git.vakhrushev.me/av/jellybit/internal/worker" +) + +func ip(n int) *int { return &n } + +// srcFiles — снимок списка файлов раздачи в порядке нумерации распознавания. +func srcFiles(paths ...string) []recognize.File { + out := make([]recognize.File, len(paths)) + for i, p := range paths { + out[i] = recognize.File{Path: p, Size: 1 << 20} + } + return out +} + +// Файл, которого модель не разметила, виден отдельной строкой без цели. +func TestBuildFileRows_UnplannedFileVisible(t *testing.T) { + files := srcFiles("s1/e01.mkv", "s1/e02.mkv", "extras/screen.jpg") + plan := recognize.Plan{Type: recognize.MediaSeries, Files: []recognize.PlanFile{ + {Src: "s1/e01.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(1)}, + }} + preview := []layout.Link{{Src: "s1/e01.mkv", Dst: "/srv/series/Show/S01/E01.mkv"}} + + w := buildFileRows(plan, preview, files, true) + if len(w.Rows) != 3 { + t.Fatalf("строк %d, ожидалось по одной на файл раздачи: %+v", len(w.Rows), w.Rows) + } + if w.Planned != 1 || w.Total != 3 || w.SourceUnknown { + t.Errorf("сводка = %+v", w) + } + bySrc := map[string]fileRow{} + for _, r := range w.Rows { + bySrc[r.Src] = r + } + for _, src := range []string{"s1/e02.mkv", "extras/screen.jpg"} { + r := bySrc[src] + if !r.Unplanned || r.Dst != "" || r.Linked { + t.Errorf("%s: ожидалась строка «не в плане» без цели, получено %+v", src, r) + } + if r.RoleLabel != "не в плане" { + t.Errorf("%s: роль = %q", src, r.RoleLabel) + } + } + // Номер строки — номер адресации: позиция в списке распознавания. + if bySrc["s1/e02.mkv"].Num != 2 || bySrc["extras/screen.jpg"].Num != 3 { + t.Errorf("номера не совпадают с нумерацией распознавания: %+v", w.Rows) + } +} + +// Порядок строк не зависит от порядка plan.Files. +func TestBuildFileRows_OrderIndependentOfPlan(t *testing.T) { + files := srcFiles( + "pack/s1e01.mkv", "pack/s1e02.mkv", "pack/s2e01.mkv", + "pack/sample.mkv", "pack/notes.nfo") + ep := func(src string, s, e int) recognize.PlanFile { + return recognize.PlanFile{Src: src, Role: recognize.RoleEpisode, Season: ip(s), Episode: ip(e)} + } + preview := []layout.Link{ + {Src: "pack/s1e01.mkv", Dst: "/srv/Show/S01/E01.mkv"}, + {Src: "pack/s1e02.mkv", Dst: "/srv/Show/S01/E02.mkv"}, + {Src: "pack/s2e01.mkv", Dst: "/srv/Show/S02/E01.mkv"}, + } + want := []string{"pack/s1e01.mkv", "pack/s1e02.mkv", "pack/s2e01.mkv", + "pack/sample.mkv", "pack/notes.nfo"} + + orders := [][]recognize.PlanFile{ + {ep("pack/s1e01.mkv", 1, 1), ep("pack/s1e02.mkv", 1, 2), ep("pack/s2e01.mkv", 2, 1), + {Src: "pack/sample.mkv", Role: recognize.RoleSample}}, + {{Src: "pack/sample.mkv", Role: recognize.RoleSample}, ep("pack/s2e01.mkv", 2, 1), + ep("pack/s1e02.mkv", 1, 2), ep("pack/s1e01.mkv", 1, 1)}, + } + for _, pf := range orders { + w := buildFileRows(recognize.Plan{Type: recognize.MediaSeries, Files: pf}, preview, files, true) + got := make([]string, 0, len(w.Rows)) + for _, r := range w.Rows { + got = append(got, r.Src) + } + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Errorf("порядок = %v, want %v", got, want) + } + } +} + +// Файлы без сезона и серии не поднимаются в начало списка. +func TestBuildFileRows_UnnumberedNotFirst(t *testing.T) { + files := srcFiles("a_sample.mkv", "z_s01e01.mkv") + plan := recognize.Plan{Type: recognize.MediaSeries, Files: []recognize.PlanFile{ + {Src: "a_sample.mkv", Role: recognize.RoleSample}, + {Src: "z_s01e01.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(1)}, + }} + preview := []layout.Link{{Src: "z_s01e01.mkv", Dst: "/srv/Show/S01/E01.mkv"}} + + w := buildFileRows(plan, preview, files, true) + if w.Rows[0].Src != "z_s01e01.mkv" { + t.Errorf("первым обязан идти разложенный файл, получено %+v", w.Rows) + } +} + +// Запись без сохранённого списка файлов: строки по плану + честная пометка. +func TestBuildFileRows_SourceUnknown(t *testing.T) { + plan := recognize.Plan{Type: recognize.MediaMovie, Files: []recognize.PlanFile{ + {Src: "film.mkv", Role: recognize.RoleMain}, + }} + w := buildFileRows(plan, []layout.Link{{Src: "film.mkv", Dst: "/srv/movies/F/f.mkv"}}, nil, false) + if !w.SourceUnknown { + t.Error("отсутствие снимка обязано быть названо явно") + } + if len(w.Rows) != 1 || w.Rows[0].Src != "film.mkv" { + t.Errorf("rows = %+v", w.Rows) + } + if w.Rows[0].Num != 0 { + t.Errorf("без снимка номер адресации неизвестен и показываться не должен: %+v", w.Rows[0]) + } +} + +// Страница загрузки говорит о недоступности списка файлов, а не показывает +// план как полный перечень. +func TestDownloadPage_SaysFileListUnavailable(t *testing.T) { + dl := store.Download{ID: testULID, SourceRef: "Film", State: store.StateReview} + plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Film", + Files: []recognize.PlanFile{{Src: "film.mkv", Role: recognize.RoleMain}}} + rd := &worker.ReviewData{ + Download: dl, + Recognition: &store.Recognition{ID: testULID, DownloadID: testULID}, + Plan: plan, + } + h := testRouter(t, stubReader{list: []store.Download{dl}, one: &dl}, stubReviewer{data: rd}) + + rr := get(t, h, "/download/"+testULID) + if rr.Code != http.StatusOK { + t.Fatalf("GET /download/{id} = %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "Список файлов раздачи не сохранён") { + t.Errorf("страница молчит о неполноте перечня") + } + + // Со снимком — пометка исчезает, а файл вне плана виден. + rd.SourceFiles = srcFiles("film.mkv", "screens/01.jpg") + rd.SourceFilesKnown = true + rr = get(t, h, "/download/"+testULID) + body := rr.Body.String() + if strings.Contains(body, "Список файлов раздачи не сохранён") { + t.Errorf("со снимком пометка о неполноте не нужна") + } + if !strings.Contains(body, "screens/01.jpg") || !strings.Contains(body, "не в плане") { + t.Errorf("файл вне плана не показан:\n%s", body) + } +} + +// Легаси-план вправе разметить один файл двумя элементами (сдвоенный эпизод): +// Apply создаст два хардлинка, и виджет обязан показать обе цели — иначе +// предпросмотр перестаёт быть равен применению. +func TestBuildFileRows_DuplicateSrcShownTwice(t *testing.T) { + files := srcFiles("pack/s01e01e02.mkv", "pack/s01e03.mkv") + plan := recognize.Plan{Type: recognize.MediaSeries, Files: []recognize.PlanFile{ + {Src: "pack/s01e01e02.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(1)}, + {Src: "pack/s01e01e02.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(2)}, + {Src: "pack/s01e03.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(3)}, + }} + preview := []layout.Link{ + {Src: "pack/s01e01e02.mkv", Dst: "/srv/Show/S01/E01.mkv"}, + {Src: "pack/s01e01e02.mkv", Dst: "/srv/Show/S01/E02.mkv"}, + {Src: "pack/s01e03.mkv", Dst: "/srv/Show/S01/E03.mkv"}, + } + + w := buildFileRows(plan, preview, files, true) + if len(w.Rows) != 3 { + t.Fatalf("строк %d, ожидалась строка на каждый элемент плана: %+v", len(w.Rows), w.Rows) + } + var dsts []string + for _, r := range w.Rows { + if r.Src == "pack/s01e01e02.mkv" { + dsts = append(dsts, r.Dst) + } + if r.Num == 0 { + t.Errorf("номер адресации потерян: %+v", r) + } + } + if len(dsts) != 2 || dsts[0] != "/srv/Show/S01/E01.mkv" || dsts[1] != "/srv/Show/S01/E02.mkv" { + t.Errorf("обе цели сдвоенного эпизода = %v", dsts) + } + // Знаменатель покрытия — файлы раздачи, а не элементы плана. + if w.Planned != 2 || w.Total != 2 { + t.Errorf("сводка = %+v", w) + } +} diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index d51e252..0d82bdc 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -57,7 +57,7 @@ type reviewView struct { Confidence string Reasons []string Hints []string - Files []fileRow + Files layoutWidget HasPlan bool HasLinks bool // есть хотя бы один целевой путь → можно применять NoBase bool // выбрано «без базы» @@ -145,7 +145,7 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView if rec.Confidence.Valid { view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64) } - view.Files = buildFileRows(rd.Plan, rd.Preview) + view.Files = buildFileRows(rd.Plan, rd.Preview, rd.SourceFiles, rd.SourceFilesKnown) view.HasPlan = len(rd.Plan.Files) > 0 view.HasLinks = len(rd.Preview) > 0 for _, src := range rd.Sources { diff --git a/internal/llm/llm.go b/internal/llm/llm.go index 614147a..4e23960 100644 --- a/internal/llm/llm.go +++ b/internal/llm/llm.go @@ -54,8 +54,17 @@ type Response struct { Content string Model string Usage Usage + // FinishReason — причина завершения генерации, как её назвал провайдер + // ("stop", "length", …). Пусто — провайдер её не сообщил. Нужна + // вызывающему: обрыв по длине неотличим от мусора по одному лишь телу + // ответа, а повторять такой запрос бессмысленно (см. recognize). + FinishReason string } +// FinishLength — значение FinishReason, которым OpenAI-совместимые провайдеры +// сообщают обрыв генерации по достижении предела токенов. +const FinishLength = "length" + // Provider — абстракция доступа к LLM. recognize работает только с ним и не // знает про конкретный транспорт. type Provider interface { @@ -75,6 +84,17 @@ type Config struct { // ErrUnknownType — запрошенный [llm].type не поддерживается. var ErrUnknownType = errors.New("llm: unknown provider type") +// ErrRequestTooLarge — провайдер отказал из-за размера самого запроса +// (переполнение контекста), а не из-за его содержимого. Вызывающему это +// отдельная ветвь: такой отказ не лечится повтором, а называется человеку +// своей причиной и крутится настройкой ([recognition].max_files). +// +// Признак ставит транспорт — там, где ещё целы HTTP-статус и полное тело +// ответа (см. openai.go). Вызывающий проверяет его errors.Is: у итоговой +// ошибки текст обрезан и в него попадает эхо запроса, так что матчить по нему +// нельзя (docs/conventions/errors.md). +var ErrRequestTooLarge = errors.New("llm: request too large") + // New собирает провайдер по дискриминатору cfg.Type. logger nil → slog.Default(). func New(cfg Config, logger *slog.Logger) (Provider, error) { if logger == nil { diff --git a/internal/llm/llm_test.go b/internal/llm/llm_test.go index 47cae3a..6d7ed8d 100644 --- a/internal/llm/llm_test.go +++ b/internal/llm/llm_test.go @@ -3,6 +3,7 @@ package llm import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -234,3 +235,72 @@ func TestNew_OpenAICompatValidation(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +// Признак обрыва генерации по длине доходит до вызывающего: по одному телу +// ответа обрыв неотличим от мусора, а повторять такой запрос бессмысленно. +func TestComplete_FinishReasonReachesCaller(t *testing.T) { + for _, want := range []string{"length", "stop"} { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"model":"m","choices":[{"message":{"content":"{\"type\":"},`+ + `"finish_reason":"`+want+`"}]}`) + })) + + p := newTestProvider(t, srv.URL, "") + resp, err := p.Complete(context.Background(), Request{ + Messages: []Message{{Role: RoleUser, Content: "hi"}}, + }) + srv.Close() + if err != nil { + t.Fatalf("Complete: %v", err) + } + if resp.FinishReason != want { + t.Errorf("finish_reason = %q, want %q", resp.FinishReason, want) + } + } + if FinishLength != "length" { + t.Errorf("FinishLength = %q, want length", FinishLength) + } +} + +// Отказ по размеру запроса помечается sentinel'ом там, где ещё целы +// HTTP-статус и ПОЛНОЕ тело: вызывающему нечего матчить по тексту, а текст +// итоговой ошибки обрезан (snippet) и несёт эхо запроса. +func TestComplete_RequestTooLargeSentinel(t *testing.T) { + cases := []struct { + name string + status int + body string + want bool + }{ + {"413 без разбора текста", http.StatusRequestEntityTooLarge, `payload too big`, true}, + {"400 с маркером в теле", http.StatusBadRequest, + `{"error":{"message":"This model's maximum context length is 128000 tokens"}}`, true}, + {"400 с маркером за пределом snippet", http.StatusBadRequest, + `{"echo":"` + strings.Repeat("x", 400) + `","error":{"message":"prompt is too long"}}`, true}, + {"400 по другой причине", http.StatusBadRequest, + `{"error":{"message":"invalid api key"}}`, false}, + {"500 — транзиентный сбой, не размер", http.StatusInternalServerError, + `context length exceeded`, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, tc.body) + })) + defer srv.Close() + + p := newTestProvider(t, srv.URL, "") + _, err := p.Complete(context.Background(), Request{ + Messages: []Message{{Role: RoleUser, Content: "hi"}}, + }) + if err == nil { + t.Fatal("ожидалась ошибка") + } + if got := errors.Is(err, ErrRequestTooLarge); got != tc.want { + t.Errorf("errors.Is(err, ErrRequestTooLarge) = %v, want %v (err = %v)", + got, tc.want, err) + } + }) + } +} diff --git a/internal/llm/openai.go b/internal/llm/openai.go index 7ed82f1..c2828c9 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -193,8 +193,11 @@ func (c *openAICompat) do(ctx context.Context, body []byte) (Response, bool, err if resp.StatusCode != http.StatusOK { retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 - return Response{}, retryable, fmt.Errorf("llm: status %d: %s", - resp.StatusCode, snippet(raw)) + err := fmt.Errorf("llm: status %d: %s", resp.StatusCode, snippet(raw)) + if requestTooLarge(resp.StatusCode, raw) { + err = fmt.Errorf("%w: %w", ErrRequestTooLarge, err) + } + return Response{}, retryable, err } var cr chatResponse @@ -209,8 +212,9 @@ func (c *openAICompat) do(ctx context.Context, body []byte) (Response, bool, err } return Response{ - Content: cr.Choices[0].Message.Content, - Model: cr.Model, + Content: cr.Choices[0].Message.Content, + Model: cr.Model, + FinishReason: cr.Choices[0].FinishReason, Usage: Usage{ PromptTokens: cr.Usage.PromptTokens, CompletionTokens: cr.Usage.CompletionTokens, @@ -236,6 +240,36 @@ func (c *openAICompat) wait(ctx context.Context, attempt int) error { } } +// tooLargeMarkers — текстовые признаки переполнения контекста в теле отказа. +// Единого машинного кода у OpenAI-совместимых шлюзов нет: часть отдаёт 413, +// остальные кладут причину в текст при обычном 400. +var tooLargeMarkers = []string{ + "context length", "context_length", "context window", "maximum context", + "too many tokens", "prompt is too long", "request too large", + "reduce the length", +} + +// requestTooLarge — отказал ли провайдер из-за размера запроса. 413 — +// безусловно, без разбора текста. 400 — только по маркерам, и смотрим их в +// ПОЛНОМ теле: обрезка тела (snippet) выбрасывает причину ровно у тех шлюзов, +// которые сперва echo'ят запрос. Ложное срабатывание безопасно: задача уходит +// в review, а не в повтор. +func requestTooLarge(status int, body []byte) bool { + if status == http.StatusRequestEntityTooLarge { + return true + } + if status != http.StatusBadRequest { + return false + } + lower := strings.ToLower(string(body)) + for _, marker := range tooLargeMarkers { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + // snippet обрезает тело для сообщения об ошибке. func snippet(b []byte) string { const max = 300 diff --git a/internal/recognize/addressing_test.go b/internal/recognize/addressing_test.go new file mode 100644 index 0000000..2a74e73 --- /dev/null +++ b/internal/recognize/addressing_test.go @@ -0,0 +1,327 @@ +package recognize + +import ( + "context" + "strconv" + "strings" + "testing" + + "git.vakhrushev.me/av/jellybit/internal/llm" +) + +// idxOf — резолвнутый номер файла (0 — не проставлен). +func idxOf(pf PlanFile) int { + if pf.Index == nil { + return 0 + } + return int(*pf.Index) +} + +// planWith собирает ответ модели из готовых элементов files[]. +func planWith(files ...string) string { + return `{"type":"series","title":"Show","confidence":0.9,"files":[` + + strings.Join(files, ",") + `]}` +} + +// Номер строки резолвится в путь нашего же списка: src подставляем мы, а не +// модель — посторонний путь невыразим. +func TestParsePlan_IndexResolvesToPath(t *testing.T) { + in := inputWith("s1/e1.mkv", "s1/e2.mkv", "s1/e3.mkv") + raw := planWith(`{"i":2,"role":"episode","season":1,"episode":2}`) + + p, problems, err := parsePlan(raw, in, testLogger()) + if err != nil { + t.Fatalf("parsePlan: %v", err) + } + if len(problems) != 0 { + t.Errorf("претензий быть не должно: %v", problems) + } + if p.Files[0].Src != "s1/e2.mkv" || idxOf(p.Files[0]) != 2 { + t.Errorf("file = %+v, want s1/e2.mkv по номеру 2", p.Files[0]) + } +} + +// Номер вне диапазона отбрасывает элемент, а не план. +func TestParsePlan_IndexOutOfRangeDropsElementOnly(t *testing.T) { + in := inputWith("a.mkv", "b.mkv") + raw := planWith( + `{"i":1,"role":"episode","season":1,"episode":1}`, + `{"i":181,"role":"episode","season":1,"episode":2}`, + `{"i":2,"role":"episode","season":1,"episode":2}`) + + p, problems, err := parsePlan(raw, in, testLogger()) + if err != nil { + t.Fatalf("план должен пережить негодный элемент: %v", err) + } + if len(p.Files) != 2 { + t.Errorf("в плане %d файлов, ожидались 2 годных: %+v", len(p.Files), p.Files) + } + if len(problems) != 1 || !strings.Contains(problems[0], "вне диапазона 1..2") { + t.Errorf("problems = %v", problems) + } +} + +// Повторная адресация: побеждает первый элемент, второй отброшен с причиной. +func TestParsePlan_DuplicateAddressingKeepsFirst(t *testing.T) { + in := inputWith("a.mkv", "b.mkv") + raw := planWith( + `{"i":1,"role":"episode","season":1,"episode":1}`, + `{"i":1,"role":"episode","season":1,"episode":7}`) + + p, problems, err := parsePlan(raw, in, testLogger()) + if err != nil { + t.Fatalf("parsePlan: %v", err) + } + if len(p.Files) != 1 || *p.Files[0].Episode != 1 { + t.Errorf("должен остаться первый элемент, получено %+v", p.Files) + } + if len(problems) != 1 || !strings.Contains(problems[0], "адресован повторно") { + t.Errorf("problems = %v", problems) + } +} + +// Элемент без номера и без пути адресует ничто — отбрасывается. +func TestParsePlan_NoIndexNoSrcDropped(t *testing.T) { + in := inputWith("a.mkv", "b.mkv") + raw := planWith( + `{"role":"episode","season":1,"episode":1}`, + `{"i":2,"role":"episode","season":1,"episode":2}`) + + p, problems, err := parsePlan(raw, in, testLogger()) + if err != nil { + t.Fatalf("parsePlan: %v", err) + } + if len(p.Files) != 1 || p.Files[0].Src != "b.mkv" { + t.Errorf("files = %+v", p.Files) + } + if len(problems) != 1 || !strings.Contains(problems[0], "без номера файла и без пути") { + t.Errorf("problems = %v", problems) + } +} + +// Запасной формат: путь вместо номера принимается при точном совпадении. +func TestParsePlan_SrcFallbackAccepted(t *testing.T) { + in := inputWith("a.mkv", "b.mkv") + raw := planWith(`{"src":"b.mkv","role":"episode","season":1,"episode":2}`) + + p, problems, err := parsePlan(raw, in, testLogger()) + if err != nil { + t.Fatalf("parsePlan: %v", err) + } + if len(problems) != 0 { + t.Errorf("запасной формат не должен давать претензий: %v", problems) + } + if p.Files[0].Src != "b.mkv" || idxOf(p.Files[0]) != 2 { + t.Errorf("file = %+v", p.Files[0]) + } +} + +// Номер и путь вместе: главенствует номер; расхождение — причина ревью. +func TestParsePlan_IndexWinsOverSrc(t *testing.T) { + in := inputWith("a.mkv", "b.mkv") + + t.Run("совпадают", func(t *testing.T) { + raw := planWith(`{"i":1,"src":"a.mkv","role":"episode","season":1,"episode":1}`) + p, problems, err := parsePlan(raw, in, testLogger()) + if err != nil { + t.Fatalf("parsePlan: %v", err) + } + if len(problems) != 0 || p.Files[0].Src != "a.mkv" { + t.Errorf("problems = %v, file = %+v", problems, p.Files[0]) + } + }) + + t.Run("расходятся", func(t *testing.T) { + raw := planWith(`{"i":1,"src":"b.mkv","role":"episode","season":1,"episode":1}`) + p, problems, err := parsePlan(raw, in, testLogger()) + if err != nil { + t.Fatalf("parsePlan: %v", err) + } + if p.Files[0].Src != "a.mkv" { + t.Errorf("src = %q, want резолв по номеру (a.mkv)", p.Files[0].Src) + } + if len(problems) != 1 || !strings.Contains(problems[0], "принят файл по номеру") { + t.Errorf("расхождение обязано стать причиной ревью: %v", problems) + } + }) +} + +// Не осталось ни одного годного элемента — план не разобран. +func TestParsePlan_AllElementsBadIsUnparsed(t *testing.T) { + in := inputWith("a.mkv") + raw := planWith( + `{"i":9,"role":"episode","season":1,"episode":1}`, + `{"src":"zzz.mkv","role":"episode","season":1,"episode":2}`) + + if _, problems, err := parsePlan(raw, in, testLogger()); err == nil { + t.Errorf("план без годных файлов обязан считаться неразобранным (problems=%v)", problems) + } +} + +// Резолв не паникует ни на одном входе и никогда не выпускает посторонний путь. +func TestParsePlan_ResolveNeverPanics(t *testing.T) { + in := inputWith("a.mkv", "b.mkv") + raws := []string{ + planWith(`{"i":0,"src":"a.mkv","role":"main"}`), + planWith(`{"i":-3,"role":"main"}`), + planWith(`{"i":3,"role":"main"}`), + planWith(`{"i":2.0,"role":"main"}`), + planWith(`{"i":1.7,"role":"main"}`), + planWith(`{"i":"2","role":"main"}`), + planWith(`{"i":"два","src":"b.mkv","role":"main"}`), + planWith(`{"i":null,"src":"a.mkv","role":"main"}`), + planWith(`{"i":99999999999999999999,"role":"main"}`), + `{"type":"movie","title":"X","files":[]}`, + planWith( + `{"i":1,"role":"main"}`, `{"i":2,"role":"extra"}`, + `{"i":3,"role":"extra"}`, `{"i":4,"role":"extra"}`), + } + known := map[string]bool{"a.mkv": true, "b.mkv": true} + for _, raw := range raws { + p, _, err := parsePlan(raw, in, testLogger()) + if err != nil { + continue // неразобранный план — законный исход, паники нет + } + for _, f := range p.Files { + if !known[f.Src] { + t.Fatalf("посторонний путь %q пролез в план из %s", f.Src, raw) + } + } + } +} + +// Порядок списка — свойство узла: перемешанный Input даёт ту же нумерацию. +func TestRecognize_NumberingIndependentOfCaller(t *testing.T) { + paths := []string{"s2/e01.mkv", "s1/e02.mkv", "s1/e01.mkv", "s2/e02.mkv"} + mk := func(order []int) Input { + in := Input{Name: "Show"} + for _, i := range order { + in.Files = append(in.Files, File{Path: paths[i], Size: 1 << 30}) + } + return in + } + resp := planWith(`{"i":3,"role":"episode","season":2,"episode":1}`) + + var prompts []string + var srcs []string + for _, order := range [][]int{{0, 1, 2, 3}, {3, 2, 1, 0}, {2, 0, 3, 1}} { + f := &fakeLLM{responses: []string{resp}} + r := New(f, nil, Config{}, testLogger()) + res, err := r.Recognize(context.Background(), mk(order)) + if err != nil { + t.Fatalf("Recognize: %v", err) + } + prompts = append(prompts, f.lastReq.Messages[1].Content) + srcs = append(srcs, res.Plan.Files[0].Src) + } + for i := 1; i < len(prompts); i++ { + if prompts[i] != prompts[0] { + t.Errorf("нумерация зависит от порядка Input:\n%s\n---\n%s", prompts[0], prompts[i]) + } + if srcs[i] != srcs[0] { + t.Errorf("резолв номера разъехался: %q != %q", srcs[i], srcs[0]) + } + } + if srcs[0] != "s2/e01.mkv" { + t.Errorf("номер 3 обязан резолвиться в третий по порядку файл, получено %q", srcs[0]) + } +} + +// Входной срез вызывающего распознавание не переупорядочивает. +func TestRecognize_DoesNotReorderCallerSlice(t *testing.T) { + in := Input{Name: "Show", Files: []File{ + {Path: "b.mkv", Size: 1}, {Path: "a.mkv", Size: 1}, + }} + f := &fakeLLM{responses: []string{planWith(`{"i":1,"role":"episode","season":1,"episode":1}`)}} + r := New(f, nil, Config{}, testLogger()) + if _, err := r.Recognize(context.Background(), in); err != nil { + t.Fatalf("Recognize: %v", err) + } + if in.Files[0].Path != "b.mkv" { + t.Errorf("срез вызывающего переупорядочен: %+v", in.Files) + } +} + +// Обрыв ответа по длине уводит в review и НЕ порождает повторных запросов. +func TestRecognize_TruncatedResponseNoRetry(t *testing.T) { + in := Input{Name: "Big", Files: []File{{Path: "a.mkv", Size: 1}}} + f := &truncatingLLM{} + r := New(f, nil, Config{MaxRetries: 3, MaxTokens: 8000}, testLogger()) + + res, err := r.Recognize(context.Background(), in) + if err != nil { + t.Fatalf("обрыв — не транспортная ошибка: %v", err) + } + if f.calls != 1 { + t.Errorf("calls = %d, want 1 (обрыв не повторяют)", f.calls) + } + if res.Decision.Auto || !hasReason(res.Decision.Reasons, "обрезан") { + t.Errorf("reasons = %v", res.Decision.Reasons) + } + if len(res.Files) != 1 { + t.Errorf("снимок списка файлов обязан быть и на этом исходе: %+v", res.Files) + } +} + +// truncatingLLM всегда отвечает обрывом генерации по длине. +type truncatingLLM struct{ calls int } + +func (f *truncatingLLM) Complete(_ context.Context, _ llm.Request) (llm.Response, error) { + f.calls++ + return llm.Response{Content: `{"type":"movie","title":"X","files":[{"i":1`, + FinishReason: llm.FinishLength}, nil +} + +// Отказ модели по размеру запроса называется своей причиной, а не текстом +// провайдера, и уводит в review вместо ошибки распознавания. +func TestRecognize_RequestTooLargeNamedReason(t *testing.T) { + in := Input{Name: "Big", Files: []File{{Path: "a.mkv", Size: 1}}} + f := &fakeLLM{errs: []error{errRequestTooLarge}} + r := New(f, nil, Config{MaxRetries: 3}, testLogger()) + + res, err := r.Recognize(context.Background(), in) + if err != nil { + t.Fatalf("отказ по размеру запроса — не ошибка распознавания: %v", err) + } + if f.calls != 1 { + t.Errorf("calls = %d, want 1", f.calls) + } + if !hasReason(res.Decision.Reasons, "по его размеру") { + t.Errorf("reasons = %v", res.Decision.Reasons) + } +} + +// Снятие лимита на список файлов не умножает число попыток: их потолок — +// [llm].max_retries, а вторая попытка сопоставима по размеру с первой. +func TestRecognize_RetryCostDoesNotGrowWithRelease(t *testing.T) { + files := make([]File, 300) + for i := range files { + files[i] = File{Path: "show/" + strconv.Itoa(1000+i) + ".mkv", Size: 1 << 30} + } + in := Input{Name: "Big.Pack", Files: files} + f := &fakeLLM{responses: []string{"мусор"}} + r := New(f, nil, Config{MaxRetries: 2, MaxFiles: 500}, testLogger()) + + if _, err := r.Recognize(context.Background(), in); err != nil { + t.Fatalf("Recognize: %v", err) + } + if f.calls != 3 { + t.Errorf("calls = %d, want 3 (1 + max_retries)", f.calls) + } + first := len(f.lastReq.Messages[1].Content) + total := 0 + for _, m := range f.lastReq.Messages[2:] { + total += len(m.Content) + } + // Три попытки на 300 файлов: дописанные сообщения не несут списка, поэтому + // их суммарный объём заведомо меньше одной его копии. + if total >= first { + t.Errorf("повторные попытки весят %d при списке в %d символов — список переприслан", + total, first) + } + for i, m := range f.lastReq.Messages { + if i > 1 && strings.Contains(m.Content, "show/1000.mkv") { + t.Errorf("сообщение %d повторно печатает список файлов", i) + } + } +} diff --git a/internal/recognize/prompt.go b/internal/recognize/prompt.go index 56ae046..555b430 100644 --- a/internal/recognize/prompt.go +++ b/internal/recognize/prompt.go @@ -36,14 +36,14 @@ const schemaText = `Схема ответа (строгий JSON, без markdow "provider_hint": "строка для поиска в базе (НЕ id)", "files": [ { - "src": "путь файла из списка ниже, БЕЗ размера в скобках в конце строки", + "i": номер файла из списка ниже (целое, ровно как напечатано), "role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore", "season": число или null, "episode": число или null } ], "confidence": число 0..1, - "notes": "пояснения и неоднозначности или пустая строка" + "notes": "неоднозначности, не больше пары предложений, или пустая строка" } Правила: @@ -53,10 +53,12 @@ const schemaText = `Схема ответа (строгий JSON, без markdow происхождения — продублируй "title" в "original_title". Если НЕ уверен в оригинальном названии — продублируй "title", но НЕ выдумывай название. - "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore". +- Поле "i" — номер файла из напечатанного ниже списка, ровно как он там стоит. + Путь файла копировать НЕ нужно: он у нас уже есть, мы подставим его сами. +- Один и тот же файл адресуй не больше одного раза. - Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode. - Для фильма ровно один основной видеофайл role "main". -- Поле src — это путь файла из списка, скопированный дословно, но БЕЗ размера - «(…)» в конце строки; не выдумывай и не нормализуй пути. +- Для ролей "sample", "ignore" и "extra" номера сезона и серии не нужны — оставляй null. - Внешние субтитры — role "subtitle".` const systemPromptBase = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента, @@ -89,15 +91,18 @@ func systemPrompt(lang string) string { return systemPromptBase + schemaText + languageDirective(lang) } -// buildMessages собирает системное и пользовательское сообщения. -func buildMessages(in Input, pre PreParse, maxFiles int, lang string) []llm.Message { +// buildMessages собирает системное и пользовательское сообщения. total — +// полное число файлов раздачи (in.Files уже упорядочен и, возможно, усечён +// пределом): список печатается ровно тем срезом, по которому потом резолвятся +// номера. +func buildMessages(in Input, pre PreParse, total int, lang string) []llm.Message { return []llm.Message{ {Role: llm.RoleSystem, Content: systemPrompt(lang)}, - {Role: llm.RoleUser, Content: userPrompt(in, pre, maxFiles)}, + {Role: llm.RoleUser, Content: userPrompt(in, pre, total)}, } } -func userPrompt(in Input, pre PreParse, maxFiles int) string { +func userPrompt(in Input, pre PreParse, total int) string { var b strings.Builder b.WriteString("Имя торрента: ") b.WriteString(orNone(in.Name)) @@ -122,7 +127,7 @@ func userPrompt(in Input, pre PreParse, maxFiles int) string { b.WriteString(preParseLine(pre)) b.WriteString("\n\n") - writeFileList(&b, in.Files, maxFiles) + writeFileList(&b, in.Files, total) return b.String() } @@ -149,19 +154,15 @@ func preParseLine(pre PreParse) string { return strings.Join(parts, ", ") } -// writeFileList печатает список файлов, усекая до maxFiles. src в плане -// должен дословно совпадать с путями отсюда. -func writeFileList(b *strings.Builder, files []File, maxFiles int) { - n := len(files) - shown := n - if maxFiles > 0 && shown > maxFiles { - shown = maxFiles - } - b.WriteString("Файлы (") - b.WriteString(strconv.Itoa(n)) - b.WriteString("). В src копируй ТОЛЬКО путь — текст после номера и до размера ") - b.WriteString("в скобках; размер «(…)» в конце строки в src НЕ включай:\n") - for i := 0; i < shown; i++ { +// writeFileList печатает нумерованный список файлов. Номер строки — то, что +// модель возвращает в поле "i"; усечение (если оно было) сделал вызывающий, +// total — полное число файлов раздачи. +func writeFileList(b *strings.Builder, files []File, total int) { + b.WriteString("Файлы раздачи (") + b.WriteString(strconv.Itoa(len(files))) + b.WriteString("). Номер в начале строки — это то, что нужно вернуть в поле \"i\";") + b.WriteString(" путь и размер копировать не нужно:\n") + for i := range files { b.WriteString(strconv.Itoa(i + 1)) b.WriteString(". ") b.WriteString(files[i].Path) @@ -169,22 +170,24 @@ func writeFileList(b *strings.Builder, files []File, maxFiles int) { b.WriteString(humanSize(files[i].Size)) b.WriteString(")\n") } - if shown < n { + if total > len(files) { b.WriteString("… и ещё ") - b.WriteString(strconv.Itoa(n - shown)) + b.WriteString(strconv.Itoa(total - len(files))) b.WriteString(" файлов (список усечён)\n") } } // correctionMessage — сообщение для повторной попытки: что было не так + схема. -func correctionMessage(err error, in Input, maxFiles int) string { +// Список файлов сюда НЕ входит: его номера названы в первом сообщении диалога +// и сохраняют смысл на всех попытках, а повторная печать умножала бы вход на +// число попыток — ровно ту цену, которую снимает индексная адресация. +func correctionMessage(err error) string { var b strings.Builder b.WriteString("Ответ не принят: ") b.WriteString(err.Error()) - b.WriteString("\nВерни ИСПРАВЛЕННЫЙ ответ строго по схеме, только JSON.\n\n") + b.WriteString("\nВерни ИСПРАВЛЕННЫЙ ответ строго по схеме, только JSON.") + b.WriteString(" Файлы адресуй номерами из списка, присланного выше.\n\n") b.WriteString(schemaText) - b.WriteString("\n\n") - writeFileList(&b, in.Files, maxFiles) return b.String() } diff --git a/internal/recognize/recognize.go b/internal/recognize/recognize.go index edb1578..aa173a7 100644 --- a/internal/recognize/recognize.go +++ b/internal/recognize/recognize.go @@ -11,15 +11,23 @@ // базой), согласованности с пред-парсом и уверенности не ниже порога. // // Без включённых баз (или без матча) авто-раскладка не делается — задача -// уходит в review. Выход LLM недоверенный: план принимается только если -// каждый files[].src совпадает с реальным файлом торрента; итоговая -// безопасность пути держится на раскладке (layout). +// уходит в review. Выход LLM недоверенный: файл адресуется НОМЕРОМ строки в +// напечатанном нами списке, а files[].src подставляет резолв номера по этому +// же списку — посторонний путь невыразим. Присланный моделью путь принимается +// лишь как запасной формат и только при точном совпадении с файлом торрента; +// итоговая безопасность пути держится на раскладке (layout). package recognize import ( "context" + "errors" "fmt" "log/slog" + "math" + "path/filepath" + "sort" + "strconv" + "strings" "git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/logctx" @@ -56,9 +64,29 @@ func (r FileRole) valid() bool { } // File — входной файл торрента (путь относительно save_path и размер). +// JSON-теги — для снимка списка файлов рядом с планом (store.Recognition, +// миграция 0012): раскладка обязана показывать и те файлы, которых нет в плане. type File struct { - Path string - Size int64 + Path string `json:"path"` + Size int64 `json:"size"` +} + +// videoExts — расширения, по которым файл считается видео (нижний регистр, с +// точкой). Единственное место перечня: на нём держится разделение покрытия +// плана — непокрытый видеофайл означает потерянную серию или фильм и блокирует +// авто-раскладку, непокрытый файл-спутник (субтитры, картинки, тексты) — нет. +// Незнакомое расширение видео попадёт в спутники, поэтому список пополняем +// здесь, а не по месту вызова. +var videoExts = map[string]bool{ + ".mkv": true, ".mp4": true, ".avi": true, ".m4v": true, ".mov": true, + ".wmv": true, ".mpg": true, ".mpeg": true, ".m2ts": true, ".mts": true, + ".ts": true, ".vob": true, ".flv": true, ".webm": true, ".ogm": true, + ".divx": true, ".rmvb": true, ".3gp": true, ".iso": true, ".img": true, +} + +// IsVideoFile — видео ли это по расширению пути. Регистр расширения не важен. +func IsVideoFile(path string) bool { + return videoExts[strings.ToLower(filepath.Ext(path))] } // Input — сигналы для распознавания одной раздачи. @@ -69,13 +97,49 @@ type Input struct { Hints []string // накопленные подсказки из review (Ф3; в Ф2 обычно пусто) } +// FileIndex — номер файла в списке, напечатанном в промпте (1-based). Модель +// иногда возвращает его строкой или дробным числом; разбор это терпит и +// сводит к целому, потому что негодный номер обязан отбраковывать элемент, а +// не весь план (см. validate.go). Неразбираемое значение даёт 0 — и элемент +// отбраковывается той же причиной, что и явно присланный ноль. +type FileIndex int + +// UnmarshalJSON разбирает номер терпимо: число, строка с числом, дробное с +// нулевой дробной частью, null. Ошибку не возвращает НИКОГДА — иначе один +// кривой элемент ронял бы разбор всего плана. +func (n *FileIndex) UnmarshalJSON(b []byte) error { + s := strings.Trim(strings.TrimSpace(string(b)), `"`) + if s == "" || s == "null" { + *n = 0 + return nil + } + if v, err := strconv.Atoi(s); err == nil { + *n = FileIndex(v) + return nil + } + if f, err := strconv.ParseFloat(s, 64); err == nil && f == math.Trunc(f) && + f > math.MinInt32 && f < math.MaxInt32 { + *n = FileIndex(int(f)) + return nil + } + *n = 0 + return nil +} + // PlanFile — файл в плане раскладки. Season/Episode заданы на файле, чтобы // выражать мультисезонные паки и спецвыпуски (см. recognition.md). type PlanFile struct { - Src string `json:"src"` - Role FileRole `json:"role"` - Season *int `json:"season,omitempty"` - Episode *int `json:"episode,omitempty"` + // Index — чем файл адресован в ответе модели: номер строки нашего списка. + // Src заполняет резолвом сама система, поэтому посторонний путь невыразим. + // Указатель, а не значение: «поле не прислано» и «прислан 0» — разные + // диагнозы. Ноль означает модель, посчитавшую список с нуля, и тогда все + // остальные её номера резолвятся со сдвигом на файл; отсутствие поля — + // запасной формат с путём либо элемент, не адресующий ничего. + Index *FileIndex `json:"i,omitempty"` + Src string `json:"src"` + Role FileRole `json:"role"` + Season *int `json:"season,omitempty"` + Episode *int `json:"episode,omitempty"` } // Plan — структурированный результат распознавания (схема ответа LLM). @@ -129,6 +193,13 @@ type Result struct { Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи) Raw string // сырой ответ LLM последней попытки + // Files — ПОЛНЫЙ список файлов раздачи в том порядке, в каком печатается + // промпт (и в каком резолвятся номера). Снимок момента распознавания: его + // сохраняют рядом с планом, чтобы раскладка показывала и файлы вне плана. + // Усечение пределом max_files сюда не распространяется — иначе снимок выдал + // бы показанный модели срез за весь перечень раздачи. Заполнен на всех + // исходах, включая review без разобранного плана. + Files []File } // LLM — нужная recognize часть провайдера. @@ -148,8 +219,12 @@ type Config struct { } const ( - defaultMaxTokens = 4000 - defaultMaxFiles = 100 + // Дефолты — предохранители для прямых вызовов конструктора (тесты, CLI без + // конфига). Канонические значения задаёт [recognition] (config.Default); + // равенство им сторожит TestDefaultsAgreeWithConfig — иначе прод и тест + // разъедутся молча. + defaultMaxTokens = 8000 + defaultMaxFiles = 500 defaultAutoThreshold = 0.85 defaultLanguage = "en" ) @@ -210,7 +285,26 @@ func New(provider LLM, providers []metadata.Provider, cfg Config, log *slog.Logg func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) { log := logctx.FromOr(ctx, r.log) pre := preParse(in.Name) - msgs := buildMessages(in, pre, r.maxFiles, r.language) + + // Порядок списка — свойство узла, а не дисциплина вызывающего (сборок + // Input две: воркер и CLI). Печать промпта и резолв номеров идут дальше по + // одному и тому же срезу, поэтому повторное распознавание той же раздачи + // даёт ту же нумерацию. + // + // Усечение пределом max_files — свойство ПРОМПТА и резолва номеров, а не + // снимка: снимок хранит полный список раздачи, иначе виджет раскладки выдаёт + // показанный модели срез за весь перечень («в план попало 100 из 100», когда + // в торренте 250). Показанный список — префикс полного, поэтому номера + // адресации от этого не меняются. + all := sortedFiles(in.Files) + shown := all + if r.maxFiles > 0 && len(shown) > r.maxFiles { + shown = all[:r.maxFiles] + } + in.Files = shown + issues := planIssues{files: shown, all: all} + + msgs := buildMessages(in, pre, len(all), r.language) temp := 0.0 var raw string @@ -227,32 +321,54 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) { MaxTokens: r.maxTokens, }) if err != nil { - return Result{}, fmt.Errorf("recognize: llm complete: %w", err) + // Отказ по размеру запроса называем своей причиной: текст провайдера + // человеку в ревью ничего не говорит, а лечится это [recognition].max_files. + if errors.Is(err, llm.ErrRequestTooLarge) { + log.Warn("recognition request rejected as too large", + "source_files", len(in.Files), "max_files", r.maxFiles, + "error", err) + return reviewResult(pre, issues, attempts, "", []string{fmt.Sprintf( + "модель отвергла запрос по его размеру: файлов в списке %d"+ + " (уменьшите [recognition].max_files)", len(in.Files))}), nil + } + return Result{Files: all}, fmt.Errorf("recognize: llm complete: %w", err) } raw = resp.Content - plan, parseErr = parsePlan(raw, in, log) + // Обрыв генерации по длине — не ошибка модели: ответ не поместился. + // Повтор тем же промптом (и его вариантом) дал бы тот же обрыв. + if resp.FinishReason == llm.FinishLength { + log.Warn("recognition llm response truncated", + "attempt", attempts, "max_tokens", r.maxTokens) + return reviewResult(pre, issues, attempts, raw, []string{fmt.Sprintf( + "ответ модели обрезан по пределу длины (max_tokens = %d):"+ + " план неполон, повтор тем же запросом не поможет", r.maxTokens)}), nil + } + + plan, issues.dropped, parseErr = parsePlan(raw, in, log) if parseErr == nil { break } log.Warn("recognition llm response unparsed", "attempt", attempts, "error", parseErr) - // Просим модель исправиться, повторяя схему и ошибку. + // Просим модель исправиться, повторяя схему и ошибку. Список файлов + // НЕ переприсылаем: его номера названы в первом сообщении диалога и + // сохраняют смысл на всех попытках (иначе цена неудачи росла бы вместе + // с размером раздачи). msgs = append(msgs, llm.Message{Role: llm.RoleAssistant, Content: raw}, - llm.Message{Role: llm.RoleUser, Content: correctionMessage(parseErr, in, r.maxFiles)}) + llm.Message{Role: llm.RoleUser, Content: correctionMessage(parseErr)}) } if parseErr != nil { - return Result{ - PreParse: pre, - Attempts: attempts, - Raw: raw, - Decision: Decision{ - Auto: false, - Reasons: []string{"ответ LLM не разобран после " + itoa(attempts) + " попыток: " + parseErr.Error()}, - }, - }, nil + // Претензии последней попытки — единственное поэлементное объяснение + // («номер 181 вне диапазона 1..2»); без них человек читает голое «ответ + // LLM не разобран». decide на успешной ветке добавляет их так же. + reasons := []string{ + "ответ LLM не разобран после " + itoa(attempts) + " попыток: " + parseErr.Error(), + } + reasons = append(reasons, issues.dropped...) + return reviewResult(pre, issues, attempts, raw, reasons), nil } // Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год @@ -275,10 +391,11 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) { } } - dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold) + dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold, issues) log.Info("recognition done", "media_type", plan.Type, "title", plan.Title, "year", plan.Year, - "files", len(plan.Files), "attempts", attempts, + "files", len(plan.Files), "source_files", len(in.Files), + "dropped", len(issues.dropped), "attempts", attempts, "matched", match != nil, "candidates", len(candidates), "auto", dec.Auto, "reasons", len(dec.Reasons)) return Result{ @@ -289,5 +406,32 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) { Candidates: candidates, Attempts: attempts, Raw: raw, + Files: all, }, nil } + +// reviewResult — исход без пригодного плана: задача уходит в review с +// названными причинами. Снимок списка файлов отдаём и здесь: он нужен +// раскладке и повторному распознаванию из ревью. +func reviewResult(pre PreParse, issues planIssues, attempts int, raw string, reasons []string) Result { + if issues.truncated() > 0 { + reasons = append(reasons, truncationReason(issues)) + } + return Result{ + PreParse: pre, + Attempts: attempts, + Raw: raw, + Files: issues.all, + Decision: Decision{Auto: false, Reasons: reasons}, + } +} + +// sortedFiles — детерминированный порядок списка файлов (по пути). Возвращает +// НОВЫЙ срез: входной принадлежит вызывающему, менять его порядок нельзя. +// Путь внутри торрента уникален, поэтому порядок воспроизводим между вызовами. +func sortedFiles(files []File) []File { + out := make([]File, len(files)) + copy(out, files) + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} diff --git a/internal/recognize/recognize_test.go b/internal/recognize/recognize_test.go index 934b883..f60e797 100644 --- a/internal/recognize/recognize_test.go +++ b/internal/recognize/recognize_test.go @@ -3,10 +3,12 @@ package recognize import ( "context" "errors" + "fmt" "log/slog" "strings" "testing" + "git.vakhrushev.me/av/jellybit/internal/config" "git.vakhrushev.me/av/jellybit/internal/llm" ) @@ -35,6 +37,13 @@ func (f *fakeLLM) Complete(_ context.Context, req llm.Request) (llm.Response, er return llm.Response{Content: content}, nil } +// errRequestTooLarge — типовой отказ OpenAI-совместимого шлюза по размеру +// запроса, как он приходит из internal/llm: текст провайдера плюс sentinel, +// которым транспорт пометил отказ (признак ставится там, где ещё целы статус и +// полное тело, а recognize ветвится по errors.Is, а не по тексту). +var errRequestTooLarge = fmt.Errorf("%w: %w", llm.ErrRequestTooLarge, errors.New( + "llm: status 400: {\"error\":{\"message\":\"This model's maximum context length is 128000 tokens\"}}")) + func testLogger() *slog.Logger { return slog.New(slog.DiscardHandler) } @@ -145,11 +154,15 @@ func TestRecognize_RetriesOnBadSrcThenSucceeds(t *testing.T) { if res.Plan.Title != "Some Movie" { t.Errorf("plan = %+v", res.Plan) } - // Корректирующее сообщение должно содержать схему и список файлов. + // Корректирующее сообщение несёт ошибку и схему, но НЕ список файлов: + // номера названы в первом сообщении диалога. last := f.lastReq.Messages[len(f.lastReq.Messages)-1] - if !strings.Contains(last.Content, "Ответ не принят") || !strings.Contains(last.Content, "film.mkv") { + if !strings.Contains(last.Content, "Ответ не принят") || !strings.Contains(last.Content, `"i"`) { t.Errorf("correction message missing context: %q", last.Content) } + if strings.Contains(last.Content, "film.mkv") { + t.Errorf("correction message must not repeat the file list: %q", last.Content) + } } func TestRecognize_ExhaustedRetriesGoesToReview(t *testing.T) { @@ -222,29 +235,129 @@ func TestRecognize_PromptCarriesSignals(t *testing.T) { func TestRecognize_FileListTruncated(t *testing.T) { files := make([]File, 250) - planFiles := make([]string, 0, 250) for i := range files { files[i] = File{Path: pathOf(i), Size: 100 << 20} } - // План ссылается только на первый файл — этого достаточно для схемы. - _ = planFiles in := Input{Name: "Big.Pack", Files: files} - resp := `{"type":"series","title":"Big","files":[{"src":"` + pathOf(0) + - `","role":"episode","season":1,"episode":1}]}` + // План ссылается только на первый по порядку файл — этого достаточно схеме. + first := sortedFiles(files)[0].Path + resp := `{"type":"series","title":"Big","files":[{"i":1` + + `,"role":"episode","season":1,"episode":1}]}` f := &fakeLLM{responses: []string{resp}} r := New(f, nil, Config{MaxFiles: 100}, testLogger()) - if _, err := r.Recognize(context.Background(), in); err != nil { + res, err := r.Recognize(context.Background(), in) + if err != nil { t.Fatalf("Recognize: %v", err) } user := f.lastReq.Messages[1].Content - if !strings.Contains(user, "усечён") { - t.Errorf("expected truncation note in prompt") + if !strings.Contains(user, "усечён") || !strings.Contains(user, "и ещё 150") { + t.Errorf("expected truncation note in prompt:\n%s", user) } - if !strings.Contains(user, "Файлы (250") { - t.Errorf("expected total count 250 in prompt") + if !strings.Contains(user, "Файлы раздачи (100)") { + t.Errorf("expected shown count 100 in prompt") + } + // Усечение — отдельная причина ухода в review, а знаменатель покрытия — + // полное число файлов раздачи, а не усечённого списка. + if !hasReason(res.Decision.Reasons, "усечён пределом") || + !hasReason(res.Decision.Reasons, "показано 100 из 250") { + t.Errorf("reasons = %v", res.Decision.Reasons) + } + if !hasReason(res.Decision.Reasons, "в план попало 1 файлов из 250") { + t.Errorf("coverage denominator must be the full file count: %v", res.Decision.Reasons) + } + if res.Plan.Files[0].Src != first { + t.Errorf("src = %q, want %q", res.Plan.Files[0].Src, first) + } + // Снимок хранит ПОЛНЫЙ список раздачи: усечение — свойство промпта, а не + // перечня, иначе виджет раскладки выдаст показанный модели срез за всю + // раздачу. Показанный список — префикс полного, номера адресации те же. + if len(res.Files) != 250 { + t.Errorf("snapshot = %d files, want полный список из 250", len(res.Files)) + } + if res.Files[0].Path != first { + t.Errorf("снимок обязан идти в порядке нумерации промпта: %q", res.Files[0].Path) } } func pathOf(i int) string { return "show/ep" + itoa(i) + ".mkv" } + +// Список урезан пределом И пригодного плана модель не дала: причина усечения +// обязана быть названа наравне с причиной неразобранного ответа, а поэлементные +// претензии последней попытки — не потеряться. Без них человек читает голое +// «ответ LLM не разобран» и не узнаёт ни про усечение, ни про то, чем именно +// ответ негоден. +func TestRecognize_TruncatedListAndUnusablePlan(t *testing.T) { + files := make([]File, 250) + for i := range files { + files[i] = File{Path: pathOf(i), Size: 100 << 20} + } + in := Input{Name: "Big.Pack", Files: files} + // Единственный элемент адресует файл вне показанного списка — годных не + // осталось, план считается неразобранным. + resp := `{"type":"series","title":"Big","files":[` + + `{"i":900,"role":"episode","season":1,"episode":1}]}` + f := &fakeLLM{responses: []string{resp}} + r := New(f, nil, Config{MaxRetries: 1, MaxFiles: 100}, testLogger()) + + res, err := r.Recognize(context.Background(), in) + if err != nil { + t.Fatalf("неразобранный ответ — не ошибка распознавания: %v", err) + } + if res.Decision.Auto { + t.Error("плана нет — авто недопустимо") + } + if !hasReason(res.Decision.Reasons, "не разобран") { + t.Errorf("reasons = %v", res.Decision.Reasons) + } + if !hasReason(res.Decision.Reasons, "усечён пределом") || + !hasReason(res.Decision.Reasons, "показано 100 из 250") { + t.Errorf("усечение обязано быть названо и на этом исходе: %v", res.Decision.Reasons) + } + if !hasReason(res.Decision.Reasons, "вне диапазона 1..100") { + t.Errorf("претензии последней попытки потеряны: %v", res.Decision.Reasons) + } + if len(res.Files) != 250 { + t.Errorf("снимок = %d файлов, ожидался полный список раздачи", len(res.Files)) + } +} + +// Отказ по размеру запроса на усечённом списке тоже называет усечение. +func TestRecognize_TruncatedListAndRequestTooLarge(t *testing.T) { + files := make([]File, 250) + for i := range files { + files[i] = File{Path: pathOf(i), Size: 100 << 20} + } + in := Input{Name: "Big.Pack", Files: files} + f := &fakeLLM{errs: []error{errRequestTooLarge}} + r := New(f, nil, Config{MaxRetries: 2, MaxFiles: 100}, testLogger()) + + res, err := r.Recognize(context.Background(), in) + if err != nil { + t.Fatalf("отказ по размеру — не ошибка распознавания: %v", err) + } + if !hasReason(res.Decision.Reasons, "по его размеру") || + !hasReason(res.Decision.Reasons, "усечён пределом") { + t.Errorf("reasons = %v", res.Decision.Reasons) + } +} + +// Дефолты распознавания живут в двух местах: канонические — в [recognition] +// (config.Default), предохранители прямых вызовов конструктора — здесь. +// Согласие держалось комментарием; теперь его держит этот страж. +func TestDefaultsAgreeWithConfig(t *testing.T) { + rec := config.Default().Recognition + if rec.MaxFiles != defaultMaxFiles { + t.Errorf("[recognition].max_files = %d, recognize.defaultMaxFiles = %d", + rec.MaxFiles, defaultMaxFiles) + } + if rec.MaxTokens != defaultMaxTokens { + t.Errorf("[recognition].max_tokens = %d, recognize.defaultMaxTokens = %d", + rec.MaxTokens, defaultMaxTokens) + } + if rec.AutoConfidenceThreshold != defaultAutoThreshold { + t.Errorf("[recognition].auto_confidence_threshold = %v, recognize.defaultAutoThreshold = %v", + rec.AutoConfidenceThreshold, defaultAutoThreshold) + } +} diff --git a/internal/recognize/validate.go b/internal/recognize/validate.go index 3ca854a..318187b 100644 --- a/internal/recognize/validate.go +++ b/internal/recognize/validate.go @@ -5,22 +5,25 @@ import ( "fmt" "log/slog" "sort" + "strconv" "strings" "git.vakhrushev.me/av/jellybit/internal/llm" ) // parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему. -// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные -// предупреждения (см. decide) ошибкой не считаются — они уводят в review. +// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Второй +// результат — претензии к отдельным элементам files[]: они НЕ ошибка разбора +// (повторный запрос к модели не делается), а причины ухода в review. +// Структурные предупреждения (см. decide) ошибкой тоже не считаются. // // Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см. // sanitizePlan) ДО структурной валидации: так, например, title из одних // zero-width символов схлопывается в пустой и корректно уводит в ретрай. -func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) { +func parsePlan(raw string, in Input, log *slog.Logger) (Plan, []string, error) { jsonStr, err := llm.ExtractJSONObject(raw) if err != nil { - return Plan{}, fmt.Errorf("no JSON object in response") + return Plan{}, nil, fmt.Errorf("no JSON object in response") } var p Plan @@ -30,57 +33,212 @@ func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) { // Повторяем без строгого режима: лишние поля — не повод падать, // но если и так не разобралось — это ошибка схемы. if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil { - return Plan{}, fmt.Errorf("JSON not parsed: %w", err2) + return Plan{}, nil, fmt.Errorf("JSON not parsed: %w", err2) } } sanitizePlan(&p, log) - if err := validateSchema(&p, in); err != nil { - return Plan{}, err + problems, err := validateSchema(&p, in) + if err != nil { + return Plan{}, problems, err } - return p, nil + return p, problems, nil } -// validateSchema проверяет обязательную структуру плана. Главный инвариант -// безопасности: каждый files[].src совпадает с реальным файлом торрента — -// недоверенный выход LLM не может сослаться на посторонний путь. -func validateSchema(p *Plan, in Input) error { +// validateSchema проверяет обязательную структуру плана и резолвит адресацию +// файлов. Главный инвариант безопасности: files[].src заполняем МЫ — из своего +// же списка, по номеру строки (i), напечатанному в промпте. Присланный моделью +// путь принимается лишь как запасной формат и только при точном совпадении с +// файлом торрента, поэтому сослаться на посторонний путь невозможно. +// +// Негодный элемент (номер вне диапазона, повторная адресация, ни номера ни +// пути, неизвестный путь) отбрасывается поимённой претензией — план при этом +// живёт: одна ошибка в одном из сотен элементов не должна ронять всю работу. +// Ошибка возвращается только когда плана не осталось вовсе. +func validateSchema(p *Plan, in Input) ([]string, error) { switch p.Type { case MediaMovie, MediaSeries: case "": - return fmt.Errorf("field type is empty (expected movie or series)") + return nil, fmt.Errorf("field type is empty (expected movie or series)") default: - return fmt.Errorf("unknown type %q", p.Type) + return nil, fmt.Errorf("unknown type %q", p.Type) } if strings.TrimSpace(p.Title) == "" { - return fmt.Errorf("field title is empty") + return nil, fmt.Errorf("field title is empty") } if len(p.Files) == 0 { - return fmt.Errorf("files list is empty") + return nil, fmt.Errorf("files list is empty") } - known := make(map[string]bool, len(in.Files)) - for _, f := range in.Files { - known[f.Path] = true + byPath := make(map[string]int, len(in.Files)) + for i, f := range in.Files { + byPath[f.Path] = i } + var problems problemList + taken := make(map[int]bool, len(p.Files)) + kept := p.Files[:0] for i := range p.Files { - pf := &p.Files[i] + pf := p.Files[i] + // Роль вне перечня — ошибка схемы, а не адресации: модель ошиблась в + // нашем же словаре, и это чинится повторной попыткой. Проверка стоит ДО + // резолва, поэтому pf.Src ещё пуст: адресуем элемент так, как его назвала + // сама модель, иначе ей (и человеку) нечего чинить в списке на 180 строк. if !pf.Role.valid() { - return fmt.Errorf("file %q: unknown role %q", pf.Src, pf.Role) + return problems.result(), fmt.Errorf("file %s: unknown role %q", + planFileRef(pf), shorten(string(pf.Role))) } - if strings.TrimSpace(pf.Src) == "" { - return fmt.Errorf("file with empty src") + idx, problem, ok := resolveFile(pf, in.Files, byPath) + if problem != "" { + problems.add(problem) } - if !known[pf.Src] { - return fmt.Errorf("src %q not found among torrent files", pf.Src) + if !ok { + continue + } + if taken[idx] { + problems.add(fmt.Sprintf( + "файл %d (%s) адресован повторно — лишний элемент плана отброшен", + idx+1, shorten(in.Files[idx].Path))) + continue } if pf.Role == RoleEpisode && pf.Episode == nil { - return fmt.Errorf("episode %q has no episode number", pf.Src) + return problems.result(), fmt.Errorf("episode %q has no episode number", + shorten(in.Files[idx].Path)) } + taken[idx] = true + n := FileIndex(idx + 1) + pf.Index = &n + pf.Src = in.Files[idx].Path + kept = append(kept, pf) } - return nil + p.Files = kept + if len(p.Files) == 0 { + return problems.result(), fmt.Errorf("no files[] element addresses a real torrent file") + } + return problems.result(), nil +} + +// maxProblems — сколько поимённых претензий к элементам плана попадает в +// причины; остальные сворачиваются в счётчик. Причины уезжают в баннер ревью, +// в карточку Telegram и в БД навсегда, а на раздаче из 180 файлов негодным +// может оказаться каждый элемент. +const maxProblems = 12 + +// problemList копит претензии к элементам плана, не давая им расти без предела. +type problemList struct { + items []string + total int +} + +func (l *problemList) add(s string) { + l.total++ + if len(l.items) < maxProblems { + l.items = append(l.items, s) + } +} + +// result — накопленное плюс свёрнутый хвост. +func (l *problemList) result() []string { + if l.total <= len(l.items) { + return l.items + } + return append(l.items, fmt.Sprintf("и ещё %d претензий к элементам плана", + l.total-len(l.items))) +} + +// planFileRef — как элемент плана адресован МОДЕЛЬЮ: номером нашего списка, +// иначе присланным путём. Нужен в сообщениях, которые строятся до резолва. +func planFileRef(pf PlanFile) string { + if pf.Index != nil { + return fmt.Sprintf("#%d", int(*pf.Index)) + } + if src := strings.TrimSpace(pf.Src); src != "" { + return strconv.Quote(shorten(src)) + } + return "без номера и пути" +} + +// reasonValueRunes — сколько рун внешнего значения показывать в причине. +const reasonValueRunes = 40 + +// shorten оставляет от внешнего значения начало и конец, выкидывая середину. +// Значения (пути, роли) приходят из недоверенного ответа модели и по длине не +// ограничены, а причина оседает в БД навсегда и занимает экран в карточке +// Telegram (docs/conventions/errors.md). Режем по рунам: обрыв посреди +// многобайтовой буквы дал бы мусор; середину — потому что у пути информативны +// оба края. +func shorten(s string) string { + r := []rune(s) + if len(r) <= reasonValueRunes { + return s + } + head := reasonValueRunes / 2 + return string(r[:head]) + "…" + string(r[len(r)-head:]) +} + +// resolveFile определяет, какой файл раздачи адресует элемент плана. Главенствует +// номер: он назван нами, и расхождение с присланным путём — сигнал сдвига +// адресации, а не повод молча затереть путь. Возвращает индекс в списке, текст +// претензии (пусто — претензий нет) и годность элемента. +func resolveFile(pf PlanFile, files []File, byPath map[string]int) (int, string, bool) { + src := strings.TrimSpace(pf.Src) + if pf.Index != nil && int(*pf.Index) != 0 { + n := int(*pf.Index) + if n < 1 || n > len(files) { + return 0, fmt.Sprintf( + "элемент плана адресует файл номером %d вне диапазона 1..%d — отброшен", + n, len(files)), false + } + idx := n - 1 + if src != "" && src != files[idx].Path { + // Модель назвала обе стороны и они разошлись — бесплатная сверка + // против сдвига нумерации. Берём номер, но сообщаем человеку. + return idx, fmt.Sprintf( + "элемент плана: номер %d указывает на %q, а присланный путь — %q;"+ + " принят файл по номеру", n, shorten(files[idx].Path), shorten(src)), true + } + return idx, "", true + } + if src != "" { + // Запасной формат: модель прислала путь вместо номера. Принимаем только + // точное совпадение с реальным файлом раздачи. + idx, found := byPath[src] + if !found { + return 0, fmt.Sprintf("путь %q не найден среди файлов раздачи — элемент плана отброшен", + shorten(src)), false + } + return idx, "", true + } + if pf.Index != nil { + // Явный ноль. Модель посчитала список с нуля: этот элемент не адресует + // ничего, а все её остальные номера резолвятся со сдвигом на файл. + // Называем причину так, чтобы сдвиг был самоописывающимся. + return 0, "элемент плана адресует файл номером 0 — список нумеруется с 1," + + " элемент отброшен (остальные номера могли уехать на файл)", false + } + return 0, "элемент плана без номера файла и без пути — отброшен", false +} + +// planIssues — что вскрылось до модели уверенности: претензии разбора, +// усечение списка файлов пределом и сам список (для сводки покрытия). +type planIssues struct { + dropped []string // претензии к элементам files[] (см. validateSchema) + files []File // список, показанный модели (префикс all, в порядке нумерации) + // all — ПОЛНЫЙ упорядоченный список файлов раздачи: знаменатель покрытия и + // снимок, уходящий в Result. Усечение пределом max_files — свойство промпта + // и резолва, а не снимка (см. Recognize). + all []File +} + +// truncated — сколько файлов раздачи не показано модели (0 — список полный). +func (is planIssues) truncated() int { return len(is.all) - len(is.files) } + +// truncationReason — усечение списка файлов пределом max_files. +func truncationReason(is planIssues) string { + return fmt.Sprintf("список файлов усечён пределом [recognition].max_files:"+ + " модели показано %d из %d — остальные распознаны быть не могли", + len(is.files), len(is.all)) } // decide считает решение модели уверенности (см. recognition.md). Авто — @@ -88,14 +246,38 @@ func validateSchema(p *Plan, in Input) error { // название матча пригодно как имя каталога; чистая структурная валидация (для // сериала — число серий бьётся с базой); согласованность с пред-парсом; // самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review. -func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision { +// +// Auto считается по числу БЛОКИРУЮЩИХ причин, а не по длине Reasons: сводка +// покрытия плана показывается человеку всегда, когда покрыты не все файлы, но +// сама по себе авто-раскладку не отменяет — модель вправе не перечислять +// .nfo и скриншоты. Блокирует только непокрытый видеофайл. +func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64, issues planIssues) Decision { var reasons []string + blocking := 0 + // add — причина, отменяющая авто; note — то же для глаз человека, без влияния + // на решение. + add := func(s string) { reasons = append(reasons, s); blocking++ } + note := func(s string) { reasons = append(reasons, s) } switch { case !metadataEnabled: - reasons = append(reasons, "метабазы отключены → авто-раскладка недоступна") + add("метабазы отключены → авто-раскладка недоступна") case match == nil: - reasons = append(reasons, "не найдено в базе или несколько кандидатов") + add("не найдено в базе или несколько кандидатов") + } + + for _, d := range issues.dropped { + add(d) + } + if issues.truncated() > 0 { + add(truncationReason(issues)) + } + for _, c := range coverageReasons(p, issues) { + if c.blocks { + add(c.text) + } else { + note(c.text) + } } // Каноническое название базы, непригодное как имя каталога (пустое или без @@ -105,23 +287,77 @@ func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold // (buildMatch), второй раз не чистим: два независимых пересчёта одного // условия разъедутся на первой же правке одного из них. if match != nil && !UsableTitle(match.Title) { - reasons = append(reasons, "название из базы непригодно как имя каталога") + add("название из базы непригодно как имя каталога") } - reasons = append(reasons, structuralWarnings(p)...) + for _, w := range structuralWarnings(p) { + add(w) + } if match != nil && p.Type == MediaSeries { - reasons = append(reasons, episodeCountWarnings(p, match.SeasonEpisodeCounts)...) + for _, w := range episodeCountWarnings(p, match.SeasonEpisodeCounts) { + add(w) + } } - reasons = append(reasons, consistencyWarnings(p, pre)...) + for _, w := range consistencyWarnings(p, pre) { + add(w) + } if p.Confidence < threshold { - reasons = append(reasons, - fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold)) + add(fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold)) } - return Decision{Auto: len(reasons) == 0, Reasons: reasons} + return Decision{Auto: blocking == 0, Reasons: reasons} +} + +// coverageEntry — строка сводки покрытия и её вес в решении auto/review. +type coverageEntry struct { + text string + blocks bool +} + +// coverageReasons — сводка «в плане N из M» и отдельная причина о непокрытых +// видеофайлах. Знаменатель — ПОЛНОЕ число файлов раздачи, а не усечённого +// списка: иначе усечение пределом молча улучшало бы покрытие. +func coverageReasons(p Plan, issues planIssues) []coverageEntry { + if len(issues.all) == 0 { + return nil + } + planned := make(map[string]bool, len(p.Files)) + for _, f := range p.Files { + planned[f.Src] = true + } + var videos []string + for _, f := range issues.files { + if !planned[f.Path] && IsVideoFile(f.Path) { + videos = append(videos, f.Path) + } + } + var out []coverageEntry + if len(planned) < len(issues.all) { + out = append(out, coverageEntry{text: fmt.Sprintf( + "в план попало %d файлов из %d", len(planned), len(issues.all))}) + } + if len(videos) > 0 { + out = append(out, coverageEntry{blocks: true, text: fmt.Sprintf( + "вне плана осталось видеофайлов: %d (%s)", len(videos), listSome(videos, 3))}) + } + return out +} + +// listSome перечисляет не больше max элементов, сворачивая хвост в «и ещё N». +// Каждое значение внешнее (путь из ответа модели) и потому усекается. +func listSome(items []string, max int) string { + out := make([]string, 0, min(len(items), max)) + for _, it := range items[:min(len(items), max)] { + out = append(out, shorten(it)) + } + s := strings.Join(out, ", ") + if len(items) > max { + s += ", и ещё " + strconv.Itoa(len(items)-max) + } + return s } // episodeCountWarnings сверяет число распознанных серий по сезонам с базой. @@ -181,13 +417,12 @@ func structuralWarnings(p Plan) []string { return w } -// seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам. +// seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам. На +// сезон приходится не больше одной причины каждого рода: на паке из восьми +// сезонов строка за каждый разрыв давала сорок строк, которые человек в ревью +// уже не читает. func seriesWarnings(files []PlanFile) []string { - type key struct{ s, e int } - seen := map[key]int{} - bySeason := map[int][]int{} - var w []string - + seen := map[int]map[int]int{} for _, f := range files { if f.Role != RoleEpisode || f.Episode == nil { continue @@ -196,27 +431,86 @@ func seriesWarnings(files []PlanFile) []string { if f.Season != nil { season = *f.Season } - k := key{season, *f.Episode} - seen[k]++ - if seen[k] == 2 { - w = append(w, fmt.Sprintf("сериал: дубль серии S%02dE%02d", season, *f.Episode)) + if seen[season] == nil { + seen[season] = map[int]int{} } - bySeason[season] = append(bySeason[season], *f.Episode) + seen[season][*f.Episode]++ } - for _, season := range sortedKeys(bySeason) { - eps := bySeason[season] - sort.Ints(eps) - for i := 1; i < len(eps); i++ { - if eps[i] > eps[i-1]+1 { - w = append(w, fmt.Sprintf("сериал: пропуск серий в сезоне %d между E%02d и E%02d", - season, eps[i-1], eps[i])) + var w []string + for _, season := range sortedKeys(toSlices2(seen)) { + eps := seen[season] + nums := make([]int, 0, len(eps)) + for e := range eps { + nums = append(nums, e) + } + sort.Ints(nums) + + var dups []int + for _, e := range nums { + if eps[e] > 1 { + dups = append(dups, e) } } + if len(dups) > 0 { + w = append(w, fmt.Sprintf("сериал: сезон %d — дубли серий %s", + season, episodeList(dups, len(dups)))) + } + + // Пропуски НЕ материализуем: номера серий приходят из недоверенного + // ответа модели и не клампятся, а датовая нумерация (обычная для + // ежедневных шоу: "episode": 20260902) рядом с единицей дала бы ~20 млн + // int на одном распознавании. Копим только то, что будет напечатано, + // остальное — счётчиком. + var missing []int + missingTotal := 0 + for i := 1; i < len(nums); i++ { + gap := nums[i] - nums[i-1] - 1 + if gap <= 0 { + continue // соседние номера (или переполнение на абсурдных значениях) + } + missingTotal += gap + for e := nums[i-1] + 1; e < nums[i] && len(missing) < episodeListMax; e++ { + missing = append(missing, e) + } + } + if missingTotal > 0 { + w = append(w, fmt.Sprintf("сериал: сезон %d — не хватает серий %s", + season, episodeList(missing, missingTotal))) + } } return w } +// episodeListMax — сколько номеров серий печатается в причине; хвост +// сворачивается в «и ещё N». Предел печати задаёт и предел накопления у +// вызывающего (см. seriesWarnings). +const episodeListMax = 12 + +// episodeList печатает номера серий как E05, E07, … сворачивая длинный хвост. +// total — сколько номеров всего: он может превышать len(eps), потому что +// вызывающий вправе накопить лишь то, что будет напечатано. +func episodeList(eps []int, total int) string { + out := make([]string, 0, min(len(eps), episodeListMax)) + for _, e := range eps[:min(len(eps), episodeListMax)] { + out = append(out, fmt.Sprintf("E%02d", e)) + } + s := strings.Join(out, ", ") + if total > len(out) { + s += ", и ещё " + strconv.Itoa(total-len(out)) + } + return s +} + +// toSlices2 — ключи map[int]map[int]int для sortedKeys. +func toSlices2(m map[int]map[int]int) map[int][]int { + out := make(map[int][]int, len(m)) + for k := range m { + out[k] = nil + } + return out +} + // consistencyWarnings — расхождения LLM с черновым пред-парсом. func consistencyWarnings(p Plan, pre PreParse) []string { var w []string diff --git a/internal/recognize/validate_test.go b/internal/recognize/validate_test.go index 44f2232..917c8b4 100644 --- a/internal/recognize/validate_test.go +++ b/internal/recognize/validate_test.go @@ -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) + } +} diff --git a/internal/store/migrations/0012_recognition_source_files.sql b/internal/store/migrations/0012_recognition_source_files.sql new file mode 100644 index 0000000..2f994c2 --- /dev/null +++ b/internal/store/migrations/0012_recognition_source_files.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- Снимок списка файлов раздачи на момент распознавания (JSON-массив +-- {path,size} в том же порядке, в каком список показан модели и в каком +-- резолвятся номера files[].i). Нужен раскладке: без него виден только план, +-- и файл, который модель молча пропустила, не показывается нигде. Ходить за +-- списком в qBittorrent на каждый рендер — лишний поход в чужой сервис. +-- NULL — запись создана до этой миграции: снимка нет, и UI обязан сказать об +-- этом прямо, а не выдавать план за полный перечень. +ALTER TABLE recognition ADD COLUMN source_files TEXT; + +-- +goose Down +ALTER TABLE recognition DROP COLUMN source_files; diff --git a/internal/store/recognition.go b/internal/store/recognition.go index def089a..63892c8 100644 --- a/internal/store/recognition.go +++ b/internal/store/recognition.go @@ -27,7 +27,11 @@ type Recognition struct { Reasons string `db:"reasons"` // JSON-массив строк RawLLM sql.NullString `db:"raw_llm"` Plan sql.NullString `db:"plan"` // JSON recognize.Plan - CreatedAt string `db:"created_at"` + // SourceFiles — снимок списка файлов раздачи на момент распознавания + // (JSON-массив {path,size} в порядке нумерации промпта). NULL — запись + // старше миграции 0012: снимка нет, полнота раскладки не гарантирована. + SourceFiles sql.NullString `db:"source_files"` + CreatedAt string `db:"created_at"` } // ReasonList разбирает JSON-поле reasons в срез строк. @@ -71,12 +75,13 @@ func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons [ const q = ` INSERT INTO recognition (id, download_id, attempt_no, is_current, media_type, title, original_title, - year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at) -VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + year, provider, provider_id, confidence, reasons, raw_llm, plan, source_files, + created_at) +VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` if _, err := tx.ExecContext(ctx, q, r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle, r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan, - FormatTime(Now())); err != nil { + r.SourceFiles, FormatTime(Now())); err != nil { return "", fmt.Errorf("insert recognition: %w", err) } if err := tx.Commit(); err != nil { diff --git a/internal/worker/review.go b/internal/worker/review.go index 9215e36..a58fe5c 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -151,14 +151,28 @@ func (w *Worker) finishRecognition(ctx context.Context, id, claim string, res re provider, providerID = res.Match.Provider, res.Match.ProviderID } + // Снимок списка файлов раздачи — из того же среза, по которому построен + // план (Result.Files), иначе номера в раскладке разъедутся с адресацией + // модели. Не сериализовался — оставляем NULL: UI назовёт список + // недоступным, а молча показать план как полный перечень нельзя. + var sourceFiles sql.NullString + if len(res.Files) > 0 { + if b, ferr := json.Marshal(res.Files); ferr != nil { + log.Warn("recognition marshal source files failed", "error", ferr) + } else { + sourceFiles = store.NullString(string(b)) + } + } + rec := &store.Recognition{ - DownloadID: id, - MediaType: store.NullString(string(res.Plan.Type)), - Title: store.NullString(res.Plan.Title), - Provider: store.NullString(provider), - ProviderID: store.NullString(providerID), - Plan: store.NullString(string(planJSON)), - RawLLM: store.NullString(res.Raw), + DownloadID: id, + MediaType: store.NullString(string(res.Plan.Type)), + Title: store.NullString(res.Plan.Title), + Provider: store.NullString(provider), + ProviderID: store.NullString(providerID), + Plan: store.NullString(string(planJSON)), + SourceFiles: sourceFiles, + RawLLM: store.NullString(res.Raw), } if res.Plan.OriginalTitle != "" { rec.OriginalTitle = store.NullString(res.Plan.OriginalTitle) @@ -968,12 +982,19 @@ type ReviewData struct { // Задача в review без записанной причины (обычный «нет матча») иначе оставила // бы экран без объяснения, почему пропала кнопка «Применить». PreviewError string - Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора - Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты) - Provider string // эффективный провайдер (с учётом выбора) - ProviderID string // эффективный id в базе - Hints []string - Overrides map[string]string + // SourceFiles — снимок списка файлов раздачи момента распознавания, в + // порядке нумерации промпта (номер строки = номер адресации модели). + SourceFiles []recognize.File + // SourceFilesKnown — есть ли снимок вообще. false у записей старше + // миграции 0012: транспорт обязан сказать, что полнота не гарантирована, + // а не выдавать план за полный перечень файлов. + SourceFilesKnown bool + Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора + Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты) + Provider string // эффективный провайдер (с учётом выбора) + ProviderID string // эффективный id в базе + Hints []string + Overrides map[string]string } // MatchURL — ссылка на подтверждённую запись метабазы для этой загрузки. Общий @@ -1068,6 +1089,14 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) Download: *d, Recognition: rec, Hints: hints, Overrides: overrides, Provider: prov, ProviderID: pid, } + if rec != nil && rec.SourceFiles.Valid { + var files []recognize.File + if err := json.Unmarshal([]byte(rec.SourceFiles.String), &files); err != nil { + log.Warn("review data unmarshal source files failed", "error", err) + } else { + rd.SourceFiles, rd.SourceFilesKnown = files, true + } + } if rec != nil { if cands, cerr := w.store.ListCandidatesByRecognition(ctx, rec.ID); cerr == nil { rd.Candidates = cands diff --git a/web/static/css/jellybit.css b/web/static/css/jellybit.css index eda773d..2bffbce 100644 --- a/web/static/css/jellybit.css +++ b/web/static/css/jellybit.css @@ -391,6 +391,11 @@ table.tbl .size{font-family:var(--mono);color:var(--text-dim);white-space:nowrap .se-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-weak)} .role-select{font:inherit;font-size:var(--fs-xs);padding:4px 6px;border-radius:var(--r-sm); border:1px solid var(--border-strong);background:var(--surface);color:var(--text)} +/* Строка о полноте перечня над таблицей раскладки: is-warn — когда список + файлов раздачи неизвестен и полнота не гарантирована. */ +.tbl-note{margin:0 0 var(--sp-3);font-size:var(--fs-sm);color:var(--text-dim)} +.tbl-note.is-warn{padding:9px 12px;border-radius:var(--r-sm);color:var(--text); + background:var(--st-wait-bg);border:1px solid color-mix(in srgb,var(--st-wait) 30%,transparent)} .tbl-actions{display:flex;gap:var(--sp-2);margin-top:var(--sp-3);flex-wrap:wrap;align-items:center} .validation{font-size:var(--fs-xs);margin-top:var(--sp-2);display:flex;gap:var(--sp-3);flex-wrap:wrap} .validation .ok{color:var(--st-ok)} diff --git a/web/templates/partials/layout_widget.html b/web/templates/partials/layout_widget.html index ef04bcf..27bcf14 100644 --- a/web/templates/partials/layout_widget.html +++ b/web/templates/partials/layout_widget.html @@ -1,18 +1,31 @@ {{define "layout_widget"}} +{{/* Полнота перечня названа явно: молчаливый показ неполного списка как + полного неотличим от честного «файлов вне плана нет». */}} +{{if .SourceUnknown}} +

Список файлов раздачи не сохранён — эта загрузка распознана до того, + как система стала его хранить. Строки построены по плану: файлы, которых в плане нет, + здесь не видны, и полнота не гарантирована. Повторное распознавание из ревью вернёт полный список.

+{{else if lt .Planned .Total}} +

В план распознавания попало {{.Planned}} файлов из {{.Total}} — + остальные показаны ниже пометкой «не в плане».

+{{end}}
- + + + + - {{range $i, $f := .}} - - + {{range .Rows}} + + - + {{end}}
#файл источника → раскладкароль
#файл источника → раскладкароль
{{add $i 1}}
{{if .Num}}{{.Num}}{{end}} -
{{$f.Src}}
+
{{.Src}}
{{$f.RoleLabel}}{{.RoleLabel}}