File size: 16,801 Bytes
77edebf c040324 0501689 77edebf c040324 0e3b21f b2f3ce6 77edebf b2f3ce6 c040324 3112173 77edebf 3112173 b5ad973 53de73a 3112173 53de73a c4e21b3 f585077 046548a c040324 0501689 c040324 77edebf b2f3ce6 c040324 97b9a4a c040324 628bc9e 97b9a4a c040324 97b9a4a 3112173 0e3b21f 3112173 0e3b21f 6facf47 c87b253 0e3b21f 3112173 0e3b21f 3112173 0e3b21f 3112173 1a8f331 3112173 77edebf 0e3b21f 3112173 0e3b21f 3112173 77edebf c040324 97b9a4a 046548a 53de73a 4e86f82 3112173 046548a c4e21b3 046548a 53de73a 046548a 3112173 046548a c4e21b3 f2f35be f585077 046548a 6facf47 046548a f585077 c040324 0e3b21f 3112173 0e3b21f 3112173 c040324 952dbca c040324 952dbca c040324 a58058c c040324 c87b253 c040324 c87b253 6facf47 c87b253 6facf47 c040324 4e86f82 046548a 4e86f82 046548a 4e86f82 c87b253 c040324 77edebf c040324 77edebf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 | """CADGenBench Leaderboard Space - Gradio UI + report-proxy mount.
Read path lives in :mod:`leaderboard`. Submit-tab validation lives in
:mod:`submit`. Both are wired into the Gradio Blocks below. The
Gradio app is mounted under a FastAPI parent so the custom
``/reports/{submission_id}.html`` route can re-serve dataset HTML
with ``Content-Type: text/html`` (HF Hub's ``/resolve/`` serves it
as ``text/plain`` by policy, which makes the browser show source
rather than render).
"""
from __future__ import annotations
import html
import logging
import os
from functools import lru_cache
from pathlib import Path
import gradio as gr
import pandas as pd
import uvicorn
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, Response
from gradio_leaderboard import Leaderboard
from huggingface_hub import hf_hub_download
from leaderboard import (
HF_DATA_REPO,
HF_SUBMISSIONS_REPO,
LEADERBOARD_DATATYPES,
LEADERBOARD_HIDE_COLUMNS,
VALIDATED_LEADERBOARD_DATATYPES,
_fmt_timestamp,
build_combined_csv,
load_leaderboard_split,
)
from submit import handle_submit
logger = logging.getLogger(__name__)
# Surface module-level logger.info / logger.warning / logger.exception
# calls from leaderboard.py + submit.py in the Space's runtime logs.
# Otherwise they go nowhere and any refresh / worker pathology is
# silent. Format keeps timestamps + module + level + message.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
# Canonical policy doc lives in the code repo so contributors reading
# the GitHub repo see it without needing to visit the Space. Linked
# from both the Leaderboard tab's Validation Guidelines accordion and
# the About tab.
VALIDATION_DOC_URL = (
"https://github.com/huggingface/cadgenbench/blob/main/docs/benchmark/validation.md"
)
ABOUT_MD = f"""## About
**CADGenBench** evaluates AI-driven CAD generation: how well a model can
turn a description of a mechanical part into a valid, geometrically
correct 3D model.
- **Reference baseline**: an iterative AI agent that writes build123d Python.
- **Submission flow**: upload a zip of per-fixture STEP files; the Space
runs the eval and appends a row to the submissions dataset.
- **Datasets**: fixture inputs in
[`{HF_DATA_REPO}`](https://huggingface.co/datasets/{HF_DATA_REPO});
submissions and computed results in
[`{HF_SUBMISSIONS_REPO}`](https://huggingface.co/datasets/{HF_SUBMISSIONS_REPO}).
- **Code**: [`huggingface/cadgenbench`](https://github.com/huggingface/cadgenbench).
- **Validation policy**: [`docs/benchmark/validation.md`]({VALIDATION_DOC_URL}).
"""
# Verbatim BibTeX entry locked in space-setup/bundles/1-2-space-ux.md
# (Locked decisions section). Shown in the Citation accordion as a
# copy-paste handle for anyone citing this benchmark; the About tab
# already links the source code via huggingface/cadgenbench so the
# Space URL is the right deep-link target for the citation.
CITATION_BIBTEX = r"""@misc{cadgenbench2026,
author = {Rabinovich, Michael and {Hugging Face}},
title = {{CADGenBench}: a benchmark for {AI}-driven {CAD} generation},
year = {2026},
publisher = {Hugging Face},
howpublished = {\url{https://huggingface.co/spaces/HuggingAI4Engineering/cadgenbench-leaderboard}},
}"""
VALIDATION_GUIDELINES_MD = f"""Submissions appear on the **Unvalidated** table the moment evaluation completes. Maintainers promote rows to **Validated** after methodology review, accepting one of four evidence types (`code`, `traces`, `api`, `manual`).
Full policy: [`docs/benchmark/validation.md`]({VALIDATION_DOC_URL})."""
DETAIL_PLACEHOLDER = "_Click a row above for details._"
def _has(value) -> bool:
"""True for values that should show up in the detail panel."""
if value is None:
return False
if isinstance(value, float) and pd.isna(value):
return False
return str(value).strip() != ""
def _build_report_iframe(html_bytes: bytes) -> str:
"""Wrap a fetched report's HTML bytes into a self-contained iframe.
``srcdoc`` puts the entire report HTML directly inside the iframe
attribute. The iframe gets its own document context, so the
report's CSS can't collide with Gradio's, and an explicit
``height: 90vh`` keeps the report from being clipped by Gradio's
column-flex layout (which was the visible cut-off the user saw
in earlier rendering attempts).
The Space is private; the FastAPI ``/reports/<id>.html`` route
works server-side under Bearer auth but breaks for a logged-in
browser user (HF's edge gates same-origin pathname navigations
on a JWT that the browser doesn't carry forward). srcdoc
sidesteps that entirely by inlining the bytes; no second HTTP
request leaves the browser.
"""
escaped = html.escape(
html_bytes.decode("utf-8", errors="replace"), quote=True,
)
return (
f'<iframe srcdoc="{escaped}" '
'style="width:100%; height:90vh; border:0; display:block;" '
'title="Submission report"></iframe>'
)
def _refresh_leaderboard_with_toast():
"""Manual Refresh button handler: toast + fresh DataFrames.
The Timer auto-refresh wires straight to ``load_leaderboard_split``
so it stays silent (a toast every 10s would be noise). Only the
explicit click goes through this wrapper.
"""
gr.Info("Leaderboard refreshed.")
return load_leaderboard_split()
def _enable_submit_when_logged_in(
profile: gr.OAuthProfile | None,
) -> gr.Button:
"""Flip the Submit button's interactivity based on login state.
Runs once per page load via ``blocks.load``. Gradio injects
``gr.OAuthProfile`` automatically (``None`` if the visitor isn't
logged in via the LoginButton). The visible-disable mirrors the
server-side gate in :func:`submit.handle_submit`; the handler
still raises ``gr.Error`` defensively if it ever gets called
without a profile.
"""
return gr.Button(interactive=profile is not None)
def _format_detail_and_report(
df: pd.DataFrame | None, evt: gr.SelectData,
) -> tuple[str, str]:
"""Return ``(detail_markdown, report_iframe_html)`` for the clicked row.
The detail panel holds metadata (submitter, status, timestamp,
notes, links to ZIP and external agent URL). The HTML viewer
holds the rendered report when one exists (status == completed
AND the row carries the modern-pipeline ``submission_sha256``
sentinel) - the leaderboard reader computes ``report_url`` only
for rows that satisfy that gate.
Returns ``(DETAIL_PLACEHOLDER, "")`` on a null / out-of-range
event so the panel falls back to its initial state.
"""
if df is None or len(df) == 0 or evt is None or evt.index is None:
return DETAIL_PLACEHOLDER, ""
idx = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
if idx < 0 or idx >= len(df):
return DETAIL_PLACEHOLDER, ""
row = df.iloc[idx]
title = row.get("submission_name") or "(unnamed submission)"
lines = [f"### {title}", ""]
if _has(row.get("submitter_name")):
lines.append(f"- **Submitter**: {row['submitter_name']}")
if _has(row.get("status")):
lines.append(f"- **Status**: {row['status']}")
if _has(row.get("submitted_at")):
lines.append(f"- **Submitted**: {_fmt_timestamp(row['submitted_at'])}")
if _has(row.get("notes")):
lines.append(f"- **Notes**: {row['notes']}")
lines.append(
f"- **Model details (optional)**: "
f"{row.get('model details (optional)') or '_None_'}"
)
if _has(row.get("submission_blob_url")):
lines.append(
f"- **Submission ZIP**: [download]({row['submission_blob_url']})"
)
if row.get("status") == "failed" and _has(row.get("failure_reason")):
lines.append(f"- **Failure reason**: {row['failure_reason']}")
detail_md = "\n".join(lines)
report_iframe = ""
if _has(row.get("report_url")) and _has(row.get("submission_id")):
content = _fetch_report_html(str(row["submission_id"]))
if content:
report_iframe = _build_report_iframe(content)
return detail_md, report_iframe
@lru_cache(maxsize=128)
def _fetch_report_html(submission_id: str) -> bytes | None:
"""Pull ``reports/<id>.html`` off the submissions dataset.
Cached in-process so repeat clicks on the same row don't hit
the Hub. Returns ``None`` on any failure so the caller can
serve a clean 404 rather than leaking a stack trace.
"""
try:
local_path = hf_hub_download(
repo_id=HF_SUBMISSIONS_REPO,
filename=f"reports/{submission_id}.html",
repo_type="dataset",
)
return Path(local_path).read_bytes()
except Exception as e: # noqa: BLE001 - any Hub failure -> 404
logger.warning(
"Failed to fetch report for %s (%s: %s)",
submission_id, type(e).__name__, e,
)
return None
def serve_report(submission_id: str) -> Response:
"""Proxy a per-submission HTML report through the Space.
HF Hub serves dataset HTML under ``/resolve/`` with
``Content-Type: text/plain`` (security: dataset files can't host
live HTML), so a direct dataset link shows source instead of
rendering. This route lives on the Space (which can legitimately
serve text/html) and re-streams the file's bytes with the right
content-type.
"""
content = _fetch_report_html(submission_id)
if content is None:
return HTMLResponse(
content="<h1>Report not found</h1>",
status_code=404,
)
return Response(content=content, media_type="text/html; charset=utf-8")
with gr.Blocks(title="CADGenBench Leaderboard", theme=gr.themes.Soft()) as blocks:
gr.Markdown(
"# CADGenBench Leaderboard\n"
"_Benchmarking AI-driven CAD generation._"
)
with gr.Tab("Leaderboard"):
# Collapsed accordions above the tables. Validation guidelines
# gives the short two-tier story + link to the full policy
# doc; Citation carries the verbatim BibTeX entry. Both start
# closed so the leaderboard itself stays above the fold.
with gr.Accordion("Validation guidelines", open=False):
gr.Markdown(VALIDATION_GUIDELINES_MD)
with gr.Accordion("Citation", open=False):
# language=None -> plain monospaced render (gr.Code doesn't
# ship a BibTeX highlighter); show_line_numbers off because
# the entry is meant to be copy-pasted, not annotated.
gr.Code(
value=CITATION_BIBTEX,
language=None,
show_line_numbers=False,
)
# Two stacked tables, split by `validation_status`. Validated
# on top so the curated results are above the fold; unvalidated
# below carries every other row (auto-published, awaiting
# methodology review). See decisions/validation-policy.md.
initial_validated, initial_unvalidated = load_leaderboard_split()
validated_view = Leaderboard(
value=initial_validated,
datatype=VALIDATED_LEADERBOARD_DATATYPES,
search_columns=["submission_name", "submitter_name"],
hide_columns=LEADERBOARD_HIDE_COLUMNS,
label="Validated Leaderboard",
interactive=False,
)
unvalidated_view = Leaderboard(
value=initial_unvalidated,
datatype=LEADERBOARD_DATATYPES,
search_columns=["submission_name", "submitter_name"],
hide_columns=LEADERBOARD_HIDE_COLUMNS,
label="Unvalidated Leaderboard",
interactive=False,
)
with gr.Row():
refresh_btn = gr.Button("Refresh", size="sm")
# One file, both tables, `validation_status` discriminator
# column. Fresh CSV is generated on every click so the
# download reflects the latest data, not a stale snapshot
# captured at boot.
download_btn = gr.DownloadButton(
label="Download CSV", size="sm",
)
refresh_btn.click(
fn=_refresh_leaderboard_with_toast,
outputs=[validated_view, unvalidated_view],
)
download_btn.click(fn=build_combined_csv, outputs=download_btn)
# Row-click panel: one shared metadata markdown component +
# one report viewer below it. The viewer holds an iframe
# containing the full per-submission report (srcdoc-inlined
# so no second HTTP request needs to leave the browser - the
# Space is private and HF's edge would 404 same-origin
# pathname navigations that aren't carrying the iframe's
# short-lived `__sign` JWT). Both tables share both outputs.
detail_panel = gr.Markdown(
value=DETAIL_PLACEHOLDER,
label="Selected submission",
)
report_viewer = gr.HTML(value="", label="Report")
for view in (validated_view, unvalidated_view):
view.select(
fn=_format_detail_and_report,
inputs=view,
outputs=[detail_panel, report_viewer],
)
with gr.Tab("Submit"):
gr.Markdown(
f"""
**Submission format.** A single zip with:
- one folder per fixture in `{HF_DATA_REPO}`, each containing `output.step`;
- a top-level `meta.json`:
```json
{{
"submitter_name": "your name or team",
"submission_name": "MyAgent v2.3 (or whatever describes your system)",
"agent_url": "https://github.com/... (optional)",
"notes": "free text, optional, max 500 chars, single line, plain text",
"agree_to_publish": true
}}
```
**Submission name.** Free text describing the system being benchmarked,
however you choose to describe it. The benchmark is system-agnostic - your
submission may use no LLM, one, or many. If you want to disclose your
stack, put it here or in `notes`.
**Notes field.** Plain text only (no markdown / HTML). Capped at 500 chars
and stripped to a single line. Shown in the per-submission detail view,
not in the main leaderboard table.
**Consent.** `"agree_to_publish": true` in `meta.json` is your consent
to publish the resulting row on the public leaderboard.
"""
)
# OAuth gate. The user must log in via the HF button before
# the Submit button becomes interactive; the row gets the
# canonical `hf_username` from `gr.OAuthProfile.username`
# (not a free-text claim in meta.json). README front-matter
# already carries `hf_oauth: true` so HF's OAuth integration
# is wired up at the Space level.
login_btn = gr.LoginButton()
zip_in = gr.File(label="Submission ZIP", file_types=[".zip"])
# Starts disabled; the `blocks.load` handler below flips it
# to interactive when an OAuthProfile is present.
submit_btn = gr.Button("Submit", variant="primary", interactive=False)
# No static markdown output: handle_submit surfaces every
# status update via gr.Info / gr.Error toasts. The handler
# also reads `gr.OAuthProfile` implicitly via its parameter
# type annotation (Gradio's dependency-injection convention).
submit_btn.click(fn=handle_submit, inputs=[zip_in])
with gr.Tab("About"):
gr.Markdown(ABOUT_MD)
# gradio_leaderboard.Leaderboard handles its own update path
# cleanly; bind a Timer to push fresh dataframes every 10 seconds.
# Single tick runs `load_leaderboard_split` once and pushes the
# tuple's two halves into the validated / unvalidated widgets.
auto_refresh_timer = gr.Timer(10)
auto_refresh_timer.tick(
fn=load_leaderboard_split,
outputs=[validated_view, unvalidated_view],
)
# On page load, read the visitor's OAuth profile (None if not
# logged in) and flip the Submit button's interactivity. Runs once
# per page load; LoginButton clicks also re-trigger this through
# Gradio's auth-event plumbing.
blocks.load(fn=_enable_submit_when_logged_in, outputs=submit_btn)
# Mount Gradio under a FastAPI parent so the custom proxy route
# above lives at the same origin as the UI. Direct routes on `app`
# get checked before the Gradio sub-app, so `/reports/<sid>.html`
# never gets shadowed.
app = FastAPI()
app.add_api_route(
"/reports/{submission_id}.html",
serve_report,
methods=["GET"],
)
app = gr.mount_gradio_app(app, blocks, path="/")
if __name__ == "__main__":
host = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0")
port = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
uvicorn.run(app, host=host, port=port)
|