Files
jellybit/openspec/changes/archive/2026-07-11-field-resolution-display-name/design.md
T
avandClaude Opus 4.8 02d4ecc2aa display_name: слоистое разрешение полей + сохранение режиссёра из контекста
Единый источник полей отображаемого имени и один рендер полного ярлыка на
всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный
«Название (режиссёр, год). Сезон N» но выбрасывал структуру, а перелив по
распознаванию — усечённый «Title (Year)».

- Слоистое разрешение скаляров имени: override → recognition(+match) →
  новый базовый слой «контекст» (download.parsed_context, JSON naming.Fields).
- naming: публичные Fields/Label/Derive, вынесен единый рендер; удалён
  FormatTitleYear. Сводка сезонов вынесена в recognize.SeasonSummary.
- Режиссёр из метабазы (решение A2): TMDB/TVDB credits через опциональный
  metadata.DirectorProvider; авто-матч кладёт в plan.Director, ручной выбор
  кандидата тянет credits и пиннит ovrDirector. Метабаза бьёт контекст.
- refreshDisplayNameLocked строит полный ярлык из эффективных полей;
  инфо-панель ревью показывает загруженного режиссёра.
- Миграция 0011_parsed_context + ER-схема. Всё косметика: на пути/раскладку
  не влияет, приём/вывод имени не валятся (best-effort).

Закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат ярлыка».
OpenSpec: archive/2026-07-11-field-resolution-display-name (ingest,
recognition, metadata-match, review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:50:08 +03:00

12 KiB
Raw Blame History

Context

download.display_name — косметический ярлык (список qBittorrent + заголовок в веб-UI), не влияющий на пути/раскладку. Сейчас его выводят два расходящихся пути:

  • Старт (worker.go:455, naming.DeriveName): LLM извлекает extracted (type/title/original_title/year/director/season), приватная render собирает полный ярлык «Название (режиссёр, год). Сезон N». Структура после рендера выбрасывается.
  • Обновление по распознаванию (review.go:1089, refreshDisplayNameLocked): ручная кнопка «Обновить имя» и авто-перелив при матче зовут naming.FormatTitleYear(plan.Title, plan.Year) → усечённый Title (Year).

В системе уже есть слоистое разрешение полей плана: effectivePlan читает recognition.Plan (в него Recognize вкладывает каноничные title/year матча) и накладывает override (ручные пины) через applyOverrides. Не хватает нижнего слоя «контекст» и поля режиссёра.

Constraints (инварианты): вывод имени НИКОГДА не валит приём/добавление (деградация к пустому); выход LLM и метабаз недоверенный; санитайзинг + лимит maxNameLen; секреты не в логах; время UTC; ULID-идентификаторы; при изменении схемы — миграция goose + ER-схема docs/specs/database.md.

Goals / Non-Goals

Goals:

  • Единый слоистый источник скалярных полей имени и одна функция рендера полного ярлыка, используемая и на старте, и при обновлении.
  • Режиссёр из контекста сохраняется (parsed_context) и не теряется; режиссёр из метабазы (TMDB/TVDB credits) его перекрывает.
  • Кнопка «Обновить имя»/авто-перелив дают полный формат (закрытие беклог-задачи knopka-obnovit-imya-polnyj-format).

Non-Goals:

  • Не вводим EAV-таблицу «поле+источник» и не переносим title/year из Plan/override в новое хранилище (Plan структурен — files[]; дубль исказит «где правда»).
  • Не храним провенанс поля (источник выводится при разрешении, если понадобится в UI).
  • Не добавляем режиссёра в промпт распознавания (его уже извлекает контекстный naming; в план он приходит из матча).
  • Не трогаем логику раскладки/путей/безопасности.

Decisions

1. Хранение контекста — JSON-колонка download.parsed_context

Извлечённую на старте структуру (naming.extracted) сериализуем JSON-ом в новую колонку download.parsed_context TEXT NOT NULL DEFAULT ''. Это единственный недостающий источник; он 1:1 с загрузкой, ставится один раз, читается точечно.

Почему не таблица-спутник: join ради 1:1 без выгоды. Почему не колонки-на-поле: миграция на каждое под-поле; JSON эволюционирует свободно, как уже хранится Plan. Почему вообще persist, а не пере-извлечение из context при обновлении: лишний вызов LLM; пользователь явно просил «сохраняем».

2. Слоистое разрешение — хелпер в коде, не хранимый провенанс

Вводим структуру эффективных полей имени и хелпер, собирающий её из слоёв override → recognition(+match) → parsed_context (первый непустой на поле). Источник каждого поля выводится позицией слоя; хранить его не нужно. Хелпер живёт рядом с effectivePlan/refreshDisplayNameLocked (worker), т.к. только он имеет доступ ко всем трём слоям под w.mu.

3. Режиссёр: два входа из метабазы + слой override (решение A2)

recognize.Plan получает опциональное Director string \json:"director,omitempty"``. LLM его не заполняет и не валидирует. Режиссёр из метабазы приходит двумя путями, оба best-effort (ошибка/пусто/провайдер-без-режиссёра — напр. TVMaze — не валят матч):

  • Авто-матч (Recognize/matchMetadata): при подтверждённом единичном матче вкладываем режиссёра в plan.Director — ровно как уже вкладываются title/year.
  • Ручной выбор кандидата в ревью (основной путь): chooseCandidateLocked/ AddManualSource при закреплении кандидата тянут режиссёра выбранного provider:id из credits и пишут его как director-override (новое поле ovr в наборе пинов источника рядом с provider/id/title/year). applyOverrides кладёт значение в plan.Director. Так режиссёр выбранного кандидата переживает перезагрузку страницы (override персистентен) без колонки на metadata_candidate.

Выборку credits по provider:id даёт новый метод интерфейса метабазы, проброшенный в worker через интерфейс Recognizer (worker уже зависит от него; прямой зависимости worker→metadata не заводим). Credits тянем только для подтверждённого/выбранного источника, а не для каждого кандидата поиска — экономим внешние вызовы.

Альтернатива A1 (отклонена пользователем): режиссёр только из авто-матча — на основном (ручном) пути подтверждения матча не проявлялся бы. Альтернатива (колонка metadata_candidate.director + фетч на поиске): вторая миграция и фетч для всех кандидатов — дороже, отклонена.

3a. Режиссёр — недоверенное косметическое поле

director (из контекста, из авто-матча или из override) — недоверенный вход. Он НЕ входит в санитайзинг плана (recognition «Санитайзинг человекочитаемых полей» чистит title/original_title/provider_hint) и НЕ участвует в структурной валидации/гейте. Очистка (управляющие символы, пробелы, лимит) применяется к нему на рендере ярлыка (render/sanitize уже это делают). На пути/раскладку режиссёр не влияет.

4. Единый рендер полного ярлыка

Экспортируем из internal/naming функцию, строящую ярлык из эффективных полей (та же логика, что приватная render): «Название (режиссёр, год)» + для сериала хвост сезона. FormatTitleYear удаляем (или переводим на новый рендер). refreshDisplayNameLocked вместо FormatTitleYear(plan.Title, plan.Year) зовёт новый рендер по эффективным полям. Старт (DeriveName) использует тот же рендер.

5. Сводка сезонов — общая с UI, отдельная форма слоя

Сезон не разрешается как плоский скаляр: у слоя recognition он выражен per-file (plan.Files[].Season) и сворачивается в строку через seasonSummary (httpapi/files.go:47: один → «Сезон N», диапазон → «Сезоны 1–3», спецвыпуски), а у слоя parsed_context это скаляр Season *int (даёт лишь «Сезон N»). Поэтому:

  • Публичный рендер ярлыка принимает готовую строку сводки сезонов, а не сырое число; хвост ярлыка — «. <сводка>» (пусто → хвоста нет).
  • effectiveNameFields вычисляет эту строку по источнику: если есть план распознавания с episode-ролями — seasonSummary(plan); иначе (плана нет — например ярлык на старте — или у сериала нет episode-ролей) fallback на контекстный скаляр parsed_context.season → «Сезон N». Для фильма сезона нет.
  • Логику seasonSummary выносим из httpapi в переиспользуемое место (recognize или naming); httpapi и рендер зовут один хелпер — карточка страницы и ярлык дают одинаковую сводку. Тесты seasonSummary переезжают вместе с кодом.

Risks / Trade-offs

  • Доп. вызов credits к TMDB/TVDB при каждом подтверждённом матче → best-effort с таймаутом клиента; провал не валит матч; кэш метабаз — отдельная задача беклога (kesh-metabaz).
  • Рассинхрон формата ярлыка и сводки сезонов между стартом, обновлением и карточкой → устраняется единой функцией рендера и общим seasonSummary (ревью проверит, что все три пути зовут одно).
  • Миграция добавляет колонку существующим строкамDEFAULT '', старые загрузки просто без parsed_context (нижний слой пуст) — деградация штатная, имя выводится из распознавания как и раньше.
  • parsed_context — недоверенный вход (LLM/фолбек) → к его полям применяется тот же санитайзинг/лимит на рендере; на пути/раскладку не влияет.

Migration Plan

  1. Миграция goose: ALTER TABLE download ADD COLUMN parsed_context TEXT NOT NULL DEFAULT ''; обновить ER-схему docs/specs/database.md.
  2. Существующие строки — с пустым parsed_context; поведение имени для них не меняется (нижний слой пуст). Откат — колонка неиспользуемая, безопасно игнорируется; down-миграция дропает колонку.
  3. Раскатка обычная (копия бинаря на umbar), без ручных шагов данных.

Open Questions

  • Нет (развилки хранения/источника/сезона согласованы с пользователем до proposal).