Добавил спеки для выведения имени загрузки при добавлении торрента
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-28
|
||||
@@ -0,0 +1,159 @@
|
||||
## 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
|
||||
один, паттерн обращения общий; разделим, если появится второй провайдер.
|
||||
@@ -0,0 +1,62 @@
|
||||
## Why
|
||||
|
||||
При добавлении magnet-загрузки jellybit не передаёт в qBittorrent
|
||||
человекочитаемое имя, поэтому в списке qBit задачи выглядят безлико:
|
||||
`rutracker-topic-6514485`. Контекст загрузки (заголовок релиза от
|
||||
торрент-бота) у нас уже есть — из него можно собрать аккуратное имя
|
||||
(«Дюна: Часть вторая (2024)») и сразу класть его в qBittorrent. Небольшая
|
||||
доработка с заметной отдачей в повседневной эксплуатации.
|
||||
|
||||
Это также первая capability, переносимая из `docs/specs` в OpenSpec
|
||||
(пилот формата): change засевает capability `ingest` дельтой `ADDED`.
|
||||
|
||||
## What Changes
|
||||
|
||||
- `ingest` выводит из контекста загрузки **отображаемое имя** и передаёт
|
||||
его в qBittorrent при добавлении.
|
||||
- Имя строится LLM: из контекста извлекаются название, год, режиссёр и (для
|
||||
сериала) номер сезона; название — на русском для российского контента,
|
||||
иначе на английском. Результат — короткая читаемая строка, а не кусок
|
||||
сырого контекста.
|
||||
- LLM даётся до **трёх попыток**; при неудаче — **алгоритмический фолбек**
|
||||
(первая содержательная строка контекста, очистка и обрезка по длине) без
|
||||
сетевых запросов.
|
||||
- Если ни LLM, ни фолбек не дали имени (контекст пуст/бесполезен) —
|
||||
отображаемое имя не передаётся: qBittorrent оставляет своё (`dn`/имя из
|
||||
торрента). Поведение при отсутствии контекста не меняется.
|
||||
- `qbt.AddRequest` получает поле для отображаемого имени, пробрасываемое в
|
||||
параметр `rename` API `/torrents/add`.
|
||||
|
||||
Не входит в объём (Non-goals):
|
||||
|
||||
- Переименование уже добавленных в qBittorrent задач.
|
||||
- Изменение логики распознавания (`recognize`) и раскладки — отображаемое
|
||||
имя нужно лишь для списка qBit и не влияет на пути на диске (jellybit
|
||||
по-прежнему читает реальные пути из qBit API).
|
||||
- Источники кроме magnet (`.torrent`/url) — отдельная задача.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `ingest`: приём загрузки (источник + контекст) — дедупликация по
|
||||
infohash, заведение задачи и передача источника в qBittorrent. В рамках
|
||||
этого change добавляется требование о выводе и передаче отображаемого
|
||||
имени. Базовое поведение приёма фиксируется как контекст существующего
|
||||
кода.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- OpenSpec-спеки пусты; существующих capability нет. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **Код:** `internal/ingest` (вывод имени, новая зависимость на LLM-провайдер
|
||||
и алгоритмический фолбек), `internal/qbt` (поле `Rename` в `AddRequest`,
|
||||
form-field `rename`). Возможен небольшой хелпер вывода имени (в `ingest`
|
||||
или соседнем пакете).
|
||||
- **Конфиг:** возможен бюджет попыток LLM (переиспользовать существующий
|
||||
`llm.max_retries` либо отдельный параметр) — уточняется в design.
|
||||
- **Внешние системы:** дополнительный вызов LLM на каждую новую загрузку
|
||||
(расход токенов); деградирует штатно — при недоступности LLM работает
|
||||
алгоритмический фолбек.
|
||||
- **Инварианты:** не затрагиваются. Имя влияет только на отображение в
|
||||
qBittorrent; пути на диске берутся из qBit API, источник не трогаем.
|
||||
@@ -0,0 +1,90 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Отображаемое имя торрента из контекста
|
||||
|
||||
При добавлении загрузки в qBittorrent система SHALL выводить из контекста
|
||||
загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent
|
||||
(параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не
|
||||
показывалась безликим `dn` magnet-ссылки.
|
||||
|
||||
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
|
||||
год; для сериала — номер сезона, если он определён), а не куском сырого
|
||||
контекста. Имя SHALL очищаться от управляющих символов и переводов строк и
|
||||
SHALL обрезаться по ограничению длины.
|
||||
|
||||
Вывод имени SHALL выполняться синхронно перед отдачей источника в
|
||||
qBittorrent (параметр `rename` действует только в момент добавления).
|
||||
|
||||
Отображаемое имя SHALL влиять только на отображение в qBittorrent и SHALL
|
||||
NOT влиять на пути файлов на диске, распознавание или раскладку — реальные
|
||||
пути система по-прежнему читает из qBit API.
|
||||
|
||||
#### Scenario: Имя из контекста передаётся в qBittorrent
|
||||
|
||||
- **WHEN** загрузку добавляют с непустым контекстом, из которого удалось
|
||||
получить имя
|
||||
- **THEN** система передаёт это имя в qBittorrent в параметре `rename`
|
||||
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
|
||||
где режиссёр и год опциональны
|
||||
|
||||
#### Scenario: Контекст пуст или имя не получено
|
||||
|
||||
- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени
|
||||
- **THEN** система добавляет загрузку без параметра `rename`
|
||||
- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента)
|
||||
|
||||
### Requirement: Вывод имени через LLM со структурированным выводом
|
||||
|
||||
Система SHALL строить отображаемое имя с помощью LLM (структурированный
|
||||
JSON-вывод), извлекая из контекста тип (movie/series), название, год,
|
||||
режиссёра и (для сериала) номер сезона. Год и режиссёр — опциональные поля.
|
||||
|
||||
Название SHALL быть на русском языке для российского контента и на
|
||||
английском (оригинальном) — для остального.
|
||||
|
||||
Система SHALL предпринять ограниченное число попыток получить от LLM валидный
|
||||
результат (корректный JSON с непустым названием); бюджет попыток —
|
||||
`[llm].max_retries` (по умолчанию 3). Транспортные ретраи провайдера LLM
|
||||
(сетевые сбои, 429, 5xx) в этот счёт не входят.
|
||||
|
||||
Недоступность или ошибка LLM SHALL NOT прерывать приём загрузки: система
|
||||
переходит к алгоритмическому фолбеку.
|
||||
|
||||
#### Scenario: LLM возвращает структурированное имя
|
||||
|
||||
- **WHEN** LLM по контексту возвращает валидный JSON с непустым названием
|
||||
- **THEN** система формирует отображаемое имя из его полей (название, год,
|
||||
для сериала — сезон)
|
||||
|
||||
#### Scenario: Российский контент — название на русском
|
||||
|
||||
- **WHEN** контент распознан как российский
|
||||
- **THEN** в отображаемом имени используется русское название
|
||||
|
||||
#### Scenario: Исчерпан бюджет попыток LLM
|
||||
|
||||
- **WHEN** LLM за отведённые попытки (`[llm].max_retries`) не вернул валидный
|
||||
результат либо недоступен
|
||||
- **THEN** система не прерывает приём и переходит к алгоритмическому фолбеку
|
||||
|
||||
### Requirement: Алгоритмический фолбек вывода имени без сети
|
||||
|
||||
При неудаче LLM система SHALL выводить имя алгоритмически, без сетевых
|
||||
запросов: брать первую содержательную строку контекста (без ссылок, команд
|
||||
бота и UI-мусора), отсекать технические характеристики, очищать и обрезать
|
||||
по длине.
|
||||
|
||||
Если и фолбек не дал непустого имени, система SHALL добавить загрузку без
|
||||
параметра `rename`.
|
||||
|
||||
#### Scenario: Фолбек извлекает имя из контекста
|
||||
|
||||
- **WHEN** LLM недоступен или исчерпал попытки, а контекст содержательный
|
||||
- **THEN** система берёт первую содержательную строку контекста, отсекает
|
||||
технические характеристики и использует результат как отображаемое имя
|
||||
- **AND** при этом не делается ни одного сетевого запроса
|
||||
|
||||
#### Scenario: Фолбек тоже пуст
|
||||
|
||||
- **WHEN** ни LLM, ни алгоритмический фолбек не дали непустого имени
|
||||
- **THEN** система добавляет загрузку без параметра `rename`
|
||||
@@ -0,0 +1,56 @@
|
||||
## 1. qBittorrent: проброс имени
|
||||
|
||||
- [ ] 1.1 Добавить поле `Rename string` в `qbt.AddRequest`
|
||||
- [ ] 1.2 В `Client.Add` писать form-field `rename` при непустом `Rename`
|
||||
- [ ] 1.3 Тест: при заданном `Rename` form-data содержит `rename`, при
|
||||
пустом — поля нет
|
||||
|
||||
## 2. Вывод имени: структура и рендер
|
||||
|
||||
- [ ] 2.1 Описать структуру извлечённого имени (type, title,
|
||||
original_title, year, director, season, is_russian)
|
||||
- [ ] 2.2 Реализовать чистую функцию рендера структуры в короткий ярлык:
|
||||
`Title (Director, Year)` с опциональными режиссёром и годом (скобка
|
||||
опускается, если обоих нет), для сериала — суффикс `. Сезон N`;
|
||||
очистка управляющих символов/переводов строк и обрезка по длине
|
||||
- [ ] 2.3 Тесты рендера: фильм с режиссёром+годом / только год / только
|
||||
режиссёр / без обоих, сериал с сезоном/без, обрезка длины, очистка
|
||||
|
||||
## 3. Вывод имени: LLM
|
||||
|
||||
- [ ] 3.1 Узкий промпт извлечения имени (RU-название для российского
|
||||
контента, иначе EN/оригинал) + описание JSON-схемы ответа
|
||||
- [ ] 3.2 Парсинг и валидация ответа (валидный JSON, непустой `title`);
|
||||
бюджет попыток — `[llm].max_retries` (переиспользуем существующий)
|
||||
- [ ] 3.3 Тесты на фикстурах (без сети): успешный разбор, выбор языка
|
||||
названия, исчерпание попыток
|
||||
|
||||
## 4. Вывод имени: алгоритмический фолбек
|
||||
|
||||
- [ ] 4.1 Реализовать фолбек без сети: первая содержательная строка
|
||||
контекста (без ссылок/команд/UI-мусора), отсечение тех.
|
||||
характеристик, очистка и обрезка
|
||||
- [ ] 4.2 Вынести/переиспользовать логику чистки строк (сейчас в
|
||||
`tgbot.cleanContext`), чтобы не дублировать
|
||||
- [ ] 4.3 Тесты фолбека: заголовок релиза, пустой/бесполезный контекст → ""
|
||||
|
||||
## 5. Интеграция в ingest
|
||||
|
||||
- [ ] 5.1 Ввести узкий интерфейс/функцию вывода имени (`DeriveName`),
|
||||
реализованную поверх LLM + фолбек; зависимость опциональна (нет LLM →
|
||||
только фолбек)
|
||||
- [ ] 5.2 В `Ingest()` синхронно выводить имя из `req.Context` (подсказка —
|
||||
`magnet.DisplayName`) перед `qbt.Add`, класть в `AddRequest.Rename`
|
||||
- [ ] 5.3 Graceful-деградация: ошибка/таймаут вывода имени логируется и не
|
||||
прерывает приём (пустое имя → без `rename`). Бюджет попыток и таймаут —
|
||||
общие `[llm].max_retries` / `[llm].timeout`, новых параметров не вводим
|
||||
- [ ] 5.4 Прокинуть зависимость (LLM-провайдер) в сборке сервиса
|
||||
(`cmd/jellybit`)
|
||||
|
||||
## 6. Проверка
|
||||
|
||||
- [ ] 6.1 `task test` и `task lint` зелёные
|
||||
- [ ] 6.2 Ручная проверка на реальном qBittorrent: имя видно в списке;
|
||||
подтвердить поведение `rename` для многофайловой раздачи
|
||||
- [ ] 6.3 Обновить `docs/specs` (architecture/ingest) и отметить todo
|
||||
«Название из контекста» как реализованную при архивации change
|
||||
Reference in New Issue
Block a user