Spaces:
Paused
Paused
| 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 | |
| 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'<div class="event"><time>{minute}</time><p>{text}</p></div>') | |
| return "".join(rows) or '<div class="signal"><span>No key events yet</span><b>Pre-match</b></div>' | |
| 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'<div class="signal"><span>{escape(name.title())}</span><b>{weight:.1f}%</b></div>' | |
| for name, weight in model["importances"][:5] | |
| ) | |
| dashboard = f""" | |
| <section class="match-shell"> | |
| <div class="match-head"> | |
| <span class="{'status-live' if state == 'in' else ''}">{escape(status)} · {escape(clock)}</span> | |
| <span>{escape(stage)} · {escape(venue.get('fullName', 'Venue TBC'))}</span> | |
| </div> | |
| <div class="scoreboard"> | |
| <div class="team"> | |
| <img src="{logo(home)}" alt="{escape(home['team']['displayName'])} badge"> | |
| <div><div class="team-name">{escape(home['team']['displayName'])}</div><div class="team-form">FORM {escape(home.get('form') or '—')}</div></div> | |
| </div> | |
| <div class="score">{escape(scoreline)}<div class="clock">{escape(clock)}</div></div> | |
| <div class="team away"> | |
| <img src="{logo(away)}" alt="{escape(away['team']['displayName'])} badge"> | |
| <div><div class="team-name">{escape(away['team']['displayName'])}</div><div class="team-form">FORM {escape(away.get('form') or '—')}</div></div> | |
| </div> | |
| </div> | |
| <div class="index-band"> | |
| <div><div class="index-number">{index}</div><div class="index-label">{index_kind}</div></div> | |
| <div class="index-copy"><div class="meter-track"><div class="meter-fill" style="width:{index}%"></div></div>{escape(lead)}</div> | |
| </div> | |
| <div class="stat-grid"> | |
| <div class="stat"><b>{model['forecast']}</b><span>Model forecast</span></div> | |
| <div class="stat"><b>{model['confidence']}%</b><span>Confidence</span></div> | |
| <div class="stat"><b>{fouls or '—'}</b><span>Fouls</span></div> | |
| <div class="stat"><b>{yellow + red or '—'}</b><span>Cards</span></div> | |
| <div class="stat"><b>{shots or '—'}</b><span>Shots</span></div> | |
| <div class="stat"><b>{on_target or '—'}</b><span>On target</span></div> | |
| </div> | |
| <div class="detail-grid"> | |
| <div class="detail"><h3>What the model sees</h3>{top_signals}</div> | |
| <div class="detail"><h3>Match timeline</h3>{timeline(summary_data)}</div> | |
| </div> | |
| <div class="stat-grid"> | |
| <div class="stat"><b>{h2h_data['games']}</b><span>Recent H2H</span></div> | |
| <div class="stat"><b>{h2h_data['world_cups']}</b><span>World Cup H2H</span></div> | |
| <div class="stat"><b>{h2h_data['shootouts']}</b><span>H2H shootouts</span></div> | |
| <div class="stat"><b>{escape(referee)}</b><span>Referee</span></div> | |
| <div class="stat"><b>{model['features']['home_ppg']}</b><span>Home PPG</span></div> | |
| <div class="stat"><b>{model['features']['away_ppg']}</b><span>Away PPG</span></div> | |
| </div> | |
| <div class="source-line">Live match data from <a href="{MATCH_PAGE.format(event['id'])}" target="_blank">ESPN Match Center</a> · fetched {escape(meta['fetched_at'])}{stale} · refresh cache 30–45 seconds</div> | |
| </section> | |
| """ | |
| model_card = f""" | |
| <div class="model-card"> | |
| <p>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.</p> | |
| <div class="model-grid"> | |
| <div class="model-metric"><b>{model['samples']}</b><span>Training matches</span></div> | |
| <div class="model-metric"><b>{model['mae']}</b><span>Held-out MAE</span></div> | |
| <div class="model-metric"><b>{model['baseline_lift']}%</b><span>MAE lift vs baseline</span></div> | |
| <div class="model-metric"><b>{model['features']['prior_games']}</b><span>Team games seen</span></div> | |
| </div> | |
| <p><b>Target:</b> an observed match-drama proxy derived from fouls, cards, goals, score closeness, late goals, knockout stage and extra time. <b>Forecast inputs:</b> 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.</p> | |
| </div> | |
| """ | |
| 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'<div class="error-card"><b>Live feed error</b><br>{escape(str(exc))}</div>', "", "" | |
| 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'<div class="error-card"><b>Live feed error</b><br>{escape(str(exc))}</div>' | |
| 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'<div class="error-card"><b>Live feed error</b><br>{escape(str(exc))}</div>' | |
| return gr.update(), error, "", "" | |
| with gr.Blocks(title="DramaMeter 2026") as demo: | |
| gr.HTML(""" | |
| <header class="topbar"> | |
| <div class="brand">DramaMeter <span>2026</span></div> | |
| <div class="tagline"><span class="live-dot"></span>Live World Cup match data. A model you can inspect. A score you can argue with.</div> | |
| </header> | |
| """) | |
| 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('<div class="match-shell"><div class="source-line">Loading the live tournament feed…</div></div>') | |
| 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('<div class="footer-note">Independent fan analytics project. Match data is attributed to ESPN Match Center. No betting advice.</div>') | |
| 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) | |