Files
avandClaude Opus 4.8 0c9421f4c1 Имя: восстановление display_name после распознавания + гард пустого входа
Голый magnet без dn/контекста заставлял namer звать LLM на пустом входе,
и модель галлюцинировала мусорное имя («Unknown»), которое писалось и в
display_name, и в rename qBittorrent, а заодно ломало UI-фолбэк на
распознанное название. Верное каноническое имя, вычисляемое позже при
распознавании, никуда не переливалось.

- naming: гард пустого входа в DeriveName (нет контекста и подсказки → ""
  без вызова LLM) + детерминированный форматтер FormatTitleYear.
- qbt: операция RenameTorrent (переименование существующей раздачи).
- store: SetDisplayName — обновление имени постфактум без гарда состояния.
- worker: refreshDisplayNameLocked/RefreshDisplayName — перелив канонического
  имени (эффективный план) в display_name + best-effort rename раздачи по
  реальному t.Hash; авто-триггер при подтверждении матча (choose/manual add).
- web-ui: кнопка «Обновить имя» на странице загрузки (htmx-своп заголовка,
  деградация без JS), видимая при наличии распознавания (вкл. done/orphaned).

Спека: дельты ingest/review/web-ui влиты в openspec/specs; change
refresh-display-name заархивирован.

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

