распознавание: большие раздачи размечаются целиком, файлы вне плана видны
- модель адресует файл номером строки нашего списка вместо копии пути: ответ на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято, max_files и max_tokens ушли в [recognition], correction-ретрай больше не переприсылает список - негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и отказ по размеру запроса названы своими причинами, покрытие плана блокирует авто только при непокрытом видеофайле - раскладка показывает все файлы раздачи со строками «не в плане» и полным порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
This commit is contained in:
@@ -3,10 +3,12 @@ package recognize
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/config"
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
)
|
||||
|
||||
@@ -35,6 +37,13 @@ func (f *fakeLLM) Complete(_ context.Context, req llm.Request) (llm.Response, er
|
||||
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)
|
||||
}
|
||||
@@ -145,11 +154,15 @@ func TestRecognize_RetriesOnBadSrcThenSucceeds(t *testing.T) {
|
||||
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, "film.mkv") {
|
||||
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) {
|
||||
@@ -222,29 +235,129 @@ func TestRecognize_PromptCarriesSignals(t *testing.T) {
|
||||
|
||||
func TestRecognize_FileListTruncated(t *testing.T) {
|
||||
files := make([]File, 250)
|
||||
planFiles := make([]string, 0, 250)
|
||||
for i := range files {
|
||||
files[i] = File{Path: pathOf(i), Size: 100 << 20}
|
||||
}
|
||||
// План ссылается только на первый файл — этого достаточно для схемы.
|
||||
_ = planFiles
|
||||
in := Input{Name: "Big.Pack", Files: files}
|
||||
resp := `{"type":"series","title":"Big","files":[{"src":"` + pathOf(0) +
|
||||
`","role":"episode","season":1,"episode":1}]}`
|
||||
// План ссылается только на первый по порядку файл — этого достаточно схеме.
|
||||
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())
|
||||
if _, err := r.Recognize(context.Background(), in); err != nil {
|
||||
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, "усечён") {
|
||||
t.Errorf("expected truncation note in prompt")
|
||||
if !strings.Contains(user, "усечён") || !strings.Contains(user, "и ещё 150") {
|
||||
t.Errorf("expected truncation note in prompt:\n%s", user)
|
||||
}
|
||||
if !strings.Contains(user, "Файлы (250") {
|
||||
t.Errorf("expected total count 250 in prompt")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user