yava-code commited on
Commit
9645b79
·
1 Parent(s): 5681627

replace hardcoded demo with live World Cup model

Browse files
Files changed (6) hide show
  1. README.md +17 -11
  2. app.py +239 -98
  3. drama.py +212 -126
  4. feed.py +83 -0
  5. requirements.txt +2 -0
  6. tests/test_drama.py +71 -18
README.md CHANGED
@@ -9,20 +9,23 @@ python_version: "3.12"
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
- short_description: How much football chaos is coming?
13
  ---
14
 
15
  # DramaMeter 2026
16
 
17
- Football is never just football. DramaMeter gives a match a 0–100 chaos score, names the flashpoints, rates a dodgy VAR moment, and turns the final whistle into a short fan-ready recap.
18
 
19
- ## What it does
20
 
21
- - **Match Forecast** rivalry, team edge, tournament VAR heat and knockout stakes become one explainable Drama Score.
22
- - **VAR Moment** — paste a controversial incident and get a controversy rating, likely fan reaction and a historical echo.
23
- - **Full-time Recap** turn a result and key moment into a sharp post-match report.
 
 
 
24
 
25
- England vs Argentina is loaded by default. You can type any two teams.
26
 
27
  ## Run locally
28
 
@@ -33,10 +36,13 @@ pip install -r requirements.txt
33
  python app.py
34
  ```
35
 
36
- Run the tests with `python -m pytest`.
37
 
38
- ## How the score works
39
 
40
- The MVP uses a small, deterministic scoring model. Known rivalries have hand-tuned history and intensity ratings. Unknown matchups fall back to team profiles and a stable matchup value. The stage and VAR heat controls are always visible, so the score never pretends to come from a hidden live feed.
 
 
 
41
 
42
- This is a fan toy, not a betting model, a referee assessment, or a live data product.
 
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
+ short_description: Live, explainable World Cup drama analytics
13
  ---
14
 
15
  # DramaMeter 2026
16
 
17
+ A live football analytics product for the 2026 World Cup. Pick a current, upcoming or recent fixture and get a pre-match forecast, a live drama index, the match events behind it and a post-ready take.
18
 
19
+ ## What makes it real
20
 
21
+ - Loads the current 2026 World Cup schedule, scores and match statistics from ESPN Match Center's public JSON feed.
22
+ - Retrains a gradient-boosted tree ensemble on every completed tournament match at startup.
23
+ - Builds rolling pre-match features without using future match statistics.
24
+ - Reports held-out MAE and improvement over a naive mean baseline instead of hiding model quality.
25
+ - Replaces the forecast with an observed live index once a match starts.
26
+ - Shows fetch time, data source, model inputs and feature importance in the UI.
27
 
28
+ The observed target is an editorial proxy derived from fouls, cards, goals, score closeness, late goals, stage and extra time. It is not a claim about social-media sentiment and it is not betting advice.
29
 
30
  ## Run locally
31
 
 
36
  python app.py
37
  ```
38
 
39
+ Run tests with `python -m pytest`.
40
 
41
+ ## Architecture
42
 
43
+ - `feed.py` live data client, 30–45 second cache and fixture selection
44
+ - `drama.py` — rolling features, observed target and gradient-boosted model training
45
+ - `app.py` — responsive Gradio product UI and data-driven match report
46
+ - `tests/` — deterministic model and feed-contract tests
47
 
48
+ Data availability depends on ESPN's public Match Center endpoints. Cached data is used if a refresh fails after at least one successful request.
app.py CHANGED
@@ -1,129 +1,270 @@
 
 
 
1
  from html import escape
2
 
3
  import gradio as gr
4
  import spaces
5
 
6
- from drama import TEAMS, analyze_moment, match_forecast, post_match
 
7
 
8
 
9
- # The existing Space is pinned to ZeroGPU and refuses CPU-only apps without a marker.
10
  @spaces.GPU(duration=1)
11
  def zero_gpu_marker():
12
  return True
13
 
14
 
15
  CSS = """
16
- :root { --ink: #11110f; --paper: #f4f0e6; --lime: #c7ff2f; --red: #ff473d; }
17
- .gradio-container { background: var(--paper) !important; color: var(--ink); max-width: 1120px !important; }
18
- .hero { border: 4px solid var(--ink); background: var(--lime); padding: 28px; box-shadow: 8px 8px 0 var(--ink); margin: 18px 0 26px; }
19
- .hero h1 { font-size: clamp(2.7rem, 8vw, 6.6rem); line-height: .82; letter-spacing: -.07em; margin: 0; text-transform: uppercase; }
20
- .hero p { font-weight: 800; font-size: 1.05rem; margin: 18px 0 0; text-transform: uppercase; letter-spacing: .04em; }
21
- .score-card { border: 4px solid var(--ink); background: var(--ink); color: white; padding: 26px; box-shadow: 8px 8px 0 var(--red); min-height: 250px; }
22
- .score-card .fixture { color: var(--lime); font-weight: 900; text-transform: uppercase; letter-spacing: .08em; }
23
- .score-card .score { font-size: clamp(5rem, 13vw, 9rem); font-weight: 950; line-height: .8; letter-spacing: -.08em; margin: 24px 0 14px; }
24
- .score-card .score span { color: #8a8a84; font-size: .28em; letter-spacing: 0; }
25
- .score-card .verdict { background: var(--red); color: var(--ink); display: inline-block; font-weight: 950; padding: 8px 12px; transform: rotate(-1deg); }
26
- .moment-card { border: 4px solid var(--ink); padding: 24px; background: white; box-shadow: 8px 8px 0 var(--lime); }
27
- .moment-card strong { display: block; font-size: 4rem; line-height: 1; }
28
- .disclaimer { border-top: 2px solid var(--ink); margin-top: 30px; padding-top: 12px; font-size: .85rem; font-weight: 700; }
29
- button.primary { background: var(--red) !important; color: var(--ink) !important; border: 3px solid var(--ink) !important; box-shadow: 5px 5px 0 var(--ink) !important; font-weight: 950 !important; text-transform: uppercase; }
30
- button.primary:hover { transform: translate(2px, 2px); box-shadow: 3px 3px 0 var(--ink) !important; }
31
- .tab-nav button { font-weight: 900 !important; text-transform: uppercase; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  """
33
 
34
 
35
- def forecast(team_a, team_b, stage, var_heat):
36
- result = match_forecast(team_a, team_b, stage, var_heat)
37
- a, b = result["teams"]
38
- card = f"""
39
- <div class="score-card">
40
- <div class="fixture">{escape(a)} vs {escape(b)}</div>
41
- <div class="score">{result['score']}<span>/100</span></div>
42
- <div class="verdict">{result['verdict']}</div>
43
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  """
45
- factors = " · ".join(f"**{name}:** {value}" for name, value in result["factors"].items())
46
- stories = "\n".join(f"{i}. {line}" for i, line in enumerate(result["narratives"], 1))
47
- breakdown = f"### Why it could kick off\n\n{stories}\n\n---\n{factors}"
48
- share = f"{a} vs {b}: {result['score']}/100 on the DramaMeter. {result['verdict']}. Who loses their head first? #DramaMeter2026"
49
- return card, breakdown, share
50
-
51
-
52
- def controversy(text, minute, tension):
53
- result = analyze_moment(text, minute, tension)
54
- card = f"""
55
- <div class="moment-card">
56
- <span>CONTROVERSY RATING</span>
57
- <strong>{result['score']}/100</strong>
58
- <p>{escape(result['reaction'])}</p>
59
  </div>
60
  """
