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
@@ -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` проходит