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")}