package naming import ( "context" "encoding/json" "fmt" "strings" "git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/logctx" ) // systemPrompt инструктирует модель вытащить из вольного контекста короткое // имя. Язык названия выбирается по происхождению контента. const systemPrompt = `Ты извлекаешь из текста о торрент-раздаче данные для короткого названия. Верни СТРОГО один JSON-объект, без markdown-ограждений и пояснений. Схема: { "type": "movie" | "series", "title": "название на нужном языке", "original_title": "оригинальное название или пустая строка", "year": число или 0, "director": "режиссёр или пустая строка", "season": число или null, "is_russian": true | false } Правила: - Определи, фильм это или сериал ("type"). - "is_russian" = true, если это российский/советский фильм или сериал. - "title": для российского контента — русское название; иначе — английское (оригинальное). Без года, страны и технических характеристик. - "year" — год выпуска (0, если не ясен). "director" — режиссёр (пустая строка, если не указан). "season" — номер сезона для сериала (null для фильма или если сезон не указан). - Если данных не хватает, заполняй что можешь; "title" должен быть непустым.` // extractViaLLM делает до n.attempts попыток получить валидную структуру. // Транспортные ошибки провайдера (сеть/429/5xx) гасятся внутри llm.Provider; // здесь повторяем только переразбор невалидного ответа. ok=false, если за // все попытки валидного результата не получено. func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (Fields, bool) { contextText = strings.TrimSpace(contextText) if contextText == "" { return Fields{}, false } user := "Контекст раздачи:\n" + contextText if hint = strings.TrimSpace(hint); hint != "" { user += "\n\nИмя из magnet (подсказка, может быть мусором): " + hint } temp := 0.0 msgs := []llm.Message{ {Role: llm.RoleSystem, Content: systemPrompt}, {Role: llm.RoleUser, Content: user}, } log := logctx.FromOr(ctx, n.log) for attempt := 1; attempt <= n.attempts; attempt++ { resp, err := n.provider.Complete(ctx, llm.Request{ Messages: msgs, JSONMode: true, Temperature: &temp, }) if err != nil { // Транспортная ошибка/таймаут залогирована клиентом LLM (ext.*); // здесь — доменное решение «уходим в фолбек, приём не валим». log.Debug("naming llm failed, using fallback") return Fields{}, false } ex, perr := parseExtracted(resp.Content) if perr == nil { return ex, true } log.Warn("naming llm response unparsed", "attempt", attempt, "error", perr) msgs = append(msgs, llm.Message{Role: llm.RoleAssistant, Content: resp.Content}, llm.Message{Role: llm.RoleUser, Content: "Ответ невалиден: " + perr.Error() + ". Верни строго один JSON-объект по схеме, без пояснений."}) } return Fields{}, false } // parseExtracted вытаскивает JSON-объект из ответа и валидирует минимум: // непустой title. Тип по умолчанию — movie. func parseExtracted(raw string) (Fields, error) { jsonStr, err := llm.ExtractJSONObject(raw) if err != nil { return Fields{}, err } var ex Fields if err := json.Unmarshal([]byte(jsonStr), &ex); err != nil { return Fields{}, fmt.Errorf("naming: unmarshal: %w", err) } if strings.TrimSpace(ex.Title) == "" { return Fields{}, fmt.Errorf("naming: empty title") } if ex.Type != typeMovie && ex.Type != typeSeries { ex.Type = typeMovie } return ex, nil }