Files
jellybit/openspec/changes/archive/2026-07-07-magnet-context-extraction/design.md
T
avandClaude Opus 4.8 e9ec26d09a 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>
2026-07-07 20:38:01 +03:00

11 KiB
Raw Blame History

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 (fallbackNamefirstMeaningfulLine) берёт первую содержательную строку контекста как название. Если подать туда синтез, то на самом частом сценарии (голый 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 с тестом.