whojavumusic commited on
Commit
577400a
·
1 Parent(s): e4e1e7d

zoom scribe + versioning

Browse files
Files changed (5) hide show
  1. app.py +80 -11
  2. init.py +354 -5
  3. recipes/registry.py +9 -0
  4. recipes/zoom_scribe/evaluate.py +84 -0
  5. storage.py +3 -0
app.py CHANGED
@@ -22,10 +22,13 @@ from metrics_config import LIVE_SCENARIO_KEYS, SCENARIO_METRICS
22
  from recipes.registry import RECIPE_CHOICES
23
 
24
  from init import (
 
25
  is_model_on_hub,
 
26
  load_raw_results,
27
  load_results,
28
  normalize_legacy_csv_row,
 
29
  )
30
  from utils_display import (
31
  AutoEvalColumn,
@@ -51,6 +54,11 @@ ALWAYS_VISIBLE_COLS = (MODEL_COL, AVG_WER_COL)
51
  # Empty table at import; real data loaded on demo.load (avoids blocking Space "preparing").
52
  original_df = pd.DataFrame(columns=COLS)
53
 
 
 
 
 
 
54
  _NEXT_UP_PLACEHOLDER = (
55
  "<div class='next-up-panel' style='font-size:0.9em;opacity:0.85'>"
56
  "<em>Loading upcoming evaluations…</em></div>"
@@ -81,7 +89,7 @@ def filter_main_table(search_query, selected_columns):
81
  columns_to_hide = set(all_toggleable) - set(selected_columns)
82
  filtered_df = filtered_df[[c for c in filtered_df.columns if c not in columns_to_hide]]
83
 
84
- raw_rows = load_raw_results()
85
  for r in raw_rows:
86
  normalize_legacy_csv_row(r)
87
  raw_by_mid = {r["model_id"]: r for r in raw_rows}
@@ -117,6 +125,28 @@ def filter_main_table(search_query, selected_columns):
117
  return filtered_df
118
 
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  # ---------------------------------------------------------------------------
121
  # Submission handler
122
  # ---------------------------------------------------------------------------
@@ -353,6 +383,13 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
353
  placeholder="Filter by model name -- separate multiple terms with commas, e.g. whisper, nvidia",
354
  scale=4,
355
  )
 
 
 
 
 
 
 
356
  leaderboard_update_btn = gr.Button(
357
  "Update", variant="primary", scale=1, min_width=120
358
  )
@@ -377,34 +414,61 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
377
  wrap=False,
378
  )
379
 
380
- def _leaderboard_refresh(search, cols):
381
- """Reload results CSV from storage, re-apply current search/column filters."""
382
- global original_df
 
 
 
 
 
 
 
 
 
383
  from init import invalidate_results_cache
384
 
385
  try:
386
  invalidate_results_cache()
387
- original_df = load_results()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  return filter_main_table(search, cols)
389
  except Exception:
390
  return original_df
391
 
392
  def _on_startup():
393
  """Load leaderboard + next-up list after UI is up (keeps Space prepare step fast)."""
394
- global original_df
395
  from init import invalidate_results_cache
396
 
 
397
  try:
398
  invalidate_results_cache()
 
399
  original_df = load_results()
400
  table = filter_main_table("", _default_visible_cols)
 
401
  except Exception as exc:
402
  table = original_df
403
  next_html = (
404
  "<div class='next-up-panel'><p style='color:orange'>"
405
  f"<strong>Startup warning:</strong> {exc}</p></div>"
406
  )
407
- return table, next_html
408
  try:
409
  job_queue.ensure_worker_started()
410
  next_html = job_queue.next_up_html()
@@ -413,15 +477,20 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
413
  "<div class='next-up-panel'><p style='color:orange'>"
414
  f"<strong>Queue unavailable:</strong> {exc}</p></div>"
415
  )
416
- return table, next_html
417
 
418
  filter_inputs = [search_box, column_checkboxes]
419
  search_box.change(filter_main_table, inputs=filter_inputs, outputs=leaderboard_table)
420
  column_checkboxes.change(filter_main_table, inputs=filter_inputs, outputs=leaderboard_table)
 
 
 
 
 
421
  leaderboard_update_btn.click(
422
  fn=_leaderboard_refresh,
423
- inputs=filter_inputs,
424
- outputs=leaderboard_table,
425
  )
426
 
427
  with gr.TabItem(tab_label("submit", "Submit"), elem_id="submit-tab", id=1):
