MTahaB commited on
Commit
f2517fa
·
verified ·
1 Parent(s): dd76363

404 problem

Browse files
Files changed (3) hide show
  1. README.md +8 -5
  2. agent.py +19 -4
  3. app.py +107 -39
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: GAIA Agent - OpenAI Safe Edition
3
  emoji: 🧠
4
  colorFrom: blue
5
  colorTo: purple
@@ -24,8 +24,10 @@ This is an **original student agent**, not an official solution. It uses **GPT-5
24
  4. Allow the Space to rebuild. In **App**, log in with Hugging Face.
25
  5. Click **1. Check setup (FREE)**. This checks only the GAIA scoring API and presence of your key; it does not make an OpenAI model call.
26
  6. Click **2. Test ONE question**. Check the cached answer and the cost estimate, then check *actual* API spending on https://platform.openai.com/usage .
27
- 7. Only after the first test looks good, tick the confirmation checkbox and use **3. Run next 3 unanswered questions**. Audio/video attachment questions are skipped by this batch because they are not yet supported. Back up answers with the download button after each few questions.
28
- 8. Once you have as many answers as possible, press **Submit answers to GAIA scoring**. You can explicitly opt to submit blank answers for remaining unsupported questions. Scoring occurs only upon this click, and blank answers count as wrong. The Unit 4 target is 30% (6 correct out of 20).
 
 
29
 
30
  **Only one model is enabled:** `gpt-5.4-mini`, list prices checked October 28, 2026: **$0.75/M input tokens and $4.50/M output tokens**. The OpenAI API key is NOT the same as a ChatGPT subscription.
31
 
@@ -35,7 +37,7 @@ This is an **original student agent**, not an official solution. It uses **GPT-5
35
  - Maximum **5 paid model requests/question**, 2,200 completion tokens/request, **8 locally run tools/question**. No implicit retries; OpenAI SDK retries disabled.
36
  - Before each request, the app temporarily reserves **$0.07** against the estimated budget; after a successful call, it reconciles using the model's reported input/output token usage. Failed calls keep their reservation to avoid undercounting. This is a conservative approximation, not an absolute per-call billing limit.
37
  - This guard **cannot enforce a hard limit on your OpenAI account**. OpenAI bills based on actual requests; actual costs could differ because of multimodal pricing, missing usage metadata, failures, deployment restarts, other apps/keys or changes in price. Set a project budget/alerts if available on the OpenAI dashboard and monitor its actual usage. Disable auto-recharge if you don't want it.
38
- - The application does **not** automatically run all 20 questions and does not automatically buy or renew credits.
39
  - Answer and local estimated spend files use **`/data` only when persistence is available**, otherwise temporary `/tmp` storage. On a Space restart, the latter may reset! Use **Download answer backup** and restore it if required. The budget estimate also resets in /tmp, so always check provider usage separately.
40
  - On purpose, **audio/video file transcription is disabled** to avoid extra, untracked transcription charges. Text, PDF, DOCX, spreadsheet and image tasks are partially supported. This means some GAIA tasks are unsupported until you choose to enable additional services.
41
  - For web search, the app uses `ddgs` via the Space itself, **not OpenAI paid web-search tools**. Search quality, page availability, YouTube captions, rate limits and exact-match accuracy are not guaranteed.
@@ -45,8 +47,9 @@ This is an **original student agent**, not an official solution. It uses **GPT-5
45
  - `429` / insufficient quota: look at your OpenAI project limits. No automatic retries.
46
  - `401` / wrong API key: verify that `OPENAI_API_KEY` is in **Secrets**.
47
  - `503`: temporary provider overload. Stop and decide whether a manual retry is worthwhile.
 
48
  - `Maximum model calls reached`: question is too complex for the conservative configuration. It is deliberately **not cached**. Decide whether to increase limits later.
49
  - Existing cached answers are **not re-run** accidentally; to retry one, select its question number, tick **I confirm clearing**, and click **Clear selected Q# answer**. This deletes the cached response without making any OpenAI API call. The next test will be billed normally.
50
  - Certificate and submission details: https://huggingface.co/learn/agents-course/unit4/hands-on
51
 
52
- This was syntax-checked and unit-tested using mocked (not billable) model responses; it has **not** been executed against your OpenAI API key or the live Hugging Face Space.
 
1
  ---
