223 lines
7.4 KiB
Go
223 lines
7.4 KiB
Go
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
|
||
}
|