from __future__ import annotations from datetime import datetime from html import escape import gradio as gr import spaces from drama import card_counts, competitors, h2h, observed_index, stat, train_and_score from feed import FeedError, MATCH_PAGE, find_event, fixture_choices, summary, tournament @spaces.GPU(duration=1) def zero_gpu_marker(): return True CSS = """ :root, .dark { --body-background-fill: #090b0d !important; --body-text-color: #f5f3ed !important; --block-background-fill: #12161a !important; --block-border-color: #2b3238 !important; --input-background-fill: #0d1013 !important; --input-border-color: #384149 !important; --input-placeholder-color: #7f898f !important; --button-secondary-background-fill: #171c20 !important; --button-secondary-text-color: #f5f3ed !important; } html, body, .gradio-container { background: #090b0d !important; color: #f5f3ed !important; } .gradio-container { max-width: 1180px !important; padding: 0 22px 60px !important; } .gradio-container label, .gradio-container .label-wrap span, .gradio-container .prose, .gradio-container p, .gradio-container h1, .gradio-container h2, .gradio-container h3 { color: #f5f3ed !important; } .topbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 34px 0 26px; border-bottom: 1px solid #2b3238; } .brand { font: 900 clamp(2rem, 5vw, 4.4rem)/.85 Arial Black, sans-serif; letter-spacing: -.075em; text-transform: uppercase; } .brand span { color: #c7ff2f; } .tagline { max-width: 340px; color: #aab3b8; font-size: .9rem; line-height: 1.45; text-align: right; } .live-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #ff503f; margin-right: 8px; box-shadow: 0 0 0 5px #ff503f22; } .control-row { margin: 24px 0 18px; } #fixture { flex: 1; } #refresh { max-width: 180px; align-self: end; } button#refresh { min-height: 48px; background: #c7ff2f !important; color: #090b0d !important; border: 0 !important; font-weight: 900 !important; } .match-shell { border: 1px solid #30373d; background: #101418; overflow: hidden; } .match-head { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 15px 20px; border-bottom: 1px solid #30373d; color: #aab3b8; font-size: .76rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } .status-live { color: #ff6557; } .scoreboard { display: grid; grid-template-columns: 1fr minmax(170px, .6fr) 1fr; align-items: center; gap: 24px; padding: 34px 28px 30px; } .team { display: flex; align-items: center; gap: 16px; min-width: 0; } .team.away { flex-direction: row-reverse; text-align: right; } .team img { width: 66px; height: 66px; object-fit: contain; } .team-name { font-size: clamp(1.1rem, 2.5vw, 1.85rem); font-weight: 900; } .team-form { color: #8c979d; font-size: .78rem; margin-top: 5px; } .score { text-align: center; font: 900 clamp(3rem, 8vw, 6.2rem)/.8 Arial Black, sans-serif; letter-spacing: -.08em; white-space: nowrap; } .clock { color: #c7ff2f; font-size: .78rem; font-weight: 900; letter-spacing: .08em; margin-top: 14px; } .index-band { display: grid; grid-template-columns: 230px 1fr; gap: 28px; padding: 26px 28px; background: #c7ff2f; color: #090b0d; } .index-band, .index-band * { color: #090b0d !important; } .index-number { font: 900 5rem/.8 Arial Black, sans-serif; letter-spacing: -.08em; } .index-label { font-weight: 900; font-size: .78rem; letter-spacing: .08em; margin-top: 14px; } .meter-track { height: 13px; background: #090b0d33; margin: 14px 0 12px; } .meter-fill { height: 100%; background: #090b0d; } .index-copy { font-weight: 750; line-height: 1.45; max-width: 630px; } .stat-grid { display: grid; grid-template-columns: repeat(6, 1fr); border-top: 1px solid #30373d; border-bottom: 1px solid #30373d; } .stat { padding: 19px 16px; border-right: 1px solid #30373d; } .stat:last-child { border-right: 0; } .stat b { display: block; font-size: 1.45rem; } .stat span { color: #879198; font-size: .7rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; } .detail-grid { display: grid; grid-template-columns: 1fr 1fr; } .detail { padding: 26px 28px 30px; } .detail + .detail { border-left: 1px solid #30373d; } .detail h3 { margin: 0 0 18px; font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; } .signal { display: flex; justify-content: space-between; gap: 20px; padding: 11px 0; border-bottom: 1px solid #262d32; color: #aeb7bc; } .signal b { color: #f5f3ed; } .event { display: grid; grid-template-columns: 58px 1fr; gap: 12px; padding: 10px 0; border-bottom: 1px solid #262d32; } .event time { color: #c7ff2f; font-weight: 900; } .event p { color: #c8ced1 !important; margin: 0; font-size: .86rem; line-height: 1.4; } .source-line { padding: 16px 28px; color: #778187; font-size: .72rem; border-top: 1px solid #30373d; } .source-line a { color: #c7ff2f !important; } .model-card { color: #bdc5c9; line-height: 1.55; } .model-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: #30373d; border: 1px solid #30373d; margin: 16px 0 20px; } .model-metric { background: #101418; padding: 18px; } .model-metric b { display: block; color: #f5f3ed; font-size: 1.55rem; } .model-metric span { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; } .error-card { border: 1px solid #ff6557; padding: 24px; color: #ffb0a9; background: #281411; } .footer-note { color: #6f797f; font-size: .72rem; padding: 18px 0; } @media (max-width: 760px) { .topbar { align-items: flex-start; padding-top: 24px; } .tagline { display: none; } .scoreboard { grid-template-columns: 1fr 120px 1fr; padding: 26px 16px; gap: 8px; } .team, .team.away { flex-direction: column; text-align: center; gap: 8px; } .team img { width: 48px; height: 48px; } .team-form { display: none; } .index-band { grid-template-columns: 1fr; gap: 18px; } .stat-grid { grid-template-columns: repeat(3, 1fr); } .stat:nth-child(3) { border-right: 0; } .detail-grid { grid-template-columns: 1fr; } .detail + .detail { border-left: 0; border-top: 1px solid #30373d; } .model-grid { grid-template-columns: repeat(2, 1fr); } } """ def logo(team: dict) -> str: return escape(team.get("team", {}).get("logo", ""), quote=True) def timeline(summary_data: dict) -> str: wanted = {"goal", "goal---header", "penalty---scored", "yellow-card", "red-card", "own-goal"} events = [item for item in summary_data.get("keyEvents", []) if item.get("type", {}).get("type") in wanted] rows = [] for item in events[-8:]: minute = escape(item.get("clock", {}).get("displayValue", "—")) text = escape(item.get("shortText") or item.get("text") or item.get("type", {}).get("text", "Match event")) rows.append(f'

{text}

') return "".join(rows) or '
No key events yetPre-match
' def match_html(event: dict, summary_data: dict, model: dict, meta: dict) -> tuple[str, str, str]: home, away = competitors(event) state = event["status"]["type"]["state"] actual = observed_index(event) if state != "pre" else None index = actual if actual is not None else model["forecast"] index_kind = "LIVE DRAMA INDEX" if state == "in" else "FINAL DRAMA INDEX" if state == "post" else "PRE-MATCH FORECAST" status = event["status"]["type"]["description"] kickoff = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).strftime("%b %d · %H:%M UTC") clock = kickoff if state == "pre" else event["status"].get("displayClock", "FT") scoreline = "VS" if state == "pre" else f"{home.get('score', '–')}–{away.get('score', '–')}" yellow, red = card_counts(event) fouls = round(stat(home, "foulsCommitted") + stat(away, "foulsCommitted")) shots = round(stat(home, "totalShots") + stat(away, "totalShots")) on_target = round(stat(home, "shotsOnTarget") + stat(away, "shotsOnTarget")) h2h_data = h2h(summary_data) venue = summary_data.get("gameInfo", {}).get("venue", {}) officials = summary_data.get("gameInfo", {}).get("officials", []) referee = officials[0].get("displayName", "Not listed") if officials else "Not listed" stage = model["features"]["stage"] stale = " · cached fallback" if meta.get("stale") else "" if state == "pre": lead = f"The model expects {stage.lower()} pressure, based on both teams’ tournament form before kickoff." else: lead = f"{fouls} fouls, {yellow + red} cards and {shots} shots are driving the live index. Pre-match model: {model['forecast']}/100." top_signals = "".join( f'
{escape(name.title())}{weight:.1f}%
' for name, weight in model["importances"][:5] ) dashboard = f"""
{escape(status)} · {escape(clock)} {escape(stage)} · {escape(venue.get('fullName', 'Venue TBC'))}
{escape(home['team']['displayName'])} badge
{escape(home['team']['displayName'])}
FORM {escape(home.get('form') or '—')}
{escape(scoreline)}
{escape(clock)}
{escape(away['team']['displayName'])} badge
{escape(away['team']['displayName'])}
FORM {escape(away.get('form') or '—')}
{index}
{index_kind}
{escape(lead)}
{model['forecast']}Model forecast
{model['confidence']}%Confidence
{fouls or '—'}Fouls
{yellow + red or '—'}Cards
{shots or '—'}Shots
{on_target or '—'}On target

What the model sees

{top_signals}

Match timeline

{timeline(summary_data)}
{h2h_data['games']}Recent H2H
{h2h_data['world_cups']}World Cup H2H
{h2h_data['shootouts']}H2H shootouts
{escape(referee)}Referee
{model['features']['home_ppg']}Home PPG
{model['features']['away_ppg']}Away PPG
Live match data from ESPN Match Center · fetched {escape(meta['fetched_at'])}{stale} · refresh cache 30–45 seconds
""" model_card = f"""

This is a live, explainable model — not an LLM guess. A gradient-boosted tree ensemble is retrained from scratch on completed 2026 World Cup matches using only information available before each kickoff.

{model['samples']}Training matches
{model['mae']}Held-out MAE
{model['baseline_lift']}%MAE lift vs baseline
{model['features']['prior_games']}Team games seen

Target: an observed match-drama proxy derived from fouls, cards, goals, score closeness, late goals, knockout stage and extra time. Forecast inputs: rolling team PPG, goals, fouls, cards, shots, stage and tournament experience. The target is an editorial index, not social-media ground truth or betting advice.

""" if state == "pre": share = f"{home['team']['displayName']} vs {away['team']['displayName']}: {model['forecast']}/100 pre-match DramaMeter forecast. Trained on {model['samples']} World Cup matches. #DramaMeter2026" else: phase = "live" if state == "in" else "final" share = f"{home['team']['displayName']} {home['score']}–{away['score']} {away['team']['displayName']}: {phase} DramaMeter {index}/100. {fouls} fouls, {yellow + red} cards, {shots} shots. #DramaMeter2026" return dashboard, share, model_card def render(event_id: str, force: bool = False): try: events, feed_meta = tournament(force) event = find_event(events, event_id) summary_data, summary_meta = summary(event_id, force) model = train_and_score(events, event) meta = { "fetched_at": max(feed_meta["fetched_at"], summary_meta["fetched_at"]), "stale": feed_meta["stale"] or summary_meta["stale"], } return match_html(event, summary_data, model, meta) except FeedError as exc: return f'
Live feed error
{escape(str(exc))}
', "", "" def load_dashboard(): try: events, _ = tournament() choices, default = fixture_choices(events) dashboard, share, model_card = render(default) return gr.update(choices=choices, value=default), dashboard, share, model_card except FeedError as exc: error = f'
Live feed error
{escape(str(exc))}
' return gr.update(choices=[], value=None), error, "", "" def refresh_dashboard(event_id: str): try: events, _ = tournament(force=True) choices, default = fixture_choices(events) selected = event_id if any(value == event_id for _, value in choices) else default dashboard, share, model_card = render(selected, force=True) return gr.update(choices=choices, value=selected), dashboard, share, model_card except FeedError as exc: error = f'
Live feed error
{escape(str(exc))}
' return gr.update(), error, "", "" with gr.Blocks(title="DramaMeter 2026") as demo: gr.HTML("""
DramaMeter 2026
Live World Cup match data. A model you can inspect. A score you can argue with.
""") with gr.Row(elem_classes="control-row"): fixture = gr.Dropdown( [], label="Match radar", info="Live, upcoming and latest World Cup fixtures", allow_custom_value=True, elem_id="fixture", ) refresh = gr.Button("Refresh live data", elem_id="refresh") dashboard = gr.HTML('
Loading the live tournament feed…
') share = gr.Textbox(label="Post-ready take", buttons=["copy"], interactive=False) with gr.Accordion("Model & data card", open=False): model_card = gr.HTML() gr.HTML('') fixture.change(render, fixture, [dashboard, share, model_card], show_progress="minimal") refresh.click(refresh_dashboard, fixture, [fixture, dashboard, share, model_card], show_progress="minimal") demo.load(load_dashboard, outputs=[fixture, dashboard, share, model_card], show_progress="minimal") if __name__ == "__main__": demo.launch(theme=gr.themes.Base(), css=CSS)