Files
jellybit/openspec/specs/ingest/spec.md
T
avandClaude Opus 4.8 512567c8ba Рефакторинг границ capabilities: цепочка загрузка→матч→ревью→раскладка (openspec)
Привёл набор capabilities в OpenSpec к цепочке обработки, чтобы имя capability
отвечало одному поведению. Чисто по спекам, код и поведение системы не меняются.

Change refactor-capability-boundaries (архивирован):
- recognition разделён на recognition (разбор LLM) + metadata-match (сверка с базами)
- review выделен из web-ui + мигрирован из docs/specs/review-ux.md
- новые capability из docs/specs: file-layout, download-tracking, notifications
- identity очищен до инфра-id; приём (инфохэши, дедуп, ядро приёма) — в ingest
- уведомление о рассинхроне перенесено из state-reconciliation в notifications
- дубль владения путём и безопасного undo оставлен в state-reconciliation

Итог: 11 capabilities, openspec validate --strict проходит (+37/−11 требований).
Источник истины по мигрированным темам переехал в openspec/specs (шапки в docs).
Снят пункт беклога «Пересмотр набора capabilities».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:17:51 +03:00

15 KiB
Raw Blame History

ingest Specification

Purpose

Приём загрузки — единый use-case для всех транспортов (HTTP, Telegram, CLI): парс источника (Ф1 — magnet), извлечение инфохэшей (download_infohash), дедупликация по активной задаче, атомарное заведение download и отдача источника в qBittorrent, а также вывод человекочитаемого отображаемого имени из контекста (через LLM или алгоритмический фолбек). Держит инвариант «не более одной активной загрузки на infohash» (атомарный возврат в активное состояние).

Requirements

Requirement: Отображаемое имя торрента из контекста

При добавлении загрузки в qBittorrent система SHALL выводить из контекста загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent (параметр rename API /torrents/add), чтобы задача в списке qBit не показывалась безликим dn magnet-ссылки. Это же имя система SHALL сохранять у загрузки (download.display_name) для последующего показа заголовком в веб-UI.

Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и год; для сериала — номер сезона, если он определён), а не куском сырого контекста. Имя SHALL очищаться от управляющих символов и переводов строк и SHALL обрезаться по ограничению длины.

Вывод имени SHALL выполняться синхронно перед отдачей источника в qBittorrent (параметр rename действует только в момент добавления).

Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание или раскладку — реальные пути система по-прежнему читает из qBit API.

Scenario: Имя из контекста передаётся в qBittorrent

  • WHEN загрузку добавляют с непустым контекстом, из которого удалось получить имя
  • THEN система передаёт это имя в qBittorrent в параметре rename
  • AND имя — короткий читаемый ярлык вида «название (режиссёр, год)», где режиссёр и год опциональны

Scenario: Имя сохраняется у загрузки

  • WHEN при приёме получено непустое отображаемое имя
  • THEN система сохраняет его в download.display_name вместе с созданием загрузки
  • AND веб-UI использует его заголовком карточки и страницы загрузки

Scenario: Контекст пуст или имя не получено

  • WHEN контекста нет либо ни один способ вывода не дал непустого имени
  • THEN система добавляет загрузку без параметра rename
  • AND qBittorrent оставляет собственное имя (из dn/торрента)
  • AND download.display_name остаётся пустым, а веб-UI берёт заголовок из фолбека (распознанное название или усечённый источник)

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

Requirement: Приём источника и заведение загрузки

Приём SHALL быть единым use-case, общим для всех транспортов (HTTP, Telegram, CLI): по источнику (Ф1 — magnet) и текстовому контексту система SHALL извлечь инфохэши, дедуплицировать по активной задаче, при отсутствии дубля завести загрузку (download в состоянии downloading + записи download_infohash) и отдать источник в qBittorrent (категория qbittorrent.category, savepath). Если добавление в qBittorrent не удалось, система SHALL перевести уже заведённую загрузку в failed (error_code qbit_add) и уведомить автора. Заведение загрузки и запись её хешей SHALL выполняться атомарно (см. «Атомарность возврата загрузки в активное состояние»).

