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

328 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}