2
+ title: GAIA Agent - OpenAI 404-safe Batch Edition
3
  emoji: 🧠
4
  colorFrom: blue
5
  colorTo: purple
 
24
  4. Allow the Space to rebuild. In **App**, log in with Hugging Face.
25
  5. Click **1. Check setup (FREE)**. This checks only the GAIA scoring API and presence of your key; it does not make an OpenAI model call.
26
  6. Click **2. Test ONE question**. Check the cached answer and the cost estimate, then check *actual* API spending on https://platform.openai.com/usage .
27
+ 7. **BEFORE UPLOADING THIS UPDATE, download your existing answer backup** from the currently running Space. If it restarts, its /tmp answer cache can vanish. After deploying, restore the saved `.json` in the Backup section and click Show saved answers to verify your earlier answers survived.
28
+ 8. To answer more, tick the paid-request consent checkbox. In **Maximum supported questions to solve in one batch**, choose **3, 5, 10 or 20**; choose **20** only after checking actual API usage/balance. Press **3. Run selected batch (OpenAI API)**. Previously cached and previously skipped questions are not repeated. A missing GAIA attachment (`/files/{task_id}` HTTP 404/410) is marked skipped **without OpenAI charges**, and the agent continues with the next available question. Audio/video attachments are skipped because transcription is disabled.
29
+ 9. After the batch, click **Download answer backup** again. The batch stops on OpenAI API errors and the best-effort local budget guard. It does NOT submit to the leaderboard automatically.
30
+ 10. Once you have as many answers as possible, press **Submit answers to GAIA scoring**. You can explicitly opt to submit blank answers for remaining unsupported questions. Scoring occurs only upon this click, and blank answers count as wrong. The Unit 4 target is 30% (6 correct out of 20).
31
 
32
  **Only one model is enabled:** `gpt-5.4-mini`, list prices checked October 28, 2026: **$0.75/M input tokens and $4.50/M output tokens**. The OpenAI API key is NOT the same as a ChatGPT subscription.
33
 
 
37
  - Maximum **5 paid model requests/question**, 2,200 completion tokens/request, **8 locally run tools/question**. No implicit retries; OpenAI SDK retries disabled.
38
  - Before each request, the app temporarily reserves **$0.07** against the estimated budget; after a successful call, it reconciles using the model's reported input/output token usage. Failed calls keep their reservation to avoid undercounting. This is a conservative approximation, not an absolute per-call billing limit.
39
  - This guard **cannot enforce a hard limit on your OpenAI account**. OpenAI bills based on actual requests; actual costs could differ because of multimodal pricing, missing usage metadata, failures, deployment restarts, other apps/keys or changes in price. Set a project budget/alerts if available on the OpenAI dashboard and monitor its actual usage. Disable auto-recharge if you don't want it.
40
+ - The application **can** run up to 20 eligible questions in one explicitly authorized batch, but defaults to 3 and does not automatically buy or renew credits.
41
  - Answer and local estimated spend files use **`/data` only when persistence is available**, otherwise temporary `/tmp` storage. On a Space restart, the latter may reset! Use **Download answer backup** and restore it if required. The budget estimate also resets in /tmp, so always check provider usage separately.
42
  - On purpose, **audio/video file transcription is disabled** to avoid extra, untracked transcription charges. Text, PDF, DOCX, spreadsheet and image tasks are partially supported. This means some GAIA tasks are unsupported until you choose to enable additional services.
43
  - For web search, the app uses `ddgs` via the Space itself, **not OpenAI paid web-search tools**. Search quality, page availability, YouTube captions, rate limits and exact-match accuracy are not guaranteed.
 
47
  - `429` / insufficient quota: look at your OpenAI project limits. No automatic retries.
48
  - `401` / wrong API key: verify that `OPENAI_API_KEY` is in **Secrets**.
49
  - `503`: temporary provider overload. Stop and decide whether a manual retry is worthwhile.
