Рефакторинг границ 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>
This commit is contained in:
av
2026-07-03 21:17:51 +03:00
co-authored by Claude Opus 4.8
parent b3d7c08f4a
commit 512567c8ba
29 changed files with 1866 additions and 315 deletions
@@ -0,0 +1,202 @@
## Context
Набор capabilities в `openspec/specs/` сложился по ходу пилотной миграции и не
отражает цепочку обработки загрузки. Три проблемы:
1. `recognition` смешивает разбор LLM и работу с метабазами.
2. Поведение ревью размазано: часть — в `web-ui`, основная часть — в не
перенесённом `docs/specs/review-ux.md`.
3. Звенья цепочки `file-layout`, `download-tracking`, `notifications` в OpenSpec
отсутствуют — источник истины по ним в `docs/specs/` (`jellyfin-layout.md`,
`workflow.md`, `architecture.md`).
Разбор цепочки «загрузка → распознавание → матч → ревью → раскладка» (действие →
артефакт в БД) дал естественный набор доменов; приводим capabilities к нему.
Ограничение: **рефакторинг только спек, код не трогаем.** Формулировки переносим
эквивалентными — ни одно нормативное требование не должно измениться по смыслу.
## Goals / Non-Goals
**Goals:**
- Одно поведение — один capability; имя читается без знания кода.
- Полная цепочка представлена в `openspec/specs/` (перенос из `docs/specs/`).
- Источник истины по мигрируемым темам переезжает в OpenSpec; в `docs/specs/`
остаётся пометка о переезде (как пилот `ingest`).
**Non-Goals:**
- Никаких изменений поведения, схемы БД, кода, конфигурации.
- Не вводим новые сущности домена (напр. «тайтл» — отдельная задача беклога).
- Не дробим метабазу на search/match (решено: одна `metadata-match`).
- Мульти-бот маршрутизация уведомлений — отдельная задача, здесь только фиксируем
текущее поведение `notifier`.
## Decisions
### D1. Механика переноса требований между capabilities
OpenSpec 1.4.x поддерживает `RENAMED` только внутри одной спеки. Межспековый
перенос оформляем парой: **`REMOVED Requirement`** в исходной спеке (с
`**Reason**`/`**Migration**`, указывающими целевую capability) + **`ADDED
Requirement`** в целевой. Текст требования в ADDED — эквивалент исходного
(перенос, не переписывание). Альтернатива (просто RENAMED) не подходит:
capability меняется, а не имя внутри одной спеки.
### D2. `recognition` → `recognition` + `metadata-match`
Разные действия: «разобрать сигналы моделью» и «найти/подтвердить запись в базе».
`Контракт LLM на оригинальное и локализованное названия` **остаётся в
recognition** (это требование к выходу модели), хотя используют эти поля обе:
recognition — чтобы модель их заполнила, metadata-match — как ключи поиска.
| Текущее требование (recognition) | Назначение |
|---|---|
| Сверка с базой по нескольким названиям | → `metadata-match` |
| Контракт LLM на оригинальное и локализованное названия | остаётся `recognition` |
| Локаль запроса к TMDB | → `metadata-match` |
| Нормализация названий при сравнении | → `metadata-match` |
| Кандидат несёт URL для внешней проверки | → `metadata-match` |
Backfill `recognition` из `docs/specs/recognition.md` (ADDED — сейчас в OpenSpec
этих требований нет):
- Пред-парс имени релиза (`go-ptn`).
- Разбор сигналов LLM в структурированный план (схема: `type`, `title`,
`original_title`, `year`, `provider_hint`, `files[]` с per-file `season`/
`episode`, `confidence`).
- Провайдер LLM за абстракцией (`openai-compat`, JSON-mode, ретраи с передачей
ошибки/схемы; неразобранный после ретраев → review, не failed).
- Модель уверенности и решение auto/review (авто только при подтверждённом матче
в базе + структурная валидация + согласованность с пред-парсом).
- Роли файлов на краях (sample/extra/ignore, привязка внешних субтитров).
ADDED в `metadata-match` (сам поиск/подтверждение, backfill из `recognition.md`
§3, помимо 4 перенесённых):
- Поиск записи в TMDB/TVDB/TVMaze, сбор кандидатов с дедупом `provider:id`.
- Подтверждение единичного сильного матча → `provider`/`provider_id`/канон. имя/год.
- Опциональность баз: нет баз или нет матча → авто-раскладки нет (граница с
recognition-решением; здесь — что матч не подтверждён).
### D3. `review` как отдельный capability
Переносим из `web-ui`:
| Текущее требование (web-ui) | Назначение |
|---|---|
| Единый список источников совпадения на ревью | → `review` |
| Ручное добавление источника по id или URL | → `review` |
| Предпросмотр полей источника до фиксации выбора | → `review` |
`Матч с записью метабазы ссылкой` **остаётся в web-ui** — это отображение на
странице загрузки, а не действие ревью.
ADDED в `review` (миграция `docs/specs/review-ux.md`):
- Триггеры входа в review с явной причиной.
- Команды: Применить, Уточнить (подсказка+перераспознавание), Распознать заново,
Тип, Игнор файла, Позже, Отклонить, Undo, Привязать заново.
- Подсказка (мягкая, интерпретирует LLM) vs override (жёсткий пин;
перераспознавание не затирает).
- Единый список источников (нейронка наравне с кандидатами) + ручное добавление
+ предпросмотр «превью = применение» (перенесены из web-ui, п. выше).
- Разделение труда транспортов (веб — точные правки, Telegram — быстрые действия/
эскалация в веб); одно состояние ревью, команды сериализует worker.
### D4. Миграция `file-layout`, `download-tracking`, `notifications` из docs/specs
**`file-layout`** ← `docs/specs/jellyfin-layout.md`:
- Целевые имена фильмов (`Название (Год) [providerid-…]`).
- Целевые имена сериалов (папка с provider-id, `Season xx`, `SxxEyy`).
- Сопоставление источник→цель хардлинками (`save_path`+относит. имя; mkdir 0755).
- Санитизация целевого имени и запрет выхода за библиотеку.
- Never-overwrite (тот же inode → готово; другой файл → коллизия → review).
- Copy-fallback при невозможности хардлинка.
**Владение целевым путём** (`superseded`) и **безопасный undo** (`nlink<=1`
`ErrLastCopy`) в `file-layout` НЕ дублируем: их доминирующая забота — сверка с
реальностью (присутствие цели определяется по владению; отказ Undo при пропавшем
источнике), и они уже живут более полными версиями в `state-reconciliation`.
`file-layout` описывает акт прямой раскладки; на владение/undo он опирается,
оставляя их дом в `state-reconciliation` (см. ревью дизайна: устранение дубля).
**`download-tracking`** ← `docs/specs/workflow.md` (прямой путь; сверка
разложенного остаётся в `state-reconciliation`):
- Поллинг qBittorrent и сопоставление его состояний с нашими.
- Готовность только когда файлы на месте (не `moving`/`checking*`).
- Таймауты-предохранители: `metaDL` > `magnet_timeout` → failed; `stalledDL` >
`stuck_after` → stuck; базис возраста — `added_on` (переживает retry/усыновление).
- Ошибка qBit (`error`/`missingFiles`) → failed (`qbit_error`).
- Усыновление раздач по категории **или** тегу, которых нет в БД → `downloading`.
- Владелец переходов — worker под per-download блокировкой (FSM).
**`notifications`** ← `workflow.md` + `state-reconciliation`:
- Уведомление автора о падении (`failed`/`stuck`), включая приёмный `qbit_add`
мимо поллинга.
- Дебаунс повторных падений одной задачи (мерцающий stalled не спамит).
- Пинг о входе в review и о готовности.
- Уведомление о рассинхроне (перенос из `state-reconciliation`).
| Текущее требование (state-reconciliation) | Назначение |
|---|---|
| Уведомление о рассинхроне | → `notifications` |
| (остальные 11) | остаются `state-reconciliation` |
### D5. `identity` → инфраструктура id; приём → `ingest`
| Текущее требование (identity) | Назначение |
|---|---|
| ULID как первичный ключ сущностей | остаётся `identity` |
| Нормализация и валидация id на входных границах | остаётся `identity` |
| Множество инфохэшей загрузки | → `ingest` |
| Дедупликация приёма по любому из хешей | → `ingest` |
| Атомарность возврата загрузки в активное состояние | → `ingest` |
| Корреляция сущностей в логах | остаётся `identity` |
| Миграция существующих записей | остаётся `identity` |
Примечание: `Атомарность возврата в активное состояние` используется и «Привязать
заново» (review) — но это инвариант приёма/активации загрузки, поэтому его дом —
`ingest`; review на него ссылается.
### D6. Формулировки — эквивалентный перенос
При ADDED в целевой capability текст берём из исходного требования (или из
`docs/specs/` при backfill), сохраняя нормативную силу (SHALL/MUST) и сценарии.
Правки — только связочные (ссылки на соседние capability), не смысловые. Это
делает `openspec archive` безопасным: живые спеки после влития эквивалентны сумме
прежних.
## Risks / Trade-offs
- **Большой diff, риск потерять требование при переносе** → Перенос по таблицам
D2–D5 (чек-лист: каждое исходное требование учтено — либо STAY, либо REMOVED+
ADDED). `openspec validate --strict` + сверка счётчика требований до/после.
- **Расхождение docs/specs ↔ openspec после переезда** → В `docs/specs/`
мигрированных файлов ставим шапку «источник истины переехал в
`openspec/specs/<cap>`»; содержимое не дублируем.
- **Граница recognition ↔ metadata-match может «поплыть» на будущих задачах**
(сила совпадения кандидата — идея беклога) → Сейчас фиксируем по действию;
дальнейшее уточнение — отдельным change.
- **Пробел: у `ingest` нет формального требования на сам приём** (парс magnet →
завести download → отдать в qBittorrent) — сейчас поведение не выражено
требованием ни в одной спеке → см. Open Questions.
## Migration Plan
1. Дельты change: ADDED в новых/целевых спеках, REMOVED в исходных (D1).
2. `openspec validate --strict refactor-capability-boundaries`.
3. Ревью дизайна (этот документ) — **чекпоинт до написания всех дельт**.
4. После апрува — генерация дельта-спек, повторная валидация.
5. Пометки о переезде в `docs/specs/` мигрированных файлов; снять пункт беклога.
6. `openspec archive` — влить дельты в `openspec/specs/`.
Откат: change не тронул код; отмена = удалить директорию change (спеки не влиты до
archive).
## Resolved Questions
1. **Ядро приёма в `ingest` — ДА.** Добавляем ADDED «Приём источника и заведение
загрузки» (backfill из `architecture.md` → «Транспорты»): парс magnet → дедуп →
завести `download`+`download_infohash` → отдать в qBittorrent; ошибка добавления
`failed` (`qbit_add`). Иначе `ingest` остаётся про имя+инфохэши без ядра.
2. **«Секция раздачи на странице загрузки» — остаётся в `live-status`** (меняется
вместе с телеметрией).
3. **Один change** (решение автора). При необходимости порядок дельт разложим на
этапе apply.