diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index 8bc73b3..8ffb8be 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -122,16 +122,21 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { // Выводится синхронно (param rename действует только при добавлении) и // ДО CreateDownload, чтобы возможный медленный вызов LLM не расширял окно // «строка в БД есть, в qBittorrent ещё нет». Имя от строки БД не зависит. + // Namer получает СЫРОЙ req.Context (+ dn-hint), не обогащённый: строки-факты + // синтеза (Размер:/Трекер:) не должны становиться отображаемым именем. var rename string if s.namer != nil { rename = s.namer.DeriveName(ctx, req.Context, info.DisplayName) } + // Контекст распознавания дополняем фактами из полей самой magnet-ссылки + // (dn/xl/tr/xs/kt) — без сети. Пользовательский текст идёт первым. Результат + // уходит только в download.Context (его читают recognition и веб-UI). d := &store.Download{ SourceType: store.SourceMagnet, SourceRef: source, DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI - Context: req.Context, + Context: mergeContext(req.Context, info.Context()), State: store.StateDownloading, } // Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине. @@ -186,6 +191,20 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { }, nil } +// mergeContext склеивает контекст от транспорта с синтезом из полей magnet: +// пользовательский текст идёт первым, затем факты из ссылки. Пустые части +// опускаются; при пустых обеих — пустая строка (пустой контекст допустим). +func mergeContext(userText, synth string) string { + parts := make([]string, 0, 2) + if s := strings.TrimSpace(userText); s != "" { + parts = append(parts, s) + } + if s := strings.TrimSpace(synth); s != "" { + parts = append(parts, s) + } + return strings.Join(parts, "\n") +} + // attached — итог дедупа на быстром чеке: присоединились к уже активной // задаче и доносим ей недостающие хеши источника (гибридный magnet мог // принести хеш, которого задача ещё не знает; guarded-путь через diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index 030d9c2..9bc8b77 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "log/slog" + "strings" "testing" "time" @@ -110,8 +111,10 @@ func TestIngestHappyPath(t *testing.T) { if len(fs.created) != 1 { t.Fatalf("создано задач: %d, want 1", len(fs.created)) } - if got := fs.created[0]; got.Context != "Дюна 2" { - t.Errorf("сохранённая задача: %+v", got) + // download.Context = пользовательский текст + синтез из полей magnet + // (dn=Dune). Текст пользователя идёт первым. + if got := fs.created[0].Context; !strings.HasPrefix(got, "Дюна 2") || !strings.Contains(got, "Dune") { + t.Errorf("сохранённый контекст = %q", got) } if len(fs.hashes) != 1 || len(fs.hashes[0]) != 1 || fs.hashes[0][0] != sampleInfohash { t.Errorf("хеши задачи: %v", fs.hashes) @@ -204,6 +207,75 @@ func TestIngestDedupTopsUpHashes(t *testing.T) { } } +// Голый magnet без текста: download.Context синтезируется из полей ссылки +// (dn-имя + размер), приём проходит штатно. +func TestIngestMagnetOnlySynthesizesContext(t *testing.T) { + const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" + + "&dn=Dune.Part.Two.2024.2160p&xl=2200000000" + fs := &fakeStore{} + if _, err := newService(fs, &fakeQbt{}).Ingest(context.Background(), + Request{Source: raw}); err != nil { + t.Fatalf("Ingest: %v", err) + } + if len(fs.created) != 1 { + t.Fatalf("создано задач: %d, want 1", len(fs.created)) + } + ctx := fs.created[0].Context + if !strings.Contains(ctx, "Dune.Part.Two.2024.2160p") || !strings.Contains(ctx, "Размер:") { + t.Errorf("контекст не синтезирован из magnet: %q", ctx) + } +} + +// Регресс B1: строки-факты синтеза (Трекер:/Размер:) не должны становиться +// отображаемым именем. Namer получает СЫРОЙ контекст (+ dn-hint), а domain +// уходит только в download.Context для recognition. Заглушка-dn как строка- +// название в контекст не попадает. +func TestIngestSynthFactsNeverBecomeName(t *testing.T) { + const raw = "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" + + "&dn=rutracker-topic-6514485&tr=http%3A%2F%2Fbt.t-ru.org%2Fann%3Fmagnet" + fs := &fakeStore{} + fq := &fakeQbt{} + nm := &fakeNamer{name: "rutracker-topic-6514485"} // как вывел бы фолбек из dn-hint + if _, err := newServiceWithNamer(fs, fq, nm).Ingest(context.Background(), + Request{Source: raw}); err != nil { + t.Fatalf("Ingest: %v", err) + } + if nm.gotContext != "" { + t.Errorf("namer получил не сырой контекст: %q", nm.gotContext) + } + if strings.Contains(fq.added[0].Rename, "Трекер") { + t.Errorf("строка-факт просочилась в rename: %q", fq.added[0].Rename) + } + got := fs.created[0].Context + if !strings.Contains(got, "t-ru.org") { + t.Errorf("download.Context не обогащён доменом трекера: %q", got) + } + if strings.Contains(got, "rutracker-topic") { + t.Errorf("заглушка-dn просочилась в контекст как имя: %q", got) + } +} + +// Реальная рутрекер-ссылка без текста: download.Context = релиз-заголовок из +// dn (раскодирован) + домен трекера; namer получает пустой контекст и dn-hint. +func TestIngestRealRutrackerMagnetOnly(t *testing.T) { + const raw = "magnet:?xt=urn:btih:BACA24E18C7382A9E9A44132C8D7DB86C4D319C2" + + "&tr=http%3A%2F%2Fbt4.t-ru.org%2Fann%3Fmagnet" + + "&dn=%D0%91%D1%83%D1%85%D1%82%D0%B0%20%D0%B2%D0%B4%D0%BE%D0%B2%20%2F%20Widow's%20Bay%20%2F%20%D0%A1%D0%B5%D0%B7%D0%BE%D0%BD%3A%201%20%5B2026%2C%20%D0%A1%D0%A8%D0%90%2C%20WEB-DL%201080p%5D" + fs := &fakeStore{} + nm := &fakeNamer{name: "Бухта вдов (2026)"} + if _, err := newServiceWithNamer(fs, &fakeQbt{}, nm).Ingest(context.Background(), + Request{Source: raw}); err != nil { + t.Fatalf("Ingest: %v", err) + } + if nm.gotContext != "" { + t.Errorf("namer получил не сырой контекст: %q", nm.gotContext) + } + ctx := fs.created[0].Context + if !strings.Contains(ctx, "Widow's Bay") || !strings.Contains(ctx, "Трекер: t-ru.org") { + t.Errorf("download.Context не обогащён: %q", ctx) + } +} + func TestIngestQbitErrorMarksFailed(t *testing.T) { fs := &fakeStore{} fq := &fakeQbt{err: errors.New("connection refused")} diff --git a/internal/magnet/magnet.go b/internal/magnet/magnet.go index 5139bba..46e920b 100644 --- a/internal/magnet/magnet.go +++ b/internal/magnet/magnet.go @@ -11,6 +11,8 @@ import ( "errors" "fmt" "net/url" + "regexp" + "strconv" "strings" ) @@ -20,6 +22,9 @@ type Info struct { Infohashes []string // все хеши ссылки (гибридный magnet несёт btih и btmh); v1 раньше v2 DisplayName string // dn — человекочитаемое имя, если задано Trackers []string // tr — трекеры + Sources []string // xs — exact source (ссылки/источники) + Keywords []string // kt — ключевые слова + ExactLength int64 // xl — размер в байтах; 0, если не задан/некорректен } // ErrNotMagnet возвращается, если строка не является magnet-ссылкой. @@ -29,9 +34,8 @@ var ErrNotMagnet = errors.New("not a magnet link") // 32-символьный base32) и btmh (v2: sha256-multihash). При нескольких xt // предпочитается v1. func Parse(raw string) (Info, error) { - raw = strings.TrimSpace(raw) - u, err := url.Parse(raw) - if err != nil || !strings.EqualFold(u.Scheme, "magnet") { + u, ok := magnetURL(strings.TrimSpace(raw)) + if !ok { return Info{}, ErrNotMagnet } vals := u.Query() @@ -60,14 +64,41 @@ func Parse(raw string) (Info, error) { return Info{}, fmt.Errorf("magnet without a usable infohash (xt)") } + var xl int64 + if s := strings.TrimSpace(vals.Get("xl")); s != "" { + if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 { + xl = n + } + } + return Info{ Infohash: hashes[0], Infohashes: hashes, DisplayName: vals.Get("dn"), Trackers: vals["tr"], + Sources: vals["xs"], + Keywords: vals["kt"], + ExactLength: xl, }, nil } +// magnetURL разбирает строку как magnet-ссылку. Если ссылку скопировали +// целиком в процент-кодировке (magnet%3A%3Fxt%3D…, например вытащили из +// другого URL или из HTML-атрибута), пробует раскодировать её один раз и +// разобрать снова. Разовый unescape применяется ТОЛЬКО когда прямой разбор не +// дал magnet — обычную ссылку (где «+» в query значим) он не затрагивает. +func magnetURL(raw string) (*url.URL, bool) { + if u, err := url.Parse(raw); err == nil && strings.EqualFold(u.Scheme, "magnet") { + return u, true + } + if dec, err := url.QueryUnescape(raw); err == nil && dec != raw { + if u, err := url.Parse(strings.TrimSpace(dec)); err == nil && strings.EqualFold(u.Scheme, "magnet") { + return u, true + } + } + return nil, false +} + // normalizeBTIH нормализует v1-infohash (SHA-1, 20 байт) к нижнему hex. func normalizeBTIH(h string) (string, error) { switch len(h) { @@ -90,6 +121,110 @@ func normalizeBTIH(h string) (string, error) { } } +// topicStubRe распознаёт dn-заглушку вида «rutracker-topic-6514485» — +// идентификатор темы трекера, а не название релиза (частый случай для голых +// magnet). Такое имя как строку-название в контекст не берём. +var topicStubRe = regexp.MustCompile(`(?i)^[\w.-]+-topic-\w+$`) + +// announceLabelRe — служебные поддомены анонса трекера, которые убираем из +// домена происхождения ради чистого сигнала (bt.t-ru.org → t-ru.org). +var announceLabelRe = regexp.MustCompile(`^(?:bt\d*|www|announce|tracker|open)\.`) + +// Context синтезирует человекочитаемый контекст распознавания из полей самой +// ссылки (без сети): название релиза (dn, если это не заглушка-идентификатор), +// размер (xl), происхождение по домену трекера/источника (tr/xs) и ключевые +// слова (kt). Возвращает строки-факты, склеенные через "\n"; пустую строку — +// если пригодных полей нет. Результат предназначен для download.Context +// (recognition, веб-UI), но НЕ для вывода отображаемого имени. +func (i Info) Context() string { + var lines []string + + if name := strings.TrimSpace(i.DisplayName); name != "" && !topicStubRe.MatchString(name) { + lines = append(lines, name) + } + if i.ExactLength > 0 { + lines = append(lines, "Размер: "+humanSize(i.ExactLength)) + } + if d := originDomains(append(append([]string{}, i.Trackers...), i.Sources...)); d != "" { + lines = append(lines, "Трекер: "+d) + } + if kw := joinKeywords(i.Keywords); kw != "" { + lines = append(lines, "Ключевые слова: "+kw) + } + + return strings.Join(lines, "\n") +} + +// humanSize форматирует размер в байтах человекочитаемо (≈ 2.1 GiB). +func humanSize(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + f := float64(n) + i := -1 + for _, u := range []string{"KiB", "MiB", "GiB", "TiB", "PiB"} { + f /= unit + i++ + if f < unit { + return fmt.Sprintf("≈ %.1f %s", f, u) + } + } + return fmt.Sprintf("≈ %.1f PiB", f) +} + +// originDomains извлекает уникальные домены происхождения из tr/xs (только +// http(s)/udp-ссылки с хостом), убирая служебные поддомены анонса. Порядок +// сохраняется; результат — через ", ". +func originDomains(refs []string) string { + var out []string + seen := map[string]bool{} + for _, ref := range refs { + u, err := url.Parse(strings.TrimSpace(ref)) + if err != nil { + continue + } + switch strings.ToLower(u.Scheme) { + case "http", "https", "udp": + default: + continue + } + host := strings.ToLower(u.Hostname()) + if host == "" { + continue + } + // Срезаем служебный поддомен анонса, но лишь пока остаётся хотя бы две + // метки — иначе для хоста вида «tracker.org» получили бы голый TLD. + if s := announceLabelRe.ReplaceAllString(host, ""); strings.Contains(s, ".") { + host = s + } + if seen[host] { + continue + } + seen[host] = true + out = append(out, host) + } + return strings.Join(out, ", ") +} + +// joinKeywords нормализует kt: отдельные ключевые слова без пустых и дублей. +// В magnet kt разделяются «+», но url.Query() декодирует «+» в пробел ещё при +// разборе, поэтому режем по пробельным символам (strings.Fields). +func joinKeywords(kt []string) string { + var out []string + seen := map[string]bool{} + for _, v := range kt { + for kw := range strings.FieldsSeq(v) { + if seen[kw] { + continue + } + seen[kw] = true + out = append(out, kw) + } + } + return strings.Join(out, ", ") +} + // normalizeBTMH нормализует v2-infohash. Multihash sha256 имеет вид // 1220<64-hex>; возвращаем сами 64-hex (так его отдаёт qBittorrent в // infohash_v2). diff --git a/internal/magnet/magnet_test.go b/internal/magnet/magnet_test.go index 5e10e88..12d6587 100644 --- a/internal/magnet/magnet_test.go +++ b/internal/magnet/magnet_test.go @@ -1,6 +1,10 @@ package magnet -import "testing" +import ( + "net/url" + "strings" + "testing" +) func TestParse(t *testing.T) { tests := []struct { @@ -79,6 +83,154 @@ func TestParseErrors(t *testing.T) { } } +func TestParseExtraFields(t *testing.T) { + raw := "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6" + + "&xl=2200000000&xs=http%3A%2F%2Fmirror.example%2Ft.torrent&kt=dune+scifi" + got, err := Parse(raw) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got.ExactLength != 2200000000 { + t.Errorf("xl = %d, want 2200000000", got.ExactLength) + } + if len(got.Sources) != 1 || got.Sources[0] != "http://mirror.example/t.torrent" { + t.Errorf("xs = %v", got.Sources) + } + if len(got.Keywords) != 1 || got.Keywords[0] != "dune scifi" { + t.Errorf("kt = %v", got.Keywords) + } +} + +func TestParseInvalidXLIgnored(t *testing.T) { + for _, raw := range []string{ + "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6&xl=notanumber", + "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6&xl=-5", + "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6", + } { + got, err := Parse(raw) + if err != nil { + t.Fatalf("Parse(%q): %v", raw, err) + } + if got.ExactLength != 0 { + t.Errorf("Parse(%q): xl = %d, want 0", raw, got.ExactLength) + } + } +} + +func TestInfoContext(t *testing.T) { + tests := []struct { + name string + info Info + want []string // подстроки, которые ДОЛЖНЫ быть + notWant []string // подстроки, которых быть НЕ должно + }{ + { + name: "содержательный dn", + info: Info{DisplayName: "Dune.Part.Two.2024.2160p"}, + want: []string{"Dune.Part.Two.2024.2160p"}, + notWant: []string{"Размер:", "Трекер:"}, + }, + { + name: "заглушка-dn не берётся как имя", + info: Info{DisplayName: "rutracker-topic-6514485", Trackers: []string{"http://bt.t-ru.org/ann?magnet"}}, + want: []string{"Трекер: t-ru.org"}, + notWant: []string{"rutracker-topic"}, + }, + { + name: "только размер", + info: Info{ExactLength: 2200000000}, + want: []string{"Размер: ≈ 2.0 GiB"}, + }, + { + name: "домен из xs, служебный поддомен убран", + info: Info{Sources: []string{"udp://tracker.example.org:80/announce"}}, + want: []string{"Трекер: example.org"}, + notWant: []string{"tracker.example.org"}, + }, + { + name: "все поля вместе", + info: Info{ + DisplayName: "Dune", + ExactLength: 1500, + Trackers: []string{"http://bt.t-ru.org/ann"}, + Keywords: []string{"scifi dune"}, // как отдаёт url.Query() из kt=scifi+dune + }, + want: []string{"Dune", "Размер:", "Трекер: t-ru.org", "Ключевые слова: scifi, dune"}, + }, + { + name: "пустой Info → пустой контекст", + info: Info{}, + want: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.info.Context() + if len(tc.want) == 0 && got != "" { + t.Errorf("Context() = %q, want пусто", got) + } + for _, w := range tc.want { + if !strings.Contains(got, w) { + t.Errorf("Context() = %q, want содержит %q", got, w) + } + } + for _, nw := range tc.notWant { + if strings.Contains(got, nw) { + t.Errorf("Context() = %q, НЕ должно содержать %q", got, nw) + } + } + }) + } +} + +// Реальная ссылка с рутрекера: dn несёт полный релиз-заголовок в +// процент-кодировке (кириллица + латиница, сезон/серии/год/жанры/качество), +// tr — announce-хост bt4.t-ru.org. Проверяем полный путь до Context(). +func TestParseRealRutrackerLink(t *testing.T) { + const raw = "magnet:?xt=urn:btih:BACA24E18C7382A9E9A44132C8D7DB86C4D319C2" + + "&tr=http%3A%2F%2Fbt4.t-ru.org%2Fann%3Fmagnet" + + "&dn=%D0%91%D1%83%D1%85%D1%82%D0%B0%20%D0%B2%D0%B4%D0%BE%D0%B2%20%2F%20Widow's%20Bay%20%2F%20%D0%A1%D0%B5%D0%B7%D0%BE%D0%BD%3A%201%20%2F%20%D0%A1%D0%B5%D1%80%D0%B8%D0%B8%3A%201-10%20%D0%B8%D0%B7%2010%20(%D0%A5%D0%B8%D1%80%D0%BE%20%D0%9C%D1%83%D1%80%D0%B0%D0%B9)%20%5B2026%2C%20%D0%A1%D0%A8%D0%90%2C%20%D0%A3%D0%B6%D0%B0%D1%81%D1%8B%2C%20%D0%B4%D1%80%D0%B0%D0%BC%D0%B0%2C%20%D0%BA%D0%BE%D0%BC%D0%B5%D0%B4%D0%B8%D1%8F%2C%20WEB-DL%201080p%5D%207%20x%20MVO" + got, err := Parse(raw) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got.Infohash != "baca24e18c7382a9e9a44132c8d7db86c4d319c2" { + t.Errorf("infohash = %q", got.Infohash) + } + // dn раскодирован url.Query() — без ручного urldecode. + const wantName = "Бухта вдов / Widow's Bay / Сезон: 1 / Серии: 1-10 из 10 " + + "(Хиро Мурай) [2026, США, Ужасы, драма, комедия, WEB-DL 1080p] 7 x MVO" + if got.DisplayName != wantName { + t.Errorf("dn = %q", got.DisplayName) + } + ctx := got.Context() + if !strings.Contains(ctx, "Widow's Bay") || !strings.Contains(ctx, "2026") { + t.Errorf("Context() без релиз-данных: %q", ctx) + } + if !strings.Contains(ctx, "Трекер: t-ru.org") { + t.Errorf("Context() без домена трекера (bt4. должен срезаться): %q", ctx) + } + if strings.Contains(ctx, "bt4.") { + t.Errorf("служебный поддомен не срезан: %q", ctx) + } +} + +// Ссылку могли скопировать целиком в процент-кодировке (например вытащили из +// другого URL) — Parse должен раскодировать её один раз и разобрать. +func TestParseDoubleEncoded(t *testing.T) { + const inner = "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6&dn=Dune" + got, err := Parse(url.QueryEscape(inner)) + if err != nil { + t.Fatalf("Parse(double-encoded): %v", err) + } + if got.Infohash != "541adcff3b6dd5dba7088ea83317d9d6fac331d6" { + t.Errorf("infohash = %q", got.Infohash) + } + if got.DisplayName != "Dune" { + t.Errorf("dn = %q", got.DisplayName) + } +} + func TestParseNormalisesCase(t *testing.T) { lower := "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6" upper := "magnet:?xt=urn:btih:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6" diff --git a/openspec/changes/archive/2026-07-07-magnet-context-extraction/.openspec.yaml b/openspec/changes/archive/2026-07-07-magnet-context-extraction/.openspec.yaml new file mode 100644 index 0000000..aee4ef1 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-magnet-context-extraction/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-07 diff --git a/openspec/changes/archive/2026-07-07-magnet-context-extraction/design.md b/openspec/changes/archive/2026-07-07-magnet-context-extraction/design.md new file mode 100644 index 0000000..472ead0 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-magnet-context-extraction/design.md @@ -0,0 +1,154 @@ +## Context + +Ingest (`internal/ingest/ingest.go`) сейчас принимает `Request{Source, +Context}`, где `Context` — опциональный текст от транспорта (Telegram-парсер +чистит пересланное сообщение бота; HTTP-форма отдаёт поле `context`). Из +magnet парсер (`internal/magnet/magnet.go`) достаёт только `xt` (хеши), `dn`, +`tr`. `req.Context` кладётся в `download.Context` и подаётся в +`namer.DeriveName(ctx, req.Context, info.DisplayName)` — `dn` идёт отдельной +«подсказкой». Важно: `dn` в `download.Context` **не** попадает, поэтому +recognition его сейчас не видит вовсе. + +`download.Context` имеет двух потребителей: recognition-промпт +(`internal/recognize/prompt.go:87`) и веб-UI страницы загрузки +(`internal/httpapi/download.go:100`) — синтез станет виден обоим. + +При приёме голого magnet без текста `download.Context` почти пуст, и +recognition теряет дешёвый сигнал (имя релиза, размер, происхождение), который +физически лежит в самой ссылке. + +## Goals / Non-Goals + +**Goals:** + +- Выжать в контекст распознавания максимум из полей magnet (`dn`, `xl`, + `tr`/`xs`, `kt`) без сетевых запросов. +- Дополнять этим контекстом пользовательский текст (не терять его, ставить + первым), а при пустом тексте — синтезировать контекст целиком. +- Обогащённый контекст идёт **только в `download.Context`** (recognition + + UI). Вход namer и отображаемое имя не меняем. + +**Non-Goals:** + +- Дообогащение со страницы трекера (`*-topic-` → HTTP). Отдельная задача. +- Изменение схемы БД, API транспортов, сигнатуры `naming.DeriveName`. +- Изменение вывода отображаемого имени: строки-факты (`Размер:`, `Трекер:`) + не должны становиться `display_name`. +- Использование дерева файлов торрента (оно приходит от qBittorrent позже, не + из ссылки) — вне этого change. + +## Decisions + +### Р1. Расширяем `magnet.Info`, синтез контекста — в `internal/magnet` + +`Info` получает поля `ExactLength int64` (из `xl`), `Sources []string` (из +`xs`), `Keywords []string` (из `kt`). `DisplayName`, `Trackers` уже есть. + +Функцию синтеза человекочитаемого контекста (`func (Info) Context() string` +или `SynthContext`) размещаем в пакете `magnet` — она чисто выводится из +полей `Info`, легко юнит-тестируется в изоляции и не тянет зависимостей. +Ingest лишь склеивает результат с `req.Context`. + +_Альтернатива:_ синтез внутри ingest. Отвергнуто — раздувает ingest и хуже +тестируется; знание формата полей magnet логичнее держать в `magnet`. + +Значения полей (`dn`, `xs`, `kt`) приходят уже раскодированными: `url.Query()` +снимает процент-кодировку — ручной urldecode не нужен. Дополнительно `Parse` +устойчив к ссылке, скопированной целиком в процент-кодировке +(`magnet%3A%3Fxt%3D…`, например вытащенной из другого URL): при неудаче +прямого разбора делаем разовый `QueryUnescape` и пробуем снова. Unescape +применяется только на этом фолбек-пути, поэтому значимый `+` в query обычной +ссылки не затрагивается. + +### Р2. Формат синтезированного контекста — строки-факты + +Синтез собирает набор коротких строк (по одной на факт) и склеивает через +`\n`, в духе того, что уже кладёт Telegram-парсер: + +``` + +Размер: ≈ 2.1 GiB +Трекер: rutracker.org +Ключевые слова: +``` + +Это дружелюбно и к LLM (namer/recognition), и к алгоритмическому фолбеку +namer, который берёт «первую содержательную строку» — поэтому строка `dn` +идёт первой. Точные ярлыки строк уточняются при apply; спека фиксирует состав, +не формулировки. + +### Р3. Слияние — «дополняет всегда», результат только в `download.Context` + +Итоговый контекст = `join(nonEmpty(userText, synthFromMagnet))`. Если +`userText` пуст — остаётся только синтез; если синтез пуст — только текст; +если пусты оба — пустая строка (приём штатно проходит с пустым контекстом, +как сейчас). Пользовательский текст идёт **первым** (он содержательнее). + +Результат кладётся **только** в `download.Context` (его читают recognition и +UI). В namer он **не** передаётся — см. Р5. + +_Альтернатива:_ синтез только при пустом тексте. Отвергнуто пользователем — +теряем размер/происхождение, когда текст есть, но беден. + +### Р4. Отсев заглушки `dn` + +`dn` вида `*-topic-` (частый случай рутрекера: `dn=rutracker-topic-6514485`) +как строку-название не берём — это не имя, а идентификатор темы. Детектим +простым паттерном (`(?i)-topic-\w+$` / `^\w+-topic-`). Остальные поля (размер, +домен) синтезируем в любом случае. Домен трекера при этом всё равно +сообщит происхождение (`rutracker.org`). + +### Р5. Namer НЕ получает синтез — вход именования не меняем + +`namer.DeriveName(ctx, req.Context, info.DisplayName)` остаётся как сейчас: +пользовательский текст + `dn`-подсказка. Синтезированные строки-факты в namer +**не** попадают. + +_Почему:_ фолбек namer (`fallbackName` → `firstMeaningfulLine`) берёт первую +содержательную строку контекста как название. Если подать туда синтез, то на +самом частом сценарии (голый rutracker-magnet: `dn=*-topic-` — заглушка, +`xl`/`kt` нет) первой строкой окажется `Трекер: t-ru.org`, и она станет и +`qBit rename`, и `download.display_name` (заголовок карточки в UI) — регресс +против нынешнего `rutracker-topic-`. Значимое имя namer и так получает +через `dn`-hint, а размер/домен для *имени* бесполезны. Поэтому синтез — +только в `download.Context` для recognition; именование не трогаем. + +_Альтернатива:_ учить `fallbackName` отбрасывать строки `^<Ярлык>:\s`. +Отвергнуто — лишняя связанность namer с форматом синтеза; чище просто не +подавать синтез в namer. + +## Risks / Trade-offs + +- [Синтетический контекст «зашумляет» вход recognition — размер/домен могут + сбить LLM] → Факты подаём короткими помеченными строками (`Размер:`, + `Трекер:`), отделимыми от названия; recognition уже толерантен к «грязному» + контексту (Telegram-пересылки). Держим синтез лаконичным. +- [Синтез виден в веб-UI (страница загрузки рендерит `download.Context`)] → + Ожидаемо и полезно (пользователь видит, откуда взят контекст). Держим + строки-факты короткими и человекочитаемыми ради этого же. +- [Дубль факта: `xl` даёт `Размер:`, а в тексте Telegram размер уже есть] → + На практике редко (реальные rutracker-magnet'ы `xl` не несут). Дедупликацию + не делаем — recognition толерантен к повтору; помечаем как известный + трейд-офф. +- [Ложное срабатывание отсева `dn`-заглушки на реальном имени с «-topic-»] → + Паттерн якорим на `-topic-<токен>` в начале/конце строки; при сомнении + трактуем консервативно (лучше включить лишнее имя, чем потерять). Покрываем + тестом на `rutracker-topic-*` и на обычное имя. +- [`xl` в разных единицах/формах] → По спецификации magnet `xl` — байты + (десятичное целое). Парсим строго; при ошибке поле пропускаем (best-effort, + приём не валим). +- [Разные транспорты дублируют логику склейки] → Склейку делаем один раз в + ingest (общий use-case для всех транспортов), транспорты не трогаем. + +## Migration Plan + +Изменение аддитивное и обратносовместимое: новые поля `Info` опциональны, +контекст лишь обогащается. Схема БД не меняется, миграций нет. Откат — +ревертом кода; уже созданные `download.Context` остаются валидными. + +## Open Questions + +- Точные ярлыки строк-фактов (`Размер:` vs `size ~`) и локаль размера — + решаем при apply, на спеку не влияет. +- Стоит ли нормализовать домен трекера (убирать `www.`, `bt.`, `ann`-хосты) — + мелочь реализации, вынесем в helper с тестом. diff --git a/openspec/changes/archive/2026-07-07-magnet-context-extraction/proposal.md b/openspec/changes/archive/2026-07-07-magnet-context-extraction/proposal.md new file mode 100644 index 0000000..91da355 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-magnet-context-extraction/proposal.md @@ -0,0 +1,63 @@ +## Why + +Приём по «голому» magnet (без сопроводительного текста бота) уже проходит, но +контекст распознавания при этом пуст: `download.Context` уходит в recognition +почти пустым, и LLM-матч работает почти вслепую до докачки метаданных +qBittorrent. При этом сама magnet-ссылка несёт полезные поля (имя релиза `dn`, +размер `xl`, трекеры `tr`/источники `xs`), которые сейчас в контекст не +попадают: парсим лишь `xt`, `dn`, `tr`, причём `dn` идёт только подсказкой в +namer и в `download.Context` (а значит и в recognition) не сохраняется. Выжав +эти поля в контекст распознавания, мы даём recognition реальный сигнал даже +когда пользователь прислал один magnet. + +## What Changes + +- Парсер `internal/magnet` извлекает дополнительные поля ссылки: `xl` (размер + в байтах), `xs` (exact source), `kt` (keywords). `dn` и `tr` уже парсятся. +- Ingest **синтезирует текст контекста из полей magnet** и **дополняет** им + контекст, пришедший из транспорта: факты из полей добавляются к + пользовательскому тексту (пользовательский — первым), а при пустом тексте + становятся единственным контекстом. Синтез — из самой ссылки, без сетевых + запросов. +- В синтез входят: имя релиза (`dn`, если это содержательное имя, а не + заглушка-идентификатор вида `*-topic-`), размер (человекочитаемо из + `xl`), происхождение по домену трекера/источника (`tr`/`xs`) как слабый + сигнал языка/типа, ключевые слова (`kt`). +- Обогащённый контекст сохраняется в `download.Context` — его читают + **recognition** (LLM-промпт) и **веб-UI** (страница загрузки). **Вывод + отображаемого имени (namer) не меняется**: он по-прежнему получает + пользовательский текст и `dn`-подсказку, а помеченные строки-факты + (`Размер:`, `Трекер:`) в имя не попадают. +- Явно фиксируем поддержку приёма **только по magnet** (пустой текст) как + штатный сценарий во всех транспортах. + +Вне объёма (сознательно): дообогащение со страницы трекера +(`*-topic-` → HTTP-запрос) — отдельная будущая задача; контекст берём +только из полей ссылки. Поведение namer/отображаемого имени не меняем. + +## Capabilities + +### New Capabilities + +_Нет._ Изменение укладывается в существующую capability `ingest`. + +### Modified Capabilities + +- `ingest`: добавляется требование «синтез контекста распознавания из полей + magnet и дополнение им контекста транспорта» (обогащённый контекст → только + `download.Context`; отображаемое имя не затрагивается) и явно фиксируется + приём при пустом тексте. Существующие требования по выводу отображаемого + имени остаются **без изменений** (namer получает тот же вход). + +## Impact + +- Код: `internal/magnet` (новые поля `Info` + синтез контекста), + `internal/ingest` (слияние `req.Context` + синтез в `download.Context` до + `CreateDownload`). `internal/naming` **не затрагивается** — вход namer тот + же (`req.Context`, `dn`-hint). +- Данные: `download.Context` начинает содержать синтезированный текст — + влияет на вход recognition и на отображение контекста в веб-UI; схема БД не + меняется. +- Транспорты (`httpapi`, `tgbot`): поведение при пустом контексте становится + штатным; изменений API не требуется. +- Внешние системы: без новых зависимостей и сетевых вызовов. diff --git a/openspec/changes/archive/2026-07-07-magnet-context-extraction/specs/ingest/spec.md b/openspec/changes/archive/2026-07-07-magnet-context-extraction/specs/ingest/spec.md new file mode 100644 index 0000000..522632d --- /dev/null +++ b/openspec/changes/archive/2026-07-07-magnet-context-extraction/specs/ingest/spec.md @@ -0,0 +1,95 @@ +## ADDED Requirements + +### Requirement: Синтез контекста распознавания из полей magnet + +При приёме система SHALL извлекать из полей magnet-ссылки дополнительный +контекст и **дополнять** им контекст, пришедший из транспорта: факты из полей +SHALL добавляться к пользовательскому тексту (пользовательский текст — +первым), а при пустом тексте SHALL становиться единственным контекстом. +Синтез SHALL выполняться только из самой ссылки, без сетевых запросов. + +В синтез SHALL включаться следующие поля, когда они присутствуют: + +- `dn` (display name) — как строка названия релиза, **если** это содержательное + имя, а не заглушка-идентификатор вида `*-topic-` (например + `rutracker-topic-6514485`); такие заглушки в контекст-название включаться + SHALL NOT. +- `xl` (exact length) — как человекочитаемый размер (например «≈ 2.1 GiB»); + нечисловое/некорректное значение игнорируется. +- `tr`/`xs` (трекеры / exact source) — как сигнал происхождения по домену + (хост трекера/источника), помогающий определить язык и тип контента. +- `kt` (keyword topic) — как ключевые слова. + +Обогащённый контекст система SHALL сохранять в `download.Context` — его читают +recognition (LLM-промпт) и веб-UI (страница загрузки). Синтез и слияние SHALL +выполняться до создания загрузки. + +Синтезированные строки-факты (размер, домен трекера, ключевые слова) SHALL NOT +влиять на вывод отображаемого имени (`download.display_name` / параметр +`rename` qBittorrent): вход вывода имени остаётся прежним (пользовательский +текст и подсказка `dn`), см. требование «Отображаемое имя торрента из +контекста». + +Приём **только по magnet** (пустой текст контекста) SHALL быть штатным +сценарием во всех транспортах (HTTP, Telegram, CLI). + +#### Scenario: dn — содержательное имя релиза + +- **WHEN** magnet содержит `dn` с релиз-именем (например + `Dune.Part.Two.2024.2160p.BluRay`) +- **THEN** это имя добавляется в `download.Context` +- **AND** становится доступно recognition + +#### Scenario: dn — заглушка-идентификатор темы + +- **WHEN** `dn` имеет вид `*-topic-` (например `rutracker-topic-6514485`) +- **THEN** система не включает его как строку-название в контекст +- **AND** остальные поля magnet (размер, домен трекера) всё равно синтезируются + +#### Scenario: Размер из xl + +- **WHEN** magnet содержит корректный числовой `xl` +- **THEN** в `download.Context` добавляется человекочитаемый размер загрузки + +#### Scenario: Происхождение по домену трекера + +- **WHEN** magnet содержит `tr` и/или `xs` с распознаваемым хостом +- **THEN** в `download.Context` добавляется сигнал происхождения (домен), + пригодный как подсказка языка/типа контента + +#### Scenario: Дополнение непустого пользовательского контекста + +- **WHEN** транспорт передал непустой текст контекста, а magnet несёт поля +- **THEN** `download.Context` содержит и текст пользователя, и факты из полей + magnet (текст пользователя не теряется) +- **AND** текст пользователя идёт первым + +#### Scenario: Приём только по magnet + +- **WHEN** magnet принят с пустым текстом контекста +- **THEN** приём проходит штатно +- **AND** `download.Context` синтезируется из полей magnet и сохраняется + +#### Scenario: Строки-факты не становятся отображаемым именем + +- **GIVEN** голый magnet с заглушкой `dn=rutracker-topic-` и трекером, без + пользовательского текста +- **WHEN** выполняется приём +- **THEN** `download.display_name` не выводится из строк-фактов (не равен + `Трекер: …`/`Размер: …`) +- **AND** отображаемое имя определяется прежним путём (подсказка `dn` или его + отсутствие → без `rename`) + +#### Scenario: Синтез без сети + +- **WHEN** выполняется извлечение контекста из полей magnet +- **THEN** не делается ни одного сетевого запроса (только разбор строки + ссылки) + +#### Scenario: Полей для контекста нет + +- **WHEN** текст пользователя пуст и ни одно пригодное поле magnet не даёт + содержательного контекста (нет `dn`-имени, `xl`, распознаваемого домена, + `kt`) +- **THEN** `download.Context` остаётся пустым +- **AND** приём проходит штатно (пустой контекст допустим) diff --git a/openspec/changes/archive/2026-07-07-magnet-context-extraction/tasks.md b/openspec/changes/archive/2026-07-07-magnet-context-extraction/tasks.md new file mode 100644 index 0000000..40db781 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-magnet-context-extraction/tasks.md @@ -0,0 +1,44 @@ +## 1. Парсинг полей magnet + +- [x] 1.1 Расширить `magnet.Info` полями `ExactLength int64` (из `xl`), + `Sources []string` (из `xs`), `Keywords []string` (из `kt`) +- [x] 1.2 В `Parse` заполнить новые поля из `vals`; `xl` парсить строго как + десятичное целое (байты), при ошибке — оставлять 0 (best-effort) +- [x] 1.3 Тесты парсинга: magnet с `xl`/`xs`/`kt`, гибридный, невалидный `xl`, + отсутствие полей +- [x] 1.4 Устойчивость `Parse` к ссылке, скопированной целиком в + процент-кодировке (`magnet%3A%3F…`): разовый `QueryUnescape` на фолбек-пути + + тест; тест на реальной рутрекер-ссылке (dn с кириллицей → Context) + +## 2. Синтез контекста из полей + +- [x] 2.1 Реализовать `func (Info) Context() string` (или `SynthContext`) в + пакете `magnet`: строки-факты через `\n` — название (`dn`, если не заглушка), + `Размер:` из `xl` (человекочитаемо), `Трекер:`/происхождение из домена + `tr`/`xs`, `Ключевые слова:` из `kt` +- [x] 2.2 Детектор заглушки `dn` вида `*-topic-` (не включать как название) +- [x] 2.3 Helper нормализации домена трекера/источника из URL (`tr`/`xs`) +- [x] 2.4 Тесты синтеза: содержательный `dn`; `dn`-заглушка `rutracker-topic-*`; + только размер; только домен; все поля вместе; пустой `Info` → пустая строка; + проверить отсутствие сетевых вызовов (чистая функция) + +## 3. Слияние в ingest + +- [x] 3.1 В `Ingest` собрать `enrichedContext = join(nonEmpty(req.Context, + info.Context()))` — пользовательский текст первым, синтез следом +- [x] 3.2 Класть `enrichedContext` **только** в `download.Context` (вместо + сырого `req.Context`); вызов `namer.DeriveName(ctx, req.Context, + info.DisplayName)` оставить как есть — namer синтез НЕ получает +- [x] 3.3 Тесты ingest: magnet-only (пустой `req.Context`) → `download.Context` + синтезирован; непустой текст + поля → оба присутствуют, текст первым; пустой + текст и бедный magnet → пустой контекст, приём проходит +- [x] 3.4 Регресс-тест: голый rutracker-magnet (`dn=rutracker-topic-`, + только `tr`, без текста) → `download.display_name` НЕ равен `Трекер: …` + (именование не деградирует), а `download.Context` содержит домен трекера + +## 4. Транспорты и проверка + +- [x] 4.1 Убедиться, что приём голого magnet (пустой контекст) штатно проходит + в `httpapi` и `tgbot` (при необходимости — тест на пустой `context`) +- [x] 4.2 `task test` и `task lint` зелёные +- [x] 4.3 `openspec validate magnet-context-extraction --strict` проходит diff --git a/openspec/specs/ingest/spec.md b/openspec/specs/ingest/spec.md index e8ae2e2..fd9408d 100644 --- a/openspec/specs/ingest/spec.md +++ b/openspec/specs/ingest/spec.md @@ -210,3 +210,97 @@ btih (v1), и btmh (v2); `kind` определяется по длине hex (40 - **THEN** переход отклоняется с пояснением, #1 остаётся в `failed` - **AND** активной по `h` остаётся #2 +### Requirement: Синтез контекста распознавания из полей magnet + +При приёме система SHALL извлекать из полей magnet-ссылки дополнительный +контекст и **дополнять** им контекст, пришедший из транспорта: факты из полей +SHALL добавляться к пользовательскому тексту (пользовательский текст — +первым), а при пустом тексте SHALL становиться единственным контекстом. +Синтез SHALL выполняться только из самой ссылки, без сетевых запросов. + +В синтез SHALL включаться следующие поля, когда они присутствуют: + +- `dn` (display name) — как строка названия релиза, **если** это содержательное + имя, а не заглушка-идентификатор вида `*-topic-` (например + `rutracker-topic-6514485`); такие заглушки в контекст-название включаться + SHALL NOT. +- `xl` (exact length) — как человекочитаемый размер (например «≈ 2.1 GiB»); + нечисловое/некорректное значение игнорируется. +- `tr`/`xs` (трекеры / exact source) — как сигнал происхождения по домену + (хост трекера/источника), помогающий определить язык и тип контента. +- `kt` (keyword topic) — как ключевые слова. + +Обогащённый контекст система SHALL сохранять в `download.Context` — его читают +recognition (LLM-промпт) и веб-UI (страница загрузки). Синтез и слияние SHALL +выполняться до создания загрузки. + +Синтезированные строки-факты (размер, домен трекера, ключевые слова) SHALL NOT +влиять на вывод отображаемого имени (`download.display_name` / параметр +`rename` qBittorrent): вход вывода имени остаётся прежним (пользовательский +текст и подсказка `dn`), см. требование «Отображаемое имя торрента из +контекста». + +Приём **только по magnet** (пустой текст контекста) SHALL быть штатным +сценарием во всех транспортах (HTTP, Telegram, CLI). + +#### Scenario: dn — содержательное имя релиза + +- **WHEN** magnet содержит `dn` с релиз-именем (например + `Dune.Part.Two.2024.2160p.BluRay`) +- **THEN** это имя добавляется в `download.Context` +- **AND** становится доступно recognition + +#### Scenario: dn — заглушка-идентификатор темы + +- **WHEN** `dn` имеет вид `*-topic-` (например `rutracker-topic-6514485`) +- **THEN** система не включает его как строку-название в контекст +- **AND** остальные поля magnet (размер, домен трекера) всё равно синтезируются + +#### Scenario: Размер из xl + +- **WHEN** magnet содержит корректный числовой `xl` +- **THEN** в `download.Context` добавляется человекочитаемый размер загрузки + +#### Scenario: Происхождение по домену трекера + +- **WHEN** magnet содержит `tr` и/или `xs` с распознаваемым хостом +- **THEN** в `download.Context` добавляется сигнал происхождения (домен), + пригодный как подсказка языка/типа контента + +#### Scenario: Дополнение непустого пользовательского контекста + +- **WHEN** транспорт передал непустой текст контекста, а magnet несёт поля +- **THEN** `download.Context` содержит и текст пользователя, и факты из полей + magnet (текст пользователя не теряется) +- **AND** текст пользователя идёт первым + +#### Scenario: Приём только по magnet + +- **WHEN** magnet принят с пустым текстом контекста +- **THEN** приём проходит штатно +- **AND** `download.Context` синтезируется из полей magnet и сохраняется + +#### Scenario: Строки-факты не становятся отображаемым именем + +- **GIVEN** голый magnet с заглушкой `dn=rutracker-topic-` и трекером, без + пользовательского текста +- **WHEN** выполняется приём +- **THEN** `download.display_name` не выводится из строк-фактов (не равен + `Трекер: …`/`Размер: …`) +- **AND** отображаемое имя определяется прежним путём (подсказка `dn` или его + отсутствие → без `rename`) + +#### Scenario: Синтез без сети + +- **WHEN** выполняется извлечение контекста из полей magnet +- **THEN** не делается ни одного сетевого запроса (только разбор строки + ссылки) + +#### Scenario: Полей для контекста нет + +- **WHEN** текст пользователя пуст и ни одно пригодное поле magnet не даёт + содержательного контекста (нет `dn`-имени, `xl`, распознаваемого домена, + `kt`) +- **THEN** `download.Context` остаётся пустым +- **AND** приём проходит штатно (пустой контекст допустим) +