package recognize import ( "encoding/json" "fmt" "sort" "strings" "git.vakhrushev.me/av/jellybit/internal/llm" ) // parsePlan извлекает JSON из ответа LLM, разбирает его и проверяет схему. // Ошибка здесь — сигнал к повторной попытке (ответ непригоден). Структурные // предупреждения (см. decide) ошибкой не считаются — они уводят в review. func parsePlan(raw string, in Input) (Plan, error) { jsonStr, err := llm.ExtractJSONObject(raw) if err != nil { return Plan{}, fmt.Errorf("в ответе нет JSON-объекта") } 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{}, fmt.Errorf("JSON не разобран: %v", err2) } } if err := validateSchema(&p, in); err != nil { return Plan{}, err } return p, nil } // validateSchema проверяет обязательную структуру плана. Главный инвариант // безопасности: каждый files[].src совпадает с реальным файлом торрента — // недоверенный выход LLM не может сослаться на посторонний путь. func validateSchema(p *Plan, in Input) error { switch p.Type { case MediaMovie, MediaSeries: case "": return fmt.Errorf("поле type пустое (ожидалось movie или series)") default: return fmt.Errorf("неизвестный type %q", p.Type) } if strings.TrimSpace(p.Title) == "" { return fmt.Errorf("поле title пустое") } if len(p.Files) == 0 { return fmt.Errorf("список files пуст") } known := make(map[string]bool, len(in.Files)) for _, f := range in.Files { known[f.Path] = true } for i := range p.Files { pf := &p.Files[i] if !pf.Role.valid() { return fmt.Errorf("файл %q: неизвестная role %q", pf.Src, pf.Role) } if strings.TrimSpace(pf.Src) == "" { return fmt.Errorf("файл с пустым src") } if !known[pf.Src] { return fmt.Errorf("src %q не найден среди файлов торрента", pf.Src) } if pf.Role == RoleEpisode && pf.Episode == nil { return fmt.Errorf("серия %q без номера episode", pf.Src) } } return nil } // decide считает решение модели уверенности (см. recognition.md). Авто — // только если выполнено всё: подтверждённый единичный матч в базе; чистая // структурная валидация (для сериала — число серий бьётся с базой); // согласованность с пред-парсом; самооценка LLM не ниже порога. Любая // невыполненная — причина ухода в review. func decide(p Plan, pre PreParse, match *Match, metadataEnabled bool, threshold float64) Decision { var reasons []string switch { case !metadataEnabled: reasons = append(reasons, "метабазы отключены → авто-раскладка недоступна") case match == nil: reasons = append(reasons, "не найдено в базе или несколько кандидатов") } reasons = append(reasons, structuralWarnings(p)...) if match != nil && p.Type == MediaSeries { reasons = append(reasons, episodeCountWarnings(p, match.SeasonEpisodeCounts)...) } reasons = append(reasons, consistencyWarnings(p, pre)...) if p.Confidence < threshold { reasons = append(reasons, fmt.Sprintf("уверенность %.2f ниже порога %.2f", p.Confidence, threshold)) } return Decision{Auto: len(reasons) == 0, Reasons: reasons} } // 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 { type key struct{ s, e int } seen := map[key]int{} bySeason := map[int][]int{} var w []string for _, f := range files { if f.Role != RoleEpisode || f.Episode == nil { continue } season := 0 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)) } bySeason[season] = append(bySeason[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])) } } } return w } // 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 }