DramaMeter2026 / app.py
yava-code's picture
expose fixture analysis through the Space API
c871ada
Raw
History Blame Contribute Delete
16.4 kB
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'<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)