feat: align builder commander hover with deck view

- reuse shared hover metadata in Step 5 and keep the preview in-app\n- let hover reasons expand without an embedded scrollbar\n- document the hover polish in CHANGELOG and release notes
This commit is contained in:
matt 2025-09-29 21:32:08 -07:00
parent b0080ed482
commit a0299fbcfc
14 changed files with 1046 additions and 473 deletions

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from typing import Any, Dict, Iterable, Optional
from fastapi import Request
from ..services import owned_store
from . import orchestrator as orch
@ -91,6 +91,141 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
return ctx
def _extend_sources(target: list[Any], values: Any) -> None:
if not values:
return
if isinstance(values, (list, tuple, set)):
for item in values:
if item is None:
continue
target.append(item)
else:
target.append(values)
def commander_hover_context(
commander_name: str | None,
deck_tags: Iterable[Any] | None,
summary: Dict[str, Any] | None,
) -> Dict[str, Any]:
try:
from .summary_utils import format_theme_label, format_theme_list
except Exception:
# Fallbacks in the unlikely event of circular import issues
def format_theme_label(value: Any) -> str: # type: ignore[redef]
text = str(value or "").strip().replace("_", " ")
if not text:
return ""
parts = []
for chunk in text.split():
if chunk.isupper():
parts.append(chunk)
else:
parts.append(chunk[:1].upper() + chunk[1:].lower())
return " ".join(parts)
def format_theme_list(values: Iterable[Any]) -> list[str]: # type: ignore[redef]
seen: set[str] = set()
result: list[str] = []
for raw in values or []: # type: ignore[arg-type]
label = format_theme_label(raw)
if not label or len(label) <= 1:
continue
key = label.casefold()
if key in seen:
continue
seen.add(key)
result.append(label)
return result
deck_theme_sources: list[Any] = []
_extend_sources(deck_theme_sources, list(deck_tags or []))
meta_info: Dict[str, Any] = {}
if isinstance(summary, dict):
meta_info = summary.get("meta") or {}
if isinstance(meta_info, dict):
for key in (
"display_themes",
"resolved_themes",
"auto_filled_themes",
"random_display_themes",
"random_resolved_themes",
"random_auto_filled_themes",
"primary_theme",
"secondary_theme",
"tertiary_theme",
):
_extend_sources(deck_theme_sources, meta_info.get(key))
deck_theme_tags = format_theme_list(deck_theme_sources)
commander_theme_sources: list[Any] = []
if isinstance(meta_info, dict):
for key in (
"commander_tags",
"commander_theme_tags",
"commander_themes",
"commander_tag_list",
"primary_commander_theme",
"secondary_commander_theme",
):
_extend_sources(commander_theme_sources, meta_info.get(key))
commander_meta = meta_info.get("commander") if isinstance(meta_info, dict) else {}
if isinstance(commander_meta, dict):
_extend_sources(commander_theme_sources, commander_meta.get("tags"))
_extend_sources(commander_theme_sources, commander_meta.get("themes"))
commander_theme_tags = format_theme_list(commander_theme_sources)
if commander_name and not commander_theme_tags:
try:
commander_theme_tags = format_theme_list(orch.tags_for_commander(commander_name))
except Exception:
commander_theme_tags = []
combined_tags: list[str] = []
combined_seen: set[str] = set()
for source in (commander_theme_tags, deck_theme_tags):
for label in source:
key = label.casefold()
if key in combined_seen:
continue
combined_seen.add(key)
combined_tags.append(label)
overlap_tags: list[str] = []
overlap_seen: set[str] = set()
combined_keys = {label.casefold() for label in combined_tags}
for label in deck_theme_tags:
key = label.casefold()
if key in combined_keys and key not in overlap_seen:
overlap_tags.append(label)
overlap_seen.add(key)
commander_tag_slugs: list[str] = []
slug_seen: set[str] = set()
for label in combined_tags:
slug = " ".join(str(label or "").strip().lower().split())
if not slug or slug in slug_seen:
continue
slug_seen.add(slug)
commander_tag_slugs.append(slug)
reason_bits: list[str] = []
if deck_theme_tags:
reason_bits.append("Deck themes: " + ", ".join(deck_theme_tags))
if commander_theme_tags:
reason_bits.append("Commander tags: " + ", ".join(commander_theme_tags))
return {
"deck_theme_tags": deck_theme_tags,
"commander_theme_tags": commander_theme_tags,
"commander_combined_tags": combined_tags,
"commander_tag_slugs": commander_tag_slugs,
"commander_overlap_tags": overlap_tags,
"commander_reason_text": "; ".join(reason_bits),
"commander_role_label": format_theme_label("Commander") if commander_name else "",
}
def step5_ctx_from_result(
request: Request,
sess: dict,
@ -132,6 +267,13 @@ def step5_ctx_from_result(
}
if extras:
ctx.update(extras)
hover_meta = commander_hover_context(
commander_name=ctx.get("commander"),
deck_tags=sess.get("tags"),
summary=ctx.get("summary") if ctx.get("summary") else res.get("summary"),
)
ctx.update(hover_meta)
return ctx

View file

@ -13,6 +13,137 @@ import re
import unicodedata
from glob import glob
_TAG_ACRONYM_KEEP = {"EDH", "ETB", "ETBs", "CMC", "ET", "OTK"}
_REASON_SOURCE_OVERRIDES = {
"creature_all_theme": "Theme Match",
"creature_add": "Creature Package",
"creature_fill": "Creature Fill",
"creature_phase": "Creature Stage",
"creatures": "Creature Stage",
"lands": "Lands",
"land_phase": "Land Stage",
"spells": "Spells",
"autocombos": "Combo Package",
"enforcement": "Enforcement",
"lock": "Lock",
}
def _humanize_tag_label(tag: Any) -> str:
"""Return a human-friendly display label for a tag identifier."""
try:
raw = str(tag).strip()
except Exception:
raw = ""
if not raw:
return ""
# Replace common separators with spaces and collapse whitespace
cleaned = raw.replace("", " ")
cleaned = re.sub(r"[_\-]+", " ", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
cleaned = re.sub(r"\s*:\s*", ": ", cleaned)
if not cleaned:
return ""
words = cleaned.split(" ")
friendly_parts: List[str] = []
for word in words:
if not word:
continue
upper_word = word.upper()
if upper_word in _TAG_ACRONYM_KEEP or (len(word) <= 3 and word.isupper()):
friendly_parts.append(upper_word)
continue
if word.isupper() or word.islower():
friendly_parts.append(word.capitalize())
continue
friendly_parts.append(word[0].upper() + word[1:])
return " ".join(friendly_parts)
def _humanize_reason_source(value: Any) -> str:
try:
raw = str(value).strip()
except Exception:
raw = ""
if not raw:
return ""
key = raw.lower()
if key in _REASON_SOURCE_OVERRIDES:
return _REASON_SOURCE_OVERRIDES[key]
# Split camelCase before normalizing underscores
split_camel = re.sub(r"(?<!^)([A-Z])", r" \1", raw).replace("-", " ")
cleaned = split_camel.replace("_", " ")
cleaned = re.sub(r"\s+", " ", cleaned).strip()
if not cleaned:
return ""
stopwords = {"all", "step", "phase", "pkg", "package", "stage"}
tokens = [t for t in cleaned.split(" ") if t]
filtered = [t for t in tokens if t.lower() not in stopwords]
base = " ".join(filtered if filtered else tokens)
friendly = _humanize_tag_label(base)
return friendly
def _split_composite_tags(value: Any) -> List[str]:
"""Split a trigger tag style string into individual tag fragments."""
if not value:
return []
try:
raw = str(value)
except Exception:
return []
parts = re.split(r"[\u2022,;/]+", raw)
return [p.strip() for p in parts if p and p.strip()]
def _coerce_tag_iterable(value: Any) -> List[str]:
"""Coerce stored tag metadata into a flat list of strings."""
if isinstance(value, (list, tuple, set)):
out: List[str] = []
for item in value:
try:
text = str(item).strip()
except Exception:
text = ""
if text:
out.append(text)
return out
if isinstance(value, str):
text = value.strip()
if not text:
return []
# Try JSON decoding first for serialized lists
try:
parsed = json.loads(text)
if isinstance(parsed, (list, tuple, set)):
return [str(item).strip() for item in parsed if str(item).strip()]
except Exception:
pass
parts = re.split(r"[;,]", text)
return [p.strip().strip("'\"") for p in parts if p and p.strip().strip("'\"")]
return []
def _display_tags_from_entry(entry: Dict[str, Any]) -> List[str]:
"""Derive a user-facing tag list for a card entry."""
base_tags = _coerce_tag_iterable(entry.get('Tags'))
trigger_tags = _split_composite_tags(entry.get('TriggerTag'))
combined: List[str] = []
seen: set[str] = set()
for source in (base_tags, trigger_tags):
for tag in source:
if not tag:
continue
key = str(tag).strip().lower()
if not key or key in seen:
continue
seen.add(key)
friendly = _humanize_tag_label(tag)
if friendly:
combined.append(friendly)
return combined
# --- Theme Metadata Enrichment Helper (Phase D+): ensure editorial scaffolding after any theme export ---
def _run_theme_metadata_enrichment(out_func=None) -> None:
"""Run full metadata enrichment sequence after theme catalog/YAML generation.
@ -2443,14 +2574,38 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
trig = str(entry.get('TriggerTag') or '').strip()
parts: list[str] = []
if role:
parts.append(role)
parts.append(_humanize_tag_label(role))
if sub_role:
parts.append(sub_role)
if added_by:
parts.append(f"by {added_by}")
parts.append(_humanize_tag_label(sub_role))
friendly_added = _humanize_reason_source(added_by)
if friendly_added:
parts.append(friendly_added)
friendly_trig = _humanize_tag_label(trig)
if trig:
parts.append(f"tag: {trig}")
reason = "".join(parts)
tag_fragment = friendly_trig or str(trig).strip()
if tag_fragment:
parts.append(f"tag: {tag_fragment}")
deduped_parts: list[str] = []
seen_parts: set[str] = set()
for part in parts:
if not part:
continue
norm = part.strip().lower()
if not norm or norm in seen_parts:
continue
seen_parts.add(norm)
deduped_parts.append(part)
reason = "".join(deduped_parts)
display_tags = _display_tags_from_entry(entry)
slug_tags: List[str] = []
slug_seen: set[str] = set()
for source_list in (_coerce_tag_iterable(entry.get('Tags')), _split_composite_tags(trig)):
for tag_val in source_list:
key_slug = str(tag_val).strip().lower()
if not key_slug or key_slug in slug_seen:
continue
slug_seen.add(key_slug)
slug_tags.append(str(tag_val).strip())
added_cards.append({
"name": name,
"count": delta_count,
@ -2458,6 +2613,8 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"role": role,
"sub_role": sub_role,
"trigger_tag": trig,
"tags": display_tags,
"tags_slug": slug_tags,
})
except Exception:
continue

View file

@ -1,31 +1,158 @@
from __future__ import annotations
from typing import Any, Dict
from typing import Any, Dict, Iterable, List, Optional
from deck_builder import builder_constants as bc
from .build_utils import owned_set as owned_set_helper
from .combo_utils import detect_for_summary as _detect_for_summary
def _sanitize_tag_list(values: Iterable[Any]) -> List[str]:
cleaned: List[str] = []
for raw in values or []: # type: ignore[arg-type]
text = str(raw or "").strip()
if not text:
continue
if text.startswith("['"):
text = text[2:]
if text.endswith("']") and len(text) >= 2:
text = text[:-2]
if text.startswith('"') and text.endswith('"') and len(text) >= 2:
text = text[1:-1]
if text.startswith("'") and text.endswith("'") and len(text) >= 2:
text = text[1:-1]
text = text.strip(" []\t\n\r")
if not text:
continue
cleaned.append(text)
return cleaned
def _normalize_summary_tags(summary: dict[str, Any] | None) -> None:
if not summary:
return
try:
type_breakdown = summary.get("type_breakdown") or {}
cards_by_type = type_breakdown.get("cards") or {}
for clist in cards_by_type.values():
if not isinstance(clist, list):
continue
for card in clist:
if not isinstance(card, dict):
continue
tags = card.get("tags") or []
if tags:
card["tags"] = _sanitize_tag_list(tags)
except Exception:
pass
def format_theme_label(raw: Any) -> str:
text = str(raw or "").strip()
if not text:
return ""
text = text.replace("_", " ")
words = []
for part in text.split():
if not part:
continue
if part.isupper():
words.append(part)
else:
words.append(part[0].upper() + part[1:].lower() if len(part) > 1 else part.upper())
return " ".join(words)
def format_theme_list(values: Iterable[Any]) -> List[str]:
seen: set[str] = set()
result: List[str] = []
for raw in values or []: # type: ignore[arg-type]
label = format_theme_label(raw)
if not label:
continue
if len(label) <= 1:
continue
key = label.casefold()
if key in seen:
continue
seen.add(key)
result.append(label)
return result
def summary_ctx(
*,
summary: dict | None,
commander: str | None = None,
tags: list[str] | None = None,
meta: Optional[dict[str, Any]] = None,
include_versions: bool = True,
) -> Dict[str, Any]:
"""Build a unified context payload for deck summary panels.
Provides owned_set, game_changers, combos/synergies, and detector versions.
"""
_normalize_summary_tags(summary)
det = _detect_for_summary(summary, commander_name=commander or "") if summary else {"combos": [], "synergies": [], "versions": {}}
combos = det.get("combos", [])
synergies = det.get("synergies", [])
synergies_raw = det.get("synergies", []) or []
# Flatten synergy tag names while preserving appearance order and collapsing duplicates case-insensitively
synergy_tags: list[str] = []
seen: set[str] = set()
for entry in synergies_raw:
if entry is None:
continue
if isinstance(entry, dict):
tags = entry.get("tags", []) or []
else:
tags = getattr(entry, "tags", None) or []
for tag in tags:
text = str(tag).strip()
if not text:
continue
key = text.casefold()
if key in seen:
continue
seen.add(key)
synergy_tags.append(text)
if not synergy_tags:
fallback_sources: list[str] = []
for collection in (tags or []):
fallback_sources.append(str(collection))
meta_obj = meta or {}
meta_keys = [
"display_themes",
"resolved_themes",
"auto_filled_themes",
"random_display_themes",
"random_resolved_themes",
"random_auto_filled_themes",
"primary_theme",
"secondary_theme",
"tertiary_theme",
]
for key in meta_keys:
value = meta_obj.get(key) if isinstance(meta_obj, dict) else None
if isinstance(value, list):
fallback_sources.extend(str(v) for v in value)
elif isinstance(value, str):
fallback_sources.append(value)
for raw in fallback_sources:
label = format_theme_label(raw)
if not label:
continue
key = label.casefold()
if key in seen:
continue
seen.add(key)
synergy_tags.append(label)
versions = det.get("versions", {} if include_versions else None)
return {
"owned_set": owned_set_helper(),
"game_changers": bc.GAME_CHANGERS,
"combos": combos,
"synergies": synergies,
"synergies": synergy_tags,
"synergy_pairs": synergies_raw,
"versions": versions,
"commander": commander,
"tags": tags or [],