@@ -1345,7 +1414,7 @@ with gr.Blocks(title=APP_TITLE, theme=_theme, css=LEADERBOARD_CSS) as demo:
1345
  _next_up_timer.tick(fn=job_queue.next_up_html, outputs=[next_up_panel])
1346
  _progress_timer.tick(fn=_mod_progress_gated, inputs=[mod_unlocked], outputs=[mod_progress])
1347
 
1348
- demo.load(fn=_on_startup, outputs=[leaderboard_table, next_up_panel])
1349
  demo.load(
1350
  fn=_analytics_initial,
1351
  outputs=[
 
22
  from recipes.registry import RECIPE_CHOICES
23
 
24
  from init import (
25
+ LATEST_VERSION,
26
  is_model_on_hub,
27
+ list_leaderboard_versions,
28
  load_raw_results,
29
  load_results,
30
  normalize_legacy_csv_row,
31
+ raw_rows_for_version,
32
  )
33
  from utils_display import (
34
  AutoEvalColumn,
 
54
  # Empty table at import; real data loaded on demo.load (avoids blocking Space "preparing").
55
  original_df = pd.DataFrame(columns=COLS)
56
 
57
+ # Raw CSV rows backing the currently displayed leaderboard version. ``None`` means "live"
58
+ # (filter_main_table falls back to load_raw_results()). Set whenever a version is selected
59
+ # so the Avg-WER recompute in filter_main_table matches the displayed snapshot.
60
+ _active_raw_rows: list[dict] | None = None
61
+
62
  _NEXT_UP_PLACEHOLDER = (
63
  "<div class='next-up-panel' style='font-size:0.9em;opacity:0.85'>"
64
  "<em>Loading upcoming evaluations…</em></div>"
 
89
  columns_to_hide = set(all_toggleable) - set(selected_columns)
90
  filtered_df = filtered_df[[c for c in filtered_df.columns if c not in columns_to_hide]]
91
 
92
+ raw_rows = _active_raw_rows if _active_raw_rows is not None else load_raw_results()
93
  for r in raw_rows:
94
  normalize_legacy_csv_row(r)
95
  raw_by_mid = {r["model_id"]: r for r in raw_rows}
 
125
  return filtered_df
126
 
127
 
128
+ def _format_version_recorded_at(ts: str) -> str:
129
+ from datetime import datetime
130
+
131
+ try:
132
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
133
+ return dt.strftime("%Y-%m-%d %H:%M UTC")
134
+ except Exception:
135
+ return str(ts)
136
+
137
+
138
+ def _version_choices() -> list[tuple[str, str]]:
139
+ """(label, value) pairs for the version dropdown; first entry is always 'Latest'."""
140
+ choices: list[tuple[str, str]] = [("Latest", LATEST_VERSION)]
141
+ try:
142
+ for v in list_leaderboard_versions():
143
+ label = f"{_format_version_recorded_at(v['recorded_at'])} — {v['label']}"
144
+ choices.append((label, v["version"]))
145
+ except Exception:
146
+ pass
147
+ return choices
148
+
149
+
150
  # ---------------------------------------------------------------------------
151
  # Submission handler
152
  # ---------------------------------------------------------------------------
 
383
  placeholder="Filter by model name -- separate multiple terms with commas, e.g. whisper, nvidia",
384
  scale=4,
385
  )
386
+ version_dropdown = gr.Dropdown(
387
+ choices=_version_choices(),
388
+ value=LATEST_VERSION,
389
+ label="Version",
390
+ scale=2,
391
+ min_width=200,
392
+ )
393
  leaderboard_update_btn = gr.Button(
394
  "Update", variant="primary", scale=1, min_width=120
395
  )
 
414
  wrap=False,
415
  )
416
 
417
+ def _set_active_version(version):
418
+ """Load the chosen version into the module globals used by filter_main_table."""
419
+ global original_df, _active_raw_rows
420
+ if version:
421
+ _active_raw_rows = raw_rows_for_version(version)
422
+ original_df = load_results(version)
423
+ else:
424
+ _active_raw_rows = None
425
+ original_df = load_results()
426
+
427
+ def _leaderboard_refresh(search, cols, version):
428
+ """Reload results from storage for the selected version, refresh version list."""
429
  from init import invalidate_results_cache
430
 
431
  try:
432
  invalidate_results_cache()
433
+ choices = _version_choices()
434
+ valid = {v for _, v in choices}
435
+ if version not in valid:
436
+ version = LATEST_VERSION
437
+ _set_active_version(version)
438
+ return (
439
+ filter_main_table(search, cols),
440
+ gr.update(choices=choices, value=version),
441
+ )
442
+ except Exception:
443
+ return original_df, gr.update()
444
+
445
+ def _on_version_change(version, search, cols):
446
+ """Swap the displayed leaderboard to the selected version, keep filters."""
447
+ try:
448
+ _set_active_version(version)
449
  return filter_main_table(search, cols)
450
  except Exception:
451
  return original_df
452
 
453
  def _on_startup():
454
  """Load leaderboard + next-up list after UI is up (keeps Space prepare step fast)."""
455
+ global original_df, _active_raw_rows
456
  from init import invalidate_results_cache
457
 
458
+ version_update = gr.update()
459
  try:
460
  invalidate_results_cache()
461
+ _active_raw_rows = None
462
  original_df = load_results()
463
  table = filter_main_table("", _default_visible_cols)
464
+ version_update = gr.update(choices=_version_choices(), value=LATEST_VERSION)
465
  except Exception as exc:
466
  table = original_df
467
  next_html = (
468
  "<div class='next-up-panel'><p style='color:orange'>"
469
  f"<strong>Startup warning:</strong> {exc}</p></div>"
470
  )
471
+ return table, next_html, version_update
472
  try:
473
  job_queue.ensure_worker_started()
474
  next_html = job_queue.next_up_html()
 
477
  "<div class='next-up-panel'><p style='color:orange'>"
478
  f"<strong>Queue unavailable:</strong> {exc}</p></div>"
479
  )
480
+ return table, next_html, version_update
481
 
482
  filter_inputs = [search_box, column_checkboxes]
483
  search_box.change(filter_main_table, inputs=filter_inputs, outputs=leaderboard_table)
484
  column_checkboxes.change(filter_main_table, inputs=filter_inputs, outputs=leaderboard_table)
485
+ version_dropdown.change(
486
+ fn=_on_version_change,
487
+ inputs=[version_dropdown, search_box, column_checkboxes],
488
+ outputs=leaderboard_table,
489
+ )
490
  leaderboard_update_btn.click(
491
  fn=_leaderboard_refresh,
492
+ inputs=[search_box, column_checkboxes, version_dropdown],
493
+ outputs=[leaderboard_table, version_dropdown],
494
  )
495
 
496
  with gr.TabItem(tab_label("submit", "Submit"), elem_id="submit-tab", id=1):
 
1414
  _next_up_timer.tick(fn=job_queue.next_up_html, outputs=[next_up_panel])
1415
  _progress_timer.tick(fn=_mod_progress_gated, inputs=[mod_unlocked], outputs=[mod_progress])
1416
 
1417
+ demo.load(fn=_on_startup, outputs=[leaderboard_table, next_up_panel, version_dropdown])
1418
  demo.load(
1419
  fn=_analytics_initial,
1420
  outputs=[
init.py CHANGED
@@ -14,6 +14,7 @@ import pandas as pd
14
  from storage import (
15
  HF_BUCKET_ID,
16
  HF_TOKEN,
 
17
  RESULTS_PATH,
18
  STORAGE_BACKEND,
19
  upload_to_bucket,
@@ -40,9 +41,15 @@ __all__ = [
40
  "load_audio_file",
41
  "load_transcript_file",
42
  "normalize_legacy_csv_row",
 
 
 
43
  ]
44
 
45
  csv_lock = threading.Lock()
 
 
 
46
 
47
  LEADERBOARD_COLUMNS = [c.name for c in fields(AutoEvalColumn)]
48
 
@@ -59,6 +66,14 @@ CSV_FIELDS = [
59
  "submitted_at",
60
  ]
61
 
 
 
 
 
 
 
 
 
62
  # Legacy CSV / evaluation wire-format keys -> canonical ``SCENARIO_KEYS`` column names.
63
  _LEGACY_WER_TO_CANONICAL: tuple[tuple[str, str], ...] = (
64
  ("wer_clean", "wer_anechoic_speech"),
@@ -281,10 +296,19 @@ def load_raw_results(*, refresh: bool = False) -> list[dict]:
281
  return []
282
 
283
 
284
- def save_raw_results(rows: list[dict]):
285
- """Write the leaderboard CSV back to storage."""
 
 
 
 
 
286
  if not rows:
287
  return
 
 
 
 
288
  with csv_lock:
289
  normalized = [{k: row.get(k, "") for k in CSV_FIELDS} for row in rows]
290
  output = io.StringIO()
@@ -301,17 +325,342 @@ def save_raw_results(rows: list[dict]):
301
  )
302
  invalidate_results_cache()
303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
 
305
- def load_results() -> pd.DataFrame:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  """Load results from storage and return a display-ready DataFrame.
307
 
 
 
 
308
  The leaderboard intentionally does NOT show eval_family, wall time, or submitted_at.
309
  Those fields remain in the CSV (for moderators and analytics) but are omitted from the
310
  public table.
311
  """
312
- import analytics
 
 
 
 
313
 
314
- raw_rows = load_raw_results()
 
 
 
315
 
316
  if not raw_rows:
317
  return pd.DataFrame(columns=LEADERBOARD_COLUMNS)
 
14
  from storage import (
15
  HF_BUCKET_ID,
16
  HF_TOKEN,
17
+ HISTORY_PATH,
18
  RESULTS_PATH,
19
  STORAGE_BACKEND,
20
  upload_to_bucket,
 
41
  "load_audio_file",
42
  "load_transcript_file",
43
  "normalize_legacy_csv_row",
44
+ "list_leaderboard_versions",
45
+ "raw_rows_for_version",
46
+ "LATEST_VERSION",
47
  ]
48
 
49
  csv_lock = threading.Lock()
50
+ # Serializes read-modify-write of the append-only history file (separate from the
51
+ # leaderboard CSV lock so history bookkeeping can never deadlock a result save).
52
+ _history_lock = threading.Lock()
53
 
54
  LEADERBOARD_COLUMNS = [c.name for c in fields(AutoEvalColumn)]
55
 
 
66
  "submitted_at",
67
  ]
68
 
69
+ # Sentinel for "show the live leaderboard" (no historical reconstruction).
70
+ LATEST_VERSION = ""
71
+
72
+ # History rows are leaderboard rows plus version bookkeeping. Stored append-only:
73
+ # each leaderboard change appends only the model row(s) that changed, tagged with a
74
+ # version id (timestamp) so any past state can be reconstructed without full copies.
75
+ HISTORY_FIELDS = [*CSV_FIELDS, "version", "recorded_at", "version_label"]
76
+
77
  # Legacy CSV / evaluation wire-format keys -> canonical ``SCENARIO_KEYS`` column names.
78
  _LEGACY_WER_TO_CANONICAL: tuple[tuple[str, str], ...] = (
79
  ("wer_clean", "wer_anechoic_speech"),
 
296
  return []
297
 
298
 
299
+ def save_raw_results(rows: list[dict], *, record_version: bool = True):
300
+ """Write the leaderboard CSV back to storage.
301
+
302
+ When ``record_version`` is set (default), the model row(s) that changed relative to
303
+ the previously persisted state are appended to the history file so the new state is
304
+ selectable from the leaderboard version dropdown.
305
+ """
306
  if not rows:
307
  return
308
+ # Snapshot the previously persisted state BEFORE acquiring csv_lock (load_raw_results
309
+ # also takes csv_lock, which is non-reentrant) so we can diff for versioning.
310
+ prev_rows = load_raw_results() if record_version else None
311
+
312
  with csv_lock:
313
  normalized = [{k: row.get(k, "") for k in CSV_FIELDS} for row in rows]
314
  output = io.StringIO()
 
325
  )
326
  invalidate_results_cache()
327
 
328
+ if record_version:
329
+ # Never let history bookkeeping break a successful result save.
330
+ try:
331
+ _record_history_from_diff(prev_rows or [], normalized)
332
+ except Exception:
333
+ pass
334
+
335
+
336
+ # ---------------------------------------------------------------------------
337
+ # Leaderboard version history (append-only, delta-based)
338
+ # ---------------------------------------------------------------------------
339
+
340
+
341
+ def _now_iso() -> str:
342
+ """UTC timestamp with microseconds so successive versions sort and stay unique."""
343
+ from datetime import datetime, timezone
344
+
345
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
346
+
347
+
348
+ def load_history_rows() -> list[dict]:
349
+ """All rows of the append-only history file (chronological), or ``[]`` if absent."""
350
+ if STORAGE_BACKEND != "hf_bucket":
351
+ return []
352
+ try:
353
+ local_path = download_bucket_file(HISTORY_PATH)
354
+ except Exception:
355
+ return []
356
+ try:
357
+ with open(local_path, "r") as f:
358
+ rows = list(csv.DictReader(f))
359
+ except Exception:
360
+ rows = []
361
+ finally:
362
+ try:
363
+ os.unlink(local_path)
364
+ except Exception:
365
+ pass
366
+ return rows
367
+
368
+
369
+ def _append_history_rows(new_rows: list[dict]) -> None:
370
+ """Append rows to the history file (read-modify-write of the whole file)."""
371
+ if STORAGE_BACKEND != "hf_bucket" or not new_rows:
372
+ return
373
+ with _history_lock:
374
+ all_rows = load_history_rows() + new_rows
375
+ output = io.StringIO()
376
+ writer = csv.DictWriter(output, fieldnames=HISTORY_FIELDS, extrasaction="ignore")
377
+ writer.writeheader()
378
+ for row in all_rows:
379
+ writer.writerow({k: row.get(k, "") for k in HISTORY_FIELDS})
380
+ upload_to_bucket(
381
+ HF_BUCKET_ID,
382
+ add=[(output.getvalue().encode("utf-8"), HISTORY_PATH)],
383
+ token=HF_TOKEN,
384
+ )
385
+
386
+
387
+ def _history_label(changed: list[dict]) -> str:
388
+ if len(changed) == 1:
389
+ mid = (changed[0].get("model_id") or "").strip()
390
+ return mid or "leaderboard updated"
391
+ return f"{len(changed)} models updated"
392
+
393
+
394
+ def _record_history_from_diff(prev_rows: list[dict], curr_rows: list[dict]) -> None:
395
+ """Append the model rows that changed between ``prev_rows`` and ``curr_rows``.
396
+
397
+ The first ever call seeds a full snapshot ("Initial release") so the earliest
398
+ selectable version reconstructs the complete board; later calls store only deltas.
399
+ """
400
+ now = _now_iso()
401
+ existing = load_history_rows()
402
+
403
+ if not existing:
404
+ seed = [
405
+ {**{k: r.get(k, "") for k in CSV_FIELDS},
406
+ "version": now, "recorded_at": now, "version_label": "Initial release"}
407
+ for r in curr_rows
408
+ ]
409
+ _append_history_rows(seed)
410
+ return
411
+
412
+ prev_by_mid = {(r.get("model_id") or "").strip(): {k: r.get(k, "") for k in CSV_FIELDS}
413
+ for r in prev_rows}
414
+ changed: list[dict] = []
415
+ for r in curr_rows:
416
+ mid = (r.get("model_id") or "").strip()
417
+ if not mid:
418
+ continue
419
+ if prev_by_mid.get(mid) != {k: r.get(k, "") for k in CSV_FIELDS}:
420
+ changed.append(r)
421
+ if not changed:
422
+ return
423
+
424
+ label = _history_label(changed)
425
+ _append_history_rows([
426
+ {**{k: r.get(k, "") for k in CSV_FIELDS},
427
+ "version": now, "recorded_at": now, "version_label": label}
428
+ for r in changed
429
+ ])
430
+
431
+
432
+ def list_leaderboard_versions() -> list[dict]:
433
+ """Distinct versions, newest first: ``[{version, label, recorded_at}, ...]``."""
434
+ rows = load_history_rows()
435
+ by_version: dict[str, dict] = {}
436
+ for r in rows:
437
+ v = (r.get("version") or "").strip()
438
+ if not v:
439
+ continue
440
+ by_version[v] = {
441
+ "version": v,
442
+ "label": (r.get("version_label") or v),
443
+ "recorded_at": (r.get("recorded_at") or v),
444
+ }
445
+ items = list(by_version.values())
446
+ items.sort(key=lambda d: d["recorded_at"], reverse=True)
447
+ return items
448
+
449
+
450
+ def raw_rows_for_version(version: str) -> list[dict]:
451
+ """Reconstruct the leaderboard (CSV_FIELDS rows) as of ``version``.
452
+
453
+ Accumulates every history entry recorded at or before the chosen version and keeps
454
+ the most recent row per model. Falls back to the live results if history is missing.
455
+ """
456
+ if not version:
457
+ return load_raw_results()
458
+ rows = load_history_rows()
459
+ if not rows:
460
+ return load_raw_results()
461
+
462
+ target = None
463
+ for r in rows:
464
+ if (r.get("version") or "").strip() == version:
465
+ ra = r.get("recorded_at") or ""
466
+ if target is None or ra > target:
467
+ target = ra
468
+ if target is None:
469
+ return load_raw_results()
470
+
471
+ # File order is chronological, so the last entry per model wins.
472
+ by_mid: dict[str, dict] = {}
473
+ for r in rows:
474
+ if (r.get("recorded_at") or "") <= target:
475
+ mid = (r.get("model_id") or "").strip()
476
+ if mid:
477
+ by_mid[mid] = r
478
+
479
+ out: list[dict] = []
480
+ for r in by_mid.values():
481
+ row = {k: r.get(k, "") for k in CSV_FIELDS}
482
+ if not row.get("eval_family"):
483
+ row["eval_family"] = "N/A"
484
+ row.setdefault("submission_notes", "")
485
+ normalize_legacy_csv_row(row)
486
+ out.append(row)
487
+ return out
488
 
489
+
490
+ # ---------------------------------------------------------------------------
491
+ # Leaderboard version history (append-only, delta-based)
492
+ # ---------------------------------------------------------------------------
493
+
494
+
495
+ def _now_iso() -> str:
496
+ """UTC timestamp with microseconds so successive versions sort and stay unique."""
497
+ from datetime import datetime, timezone
498
+
499
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
500
+
501
+
502
+ def load_history_rows() -> list[dict]:
503
+ """All rows of the append-only history file (chronological), or ``[]`` if absent."""
504
+ if STORAGE_BACKEND != "hf_bucket":
505
+ return []
506
+ try:
507
+ local_path = download_bucket_file(HISTORY_PATH)
508
+ except Exception:
509
+ return []
510
+ try:
511
+ with open(local_path, "r") as f:
512
+ rows = list(csv.DictReader(f))
513
+ except Exception:
514
+ rows = []
515
+ finally:
516
+ try:
517
+ os.unlink(local_path)
518
+ except Exception:
519
+ pass
520
+ return rows
521
+
522
+
523
+ def _append_history_rows(new_rows: list[dict]) -> None:
524
+ """Append rows to the history file (read-modify-write of the whole file)."""
525
+ if STORAGE_BACKEND != "hf_bucket" or not new_rows:
526
+ return
527
+ with _history_lock:
528
+ all_rows = load_history_rows() + new_rows
529
+ output = io.StringIO()
530
+ writer = csv.DictWriter(output, fieldnames=HISTORY_FIELDS, extrasaction="ignore")
531
+ writer.writeheader()
532
+ for row in all_rows:
533
+ writer.writerow({k: row.get(k, "") for k in HISTORY_FIELDS})
534
+ upload_to_bucket(
535
+ HF_BUCKET_ID,
536
+ add=[(output.getvalue().encode("utf-8"), HISTORY_PATH)],
537
+ token=HF_TOKEN,
538
+ )
539
+
540
+
541
+ def _history_label(changed: list[dict]) -> str:
542
+ if len(changed) == 1:
543
+ mid = (changed[0].get("model_id") or "").strip()
544
+ return mid or "leaderboard updated"
545
+ return f"{len(changed)} models updated"
546
+
547
+
548
+ def _record_history_from_diff(prev_rows: list[dict], curr_rows: list[dict]) -> None:
549
+ """Append the model rows that changed between ``prev_rows`` and ``curr_rows``.
550
+
551
+ The first ever call seeds a full snapshot ("Initial release") so the earliest
552
+ selectable version reconstructs the complete board; later calls store only deltas.
553
+ """
554
+ now = _now_iso()
555
+ existing = load_history_rows()
556
+
557
+ if not existing:
558
+ seed = [
559
+ {**{k: r.get(k, "") for k in CSV_FIELDS},
560
+ "version": now, "recorded_at": now, "version_label": "Initial release"}
561
+ for r in curr_rows
562
+ ]
563
+ _append_history_rows(seed)
564
+ return
565
+
566
+ prev_by_mid = {(r.get("model_id") or "").strip(): {k: r.get(k, "") for k in CSV_FIELDS}
567
+ for r in prev_rows}
568
+ changed: list[dict] = []
569
+ for r in curr_rows:
570
+ mid = (r.get("model_id") or "").strip()
571
+ if not mid:
572
+ continue
573
+ if prev_by_mid.get(mid) != {k: r.get(k, "") for k in CSV_FIELDS}:
574
+ changed.append(r)
575
+ if not changed:
576
+ return
577
+
578
+ label = _history_label(changed)
579
+ _append_history_rows([
580
+ {**{k: r.get(k, "") for k in CSV_FIELDS},
581
+ "version": now, "recorded_at": now, "version_label": label}
582
+ for r in changed
583
+ ])
584
+
585
+
586
+ def list_leaderboard_versions() -> list[dict]:
587
+ """Distinct versions, newest first: ``[{version, label, recorded_at}, ...]``."""
588
+ rows = load_history_rows()
589
+ by_version: dict[str, dict] = {}
590
+ for r in rows:
591
+ v = (r.get("version") or "").strip()
592
+ if not v:
593
+ continue
594
+ by_version[v] = {
595
+ "version": v,
596
+ "label": (r.get("version_label") or v),
597
+ "recorded_at": (r.get("recorded_at") or v),
598
+ }
599
+ items = list(by_version.values())
600
+ items.sort(key=lambda d: d["recorded_at"], reverse=True)
601
+ return items
602
+
603
+
604
+ def raw_rows_for_version(version: str) -> list[dict]:
605
+ """Reconstruct the leaderboard (CSV_FIELDS rows) as of ``version``.
606
+
607
+ Accumulates every history entry recorded at or before the chosen version and keeps
608
+ the most recent row per model. Falls back to the live results if history is missing.
609
+ """
610
+ if not version:
611
+ return load_raw_results()
612
+ rows = load_history_rows()
613
+ if not rows:
614
+ return load_raw_results()
615
+
616
+ target = None
617
+ for r in rows:
618
+ if (r.get("version") or "").strip() == version:
619
+ ra = r.get("recorded_at") or ""
620
+ if target is None or ra > target:
621
+ target = ra
622
+ if target is None:
623
+ return load_raw_results()
624
+
625
+ # File order is chronological, so the last entry per model wins.
626
+ by_mid: dict[str, dict] = {}
627
+ for r in rows:
628
+ if (r.get("recorded_at") or "") <= target:
629
+ mid = (r.get("model_id") or "").strip()
630
+ if mid:
631
+ by_mid[mid] = r
632
+
633
+ out: list[dict] = []
634
+ for r in by_mid.values():
635
+ row = {k: r.get(k, "") for k in CSV_FIELDS}
636
+ if not row.get("eval_family"):
637
+ row["eval_family"] = "N/A"
638
+ row.setdefault("submission_notes", "")
639
+ normalize_legacy_csv_row(row)
640
+ out.append(row)
641
+ return out
642
+
643
+
644
+ def load_results(version: str | None = None) -> pd.DataFrame:
645
  """Load results from storage and return a display-ready DataFrame.
646
 
647
+ With ``version`` empty/``None`` the live leaderboard is returned. Otherwise the
648
+ leaderboard is reconstructed from the append-only history as of that version.
649
+
650
  The leaderboard intentionally does NOT show eval_family, wall time, or submitted_at.
651
  Those fields remain in the CSV (for moderators and analytics) but are omitted from the
652
  public table.
653
  """
654
+ if version:
655
+ raw_rows = raw_rows_for_version(version)
656
+ else:
657
+ raw_rows = load_raw_results()
658
+ return _display_df_from_raw_rows(raw_rows)
659
 
660
+
661
+ def _display_df_from_raw_rows(raw_rows: list[dict]) -> pd.DataFrame:
662
+ """Transform raw CSV rows into the public, sorted, display-formatted DataFrame."""
663
+ import analytics
664
 
665
  if not raw_rows:
666
  return pd.DataFrame(columns=LEADERBOARD_COLUMNS)
recipes/registry.py CHANGED
@@ -29,6 +29,15 @@ RECIPES: dict[str, dict] = {
29
  },
30
  "evaluate_relative": "recipes/mega_asr/evaluate.py",
31
  },
 
 
 
 
 
 
 
 
 
32
  }
33
 
34
  RECIPE_CHOICES: list[tuple[str, str]] = [
 
29
  },
30
  "evaluate_relative": "recipes/mega_asr/evaluate.py",
31
  },