61
- notes = f"### Historical echo\n\n{result['echo']}\n\n### The moment\n\n{escape(result['moment'])}"
62
- return card, notes
 
 
 
 
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
- def recap(home, away, home_goals, away_goals, key_moment, fan_heat):
66
- result = post_match(home, away, home_goals, away_goals, key_moment, fan_heat)
67
- return result["report"], result["share"]
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
- team_choices = sorted(TEAMS)
71
 
72
  with gr.Blocks(title="DramaMeter 2026") as demo:
73
  gr.HTML("""
74
- <section class="hero">
75
- <h1>Drama<br>Meter '26</h1>
76
- <p>How much football chaos is coming?</p>
77
- </section>
78
  """)
 
 
 
 
 
 
 
 
79
 
80
- with gr.Tabs():
81
- with gr.Tab("Match Forecast"):
82
- with gr.Row():
83
- with gr.Column(scale=4):
84
- team_a = gr.Dropdown(team_choices, value="England", allow_custom_value=True, label="Team one")
85
- team_b = gr.Dropdown(team_choices, value="Argentina", allow_custom_value=True, label="Team two")
86
- stage = gr.Radio(list(("Group stage", "Round of 16", "Quarter-final", "Semi-final", "Final")), value="Quarter-final", label="Stage")
87
- var_heat = gr.Slider(0, 100, value=72, step=1, label="Tournament VAR heat", info="Your read on how angry the tournament feels")
88
- forecast_btn = gr.Button("Calculate the drama", variant="primary")
89
- with gr.Column(scale=5):
90
- forecast_card = gr.HTML()
91
- forecast_notes = gr.Markdown()
92
- forecast_share = gr.Textbox(label="Copy this post", buttons=["copy"], interactive=False)
93
-
94
- forecast_btn.click(forecast, [team_a, team_b, stage, var_heat], [forecast_card, forecast_notes, forecast_share])
95
- demo.load(forecast, [team_a, team_b, stage, var_heat], [forecast_card, forecast_notes, forecast_share])
96
-
97
- with gr.Tab("VAR Moment"):
98
- with gr.Row():
99
- with gr.Column():
100
- moment = gr.Textbox(lines=5, label="What happened?", placeholder="88th minute. The ball hits a defender's arm in the box. VAR says no penalty.")
101
- minute = gr.Slider(1, 120, value=88, step=1, label="Minute")
102
- tension = gr.Slider(0, 100, value=78, step=1, label="Match tension")
103
- moment_btn = gr.Button("Rate the controversy", variant="primary")
104
- with gr.Column():
105
- moment_card = gr.HTML()
106
- moment_notes = gr.Markdown()
107
- moment_btn.click(controversy, [moment, minute, tension], [moment_card, moment_notes])
108
-
109
- with gr.Tab("Full-time Recap"):
110
- with gr.Row():
111
- with gr.Column():
112
- home = gr.Dropdown(team_choices, value="England", allow_custom_value=True, label="Home team")
113
- away = gr.Dropdown(team_choices, value="Argentina", allow_custom_value=True, label="Away team")
114
- with gr.Row():
115
- home_goals = gr.Number(value=1, minimum=0, precision=0, label="Home goals")
116
- away_goals = gr.Number(value=2, minimum=0, precision=0, label="Away goals")
117
- key_moment = gr.Textbox(lines=4, label="The moment everyone is arguing about")
118
- fan_heat = gr.Slider(0, 100, value=85, step=1, label="Fan heat")
119
- recap_btn = gr.Button("Write the recap", variant="primary")
120
- with gr.Column():
121
- report = gr.Markdown()
122
- recap_share = gr.Textbox(label="Copy this post", lines=3, buttons=["copy"], interactive=False)
123
- recap_btn.click(recap, [home, away, home_goals, away_goals, key_moment, fan_heat], [report, recap_share])
124
-
125
- gr.HTML("<div class='disclaimer'>Fan-made entertainment. No live data. No betting advice. Referees may disagree.</div>")
126
 
127
 
128
  if __name__ == "__main__":
129
- demo.launch(css=CSS)
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
  from html import escape
5
 
6
  import gradio as gr
7
  import spaces
8
 
9
+ from drama import card_counts, competitors, h2h, observed_index, stat, train_and_score
10
+ from feed import FeedError, MATCH_PAGE, find_event, fixture_choices, summary, tournament
11
 
12
 
 
13
  @spaces.GPU(duration=1)
14
  def zero_gpu_marker():
15
  return True
16
 
17
 
