распознавание: большие раздачи размечаются целиком, файлы вне плана видны
- модель адресует файл номером строки нашего списка вместо копии пути: ответ на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято, max_files и max_tokens ушли в [recognition], correction-ретрай больше не переприсылает список - негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и отказ по размеру запроса названы своими причинами, покрытие плана блокирует авто только при непокрытом видеофайле - раскладка показывает все файлы раздачи со строками «не в плане» и полным порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
This commit is contained in:
+171
-27
@@ -11,15 +11,23 @@
|
||||
// базой), согласованности с пред-парсом и уверенности не ниже порога.
|
||||
//
|
||||
// Без включённых баз (или без матча) авто-раскладка не делается — задача
|
||||
// уходит в review. Выход LLM недоверенный: план принимается только если
|
||||
// каждый files[].src совпадает с реальным файлом торрента; итоговая
|
||||
// безопасность пути держится на раскладке (layout).
|
||||
// уходит в review. Выход LLM недоверенный: файл адресуется НОМЕРОМ строки в
|
||||
// напечатанном нами списке, а files[].src подставляет резолв номера по этому
|
||||
// же списку — посторонний путь невыразим. Присланный моделью путь принимается
|
||||
// лишь как запасной формат и только при точном совпадении с файлом торрента;
|
||||
// итоговая безопасность пути держится на раскладке (layout).
|
||||
package recognize
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
@@ -56,9 +64,29 @@ func (r FileRole) valid() bool {
|
||||
}
|
||||
|
||||
// File — входной файл торрента (путь относительно save_path и размер).
|
||||
// JSON-теги — для снимка списка файлов рядом с планом (store.Recognition,
|
||||
// миграция 0012): раскладка обязана показывать и те файлы, которых нет в плане.
|
||||
type File struct {
|
||||
Path string
|
||||
Size int64
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// videoExts — расширения, по которым файл считается видео (нижний регистр, с
|
||||
// точкой). Единственное место перечня: на нём держится разделение покрытия
|
||||
// плана — непокрытый видеофайл означает потерянную серию или фильм и блокирует
|
||||
// авто-раскладку, непокрытый файл-спутник (субтитры, картинки, тексты) — нет.
|
||||
// Незнакомое расширение видео попадёт в спутники, поэтому список пополняем
|
||||
// здесь, а не по месту вызова.
|
||||
var videoExts = map[string]bool{
|
||||
".mkv": true, ".mp4": true, ".avi": true, ".m4v": true, ".mov": true,
|
||||
".wmv": true, ".mpg": true, ".mpeg": true, ".m2ts": true, ".mts": true,
|
||||
".ts": true, ".vob": true, ".flv": true, ".webm": true, ".ogm": true,
|
||||
".divx": true, ".rmvb": true, ".3gp": true, ".iso": true, ".img": true,
|
||||
}
|
||||
|
||||
// IsVideoFile — видео ли это по расширению пути. Регистр расширения не важен.
|
||||
func IsVideoFile(path string) bool {
|
||||
return videoExts[strings.ToLower(filepath.Ext(path))]
|
||||
}
|
||||
|
||||
// Input — сигналы для распознавания одной раздачи.
|
||||
@@ -69,13 +97,49 @@ type Input struct {
|
||||
Hints []string // накопленные подсказки из review (Ф3; в Ф2 обычно пусто)
|
||||
}
|
||||
|
||||
// FileIndex — номер файла в списке, напечатанном в промпте (1-based). Модель
|
||||
// иногда возвращает его строкой или дробным числом; разбор это терпит и
|
||||
// сводит к целому, потому что негодный номер обязан отбраковывать элемент, а
|
||||
// не весь план (см. validate.go). Неразбираемое значение даёт 0 — и элемент
|
||||
// отбраковывается той же причиной, что и явно присланный ноль.
|
||||
type FileIndex int
|
||||
|
||||
// UnmarshalJSON разбирает номер терпимо: число, строка с числом, дробное с
|
||||
// нулевой дробной частью, null. Ошибку не возвращает НИКОГДА — иначе один
|
||||
// кривой элемент ронял бы разбор всего плана.
|
||||
func (n *FileIndex) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
|
||||
if s == "" || s == "null" {
|
||||
*n = 0
|
||||
return nil
|
||||
}
|
||||
if v, err := strconv.Atoi(s); err == nil {
|
||||
*n = FileIndex(v)
|
||||
return nil
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil && f == math.Trunc(f) &&
|
||||
f > math.MinInt32 && f < math.MaxInt32 {
|
||||
*n = FileIndex(int(f))
|
||||
return nil
|
||||
}
|
||||
*n = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlanFile — файл в плане раскладки. Season/Episode заданы на файле, чтобы
|
||||
// выражать мультисезонные паки и спецвыпуски (см. recognition.md).
|
||||
type PlanFile struct {
|
||||
Src string `json:"src"`
|
||||
Role FileRole `json:"role"`
|
||||
Season *int `json:"season,omitempty"`
|
||||
Episode *int `json:"episode,omitempty"`
|
||||
// Index — чем файл адресован в ответе модели: номер строки нашего списка.
|
||||
// Src заполняет резолвом сама система, поэтому посторонний путь невыразим.
|
||||
// Указатель, а не значение: «поле не прислано» и «прислан 0» — разные
|
||||
// диагнозы. Ноль означает модель, посчитавшую список с нуля, и тогда все
|
||||
// остальные её номера резолвятся со сдвигом на файл; отсутствие поля —
|
||||
// запасной формат с путём либо элемент, не адресующий ничего.
|
||||
Index *FileIndex `json:"i,omitempty"`
|
||||
Src string `json:"src"`
|
||||
Role FileRole `json:"role"`
|
||||
Season *int `json:"season,omitempty"`
|
||||
Episode *int `json:"episode,omitempty"`
|
||||
}
|
||||
|
||||
// Plan — структурированный результат распознавания (схема ответа LLM).
|
||||
@@ -129,6 +193,13 @@ type Result struct {
|
||||
Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review
|
||||
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи)
|
||||
Raw string // сырой ответ LLM последней попытки
|
||||
// Files — ПОЛНЫЙ список файлов раздачи в том порядке, в каком печатается
|
||||
// промпт (и в каком резолвятся номера). Снимок момента распознавания: его
|
||||
// сохраняют рядом с планом, чтобы раскладка показывала и файлы вне плана.
|
||||
// Усечение пределом max_files сюда не распространяется — иначе снимок выдал
|
||||
// бы показанный модели срез за весь перечень раздачи. Заполнен на всех
|
||||
// исходах, включая review без разобранного плана.
|
||||
Files []File
|
||||
}
|
||||
|
||||
// LLM — нужная recognize часть провайдера.
|
||||
@@ -148,8 +219,12 @@ type Config struct {
|
||||
}
|
||||
|
||||
const (
|
||||
defaultMaxTokens = 4000
|
||||
defaultMaxFiles = 100
|
||||
// Дефолты — предохранители для прямых вызовов конструктора (тесты, CLI без
|
||||
// конфига). Канонические значения задаёт [recognition] (config.Default);
|
||||
// равенство им сторожит TestDefaultsAgreeWithConfig — иначе прод и тест
|
||||
// разъедутся молча.
|
||||
defaultMaxTokens = 8000
|
||||
defaultMaxFiles = 500
|
||||
defaultAutoThreshold = 0.85
|
||||
defaultLanguage = "en"
|
||||
)
|
||||
@@ -210,7 +285,26 @@ func New(provider LLM, providers []metadata.Provider, cfg Config, log *slog.Logg
|
||||
func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
||||
log := logctx.FromOr(ctx, r.log)
|
||||
pre := preParse(in.Name)
|
||||
msgs := buildMessages(in, pre, r.maxFiles, r.language)
|
||||
|
||||
// Порядок списка — свойство узла, а не дисциплина вызывающего (сборок
|
||||
// Input две: воркер и CLI). Печать промпта и резолв номеров идут дальше по
|
||||
// одному и тому же срезу, поэтому повторное распознавание той же раздачи
|
||||
// даёт ту же нумерацию.
|
||||
//
|
||||
// Усечение пределом max_files — свойство ПРОМПТА и резолва номеров, а не
|
||||
// снимка: снимок хранит полный список раздачи, иначе виджет раскладки выдаёт
|
||||
// показанный модели срез за весь перечень («в план попало 100 из 100», когда
|
||||
// в торренте 250). Показанный список — префикс полного, поэтому номера
|
||||
// адресации от этого не меняются.
|
||||
all := sortedFiles(in.Files)
|
||||
shown := all
|
||||
if r.maxFiles > 0 && len(shown) > r.maxFiles {
|
||||
shown = all[:r.maxFiles]
|
||||
}
|
||||
in.Files = shown
|
||||
issues := planIssues{files: shown, all: all}
|
||||
|
||||
msgs := buildMessages(in, pre, len(all), r.language)
|
||||
|
||||
temp := 0.0
|
||||
var raw string
|
||||
@@ -227,32 +321,54 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
||||
MaxTokens: r.maxTokens,
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("recognize: llm complete: %w", err)
|
||||
// Отказ по размеру запроса называем своей причиной: текст провайдера
|
||||
// человеку в ревью ничего не говорит, а лечится это [recognition].max_files.
|
||||
if errors.Is(err, llm.ErrRequestTooLarge) {
|
||||
log.Warn("recognition request rejected as too large",
|
||||
"source_files", len(in.Files), "max_files", r.maxFiles,
|
||||
"error", err)
|
||||
return reviewResult(pre, issues, attempts, "", []string{fmt.Sprintf(
|
||||
"модель отвергла запрос по его размеру: файлов в списке %d"+
|
||||
" (уменьшите [recognition].max_files)", len(in.Files))}), nil
|
||||
}
|
||||
return Result{Files: all}, fmt.Errorf("recognize: llm complete: %w", err)
|
||||
}
|
||||
raw = resp.Content
|
||||
|
||||
plan, parseErr = parsePlan(raw, in, log)
|
||||
// Обрыв генерации по длине — не ошибка модели: ответ не поместился.
|
||||
// Повтор тем же промптом (и его вариантом) дал бы тот же обрыв.
|
||||
if resp.FinishReason == llm.FinishLength {
|
||||
log.Warn("recognition llm response truncated",
|
||||
"attempt", attempts, "max_tokens", r.maxTokens)
|
||||
return reviewResult(pre, issues, attempts, raw, []string{fmt.Sprintf(
|
||||
"ответ модели обрезан по пределу длины (max_tokens = %d):"+
|
||||
" план неполон, повтор тем же запросом не поможет", r.maxTokens)}), nil
|
||||
}
|
||||
|
||||
plan, issues.dropped, parseErr = parsePlan(raw, in, log)
|
||||
if parseErr == nil {
|
||||
break
|
||||
}
|
||||
log.Warn("recognition llm response unparsed",
|
||||
"attempt", attempts, "error", parseErr)
|
||||
// Просим модель исправиться, повторяя схему и ошибку.
|
||||
// Просим модель исправиться, повторяя схему и ошибку. Список файлов
|
||||
// НЕ переприсылаем: его номера названы в первом сообщении диалога и
|
||||
// сохраняют смысл на всех попытках (иначе цена неудачи росла бы вместе
|
||||
// с размером раздачи).
|
||||
msgs = append(msgs,
|
||||
llm.Message{Role: llm.RoleAssistant, Content: raw},
|
||||
llm.Message{Role: llm.RoleUser, Content: correctionMessage(parseErr, in, r.maxFiles)})
|
||||
llm.Message{Role: llm.RoleUser, Content: correctionMessage(parseErr)})
|
||||
}
|
||||
|
||||
if parseErr != nil {
|
||||
return Result{
|
||||
PreParse: pre,
|
||||
Attempts: attempts,
|
||||
Raw: raw,
|
||||
Decision: Decision{
|
||||
Auto: false,
|
||||
Reasons: []string{"ответ LLM не разобран после " + itoa(attempts) + " попыток: " + parseErr.Error()},
|
||||
},
|
||||
}, nil
|
||||
// Претензии последней попытки — единственное поэлементное объяснение
|
||||
// («номер 181 вне диапазона 1..2»); без них человек читает голое «ответ
|
||||
// LLM не разобран». decide на успешной ветке добавляет их так же.
|
||||
reasons := []string{
|
||||
"ответ LLM не разобран после " + itoa(attempts) + " попыток: " + parseErr.Error(),
|
||||
}
|
||||
reasons = append(reasons, issues.dropped...)
|
||||
return reviewResult(pre, issues, attempts, raw, reasons), nil
|
||||
}
|
||||
|
||||
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
|
||||
@@ -275,10 +391,11 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
||||
}
|
||||
}
|
||||
|
||||
dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold)
|
||||
dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold, issues)
|
||||
log.Info("recognition done",
|
||||
"media_type", plan.Type, "title", plan.Title, "year", plan.Year,
|
||||
"files", len(plan.Files), "attempts", attempts,
|
||||
"files", len(plan.Files), "source_files", len(in.Files),
|
||||
"dropped", len(issues.dropped), "attempts", attempts,
|
||||
"matched", match != nil, "candidates", len(candidates),
|
||||
"auto", dec.Auto, "reasons", len(dec.Reasons))
|
||||
return Result{
|
||||
@@ -289,5 +406,32 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
||||
Candidates: candidates,
|
||||
Attempts: attempts,
|
||||
Raw: raw,
|
||||
Files: all,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// reviewResult — исход без пригодного плана: задача уходит в review с
|
||||
// названными причинами. Снимок списка файлов отдаём и здесь: он нужен
|
||||
// раскладке и повторному распознаванию из ревью.
|
||||
func reviewResult(pre PreParse, issues planIssues, attempts int, raw string, reasons []string) Result {
|
||||
if issues.truncated() > 0 {
|
||||
reasons = append(reasons, truncationReason(issues))
|
||||
}
|
||||
return Result{
|
||||
PreParse: pre,
|
||||
Attempts: attempts,
|
||||
Raw: raw,
|
||||
Files: issues.all,
|
||||
Decision: Decision{Auto: false, Reasons: reasons},
|
||||
}
|
||||
}
|
||||
|
||||
// sortedFiles — детерминированный порядок списка файлов (по пути). Возвращает
|
||||
// НОВЫЙ срез: входной принадлежит вызывающему, менять его порядок нельзя.
|
||||
// Путь внутри торрента уникален, поэтому порядок воспроизводим между вызовами.
|
||||
func sortedFiles(files []File) []File {
|
||||
out := make([]File, len(files))
|
||||
copy(out, files)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user