package recognize import ( "encoding/json" "fmt" "log/slog" "sort" "strconv" "strings" "git.vakhrushev.me/av/jellybit/internal/llm" ) // parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему. // Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Второй // результат — претензии к отдельным элементам files[]: они НЕ ошибка разбора // (повторный запрос к модели не делается), а причины ухода в review. // Структурные предупреждения (см. decide) ошибкой тоже не считаются. // // Человекочитаемые поля плана санитизируются как недоверенный вывод LLM (см. // sanitizePlan) ДО структурной валидации: так, например, title из одних // zero-width символов схлопывается в пустой и корректно уводит в ретрай. func parsePlan(raw string, in Input, log *slog.Logger) (Plan, []string, error) { jsonStr, err := llm.ExtractJSONObject(raw) if err != nil { return Plan{}, nil, fmt.Errorf("no JSON object in response") } var p Plan dec := json.NewDecoder(strings.NewReader(jsonStr)) dec.DisallowUnknownFields() if err := dec.Decode(&p); err != nil { // Повторяем без строгого режима: лишние поля — не повод падать, // но если и так не разобралось — это ошибка схемы. if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil { return Plan{}, nil, fmt.Errorf("JSON not parsed: %w", err2) } } sanitizePlan(&p, log) problems, err := validateSchema(&p, in) if err != nil { return Plan{}, problems, err } return p, problems, nil } // validateSchema проверяет обязательную структуру плана и резолвит адресацию // файлов. Главный инвариант безопасности: files[].src заполняем МЫ — из своего // же списка, по номеру строки (i), напечатанному в промпте. Присланный моделью // путь принимается лишь как запасной формат и только при точном совпадении с // файлом торрента, поэтому сослаться на посторонний путь невозможно. // // Негодный элемент (номер вне диапазона, повторная адресация, ни номера ни // пути, неизвестный путь) отбрасывается поимённой претензией — план при этом // живёт: одна ошибка в одном из сотен элементов не должна ронять всю работу. // Ошибка возвращается только когда плана не осталось вовсе. func validateSchema(p *Plan, in Input) ([]string, error) { switch p.Type { case MediaMovie, MediaSeries: case "": return nil, fmt.Errorf("field type is empty (expected movie or series)") default: return nil, fmt.Errorf("unknown type %q", p.Type) } if strings.TrimSpace(p.Title) == "" { return nil, fmt.Errorf("field title is empty") } if len(p.Files) == 0 { return nil, fmt.Errorf("files list is empty") } 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.Src ещё пуст: адресуем элемент так, как его назвала // сама модель, иначе ей (и человеку) нечего чинить в списке на 180 строк. if !pf.Role.valid() { return problems.result(), fmt.Errorf("file %s: unknown role %q", planFileRef(pf), shorten(string(pf.Role))) } idx, problem, ok := resolveFile(pf, in.Files, byPath) if problem != "" { problems.add(problem) } 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 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) } 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). Авто — // только если выполнено всё: подтверждённый единичный матч в базе; каноническое // название матча пригодно как имя каталога; чистая структурная валидация (для // сериала — число серий бьётся с базой); согласованность с пред-парсом; // самооценка LLM не ниже порога. Любая невыполненная — причина ухода в review. // // 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: add("метабазы отключены → авто-раскладка недоступна") case match == nil: 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) } } // Каноническое название базы, непригодное как имя каталога (пустое или без // единой буквы и цифры), в план не подставлено — там осталось название // распознавания. Матч при этом верен: id, год и режиссёр на месте, поэтому // отклоняем не матч, а авто-раскладку. Match.Title уже санитизирован // (buildMatch), второй раз не чистим: два независимых пересчёта одного // условия разъедутся на первой же правке одного из них. if match != nil && !UsableTitle(match.Title) { add("название из базы непригодно как имя каталога") } for _, w := range structuralWarnings(p) { add(w) } if match != nil && p.Type == MediaSeries { for _, w := range episodeCountWarnings(p, match.SeasonEpisodeCounts) { add(w) } } for _, w := range consistencyWarnings(p, pre) { add(w) } if p.Confidence < threshold { add(fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold)) } 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 сверяет число распознанных серий по сезонам с базой. // Нет данных по сезону → блокируем авто (полноту пака не подтвердить). func episodeCountWarnings(p Plan, counts map[int]int) []string { recognized := map[int]int{} for _, f := range p.Files { if f.Role == RoleEpisode && f.Episode != nil { season := 0 if f.Season != nil { season = *f.Season } recognized[season]++ } } var w []string for _, season := range sortedKeys(toSlices(recognized)) { rc := recognized[season] dbc, ok := counts[season] switch { case !ok || dbc == 0: w = append(w, fmt.Sprintf("сезон %d: в базе нет данных о числе серий", season)) case rc != dbc: w = append(w, fmt.Sprintf("сезон %d: распознано серий %d, в базе %d", season, rc, dbc)) } } return w } // toSlices превращает map[int]int в map[int][]int для sortedKeys (нужны // только ключи). func toSlices(m map[int]int) map[int][]int { out := make(map[int][]int, len(m)) for k := range m { out[k] = nil } return out } // structuralWarnings — нарушения структуры плана (мягкие, не блокируют разбор). func structuralWarnings(p Plan) []string { var w []string switch p.Type { case MediaMovie: mains := 0 for _, f := range p.Files { if f.Role == RoleMain { mains++ } } if mains != 1 { w = append(w, fmt.Sprintf("фильм: основных видеофайлов %d, ожидался ровно 1", mains)) } case MediaSeries: w = append(w, seriesWarnings(p.Files)...) } return w } // seriesWarnings ловит дубли и пропуски в нумерации серий по сезонам. На // сезон приходится не больше одной причины каждого рода: на паке из восьми // сезонов строка за каждый разрыв давала сорок строк, которые человек в ревью // уже не читает. func seriesWarnings(files []PlanFile) []string { seen := map[int]map[int]int{} for _, f := range files { if f.Role != RoleEpisode || f.Episode == nil { continue } season := 0 if f.Season != nil { season = *f.Season } if seen[season] == nil { seen[season] = map[int]int{} } seen[season][*f.Episode]++ } 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 if pre.Year != 0 && p.Year != 0 && pre.Year != p.Year { w = append(w, fmt.Sprintf("год расходится: пред-парс=%d, LLM=%d", pre.Year, p.Year)) } if (pre.Season != 0 || pre.Episode != 0) && p.Type == MediaMovie { w = append(w, "тип расходится: пред-парс указывает на сериал, LLM — фильм") } return w } func sortedKeys(m map[int][]int) []int { ks := make([]int, 0, len(m)) for k := range m { ks = append(ks, k) } sort.Ints(ks) return ks }