На /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>
8.7 KiB
Context
display_name и поля блоков «Распознано как» / «инфо источника» — косметика
отображения, не влияющая на пути и раскладку. Слоистое разрешение полей ярлыка
(первый непустой слой override → recognition+матч → контекст) уже
реализовано, но заперто в неэкспортируемом worker.effectiveDisplayName
(internal/worker/review.go:1145), который:
- читает
naming.Fieldsизdownload.parsed_context(нижний слой «контекст»); - для каждого поля берёт первый непустой слой:
plan.Title→ctxf.Title,plan.Director→ctxf.Director,plan.Year→ctxf.Year, сводка сезоновrecognize.SeasonSummary(plan)→ctxf.SeasonLabel(); - рендерит строку
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
(проверено).
Возвращает не строку, а тип полей:
// 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
- Импорт
naming→recognizeрасширяет зависимости пакетаnaming. Приемлемо: цикла нет,recognize— доменный тип плана, аnamingуже оперирует его сводкой сезонов косвенно (через worker). Альтернатива — дублировать логику разрешения в трёх местах — хуже (дрейф). - Незначительный:
EffectiveFieldsпарситparsed_contextна каждый рендер страницы. Это дешёвыйjson.Unmarshalкороткой строки, страница и так server-rendered без БД на рендере блока — некритично.