183 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
Отображаемое имя (`download.display_name`) выводится **один раз** — на шаге
добавления пойманной загрузки в qBittorrent (`worker.processCatched`
`namer.DeriveName``PromoteCatched`). Источник имени на этом шаге — только
пользовательский контекст и подсказка из полей источника (`dn` magnet / имя
`.torrent`). Дерева файлов ещё нет (magnet не разрезолвлен), поэтому для голого
magnet без контекста выводить имя не из чего.
Наблюдавшийся дефект (`download_id=01kx5sk1q2vdm7xgyznrz5kheg`): при пустом входе
`DeriveName` всё равно вызывает LLM, и модель возвращает мусорное непустое имя
(«Unknown»). Оно пишется в `display_name` и в `rename` qBittorrent и блокирует
UI-фолбэк `downloadTitle` (тот отдаёт непустой `display_name` вместо
распознанного названия). Между тем к моменту ревью каноническое имя **уже
вычислено и сохранено**: `recognition.title`/`year`, а при выборе кандидата —
пины `title`/`year` через `SetOverride` (`chooseCandidateLocked`) и
`metadata_candidate.title`/`year`. Ничто не переливает его обратно.
Ограничения: инвариант «источник неприкосновенен», отображаемое имя —
исключительно косметика (не влияет на пути/распознавание/раскладку); переименовать
можно только свою раздачу (адресация по infohash-владению).
## Goals / Non-Goals
**Goals:**
- Убрать корень «Unknown»: не выводить имя из пустого входа (не звать LLM, когда
нет ни контекста, ни подсказки).
- Дать способ обновить `display_name` и имя раздачи в qBittorrent на уже
вычисленное каноническое имя — авто (при подтверждённом матче) и вручную
(кнопка), **без нового вызова LLM**.
- Добавить операцию переименования существующей раздачи в qBittorrent.
**Non-Goals:**
- Переизобретать вывод имени: новый LLM-вызов по контексту не делаем (контекст
тот же скудный, что дал «Unknown»).
- Локализация/выбор языка названия — берём каноническое как есть (то же, что
использует раскладка).
- Суффикс сезона в имени сериала — распознавание не несёт скалярного `season`
(сезоны — per-file); ограничиваемся `Title (Year)`.
- Изменения схемы БД — используем существующий `download.display_name`.
## Decisions
### D1. Источник имени — эффективное распознанное название, не новый LLM
Обновлённое имя берём из **эффективного** распознанного названия: пины
`title`/`year` (если матч подтверждён выбором кандидата/ручным пином) поверх
`recognition.title`/`year`. Это ровно то, что использует раскладка, — значит имя
согласовано с тем, куда лягут файлы.
- _Альтернатива A: заново звать `namer.DeriveName` по контексту._ Отклонено:
контекст тот же скудный, что уже дал «Unknown»; распознавание видело дерево
файлов и точнее.
- _Альтернатива B: `rec_title` как есть._ `recognition.title` без года —
беднее; формат `Title (Year)` информативнее и совпадает с ярлыком add-шага.
### D2. Детерминированный формат имени
Формат — `Title (Year)` (год опционален; при отсутствии — просто `Title`),
очистка от управляющих символов и обрезка по длине переиспользуют существующую
логику `naming` (`sanitize`/`truncate`). Без сети, без LLM.
Форматтер `Title (Year)` живёт в пакете `naming` (там же, где `sanitize`/
`truncate` — их придётся экспортировать) и вызывается и авто-триггером, и ручным
эндпоинтом. Он **не** унифицируется с add-шаговым `render()`: тот даёт иной формат
`Title (Director, Year)` + `. Сезон N`; общими остаются только `sanitize`/
`truncate`, но не сам форматтер (у refresh нет режиссёра/скалярного сезона).
### D3. Переименование раздачи в qBittorrent — новая best-effort операция
Добавляем `qbt.Client.RenameTorrent(ctx, hash, name)``POST
/api/v2/torrents/rename` (form `hash`, `name`).
Адресация: qBittorrent индексирует раздачу собственным `hash`, который для
гибридных/v2-only раздач может не совпадать с нашим `PrimaryInfohash()` (первый из
сохранённых v1/v2). Поэтому helper резолвит раздачу через существующий
`torrentByInfohash` (`review.go`) по любому из наших хешей и передаёт в
`RenameTorrent` настоящий `t.Hash`. Побочно это бесплатно закрывает случай
«раздача удалена» — резолв не находит `t`, rename не вызывается (best-effort no-op).
Переименование в qBittorrent — **best-effort**: сбой (раздача уже удалена, qBit
недоступен) НЕ проваливает обновление имени. `display_name` в нашей БД
обновляется в любом случае; ошибку qBit логируем `WARN` (внешний вызов —
`ext.service=qbittorrent`). Обоснование: имя косметическое, недоступность qBit не
должна блокировать команду ревью или кнопку.
### D4. Точки входа: авто при подтверждённом матче + ручная кнопка
Помощник расщеплён по контракту блокировки (конвенция суффикса `*Locked` в
worker): вся логика — в `refreshDisplayNameLocked(ctx, id)`, **вызывается под
`w.mu`**; публичная обёртка `RefreshDisplayName(ctx, id)` берёт `w.mu` сама.
`refreshDisplayNameLocked`:
1. читает загрузку и текущее распознавание с учётом пинов;
2. формирует имя (D2); пустое имя → ничего не делаем (no-op);
3. пишет `display_name` (новый store-метод, D5);
4. резолвит раздачу через `torrentByInfohash` и best-effort переименовывает её в
qBittorrent (D3).
- **Авто:** `chooseCandidateLocked` (уже под `w.mu`) вызывает
`refreshDisplayNameLocked` **после** успешного `SetCandidateChosen`.
`chooseCandidateLocked` — общий путь и для выбора кандидата из списка
(`ChooseCandidate`), и для ручного добавления источника
(`AddManualSource`); оба — подтверждение матча, оба получают авто-refresh
(согласуется с инвариантом «авто-действие только при подтверждённом матче»).
Наблюдавшийся кейс (`matched=false` → ручной выбор кандидата) закрывается
именно этим.
- **Ручной:** HTTP-эндпоинт `POST /ui/downloads/{id}/refresh-name` вызывает
обёртку `RefreshDisplayName` (берёт `w.mu`) и возвращает htmx-партиал
заголовка. Доступен, когда у загрузки есть распознавание (в т.ч. `done`/
`orphaned`, а не только reviewable-состояния — см. D8).
Замечание по блокировке: авто-путь делает сетевой резолв `Torrents`/`RenameTorrent`
под `w.mu` (как уже делают `Apply`/`Delete`), т.е. на время rename команды ревью
заблокированы. Приемлемо: rename — один короткий вызов; альтернатива (вынести из-под
mu) усложнила бы ре-валидацию state и не стоит того для косметики.
### D5. Store-метод обновления имени пост-фактум
Текущий `PromoteCatched` пишет `display_name` только на переходе
`catched→downloading`. Нужен отдельный метод `SetDisplayName(ctx, id, name)`
(UPDATE `display_name`, `updated_at`), не завязанный на состояние: обновление
имени валидно в `review`/`done` и не двигает FSM.
### D6. Политика перезаписи
- **Ручная кнопка:** всегда перезаписывает (явное действие пользователя).
- **Авто при подтверждённом матче:** перезаписывает, когда выведенное имя
непусто. Подтверждённый матч авторитетнее add-догадки; отдельного
пользовательского редактирования `display_name` в системе нет, затирать нечего.
### D7. Гард пустого входа в naming (корень «Unknown»)
`DeriveName`: если `strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint)
== ""` — сразу вернуть `""`, не вызывая ни LLM, ни фолбек. Приём добавляет
загрузку без `rename`, `display_name` пуст, UI берёт заголовок из фолбека
(распознанное имя или усечённый источник). Позже refresh донесёт каноническое имя.
### D8. Видимость кнопки «Обновить имя» — по наличию распознавания
Кнопка гейтится **наличием распознавания** (`rd.Recognition != nil`/наличие плана),
а НЕ полем `Reviewable`. Иначе на `done`/`orphaned` (распознавание уже есть, но
состояние не reviewable) кнопка пропала бы — а именно там она и нужна, чтобы
переименовать раздачу постфактум. Так же это оставляет запас на будущее: при
включённых метабазах авто-раскладка (`finishRecognition`, Auto без review) минует
`chooseCandidateLocked`, и единственной точкой обновления имени для голого magnet
на `done` останется кнопка.
## Risks / Trade-offs
- **Переименование раздачи в qBittorrent удивит пользователя, следящего за
клиентом.** → Это и есть цель (осмысленное имя вместо «Unknown»); срабатывает
только при подтверждённом матче или явной кнопке.
- **Каноническое имя на языке оригинала может отличаться от ожидаемого.** →
Non-goal (D1/Non-Goals); берём то же имя, что и раскладка, — консистентность
важнее локализации.
- **Гонки: раздачу удалили между чтением и rename.** → Best-effort (D3): rename
не проваливает операцию, `display_name` уже обновлён.
- **Затирание add-имени слегка иным форматом.** → Приемлемо (D6): подтверждённый
матч авторитетнее; формат совпадает с add-ярлыком.
## Migration Plan
Обратная совместимость полная: схема БД не меняется, старые загрузки продолжают
работать. Развёртывание — обычный деплой бинаря. Откат — откат бинаря; данные не
мигрируют. Уже существующие «Unknown»-загрузки чинятся ручной кнопкой или
повторным подтверждением кандидата.
## Open Questions
Разрешено на ревью дизайна (чекпоинт №1):
- **Авто-триггер держим узким:** только `chooseCandidateLocked` (выбор кандидата +
ручное добавление источника) + ручная кнопка. `SetProviderID` и `Apply`
авто-refresh НЕ получают. Причины: `SetProviderID` пинит пустые `title`/`year`
(`sourcePins(...,"",0)`) — эффективное имя всё равно падает на
`recognition.title`, «подтверждённости» меньше, а кнопка это закрывает; `Apply`
относится к `file-layout`, вшивать туда косметику — размывать границы capability
и дёргать rename на каждом повторном apply.