Files
jellybit/internal/recognize/recognize_test.go
T
av fc9a3b4066 распознавание: большие раздачи размечаются целиком, файлы вне плана видны
- модель адресует файл номером строки нашего списка вместо копии пути: ответ
  на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято,
  max_files и max_tokens ушли в [recognition], correction-ретрай больше не
  переприсылает список
- негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и
  отказ по размеру запроса названы своими причинами, покрытие плана блокирует
  авто только при непокрытом видеофайле
- раскладка показывает все файлы раздачи со строками «не в плане» и полным
  порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
2026-09-02 08:54:58 +03:00

364 lines
14 KiB
Go

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)
}
}