Files
jellybit/openspec/changes/archive/2026-07-17-download-card-director/design.md
T
avandClaude Opus 4.8 5948f4d219 Веб-UI: режиссёр в блоке «Распознано как» на странице загрузки
На /download/{id} поле «Режиссёр» было захардкожено прочерком, хотя экран
ревью режиссёра уже выводит: слоистое разрешение полей ярлыка было заперто в
неэкспортируемом worker.effectiveDisplayName. Из-за этого билдеры вью видели
только слой распознавания+матч (rd.Plan.Director) без слоя контекста — то же
на экране ревью.

Вынес разрешение в экспортируемую naming.EffectiveFields(parsedContext, plan)
LabelFields с методом Label(): выбор слоя по сырым значениям (как прежде),
выбранные скаляры возвращаются очищенными (sanitize идемпотентен, display_name
побайтно тот же). effectiveDisplayName стал тонкой обёрткой; страница загрузки
и экран ревью берут режиссёра из той же функции — согласованно с заголовком.

OpenSpec: web-ui (ADDED «Режиссёр в блоке распознавания страницы загрузки»),
review (MODIFIED «Инфо и предпросмотр выбранного источника» — слоистое
разрешение с фолбэком на контекст). Change заархивирован. Беклог: закрыта
rezhisser-v-kartochke-zagruzki.

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

8.7 KiB
Raw Blame History

Context

display_name и поля блоков «Распознано как» / «инфо источника» — косметика отображения, не влияющая на пути и раскладку. Слоистое разрешение полей ярлыка (первый непустой слой overriderecognition+матч → контекст) уже реализовано, но заперто в неэкспортируемом worker.effectiveDisplayName (internal/worker/review.go:1145), который:

  1. читает naming.Fields из download.parsed_context (нижний слой «контекст»);
  2. для каждого поля берёт первый непустой слой: plan.Title→ctxf.Title, plan.Director→ctxf.Director, plan.Year→ctxf.Year, сводка сезонов recognize.SeasonSummary(plan)→ctxf.SeasonLabel();
  3. рендерит строку naming.Label(...).

Билдеры вью строят режиссёра в обход этой логики — прямо rd.Plan.Director (internal/httpapi/review.go:122), а на странице загрузки (internal/httpapi/download.go) поле вообще захардкожено прочерком в шаблоне. Итог — слой контекста теряется, и в заголовке страницы режиссёр есть, а в поле — прочерк.

Constraints (инварианты): вывод имени НИКОГДА не валит приём (деградация к пустому); parsed_context недоверен (битый JSON → пустой слой, best-effort); director — недоверенное косметическое поле, чистится на рендере (naming.Label уже санитизирует); без сети и без изменения схемы БД.

Goals / Non-Goals

Goals:

  • Один источник истины для слоистого разрешения полей ярлыка — экспортируемая функция в naming, переиспользуемая в effectiveDisplayName и в обоих билдерах вью.
  • Режиссёр на /download/{id} согласован с режиссёром в заголовке страницы.
  • Поведение display_name не меняется (побайтно тот же результат) — существующие тесты worker/naming зелёные.

Non-Goals:

  • Не меняем слои Title/Year/Season в блоке «Распознано как»: они остаются из плана (что распознано), как сейчас — расширение слоёв контекста на эти поля вне scope (это изменило бы отображение блока). Правится только режиссёр — поле, которое даже в норме приходит не из LLM, а из матча/override/контекста.
  • Не трогаем схему БД, метабазы, распознавание, раскладку.

Decisions

D1. Сигнатура: naming.EffectiveFields(parsedContext string, plan recognize.Plan)

Функция принимает строку parsed_context, а не store.Download. Так naming не тянет зависимость на store (пакет naming — низкоуровневый вывод имени; уже знает про parsed_context концептуально — его схема это naming.Fields). Импорт namingrecognize добавляется (нужен recognize.Plan и recognize.SeasonSummary); цикла нет — recognize не импортирует naming (проверено).

Возвращает не строку, а тип полей:

// LabelFields — эффективные скалярные поля ярлыка после слоистого разрешения.
// Значения уже очищены (sanitize): управляющие символы вырезаны, пробелы
// схлопнуты — те же, что попадут внутрь Label. Season — готовая строка-сводка
// сезонов (для фильма пусто).
type LabelFields struct {
    Title    string
    Director string
    Year     int
    Season   string
}

func (f LabelFields) Label() string { return Label(f.Title, f.Director, f.Year, f.Season) }

func EffectiveFields(parsedContext string, plan recognize.Plan) LabelFields

Season — строка, а не *int: сводка плана многосезонна («Сезоны 1–3», «Спецвыпуски»), в *int не выражается. Метод Label() даёт единый рендер, чтобы effectiveDisplayName остался тонкой обёрткой.

Санитайзинг на возврате. Выбор слоя (первый непустой) идёт по сырым значениям (plan.Director != "" и т.д. — как в исходном effectiveDisplayName), но выбранное значение возвращается уже прогнанным через naming.sanitize (в пакете naming он доступен). Причина — билдеры вью печатают .Director напрямую ({{.Director}}), в обход Label; без очистки на возврате (а) недоверенный parsed_context.director дошёл бы до страницы без санитайзинга, (б) режиссёр из одних управляющих символов дал бы {{if .Director}} истинным в поле, тогда как Label его выбросил бы из заголовка — то самое рассогласование «в поле есть, в шапке нет». sanitize идемпотентен, поэтому повторная очистка внутри Label ничего не меняет: Label() даёт побайтно прежний display_name (тесты worker/naming зелёные).

Альтернатива (вариант A из беклога — присвоить view.Director = rd.Plan.Director на странице загрузки) отклонена: оставляет ровно тот баг (нет слоя контекста → нестыковка с заголовком) и не чинит review.go.

D2. worker.effectiveDisplayName — тонкая обёртка

func effectiveDisplayName(d store.Download, plan recognize.Plan) string {
    return naming.EffectiveFields(d.ParsedContext, plan).Label()
}

Логика перенесена дословно, поэтому результат идентичен. Обёртку и её тесты (displayname_test.go) сохраняем — они продолжают проверять инвариант «имя не изменилось».

D3. Билдеры вью читают .Director

  • download.go: новое поле downloadDetailView.Director; в buildDownloadView (внутри if rd.Recognition != nil) — view.Director = naming.EffectiveFields(d.ParsedContext, rd.Plan).Director.
  • review.go: view.Director = naming.EffectiveFields(rd.Download.ParsedContext, rd.Plan).Director вместо rd.Plan.Director.

Только Director берётся из слоистого разрешения; Title/Year/Season в блоке остаются как есть (см. Non-Goals).

D4. Шаблон

download_main.html:40 — прочерк заменяется на условный вывод по конвенции соседних полей: {{if .Director}}{{.Director}}{{else}}<span class="faint">—</span>{{end}}. Деградация без JS сохраняется (страница server-rendered, поле статично).

Risks / Trade-offs

  • Импорт namingrecognize расширяет зависимости пакета naming. Приемлемо: цикла нет, recognize — доменный тип плана, а naming уже оперирует его сводкой сезонов косвенно (через worker). Альтернатива — дублировать логику разрешения в трёх местах — хуже (дрейф).
  • Незначительный: EffectiveFields парсит parsed_context на каждый рендер страницы. Это дешёвый json.Unmarshal короткой строки, страница и так server-rendered без БД на рендере блока — некритично.