Scenario: Успешный приём magnet

  • GIVEN валидная magnet-ссылка и контекст
  • WHEN вызывается приём
  • THEN создаётся download в downloading с записями download_infohash
  • AND источник отдан в qBittorrent с нашей категорией

Scenario: Падение добавления в qBittorrent

  • GIVEN заведённую загрузку не удалось добавить в qBittorrent
  • WHEN обрабатывается ошибка добавления
  • THEN загрузка переходит в failed с error_code qbit_add
  • AND автор загрузки уведомляется

Requirement: Множество инфохэшей загрузки

Загрузка SHALL иметь одну или более записей инфохэша (download_infohash: infohash lowercase hex, kindv1|v2). При приёме magnet-ссылки SHALL записываться ВСЕ известные из неё хеши — гибридный magnet несёт и btih (v1), и btmh (v2); kind определяется по длине hex (40 — v1, 64 — v2). Когда qBittorrent сообщает для раздачи оба хеша (infohash_v1, infohash_v2), система SHALL дописывать недостающие записи загрузке; усечённый хеш v2-only раздачи (поле hash qBittorrent, 40 hex от v2) записываться SHALL NOT. Сопоставление раздачи qBittorrent с загрузкой (поллинг, discover) SHALL выполняться по любому из известных хешей. Один и тот же infohash MAY принадлежать нескольким загрузкам во времени (повторный приём после терминального состояния), но активной из них MUST быть не более одной.

Scenario: Гибридный торрент раскрывает оба хеша

  • GIVEN загрузка принята по magnet с v1-хешем
  • WHEN qBittorrent отдаёт раздачу с заполненными infohash_v1 и infohash_v2
  • THEN у загрузки появляются обе записи (kind = v1 и v2)

Scenario: Сопоставление по v2-хешу

  • GIVEN загрузка с записями v1- и v2-хешей
  • WHEN поллинг находит раздачу, совпавшую только по v2-хешу
  • THEN раздача сопоставляется с этой загрузкой

Requirement: Дедупликация приёма по любому из хешей

При приёме система SHALL искать активную (нетерминальную) загрузку по любому из известных хешей и, найдя, SHALL возвращать её вместо создания новой. Проверка активности и вставка новой загрузки с её хешами SHALL выполняться атомарно (в одной write-транзакции), поддерживая инвариант «не более одной активной загрузки на infohash». Отдельного снимаемого/ восстанавливаемого ключа идемпотентности в схеме быть SHALL NOT — активность выводится только из state.

Scenario: Повторный приём при активной загрузке

  • GIVEN активная загрузка с infohash h
  • WHEN принимается magnet с тем же h
  • THEN новая загрузка не создаётся, возвращается существующая

Scenario: Повторный приём после завершения

  • GIVEN загрузка с infohash h в терминальном состоянии (done)
  • WHEN принимается magnet с тем же h
  • THEN создаётся новая загрузка со своим ULID и записью h

Requirement: Атомарность возврата загрузки в активное состояние

Система SHALL атомарно (в одной write-транзакции) проверять на каждом пути, возвращающем загрузку из терминального состояния в активное (ручной retry, воскрешение фоновой сверкой, повторная раскладка/relink) или создающем её (приём, adopt чужой раздачи), что никакая другая активная загрузка не владеет любым из хешей этой, и при владении SHALL отказывать в переходе, сохраняя инвариант «не более одной активной загрузки на infohash». Отказ SHALL происходить до побочных эффектов во внешних системах (повторного добавления торрента в qBittorrent).

Та же проверка SHALL применяться к дозаписи хешей загрузке (раскрытие гибридного торрента): хеш, которым владеет другая активная загрузка, дописан быть SHALL NOT. Прямой перевод терминальной загрузки в активное состояние в обход этой проверки SHALL отклоняться хранилищем (механический бэкстоп вместо удалённого unique-индекса).

Scenario: Retry при занятом хеше

  • GIVEN загрузка #1 в failed с хешем h, и другая активная загрузка #2 с тем же h
  • WHEN пользователь вызывает retry для #1
  • THEN переход отклоняется с пояснением, #1 остаётся в failed
  • AND активной по h остаётся #2