распознавание: большие раздачи размечаются целиком, файлы вне плана видны
- модель адресует файл номером строки нашего списка вместо копии пути: ответ на 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,14 +36,14 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
||||
"provider_hint": "строка для поиска в базе (НЕ id)",
|
||||
"files": [
|
||||
{
|
||||
"src": "путь файла из списка ниже, БЕЗ размера в скобках в конце строки",
|
||||
"i": номер файла из списка ниже (целое, ровно как напечатано),
|
||||
"role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore",
|
||||
"season": число или null,
|
||||
"episode": число или null
|
||||
}
|
||||
],
|
||||
"confidence": число 0..1,
|
||||
"notes": "пояснения и неоднозначности или пустая строка"
|
||||
"notes": "неоднозначности, не больше пары предложений, или пустая строка"
|
||||
}
|
||||
|
||||
Правила:
|
||||
@@ -53,10 +53,12 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
||||
происхождения — продублируй "title" в "original_title". Если НЕ уверен в
|
||||
оригинальном названии — продублируй "title", но НЕ выдумывай название.
|
||||
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
|
||||
- Поле "i" — номер файла из напечатанного ниже списка, ровно как он там стоит.
|
||||
Путь файла копировать НЕ нужно: он у нас уже есть, мы подставим его сами.
|
||||
- Один и тот же файл адресуй не больше одного раза.
|
||||
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
|
||||
- Для фильма ровно один основной видеофайл role "main".
|
||||
- Поле src — это путь файла из списка, скопированный дословно, но БЕЗ размера
|
||||
«(…)» в конце строки; не выдумывай и не нормализуй пути.
|
||||
- Для ролей "sample", "ignore" и "extra" номера сезона и серии не нужны — оставляй null.
|
||||
- Внешние субтитры — role "subtitle".`
|
||||
|
||||
const systemPromptBase = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента,
|
||||
@@ -89,15 +91,18 @@ func systemPrompt(lang string) string {
|
||||
return systemPromptBase + schemaText + languageDirective(lang)
|
||||
}
|
||||
|
||||
// buildMessages собирает системное и пользовательское сообщения.
|
||||
func buildMessages(in Input, pre PreParse, maxFiles int, lang string) []llm.Message {
|
||||
// buildMessages собирает системное и пользовательское сообщения. total —
|
||||
// полное число файлов раздачи (in.Files уже упорядочен и, возможно, усечён
|
||||
// пределом): список печатается ровно тем срезом, по которому потом резолвятся
|
||||
// номера.
|
||||
func buildMessages(in Input, pre PreParse, total int, lang string) []llm.Message {
|
||||
return []llm.Message{
|
||||
{Role: llm.RoleSystem, Content: systemPrompt(lang)},
|
||||
{Role: llm.RoleUser, Content: userPrompt(in, pre, maxFiles)},
|
||||
{Role: llm.RoleUser, Content: userPrompt(in, pre, total)},
|
||||
}
|
||||
}
|
||||
|
||||
func userPrompt(in Input, pre PreParse, maxFiles int) string {
|
||||
func userPrompt(in Input, pre PreParse, total int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Имя торрента: ")
|
||||
b.WriteString(orNone(in.Name))
|
||||
@@ -122,7 +127,7 @@ func userPrompt(in Input, pre PreParse, maxFiles int) string {
|
||||
b.WriteString(preParseLine(pre))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
writeFileList(&b, in.Files, maxFiles)
|
||||
writeFileList(&b, in.Files, total)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -149,19 +154,15 @@ func preParseLine(pre PreParse) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// writeFileList печатает список файлов, усекая до maxFiles. src в плане
|
||||
// должен дословно совпадать с путями отсюда.
|
||||
func writeFileList(b *strings.Builder, files []File, maxFiles int) {
|
||||
n := len(files)
|
||||
shown := n
|
||||
if maxFiles > 0 && shown > maxFiles {
|
||||
shown = maxFiles
|
||||
}
|
||||
b.WriteString("Файлы (")
|
||||
b.WriteString(strconv.Itoa(n))
|
||||
b.WriteString("). В src копируй ТОЛЬКО путь — текст после номера и до размера ")
|
||||
b.WriteString("в скобках; размер «(…)» в конце строки в src НЕ включай:\n")
|
||||
for i := 0; i < shown; i++ {
|
||||
// writeFileList печатает нумерованный список файлов. Номер строки — то, что
|
||||
// модель возвращает в поле "i"; усечение (если оно было) сделал вызывающий,
|
||||
// total — полное число файлов раздачи.
|
||||
func writeFileList(b *strings.Builder, files []File, total int) {
|
||||
b.WriteString("Файлы раздачи (")
|
||||
b.WriteString(strconv.Itoa(len(files)))
|
||||
b.WriteString("). Номер в начале строки — это то, что нужно вернуть в поле \"i\";")
|
||||
b.WriteString(" путь и размер копировать не нужно:\n")
|
||||
for i := range files {
|
||||
b.WriteString(strconv.Itoa(i + 1))
|
||||
b.WriteString(". ")
|
||||
b.WriteString(files[i].Path)
|
||||
@@ -169,22 +170,24 @@ func writeFileList(b *strings.Builder, files []File, maxFiles int) {
|
||||
b.WriteString(humanSize(files[i].Size))
|
||||
b.WriteString(")\n")
|
||||
}
|
||||
if shown < n {
|
||||
if total > len(files) {
|
||||
b.WriteString("… и ещё ")
|
||||
b.WriteString(strconv.Itoa(n - shown))
|
||||
b.WriteString(strconv.Itoa(total - len(files)))
|
||||
b.WriteString(" файлов (список усечён)\n")
|
||||
}
|
||||
}
|
||||
|
||||
// correctionMessage — сообщение для повторной попытки: что было не так + схема.
|
||||
func correctionMessage(err error, in Input, maxFiles int) string {
|
||||
// Список файлов сюда НЕ входит: его номера названы в первом сообщении диалога
|
||||
// и сохраняют смысл на всех попытках, а повторная печать умножала бы вход на
|
||||
// число попыток — ровно ту цену, которую снимает индексная адресация.
|
||||
func correctionMessage(err error) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Ответ не принят: ")
|
||||
b.WriteString(err.Error())
|
||||
b.WriteString("\nВерни ИСПРАВЛЕННЫЙ ответ строго по схеме, только JSON.\n\n")
|
||||
b.WriteString("\nВерни ИСПРАВЛЕННЫЙ ответ строго по схеме, только JSON.")
|
||||
b.WriteString(" Файлы адресуй номерами из списка, присланного выше.\n\n")
|
||||
b.WriteString(schemaText)
|
||||
b.WriteString("\n\n")
|
||||
writeFileList(&b, in.Files, maxFiles)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+349
-55
@@ -5,22 +5,25 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
)
|
||||
|
||||
// parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему.
|
||||
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные
|
||||
// предупреждения (см. decide) ошибкой не считаются — они уводят в review.
|
||||
// Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Второй
|
||||
// результат — претензии к отдельным элементам files[]: они НЕ ошибка разбора
|
||||
// (повторный запрос к модели не делается), а причины ухода в review.
|
||||
// Структурные предупреждения (см. decide) ошибкой тоже не считаются.
|
||||
//
|
||||
// Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см.
|
||||
// sanitizePlan) ДО структурной валидации: так, например, title из одних
|
||||
// zero-width символов схлопывается в пустой и корректно уводит в ретрай.
|
||||
func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) {
|
||||
func parsePlan(raw string, in Input, log *slog.Logger) (Plan, []string, error) {
|
||||
jsonStr, err := llm.ExtractJSONObject(raw)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("no JSON object in response")
|
||||
return Plan{}, nil, fmt.Errorf("no JSON object in response")
|
||||
}
|
||||
|
||||
var p Plan
|
||||
@@ -30,57 +33,212 @@ func parsePlan(raw string, in Input, log *slog.Logger) (Plan, error) {
|
||||
// Повторяем без строгого режима: лишние поля — не повод падать,
|
||||
// но если и так не разобралось — это ошибка схемы.
|
||||
if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil {
|
||||
return Plan{}, fmt.Errorf("JSON not parsed: %w", err2)
|
||||
return Plan{}, nil, fmt.Errorf("JSON not parsed: %w", err2)
|
||||
}
|
||||
}
|
||||
|
||||
sanitizePlan(&p, log)
|
||||
|
||||
if err := validateSchema(&p, in); err != nil {
|
||||
return Plan{}, err
|
||||
problems, err := validateSchema(&p, in)
|
||||
if err != nil {
|
||||
return Plan{}, problems, err
|
||||
}
|
||||
return p, nil
|
||||
return p, problems, nil
|
||||
}
|
||||
|
||||
// validateSchema проверяет обязательную структуру плана. Главный инвариант
|
||||
// безопасности: каждый files[].src совпадает с реальным файлом торрента —
|
||||
// недоверенный выход LLM не может сослаться на посторонний путь.
|
||||
func validateSchema(p *Plan, in Input) error {
|
||||
// validateSchema проверяет обязательную структуру плана и резолвит адресацию
|
||||
// файлов. Главный инвариант безопасности: files[].src заполняем МЫ — из своего
|
||||
// же списка, по номеру строки (i), напечатанному в промпте. Присланный моделью
|
||||
// путь принимается лишь как запасной формат и только при точном совпадении с
|
||||
// файлом торрента, поэтому сослаться на посторонний путь невозможно.
|
||||
//
|
||||
// Негодный элемент (номер вне диапазона, повторная адресация, ни номера ни
|
||||
// пути, неизвестный путь) отбрасывается поимённой претензией — план при этом
|
||||
// живёт: одна ошибка в одном из сотен элементов не должна ронять всю работу.
|
||||
// Ошибка возвращается только когда плана не осталось вовсе.
|
||||
func validateSchema(p *Plan, in Input) ([]string, error) {
|
||||
switch p.Type {
|
||||
case MediaMovie, MediaSeries:
|
||||
case "":
|
||||
return fmt.Errorf("field type is empty (expected movie or series)")
|
||||
return nil, fmt.Errorf("field type is empty (expected movie or series)")
|
||||
default:
|
||||
return fmt.Errorf("unknown type %q", p.Type)
|
||||
return nil, fmt.Errorf("unknown type %q", p.Type)
|
||||
}
|
||||
if strings.TrimSpace(p.Title) == "" {
|
||||
return fmt.Errorf("field title is empty")
|
||||
return nil, fmt.Errorf("field title is empty")
|
||||
}
|
||||
if len(p.Files) == 0 {
|
||||
return fmt.Errorf("files list is empty")
|
||||
return nil, fmt.Errorf("files list is empty")
|
||||
}
|
||||
|
||||
known := make(map[string]bool, len(in.Files))
|
||||
for _, f := range in.Files {
|
||||
known[f.Path] = true
|
||||
byPath := make(map[string]int, len(in.Files))
|
||||
for i, f := range in.Files {
|
||||
byPath[f.Path] = i
|
||||
}
|
||||
|
||||
var problems problemList
|
||||
taken := make(map[int]bool, len(p.Files))
|
||||
kept := p.Files[:0]
|
||||
for i := range p.Files {
|
||||
pf := &p.Files[i]
|
||||
pf := p.Files[i]
|
||||
// Роль вне перечня — ошибка схемы, а не адресации: модель ошиблась в
|
||||
// нашем же словаре, и это чинится повторной попыткой. Проверка стоит ДО
|
||||
// резолва, поэтому pf.Src ещё пуст: адресуем элемент так, как его назвала
|
||||
// сама модель, иначе ей (и человеку) нечего чинить в списке на 180 строк.
|
||||
if !pf.Role.valid() {
|
||||
return fmt.Errorf("file %q: unknown role %q", pf.Src, pf.Role)
|
||||
return problems.result(), fmt.Errorf("file %s: unknown role %q",
|
||||
planFileRef(pf), shorten(string(pf.Role)))
|
||||
}
|
||||
if strings.TrimSpace(pf.Src) == "" {
|
||||
return fmt.Errorf("file with empty src")
|
||||
idx, problem, ok := resolveFile(pf, in.Files, byPath)
|
||||
if problem != "" {
|
||||
problems.add(problem)
|
||||
}
|
||||
if !known[pf.Src] {
|
||||
return fmt.Errorf("src %q not found among torrent files", pf.Src)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if taken[idx] {
|
||||
problems.add(fmt.Sprintf(
|
||||
"файл %d (%s) адресован повторно — лишний элемент плана отброшен",
|
||||
idx+1, shorten(in.Files[idx].Path)))
|
||||
continue
|
||||
}
|
||||
if pf.Role == RoleEpisode && pf.Episode == nil {
|
||||
return fmt.Errorf("episode %q has no episode number", pf.Src)
|
||||
return problems.result(), fmt.Errorf("episode %q has no episode number",
|
||||
shorten(in.Files[idx].Path))
|
||||
}
|
||||
taken[idx] = true
|
||||
n := FileIndex(idx + 1)
|
||||
pf.Index = &n
|
||||
pf.Src = in.Files[idx].Path
|
||||
kept = append(kept, pf)
|
||||
}
|
||||
return nil
|
||||
p.Files = kept
|
||||
if len(p.Files) == 0 {
|
||||
return problems.result(), fmt.Errorf("no files[] element addresses a real torrent file")
|
||||
}
|
||||
return problems.result(), nil
|
||||
}
|
||||
|
||||
// maxProblems — сколько поимённых претензий к элементам плана попадает в
|
||||
// причины; остальные сворачиваются в счётчик. Причины уезжают в баннер ревью,
|
||||
// в карточку Telegram и в БД навсегда, а на раздаче из 180 файлов негодным
|
||||
// может оказаться каждый элемент.
|
||||
const maxProblems = 12
|
||||
|
||||
// problemList копит претензии к элементам плана, не давая им расти без предела.
|
||||
type problemList struct {
|
||||
items []string
|
||||
total int
|
||||
}
|
||||
|
||||
func (l *problemList) add(s string) {
|
||||
l.total++
|
||||
if len(l.items) < maxProblems {
|
||||
l.items = append(l.items, s)
|
||||
}
|
||||
}
|
||||
|
||||
// result — накопленное плюс свёрнутый хвост.
|
||||
func (l *problemList) result() []string {
|
||||
if l.total <= len(l.items) {
|
||||
return l.items
|
||||
}
|
||||
return append(l.items, fmt.Sprintf("и ещё %d претензий к элементам плана",
|
||||
l.total-len(l.items)))
|
||||
}
|
||||
|
||||
// planFileRef — как элемент плана адресован МОДЕЛЬЮ: номером нашего списка,
|
||||
// иначе присланным путём. Нужен в сообщениях, которые строятся до резолва.
|
||||
func planFileRef(pf PlanFile) string {
|
||||
if pf.Index != nil {
|
||||
return fmt.Sprintf("#%d", int(*pf.Index))
|
||||
}
|
||||
if src := strings.TrimSpace(pf.Src); src != "" {
|
||||
return strconv.Quote(shorten(src))
|
||||
}
|
||||
return "без номера и пути"
|
||||
}
|
||||
|
||||
// reasonValueRunes — сколько рун внешнего значения показывать в причине.
|
||||
const reasonValueRunes = 40
|
||||
|
||||
// shorten оставляет от внешнего значения начало и конец, выкидывая середину.
|
||||
// Значения (пути, роли) приходят из недоверенного ответа модели и по длине не
|
||||
// ограничены, а причина оседает в БД навсегда и занимает экран в карточке
|
||||
// Telegram (docs/conventions/errors.md). Режем по рунам: обрыв посреди
|
||||
// многобайтовой буквы дал бы мусор; середину — потому что у пути информативны
|
||||
// оба края.
|
||||
func shorten(s string) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= reasonValueRunes {
|
||||
return s
|
||||
}
|
||||
head := reasonValueRunes / 2
|
||||
return string(r[:head]) + "…" + string(r[len(r)-head:])
|
||||
}
|
||||
|
||||
// resolveFile определяет, какой файл раздачи адресует элемент плана. Главенствует
|
||||
// номер: он назван нами, и расхождение с присланным путём — сигнал сдвига
|
||||
// адресации, а не повод молча затереть путь. Возвращает индекс в списке, текст
|
||||
// претензии (пусто — претензий нет) и годность элемента.
|
||||
func resolveFile(pf PlanFile, files []File, byPath map[string]int) (int, string, bool) {
|
||||
src := strings.TrimSpace(pf.Src)
|
||||
if pf.Index != nil && int(*pf.Index) != 0 {
|
||||
n := int(*pf.Index)
|
||||
if n < 1 || n > len(files) {
|
||||
return 0, fmt.Sprintf(
|
||||
"элемент плана адресует файл номером %d вне диапазона 1..%d — отброшен",
|
||||
n, len(files)), false
|
||||
}
|
||||
idx := n - 1
|
||||
if src != "" && src != files[idx].Path {
|
||||
// Модель назвала обе стороны и они разошлись — бесплатная сверка
|
||||
// против сдвига нумерации. Берём номер, но сообщаем человеку.
|
||||
return idx, fmt.Sprintf(
|
||||
"элемент плана: номер %d указывает на %q, а присланный путь — %q;"+
|
||||
" принят файл по номеру", n, shorten(files[idx].Path), shorten(src)), true
|
||||
}
|
||||
return idx, "", true
|
||||
}
|
||||
if src != "" {
|
||||
// Запасной формат: модель прислала путь вместо номера. Принимаем только
|
||||
// точное совпадение с реальным файлом раздачи.
|
||||
idx, found := byPath[src]
|
||||
if !found {
|
||||
return 0, fmt.Sprintf("путь %q не найден среди файлов раздачи — элемент плана отброшен",
|
||||
shorten(src)), false
|
||||
}
|
||||
return idx, "", true
|
||||
}
|
||||
if pf.Index != nil {
|
||||
// Явный ноль. Модель посчитала список с нуля: этот элемент не адресует
|
||||
// ничего, а все её остальные номера резолвятся со сдвигом на файл.
|
||||
// Называем причину так, чтобы сдвиг был самоописывающимся.
|
||||
return 0, "элемент плана адресует файл номером 0 — список нумеруется с 1," +
|
||||
" элемент отброшен (остальные номера могли уехать на файл)", false
|
||||
}
|
||||
return 0, "элемент плана без номера файла и без пути — отброшен", false
|
||||
}
|
||||
|
||||
// planIssues — что вскрылось до модели уверенности: претензии разбора,
|
||||
// усечение списка файлов пределом и сам список (для сводки покрытия).
|
||||
type planIssues struct {
|
||||
dropped []string // претензии к элементам files[] (см. validateSchema)
|
||||
files []File // список, показанный модели (префикс all, в порядке нумерации)
|
||||
// all — ПОЛНЫЙ упорядоченный список файлов раздачи: знаменатель покрытия и
|
||||
// снимок, уходящий в Result. Усечение пределом max_files — свойство промпта
|
||||
// и резолва, а не снимка (см. Recognize).
|
||||
all []File
|
||||
}
|
||||
|
||||
// truncated — сколько файлов раздачи не показано модели (0 — список полный).
|
||||
func (is planIssues) truncated() int { return len(is.all) - len(is.files) }
|
||||
|
||||
// truncationReason — усечение списка файлов пределом max_files.
|
||||
func truncationReason(is planIssues) string {
|
||||
return fmt.Sprintf("список файлов усечён пределом [recognition].max_files:"+
|
||||
" модели показано %d из %d — остальные распознаны быть не могли",
|
||||
len(is.files), len(is.all))
|
||||
}
|
||||
|
||||
// decide считает решение модели уверенности (см. recognition.md). Авто —
|
||||
@@ -88,14 +246,38 @@ func validateSchema(p *Plan, in Input) error {
|
||||
// название матча пригодно как имя каталога; чистая структурная валидация (для
|
||||
// сериала — число серий бьётся с базой); согласованность с пред-парсом;
|
||||
// самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review.
|
||||
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision {
|
||||
//
|
||||
// Auto считается по числу БЛОКИРУЮЩИХ причин, а не по длине Reasons: сводка
|
||||
// покрытия плана показывается человеку всегда, когда покрыты не все файлы, но
|
||||
// сама по себе авто-раскладку не отменяет — модель вправе не перечислять
|
||||
// .nfo и скриншоты. Блокирует только непокрытый видеофайл.
|
||||
func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64, issues planIssues) Decision {
|
||||
var reasons []string
|
||||
blocking := 0
|
||||
// add — причина, отменяющая авто; note — то же для глаз человека, без влияния
|
||||
// на решение.
|
||||
add := func(s string) { reasons = append(reasons, s); blocking++ }
|
||||
note := func(s string) { reasons = append(reasons, s) }
|
||||
|
||||
switch {
|
||||
case !metadataEnabled:
|
||||
reasons = append(reasons, "метабазы отключены → авто-раскладка недоступна")
|
||||
add("метабазы отключены → авто-раскладка недоступна")
|
||||
case match == nil:
|
||||
reasons = append(reasons, "не найдено в базе или несколько кандидатов")
|
||||
add("не найдено в базе или несколько кандидатов")
|
||||
}
|
||||
|
||||
for _, d := range issues.dropped {
|
||||
add(d)
|
||||
}
|
||||
if issues.truncated() > 0 {
|
||||
add(truncationReason(issues))
|
||||
}
|
||||
for _, c := range coverageReasons(p, issues) {
|
||||
if c.blocks {
|
||||
add(c.text)
|
||||
} else {
|
||||
note(c.text)
|
||||
}
|
||||
}
|
||||
|
||||
// Каноническое название базы, непригодное как имя каталога (пустое или без
|
||||
@@ -105,23 +287,77 @@ func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold
|
||||
// (buildMatch), второй раз не чистим: два независимых пересчёта одного
|
||||
// условия разъедутся на первой же правке одного из них.
|
||||
if match != nil && !UsableTitle(match.Title) {
|
||||
reasons = append(reasons, "название из базы непригодно как имя каталога")
|
||||
add("название из базы непригодно как имя каталога")
|
||||
}
|
||||
|
||||
reasons = append(reasons, structuralWarnings(p)...)
|
||||
for _, w := range structuralWarnings(p) {
|
||||
add(w)
|
||||
}
|
||||
|
||||
if match != nil && p.Type == MediaSeries {
|
||||
reasons = append(reasons, episodeCountWarnings(p, match.SeasonEpisodeCounts)...)
|
||||
for _, w := range episodeCountWarnings(p, match.SeasonEpisodeCounts) {
|
||||
add(w)
|
||||
}
|
||||
}
|
||||
|
||||
reasons = append(reasons, consistencyWarnings(p, pre)...)
|
||||
for _, w := range consistencyWarnings(p, pre) {
|
||||
add(w)
|
||||
}
|
||||
|
||||
if p.Confidence < threshold {
|
||||
reasons = append(reasons,
|
||||
fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold))
|
||||
add(fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold))
|
||||
}
|
||||
|
||||
return Decision{Auto: len(reasons) == 0, Reasons: reasons}
|
||||
return Decision{Auto: blocking == 0, Reasons: reasons}
|
||||
}
|
||||
|
||||
// coverageEntry — строка сводки покрытия и её вес в решении auto/review.
|
||||
type coverageEntry struct {
|
||||
text string
|
||||
blocks bool
|
||||
}
|
||||
|
||||
// coverageReasons — сводка «в плане N из M» и отдельная причина о непокрытых
|
||||
// видеофайлах. Знаменатель — ПОЛНОЕ число файлов раздачи, а не усечённого
|
||||
// списка: иначе усечение пределом молча улучшало бы покрытие.
|
||||
func coverageReasons(p Plan, issues planIssues) []coverageEntry {
|
||||
if len(issues.all) == 0 {
|
||||
return nil
|
||||
}
|
||||
planned := make(map[string]bool, len(p.Files))
|
||||
for _, f := range p.Files {
|
||||
planned[f.Src] = true
|
||||
}
|
||||
var videos []string
|
||||
for _, f := range issues.files {
|
||||
if !planned[f.Path] && IsVideoFile(f.Path) {
|
||||
videos = append(videos, f.Path)
|
||||
}
|
||||
}
|
||||
var out []coverageEntry
|
||||
if len(planned) < len(issues.all) {
|
||||
out = append(out, coverageEntry{text: fmt.Sprintf(
|
||||
"в план попало %d файлов из %d", len(planned), len(issues.all))})
|
||||
}
|
||||
if len(videos) > 0 {
|
||||
out = append(out, coverageEntry{blocks: true, text: fmt.Sprintf(
|
||||
"вне плана осталось видеофайлов: %d (%s)", len(videos), listSome(videos, 3))})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listSome перечисляет не больше max элементов, сворачивая хвост в «и ещё N».
|
||||
// Каждое значение внешнее (путь из ответа модели) и потому усекается.
|
||||
func listSome(items []string, max int) string {
|
||||
out := make([]string, 0, min(len(items), max))
|
||||
for _, it := range items[:min(len(items), max)] {
|
||||
out = append(out, shorten(it))
|
||||
}
|
||||
s := strings.Join(out, ", ")
|
||||
if len(items) > max {
|
||||
s += ", и ещё " + strconv.Itoa(len(items)-max)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// episodeCountWarnings сверяет число распознанных серий по сезонам с базой.
|
||||
@@ -181,13 +417,12 @@ func structuralWarnings(p Plan) []string {
|
||||
return w
|
||||
}
|
||||
|
||||
// seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам.
|
||||
// seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам. На
|
||||
// сезон приходится не больше одной причины каждого рода: на паке из восьми
|
||||
// сезонов строка за каждый разрыв давала сорок строк, которые человек в ревью
|
||||
// уже не читает.
|
||||
func seriesWarnings(files []PlanFile) []string {
|
||||
type key struct{ s, e int }
|
||||
seen := map[key]int{}
|
||||
bySeason := map[int][]int{}
|
||||
var w []string
|
||||
|
||||
seen := map[int]map[int]int{}
|
||||
for _, f := range files {
|
||||
if f.Role != RoleEpisode || f.Episode == nil {
|
||||
continue
|
||||
@@ -196,27 +431,86 @@ func seriesWarnings(files []PlanFile) []string {
|
||||
if f.Season != nil {
|
||||
season = *f.Season
|
||||
}
|
||||
k := key{season, *f.Episode}
|
||||
seen[k]++
|
||||
if seen[k] == 2 {
|
||||
w = append(w, fmt.Sprintf("сериал: дубль серии S%02dE%02d", season, *f.Episode))
|
||||
if seen[season] == nil {
|
||||
seen[season] = map[int]int{}
|
||||
}
|
||||
bySeason[season] = append(bySeason[season], *f.Episode)
|
||||
seen[season][*f.Episode]++
|
||||
}
|
||||
|
||||
for _, season := range sortedKeys(bySeason) {
|
||||
eps := bySeason[season]
|
||||
sort.Ints(eps)
|
||||
for i := 1; i < len(eps); i++ {
|
||||
if eps[i] > eps[i-1]+1 {
|
||||
w = append(w, fmt.Sprintf("сериал: пропуск серий в сезоне %d между E%02d и E%02d",
|
||||
season, eps[i-1], eps[i]))
|
||||
var w []string
|
||||
for _, season := range sortedKeys(toSlices2(seen)) {
|
||||
eps := seen[season]
|
||||
nums := make([]int, 0, len(eps))
|
||||
for e := range eps {
|
||||
nums = append(nums, e)
|
||||
}
|
||||
sort.Ints(nums)
|
||||
|
||||
var dups []int
|
||||
for _, e := range nums {
|
||||
if eps[e] > 1 {
|
||||
dups = append(dups, e)
|
||||
}
|
||||
}
|
||||
if len(dups) > 0 {
|
||||
w = append(w, fmt.Sprintf("сериал: сезон %d — дубли серий %s",
|
||||
season, episodeList(dups, len(dups))))
|
||||
}
|
||||
|
||||
// Пропуски НЕ материализуем: номера серий приходят из недоверенного
|
||||
// ответа модели и не клампятся, а датовая нумерация (обычная для
|
||||
// ежедневных шоу: "episode": 20260902) рядом с единицей дала бы ~20 млн
|
||||
// int на одном распознавании. Копим только то, что будет напечатано,
|
||||
// остальное — счётчиком.
|
||||
var missing []int
|
||||
missingTotal := 0
|
||||
for i := 1; i < len(nums); i++ {
|
||||
gap := nums[i] - nums[i-1] - 1
|
||||
if gap <= 0 {
|
||||
continue // соседние номера (или переполнение на абсурдных значениях)
|
||||
}
|
||||
missingTotal += gap
|
||||
for e := nums[i-1] + 1; e < nums[i] && len(missing) < episodeListMax; e++ {
|
||||
missing = append(missing, e)
|
||||
}
|
||||
}
|
||||
if missingTotal > 0 {
|
||||
w = append(w, fmt.Sprintf("сериал: сезон %d — не хватает серий %s",
|
||||
season, episodeList(missing, missingTotal)))
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// episodeListMax — сколько номеров серий печатается в причине; хвост
|
||||
// сворачивается в «и ещё N». Предел печати задаёт и предел накопления у
|
||||
// вызывающего (см. seriesWarnings).
|
||||
const episodeListMax = 12
|
||||
|
||||
// episodeList печатает номера серий как E05, E07, … сворачивая длинный хвост.
|
||||
// total — сколько номеров всего: он может превышать len(eps), потому что
|
||||
// вызывающий вправе накопить лишь то, что будет напечатано.
|
||||
func episodeList(eps []int, total int) string {
|
||||
out := make([]string, 0, min(len(eps), episodeListMax))
|
||||
for _, e := range eps[:min(len(eps), episodeListMax)] {
|
||||
out = append(out, fmt.Sprintf("E%02d", e))
|
||||
}
|
||||
s := strings.Join(out, ", ")
|
||||
if total > len(out) {
|
||||
s += ", и ещё " + strconv.Itoa(total-len(out))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// toSlices2 — ключи map[int]map[int]int для sortedKeys.
|
||||
func toSlices2(m map[int]map[int]int) map[int][]int {
|
||||
out := make(map[int][]int, len(m))
|
||||
for k := range m {
|
||||
out[k] = nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// consistencyWarnings — расхождения LLM с черновым пред-парсом.
|
||||
func consistencyWarnings(p Plan, pre PreParse) []string {
|
||||
var w []string
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package recognize
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func intp(n int) *int { return &n }
|
||||
|
||||
func idxp(n int) *FileIndex { i := FileIndex(n); return &i }
|
||||
|
||||
func inputWith(paths ...string) Input {
|
||||
files := make([]File, len(paths))
|
||||
for i, p := range paths {
|
||||
@@ -25,9 +28,17 @@ func TestValidateSchema_OK(t *testing.T) {
|
||||
{Src: "b.mkv", Role: RoleEpisode, Season: intp(1), Episode: intp(2)},
|
||||
},
|
||||
}
|
||||
if err := validateSchema(&p, in); err != nil {
|
||||
problems, err := validateSchema(&p, in)
|
||||
if err != nil {
|
||||
t.Fatalf("validateSchema: %v", err)
|
||||
}
|
||||
if len(problems) != 0 {
|
||||
t.Errorf("чистый план не должен давать претензий: %v", problems)
|
||||
}
|
||||
// Запасной формат (src без номера) резолвится в свой же номер.
|
||||
if idxOf(p.Files[0]) != 1 || idxOf(p.Files[1]) != 2 {
|
||||
t.Errorf("индексы не проставлены: %+v", p.Files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSchema_Errors(t *testing.T) {
|
||||
@@ -42,13 +53,13 @@ func TestValidateSchema_Errors(t *testing.T) {
|
||||
{"empty title", Plan{Type: MediaMovie, Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "title is empty"},
|
||||
{"no files", Plan{Type: MediaMovie, Title: "x"}, "files list is empty"},
|
||||
{"bad role", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: "boss"}}}, "unknown role"},
|
||||
{"empty src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "empty src"},
|
||||
{"unknown src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "not found among torrent files"},
|
||||
{"no addressing at all", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "no files[] element addresses"},
|
||||
{"unknown src only", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "no files[] element addresses"},
|
||||
{"episode no num", Plan{Type: MediaSeries, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleEpisode, Season: intp(1)}}}, "has no episode number"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateSchema(&tt.p, in)
|
||||
_, err := validateSchema(&tt.p, in)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Errorf("err = %v, want contains %q", err, tt.want)
|
||||
}
|
||||
@@ -60,7 +71,7 @@ func TestParsePlan_FencedJSON(t *testing.T) {
|
||||
in := inputWith("film.mkv")
|
||||
raw := "Вот результат:\n```json\n{\"type\":\"movie\",\"title\":\"Film\"," +
|
||||
"\"files\":[{\"src\":\"film.mkv\",\"role\":\"main\"}]}\n```"
|
||||
p, err := parsePlan(raw, in, testLogger())
|
||||
p, _, err := parsePlan(raw, in, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("parsePlan: %v", err)
|
||||
}
|
||||
@@ -73,7 +84,7 @@ func TestParsePlan_UnknownFieldTolerated(t *testing.T) {
|
||||
in := inputWith("film.mkv")
|
||||
raw := `{"type":"movie","title":"Film","extra_field":123,
|
||||
"files":[{"src":"film.mkv","role":"main"}]}`
|
||||
if _, err := parsePlan(raw, in, testLogger()); err != nil {
|
||||
if _, _, err := parsePlan(raw, in, testLogger()); err != nil {
|
||||
t.Fatalf("unknown field should be tolerated: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -106,10 +117,10 @@ func TestSeriesWarnings_GapAndDup(t *testing.T) {
|
||||
w := seriesWarnings(files)
|
||||
var dup, gap bool
|
||||
for _, s := range w {
|
||||
if strings.Contains(s, "дубль") {
|
||||
if strings.Contains(s, "дубли серий") {
|
||||
dup = true
|
||||
}
|
||||
if strings.Contains(s, "пропуск") {
|
||||
if strings.Contains(s, "не хватает серий") {
|
||||
gap = true
|
||||
}
|
||||
}
|
||||
@@ -157,7 +168,7 @@ func TestConsistencyWarnings(t *testing.T) {
|
||||
|
||||
func TestDecide_MetadataDisabled(t *testing.T) {
|
||||
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
|
||||
d := decide(p, PreParse{}, nil, false, 0.85)
|
||||
d := decide(p, PreParse{}, nil, false, 0.85, planIssues{})
|
||||
if d.Auto {
|
||||
t.Error("без метабаз авто недопустимо")
|
||||
}
|
||||
@@ -168,7 +179,7 @@ func TestDecide_MetadataDisabled(t *testing.T) {
|
||||
|
||||
func TestDecide_NoMatch(t *testing.T) {
|
||||
p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}}
|
||||
d := decide(p, PreParse{}, nil, true, 0.85)
|
||||
d := decide(p, PreParse{}, nil, true, 0.85, planIssues{})
|
||||
if d.Auto || !strings.Contains(d.Reasons[0], "не найдено в базе") {
|
||||
t.Errorf("reasons = %v", d.Reasons)
|
||||
}
|
||||
@@ -178,7 +189,7 @@ func TestDecide_AutoMovie(t *testing.T) {
|
||||
p := Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999, Confidence: 0.95,
|
||||
Files: []PlanFile{{Role: RoleMain}, {Role: RoleSample}}}
|
||||
match := &Match{Provider: "tmdb", ProviderID: "603", Title: "The Matrix", Year: 1999}
|
||||
d := decide(p, PreParse{Year: 1999}, match, true, 0.85)
|
||||
d := decide(p, PreParse{Year: 1999}, match, true, 0.85, planIssues{})
|
||||
if !d.Auto {
|
||||
t.Errorf("clean movie with match must be auto, reasons: %v", d.Reasons)
|
||||
}
|
||||
@@ -188,7 +199,7 @@ func TestDecide_LowConfidenceBlocksAuto(t *testing.T) {
|
||||
p := Plan{Type: MediaMovie, Title: "X", Year: 2000, Confidence: 0.5,
|
||||
Files: []PlanFile{{Role: RoleMain}}}
|
||||
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "X", Year: 2000}
|
||||
d := decide(p, PreParse{}, match, true, 0.85)
|
||||
d := decide(p, PreParse{}, match, true, 0.85, planIssues{})
|
||||
if d.Auto || !hasReason(d.Reasons, "уверенность") {
|
||||
t.Errorf("low confidence must block auto, reasons: %v", d.Reasons)
|
||||
}
|
||||
@@ -201,20 +212,20 @@ func TestDecide_AutoSeriesEpisodeCount(t *testing.T) {
|
||||
Files: []PlanFile{mk(1), mk(2), mk(3)}}
|
||||
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Fargo", Year: 2014,
|
||||
SeasonEpisodeCounts: map[int]int{2: 3}}
|
||||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); !d.Auto {
|
||||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); !d.Auto {
|
||||
t.Errorf("full season must be auto, reasons: %v", d.Reasons)
|
||||
}
|
||||
|
||||
// Неполный пак (в базе 10) — авто блокируется.
|
||||
match.SeasonEpisodeCounts = map[int]int{2: 10}
|
||||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto ||
|
||||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); d.Auto ||
|
||||
!hasReason(d.Reasons, "распознано серий 3, в базе 10") {
|
||||
t.Errorf("partial pack must block auto, reasons: %v", d.Reasons)
|
||||
}
|
||||
|
||||
// Нет данных о числе серий — авто блокируется.
|
||||
match.SeasonEpisodeCounts = nil
|
||||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85); d.Auto ||
|
||||
if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); d.Auto ||
|
||||
!hasReason(d.Reasons, "нет данных о числе серий") {
|
||||
t.Errorf("missing counts must block auto, reasons: %v", d.Reasons)
|
||||
}
|
||||
@@ -234,3 +245,256 @@ func TestPreParse(t *testing.T) {
|
||||
t.Errorf("season/episode = %d/%d, want 2/5", series.Season, series.Episode)
|
||||
}
|
||||
}
|
||||
|
||||
// Перечень видеорасширений — единственное место, где решается, что считать
|
||||
// потерянной серией: незнакомое расширение уедет в спутники и авто не заблокирует.
|
||||
func TestIsVideoFile(t *testing.T) {
|
||||
video := []string{
|
||||
"a.mkv", "a.MKV", "s1/e01.avi", "film.mp4", "x.m4v", "x.mov", "x.wmv",
|
||||
"x.mpg", "x.mpeg", "BDMV/STREAM/00001.m2ts", "x.mts", "x.ts", "x.vob",
|
||||
"x.flv", "x.webm", "x.ogm", "x.divx", "x.rmvb", "x.3gp", "disc.iso", "disc.img",
|
||||
}
|
||||
for _, p := range video {
|
||||
if !IsVideoFile(p) {
|
||||
t.Errorf("IsVideoFile(%q) = false, want true", p)
|
||||
}
|
||||
}
|
||||
other := []string{
|
||||
"a.srt", "a.ass", "a.sub", "a.idx", "a.nfo", "a.txt", "a.jpg", "a.png",
|
||||
"a.md5", "a.sfv", "cover", "a.mkv.txt", "",
|
||||
}
|
||||
for _, p := range other {
|
||||
if IsVideoFile(p) {
|
||||
t.Errorf("IsVideoFile(%q) = true, want false", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сезон с тремя дырами даёт ровно одну причину, перечисляющую недостающие серии.
|
||||
func TestSeriesWarnings_GapsCollapsedPerSeason(t *testing.T) {
|
||||
var files []PlanFile
|
||||
for _, e := range []int{1, 2, 3, 4, 6, 8, 9, 10, 12} { // нет 5, 7, 11
|
||||
files = append(files, PlanFile{Role: RoleEpisode, Season: intp(1), Episode: intp(e)})
|
||||
}
|
||||
w := seriesWarnings(files)
|
||||
if len(w) != 1 {
|
||||
t.Fatalf("ожидалась одна причина на сезон, получено %d: %v", len(w), w)
|
||||
}
|
||||
if !strings.Contains(w[0], "E05") || !strings.Contains(w[0], "E07") || !strings.Contains(w[0], "E11") {
|
||||
t.Errorf("причина не перечисляет недостающие серии: %q", w[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Мультисезонный пак: на сезон — не больше одной причины о пропусках.
|
||||
func TestSeriesWarnings_OneReasonPerSeason(t *testing.T) {
|
||||
var files []PlanFile
|
||||
for s := 1; s <= 8; s++ {
|
||||
for _, e := range []int{1, 3, 5} { // дыры в каждом сезоне
|
||||
files = append(files, PlanFile{Role: RoleEpisode, Season: intp(s), Episode: intp(e)})
|
||||
}
|
||||
}
|
||||
if w := seriesWarnings(files); len(w) != 8 {
|
||||
t.Errorf("причин %d при 8 сезонах, ожидалось 8: %v", len(w), w)
|
||||
}
|
||||
}
|
||||
|
||||
// Непокрытый видеофайл означает потерянную серию — авто блокируется.
|
||||
func TestDecide_UncoveredVideoBlocksAuto(t *testing.T) {
|
||||
in := inputWith("s1/e01.mkv", "s1/e02.mkv")
|
||||
p := Plan{Type: MediaSeries, Title: "Show", Year: 2020, Confidence: 0.95,
|
||||
Files: []PlanFile{{Src: "s1/e01.mkv", Role: RoleEpisode, Season: intp(1), Episode: intp(1)}}}
|
||||
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Show", Year: 2020,
|
||||
SeasonEpisodeCounts: map[int]int{1: 1}}
|
||||
|
||||
d := decide(p, PreParse{}, match, true, 0.85,
|
||||
planIssues{files: in.Files, all: in.Files})
|
||||
if d.Auto {
|
||||
t.Errorf("непокрытый видеофайл обязан блокировать авто: %v", d.Reasons)
|
||||
}
|
||||
if !hasReason(d.Reasons, "вне плана осталось видеофайлов: 1") {
|
||||
t.Errorf("reasons = %v", d.Reasons)
|
||||
}
|
||||
if !hasReason(d.Reasons, "в план попало 1 файлов из 2") {
|
||||
t.Errorf("сводка покрытия обязана быть названа: %v", d.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
// Непокрытые спутники (.nfo, скриншоты, тексты) видны в покрытии, но авто не
|
||||
// отменяют: модель вправе не перечислять то, что не раскладывается.
|
||||
func TestDecide_UncoveredCompanionsKeepAuto(t *testing.T) {
|
||||
in := inputWith("film.mkv", "film.nfo", "screens/01.jpg", "readme.txt")
|
||||
p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95,
|
||||
Files: []PlanFile{{Src: "film.mkv", Role: RoleMain}}}
|
||||
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999}
|
||||
|
||||
d := decide(p, PreParse{Year: 1999}, match, true, 0.85,
|
||||
planIssues{files: in.Files, all: in.Files})
|
||||
if !d.Auto {
|
||||
t.Errorf("непокрытые спутники не должны отменять авто: %v", d.Reasons)
|
||||
}
|
||||
if !hasReason(d.Reasons, "в план попало 1 файлов из 4") {
|
||||
t.Errorf("покрытие обязано быть показано человеку: %v", d.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
// Отброшенный элемент — блокирующая причина, а полное покрытие сводкой не шумит.
|
||||
func TestDecide_FullCoverageIsSilent(t *testing.T) {
|
||||
in := inputWith("film.mkv")
|
||||
p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95,
|
||||
Files: []PlanFile{{Src: "film.mkv", Role: RoleMain}}}
|
||||
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999}
|
||||
|
||||
d := decide(p, PreParse{Year: 1999}, match, true, 0.85,
|
||||
planIssues{files: in.Files, all: in.Files})
|
||||
if !d.Auto || len(d.Reasons) != 0 {
|
||||
t.Errorf("полное покрытие не должно давать причин: %+v", d)
|
||||
}
|
||||
|
||||
withDrop := decide(p, PreParse{Year: 1999}, match, true, 0.85,
|
||||
planIssues{files: in.Files, all: in.Files, dropped: []string{"элемент отброшен"}})
|
||||
if withDrop.Auto || !hasReason(withDrop.Reasons, "элемент отброшен") {
|
||||
t.Errorf("отбраковка обязана блокировать авто: %+v", withDrop)
|
||||
}
|
||||
}
|
||||
|
||||
// Датовая нумерация серий (обычная для ежедневных шоу) рядом с обычной даёт
|
||||
// разрыв в миллионы номеров. Перечисление такого разрыва материализовало бы
|
||||
// сотни мегабайт int при пределе печати в 12 номеров, поэтому пропуски
|
||||
// считаются арифметикой, а не перечислением.
|
||||
func TestSeriesWarnings_HugeGapNotMaterialized(t *testing.T) {
|
||||
files := []PlanFile{
|
||||
{Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
|
||||
{Role: RoleEpisode, Season: intp(1), Episode: intp(20260902)},
|
||||
}
|
||||
|
||||
var before, after runtime.MemStats
|
||||
runtime.ReadMemStats(&before)
|
||||
w := seriesWarnings(files)
|
||||
runtime.ReadMemStats(&after)
|
||||
|
||||
// Бюджет — на строки причины, а не на 20 млн номеров: старая реализация
|
||||
// брала здесь ~160 МиБ.
|
||||
if grew := after.TotalAlloc - before.TotalAlloc; grew > 1<<20 {
|
||||
t.Errorf("разрыв материализован: выделено %d байт", grew)
|
||||
}
|
||||
if len(w) != 1 {
|
||||
t.Fatalf("ожидалась одна причина на сезон, получено %v", w)
|
||||
}
|
||||
if !strings.Contains(w[0], "E02, E03") || !strings.Contains(w[0], "E13") {
|
||||
t.Errorf("причина обязана перечислять начало разрыва: %q", w[0])
|
||||
}
|
||||
if !strings.Contains(w[0], "и ещё 20260888") {
|
||||
t.Errorf("хвост обязан быть назван числом: %q", w[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Длинный хвост пропусков сворачивается: печатается предел, остальное — счётчик.
|
||||
func TestSeriesWarnings_LongTailFolded(t *testing.T) {
|
||||
files := []PlanFile{
|
||||
{Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
|
||||
{Role: RoleEpisode, Season: intp(1), Episode: intp(20)}, // нет 2..19 — 18 штук
|
||||
}
|
||||
w := seriesWarnings(files)
|
||||
if len(w) != 1 {
|
||||
t.Fatalf("причины = %v", w)
|
||||
}
|
||||
if strings.Count(w[0], "E") != episodeListMax {
|
||||
t.Errorf("напечатано номеров: %d, ожидался предел %d: %q",
|
||||
strings.Count(w[0], "E"), episodeListMax, w[0])
|
||||
}
|
||||
if !strings.Contains(w[0], "E13") || strings.Contains(w[0], "E14") {
|
||||
t.Errorf("предел печати сдвинулся: %q", w[0])
|
||||
}
|
||||
if !strings.Contains(w[0], "и ещё 6") {
|
||||
t.Errorf("свёрнутый хвост не назван числом: %q", w[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Претензии к элементам не растут без предела, а внешние значения в них
|
||||
// усечены: причины оседают в БД навсегда и уезжают в карточку Telegram.
|
||||
func TestValidateSchema_ProblemsCappedAndValuesShortened(t *testing.T) {
|
||||
long := "сезон 1/" + strings.Repeat("длинное-имя-", 30) + "серия.mkv"
|
||||
in := inputWith(long, "b.mkv")
|
||||
p := Plan{Type: MediaSeries, Title: "Show", Files: []PlanFile{
|
||||
{Src: long, Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
|
||||
}}
|
||||
// Тридцать элементов, каждый повторно адресующий первый файл.
|
||||
for range 30 {
|
||||
p.Files = append(p.Files,
|
||||
PlanFile{Src: long, Role: RoleEpisode, Season: intp(1), Episode: intp(2)})
|
||||
}
|
||||
problems, err := validateSchema(&p, in)
|
||||
if err != nil {
|
||||
t.Fatalf("validateSchema: %v", err)
|
||||
}
|
||||
if len(problems) != maxProblems+1 {
|
||||
t.Fatalf("претензий %d, ожидались %d поимённых плюс свёрнутый хвост: %v",
|
||||
len(problems), maxProblems, problems)
|
||||
}
|
||||
if !strings.Contains(problems[len(problems)-1], "и ещё 18") {
|
||||
t.Errorf("хвост претензий не назван числом: %q", problems[len(problems)-1])
|
||||
}
|
||||
for _, s := range problems {
|
||||
if strings.Contains(s, long) {
|
||||
t.Errorf("внешнее значение не усечено: %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ по неизвестной роли называет файл так, как его адресовала модель:
|
||||
// проверка стоит до резолва, и pf.Src там ещё пуст.
|
||||
func TestValidateSchema_UnknownRoleNamesAddressing(t *testing.T) {
|
||||
in := inputWith("a.mkv", "b.mkv")
|
||||
p := Plan{Type: MediaMovie, Title: "Film", Files: []PlanFile{
|
||||
{Index: idxp(2), Role: "trailer"},
|
||||
}}
|
||||
_, err := validateSchema(&p, in)
|
||||
if err == nil || !strings.Contains(err.Error(), "#2") {
|
||||
t.Errorf("err = %v, ожидался номер адресации", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ноль — не «номера нет»: так адресует модель, посчитавшая список с нуля, и
|
||||
// остальные её номера уезжают на файл. Диагноз обязан быть самоописывающимся.
|
||||
func TestResolveFile_ZeroIndexIsItsOwnCase(t *testing.T) {
|
||||
in := inputWith("a.mkv", "b.mkv")
|
||||
byPath := map[string]int{"a.mkv": 0, "b.mkv": 1}
|
||||
|
||||
_, problem, ok := resolveFile(PlanFile{Index: idxp(0)}, in.Files, byPath)
|
||||
if ok || !strings.Contains(problem, "нумеруется с 1") {
|
||||
t.Errorf("явный ноль: ok=%v, problem=%q", ok, problem)
|
||||
}
|
||||
_, problem, ok = resolveFile(PlanFile{}, in.Files, byPath)
|
||||
if ok || !strings.Contains(problem, "без номера файла и без пути") {
|
||||
t.Errorf("поля нет вовсе: ok=%v, problem=%q", ok, problem)
|
||||
}
|
||||
// Ноль рядом с путём остаётся запасным форматом: путь резолвится.
|
||||
idx, problem, ok := resolveFile(PlanFile{Index: idxp(0), Src: "b.mkv"}, in.Files, byPath)
|
||||
if !ok || idx != 1 || problem != "" {
|
||||
t.Errorf("путь при нулевом номере обязан резолвиться: idx=%d ok=%v %q", idx, ok, problem)
|
||||
}
|
||||
}
|
||||
|
||||
// Структурное предупреждение и рассинхрон года — блокирующие причины, а не
|
||||
// информационные заметки: развод «блокирует / не блокирует» обязан исполняться.
|
||||
func TestDecide_StructuralAndConsistencyBlockAuto(t *testing.T) {
|
||||
in := inputWith("a.mkv", "b.mkv")
|
||||
p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95,
|
||||
Files: []PlanFile{
|
||||
{Src: "a.mkv", Role: RoleMain},
|
||||
{Src: "b.mkv", Role: RoleMain}, // два main у фильма
|
||||
}}
|
||||
match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999}
|
||||
|
||||
d := decide(p, PreParse{Year: 1998}, match, true, 0.85,
|
||||
planIssues{files: in.Files, all: in.Files})
|
||||
if d.Auto {
|
||||
t.Errorf("структурное предупреждение обязано блокировать авто: %v", d.Reasons)
|
||||
}
|
||||
if !hasReason(d.Reasons, "основных видеофайлов 2") {
|
||||
t.Errorf("структурная причина не названа: %v", d.Reasons)
|
||||
}
|
||||
if !hasReason(d.Reasons, "год расходится: пред-парс=1998, LLM=1999") {
|
||||
t.Errorf("рассинхрон года не назван: %v", d.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user