Files
jellybit/internal/recognize/validate.go
T

223 lines
7.4 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 считает решение модели уверенности (см. 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
}