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

14 KiB

Context

Отображаемое имя (download.display_name) выводится один раз — на шаге добавления пойманной загрузки в qBittorrent (worker.processCatchednamer.DeriveNamePromoteCatched). Источник имени на этом шаге — только пользовательский контекст и подсказка из полей источника (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.