Ingest: контекст распознавания из полей magnet-ссылки

Синтезируем контекст из полей самой magnet-ссылки (dn, xl, tr/xs, kt) без
сети и дополняем им текст от транспорта: пользовательский текст первым, при
пустом — синтез единственный. Обогащённый контекст идёт только в
download.Context (его читают recognition и веб-UI); вход namer и отображаемое
имя не меняются — строки-факты (Размер:/Трекер:) в display_name не текут.

- magnet.Info: поля ExactLength/Sources/Keywords + Info.Context() (синтез,
  отсев dn-заглушек *-topic-<id>, домен трекера, человекочитаемый размер)
- Parse устойчив к ссылке в процент-кодировке (разовый QueryUnescape)
- ingest: mergeContext → download.Context, namer на сыром req.Context
- Влита дельта capability ingest в openspec/specs, change заархивирован

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-07 20:38:01 +03:00
co-authored by Claude Opus 4.8
parent 6d801ed03b
commit e9ec26d09a
10 changed files with 837 additions and 7 deletions
+20 -1
View File
@@ -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-путь через
+74 -2
View File
@@ -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")}
+138 -3
View File
@@ -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).
+153 -1
View File
@@ -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"
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-07
@@ -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-<id>` → 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-парсер:
```
<dn как есть, если не заглушка>
Размер: ≈ 2.1 GiB
Трекер: rutracker.org
Ключевые слова: <kt>
```
Это дружелюбно и к LLM (namer/recognition), и к алгоритмическому фолбеку
namer, который берёт «первую содержательную строку» — поэтому строка `dn`
идёт первой. Точные ярлыки строк уточняются при apply; спека фиксирует состав,
не формулировки.
### Р3. Слияние — «дополняет всегда», результат только в `download.Context`
Итоговый контекст = `join(nonEmpty(userText, synthFromMagnet))`. Если
`userText` пуст — остаётся только синтез; если синтез пуст — только текст;
если пусты оба — пустая строка (приём штатно проходит с пустым контекстом,
как сейчас). Пользовательский текст идёт **первым** (он содержательнее).
Результат кладётся **только** в `download.Context` (его читают recognition и
UI). В namer он **не** передаётся — см. Р5.
_Альтернатива:_ синтез только при пустом тексте. Отвергнуто пользователем —
теряем размер/происхождение, когда текст есть, но беден.
### Р4. Отсев заглушки `dn`
`dn` вида `*-topic-<id>` (частый случай рутрекера: `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-<id>` — заглушка,
`xl`/`kt` нет) первой строкой окажется `Трекер: t-ru.org`, и она станет и
`qBit rename`, и `download.display_name` (заголовок карточки в UI) — регресс
против нынешнего `rutracker-topic-<id>`. Значимое имя 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 с тестом.
@@ -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-<id>`), размер (человекочитаемо из
`xl`), происхождение по домену трекера/источника (`tr`/`xs`) как слабый
сигнал языка/типа, ключевые слова (`kt`).
- Обогащённый контекст сохраняется в `download.Context` — его читают
**recognition** (LLM-промпт) и **веб-UI** (страница загрузки). **Вывод
отображаемого имени (namer) не меняется**: он по-прежнему получает
пользовательский текст и `dn`-подсказку, а помеченные строки-факты
(`Размер:`, `Трекер:`) в имя не попадают.
- Явно фиксируем поддержку приёма **только по magnet** (пустой текст) как
штатный сценарий во всех транспортах.
Вне объёма (сознательно): дообогащение со страницы трекера
(`*-topic-<id>` → 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 не требуется.
- Внешние системы: без новых зависимостей и сетевых вызовов.
@@ -0,0 +1,95 @@
## ADDED Requirements
### Requirement: Синтез контекста распознавания из полей magnet
При приёме система SHALL извлекать из полей magnet-ссылки дополнительный
контекст и **дополнять** им контекст, пришедший из транспорта: факты из полей
SHALL добавляться к пользовательскому тексту (пользовательский текст —
первым), а при пустом тексте SHALL становиться единственным контекстом.
Синтез SHALL выполняться только из самой ссылки, без сетевых запросов.
В синтез SHALL включаться следующие поля, когда они присутствуют:
- `dn` (display name) — как строка названия релиза, **если** это содержательное
имя, а не заглушка-идентификатор вида `*-topic-<id>` (например
`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-<id>` (например `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-<id>` и трекером, без
пользовательского текста
- **WHEN** выполняется приём
- **THEN** `download.display_name` не выводится из строк-фактов (не равен
`Трекер: …`/`Размер: …`)
- **AND** отображаемое имя определяется прежним путём (подсказка `dn` или его
отсутствие → без `rename`)
#### Scenario: Синтез без сети
- **WHEN** выполняется извлечение контекста из полей magnet
- **THEN** не делается ни одного сетевого запроса (только разбор строки
ссылки)
#### Scenario: Полей для контекста нет
- **WHEN** текст пользователя пуст и ни одно пригодное поле magnet не даёт
содержательного контекста (нет `dn`-имени, `xl`, распознаваемого домена,
`kt`)
- **THEN** `download.Context` остаётся пустым
- **AND** приём проходит штатно (пустой контекст допустим)
@@ -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-<id>` (не включать как название)
- [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-<id>`,
только `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` проходит
+94
View File
@@ -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-<id>` (например
`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-<id>` (например `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-<id>` и трекером, без
пользовательского текста
- **WHEN** выполняется приём
- **THEN** `download.display_name` не выводится из строк-фактов (не равен
`Трекер: …`/`Размер: …`)
- **AND** отображаемое имя определяется прежним путём (подсказка `dn` или его
отсутствие → без `rename`)
#### Scenario: Синтез без сети
- **WHEN** выполняется извлечение контекста из полей magnet
- **THEN** не делается ни одного сетевого запроса (только разбор строки
ссылки)
#### Scenario: Полей для контекста нет
- **WHEN** текст пользователя пуст и ни одно пригодное поле magnet не даёт
содержательного контекста (нет `dn`-имени, `xl`, распознаваемого домена,
`kt`)
- **THEN** `download.Context` остаётся пустым
- **AND** приём проходит штатно (пустой контекст допустим)