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

- модель адресует файл номером строки нашего списка вместо копии пути: ответ
  на 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
+70
View File
@@ -3,6 +3,7 @@ package llm
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -234,3 +235,72 @@ func TestNew_OpenAICompatValidation(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}
// Признак обрыва генерации по длине доходит до вызывающего: по одному телу
// ответа обрыв неотличим от мусора, а повторять такой запрос бессмысленно.
func TestComplete_FinishReasonReachesCaller(t *testing.T) {
for _, want := range []string{"length", "stop"} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"model":"m","choices":[{"message":{"content":"{\"type\":"},`+
`"finish_reason":"`+want+`"}]}`)
}))
p := newTestProvider(t, srv.URL, "")
resp, err := p.Complete(context.Background(), Request{
Messages: []Message{{Role: RoleUser, Content: "hi"}},
})
srv.Close()
if err != nil {
t.Fatalf("Complete: %v", err)
}
if resp.FinishReason != want {
t.Errorf("finish_reason = %q, want %q", resp.FinishReason, want)
}
}
if FinishLength != "length" {
t.Errorf("FinishLength = %q, want length", FinishLength)
}
}
// Отказ по размеру запроса помечается sentinel'ом там, где ещё целы
// HTTP-статус и ПОЛНОЕ тело: вызывающему нечего матчить по тексту, а текст
// итоговой ошибки обрезан (snippet) и несёт эхо запроса.
func TestComplete_RequestTooLargeSentinel(t *testing.T) {
cases := []struct {
name string
status int
body string
want bool
}{
{"413 без разбора текста", http.StatusRequestEntityTooLarge, `payload too big`, true},
{"400 с маркером в теле", http.StatusBadRequest,
`{"error":{"message":"This model's maximum context length is 128000 tokens"}}`, true},
{"400 с маркером за пределом snippet", http.StatusBadRequest,
`{"echo":"` + strings.Repeat("x", 400) + `","error":{"message":"prompt is too long"}}`, true},
{"400 по другой причине", http.StatusBadRequest,
`{"error":{"message":"invalid api key"}}`, false},
{"500 — транзиентный сбой, не размер", http.StatusInternalServerError,
`context length exceeded`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.body)
}))
defer srv.Close()
p := newTestProvider(t, srv.URL, "")
_, err := p.Complete(context.Background(), Request{
Messages: []Message{{Role: RoleUser, Content: "hi"}},
})
if err == nil {
t.Fatal("ожидалась ошибка")
}
if got := errors.Is(err, ErrRequestTooLarge); got != tc.want {
t.Errorf("errors.Is(err, ErrRequestTooLarge) = %v, want %v (err = %v)",
got, tc.want, err)
}
})
}
}