package recognize import ( "context" "errors" "fmt" "log/slog" "strings" "testing" "git.vakhrushev.me/av/jellybit/internal/config" "git.vakhrushev.me/av/jellybit/internal/llm" ) // fakeLLM отдаёт заранее заданные ответы/ошибки по порядку вызовов. type fakeLLM struct { responses []string errs []error calls int lastReq llm.Request } func (f *fakeLLM) Complete(_ context.Context, req llm.Request) (llm.Response, error) { f.lastReq = req i := f.calls f.calls++ if i < len(f.errs) && f.errs[i] != nil { return llm.Response{}, f.errs[i] } content := "" switch { case i < len(f.responses): content = f.responses[i] case len(f.responses) > 0: content = f.responses[len(f.responses)-1] } 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) } func TestRecognize_Movie(t *testing.T) { in := Input{ Name: "The.Matrix.1999.1080p.BluRay.x264", Context: "научная фантастика", Files: []File{ {Path: "The.Matrix.1999/movie.mkv", Size: 8 << 30}, {Path: "The.Matrix.1999/sample.mkv", Size: 50 << 20}, }, } resp := `{"type":"movie","title":"The Matrix","original_title":"","year":1999, "provider_hint":"The Matrix 1999","confidence":0.9,"notes":"", "files":[ {"src":"The.Matrix.1999/movie.mkv","role":"main","season":null,"episode":null}, {"src":"The.Matrix.1999/sample.mkv","role":"sample","season":null,"episode":null} ]}` f := &fakeLLM{responses: []string{resp}} r := New(f, nil, Config{MaxRetries: 2}, testLogger()) res, err := r.Recognize(context.Background(), in) if err != nil { t.Fatalf("Recognize: %v", err) } if res.Plan.Type != MediaMovie || res.Plan.Title != "The Matrix" || res.Plan.Year != 1999 { t.Errorf("plan = %+v", res.Plan) } if res.Attempts != 1 { t.Errorf("attempts = %d, want 1", res.Attempts) } if res.Decision.Auto { t.Error("auto must be false in Ф2 (no DB match)") } if len(res.Decision.Reasons) == 0 { t.Error("expected at least the no-DB-match reason") } // Чистая структура + уверенность 0.9 ≥ порога: единственная причина — // отсутствие матча в базе. if len(res.Decision.Reasons) != 1 || !hasReason(res.Decision.Reasons, "метабазы отключены") { t.Errorf("unexpected reasons: %v", res.Decision.Reasons) } } func TestRecognize_Series(t *testing.T) { in := Input{ Name: "Avatar.The.Last.Airbender.Book.2", Files: []File{ {Path: "Avatar/01.mkv", Size: 200 << 20}, {Path: "Avatar/02.mkv", Size: 200 << 20}, {Path: "Avatar/03.mkv", Size: 200 << 20}, }, } resp := `{"type":"series","title":"Avatar: The Last Airbender","year":2006, "confidence":0.8,"files":[ {"src":"Avatar/01.mkv","role":"episode","season":2,"episode":1}, {"src":"Avatar/02.mkv","role":"episode","season":2,"episode":2}, {"src":"Avatar/03.mkv","role":"episode","season":2,"episode":3} ]}` f := &fakeLLM{responses: []string{resp}} r := New(f, nil, Config{}, testLogger()) res, err := r.Recognize(context.Background(), in) if err != nil { t.Fatalf("Recognize: %v", err) } if res.Plan.Type != MediaSeries || len(res.Plan.Files) != 3 { t.Errorf("plan = %+v", res.Plan) } // Метабазы выключены → авто нет; причина про базу обязательна. if res.Decision.Auto { t.Error("auto must be false without metadata providers") } if !hasReason(res.Decision.Reasons, "метабазы отключены") { t.Errorf("expected metadata-off reason, got: %v", res.Decision.Reasons) } } func hasReason(reasons []string, substr string) bool { for _, r := range reasons { if strings.Contains(r, substr) { return true } } return false } func TestRecognize_RetriesOnBadSrcThenSucceeds(t *testing.T) { in := Input{ Name: "Some.Movie.2020", Files: []File{{Path: "movie/film.mkv", Size: 4 << 30}}, } bad := `{"type":"movie","title":"Some Movie","files":[ {"src":"movie/WRONG.mkv","role":"main"}]}` good := `{"type":"movie","title":"Some Movie","year":2020,"files":[ {"src":"movie/film.mkv","role":"main"}]}` f := &fakeLLM{responses: []string{bad, good}} r := New(f, nil, Config{MaxRetries: 2}, testLogger()) res, err := r.Recognize(context.Background(), in) if err != nil { t.Fatalf("Recognize: %v", err) } if res.Attempts != 2 { t.Errorf("attempts = %d, want 2", res.Attempts) } 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, `"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) { in := Input{Name: "x", Files: []File{{Path: "a.mkv", Size: 1}}} bad := `not a json at all` f := &fakeLLM{responses: []string{bad}} r := New(f, nil, Config{MaxRetries: 2}, testLogger()) res, err := r.Recognize(context.Background(), in) if err != nil { t.Fatalf("Recognize should not error on unparsed response: %v", err) } if f.calls != 3 { // 1 + 2 ретрая t.Errorf("calls = %d, want 3", f.calls) } if res.Decision.Auto || len(res.Decision.Reasons) == 0 { t.Errorf("expected review with reason, got %+v", res.Decision) } if !strings.Contains(res.Decision.Reasons[0], "не разобран") { t.Errorf("reason = %q", res.Decision.Reasons[0]) } if res.Raw != bad { t.Errorf("raw = %q, want last response", res.Raw) } } func TestRecognize_TransportErrorPropagates(t *testing.T) { in := Input{Name: "x", Files: []File{{Path: "a.mkv", Size: 1}}} wantErr := errors.New("connection refused") f := &fakeLLM{errs: []error{wantErr}} r := New(f, nil, Config{MaxRetries: 2}, testLogger()) _, err := r.Recognize(context.Background(), in) if err == nil || !errors.Is(err, wantErr) { t.Fatalf("err = %v, want wrapped %v", err, wantErr) } if f.calls != 1 { t.Errorf("calls = %d, want 1 (transport errors not retried here)", f.calls) } } func TestRecognize_PromptCarriesSignals(t *testing.T) { in := Input{ Name: "Some.Show.S01", Context: "сериал от HBO", Hints: []string{"это второй сезон", ""}, Files: []File{{Path: "ep1.mkv", Size: 1 << 30}}, } resp := `{"type":"series","title":"Some Show","files":[ {"src":"ep1.mkv","role":"episode","season":1,"episode":1}]}` f := &fakeLLM{responses: []string{resp}} r := New(f, nil, Config{}, testLogger()) if _, err := r.Recognize(context.Background(), in); err != nil { t.Fatalf("Recognize: %v", err) } if len(f.lastReq.Messages) != 2 { t.Fatalf("want system+user, got %d messages", len(f.lastReq.Messages)) } user := f.lastReq.Messages[1].Content for _, want := range []string{"Some.Show.S01", "сериал от HBO", "это второй сезон", "ep1.mkv"} { if !strings.Contains(user, want) { t.Errorf("user prompt missing %q\n%s", want, user) } } if !f.lastReq.JSONMode { t.Error("JSONMode must be set") } } func TestRecognize_FileListTruncated(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} // План ссылается только на первый по порядку файл — этого достаточно схеме. 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()) 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, "усечён") || !strings.Contains(user, "и ещё 150") { t.Errorf("expected truncation note in prompt:\n%s", user) } 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) } }