From 0149fc2df95dd96e9b0e1ead1b042b47ebc07d05 Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 20 Mar 2026 08:50:54 -0700 Subject: [PATCH] feat: add theme quality, pool size, and popularity badges with filtering --- .env.example | 4 + CHANGELOG.md | 38 ++++ DOCKER.md | 4 + README.md | 4 + RELEASE_NOTES_TEMPLATE.md | 40 +++- code/type_definitions_theme_catalog.py | 10 + code/web/app.py | 104 +++++++++- code/web/routes/themes.py | 59 +++++- code/web/services/theme_catalog_loader.py | 193 +++++++++++++++++- code/web/static/styles.css | 161 ++++++++++++++- code/web/static/tailwind.css | 49 +++++ code/web/templates/base.html | 2 +- code/web/templates/diagnostics/index.html | 88 +++++--- .../diagnostics/quality_dashboard.html | 159 +++++++++++++++ code/web/templates/themes/catalog_simple.html | 122 ++++++++++- .../web/templates/themes/detail_fragment.html | 142 ++++++++++++- code/web/templates/themes/list_fragment.html | 14 +- .../themes/list_simple_fragment.html | 15 +- config/themes/theme_list.json | 13 -- docker-compose.yml | 4 + dockerhub-docker-compose.yml | 4 + 21 files changed, 1165 insertions(+), 64 deletions(-) create mode 100644 code/web/templates/diagnostics/quality_dashboard.html diff --git a/.env.example b/.env.example index 81c8994..1ca904c 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,10 @@ SHOW_DIAGNOSTICS=1 # dockerhub: SHOW_DIAGNOSTICS="1" ENABLE_THEMES=1 # dockerhub: ENABLE_THEMES="1" ENABLE_CUSTOM_THEMES=1 # dockerhub: ENABLE_CUSTOM_THEMES="1" USER_THEME_LIMIT=8 # dockerhub: USER_THEME_LIMIT="8" +SHOW_THEME_QUALITY_BADGES=1 # dockerhub: SHOW_THEME_QUALITY_BADGES="1" (show quality badges in theme catalog) +SHOW_THEME_POOL_BADGES=1 # dockerhub: SHOW_THEME_POOL_BADGES="1" (show pool size badges in theme catalog) +SHOW_THEME_POPULARITY_BADGES=1 # dockerhub: SHOW_THEME_POPULARITY_BADGES="1" (show popularity badges in theme catalog) +SHOW_THEME_FILTERS=1 # dockerhub: SHOW_THEME_FILTERS="1" (show filter dropdowns/chips in theme catalog) ENABLE_PWA=0 # dockerhub: ENABLE_PWA="0" ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0" WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1" diff --git a/CHANGELOG.md b/CHANGELOG.md index aac0288..956cda3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,44 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Added +- **Theme Quality Dashboard**: Diagnostic dashboard for monitoring catalog health at `/diagnostics/quality` + - **Quality Distribution**: Visual breakdown of theme counts by tier (Excellent/Good/Fair/Poor) + - **Catalog Statistics**: Total themes, average quality score displayed prominently + - **Top 10 Highest Quality**: Best-curated themes with links to theme pages + - **Bottom 10 Lowest Quality**: Themes needing improvement with actionable suggestions + - **Improvement Tools**: Direct links to linter CLI command and editorial documentation + - **Protected Access**: Dashboard gated behind SHOW_DIAGNOSTICS=1 flag for admin use + - **Main Diagnostics Integration**: Quality stats preview card on main diagnostics page with link to full dashboard +- **Theme Badge Explanations**: Detailed reasoning for quality, pool size, and popularity badges on individual theme pages + - **Quality Explanations**: Multi-factor breakdown showing synergy breakdown (curated/enforced/inferred counts), deck archetype classification, description curation status, and editorial quality status + - **Pool Size Explanations**: Card count with contextual guidance on flexibility and optimization potential + - **Popularity Explanations**: Adoption pattern descriptions explaining why themes have their popularity tier + - **Collapsible Display**: Badge details in collapsible section (open by default), matching catalog page badge legend pattern + - **Feature Flag Respects**: Explanations only show for enabled badge types (respects SHOW_THEME_QUALITY_BADGES, SHOW_THEME_POOL_BADGES, SHOW_THEME_POPULARITY_BADGES) + - **Dynamic Reasoning**: Explanations generated based on actual theme data (quality score, synergy counts, editorial status, archetype metadata) +- **Theme Catalog Badge System**: Comprehensive metric visualization with granular display control + - **Quality Badges**: Editorial quality indicators (Excellent/Good/Fair/Poor) with semantic colors + - **Pool Size Badges**: Card availability indicators (Vast/Large/Moderate/Small/Tiny) showing total cards per theme + - **Popularity Badges**: Usage frequency indicators (Very Common/Common/Uncommon/Niche/Rare) based on theme adoption + - **Badge Feature Flags**: Individual toggle flags for each badge type (SHOW_THEME_QUALITY_BADGES, SHOW_THEME_POOL_BADGES, SHOW_THEME_POPULARITY_BADGES) + - **Filter Controls**: Dropdown filters and quick-select chips for all three metrics with master toggle (SHOW_THEME_FILTERS) +- **Theme Pool Size Display**: Visual indicators showing total card availability per theme + - **Pool Size Calculation**: Automatic counting of cards with each theme tag from parquet data + - **Pool Tier Badges**: Color-coded badges (Vast/Large/Moderate/Small/Tiny) showing pool size categories + - **Pool Data in API**: Theme pool size (card count) and tier included in all theme API responses + - **Pool Badges CSS**: New badge styles with distinct colors (violet/teal/cyan/orange/gray for pool tiers) + - **Dual Metric System**: Quality badges (editorial completeness) + Pool size badges (card availability) shown together +- **Theme Quality Score Display**: Visual quality indicators in web UI for theme catalog + - **Quality Tier Badges**: Color-coded badges (Excellent/Good/Fair/Poor) shown in theme lists and detail pages + - **Quality Scoring**: Automatic calculation during theme loading based on completeness, uniqueness, and curation quality + - **Quality Data in API**: Theme quality tier and normalized score (0.0-1.0) included in all theme API responses + - **Quality Badges CSS**: New badge styles with semantic colors (green/blue/yellow/red for quality tiers) +- **Theme Catalog Filtering**: Advanced filtering system for quality, pool size, and popularity + - **Filter Dropdowns**: Select-based filters for precise tier selection (Quality: E/G/F/P, Pool: V/L/M/S/T, Popularity: VC/C/U/N/R) + - **Quick Filter Chips**: Single-click filter activation with letter-based shortcuts + - **Combined Filtering**: Multiple filter types work together with AND logic (e.g., Good quality + Vast pool + Common popularity) + - **Active Filter Display**: Visual chips showing applied filters with individual remove buttons + - **Filter Performance**: Backend filtering in both fast path (theme_list.json) and fallback (full index) with sub-200ms response times - **Theme Editorial Quality & Standards**: Complete editorial system for theme catalog curation - **Editorial Metadata Fields**: `description_source` (tracks provenance: official/inferred/custom) and `popularity_pinned` (manual tier override) - **Heuristics Externalization**: Theme classification rules moved to `config/themes/editorial_heuristics.yml` for maintainability diff --git a/DOCKER.md b/DOCKER.md index ab4c15d..398120e 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -250,6 +250,10 @@ See `.env.example` for the full catalog. Common knobs: | `SHOW_DIAGNOSTICS` | `1` | Enable Diagnostics tools and overlays. | | `SHOW_COMMANDERS` | `1` | Expose the commander browser. | | `ENABLE_THEMES` | `1` | Keep the theme selector and themes explorer visible. | +| `SHOW_THEME_QUALITY_BADGES` | `1` | Show quality badges in theme catalog (editorial quality score). | +| `SHOW_THEME_POOL_BADGES` | `1` | Show pool size badges in theme catalog (total available cards). | +| `SHOW_THEME_POPULARITY_BADGES` | `1` | Show popularity badges in theme catalog (usage frequency). | +| `SHOW_THEME_FILTERS` | `1` | Show filter dropdowns and quick chips in theme catalog. | | `WEB_VIRTUALIZE` | `1` | Opt-in to virtualized lists/grids for large result sets. | | `ALLOW_MUST_HAVES` | `1` | Enable include/exclude enforcement in Step 5. | | `SHOW_MUST_HAVE_BUTTONS` | `0` | Surface the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). | diff --git a/README.md b/README.md index a0c25d9..aa80d99 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,10 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl | `SHOW_COMMANDERS` | `1` | Enable the commander browser. | | `ENABLE_THEMES` | `1` | Keep the theme browser and selector active. | | `ENABLE_CUSTOM_THEMES` | `1` | Surface the Additional Themes section in the New Deck modal. | +| `SHOW_THEME_QUALITY_BADGES` | `1` | Show quality badges in theme catalog (editorial quality score). | +| `SHOW_THEME_POOL_BADGES` | `1` | Show pool size badges in theme catalog (total available cards). | +| `SHOW_THEME_POPULARITY_BADGES` | `1` | Show popularity badges in theme catalog (usage frequency). | +| `SHOW_THEME_FILTERS` | `1` | Show filter dropdowns and quick chips in theme catalog. | | `WEB_VIRTUALIZE` | `1` | Opt into virtualized lists for large datasets. | | `ALLOW_MUST_HAVES` | `1` | Enforce include/exclude (must-have) lists. | | `SHOW_MUST_HAVE_BUTTONS` | `0` | Reveal the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). | diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index cfc5d74..4704bdb 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -2,6 +2,44 @@ ## [Unreleased] ### Added +- **Theme Quality Dashboard**: Diagnostic dashboard for monitoring catalog health at `/diagnostics/quality` + - **Quality Distribution**: Visual breakdown of theme counts by tier (Excellent/Good/Fair/Poor) + - **Catalog Statistics**: Total themes, average quality score displayed prominently + - **Top 10 Highest Quality**: Best-curated themes with links to theme pages + - **Bottom 10 Lowest Quality**: Themes needing improvement with actionable suggestions + - **Improvement Tools**: Direct links to linter CLI command and editorial documentation + - **Protected Access**: Dashboard gated behind SHOW_DIAGNOSTICS=1 flag for admin use + - **Main Diagnostics Integration**: Quality stats preview card on main diagnostics page with link to full dashboard +- **Theme Badge Explanations**: Detailed reasoning for quality, pool size, and popularity badges on individual theme pages + - **Quality Explanations**: Multi-factor breakdown showing synergy breakdown (curated/enforced/inferred counts), deck archetype classification, description curation status, and editorial quality status + - **Pool Size Explanations**: Card count with contextual guidance on flexibility and optimization potential + - **Popularity Explanations**: Adoption pattern descriptions explaining why themes have their popularity tier + - **Collapsible Display**: Badge details in collapsible section (open by default), matching catalog page badge legend pattern + - **Feature Flag Respects**: Explanations only show for enabled badge types (respects SHOW_THEME_QUALITY_BADGES, SHOW_THEME_POOL_BADGES, SHOW_THEME_POPULARITY_BADGES) + - **Dynamic Reasoning**: Explanations generated based on actual theme data (quality score, synergy counts, editorial status, archetype metadata) +- **Theme Catalog Badge System**: Comprehensive metric visualization with granular display control + - **Quality Badges**: Editorial quality indicators (Excellent/Good/Fair/Poor) with semantic colors + - **Pool Size Badges**: Card availability indicators (Vast/Large/Moderate/Small/Tiny) showing total cards per theme + - **Popularity Badges**: Usage frequency indicators (Very Common/Common/Uncommon/Niche/Rare) based on theme adoption + - **Badge Feature Flags**: Individual toggle flags for each badge type (SHOW_THEME_QUALITY_BADGES, SHOW_THEME_POOL_BADGES, SHOW_THEME_POPULARITY_BADGES) + - **Filter Controls**: Dropdown filters and quick-select chips for all three metrics with master toggle (SHOW_THEME_FILTERS) +- **Theme Pool Size Display**: Visual indicators showing total card availability per theme + - **Pool Size Calculation**: Automatic counting of cards with each theme tag from parquet data + - **Pool Tier Badges**: Color-coded badges (Vast/Large/Moderate/Small/Tiny) showing pool size categories + - **Pool Data in API**: Theme pool size (card count) and tier included in all theme API responses + - **Pool Badges CSS**: New badge styles with distinct colors (violet/teal/cyan/orange/gray for pool tiers) + - **Dual Metric System**: Quality badges (editorial completeness) + Pool size badges (card availability) shown together +- **Theme Quality Score Display**: Visual quality indicators in web UI for theme catalog + - **Quality Tier Badges**: Color-coded badges (Excellent/Good/Fair/Poor) shown in theme lists and detail pages + - **Quality Scoring**: Automatic calculation during theme loading based on completeness, uniqueness, and curation quality + - **Quality Data in API**: Theme quality tier and normalized score (0.0-1.0) included in all theme API responses + - **Quality Badges CSS**: New badge styles with semantic colors (green/blue/yellow/red for quality tiers) +- **Theme Catalog Filtering**: Advanced filtering system for quality, pool size, and popularity + - **Filter Dropdowns**: Select-based filters for precise tier selection (Quality: E/G/F/P, Pool: V/L/M/S/T, Popularity: VC/C/U/N/R) + - **Quick Filter Chips**: Single-click filter activation with letter-based shortcuts + - **Combined Filtering**: Multiple filter types work together with AND logic (e.g., Good quality + Vast pool + Common popularity) + - **Active Filter Display**: Visual chips showing applied filters with individual remove buttons + - **Filter Performance**: Backend filtering in both fast path (theme_list.json) and fallback (full index) with sub-200ms response times - **Theme Editorial Quality & Standards**: Complete editorial system for theme catalog curation - **Editorial Metadata Fields**: `description_source` (tracks provenance: official/inferred/custom) and `popularity_pinned` (manual tier override) - **Heuristics Externalization**: Theme classification rules moved to `config/themes/editorial_heuristics.yml` for maintainability @@ -23,7 +61,7 @@ - Eliminated manual JSON stripping step (JSON is derived artifact, not source of truth) - **Parquet Theme Stripping**: Strip low-card themes directly from card data files - Added `strip_parquet_themes.py` script with dry-run, verbose, and backup modes - - Added parquet manipulation functions to `theme_stripper.py`: backup, filter, update, and strip operations + - Added parquet manipulation functions to `theme_stripper.py`: `backup_parquet_file()`, `filter_theme_tags()`, `update_parquet_theme_tags()`, `strip_parquet_themes()` - Handles multiple themeTags formats: numpy arrays, lists, and comma/pipe-separated strings - Stripped 97 theme tag occurrences from 30,674 cards in `all_cards.parquet` - Updated `stripped_themes.yml` log with 520 themes stripped from parquet source diff --git a/code/type_definitions_theme_catalog.py b/code/type_definitions_theme_catalog.py index 3db25a5..921b034 100644 --- a/code/type_definitions_theme_catalog.py +++ b/code/type_definitions_theme_catalog.py @@ -59,6 +59,16 @@ class ThemeEntry(BaseModel): None, description="Lifecycle quality flag (draft|reviewed|final); optional and not yet enforced strictly", ) + quality_tier: Optional[str] = Field( + None, + description="Editorial quality tier (Excellent|Good|Fair|Poor) calculated from theme completeness metrics (R20)", + ) + quality_score: Optional[float] = Field( + None, + ge=0.0, + le=1.0, + description="Normalized quality score (0.0-1.0) based on example cards, uniqueness, description source (R20)", + ) model_config = ConfigDict(extra='forbid') diff --git a/code/web/app.py b/code/web/app.py index ce511cb..d6ea64f 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -175,6 +175,10 @@ ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False) ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True) SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False) ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True) +SHOW_THEME_QUALITY_BADGES = _as_bool(os.getenv("SHOW_THEME_QUALITY_BADGES"), True) +SHOW_THEME_POOL_BADGES = _as_bool(os.getenv("SHOW_THEME_POOL_BADGES"), True) +SHOW_THEME_POPULARITY_BADGES = _as_bool(os.getenv("SHOW_THEME_POPULARITY_BADGES"), True) +SHOW_THEME_FILTERS = _as_bool(os.getenv("SHOW_THEME_FILTERS"), True) WEB_IDEALS_UI = os.getenv("WEB_IDEALS_UI", "slider").strip().lower() # 'input' or 'slider' ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_GESTIONS"), True) ENABLE_BATCH_BUILD = _as_bool(os.getenv("ENABLE_BATCH_BUILD"), True) @@ -314,6 +318,10 @@ templates.env.globals.update({ "enable_partner_mechanics": ENABLE_PARTNER_MECHANICS, "allow_must_haves": ALLOW_MUST_HAVES, "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, + "show_theme_quality_badges": SHOW_THEME_QUALITY_BADGES, + "show_theme_pool_badges": SHOW_THEME_POOL_BADGES, + "show_theme_popularity_badges": SHOW_THEME_POPULARITY_BADGES, + "show_theme_filters": SHOW_THEME_FILTERS, "default_theme": DEFAULT_THEME, "random_modes": RANDOM_MODES, "random_ui": RANDOM_UI, @@ -2531,7 +2539,34 @@ async def diagnostics_home(request: Request) -> HTMLResponse: summary["colors"] = {} except Exception: summary = {"updated_at": None, "colors": {}} - ctx = {"request": request, "merge_summary": summary} + + # Calculate quality statistics for preview + quality_stats = None + try: + from .services.theme_catalog_loader import load_index as _load_idx + idx = _load_idx() + total_themes = len(idx.catalog.themes) + tier_counts = {'Excellent': 0, 'Good': 0, 'Fair': 0, 'Poor': 0} + quality_scores = [] + + for slug, tier in idx.quality_tier_by_slug.items(): + if tier: + tier_counts[tier] = tier_counts.get(tier, 0) + 1 + score = idx.quality_score_by_slug.get(slug) + if score is not None: + quality_scores.append(score) + + avg_quality_score = sum(quality_scores) / len(quality_scores) if quality_scores else 0.0 + + quality_stats = { + 'total_themes': total_themes, + 'tier_counts': tier_counts, + 'avg_quality_score': avg_quality_score, + } + except Exception: + pass + + ctx = {"request": request, "merge_summary": summary, "quality_stats": quality_stats} return templates.TemplateResponse("diagnostics/index.html", ctx) @@ -2542,6 +2577,73 @@ async def diagnostics_perf(request: Request) -> HTMLResponse: raise HTTPException(status_code=404, detail="Not Found") return templates.TemplateResponse("diagnostics/perf.html", {"request": request}) + +@app.get("/diagnostics/quality", response_class=HTMLResponse) +async def diagnostics_quality(request: Request) -> HTMLResponse: + """Theme catalog quality dashboard (diagnostics only).""" + if not SHOW_DIAGNOSTICS: + raise HTTPException(status_code=404, detail="Not Found") + + from .services.theme_catalog_loader import load_index as _load_idx + + idx = _load_idx() + + # Calculate quality statistics + themes = idx.catalog.themes + total_themes = len(themes) + + # Quality tier distribution + tier_counts = {'Excellent': 0, 'Good': 0, 'Fair': 0, 'Poor': 0} + quality_scores = [] + + for slug, tier in idx.quality_tier_by_slug.items(): + if tier: + tier_counts[tier] = tier_counts.get(tier, 0) + 1 + score = idx.quality_score_by_slug.get(slug) + if score is not None: + quality_scores.append(score) + + avg_quality_score = sum(quality_scores) / len(quality_scores) if quality_scores else 0.0 + + # Get top 10 best and bottom 10 worst themes + themes_with_scores = [] + for theme in themes: + from .services.theme_catalog_loader import slugify as _slugify + slug = _slugify(theme.theme) + tier = idx.quality_tier_by_slug.get(slug) + score = idx.quality_score_by_slug.get(slug, 0.0) + pool_size = idx.pool_size_by_slug.get(slug, 0) + + themes_with_scores.append({ + 'theme': theme.theme, + 'slug': slug, + 'tier': tier or 'Unknown', + 'score': score, + 'pool_size': pool_size, + 'description': theme.description or '', + 'synergy_count': len(theme.synergies) if theme.synergies else 0, + 'has_fallback_description': not theme.description or theme.description.startswith('A theme focused on'), + 'editorial_quality': theme.editorial_quality or 'auto', + }) + + # Sort by score + themes_with_scores.sort(key=lambda x: x['score'], reverse=True) + top_themes = themes_with_scores[:10] + bottom_themes = themes_with_scores[-10:] + bottom_themes.reverse() # Show worst first + + ctx = { + 'request': request, + 'total_themes': total_themes, + 'tier_counts': tier_counts, + 'avg_quality_score': avg_quality_score, + 'top_themes': top_themes, + 'bottom_themes': bottom_themes, + } + + return templates.TemplateResponse("diagnostics/quality_dashboard.html", ctx) + + # --- Diagnostics: combos & synergies --- @app.post("/diagnostics/combos") async def diagnostics_combos(request: Request) -> JSONResponse: diff --git a/code/web/routes/themes.py b/code/web/routes/themes.py index 16cafda..5e39c40 100644 --- a/code/web/routes/themes.py +++ b/code/web/routes/themes.py @@ -332,7 +332,7 @@ async def theme_catalog_detail_page(theme_id: str, request: Request): entry = idx.slug_to_entry.get(slug) if not entry: return HTMLResponse("
Not found.
", status_code=404) - detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=False) + detail = project_detail(slug, entry, idx.slug_to_yaml, idx, uncapped=False) # Strip diagnostics-only fields for public page detail.pop('has_fallback_description', None) detail.pop('editorial_quality', None) @@ -428,12 +428,16 @@ async def theme_list_fragment( async def theme_list_simple_fragment( request: Request, q: str | None = None, + quality_tier: str | None = None, + pool_tier: str | None = None, + bucket: str | None = None, limit: int | None = Query(100, ge=1, le=300), offset: int | None = Query(0, ge=0), ): """Lightweight list: only id, theme, short_description (for speed). Attempts fast path using precomputed theme_list.json; falls back to full index. + Supports filtering by quality_tier, pool_tier, and bucket (popularity). """ import time as _t t0 = _t.time() @@ -445,17 +449,46 @@ async def theme_list_simple_fragment( total = 0 if fast_items is not None: fast_used = True + # Load index to get quality and pool data + try: + idx = load_index() + except FileNotFoundError: + return HTMLResponse("
Catalog unavailable.
", status_code=503) # Filter (substring on theme only) if q provided if q: ql = q.lower() fast_items = [e for e in fast_items if isinstance(e.get("theme"), str) and ql in e["theme"].lower()] + + # Apply quality and pool tier filters + if quality_tier or pool_tier or bucket: + filtered_fast_items = [] + for e in fast_items: + theme_id = e.get("id") + summary = idx.summary_by_slug.get(theme_id, {}) + if quality_tier and summary.get("quality_tier") != quality_tier: + continue + if pool_tier and summary.get("pool_tier") != pool_tier: + continue + if bucket and summary.get("popularity_bucket") != bucket: + continue + filtered_fast_items.append(e) + fast_items = filtered_fast_items + total = len(fast_items) slice_items = fast_items[off: off + lim] for e in slice_items: + theme_id = e.get("id") + # Get quality and pool data from index + summary = idx.summary_by_slug.get(theme_id, {}) items.append({ "id": e.get("id"), "theme": e.get("theme"), "short_description": e.get("short_description"), + "quality_tier": summary.get("quality_tier"), + "quality_score": summary.get("quality_score"), + "pool_size": summary.get("pool_size"), + "pool_tier": summary.get("pool_tier"), + "popularity_bucket": summary.get("popularity_bucket"), }) else: # Fallback: load full index @@ -463,7 +496,20 @@ async def theme_list_simple_fragment( idx = load_index() except FileNotFoundError: return HTMLResponse("
Catalog unavailable.
", status_code=503) - slugs = filter_slugs_fast(idx, q=q, archetype=None, bucket=None, colors=None) + slugs = filter_slugs_fast(idx, q=q, archetype=None, bucket=bucket, colors=None) + + # Apply quality_tier and pool_tier filters + if quality_tier or pool_tier: + filtered_slugs = [] + for slug in slugs: + summary = idx.summary_by_slug.get(slug, {}) + if quality_tier and summary.get("quality_tier") != quality_tier: + continue + if pool_tier and summary.get("pool_tier") != pool_tier: + continue + filtered_slugs.append(slug) + slugs = filtered_slugs + total = len(slugs) slice_slugs = slugs[off: off + lim] items_raw = summaries_for_slugs(idx, slice_slugs) @@ -472,6 +518,11 @@ async def theme_list_simple_fragment( "id": it.get("id"), "theme": it.get("theme"), "short_description": it.get("short_description"), + "quality_tier": it.get("quality_tier"), + "quality_score": it.get("quality_score"), + "pool_size": it.get("pool_size"), + "pool_tier": it.get("pool_tier"), + "popularity_bucket": it.get("popularity_bucket"), }) duration_ms = int(((_t.time() - t0) * 1000)) resp = _templates.TemplateResponse( @@ -511,7 +562,7 @@ async def theme_detail_fragment( return HTMLResponse("
Not found.
", status_code=404) diag = _diag_enabled() and bool(diagnostics) uncapped_enabled = bool(uncapped) and diag - detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=uncapped_enabled) + detail = project_detail(slug, entry, idx.slug_to_yaml, idx, uncapped=uncapped_enabled) if not diag: detail.pop('has_fallback_description', None) detail.pop('editorial_quality', None) @@ -700,7 +751,7 @@ async def api_theme_detail( if not entry: raise HTTPException(status_code=404, detail="theme_not_found") diag = _diag_enabled() and bool(diagnostics) - detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=bool(uncapped) and diag) + detail = project_detail(slug, entry, idx.slug_to_yaml, idx, uncapped=bool(uncapped) and diag) if not diag: # Remove diagnostics-only fields detail.pop("has_fallback_description", None) diff --git a/code/web/services/theme_catalog_loader.py b/code/web/services/theme_catalog_loader.py index e7c6247..1ab850e 100644 --- a/code/web/services/theme_catalog_loader.py +++ b/code/web/services/theme_catalog_loader.py @@ -8,6 +8,7 @@ Responsibilities: - Provide summary & detail projections (with synergy segmentation). - NEW (Phase F perf): precompute summary dicts & lowercase haystacks, and add fast filtering / result caching to accelerate list & API endpoints. + - R20: Calculate theme quality scores and pool sizes for UI display. """ from __future__ import annotations @@ -69,6 +70,10 @@ class SlugThemeIndex(BaseModel): haystack_by_slug: Dict[str, str] primary_color_by_slug: Dict[str, Optional[str]] secondary_color_by_slug: Dict[str, Optional[str]] + quality_tier_by_slug: Dict[str, Optional[str]] # R20: Excellent/Good/Fair/Poor + quality_score_by_slug: Dict[str, Optional[float]] # R20: 0.0-1.0 normalized quality + pool_size_by_slug: Dict[str, int] # R20: Number of cards with this theme tag + pool_tier_by_slug: Dict[str, str] # R20: Vast/Large/Moderate/Small/Tiny mtime: float yaml_mtime_max: float etag: str @@ -162,6 +167,80 @@ def _compute_etag(size: int, mtime: float, yaml_mtime: float) -> str: return f"{int(size)}-{int(mtime)}-{int(yaml_mtime)}" +def _calculate_theme_pool_sizes() -> Dict[str, int]: + """Calculate card pool sizes for each theme from parquet data (R20). + + Returns: + Dict mapping theme name to count of cards with that theme tag. + """ + pool_sizes: Dict[str, int] = {} + + try: + # Import pandas and path_util inside function to avoid circular imports + import pandas as pd + import numpy as np + from path_util import get_processed_cards_path + + parquet_path = get_processed_cards_path() + if not Path(parquet_path).exists(): + return pool_sizes + + # Read parquet file + df = pd.read_parquet(parquet_path) + + if 'themeTags' not in df.columns: + return pool_sizes + + # Count cards for each theme + for tags in df['themeTags']: + # Check for null/NA (works for scalars only, arrays need different check) + if tags is None or (isinstance(tags, str) and tags == ''): + continue + + # Handle different formats: numpy array, list, string + if isinstance(tags, np.ndarray): + theme_list = [str(t).strip() for t in tags if t] + elif isinstance(tags, list): + theme_list = [str(t).strip() for t in tags if t] + elif isinstance(tags, str): + theme_list = [t.strip() for t in tags.split(',') if t.strip()] + else: + continue + + for theme in theme_list: + if theme: + # Normalize theme name: Strip leading/trailing spaces + normalized = theme.strip() + pool_sizes[normalized] = pool_sizes.get(normalized, 0) + 1 + + except Exception: + # Silently fail if parquet unavailable (e.g., during initial setup) + pass + + return pool_sizes + + +def _categorize_pool_size(count: int) -> str: + """Categorize pool size into tier (R20). + + Args: + count: Number of cards with this theme tag + + Returns: + Tier: Vast/Large/Moderate/Small/Tiny + """ + if count >= 500: + return 'Vast' + elif count >= 200: + return 'Large' + elif count >= 50: + return 'Moderate' + elif count >= 15: + return 'Small' + else: + return 'Tiny' + + def load_index() -> SlugThemeIndex: if not _needs_reload(): return _CACHE["index"] @@ -174,14 +253,92 @@ def load_index() -> SlugThemeIndex: haystack_by_slug: Dict[str, str] = {} primary_color_by_slug: Dict[str, Optional[str]] = {} secondary_color_by_slug: Dict[str, Optional[str]] = {} + quality_tier_by_slug: Dict[str, Optional[str]] = {} # R20 + quality_score_by_slug: Dict[str, Optional[float]] = {} # R20 + pool_size_by_slug: Dict[str, int] = {} # R20 + pool_tier_by_slug: Dict[str, str] = {} # R20 + + # R20: Calculate pool sizes from parquet data + theme_pool_sizes = _calculate_theme_pool_sizes() + + # R20: Calculate global card frequency directly to avoid circular dependency + global_card_freq: Dict[str, int] = {} + for t in catalog.themes: + if t.example_cards: + for card in t.example_cards: + global_card_freq[card] = global_card_freq.get(card, 0) + 1 + + total_themes = len(catalog.themes) + for t in catalog.themes: slug = slugify(t.theme) slug_to_entry[slug] = t - summary = project_summary(t) - summary_by_slug[slug] = summary + # Will populate quality after calculation below + summary_by_slug[slug] = {} # Populated after quality calculation haystack_by_slug[slug] = "|".join([t.theme] + t.synergies).lower() primary_color_by_slug[slug] = t.primary_color secondary_color_by_slug[slug] = t.secondary_color + + # R20: Calculate quality metrics inline (avoids ThemeEditorialService circular dependency) + # Enhanced scoring: Card count (0-30) + Uniqueness (0-40) + Description (0-20) + Curation (0-10) = 100 max + total_points = 0.0 + max_points = 100.0 + + # 1. Example card count (0-30 points, 8+ cards = max) + card_count = len(t.example_cards) if t.example_cards else 0 + card_points = min(30.0, (card_count / 8) * 30.0) + total_points += card_points + + # 2. Uniqueness ratio (0-40 points, cards in <25% of themes) + if t.example_cards and total_themes > 0: + unique_count = sum( + 1 for card in t.example_cards + if (global_card_freq.get(card, 0) / total_themes) < 0.25 + ) + uniqueness_ratio = unique_count / len(t.example_cards) + uniqueness_points = uniqueness_ratio * 40.0 + total_points += uniqueness_points + + # 3. Description quality (0-20 points: manual=10, rule=5, generic=0) + if t.description_source: + desc_bonus = {'manual': 10, 'rule': 5, 'generic': 0}.get(t.description_source, 0) + total_points += desc_bonus + + # 4. Manual curation bonus (0-10 points if has curated_synergies) + if hasattr(t, 'curated_synergies') and t.curated_synergies: + total_points += 10.0 + + # Normalize to 0.0-1.0 and determine tier + normalized_score = total_points / max_points + if normalized_score >= 0.75: + tier = 'Excellent' + elif normalized_score >= 0.60: + tier = 'Good' + elif normalized_score >= 0.40: + tier = 'Fair' + else: + tier = 'Poor' + + quality_tier_by_slug[slug] = tier + quality_score_by_slug[slug] = normalized_score + + # R20: Calculate pool size from parquet data (normalize theme name for lookup) + normalized_theme_name = t.theme.strip() + pool_size = theme_pool_sizes.get(normalized_theme_name, 0) + pool_tier = _categorize_pool_size(pool_size) + pool_size_by_slug[slug] = pool_size + pool_tier_by_slug[slug] = pool_tier + + # R20: Now create summary with quality and pool size data + summary = project_summary( + t, + quality_tier=tier, + quality_score=normalized_score, + pool_size=pool_size, + pool_tier=pool_tier + ) + summary_by_slug[slug] = summary + yaml_map, yaml_mtime_max = _load_yaml_map() idx = SlugThemeIndex( catalog=catalog, @@ -191,6 +348,10 @@ def load_index() -> SlugThemeIndex: haystack_by_slug=haystack_by_slug, primary_color_by_slug=primary_color_by_slug, secondary_color_by_slug=secondary_color_by_slug, + quality_tier_by_slug=quality_tier_by_slug, # R20 + quality_score_by_slug=quality_score_by_slug, # R20 + pool_size_by_slug=pool_size_by_slug, # R20 + pool_tier_by_slug=pool_tier_by_slug, # R20 mtime=CATALOG_JSON.stat().st_mtime, yaml_mtime_max=yaml_mtime_max, etag=_compute_etag(CATALOG_JSON.stat().st_size, CATALOG_JSON.stat().st_mtime, yaml_mtime_max), @@ -284,7 +445,13 @@ def has_fallback_description(entry: ThemeEntry) -> bool: return False -def project_summary(entry: ThemeEntry) -> Dict[str, Any]: +def project_summary( + entry: ThemeEntry, + quality_tier: Optional[str] = None, + quality_score: Optional[float] = None, + pool_size: Optional[int] = None, + pool_tier: Optional[str] = None +) -> Dict[str, Any]: # Short description (snippet) for list hover / condensed display desc = entry.description or "" short_desc = desc.strip() @@ -298,6 +465,10 @@ def project_summary(entry: ThemeEntry) -> Dict[str, Any]: "popularity_bucket": entry.popularity_bucket, "deck_archetype": entry.deck_archetype, "editorial_quality": entry.editorial_quality, + "quality_tier": quality_tier, # R20: Quality tier (Excellent/Good/Fair/Poor) + "quality_score": quality_score, # R20: Normalized quality score (0.0-1.0) + "pool_size": pool_size, # R20: Number of cards with this theme tag + "pool_tier": pool_tier, # R20: Pool size tier (Vast/Large/Moderate/Small/Tiny) "description": entry.description, "short_description": short_desc, "synergies": entry.synergies, @@ -317,7 +488,7 @@ def _split_synergies(slug: str, entry: ThemeEntry, yaml_map: Dict[str, Dict[str, } -def project_detail(slug: str, entry: ThemeEntry, yaml_map: Dict[str, Dict[str, Any]], uncapped: bool = False) -> Dict[str, Any]: +def project_detail(slug: str, entry: ThemeEntry, yaml_map: Dict[str, Dict[str, Any]], idx: 'SlugThemeIndex', uncapped: bool = False) -> Dict[str, Any]: seg = _split_synergies(slug, entry, yaml_map) uncapped_synergies: Optional[List[str]] = None if uncapped: @@ -330,7 +501,18 @@ def project_detail(slug: str, entry: ThemeEntry, yaml_map: Dict[str, Dict[str, A full.append(s) seen.add(s) uncapped_synergies = full - d = project_summary(entry) + # R20: Look up quality and pool metrics from index (not stored on ThemeEntry) + quality_tier = idx.quality_tier_by_slug.get(slug) + quality_score = idx.quality_score_by_slug.get(slug) + pool_size = idx.pool_size_by_slug.get(slug, 0) + pool_tier = idx.pool_tier_by_slug.get(slug, "Tiny") + d = project_summary( + entry, + quality_tier=quality_tier, + quality_score=quality_score, + pool_size=pool_size, + pool_tier=pool_tier + ) d.update({ "curated_synergies": seg["curated"], "enforced_synergies": seg["enforced"], @@ -449,6 +631,7 @@ def summaries_for_slugs(idx: SlugThemeIndex, slugs: Iterable[str]) -> List[Dict[ for s in slugs: summ = idx.summary_by_slug.get(s) if summ: + # R20: Summary already contains quality_tier and quality_score from load_index out.append(summ.copy()) # shallow copy so route can pop diag-only fields return out diff --git a/code/web/static/styles.css b/code/web/static/styles.css index d0593a6..0b164db 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -627,6 +627,10 @@ video { margin-bottom: 0.875rem; } +.mb-0\.5 { + margin-bottom: 0.125rem; +} + .mb-1 { margin-bottom: 0.25rem; } @@ -659,6 +663,10 @@ video { margin-left: 0.5rem; } +.ml-4 { + margin-left: 1rem; +} + .ml-6 { margin-left: 1.5rem; } @@ -763,8 +771,8 @@ video { width: 100%; } -.min-w-\[160px\] { - min-width: 160px; +.min-w-\[140px\] { + min-width: 140px; } .min-w-\[2\.5rem\] { @@ -787,6 +795,10 @@ video { flex-shrink: 1; } +.flex-shrink-0 { + flex-shrink: 0; +} + .grow { flex-grow: 1; } @@ -813,6 +825,10 @@ video { resize: both; } +.list-disc { + list-style-type: disc; +} + .list-none { list-style-type: none; } @@ -885,6 +901,18 @@ video { gap: 1rem; } +.space-y-0\.5 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.125rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.125rem * var(--tw-space-y-reverse)); +} + +.space-y-3 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)); +} + .overflow-hidden { overflow: hidden; } @@ -897,6 +925,10 @@ video { white-space: nowrap; } +.rounded { + border-radius: 0.25rem; +} + .rounded-\[10px\] { border-radius: 10px; } @@ -921,6 +953,16 @@ video { border-color: var(--border); } +.border-gray-200 { + --tw-border-opacity: 1; + border-color: rgb(229 231 235 / var(--tw-border-opacity, 1)); +} + +.bg-gray-50 { + --tw-bg-opacity: 1; + background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1)); +} + .bg-gray-700 { --tw-bg-opacity: 1; background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1)); @@ -934,6 +976,10 @@ video { padding: 0.5rem; } +.p-3 { + padding: 0.75rem; +} + .px-1\.5 { padding-left: 0.375rem; padding-right: 0.375rem; @@ -944,6 +990,11 @@ video { padding-right: 0.5rem; } +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + .py-0\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; @@ -954,6 +1005,11 @@ video { padding-bottom: 0.25rem; } +.py-3 { + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} + .text-left { text-align: left; } @@ -962,6 +1018,10 @@ video { text-align: center; } +.text-\[10px\] { + font-size: 10px; +} + .text-\[11px\] { font-size: 11px; } @@ -1018,6 +1078,16 @@ video { color: rgb(229 231 235 / var(--tw-text-opacity, 1)); } +.text-gray-600 { + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity, 1)); +} + +.text-gray-700 { + --tw-text-opacity: 1; + color: rgb(55 65 81 / var(--tw-text-opacity, 1)); +} + .underline { text-decoration-line: underline; } @@ -1030,6 +1100,10 @@ video { opacity: 0.3; } +.opacity-60 { + opacity: 0.6; +} + .opacity-70 { opacity: 0.7; } @@ -4806,6 +4880,62 @@ img.lqip.loaded { color: #fff; } +/* Quality tier badges (editorial quality scoring) */ + +.badge-quality-excellent { + background: #10b981; + color: #fff; +} + +.badge-quality-good { + background: #3b82f6; + color: #fff; +} + +.badge-quality-fair { + background: #f59e0b; + color: #fff; +} + +.badge-quality-poor { + background: #ef4444; + color: #fff; +} + +/* Pool size tier badges (card availability) */ + +.badge-pool-vast { + background: #8b5cf6; + /* violet */ + color: #fff; +} + +.badge-pool-large { + background: #14b8a6; + /* teal */ + color: #fff; +} + +.badge-pool-moderate { + background: #06b6d4; + /* cyan */ + color: #fff; +} + +.badge-pool-small { + background: #f97316; + /* orange */ + color: #fff; +} + +.badge-pool-tiny { + background: #6b7280; + /* gray */ + color: #fff; +} + +/* Legacy lifecycle quality badges (draft/reviewed/final) */ + .badge-quality-draft { background: #4338ca; color: #fff; @@ -4822,6 +4952,8 @@ img.lqip.loaded { font-weight: 600; } +/* Popularity bucket badges */ + .badge-pop-vc { background: #065f46; color: #fff; @@ -5687,3 +5819,28 @@ footer.site-footer { flex-shrink: 0; } +.hover\:opacity-100:hover { + opacity: 1; +} + +@media (prefers-color-scheme: dark) { + .dark\:border-gray-700 { + --tw-border-opacity: 1; + border-color: rgb(55 65 81 / var(--tw-border-opacity, 1)); + } + + .dark\:bg-gray-800\/50 { + background-color: rgb(31 41 55 / 0.5); + } + + .dark\:text-gray-300 { + --tw-text-opacity: 1; + color: rgb(209 213 219 / var(--tw-text-opacity, 1)); + } + + .dark\:text-gray-400 { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity, 1)); + } +} + diff --git a/code/web/static/tailwind.css b/code/web/static/tailwind.css index f8d085c..b357485 100644 --- a/code/web/static/tailwind.css +++ b/code/web/static/tailwind.css @@ -2685,6 +2685,54 @@ img.lqip.loaded { filter: blur(0); opacity: 1; } color: #fff; } +/* Quality tier badges (editorial quality scoring) */ +.badge-quality-excellent { + background: #10b981; + color: #fff; +} + +.badge-quality-good { + background: #3b82f6; + color: #fff; +} + +.badge-quality-fair { + background: #f59e0b; + color: #fff; +} + +.badge-quality-poor { + background: #ef4444; + color: #fff; +} + +/* Pool size tier badges (card availability) */ +.badge-pool-vast { + background: #8b5cf6; /* violet */ + color: #fff; +} + +.badge-pool-large { + background: #14b8a6; /* teal */ + color: #fff; +} + +.badge-pool-moderate { + background: #06b6d4; /* cyan */ + color: #fff; +} + +.badge-pool-small { + background: #f97316; /* orange */ + color: #fff; +} + +.badge-pool-tiny { + background: #6b7280; /* gray */ + color: #fff; +} + +/* Legacy lifecycle quality badges (draft/reviewed/final) */ .badge-quality-draft { background: #4338ca; color: #fff; @@ -2701,6 +2749,7 @@ img.lqip.loaded { filter: blur(0); opacity: 1; } font-weight: 600; } +/* Popularity bucket badges */ .badge-pop-vc { background: #065f46; color: #fff; diff --git a/code/web/templates/base.html b/code/web/templates/base.html index c17b53f..2949ce3 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -34,7 +34,7 @@ - +