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

- модель адресует файл номером строки нашего списка вместо копии пути: ответ
  на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято,
  max_files и max_tokens ушли в [recognition], correction-ретрай больше не
  переприсылает список
- негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и
  отказ по размеру запроса названы своими причинами, покрытие плана блокирует
  авто только при непокрытом видеофайле
- раскладка показывает все файлы раздачи со строками «не в плане» и полным
  порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
This commit is contained in:
av
2026-09-02 08:54:58 +03:00
parent 01ac0430a9
commit fc9a3b4066
26 changed files with 1978 additions and 201 deletions
+12 -3
View File
@@ -94,6 +94,8 @@ func runRecognize(args []string) error {
} }
rec := recognize.New(provider, providers, recognize.Config{ rec := recognize.New(provider, providers, recognize.Config{
MaxRetries: cfg.LLM.MaxRetries, MaxRetries: cfg.LLM.MaxRetries,
MaxFiles: cfg.Recognition.MaxFiles,
MaxTokens: cfg.Recognition.MaxTokens,
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold, AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
Language: cfg.ContentLanguage(), Language: cfg.ContentLanguage(),
}, logger) }, logger)
@@ -169,13 +171,20 @@ func printDryRun(t qbt.Torrent, files []qbt.File, res recognize.Result, lay *lay
} }
fmt.Printf("\n──── Решение ────\n") fmt.Printf("\n──── Решение ────\n")
// Reasons печатаем на обеих ветках: на авто-ветке там лежит сводка покрытия
// («в план попало N из M») — информационные строки, не отменяющие авто.
// Ровно тем прогоном, которым эту сводку и проверяют руками, прятать её
// нельзя.
if res.Decision.Auto { if res.Decision.Auto {
fmt.Printf("АВТО-раскладка (review не нужен)\n") fmt.Printf("АВТО-раскладка (review не нужен)\n")
if len(res.Decision.Reasons) > 0 {
fmt.Printf("замечания:\n")
}
} else { } else {
fmt.Printf("REVIEW — причины:\n") 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") fmt.Printf("\n──── Превью раскладки (хардлинки НЕ создаются) ────\n")
+2
View File
@@ -103,6 +103,8 @@ func runServe(args []string) error {
if llmProvider != nil { if llmProvider != nil {
recognizer = recognize.New(llmProvider, providers, recognize.Config{ recognizer = recognize.New(llmProvider, providers, recognize.Config{
MaxRetries: cfg.LLM.MaxRetries, MaxRetries: cfg.LLM.MaxRetries,
MaxFiles: cfg.Recognition.MaxFiles,
MaxTokens: cfg.Recognition.MaxTokens,
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold, AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
Language: cfg.ContentLanguage(), Language: cfg.ContentLanguage(),
}, logger) }, logger)
+2
View File
@@ -72,6 +72,8 @@ source_missing_threshold = 3 # подряд тиков сверки бе
[recognition] [recognition]
auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0 auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0
max_files = 500 # сколько файлов раздачи максимум показывать модели; целое ≥ 1. Предохранитель от аномальной раздачи, а не рабочее ограничение: усечение уводит задачу в review отдельной причиной
max_tokens = 8000 # предел длины ответа модели, токены; целое ≥ 1. Не хватило — ответ обрывается, и задача уходит в review с причиной «ответ модели обрезан»
[telegram] [telegram]
enabled = false # включить Telegram-бота enabled = false # включить Telegram-бота
+1 -1
View File
@@ -191,4 +191,4 @@ Jellyfin указывают на `movies`/`series`, а не на корень
| Идентичность раздачи | split v1/v2-хеши не связаны, паре `xt` из магнета доверяем | `infohash-identity-integrity` | | Идентичность раздачи | split v1/v2-хеши не связаны, паре `xt` из магнета доверяем | `infohash-identity-integrity` |
| Расход внешних лимитов | кэша ответов метабаз нет, повтор распознавания бьёт провайдера заново | `metadata-cache` | | Расход внешних лимитов | кэша ответов метабаз нет, повтор распознавания бьёт провайдера заново | `metadata-cache` |
| История переходов | хранится только текущее состояние, «как сюда попали» восстанавливается по логам | `download-transition-history` | | История переходов | хранится только текущее состояние, «как сюда попали» восстанавливается по логам | `download-transition-history` |
| Предел ответа LLM | лимита на размер ответа нет — единственный недоверенный канал без предела; задачей пока не заведено | — | | Предел ответа LLM | предел размера ответа есть (`[recognition].max_tokens`), обрыв по нему распознаётся и уводит в review; открытым остаётся согласование предела с потолком провайдера и с `[llm].timeout` | — |
+11 -4
View File
@@ -14,7 +14,8 @@
> `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`), > `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`),
> `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC, > `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC,
> `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла), > `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). Назначение таблиц и роль компонентов — [architecture.md](architecture.md).
Значения `state` и легальные переходы — нормативно в Значения `state` и легальные переходы — нормативно в
@@ -78,9 +79,10 @@ erDiagram
TEXT provider "nullable; tmdb|tvdb|tvmaze|none" TEXT provider "nullable; tmdb|tvdb|tvmaze|none"
TEXT provider_id "nullable" TEXT provider_id "nullable"
REAL confidence "nullable" REAL confidence "nullable"
TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто" TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины ухода в review + информационные заметки (сводка покрытия), не отменяющие авто"
TEXT raw_llm "nullable; сырой ответ LLM" TEXT raw_llm "nullable; сырой ответ LLM"
TEXT plan "nullable; JSON recognize.Plan (миграция 0002)" 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), пишет приложение" TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
} }
@@ -165,8 +167,11 @@ erDiagram
- **Всё, кроме одного поля, — плоские колонки.** Никакого сжатия, никаких - **Всё, кроме одного поля, — плоские колонки.** Никакого сжатия, никаких
внешних файлов: строка читается и пишется целиком обычным запросом. внешних файлов: строка читается и пишется целиком обычным запросом.
- **JSON-строками в TEXT** лежат три поля: `recognition.plan` (канонический - **JSON-строками в TEXT** лежат три поля: `recognition.plan` (канонический
`recognize.Plan` — файл → роль/сезон/серия), `recognition.reasons` (список `recognize.Plan` — файл → роль/сезон/серия), `recognition.reasons` (диагностика
причин не-авто) и `download.parsed_context` (структура имени из контекста). распознавания: блокирующие причины ухода в review **и** информационные
заметки вроде сводки покрытия, которые авто-раскладку не отменяют, — само
решение auto/review из длины списка не выводится) и `download.parsed_context`
(структура имени из контекста).
Читаются целиком и разбираются в Go; частичного чтения и обновления поля Читаются целиком и разбираются в Go; частичного чтения и обновления поля
внутри JSON нет, SQL по содержимому этих полей не делается. внутри JSON нет, SQL по содержимому этих полей не делается.
- **`recognition.raw_llm`** — сырой ответ модели как есть, **несжатый**. Это - **`recognition.raw_llm`** — сырой ответ модели как есть, **несжатый**. Это
@@ -204,6 +209,8 @@ erDiagram
| `[worker].catch_timeout` | `10m` | предел для пойманной задачи, не добавившейся в qBittorrent | | `[worker].catch_timeout` | `10m` | предел для пойманной задачи, не добавившейся в qBittorrent |
| `[worker].source_missing_threshold` | `3` тика | дебаунс пропажи источника | | `[worker].source_missing_threshold` | `3` тика | дебаунс пропажи источника |
| `[recognition].auto_confidence_threshold` | `0.85` | порог авто-раскладки (доп. проверка к матчу в базе) | | `[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` с сырым ответом | | `[llm].timeout` / `max_retries` | `120s` / `3` | каждая попытка порождает строку `recognition` с сырым ответом |
| `[metadata.*].timeout` | `10s` | таймаут запроса к метабазе | | `[metadata.*].timeout` | `10s` | таймаут запроса к метабазе |
+24 -4
View File
@@ -96,16 +96,36 @@ func TestМиграцииБезAutoincrementИСерверногоВремени
// docs/conventions/errors.md: сравнение ошибок — errors.Is/errors.As, никогда // docs/conventions/errors.md: сравнение ошибок — errors.Is/errors.As, никогда
// по тексту. errorlint ловит `err == ErrX` и приведение типа, но не матчинг // по тексту. errorlint ловит `err == ErrX` и приведение типа, но не матчинг
// подстрокой — его ловим здесь. // подстрокой — его ловим здесь.
//
// Прямой формы мало: `msg := strings.ToLower(err.Error())` и следом
// `strings.Contains(msg, …)` — то же сравнение по тексту, просто через
// промежуточную переменную (так оно и просочилось в recognize). Поэтому вторым
// проходом собираем имена, которым присвоен `.Error()`, и ищем их в тех же
// сравнениях.
func TestОшибкиНеМатчатсяПоТексту(t *testing.T) { 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) { for _, path := range goFiles(t) {
body, err := os.ReadFile(path) body, err := os.ReadFile(path)
if err != nil { if err != nil {
t.Fatalf("читаю %s: %v", path, err) t.Fatalf("читаю %s: %v", path, err)
} }
if loc := re.FindIndex(body); loc != nil { rel, _ := filepath.Rel(repoRoot, path)
rel, _ := filepath.Rel(repoRoot, path) if loc := direct.FindIndex(body); loc != nil {
t.Errorf("%s:%d — ошибку матчим через errors.Is/errors.As, а не по тексту сообщения", rel, lineOf(body, loc[0])) 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])
}
} }
} }
} }
+26 -4
View File
@@ -127,9 +127,17 @@ type Worker struct {
SourceMissingThreshold int `toml:"source_missing_threshold"` SourceMissingThreshold int `toml:"source_missing_threshold"`
} }
// Recognition — пороги распознавания. // Recognition — пороги и пределы распознавания.
type Recognition struct { type Recognition struct {
AutoConfidenceThreshold float64 `toml:"auto_confidence_threshold"` 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). // Telegram — настройки бота (Ф5).
@@ -232,9 +240,17 @@ func Default() *Config {
CatchTimeout: Duration(10 * time.Minute), CatchTimeout: Duration(10 * time.Minute),
SourceMissingThreshold: 3, SourceMissingThreshold: 3,
}, },
Recognition: Recognition{AutoConfidenceThreshold: 0.85}, Recognition: Recognition{
HTTP: HTTP{Listen: ":8080"}, AutoConfidenceThreshold: 0.85,
Log: Log{Level: "info", Format: "json"}, // 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 { 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)) 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 { if c.LLM.MaxRetries < 0 {
errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries)) errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries))
} }
+51
View File
@@ -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)
}
})
}
}
+2 -2
View File
@@ -45,7 +45,7 @@ type downloadDetailView struct {
MatchURL string // ссылка на запись метабазы (пусто — показываем текстом) MatchURL string // ссылка на запись метабазы (пусто — показываем текстом)
NoBase bool NoBase bool
Confidence string Confidence string
Files []fileRow Files layoutWidget
// Живая статистика раздачи (заполняется из снимка воркера). // Живая статистика раздачи (заполняется из снимка воркера).
Seeding seedingView Seeding seedingView
@@ -178,7 +178,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
} }
// Файл источника → целевой путь из превью (единая логика layout). // Файл источника → целевой путь из превью (единая логика layout).
view.Files = buildFileRows(rd.Plan, rd.Preview) view.Files = buildFileRows(rd.Plan, rd.Preview, rd.SourceFiles, rd.SourceFilesKnown)
} }
// Живая статистика раздачи — со значениями уже в первом кадре; секция // Живая статистика раздачи — со значениями уже в первом кадре; секция
+152 -16
View File
@@ -1,6 +1,8 @@
package httpapi package httpapi
import ( import (
"sort"
"git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/recognize"
) )
@@ -9,31 +11,165 @@ import (
// ревью и просмотра загрузки. Целевой путь берётся из превью (единая логика // ревью и просмотра загрузки. Целевой путь берётся из превью (единая логика
// internal/layout), а не вычисляется в шаблоне. // internal/layout), а не вычисляется в шаблоне.
type fileRow struct { type fileRow struct {
// Num — номер файла в списке распознавания: тот самый, которым его
// адресует ответ модели. 0 — номер неизвестен (снимка списка нет), и тогда
// он не показывается вовсе: расхождение сделало бы разбор ошибок адресации
// ложным.
Num int
Src string Src string
Dst string // целевой путь; пусто — файл не раскладывается Dst string // целевой путь; пусто — файл не раскладывается
RoleLabel string RoleLabel string
Linked bool Linked bool
Ignored bool Ignored bool
// Unplanned — файла нет в плане распознавания: модель его не разметила.
// Молчаливый пропуск файла моделью не должен быть невидимым.
Unplanned bool
season int // ключи сортировки; 0 у файлов без нумерации
episode int
group int // 0 — разложенные, 1 — план без цели, 2 — вне плана
} }
// buildFileRows сшивает файлы плана с целевыми путями из превью раскладки. // layoutWidget — данные виджета раскладки: строки плюс честность о полноте.
func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow { type layoutWidget struct {
dstBySrc := make(map[string]string, len(preview)) Rows []fileRow
for _, l := range preview { // SourceUnknown — списка файлов раздачи для этой загрузки нет (запись
dstBySrc[l.Src] = l.Dst // создана до того, как система стала его сохранять). Строки построены по
// плану, и виджет обязан назвать это прямо.
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 { w := layoutWidget{
dst := dstBySrc[f.Src] SourceUnknown: !known,
rows = append(rows, fileRow{ Planned: len(distinct),
Src: f.Src, Rows: make([]fileRow, 0, len(files)+len(plan.Files)),
Dst: dst,
RoleLabel: roleLabel(string(f.Role)),
Linked: dst != "",
Ignored: f.Role == "ignore",
})
} }
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 — человекочитаемая роль файла раскладки. // roleLabel — человекочитаемая роль файла раскладки.
+195
View File
@@ -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)
}
}
+2 -2
View File
@@ -57,7 +57,7 @@ type reviewView struct {
Confidence string Confidence string
Reasons []string Reasons []string
Hints []string Hints []string
Files []fileRow Files layoutWidget
HasPlan bool HasPlan bool
HasLinks bool // есть хотя бы один целевой путь → можно применять HasLinks bool // есть хотя бы один целевой путь → можно применять
NoBase bool // выбрано «без базы» NoBase bool // выбрано «без базы»
@@ -145,7 +145,7 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
if rec.Confidence.Valid { if rec.Confidence.Valid {
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64) 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.HasPlan = len(rd.Plan.Files) > 0
view.HasLinks = len(rd.Preview) > 0 view.HasLinks = len(rd.Preview) > 0
for _, src := range rd.Sources { for _, src := range rd.Sources {
+20
View File
@@ -54,8 +54,17 @@ type Response struct {
Content string Content string
Model string Model string
Usage Usage Usage Usage
// FinishReason — причина завершения генерации, как её назвал провайдер
// ("stop", "length", …). Пусто — провайдер её не сообщил. Нужна
// вызывающему: обрыв по длине неотличим от мусора по одному лишь телу
// ответа, а повторять такой запрос бессмысленно (см. recognize).
FinishReason string
} }
// FinishLength — значение FinishReason, которым OpenAI-совместимые провайдеры
// сообщают обрыв генерации по достижении предела токенов.
const FinishLength = "length"
// Provider — абстракция доступа к LLM. recognize работает только с ним и не // Provider — абстракция доступа к LLM. recognize работает только с ним и не
// знает про конкретный транспорт. // знает про конкретный транспорт.
type Provider interface { type Provider interface {
@@ -75,6 +84,17 @@ type Config struct {
// ErrUnknownType — запрошенный [llm].type не поддерживается. // ErrUnknownType — запрошенный [llm].type не поддерживается.
var ErrUnknownType = errors.New("llm: unknown provider 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(). // New собирает провайдер по дискриминатору cfg.Type. logger nil → slog.Default().
func New(cfg Config, logger *slog.Logger) (Provider, error) { func New(cfg Config, logger *slog.Logger) (Provider, error) {
if logger == nil { if logger == nil {
+70
View File
@@ -3,6 +3,7 @@ package llm
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -234,3 +235,72 @@ func TestNew_OpenAICompatValidation(t *testing.T) {
t.Fatalf("unexpected error: %v", err) 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)
}
})
}
}
+38 -4
View File
@@ -193,8 +193,11 @@ func (c *openAICompat) do(ctx context.Context, body []byte) (Response, bool, err
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
return Response{}, retryable, fmt.Errorf("llm: status %d: %s", err := fmt.Errorf("llm: status %d: %s", resp.StatusCode, snippet(raw))
resp.StatusCode, snippet(raw)) if requestTooLarge(resp.StatusCode, raw) {
err = fmt.Errorf("%w: %w", ErrRequestTooLarge, err)
}
return Response{}, retryable, err
} }
var cr chatResponse var cr chatResponse
@@ -209,8 +212,9 @@ func (c *openAICompat) do(ctx context.Context, body []byte) (Response, bool, err
} }
return Response{ return Response{
Content: cr.Choices[0].Message.Content, Content: cr.Choices[0].Message.Content,
Model: cr.Model, Model: cr.Model,
FinishReason: cr.Choices[0].FinishReason,
Usage: Usage{ Usage: Usage{
PromptTokens: cr.Usage.PromptTokens, PromptTokens: cr.Usage.PromptTokens,
CompletionTokens: cr.Usage.CompletionTokens, 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 обрезает тело для сообщения об ошибке. // snippet обрезает тело для сообщения об ошибке.
func snippet(b []byte) string { func snippet(b []byte) string {
const max = 300 const max = 300
+327
View File
@@ -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)
}
}
}
+31 -28
View File
@@ -36,14 +36,14 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
"provider_hint": "строка для поиска в базе (НЕ id)", "provider_hint": "строка для поиска в базе (НЕ id)",
"files": [ "files": [
{ {
"src": "путь файла из списка ниже, БЕЗ размера в скобках в конце строки", "i": номер файла из списка ниже (целое, ровно как напечатано),
"role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore", "role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore",
"season": число или null, "season": число или null,
"episode": число или null "episode": число или null
} }
], ],
"confidence": число 0..1, "confidence": число 0..1,
"notes": "пояснения и неоднозначности или пустая строка" "notes": "неоднозначности, не больше пары предложений, или пустая строка"
} }
Правила: Правила:
@@ -53,10 +53,12 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
происхождения — продублируй "title" в "original_title". Если НЕ уверен в происхождения — продублируй "title" в "original_title". Если НЕ уверен в
оригинальном названии — продублируй "title", но НЕ выдумывай название. оригинальном названии — продублируй "title", но НЕ выдумывай название.
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore". - "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
- Поле "i" — номер файла из напечатанного ниже списка, ровно как он там стоит.
Путь файла копировать НЕ нужно: он у нас уже есть, мы подставим его сами.
- Один и тот же файл адресуй не больше одного раза.
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode. - Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
- Для фильма ровно один основной видеофайл role "main". - Для фильма ровно один основной видеофайл role "main".
- Поле src — это путь файла из списка, скопированный дословно, но БЕЗ размера - Для ролей "sample", "ignore" и "extra" номера сезона и серии не нужны — оставляй null.
«(…)» в конце строки; не выдумывай и не нормализуй пути.
- Внешние субтитры — role "subtitle".` - Внешние субтитры — role "subtitle".`
const systemPromptBase = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента, const systemPromptBase = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента,
@@ -89,15 +91,18 @@ func systemPrompt(lang string) string {
return systemPromptBase + schemaText + languageDirective(lang) return systemPromptBase + schemaText + languageDirective(lang)
} }
// buildMessages собирает системное и пользовательское сообщения. // buildMessages собирает системное и пользовательское сообщения. total —
func buildMessages(in Input, pre PreParse, maxFiles int, lang string) []llm.Message { // полное число файлов раздачи (in.Files уже упорядочен и, возможно, усечён
// пределом): список печатается ровно тем срезом, по которому потом резолвятся
// номера.
func buildMessages(in Input, pre PreParse, total int, lang string) []llm.Message {
return []llm.Message{ return []llm.Message{
{Role: llm.RoleSystem, Content: systemPrompt(lang)}, {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 var b strings.Builder
b.WriteString("Имя торрента: ") b.WriteString("Имя торрента: ")
b.WriteString(orNone(in.Name)) b.WriteString(orNone(in.Name))
@@ -122,7 +127,7 @@ func userPrompt(in Input, pre PreParse, maxFiles int) string {
b.WriteString(preParseLine(pre)) b.WriteString(preParseLine(pre))
b.WriteString("\n\n") b.WriteString("\n\n")
writeFileList(&b, in.Files, maxFiles) writeFileList(&b, in.Files, total)
return b.String() return b.String()
} }
@@ -149,19 +154,15 @@ func preParseLine(pre PreParse) string {
return strings.Join(parts, ", ") return strings.Join(parts, ", ")
} }
// writeFileList печатает список файлов, усекая до maxFiles. src в плане // writeFileList печатает нумерованный список файлов. Номер строки — то, что
// должен дословно совпадать с путями отсюда. // модель возвращает в поле "i"; усечение (если оно было) сделал вызывающий,
func writeFileList(b *strings.Builder, files []File, maxFiles int) { // total — полное число файлов раздачи.
n := len(files) func writeFileList(b *strings.Builder, files []File, total int) {
shown := n b.WriteString("Файлы раздачи (")
if maxFiles > 0 && shown > maxFiles { b.WriteString(strconv.Itoa(len(files)))
shown = maxFiles b.WriteString("). Номер в начале строки — это то, что нужно вернуть в поле \"i\";")
} b.WriteString(" путь и размер копировать не нужно:\n")
b.WriteString("Файлы (") for i := range files {
b.WriteString(strconv.Itoa(n))
b.WriteString("). В src копируй ТОЛЬКО путь — текст после номера и до размера ")
b.WriteString("в скобках; размер «(…)» в конце строки в src НЕ включай:\n")
for i := 0; i < shown; i++ {
b.WriteString(strconv.Itoa(i + 1)) b.WriteString(strconv.Itoa(i + 1))
b.WriteString(". ") b.WriteString(". ")
b.WriteString(files[i].Path) 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(humanSize(files[i].Size))
b.WriteString(")\n") b.WriteString(")\n")
} }
if shown < n { if total > len(files) {
b.WriteString("… и ещё ") b.WriteString("… и ещё ")
b.WriteString(strconv.Itoa(n - shown)) b.WriteString(strconv.Itoa(total - len(files)))
b.WriteString(" файлов (список усечён)\n") b.WriteString(" файлов (список усечён)\n")
} }
} }
// correctionMessage — сообщение для повторной попытки: что было не так + схема. // correctionMessage — сообщение для повторной попытки: что было не так + схема.
func correctionMessage(err error, in Input, maxFiles int) string { // Список файлов сюда НЕ входит: его номера названы в первом сообщении диалога
// и сохраняют смысл на всех попытках, а повторная печать умножала бы вход на
// число попыток — ровно ту цену, которую снимает индексная адресация.
func correctionMessage(err error) string {
var b strings.Builder var b strings.Builder
b.WriteString("Ответ не принят: ") b.WriteString("Ответ не принят: ")
b.WriteString(err.Error()) b.WriteString(err.Error())
b.WriteString("\nВерни ИСПРАВЛЕННЫЙ ответ строго по схеме, только JSON.\n\n") b.WriteString("\nВерни ИСПРАВЛЕННЫЙ ответ строго по схеме, только JSON.")
b.WriteString(" Файлы адресуй номерами из списка, присланного выше.\n\n")
b.WriteString(schemaText) b.WriteString(schemaText)
b.WriteString("\n\n")
writeFileList(&b, in.Files, maxFiles)
return b.String() return b.String()
} }
+171 -27
View File
@@ -11,15 +11,23 @@
// базой), согласованности с пред-парсом и уверенности не ниже порога. // базой), согласованности с пред-парсом и уверенности не ниже порога.
// //
// Без включённых баз (или без матча) авто-раскладка не делается — задача // Без включённых баз (или без матча) авто-раскладка не делается — задача
// уходит в review. Выход LLM недоверенный: план принимается только если // уходит в review. Выход LLM недоверенный: файл адресуется НОМЕРОМ строки в
// каждый files[].src совпадает с реальным файлом торрента; итоговая // напечатанном нами списке, а files[].src подставляет резолв номера по этому
// безопасность пути держится на раскладке (layout). // же списку — посторонний путь невыразим. Присланный моделью путь принимается
// лишь как запасной формат и только при точном совпадении с файлом торрента;
// итоговая безопасность пути держится на раскладке (layout).
package recognize package recognize
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math"
"path/filepath"
"sort"
"strconv"
"strings"
"git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/llm"
"git.vakhrushev.me/av/jellybit/internal/logctx" "git.vakhrushev.me/av/jellybit/internal/logctx"
@@ -56,9 +64,29 @@ func (r FileRole) valid() bool {
} }
// File — входной файл торрента (путь относительно save_path и размер). // File — входной файл торрента (путь относительно save_path и размер).
// JSON-теги — для снимка списка файлов рядом с планом (store.Recognition,
// миграция 0012): раскладка обязана показывать и те файлы, которых нет в плане.
type File struct { type File struct {
Path string Path string `json:"path"`
Size int64 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 — сигналы для распознавания одной раздачи. // Input — сигналы для распознавания одной раздачи.
@@ -69,13 +97,49 @@ type Input struct {
Hints []string // накопленные подсказки из review (Ф3; в Ф2 обычно пусто) 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 заданы на файле, чтобы // PlanFile — файл в плане раскладки. Season/Episode заданы на файле, чтобы
// выражать мультисезонные паки и спецвыпуски (см. recognition.md). // выражать мультисезонные паки и спецвыпуски (см. recognition.md).
type PlanFile struct { type PlanFile struct {
Src string `json:"src"` // Index — чем файл адресован в ответе модели: номер строки нашего списка.
Role FileRole `json:"role"` // Src заполняет резолвом сама система, поэтому посторонний путь невыразим.
Season *int `json:"season,omitempty"` // Указатель, а не значение: «поле не прислано» и «прислан 0» — разные
Episode *int `json:"episode,omitempty"` // диагнозы. Ноль означает модель, посчитавшую список с нуля, и тогда все
// остальные её номера резолвятся со сдвигом на файл; отсутствие поля —
// запасной формат с путём либо элемент, не адресующий ничего.
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). // Plan — структурированный результат распознавания (схема ответа LLM).
@@ -129,6 +193,13 @@ type Result struct {
Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи) Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи)
Raw string // сырой ответ LLM последней попытки Raw string // сырой ответ LLM последней попытки
// Files — ПОЛНЫЙ список файлов раздачи в том порядке, в каком печатается
// промпт (и в каком резолвятся номера). Снимок момента распознавания: его
// сохраняют рядом с планом, чтобы раскладка показывала и файлы вне плана.
// Усечение пределом max_files сюда не распространяется — иначе снимок выдал
// бы показанный модели срез за весь перечень раздачи. Заполнен на всех
// исходах, включая review без разобранного плана.
Files []File
} }
// LLM — нужная recognize часть провайдера. // LLM — нужная recognize часть провайдера.
@@ -148,8 +219,12 @@ type Config struct {
} }
const ( const (
defaultMaxTokens = 4000 // Дефолты — предохранители для прямых вызовов конструктора (тесты, CLI без
defaultMaxFiles = 100 // конфига). Канонические значения задаёт [recognition] (config.Default);
// равенство им сторожит TestDefaultsAgreeWithConfig — иначе прод и тест
// разъедутся молча.
defaultMaxTokens = 8000
defaultMaxFiles = 500
defaultAutoThreshold = 0.85 defaultAutoThreshold = 0.85
defaultLanguage = "en" 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) { func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
log := logctx.FromOr(ctx, r.log) log := logctx.FromOr(ctx, r.log)
pre := preParse(in.Name) 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 temp := 0.0
var raw string var raw string
@@ -227,32 +321,54 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
MaxTokens: r.maxTokens, MaxTokens: r.maxTokens,
}) })
if err != nil { 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 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 { if parseErr == nil {
break break
} }
log.Warn("recognition llm response unparsed", log.Warn("recognition llm response unparsed",
"attempt", attempts, "error", parseErr) "attempt", attempts, "error", parseErr)
// Просим модель исправиться, повторяя схему и ошибку. // Просим модель исправиться, повторяя схему и ошибку. Список файлов
// НЕ переприсылаем: его номера названы в первом сообщении диалога и
// сохраняют смысл на всех попытках (иначе цена неудачи росла бы вместе
// с размером раздачи).
msgs = append(msgs, msgs = append(msgs,
llm.Message{Role: llm.RoleAssistant, Content: raw}, 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 { if parseErr != nil {
return Result{ // Претензии последней попытки — единственное поэлементное объяснение
PreParse: pre, // («номер 181 вне диапазона 1..2»); без них человек читает голое «ответ
Attempts: attempts, // LLM не разобран». decide на успешной ветке добавляет их так же.
Raw: raw, reasons := []string{
Decision: Decision{ "ответ LLM не разобран после " + itoa(attempts) + " попыток: " + parseErr.Error(),
Auto: false, }
Reasons: []string{"ответ LLM не разобран после " + itoa(attempts) + " попыток: " + parseErr.Error()}, reasons = append(reasons, issues.dropped...)
}, return reviewResult(pre, issues, attempts, raw, reasons), nil
}, nil
} }
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год // Сверка с базой: подтверждаем 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", log.Info("recognition done",
"media_type", plan.Type, "title", plan.Title, "year", plan.Year, "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), "matched", match != nil, "candidates", len(candidates),
"auto", dec.Auto, "reasons", len(dec.Reasons)) "auto", dec.Auto, "reasons", len(dec.Reasons))
return Result{ return Result{
@@ -289,5 +406,32 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
Candidates: candidates, Candidates: candidates,
Attempts: attempts, Attempts: attempts,
Raw: raw, Raw: raw,
Files: all,
}, nil }, 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
}
+125 -12
View File
@@ -3,10 +3,12 @@ package recognize
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"strings" "strings"
"testing" "testing"
"git.vakhrushev.me/av/jellybit/internal/config"
"git.vakhrushev.me/av/jellybit/internal/llm" "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 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 { func testLogger() *slog.Logger {
return slog.New(slog.DiscardHandler) return slog.New(slog.DiscardHandler)
} }
@@ -145,11 +154,15 @@ func TestRecognize_RetriesOnBadSrcThenSucceeds(t *testing.T) {
if res.Plan.Title != "Some Movie" { if res.Plan.Title != "Some Movie" {
t.Errorf("plan = %+v", res.Plan) t.Errorf("plan = %+v", res.Plan)
} }
// Корректирующее сообщение должно содержать схему и список файлов. // Корректирующее сообщение несёт ошибку и схему, но НЕ список файлов:
// номера названы в первом сообщении диалога.
last := f.lastReq.Messages[len(f.lastReq.Messages)-1] 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) 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) { func TestRecognize_ExhaustedRetriesGoesToReview(t *testing.T) {
@@ -222,29 +235,129 @@ func TestRecognize_PromptCarriesSignals(t *testing.T) {
func TestRecognize_FileListTruncated(t *testing.T) { func TestRecognize_FileListTruncated(t *testing.T) {
files := make([]File, 250) files := make([]File, 250)
planFiles := make([]string, 0, 250)
for i := range files { for i := range files {
files[i] = File{Path: pathOf(i), Size: 100 << 20} files[i] = File{Path: pathOf(i), Size: 100 << 20}
} }
// План ссылается только на первый файл — этого достаточно для схемы.
_ = planFiles
in := Input{Name: "Big.Pack", Files: files} 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}} f := &fakeLLM{responses: []string{resp}}
r := New(f, nil, Config{MaxFiles: 100}, testLogger()) 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) t.Fatalf("Recognize: %v", err)
} }
user := f.lastReq.Messages[1].Content user := f.lastReq.Messages[1].Content
if !strings.Contains(user, "усечён") { if !strings.Contains(user, "усечён") || !strings.Contains(user, "и ещё 150") {
t.Errorf("expected truncation note in prompt") t.Errorf("expected truncation note in prompt:\n%s", user)
} }
if !strings.Contains(user, "Файлы (250") { if !strings.Contains(user, "Файлы раздачи (100)") {
t.Errorf("expected total count 250 in prompt") 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 { func pathOf(i int) string {
return "show/ep" + itoa(i) + ".mkv" 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)
}
}
+349 -55
View File
@@ -5,22 +5,25 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"sort" "sort"
"strconv"
"strings" "strings"
"git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/llm"
) )
// parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему. // parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему.
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные // Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Второй
// предупреждения (см. decide) ошибкой не считаются — они уводят в review. // результат — претензии к отдельным элементам files[]: они НЕ ошибка разбора
// (повторный запрос к модели не делается), а причины ухода в review.
// Структурные предупреждения (см. decide) ошибкой тоже не считаются.
// //
// Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см. // Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см.
// sanitizePlan) ДО структурной валидации: так, например, title из одних // sanitizePlan) ДО структурной валидации: так, например, title из одних
// zero-width символов схлопывается в пустой и корректно уводит в ретрай. // 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) jsonStr, err := llm.ExtractJSONObject(raw)
if err != nil { 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 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 { 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) sanitizePlan(&p, log)
if err := validateSchema(&p, in); err != nil { problems, err := validateSchema(&p, in)
return Plan{}, err if err != nil {
return Plan{}, problems, err
} }
return p, nil return p, problems, nil
} }
// validateSchema проверяет обязательную структуру плана. Главный инвариант // validateSchema проверяет обязательную структуру плана и резолвит адресацию
// безопасности: каждый files[].src совпадает с реальным файлом торрента — // файлов. Главный инвариант безопасности: files[].src заполняем МЫ — из своего
// недоверенный выход LLM не может сослаться на посторонний путь. // же списка, по номеру строки (i), напечатанному в промпте. Присланный моделью
func validateSchema(p *Plan, in Input) error { // путь принимается лишь как запасной формат и только при точном совпадении с
// файлом торрента, поэтому сослаться на посторонний путь невозможно.
//
// Негодный элемент (номер вне диапазона, повторная адресация, ни номера ни
// пути, неизвестный путь) отбрасывается поимённой претензией — план при этом
// живёт: одна ошибка в одном из сотен элементов не должна ронять всю работу.
// Ошибка возвращается только когда плана не осталось вовсе.
func validateSchema(p *Plan, in Input) ([]string, error) {
switch p.Type { switch p.Type {
case MediaMovie, MediaSeries: case MediaMovie, MediaSeries:
case "": 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: default:
return fmt.Errorf("unknown type %q", p.Type) return nil, fmt.Errorf("unknown type %q", p.Type)
} }
if strings.TrimSpace(p.Title) == "" { 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 { 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)) byPath := make(map[string]int, len(in.Files))
for _, f := range in.Files { for i, f := range in.Files {
known[f.Path] = true byPath[f.Path] = i
} }
var problems problemList
taken := make(map[int]bool, len(p.Files))
kept := p.Files[:0]
for i := range p.Files { for i := range p.Files {
pf := &p.Files[i] pf := p.Files[i]
// Роль вне перечня — ошибка схемы, а не адресации: модель ошиблась в
// нашем же словаре, и это чинится повторной попыткой. Проверка стоит ДО
// резолва, поэтому pf.Src ещё пуст: адресуем элемент так, как его назвала
// сама модель, иначе ей (и человеку) нечего чинить в списке на 180 строк.
if !pf.Role.valid() { 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) == "" { idx, problem, ok := resolveFile(pf, in.Files, byPath)
return fmt.Errorf("file with empty src") if problem != "" {
problems.add(problem)
} }
if !known[pf.Src] { if !ok {
return fmt.Errorf("src %q not found among torrent files", pf.Src) 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 { 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). Авто — // decide считает решение модели уверенности (см. recognition.md). Авто —
@@ -88,14 +246,38 @@ func validateSchema(p *Plan, in Input) error {
// название матча пригодно как имя каталога; чистая структурная валидация (для // название матча пригодно как имя каталога; чистая структурная валидация (для
// сериала — число серий бьётся с базой); согласованность с пред-парсом; // сериала — число серий бьётся с базой); согласованность с пред-парсом;
// самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review. // самооценка 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 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 { switch {
case !metadataEnabled: case !metadataEnabled:
reasons = append(reasons, "метабазы отключены → авто-раскладка недоступна") add("метабазы отключены → авто-раскладка недоступна")
case match == nil: 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), второй раз не чистим: два независимых пересчёта одного // (buildMatch), второй раз не чистим: два независимых пересчёта одного
// условия разъедутся на первой же правке одного из них. // условия разъедутся на первой же правке одного из них.
if match != nil && !UsableTitle(match.Title) { 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 { 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 { if p.Confidence < threshold {
reasons = append(reasons, add(fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold))
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 сверяет число распознанных серий по сезонам с базой. // episodeCountWarnings сверяет число распознанных серий по сезонам с базой.
@@ -181,13 +417,12 @@ func structuralWarnings(p Plan) []string {
return w return w
} }
// seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам. // seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам. На
// сезон приходится не больше одной причины каждого рода: на паке из восьми
// сезонов строка за каждый разрыв давала сорок строк, которые человек в ревью
// уже не читает.
func seriesWarnings(files []PlanFile) []string { func seriesWarnings(files []PlanFile) []string {
type key struct{ s, e int } seen := map[int]map[int]int{}
seen := map[key]int{}
bySeason := map[int][]int{}
var w []string
for _, f := range files { for _, f := range files {
if f.Role != RoleEpisode || f.Episode == nil { if f.Role != RoleEpisode || f.Episode == nil {
continue continue
@@ -196,27 +431,86 @@ func seriesWarnings(files []PlanFile) []string {
if f.Season != nil { if f.Season != nil {
season = *f.Season season = *f.Season
} }
k := key{season, *f.Episode} if seen[season] == nil {
seen[k]++ seen[season] = map[int]int{}
if seen[k] == 2 {
w = append(w, fmt.Sprintf("сериал: дубль серии S%02dE%02d", season, *f.Episode))
} }
bySeason[season] = append(bySeason[season], *f.Episode) seen[season][*f.Episode]++
} }
for _, season := range sortedKeys(bySeason) { var w []string
eps := bySeason[season] for _, season := range sortedKeys(toSlices2(seen)) {
sort.Ints(eps) eps := seen[season]
for i := 1; i < len(eps); i++ { nums := make([]int, 0, len(eps))
if eps[i] > eps[i-1]+1 { for e := range eps {
w = append(w, fmt.Sprintf("сериал: пропуск серий в сезоне %d между E%02d и E%02d", nums = append(nums, e)
season, eps[i-1], eps[i])) }
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 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 с черновым пред-парсом. // consistencyWarnings — расхождения LLM с черновым пред-парсом.
func consistencyWarnings(p Plan, pre PreParse) []string { func consistencyWarnings(p Plan, pre PreParse) []string {
var w []string var w []string
+279 -15
View File
@@ -1,12 +1,15 @@
package recognize package recognize
import ( import (
"runtime"
"strings" "strings"
"testing" "testing"
) )
func intp(n int) *int { return &n } func intp(n int) *int { return &n }
func idxp(n int) *FileIndex { i := FileIndex(n); return &i }
func inputWith(paths ...string) Input { func inputWith(paths ...string) Input {
files := make([]File, len(paths)) files := make([]File, len(paths))
for i, p := range 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)}, {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) 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) { 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"}, {"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"}, {"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"}, {"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"}, {"no addressing at all", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "no files[] element addresses"},
{"unknown src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "not found among torrent files"}, {"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"}, {"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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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) { if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Errorf("err = %v, want contains %q", err, 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") in := inputWith("film.mkv")
raw := "Вот результат:\n```json\n{\"type\":\"movie\",\"title\":\"Film\"," + raw := "Вот результат:\n```json\n{\"type\":\"movie\",\"title\":\"Film\"," +
"\"files\":[{\"src\":\"film.mkv\",\"role\":\"main\"}]}\n```" "\"files\":[{\"src\":\"film.mkv\",\"role\":\"main\"}]}\n```"
p, err := parsePlan(raw, in, testLogger()) p, _, err := parsePlan(raw, in, testLogger())
if err != nil { if err != nil {
t.Fatalf("parsePlan: %v", err) t.Fatalf("parsePlan: %v", err)
} }
@@ -73,7 +84,7 @@ func TestParsePlan_UnknownFieldTolerated(t *testing.T) {
in := inputWith("film.mkv") in := inputWith("film.mkv")
raw := `{"type":"movie","title":"Film","extra_field":123, raw := `{"type":"movie","title":"Film","extra_field":123,
"files":[{"src":"film.mkv","role":"main"}]}` "files":[{"src":"film.mkv","role":"main"}]}`
if _, err := parsePlan(raw, in, testLogger()); err != nil { if _, _, err := parsePlan(raw, in, testLogger()); err != nil {
t.Fatalf("unknown field should be tolerated: %v", err) t.Fatalf("unknown field should be tolerated: %v", err)
} }
} }
@@ -106,10 +117,10 @@ func TestSeriesWarnings_GapAndDup(t *testing.T) {
w := seriesWarnings(files) w := seriesWarnings(files)
var dup, gap bool var dup, gap bool
for _, s := range w { for _, s := range w {
if strings.Contains(s, "дубль") { if strings.Contains(s, "дубли серий") {
dup = true dup = true
} }
if strings.Contains(s, "пропуск") { if strings.Contains(s, "не хватает серий") {
gap = true gap = true
} }
} }
@@ -157,7 +168,7 @@ func TestConsistencyWarnings(t *testing.T) {
func TestDecide_MetadataDisabled(t *testing.T) { func TestDecide_MetadataDisabled(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}} 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 { if d.Auto {
t.Error("без метабаз авто недопустимо") t.Error("без метабаз авто недопустимо")
} }
@@ -168,7 +179,7 @@ func TestDecide_MetadataDisabled(t *testing.T) {
func TestDecide_NoMatch(t *testing.T) { func TestDecide_NoMatch(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}} 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], "не найдено в базе") { if d.Auto || !strings.Contains(d.Reasons[0], "не найдено в базе") {
t.Errorf("reasons = %v", d.Reasons) 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, p := Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999, Confidence: 0.95,
Files: []PlanFile{{Role: RoleMain}, {Role: RoleSample}}} Files: []PlanFile{{Role: RoleMain}, {Role: RoleSample}}}
match := &Match{Provider: "tmdb", ProviderID: "603", Title: "The Matrix", Year: 1999} 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 { if !d.Auto {
t.Errorf("clean movie with match must be auto, reasons: %v", d.Reasons) 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, p := Plan{Type: MediaMovie, Title: "X", Year: 2000, Confidence: 0.5,
Files: []PlanFile{{Role: RoleMain}}} Files: []PlanFile{{Role: RoleMain}}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "X", Year: 2000} 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, "уверенность") { if d.Auto || !hasReason(d.Reasons, "уверенность") {
t.Errorf("low confidence must block auto, reasons: %v", 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)}} Files: []PlanFile{mk(1), mk(2), mk(3)}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Fargo", Year: 2014, match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Fargo", Year: 2014,
SeasonEpisodeCounts: map[int]int{2: 3}} 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) t.Errorf("full season must be auto, reasons: %v", d.Reasons)
} }
// Неполный пак (в базе 10) — авто блокируется. // Неполный пак (в базе 10) — авто блокируется.
match.SeasonEpisodeCounts = map[int]int{2: 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") { !hasReason(d.Reasons, "распознано серий 3, в базе 10") {
t.Errorf("partial pack must block auto, reasons: %v", d.Reasons) t.Errorf("partial pack must block auto, reasons: %v", d.Reasons)
} }
// Нет данных о числе серий — авто блокируется. // Нет данных о числе серий — авто блокируется.
match.SeasonEpisodeCounts = nil 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, "нет данных о числе серий") { !hasReason(d.Reasons, "нет данных о числе серий") {
t.Errorf("missing counts must block auto, reasons: %v", 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) 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)
}
}
@@ -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;
+9 -4
View File
@@ -27,7 +27,11 @@ type Recognition struct {
Reasons string `db:"reasons"` // JSON-массив строк Reasons string `db:"reasons"` // JSON-массив строк
RawLLM sql.NullString `db:"raw_llm"` RawLLM sql.NullString `db:"raw_llm"`
Plan sql.NullString `db:"plan"` // JSON recognize.Plan 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 в срез строк. // ReasonList разбирает JSON-поле reasons в срез строк.
@@ -71,12 +75,13 @@ func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons [
const q = ` const q = `
INSERT INTO recognition INSERT INTO recognition
(id, download_id, attempt_no, is_current, media_type, title, original_title, (id, download_id, attempt_no, is_current, media_type, title, original_title,
year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at) year, provider, provider_id, confidence, reasons, raw_llm, plan, source_files,
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` created_at)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if _, err := tx.ExecContext(ctx, q, if _, err := tx.ExecContext(ctx, q,
r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle, 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, 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) return "", fmt.Errorf("insert recognition: %w", err)
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
+42 -13
View File
@@ -151,14 +151,28 @@ func (w *Worker) finishRecognition(ctx context.Context, id, claim string, res re
provider, providerID = res.Match.Provider, res.Match.ProviderID 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{ rec := &store.Recognition{
DownloadID: id, DownloadID: id,
MediaType: store.NullString(string(res.Plan.Type)), MediaType: store.NullString(string(res.Plan.Type)),
Title: store.NullString(res.Plan.Title), Title: store.NullString(res.Plan.Title),
Provider: store.NullString(provider), Provider: store.NullString(provider),
ProviderID: store.NullString(providerID), ProviderID: store.NullString(providerID),
Plan: store.NullString(string(planJSON)), Plan: store.NullString(string(planJSON)),
RawLLM: store.NullString(res.Raw), SourceFiles: sourceFiles,
RawLLM: store.NullString(res.Raw),
} }
if res.Plan.OriginalTitle != "" { if res.Plan.OriginalTitle != "" {
rec.OriginalTitle = store.NullString(res.Plan.OriginalTitle) rec.OriginalTitle = store.NullString(res.Plan.OriginalTitle)
@@ -968,12 +982,19 @@ type ReviewData struct {
// Задача в review без записанной причины (обычный «нет матча») иначе оставила // Задача в review без записанной причины (обычный «нет матча») иначе оставила
// бы экран без объяснения, почему пропала кнопка «Применить». // бы экран без объяснения, почему пропала кнопка «Применить».
PreviewError string PreviewError string
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора // SourceFiles — снимок списка файлов раздачи момента распознавания, в
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты) // порядке нумерации промпта (номер строки = номер адресации модели).
Provider string // эффективный провайдер (с учётом выбора) SourceFiles []recognize.File
ProviderID string // эффективный id в базе // SourceFilesKnown — есть ли снимок вообще. false у записей старше
Hints []string // миграции 0012: транспорт обязан сказать, что полнота не гарантирована,
Overrides map[string]string // а не выдавать план за полный перечень файлов.
SourceFilesKnown bool
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
Provider string // эффективный провайдер (с учётом выбора)
ProviderID string // эффективный id в базе
Hints []string
Overrides map[string]string
} }
// MatchURL — ссылка на подтверждённую запись метабазы для этой загрузки. Общий // MatchURL — ссылка на подтверждённую запись метабазы для этой загрузки. Общий
@@ -1068,6 +1089,14 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
Download: *d, Recognition: rec, Hints: hints, Overrides: overrides, Download: *d, Recognition: rec, Hints: hints, Overrides: overrides,
Provider: prov, ProviderID: pid, 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 rec != nil {
if cands, cerr := w.store.ListCandidatesByRecognition(ctx, rec.ID); cerr == nil { if cands, cerr := w.store.ListCandidatesByRecognition(ctx, rec.ID); cerr == nil {
rd.Candidates = cands rd.Candidates = cands
+5
View File
@@ -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)} .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); .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)} 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} .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{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)} .validation .ok{color:var(--st-ok)}
+20 -7
View File
@@ -1,18 +1,31 @@
{{define "layout_widget"}} {{define "layout_widget"}}
{{/* Полнота перечня названа явно: молчаливый показ неполного списка как
полного неотличим от честного «файлов вне плана нет». */}}
{{if .SourceUnknown}}
<p class="tbl-note is-warn">Список файлов раздачи не сохранён — эта загрузка распознана до того,
как система стала его хранить. Строки построены по плану: файлы, которых в плане нет,
здесь не видны, и полнота не гарантирована. Повторное распознавание из ревью вернёт полный список.</p>
{{else if lt .Planned .Total}}
<p class="tbl-note">В план распознавания попало {{.Planned}} файлов из {{.Total}} —
остальные показаны ниже пометкой «не в плане».</p>
{{end}}
<div class="table-wrap"> <div class="table-wrap">
<table class="tbl"> <table class="tbl">
<thead><tr><th class="cell-num">#</th><th>файл источника → раскладка</th><th>роль</th></tr></thead> <thead><tr>
<th class="cell-num" title="номер файла в списке распознавания — им же его адресует ответ модели">#</th>
<th>файл источника → раскладка</th><th>роль</th>
</tr></thead>
<tbody> <tbody>
{{range $i, $f := .}} {{range .Rows}}
<tr class="{{if $f.Ignored}}ignored{{end}}"> <tr class="{{if .Ignored}}ignored{{end}}">
<td class="cell-num">{{add $i 1}}</td> <td class="cell-num">{{if .Num}}{{.Num}}{{end}}</td>
<td> <td>
<div class="fname">{{$f.Src}} <span class="arr"></span></div> <div class="fname">{{.Src}} <span class="arr"></span></div>
<div class="link-to"> <div class="link-to">
{{if $f.Linked}}<span class="src">{{$f.Dst}}</span>{{else}}<span class="no-link">не разложен</span>{{end}} {{if .Linked}}<span class="src">{{.Dst}}</span>{{else if .Unplanned}}<span class="no-link">модель не разметила этот файл</span>{{else}}<span class="no-link">не разложен</span>{{end}}
</div> </div>
</td> </td>
<td><span class="badge {{if $f.Ignored}}st-cancelled{{else}}st-done{{end}}">{{$f.RoleLabel}}</span></td> <td><span class="badge {{if .Unplanned}}st-review{{else if .Ignored}}st-cancelled{{else}}st-done{{end}}">{{.RoleLabel}}</span></td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>