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 считает решение модели уверенности. В Ф2 метабазы выключены, а без // подтверждённого матча в базе авто-раскладка не делается (recognition.md), // поэтому Auto всегда false; здесь же копим структурные предупреждения и // расхождения с пред-парсом — они объясняют ревью человеку. func decide(p Plan, pre PreParse) Decision { reasons := []string{"матч в базе не подтверждён (метабазы отключены в Ф2) → review"} reasons = append(reasons, structuralWarnings(p)...) reasons = append(reasons, consistencyWarnings(p, pre)...) return Decision{Auto: false, Reasons: reasons} } // 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 }