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

- модель адресует файл номером строки нашего списка вместо копии пути: ответ
  на 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
+24 -4
View File
@@ -96,16 +96,36 @@ func TestМиграцииБезAutoincrementИСерверногоВремени
// docs/conventions/errors.md: сравнение ошибок — errors.Is/errors.As, никогда
// по тексту. errorlint ловит `err == ErrX` и приведение типа, но не матчинг
// подстрокой — его ловим здесь.
//
// Прямой формы мало: `msg := strings.ToLower(err.Error())` и следом
// `strings.Contains(msg, …)` — то же сравнение по тексту, просто через
// промежуточную переменную (так оно и просочилось в recognize). Поэтому вторым
// проходом собираем имена, которым присвоен `.Error()`, и ищем их в тех же
// сравнениях.
func TestОшибкиНеМатчатсяПоТексту(t *testing.T) {
re := regexp.MustCompile(`(strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\([^)]*\.Error\(\)|\.Error\(\)\s*==)`)
direct := regexp.MustCompile(`(strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\([^)]*\.Error\(\)|\.Error\(\)\s*==)`)
// Присваивание текста ошибки переменной, в т.ч. завёрнутое в вызовы
// (strings.ToLower, strings.TrimSpace и подобные).
assigned := regexp.MustCompile(`(?m)^\s*(\w+)\s*:?=\s*(?:[\w.]+\()*\s*[\w.]+\.Error\(\)`)
const why = "ошибку матчим через errors.Is/errors.As, а не по тексту сообщения"
for _, path := range goFiles(t) {
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("читаю %s: %v", path, err)
}
if loc := re.FindIndex(body); loc != nil {
rel, _ := filepath.Rel(repoRoot, path)
t.Errorf("%s:%d — ошибку матчим через errors.Is/errors.As, а не по тексту сообщения", rel, lineOf(body, loc[0]))
rel, _ := filepath.Rel(repoRoot, path)
if loc := direct.FindIndex(body); loc != nil {
t.Errorf("%s:%d — %s", rel, lineOf(body, loc[0]), why)
}
for _, m := range assigned.FindAllSubmatch(body, -1) {
name := regexp.QuoteMeta(string(m[1]))
used := regexp.MustCompile(
`strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\(\s*` + name + `\b` +
`|\b` + name + `\s*==\s*"` + `|"\s*==\s*` + name + `\b`)
if loc := used.FindIndex(body); loc != nil {
t.Errorf("%s:%d — %s (текст ошибки положен в %s)",
rel, lineOf(body, loc[0]), why, m[1])
}
}
}
}
+26 -4
View File
@@ -127,9 +127,17 @@ type Worker struct {
SourceMissingThreshold int `toml:"source_missing_threshold"`
}
// Recognition — пороги распознавания.
// Recognition — пороги и пределы распознавания.
type Recognition struct {
AutoConfidenceThreshold float64 `toml:"auto_confidence_threshold"`
// MaxFiles — предохранитель от аномальной раздачи: сколько файлов
// максимум печатать модели в промпте. Рабочим ограничением быть не должен
// (усечение уводит задачу в review отдельной причиной), поэтому значение
// держим на уровне промпта, который модель заведомо принимает.
MaxFiles int `toml:"max_files"`
// MaxTokens — предел длины ответа модели. Обрыв по нему распознаётся
// отдельно (finish_reason = length) и уводит в review без повтора запроса.
MaxTokens int `toml:"max_tokens"`
}
// Telegram — настройки бота (Ф5).
@@ -232,9 +240,17 @@ func Default() *Config {
CatchTimeout: Duration(10 * time.Minute),
SourceMissingThreshold: 3,
},
Recognition: Recognition{AutoConfidenceThreshold: 0.85},
HTTP: HTTP{Listen: ":8080"},
Log: Log{Level: "info", Format: "json"},
Recognition: Recognition{
AutoConfidenceThreshold: 0.85,
// 500 файлов — порядка 35k токенов промпта: столько модель принимает,
// а раздача крупнее уже требует разбираться руками. Согласие с
// предохранителями конструктора (recognize.defaultMaxFiles /
// defaultMaxTokens) сторожит TestDefaultsAgreeWithConfig.
MaxFiles: 500,
MaxTokens: 8000,
},
HTTP: HTTP{Listen: ":8080"},
Log: Log{Level: "info", Format: "json"},
}
}
@@ -307,6 +323,12 @@ func (c *Config) validate() error {
if t := c.Recognition.AutoConfidenceThreshold; t < 0 || t > 1 {
errs = append(errs, fmt.Errorf("recognition.auto_confidence_threshold %.3f is out of range [0, 1]", t))
}
if c.Recognition.MaxFiles < 1 {
errs = append(errs, fmt.Errorf("recognition.max_files %d must be >= 1", c.Recognition.MaxFiles))
}
if c.Recognition.MaxTokens < 1 {
errs = append(errs, fmt.Errorf("recognition.max_tokens %d must be >= 1", c.Recognition.MaxTokens))
}
if c.LLM.MaxRetries < 0 {
errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries))
}
+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 // ссылка на запись метабазы (пусто — показываем текстом)
NoBase bool
Confidence string
Files []fileRow
Files layoutWidget
// Живая статистика раздачи (заполняется из снимка воркера).
Seeding seedingView
@@ -178,7 +178,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
}
// Файл источника → целевой путь из превью (единая логика layout).
view.Files = buildFileRows(rd.Plan, rd.Preview)
view.Files = buildFileRows(rd.Plan, rd.Preview, rd.SourceFiles, rd.SourceFilesKnown)
}
// Живая статистика раздачи — со значениями уже в первом кадре; секция
+152 -16
View File
@@ -1,6 +1,8 @@
package httpapi
import (
"sort"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/recognize"
)
@@ -9,31 +11,165 @@ import (
// ревью и просмотра загрузки. Целевой путь берётся из превью (единая логика
// internal/layout), а не вычисляется в шаблоне.
type fileRow struct {
// Num — номер файла в списке распознавания: тот самый, которым его
// адресует ответ модели. 0 — номер неизвестен (снимка списка нет), и тогда
// он не показывается вовсе: расхождение сделало бы разбор ошибок адресации
// ложным.
Num int
Src string
Dst string // целевой путь; пусто — файл не раскладывается
RoleLabel string
Linked bool
Ignored bool
// Unplanned — файла нет в плане распознавания: модель его не разметила.
// Молчаливый пропуск файла моделью не должен быть невидимым.
Unplanned bool
season int // ключи сортировки; 0 у файлов без нумерации
episode int
group int // 0 — разложенные, 1 — план без цели, 2 — вне плана
}
// buildFileRows сшивает файлы плана с целевыми путями из превью раскладки.
func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow {
dstBySrc := make(map[string]string, len(preview))
for _, l := range preview {
dstBySrc[l.Src] = l.Dst
// layoutWidget — данные виджета раскладки: строки плюс честность о полноте.
type layoutWidget struct {
Rows []fileRow
// SourceUnknown — списка файлов раздачи для этой загрузки нет (запись
// создана до того, как система стала его сохранять). Строки построены по
// плану, и виджет обязан назвать это прямо.
SourceUnknown bool
Planned int // файлов в плане
Total int // всего файлов раздачи (0, когда список неизвестен)
}
// buildFileRows сшивает файлы РАЗДАЧИ с планом и целевыми путями из превью.
// files — снимок списка файлов раздачи момента распознавания (в порядке
// нумерации промпта); nil означает, что снимка нет: тогда строки строятся по
// плану, а виджет сообщает о неполноте.
func buildFileRows(plan recognize.Plan, preview []layout.Link, files []recognize.File, known bool) layoutWidget {
dsts := planDsts(plan, preview)
// Строки строятся по ЭЛЕМЕНТАМ плана, а не по путям источников: план из БД
// вправе разметить один файл двумя элементами (сдвоенный эпизод в записях,
// сделанных до индексной адресации). Схлопывание таких элементов по пути
// показало бы одну цель там, где Apply создаст два хардлинка, — предпросмотр
// перестал бы быть равен применению (см. требование web-ui).
planBySrc := make(map[string][]int, len(plan.Files))
distinct := make(map[string]bool, len(plan.Files))
for i, f := range plan.Files {
planBySrc[f.Src] = append(planBySrc[f.Src], i)
distinct[f.Src] = true
}
rows := make([]fileRow, 0, len(plan.Files))
for _, f := range plan.Files {
dst := dstBySrc[f.Src]
rows = append(rows, fileRow{
Src: f.Src,
Dst: dst,
RoleLabel: roleLabel(string(f.Role)),
Linked: dst != "",
Ignored: f.Role == "ignore",
})
w := layoutWidget{
SourceUnknown: !known,
Planned: len(distinct),
Rows: make([]fileRow, 0, len(files)+len(plan.Files)),
}
return rows
if known {
w.Total = len(files)
}
seen := make(map[string]bool, len(files))
for i, f := range files {
seen[f.Path] = true
idxs := planBySrc[f.Path]
if len(idxs) == 0 {
w.Rows = append(w.Rows, newFileRow(i+1, f.Path, "", recognize.PlanFile{}, false))
continue
}
for _, j := range idxs {
w.Rows = append(w.Rows, newFileRow(i+1, f.Path, dsts[j], plan.Files[j], true))
}
}
// План может ссылаться на файл, которого в снимке нет (снимка нет вовсе
// либо раздачу переименовали после распознавания) — такую строку всё равно
// показываем, но без номера: он ничего не адресует.
for j, f := range plan.Files {
if seen[f.Src] {
continue
}
w.Rows = append(w.Rows, newFileRow(0, f.Src, dsts[j], f, true))
}
// Порядок задан полностью и не зависит от порядка файлов в плане:
// разложенные (сезон, серия, путь) → файлы плана без цели → файлы вне
// плана. Пустые season/episode — указатели, и свёрнутые в ноль они
// поставили бы семплы и скриншоты перед первым сезоном, поэтому место
// таких файлов задаёт group, а не умолчание нумерации.
sort.SliceStable(w.Rows, func(i, j int) bool {
a, b := w.Rows[i], w.Rows[j]
switch {
case a.group != b.group:
return a.group < b.group
case a.group == 0 && a.season != b.season:
return a.season < b.season
case a.group == 0 && a.episode != b.episode:
return a.episode < b.episode
default:
return a.Src < b.Src
}
})
return w
}
// planDsts — целевой путь КАЖДОГО элемента плана (пусто — элемент не
// раскладывается). Карта «путь → цель» тут не годится: у двух элементов с одним
// источником цели разные. Превью строится по этому же плану, в том же порядке и
// только по раскладываемым ролям, поэтому элементы и ссылки сопоставляются
// проходом в два указателя; расхождение (превью от другого плана) оставляет
// строку без цели, но не сдвигает остальные.
func planDsts(plan recognize.Plan, preview []layout.Link) []string {
out := make([]string, len(plan.Files))
k := 0
for i, f := range plan.Files {
if !linkableRole(f.Role) || k >= len(preview) || preview[k].Src != f.Src {
continue
}
out[i] = preview[k].Dst
k++
}
return out
}
// linkableRole — роль, которую раскладка превращает в ссылку (зеркало
// worker.mapRole; здесь нужна лишь для сопоставления строк с превью, и промах
// стоит пустой цели в строке, а не неверной раскладки).
func linkableRole(r recognize.FileRole) bool {
switch r {
case recognize.RoleMain, recognize.RoleEpisode, recognize.RoleSubtitle:
return true
default:
return false
}
}
// newFileRow собирает строку виджета по файлу раздачи и его записи в плане.
func newFileRow(num int, src, dst string, pf recognize.PlanFile, inPlan bool) fileRow {
row := fileRow{
Num: num,
Src: src,
Dst: dst,
Linked: dst != "",
Unplanned: !inPlan,
group: 2,
}
if !inPlan {
row.RoleLabel = "не в плане"
return row
}
row.RoleLabel = roleLabel(string(pf.Role))
row.Ignored = pf.Role == "ignore"
row.group = 1
if row.Linked {
row.group = 0
}
if pf.Season != nil {
row.season = *pf.Season
}
if pf.Episode != nil {
row.episode = *pf.Episode
}
return row
}
// roleLabel — человекочитаемая роль файла раскладки.
+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
Reasons []string
Hints []string
Files []fileRow
Files layoutWidget
HasPlan bool
HasLinks bool // есть хотя бы один целевой путь → можно применять
NoBase bool // выбрано «без базы»
@@ -145,7 +145,7 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
if rec.Confidence.Valid {
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
}
view.Files = buildFileRows(rd.Plan, rd.Preview)
view.Files = buildFileRows(rd.Plan, rd.Preview, rd.SourceFiles, rd.SourceFilesKnown)
view.HasPlan = len(rd.Plan.Files) > 0
view.HasLinks = len(rd.Preview) > 0
for _, src := range rd.Sources {
+20
View File
@@ -54,8 +54,17 @@ type Response struct {
Content string
Model string
Usage Usage
// FinishReason — причина завершения генерации, как её назвал провайдер
// ("stop", "length", …). Пусто — провайдер её не сообщил. Нужна
// вызывающему: обрыв по длине неотличим от мусора по одному лишь телу
// ответа, а повторять такой запрос бессмысленно (см. recognize).
FinishReason string
}
// FinishLength — значение FinishReason, которым OpenAI-совместимые провайдеры
// сообщают обрыв генерации по достижении предела токенов.
const FinishLength = "length"
// Provider — абстракция доступа к LLM. recognize работает только с ним и не
// знает про конкретный транспорт.
type Provider interface {
@@ -75,6 +84,17 @@ type Config struct {
// ErrUnknownType — запрошенный [llm].type не поддерживается.
var ErrUnknownType = errors.New("llm: unknown provider type")
// ErrRequestTooLarge — провайдер отказал из-за размера самого запроса
// (переполнение контекста), а не из-за его содержимого. Вызывающему это
// отдельная ветвь: такой отказ не лечится повтором, а называется человеку
// своей причиной и крутится настройкой ([recognition].max_files).
//
// Признак ставит транспорт — там, где ещё целы HTTP-статус и полное тело
// ответа (см. openai.go). Вызывающий проверяет его errors.Is: у итоговой
// ошибки текст обрезан и в него попадает эхо запроса, так что матчить по нему
// нельзя (docs/conventions/errors.md).
var ErrRequestTooLarge = errors.New("llm: request too large")
// New собирает провайдер по дискриминатору cfg.Type. logger nil → slog.Default().
func New(cfg Config, logger *slog.Logger) (Provider, error) {
if logger == nil {
+70
View File
@@ -3,6 +3,7 @@ package llm
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -234,3 +235,72 @@ func TestNew_OpenAICompatValidation(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}
// Признак обрыва генерации по длине доходит до вызывающего: по одному телу
// ответа обрыв неотличим от мусора, а повторять такой запрос бессмысленно.
func TestComplete_FinishReasonReachesCaller(t *testing.T) {
for _, want := range []string{"length", "stop"} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"model":"m","choices":[{"message":{"content":"{\"type\":"},`+
`"finish_reason":"`+want+`"}]}`)
}))
p := newTestProvider(t, srv.URL, "")
resp, err := p.Complete(context.Background(), Request{
Messages: []Message{{Role: RoleUser, Content: "hi"}},
})
srv.Close()
if err != nil {
t.Fatalf("Complete: %v", err)
}
if resp.FinishReason != want {
t.Errorf("finish_reason = %q, want %q", resp.FinishReason, want)
}
}
if FinishLength != "length" {
t.Errorf("FinishLength = %q, want length", FinishLength)
}
}
// Отказ по размеру запроса помечается sentinel'ом там, где ещё целы
// HTTP-статус и ПОЛНОЕ тело: вызывающему нечего матчить по тексту, а текст
// итоговой ошибки обрезан (snippet) и несёт эхо запроса.
func TestComplete_RequestTooLargeSentinel(t *testing.T) {
cases := []struct {
name string
status int
body string
want bool
}{
{"413 без разбора текста", http.StatusRequestEntityTooLarge, `payload too big`, true},
{"400 с маркером в теле", http.StatusBadRequest,
`{"error":{"message":"This model's maximum context length is 128000 tokens"}}`, true},
{"400 с маркером за пределом snippet", http.StatusBadRequest,
`{"echo":"` + strings.Repeat("x", 400) + `","error":{"message":"prompt is too long"}}`, true},
{"400 по другой причине", http.StatusBadRequest,
`{"error":{"message":"invalid api key"}}`, false},
{"500 — транзиентный сбой, не размер", http.StatusInternalServerError,
`context length exceeded`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.body)
}))
defer srv.Close()
p := newTestProvider(t, srv.URL, "")
_, err := p.Complete(context.Background(), Request{
Messages: []Message{{Role: RoleUser, Content: "hi"}},
})
if err == nil {
t.Fatal("ожидалась ошибка")
}
if got := errors.Is(err, ErrRequestTooLarge); got != tc.want {
t.Errorf("errors.Is(err, ErrRequestTooLarge) = %v, want %v (err = %v)",
got, tc.want, err)
}
})
}
}
+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 {
retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
return Response{}, retryable, fmt.Errorf("llm: status %d: %s",
resp.StatusCode, snippet(raw))
err := fmt.Errorf("llm: status %d: %s", resp.StatusCode, snippet(raw))
if requestTooLarge(resp.StatusCode, raw) {
err = fmt.Errorf("%w: %w", ErrRequestTooLarge, err)
}
return Response{}, retryable, err
}
var cr chatResponse
@@ -209,8 +212,9 @@ func (c *openAICompat) do(ctx context.Context, body []byte) (Response, bool, err
}
return Response{
Content: cr.Choices[0].Message.Content,
Model: cr.Model,
Content: cr.Choices[0].Message.Content,
Model: cr.Model,
FinishReason: cr.Choices[0].FinishReason,
Usage: Usage{
PromptTokens: cr.Usage.PromptTokens,
CompletionTokens: cr.Usage.CompletionTokens,
@@ -236,6 +240,36 @@ func (c *openAICompat) wait(ctx context.Context, attempt int) error {
}
}
// tooLargeMarkers — текстовые признаки переполнения контекста в теле отказа.
// Единого машинного кода у OpenAI-совместимых шлюзов нет: часть отдаёт 413,
// остальные кладут причину в текст при обычном 400.
var tooLargeMarkers = []string{
"context length", "context_length", "context window", "maximum context",
"too many tokens", "prompt is too long", "request too large",
"reduce the length",
}
// requestTooLarge — отказал ли провайдер из-за размера запроса. 413 —
// безусловно, без разбора текста. 400 — только по маркерам, и смотрим их в
// ПОЛНОМ теле: обрезка тела (snippet) выбрасывает причину ровно у тех шлюзов,
// которые сперва echo'ят запрос. Ложное срабатывание безопасно: задача уходит
// в review, а не в повтор.
func requestTooLarge(status int, body []byte) bool {
if status == http.StatusRequestEntityTooLarge {
return true
}
if status != http.StatusBadRequest {
return false
}
lower := strings.ToLower(string(body))
for _, marker := range tooLargeMarkers {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
// snippet обрезает тело для сообщения об ошибке.
func snippet(b []byte) string {
const max = 300
+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)",
"files": [
{
"src": "путь файла из списка ниже, БЕЗ размера в скобках в конце строки",
"i": номер файла из списка ниже (целое, ровно как напечатано),
"role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore",
"season": число или null,
"episode": число или null
}
],
"confidence": число 0..1,
"notes": "пояснения и неоднозначности или пустая строка"
"notes": "неоднозначности, не больше пары предложений, или пустая строка"
}
Правила:
@@ -53,10 +53,12 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
происхождения — продублируй "title" в "original_title". Если НЕ уверен в
оригинальном названии — продублируй "title", но НЕ выдумывай название.
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
- Поле "i" — номер файла из напечатанного ниже списка, ровно как он там стоит.
Путь файла копировать НЕ нужно: он у нас уже есть, мы подставим его сами.
- Один и тот же файл адресуй не больше одного раза.
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
- Для фильма ровно один основной видеофайл role "main".
- Поле src — это путь файла из списка, скопированный дословно, но БЕЗ размера
«(…)» в конце строки; не выдумывай и не нормализуй пути.
- Для ролей "sample", "ignore" и "extra" номера сезона и серии не нужны — оставляй null.
- Внешние субтитры — role "subtitle".`
const systemPromptBase = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента,
@@ -89,15 +91,18 @@ func systemPrompt(lang string) string {
return systemPromptBase + schemaText + languageDirective(lang)
}
// buildMessages собирает системное и пользовательское сообщения.
func buildMessages(in Input, pre PreParse, maxFiles int, lang string) []llm.Message {
// buildMessages собирает системное и пользовательское сообщения. total —
// полное число файлов раздачи (in.Files уже упорядочен и, возможно, усечён
// пределом): список печатается ровно тем срезом, по которому потом резолвятся
// номера.
func buildMessages(in Input, pre PreParse, total int, lang string) []llm.Message {
return []llm.Message{
{Role: llm.RoleSystem, Content: systemPrompt(lang)},
{Role: llm.RoleUser, Content: userPrompt(in, pre, maxFiles)},
{Role: llm.RoleUser, Content: userPrompt(in, pre, total)},
}
}
func userPrompt(in Input, pre PreParse, maxFiles int) string {
func userPrompt(in Input, pre PreParse, total int) string {
var b strings.Builder
b.WriteString("Имя торрента: ")
b.WriteString(orNone(in.Name))
@@ -122,7 +127,7 @@ func userPrompt(in Input, pre PreParse, maxFiles int) string {
b.WriteString(preParseLine(pre))
b.WriteString("\n\n")
writeFileList(&b, in.Files, maxFiles)
writeFileList(&b, in.Files, total)
return b.String()
}
@@ -149,19 +154,15 @@ func preParseLine(pre PreParse) string {
return strings.Join(parts, ", ")
}
// writeFileList печатает список файлов, усекая до maxFiles. src в плане
// должен дословно совпадать с путями отсюда.
func writeFileList(b *strings.Builder, files []File, maxFiles int) {
n := len(files)
shown := n
if maxFiles > 0 && shown > maxFiles {
shown = maxFiles
}
b.WriteString("Файлы (")
b.WriteString(strconv.Itoa(n))
b.WriteString("). В src копируй ТОЛЬКО путь — текст после номера и до размера ")
b.WriteString("в скобках; размер «(…)» в конце строки в src НЕ включай:\n")
for i := 0; i < shown; i++ {
// writeFileList печатает нумерованный список файлов. Номер строки — то, что
// модель возвращает в поле "i"; усечение (если оно было) сделал вызывающий,
// total — полное число файлов раздачи.
func writeFileList(b *strings.Builder, files []File, total int) {
b.WriteString("Файлы раздачи (")
b.WriteString(strconv.Itoa(len(files)))
b.WriteString("). Номер в начале строки — это то, что нужно вернуть в поле \"i\";")
b.WriteString(" путь и размер копировать не нужно:\n")
for i := range files {
b.WriteString(strconv.Itoa(i + 1))
b.WriteString(". ")
b.WriteString(files[i].Path)
@@ -169,22 +170,24 @@ func writeFileList(b *strings.Builder, files []File, maxFiles int) {
b.WriteString(humanSize(files[i].Size))
b.WriteString(")\n")
}
if shown < n {
if total > len(files) {
b.WriteString("… и ещё ")
b.WriteString(strconv.Itoa(n - shown))
b.WriteString(strconv.Itoa(total - len(files)))
b.WriteString(" файлов (список усечён)\n")
}
}
// correctionMessage — сообщение для повторной попытки: что было не так + схема.
func correctionMessage(err error, in Input, maxFiles int) string {
// Список файлов сюда НЕ входит: его номера названы в первом сообщении диалога
// и сохраняют смысл на всех попытках, а повторная печать умножала бы вход на
// число попыток — ровно ту цену, которую снимает индексная адресация.
func correctionMessage(err error) string {
var b strings.Builder
b.WriteString("Ответ не принят: ")
b.WriteString(err.Error())
b.WriteString("\nВерни ИСПРАВЛЕННЫЙ ответ строго по схеме, только JSON.\n\n")
b.WriteString("\nВерни ИСПРАВЛЕННЫЙ ответ строго по схеме, только JSON.")
b.WriteString(" Файлы адресуй номерами из списка, присланного выше.\n\n")
b.WriteString(schemaText)
b.WriteString("\n\n")
writeFileList(&b, in.Files, maxFiles)
return b.String()
}
+171 -27
View File
@@ -11,15 +11,23 @@
// базой), согласованности с пред-парсом и уверенности не ниже порога.
//
// Без включённых баз (или без матча) авто-раскладка не делается — задача
// уходит в review. Выход LLM недоверенный: план принимается только если
// каждый files[].src совпадает с реальным файлом торрента; итоговая
// безопасность пути держится на раскладке (layout).
// уходит в review. Выход LLM недоверенный: файл адресуется НОМЕРОМ строки в
// напечатанном нами списке, а files[].src подставляет резолв номера по этому
// же списку — посторонний путь невыразим. Присланный моделью путь принимается
// лишь как запасной формат и только при точном совпадении с файлом торрента;
// итоговая безопасность пути держится на раскладке (layout).
package recognize
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"path/filepath"
"sort"
"strconv"
"strings"
"git.vakhrushev.me/av/jellybit/internal/llm"
"git.vakhrushev.me/av/jellybit/internal/logctx"
@@ -56,9 +64,29 @@ func (r FileRole) valid() bool {
}
// File — входной файл торрента (путь относительно save_path и размер).
// JSON-теги — для снимка списка файлов рядом с планом (store.Recognition,
// миграция 0012): раскладка обязана показывать и те файлы, которых нет в плане.
type File struct {
Path string
Size int64
Path string `json:"path"`
Size int64 `json:"size"`
}
// videoExts — расширения, по которым файл считается видео (нижний регистр, с
// точкой). Единственное место перечня: на нём держится разделение покрытия
// плана — непокрытый видеофайл означает потерянную серию или фильм и блокирует
// авто-раскладку, непокрытый файл-спутник (субтитры, картинки, тексты) — нет.
// Незнакомое расширение видео попадёт в спутники, поэтому список пополняем
// здесь, а не по месту вызова.
var videoExts = map[string]bool{
".mkv": true, ".mp4": true, ".avi": true, ".m4v": true, ".mov": true,
".wmv": true, ".mpg": true, ".mpeg": true, ".m2ts": true, ".mts": true,
".ts": true, ".vob": true, ".flv": true, ".webm": true, ".ogm": true,
".divx": true, ".rmvb": true, ".3gp": true, ".iso": true, ".img": true,
}
// IsVideoFile — видео ли это по расширению пути. Регистр расширения не важен.
func IsVideoFile(path string) bool {
return videoExts[strings.ToLower(filepath.Ext(path))]
}
// Input — сигналы для распознавания одной раздачи.
@@ -69,13 +97,49 @@ type Input struct {
Hints []string // накопленные подсказки из review (Ф3; в Ф2 обычно пусто)
}
// FileIndex — номер файла в списке, напечатанном в промпте (1-based). Модель
// иногда возвращает его строкой или дробным числом; разбор это терпит и
// сводит к целому, потому что негодный номер обязан отбраковывать элемент, а
// не весь план (см. validate.go). Неразбираемое значение даёт 0 — и элемент
// отбраковывается той же причиной, что и явно присланный ноль.
type FileIndex int
// UnmarshalJSON разбирает номер терпимо: число, строка с числом, дробное с
// нулевой дробной частью, null. Ошибку не возвращает НИКОГДА — иначе один
// кривой элемент ронял бы разбор всего плана.
func (n *FileIndex) UnmarshalJSON(b []byte) error {
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
if s == "" || s == "null" {
*n = 0
return nil
}
if v, err := strconv.Atoi(s); err == nil {
*n = FileIndex(v)
return nil
}
if f, err := strconv.ParseFloat(s, 64); err == nil && f == math.Trunc(f) &&
f > math.MinInt32 && f < math.MaxInt32 {
*n = FileIndex(int(f))
return nil
}
*n = 0
return nil
}
// PlanFile — файл в плане раскладки. Season/Episode заданы на файле, чтобы
// выражать мультисезонные паки и спецвыпуски (см. recognition.md).
type PlanFile struct {
Src string `json:"src"`
Role FileRole `json:"role"`
Season *int `json:"season,omitempty"`
Episode *int `json:"episode,omitempty"`
// Index — чем файл адресован в ответе модели: номер строки нашего списка.
// Src заполняет резолвом сама система, поэтому посторонний путь невыразим.
// Указатель, а не значение: «поле не прислано» и «прислан 0» — разные
// диагнозы. Ноль означает модель, посчитавшую список с нуля, и тогда все
// остальные её номера резолвятся со сдвигом на файл; отсутствие поля —
// запасной формат с путём либо элемент, не адресующий ничего.
Index *FileIndex `json:"i,omitempty"`
Src string `json:"src"`
Role FileRole `json:"role"`
Season *int `json:"season,omitempty"`
Episode *int `json:"episode,omitempty"`
}
// Plan — структурированный результат распознавания (схема ответа LLM).
@@ -129,6 +193,13 @@ type Result struct {
Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи)
Raw string // сырой ответ LLM последней попытки
// Files — ПОЛНЫЙ список файлов раздачи в том порядке, в каком печатается
// промпт (и в каком резолвятся номера). Снимок момента распознавания: его
// сохраняют рядом с планом, чтобы раскладка показывала и файлы вне плана.
// Усечение пределом max_files сюда не распространяется — иначе снимок выдал
// бы показанный модели срез за весь перечень раздачи. Заполнен на всех
// исходах, включая review без разобранного плана.
Files []File
}
// LLM — нужная recognize часть провайдера.
@@ -148,8 +219,12 @@ type Config struct {
}
const (
defaultMaxTokens = 4000
defaultMaxFiles = 100
// Дефолты — предохранители для прямых вызовов конструктора (тесты, CLI без
// конфига). Канонические значения задаёт [recognition] (config.Default);
// равенство им сторожит TestDefaultsAgreeWithConfig — иначе прод и тест
// разъедутся молча.
defaultMaxTokens = 8000
defaultMaxFiles = 500
defaultAutoThreshold = 0.85
defaultLanguage = "en"
)
@@ -210,7 +285,26 @@ func New(provider LLM, providers []metadata.Provider, cfg Config, log *slog.Logg
func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
log := logctx.FromOr(ctx, r.log)
pre := preParse(in.Name)
msgs := buildMessages(in, pre, r.maxFiles, r.language)
// Порядок списка — свойство узла, а не дисциплина вызывающего (сборок
// Input две: воркер и CLI). Печать промпта и резолв номеров идут дальше по
// одному и тому же срезу, поэтому повторное распознавание той же раздачи
// даёт ту же нумерацию.
//
// Усечение пределом max_files — свойство ПРОМПТА и резолва номеров, а не
// снимка: снимок хранит полный список раздачи, иначе виджет раскладки выдаёт
// показанный модели срез за весь перечень («в план попало 100 из 100», когда
// в торренте 250). Показанный список — префикс полного, поэтому номера
// адресации от этого не меняются.
all := sortedFiles(in.Files)
shown := all
if r.maxFiles > 0 && len(shown) > r.maxFiles {
shown = all[:r.maxFiles]
}
in.Files = shown
issues := planIssues{files: shown, all: all}
msgs := buildMessages(in, pre, len(all), r.language)
temp := 0.0
var raw string
@@ -227,32 +321,54 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
MaxTokens: r.maxTokens,
})
if err != nil {
return Result{}, fmt.Errorf("recognize: llm complete: %w", err)
// Отказ по размеру запроса называем своей причиной: текст провайдера
// человеку в ревью ничего не говорит, а лечится это [recognition].max_files.
if errors.Is(err, llm.ErrRequestTooLarge) {
log.Warn("recognition request rejected as too large",
"source_files", len(in.Files), "max_files", r.maxFiles,
"error", err)
return reviewResult(pre, issues, attempts, "", []string{fmt.Sprintf(
"модель отвергла запрос по его размеру: файлов в списке %d"+
" (уменьшите [recognition].max_files)", len(in.Files))}), nil
}
return Result{Files: all}, fmt.Errorf("recognize: llm complete: %w", err)
}
raw = resp.Content
plan, parseErr = parsePlan(raw, in, log)
// Обрыв генерации по длине — не ошибка модели: ответ не поместился.
// Повтор тем же промптом (и его вариантом) дал бы тот же обрыв.
if resp.FinishReason == llm.FinishLength {
log.Warn("recognition llm response truncated",
"attempt", attempts, "max_tokens", r.maxTokens)
return reviewResult(pre, issues, attempts, raw, []string{fmt.Sprintf(
"ответ модели обрезан по пределу длины (max_tokens = %d):"+
" план неполон, повтор тем же запросом не поможет", r.maxTokens)}), nil
}
plan, issues.dropped, parseErr = parsePlan(raw, in, log)
if parseErr == nil {
break
}
log.Warn("recognition llm response unparsed",
"attempt", attempts, "error", parseErr)
// Просим модель исправиться, повторяя схему и ошибку.
// Просим модель исправиться, повторяя схему и ошибку. Список файлов
// НЕ переприсылаем: его номера названы в первом сообщении диалога и
// сохраняют смысл на всех попытках (иначе цена неудачи росла бы вместе
// с размером раздачи).
msgs = append(msgs,
llm.Message{Role: llm.RoleAssistant, Content: raw},
llm.Message{Role: llm.RoleUser, Content: correctionMessage(parseErr, in, r.maxFiles)})
llm.Message{Role: llm.RoleUser, Content: correctionMessage(parseErr)})
}
if parseErr != nil {
return Result{
PreParse: pre,
Attempts: attempts,
Raw: raw,
Decision: Decision{
Auto: false,
Reasons: []string{"ответ LLM не разобран после " + itoa(attempts) + " попыток: " + parseErr.Error()},
},
}, nil
// Претензии последней попытки — единственное поэлементное объяснение
// («номер 181 вне диапазона 1..2»); без них человек читает голое «ответ
// LLM не разобран». decide на успешной ветке добавляет их так же.
reasons := []string{
"ответ LLM не разобран после " + itoa(attempts) + " попыток: " + parseErr.Error(),
}
reasons = append(reasons, issues.dropped...)
return reviewResult(pre, issues, attempts, raw, reasons), nil
}
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
@@ -275,10 +391,11 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
}
}
dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold)
dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold, issues)
log.Info("recognition done",
"media_type", plan.Type, "title", plan.Title, "year", plan.Year,
"files", len(plan.Files), "attempts", attempts,
"files", len(plan.Files), "source_files", len(in.Files),
"dropped", len(issues.dropped), "attempts", attempts,
"matched", match != nil, "candidates", len(candidates),
"auto", dec.Auto, "reasons", len(dec.Reasons))
return Result{
@@ -289,5 +406,32 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
Candidates: candidates,
Attempts: attempts,
Raw: raw,
Files: all,
}, nil
}
// reviewResult — исход без пригодного плана: задача уходит в review с
// названными причинами. Снимок списка файлов отдаём и здесь: он нужен
// раскладке и повторному распознаванию из ревью.
func reviewResult(pre PreParse, issues planIssues, attempts int, raw string, reasons []string) Result {
if issues.truncated() > 0 {
reasons = append(reasons, truncationReason(issues))
}
return Result{
PreParse: pre,
Attempts: attempts,
Raw: raw,
Files: issues.all,
Decision: Decision{Auto: false, Reasons: reasons},
}
}
// sortedFiles — детерминированный порядок списка файлов (по пути). Возвращает
// НОВЫЙ срез: входной принадлежит вызывающему, менять его порядок нельзя.
// Путь внутри торрента уникален, поэтому порядок воспроизводим между вызовами.
func sortedFiles(files []File) []File {
out := make([]File, len(files))
copy(out, files)
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
+125 -12
View File
@@ -3,10 +3,12 @@ package recognize
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"testing"
"git.vakhrushev.me/av/jellybit/internal/config"
"git.vakhrushev.me/av/jellybit/internal/llm"
)
@@ -35,6 +37,13 @@ func (f *fakeLLM) Complete(_ context.Context, req llm.Request) (llm.Response, er
return llm.Response{Content: content}, nil
}
// errRequestTooLarge — типовой отказ OpenAI-совместимого шлюза по размеру
// запроса, как он приходит из internal/llm: текст провайдера плюс sentinel,
// которым транспорт пометил отказ (признак ставится там, где ещё целы статус и
// полное тело, а recognize ветвится по errors.Is, а не по тексту).
var errRequestTooLarge = fmt.Errorf("%w: %w", llm.ErrRequestTooLarge, errors.New(
"llm: status 400: {\"error\":{\"message\":\"This model's maximum context length is 128000 tokens\"}}"))
func testLogger() *slog.Logger {
return slog.New(slog.DiscardHandler)
}
@@ -145,11 +154,15 @@ func TestRecognize_RetriesOnBadSrcThenSucceeds(t *testing.T) {
if res.Plan.Title != "Some Movie" {
t.Errorf("plan = %+v", res.Plan)
}
// Корректирующее сообщение должно содержать схему и список файлов.
// Корректирующее сообщение несёт ошибку и схему, но НЕ список файлов:
// номера названы в первом сообщении диалога.
last := f.lastReq.Messages[len(f.lastReq.Messages)-1]
if !strings.Contains(last.Content, "Ответ не принят") || !strings.Contains(last.Content, "film.mkv") {
if !strings.Contains(last.Content, "Ответ не принят") || !strings.Contains(last.Content, `"i"`) {
t.Errorf("correction message missing context: %q", last.Content)
}
if strings.Contains(last.Content, "film.mkv") {
t.Errorf("correction message must not repeat the file list: %q", last.Content)
}
}
func TestRecognize_ExhaustedRetriesGoesToReview(t *testing.T) {
@@ -222,29 +235,129 @@ func TestRecognize_PromptCarriesSignals(t *testing.T) {
func TestRecognize_FileListTruncated(t *testing.T) {
files := make([]File, 250)
planFiles := make([]string, 0, 250)
for i := range files {
files[i] = File{Path: pathOf(i), Size: 100 << 20}
}
// План ссылается только на первый файл — этого достаточно для схемы.
_ = planFiles
in := Input{Name: "Big.Pack", Files: files}
resp := `{"type":"series","title":"Big","files":[{"src":"` + pathOf(0) +
`","role":"episode","season":1,"episode":1}]}`
// План ссылается только на первый по порядку файл — этого достаточно схеме.
first := sortedFiles(files)[0].Path
resp := `{"type":"series","title":"Big","files":[{"i":1` +
`,"role":"episode","season":1,"episode":1}]}`
f := &fakeLLM{responses: []string{resp}}
r := New(f, nil, Config{MaxFiles: 100}, testLogger())
if _, err := r.Recognize(context.Background(), in); err != nil {
res, err := r.Recognize(context.Background(), in)
if err != nil {
t.Fatalf("Recognize: %v", err)
}
user := f.lastReq.Messages[1].Content
if !strings.Contains(user, "усечён") {
t.Errorf("expected truncation note in prompt")
if !strings.Contains(user, "усечён") || !strings.Contains(user, "и ещё 150") {
t.Errorf("expected truncation note in prompt:\n%s", user)
}
if !strings.Contains(user, "Файлы (250") {
t.Errorf("expected total count 250 in prompt")
if !strings.Contains(user, "Файлы раздачи (100)") {
t.Errorf("expected shown count 100 in prompt")
}
// Усечение — отдельная причина ухода в review, а знаменатель покрытия —
// полное число файлов раздачи, а не усечённого списка.
if !hasReason(res.Decision.Reasons, "усечён пределом") ||
!hasReason(res.Decision.Reasons, "показано 100 из 250") {
t.Errorf("reasons = %v", res.Decision.Reasons)
}
if !hasReason(res.Decision.Reasons, "в план попало 1 файлов из 250") {
t.Errorf("coverage denominator must be the full file count: %v", res.Decision.Reasons)
}
if res.Plan.Files[0].Src != first {
t.Errorf("src = %q, want %q", res.Plan.Files[0].Src, first)
}
// Снимок хранит ПОЛНЫЙ список раздачи: усечение — свойство промпта, а не
// перечня, иначе виджет раскладки выдаст показанный модели срез за всю
// раздачу. Показанный список — префикс полного, номера адресации те же.
if len(res.Files) != 250 {
t.Errorf("snapshot = %d files, want полный список из 250", len(res.Files))
}
if res.Files[0].Path != first {
t.Errorf("снимок обязан идти в порядке нумерации промпта: %q", res.Files[0].Path)
}
}
func pathOf(i int) string {
return "show/ep" + itoa(i) + ".mkv"
}
// Список урезан пределом И пригодного плана модель не дала: причина усечения
// обязана быть названа наравне с причиной неразобранного ответа, а поэлементные
// претензии последней попытки — не потеряться. Без них человек читает голое
// «ответ LLM не разобран» и не узнаёт ни про усечение, ни про то, чем именно
// ответ негоден.
func TestRecognize_TruncatedListAndUnusablePlan(t *testing.T) {
files := make([]File, 250)
for i := range files {
files[i] = File{Path: pathOf(i), Size: 100 << 20}
}
in := Input{Name: "Big.Pack", Files: files}
// Единственный элемент адресует файл вне показанного списка — годных не
// осталось, план считается неразобранным.
resp := `{"type":"series","title":"Big","files":[` +
`{"i":900,"role":"episode","season":1,"episode":1}]}`
f := &fakeLLM{responses: []string{resp}}
r := New(f, nil, Config{MaxRetries: 1, MaxFiles: 100}, testLogger())
res, err := r.Recognize(context.Background(), in)
if err != nil {
t.Fatalf("неразобранный ответ — не ошибка распознавания: %v", err)
}
if res.Decision.Auto {
t.Error("плана нет — авто недопустимо")
}
if !hasReason(res.Decision.Reasons, "не разобран") {
t.Errorf("reasons = %v", res.Decision.Reasons)
}
if !hasReason(res.Decision.Reasons, "усечён пределом") ||
!hasReason(res.Decision.Reasons, "показано 100 из 250") {
t.Errorf("усечение обязано быть названо и на этом исходе: %v", res.Decision.Reasons)
}
if !hasReason(res.Decision.Reasons, "вне диапазона 1..100") {
t.Errorf("претензии последней попытки потеряны: %v", res.Decision.Reasons)
}
if len(res.Files) != 250 {
t.Errorf("снимок = %d файлов, ожидался полный список раздачи", len(res.Files))
}
}
// Отказ по размеру запроса на усечённом списке тоже называет усечение.
func TestRecognize_TruncatedListAndRequestTooLarge(t *testing.T) {
files := make([]File, 250)
for i := range files {
files[i] = File{Path: pathOf(i), Size: 100 << 20}
}
in := Input{Name: "Big.Pack", Files: files}
f := &fakeLLM{errs: []error{errRequestTooLarge}}
r := New(f, nil, Config{MaxRetries: 2, MaxFiles: 100}, testLogger())
res, err := r.Recognize(context.Background(), in)
if err != nil {
t.Fatalf("отказ по размеру — не ошибка распознавания: %v", err)
}
if !hasReason(res.Decision.Reasons, "по его размеру") ||
!hasReason(res.Decision.Reasons, "усечён пределом") {
t.Errorf("reasons = %v", res.Decision.Reasons)
}
}
// Дефолты распознавания живут в двух местах: канонические — в [recognition]
// (config.Default), предохранители прямых вызовов конструктора — здесь.
// Согласие держалось комментарием; теперь его держит этот страж.
func TestDefaultsAgreeWithConfig(t *testing.T) {
rec := config.Default().Recognition
if rec.MaxFiles != defaultMaxFiles {
t.Errorf("[recognition].max_files = %d, recognize.defaultMaxFiles = %d",
rec.MaxFiles, defaultMaxFiles)
}
if rec.MaxTokens != defaultMaxTokens {
t.Errorf("[recognition].max_tokens = %d, recognize.defaultMaxTokens = %d",
rec.MaxTokens, defaultMaxTokens)
}
if rec.AutoConfidenceThreshold != defaultAutoThreshold {
t.Errorf("[recognition].auto_confidence_threshold = %v, recognize.defaultAutoThreshold = %v",
rec.AutoConfidenceThreshold, defaultAutoThreshold)
}
}
+349 -55
View File
@@ -5,22 +5,25 @@ import (
"fmt"
"log/slog"
"sort"
"strconv"
"strings"
"git.vakhrushev.me/av/jellybit/internal/llm"
)
// parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему.
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные
// предупреждения (см. decide) ошибкой не считаются — они уводят в review.
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Второй
// результат — претензии к отдельным элементам files[]: они НЕ ошибка разбора
// (повторный запрос к модели не делается), а причины ухода в review.
// Структурные предупреждения (см. decide) ошибкой тоже не считаются.
//
// Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см.
// sanitizePlan) ДО структурной валидации: так, например, title из одних
// zero-width символов схлопывается в пустой и корректно уводит в ретрай.
func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) {
func parsePlan(raw string, in Input, log *slog.Logger) (Plan, []string, error) {
jsonStr, err := llm.ExtractJSONObject(raw)
if err != nil {
return Plan{}, fmt.Errorf("no JSON object in response")
return Plan{}, nil, fmt.Errorf("no JSON object in response")
}
var p Plan
@@ -30,57 +33,212 @@ func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) {
// Повторяем без строгого режима: лишние поля — не повод падать,
// но если и так не разобралось — это ошибка схемы.
if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil {
return Plan{}, fmt.Errorf("JSON not parsed: %w", err2)
return Plan{}, nil, fmt.Errorf("JSON not parsed: %w", err2)
}
}
sanitizePlan(&p, log)
if err := validateSchema(&p, in); err != nil {
return Plan{}, err
problems, err := validateSchema(&p, in)
if err != nil {
return Plan{}, problems, err
}
return p, nil
return p, problems, nil
}
// validateSchema проверяет обязательную структуру плана. Главный инвариант
// безопасности: каждый files[].src совпадает с реальным файлом торрента —
// недоверенный выход LLM не может сослаться на посторонний путь.
func validateSchema(p *Plan, in Input) error {
// validateSchema проверяет обязательную структуру плана и резолвит адресацию
// файлов. Главный инвариант безопасности: files[].src заполняем МЫ — из своего
// же списка, по номеру строки (i), напечатанному в промпте. Присланный моделью
// путь принимается лишь как запасной формат и только при точном совпадении с
// файлом торрента, поэтому сослаться на посторонний путь невозможно.
//
// Негодный элемент (номер вне диапазона, повторная адресация, ни номера ни
// пути, неизвестный путь) отбрасывается поимённой претензией — план при этом
// живёт: одна ошибка в одном из сотен элементов не должна ронять всю работу.
// Ошибка возвращается только когда плана не осталось вовсе.
func validateSchema(p *Plan, in Input) ([]string, error) {
switch p.Type {
case MediaMovie, MediaSeries:
case "":
return fmt.Errorf("field type is empty (expected movie or series)")
return nil, fmt.Errorf("field type is empty (expected movie or series)")
default:
return fmt.Errorf("unknown type %q", p.Type)
return nil, fmt.Errorf("unknown type %q", p.Type)
}
if strings.TrimSpace(p.Title) == "" {
return fmt.Errorf("field title is empty")
return nil, fmt.Errorf("field title is empty")
}
if len(p.Files) == 0 {
return fmt.Errorf("files list is empty")
return nil, fmt.Errorf("files list is empty")
}
known := make(map[string]bool, len(in.Files))
for _, f := range in.Files {
known[f.Path] = true
byPath := make(map[string]int, len(in.Files))
for i, f := range in.Files {
byPath[f.Path] = i
}
var problems problemList
taken := make(map[int]bool, len(p.Files))
kept := p.Files[:0]
for i := range p.Files {
pf := &p.Files[i]
pf := p.Files[i]
// Роль вне перечня — ошибка схемы, а не адресации: модель ошиблась в
// нашем же словаре, и это чинится повторной попыткой. Проверка стоит ДО
// резолва, поэтому pf.Src ещё пуст: адресуем элемент так, как его назвала
// сама модель, иначе ей (и человеку) нечего чинить в списке на 180 строк.
if !pf.Role.valid() {
return fmt.Errorf("file %q: unknown role %q", pf.Src, pf.Role)
return problems.result(), fmt.Errorf("file %s: unknown role %q",
planFileRef(pf), shorten(string(pf.Role)))
}
if strings.TrimSpace(pf.Src) == "" {
return fmt.Errorf("file with empty src")
idx, problem, ok := resolveFile(pf, in.Files, byPath)
if problem != "" {
problems.add(problem)
}
if !known[pf.Src] {
return fmt.Errorf("src %q not found among torrent files", pf.Src)
if !ok {
continue
}
if taken[idx] {
problems.add(fmt.Sprintf(
"файл %d (%s) адресован повторно — лишний элемент плана отброшен",
idx+1, shorten(in.Files[idx].Path)))
continue
}
if pf.Role == RoleEpisode && pf.Episode == nil {
return fmt.Errorf("episode %q has no episode number", pf.Src)
return problems.result(), fmt.Errorf("episode %q has no episode number",
shorten(in.Files[idx].Path))
}
taken[idx] = true
n := FileIndex(idx + 1)
pf.Index = &n
pf.Src = in.Files[idx].Path
kept = append(kept, pf)
}
return nil
p.Files = kept
if len(p.Files) == 0 {
return problems.result(), fmt.Errorf("no files[] element addresses a real torrent file")
}
return problems.result(), nil
}
// maxProblems — сколько поимённых претензий к элементам плана попадает в
// причины; остальные сворачиваются в счётчик. Причины уезжают в баннер ревью,
// в карточку Telegram и в БД навсегда, а на раздаче из 180 файлов негодным
// может оказаться каждый элемент.
const maxProblems = 12
// problemList копит претензии к элементам плана, не давая им расти без предела.
type problemList struct {
items []string
total int
}
func (l *problemList) add(s string) {
l.total++
if len(l.items) < maxProblems {
l.items = append(l.items, s)
}
}
// result — накопленное плюс свёрнутый хвост.
func (l *problemList) result() []string {
if l.total <= len(l.items) {
return l.items
}
return append(l.items, fmt.Sprintf("и ещё %d претензий к элементам плана",
l.total-len(l.items)))
}
// planFileRef — как элемент плана адресован МОДЕЛЬЮ: номером нашего списка,
// иначе присланным путём. Нужен в сообщениях, которые строятся до резолва.
func planFileRef(pf PlanFile) string {
if pf.Index != nil {
return fmt.Sprintf("#%d", int(*pf.Index))
}
if src := strings.TrimSpace(pf.Src); src != "" {
return strconv.Quote(shorten(src))
}
return "без номера и пути"
}
// reasonValueRunes — сколько рун внешнего значения показывать в причине.
const reasonValueRunes = 40
// shorten оставляет от внешнего значения начало и конец, выкидывая середину.
// Значения (пути, роли) приходят из недоверенного ответа модели и по длине не
// ограничены, а причина оседает в БД навсегда и занимает экран в карточке
// Telegram (docs/conventions/errors.md). Режем по рунам: обрыв посреди
// многобайтовой буквы дал бы мусор; середину — потому что у пути информативны
// оба края.
func shorten(s string) string {
r := []rune(s)
if len(r) <= reasonValueRunes {
return s
}
head := reasonValueRunes / 2
return string(r[:head]) + "…" + string(r[len(r)-head:])
}
// resolveFile определяет, какой файл раздачи адресует элемент плана. Главенствует
// номер: он назван нами, и расхождение с присланным путём — сигнал сдвига
// адресации, а не повод молча затереть путь. Возвращает индекс в списке, текст
// претензии (пусто — претензий нет) и годность элемента.
func resolveFile(pf PlanFile, files []File, byPath map[string]int) (int, string, bool) {
src := strings.TrimSpace(pf.Src)
if pf.Index != nil && int(*pf.Index) != 0 {
n := int(*pf.Index)
if n < 1 || n > len(files) {
return 0, fmt.Sprintf(
"элемент плана адресует файл номером %d вне диапазона 1..%d — отброшен",
n, len(files)), false
}
idx := n - 1
if src != "" && src != files[idx].Path {
// Модель назвала обе стороны и они разошлись — бесплатная сверка
// против сдвига нумерации. Берём номер, но сообщаем человеку.
return idx, fmt.Sprintf(
"элемент плана: номер %d указывает на %q, а присланный путь — %q;"+
" принят файл по номеру", n, shorten(files[idx].Path), shorten(src)), true
}
return idx, "", true
}
if src != "" {
// Запасной формат: модель прислала путь вместо номера. Принимаем только
// точное совпадение с реальным файлом раздачи.
idx, found := byPath[src]
if !found {
return 0, fmt.Sprintf("путь %q не найден среди файлов раздачи — элемент плана отброшен",
shorten(src)), false
}
return idx, "", true
}
if pf.Index != nil {
// Явный ноль. Модель посчитала список с нуля: этот элемент не адресует
// ничего, а все её остальные номера резолвятся со сдвигом на файл.
// Называем причину так, чтобы сдвиг был самоописывающимся.
return 0, "элемент плана адресует файл номером 0 — список нумеруется с 1," +
" элемент отброшен (остальные номера могли уехать на файл)", false
}
return 0, "элемент плана без номера файла и без пути — отброшен", false
}
// planIssues — что вскрылось до модели уверенности: претензии разбора,
// усечение списка файлов пределом и сам список (для сводки покрытия).
type planIssues struct {
dropped []string // претензии к элементам files[] (см. validateSchema)
files []File // список, показанный модели (префикс all, в порядке нумерации)
// all — ПОЛНЫЙ упорядоченный список файлов раздачи: знаменатель покрытия и
// снимок, уходящий в Result. Усечение пределом max_files — свойство промпта
// и резолва, а не снимка (см. Recognize).
all []File
}
// truncated — сколько файлов раздачи не показано модели (0 — список полный).
func (is planIssues) truncated() int { return len(is.all) - len(is.files) }
// truncationReason — усечение списка файлов пределом max_files.
func truncationReason(is planIssues) string {
return fmt.Sprintf("список файлов усечён пределом [recognition].max_files:"+
" модели показано %d из %d — остальные распознаны быть не могли",
len(is.files), len(is.all))
}
// decide считает решение модели уверенности (см. recognition.md). Авто —
@@ -88,14 +246,38 @@ func validateSchema(p *Plan, in Input) error {
// название матча пригодно как имя каталога; чистая структурная валидация (для
// сериала — число серий бьётся с базой); согласованность с пред-парсом;
// самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review.
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision {
//
// Auto считается по числу БЛОКИРУЮЩИХ причин, а не по длине Reasons: сводка
// покрытия плана показывается человеку всегда, когда покрыты не все файлы, но
// сама по себе авто-раскладку не отменяет — модель вправе не перечислять
// .nfo и скриншоты. Блокирует только непокрытый видеофайл.
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64, issues planIssues) Decision {
var reasons []string
blocking := 0
// add — причина, отменяющая авто; note — то же для глаз человека, без влияния
// на решение.
add := func(s string) { reasons = append(reasons, s); blocking++ }
note := func(s string) { reasons = append(reasons, s) }
switch {
case !metadataEnabled:
reasons = append(reasons, "метабазы отключены → авто-раскладка недоступна")
add("метабазы отключены → авто-раскладка недоступна")
case match == nil:
reasons = append(reasons, "не найдено в базе или несколько кандидатов")
add("не найдено в базе или несколько кандидатов")
}
for _, d := range issues.dropped {
add(d)
}
if issues.truncated() > 0 {
add(truncationReason(issues))
}
for _, c := range coverageReasons(p, issues) {
if c.blocks {
add(c.text)
} else {
note(c.text)
}
}
// Каноническое название базы, непригодное как имя каталога (пустое или без
@@ -105,23 +287,77 @@ func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold
// (buildMatch), второй раз не чистим: два независимых пересчёта одного
// условия разъедутся на первой же правке одного из них.
if match != nil && !UsableTitle(match.Title) {
reasons = append(reasons, "название из базы непригодно как имя каталога")
add("название из базы непригодно как имя каталога")
}
reasons = append(reasons, structuralWarnings(p)...)
for _, w := range structuralWarnings(p) {
add(w)
}
if match != nil && p.Type == MediaSeries {
reasons = append(reasons, episodeCountWarnings(p, match.SeasonEpisodeCounts)...)
for _, w := range episodeCountWarnings(p, match.SeasonEpisodeCounts) {
add(w)
}
}
reasons = append(reasons, consistencyWarnings(p, pre)...)
for _, w := range consistencyWarnings(p, pre) {
add(w)
}
if p.Confidence < threshold {
reasons = append(reasons,
fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold))
add(fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold))
}
return Decision{Auto: len(reasons) == 0, Reasons: reasons}
return Decision{Auto: blocking == 0, Reasons: reasons}
}
// coverageEntry — строка сводки покрытия и её вес в решении auto/review.
type coverageEntry struct {
text string
blocks bool
}
// coverageReasons — сводка «в плане N из M» и отдельная причина о непокрытых
// видеофайлах. Знаменатель — ПОЛНОЕ число файлов раздачи, а не усечённого
// списка: иначе усечение пределом молча улучшало бы покрытие.
func coverageReasons(p Plan, issues planIssues) []coverageEntry {
if len(issues.all) == 0 {
return nil
}
planned := make(map[string]bool, len(p.Files))
for _, f := range p.Files {
planned[f.Src] = true
}
var videos []string
for _, f := range issues.files {
if !planned[f.Path] && IsVideoFile(f.Path) {
videos = append(videos, f.Path)
}
}
var out []coverageEntry
if len(planned) < len(issues.all) {
out = append(out, coverageEntry{text: fmt.Sprintf(
"в план попало %d файлов из %d", len(planned), len(issues.all))})
}
if len(videos) > 0 {
out = append(out, coverageEntry{blocks: true, text: fmt.Sprintf(
"вне плана осталось видеофайлов: %d (%s)", len(videos), listSome(videos, 3))})
}
return out
}
// listSome перечисляет не больше max элементов, сворачивая хвост в «и ещё N».
// Каждое значение внешнее (путь из ответа модели) и потому усекается.
func listSome(items []string, max int) string {
out := make([]string, 0, min(len(items), max))
for _, it := range items[:min(len(items), max)] {
out = append(out, shorten(it))
}
s := strings.Join(out, ", ")
if len(items) > max {
s += ", и ещё " + strconv.Itoa(len(items)-max)
}
return s
}
// episodeCountWarnings сверяет число распознанных серий по сезонам с базой.
@@ -181,13 +417,12 @@ func structuralWarnings(p Plan) []string {
return w
}
// seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам.
// seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам. На
// сезон приходится не больше одной причины каждого рода: на паке из восьми
// сезонов строка за каждый разрыв давала сорок строк, которые человек в ревью
// уже не читает.
func seriesWarnings(files []PlanFile) []string {
type key struct{ s, e int }
seen := map[key]int{}
bySeason := map[int][]int{}
var w []string
seen := map[int]map[int]int{}
for _, f := range files {
if f.Role != RoleEpisode || f.Episode == nil {
continue
@@ -196,27 +431,86 @@ func seriesWarnings(files []PlanFile) []string {
if f.Season != nil {
season = *f.Season
}
k := key{season, *f.Episode}
seen[k]++
if seen[k] == 2 {
w = append(w, fmt.Sprintf("сериал: дубль серии S%02dE%02d", season, *f.Episode))
if seen[season] == nil {
seen[season] = map[int]int{}
}
bySeason[season] = append(bySeason[season], *f.Episode)
seen[season][*f.Episode]++
}
for _, season := range sortedKeys(bySeason) {
eps := bySeason[season]
sort.Ints(eps)
for i := 1; i < len(eps); i++ {
if eps[i] > eps[i-1]+1 {
w = append(w, fmt.Sprintf("сериал: пропуск серий в сезоне %d между E%02d и E%02d",
season, eps[i-1], eps[i]))
var w []string
for _, season := range sortedKeys(toSlices2(seen)) {
eps := seen[season]
nums := make([]int, 0, len(eps))
for e := range eps {
nums = append(nums, e)
}
sort.Ints(nums)
var dups []int
for _, e := range nums {
if eps[e] > 1 {
dups = append(dups, e)
}
}
if len(dups) > 0 {
w = append(w, fmt.Sprintf("сериал: сезон %d — дубли серий %s",
season, episodeList(dups, len(dups))))
}
// Пропуски НЕ материализуем: номера серий приходят из недоверенного
// ответа модели и не клампятся, а датовая нумерация (обычная для
// ежедневных шоу: "episode": 20260902) рядом с единицей дала бы ~20 млн
// int на одном распознавании. Копим только то, что будет напечатано,
// остальное — счётчиком.
var missing []int
missingTotal := 0
for i := 1; i < len(nums); i++ {
gap := nums[i] - nums[i-1] - 1
if gap <= 0 {
continue // соседние номера (или переполнение на абсурдных значениях)
}
missingTotal += gap
for e := nums[i-1] + 1; e < nums[i] && len(missing) < episodeListMax; e++ {
missing = append(missing, e)
}
}
if missingTotal > 0 {
w = append(w, fmt.Sprintf("сериал: сезон %d — не хватает серий %s",
season, episodeList(missing, missingTotal)))
}
}
return w
}
// episodeListMax — сколько номеров серий печатается в причине; хвост
// сворачивается в «и ещё N». Предел печати задаёт и предел накопления у
// вызывающего (см. seriesWarnings).
const episodeListMax = 12
// episodeList печатает номера серий как E05, E07, … сворачивая длинный хвост.
// total — сколько номеров всего: он может превышать len(eps), потому что
// вызывающий вправе накопить лишь то, что будет напечатано.
func episodeList(eps []int, total int) string {
out := make([]string, 0, min(len(eps), episodeListMax))
for _, e := range eps[:min(len(eps), episodeListMax)] {
out = append(out, fmt.Sprintf("E%02d", e))
}
s := strings.Join(out, ", ")
if total > len(out) {
s += ", и ещё " + strconv.Itoa(total-len(out))
}
return s
}
// toSlices2 — ключи map[int]map[int]int для sortedKeys.
func toSlices2(m map[int]map[int]int) map[int][]int {
out := make(map[int][]int, len(m))
for k := range m {
out[k] = nil
}
return out
}
// consistencyWarnings — расхождения LLM с черновым пред-парсом.
func consistencyWarnings(p Plan, pre PreParse) []string {
var w []string
+279 -15
View File
@@ -1,12 +1,15 @@
package recognize
import (
"runtime"
"strings"
"testing"
)
func intp(n int) *int { return &n }
func idxp(n int) *FileIndex { i := FileIndex(n); return &i }
func inputWith(paths ...string) Input {
files := make([]File, len(paths))
for i, p := range paths {
@@ -25,9 +28,17 @@ func TestValidateSchema_OK(t *testing.T) {
{Src: "b.mkv", Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
},
}
if err := validateSchema(&p, in); err != nil {
problems, err := validateSchema(&p, in)
if err != nil {
t.Fatalf("validateSchema: %v", err)
}
if len(problems) != 0 {
t.Errorf("чистый план не должен давать претензий: %v", problems)
}
// Запасной формат (src без номера) резолвится в свой же номер.
if idxOf(p.Files[0]) != 1 || idxOf(p.Files[1]) != 2 {
t.Errorf("индексы не проставлены: %+v", p.Files)
}
}
func TestValidateSchema_Errors(t *testing.T) {
@@ -42,13 +53,13 @@ func TestValidateSchema_Errors(t *testing.T) {
{"empty title", Plan{Type: MediaMovie, Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "title is empty"},
{"no files", Plan{Type: MediaMovie, Title: "x"}, "files list is empty"},
{"bad role", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: "boss"}}}, "unknown role"},
{"empty src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "empty src"},
{"unknown src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "not found among torrent files"},
{"no addressing at all", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "no files[] element addresses"},
{"unknown src only", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "no files[] element addresses"},
{"episode no num", Plan{Type: MediaSeries, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleEpisode, Season: intp(1)}}}, "has no episode number"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateSchema(&tt.p, in)
_, err := validateSchema(&tt.p, in)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Errorf("err = %v, want contains %q", err, tt.want)
}
@@ -60,7 +71,7 @@ func TestParsePlan_FencedJSON(t *testing.T) {
in := inputWith("film.mkv")
raw := "Вот результат:\n```json\n{\"type\":\"movie\",\"title\":\"Film\"," +
"\"files\":[{\"src\":\"film.mkv\",\"role\":\"main\"}]}\n```"
p, err := parsePlan(raw, in, testLogger())
p, _, err := parsePlan(raw, in, testLogger())
if err != nil {
t.Fatalf("parsePlan: %v", err)
}
@@ -73,7 +84,7 @@ func TestParsePlan_UnknownFieldTolerated(t *testing.T) {
in := inputWith("film.mkv")
raw := `{"type":"movie","title":"Film","extra_field":123,
"files":[{"src":"film.mkv","role":"main"}]}`
if _, err := parsePlan(raw, in, testLogger()); err != nil {
if _, _, err := parsePlan(raw, in, testLogger()); err != nil {
t.Fatalf("unknown field should be tolerated: %v", err)
}
}
@@ -106,10 +117,10 @@ func TestSeriesWarnings_GapAndDup(t *testing.T) {
w := seriesWarnings(files)
var dup, gap bool
for _, s := range w {
if strings.Contains(s, "дубль") {
if strings.Contains(s, "дубли серий") {
dup = true
}
if strings.Contains(s, "пропуск") {
if strings.Contains(s, "не хватает серий") {
gap = true
}
}
@@ -157,7 +168,7 @@ func TestConsistencyWarnings(t *testing.T) {
func TestDecide_MetadataDisabled(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
d := decide(p, PreParse{}, nil, false, 0.85)
d := decide(p, PreParse{}, nil, false, 0.85, planIssues{})
if d.Auto {
t.Error("без метабаз авто недопустимо")
}
@@ -168,7 +179,7 @@ func TestDecide_MetadataDisabled(t *testing.T) {
func TestDecide_NoMatch(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
d := decide(p, PreParse{}, nil, true, 0.85)
d := decide(p, PreParse{}, nil, true, 0.85, planIssues{})
if d.Auto || !strings.Contains(d.Reasons[0], "не найдено в базе") {
t.Errorf("reasons = %v", d.Reasons)
}
@@ -178,7 +189,7 @@ func TestDecide_AutoMovie(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999, Confidence: 0.95,
Files: []PlanFile{{Role: RoleMain}, {Role: RoleSample}}}
match := &Match{Provider: "tmdb", ProviderID: "603", Title: "The Matrix", Year: 1999}
d := decide(p, PreParse{Year: 1999}, match, true, 0.85)
d := decide(p, PreParse{Year: 1999}, match, true, 0.85, planIssues{})
if !d.Auto {
t.Errorf("clean movie with match must be auto, reasons: %v", d.Reasons)
}
@@ -188,7 +199,7 @@ func TestDecide_LowConfidenceBlocksAuto(t *testing.T) {
p := Plan{Type: MediaMovie, Title: "X", Year: 2000, Confidence: 0.5,
Files: []PlanFile{{Role: RoleMain}}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "X", Year: 2000}
d := decide(p, PreParse{}, match, true, 0.85)
d := decide(p, PreParse{}, match, true, 0.85, planIssues{})
if d.Auto || !hasReason(d.Reasons, "уверенность") {
t.Errorf("low confidence must block auto, reasons: %v", d.Reasons)
}
@@ -201,20 +212,20 @@ func TestDecide_AutoSeriesEpisodeCount(t *testing.T) {
Files: []PlanFile{mk(1), mk(2), mk(3)}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Fargo", Year: 2014,
SeasonEpisodeCounts: map[int]int{2: 3}}
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); !d.Auto {
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); !d.Auto {
t.Errorf("full season must be auto, reasons: %v", d.Reasons)
}
// Неполный пак (в базе 10) — авто блокируется.
match.SeasonEpisodeCounts = map[int]int{2: 10}
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto ||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); d.Auto ||
!hasReason(d.Reasons, "распознано серий 3, в базе 10") {
t.Errorf("partial pack must block auto, reasons: %v", d.Reasons)
}
// Нет данных о числе серий — авто блокируется.
match.SeasonEpisodeCounts = nil
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto ||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); d.Auto ||
!hasReason(d.Reasons, "нет данных о числе серий") {
t.Errorf("missing counts must block auto, reasons: %v", d.Reasons)
}
@@ -234,3 +245,256 @@ func TestPreParse(t *testing.T) {
t.Errorf("season/episode = %d/%d, want 2/5", series.Season, series.Episode)
}
}
// Перечень видеорасширений — единственное место, где решается, что считать
// потерянной серией: незнакомое расширение уедет в спутники и авто не заблокирует.
func TestIsVideoFile(t *testing.T) {
video := []string{
"a.mkv", "a.MKV", "s1/e01.avi", "film.mp4", "x.m4v", "x.mov", "x.wmv",
"x.mpg", "x.mpeg", "BDMV/STREAM/00001.m2ts", "x.mts", "x.ts", "x.vob",
"x.flv", "x.webm", "x.ogm", "x.divx", "x.rmvb", "x.3gp", "disc.iso", "disc.img",
}
for _, p := range video {
if !IsVideoFile(p) {
t.Errorf("IsVideoFile(%q) = false, want true", p)
}
}
other := []string{
"a.srt", "a.ass", "a.sub", "a.idx", "a.nfo", "a.txt", "a.jpg", "a.png",
"a.md5", "a.sfv", "cover", "a.mkv.txt", "",
}
for _, p := range other {
if IsVideoFile(p) {
t.Errorf("IsVideoFile(%q) = true, want false", p)
}
}
}
// Сезон с тремя дырами даёт ровно одну причину, перечисляющую недостающие серии.
func TestSeriesWarnings_GapsCollapsedPerSeason(t *testing.T) {
var files []PlanFile
for _, e := range []int{1, 2, 3, 4, 6, 8, 9, 10, 12} { // нет 5, 7, 11
files = append(files, PlanFile{Role: RoleEpisode, Season: intp(1), Episode: intp(e)})
}
w := seriesWarnings(files)
if len(w) != 1 {
t.Fatalf("ожидалась одна причина на сезон, получено %d: %v", len(w), w)
}
if !strings.Contains(w[0], "E05") || !strings.Contains(w[0], "E07") || !strings.Contains(w[0], "E11") {
t.Errorf("причина не перечисляет недостающие серии: %q", w[0])
}
}
// Мультисезонный пак: на сезон — не больше одной причины о пропусках.
func TestSeriesWarnings_OneReasonPerSeason(t *testing.T) {
var files []PlanFile
for s := 1; s <= 8; s++ {
for _, e := range []int{1, 3, 5} { // дыры в каждом сезоне
files = append(files, PlanFile{Role: RoleEpisode, Season: intp(s), Episode: intp(e)})
}
}
if w := seriesWarnings(files); len(w) != 8 {
t.Errorf("причин %d при 8 сезонах, ожидалось 8: %v", len(w), w)
}
}
// Непокрытый видеофайл означает потерянную серию — авто блокируется.
func TestDecide_UncoveredVideoBlocksAuto(t *testing.T) {
in := inputWith("s1/e01.mkv", "s1/e02.mkv")
p := Plan{Type: MediaSeries, Title: "Show", Year: 2020, Confidence: 0.95,
Files: []PlanFile{{Src: "s1/e01.mkv", Role: RoleEpisode, Season: intp(1), Episode: intp(1)}}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Show", Year: 2020,
SeasonEpisodeCounts: map[int]int{1: 1}}
d := decide(p, PreParse{}, match, true, 0.85,
planIssues{files: in.Files, all: in.Files})
if d.Auto {
t.Errorf("непокрытый видеофайл обязан блокировать авто: %v", d.Reasons)
}
if !hasReason(d.Reasons, "вне плана осталось видеофайлов: 1") {
t.Errorf("reasons = %v", d.Reasons)
}
if !hasReason(d.Reasons, "в план попало 1 файлов из 2") {
t.Errorf("сводка покрытия обязана быть названа: %v", d.Reasons)
}
}
// Непокрытые спутники (.nfo, скриншоты, тексты) видны в покрытии, но авто не
// отменяют: модель вправе не перечислять то, что не раскладывается.
func TestDecide_UncoveredCompanionsKeepAuto(t *testing.T) {
in := inputWith("film.mkv", "film.nfo", "screens/01.jpg", "readme.txt")
p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95,
Files: []PlanFile{{Src: "film.mkv", Role: RoleMain}}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999}
d := decide(p, PreParse{Year: 1999}, match, true, 0.85,
planIssues{files: in.Files, all: in.Files})
if !d.Auto {
t.Errorf("непокрытые спутники не должны отменять авто: %v", d.Reasons)
}
if !hasReason(d.Reasons, "в план попало 1 файлов из 4") {
t.Errorf("покрытие обязано быть показано человеку: %v", d.Reasons)
}
}
// Отброшенный элемент — блокирующая причина, а полное покрытие сводкой не шумит.
func TestDecide_FullCoverageIsSilent(t *testing.T) {
in := inputWith("film.mkv")
p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95,
Files: []PlanFile{{Src: "film.mkv", Role: RoleMain}}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999}
d := decide(p, PreParse{Year: 1999}, match, true, 0.85,
planIssues{files: in.Files, all: in.Files})
if !d.Auto || len(d.Reasons) != 0 {
t.Errorf("полное покрытие не должно давать причин: %+v", d)
}
withDrop := decide(p, PreParse{Year: 1999}, match, true, 0.85,
planIssues{files: in.Files, all: in.Files, dropped: []string{"элемент отброшен"}})
if withDrop.Auto || !hasReason(withDrop.Reasons, "элемент отброшен") {
t.Errorf("отбраковка обязана блокировать авто: %+v", withDrop)
}
}
// Датовая нумерация серий (обычная для ежедневных шоу) рядом с обычной даёт
// разрыв в миллионы номеров. Перечисление такого разрыва материализовало бы
// сотни мегабайт int при пределе печати в 12 номеров, поэтому пропуски
// считаются арифметикой, а не перечислением.
func TestSeriesWarnings_HugeGapNotMaterialized(t *testing.T) {
files := []PlanFile{
{Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
{Role: RoleEpisode, Season: intp(1), Episode: intp(20260902)},
}
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
w := seriesWarnings(files)
runtime.ReadMemStats(&after)
// Бюджет — на строки причины, а не на 20 млн номеров: старая реализация
// брала здесь ~160 МиБ.
if grew := after.TotalAlloc - before.TotalAlloc; grew > 1<<20 {
t.Errorf("разрыв материализован: выделено %d байт", grew)
}
if len(w) != 1 {
t.Fatalf("ожидалась одна причина на сезон, получено %v", w)
}
if !strings.Contains(w[0], "E02, E03") || !strings.Contains(w[0], "E13") {
t.Errorf("причина обязана перечислять начало разрыва: %q", w[0])
}
if !strings.Contains(w[0], "и ещё 20260888") {
t.Errorf("хвост обязан быть назван числом: %q", w[0])
}
}
// Длинный хвост пропусков сворачивается: печатается предел, остальное — счётчик.
func TestSeriesWarnings_LongTailFolded(t *testing.T) {
files := []PlanFile{
{Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
{Role: RoleEpisode, Season: intp(1), Episode: intp(20)}, // нет 2..19 — 18 штук
}
w := seriesWarnings(files)
if len(w) != 1 {
t.Fatalf("причины = %v", w)
}
if strings.Count(w[0], "E") != episodeListMax {
t.Errorf("напечатано номеров: %d, ожидался предел %d: %q",
strings.Count(w[0], "E"), episodeListMax, w[0])
}
if !strings.Contains(w[0], "E13") || strings.Contains(w[0], "E14") {
t.Errorf("предел печати сдвинулся: %q", w[0])
}
if !strings.Contains(w[0], "и ещё 6") {
t.Errorf("свёрнутый хвост не назван числом: %q", w[0])
}
}
// Претензии к элементам не растут без предела, а внешние значения в них
// усечены: причины оседают в БД навсегда и уезжают в карточку Telegram.
func TestValidateSchema_ProblemsCappedAndValuesShortened(t *testing.T) {
long := "сезон 1/" + strings.Repeat("длинное-имя-", 30) + "серия.mkv"
in := inputWith(long, "b.mkv")
p := Plan{Type: MediaSeries, Title: "Show", Files: []PlanFile{
{Src: long, Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
}}
// Тридцать элементов, каждый повторно адресующий первый файл.
for range 30 {
p.Files = append(p.Files,
PlanFile{Src: long, Role: RoleEpisode, Season: intp(1), Episode: intp(2)})
}
problems, err := validateSchema(&p, in)
if err != nil {
t.Fatalf("validateSchema: %v", err)
}
if len(problems) != maxProblems+1 {
t.Fatalf("претензий %d, ожидались %d поимённых плюс свёрнутый хвост: %v",
len(problems), maxProblems, problems)
}
if !strings.Contains(problems[len(problems)-1], "и ещё 18") {
t.Errorf("хвост претензий не назван числом: %q", problems[len(problems)-1])
}
for _, s := range problems {
if strings.Contains(s, long) {
t.Errorf("внешнее значение не усечено: %q", s)
}
}
}
// Отказ по неизвестной роли называет файл так, как его адресовала модель:
// проверка стоит до резолва, и pf.Src там ещё пуст.
func TestValidateSchema_UnknownRoleNamesAddressing(t *testing.T) {
in := inputWith("a.mkv", "b.mkv")
p := Plan{Type: MediaMovie, Title: "Film", Files: []PlanFile{
{Index: idxp(2), Role: "trailer"},
}}
_, err := validateSchema(&p, in)
if err == nil || !strings.Contains(err.Error(), "#2") {
t.Errorf("err = %v, ожидался номер адресации", err)
}
}
// Ноль — не «номера нет»: так адресует модель, посчитавшая список с нуля, и
// остальные её номера уезжают на файл. Диагноз обязан быть самоописывающимся.
func TestResolveFile_ZeroIndexIsItsOwnCase(t *testing.T) {
in := inputWith("a.mkv", "b.mkv")
byPath := map[string]int{"a.mkv": 0, "b.mkv": 1}
_, problem, ok := resolveFile(PlanFile{Index: idxp(0)}, in.Files, byPath)
if ok || !strings.Contains(problem, "нумеруется с 1") {
t.Errorf("явный ноль: ok=%v, problem=%q", ok, problem)
}
_, problem, ok = resolveFile(PlanFile{}, in.Files, byPath)
if ok || !strings.Contains(problem, "без номера файла и без пути") {
t.Errorf("поля нет вовсе: ok=%v, problem=%q", ok, problem)
}
// Ноль рядом с путём остаётся запасным форматом: путь резолвится.
idx, problem, ok := resolveFile(PlanFile{Index: idxp(0), Src: "b.mkv"}, in.Files, byPath)
if !ok || idx != 1 || problem != "" {
t.Errorf("путь при нулевом номере обязан резолвиться: idx=%d ok=%v %q", idx, ok, problem)
}
}
// Структурное предупреждение и рассинхрон года — блокирующие причины, а не
// информационные заметки: развод «блокирует / не блокирует» обязан исполняться.
func TestDecide_StructuralAndConsistencyBlockAuto(t *testing.T) {
in := inputWith("a.mkv", "b.mkv")
p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95,
Files: []PlanFile{
{Src: "a.mkv", Role: RoleMain},
{Src: "b.mkv", Role: RoleMain}, // два main у фильма
}}
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999}
d := decide(p, PreParse{Year: 1998}, match, true, 0.85,
planIssues{files: in.Files, all: in.Files})
if d.Auto {
t.Errorf("структурное предупреждение обязано блокировать авто: %v", d.Reasons)
}
if !hasReason(d.Reasons, "основных видеофайлов 2") {
t.Errorf("структурная причина не названа: %v", d.Reasons)
}
if !hasReason(d.Reasons, "год расходится: пред-парс=1998, LLM=1999") {
t.Errorf("рассинхрон года не назван: %v", d.Reasons)
}
}
@@ -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-массив строк
RawLLM sql.NullString `db:"raw_llm"`
Plan sql.NullString `db:"plan"` // JSON recognize.Plan
CreatedAt string `db:"created_at"`
// SourceFiles — снимок списка файлов раздачи на момент распознавания
// (JSON-массив {path,size} в порядке нумерации промпта). NULL — запись
// старше миграции 0012: снимка нет, полнота раскладки не гарантирована.
SourceFiles sql.NullString `db:"source_files"`
CreatedAt string `db:"created_at"`
}
// ReasonList разбирает JSON-поле reasons в срез строк.
@@ -71,12 +75,13 @@ func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons [
const q = `
INSERT INTO recognition
(id, download_id, attempt_no, is_current, media_type, title, original_title,
year, provider, provider_id, confidence, reasons, raw_llm, plan, created_at)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
year, provider, provider_id, confidence, reasons, raw_llm, plan, source_files,
created_at)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if _, err := tx.ExecContext(ctx, q,
r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan,
FormatTime(Now())); err != nil {
r.SourceFiles, FormatTime(Now())); err != nil {
return "", fmt.Errorf("insert recognition: %w", err)
}
if err := tx.Commit(); err != nil {
+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
}
// Снимок списка файлов раздачи — из того же среза, по которому построен
// план (Result.Files), иначе номера в раскладке разъедутся с адресацией
// модели. Не сериализовался — оставляем NULL: UI назовёт список
// недоступным, а молча показать план как полный перечень нельзя.
var sourceFiles sql.NullString
if len(res.Files) > 0 {
if b, ferr := json.Marshal(res.Files); ferr != nil {
log.Warn("recognition marshal source files failed", "error", ferr)
} else {
sourceFiles = store.NullString(string(b))
}
}
rec := &store.Recognition{
DownloadID: id,
MediaType: store.NullString(string(res.Plan.Type)),
Title: store.NullString(res.Plan.Title),
Provider: store.NullString(provider),
ProviderID: store.NullString(providerID),
Plan: store.NullString(string(planJSON)),
RawLLM: store.NullString(res.Raw),
DownloadID: id,
MediaType: store.NullString(string(res.Plan.Type)),
Title: store.NullString(res.Plan.Title),
Provider: store.NullString(provider),
ProviderID: store.NullString(providerID),
Plan: store.NullString(string(planJSON)),
SourceFiles: sourceFiles,
RawLLM: store.NullString(res.Raw),
}
if res.Plan.OriginalTitle != "" {
rec.OriginalTitle = store.NullString(res.Plan.OriginalTitle)
@@ -968,12 +982,19 @@ type ReviewData struct {
// Задача в review без записанной причины (обычный «нет матча») иначе оставила
// бы экран без объяснения, почему пропала кнопка «Применить».
PreviewError string
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
Provider string // эффективный провайдер (с учётом выбора)
ProviderID string // эффективный id в базе
Hints []string
Overrides map[string]string
// SourceFiles — снимок списка файлов раздачи момента распознавания, в
// порядке нумерации промпта (номер строки = номер адресации модели).
SourceFiles []recognize.File
// SourceFilesKnown — есть ли снимок вообще. false у записей старше
// миграции 0012: транспорт обязан сказать, что полнота не гарантирована,
// а не выдавать план за полный перечень файлов.
SourceFilesKnown bool
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
Provider string // эффективный провайдер (с учётом выбора)
ProviderID string // эффективный id в базе
Hints []string
Overrides map[string]string
}
// MatchURL — ссылка на подтверждённую запись метабазы для этой загрузки. Общий
@@ -1068,6 +1089,14 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
Download: *d, Recognition: rec, Hints: hints, Overrides: overrides,
Provider: prov, ProviderID: pid,
}
if rec != nil && rec.SourceFiles.Valid {
var files []recognize.File
if err := json.Unmarshal([]byte(rec.SourceFiles.String), &files); err != nil {
log.Warn("review data unmarshal source files failed", "error", err)
} else {
rd.SourceFiles, rd.SourceFilesKnown = files, true
}
}
if rec != nil {
if cands, cerr := w.store.ListCandidatesByRecognition(ctx, rec.ID); cerr == nil {
rd.Candidates = cands