## Context `display_name` и поля блоков «Распознано как» / «инфо источника» — косметика отображения, не влияющая на пути и раскладку. Слоистое разрешение полей ярлыка (первый непустой слой `override` → `recognition`+матч → **контекст**) уже реализовано, но заперто в неэкспортируемом `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`). Импорт `naming`→`recognize` добавляется (нужен `recognize.Plan` и `recognize.SeasonSummary`); цикла нет — `recognize` не импортирует `naming` (проверено). Возвращает не строку, а тип полей: ```go // 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` — тонкая обёртка ```go 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}}{{end}}`. Деградация без JS сохраняется (страница server-rendered, поле статично). ## Risks / Trade-offs - Импорт `naming`→`recognize` расширяет зависимости пакета `naming`. Приемлемо: цикла нет, `recognize` — доменный тип плана, а `naming` уже оперирует его сводкой сезонов косвенно (через worker). Альтернатива — дублировать логику разрешения в трёх местах — хуже (дрейф). - Незначительный: `EffectiveFields` парсит `parsed_context` на каждый рендер страницы. Это дешёвый `json.Unmarshal` короткой строки, страница и так server-rendered без БД на рендере блока — некритично.