18
  CSS = """
19
+ :root, .dark {
20
+ --body-background-fill: #090b0d !important;
21
+ --body-text-color: #f5f3ed !important;
22
+ --block-background-fill: #12161a !important;
23
+ --block-border-color: #2b3238 !important;
24
+ --input-background-fill: #0d1013 !important;
25
+ --input-border-color: #384149 !important;
26
+ --input-placeholder-color: #7f898f !important;
27
+ --button-secondary-background-fill: #171c20 !important;
28
+ --button-secondary-text-color: #f5f3ed !important;
29
+ }
30
+ html, body, .gradio-container { background: #090b0d !important; color: #f5f3ed !important; }
31
+ .gradio-container { max-width: 1180px !important; padding: 0 22px 60px !important; }
32
+ .gradio-container label, .gradio-container .label-wrap span, .gradio-container .prose,
33
+ .gradio-container p, .gradio-container h1, .gradio-container h2, .gradio-container h3 { color: #f5f3ed !important; }
34
+ .topbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 34px 0 26px; border-bottom: 1px solid #2b3238; }
35
+ .brand { font: 900 clamp(2rem, 5vw, 4.4rem)/.85 Arial Black, sans-serif; letter-spacing: -.075em; text-transform: uppercase; }
36
+ .brand span { color: #c7ff2f; }
37
+ .tagline { max-width: 340px; color: #aab3b8; font-size: .9rem; line-height: 1.45; text-align: right; }
38
+ .live-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #ff503f; margin-right: 8px; box-shadow: 0 0 0 5px #ff503f22; }
39
+ .control-row { margin: 24px 0 18px; }
40
+ #fixture { flex: 1; }
41
+ #refresh { max-width: 180px; align-self: end; }
42
+ button#refresh { min-height: 48px; background: #c7ff2f !important; color: #090b0d !important; border: 0 !important; font-weight: 900 !important; }
43
+ .match-shell { border: 1px solid #30373d; background: #101418; overflow: hidden; }
44
+ .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; }
45
+ .status-live { color: #ff6557; }
46
+ .scoreboard { display: grid; grid-template-columns: 1fr minmax(170px, .6fr) 1fr; align-items: center; gap: 24px; padding: 34px 28px 30px; }
47
+ .team { display: flex; align-items: center; gap: 16px; min-width: 0; }
48
+ .team.away { flex-direction: row-reverse; text-align: right; }
49
+ .team img { width: 66px; height: 66px; object-fit: contain; }
50
+ .team-name { font-size: clamp(1.1rem, 2.5vw, 1.85rem); font-weight: 900; }
51
+ .team-form { color: #8c979d; font-size: .78rem; margin-top: 5px; }
52
+ .score { text-align: center; font: 900 clamp(3rem, 8vw, 6.2rem)/.8 Arial Black, sans-serif; letter-spacing: -.08em; white-space: nowrap; }
53
+ .clock { color: #c7ff2f; font-size: .78rem; font-weight: 900; letter-spacing: .08em; margin-top: 14px; }
54
+ .index-band { display: grid; grid-template-columns: 230px 1fr; gap: 28px; padding: 26px 28px; background: #c7ff2f; color: #090b0d; }
55
+ .index-band, .index-band * { color: #090b0d !important; }
56
+ .index-number { font: 900 5rem/.8 Arial Black, sans-serif; letter-spacing: -.08em; }
57
+ .index-label { font-weight: 900; font-size: .78rem; letter-spacing: .08em; margin-top: 14px; }
58
+ .meter-track { height: 13px; background: #090b0d33; margin: 14px 0 12px; }
59
+ .meter-fill { height: 100%; background: #090b0d; }
60
+ .index-copy { font-weight: 750; line-height: 1.45; max-width: 630px; }
61
+ .stat-grid { display: grid; grid-template-columns: repeat(6, 1fr); border-top: 1px solid #30373d; border-bottom: 1px solid #30373d; }
62
+ .stat { padding: 19px 16px; border-right: 1px solid #30373d; }
63
+ .stat:last-child { border-right: 0; }
64
+ .stat b { display: block; font-size: 1.45rem; }
65
+ .stat span { color: #879198; font-size: .7rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
66
+ .detail-grid { display: grid; grid-template-columns: 1fr 1fr; }
67
+ .detail { padding: 26px 28px 30px; }
68
+ .detail + .detail { border-left: 1px solid #30373d; }
69
+ .detail h3 { margin: 0 0 18px; font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }
70
+ .signal { display: flex; justify-content: space-between; gap: 20px; padding: 11px 0; border-bottom: 1px solid #262d32; color: #aeb7bc; }
71
+ .signal b { color: #f5f3ed; }
72
+ .event { display: grid; grid-template-columns: 58px 1fr; gap: 12px; padding: 10px 0; border-bottom: 1px solid #262d32; }
73
+ .event time { color: #c7ff2f; font-weight: 900; }
74
+ .event p { color: #c8ced1 !important; margin: 0; font-size: .86rem; line-height: 1.4; }
75
+ .source-line { padding: 16px 28px; color: #778187; font-size: .72rem; border-top: 1px solid #30373d; }
76
+ .source-line a { color: #c7ff2f !important; }
77
+ .model-card { color: #bdc5c9; line-height: 1.55; }
78
+ .model-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: #30373d; border: 1px solid #30373d; margin: 16px 0 20px; }
79
+ .model-metric { background: #101418; padding: 18px; }
80
+ .model-metric b { display: block; color: #f5f3ed; font-size: 1.55rem; }
81
+ .model-metric span { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; }
82
+ .error-card { border: 1px solid #ff6557; padding: 24px; color: #ffb0a9; background: #281411; }
83
+ .footer-note { color: #6f797f; font-size: .72rem; padding: 18px 0; }
84
+ @media (max-width: 760px) {
85
+ .topbar { align-items: flex-start; padding-top: 24px; }
86
+ .tagline { display: none; }
87
+ .scoreboard { grid-template-columns: 1fr 120px 1fr; padding: 26px 16px; gap: 8px; }
88
+ .team, .team.away { flex-direction: column; text-align: center; gap: 8px; }
89
+ .team img { width: 48px; height: 48px; }
90
+ .team-form { display: none; }
91
+ .index-band { grid-template-columns: 1fr; gap: 18px; }
92
+ .stat-grid { grid-template-columns: repeat(3, 1fr); }
93
+ .stat:nth-child(3) { border-right: 0; }
94
+ .detail-grid { grid-template-columns: 1fr; }
95
+ .detail + .detail { border-left: 0; border-top: 1px solid #30373d; }
96
+ .model-grid { grid-template-columns: repeat(2, 1fr); }
97
+ }
98
  """
99
 
100
 
