Рефакторинг границ 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
+77 -94
View File
@@ -2,46 +2,12 @@
## Purpose
Распознавание: сопоставление загрузки с конкретным фильмом/сериалом во
включённых базах метаданных. Capability описывает контракт LLM на названия,
порядок и нормализацию сверки по нескольким названиям, локаль запроса к TMDB
и сбор кандидатов для ручного выбора в review.
Распознавание: разбор недоверенных сигналов раздачи моделью в структурированный
план (тип фильм/сериал, каноническое название и год, файлы → серии). Capability
описывает пред-парс имени, контракт и провайдер LLM со структурированным
выводом, роли файлов на краях и модель уверенности (решение auto/review). Сверка
с внешними базами метаданных — в `metadata-match`.
## Requirements
### Requirement: Сверка с базой по нескольким названиям
При сверке плана с включёнными базами метаданных система SHALL искать по
нескольким названиям в порядке убывания силы ключа: сначала по
`original_title`, затем по локализованному `title`, затем по `provider_hint`.
Поиск SHALL останавливаться, как только очередной запрос дал единичный
сильный матч (ровно один кандидат с совпадением названия и года). Запрос с
названием, нормализованно совпадающим с уже выполненным, система SHALL
пропускать, чтобы не обращаться к базе повторно с тем же ключом.
Кандидаты для ручного выбора в review система SHALL собирать из всех
выполненных заходов с дедупликацией по `provider:id` и общим потолком.
#### Scenario: Иностранный фильм находится по оригинальному названию
- **GIVEN** план с `title` «Тёмный рыцарь», `original_title` «The Dark Knight», год 2008
- **WHEN** выполняется сверка с базой
- **THEN** первый запрос идёт по «The Dark Knight»
- **AND** при единичном сильном матче дальнейшие запросы (по `title`, `provider_hint`) не выполняются
#### Scenario: Фолбэк на локализованное название
- **GIVEN** план, для которого запрос по `original_title` не дал единичного сильного матча
- **WHEN** продолжается сверка
- **THEN** выполняется запрос по локализованному `title`
- **AND** при отсутствии матча и там — запрос по `provider_hint`
#### Scenario: Дублирующий запрос пропускается
- **GIVEN** план, у которого `original_title` нормализованно совпадает с `title`
- **WHEN** выполняется сверка
- **THEN** база запрашивается этим названием один раз, повторный заход по `title` не делается
### Requirement: Контракт LLM на оригинальное и локализованное названия
Промпт распознавания SHALL требовать от модели всегда заполнять и `title`, и
@@ -67,78 +33,95 @@ gracefully использует доступные названия.
- **THEN** разбор успешен без correction-ретрая
- **AND** сверка использует `title``provider_hint`)
### Requirement: Локаль запроса к TMDB
### Requirement: Пред-парс имени релиза
Запрос поиска к TMDB SHALL передавать параметр `language`, по умолчанию
`ru-RU`, со значением, настраиваемым конфигом `[metadata.tmdb].language`.
Это влияет только на локализованное поле `Title`/`Name`; поле
`original_title`/`original_name` остаётся на языке оригинала, поэтому
оригинальная сторона сравнения не затрагивается.
Перед вызовом LLM система SHALL выполнять дешёвый пред-парс имени торрента
(`go-ptn`): извлекать черновые название, год, сезон, серию и качество. Результат
пред-парса SHALL использоваться как вспомогательный сигнал в промпте и как
сторона проверки согласованности при решении auto/review, но НЕ SHALL считаться
итоговым распознаванием.
#### Scenario: Локализованный заголовок приходит по-русски
#### Scenario: Пред-парс даёт черновые поля
- **GIVEN** TMDB включён, `language` не задан в конфиге
- **WHEN** выполняется поиск фильма с русской локализацией
- **THEN** запрос содержит `language=ru-RU`
- **AND** в кандидате `Title` приходит на русском, а `OriginalTitle` — на языке оригинала
- **WHEN** на вход распознавания поступает имя релиза `Fargo.S02.2015.WEB-DL.1080p`
- **THEN** пред-парс возвращает черновые `title`, `year`, `season`, `quality`
- **AND** эти значения передаются в промпт LLM как подсказка
### Requirement: Нормализация названий при сравнении
### Requirement: Разбор сигналов LLM в структурированный план
Нормализация названий для гейта сильного матча SHALL сводить букву `ё` к `е`,
чтобы написания, различающиеся только `ё`/`е`, считались одним названием.
Система SHALL передавать LLM недоверенные сигналы (имя торрента, дерево файлов с
размерами, текстовый контекст и накопленные подсказки, пред-парс) и получать
структурированный план в схеме: `type` (`movie`|`series`), `title`,
`original_title`, `year`, `provider_hint`, `files[]` и `confidence`. Каждый
элемент `files[]` SHALL нести `src`, `role`
(`main`|`episode`|`subtitle`|`extra`|`sample`|`ignore`) и, для сериала,
per-file `season`/`episode` (отдельного скалярного `season` быть SHALL NOT — так
выражаются мультисезонные паки и спецвыпуски). План SHALL приниматься только
если каждый `files[].src` совпадает с реальным файлом торрента.
#### Scenario: «Тёмный» и «Темный» совпадают
#### Scenario: План сериала с per-file нумерацией
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
- **WHEN** сравниваются нормализованные названия
- **THEN** они считаются совпадающими
- **GIVEN** сезон-пак из 10 видеофайлов
- **WHEN** LLM возвращает план
- **THEN** `type` = `series`, а каждый видеофайл несёт свои `season`/`episode`
### Requirement: Кандидат несёт URL для внешней проверки
#### Scenario: Несуществующий src отклоняется
Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести
поле `URL` — ссылку на страницу элемента (фильма/сериала) на сайте
провайдера. URL SHALL формироваться клиентом провайдера при поиске
(`Search`) и сохраняться в таблице `metadata_candidate`. На странице ревью
URL SHALL отображаться кликабельной ссылкой, открывающейся в новой вкладке
браузера.
- **GIVEN** ответ LLM, где `files[].src` не совпадает ни с одним файлом торрента
- **WHEN** план разбирается
- **THEN** такой план не принимается как валидный
Формат URL для каждого провайдера:
### Requirement: Провайдер LLM за абстракцией со структурированным выводом
- **TMDB**: `https://www.themoviedb.org/movie/{id}` (фильм) или
`https://www.themoviedb.org/tv/{id}` (сериал) — тип контента известен из
запроса `Query.Type`
- **TVDB**: `https://www.thetvdb.com/dereferrer/series/{id}`
- **TVMaze**: `https://www.tvmaze.com/shows/{id}` — URL SHALL использовать
нативный id TVMaze, а не внешний тег (TVDB/IMDb), чтобы ссылка вела на
TVMaze-страницу
Доступ к LLM SHALL быть за интерфейсом с выбором реализации по полю `[llm].type`
(первый тип — `openai-compat`). Система SHALL запрашивать JSON-режим
(`response_format: {"type":"json_object"}`), срезать ```-ограждения и
валидировать ответ в Go против схемы плана. При ошибке разбора система SHALL
ретраить до `[llm].max_retries`, передавая модели саму ошибку и схему. Если после
ретраев ответ не разобран, задача SHALL уходить в `review` (НЕ в `failed`) с
причиной «ответ LLM не разобран».
#### Scenario: Кандидат TMDB с корректной ссылкой
#### Scenario: Неразобранный ответ уходит в review
- **GIVEN** TMDB найден кандидат-фильм с id `603` («Матрица»)
- **WHEN** клиент TMDB формирует Candidate
- **THEN** `URL` = `https://www.themoviedb.org/movie/603`
- **GIVEN** LLM, чей ответ не проходит валидацию схемы после всех ретраев
- **WHEN** завершается распознавание
- **THEN** задача переходит в `review` с причиной «ответ LLM не разобран»
- **AND** задача НЕ переходит в `failed`
#### Scenario: Кандидат TVMaze с нативной ссылкой
### Requirement: Модель уверенности и решение auto/review
- **GIVEN** TVMaze найден сериал с id `169` («Фарго»), внешний тег — TVDB id `269613`
- **WHEN** клиент TVMaze формирует Candidate
- **THEN** `URL` = `https://www.tvmaze.com/shows/169`
- **AND** `TagProvider`/`TagID` остаются `tvdb`/`269613` (тег папки Jellyfin не меняется)
Система SHALL раскладывать автоматически (без review) только при выполнении
ВСЕГО: (1) подтверждённый единичный сильный матч в базе (`metadata-match`) с
`provider_id`; (2) структурная валидация без предупреждений (фильм — ровно один
основной видеофайл; сериал — число серий бьётся с базой, нумерация S·E
консистентна); (3) согласованность пред-парса и LLM по типу/названию/году. Иначе
задача SHALL уходить в `review` с явной причиной. Самооценку LLM (`confidence`)
система SHALL учитывать лишь как вспомогательный сигнал, НЕ как единственный гейт.
#### Scenario: Ссылка в интерфейсе ревью
#### Scenario: Нет матча в базе — всегда review
- **GIVEN** загрузка в состоянии `review` с кандидатами, у которых заполнен `url`
- **WHEN** рендерится страница ревью
- **THEN** в таблице кандидатов каждый кандидат SHALL отображаться со
ссылкой на внешний сайт
- **AND** ссылка открывается в новой вкладке (`target="_blank"`)
- **AND** текстом ссылки служит провайдер или сокращённый url
- **GIVEN** план без подтверждённого матча в базе (база выключена или матча нет)
- **WHEN** принимается решение auto/review
- **THEN** задача уходит в `review`, авто-раскладка не делается
#### Scenario: URL сохраняется в БД
#### Scenario: Матч и чистая валидация — авто
- **GIVEN** результат поиска с кандидатами
- **WHEN** кандидаты сохраняются в таблицу `metadata_candidate`
- **THEN** значение `url` SHALL быть записано в колонку `url`
- **AND** при последующей загрузке данных ревью url доступен без повторной
генерации
- **GIVEN** подтверждённый единичный матч, чистая структурная валидация и
согласованность сигналов
- **WHEN** принимается решение
- **THEN** допускается авто-раскладка (при отсутствии `force_review`)
### Requirement: Роли файлов на краях раздачи
Система SHALL относить семплы, «экстра» и мусор к роли `ignore` (эвристики размер/
имя + LLM), а внешние субтитры (`.srt`, `.ass`, пары VobSub `.idx`+`.sub`) —
привязывать к соответствующему видео. Любую неоднозначность нумерации (дыры,
дубли, спорные спецвыпуски) система SHALL эскалировать в `review`, а не разрешать
молча.
#### Scenario: Семпл помечается ignore
- **GIVEN** раздача с файлом `sample.mkv` малого размера
- **WHEN** строится план
- **THEN** этот файл получает роль `ignore` и в раскладку не попадает