106 lines
4.5 KiB
Go
106 lines
4.5 KiB
Go
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) (extracted, bool) {
|
|
contextText = strings.TrimSpace(contextText)
|
|
if contextText == "" {
|
|
return extracted{}, 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 extracted{}, 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 extracted{}, false
|
|
}
|
|
|
|
// parseExtracted вытаскивает JSON-объект из ответа и валидирует минимум:
|
|
// непустой title. Тип по умолчанию — movie.
|
|
func parseExtracted(raw string) (extracted, error) {
|
|
jsonStr, err := llm.ExtractJSONObject(raw)
|
|
if err != nil {
|
|
return extracted{}, err
|
|
}
|
|
var ex extracted
|
|
if err := json.Unmarshal([]byte(jsonStr), &ex); err != nil {
|
|
return extracted{}, fmt.Errorf("naming: unmarshal: %w", err)
|
|
}
|
|
if strings.TrimSpace(ex.Title) == "" {
|
|
return extracted{}, fmt.Errorf("naming: empty title")
|
|
}
|
|
if ex.Type != typeMovie && ex.Type != typeSeries {
|
|
ex.Type = typeMovie
|
|
}
|
|
return ex, nil
|
|
}
|