32
+ "zoom_scribe": {
33
+ "title": "Zoom Scribe API (zoom/scribe_v1)",
34
+ "model_id_patterns": ("zoom/", "zoom-scribe", "zoom_scribe"),
35
+ # API model: no checkpoint download. Requires ZOOM_API_KEY as a Space secret.
36
+ "extra_deps": ["requests"],
37
+ "setup_commands": [],
38
+ "env": {},
39
+ "evaluate_relative": "recipes/zoom_scribe/evaluate.py",
40
+ },
41
  }
42
 
43
  RECIPE_CHOICES: list[tuple[str, str]] = [
recipes/zoom_scribe/evaluate.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Zoom Scribe API custom evaluator for FFASR.
3
+
4
+ Calls the Zoom Scribe v1 transcription endpoint once per audio sample. Adapted from the
5
+ open_asr_leaderboard provider (huggingface/open_asr_leaderboard#130). FFASR serves a
6
+ private held-out set as local files (no public URLs), so audio is uploaded inline as a
7
+ base64 ``data:`` payload; it is downmixed to mono FLAC to stay under Zoom's ~2MB
8
+ local-upload limit.
9
+
10
+ Requires ``ZOOM_API_KEY`` in the environment. For local (on-Space) evaluation, add it as
11
+ a Space repository secret. ``requests`` is pulled in via the recipe's extra deps.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import io
18
+ import os
19
+ import time
20
+ from pathlib import Path
21
+
22
+ import soundfile as sf
23
+
24
+ ENDPOINT = "https://api.zoom.us/v2/aiservices/scribe/transcribe"
25
+ MAX_UPLOAD_BYTES = 2_000_000 # Zoom Scribe ~2MB local-file limit
26
+ _TIMEOUT_S = 300
27
+ _RETRYABLE_STATUS = (429, 500, 502, 503, 504)
28
+
29
+
30
+ def _encode_payload(file: Path) -> tuple[str, float]:
31
+ """Return ``(data-URI base64 payload, duration_s)``; mono FLAC keeps us under 2MB."""
32
+ audio, sr = sf.read(str(file), dtype="float32", always_2d=True)
33
+ audio = audio.mean(axis=1) # downmix to mono
34
+ duration = len(audio) / float(sr)
35
+
36
+ buf = io.BytesIO()
37
+ sf.write(buf, audio, int(sr), format="FLAC")
38
+ raw = buf.getvalue()
39
+ if len(raw) > MAX_UPLOAD_BYTES:
40
+ raise RuntimeError(
41
+ f"{file.name}: {len(raw)} bytes exceeds Zoom's {MAX_UPLOAD_BYTES}-byte upload limit"
42
+ )
43
+ return f"data:audio/flac;base64,{base64.b64encode(raw).decode('ascii')}", duration
44
+
45
+
46
+ def evaluate(file: Path) -> str:
47
+ import requests
48
+
49
+ api_key = (os.getenv("ZOOM_API_KEY") or "").strip()
50
+ if not api_key or api_key == "your_api_key":
51
+ raise ValueError(
52
+ "ZOOM_API_KEY environment variable not set (add it as a Space secret)."
53
+ )
54
+
55
+ file_payload, duration = _encode_payload(Path(file))
56
+ # Short clips transcribe in a single pass; longer ones need auto segmentation.
57
+ segmentation_mode = "none" if duration <= 29.9 else "auto"
58
+
59
+ body = {
60
+ "file": file_payload,
61
+ "config": {
62
+ "language": "en-US",
63
+ "segmentation_mode": segmentation_mode,
64
+ "experimental_feature": {"model_pro": True},
65
+ "timestamps": True,
66
+ },
67
+ }
68
+ headers = {
69
+ "Authorization": f"Bearer {api_key}",
70
+ "Content-Type": "application/json",
71
+ }
72
+
73
+ last_err: Exception | None = None
74
+ for attempt in range(4):
75
+ try:
76
+ resp = requests.post(ENDPOINT, headers=headers, json=body, timeout=_TIMEOUT_S)
77
+ if resp.status_code in _RETRYABLE_STATUS:
78
+ raise RuntimeError(f"Zoom API {resp.status_code}: {resp.text[:200]}")
79
+ resp.raise_for_status()
80
+ return resp.json().get("result", {}).get("text_display", "") or ""
81
+ except Exception as e: # noqa: BLE001
82
+ last_err = e
83
+ time.sleep(2.0 * (2 ** attempt))
84
+ raise RuntimeError(f"Zoom Scribe request failed after retries: {last_err}")
storage.py CHANGED
@@ -33,6 +33,9 @@ HF_BUCKET_ID = os.environ.get("FFASR_BUCKET_ID", "treble-technologies/FFASR_Lead
33
  DATASET_PREFIX = ""
34
  RESULTS_PATH = os.environ.get("FFASR_RESULTS_PATH", "results/leaderboard.csv")
35
  JOBS_STATE_PATH = os.environ.get("FFASR_JOBS_PATH", "results/jobs_state.csv")
 
 
 
36
 
37
  HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("ds_token")
38
 
 
33
  DATASET_PREFIX = ""
34
  RESULTS_PATH = os.environ.get("FFASR_RESULTS_PATH", "results/leaderboard.csv")
35
  JOBS_STATE_PATH = os.environ.get("FFASR_JOBS_PATH", "results/jobs_state.csv")
36
+ # Append-only leaderboard history (one row per changed model per version). Used to
37
+ # reconstruct and display past leaderboard states from the version dropdown.
38
+ HISTORY_PATH = os.environ.get("FFASR_HISTORY_PATH", "results/leaderboard_history.csv")
39
 
40
  HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("ds_token")
41