распознавание: большие раздачи размечаются целиком, файлы вне плана видны
- модель адресует файл номером строки нашего списка вместо копии пути: ответ на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято, max_files и max_tokens ушли в [recognition], correction-ретрай больше не переприсылает список - негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и отказ по размеру запроса названы своими причинами, покрытие плана блокирует авто только при непокрытом видеофайле - раскладка показывает все файлы раздачи со строками «не в плане» и полным порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user