Spaces:
Sleeping
Sleeping
File size: 18,241 Bytes
dd76363 8992f5f 10e9b7d 8992f5f 10e9b7d 3c4371f 8992f5f dd76363 03e4dc0 5acb686 8992f5f dd76363 8992f5f f2517fa dd76363 8992f5f dd76363 8992f5f dd76363 8992f5f 10e9b7d 8992f5f f2517fa dd76363 f2517fa dd76363 8992f5f dd76363 8992f5f dd76363 f2517fa dd76363 31243f4 dd76363 f2517fa dd76363 f2517fa dd76363 5acb686 f2517fa 03e4dc0 f2517fa 03e4dc0 f2517fa dd76363 f2517fa dd76363 8992f5f dd76363 8992f5f f2517fa dd76363 f2517fa dd76363 eccf8e4 dd76363 f2517fa dd76363 f2517fa dd76363 f2517fa dd76363 f2517fa dd76363 f2517fa 03e4dc0 f2517fa 03e4dc0 f2517fa dd76363 f2517fa dd76363 f2517fa dd76363 f2517fa dd76363 8992f5f dd76363 8992f5f dd76363 e80aab9 dd76363 f2517fa 8992f5f dd76363 e80aab9 dd76363 8992f5f dd76363 f2517fa dd76363 f2517fa dd76363 f2517fa 8992f5f dd76363 8992f5f dd76363 8992f5f dd76363 f2517fa dd76363 8992f5f dd76363 f2517fa dd76363 f2517fa dd76363 8992f5f dd76363 5acb686 dd76363 5acb686 dd76363 8992f5f dd76363 f2517fa 8992f5f dd76363 8992f5f dd76363 8992f5f dd76363 f2517fa dd76363 f2517fa dd76363 8992f5f | 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 | """GAIA course submission UI: safe one-question OpenAI tests and explicit submit."""
from __future__ import annotations
import json
import os
import re
import threading
from pathlib import Path
import gradio as gr
import pandas as pd
import requests
import spaces
from agent import (AttachmentUnavailable, UnsupportedMedia, UnresolvedAnswer, BudgetError, BudgetLedger, GaiaAgent, MODEL, SCORING_API,
STORAGE, MAX_ESTIMATED_TOTAL_USD, MAX_ESTIMATED_QUESTION_USD, MAX_MODEL_CALLS_PER_QUESTION)
ANSWERS_DIR = STORAGE / "answers"
ANSWERS_DIR.mkdir(parents=True, exist_ok=True)
# Gradio event queue has concurrency 1; the lock guards accidental parallel requests.
RUN_LOCK = threading.Lock()
FILES_LOCK = threading.RLock()
COLS = ["#", "Question", "Cached answer", "Status"]
def _skips_path(username: str) -> Path:
return ANSWERS_DIR / (username + "_skips.json")
def _load_skips(username: str) -> dict:
with FILES_LOCK:
try:
data = json.loads(_skips_path(username).read_text())
return data if isinstance(data, dict) else {}
except (FileNotFoundError, ValueError, OSError):
return {}
def _save_skips(username: str, data: dict) -> None:
with FILES_LOCK:
dest = _skips_path(username)
tmp = dest.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
tmp.replace(dest)
def _user(profile) -> str:
username = getattr(profile, "username", "") if profile else ""
if not username:
raise RuntimeError("Sign in with Hugging Face first")
return re.sub(r"[^A-Za-z0-9_-]", "", username)
def _path(username: str) -> Path:
return ANSWERS_DIR / (username + ".json")
def _load(username: str) -> dict:
with FILES_LOCK:
try:
raw = json.loads(_path(username).read_text())
return raw if isinstance(raw, dict) else {}
except (FileNotFoundError, ValueError, OSError):
return {}
def _save(username: str, data: dict) -> None:
with FILES_LOCK:
dest = _path(username)
tmp = dest.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
tmp.replace(dest)
def _questions() -> list:
r = requests.get(f"{SCORING_API}/questions", timeout=25)
r.raise_for_status()
data = r.json()
if not isinstance(data, list) or not data or any(not q.get("task_id") or not q.get("question") for q in data):
raise RuntimeError("Scoring API returned invalid question data")
return data
def _table(questions: list, answers: dict, skips: dict | None = None) -> pd.DataFrame:
skips = skips or {}
return pd.DataFrame([{"#":i, "Question":q["question"],
"Cached answer":answers.get(q["task_id"], ""),
"Status": ("Cached" if q["task_id"] in answers
else "Skipped: " + skips[q["task_id"]] if q["task_id"] in skips
else "Not run")}
for i,q in enumerate(questions,1)], columns=COLS)
def _usage() -> str:
return (f"Estimated OpenAI usage by this Space: ${BudgetLedger.total():.4f} / "
f"${MAX_ESTIMATED_TOTAL_USD:.2f} initial budget. "
"Not a billing cap; verify actual usage in OpenAI Platform.")
def check_setup(profile: gr.OAuthProfile | None):
"""Free checks: does not create an OpenAI client or make a model request."""
try:
user = _user(profile)
if not os.environ.get("OPENAI_API_KEY"):
raise RuntimeError("OPENAI_API_KEY missing: add it in Settings > Secrets")
questions = _questions()
return (f"Setup OK: {len(questions)} GAIA questions found, OpenAI key is configured, "
f"HF username: {user}. NO paid OpenAI call was made.\n{_usage()}",
_table(questions, _load(user), _load_skips(user)))
except Exception as e:
return f"Setup check: {e} (no paid OpenAI call made)", pd.DataFrame(columns=COLS)
def solve_one(n, profile: gr.OAuthProfile | None):
try:
user = _user(profile)
if not os.environ.get("OPENAI_API_KEY"):
raise RuntimeError("OPENAI_API_KEY secret missing")
questions = _questions()
num = int(n)
if not (1 <= num <= len(questions)):
raise ValueError(f"Question number must be between 1 and {len(questions)}")
answers = _load(user)
q = questions[num-1]
if q["task_id"] in answers or q["task_id"] in _load_skips(user):
yield f"Question {num} is cached or marked skipped. Use Clear selected Q# to retry.\n{_usage()}", _table(questions,answers, _load_skips(user))
return
if not RUN_LOCK.acquire(blocking=False):
yield "Another evaluation is running. No OpenAI request was sent.", _table(questions,answers, _load_skips(user))
return
try:
yield f"Solving question {num} with {MODEL} (maximum {MAX_MODEL_CALLS_PER_QUESTION} model calls). Not submitting yet...", _table(questions,answers, _load_skips(user))
try:
answer, calls = GaiaAgent().run(q["question"], q["task_id"], q.get("file_name") or "")
except (AttachmentUnavailable, UnsupportedMedia, UnresolvedAnswer) as exc:
skips = _load_skips(user)
skips[q["task_id"]] = str(exc)[:200]
_save_skips(user, skips)
yield f"Unresolved/Skipped Q{num}: {exc}\n{_usage()}", _table(questions,answers,skips)
return
answers[q["task_id"]] = answer
_save(user,answers)
yield (f"Question {num} saved using {calls} OpenAI calls. "
f"{len(answers)}/{len(questions)} cached.\n{_usage()}"), _table(questions,answers, _load_skips(user))
finally:
RUN_LOCK.release()
except Exception as exc:
yield f"Stopped: {type(exc).__name__}: {str(exc)[:450]}\n{_usage()}", pd.DataFrame(columns=COLS)
def run_batch(approved: bool, max_questions: int, profile: gr.OAuthProfile | None):
"""Run up to N billable questions, skip missing attachments without stalling.
No automatic model retries; all successful answers are saved immediately.
"""
if not approved:
yield "Tick the OpenAI API consent box first. No paid requests made.", pd.DataFrame(columns=COLS)
return
try:
user = _user(profile)
if not os.environ.get("OPENAI_API_KEY"):
raise RuntimeError("OPENAI_API_KEY secret missing")
limit = int(max_questions)
if limit not in (3, 5, 10, 20):
raise ValueError("Choose 3, 5, 10 or 20")
questions = _questions()
answers, skips = _load(user), _load_skips(user)
if not RUN_LOCK.acquire(blocking=False):
yield "Another evaluation is running; no paid requests made.", _table(questions,answers,skips)
return
try:
paid_questions = 0
newly_skipped = 0
for i,q in enumerate(questions,1):
task_id = q["task_id"]
if task_id in answers or task_id in skips:
continue
if paid_questions >= limit:
break
file_name = (q.get("file_name") or "").lower()
if file_name.endswith((".m4a", ".mp3", ".wav", ".mp4", ".webm")):
skips[task_id] = "Audio/video attachment unsupported (no OpenAI charge)"
_save_skips(user, skips)
newly_skipped += 1
yield f"Skipped unsupported audio/video Q{i}.\n{_usage()}", _table(questions,answers,skips)
continue
yield (f"Starting Q{i}. {paid_questions}/{limit} supported questions processed in this batch. "
f"No answers are submitted automatically.\n{_usage()}"), _table(questions,answers,skips)
try:
answer,calls = GaiaAgent().run(q["question"], task_id, q.get("file_name") or "")
except (AttachmentUnavailable, UnsupportedMedia, UnresolvedAnswer) as exc:
skips[task_id] = str(exc)[:200]
_save_skips(user, skips)
newly_skipped += 1
yield f"Unresolved/Skipped Q{i}: {exc}\n{_usage()}", _table(questions,answers,skips)
continue
except BudgetError as exc:
yield f"Budget guard stopped the batch before Q{i}: {exc}\n{_usage()}", _table(questions,answers,skips)
return
except Exception as exc:
yield (f"Stopped at Q{i}: {type(exc).__name__}: {str(exc)[:300]}. "
f"Already saved answers remain intact.\n{_usage()}"), _table(questions,answers,skips)
return
answers[task_id] = answer
_save(user,answers)
paid_questions += 1
yield (f"Saved Q{i} ({calls} model calls). {len(answers)}/{len(questions)} answers saved; "
f"{len(skips)} questions skipped.\n{_usage()}"), _table(questions,answers,skips)
yield (f"Batch finished. Saved {len(answers)}/{len(questions)}; skipped {len(skips)} "
f"({newly_skipped} new skips in this batch). Download a backup now.\n{_usage()}"), _table(questions,answers,skips)
finally:
RUN_LOCK.release()
except Exception as exc:
yield f"Stopped: {type(exc).__name__}: {str(exc)[:400]}", pd.DataFrame(columns=COLS)
def view_cached(profile: gr.OAuthProfile | None):
try:
user = _user(profile)
questions = _questions()
answers = _load(user)
return f"{len(answers)}/{len(questions)} saved.\n{_usage()}",_table(questions,answers, _load_skips(user))
except Exception as exc:
return f"Error: {exc}", pd.DataFrame(columns=COLS)
def clear_one(n, confirmed: bool, profile: gr.OAuthProfile | None):
"""Explicitly clear exactly one answer so it may be rerun if needed."""
try:
user = _user(profile)
questions = _questions()
if not confirmed:
return "Check confirmation before clearing an answer. No paid requests made.", _table(questions,_load(user), _load_skips(user))
num = int(n)
if not (1 <= num <= len(questions)):
raise ValueError("Question number out of range")
answers = _load(user)
task_id = questions[num-1]["task_id"]
removed = answers.pop(task_id, None)
skips = _load_skips(user)
skipped = skips.pop(task_id, None)
_save(user, answers)
_save_skips(user, skips)
return (f"Cleared Q{num} cached answer/skip. Now eligible to retry. No API charge."
if removed is not None or skipped is not None
else f"Q{num} was neither cached nor skipped. No changes."), _table(questions,answers,skips)
except Exception as exc:
return f"Clear failed: {exc}", pd.DataFrame(columns=COLS)
def backup(profile: gr.OAuthProfile | None):
"""Download after each few questions to survive a restart on nonpersistent Spaces."""
try:
user = _user(profile)
data = _load(user)
if not data:
return None, "No cached answers to back up"
source = _path(user)
return str(source), f"Backup ready ({len(data)} answers). Save it to your computer."
except Exception as exc:
return None, f"Backup failed: {exc}"
def restore(uploaded_path, profile: gr.OAuthProfile | None):
try:
user = _user(profile)
if not uploaded_path:
raise ValueError("Upload a previously downloaded JSON backup")
questions = _questions()
allowed = {q["task_id"] for q in questions}
path = Path(uploaded_path)
if path.stat().st_size > 500000:
raise ValueError("Backup file too large")
incoming = json.loads(path.read_text())
if not isinstance(incoming, dict):
raise ValueError("Backup must be a JSON object")
cleaned = {k:v for k,v in incoming.items() if k in allowed and isinstance(v,str) and len(v) < 2000}
answers = _load(user)
for k,v in cleaned.items():
answers.setdefault(k,v) # Never overwrite an existing answer silently
_save(user,answers)
return f"Restored {len(cleaned)} candidate answers; {len(answers)}/{len(questions)} cached. No API charges.", _table(questions,answers, _load_skips(user))
except Exception as exc:
return f"Restore failed: {exc}", pd.DataFrame(columns=COLS)
def submit(allow_blanks: bool, profile: gr.OAuthProfile | None):
"""Explicit action; submission itself uses the free course scoring API."""
try:
user = _user(profile)
questions = _questions()
answers = _load(user)
missing = [i for i,q in enumerate(questions,1) if q["task_id"] not in answers]
if missing and not allow_blanks:
return (f"Not submitted: missing Q{missing}. To submit now with empty answers "
"for unsupported questions, tick the confirmation checkbox. No API spend."), _table(questions,answers, _load_skips(user))
space_id = os.environ.get("SPACE_ID")
if not space_id:
raise RuntimeError("SPACE_ID not set. Must submit from your public Hugging Face Space")
payload = {"username":user,
"agent_code":f"https://huggingface.co/spaces/{space_id}/tree/main",
"answers":[{"task_id":q["task_id"],"submitted_answer":answers.get(q["task_id"], "")} for q in questions]}
r = requests.post(f"{SCORING_API}/submit", json=payload, timeout=60)
r.raise_for_status()
score = r.json()
return (f"Official submission: score {score.get('score', 'n/a')}%, "
f"{score.get('correct_count','?')}/{score.get('total_attempted','?')} correct "
f"({len(missing)} unanswered submitted as blank). "
f"{str(score.get('message',''))[:400]}"), _table(questions,answers, _load_skips(user))
except Exception as exc:
return f"Submission failed: {exc}", pd.DataFrame(columns=COLS)
# ZeroGPU hosting requires a registered @spaces.GPU Gradio handler. It is deliberately
# never clicked; OpenAI requests always run on CPU via remote API.
@spaces.GPU(duration=1)
def _unused_zero_gpu_probe():
return "registered"
with gr.Blocks(title="GAIA Agent - budget-safe OpenAI") as demo:
gr.Markdown("# 🧠 GAIA Agent — GPT-6 Sol precision edition")
gr.Markdown(f"**Model:** `{MODEL}` · **Initial local spending guard:** ${MAX_ESTIMATED_TOTAL_USD:.2f} "
f"· **Per-question estimate guard:** ${MAX_ESTIMATED_QUESTION_USD:.2f} · "
f"**Maximum {MAX_MODEL_CALLS_PER_QUESTION} model requests/question**. No automatic retries.\n\n"
"Estimated guards are **not** hard limits on OpenAI billing. Check your actual usage dashboard.")
gr.LoginButton()
invisible = gr.Button("ZeroGPU startup registration",visible=False)
invisible_result = gr.Textbox(visible=False)
invisible.click(_unused_zero_gpu_probe,outputs=[invisible_result])
setup = gr.Button("1. Check setup (FREE: no OpenAI call)")
with gr.Row():
number = gr.Number(label="Question number", value=1, minimum=1, maximum=20, precision=0)
test = gr.Button("2. Test ONE question", variant="primary")
clear_confirm = gr.Checkbox(label="I confirm clearing the selected cached answer (free)", value=False)
clear_button = gr.Button("Clear selected Q# answer to allow a retry (free)")
consent = gr.Checkbox(label="I understand this makes paid OpenAI API requests", value=False)
batch_size = gr.Dropdown([3, 5, 10, 20], value=3, label="Maximum supported questions to solve in one batch", interactive=True)
batch = gr.Button("3. Run selected batch (OpenAI API)")
with gr.Row():
see = gr.Button("Show saved answers")
send = gr.Button("Submit answers to GAIA scoring (free)")
allow_blanks = gr.Checkbox(label="Allow official submission with blank answers for missing questions", value=False)
status = gr.Textbox(label="Progress and cost estimate", lines=5, interactive=False)
table = gr.Dataframe(label="Questions and cached answers", wrap=True, interactive=False)
gr.Markdown("### Backup (recommended)")
with gr.Row():
export = gr.Button("Download answer backup")
exported = gr.File(label="Download your saved JSON answers", interactive=False)
backup_status = gr.Textbox(label="Backup status", interactive=False)
with gr.Row():
uploaded = gr.File(label="Upload an earlier answer backup (.json)", file_types=[".json"], type="filepath")
restore_button = gr.Button("Restore answers (free)")
gr.Markdown("**Before updating your Space or running a large batch:** click Download answer backup. "
"Unavailable GAIA attachments (HTTP 404) are marked Skipped, not answered blindly. "
"The batch stops at any model/API error or local budget limit. "
"Your HF Space remains PUBLIC, but your API key must be stored only as a **Secret**. "
"Audio transcription is disabled by default to avoid untracked charges. "
"The answer cache and cost ledger persist only if /data persistent storage is mounted; otherwise export backups regularly.")
setup.click(check_setup, outputs=[status,table])
test.click(solve_one,inputs=[number],outputs=[status,table])
clear_button.click(clear_one,inputs=[number,clear_confirm],outputs=[status,table])
batch.click(run_batch,inputs=[consent,batch_size],outputs=[status,table])
see.click(view_cached,outputs=[status,table])
send.click(submit,inputs=[allow_blanks],outputs=[status,table])
export.click(backup,outputs=[exported,backup_status])
restore_button.click(restore,inputs=[uploaded],outputs=[status,table])
if __name__ == "__main__":
demo.queue(default_concurrency_limit=1).launch()
|