Linker1907 Claude Sonnet 4.6 commited on
Commit
1120bf0
Β·
0 Parent(s):

Initial commit: sidebar+cards frontend redesign

Browse files

Replaces the flat markdown-table layout with a sidebar (category nav) +
main panel (benchmark cards β†’ inline leaderboard) using gr.Radio styled
as nav buttons and cards, gr.DataFrame for the leaderboard, and a
gradient topbar with stat pills.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ dashboard_cache.json
4
+ state.json
5
+ watcher.log
6
+ .superpowers/
7
+ *.html
README.md ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HF Leaderboard Watcher β€” daily email digest
2
+
3
+ Polls official benchmark leaderboards on the [Hugging Face Hub](https://huggingface.co/docs/hub/leaderboard-data-guide),
4
+ diffs against local state, and emails you a digest of new model entries.
5
+
6
+ Stdlib only and packaged as a [uv single-file script](https://docs.astral.sh/uv/guides/scripts/) (PEP 723 inline metadata). uv handles the Python interpreter β€” no virtualenv, no `pip install`.
7
+
8
+ ## Files
9
+
10
+ - `watch.py` β€” the watcher.
11
+ - `config.json` β€” created on first run; fill in your SMTP details.
12
+ - `state.json` β€” auto; tracks already-seen `(benchmark, model)` pairs.
13
+ - `watcher.log` β€” every run appends here.
14
+ - `cron.log` β€” captured stdout/stderr from cron runs.
15
+
16
+ ## 1. Configure SMTP
17
+
18
+ Edit `config.json` (or set env vars in your crontab β€” see below):
19
+
20
+ ```json
21
+ {
22
+ "smtp_host": "smtp.gmail.com",
23
+ "smtp_port": 465,
24
+ "smtp_user": "you@gmail.com",
25
+ "smtp_password": "<app password>",
26
+ "email_from": "you@gmail.com",
27
+ "email_to": "you@gmail.com"
28
+ }
29
+ ```
30
+
31
+ For **Gmail**, generate an App Password at
32
+ <https://myaccount.google.com/apppasswords> (requires 2FA).
33
+ Other SMTP providers (Fastmail, Proton Bridge, SES, Mailgun, etc.) work
34
+ the same way β€” port 465 = SMTPS, anything else = STARTTLS.
35
+
36
+ Test it:
37
+
38
+ ```sh
39
+ ~/leaderboard-watcher/watch.py --test-email
40
+ # or explicitly:
41
+ uv run --script ~/leaderboard-watcher/watch.py --test-email
42
+ ```
43
+
44
+ ## 2. Cron job (already installed)
45
+
46
+ ```
47
+ 0 9 * * * cd /Users/nathan/leaderboard-watcher && /Users/nathan/.cargo/bin/uv run --script watch.py >> cron.log 2>&1
48
+ ```
49
+
50
+ This runs every day at **09:00** local time.
51
+
52
+ > **macOS gotcha**: `cron` needs **Full Disk Access**. Go to System
53
+ > Settings β†’ Privacy & Security β†’ Full Disk Access β†’ click `+` β†’ press
54
+ > βŒ˜β‡§G β†’ enter `/usr/sbin/cron` β†’ enable. (You already have another
55
+ > cron job running, so this is probably already done.)
56
+
57
+ Useful commands:
58
+
59
+ ```sh
60
+ crontab -l # see installed jobs
61
+ crontab -e # edit
62
+ tail -f ~/leaderboard-watcher/watcher.log # tail the run log
63
+ tail -f ~/leaderboard-watcher/cron.log # tail cron's stdout/stderr
64
+ ```
65
+
66
+ To change cadence, edit the `0 9 * * *` part:
67
+ - `0 9 * * 1-5` = weekdays at 9am
68
+ - `0 9,18 * * *` = 9am and 6pm
69
+ - `*/30 * * * *` = every 30 minutes
70
+
71
+ ## 3. CLI flags
72
+
73
+ The shebang line (`#!/usr/bin/env -S uv run --script`) makes the file directly executable:
74
+
75
+ ```sh
76
+ ./watch.py # run one cycle (what cron calls)
77
+ ./watch.py --list # print all official benchmark IDs
78
+ ./watch.py --reset # delete state.json (next run reseeds silently)
79
+ ./watch.py --test-email # send a test email and exit
80
+ ```
81
+
82
+ Equivalent explicit form: `uv run --script watch.py [...]`.
83
+
84
+ ## How it works
85
+
86
+ 1. `GET /api/datasets?filter=benchmark:official` to discover ~22 benchmarks.
87
+ (Override with `"benchmarks": ["SWE-bench/SWE-bench_Verified", "cais/hle"]` in `config.json` to watch a fixed list.)
88
+ 2. `GET /api/datasets/{id}/leaderboard` per benchmark.
89
+ 3. New `modelId`s relative to `state.json` are collected.
90
+ 4. If there are any, an HTML+text digest email is sent grouped by benchmark
91
+ with rank, score, verified flag, and links to each model card.
92
+ 5. State is updated so each new model is reported only once.
93
+
94
+ The first run is **silent** (it just seeds state). Set
95
+ `silent_first_run: false` if you want to receive the entire current state
96
+ on first run instead.
97
+
98
+ By default no email is sent on days with zero new entries
99
+ (`skip_email_when_empty: true`).
100
+
101
+ ## Gated benchmarks
102
+
103
+ `cais/hle` and similar gated datasets need an HF token. The script
104
+ auto-uses your `~/.cache/huggingface/token` (created by `hf auth login`),
105
+ or you can set `HF_TOKEN` in the environment. **Cron has no access to
106
+ your shell environment**, so if you rely on env vars, set them inside the
107
+ crontab line itself, e.g.:
108
+
109
+ ```cron
110
+ 0 9 * * * SMTP_PASSWORD=xxxxx HF_TOKEN=hf_yyyy cd /Users/nathan/leaderboard-watcher && /Users/nathan/.cargo/bin/uv run --script watch.py >> cron.log 2>&1
111
+ ```
112
+
113
+ ## Uninstall
114
+
115
+ ```sh
116
+ crontab -l | grep -v leaderboard-watcher | crontab -
117
+ rm -rf ~/leaderboard-watcher
118
+ ```
app.py ADDED
@@ -0,0 +1,594 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ HF Hub Benchmark Dashboard β€” Gradio app.
4
+ Run: python app.py
5
+ """
6
+
7
+ import json
8
+ import urllib.request
9
+ import urllib.error
10
+ from collections import defaultdict
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ import gradio as gr
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Constants
18
+ # ---------------------------------------------------------------------------
19
+
20
+ ROOT = Path(__file__).resolve().parent
21
+ CACHE_PATH = ROOT / "dashboard_cache.json"
22
+ CACHE_TTL_SECONDS = 6 * 60 * 60 # 6 hours
23
+
24
+ CATEGORY_ORDER = [
25
+ "Knowledge",
26
+ "Math / Reasoning",
27
+ "Code / Engineering",
28
+ "Agents",
29
+ "Vision",
30
+ "Audio / Speech",
31
+ "Document / OCR",
32
+ "Retrieval / Embedding",
33
+ "NLP / Classification",
34
+ "Robotics",
35
+ "Other",
36
+ ]
37
+
38
+ CATEGORY_ICONS = {
39
+ "Knowledge": "🧠",
40
+ "Math / Reasoning": "πŸ”’",
41
+ "Code / Engineering": "πŸ’»",
42
+ "Agents": "πŸ€–",
43
+ "Vision": "πŸ‘οΈ",
44
+ "Audio / Speech": "πŸ”Š",
45
+ "Document / OCR": "πŸ“„",
46
+ "Retrieval / Embedding": "πŸ”Ž",
47
+ "NLP / Classification": "🏷️",
48
+ "Robotics": "🦾",
49
+ "Other": "πŸ“¦",
50
+ }
51
+
52
+ BENCHMARK_DISPLAY_NAMES = {
53
+ "openai/gsm8k": "GSM8K",
54
+ "Idavidrein/gpqa": "GPQA",
55
+ "allenai/olmOCR-bench": "olmOCR-Bench",
56
+ "llamaindex/ParseBench": "ParseBench",
57
+ "mercor/apex-agents": "APEX-Agents",
58
+ "harborframework/terminal-bench-2.0": "Terminal-Bench 2.0",
59
+ "SWE-bench/SWE-bench_Verified": "SWE-bench Verified",
60
+ "TIGER-Lab/MMLU-Pro": "MMLU-Pro",
61
+ "hf-audio/open-asr-leaderboard": "Open ASR Leaderboard",
62
+ "MathArena/aime_2026": "AIME 2026",
63
+ "claw-eval/Claw-Eval": "Claw-Eval",
64
+ "cais/hle": "HLE",
65
+ "likaixin/ScreenSpot-Pro": "ScreenSpot-Pro",
66
+ "nvidia/compute-eval": "ComputeEval",
67
+ "ScaleAI/SWE-bench_Pro": "SWE-bench Pro",
68
+ "FutureMa/EvasionBench": "EvasionBench",
69
+ "mteb/BRIGHT": "BRIGHT",
70
+ "Delores-Lin/MDPBench": "MDPBench",
71
+ "mteb/arguana": "ArguAna",
72
+ "MMMU/MMMU_Pro": "MMMU-Pro",
73
+ "LEXam-Benchmark/LEXam": "LEXam",
74
+ "mercor/ACE": "ACE",
75
+ "mercor/APEX-v1-extended": "APEX-v1",
76
+ "VLABench/vlabench_primitive_ft_lerobot_video": "VLABench",
77
+ "tiiuae/PBench": "PBench",
78
+ "MathArena/hmmt_feb_2026": "HMMT Feb 2026",
79
+ "collinear-ai/yc-bench": "YC-Bench",
80
+ "internlm/WildClawBench": "WildClawBench",
81
+ "MME-Benchmarks/Video-MME-v2": "Video-MME v2",
82
+ "open-agent-leaderboard/results": "Open Agent Leaderboard",
83
+ }
84
+
85
+ CUSTOM_CSS = """
86
+ /* ---- Topbar ---- */
87
+ .topbar {
88
+ display: flex;
89
+ align-items: center;
90
+ justify-content: space-between;
91
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
92
+ color: white;
93
+ padding: 14px 24px;
94
+ border-radius: 10px;
95
+ margin-bottom: 8px;
96
+ flex-wrap: wrap;
97
+ gap: 12px;
98
+ }
99
+ .topbar-title { font-size: 18px; font-weight: 700; margin-bottom: 2px; }
100
+ .topbar-meta { font-size: 11px; opacity: 0.85; }
101
+ .topbar-pills { display: flex; gap: 8px; flex-wrap: wrap; }
102
+ .stat-pill {
103
+ background: rgba(255,255,255,0.2);
104
+ border-radius: 20px;
105
+ padding: 4px 14px;
106
+ font-size: 12px;
107
+ white-space: nowrap;
108
+ }
109
+ .stat-pill b { font-size: 14px; }
110
+
111
+ /* ---- Sidebar column ---- */
112
+ #sidebar-col {
113
+ background: white !important;
114
+ border-right: 1px solid #e5e7eb !important;
115
+ padding: 12px 0 !important;
116
+ border-radius: 10px 0 0 10px !important;
117
+ }
118
+ #sidebar-col > .form { background: transparent !important; box-shadow: none !important; border: none !important; }
119
+
120
+ /* ---- Sidebar Radio β†’ nav buttons ---- */
121
+ #cat_radio {
122
+ background: transparent !important;
123
+ border: none !important;
124
+ box-shadow: none !important;
125
+ padding: 0 !important;
126
+ }
127
+ #cat_radio > .wrap {
128
+ flex-direction: column !important;
129
+ gap: 0 !important;
130
+ padding: 0 !important;
131
+ }
132
+ #cat_radio label {
133
+ display: flex !important;
134
+ align-items: center !important;
135
+ padding: 9px 16px !important;
136
+ margin: 0 !important;
137
+ border-left: 3px solid transparent !important;
138
+ border-radius: 0 !important;
139
+ cursor: pointer !important;
140
+ font-size: 13px !important;
141
+ color: #374151 !important;
142
+ background: white !important;
143
+ width: 100% !important;
144
+ box-sizing: border-box !important;
145
+ gap: 0 !important;
146
+ }
147
+ #cat_radio label:hover { background: #f3f4f6 !important; }
148
+ #cat_radio label:has(input:checked) {
149
+ background: #ede9fe !important;
150
+ border-left-color: #7c3aed !important;
151
+ color: #5b21b6 !important;
152
+ font-weight: 600 !important;
153
+ }
154
+ #cat_radio input[type="radio"] { display: none !important; }
155
+ #cat_radio .wrap span { margin-left: 0 !important; padding-left: 0 !important; }
156
+
157
+ /* ---- Main column ---- */
158
+ #main-col {
159
+ background: #f9fafb !important;
160
+ border-radius: 0 10px 10px 0 !important;
161
+ padding: 16px 20px !important;
162
+ }
163
+ #main-col > .form { background: transparent !important; box-shadow: none !important; border: none !important; }
164
+
165
+ /* ---- Benchmark card Radio ---- */
166
+ #bench_radio {
167
+ background: transparent !important;
168
+ border: none !important;
169
+ box-shadow: none !important;
170
+ padding: 0 !important;
171
+ }
172
+ #bench_radio > .wrap {
173
+ flex-direction: row !important;
174
+ flex-wrap: wrap !important;
175
+ gap: 10px !important;
176
+ padding: 4px 0 12px !important;
177
+ }
178
+ #bench_radio label {
179
+ display: flex !important;
180
+ align-items: center !important;
181
+ padding: 10px 14px !important;
182
+ border: 2px solid #e5e7eb !important;
183
+ border-radius: 10px !important;
184
+ cursor: pointer !important;
185
+ font-size: 12px !important;
186
+ background: white !important;
187
+ color: #374151 !important;
188
+ min-width: 150px !important;
189
+ margin: 0 !important;
190
+ gap: 0 !important;
191
+ transition: border-color 0.15s !important;
192
+ }
193
+ #bench_radio label:hover { border-color: #a78bfa !important; }
194
+ #bench_radio label:has(input:checked) {
195
+ border-color: #7c3aed !important;
196
+ background: #faf5ff !important;
197
+ font-weight: 600 !important;
198
+ color: #5b21b6 !important;
199
+ }
200
+ #bench_radio input[type="radio"] { display: none !important; }
201
+ #bench_radio .wrap span { margin-left: 0 !important; padding-left: 0 !important; }
202
+ """
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # HF API helpers
206
+ # ---------------------------------------------------------------------------
207
+
208
+
209
+ def _http_get_json(url: str, token: str | None = None, timeout: int = 30):
210
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
211
+ if token:
212
+ req.add_header("Authorization", f"Bearer {token}")
213
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
214
+ return json.loads(resp.read().decode("utf-8"))
215
+
216
+
217
+ def _read_token() -> str | None:
218
+ import os
219
+ p = Path(os.path.expanduser("~/.cache/huggingface/token"))
220
+ if p.exists():
221
+ tok = p.read_text().strip()
222
+ if tok:
223
+ return tok
224
+ return os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
225
+
226
+
227
+ def discover_benchmarks(token=None) -> list[dict]:
228
+ url = "https://huggingface.co/api/datasets?filter=benchmark:official&limit=1000"
229
+ data = _http_get_json(url, token, timeout=30)
230
+ results = []
231
+ for d in data:
232
+ if not isinstance(d, dict) or "id" not in d:
233
+ continue
234
+ results.append({
235
+ "id": d["id"],
236
+ "tags": d.get("tags", []),
237
+ "description": (d.get("description") or "")[:200],
238
+ })
239
+ return results
240
+
241
+
242
+ def get_leaderboard(dataset_id: str, token=None) -> list[dict]:
243
+ url = f"https://huggingface.co/api/datasets/{dataset_id}/leaderboard"
244
+ try:
245
+ data = _http_get_json(url, token, timeout=30)
246
+ except (urllib.error.HTTPError, urllib.error.URLError):
247
+ return []
248
+ if isinstance(data, dict) and "entries" in data:
249
+ data = data["entries"]
250
+ return data if isinstance(data, list) else []
251
+
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # Categorisation
255
+ # ---------------------------------------------------------------------------
256
+
257
+
258
+ def categorize_benchmark(bench: dict) -> list[str]:
259
+ tags = bench.get("tags", [])
260
+ bid = bench["id"]
261
+ bid_lower = bid.lower()
262
+ categories = set()
263
+
264
+ if any(t in tags for t in ["modality:audio", "modality:speech"]):
265
+ categories.add("Audio / Speech")
266
+ if any(t in tags for t in ["modality:image", "modality:video"]):
267
+ categories.add("Vision")
268
+ if any(t in tags for t in ["modality:document"]):
269
+ categories.add("Document / OCR")
270
+ if any(t in tags for t in ["task_categories:robotics"]):
271
+ categories.add("Robotics")
272
+ if any(t in tags for t in ["task_categories:text-retrieval"]):
273
+ categories.add("Retrieval / Embedding")
274
+
275
+ if "math" in bid_lower or "aime" in bid_lower or "hmmt" in bid_lower or "gsm8k" in bid_lower:
276
+ categories.add("Math / Reasoning")
277
+ if "swe" in bid_lower or "terminal" in bid_lower or "compute-eval" in bid_lower:
278
+ categories.add("Code / Engineering")
279
+ if "agent" in bid_lower or "claw" in bid_lower or "apex-agent" in bid_lower or "wildclaw" in bid_lower or "yc-bench" in bid_lower:
280
+ categories.add("Agents")
281
+ if "mmlu" in bid_lower or "gpqa" in bid_lower or "hle" in bid_lower:
282
+ categories.add("Knowledge")
283
+ if "ocr" in bid_lower or "parse" in bid_lower or "mdp" in bid_lower:
284
+ categories.add("Document / OCR")
285
+ if "asr" in bid_lower:
286
+ categories.add("Audio / Speech")
287
+ if "screen" in bid_lower or "mmmu" in bid_lower or "video" in bid_lower or "pbench" in bid_lower:
288
+ categories.add("Vision")
289
+ if "evasion" in bid_lower or "lex" in bid_lower:
290
+ categories.add("NLP / Classification")
291
+ if "bright" in bid_lower or "arguana" in bid_lower:
292
+ categories.add("Retrieval / Embedding")
293
+
294
+ if not categories:
295
+ categories.add("Other")
296
+
297
+ return sorted(categories, key=lambda c: CATEGORY_ORDER.index(c) if c in CATEGORY_ORDER else 99)
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # Data fetching & aggregation
302
+ # ---------------------------------------------------------------------------
303
+
304
+
305
+ def fetch_all_data() -> dict:
306
+ token = _read_token()
307
+ benchmarks = discover_benchmarks(token)
308
+ all_models: set[str] = set()
309
+ benchmark_data = []
310
+
311
+ for bench in benchmarks:
312
+ bid = bench["id"]
313
+ entries = get_leaderboard(bid, token)
314
+ models: set[str] = set()
315
+ model_details = []
316
+ for entry in entries:
317
+ mid = entry.get("modelId") or entry.get("model_id") or entry.get("model") or ""
318
+ if not mid:
319
+ continue
320
+ models.add(mid)
321
+ model_details.append({
322
+ "rank": entry.get("rank"),
323
+ "model_id": mid,
324
+ "value": entry.get("value"),
325
+ "verified": entry.get("verified", False),
326
+ })
327
+
328
+ model_details.sort(key=lambda x: (x["rank"] is None, x["rank"] or 999))
329
+ all_models.update(models)
330
+ cats = categorize_benchmark(bench)
331
+ display_name = BENCHMARK_DISPLAY_NAMES.get(bid, bid.split("/")[-1])
332
+
333
+ benchmark_data.append({
334
+ "id": bid,
335
+ "display_name": display_name,
336
+ "categories": cats,
337
+ "num_models": len(models),
338
+ "models": sorted(models),
339
+ "model_details": model_details,
340
+ "description": bench["description"],
341
+ })
342
+
343
+ cat_benchmarks: dict[str, list] = defaultdict(list)
344
+ cat_models: dict[str, set] = defaultdict(set)
345
+ for bd in benchmark_data:
346
+ for cat in bd["categories"]:
347
+ cat_benchmarks[cat].append(bd)
348
+ cat_models[cat].update(bd["models"])
349
+
350
+ return {
351
+ "total_benchmarks": len(benchmarks),
352
+ "total_unique_models": len(all_models),
353
+ "benchmarks_with_entries": sum(1 for bd in benchmark_data if bd["num_models"] > 0),
354
+ "benchmarks_empty": sum(1 for bd in benchmark_data if bd["num_models"] == 0),
355
+ "timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
356
+ "all_models": sorted(all_models),
357
+ "benchmark_data": benchmark_data,
358
+ "categories": {
359
+ cat: {
360
+ "benchmarks": len(cat_benchmarks[cat]),
361
+ "unique_models": len(cat_models[cat]),
362
+ }
363
+ for cat in CATEGORY_ORDER
364
+ if cat in cat_benchmarks
365
+ },
366
+ }
367
+
368
+
369
+ def load_cached_data() -> dict | None:
370
+ if not CACHE_PATH.exists():
371
+ return None
372
+ try:
373
+ d = json.loads(CACHE_PATH.read_text())
374
+ ts = d.get("timestamp", "")
375
+ if ts:
376
+ age = (datetime.now(timezone.utc) - datetime.fromisoformat(ts)).total_seconds()
377
+ if age < CACHE_TTL_SECONDS:
378
+ return d
379
+ except Exception:
380
+ pass
381
+ return None
382
+
383
+
384
+ def save_cache(data: dict) -> None:
385
+ CACHE_PATH.write_text(json.dumps(data, indent=2))
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # UI helpers
390
+ # ---------------------------------------------------------------------------
391
+
392
+ _app_data: dict = {}
393
+
394
+
395
+ def _render_topbar(data: dict) -> str:
396
+ ts = data.get("timestamp", "?")[:19]
397
+ return (
398
+ f'<div class="topbar">'
399
+ f'<div><div class="topbar-title">πŸ† HF Hub Benchmark Dashboard</div>'
400
+ f'<div class="topbar-meta">Last updated: {ts} UTC Β· auto-refreshes every 6h</div></div>'
401
+ f'<div class="topbar-pills">'
402
+ f'<div class="stat-pill"><b>{data["total_benchmarks"]}</b> benchmarks</div>'
403
+ f'<div class="stat-pill"><b>{data["total_unique_models"]}</b> models</div>'
404
+ f'<div class="stat-pill"><b>{data["benchmarks_with_entries"]}</b> active</div>'
405
+ f'<div class="stat-pill"><b>{data["benchmarks_empty"]}</b> empty</div>'
406
+ f'</div></div>'
407
+ )
408
+
409
+
410
+ def _sidebar_choices(data: dict) -> list[tuple[str, str]]:
411
+ cats = data.get("categories", {})
412
+ result = []
413
+ for cat in CATEGORY_ORDER:
414
+ if cat not in cats:
415
+ continue
416
+ icon = CATEGORY_ICONS.get(cat, "")
417
+ count = cats[cat]["benchmarks"]
418
+ result.append((f"{icon} {cat} ({count})", cat))
419
+ return result
420
+
421
+
422
+ def _card_choices(data: dict, category: str) -> list[tuple[str, str]]:
423
+ bds = [bd for bd in data["benchmark_data"] if category in bd["categories"]]
424
+ bds.sort(key=lambda x: x["num_models"], reverse=True)
425
+ choices = []
426
+ for bd in bds:
427
+ owner = bd["id"].split("/")[0] if "/" in bd["id"] else ""
428
+ label = f"{bd['display_name']} Β· {bd['num_models']} models"
429
+ if owner:
430
+ label += f" [{owner}]"
431
+ choices.append((label, bd["id"]))
432
+ return choices
433
+
434
+
435
+ def _lb_rows(data: dict, bid: str) -> list[list]:
436
+ lookup = {bd["id"]: bd for bd in data["benchmark_data"]}
437
+ details = lookup.get(bid, {}).get("model_details", [])[:50]
438
+ return [
439
+ [
440
+ m["rank"] if m["rank"] is not None else "β€”",
441
+ m["model_id"],
442
+ str(m["value"]) if m["value"] is not None else "β€”",
443
+ "βœ…" if m["verified"] else "",
444
+ ]
445
+ for m in details
446
+ ]
447
+
448
+
449
+ def _cat_header(data: dict, cat: str) -> str:
450
+ icon = CATEGORY_ICONS.get(cat, "")
451
+ info = data.get("categories", {}).get(cat, {})
452
+ return f"### {icon} {cat} &nbsp;Β·&nbsp; {info.get('benchmarks', 0)} benchmarks Β· {info.get('unique_models', 0)} models"
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # Gradio app
457
+ # ---------------------------------------------------------------------------
458
+
459
+
460
+ def build_app() -> gr.Blocks:
461
+ global _app_data
462
+ _app_data = load_cached_data()
463
+ if _app_data is None:
464
+ _app_data = fetch_all_data()
465
+ save_cache(_app_data)
466
+
467
+ s_choices = _sidebar_choices(_app_data)
468
+ init_cat = s_choices[0][1] if s_choices else ""
469
+ c_choices = _card_choices(_app_data, init_cat)
470
+ init_bid = c_choices[0][1] if c_choices else ""
471
+
472
+ with gr.Blocks(
473
+ title="HF Hub Benchmark Dashboard",
474
+ css=CUSTOM_CSS,
475
+ theme=gr.themes.Soft(),
476
+ ) as demo:
477
+
478
+ topbar = gr.HTML(_render_topbar(_app_data))
479
+
480
+ with gr.Row(equal_height=True):
481
+ # --- Sidebar ---
482
+ with gr.Column(scale=1, min_width=210, elem_id="sidebar-col"):
483
+ cat_radio = gr.Radio(
484
+ choices=s_choices,
485
+ value=init_cat,
486
+ label="Categories",
487
+ elem_id="cat_radio",
488
+ )
489
+
490
+ # --- Main panel ---
491
+ with gr.Column(scale=4, elem_id="main-col"):
492
+ with gr.Row():
493
+ cat_header = gr.Markdown(
494
+ _cat_header(_app_data, init_cat),
495
+ elem_id="cat-header",
496
+ )
497
+ refresh_btn = gr.Button(
498
+ "πŸ”„ Refresh Now", variant="primary", scale=0, min_width=150,
499
+ )
500
+
501
+ bench_radio = gr.Radio(
502
+ choices=c_choices,
503
+ value=init_bid,
504
+ show_label=False,
505
+ elem_id="bench_radio",
506
+ )
507
+
508
+ lb_df = gr.DataFrame(
509
+ value=_lb_rows(_app_data, init_bid),
510
+ headers=["Rank", "Model", "Score", "Verified"],
511
+ col_count=(4, "fixed"),
512
+ interactive=False,
513
+ wrap=True,
514
+ )
515
+
516
+ lb_link = gr.Markdown(
517
+ f"[β†— View on Hub](https://huggingface.co/datasets/{init_bid})"
518
+ if init_bid else ""
519
+ )
520
+
521
+ # ---- Event handlers ----
522
+
523
+ def on_category_change(cat: str):
524
+ new_c = _card_choices(_app_data, cat)
525
+ new_bid = new_c[0][1] if new_c else ""
526
+ return (
527
+ _cat_header(_app_data, cat),
528
+ gr.update(choices=new_c, value=new_bid),
529
+ _lb_rows(_app_data, new_bid),
530
+ f"[β†— View on Hub](https://huggingface.co/datasets/{new_bid})" if new_bid else "",
531
+ )
532
+
533
+ def on_benchmark_change(bid: str):
534
+ if not bid:
535
+ return [], ""
536
+ return (
537
+ _lb_rows(_app_data, bid),
538
+ f"[β†— View on Hub](https://huggingface.co/datasets/{bid})",
539
+ )
540
+
541
+ def on_refresh():
542
+ global _app_data
543
+ try:
544
+ new_data = fetch_all_data()
545
+ save_cache(new_data)
546
+ _app_data = new_data
547
+ except Exception as e:
548
+ err = f'<p style="color:#dc2626;font-size:12px;margin-top:4px">⚠️ Refresh failed: {e}</p>'
549
+ return (
550
+ _render_topbar(_app_data) + err,
551
+ gr.update(), gr.update(), gr.update(), gr.update(), gr.update(),
552
+ )
553
+
554
+ new_s = _sidebar_choices(_app_data)
555
+ new_cat = new_s[0][1] if new_s else ""
556
+ new_c = _card_choices(_app_data, new_cat)
557
+ new_bid = new_c[0][1] if new_c else ""
558
+ return (
559
+ _render_topbar(_app_data),
560
+ gr.update(choices=new_s, value=new_cat),
561
+ _cat_header(_app_data, new_cat),
562
+ gr.update(choices=new_c, value=new_bid),
563
+ _lb_rows(_app_data, new_bid),
564
+ f"[β†— View on Hub](https://huggingface.co/datasets/{new_bid})" if new_bid else "",
565
+ )
566
+
567
+ cat_radio.change(
568
+ fn=on_category_change,
569
+ inputs=cat_radio,
570
+ outputs=[cat_header, bench_radio, lb_df, lb_link],
571
+ )
572
+
573
+ bench_radio.change(
574
+ fn=on_benchmark_change,
575
+ inputs=bench_radio,
576
+ outputs=[lb_df, lb_link],
577
+ )
578
+
579
+ refresh_btn.click(
580
+ fn=on_refresh,
581
+ inputs=[],
582
+ outputs=[topbar, cat_radio, cat_header, bench_radio, lb_df, lb_link],
583
+ show_progress="full",
584
+ )
585
+
586
+ return demo
587
+
588
+
589
+ if __name__ == "__main__":
590
+ demo = build_app()
591
+ demo.launch(
592
+ server_name="0.0.0.0",
593
+ server_port=7861,
594
+ )
config.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "benchmarks": [],
3
+ "email_from": "",
4
+ "email_to": "",
5
+ "hf_token_path": "~/.cache/huggingface/token",
6
+ "request_delay_seconds": 0.3,
7
+ "silent_first_run": true,
8
+ "skip_email_when_empty": true,
9
+ "smtp_host": "smtp.gmail.com",
10
+ "smtp_password": "",
11
+ "smtp_port": 465,
12
+ "smtp_user": "",
13
+ "timeout": 30
14
+ }
docs/superpowers/specs/2026-06-01-dashboard-frontend-redesign.md ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dashboard Frontend Redesign
2
+
3
+ **Date:** 2026-06-01
4
+ **Scope:** `app.py` β€” UI layer only. Data fetching, caching, and categorisation logic are unchanged.
5
+
6
+ ---
7
+
8
+ ## Goal
9
+
10
+ Replace the current single-scroll page of markdown tables with a sidebar + main-panel layout that makes the dashboard faster to navigate and easier to read.
11
+
12
+ ---
13
+
14
+ ## Layout
15
+
16
+ ```
17
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
18
+ β”‚ TOPBAR: title Β· stat pills Β· last-updated β”‚
19
+ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
20
+ β”‚ SIDEBAR β”‚ MAIN PANEL β”‚
21
+ β”‚ (180px) β”‚ header: category name + model count β”‚
22
+ β”‚ β”‚ + Refresh Now button β”‚
23
+ β”‚ Categories β”‚ β”‚
24
+ β”‚ as buttons β”‚ Benchmark cards (chips) β”‚
25
+ β”‚ β”‚ β”‚
26
+ β”‚ β”‚ ──────────────────────────────── β”‚
27
+ β”‚ β”‚ Inline leaderboard (gr.DataFrame) β”‚
28
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
29
+ ```
30
+
31
+ The sidebar is fixed-width (180 px). The main panel scrolls independently.
32
+
33
+ ---
34
+
35
+ ## Components
36
+
37
+ ### Top bar
38
+ - `gr.HTML` block rendering a gradient bar with:
39
+ - App title ("πŸ† HF Hub Benchmark Dashboard")
40
+ - Four stat pills: total benchmarks, unique models, active count, empty count
41
+ - Last-updated timestamp
42
+
43
+ ### Sidebar
44
+ - `gr.Radio` component with `choices` built from `CATEGORY_ORDER` (icon + name + count)
45
+ - `show_label=False`, `container=False` so it renders as a bare list
46
+ - CSS hides the radio circle and styles each item as a full-width button with left-border active state
47
+ - `.change()` event updates `selected_category` state
48
+
49
+ ### Main panel header
50
+ - `gr.Markdown` showing the selected category name, icon, and unique model count
51
+ - `gr.Button` ("πŸ”„ Refresh Now") aligned right via `gr.Row`
52
+
53
+ ### Benchmark cards
54
+ - `gr.Radio` component with `choices` built from the benchmarks in the selected category
55
+ - Each choice label includes display name, model count, and dataset owner badge (rendered via `gr.Radio` label HTML)
56
+ - CSS styles items as flex cards with purple border on selection
57
+ - `.change()` event updates `selected_benchmark_id` state
58
+
59
+ ### Leaderboard table
60
+ - `gr.DataFrame` populated from `model_details` of the selected benchmark
61
+ - Columns: Rank, Model (plain string, not hyperlink β€” gr.DataFrame limitation), Score, Verified
62
+ - Sortable by clicking column headers (built-in gr.DataFrame behaviour)
63
+ - Shows up to 50 rows (same limit as current app)
64
+ - Below the table: `gr.Markdown` with a "β†— View on Hub" link to the dataset page
65
+
66
+ ---
67
+
68
+ ## State
69
+
70
+ Two `gr.State` values:
71
+
72
+ | State | Type | Initial value |
73
+ |---|---|---|
74
+ | `selected_category` | `str` | First category that has benchmarks |
75
+ | `selected_benchmark_id` | `str` | ID of the benchmark with the most models in the initial category |
76
+
77
+ Both are updated by click handlers on sidebar buttons and benchmark cards.
78
+
79
+ ---
80
+
81
+ ## Event wiring
82
+
83
+ ```
84
+ sidebar Radio.change β†’ update selected_category state
85
+ β†’ update main header Markdown (category name + model count)
86
+ β†’ update benchmark cards Radio choices + default value
87
+ β†’ update leaderboard DataFrame + Hub link Markdown
88
+
89
+ cards Radio.change β†’ update selected_benchmark_id state
90
+ β†’ update leaderboard DataFrame + Hub link Markdown
91
+ ```
92
+
93
+ The refresh button clears both state values back to defaults after fetching new data, then re-renders all outputs (same outputs list as today).
94
+
95
+ ---
96
+
97
+ ## CSS / Styling
98
+
99
+ Custom CSS injected via `demo.launch(css=...)`. Key rules:
100
+
101
+ - Topbar: `linear-gradient(135deg, #667eea 0%, #764ba2 100%)`
102
+ - Active sidebar item: `border-left: 3px solid #7c3aed; background: #ede9fe; color: #5b21b6`
103
+ - Selected benchmark card: `border: 2px solid #7c3aed; background: #faf5ff`
104
+ - Table rows: subtle hover tint `#faf5ff`
105
+ - Rank column: plain numbers, no per-cell colouring (gr.DataFrame does not support per-cell CSS)
106
+
107
+ ---
108
+
109
+ ## What is NOT changing
110
+
111
+ - `fetch_all_data()`, `load_cached_data()`, `save_cache()` β€” untouched
112
+ - `discover_benchmarks()`, `get_leaderboard()`, `categorize_benchmark()` β€” untouched
113
+ - Cache TTL, cache file location β€” untouched
114
+ - `BENCHMARK_DISPLAY_NAMES`, `CATEGORY_ORDER`, `CATEGORY_ICONS` β€” untouched
115
+ - `demo.launch()` call (not switching to `gr.Server`)
116
+
117
+ ---
118
+
119
+ ## File structure
120
+
121
+ All changes are in `app.py`. No new files needed.
122
+
123
+ - Remove: `render_top_bar`, `render_stat_cards`, `render_category_table`, `render_benchmark_table`, `render_benchmark_detail`, `render_top_models` (all replaced by new helpers)
124
+ - Add: `render_topbar_html`, `sidebar_choices`, `card_choices`, `render_lb_link_md`
125
+ - The "Most-Benchmarked Models" section from the current app is removed β€” model counts are visible in the leaderboard table
126
+ - `build_app()` is rewritten; data-layer functions above it are untouched
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ gradio>=5.0.0
test_watch.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Tests for leaderboard watcher β€” first_seen tracking."""
3
+
4
+ import json
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from unittest.mock import patch
11
+
12
+ # Make watch.py importable without executing main()
13
+ sys.path.insert(0, str(Path(__file__).parent))
14
+ import watch
15
+
16
+ NOW = datetime(2026, 4, 30, 12, 0, 0, tzinfo=timezone.utc)
17
+ NOW_ISO = "2026-04-30T12:00:00+00:00"
18
+
19
+
20
+ class TestMigrateSeen(unittest.TestCase):
21
+ def test_list_migrates_to_dict_with_none_timestamps(self):
22
+ old = ["modelA", "modelB"]
23
+ result = watch.migrate_seen(old)
24
+ self.assertEqual(result, {"modelA": None, "modelB": None})
25
+
26
+ def test_dict_is_returned_unchanged(self):
27
+ existing = {"modelA": "2026-01-01T00:00:00+00:00", "modelB": None}
28
+ result = watch.migrate_seen(existing)
29
+ self.assertEqual(result, existing)
30
+
31
+ def test_empty_list_migrates_to_empty_dict(self):
32
+ self.assertEqual(watch.migrate_seen([]), {})
33
+
34
+ def test_empty_dict_returned_unchanged(self):
35
+ self.assertEqual(watch.migrate_seen({}), {})
36
+
37
+
38
+ class TestDiffModels(unittest.TestCase):
39
+ def test_new_models_get_current_timestamp(self):
40
+ seen = {"old-model": "2026-01-01T00:00:00+00:00"}
41
+ current = ["old-model", "new-model"]
42
+ new_ids, updated_seen = watch.diff_models(seen, current, NOW)
43
+ self.assertEqual(new_ids, ["new-model"])
44
+ self.assertEqual(updated_seen["new-model"], NOW_ISO)
45
+
46
+ def test_existing_models_keep_their_timestamp(self):
47
+ original_ts = "2026-01-01T00:00:00+00:00"
48
+ seen = {"old-model": original_ts}
49
+ current = ["old-model"]
50
+ _, updated_seen = watch.diff_models(seen, current, NOW)
51
+ self.assertEqual(updated_seen["old-model"], original_ts)
52
+
53
+ def test_existing_model_with_none_timestamp_stays_none(self):
54
+ seen = {"old-model": None}
55
+ current = ["old-model"]
56
+ _, updated_seen = watch.diff_models(seen, current, NOW)
57
+ self.assertIsNone(updated_seen["old-model"])
58
+
59
+ def test_no_new_models_returns_empty_list(self):
60
+ seen = {"a": NOW_ISO, "b": NOW_ISO}
61
+ current = ["a", "b"]
62
+ new_ids, _ = watch.diff_models(seen, current, NOW)
63
+ self.assertEqual(new_ids, [])
64
+
65
+ def test_all_models_new_when_seen_is_empty(self):
66
+ current = ["x", "y"]
67
+ new_ids, updated_seen = watch.diff_models({}, current, NOW)
68
+ self.assertEqual(sorted(new_ids), ["x", "y"])
69
+ self.assertEqual(updated_seen["x"], NOW_ISO)
70
+ self.assertEqual(updated_seen["y"], NOW_ISO)
71
+
72
+ def test_models_removed_from_leaderboard_are_retained_in_seen(self):
73
+ seen = {"gone": "2026-01-01T00:00:00+00:00", "still-here": "2026-01-01T00:00:00+00:00"}
74
+ current = ["still-here"]
75
+ _, updated_seen = watch.diff_models(seen, current, NOW)
76
+ self.assertIn("gone", updated_seen)
77
+ self.assertIn("still-here", updated_seen)
78
+
79
+
80
+ class TestStateRoundtrip(unittest.TestCase):
81
+ def test_state_saved_and_loaded_preserves_first_seen(self):
82
+ with tempfile.TemporaryDirectory() as tmp:
83
+ path = Path(tmp) / "state.json"
84
+ data = {
85
+ "some/dataset": {
86
+ "seen": {"modelA": NOW_ISO, "modelB": None},
87
+ "last_checked": NOW_ISO,
88
+ "count": 2,
89
+ }
90
+ }
91
+ watch.save_json(path, data)
92
+ loaded = watch.load_json(path, {})
93
+ self.assertEqual(loaded["some/dataset"]["seen"]["modelA"], NOW_ISO)
94
+ self.assertIsNone(loaded["some/dataset"]["seen"]["modelB"])
95
+
96
+ def test_old_state_with_list_seen_is_migrated_on_load(self):
97
+ with tempfile.TemporaryDirectory() as tmp:
98
+ path = Path(tmp) / "state.json"
99
+ old_state = {
100
+ "some/dataset": {
101
+ "seen": ["modelA", "modelB"],
102
+ "last_checked": "2026-04-29T00:00:00+00:00",
103
+ "count": 2,
104
+ }
105
+ }
106
+ path.write_text(json.dumps(old_state))
107
+ loaded = watch.load_json(path, {})
108
+ # Migration happens in run_once, not load_json β€” test the migration utility
109
+ ds_state = loaded["some/dataset"]
110
+ migrated = watch.migrate_seen(ds_state["seen"])
111
+ self.assertIsInstance(migrated, dict)
112
+ self.assertIsNone(migrated["modelA"])
113
+ self.assertIsNone(migrated["modelB"])
114
+
115
+
116
+ if __name__ == "__main__":
117
+ unittest.main()
watch.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = []
5
+ # ///
6
+ """
7
+ HF Hub leaderboard watcher β€” daily email digest.
8
+
9
+ Run directly via the shebang (`./watch.py`) or `uv run --script watch.py`.
10
+ uv will provision an isolated Python interpreter automatically; no
11
+ `pip install` and no virtualenv to manage.
12
+
13
+ Polls official benchmark leaderboards on the Hugging Face Hub, diffs against
14
+ local state, and emails a digest of new model entries since the last run.
15
+
16
+ Designed to be run once per day from cron.
17
+
18
+ Docs: https://huggingface.co/docs/hub/leaderboard-data-guide
19
+
20
+ Configuration: edit `config.json` (next to this script) or set the SMTP
21
+ credentials via env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD,
22
+ EMAIL_FROM, EMAIL_TO.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import smtplib
30
+ import ssl
31
+ import sys
32
+ import time
33
+ import urllib.error
34
+ import urllib.request
35
+ from datetime import datetime, timezone
36
+ from email.message import EmailMessage
37
+ from pathlib import Path
38
+
39
+ ROOT = Path(__file__).resolve().parent
40
+ CONFIG_PATH = ROOT / "config.json"
41
+ STATE_PATH = ROOT / "state.json"
42
+ LOG_PATH = ROOT / "watcher.log"
43
+
44
+ DEFAULT_CONFIG = {
45
+ # If empty, auto-discover all official benchmarks. Otherwise pin a list.
46
+ "benchmarks": [],
47
+
48
+ # SMTP settings β€” override with env vars (SMTP_HOST etc.) if you prefer.
49
+ # For Gmail: host=smtp.gmail.com, port=465, user=you@gmail.com,
50
+ # password=<App Password from https://myaccount.google.com/apppasswords>.
51
+ "smtp_host": "smtp.gmail.com",
52
+ "smtp_port": 465,
53
+ "smtp_user": "",
54
+ "smtp_password": "",
55
+ "email_from": "",
56
+ "email_to": "",
57
+
58
+ # Don't send an email if there are zero new entries.
59
+ "skip_email_when_empty": True,
60
+
61
+ # Path to your HF token (used for gated benchmarks). Optional.
62
+ "hf_token_path": "~/.cache/huggingface/token",
63
+
64
+ # First run only seeds state silently β€” no flood email.
65
+ "silent_first_run": True,
66
+
67
+ "timeout": 30,
68
+ "request_delay_seconds": 0.3,
69
+ }
70
+
71
+
72
+ # ---------- utilities ----------
73
+
74
+ def log(msg: str) -> None:
75
+ line = f"[{datetime.now(timezone.utc).isoformat(timespec='seconds')}] {msg}"
76
+ print(line, flush=True)
77
+ try:
78
+ with LOG_PATH.open("a") as f:
79
+ f.write(line + "\n")
80
+ except OSError:
81
+ pass
82
+
83
+
84
+ def load_json(path: Path, default):
85
+ if not path.exists():
86
+ return default
87
+ try:
88
+ return json.loads(path.read_text())
89
+ except json.JSONDecodeError:
90
+ log(f"WARN: corrupt JSON at {path}, ignoring")
91
+ return default
92
+
93
+
94
+ def save_json(path: Path, data) -> None:
95
+ tmp = path.with_suffix(path.suffix + ".tmp")
96
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True))
97
+ tmp.replace(path)
98
+
99
+
100
+ def load_config() -> dict:
101
+ if not CONFIG_PATH.exists():
102
+ save_json(CONFIG_PATH, DEFAULT_CONFIG)
103
+ cfg = dict(DEFAULT_CONFIG)
104
+ cfg.update(load_json(CONFIG_PATH, {}))
105
+ # Env var overrides for secrets / portability.
106
+ env_map = {
107
+ "smtp_host": "SMTP_HOST",
108
+ "smtp_port": "SMTP_PORT",
109
+ "smtp_user": "SMTP_USER",
110
+ "smtp_password": "SMTP_PASSWORD",
111
+ "email_from": "EMAIL_FROM",
112
+ "email_to": "EMAIL_TO",
113
+ }
114
+ for key, env in env_map.items():
115
+ if os.environ.get(env):
116
+ cfg[key] = os.environ[env]
117
+ cfg["smtp_port"] = int(cfg["smtp_port"])
118
+ return cfg
119
+
120
+
121
+ def read_token(cfg: dict) -> str | None:
122
+ p = cfg.get("hf_token_path", "")
123
+ if p:
124
+ path = Path(os.path.expanduser(p))
125
+ if path.exists():
126
+ tok = path.read_text().strip()
127
+ if tok:
128
+ return tok
129
+ return os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
130
+
131
+
132
+ def http_get_json(url: str, token: str | None, timeout: int):
133
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
134
+ if token:
135
+ req.add_header("Authorization", f"Bearer {token}")
136
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
137
+ return json.loads(resp.read().decode("utf-8"))
138
+
139
+
140
+ # ---------- HF API ----------
141
+
142
+ def discover_official_benchmarks(token, timeout):
143
+ url = "https://huggingface.co/api/datasets?filter=benchmark:official&limit=1000"
144
+ data = http_get_json(url, token, timeout)
145
+ return [d["id"] for d in data if isinstance(d, dict) and "id" in d]
146
+
147
+
148
+ def get_leaderboard(dataset_id, token, timeout):
149
+ url = f"https://huggingface.co/api/datasets/{dataset_id}/leaderboard"
150
+ data = http_get_json(url, token, timeout)
151
+ if isinstance(data, dict) and "entries" in data:
152
+ data = data["entries"]
153
+ return data if isinstance(data, list) else []
154
+
155
+
156
+ # ---------- email ----------
157
+
158
+ def render_digest(new_by_ds: dict[str, list[dict]]) -> tuple[str, str]:
159
+ """Return (plain_text, html) bodies."""
160
+ today = datetime.now().strftime("%Y-%m-%d")
161
+ total = sum(len(v) for v in new_by_ds.values())
162
+
163
+ # Plain text
164
+ lines = [f"HF Leaderboard digest β€” {today}", f"{total} new model entr{'y' if total == 1 else 'ies'} across {len(new_by_ds)} benchmark(s).", ""]
165
+ for ds, entries in sorted(new_by_ds.items()):
166
+ lines.append(f"### {ds} ({len(entries)} new)")
167
+ lines.append(f"https://huggingface.co/datasets/{ds}")
168
+ for e in entries:
169
+ mid = e.get("modelId") or e.get("model_id") or "?"
170
+ rank = e.get("rank")
171
+ val = e.get("value")
172
+ verified = e.get("verified")
173
+ bits = []
174
+ if rank is not None:
175
+ bits.append(f"#{rank}")
176
+ bits.append(mid)
177
+ if val is not None:
178
+ bits.append(f"score={val}")
179
+ if verified:
180
+ bits.append("βœ“verified")
181
+ lines.append(" - " + " ".join(bits))
182
+ lines.append("")
183
+ text_body = "\n".join(lines)
184
+
185
+ # HTML
186
+ rows = []
187
+ for ds, entries in sorted(new_by_ds.items()):
188
+ ds_url = f"https://huggingface.co/datasets/{ds}"
189
+ rows.append(f'<h3 style="margin:18px 0 6px"><a href="{ds_url}">{ds}</a> '
190
+ f'<span style="color:#666;font-weight:normal">({len(entries)} new)</span></h3>')
191
+ rows.append('<table style="border-collapse:collapse;width:100%;font-size:14px">')
192
+ rows.append('<thead><tr style="background:#f4f4f5;text-align:left">'
193
+ '<th style="padding:6px 8px">Rank</th>'
194
+ '<th style="padding:6px 8px">Model</th>'
195
+ '<th style="padding:6px 8px">Score</th>'
196
+ '<th style="padding:6px 8px">Verified</th></tr></thead><tbody>')
197
+ for e in entries:
198
+ mid = e.get("modelId") or e.get("model_id") or "?"
199
+ mid_url = f"https://huggingface.co/{mid}"
200
+ rank = e.get("rank", "")
201
+ val = e.get("value", "")
202
+ verified = "βœ“" if e.get("verified") else ""
203
+ rows.append(f'<tr style="border-top:1px solid #eee">'
204
+ f'<td style="padding:6px 8px">#{rank}</td>'
205
+ f'<td style="padding:6px 8px"><a href="{mid_url}">{mid}</a></td>'
206
+ f'<td style="padding:6px 8px">{val}</td>'
207
+ f'<td style="padding:6px 8px">{verified}</td></tr>')
208
+ rows.append("</tbody></table>")
209
+
210
+ html_body = (
211
+ f'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:760px">'
212
+ f'<h2 style="margin-bottom:4px">HF Leaderboard digest β€” {today}</h2>'
213
+ f'<p style="color:#555;margin-top:0">'
214
+ f'{total} new model entr{"y" if total == 1 else "ies"} across {len(new_by_ds)} benchmark(s).</p>'
215
+ + "\n".join(rows) +
216
+ '<p style="color:#888;font-size:12px;margin-top:24px">'
217
+ 'Source: <a href="https://huggingface.co/docs/hub/leaderboard-data-guide">HF leaderboard API</a>. '
218
+ 'Sent by ~/leaderboard-watcher/watch.py.</p></div>'
219
+ )
220
+ return text_body, html_body
221
+
222
+
223
+ def send_email(cfg: dict, subject: str, text_body: str, html_body: str) -> None:
224
+ user = cfg["smtp_user"]
225
+ pwd = cfg["smtp_password"]
226
+ sender = cfg["email_from"] or user
227
+ recipient = cfg["email_to"]
228
+ if not (user and pwd and recipient):
229
+ log("ERROR: SMTP credentials or recipient missing β€” set them in config.json or env vars (SMTP_USER, SMTP_PASSWORD, EMAIL_TO).")
230
+ return
231
+
232
+ msg = EmailMessage()
233
+ msg["From"] = sender
234
+ msg["To"] = recipient
235
+ msg["Subject"] = subject
236
+ msg.set_content(text_body)
237
+ msg.add_alternative(html_body, subtype="html")
238
+
239
+ host = cfg["smtp_host"]
240
+ port = cfg["smtp_port"]
241
+ ctx = ssl.create_default_context()
242
+ try:
243
+ if port == 465:
244
+ with smtplib.SMTP_SSL(host, port, context=ctx, timeout=30) as s:
245
+ s.login(user, pwd)
246
+ s.send_message(msg)
247
+ else:
248
+ with smtplib.SMTP(host, port, timeout=30) as s:
249
+ s.starttls(context=ctx)
250
+ s.login(user, pwd)
251
+ s.send_message(msg)
252
+ log(f"Email sent to {recipient} via {host}:{port}.")
253
+ except Exception as e:
254
+ log(f"ERROR sending email: {e}")
255
+
256
+
257
+ # ---------- state helpers ----------
258
+
259
+ def migrate_seen(seen_data) -> dict:
260
+ """Convert legacy list-of-ids to {model_id: first_seen_iso | None} dict."""
261
+ if isinstance(seen_data, list):
262
+ return {m: None for m in seen_data}
263
+ return seen_data
264
+
265
+
266
+ def diff_models(seen: dict, current_ids: list, now: datetime) -> tuple[list, dict]:
267
+ """Return (new_ids, updated_seen_dict) given current leaderboard snapshot.
268
+
269
+ A model is only reported as "new" if it has appeared in at least 2 consecutive
270
+ runs. First-time appearances are recorded in state with a ``pending`` sentinel
271
+ and promoted on the next sighting. This guards against flaky external
272
+ leaderboards that intermittently drop and restore model entries.
273
+ """
274
+ now_iso = now.isoformat(timespec="seconds")
275
+ pending_marker = "pending"
276
+ current_set = set(current_ids)
277
+ new_ids: list[str] = []
278
+ updated = dict(seen)
279
+
280
+ # Handle pending models from the previous run that are no longer in
281
+ # the API. They stay in state so they won't be re-reported later,
282
+ # but we demote the pending marker to None so they don't persist.
283
+ for m, first_seen in list(updated.items()):
284
+ if first_seen == pending_marker and m not in current_set:
285
+ updated[m] = None # treat as seeded, not new
286
+
287
+ for m in current_ids:
288
+ if m not in updated:
289
+ # First ever sighting β€” mark as pending, don't report yet.
290
+ updated[m] = pending_marker
291
+ elif updated[m] == pending_marker:
292
+ # Seen in the previous run too β€” promote to confirmed new.
293
+ updated[m] = now_iso
294
+ new_ids.append(m)
295
+ # else: already confirmed (has a real first_seen date) β€” no change.
296
+ return new_ids, updated
297
+
298
+
299
+ # ---------- core ----------
300
+
301
+ def run_once() -> int:
302
+ cfg = load_config()
303
+ token = read_token(cfg)
304
+ first_run_overall = not STATE_PATH.exists()
305
+ state = load_json(STATE_PATH, {})
306
+
307
+ benchmarks = cfg.get("benchmarks") or []
308
+ if not benchmarks:
309
+ log("Discovering official benchmark datasets...")
310
+ try:
311
+ benchmarks = discover_official_benchmarks(token, cfg["timeout"])
312
+ except Exception as e:
313
+ log(f"ERROR discovering benchmarks: {e}")
314
+ return 1
315
+ log(f"Checking {len(benchmarks)} benchmark(s).")
316
+
317
+ new_by_ds: dict[str, list[dict]] = {}
318
+
319
+ for ds in benchmarks:
320
+ try:
321
+ entries = get_leaderboard(ds, token, cfg["timeout"])
322
+ except urllib.error.HTTPError as e:
323
+ log(f" {ds}: HTTP {e.code} ({e.reason}) β€” skipping")
324
+ continue
325
+ except Exception as e:
326
+ log(f" {ds}: error {e} β€” skipping")
327
+ continue
328
+
329
+ current_ids = []
330
+ details: dict[str, dict] = {}
331
+ for entry in entries:
332
+ mid = entry.get("modelId") or entry.get("model_id") or entry.get("model")
333
+ if not mid:
334
+ continue
335
+ current_ids.append(mid)
336
+ details[mid] = entry
337
+
338
+ ds_state = state.get(ds, {})
339
+ seen = migrate_seen(ds_state.get("seen", {}))
340
+ first_time_for_ds = ds not in state
341
+ now = datetime.now(timezone.utc)
342
+ new_ids, updated_seen = diff_models(seen, current_ids, now)
343
+
344
+ state[ds] = {
345
+ "seen": updated_seen,
346
+ "last_checked": now.isoformat(timespec="seconds"),
347
+ "count": len(current_ids),
348
+ }
349
+
350
+ if not new_ids:
351
+ log(f" {ds}: no new models ({len(current_ids)} total)")
352
+ continue
353
+
354
+ log(f" {ds}: {len(new_ids)} new (of {len(current_ids)} total)")
355
+ for m in new_ids[:10]:
356
+ e = details.get(m, {})
357
+ rank = e.get("rank")
358
+ val = e.get("value")
359
+ first_seen = updated_seen.get(m)
360
+ log(f" + #{rank} {m} score={val} first_seen={first_seen}")
361
+ if len(new_ids) > 10:
362
+ log(f" + … and {len(new_ids) - 10} more")
363
+ silent = (first_run_overall or first_time_for_ds) and cfg["silent_first_run"]
364
+ if not silent:
365
+ new_by_ds[ds] = [details[m] for m in new_ids]
366
+
367
+ time.sleep(cfg["request_delay_seconds"])
368
+
369
+ save_json(STATE_PATH, state)
370
+
371
+ total_new = sum(len(v) for v in new_by_ds.values())
372
+ if total_new == 0:
373
+ log("No new models to report.")
374
+ if cfg["skip_email_when_empty"]:
375
+ return 0
376
+ text_body = f"No new models on any of the {len(benchmarks)} watched benchmarks today."
377
+ html_body = f"<p>{text_body}</p>"
378
+ send_email(cfg, "HF Leaderboard digest β€” no new models", text_body, html_body)
379
+ return 0
380
+
381
+ subject = f"HF Leaderboard digest β€” {total_new} new model entr{'y' if total_new == 1 else 'ies'}"
382
+ text_body, html_body = render_digest(new_by_ds)
383
+ log(f"Sending digest: {total_new} new entries across {len(new_by_ds)} benchmark(s).")
384
+ send_email(cfg, subject, text_body, html_body)
385
+ return 0
386
+
387
+
388
+ def main(argv):
389
+ if "--list" in argv:
390
+ cfg = load_config()
391
+ for b in discover_official_benchmarks(read_token(cfg), cfg["timeout"]):
392
+ print(b)
393
+ return 0
394
+ if "--reset" in argv:
395
+ if STATE_PATH.exists():
396
+ STATE_PATH.unlink()
397
+ print(f"Removed {STATE_PATH}")
398
+ return 0
399
+ if "--test-email" in argv:
400
+ cfg = load_config()
401
+ send_email(cfg, "HF Leaderboard watcher β€” test email",
402
+ "If you can read this, SMTP works. πŸŽ‰",
403
+ "<p>If you can read this, SMTP works. πŸŽ‰</p>")
404
+ return 0
405
+ return run_once()
406
+
407
+
408
+ if __name__ == "__main__":
409
+ sys.exit(main(sys.argv[1:]))