101
+ def logo(team: dict) -> str:
102
+ return escape(team.get("team", {}).get("logo", ""), quote=True)
103
+
104
+
105
+ def timeline(summary_data: dict) -> str:
106
+ wanted = {"goal", "goal---header", "penalty---scored", "yellow-card", "red-card", "own-goal"}
107
+ events = [item for item in summary_data.get("keyEvents", []) if item.get("type", {}).get("type") in wanted]
108
+ rows = []
109
+ for item in events[-8:]:
110
+ minute = escape(item.get("clock", {}).get("displayValue", "—"))
111
+ text = escape(item.get("shortText") or item.get("text") or item.get("type", {}).get("text", "Match event"))
112
+ rows.append(f'<div class="event"><time>{minute}</time><p>{text}</p></div>')
113
+ return "".join(rows) or '<div class="signal"><span>No key events yet</span><b>Pre-match</b></div>'
114
+
115
+
116
+ def match_html(event: dict, summary_data: dict, model: dict, meta: dict) -> tuple[str, str, str]:
117
+ home, away = competitors(event)
118
+ state = event["status"]["type"]["state"]
119
+ actual = observed_index(event) if state != "pre" else None
120
+ index = actual if actual is not None else model["forecast"]
121
+ index_kind = "LIVE DRAMA INDEX" if state == "in" else "FINAL DRAMA INDEX" if state == "post" else "PRE-MATCH FORECAST"
122
+ status = event["status"]["type"]["description"]
123
+ kickoff = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).strftime("%b %d · %H:%M UTC")
124
+ clock = kickoff if state == "pre" else event["status"].get("displayClock", "FT")
125
+ scoreline = "VS" if state == "pre" else f"{home.get('score', '–')}–{away.get('score', '–')}"
126
+ yellow, red = card_counts(event)
127
+ fouls = round(stat(home, "foulsCommitted") + stat(away, "foulsCommitted"))
128
+ shots = round(stat(home, "totalShots") + stat(away, "totalShots"))
129
+ on_target = round(stat(home, "shotsOnTarget") + stat(away, "shotsOnTarget"))
130
+ h2h_data = h2h(summary_data)
131
+ venue = summary_data.get("gameInfo", {}).get("venue", {})
132
+ officials = summary_data.get("gameInfo", {}).get("officials", [])
133
+ referee = officials[0].get("displayName", "Not listed") if officials else "Not listed"
134
+ stage = model["features"]["stage"]
135
+ stale = " · cached fallback" if meta.get("stale") else ""
136
+
137
+ if state == "pre":
138
+ lead = f"The model expects {stage.lower()} pressure, based on both teams’ tournament form before kickoff."
139
+ else:
140
+ lead = f"{fouls} fouls, {yellow + red} cards and {shots} shots are driving the live index. Pre-match model: {model['forecast']}/100."
141
+
142
+ top_signals = "".join(
143
+ f'<div class="signal"><span>{escape(name.title())}</span><b>{weight:.1f}%</b></div>'
144
+ for name, weight in model["importances"][:5]
145
+ )
146
+ dashboard = f"""
147
+ <section class="match-shell">
148
+ <div class="match-head">
149
+ <span class="{'status-live' if state == 'in' else ''}">{escape(status)} · {escape(clock)}</span>
150
+ <span>{escape(stage)} · {escape(venue.get('fullName', 'Venue TBC'))}</span>
151
+ </div>
152
+ <div class="scoreboard">
153
+ <div class="team">
154
+ <img src="{logo(home)}" alt="{escape(home['team']['displayName'])} badge">
155
+ <div><div class="team-name">{escape(home['team']['displayName'])}</div><div class="team-form">FORM {escape(home.get('form') or '—')}</div></div>
156
+ </div>
157
+ <div class="score">{escape(scoreline)}<div class="clock">{escape(clock)}</div></div>
158
+ <div class="team away">
159
+ <img src="{logo(away)}" alt="{escape(away['team']['displayName'])} badge">
160
+ <div><div class="team-name">{escape(away['team']['displayName'])}</div><div class="team-form">FORM {escape(away.get('form') or '—')}</div></div>
161
+ </div>
162
+ </div>
163
+ <div class="index-band">
164
+ <div><div class="index-number">{index}</div><div class="index-label">{index_kind}</div></div>
165
+ <div class="index-copy"><div class="meter-track"><div class="meter-fill" style="width:{index}%"></div></div>{escape(lead)}</div>
166
+ </div>
167
+ <div class="stat-grid">
168
+ <div class="stat"><b>{model['forecast']}</b><span>Model forecast</span></div>
169
+ <div class="stat"><b>{model['confidence']}%</b><span>Confidence</span></div>
170
+ <div class="stat"><b>{fouls or '—'}</b><span>Fouls</span></div>
171
+ <div class="stat"><b>{yellow + red or '—'}</b><span>Cards</span></div>
172
+ <div class="stat"><b>{shots or '—'}</b><span>Shots</span></div>
173
+ <div class="stat"><b>{on_target or '—'}</b><span>On target</span></div>
174
+ </div>
175
+ <div class="detail-grid">
176
+ <div class="detail"><h3>What the model sees</h3>{top_signals}</div>
177
+ <div class="detail"><h3>Match timeline</h3>{timeline(summary_data)}</div>
178
+ </div>
179
+ <div class="stat-grid">
180
+ <div class="stat"><b>{h2h_data['games']}</b><span>Recent H2H</span></div>
181
+ <div class="stat"><b>{h2h_data['world_cups']}</b><span>World Cup H2H</span></div>
182
+ <div class="stat"><b>{h2h_data['shootouts']}</b><span>H2H shootouts</span></div>
183
+ <div class="stat"><b>{escape(referee)}</b><span>Referee</span></div>
184
+ <div class="stat"><b>{model['features']['home_ppg']}</b><span>Home PPG</span></div>
185
+ <div class="stat"><b>{model['features']['away_ppg']}</b><span>Away PPG</span></div>
186
+ </div>
187
+ <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>
188
+ </section>
189
  """
190
+ model_card = f"""
191
+ <div class="model-card">
192
+ <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>
193
+ <div class="model-grid">
194
+ <div class="model-metric"><b>{model['samples']}</b><span>Training matches</span></div>
195
+ <div class="model-metric"><b>{model['mae']}</b><span>Held-out MAE</span></div>
196
+ <div class="model-metric"><b>{model['baseline_lift']}%</b><span>MAE lift vs baseline</span></div>
197
+ <div class="model-metric"><b>{model['features']['prior_games']}</b><span>Team games seen</span></div>
198
+ </div>
199
+ <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>
 
 
 
 
200
  </div>
201
  """
202
+ if state == "pre":
203
+ share = f"{home['team']['displayName']} vs {away['team']['displayName']}: {model['forecast']}/100 pre-match DramaMeter forecast. Trained on {model['samples']} World Cup matches. #DramaMeter2026"
204
+ else:
205
+ phase = "live" if state == "in" else "final"
206
+ share = f"{home['team']['displayName']} {home['score']}–{away['score']} {away['team']['displayName']}: {phase} DramaMeter {index}/100. {fouls} fouls, {yellow + red} cards, {shots} shots. #DramaMeter2026"
207
+ return dashboard, share, model_card
208
+
209
 
210
+ def render(event_id: str, force: bool = False):
211
+ try:
212
+ events, feed_meta = tournament(force)
213
+ event = find_event(events, event_id)
214
+ summary_data, summary_meta = summary(event_id, force)
215
+ model = train_and_score(events, event)
216
+ meta = {
217
+ "fetched_at": max(feed_meta["fetched_at"], summary_meta["fetched_at"]),
218
+ "stale": feed_meta["stale"] or summary_meta["stale"],
219
+ }
220
+ return match_html(event, summary_data, model, meta)
221
+ except FeedError as exc:
222
+ return f'<div class="error-card"><b>Live feed error</b><br>{escape(str(exc))}</div>', "", ""
223
 
 
 
 
224
 
225
+ def load_dashboard():
226
+ try:
227
+ events, _ = tournament()
228
+ choices, default = fixture_choices(events)
229
+ dashboard, share, model_card = render(default)
230
+ return gr.update(choices=choices, value=default), dashboard, share, model_card
231
+ except FeedError as exc:
232
+ error = f'<div class="error-card"><b>Live feed error</b><br>{escape(str(exc))}</div>'
233
+ return gr.update(choices=[], value=None), error, "", ""
234
+
235
+
236
+ def refresh_dashboard(event_id: str):
237
+ try:
238
+ events, _ = tournament(force=True)
239
+ choices, default = fixture_choices(events)
240
+ selected = event_id if any(value == event_id for _, value in choices) else default
241
+ dashboard, share, model_card = render(selected, force=True)
242
+ return gr.update(choices=choices, value=selected), dashboard, share, model_card
243
+ except FeedError as exc:
244
+ error = f'<div class="error-card"><b>Live feed error</b><br>{escape(str(exc))}</div>'
245
+ return gr.update(), error, "", ""
246
 
 
247
 
248
  with gr.Blocks(title="DramaMeter 2026") as demo:
249
  gr.HTML("""
250
+ <header class="topbar">
251
+ <div class="brand">DramaMeter <span>2026</span></div>
252
+ <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>
253
+ </header>
254
  """)
