Распознавание файлов и структуры с помощью LLM

This commit is contained in:
2026-06-14 12:48:08 +03:00
parent 2ec0cf9747
commit 91c501624a
9 changed files with 1097 additions and 4 deletions
+165
View File
@@ -0,0 +1,165 @@
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
}