50
+ - `404` from the official scoring `GET /files/{task_id}`: known server-side bug (https://github.com/huggingface/agents-course/issues/624). The app skips that task before sending an OpenAI request, and continues. Do not invent an answer to the missing attachment.
51
  - `Maximum model calls reached`: question is too complex for the conservative configuration. It is deliberately **not cached**. Decide whether to increase limits later.
52
  - Existing cached answers are **not re-run** accidentally; to retry one, select its question number, tick **I confirm clearing**, and click **Clear selected Q# answer**. This deletes the cached response without making any OpenAI API call. The next test will be billed normally.
53
  - Certificate and submission details: https://huggingface.co/learn/agents-course/unit4/hands-on
54
 
55
+ The current version was syntax-checked and offline-tested for 404 skip, cache preservation, continuation past unavailable tasks, no model calls on missing attachments, and the scoring payload. It has **not** been executed against your OpenAI key or deployed live on your Space.
agent.py CHANGED
@@ -61,6 +61,16 @@ class BudgetError(RuntimeError):
61
  pass
62
 
63
 
 
 
 
 
 
 
 
 
 
 
64
  class BudgetLedger:
65
  """Best-effort estimated budget, not a provider-side hard billing limit.
66
 
@@ -310,14 +320,19 @@ class GaiaAgent:
310
  ledger = BudgetLedger()
311
  context = ""
312
  if file_name:
 
 
 
 
313
  r = requests.get(f"{SCORING_API}/files/{task_id}", timeout=25)
 
 
 
 
314
  r.raise_for_status()
315
  content = r.content
316
  if len(content) > MAX_ATTACHMENT_BYTES:
317
- raise RuntimeError("Attachment too large: no model calls were made")
318
- mime = mimetypes.guess_type(file_name)[0] or ""
319
- if mime.startswith("audio/") or file_name.lower().endswith((".m4a", ".mp3", ".wav", ".mp4", ".webm")):
320
- raise RuntimeError("Audio/video attachment: transcription is disabled to avoid untracked API charges. Skip for now.")
321
  if mime.startswith("image/"):
322
  context = "IMAGE DESCRIPTION: " + self._describe_image(ledger, question, file_name, content)
323
  else:
 
61
  pass
62
 
63
 
64
+ class AttachmentUnavailable(RuntimeError):
65
+ """An attachment could not be retrieved from the course scoring server."""
66
+ pass
67
+
68
+
69
+ class UnsupportedMedia(RuntimeError):
70
+ """Audio/video needs a separate non-free transcription implementation."""
71
+ pass
72
+
73
+
74
  class BudgetLedger:
75
  """Best-effort estimated budget, not a provider-side hard billing limit.
76
 
 
320
  ledger = BudgetLedger()
321
  context = ""
322
  if file_name:
323
+ # Detect unsupported media BEFORE downloading or invoking the model.
324
+ mime = mimetypes.guess_type(file_name)[0] or ""
325
+ if mime.startswith(("audio/", "video/")) or file_name.lower().endswith((".m4a", ".mp3", ".wav", ".mp4", ".webm")):
326
+ raise UnsupportedMedia("Audio/video attachment requires separate transcription; skipped without any OpenAI call")
327
  r = requests.get(f"{SCORING_API}/files/{task_id}", timeout=25)
328
+ if r.status_code in (404, 410):
329
+ # Known HF unit 4 bug: /files/{task_id} returns 404 despite file_name.
330
+ # No OpenAI model call has been made for this question.
331
+ raise AttachmentUnavailable(f"GAIA attachment unavailable (HTTP {r.status_code}) for task {task_id}; skipped without any OpenAI call")
332
  r.raise_for_status()
333
  content = r.content
334
  if len(content) > MAX_ATTACHMENT_BYTES:
335
+ raise AttachmentUnavailable("Attachment too large to process safely; skipped without any OpenAI call")
 
 
 
336
  if mime.startswith("image/"):
337
  context = "IMAGE DESCRIPTION: " + self._describe_image(ledger, question, file_name, content)
338
  else:
app.py CHANGED
@@ -12,7 +12,7 @@ import pandas as pd
12
  import requests
13
  import spaces
14
 
15
- from agent import (BudgetError, BudgetLedger, GaiaAgent, MODEL, SCORING_API,
16
  STORAGE, MAX_ESTIMATED_TOTAL_USD, MAX_ESTIMATED_QUESTION_USD)
17
 
18
  ANSWERS_DIR = STORAGE / "answers"
@@ -23,6 +23,28 @@ FILES_LOCK = threading.RLock()
23
  COLS = ["#", "Question", "Cached answer", "Status"]
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  def _user(profile) -> str:
27
  username = getattr(profile, "username", "") if profile else ""
28
  if not username:
@@ -60,10 +82,13 @@ def _questions() -> list:
60
  return data
61
 
62
 
63
- def _table(questions: list, answers: dict) -> pd.DataFrame:
 
64
  return pd.DataFrame([{"#":i, "Question":q["question"],
65
  "Cached answer":answers.get(q["task_id"], ""),
66
- "Status":"Cached" if q["task_id"] in answers else "Not run"}
 
 
67
  for i,q in enumerate(questions,1)], columns=COLS)
68
 
69
 
@@ -82,7 +107,7 @@ def check_setup(profile: gr.OAuthProfile | None):
82
  questions = _questions()
83
  return (f"Setup OK: {len(questions)} GAIA questions found, OpenAI key is configured, "
84
  f"HF username: {user}. NO paid OpenAI call was made.\n{_usage()}",
85
- _table(questions, _load(user)))
86
  except Exception as e:
87
  return f"Setup check: {e} (no paid OpenAI call made)", pd.DataFrame(columns=COLS)
88
 
@@ -98,58 +123,92 @@ def solve_one(n, profile: gr.OAuthProfile | None):
98
  raise ValueError(f"Question number must be between 1 and {len(questions)}")
99
  answers = _load(user)
100
  q = questions[num-1]
101
- if q["task_id"] in answers:
102
- yield f"Question {num} is already cached. To retry, clear its answer first (see README).\n{_usage()}", _table(questions,answers)
103
  return
104
  if not RUN_LOCK.acquire(blocking=False):
105
- yield "Another evaluation is running. No OpenAI request was sent.", _table(questions,answers)
106
  return
107
  try:
108
- yield f"Solving question {num} with {MODEL} (maximum 5 model calls). Not submitting yet...", _table(questions,answers)
109
- answer, calls = GaiaAgent().run(q["question"], q["task_id"], q.get("file_name") or "")
 
 
 
 
 
 
 
110
  answers[q["task_id"]] = answer
111
  _save(user,answers)
112
  yield (f"Question {num} saved using {calls} OpenAI calls. "
113
- f"{len(answers)}/{len(questions)} cached.\n{_usage()}"), _table(questions,answers)
114
  finally:
115
  RUN_LOCK.release()
116
  except Exception as exc:
117
  yield f"Stopped: {type(exc).__name__}: {str(exc)[:450]}\n{_usage()}", pd.DataFrame(columns=COLS)
118
 
119
 
120
- def next_three(approved: bool, profile: gr.OAuthProfile | None):
 
 
 
 
121
  if not approved:
122
- yield "Check the consent box before running a paid batch. No paid requests made.", pd.DataFrame(columns=COLS)
123
  return
124
  try:
125
  user = _user(profile)
126
  if not os.environ.get("OPENAI_API_KEY"):
127
  raise RuntimeError("OPENAI_API_KEY secret missing")
 
 
 
128
  questions = _questions()
129
- answers = _load(user)
130
- todo = [(i,q) for i,q in enumerate(questions,1)
131
- if q["task_id"] not in answers and not (q.get("file_name") or "").lower().endswith(
132
- (".m4a", ".mp3", ".wav", ".mp4", ".webm"))][:3]
133
- if not todo:
134
- yield "No remaining text/image-supported questions. Check audio/video tasks separately before submitting.", _table(questions,answers)
135
- return
136
  if not RUN_LOCK.acquire(blocking=False):
137
- yield "Another evaluation is running; no paid requests made.", _table(questions,answers)
138
  return
139
  try:
140
- for i,q in todo:
141
- yield f"Solving Q{i} of {len(questions)}. Up to 3 questions in this batch.\n{_usage()}",_table(questions,answers)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  try:
143
- answer,calls = GaiaAgent().run(q["question"], q["task_id"], q.get("file_name") or "")
144
- answers[q["task_id"]] = answer
145
- _save(user,answers)
 
 
 
 
146
  except BudgetError as exc:
147
- yield f"Stopped to protect your budget: {exc}\n{_usage()}", _table(questions,answers)
148
  return
149
  except Exception as exc:
150
- yield f"Stopped at Q{i}: {type(exc).__name__}: {str(exc)[:300]}. Fix before proceeding.\n{_usage()}", _table(questions,answers)
 
151
  return
152
- yield f"Batch complete. {len(answers)}/{len(questions)} cached.\n{_usage()}", _table(questions,answers)
 
 
 
 
 
 
153
  finally:
154
  RUN_LOCK.release()
155
  except Exception as exc:
@@ -161,7 +220,7 @@ def view_cached(profile: gr.OAuthProfile | None):
161
  user = _user(profile)
162
  questions = _questions()
163
  answers = _load(user)
164
- return f"{len(answers)}/{len(questions)} saved.\n{_usage()}",_table(questions,answers)
165
  except Exception as exc:
166
  return f"Error: {exc}", pd.DataFrame(columns=COLS)
167
 
@@ -172,15 +231,20 @@ def clear_one(n, confirmed: bool, profile: gr.OAuthProfile | None):
172
  user = _user(profile)
173
  questions = _questions()
174
  if not confirmed:
175
- return "Check confirmation before clearing an answer. No paid requests made.", _table(questions,_load(user))
176
  num = int(n)
177
  if not (1 <= num <= len(questions)):
178
  raise ValueError("Question number out of range")
179
  answers = _load(user)
180
- removed = answers.pop(questions[num-1]["task_id"], None)
 
 
 
181
  _save(user, answers)
182
- return (f"Cleared Q{num}; you can test it again. No OpenAI request made." if removed is not None
183
- else f"Q{num} was not cached. No changes."), _table(questions,answers)
 
 
184
  except Exception as exc:
185
  return f"Clear failed: {exc}", pd.DataFrame(columns=COLS)
186
 
@@ -216,7 +280,7 @@ def restore(uploaded_path, profile: gr.OAuthProfile | None):
216
  for k,v in cleaned.items():
217
  answers.setdefault(k,v) # Never overwrite an existing answer silently
218
  _save(user,answers)
219
- return f"Restored {len(cleaned)} candidate answers; {len(answers)}/{len(questions)} cached. No API charges.", _table(questions,answers)
220
  except Exception as exc:
221
  return f"Restore failed: {exc}", pd.DataFrame(columns=COLS)
222
 
@@ -230,7 +294,7 @@ def submit(allow_blanks: bool, profile: gr.OAuthProfile | None):
230
  missing = [i for i,q in enumerate(questions,1) if q["task_id"] not in answers]
231
  if missing and not allow_blanks:
232
  return (f"Not submitted: missing Q{missing}. To submit now with empty answers "
233
- "for unsupported questions, tick the confirmation checkbox. No API spend."), _table(questions,answers)
234
  space_id = os.environ.get("SPACE_ID")
235
  if not space_id:
236
  raise RuntimeError("SPACE_ID not set. Must submit from your public Hugging Face Space")
@@ -243,7 +307,7 @@ def submit(allow_blanks: bool, profile: gr.OAuthProfile | None):
243
  return (f"Official submission: score {score.get('score', 'n/a')}%, "
244
  f"{score.get('correct_count','?')}/{score.get('total_attempted','?')} correct "
245
  f"({len(missing)} unanswered submitted as blank). "
246
- f"{str(score.get('message',''))[:400]}"), _table(questions,answers)
247
  except Exception as exc:
248
  return f"Submission failed: {exc}", pd.DataFrame(columns=COLS)
249
 
@@ -272,7 +336,8 @@ with gr.Blocks(title="GAIA Agent - budget-safe OpenAI") as demo:
272
  clear_confirm = gr.Checkbox(label="I confirm clearing the selected cached answer (free)", value=False)
273
  clear_button = gr.Button("Clear selected Q# answer to allow a retry (free)")
274
  consent = gr.Checkbox(label="I understand this makes paid OpenAI API requests", value=False)
275
- batch = gr.Button("3. Run next 3 unanswered questions (paid)")
 
276
  with gr.Row():
277
  see = gr.Button("Show saved answers")
278
  send = gr.Button("Submit answers to GAIA scoring (free)")
@@ -287,13 +352,16 @@ with gr.Blocks(title="GAIA Agent - budget-safe OpenAI") as demo:
287
  with gr.Row():
288
  uploaded = gr.File(label="Upload an earlier answer backup (.json)", file_types=[".json"], type="filepath")
289
  restore_button = gr.Button("Restore answers (free)")
290
- gr.Markdown("Your HF Space remains PUBLIC, but your API key must be stored only as a **Secret**. "
 
 
 
291
  "Audio transcription is disabled by default to avoid untracked charges. "
292
  "The answer cache and cost ledger persist only if /data persistent storage is mounted; otherwise export backups regularly.")
293
  setup.click(check_setup, outputs=[status,table])
294
  test.click(solve_one,inputs=[number],outputs=[status,table])
295
  clear_button.click(clear_one,inputs=[number,clear_confirm],outputs=[status,table])
296
- batch.click(next_three,inputs=[consent],outputs=[status,table])
297
  see.click(view_cached,outputs=[status,table])
298
  send.click(submit,inputs=[allow_blanks],outputs=[status,table])
299
  export.click(backup,outputs=[exported,backup_status])
 
12
  import requests
13
  import spaces
14
 
15
+ from agent import (AttachmentUnavailable, UnsupportedMedia, BudgetError, BudgetLedger, GaiaAgent, MODEL, SCORING_API,
16
  STORAGE, MAX_ESTIMATED_TOTAL_USD, MAX_ESTIMATED_QUESTION_USD)
17
 
18
  ANSWERS_DIR = STORAGE / "answers"
 
23
  COLS = ["#", "Question", "Cached answer", "Status"]
24
 
25
 
26
+ def _skips_path(username: str) -> Path:
27
+ return ANSWERS_DIR / (username + "_skips.json")
28
+
29
+
30
+ def _load_skips(username: str) -> dict:
31
+ with FILES_LOCK:
32
+ try:
33
+ data = json.loads(_skips_path(username).read_text())
34
+ return data if isinstance(data, dict) else {}
35
+ except (FileNotFoundError, ValueError, OSError):
36
+ return {}
37
+
38
+
39
+ def _save_skips(username: str, data: dict) -> None:
40
+ with FILES_LOCK:
41
+ dest = _skips_path(username)
42
+ tmp = dest.with_suffix(".tmp")
43
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
44
+ tmp.replace(dest)
45
+
46
+
47
+
48
  def _user(profile) -> str:
49
  username = getattr(profile, "username", "") if profile else ""
50
  if not username:
 
82
  return data
83
 
84
 
85
+ def _table(questions: list, answers: dict, skips: dict | None = None) -> pd.DataFrame:
86
+ skips = skips or {}
87
  return pd.DataFrame([{"#":i, "Question":q["question"],
88
  "Cached answer":answers.get(q["task_id"], ""),
89
+ "Status": ("Cached" if q["task_id"] in answers
90
+ else "Skipped: " + skips[q["task_id"]] if q["task_id"] in skips
91
+ else "Not run")}
92
  for i,q in enumerate(questions,1)], columns=COLS)
93
 
94
 
 
107
  questions = _questions()
108
  return (f"Setup OK: {len(questions)} GAIA questions found, OpenAI key is configured, "
109
  f"HF username: {user}. NO paid OpenAI call was made.\n{_usage()}",
110
+ _table(questions, _load(user), _load_skips(user)))
111
  except Exception as e:
112
  return f"Setup check: {e} (no paid OpenAI call made)", pd.DataFrame(columns=COLS)
113
 
 
123
  raise ValueError(f"Question number must be between 1 and {len(questions)}")
124
  answers = _load(user)
125
  q = questions[num-1]
126
+ if q["task_id"] in answers or q["task_id"] in _load_skips(user):
127
+ yield f"Question {num} is cached or marked skipped. Use Clear selected Q# to retry.\n{_usage()}", _table(questions,answers, _load_skips(user))
128
  return
129
  if not RUN_LOCK.acquire(blocking=False):
130
+ yield "Another evaluation is running. No OpenAI request was sent.", _table(questions,answers, _load_skips(user))
131
  return
132
  try:
133
+ yield f"Solving question {num} with {MODEL} (maximum 5 model calls). Not submitting yet...", _table(questions,answers, _load_skips(user))
134
+ try:
135
+ answer, calls = GaiaAgent().run(q["question"], q["task_id"], q.get("file_name") or "")
136
+ except (AttachmentUnavailable, UnsupportedMedia) as exc:
137
+ skips = _load_skips(user)
138
+ skips[q["task_id"]] = str(exc)[:200]
139
+ _save_skips(user, skips)
140
+ yield f"Skipped Q{num}: {exc}\n{_usage()}", _table(questions,answers,skips)
141
+ return
142
  answers[q["task_id"]] = answer
143
  _save(user,answers)
144
  yield (f"Question {num} saved using {calls} OpenAI calls. "
145
+ f"{len(answers)}/{len(questions)} cached.\n{_usage()}"), _table(questions,answers, _load_skips(user))
146
  finally:
147
  RUN_LOCK.release()
148
  except Exception as exc:
149
  yield f"Stopped: {type(exc).__name__}: {str(exc)[:450]}\n{_usage()}", pd.DataFrame(columns=COLS)
150
 
151
 
152
+ def run_batch(approved: bool, max_questions: int, profile: gr.OAuthProfile | None):
153
+ """Run up to N billable questions, skip missing attachments without stalling.
154
+
155
+ No automatic model retries; all successful answers are saved immediately.
156
+ """
157
  if not approved:
158
+ yield "Tick the OpenAI API consent box first. No paid requests made.", pd.DataFrame(columns=COLS)
159
  return
160
  try:
161
  user = _user(profile)
162
  if not os.environ.get("OPENAI_API_KEY"):
163
  raise RuntimeError("OPENAI_API_KEY secret missing")
164
+ limit = int(max_questions)
165
+ if limit not in (3, 5, 10, 20):
166
+ raise ValueError("Choose 3, 5, 10 or 20")
167
  questions = _questions()
168
+ answers, skips = _load(user), _load_skips(user)
 
 
 
 
 
 
169
  if not RUN_LOCK.acquire(blocking=False):
170
+ yield "Another evaluation is running; no paid requests made.", _table(questions,answers,skips)
171
  return
172
  try:
173
+ paid_questions = 0
174
+ newly_skipped = 0
175
+ for i,q in enumerate(questions,1):
176
+ task_id = q["task_id"]
177
+ if task_id in answers or task_id in skips:
178
+ continue
179
+ if paid_questions >= limit:
180
+ break
181
+ file_name = (q.get("file_name") or "").lower()
182
+ if file_name.endswith((".m4a", ".mp3", ".wav", ".mp4", ".webm")):
183
+ skips[task_id] = "Audio/video attachment unsupported (no OpenAI charge)"
184
+ _save_skips(user, skips)
185
+ newly_skipped += 1
186
+ yield f"Skipped unsupported audio/video Q{i}.\n{_usage()}", _table(questions,answers,skips)
187
+ continue
188
+ yield (f"Starting Q{i}. {paid_questions}/{limit} supported questions processed in this batch. "
189
+ f"No answers are submitted automatically.\n{_usage()}"), _table(questions,answers,skips)
190
  try:
191
+ answer,calls = GaiaAgent().run(q["question"], task_id, q.get("file_name") or "")
192
+ except (AttachmentUnavailable, UnsupportedMedia) as exc:
193
+ skips[task_id] = str(exc)[:200]
194
+ _save_skips(user, skips)
195
+ newly_skipped += 1
196
+ yield f"Skipped Q{i}: {exc}\n{_usage()}", _table(questions,answers,skips)
197
+ continue
198
  except BudgetError as exc:
199
+ yield f"Budget guard stopped the batch before Q{i}: {exc}\n{_usage()}", _table(questions,answers,skips)
200
  return
201
  except Exception as exc:
202
+ yield (f"Stopped at Q{i}: {type(exc).__name__}: {str(exc)[:300]}. "
203
+ f"Already saved answers remain intact.\n{_usage()}"), _table(questions,answers,skips)
204
  return
205
+ answers[task_id] = answer
206
+ _save(user,answers)
207
+ paid_questions += 1
208
+ yield (f"Saved Q{i} ({calls} model calls). {len(answers)}/{len(questions)} answers saved; "
209
+ f"{len(skips)} questions skipped.\n{_usage()}"), _table(questions,answers,skips)
210
+ yield (f"Batch finished. Saved {len(answers)}/{len(questions)}; skipped {len(skips)} "
211
+ f"({newly_skipped} new skips in this batch). Download a backup now.\n{_usage()}"), _table(questions,answers,skips)
212
  finally:
213
  RUN_LOCK.release()
214
  except Exception as exc:
 
220
  user = _user(profile)
221
  questions = _questions()
222
  answers = _load(user)
223
+ return f"{len(answers)}/{len(questions)} saved.\n{_usage()}",_table(questions,answers, _load_skips(user))
224
  except Exception as exc:
225
  return f"Error: {exc}", pd.DataFrame(columns=COLS)
226
 
 
231
  user = _user(profile)
232
  questions = _questions()
233
  if not confirmed:
234
+ return "Check confirmation before clearing an answer. No paid requests made.", _table(questions,_load(user), _load_skips(user))
235
  num = int(n)
236
  if not (1 <= num <= len(questions)):
237
  raise ValueError("Question number out of range")
238
  answers = _load(user)
239
+ task_id = questions[num-1]["task_id"]
240
+ removed = answers.pop(task_id, None)
241
+ skips = _load_skips(user)
242
+ skipped = skips.pop(task_id, None)
243
  _save(user, answers)
244
+ _save_skips(user, skips)
245
+ return (f"Cleared Q{num} cached answer/skip. Now eligible to retry. No API charge."
246
+ if removed is not None or skipped is not None
247
+ else f"Q{num} was neither cached nor skipped. No changes."), _table(questions,answers,skips)
248
  except Exception as exc:
249
  return f"Clear failed: {exc}", pd.DataFrame(columns=COLS)
250
 
 
280
  for k,v in cleaned.items():
281
  answers.setdefault(k,v) # Never overwrite an existing answer silently
282
  _save(user,answers)
283
+ return f"Restored {len(cleaned)} candidate answers; {len(answers)}/{len(questions)} cached. No API charges.", _table(questions,answers, _load_skips(user))
284
  except Exception as exc:
285
  return f"Restore failed: {exc}", pd.DataFrame(columns=COLS)
286
 
 
294
  missing = [i for i,q in enumerate(questions,1) if q["task_id"] not in answers]
295
  if missing and not allow_blanks:
296
  return (f"Not submitted: missing Q{missing}. To submit now with empty answers "
297
+ "for unsupported questions, tick the confirmation checkbox. No API spend."), _table(questions,answers, _load_skips(user))
298
  space_id = os.environ.get("SPACE_ID")
299
  if not space_id:
300
  raise RuntimeError("SPACE_ID not set. Must submit from your public Hugging Face Space")
 
307
  return (f"Official submission: score {score.get('score', 'n/a')}%, "
308
  f"{score.get('correct_count','?')}/{score.get('total_attempted','?')} correct "
309
  f"({len(missing)} unanswered submitted as blank). "
310
+ f"{str(score.get('message',''))[:400]}"), _table(questions,answers, _load_skips(user))
311
  except Exception as exc:
312
  return f"Submission failed: {exc}", pd.DataFrame(columns=COLS)
313
 
 
336
  clear_confirm = gr.Checkbox(label="I confirm clearing the selected cached answer (free)", value=False)
337
  clear_button = gr.Button("Clear selected Q# answer to allow a retry (free)")
338
  consent = gr.Checkbox(label="I understand this makes paid OpenAI API requests", value=False)
339
+ batch_size = gr.Dropdown([3, 5, 10, 20], value=3, label="Maximum supported questions to solve in one batch", interactive=True)
340
+ batch = gr.Button("3. Run selected batch (OpenAI API)")
341
  with gr.Row():
342
  see = gr.Button("Show saved answers")
343
  send = gr.Button("Submit answers to GAIA scoring (free)")
 
352
  with gr.Row():
353
  uploaded = gr.File(label="Upload an earlier answer backup (.json)", file_types=[".json"], type="filepath")
354
  restore_button = gr.Button("Restore answers (free)")
355
+ gr.Markdown("**Before updating your Space or running a large batch:** click Download answer backup. "
356
+ "Unavailable GAIA attachments (HTTP 404) are marked Skipped, not answered blindly. "
357
+ "The batch stops at any model/API error or local budget limit. "
358
+ "Your HF Space remains PUBLIC, but your API key must be stored only as a **Secret**. "
359
  "Audio transcription is disabled by default to avoid untracked charges. "
360
  "The answer cache and cost ledger persist only if /data persistent storage is mounted; otherwise export backups regularly.")
361
  setup.click(check_setup, outputs=[status,table])
362
  test.click(solve_one,inputs=[number],outputs=[status,table])
363
  clear_button.click(clear_one,inputs=[number,clear_confirm],outputs=[status,table])
364
+ batch.click(run_batch,inputs=[consent,batch_size],outputs=[status,table])
365
  see.click(view_cached,outputs=[status,table])
366
  send.click(submit,inputs=[allow_blanks],outputs=[status,table])
367
  export.click(backup,outputs=[exported,backup_status])