Files
jellybit/openspec/changes/add-qbt-display-name/design.md
T

160 lines
11 KiB
Markdown

## Context
`ingest.Ingest()` принимает источник (magnet) и текстовый контекст
(вычищенный заголовок релиза от торрент-бота), дедуплицирует по infohash,
заводит задачу и отдаёт источник в qBittorrent через `qbt.Add()`. Сейчас
`qbt.AddRequest` несёт `URLs/Category/SavePath/Paused`, но не имя — в списке
qBit задача показывается своим `dn` из magnet, часто мусорным
(`rutracker-topic-…`).
В проекте уже есть всё нужное: `llm.Provider` (один вызов модели, `JSONMode`,
транспортные ретраи внутри; бюджет переразбора схемы — на стороне
вызывающего, как в `recognize`), пред-парс имени через go-ptn, контекст с
заголовком релиза. API qBittorrent `/torrents/add` принимает поле `rename`,
задающее отображаемое имя торрента.
Ключевое ограничение: `rename` действует **только в момент добавления**
значит вывод имени должен случиться синхронно, до `qbt.Add()`.
## Goals / Non-Goals
**Goals:**
- Из контекста загрузки получать короткое читаемое имя и класть его в qBit.
- Имя строит LLM (структурированный вывод): название, год, режиссёр, тип,
сезон; язык названия — русский для российского контента, иначе английский.
- До трёх попыток получить валидное имя от LLM; иначе алгоритмический фолбек
без сети.
- Вывод имени — в ядре (`ingest`), общий для всех транспортов; деградирует
штатно и **никогда не валит приём** загрузки.
**Non-Goals:**
- Переименование уже добавленных задач; влияние на пути на диске и на
`recognize`/`layout` (имя — только ярлык в qBit).
- Сетевая сверка имени с метабазами (TMDB/TVDB) — это `recognize`, не здесь.
- Источники кроме magnet.
## Decisions
### Решение 1: вывод имени — синхронно в `ingest`, перед `qbt.Add()`
`rename` применяется лишь при добавлении, поэтому имя считается до отдачи
источника в qBit. Вывод живёт в ядре `ingest` (принцип «единое ядро, тонкие
транспорты»): транспорты по-прежнему передают только `Source` + `Context`.
- **Альтернатива** (отклонена): добавить торрент `paused`, переименовать
отдельным вызовом API, снять с паузы. Сложнее, лишние запросы, гонка с
поллингом — выгоды для ярлыка не оправдывают.
- **Плата:** приём становится зависим от LLM по латентности. Гасится
ограниченным таймаутом и быстрым фолбеком (см. Решение 4 и Риски).
### Решение 2: имя строит LLM со структурированным выводом
Отдельный узкий промпт (не трогаем схему `recognize`): на вход — контекст
(и, как подсказка, имя/`dn` из magnet), на выход — строгий JSON:
```
{
"type": "movie" | "series",
"title": "название на нужном языке",
"original_title": "оригинальное название или пустая строка",
"year": число или 0,
"director": "режиссёр или пустая строка",
"season": число или null,
"is_russian": true | false
}
```
`title` модель отдаёт уже на нужном языке: для российского контента
(`is_russian=true`) — русское название, иначе — английское/оригинальное.
`director` и `year` — опциональные поля; если модель их извлекла, они
попадают в ярлык (Решение 3).
- **Почему LLM, а не только go-ptn/регэкспы:** контекст — вольный
человеческий текст с двойными названиями (`Рус / Eng`), годом внутри
скобок и тех. характеристиками; алгоритмически чисто вытащить «красивое»
имя ненадёжно. go-ptn остаётся фолбеком (Решение 4).
- **Недоверенный вывод:** результат — только ярлык в qBit, на пути и
инварианты не влияет; жёсткая валидация пути здесь не нужна, но имя
очищается от управляющих символов и переводов строк и обрезается по длине.
### Решение 3: формат отображаемого имени
Рендер имени — чистая функция от структуры. Режиссёр и год — **опциональные**
части скобки; внутри неё порядок «режиссёр, год»:
- оба: `Title (Director, Year)``Дюна: Часть вторая (Дени Вильнёв, 2024)`.
- только год: `Title (Year)`; только режиссёр: `Title (Director)`; без обоих:
`Title` (скобка опускается).
- series: к любому из вариантов добавляется `. Сезон N`, если сезон есть.
Имя держим коротким и без тех. характеристик; `original_title` остаётся в
структуре, но в строку не добавляется. Длина ограничивается (напр. 200
символов).
### Решение 4: три попытки LLM, затем алгоритмический фолбек
«Попытка» — получить от модели валидный JSON с непустым `title`. Бюджет —
существующий `[llm].max_retries` (по умолчанию 3; тот же, что у переразбора
схемы в `recognize`); транспортные ретраи (сеть/429/5xx) остаются внутри
`llm.Provider` и в этот счёт не входят. Исчерпали попытки или LLM
недоступна → **алгоритмический фолбек**:
первая содержательная строка контекста (та же логика, что в
`tgbot.cleanContext`: без ссылок, команд и UI-мусора), срез до тех.
характеристик (до `[`/`(` с годом), очистка и обрезка по длине. Фолбек —
без сетевых запросов.
Если и фолбек пуст (контекста нет/он бесполезен) → `Rename` не задаём,
qBittorrent оставляет своё имя. Поведение «без контекста» не меняется.
- **Бюджет попыток:** переиспользуем существующий `[llm].max_retries`
(default 3). Семантика чуть иная, чем у переразбора схемы, но для проекта
такого размера отдельный параметр избыточен — разделим при необходимости.
### Решение 5: интерфейс вывода имени и graceful-деградация
`ingest` зависит от узкого интерфейса (напр. `Namer`/функция
`DeriveName(ctx, Context, magnetHint) string`), реализованного поверх
`llm.Provider` + фолбек. Так вывод имени тестируется без сети, а при
отсутствии настроенного LLM работает только фолбек. Любая ошибка вывода
имени **логируется и не прерывает** `Ingest`: пустое имя → добавляем без
`rename`.
### Решение 6: `qbt.AddRequest.Rename`
Добавляем поле `Rename string`; при непустом значении пишем form-field
`rename`. Пустое — поле не отправляется (поведение не меняется).
## Risks / Trade-offs
- **Латентность приёма из-за вызова LLM** → вывод имени ограничен общим
`[llm].timeout`; по таймауту/ошибке — фолбек. Приём не должен зависать на
медленной модели.
- **Стоимость токенов на каждую загрузку** → промпт узкий и короткий;
расход уже снимается с провода (`llm.Response.Usage`). При желании в
будущем — кэш/выключатель, вне объёма.
- **`rename` затрагивает имя корневой папки многофайловой раздачи** →
downstream безопасен: jellybit всегда читает реальные пути из qBit API
(`Files`, `content_path`), а не выводит их из имени. Инвариант «источник
неприкосновенен» цел — переименование делает сам qBittorrent при
добавлении. Перепроверить руками на реальном qBit (Open Questions).
- **Галлюцинация имени LLM** → последствия минимальны (всего лишь ярлык);
имя очищается и обрезается; на распознавание/раскладку не влияет.
## Migration Plan
Чистое добавление, без миграций БД и слома API. Включается само (если LLM
настроен — работает LLM-путь, иначе только фолбек). Откат — снять
проброс `Rename` (старые задачи в qBit не затрагиваются).
## Open Questions
- Подтвердить на реальном qBittorrent, что `rename` меняет отображаемое имя
(и поведение для многофайловой раздачи) ожидаемо. Решается верификацией на
задаче 6.2; код от ответа не зависит (пути берутся из qBit API).
Решено: бюджет попыток — `[llm].max_retries` (default 3); таймаут вывода
имени — общий `[llm].timeout`. Отдельные параметры не вводим: провайдер LLM
один, паттерн обращения общий; разделим, если появится второй провайдер.