chess-tutor / src /chess_tutor /web /sections.py
github-actions[bot]
deploy prod from 06dbd16a01ddcfe02b2d936c681e3e4eaa9b141f
8e756fd
Raw
History Blame Contribute Delete
8.09 kB
"""Web UI section registry.
Every known section/block is registered here. To disable something, set
``enabled=False`` (sections) or ``skip=True`` (narrative blocks) — do not delete
entries. Removed ids are re-added automatically as disabled so they can be
turned back on later via ``with_section_enabled`` / ``with_narrative_block_skip``.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from enum import StrEnum
from typing import Any
class WebSectionId(StrEnum):
"""Top-level blocks in the results panel."""
PLAYER_BANNER = "player_banner"
STYLE_SCALE = "style_scale"
STYLE_TRAITS = "style_traits"
TSNE_PLOT = "tsne_plot"
ELO_TSNE_PLOT = "elo_tsne_plot"
NARRATIVE = "narrative"
OPENINGS = "openings"
PUZZLES = "puzzles"
class NarrativeBlockId(StrEnum):
"""Narrative subsections filtered out of ``narrative_sections``."""
COACHING_REPORT = "coaching_report"
COHORT_TENDENCY = "cohort_tendency"
OPENING_DUPLICATE = "opening_duplicate"
PUZZLE_DUPLICATE = "puzzle_duplicate"
@dataclass(frozen=True)
class WebSectionSpec:
id: WebSectionId
enabled: bool
order: int
label: str = ""
@dataclass(frozen=True)
class NarrativeBlockSpec:
id: NarrativeBlockId
"""When ``True``, matching narrative blocks are omitted from the web payload."""
skip: bool
# Canonical registry — keep every section here; toggle ``enabled`` instead of deleting.
_SECTION_REGISTRY: dict[WebSectionId, WebSectionSpec] = {
WebSectionId.PLAYER_BANNER: WebSectionSpec(
WebSectionId.PLAYER_BANNER, True, 10, "Player summary"
),
WebSectionId.STYLE_SCALE: WebSectionSpec(
WebSectionId.STYLE_SCALE, True, 20, "Playing style"
),
WebSectionId.TSNE_PLOT: WebSectionSpec(
WebSectionId.TSNE_PLOT, True, 30, "Playstyle map (t-SNE)"
),
WebSectionId.ELO_TSNE_PLOT: WebSectionSpec(
WebSectionId.ELO_TSNE_PLOT, True, 40, "Rating vs t-SNE"
),
WebSectionId.STYLE_TRAITS: WebSectionSpec(
WebSectionId.STYLE_TRAITS, True, 50, "Playstyle changes"
),
WebSectionId.NARRATIVE: WebSectionSpec(
WebSectionId.NARRATIVE, True, 60, "Coaching narrative"
),
WebSectionId.OPENINGS: WebSectionSpec(
WebSectionId.OPENINGS, True, 70, "Top opening positions"
),
WebSectionId.PUZZLES: WebSectionSpec(
WebSectionId.PUZZLES, True, 80, "Puzzle practice"
),
}
_NARRATIVE_BLOCK_REGISTRY: dict[NarrativeBlockId, NarrativeBlockSpec] = {
NarrativeBlockId.COACHING_REPORT: NarrativeBlockSpec(
NarrativeBlockId.COACHING_REPORT, skip=True
),
NarrativeBlockId.COHORT_TENDENCY: NarrativeBlockSpec(
NarrativeBlockId.COHORT_TENDENCY, skip=True
),
NarrativeBlockId.OPENING_DUPLICATE: NarrativeBlockSpec(
NarrativeBlockId.OPENING_DUPLICATE, skip=True
),
NarrativeBlockId.PUZZLE_DUPLICATE: NarrativeBlockSpec(
NarrativeBlockId.PUZZLE_DUPLICATE, skip=True
),
}
# Active layout — edit ``enabled`` / ``order`` on registry entries, or pass overrides
# to ``resolve_web_layout``. Entries must not be removed from the registries above.
DEFAULT_WEB_UI_LAYOUT: tuple[WebSectionSpec, ...] = tuple(
sorted(_SECTION_REGISTRY.values(), key=lambda s: s.order)
)
DEFAULT_NARRATIVE_BLOCK_FILTERS: tuple[NarrativeBlockSpec, ...] = tuple(
_NARRATIVE_BLOCK_REGISTRY.values()
)
def resolve_web_layout(
overrides: tuple[WebSectionSpec, ...] | None = None,
) -> tuple[WebSectionSpec, ...]:
"""Merge optional overrides onto the canonical section registry."""
merged = dict(_SECTION_REGISTRY)
if overrides is not None:
for spec in overrides:
merged[spec.id] = spec
return tuple(sorted(merged.values(), key=lambda s: s.order))
def resolve_narrative_block_filters(
overrides: tuple[NarrativeBlockSpec, ...] | None = None,
) -> tuple[NarrativeBlockSpec, ...]:
"""Merge optional overrides onto the canonical narrative block registry."""
merged = dict(_NARRATIVE_BLOCK_REGISTRY)
if overrides is not None:
for spec in overrides:
merged[spec.id] = spec
return tuple(merged.values())
def ensure_complete_layout(
layout: tuple[WebSectionSpec, ...] | None = None,
) -> tuple[WebSectionSpec, ...]:
"""Return a full layout with every ``WebSectionId`` (disabled if omitted)."""
partial = {spec.id: spec for spec in (layout or ())}
complete: list[WebSectionSpec] = []
for sid, default in _SECTION_REGISTRY.items():
if sid in partial:
complete.append(partial[sid])
else:
complete.append(replace(default, enabled=False))
return tuple(sorted(complete, key=lambda s: s.order))
def ensure_complete_narrative_filters(
filters: tuple[NarrativeBlockSpec, ...] | None = None,
) -> tuple[NarrativeBlockSpec, ...]:
"""Return all narrative block filters (not skipped if omitted)."""
partial = {spec.id: spec for spec in (filters or ())}
complete: list[NarrativeBlockSpec] = []
for bid, default in _NARRATIVE_BLOCK_REGISTRY.items():
if bid in partial:
complete.append(partial[bid])
else:
complete.append(replace(default, skip=False))
return tuple(complete)
def with_section_enabled(
layout: tuple[WebSectionSpec, ...],
section_id: WebSectionId | str,
*,
enabled: bool,
) -> tuple[WebSectionSpec, ...]:
"""Return a copy of ``layout`` with one section enabled or disabled."""
sid = WebSectionId(section_id)
base = ensure_complete_layout(layout)
return tuple(
replace(spec, enabled=enabled) if spec.id == sid else spec for spec in base
)
def with_narrative_block_skip(
filters: tuple[NarrativeBlockSpec, ...],
block_id: NarrativeBlockId | str,
*,
skip: bool,
) -> tuple[NarrativeBlockSpec, ...]:
"""Return a copy of ``filters`` with one narrative block shown or hidden."""
bid = NarrativeBlockId(block_id)
base = ensure_complete_narrative_filters(filters)
return tuple(
replace(spec, skip=skip) if spec.id == bid else spec for spec in base
)
def ui_layout_config(
*,
sections: tuple[WebSectionSpec, ...] | None = None,
narrative_blocks: tuple[NarrativeBlockSpec, ...] | None = None,
) -> dict[str, Any]:
"""Serializable layout for the frontend and API consumers."""
resolved_sections = ensure_complete_layout(
resolve_web_layout(sections) if sections is not None else DEFAULT_WEB_UI_LAYOUT
)
resolved_blocks = ensure_complete_narrative_filters(
resolve_narrative_block_filters(narrative_blocks)
if narrative_blocks is not None
else DEFAULT_NARRATIVE_BLOCK_FILTERS
)
return {
"sections": [
{
"id": spec.id.value,
"enabled": spec.enabled,
"order": spec.order,
"label": spec.label,
}
for spec in resolved_sections
],
"narrative_blocks": [
{"id": block.id.value, "skip": block.skip}
for block in resolved_blocks
],
}
def section_enabled(
section_id: WebSectionId | str,
*,
layout: tuple[WebSectionSpec, ...] = DEFAULT_WEB_UI_LAYOUT,
) -> bool:
sid = WebSectionId(section_id)
for spec in ensure_complete_layout(layout):
if spec.id == sid:
return spec.enabled
return False
def narrative_block_skipped(
block_id: NarrativeBlockId | str,
*,
filters: tuple[NarrativeBlockSpec, ...] | None = None,
) -> bool:
bid = NarrativeBlockId(block_id)
resolved = ensure_complete_narrative_filters(
filters if filters is not None else DEFAULT_NARRATIVE_BLOCK_FILTERS
)
for spec in resolved:
if spec.id == bid:
return spec.skip
return False
def enabled_section_ids_ordered(
layout: tuple[WebSectionSpec, ...] = DEFAULT_WEB_UI_LAYOUT,
) -> list[str]:
return [
spec.id.value
for spec in ensure_complete_layout(layout)
if spec.enabled
]