Files
jellybit/internal/recognize/validate.go
T

166 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}