255
+ with gr.Row(elem_classes="control-row"):
256
+ fixture = gr.Dropdown([], label="Match radar", info="Live, upcoming and latest World Cup fixtures", elem_id="fixture")
257
+ refresh = gr.Button("Refresh live data", elem_id="refresh")
258
+ dashboard = gr.HTML('<div class="match-shell"><div class="source-line">Loading the live tournament feed…</div></div>')
259
+ share = gr.Textbox(label="Post-ready take", buttons=["copy"], interactive=False)
260
+ with gr.Accordion("Model & data card", open=False):
261
+ model_card = gr.HTML()
262
+ gr.HTML('<div class="footer-note">Independent fan analytics project. Match data is attributed to ESPN Match Center. No betting advice.</div>')
263
 
264
+ fixture.change(render, fixture, [dashboard, share, model_card], show_progress="minimal")
265
+ refresh.click(refresh_dashboard, fixture, [fixture, dashboard, share, model_card], show_progress="minimal")
266
+ demo.load(load_dashboard, outputs=[fixture, dashboard, share, model_card], show_progress="minimal")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
 
269
  if __name__ == "__main__":
270
+ demo.launch(theme=gr.themes.Base(), css=CSS)
drama.py CHANGED
@@ -1,137 +1,223 @@
1
  from __future__ import annotations
2
 
3
- import hashlib
4
- import re
5
-
6
-
7
- TEAMS = {
8
- "Argentina": 91,
9
- "Brazil": 88,
10
- "England": 84,
11
- "France": 80,
12
- "Germany": 82,
13
- "Italy": 86,
14
- "Mexico": 78,
15
- "Netherlands": 87,
16
- "Portugal": 83,
17
- "Spain": 74,
18
- "Uruguay": 89,
 
 
 
 
 
 
 
 
 
 
 
 
19
  }
20
 
21
- RIVALRIES = {
22
- frozenset(("Argentina", "Brazil")): (98, "South American royalty with no quiet tackles"),
23
- frozenset(("Argentina", "England")): (97, "1986, 1998 and decades of unfinished business"),
24
- frozenset(("Argentina", "Netherlands")): (94, "knockout grudges, touchline heat and penalty scars"),
25
- frozenset(("England", "Germany")): (91, "ghost goals, shootouts and national nerves"),
26
- frozenset(("Argentina", "Uruguay")): (90, "the oldest international rivalry still bites"),
27
- frozenset(("Brazil", "Uruguay")): (89, "Maracanazo history hangs over every meeting"),
28
- frozenset(("Italy", "Germany")): (84, "heavyweight knockout history and zero easy minutes"),
29
- frozenset(("Portugal", "Spain")): (82, "neighbours, stars and an Iberian point to prove"),
30
- frozenset(("England", "Scotland")): (88, "the oldest fixture in football needs no warm-up"),
31
  }
32
 
33
- STAGE_BONUS = {
34
- "Group stage": 0,
35
- "Round of 16": 3,
36
- "Quarter-final": 6,
37
- "Semi-final": 9,
38
- "Final": 12,
39
- }
40
-
41
-
42
- def clamp(value: float, low: int = 0, high: int = 100) -> int:
43
- return max(low, min(high, round(value)))
44
-
45
-
46
- def clean_team(name: str) -> str:
47
- name = re.sub(r"\s+", " ", (name or "").strip())
48
- return name[:40] or "Unknown XI"
49
-
50
-
51
- def stable_number(*parts: str, low: int = 58, span: int = 24) -> int:
52
- key = "|".join(part.casefold() for part in parts).encode()
53
- return low + int(hashlib.sha256(key).hexdigest()[:8], 16) % span
54
 
