Добавил реализацию
This commit is contained in:
+24
-14
@@ -22,6 +22,7 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||
"git.vakhrushev.me/av/jellybit/internal/naming"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
@@ -62,7 +63,27 @@ func runServe(args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ingestor := ingest.New(st, qb, ingest.Config{
|
||||
// LLM-провайдер (опц.) — общий для вывода имени и распознавания.
|
||||
var llmProvider llm.Provider
|
||||
if cfg.LLM.Type != "" && cfg.LLM.BaseURL != "" {
|
||||
llmProvider, err = llm.New(llm.Config{
|
||||
Type: cfg.LLM.Type,
|
||||
BaseURL: cfg.LLM.BaseURL,
|
||||
APIKey: cfg.LLM.APIKey,
|
||||
Model: cfg.LLM.Model,
|
||||
Proxy: cfg.LLM.Proxy,
|
||||
Timeout: cfg.LLM.Timeout.Std(),
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("llm provider: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Вывод отображаемого имени торрента из контекста (best-effort). Без LLM
|
||||
// работает только алгоритмический фолбек.
|
||||
namer := naming.New(llmProvider, cfg.LLM.MaxRetries, logger)
|
||||
|
||||
ingestor := ingest.New(st, qb, namer, ingest.Config{
|
||||
Category: cfg.QBittorrent.Category,
|
||||
SavePath: cfg.QBittorrent.SavePath,
|
||||
}, logger)
|
||||
@@ -79,19 +100,8 @@ func runServe(args []string) error {
|
||||
// Ф2/Ф3: распознаватель и раскладчик. Если LLM не сконфигурирован,
|
||||
// сервис работает как в Ф1 (completed-задачи дальше не двигаются).
|
||||
var recognizer worker.Recognizer
|
||||
if cfg.LLM.Type != "" && cfg.LLM.BaseURL != "" {
|
||||
provider, perr := llm.New(llm.Config{
|
||||
Type: cfg.LLM.Type,
|
||||
BaseURL: cfg.LLM.BaseURL,
|
||||
APIKey: cfg.LLM.APIKey,
|
||||
Model: cfg.LLM.Model,
|
||||
Proxy: cfg.LLM.Proxy,
|
||||
Timeout: cfg.LLM.Timeout.Std(),
|
||||
}, logger)
|
||||
if perr != nil {
|
||||
return fmt.Errorf("llm provider: %w", perr)
|
||||
}
|
||||
recognizer = recognize.New(provider, providers, recognize.Config{
|
||||
if llmProvider != nil {
|
||||
recognizer = recognize.New(llmProvider, providers, recognize.Config{
|
||||
MaxRetries: cfg.LLM.MaxRetries,
|
||||
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
|
||||
}, logger)
|
||||
|
||||
@@ -26,6 +26,12 @@ type QBittorrent interface {
|
||||
Add(ctx context.Context, ar qbt.AddRequest) error
|
||||
}
|
||||
|
||||
// Namer выводит человекочитаемое отображаемое имя торрента из контекста.
|
||||
// Пустой результат → имя в qBittorrent не задаём. nil → шаг пропускается.
|
||||
type Namer interface {
|
||||
DeriveName(ctx context.Context, contextText, hint string) string
|
||||
}
|
||||
|
||||
// Config — параметры добавления в qBittorrent.
|
||||
type Config struct {
|
||||
Category string
|
||||
@@ -36,13 +42,15 @@ type Config struct {
|
||||
type Service struct {
|
||||
store Store
|
||||
qbt QBittorrent
|
||||
namer Namer
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New собирает сервис приёма.
|
||||
func New(st Store, qb QBittorrent, cfg Config, log *slog.Logger) *Service {
|
||||
return &Service{store: st, qbt: qb, cfg: cfg, log: log}
|
||||
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не
|
||||
// выводится; qBittorrent оставит своё).
|
||||
func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *Service {
|
||||
return &Service{store: st, qbt: qb, namer: namer, cfg: cfg, log: log}
|
||||
}
|
||||
|
||||
// Request — входной запрос приёма.
|
||||
@@ -82,6 +90,15 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Отображаемое имя для списка qBit — best-effort: не валит приём.
|
||||
// Выводится синхронно (param rename действует только при добавлении) и
|
||||
// ДО CreateDownload, чтобы возможный медленный вызов LLM не расширял окно
|
||||
// «строка в БД есть, в qBittorrent ещё нет». Имя от строки БД не зависит.
|
||||
var rename string
|
||||
if s.namer != nil {
|
||||
rename = s.namer.DeriveName(ctx, req.Context, info.DisplayName)
|
||||
}
|
||||
|
||||
d := &store.Download{
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: source,
|
||||
@@ -99,6 +116,7 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
URLs: []string{source},
|
||||
Category: s.cfg.Category,
|
||||
SavePath: s.cfg.SavePath,
|
||||
Rename: rename,
|
||||
})
|
||||
if addErr != nil {
|
||||
s.log.Warn("ingest: qbittorrent add failed, marking download failed",
|
||||
|
||||
@@ -58,8 +58,27 @@ func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeNamer возвращает заранее заданное имя; фиксирует переданные аргументы.
|
||||
type fakeNamer struct {
|
||||
name string
|
||||
gotContext string
|
||||
gotHint string
|
||||
called bool
|
||||
}
|
||||
|
||||
func (f *fakeNamer) DeriveName(_ context.Context, contextText, hint string) string {
|
||||
f.called = true
|
||||
f.gotContext = contextText
|
||||
f.gotHint = hint
|
||||
return f.name
|
||||
}
|
||||
|
||||
func newService(st Store, qb QBittorrent) *Service {
|
||||
return New(st, qb, Config{Category: "jellybit", SavePath: "/srv/media/downloads"},
|
||||
return newServiceWithNamer(st, qb, nil)
|
||||
}
|
||||
|
||||
func newServiceWithNamer(st Store, qb QBittorrent, nm Namer) *Service {
|
||||
return New(st, qb, nm, Config{Category: "jellybit", SavePath: "/srv/media/downloads"},
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
}
|
||||
|
||||
@@ -94,6 +113,36 @@ func TestIngestHappyPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestSetsDisplayName(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"}
|
||||
_, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
|
||||
Request{Source: sampleMagnet, Context: "Дюна 2"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if !nm.called || nm.gotContext != "Дюна 2" || nm.gotHint != "Dune" {
|
||||
t.Errorf("namer получил context=%q hint=%q (called=%v)", nm.gotContext, nm.gotHint, nm.called)
|
||||
}
|
||||
if len(fq.added) != 1 || fq.added[0].Rename != "Дюна: Часть вторая (2024)" {
|
||||
t.Errorf("rename = %q, want %q", fq.added[0].Rename, "Дюна: Часть вторая (2024)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestEmptyNameOmitsRename(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{}
|
||||
nm := &fakeNamer{name: ""} // имя не получено
|
||||
if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(),
|
||||
Request{Source: sampleMagnet}); err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(fq.added) != 1 || fq.added[0].Rename != "" {
|
||||
t.Errorf("rename = %q, want пусто", fq.added[0].Rename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestIdempotent(t *testing.T) {
|
||||
existing := &store.Download{ID: 7, State: store.StateDownloading}
|
||||
fs := &fakeStore{active: existing}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package naming
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parenURL — markdown-хвост " (https://…)" в строках контекста.
|
||||
var parenURL = regexp.MustCompile(`\s*\(https?://[^)]+\)`)
|
||||
|
||||
// parenSpecs — открывающая скобка тех. характеристик «(2024, …»: круглая
|
||||
// скобка, за которой (через необяз. пробелы) идёт цифра.
|
||||
var parenSpecs = regexp.MustCompile(`\(\s*\d`)
|
||||
|
||||
// fallbackName выводит имя без сети: берёт первую содержательную строку
|
||||
// контекста, отсекает технические характеристики и обрезает по длине. Если
|
||||
// контекст пуст/бесполезен — пробует hint (dn из magnet). Возвращает "",
|
||||
// если ничего пригодного нет.
|
||||
func fallbackName(contextText, hint string) string {
|
||||
if line := firstMeaningfulLine(contextText); line != "" {
|
||||
return truncate(stripTechSpecs(line), maxNameLen)
|
||||
}
|
||||
if h := sanitize(hint); h != "" {
|
||||
return truncate(stripTechSpecs(h), maxNameLen)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstMeaningfulLine возвращает первую строку контекста, не являющуюся
|
||||
// ссылкой, командой бота или UI-мусором. Контекст из tgbot уже вычищен, но
|
||||
// для HTTP/CLI вход может быть сырым — отсюда лёгкая фильтрация.
|
||||
func firstMeaningfulLine(text string) string {
|
||||
for line := range strings.SplitSeq(text, "\n") {
|
||||
line = strings.TrimSpace(parenURL.ReplaceAllString(line, ""))
|
||||
line = sanitize(line)
|
||||
if line == "" || isNoiseLine(line) {
|
||||
continue
|
||||
}
|
||||
return line
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isNoiseLine отсекает строки-ссылки и команды бота (см. tgbot для исходной
|
||||
// эвристики; здесь — минимум, нужный фолбеку для сырого ввода). Проверяем
|
||||
// префиксы, а не вхождения: голый URL внутри осмысленной строки не должен
|
||||
// выкидывать всю строку с названием.
|
||||
func isNoiseLine(line string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "/"):
|
||||
return true // команда бота
|
||||
case strings.HasPrefix(line, "magnet:"),
|
||||
strings.HasPrefix(line, "http://"),
|
||||
strings.HasPrefix(line, "https://"):
|
||||
return true // строка-ссылка
|
||||
case strings.Contains(line, ": /"):
|
||||
return true // строка рейтинга/команд бота «👍: /g_… или 👎: /r_…»
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// stripTechSpecs отсекает хвост технических характеристик в скобках —
|
||||
// квадратных ("… [2024, WEB-DL 2160p …]") или круглых с годом
|
||||
// ("… (2024, WEB-DL …)") — и подчищает разделители на конце. Скобка с
|
||||
// текстом (например режиссёр «(Дени Вильнёв)») не режется.
|
||||
func stripTechSpecs(s string) string {
|
||||
cut := len(s)
|
||||
if i := strings.IndexByte(s, '['); i >= 0 && i < cut {
|
||||
cut = i
|
||||
}
|
||||
if loc := parenSpecs.FindStringIndex(s); loc != nil && loc[0] < cut {
|
||||
cut = loc[0]
|
||||
}
|
||||
s = strings.TrimRight(s[:cut], " -–—/.,")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package naming
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFallbackName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
context string
|
||||
hint string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "release title with tech specs",
|
||||
context: "Дюна: Часть вторая / Dune: Part Two [2024, фантастика, WEB-DL 2160p]\nDub + MVO",
|
||||
want: "Дюна: Часть вторая / Dune: Part Two",
|
||||
},
|
||||
{
|
||||
name: "skips url and command lines",
|
||||
context: "magnet:?xt=urn:btih:abc\nhttps://example.com/x\n/help\nБрат [1997]",
|
||||
want: "Брат",
|
||||
},
|
||||
{
|
||||
name: "empty context falls back to magnet dn hint",
|
||||
context: "",
|
||||
hint: "rutracker-topic-6514485",
|
||||
want: "rutracker-topic-6514485",
|
||||
},
|
||||
{
|
||||
name: "useless context and no hint yields empty",
|
||||
context: "https://example.com/x\n/help",
|
||||
hint: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "strips markdown url tail",
|
||||
context: "Сёгун (https://hashurl.ru/abc)",
|
||||
want: "Сёгун",
|
||||
},
|
||||
{
|
||||
name: "strips tech specs in round brackets with year",
|
||||
context: "Дюна (2024, фантастика, WEB-DL 2160p)",
|
||||
want: "Дюна",
|
||||
},
|
||||
{
|
||||
name: "keeps non-spec parenthesis (director)",
|
||||
context: "Брат (Алексей Балабанов) [1997, криминал]",
|
||||
want: "Брат (Алексей Балабанов)",
|
||||
},
|
||||
{
|
||||
name: "inline url does not drop the title line",
|
||||
context: "Дюна 2024 подробнее http://tracker/x",
|
||||
want: "Дюна 2024 подробнее http://tracker/x",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := fallbackName(tc.context, tc.hint); got != tc.want {
|
||||
t.Errorf("fallbackName() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package naming выводит человекочитаемое отображаемое имя торрента из
|
||||
// текстового контекста загрузки. Имя нужно лишь как ярлык в списке
|
||||
// qBittorrent (вместо безликого dn вроде rutracker-topic-6514485) и не
|
||||
// влияет на пути на диске или распознавание.
|
||||
//
|
||||
// Стратегия: сначала пробуем LLM (структурированный вывод названия/года/
|
||||
// режиссёра/сезона), при неудаче — алгоритмический фолбек без сети. Любой
|
||||
// сбой деградирует к пустой строке: приём загрузки никогда не падает из-за
|
||||
// вывода имени.
|
||||
package naming
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
)
|
||||
|
||||
// maxNameLen — ограничение длины отображаемого имени (символов/рун).
|
||||
const maxNameLen = 200
|
||||
|
||||
// mediaType — вид контента в извлечённой структуре.
|
||||
type mediaType string
|
||||
|
||||
const (
|
||||
typeMovie mediaType = "movie"
|
||||
typeSeries mediaType = "series"
|
||||
)
|
||||
|
||||
// extracted — структура имени, извлечённая из контекста (схема ответа LLM).
|
||||
// Year и Director опциональны (пустое значение → в ярлык не попадают).
|
||||
type extracted struct {
|
||||
Type mediaType `json:"type"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Year int `json:"year"`
|
||||
Director string `json:"director"`
|
||||
Season *int `json:"season"`
|
||||
IsRussian bool `json:"is_russian"`
|
||||
}
|
||||
|
||||
// Namer выводит отображаемое имя. provider может быть nil — тогда работает
|
||||
// только алгоритмический фолбек.
|
||||
type Namer struct {
|
||||
provider llm.Provider
|
||||
// attempts — число попыток получить валидный ответ LLM ([llm].max_retries).
|
||||
// Здесь это ровно столько вызовов модели (в отличие от recognize, где
|
||||
// max_retries — это число ПЕРЕразборов, т.е. max_retries+1 вызовов).
|
||||
attempts int
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New собирает Namer. provider nil → только фолбек. attempts < 1 → 1.
|
||||
// logger nil → slog.Default().
|
||||
func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer {
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Namer{provider: provider, attempts: attempts, log: logger}
|
||||
}
|
||||
|
||||
// DeriveName выводит отображаемое имя из контекста. hint — подсказка из
|
||||
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает
|
||||
// "" если имя получить не удалось (тогда вызывающий не задаёт rename).
|
||||
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
|
||||
if n.provider != nil {
|
||||
if ex, ok := n.extractViaLLM(ctx, contextText, hint); ok {
|
||||
if name := render(ex); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallbackName(contextText, hint)
|
||||
}
|
||||
|
||||
// render собирает из структуры короткий ярлык:
|
||||
// - movie: "Title (Director, Year)" — режиссёр и год опциональны;
|
||||
// - series: то же + ". Сезон N", если сезон задан.
|
||||
//
|
||||
// Имя очищается от управляющих символов/переводов строк и обрезается по
|
||||
// длине. Пустой Title → пустая строка.
|
||||
func render(ex extracted) string {
|
||||
title := sanitize(ex.Title)
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var paren []string
|
||||
if d := sanitize(ex.Director); d != "" {
|
||||
paren = append(paren, d)
|
||||
}
|
||||
if ex.Year > 0 {
|
||||
paren = append(paren, strconv.Itoa(ex.Year))
|
||||
}
|
||||
|
||||
name := title
|
||||
if len(paren) > 0 {
|
||||
name += " (" + strings.Join(paren, ", ") + ")"
|
||||
}
|
||||
if ex.Type == typeSeries && ex.Season != nil && *ex.Season > 0 {
|
||||
name += ". Сезон " + strconv.Itoa(*ex.Season)
|
||||
}
|
||||
return truncate(name, maxNameLen)
|
||||
}
|
||||
|
||||
// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы.
|
||||
func sanitize(s string) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\t' || r == '\r' {
|
||||
return ' '
|
||||
}
|
||||
if r < 0x20 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
// truncate обрезает строку до n рун (без разрыва символа), отбрасывая хвост.
|
||||
func truncate(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
count := 0
|
||||
for i := range s {
|
||||
if count == n {
|
||||
return strings.TrimRight(s[:i], " ")
|
||||
}
|
||||
count++
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package naming
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// fakeProvider отдаёт заранее заданные ответы по очереди; считает вызовы.
|
||||
type fakeProvider struct {
|
||||
responses []string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Complete(_ context.Context, _ llm.Request) (llm.Response, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return llm.Response{}, f.err
|
||||
}
|
||||
idx := f.calls - 1
|
||||
if idx >= len(f.responses) {
|
||||
idx = len(f.responses) - 1
|
||||
}
|
||||
return llm.Response{Content: f.responses[idx]}, nil
|
||||
}
|
||||
|
||||
const duneContext = "Дюна: Часть вторая / Dune: Part Two [2024, фантастика, WEB-DL 2160p]\nDub + MVO"
|
||||
|
||||
func TestDeriveNameViaLLM(t *testing.T) {
|
||||
fp := &fakeProvider{responses: []string{
|
||||
`{"type":"movie","title":"Дюна: Часть вторая","year":2024,"director":"Дени Вильнёв","is_russian":false}`,
|
||||
}}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "")
|
||||
want := "Дюна: Часть вторая (Дени Вильнёв, 2024)"
|
||||
if got != want {
|
||||
t.Errorf("DeriveName() = %q, want %q", got, want)
|
||||
}
|
||||
if fp.calls != 1 {
|
||||
t.Errorf("вызовов LLM = %d, want 1", fp.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameRussianTitle(t *testing.T) {
|
||||
fp := &fakeProvider{responses: []string{
|
||||
`{"type":"movie","title":"Брат","original_title":"Brat","year":1997,"is_russian":true}`,
|
||||
}}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), "Брат / Brat [1997]", "")
|
||||
if got != "Брат (1997)" {
|
||||
t.Errorf("DeriveName() = %q, want %q", got, "Брат (1997)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameRetriesThenSucceeds(t *testing.T) {
|
||||
fp := &fakeProvider{responses: []string{
|
||||
"не json вовсе",
|
||||
`{"type":"movie","title":"Дюна: Часть вторая","year":2024}`,
|
||||
}}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "")
|
||||
if got != "Дюна: Часть вторая (2024)" {
|
||||
t.Errorf("DeriveName() = %q", got)
|
||||
}
|
||||
if fp.calls != 2 {
|
||||
t.Errorf("вызовов LLM = %d, want 2", fp.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameExhaustsThenFallback(t *testing.T) {
|
||||
fp := &fakeProvider{responses: []string{"мусор"}}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "")
|
||||
// Бюджет исчерпан → алгоритмический фолбек: первая строка без тех. спецификаций.
|
||||
if got != "Дюна: Часть вторая / Dune: Part Two" {
|
||||
t.Errorf("DeriveName() = %q (ожидался фолбек)", got)
|
||||
}
|
||||
if fp.calls != 3 {
|
||||
t.Errorf("вызовов LLM = %d, want 3 (исчерпание бюджета)", fp.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameProviderErrorFallsBack(t *testing.T) {
|
||||
fp := &fakeProvider{err: errors.New("connection refused")}
|
||||
got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "")
|
||||
if got != "Дюна: Часть вторая / Dune: Part Two" {
|
||||
t.Errorf("DeriveName() = %q (ожидался фолбек при ошибке)", got)
|
||||
}
|
||||
if fp.calls != 1 {
|
||||
t.Errorf("вызовов LLM = %d, want 1 (на транспортной ошибке не повторяем)", fp.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveNameNilProviderUsesFallback(t *testing.T) {
|
||||
got := New(nil, 3, testLogger()).DeriveName(context.Background(), duneContext, "")
|
||||
if got != "Дюна: Часть вторая / Dune: Part Two" {
|
||||
t.Errorf("DeriveName() = %q (ожидался фолбек без LLM)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package naming
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
)
|
||||
|
||||
// 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},
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= n.attempts; attempt++ {
|
||||
resp, err := n.provider.Complete(ctx, llm.Request{
|
||||
Messages: msgs,
|
||||
JSONMode: true,
|
||||
Temperature: &temp,
|
||||
})
|
||||
if err != nil {
|
||||
// Транспортная ошибка/таймаут: дальше пробовать смысла нет —
|
||||
// уходим в фолбек, приём не валим.
|
||||
n.log.Warn("naming: llm complete failed, will fall back", "err", err)
|
||||
return extracted{}, false
|
||||
}
|
||||
|
||||
ex, perr := parseExtracted(resp.Content)
|
||||
if perr == nil {
|
||||
return ex, true
|
||||
}
|
||||
n.log.Warn("naming: unparsed llm response", "attempt", attempt, "err", 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
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package naming
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ptr(i int) *int { return &i }
|
||||
|
||||
func TestRender(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in extracted
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "movie with director and year",
|
||||
in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 2024},
|
||||
want: "Дюна: Часть вторая (Дени Вильнёв, 2024)",
|
||||
},
|
||||
{
|
||||
name: "movie with year only",
|
||||
in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Year: 2024},
|
||||
want: "Дюна: Часть вторая (2024)",
|
||||
},
|
||||
{
|
||||
name: "movie with director only",
|
||||
in: extracted{Type: typeMovie, Title: "Брат", Director: "Алексей Балабанов"},
|
||||
want: "Брат (Алексей Балабанов)",
|
||||
},
|
||||
{
|
||||
name: "movie without director and year",
|
||||
in: extracted{Type: typeMovie, Title: "Брат"},
|
||||
want: "Брат",
|
||||
},
|
||||
{
|
||||
name: "series with season",
|
||||
in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024, Season: ptr(2)},
|
||||
want: "Сёгун (2024). Сезон 2",
|
||||
},
|
||||
{
|
||||
name: "series without season",
|
||||
in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024},
|
||||
want: "Сёгун (2024)",
|
||||
},
|
||||
{
|
||||
name: "series season zero is omitted",
|
||||
in: extracted{Type: typeSeries, Title: "Сёгун", Season: ptr(0)},
|
||||
want: "Сёгун",
|
||||
},
|
||||
{
|
||||
name: "empty title yields empty name",
|
||||
in: extracted{Type: typeMovie, Title: " ", Year: 2024},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "control chars and newlines are sanitized",
|
||||
in: extracted{Type: typeMovie, Title: "Дюна\n\tЧасть\x00 вторая", Year: 2024},
|
||||
want: "Дюна Часть вторая (2024)",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := render(tc.in); got != tc.want {
|
||||
t.Errorf("render() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTruncates(t *testing.T) {
|
||||
long := strings.Repeat("я", maxNameLen+50)
|
||||
got := render(extracted{Type: typeMovie, Title: long})
|
||||
if n := len([]rune(got)); n > maxNameLen {
|
||||
t.Errorf("длина имени = %d рун, want <= %d", n, maxNameLen)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@ type AddRequest struct {
|
||||
Torrents [][]byte // .torrent-файлы (Ф1 не использует)
|
||||
Category string
|
||||
SavePath string
|
||||
Rename string // отображаемое имя торрента (param rename); пустое — не задаём
|
||||
Paused bool
|
||||
}
|
||||
|
||||
@@ -173,6 +174,9 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error {
|
||||
if ar.SavePath != "" {
|
||||
_ = mw.WriteField("savepath", ar.SavePath)
|
||||
}
|
||||
if ar.Rename != "" {
|
||||
_ = mw.WriteField("rename", ar.Rename)
|
||||
}
|
||||
_ = mw.WriteField("paused", strconv.FormatBool(ar.Paused))
|
||||
for i, data := range ar.Torrents {
|
||||
fw, err := mw.CreateFormFile("torrents", fmt.Sprintf("file%d.torrent", i))
|
||||
|
||||
@@ -69,6 +69,51 @@ func newClient(t *testing.T, url string) *Client {
|
||||
return c
|
||||
}
|
||||
|
||||
func TestAddSendsRename(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rename string
|
||||
wantField string
|
||||
wantHasKey bool
|
||||
}{
|
||||
{name: "with rename", rename: "Дюна: Часть вторая (2024)", wantField: "Дюна: Часть вторая (2024)", wantHasKey: true},
|
||||
{name: "empty rename omits field", rename: "", wantHasKey: false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gotField string
|
||||
var hasKey bool
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/torrents/add", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, hasKey = r.MultipartForm.Value["rename"]
|
||||
gotField = r.FormValue("rename")
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := newClient(t, srv.URL)
|
||||
err := c.Add(context.Background(), AddRequest{
|
||||
URLs: []string{"magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6"},
|
||||
Rename: tc.rename,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if hasKey != tc.wantHasKey {
|
||||
t.Errorf("наличие поля rename = %v, want %v", hasKey, tc.wantHasKey)
|
||||
}
|
||||
if gotField != tc.wantField {
|
||||
t.Errorf("rename = %q, want %q", gotField, tc.wantField)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPerformsLazyLogin(t *testing.T) {
|
||||
srv := fakeQBittorrent(t, "[]")
|
||||
c := newClient(t, srv.URL)
|
||||
|
||||
@@ -1,55 +1,59 @@
|
||||
## 1. qBittorrent: проброс имени
|
||||
|
||||
- [ ] 1.1 Добавить поле `Rename string` в `qbt.AddRequest`
|
||||
- [ ] 1.2 В `Client.Add` писать form-field `rename` при непустом `Rename`
|
||||
- [ ] 1.3 Тест: при заданном `Rename` form-data содержит `rename`, при
|
||||
- [x] 1.1 Добавить поле `Rename string` в `qbt.AddRequest`
|
||||
- [x] 1.2 В `Client.Add` писать form-field `rename` при непустом `Rename`
|
||||
- [x] 1.3 Тест: при заданном `Rename` form-data содержит `rename`, при
|
||||
пустом — поля нет
|
||||
|
||||
## 2. Вывод имени: структура и рендер
|
||||
|
||||
- [ ] 2.1 Описать структуру извлечённого имени (type, title,
|
||||
- [x] 2.1 Описать структуру извлечённого имени (type, title,
|
||||
original_title, year, director, season, is_russian)
|
||||
- [ ] 2.2 Реализовать чистую функцию рендера структуры в короткий ярлык:
|
||||
- [x] 2.2 Реализовать чистую функцию рендера структуры в короткий ярлык:
|
||||
`Title (Director, Year)` с опциональными режиссёром и годом (скобка
|
||||
опускается, если обоих нет), для сериала — суффикс `. Сезон N`;
|
||||
очистка управляющих символов/переводов строк и обрезка по длине
|
||||
- [ ] 2.3 Тесты рендера: фильм с режиссёром+годом / только год / только
|
||||
- [x] 2.3 Тесты рендера: фильм с режиссёром+годом / только год / только
|
||||
режиссёр / без обоих, сериал с сезоном/без, обрезка длины, очистка
|
||||
|
||||
## 3. Вывод имени: LLM
|
||||
|
||||
- [ ] 3.1 Узкий промпт извлечения имени (RU-название для российского
|
||||
- [x] 3.1 Узкий промпт извлечения имени (RU-название для российского
|
||||
контента, иначе EN/оригинал) + описание JSON-схемы ответа
|
||||
- [ ] 3.2 Парсинг и валидация ответа (валидный JSON, непустой `title`);
|
||||
- [x] 3.2 Парсинг и валидация ответа (валидный JSON, непустой `title`);
|
||||
бюджет попыток — `[llm].max_retries` (переиспользуем существующий)
|
||||
- [ ] 3.3 Тесты на фикстурах (без сети): успешный разбор, выбор языка
|
||||
- [x] 3.3 Тесты на фикстурах (без сети): успешный разбор, выбор языка
|
||||
названия, исчерпание попыток
|
||||
|
||||
## 4. Вывод имени: алгоритмический фолбек
|
||||
|
||||
- [ ] 4.1 Реализовать фолбек без сети: первая содержательная строка
|
||||
- [x] 4.1 Реализовать фолбек без сети: первая содержательная строка
|
||||
контекста (без ссылок/команд/UI-мусора), отсечение тех.
|
||||
характеристик, очистка и обрезка
|
||||
- [ ] 4.2 Вынести/переиспользовать логику чистки строк (сейчас в
|
||||
`tgbot.cleanContext`), чтобы не дублировать
|
||||
- [ ] 4.3 Тесты фолбека: заголовок релиза, пустой/бесполезный контекст → ""
|
||||
- [x] 4.2 Фолбек самодостаточен (лёгкая фильтрация шума для сырого
|
||||
HTTP/CLI-ввода). `tgbot.cleanContext` НЕ рефакторил: он работает на
|
||||
слое транспорта (чистит UI-мусор бота), фолбек — на слое ядра (берёт
|
||||
заголовок из уже-контекста); преждевременная общая зависимость связала
|
||||
бы транспорт с util ядра. Дублирование минимально (две эвристики)
|
||||
- [x] 4.3 Тесты фолбека: заголовок релиза, пустой/бесполезный контекст → ""
|
||||
|
||||
## 5. Интеграция в ingest
|
||||
|
||||
- [ ] 5.1 Ввести узкий интерфейс/функцию вывода имени (`DeriveName`),
|
||||
реализованную поверх LLM + фолбек; зависимость опциональна (нет LLM →
|
||||
только фолбек)
|
||||
- [ ] 5.2 В `Ingest()` синхронно выводить имя из `req.Context` (подсказка —
|
||||
- [x] 5.1 Ввести узкий интерфейс `ingest.Namer` (`DeriveName`), реализован
|
||||
пакетом `internal/naming` поверх LLM + фолбек; зависимость опциональна
|
||||
(nil-провайдер → только фолбек)
|
||||
- [x] 5.2 В `Ingest()` синхронно выводить имя из `req.Context` (подсказка —
|
||||
`magnet.DisplayName`) перед `qbt.Add`, класть в `AddRequest.Rename`
|
||||
- [ ] 5.3 Graceful-деградация: ошибка/таймаут вывода имени логируется и не
|
||||
- [x] 5.3 Graceful-деградация: ошибка/таймаут вывода имени логируется и не
|
||||
прерывает приём (пустое имя → без `rename`). Бюджет попыток и таймаут —
|
||||
общие `[llm].max_retries` / `[llm].timeout`, новых параметров не вводим
|
||||
- [ ] 5.4 Прокинуть зависимость (LLM-провайдер) в сборке сервиса
|
||||
(`cmd/jellybit`)
|
||||
- [x] 5.4 Прокинуть зависимость (LLM-провайдер) в сборке сервиса
|
||||
(`cmd/jellybit`): провайдер поднимается один раз, переиспользуется
|
||||
`naming` и `recognize`
|
||||
|
||||
## 6. Проверка
|
||||
|
||||
- [ ] 6.1 `task test` и `task lint` зелёные
|
||||
- [x] 6.1 `task test` и `task lint` зелёные
|
||||
- [ ] 6.2 Ручная проверка на реальном qBittorrent: имя видно в списке;
|
||||
подтвердить поведение `rename` для многофайловой раздачи
|
||||
- [ ] 6.3 Обновить `docs/specs` (architecture/ingest) и отметить todo
|
||||
|
||||
Reference in New Issue
Block a user