ha251 commited on
Commit
db38d3b
·
verified ·
1 Parent(s): 7be1132

Update miniapp_leaderboard.py

Browse files
Files changed (1) hide show
  1. miniapp_leaderboard.py +728 -0
miniapp_leaderboard.py CHANGED
@@ -0,0 +1,728 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import datetime
2
+ import io
3
+ import json
4
+ import os
5
+ import re
6
+ import uuid
7
+ from urllib.parse import urlparse
8
+
9
+ import gradio as gr
10
+ import pandas as pd
11
+ import requests
12
+ from huggingface_hub import HfApi, hf_hub_download
13
+ from huggingface_hub.errors import RepositoryNotFoundError
14
+
15
+
16
+ APP_NAME = "miniapp"
17
+
18
+ # Space Secrets:
19
+ # - HF_TOKEN: Hugging Face access token with WRITE access to the dataset repo
20
+ # - LEADERBOARD_DATASET: dataset repo id like "ha251/miniapp-leaderboard"
21
+ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
22
+ LEADERBOARD_DATASET = (os.environ.get("LEADERBOARD_DATASET") or "").strip()
23
+ MAX_ENTRIES = int(os.environ.get("MAX_ENTRIES", "500"))
24
+
25
+ ENTRIES_PREFIX = "entries/"
26
+
27
+ LEADERBOARD_COLUMNS = [
28
+ "Model name",
29
+ "Avg",
30
+ "Easy",
31
+ "Mid",
32
+ "Hard",
33
+ "Games",
34
+ "Science",
35
+ "Tools",
36
+ "Humanities",
37
+ "Viz",
38
+ "Lifestyle",
39
+ "Submitted at",
40
+ "Submitter",
41
+ ]
42
+
43
+ NUMERIC_COLS = [
44
+ "Avg",
45
+ "Easy",
46
+ "Mid",
47
+ "Hard",
48
+ "Games",
49
+ "Science",
50
+ "Tools",
51
+ "Humanities",
52
+ "Viz",
53
+ "Lifestyle",
54
+ ]
55
+
56
+ IN_SPACES = bool(
57
+ os.environ.get("SPACE_ID")
58
+ or os.environ.get("SPACE_REPO_NAME")
59
+ or os.environ.get("SPACE_AUTHOR_NAME")
60
+ or os.environ.get("system", "") == "spaces"
61
+ )
62
+
63
+
64
+ def _api() -> HfApi:
65
+ return HfApi(token=HF_TOKEN)
66
+
67
+
68
+ def _is_valid_http_url(url: str) -> bool:
69
+ try:
70
+ parsed = urlparse(url)
71
+ return parsed.scheme in ("http", "https") and bool(parsed.netloc)
72
+ except Exception:
73
+ return False
74
+
75
+
76
+ def _slug(s: str, max_len: int = 60) -> str:
77
+ s = (s or "").strip().lower()
78
+ s = re.sub(r"[^a-z0-9]+", "-", s)
79
+ s = re.sub(r"-{2,}", "-", s).strip("-")
80
+ return (s[:max_len] or "x")
81
+
82
+
83
+ def _empty_df() -> pd.DataFrame:
84
+ return pd.DataFrame(columns=LEADERBOARD_COLUMNS)
85
+
86
+
87
+ def _ensure_dataset_readable() -> tuple[bool, str]:
88
+ if not HF_TOKEN:
89
+ return False, "Space is missing HF_TOKEN (Secrets)."
90
+ if not LEADERBOARD_DATASET:
91
+ return False, "Space is missing LEADERBOARD_DATASET (Secrets)."
92
+ api = _api()
93
+ try:
94
+ api.repo_info(repo_id=LEADERBOARD_DATASET, repo_type="dataset")
95
+ return True, ""
96
+ except RepositoryNotFoundError:
97
+ return False, (
98
+ f"Dataset repo not found: {LEADERBOARD_DATASET}. "
99
+ "Create it first (as a dataset) or fix LEADERBOARD_DATASET."
100
+ )
101
+ except Exception:
102
+ return False, "Cannot access the dataset repo. Check token permissions."
103
+
104
+
105
+ def _list_entry_files() -> list[str]:
106
+ ok, _ = _ensure_dataset_readable()
107
+ if not ok:
108
+ return []
109
+ api = _api()
110
+ try:
111
+ files = api.list_repo_files(repo_id=LEADERBOARD_DATASET, repo_type="dataset")
112
+ except Exception:
113
+ return []
114
+
115
+ entry_files = [f for f in files if f.startswith(ENTRIES_PREFIX) and f.endswith(".json")]
116
+ entry_files.sort(reverse=True)
117
+ return entry_files[:MAX_ENTRIES]
118
+
119
+
120
+ def _load_entries_df() -> pd.DataFrame:
121
+ ok, _ = _ensure_dataset_readable()
122
+ if not ok:
123
+ return _empty_df()
124
+
125
+ rows: list[dict] = []
126
+ for filename in _list_entry_files():
127
+ try:
128
+ path = hf_hub_download(
129
+ repo_id=LEADERBOARD_DATASET,
130
+ repo_type="dataset",
131
+ filename=filename,
132
+ token=HF_TOKEN,
133
+ )
134
+ with open(path, "r", encoding="utf-8") as fp:
135
+ row = json.load(fp)
136
+ rows.append(row)
137
+ except Exception:
138
+ continue
139
+
140
+ if not rows:
141
+ return _empty_df()
142
+
143
+ df = pd.DataFrame(rows)
144
+ for c in LEADERBOARD_COLUMNS:
145
+ if c not in df.columns:
146
+ df[c] = ""
147
+ df = df[LEADERBOARD_COLUMNS]
148
+ for c in NUMERIC_COLS:
149
+ df[c] = pd.to_numeric(df[c], errors="coerce")
150
+ df = df.sort_values(by=["Submitted at"], ascending=False, kind="stable")
151
+ return df
152
+
153
+
154
+ def _apply_search_and_sort(
155
+ df: pd.DataFrame,
156
+ search_text: str,
157
+ search_in: str,
158
+ sort_by: str,
159
+ sort_order: str,
160
+ ) -> pd.DataFrame:
161
+ search_text = (search_text or "").strip().lower()
162
+ if search_text:
163
+ if search_in == "Model name":
164
+ df = df[df["Model name"].astype(str).str.lower().str.contains(search_text, na=False)]
165
+ elif search_in == "Submitter":
166
+ df = df[df["Submitter"].astype(str).str.lower().str.contains(search_text, na=False)]
167
+ else:
168
+ mask = (
169
+ df["Model name"].astype(str).str.lower().str.contains(search_text, na=False)
170
+ | df["Submitter"].astype(str).str.lower().str.contains(search_text, na=False)
171
+ )
172
+ df = df[mask]
173
+
174
+ ascending = sort_order == "Ascending"
175
+ if sort_by in df.columns:
176
+ df = df.sort_values(by=[sort_by], ascending=ascending, kind="stable", na_position="last")
177
+ return df
178
+
179
+
180
+ def refresh(search_text: str, search_in: str, sort_by: str, sort_order: str):
181
+ df = _load_entries_df()
182
+ return _apply_search_and_sort(df, search_text, search_in, sort_by, sort_order)
183
+
184
+
185
+ def _parse_hf_created_at(created_at: str) -> datetime.datetime | None:
186
+ try:
187
+ if created_at.endswith("Z"):
188
+ created_at = created_at[:-1] + "+00:00"
189
+ return datetime.datetime.fromisoformat(created_at)
190
+ except Exception:
191
+ return None
192
+
193
+
194
+ def _check_user_eligibility(username: str) -> tuple[bool, str]:
195
+ """
196
+ - Must be older than ~4 months (>= 120 days)
197
+ """
198
+ try:
199
+ r = requests.get(f"https://huggingface.co/api/users/{username}/overview", timeout=10)
200
+ r.raise_for_status()
201
+ created_at = r.json().get("createdAt")
202
+ if not created_at:
203
+ return False, "Cannot verify account creation date."
204
+ dt = _parse_hf_created_at(created_at)
205
+ if not dt:
206
+ return False, "Cannot parse account creation date."
207
+ now = datetime.datetime.now(datetime.timezone.utc)
208
+ if dt.tzinfo is None:
209
+ dt = dt.replace(tzinfo=datetime.timezone.utc)
210
+ age_days = (now - dt).days
211
+ if age_days < 120:
212
+ return False, "Account must be older than 4 months to submit."
213
+ return True, ""
214
+ except Exception:
215
+ return False, "Cannot verify Hugging Face account. Please try again later."
216
+
217
+
218
+ def _submitted_today(username: str) -> bool:
219
+ df = _load_entries_df()
220
+ if df.empty:
221
+ return False
222
+ today = datetime.datetime.utcnow().date().isoformat()
223
+ user_rows = df[df["Submitter"].astype(str) == username]
224
+ if user_rows.empty:
225
+ return False
226
+ return any(str(v).startswith(today) for v in user_rows["Submitted at"].tolist())
227
+
228
+
229
+ def submit(
230
+ model_name: str,
231
+ model_api: str,
232
+ api_key: str,
233
+ avg: float,
234
+ easy: float,
235
+ mid: float,
236
+ hard: float,
237
+ games: float,
238
+ science: float,
239
+ tools: float,
240
+ humanities: float,
241
+ viz: float,
242
+ lifestyle: float,
243
+ search_text: str,
244
+ search_in: str,
245
+ sort_by: str,
246
+ sort_order: str,
247
+ profile: gr.OAuthProfile | None,
248
+ ):
249
+ # Login required to submit (in Spaces)
250
+ if IN_SPACES and (profile is None or not getattr(profile, "username", None)):
251
+ return "You must log in to submit.", refresh(search_text, search_in, sort_by, sort_order)
252
+
253
+ submitter = (getattr(profile, "username", None) if profile is not None else "local") or "anonymous"
254
+
255
+ model_name = (model_name or "").strip()
256
+ model_api = (model_api or "").strip()
257
+ api_key = (api_key or "").strip()
258
+
259
+ if not model_name:
260
+ return "Model name is required.", refresh(search_text, search_in, sort_by, sort_order)
261
+ if not model_api:
262
+ return "Model API URL is required.", refresh(search_text, search_in, sort_by, sort_order)
263
+ if not _is_valid_http_url(model_api):
264
+ return "Model API must be a valid http(s) URL.", refresh(search_text, search_in, sort_by, sort_order)
265
+ if not api_key:
266
+ return "API key is required.", refresh(search_text, search_in, sort_by, sort_order)
267
+
268
+ ok, msg = _ensure_dataset_readable()
269
+ if not ok:
270
+ return msg, refresh(search_text, search_in, sort_by, sort_order)
271
+
272
+ if IN_SPACES:
273
+ ok, msg = _check_user_eligibility(submitter)
274
+ if not ok:
275
+ return msg, refresh(search_text, search_in, sort_by, sort_order)
276
+
277
+ if _submitted_today(submitter):
278
+ return "You have already submitted today. Please try again tomorrow.", refresh(search_text, search_in, sort_by, sort_order)
279
+
280
+ now = datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
281
+ nonce = uuid.uuid4().hex[:8]
282
+ safe_model = _slug(model_name)
283
+ safe_user = _slug(submitter)
284
+ path_in_repo = f"{ENTRIES_PREFIX}{now[:10]}/{now}-{safe_user}-{safe_model}-{nonce}.json"
285
+
286
+ # NOTE: api_key is collected but NOT stored.
287
+ payload = {
288
+ "Model name": model_name,
289
+ "Avg": avg,
290
+ "Easy": easy,
291
+ "Mid": mid,
292
+ "Hard": hard,
293
+ "Games": games,
294
+ "Science": science,
295
+ "Tools": tools,
296
+ "Humanities": humanities,
297
+ "Viz": viz,
298
+ "Lifestyle": lifestyle,
299
+ "Submitted at": now,
300
+ "Submitter": submitter,
301
+ "Model API": model_api,
302
+ }
303
+
304
+ api = _api()
305
+ data = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
306
+ api.upload_file(
307
+ repo_id=LEADERBOARD_DATASET,
308
+ repo_type="dataset",
309
+ path_or_fileobj=io.BytesIO(data),
310
+ path_in_repo=path_in_repo,
311
+ commit_message=f"miniapp: submit {submitter}/{model_name}",
312
+ token=HF_TOKEN,
313
+ )
314
+
315
+ return "Submitted successfully.", refresh(search_text, search_in, sort_by, sort_order)
316
+
317
+
318
+ with gr.Blocks(title=f"{APP_NAME} leaderboard") as demo:
319
+ # Layout: Overview -> Leaderboard -> Submission instructions
320
+ gr.Markdown(
321
+ f"## {APP_NAME} leaderboard\n\n"
322
+ "### Overview\n"
323
+ "_Placeholder overview text. Replace this with your benchmark description, rules, and links._\n\n"
324
+ "### Leaderboard\n"
325
+ "_The leaderboard below supports sorting and searching._\n\n"
326
+ "### Submission\n"
327
+ "_Placeholder submission instructions. Requires login. One submission per user per day. "
328
+ "Account must be older than 4 months._\n"
329
+ )
330
+
331
+ with gr.Row():
332
+ with gr.Column(scale=3):
333
+ with gr.Row():
334
+ search_text = gr.Textbox(label="Search", placeholder="Model name or submitter")
335
+ search_in = gr.Dropdown(
336
+ label="Search in",
337
+ choices=["Both", "Model name", "Submitter"],
338
+ value="Both",
339
+ )
340
+ with gr.Row():
341
+ sort_by = gr.Dropdown(label="Sort by", choices=LEADERBOARD_COLUMNS, value="Avg")
342
+ sort_order = gr.Radio(label="Order", choices=["Descending", "Ascending"], value="Descending")
343
+ refresh_btn = gr.Button("Refresh")
344
+
345
+ leaderboard = gr.Dataframe(
346
+ label="Leaderboard",
347
+ value=_load_entries_df(),
348
+ interactive=False,
349
+ wrap=True,
350
+ )
351
+
352
+ with gr.Column(scale=2):
353
+ with gr.Accordion("Submit (login required)", open=True):
354
+ model_name = gr.Textbox(label="Model name (required)")
355
+ model_api = gr.Textbox(label="Model API (required)", placeholder="https://...")
356
+ api_key = gr.Textbox(label="API key (required)", type="password", placeholder="Will not be stored")
357
+
358
+ with gr.Row():
359
+ avg = gr.Number(label="Avg", value=0)
360
+ easy = gr.Number(label="Easy", value=0)
361
+ mid = gr.Number(label="Mid", value=0)
362
+ hard = gr.Number(label="Hard", value=0)
363
+
364
+ with gr.Row():
365
+ games = gr.Number(label="Games", value=0)
366
+ science = gr.Number(label="Science", value=0)
367
+ tools = gr.Number(label="Tools", value=0)
368
+
369
+ with gr.Row():
370
+ humanities = gr.Number(label="Humanities", value=0)
371
+ viz = gr.Number(label="Viz", value=0)
372
+ lifestyle = gr.Number(label="Lifestyle", value=0)
373
+
374
+ with gr.Row():
375
+ gr.LoginButton()
376
+ submit_btn = gr.Button("Submit", variant="primary")
377
+ status = gr.Markdown()
378
+
379
+ refresh_btn.click(refresh, inputs=[search_text, search_in, sort_by, sort_order], outputs=[leaderboard])
380
+ submit_btn.click(
381
+ submit,
382
+ inputs=[
383
+ model_name,
384
+ model_api,
385
+ api_key,
386
+ avg,
387
+ easy,
388
+ mid,
389
+ hard,
390
+ games,
391
+ science,
392
+ tools,
393
+ humanities,
394
+ viz,
395
+ lifestyle,
396
+ search_text,
397
+ search_in,
398
+ sort_by,
399
+ sort_order,
400
+ ],
401
+ outputs=[status, leaderboard],
402
+ )
403
+
404
+ demo.launch()
405
+
406
+ import datetime
407
+ import io
408
+ import json
409
+ import os
410
+ import re
411
+ import uuid
412
+ from urllib.parse import urlparse
413
+
414
+ import gradio as gr
415
+ import pandas as pd
416
+ from huggingface_hub import HfApi, hf_hub_download
417
+
418
+
419
+ APP_NAME = "miniapp"
420
+
421
+ # 在 Space 里通过 Secrets 配置:
422
+ # - HF_TOKEN: 具有写 dataset 权限的 token(Settings -> Variables and secrets -> Secrets)
423
+ # - LEADERBOARD_DATASET: 形如 "your-username/miniapp-leaderboard"(repo_type=dataset)
424
+ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
425
+ LEADERBOARD_DATASET = os.environ.get("LEADERBOARD_DATASET", "").strip()
426
+ # Owner 审核口令(放到 Space Secrets;不要放到公开 Variables)
427
+ OWNER_REVIEW_TOKEN = os.environ.get("OWNER_REVIEW_TOKEN", "").strip()
428
+
429
+ # 判断是否运行在 Hugging Face Spaces
430
+ IN_SPACES = bool(
431
+ os.environ.get("SPACE_ID")
432
+ or os.environ.get("SPACE_REPO_NAME")
433
+ or os.environ.get("SPACE_AUTHOR_NAME")
434
+ or os.environ.get("system", "") == "spaces"
435
+ )
436
+
437
+ MAX_ENTRIES = int(os.environ.get("MAX_ENTRIES", "200"))
438
+
439
+ PENDING_PREFIX = "pending/"
440
+ APPROVED_PREFIX = "approved/"
441
+
442
+
443
+ def _is_valid_http_url(url: str) -> bool:
444
+ try:
445
+ parsed = urlparse(url)
446
+ return parsed.scheme in ("http", "https") and bool(parsed.netloc)
447
+ except Exception:
448
+ return False
449
+
450
+
451
+ def _slug(s: str, max_len: int = 60) -> str:
452
+ s = (s or "").strip().lower()
453
+ s = re.sub(r"[^a-z0-9]+", "-", s)
454
+ s = re.sub(r"-{2,}", "-", s).strip("-")
455
+ return (s[:max_len] or "model")
456
+
457
+
458
+ def _api() -> HfApi:
459
+ return HfApi(token=HF_TOKEN)
460
+
461
+
462
+ def _ensure_dataset_repo():
463
+ if not HF_TOKEN:
464
+ raise RuntimeError("未配置 HF_TOKEN(Space Secrets)。")
465
+ if not LEADERBOARD_DATASET:
466
+ raise RuntimeError("未配置 LEADERBOARD_DATASET(例如:your-username/miniapp-leaderboard)。")
467
+ api = _api()
468
+ try:
469
+ api.repo_info(repo_id=LEADERBOARD_DATASET, repo_type="dataset")
470
+ except Exception:
471
+ # 不存在则创建(public dataset;你也可以手动创建并设为 private)
472
+ api.create_repo(repo_id=LEADERBOARD_DATASET, repo_type="dataset", private=False, exist_ok=True)
473
+
474
+
475
+ def _empty_df() -> pd.DataFrame:
476
+ return pd.DataFrame(columns=["submitted_at", "username", "model_name", "model_api", "notes"])
477
+
478
+
479
+ def _list_json_files(prefix: str) -> list[str]:
480
+ if not HF_TOKEN or not LEADERBOARD_DATASET:
481
+ return []
482
+
483
+ api = _api()
484
+ try:
485
+ files = api.list_repo_files(repo_id=LEADERBOARD_DATASET, repo_type="dataset")
486
+ except Exception:
487
+ return []
488
+
489
+ return sorted(
490
+ [f for f in files if f.startswith(prefix) and f.endswith(".json")],
491
+ reverse=True,
492
+ )[:MAX_ENTRIES]
493
+
494
+
495
+ def _load_entries_df(prefix: str, include_filename: bool) -> pd.DataFrame:
496
+ files = _list_json_files(prefix)
497
+ rows = []
498
+ for filename in files:
499
+ try:
500
+ path = hf_hub_download(
501
+ repo_id=LEADERBOARD_DATASET,
502
+ repo_type="dataset",
503
+ filename=filename,
504
+ token=HF_TOKEN,
505
+ )
506
+ with open(path, "r", encoding="utf-8") as fp:
507
+ row = json.load(fp)
508
+ if include_filename:
509
+ row["_filename"] = filename
510
+ rows.append(row)
511
+ except Exception:
512
+ continue
513
+
514
+ if not rows:
515
+ df = _empty_df()
516
+ if include_filename:
517
+ df["_filename"] = []
518
+ return df
519
+
520
+ df = pd.DataFrame(rows)
521
+ for col in ["submitted_at", "username", "model_name", "model_api", "notes"]:
522
+ if col not in df.columns:
523
+ df[col] = ""
524
+ cols = ["submitted_at", "username", "model_name", "model_api", "notes"]
525
+ if include_filename:
526
+ if "_filename" not in df.columns:
527
+ df["_filename"] = ""
528
+ cols = cols + ["_filename"]
529
+ df = df[cols]
530
+ df = df.sort_values(by=["submitted_at"], ascending=False, kind="stable")
531
+ return df
532
+
533
+
534
+ def refresh():
535
+ return _load_entries_df(APPROVED_PREFIX, include_filename=False)
536
+
537
+
538
+ def refresh_pending():
539
+ df = _load_entries_df(PENDING_PREFIX, include_filename=True)
540
+ choices = list(df["_filename"]) if "_filename" in df.columns else []
541
+ return df, gr.update(choices=choices, value=choices[0] if choices else None)
542
+
543
+
544
+ def submit(model_name: str, model_api: str, notes: str, username: str | None):
545
+ model_name = (model_name or "").strip()
546
+ model_api = (model_api or "").strip()
547
+ notes = (notes or "").strip()
548
+ username = (username or "").strip() or ("local" if not IN_SPACES else "anonymous")
549
+
550
+ if not model_name:
551
+ return "请填写 **模型名称**。", refresh()
552
+ if not model_api:
553
+ return "请填写 **模型 API**。", refresh()
554
+ if not _is_valid_http_url(model_api):
555
+ return "**模型 API** 需要是合法的 `http(s)://...` URL。", refresh()
556
+
557
+ if not HF_TOKEN:
558
+ return "Space 未配置 **HF_TOKEN**(Secrets),无法写入排行榜。", refresh()
559
+ if not LEADERBOARD_DATASET:
560
+ return "Space 未配置 **LEADERBOARD_DATASET**(例如:`your-username/miniapp-leaderboard`)。", refresh()
561
+
562
+ _ensure_dataset_repo()
563
+ api = _api()
564
+
565
+ now = datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
566
+ safe_model = _slug(model_name)
567
+ safe_user = _slug(username)
568
+ nonce = uuid.uuid4().hex[:8]
569
+ path_in_repo = f"{PENDING_PREFIX}{now[:10]}/{now}-{safe_user}-{safe_model}-{nonce}.json"
570
+
571
+ payload = {
572
+ "submitted_at": now,
573
+ "username": username,
574
+ "model_name": model_name,
575
+ "model_api": model_api,
576
+ "notes": notes,
577
+ }
578
+ data = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
579
+ bio = io.BytesIO(data)
580
+
581
+ api.upload_file(
582
+ repo_id=LEADERBOARD_DATASET,
583
+ repo_type="dataset",
584
+ path_or_fileobj=bio,
585
+ path_in_repo=path_in_repo,
586
+ commit_message=f"miniapp: submit(pending) {username}/{model_name}",
587
+ token=HF_TOKEN,
588
+ )
589
+
590
+ return "已提交,等待 owner 审核后才会上榜。", refresh()
591
+
592
+
593
+ def approve(pending_filename: str | None, review_token: str | None):
594
+ pending_filename = (pending_filename or "").strip()
595
+ review_token = (review_token or "").strip()
596
+
597
+ if not pending_filename:
598
+ df, dd = refresh_pending()
599
+ return "请选择一条待审核提交。", df, dd, refresh()
600
+
601
+ if not OWNER_REVIEW_TOKEN:
602
+ df, dd = refresh_pending()
603
+ return "Space 未配置 **OWNER_REVIEW_TOKEN**(Secrets),无法启用审核。", df, dd, refresh()
604
+
605
+ if review_token != OWNER_REVIEW_TOKEN:
606
+ df, dd = refresh_pending()
607
+ return "审核口令不正确。", df, dd, refresh()
608
+
609
+ if not HF_TOKEN or not LEADERBOARD_DATASET:
610
+ df, dd = refresh_pending()
611
+ return "Space 未配置 HF 写入权限,无法审核。", df, dd, refresh()
612
+
613
+ api = _api()
614
+ try:
615
+ local_path = hf_hub_download(
616
+ repo_id=LEADERBOARD_DATASET,
617
+ repo_type="dataset",
618
+ filename=pending_filename,
619
+ token=HF_TOKEN,
620
+ )
621
+ with open(local_path, "r", encoding="utf-8") as fp:
622
+ payload = json.load(fp)
623
+ except Exception as e:
624
+ df, dd = refresh_pending()
625
+ return f"读取待审核文件失败:{e}", df, dd, refresh()
626
+
627
+ # 将文件复制到 approved 目录(保留原提交时间等字段)
628
+ base = pending_filename[len(PENDING_PREFIX) :] if pending_filename.startswith(PENDING_PREFIX) else pending_filename
629
+ approved_filename = f"{APPROVED_PREFIX}{base}"
630
+ data = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
631
+ bio = io.BytesIO(data)
632
+
633
+ try:
634
+ api.upload_file(
635
+ repo_id=LEADERBOARD_DATASET,
636
+ repo_type="dataset",
637
+ path_or_fileobj=bio,
638
+ path_in_repo=approved_filename,
639
+ commit_message=f"miniapp: approve {payload.get('username','')}/{payload.get('model_name','')}",
640
+ token=HF_TOKEN,
641
+ )
642
+ # 删除 pending 原文件(真正“上榜前审核”)
643
+ api.delete_file(
644
+ repo_id=LEADERBOARD_DATASET,
645
+ repo_type="dataset",
646
+ path_in_repo=pending_filename,
647
+ commit_message=f"miniapp: remove pending {pending_filename}",
648
+ token=HF_TOKEN,
649
+ )
650
+ except Exception as e:
651
+ df, dd = refresh_pending()
652
+ return f"审核写入失败:{e}", df, dd, refresh()
653
+
654
+ pending_df, pending_dd = refresh_pending()
655
+ approved_df = refresh()
656
+ return "已通过审核并更新榜单。", pending_df, pending_dd, approved_df
657
+
658
+
659
+ with gr.Blocks(title=f"{APP_NAME} leaderboard") as demo:
660
+ gr.Markdown(
661
+ f"## {APP_NAME} leaderboard\n\n"
662
+ "用户提交信息后会进入 **pending(待审核)**;owner 审核通过后才会进入 **approved(上榜)**。\n\n"
663
+ f"- 当前 `LEADERBOARD_DATASET`: `{LEADERBOARD_DATASET or '(未配置)'}`\n"
664
+ )
665
+
666
+ with gr.Row():
667
+ with gr.Column(scale=2):
668
+ model_name = gr.Textbox(label="模型名称(必填)", placeholder="例如:my-agent-v1")
669
+ model_api = gr.Textbox(
670
+ label="模型 API(必填)",
671
+ placeholder="例如:https://api.example.com/v1/chat/completions",
672
+ )
673
+ notes = gr.Textbox(label="备注(可选)", lines=4)
674
+ username = gr.Textbox(
675
+ label="用户名(可选)",
676
+ placeholder="建议填你的 HF 用户名(也可留空)",
677
+ value="" if IN_SPACES else "local",
678
+ )
679
+ submit_btn = gr.Button("提交", variant="primary")
680
+ status = gr.Markdown()
681
+ with gr.Column(scale=3):
682
+ leaderboard = gr.Dataframe(
683
+ label="Leaderboard(按提交时间倒序)",
684
+ value=_load_entries_df(APPROVED_PREFIX, include_filename=False),
685
+ interactive=False,
686
+ wrap=True,
687
+ )
688
+ refresh_btn = gr.Button("刷新")
689
+
690
+ submit_btn.click(
691
+ submit,
692
+ inputs=[model_name, model_api, notes, username],
693
+ outputs=[status, leaderboard],
694
+ )
695
+ refresh_btn.click(refresh, inputs=[], outputs=[leaderboard])
696
+
697
+ with gr.Accordion("Owner 审核(测试 demo)", open=False):
698
+ gr.Markdown(
699
+ "需要在 Space Secrets 中配置 `OWNER_REVIEW_TOKEN`。只有输入正确口令,才允许把 pending 放入榜单。"
700
+ )
701
+ review_token = gr.Textbox(label="审核口令", type="password", placeholder="OWNER_REVIEW_TOKEN")
702
+ pending_refresh_btn = gr.Button("刷新待审核列表")
703
+ pending_df = gr.Dataframe(
704
+ label="待审核(pending)",
705
+ value=_load_entries_df(PENDING_PREFIX, include_filename=True),
706
+ interactive=False,
707
+ wrap=True,
708
+ )
709
+ pending_pick = gr.Dropdown(
710
+ label="选择要通过的提交(文件)",
711
+ choices=_list_json_files(PENDING_PREFIX),
712
+ )
713
+ approve_btn = gr.Button("通过审核并上榜", variant="primary")
714
+ approve_status = gr.Markdown()
715
+
716
+ pending_refresh_btn.click(
717
+ refresh_pending,
718
+ inputs=[],
719
+ outputs=[pending_df, pending_pick],
720
+ )
721
+ approve_btn.click(
722
+ approve,
723
+ inputs=[pending_pick, review_token],
724
+ outputs=[approve_status, pending_df, pending_pick, leaderboard],
725
+ )
726
+
727
+ demo.launch()
728
+