Архивация спеки

This commit is contained in:
av
2026-06-28 12:27:50 +03:00
parent d8ef7063f4
commit 694a9f4bef
7 changed files with 101 additions and 12 deletions
@@ -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,61 @@
## 1. qBittorrent: проброс имени
- [x] 1.1 Добавить поле `Rename string` в `qbt.AddRequest`
- [x] 1.2 В `Client.Add` писать form-field `rename` при непустом `Rename`
- [x] 1.3 Тест: при заданном `Rename` form-data содержит `rename`, при
пустом — поля нет
## 2. Вывод имени: структура и рендер
- [x] 2.1 Описать структуру извлечённого имени (type, title,
original_title, year, director, season, is_russian)
- [x] 2.2 Реализовать чистую функцию рендера структуры в короткий ярлык:
`Title (Director, Year)` с опциональными режиссёром и годом (скобка
опускается, если обоих нет), для сериала — суффикс `. Сезон N`;
очистка управляющих символов/переводов строк и обрезка по длине
- [x] 2.3 Тесты рендера: фильм с режиссёром+годом / только год / только
режиссёр / без обоих, сериал с сезоном/без, обрезка длины, очистка
## 3. Вывод имени: LLM
- [x] 3.1 Узкий промпт извлечения имени (RU-название для российского
контента, иначе EN/оригинал) + описание JSON-схемы ответа
- [x] 3.2 Парсинг и валидация ответа (валидный JSON, непустой `title`);
бюджет попыток — `[llm].max_retries` (переиспользуем существующий)
- [x] 3.3 Тесты на фикстурах (без сети): успешный разбор, выбор языка
названия, исчерпание попыток
## 4. Вывод имени: алгоритмический фолбек
- [x] 4.1 Реализовать фолбек без сети: первая содержательная строка
контекста (без ссылок/команд/UI-мусора), отсечение тех.
характеристик, очистка и обрезка
- [x] 4.2 Фолбек самодостаточен (лёгкая фильтрация шума для сырого
HTTP/CLI-ввода). `tgbot.cleanContext` НЕ рефакторил: он работает на
слое транспорта (чистит UI-мусор бота), фолбек — на слое ядра (берёт
заголовок из уже-контекста); преждевременная общая зависимость связала
бы транспорт с util ядра. Дублирование минимально (две эвристики)
- [x] 4.3 Тесты фолбека: заголовок релиза, пустой/бесполезный контекст → ""
## 5. Интеграция в ingest
- [x] 5.1 Ввести узкий интерфейс `ingest.Namer` (`DeriveName`), реализован
пакетом `internal/naming` поверх LLM + фолбек; зависимость опциональна
(nil-провайдер → только фолбек)
- [x] 5.2 В `Ingest()` синхронно выводить имя из `req.Context` (подсказка —
`magnet.DisplayName`) перед `qbt.Add`, класть в `AddRequest.Rename`
- [x] 5.3 Graceful-деградация: ошибка/таймаут вывода имени логируется и не
прерывает приём (пустое имя → без `rename`). Бюджет попыток и таймаут —
общие `[llm].max_retries` / `[llm].timeout`, новых параметров не вводим
- [x] 5.4 Прокинуть зависимость (LLM-провайдер) в сборке сервиса
(`cmd/jellybit`): провайдер поднимается один раз, переиспользуется
`naming` и `recognize`
## 6. Проверка
- [x] 6.1 `task test` и `task lint` зелёные
- [ ] 6.2 Ручная проверка на реальном qBittorrent: имя видно в списке;
подтвердить поведение `rename` для многофайловой раздачи
- [x] 6.3 При архивации: дельта-спека синкнута в `openspec/specs/ingest`
(новая capability), пункт «Название из контекста» убран из `docs/todo.md`
(реализованное переехало в OpenSpec-спеки)