feat(editorial): Phase D synergy commander enrichment, augmentation, lint & docs\n\nAdds Phase D editorial tooling: synergy-based commander selection with 3/2/1 pattern, duplicate filtering, annotated synergy_commanders, promotion to minimum examples, and augmentation heuristics (e.g. Counters Matter/Proliferate injection). Includes new scripts (generate_theme_editorial_suggestions, lint, validate, catalog build/apply), updates orchestrator & web routes, expands CI workflow, and documents usage & non-determinism policies. Updates lint rules, type definitions, and docker configs.
2025-09-18 10:59:20 -07:00
from __future__ import annotations
import json
from datetime import datetime as _dt
from pathlib import Path
from typing import Optional , Dict , Any
2025-09-23 09:19:23 -07:00
from fastapi import APIRouter , Request , HTTPException , Query
feat(editorial): Phase D synergy commander enrichment, augmentation, lint & docs\n\nAdds Phase D editorial tooling: synergy-based commander selection with 3/2/1 pattern, duplicate filtering, annotated synergy_commanders, promotion to minimum examples, and augmentation heuristics (e.g. Counters Matter/Proliferate injection). Includes new scripts (generate_theme_editorial_suggestions, lint, validate, catalog build/apply), updates orchestrator & web routes, expands CI workflow, and documents usage & non-determinism policies. Updates lint rules, type definitions, and docker configs.
2025-09-18 10:59:20 -07:00
from fastapi import BackgroundTasks
2025-09-19 11:53:52 -07:00
from . . services . orchestrator import _ensure_setup_ready , _run_theme_metadata_enrichment # type: ignore
2025-09-23 09:19:23 -07:00
from fastapi . responses import JSONResponse , HTMLResponse
from fastapi . templating import Jinja2Templates
from . . services . theme_catalog_loader import (
load_index ,
project_detail ,
slugify ,
filter_slugs_fast ,
summaries_for_slugs ,
)
from . . services . theme_preview import get_theme_preview # type: ignore
from . . services . theme_catalog_loader import catalog_metrics , prewarm_common_filters # type: ignore
from . . services . theme_preview import preview_metrics # type: ignore
from . . services import theme_preview as _theme_preview_mod # type: ignore # for error counters
import os
from fastapi import Body
# In-memory client metrics & structured log counters (diagnostics only)
CLIENT_PERF : dict [ str , list [ float ] ] = {
" list_render_ms " : [ ] , # list_ready - list_render_start
" preview_load_ms " : [ ] , # optional future measure (not yet emitted)
}
LOG_COUNTS : dict [ str , int ] = { }
MAX_CLIENT_SAMPLES = 500 # cap to avoid unbounded growth
feat(editorial): Phase D synergy commander enrichment, augmentation, lint & docs\n\nAdds Phase D editorial tooling: synergy-based commander selection with 3/2/1 pattern, duplicate filtering, annotated synergy_commanders, promotion to minimum examples, and augmentation heuristics (e.g. Counters Matter/Proliferate injection). Includes new scripts (generate_theme_editorial_suggestions, lint, validate, catalog build/apply), updates orchestrator & web routes, expands CI workflow, and documents usage & non-determinism policies. Updates lint rules, type definitions, and docker configs.
2025-09-18 10:59:20 -07:00
router = APIRouter ( prefix = " /themes " , tags = [ " themes " ] ) # /themes/status
2025-09-23 09:19:23 -07:00
# Reuse the main app's template environment so nav globals stay consistent.
try : # circular-safe import: app defines templates before importing this router
from . . app import templates as _templates # type: ignore
except Exception : # Fallback (tests/minimal contexts)
_templates = Jinja2Templates ( directory = str ( Path ( __file__ ) . resolve ( ) . parent . parent / ' templates ' ) )
feat(editorial): Phase D synergy commander enrichment, augmentation, lint & docs\n\nAdds Phase D editorial tooling: synergy-based commander selection with 3/2/1 pattern, duplicate filtering, annotated synergy_commanders, promotion to minimum examples, and augmentation heuristics (e.g. Counters Matter/Proliferate injection). Includes new scripts (generate_theme_editorial_suggestions, lint, validate, catalog build/apply), updates orchestrator & web routes, expands CI workflow, and documents usage & non-determinism policies. Updates lint rules, type definitions, and docker configs.
2025-09-18 10:59:20 -07:00
THEME_LIST_PATH = Path ( " config/themes/theme_list.json " )
CATALOG_DIR = Path ( " config/themes/catalog " )
STATUS_PATH = Path ( " csv_files/.setup_status.json " )
TAG_FLAG_PATH = Path ( " csv_files/.tagging_complete.json " )
def _iso ( ts : float | int | None ) - > Optional [ str ] :
if ts is None or ts < = 0 :
return None
try :
return _dt . fromtimestamp ( ts ) . isoformat ( timespec = " seconds " )
except Exception :
return None
def _load_status ( ) - > Dict [ str , Any ] :
try :
if STATUS_PATH . exists ( ) :
return json . loads ( STATUS_PATH . read_text ( encoding = " utf-8 " ) or " {} " ) or { }
except Exception :
pass
return { }
2025-09-23 09:19:23 -07:00
def _load_fast_theme_list ( ) - > Optional [ list [ dict [ str , Any ] ] ] :
""" Load precomputed lightweight theme list JSON if available.
Expected structure : { " themes " : [ { " id " : str , " theme " : str , " short_description " : str , . . . } , . . . ] }
Returns list or None on failure .
"""
try :
if THEME_LIST_PATH . exists ( ) :
raw = json . loads ( THEME_LIST_PATH . read_text ( encoding = " utf-8 " ) or " {} " )
if isinstance ( raw , dict ) :
arr = raw . get ( " themes " )
if isinstance ( arr , list ) :
# Shallow copy to avoid mutating original reference
# NOTE: Regression fix (2025-09-20): theme_list.json produced by current
# build pipeline does NOT include an explicit 'id' per theme (only 'theme').
# Earlier implementation required e.get('id') causing the fast path to
# treat the catalog as empty and show "No themes found." even though
# hundreds of themes exist. We now derive the id via slugify(theme) when
# missing, and also opportunistically compute a short_description snippet
# if absent (trim description to ~110 chars mirroring project_summary logic).
out : list [ dict [ str , Any ] ] = [ ]
for e in arr :
if not isinstance ( e , dict ) :
continue
theme_name = e . get ( " theme " )
if not theme_name or not isinstance ( theme_name , str ) :
continue
_id = e . get ( " id " ) or slugify ( theme_name )
short_desc = e . get ( " short_description " )
if not short_desc :
desc = e . get ( " description " )
if isinstance ( desc , str ) and desc . strip ( ) :
sd = desc . strip ( )
if len ( sd ) > 110 :
sd = sd [ : 107 ] . rstrip ( ) + " … "
short_desc = sd
out . append ( {
" id " : _id ,
" theme " : theme_name ,
" short_description " : short_desc ,
} )
# If we ended up with zero items (unexpected) fall back to None so caller
# will use full index logic instead of rendering empty state incorrectly.
if not out :
return None
return out
except Exception :
return None
return None
2025-09-25 15:14:15 -07:00
@router.get ( " /suggest " )
@router.get ( " /api/suggest " )
async def theme_suggest (
request : Request ,
q : str | None = None ,
limit : int | None = Query ( 10 , ge = 1 , le = 50 ) ,
) :
""" Lightweight theme name suggestions for typeahead.
Prefers the precomputed fast path ( theme_list . json ) . Falls back to full index if unavailable .
Returns a compact JSON : { " themes " : [ " <name> " , . . . ] } .
"""
try :
# Optional rate limit using app helper if available
rl_result = None
try :
from . . app import rate_limit_check # type: ignore
rl_result = rate_limit_check ( request , " suggest " )
except HTTPException as http_ex : # propagate 429 with headers
raise http_ex
except Exception :
rl_result = None
lim = int ( limit or 10 )
names : list [ str ] = [ ]
fast = _load_fast_theme_list ( )
if fast is not None :
try :
items = fast
if q :
ql = q . lower ( )
items = [ e for e in items if isinstance ( e . get ( " theme " ) , str ) and ql in e [ " theme " ] . lower ( ) ]
for e in items [ : lim * 3 ] : # pre-slice before unique
nm = e . get ( " theme " )
if isinstance ( nm , str ) :
names . append ( nm )
except Exception :
names = [ ]
if not names :
# Fallback to full index
try :
idx = load_index ( )
slugs = filter_slugs_fast ( idx , q = q )
# summaries_for_slugs returns dicts including 'theme'
infos = summaries_for_slugs ( idx , slugs [ : lim * 3 ] )
for inf in infos :
nm = inf . get ( " theme " )
if isinstance ( nm , str ) :
names . append ( nm )
except Exception :
names = [ ]
# Deduplicate preserving order, then clamp
seen : set [ str ] = set ( )
out : list [ str ] = [ ]
for nm in names :
if nm in seen :
continue
seen . add ( nm )
out . append ( nm )
if len ( out ) > = lim :
break
resp = JSONResponse ( { " themes " : out } )
if rl_result :
remaining , reset_epoch = rl_result
try :
resp . headers [ " X-RateLimit-Remaining " ] = str ( remaining )
resp . headers [ " X-RateLimit-Reset " ] = str ( reset_epoch )
except Exception :
pass
return resp
except HTTPException as e :
# Propagate FastAPI HTTPException (e.g., 429 with headers)
raise e
except Exception as e :
return JSONResponse ( { " themes " : [ ] , " error " : str ( e ) } , status_code = 500 )
feat(editorial): Phase D synergy commander enrichment, augmentation, lint & docs\n\nAdds Phase D editorial tooling: synergy-based commander selection with 3/2/1 pattern, duplicate filtering, annotated synergy_commanders, promotion to minimum examples, and augmentation heuristics (e.g. Counters Matter/Proliferate injection). Includes new scripts (generate_theme_editorial_suggestions, lint, validate, catalog build/apply), updates orchestrator & web routes, expands CI workflow, and documents usage & non-determinism policies. Updates lint rules, type definitions, and docker configs.
2025-09-18 10:59:20 -07:00
def _load_tag_flag_time ( ) - > Optional [ float ] :
try :
if TAG_FLAG_PATH . exists ( ) :
data = json . loads ( TAG_FLAG_PATH . read_text ( encoding = " utf-8 " ) or " {} " ) or { }
t = data . get ( " tagged_at " )
if isinstance ( t , str ) and t . strip ( ) :
try :
return _dt . fromisoformat ( t . strip ( ) ) . timestamp ( )
except Exception :
return None
except Exception :
return None
return None
@router.get ( " /status " )
async def theme_status ( ) :
""" Return current theme export status for the UI.
Provides counts , mtimes , and freshness vs . tagging flag .
"""
try :
status = _load_status ( )
theme_list_exists = THEME_LIST_PATH . exists ( )
theme_list_mtime_s = THEME_LIST_PATH . stat ( ) . st_mtime if theme_list_exists else None
theme_count : Optional [ int ] = None
parse_error : Optional [ str ] = None
if theme_list_exists :
try :
raw = json . loads ( THEME_LIST_PATH . read_text ( encoding = " utf-8 " ) or " {} " ) or { }
if isinstance ( raw , dict ) :
themes = raw . get ( " themes " )
if isinstance ( themes , list ) :
theme_count = len ( themes )
except Exception as e : # pragma: no cover
parse_error = f " parse_error: { e } " # keep short
yaml_catalog_exists = CATALOG_DIR . exists ( ) and CATALOG_DIR . is_dir ( )
yaml_file_count = 0
if yaml_catalog_exists :
try :
yaml_file_count = len ( [ p for p in CATALOG_DIR . iterdir ( ) if p . suffix == " .yml " ] ) # type: ignore[arg-type]
except Exception :
yaml_file_count = - 1
tagged_time = _load_tag_flag_time ( )
stale = False
if tagged_time and theme_list_mtime_s :
# Stale if tagging flag is newer by > 1 second
stale = tagged_time > ( theme_list_mtime_s + 1 )
# Also stale if we expect a catalog (after any tagging) but have suspiciously few YAMLs (< 100)
if yaml_catalog_exists and yaml_file_count > = 0 and yaml_file_count < 100 :
stale = True
last_export_at = status . get ( " themes_last_export_at " ) or _iso ( theme_list_mtime_s ) or None
resp = {
" ok " : True ,
" theme_list_exists " : theme_list_exists ,
" theme_list_mtime " : _iso ( theme_list_mtime_s ) ,
" theme_count " : theme_count ,
" yaml_catalog_exists " : yaml_catalog_exists ,
" yaml_file_count " : yaml_file_count ,
" stale " : stale ,
" last_export_at " : last_export_at ,
" last_export_fast_path " : status . get ( " themes_last_export_fast_path " ) ,
" phase " : status . get ( " phase " ) ,
" running " : status . get ( " running " ) ,
}
if parse_error :
resp [ " parse_error " ] = parse_error
return JSONResponse ( resp )
except Exception as e : # pragma: no cover
return JSONResponse ( { " ok " : False , " error " : str ( e ) } , status_code = 500 )
@router.post ( " /refresh " )
async def theme_refresh ( background : BackgroundTasks ) :
""" Force a theme export refresh without re-tagging if not needed.
Runs setup readiness with force = False ( fast - path export fallback will run ) . Returns immediately .
"""
try :
def _runner ( ) :
try :
2025-09-19 11:53:52 -07:00
_ensure_setup_ready ( lambda _m : None , force = False )
except Exception :
pass
try :
_run_theme_metadata_enrichment ( )
feat(editorial): Phase D synergy commander enrichment, augmentation, lint & docs\n\nAdds Phase D editorial tooling: synergy-based commander selection with 3/2/1 pattern, duplicate filtering, annotated synergy_commanders, promotion to minimum examples, and augmentation heuristics (e.g. Counters Matter/Proliferate injection). Includes new scripts (generate_theme_editorial_suggestions, lint, validate, catalog build/apply), updates orchestrator & web routes, expands CI workflow, and documents usage & non-determinism policies. Updates lint rules, type definitions, and docker configs.
2025-09-18 10:59:20 -07:00
except Exception :
pass
background . add_task ( _runner )
return JSONResponse ( { " ok " : True , " started " : True } )
except Exception as e : # pragma: no cover
return JSONResponse ( { " ok " : False , " error " : str ( e ) } , status_code = 500 )
2025-09-23 09:19:23 -07:00
# --- Phase E Theme Catalog APIs ---
def _diag_enabled ( ) - > bool :
return ( os . getenv ( " WEB_THEME_PICKER_DIAGNOSTICS " ) or " " ) . strip ( ) . lower ( ) in { " 1 " , " true " , " yes " , " on " }
@router.get ( " /picker " , response_class = HTMLResponse )
async def theme_picker_page ( request : Request ) :
""" Render the theme picker shell.
Dynamic data ( list , detail ) loads via fragment endpoints . We still inject
known archetype list for the filter select so it is populated on initial load .
"""
archetypes : list [ str ] = [ ]
try :
idx = load_index ( )
archetypes = sorted ( { t . deck_archetype for t in idx . catalog . themes if t . deck_archetype } ) # type: ignore[arg-type]
except Exception :
archetypes = [ ]
return _templates . TemplateResponse (
" themes/picker.html " ,
{
" request " : request ,
" archetypes " : archetypes ,
" theme_picker_diagnostics " : _diag_enabled ( ) ,
} ,
)
@router.get ( " /metrics " )
async def theme_metrics ( ) :
if not _diag_enabled ( ) :
raise HTTPException ( status_code = 403 , detail = " diagnostics_disabled " )
try :
idx = load_index ( )
prewarm_common_filters ( )
return JSONResponse ( {
" ok " : True ,
" etag " : idx . etag ,
" catalog " : catalog_metrics ( ) ,
" preview " : preview_metrics ( ) ,
" client_perf " : {
" list_render_avg_ms " : round ( sum ( CLIENT_PERF [ " list_render_ms " ] ) / len ( CLIENT_PERF [ " list_render_ms " ] ) ) if CLIENT_PERF [ " list_render_ms " ] else 0 ,
" list_render_count " : len ( CLIENT_PERF [ " list_render_ms " ] ) ,
" preview_load_avg_ms " : round ( sum ( CLIENT_PERF [ " preview_load_ms " ] ) / len ( CLIENT_PERF [ " preview_load_ms " ] ) ) if CLIENT_PERF [ " preview_load_ms " ] else 0 ,
" preview_load_batch_count " : len ( CLIENT_PERF [ " preview_load_ms " ] ) ,
} ,
" log_counts " : LOG_COUNTS ,
} )
except Exception as e :
return JSONResponse ( { " ok " : False , " error " : str ( e ) } , status_code = 500 )
@router.get ( " / " , response_class = HTMLResponse )
async def theme_catalog_simple ( request : Request ) :
""" Simplified catalog: list + search only (no per-row heavy data). """
return _templates . TemplateResponse ( " themes/catalog_simple.html " , { " request " : request } )
@router.get ( " / {theme_id} " , response_class = HTMLResponse )
async def theme_catalog_detail_page ( theme_id : str , request : Request ) :
""" Full detail page for a single theme (standalone route). """
try :
idx = load_index ( )
except FileNotFoundError :
return HTMLResponse ( " <div class= ' error ' >Catalog unavailable.</div> " , status_code = 503 )
slug = slugify ( theme_id )
entry = idx . slug_to_entry . get ( slug )
if not entry :
return HTMLResponse ( " <div class= ' error ' >Not found.</div> " , status_code = 404 )
detail = project_detail ( slug , entry , idx . slug_to_yaml , uncapped = False )
# Strip diagnostics-only fields for public page
detail . pop ( ' has_fallback_description ' , None )
detail . pop ( ' editorial_quality ' , None )
detail . pop ( ' uncapped_synergies ' , None )
# Build example + synergy commanders (reuse logic from preview)
example_commanders = [ c for c in ( detail . get ( " example_commanders " ) or [ ] ) if isinstance ( c , str ) ]
synergy_commanders_raw = [ c for c in ( detail . get ( " synergy_commanders " ) or [ ] ) if isinstance ( c , str ) ]
seen = set ( example_commanders )
synergy_commanders : list [ str ] = [ ]
for c in synergy_commanders_raw :
if c not in seen :
synergy_commanders . append ( c )
seen . add ( c )
# Render via reuse of detail fragment inside a page shell
return _templates . TemplateResponse (
" themes/detail_page.html " ,
{
" request " : request ,
" theme " : detail ,
" diagnostics " : False ,
" uncapped " : False ,
" yaml_available " : False ,
" example_commanders " : example_commanders ,
" synergy_commanders " : synergy_commanders ,
" standalone_page " : True ,
} ,
)
@router.get ( " /fragment/list " , response_class = HTMLResponse )
async def theme_list_fragment (
request : Request ,
q : str | None = None ,
archetype : str | None = None ,
bucket : str | None = None ,
colors : str | None = None ,
diagnostics : bool | None = None ,
synergy_mode : str | None = Query ( None , description = " Synergy display mode: ' capped ' (default) or ' full ' " ) ,
limit : int | None = Query ( 20 , ge = 1 , le = 100 ) ,
offset : int | None = Query ( 0 , ge = 0 ) ,
) :
import time as _t
t0 = _t . time ( )
try :
idx = load_index ( )
except FileNotFoundError :
return HTMLResponse ( " <div class= ' error ' >Catalog unavailable.</div> " , status_code = 503 )
color_list = [ c . strip ( ) for c in colors . split ( ' , ' ) ] if colors else None
# Fast filtering (falls back only for legacy logic differences if needed)
slugs = filter_slugs_fast ( idx , q = q , archetype = archetype , bucket = bucket , colors = color_list )
diag = _diag_enabled ( ) and bool ( diagnostics )
lim = int ( limit or 30 )
off = int ( offset or 0 )
total = len ( slugs )
slice_slugs = slugs [ off : off + lim ]
items = summaries_for_slugs ( idx , slice_slugs )
# Synergy display logic: default 'capped' mode (cap at 6) unless diagnostics & user explicitly requests full
# synergy_mode can be 'full' to force uncapped in list (still diagnostics-gated to prevent layout spam in prod)
mode = ( synergy_mode or ' ' ) . strip ( ) . lower ( )
allow_full = ( mode == ' full ' ) and diag # only diagnostics may request full
SYNERGY_CAP = 6
if not allow_full :
for it in items :
syns = it . get ( " synergies " ) or [ ]
if isinstance ( syns , list ) and len ( syns ) > SYNERGY_CAP :
it [ " synergies_capped " ] = True
it [ " synergies_full " ] = syns
it [ " synergies " ] = syns [ : SYNERGY_CAP ]
if not diag :
for it in items :
it . pop ( ' has_fallback_description ' , None )
it . pop ( ' editorial_quality ' , None )
duration_ms = int ( ( ( _t . time ( ) - t0 ) * 1000 ) )
resp = _templates . TemplateResponse (
" themes/list_fragment.html " ,
{
" request " : request ,
" items " : items ,
" diagnostics " : diag ,
" total " : total ,
" limit " : lim ,
" offset " : off ,
" next_offset " : off + lim if ( off + lim ) < total else None ,
" prev_offset " : off - lim if off - lim > = 0 else None ,
} ,
)
resp . headers [ " X-ThemeCatalog-Filter-Duration-ms " ] = str ( duration_ms )
resp . headers [ " X-ThemeCatalog-Index-ETag " ] = idx . etag
return resp
@router.get ( " /fragment/list_simple " , response_class = HTMLResponse )
async def theme_list_simple_fragment (
request : Request ,
q : 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 .
"""
import time as _t
t0 = _t . time ( )
lim = int ( limit or 100 )
off = int ( offset or 0 )
fast_items = _load_fast_theme_list ( )
fast_used = False
items : list [ dict [ str , Any ] ] = [ ]
total = 0
if fast_items is not None :
fast_used = True
# 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 ( ) ]
total = len ( fast_items )
slice_items = fast_items [ off : off + lim ]
for e in slice_items :
items . append ( {
" id " : e . get ( " id " ) ,
" theme " : e . get ( " theme " ) ,
" short_description " : e . get ( " short_description " ) ,
} )
else :
# Fallback: load full index
try :
idx = load_index ( )
except FileNotFoundError :
return HTMLResponse ( " <div class= ' error ' >Catalog unavailable.</div> " , status_code = 503 )
slugs = filter_slugs_fast ( idx , q = q , archetype = None , bucket = None , colors = None )
total = len ( slugs )
slice_slugs = slugs [ off : off + lim ]
items_raw = summaries_for_slugs ( idx , slice_slugs )
for it in items_raw :
items . append ( {
" id " : it . get ( " id " ) ,
" theme " : it . get ( " theme " ) ,
" short_description " : it . get ( " short_description " ) ,
} )
duration_ms = int ( ( ( _t . time ( ) - t0 ) * 1000 ) )
resp = _templates . TemplateResponse (
" themes/list_simple_fragment.html " ,
{
" request " : request ,
" items " : items ,
" total " : total ,
" limit " : lim ,
" offset " : off ,
" next_offset " : off + lim if ( off + lim ) < total else None ,
" prev_offset " : off - lim if off - lim > = 0 else None ,
} ,
)
resp . headers [ ' X-ThemeCatalog-Simple-Duration-ms ' ] = str ( duration_ms )
resp . headers [ ' X-ThemeCatalog-Simple-Fast ' ] = ' 1 ' if fast_used else ' 0 '
# Consistency: expose same filter duration style header used by full list fragment so
# tooling / DevTools inspection does not depend on which catalog view is active.
resp . headers [ ' X-ThemeCatalog-Filter-Duration-ms ' ] = str ( duration_ms )
return resp
@router.get ( " /fragment/detail/ {theme_id} " , response_class = HTMLResponse )
async def theme_detail_fragment (
theme_id : str ,
diagnostics : bool | None = None ,
uncapped : bool | None = None ,
request : Request = None ,
) :
try :
idx = load_index ( )
except FileNotFoundError :
return HTMLResponse ( " <div class= ' error ' >Catalog unavailable.</div> " , status_code = 503 )
slug = slugify ( theme_id )
entry = idx . slug_to_entry . get ( slug )
if not entry :
return HTMLResponse ( " <div class= ' error ' >Not found.</div> " , 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 )
if not diag :
detail . pop ( ' has_fallback_description ' , None )
detail . pop ( ' editorial_quality ' , None )
detail . pop ( ' uncapped_synergies ' , None )
return _templates . TemplateResponse (
" themes/detail_fragment.html " ,
{
" request " : request ,
" theme " : detail ,
" diagnostics " : diag ,
" uncapped " : uncapped_enabled ,
" yaml_available " : diag , # gate by diagnostics flag
} ,
)
## (moved metrics route earlier to avoid collision with catch-all /{theme_id})
@router.get ( " /yaml/ {theme_id} " )
async def theme_yaml ( theme_id : str ) :
""" Return raw YAML file for a theme (diagnostics/dev only). """
if not _diag_enabled ( ) :
raise HTTPException ( status_code = 403 , detail = " diagnostics_disabled " )
try :
idx = load_index ( )
except FileNotFoundError :
raise HTTPException ( status_code = 503 , detail = " catalog_unavailable " )
slug = slugify ( theme_id )
# Attempt to locate via slug -> YAML map, fallback path guess
y = idx . slug_to_yaml . get ( slug )
if not y :
raise HTTPException ( status_code = 404 , detail = " yaml_not_found " )
# Reconstruct minimal YAML (we have dict already)
import yaml as _yaml # local import to keep top-level lean
text = _yaml . safe_dump ( y , sort_keys = False ) # type: ignore
headers = { " Content-Type " : " text/plain; charset=utf-8 " }
return HTMLResponse ( text , headers = headers )
@router.get ( " /api/themes " )
async def api_themes (
request : Request ,
q : str | None = Query ( None , description = " Substring filter on theme or synergies " ) ,
archetype : str | None = Query ( None , description = " Filter by deck_archetype " ) ,
bucket : str | None = Query ( None , description = " Filter by popularity bucket " ) ,
colors : str | None = Query ( None , description = " Comma-separated color initials (e.g. G,W) " ) ,
limit : int = Query ( 50 , ge = 1 , le = 200 ) ,
offset : int = Query ( 0 , ge = 0 ) ,
diagnostics : bool | None = Query ( None , description = " Force diagnostics mode (allowed only if flag enabled) " ) ,
) :
import time as _t
t0 = _t . time ( )
try :
idx = load_index ( )
except FileNotFoundError :
raise HTTPException ( status_code = 503 , detail = " catalog_unavailable " )
color_list = [ c . strip ( ) for c in colors . split ( " , " ) if c . strip ( ) ] if colors else None
# Validate archetype quickly (fast path uses underlying entries anyway)
if archetype :
present_archetypes = { e . deck_archetype for e in idx . catalog . themes if e . deck_archetype }
if archetype not in present_archetypes :
slugs : list [ str ] = [ ]
else :
slugs = filter_slugs_fast ( idx , q = q , archetype = archetype , bucket = bucket , colors = color_list )
else :
slugs = filter_slugs_fast ( idx , q = q , archetype = None , bucket = bucket , colors = color_list )
total = len ( slugs )
slice_slugs = slugs [ offset : offset + limit ]
items = summaries_for_slugs ( idx , slice_slugs )
diag = _diag_enabled ( ) and bool ( diagnostics )
if not diag :
# Strip diagnostics-only fields
for it in items :
# has_fallback_description is diagnostics-only
it . pop ( " has_fallback_description " , None )
it . pop ( " editorial_quality " , None )
duration_ms = int ( ( ( _t . time ( ) - t0 ) * 1000 ) )
headers = {
" ETag " : idx . etag ,
" Cache-Control " : " no-cache " , # Clients may still conditional GET using ETag
" X-ThemeCatalog-Filter-Duration-ms " : str ( duration_ms ) ,
}
return JSONResponse ( {
" ok " : True ,
" count " : total ,
" items " : items ,
" next_offset " : offset + limit if ( offset + limit ) < total else None ,
" stale " : False , # status already exposed elsewhere; keep placeholder for UI
" generated_at " : idx . catalog . metadata_info . generated_at if idx . catalog . metadata_info else None ,
" diagnostics " : diag ,
} , headers = headers )
@router.get ( " /api/search " )
async def api_theme_search (
q : str = Query ( . . . , min_length = 1 , description = " Search query " ) ,
limit : int = Query ( 15 , ge = 1 , le = 50 ) ,
include_synergies : bool = Query ( False , description = " Also match synergies (slower) " ) ,
) :
""" Lightweight search with tiered matching (exact > prefix > substring).
Performance safeguards :
- Stop scanning once we have > = limit and at least one exact / prefix .
- Substring phase limited to first 250 themes unless still under limit .
- Optional synergy search ( off by default ) to avoid wide fan - out of matches like ' aggro ' in many synergy lists .
"""
try :
idx = load_index ( )
except FileNotFoundError :
return JSONResponse ( { " ok " : False , " error " : " catalog_unavailable " } , status_code = 503 )
qnorm = q . strip ( )
if not qnorm :
return JSONResponse ( { " ok " : True , " items " : [ ] } )
qlower = qnorm . lower ( )
exact : list [ dict [ str , Any ] ] = [ ]
prefix : list [ dict [ str , Any ] ] = [ ]
substr : list [ dict [ str , Any ] ] = [ ]
seen : set [ str ] = set ( )
themes_iter = list ( idx . catalog . themes ) # type: ignore[attr-defined]
# Phase 1 + 2: exact / prefix
for t in themes_iter :
name = t . theme
slug = slugify ( name )
lower_name = name . lower ( )
if lower_name == qlower or slug == qlower :
if slug not in seen :
exact . append ( { " id " : slug , " theme " : name } )
seen . add ( slug )
continue
if lower_name . startswith ( qlower ) :
if slug not in seen :
prefix . append ( { " id " : slug , " theme " : name } )
seen . add ( slug )
if len ( exact ) + len ( prefix ) > = limit :
break
# Phase 3: substring (only if still room)
if ( len ( exact ) + len ( prefix ) ) < limit :
scan_limit = 250 # cap scan for responsiveness
for t in themes_iter [ : scan_limit ] :
name = t . theme
slug = slugify ( name )
if slug in seen :
continue
if qlower in name . lower ( ) :
substr . append ( { " id " : slug , " theme " : name } )
seen . add ( slug )
if ( len ( exact ) + len ( prefix ) + len ( substr ) ) > = limit :
break
ordered = exact + prefix + substr
# Optional synergy search fill (lowest priority) if still space
if include_synergies and len ( ordered ) < limit :
remaining = limit - len ( ordered )
for t in themes_iter :
if remaining < = 0 :
break
slug = slugify ( t . theme )
if slug in seen :
continue
syns = getattr ( t , ' synergies ' , None ) or [ ]
try :
# Only a quick any() scan to keep it cheap
if any ( qlower in s . lower ( ) for s in syns ) :
ordered . append ( { " id " : slug , " theme " : t . theme } )
seen . add ( slug )
remaining - = 1
except Exception :
continue
if len ( ordered ) > limit :
ordered = ordered [ : limit ]
return JSONResponse ( { " ok " : True , " items " : ordered } )
@router.get ( " /api/theme/ {theme_id} " )
async def api_theme_detail (
theme_id : str ,
uncapped : bool | None = Query ( False , description = " Return uncapped synergy set (diagnostics mode only) " ) ,
diagnostics : bool | None = Query ( None , description = " Diagnostics mode gating extra fields " ) ,
) :
try :
idx = load_index ( )
except FileNotFoundError :
raise HTTPException ( status_code = 503 , detail = " catalog_unavailable " )
slug = slugify ( theme_id )
entry = idx . slug_to_entry . get ( slug )
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 )
if not diag :
# Remove diagnostics-only fields
detail . pop ( " has_fallback_description " , None )
detail . pop ( " editorial_quality " , None )
detail . pop ( " uncapped_synergies " , None )
headers = { " ETag " : idx . etag , " Cache-Control " : " no-cache " }
return JSONResponse ( { " ok " : True , " theme " : detail , " diagnostics " : diag } , headers = headers )
@router.get ( " /api/theme/ {theme_id} /preview " )
async def api_theme_preview (
theme_id : str ,
limit : int = Query ( 12 , ge = 1 , le = 30 ) ,
colors : str | None = Query ( None , description = " Comma separated color filter (currently placeholder) " ) ,
commander : str | None = Query ( None , description = " Commander name to bias sampling (future) " ) ,
) :
try :
payload = get_theme_preview ( theme_id , limit = limit , colors = colors , commander = commander )
except KeyError :
raise HTTPException ( status_code = 404 , detail = " theme_not_found " )
return JSONResponse ( { " ok " : True , " preview " : payload } )
@router.get ( " /fragment/preview/ {theme_id} " , response_class = HTMLResponse )
async def theme_preview_fragment (
theme_id : str ,
limit : int = Query ( 12 , ge = 1 , le = 30 ) ,
colors : str | None = None ,
commander : str | None = None ,
suppress_curated : bool = Query ( False , description = " If true, omit curated example cards/commanders from the sample area (used on detail page to avoid duplication) " ) ,
minimal : bool = Query ( False , description = " Minimal inline variant (no header/controls/rationale – used in detail page collapsible preview) " ) ,
request : Request = None ,
) :
""" Return HTML fragment for theme preview with caching headers.
Adds ETag and Last - Modified headers ( no strong caching – enables conditional GET / 304 ) .
ETag composed of catalog index etag + stable hash of preview payload ( theme id + limit + commander ) .
"""
try :
payload = get_theme_preview ( theme_id , limit = limit , colors = colors , commander = commander )
except KeyError :
return HTMLResponse ( " <div class= ' error ' >Theme not found.</div> " , status_code = 404 )
# Load example commanders (authoritative list) from catalog detail for legality instead of inferring
example_commanders : list [ str ] = [ ]
synergy_commanders : list [ str ] = [ ]
try :
idx = load_index ( )
slug = slugify ( theme_id )
entry = idx . slug_to_entry . get ( slug )
if entry :
detail = project_detail ( slug , entry , idx . slug_to_yaml , uncapped = False )
example_commanders = [ c for c in ( detail . get ( " example_commanders " ) or [ ] ) if isinstance ( c , str ) ]
synergy_commanders_raw = [ c for c in ( detail . get ( " synergy_commanders " ) or [ ] ) if isinstance ( c , str ) ]
# De-duplicate any overlap with example commanders while preserving order
seen = set ( example_commanders )
for c in synergy_commanders_raw :
if c not in seen :
synergy_commanders . append ( c )
seen . add ( c )
except Exception :
example_commanders = [ ]
synergy_commanders = [ ]
# Build ETag (use catalog etag + hash of core identifying fields to reflect underlying data drift)
import hashlib
import json as _json
import time as _time
try :
idx = load_index ( )
catalog_tag = idx . etag
except Exception :
catalog_tag = " unknown "
hash_src = _json . dumps ( {
" theme " : theme_id ,
" limit " : limit ,
" commander " : commander ,
" sample " : payload . get ( " sample " , [ ] ) [ : 3 ] , # small slice for stability & speed
" v " : 1 ,
} , sort_keys = True ) . encode ( " utf-8 " )
etag = " pv- " + hashlib . sha256 ( hash_src ) . hexdigest ( ) [ : 20 ] + f " - { catalog_tag } "
# Conditional request support
if request is not None :
inm = request . headers . get ( " if-none-match " )
if inm and inm == etag :
# 304 Not Modified – FastAPI HTMLResponse with empty body & headers
resp = HTMLResponse ( status_code = 304 , content = " " )
resp . headers [ " ETag " ] = etag
from email . utils import formatdate as _fmtdate
resp . headers [ " Last-Modified " ] = _fmtdate ( timeval = _time . time ( ) , usegmt = True )
resp . headers [ " Cache-Control " ] = " no-cache "
return resp
ctx = {
" request " : request ,
" preview " : payload ,
" example_commanders " : example_commanders ,
" synergy_commanders " : synergy_commanders ,
" theme_id " : theme_id ,
" etag " : etag ,
" suppress_curated " : suppress_curated ,
" minimal " : minimal ,
}
resp = _templates . TemplateResponse ( " themes/preview_fragment.html " , ctx )
resp . headers [ " ETag " ] = etag
from email . utils import formatdate as _fmtdate
resp . headers [ " Last-Modified " ] = _fmtdate ( timeval = _time . time ( ) , usegmt = True )
resp . headers [ " Cache-Control " ] = " no-cache "
return resp
# --- Preview Export Endpoints (CSV / JSON) ---
@router.get ( " /preview/ {theme_id} /export.json " )
async def export_preview_json (
theme_id : str ,
limit : int = Query ( 12 , ge = 1 , le = 60 ) ,
colors : str | None = None ,
commander : str | None = None ,
curated_only : bool | None = Query ( False , description = " If true, only curated example + curated synergy entries returned " ) ,
) :
try :
payload = get_theme_preview ( theme_id , limit = limit , colors = colors , commander = commander )
except KeyError :
raise HTTPException ( status_code = 404 , detail = " theme_not_found " )
items = payload . get ( " sample " , [ ] )
if curated_only :
items = [ i for i in items if any ( r in { " example " , " curated_synergy " , " synthetic " } for r in ( i . get ( " roles " ) or [ ] ) ) ]
return JSONResponse ( {
" ok " : True ,
" theme " : payload . get ( " theme " ) ,
" theme_id " : payload . get ( " theme_id " ) ,
" curated_only " : bool ( curated_only ) ,
" generated_at " : payload . get ( " generated_at " ) ,
" limit " : limit ,
" count " : len ( items ) ,
" items " : items ,
} )
@router.get ( " /preview/ {theme_id} /export.csv " )
async def export_preview_csv (
theme_id : str ,
limit : int = Query ( 12 , ge = 1 , le = 60 ) ,
colors : str | None = None ,
commander : str | None = None ,
curated_only : bool | None = Query ( False , description = " If true, only curated example + curated synergy entries returned " ) ,
) :
import csv as _csv
import io as _io
try :
payload = get_theme_preview ( theme_id , limit = limit , colors = colors , commander = commander )
except KeyError :
raise HTTPException ( status_code = 404 , detail = " theme_not_found " )
rows = payload . get ( " sample " , [ ] )
if curated_only :
rows = [ r for r in rows if any ( role in { " example " , " curated_synergy " , " synthetic " } for role in ( r . get ( " roles " ) or [ ] ) ) ]
buf = _io . StringIO ( )
fieldnames = [ " name " , " roles " , " score " , " rarity " , " mana_cost " , " color_identity_list " , " pip_colors " , " reasons " , " tags " ]
w = _csv . DictWriter ( buf , fieldnames = fieldnames )
w . writeheader ( )
for r in rows :
w . writerow ( {
" name " : r . get ( " name " ) ,
" roles " : " ; " . join ( r . get ( " roles " ) or [ ] ) ,
" score " : r . get ( " score " ) ,
" rarity " : r . get ( " rarity " ) ,
" mana_cost " : r . get ( " mana_cost " ) ,
" color_identity_list " : " ; " . join ( r . get ( " color_identity_list " ) or [ ] ) ,
" pip_colors " : " ; " . join ( r . get ( " pip_colors " ) or [ ] ) ,
" reasons " : " ; " . join ( r . get ( " reasons " ) or [ ] ) ,
" tags " : " ; " . join ( r . get ( " tags " ) or [ ] ) ,
} )
csv_text = buf . getvalue ( )
from fastapi . responses import Response
filename = f " preview_ { theme_id } .csv "
headers = {
" Content-Disposition " : f " attachment; filename= { filename } " ,
" Content-Type " : " text/csv; charset=utf-8 " ,
}
return Response ( content = csv_text , media_type = " text/csv " , headers = headers )
2025-09-24 13:57:23 -07:00
# --- Export preview as deck seed (lightweight) ---
@router.get ( " /preview/ {theme_id} /export_seed.json " )
async def export_preview_seed (
theme_id : str ,
limit : int = Query ( 12 , ge = 1 , le = 60 ) ,
colors : str | None = None ,
commander : str | None = None ,
curated_only : bool | None = Query ( False , description = " If true, only curated example + curated synergy entries influence seed list " ) ,
) :
""" Return a minimal structure usable to bootstrap a deck build flow.
Output :
theme_id , theme , commander ( if any ) , cards ( list of names ) , curated ( subset ) , generated_at .
"""
try :
payload = get_theme_preview ( theme_id , limit = limit , colors = colors , commander = commander )
except KeyError :
raise HTTPException ( status_code = 404 , detail = " theme_not_found " )
items = payload . get ( " sample " , [ ] )
def _is_curated ( it : dict ) - > bool :
roles = it . get ( " roles " ) or [ ]
return any ( r in { " example " , " curated_synergy " } for r in roles )
if curated_only :
items = [ i for i in items if _is_curated ( i ) ]
card_names = [ i . get ( " name " ) for i in items if i . get ( " name " ) and not i . get ( " name " ) . startswith ( " [ " ) ]
curated_names = [ i . get ( " name " ) for i in items if _is_curated ( i ) and i . get ( " name " ) ] # exclude synthetic placeholders
return JSONResponse ( {
" ok " : True ,
" theme " : payload . get ( " theme " ) ,
" theme_id " : payload . get ( " theme_id " ) ,
" commander " : commander ,
" limit " : limit ,
" curated_only " : bool ( curated_only ) ,
" generated_at " : payload . get ( " generated_at " ) ,
" count " : len ( card_names ) ,
" cards " : card_names ,
" curated " : curated_names ,
} )
2025-09-23 09:19:23 -07:00
# --- New: Client performance marks ingestion (Section E) ---
@router.post ( " /metrics/client " )
async def ingest_client_metrics ( request : Request , payload : dict [ str , Any ] = Body ( . . . ) ) :
if not _diag_enabled ( ) :
raise HTTPException ( status_code = 403 , detail = " diagnostics_disabled " )
try :
events = payload . get ( " events " )
if not isinstance ( events , list ) :
return JSONResponse ( { " ok " : False , " error " : " invalid_events " } , status_code = 400 )
for ev in events :
if not isinstance ( ev , dict ) :
continue
name = ev . get ( " name " )
dur = ev . get ( " duration_ms " )
if name == " list_render " and isinstance ( dur , ( int , float ) ) and dur > = 0 :
CLIENT_PERF [ " list_render_ms " ] . append ( float ( dur ) )
if len ( CLIENT_PERF [ " list_render_ms " ] ) > MAX_CLIENT_SAMPLES :
# Drop oldest half to keep memory bounded
CLIENT_PERF [ " list_render_ms " ] = CLIENT_PERF [ " list_render_ms " ] [ len ( CLIENT_PERF [ " list_render_ms " ] ) / / 2 : ]
elif name == " preview_load_batch " :
# Aggregate average into samples list (store avg redundantly for now)
avg_ms = ev . get ( " avg_ms " )
if isinstance ( avg_ms , ( int , float ) ) and avg_ms > = 0 :
CLIENT_PERF [ " preview_load_ms " ] . append ( float ( avg_ms ) )
if len ( CLIENT_PERF [ " preview_load_ms " ] ) > MAX_CLIENT_SAMPLES :
CLIENT_PERF [ " preview_load_ms " ] = CLIENT_PERF [ " preview_load_ms " ] [ len ( CLIENT_PERF [ " preview_load_ms " ] ) / / 2 : ]
return JSONResponse ( { " ok " : True , " ingested " : len ( events ) } )
except Exception as e : # pragma: no cover
return JSONResponse ( { " ok " : False , " error " : str ( e ) } , status_code = 500 )
# --- New: Structured logging ingestion for cache/prefetch events (Section E) ---
@router.post ( " /log " )
async def ingest_structured_log ( request : Request , payload : dict [ str , Any ] = Body ( . . . ) ) :
if not _diag_enabled ( ) :
raise HTTPException ( status_code = 403 , detail = " diagnostics_disabled " )
try :
event = payload . get ( " event " )
if not isinstance ( event , str ) or not event :
return JSONResponse ( { " ok " : False , " error " : " missing_event " } , status_code = 400 )
LOG_COUNTS [ event ] = LOG_COUNTS . get ( event , 0 ) + 1
if event == " preview_fetch_error " : # client-side fetch failure
try :
_theme_preview_mod . _PREVIEW_REQUEST_ERROR_COUNT + = 1 # type: ignore[attr-defined]
except Exception :
pass
# Lightweight echo back
return JSONResponse ( { " ok " : True , " count " : LOG_COUNTS [ event ] } )
except Exception as e : # pragma: no cover
return JSONResponse ( { " ok " : False , " error " : str ( e ) } , status_code = 500 )