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

- модель адресует файл номером строки нашего списка вместо копии пути: ответ
  на 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
+20
View File
@@ -54,8 +54,17 @@ type Response struct {
Content string
Model string
Usage Usage
// FinishReason — причина завершения генерации, как её назвал провайдер
// ("stop", "length", …). Пусто — провайдер её не сообщил. Нужна
// вызывающему: обрыв по длине неотличим от мусора по одному лишь телу
// ответа, а повторять такой запрос бессмысленно (см. recognize).
FinishReason string
}
// FinishLength — значение FinishReason, которым OpenAI-совместимые провайдеры
// сообщают обрыв генерации по достижении предела токенов.
const FinishLength = "length"
// Provider — абстракция доступа к LLM. recognize работает только с ним и не
// знает про конкретный транспорт.
type Provider interface {
@@ -75,6 +84,17 @@ type Config struct {
// ErrUnknownType — запрошенный [llm].type не поддерживается.
var ErrUnknownType = errors.New("llm: unknown provider type")
// ErrRequestTooLarge — провайдер отказал из-за размера самого запроса
// (переполнение контекста), а не из-за его содержимого. Вызывающему это
// отдельная ветвь: такой отказ не лечится повтором, а называется человеку
// своей причиной и крутится настройкой ([recognition].max_files).
//
// Признак ставит транспорт — там, где ещё целы HTTP-статус и полное тело
// ответа (см. openai.go). Вызывающий проверяет его errors.Is: у итоговой
// ошибки текст обрезан и в него попадает эхо запроса, так что матчить по нему
// нельзя (docs/conventions/errors.md).
var ErrRequestTooLarge = errors.New("llm: request too large")
// New собирает провайдер по дискриминатору cfg.Type. logger nil → slog.Default().
func New(cfg Config, logger *slog.Logger) (Provider, error) {
if logger == nil {
+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)
}
})
}
}
+38 -4
View File
@@ -193,8 +193,11 @@ func (c *openAICompat) do(ctx context.Context, body []byte) (Response, bool, err
if resp.StatusCode != http.StatusOK {
retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
return Response{}, retryable, fmt.Errorf("llm: status %d: %s",
resp.StatusCode, snippet(raw))
err := fmt.Errorf("llm: status %d: %s", resp.StatusCode, snippet(raw))
if requestTooLarge(resp.StatusCode, raw) {
err = fmt.Errorf("%w: %w", ErrRequestTooLarge, err)
}
return Response{}, retryable, err
}
var cr chatResponse
@@ -209,8 +212,9 @@ func (c *openAICompat) do(ctx context.Context, body []byte) (Response, bool, err
}
return Response{
Content: cr.Choices[0].Message.Content,
Model: cr.Model,
Content: cr.Choices[0].Message.Content,
Model: cr.Model,
FinishReason: cr.Choices[0].FinishReason,
Usage: Usage{
PromptTokens: cr.Usage.PromptTokens,
CompletionTokens: cr.Usage.CompletionTokens,
@@ -236,6 +240,36 @@ func (c *openAICompat) wait(ctx context.Context, attempt int) error {
}
}
// tooLargeMarkers — текстовые признаки переполнения контекста в теле отказа.
// Единого машинного кода у OpenAI-совместимых шлюзов нет: часть отдаёт 413,
// остальные кладут причину в текст при обычном 400.
var tooLargeMarkers = []string{
"context length", "context_length", "context window", "maximum context",
"too many tokens", "prompt is too long", "request too large",
"reduce the length",
}
// requestTooLarge — отказал ли провайдер из-за размера запроса. 413 —
// безусловно, без разбора текста. 400 — только по маркерам, и смотрим их в
// ПОЛНОМ теле: обрезка тела (snippet) выбрасывает причину ровно у тех шлюзов,
// которые сперва echo'ят запрос. Ложное срабатывание безопасно: задача уходит
// в review, а не в повтор.
func requestTooLarge(status int, body []byte) bool {
if status == http.StatusRequestEntityTooLarge {
return true
}
if status != http.StatusBadRequest {
return false
}
lower := strings.ToLower(string(body))
for _, marker := range tooLargeMarkers {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
// snippet обрезает тело для сообщения об ошибке.
func snippet(b []byte) string {
const max = 300