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

- модель адресует файл номером строки нашего списка вместо копии пути: ответ
  на 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
+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)
}
}
}