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()