# Распознавание контента

> **Источник истины переехал в OpenSpec.** Актуальные требования —
> `openspec/specs/recognition/` (разбор сигналов LLM) и
> `openspec/specs/metadata-match/` (сверка с внешними базами). Этот файл остаётся
> справочным нарративом; при расхождении верна спека OpenSpec.

## Задача

По доступным сигналам определить: фильм или сериал; каноническое название
и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых
базах — провайдер и его id. На выходе — план раскладки, оценка уверенности
и решение «авто или review» (как оно встраивается в машину состояний —
[workflow.md](workflow.md), состояния `recognizing`/`linking`/`review`).

## Сигналы

- Имя торрента и структура каталогов.
- Список файлов с размерами и расширениями. Абсолютный путь источника
  восстанавливаем как `save_path` из qBit (= хост-путь; `path_map` обычно
  тождественен) + относительное имя файла из `/torrents/files`. Имя уже
  включает корневую папку для многофайловых торрентов, поэтому префикс —
  именно `save_path`, а не `content_path` (последний удвоил бы корневую
  папку и сломал бы однофайловые раздачи).
- Текстовый контекст человека (+ накопленные подсказки из review).
- Распарсенное сообщение торрент-бота (если через Telegram): название с
  годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md).

**Все сигналы недоверенные** — имя торрента, сообщение бота и контекст
управляются извне и могут содержать инъекции. Выход LLM не отвечает за
безопасность: целевой путь всё равно санитизируется и проверяется на
выход за пределы библиотеки (см. architecture.md → «Раскладка файлов»).

## Конвейер

1. **Пред-парс** имени релиза (`go-ptn`): черновые название/год/сезон/
   серия и качество. Грубо, но бесплатно.
2. **LLM** (через провайдер-абстракцию, см. ниже): получает сигналы и
   пред-парс, возвращает структурированный план в нашей схеме. Хорошо
   берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под
   контекст модели.
3. **Сверка с базой** (если включена TMDB/TVDB/TVMaze): ищем по
   названию+году, берём официальный id и каноническое имя, собираем
   кандидатов. TVMaze — без ключа, только сериалы; внешний id
   (TVDB/IMDb) из `externals` идёт в имя папки.
   - **Поиск по нескольким названиям** в порядке убывания силы ключа:
     `original_title` → локализованное `title` → `provider_hint`. Базы
     индексированы прежде всего по оригинальным названиям, поэтому
     оригинал — первым; останавливаемся, как только очередной ключ дал
     единичный сильный матч. Пустые и нормализованно-дублирующие ключи
     пропускаем (русский фильм, где оригинал = локализованное, дёргает
     базу один раз). Кандидатов для review копим из всех заходов.
   - **Локаль TMDB:** запрос передаёт `language` (по умолчанию `ru-RU`,
     настраивается `[metadata.tmdb].language`). Влияет только на
     локализованный `Title`/`Name`; `original_title`/`original_name`
     остаётся на языке оригинала, поэтому оригинальная сторона сравнения
     не страдает, а русская — сходится.
   - **Нормализация названий** при сравнении сводит `ё`→`е` («Тёмный» и
     «Темный» — одно название).
4. **Оценка уверенности** и решение: авто или review.

## Структура ответа LLM (предварительная)

```
type            movie | series
title           каноническое название
original_title  оригинальное (обычно англ.) название — заполняется всегда:
                нет отдельного / российский контент → дублирует title;
                при неуверенности дублируем, а не выдумываем
year            год
provider_hint   строка для поиска в базе (НЕ итоговый id)
files[]         { src, role: main|episode|subtitle|extra|sample|ignore,
                  season?, episode? }      # season/episode — на файл
confidence      0..1 — самооценка модели (вспомогательный сигнал)
notes           пояснения, неоднозначности
```

Сезон/серия — **на файле**: так выражаются мультисезонные паки,
спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет.
`provider_hint` — только подсказка для поиска; итоговые `provider`
(`tmdb|tvdb|tvmaze|none`) и `provider_id` появляются после сверки с базой
и хранятся отдельно.

## Провайдер LLM

Доступ к LLM — за интерфейсом; реализация выбирается полем `[llm].type`
(дискриминатор). Это позволяет подключать локальные модели и сторонние
(в т.ч. китайские) эндпоинты — ради экономии и независимости от вендора.

- Первый и пока единственный тип — **`openai-compat`**: OpenAI-совместимый
  Chat Completions API (`base_url` + `api_key` + `model`). Подходят
  локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые
  провайдеры (DeepSeek, Qwen и др.).
- **Структурированный вывод надёжно:** просим JSON-режим
  (`response_format: {"type":"json_object"}`) — это поддерживают и мелкие
  локальные модели, в отличие от строгих JSON Schema. На приёме срезаем
  ```-ограждения и извлекаем JSON, **валидируем в Go** против нашей схемы;
  при ошибке разбора ретраим, передавая модели саму ошибку и схему в
  промпте, до `llm.max_retries`. Если так и не распарсилось — уходим в
  **review** (не в `failed`) с причиной «ответ LLM не разобран».
- Новые типы (напр. нативный `anthropic`) добавляются, не трогая
  `recognize`.

## Модель уверенности

Почему авто только при матче в базе, а не по самооценке LLM —
[ADR-2026-06-13-auto-link-requires-db-match](../adr/ADR-2026-06-13-auto-link-requires-db-match.md).

Авто-раскладка — только если выполнено **всё**:

1. **Подтверждённый матч в базе** — единственный сильный результат
   TMDB/TVDB/TVMaze по названию+году, давший `provider_id`. **Нет матча (или
   база выключена) → всегда review.** Это и закрывает основной кейс
   (рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал».
2. **Структурная валидация** без предупреждений:
   - фильм: ровно один основной видеофайл (семплы/экстра/ignore отброшены);
   - сериал: число серий бьётся с базой, нумерация S·E консистентна, без
     пропусков, дублей и неоднозначных спецвыпусков.
3. **Согласованность сигналов** — пред-парс (`go-ptn`) и LLM не
   противоречат по типу/названию/году.

Самооценку LLM (`confidence`) учитываем как вспомогательный сигнал, но
**не как единственный гейт**: она плохо откалибрована и поддаётся
инъекции. Решают матч в базе и валидация.

Иначе — **review** ([review-ux.md](review-ux.md)) с явной причиной.

## Что делаем с краёв

- Семплы/«экстра»/мусор → роль `ignore` (эвристики размер/имя + LLM).
- Внешние субтитры (`.srt`, `.ass`, пары VobSub `.idx`+`.sub`) привязываем
  к видео и именуем по Jellyfin (`*.ru.srt`).
- Сезон-паки разбираем по сериям; смешанные паки, спецвыпуски (`Season
  00`), двойные серии (`SxxEyy-Eyy`) — через per-file season/episode;
  любая неоднозначность → review.
- Аниме с абсолютной нумерацией — отдельный крайний случай,
  [задача в беклоге](../backlog/anime-absolyutnaya-numeraciya.md).

## На будущее

`go-ptn` слабее питоновского `guessit`. Если точности пред-парса не
хватит — завернуть `guessit` лёгким сервисом-спутником (один файл рядом с
бинарём). Задача [«guessit как сервис-спутник»](../backlog/guessit-sputnik.md)
в беклоге.