55
-
56
- def match_forecast(team_a: str, team_b: str, stage: str, var_heat: float) -> dict:
57
- a, b = clean_team(team_a), clean_team(team_b)
58
- pair = frozenset((a, b))
59
- known = RIVALRIES.get(pair)
60
- rivalry = known[0] if known else stable_number(*sorted(pair), low=54, span=28)
61
- history = known[1] if known else "no famous grudge — yet"
62
- edge_a = TEAMS.get(a, stable_number(a, low=66, span=18))
63
- edge_b = TEAMS.get(b, stable_number(b, low=66, span=18))
64
- intensity = round((edge_a + edge_b) / 2)
65
- score = clamp(rivalry * 0.48 + intensity * 0.27 + float(var_heat) * 0.2 + STAGE_BONUS[stage])
66
-
67
- if score >= 90:
68
- verdict = "ABSOLUTE CINEMA"
69
- elif score >= 78:
70
- verdict = "GET THE POPCORN"
71
- elif score >= 64:
72
- verdict = "ONE BAD WHISTLE AWAY"
73
- else:
74
- verdict = "COULD STILL KICK OFF"
75
-
76
- narratives = [
77
- f"First flashpoint: one early yellow and {history} comes straight back.",
78
- f"VAR watch: at {round(var_heat)}% heat, every slow-motion replay becomes a national debate.",
79
- f"Pressure point: a {stage.lower()} leaves no room for a calm reaction after a late call.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  ]
81
- factors = {
82
- "Rivalry heat": rivalry,
83
- "Team edge": intensity,
84
- "VAR heat": round(float(var_heat)),
85
- "Stage pressure": STAGE_BONUS[stage],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  }
87
- return {"teams": (a, b), "score": score, "verdict": verdict, "narratives": narratives, "factors": factors}
88
-
89
-
90
- MOMENT_RULES = {
91
- "handball": (24, "Hand of God, 1986 — one touch can live for generations"),
92
- "penalty": (19, "Robben vs Mexico, 2014 — the penalty argument never really ended"),
93
- "red card": (17, "Beckham vs Argentina, 1998 — one red card can own the whole story"),
94
- "offside": (15, "tight tournament offside calls — one freeze-frame splits the room"),
95
- "goal line": (25, "Lampard vs Germany, 2010 — the goal everybody saw except the officials"),
96
- "var": (13, "modern VAR delays — the wait often makes the anger worse"),
97
- "dive": (16, "big-stage simulation rows — replay rarely ends the argument"),
98
- "foul": (9, "classic knockout flashpoints — the second tackle changes the mood"),
99
- }
100
 
101
 
102
- def analyze_moment(text: str, minute: float, match_tension: float) -> dict:
103
- moment = re.sub(r"\s+", " ", (text or "").strip())
104
- haystack = moment.casefold()
105
- hits = [(word, rule) for word, rule in MOMENT_RULES.items() if word in haystack]
106
- bump = max((rule[0] for _, rule in hits), default=6)
107
- late = 16 if minute >= 85 else 10 if minute >= 70 else 4 if minute >= 45 else 0
108
- score = clamp(22 + bump + float(match_tension) * 0.42 + late)
109
- echo = hits[0][1][1] if hits else "any disputed winner — the clip matters more than the full match"
110
-
111
- if score >= 88:
112
- reaction = "Timeline meltdown. Everyone has a screenshot and nobody agrees."
113
- elif score >= 72:
114
- reaction = "Heavy outrage. Expect referee clips, conspiracy posts and rival fans piling in."
115
- elif score >= 55:
116
- reaction = "Proper argument territory. The replay will be posted all night."
117
- else:
118
- reaction = "Some noise, but the next goal probably kills the story."
119
-
120
- return {"score": score, "reaction": reaction, "echo": echo, "moment": moment or "No moment entered"}
121
-
122
-
123
- def post_match(home: str, away: str, home_goals: float, away_goals: float, key_moment: str, fan_heat: float) -> dict:
124
- a, b = clean_team(home), clean_team(away)
125
- ga, gb = max(0, int(home_goals)), max(0, int(away_goals))
126
- moment = re.sub(r"\s+", " ", (key_moment or "").strip())
127
- if ga == gb:
128
- lead = f"{a} and {b} finished {ga}-{gb}, but nobody left with the same version of the match."
129
- else:
130
- winner = a if ga > gb else b
131
- lead = f"{winner} took it {ga}-{gb}. The score is clean; the night was not."
132
-
133
- mood = "furious" if fan_heat >= 80 else "boiling" if fan_heat >= 60 else "split" if fan_heat >= 40 else "surprisingly calm"
134
- detail = moment or "The biggest argument arrived after the final whistle"
135
- report = f"{lead}\n\n{detail.rstrip('.')} became the clip everyone replayed. Fan mood: {mood}. Tomorrow the result stays; the argument gets louder."
136
- share = f"FT: {a} {ga}-{gb} {b}. {detail.rstrip('.')}. Drama never needs extra time. #DramaMeter2026"
137
- return {"report": report, "share": share, "mood": mood}
 
1
  from __future__ import annotations
2
 
3
+ from collections import defaultdict
4
+
5
+ import numpy as np
6
+ from sklearn.ensemble import HistGradientBoostingRegressor
7
+ from sklearn.inspection import permutation_importance
8
+ from sklearn.metrics import mean_absolute_error, r2_score
9
+
10
+
11
+ FEATURES = (
12
+ "stage pressure",
13
+ "combined points per game",
14
+ "form gap",
15
+ "goals scored per game",
16
+ "goals allowed per game",
17
+ "fouls per game",
18
+ "cards per game",
19
+ "shots per game",
20
+ "tournament experience",
21
+ )
22
+
23
+ STAGES = {
24
+ "group-stage": 0.15,
25
+ "round-of-32": 0.3,
26
+ "round-of-16": 0.48,
27
+ "quarterfinals": 0.68,
28
+ "semifinals": 0.86,
29
+ "3rd-place-match": 0.5,
30
+ "final": 1.0,
31
  }
32
 
33
+ STAGE_NAMES = {
34
+ "group-stage": "Group stage",
35
+ "round-of-32": "Round of 32",
36
+ "round-of-16": "Round of 16",
37
+ "quarterfinals": "Quarter-final",
38
+ "semifinals": "Semi-final",
39
+ "3rd-place-match": "Third-place match",
40
+ "final": "Final",
 
 
41
  }
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
+ def clamp(value: float) -> int:
45
+ return max(0, min(100, round(value)))
46
+
47
+
48
+ def competitors(event: dict) -> tuple[dict, dict]:
49
+ teams = event["competitions"][0]["competitors"]
50
+ ordered = sorted(teams, key=lambda item: item["homeAway"] != "home")
51
+ return ordered[0], ordered[1]
52
+
53
+
54
+ def stat(team: dict, name: str) -> float:
55
+ for item in team.get("statistics", []):
56
+ if item.get("name") == name:
57
+ try:
58
+ return float(str(item.get("displayValue", 0)).replace("%", ""))
59
+ except ValueError:
60
+ return 0.0
61
+ return 0.0
62
+
63
+
64
+ def card_counts(event: dict) -> tuple[int, int]:
65
+ yellow = red = 0
66
+ for detail in event["competitions"][0].get("details", []):
67
+ kind = detail.get("type", {}).get("text", "").lower()
68
+ yellow += "yellow card" in kind
69
+ red += "red card" in kind
70
+ return yellow, red
71
+
72
+
73
+ def team_cards(event: dict, team_id: str) -> int:
74
+ total = 0
75
+ for detail in event["competitions"][0].get("details", []):
76
+ if str(detail.get("team", {}).get("id")) != str(team_id):
77
+ continue
78
+ kind = detail.get("type", {}).get("text", "").lower()
79
+ total += 1 if "yellow card" in kind else 2 if "red card" in kind else 0
80
+ return total
81
+
82
+
83
+ def observed_index(event: dict) -> int:
84
+ home, away = competitors(event)
85
+ yellow, red = card_counts(event)
86
+ fouls = stat(home, "foulsCommitted") + stat(away, "foulsCommitted")
87
+ goals = int(float(home.get("score", 0))) + int(float(away.get("score", 0)))
88
+ score_gap = abs(int(float(home.get("score", 0))) - int(float(away.get("score", 0))))
89
+ details = event["competitions"][0].get("details", [])
90
+ late_goal = any(
91
+ "goal" in detail.get("type", {}).get("text", "").lower()
92
+ and float(detail.get("clock", {}).get("value", 0)) >= 75 * 60
93
+ for detail in details
94
+ )
95
+ status = event["status"]["type"].get("description", "").lower()
96
+ knockout = STAGES.get(event.get("season", {}).get("slug", ""), 0.15)
97
+ raw = 10 + fouls * 0.78 + yellow * 4.8 + red * 12 + min(goals, 6) * 3.2
98
+ raw += (10 if score_gap <= 1 else 0) + (9 if late_goal else 0) + knockout * 12
99
+ raw += 10 if "extra time" in status or "penalties" in status else 0
100
+ return clamp(raw)
101
+
102
+
103
+ def _empty_form() -> dict:
104
+ return {"games": 0, "points": 0, "gf": 0, "ga": 0, "fouls": 0.0, "cards": 0, "shots": 0.0}
105
+
106
+
107
+ def _rate(form: dict, key: str, fallback: float) -> float:
108
+ return form[key] / form["games"] if form["games"] else fallback
109
+
110
+
111
+ def _features(event: dict, forms: dict[str, dict]) -> tuple[list[float], dict]:
112
+ home, away = competitors(event)
113
+ a = forms[home["team"]["id"]]
114
+ b = forms[away["team"]["id"]]
115
+ ppg_a, ppg_b = _rate(a, "points", 1.5), _rate(b, "points", 1.5)
116
+ values = [
117
+ STAGES.get(event.get("season", {}).get("slug", ""), 0.15),
118
+ (ppg_a + ppg_b) / 6,
119
+ abs(ppg_a - ppg_b) / 3,
120
+ (_rate(a, "gf", 1.25) + _rate(b, "gf", 1.25)) / 6,
121
+ (_rate(a, "ga", 1.25) + _rate(b, "ga", 1.25)) / 6,
122
+ (_rate(a, "fouls", 11.5) + _rate(b, "fouls", 11.5)) / 35,
123
+ (_rate(a, "cards", 1.8) + _rate(b, "cards", 1.8)) / 8,
124
+ (_rate(a, "shots", 10) + _rate(b, "shots", 10)) / 35,
125
+ min((a["games"] + b["games"]) / 10, 1),
126
  ]
127
+ raw = {
128
+ "stage": STAGE_NAMES.get(event.get("season", {}).get("slug", ""), "Tournament match"),
129
+ "home_ppg": round(ppg_a, 2),
130
+ "away_ppg": round(ppg_b, 2),
131
+ "combined_fouls_pg": round(_rate(a, "fouls", 11.5) + _rate(b, "fouls", 11.5), 1),
132
+ "combined_cards_pg": round(_rate(a, "cards", 1.8) + _rate(b, "cards", 1.8), 1),
133
+ "combined_shots_pg": round(_rate(a, "shots", 10) + _rate(b, "shots", 10), 1),
134
+ "prior_games": a["games"] + b["games"],
135
+ }
136
+ return values, raw
137
+
138
+
139
+ def _update(forms: dict[str, dict], event: dict) -> None:
140
+ home, away = competitors(event)
141
+ home_score, away_score = int(float(home["score"])), int(float(away["score"]))
142
+ for team, scored, allowed in (
143
+ (home, home_score, away_score),
144
+ (away, away_score, home_score),
145
+ ):
146
+ form = forms[team["team"]["id"]]
147
+ form["games"] += 1
148
+ form["points"] += 3 if scored > allowed else 1 if scored == allowed else 0
149
+ form["gf"] += scored
150
+ form["ga"] += allowed
151
+ form["fouls"] += stat(team, "foulsCommitted")
152
+ form["cards"] += team_cards(event, team["team"]["id"])
153
+ form["shots"] += stat(team, "totalShots")
154
+
155
+
156
+ def train_and_score(events: list[dict], target: dict) -> dict:
157
+ ordered = sorted(events, key=lambda item: item["date"])
158
+ forms: dict[str, dict] = defaultdict(_empty_form)
159
+ x: list[list[float]] = []
160
+ y: list[int] = []
161
+ target_x = target_raw = None
162
+
163
+ for event in ordered:
164
+ features, raw = _features(event, forms)
165
+ if str(event["id"]) == str(target["id"]):
166
+ target_x, target_raw = features, raw
167
+ continue
168
+ if event["date"] > target["date"]:
169
+ continue
170
+ if event["status"]["type"].get("completed"):
171
+ x.append(features)
172
+ y.append(observed_index(event))
173
+ _update(forms, event)
174
+
175
+ if target_x is None:
176
+ target_x, target_raw = _features(target, forms)
177
+
178
+ x_data, y_data = np.asarray(x), np.asarray(y)
179
+ split = max(30, int(len(x_data) * 0.8))
180
+ eval_model = HistGradientBoostingRegressor(
181
+ max_iter=120, max_leaf_nodes=8, l2_regularization=2, random_state=26
182
+ )
183
+ eval_model.fit(x_data[:split], y_data[:split])
184
+ held_out = eval_model.predict(x_data[split:])
185
+ mae = mean_absolute_error(y_data[split:], held_out)
186
+ r2 = r2_score(y_data[split:], held_out)
187
+ baseline_mae = mean_absolute_error(y_data[split:], np.full(len(y_data[split:]), y_data[:split].mean()))
188
+ baseline_lift = (baseline_mae - mae) / baseline_mae * 100
189
+
190
+ model = HistGradientBoostingRegressor(
191
+ max_iter=160, max_leaf_nodes=8, l2_regularization=2, random_state=26
192
+ )
193
+ model.fit(x_data, y_data)
194
+ forecast = clamp(model.predict(np.asarray([target_x]))[0])
195
+ permuted = permutation_importance(model, x_data, y_data, n_repeats=8, random_state=26)
196
+ weights = np.maximum(permuted.importances_mean, 0)
197
+ weights = weights / weights.sum() if weights.sum() else np.ones(len(FEATURES)) / len(FEATURES)
198
+ importances = sorted(zip(FEATURES, weights), key=lambda item: item[1], reverse=True)
199
+ confidence = clamp(88 - mae * 1.6 + min(target_raw["prior_games"], 10))
200
+ return {
201
+ "forecast": forecast,
202
+ "confidence": confidence,
203
+ "samples": len(x),
204
+ "mae": round(float(mae), 1),
205
+ "r2": round(float(r2), 2),
206
+ "baseline_lift": round(float(baseline_lift), 1),
207
+ "features": target_raw,
208
+ "importances": [(name, round(float(weight) * 100, 1)) for name, weight in importances],
209
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
 
212
+ def h2h(summary: dict) -> dict:
213
+ groups = summary.get("headToHeadGames", [])
214
+ games = groups[0].get("events", []) if groups else []
215
+ world_cups = [game for game in games if "World Cup" in game.get("leagueName", "")]
216
+ shootouts = [game for game in games if int(game.get("homeShootoutScore", 0)) or int(game.get("awayShootoutScore", 0))]
217
+ latest = games[0] if games else None
218
+ return {
219
+ "games": len(games),
220
+ "world_cups": len(world_cups),
221
+ "shootouts": len(shootouts),
222
+ "latest": f"{latest['competitionName']} · {latest['score']}" if latest else "No H2H record returned",
223
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
feed.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from datetime import datetime, timezone
5
+
6
+ import requests
7
+
8
+
9
+ SCOREBOARD = "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard?dates=2026&limit=200"
10
+ SUMMARY = "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/summary?event={}"
11
+ MATCH_PAGE = "https://www.espn.com/soccer/match/_/gameId/{}"
12
+
13
+ _session = requests.Session()
14
+ _session.headers["User-Agent"] = "DramaMeter2026/2.0 (+https://github.com/yava-code/DramaMeter-2026)"
15
+ _cache: dict[str, tuple[float, dict, str]] = {}
16
+
17
+
18
+ class FeedError(RuntimeError):
19
+ pass
20
+
21
+
22
+ def _get(url: str, ttl: int, force: bool = False) -> tuple[dict, str, bool]:
23
+ now = time.monotonic()
24
+ cached = _cache.get(url)
25
+ if cached and not force and now - cached[0] < ttl:
26
+ return cached[1], cached[2], False
27
+
28
+ try:
29
+ response = _session.get(url, timeout=12)
30
+ response.raise_for_status()
31
+ fetched_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
32
+ data = response.json()
33
+ _cache[url] = (now, data, fetched_at)
34
+ return data, fetched_at, False
35
+ except (requests.RequestException, ValueError) as exc:
36
+ if cached:
37
+ return cached[1], cached[2], True
38
+ raise FeedError("The live football feed is unavailable. Try refresh in a moment.") from exc
39
+
40
+
41
+ def tournament(force: bool = False) -> tuple[list[dict], dict]:
42
+ data, fetched_at, stale = _get(SCOREBOARD, 45, force)
43
+ return data.get("events", []), {"fetched_at": fetched_at, "stale": stale}
44
+
45
+
46
+ def summary(event_id: str, force: bool = False) -> tuple[dict, dict]:
47
+ data, fetched_at, stale = _get(SUMMARY.format(event_id), 30, force)
48
+ return data, {"fetched_at": fetched_at, "stale": stale}
49
+
50
+
51
+ def find_event(events: list[dict], event_id: str) -> dict:
52
+ for event in events:
53
+ if str(event.get("id")) == str(event_id):
54
+ return event
55
+ raise FeedError("That fixture is no longer in the tournament feed.")
56
+
57
+
58
+ def fixture_choices(events: list[dict]) -> tuple[list[tuple[str, str]], str | None]:
59
+ live = [event for event in events if event["status"]["type"]["state"] == "in"]
60
+ upcoming = [event for event in events if event["status"]["type"]["state"] == "pre"]
61
+ finished = [event for event in events if event["status"]["type"]["state"] == "post"][-10:]
62
+ visible = live + upcoming + list(reversed(finished))
63
+ choices = [(fixture_label(event), str(event["id"])) for event in visible]
64
+ latest = finished[-1] if finished else None
65
+ recent = latest and (
66
+ datetime.now(timezone.utc) - datetime.fromisoformat(latest["date"].replace("Z", "+00:00"))
67
+ ).total_seconds() < 8 * 60 * 60
68
+ preferred = live or ([latest] if recent else []) or upcoming or list(reversed(finished))
69
+ default = str(preferred[0]["id"]) if preferred else None
70
+ return choices, default
71
+
72
+
73
+ def fixture_label(event: dict) -> str:
74
+ teams = sorted(event["competitions"][0]["competitors"], key=lambda item: item["homeAway"] != "home")
75
+ home, away = teams
76
+ home_name, away_name = home["team"]["displayName"], away["team"]["displayName"]
77
+ state = event["status"]["type"]["state"]
78
+ if state == "in":
79
+ return f"LIVE {event['status']['displayClock']} · {home_name} {home['score']}–{away['score']} {away_name}"
80
+ if state == "post":
81
+ return f"FT · {home_name} {home['score']}–{away['score']} {away_name}"
82
+ kickoff = datetime.fromisoformat(event["date"].replace("Z", "+00:00"))
83
+ return f"{kickoff:%b %d · %H:%M UTC} · {home_name} vs {away_name}"
requirements.txt CHANGED
@@ -1,3 +1,5 @@
1
  gradio==6.20.0
2
  pytest==8.4.1
 
 
3
  spaces==0.51.0
 
1
  gradio==6.20.0
2
  pytest==8.4.1
3
+ requests==2.34.2
4
+ scikit-learn==1.9.0
5
  spaces==0.51.0
tests/test_drama.py CHANGED
@@ -1,30 +1,83 @@
1
- from drama import analyze_moment, match_forecast, post_match
2
 
 
 
3
 
4
- def test_england_argentina_is_box_office():
5
- result = match_forecast("England", "Argentina", "Quarter-final", 72)
6
 
7
- assert result["score"] >= 85
8
- assert result["verdict"] in {"ABSOLUTE CINEMA", "GET THE POPCORN"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
 
11
- def test_forecast_is_stable_for_unknown_teams():
12
- first = match_forecast("Japan", "Canada", "Group stage", 50)
13
- second = match_forecast("Japan", "Canada", "Group stage", 50)
 
 
14
 
15
- assert first == second
16
- assert 0 <= first["score"] <= 100
17
 
 
 
 
 
 
 
 
18
 
19
- def test_late_handball_beats_early_foul():
20
- late = analyze_moment("VAR checks a handball and possible penalty", 91, 80)
21
- early = analyze_moment("A foul in midfield", 12, 40)
22
 
23
- assert late["score"] > early["score"]
24
 
 
 
 
 
25
 
26
- def test_post_match_handles_draw():
27
- result = post_match("Spain", "Portugal", 2, 2, "A late penalty was overturned", 70)
 
28
 
29
- assert "finished 2-2" in result["report"]
30
- assert "#DramaMeter2026" in result["share"]
 
 
 
 
 
 
 
 
 
1
+ from copy import deepcopy
2
 
3
+ from drama import observed_index, train_and_score
4
+ from feed import fixture_choices
5
 
 
 
6
 
7
+ def event(event_id, date, home_id, away_id, home_score, away_score, state="post", stage="group-stage", cards=1):
8
+ details = [
9
+ {"type": {"text": "Yellow Card"}, "clock": {"value": 1800}}
10
+ for _ in range(cards)
11
+ ]
12
+ details.append({"type": {"text": "Goal"}, "clock": {"value": 5100}})
13
+ return {
14
+ "id": str(event_id),
15
+ "date": date,
16
+ "season": {"slug": stage},
17
+ "status": {
18
+ "displayClock": "90'",
19
+ "type": {"state": state, "completed": state == "post", "description": "Full Time"},
20
+ },
21
+ "competitions": [{
22
+ "details": details,
23
+ "competitors": [
24
+ {
25
+ "homeAway": "home",
26
+ "score": str(home_score),
27
+ "team": {"id": str(home_id), "displayName": f"Team {home_id}"},
28
+ "statistics": [
29
+ {"name": "foulsCommitted", "displayValue": "12"},
30
+ {"name": "totalShots", "displayValue": "11"},
31
+ ],
32
+ },
33
+ {
34
+ "homeAway": "away",
35
+ "score": str(away_score),
36
+ "team": {"id": str(away_id), "displayName": f"Team {away_id}"},
37
+ "statistics": [
38
+ {"name": "foulsCommitted", "displayValue": "10"},
39
+ {"name": "totalShots", "displayValue": "9"},
40
+ ],
41
+ },
42
+ ],
43
+ }],
44
+ }
45
 
46
 
47
+ def tournament_sample(count=40):
48
+ games = []
49
+ for i in range(count):
50
+ games.append(event(i, f"2026-06-{i % 28 + 1:02d}T12:00Z", i % 8, (i + 1) % 8, i % 4, (i + 2) % 3, cards=i % 5))
51
+ return games
52
 
 
 
53
 
54
+ def test_cards_raise_observed_index():
55
+ calm = event(1, "2026-06-01T12:00Z", 1, 2, 1, 0, cards=0)
56
+ heated = deepcopy(calm)
57
+ heated["competitions"][0]["details"] += [
58
+ {"type": {"text": "Yellow Card"}, "clock": {"value": 4000}}
59
+ for _ in range(5)
60
+ ]
61
 
62
+ assert observed_index(heated) > observed_index(calm)
 
 
63
 
 
64
 
65
+ def test_model_trains_on_completed_tournament_matches():
66
+ games = tournament_sample()
67
+ target = event(99, "2026-07-15T19:00Z", 1, 4, 0, 0, state="pre", stage="semifinals")
68
+ result = train_and_score(games + [target], target)
69
 
70
+ assert result["samples"] == 40
71
+ assert 0 <= result["forecast"] <= 100
72
+ assert len(result["importances"]) == 9
73
 
74
+
75
+ def test_fixture_choices_put_live_match_first():
76
+ finished = event(1, "2026-07-14T19:00Z", 1, 2, 2, 1)
77
+ live = event(2, "2026-07-15T19:00Z", 3, 4, 1, 1, state="in")
78
+ upcoming = event(3, "2026-07-16T19:00Z", 5, 6, 0, 0, state="pre")
79
+
80
+ choices, default = fixture_choices([finished, upcoming, live])
81
+
82
+ assert default == "2"
83
+ assert choices[0][1] == "2"