Spaces:
Sleeping
Sleeping
Download text.py from ovo456/Final_Assignment_Template: direct link, hf CLI and curl.
- Browser
- Download file 91 kB
-
https://huggingface.co/spaces/ovo456/Final_Assignment_Template/resolve/main/text.py
- Command line
-
hf download hf://spaces/ovo456/Final_Assignment_Template/text.py
-
curl -L -o text.py https://huggingface.co/spaces/ovo456/Final_Assignment_Template/resolve/main/text.py
91 kB
| """ | |
| GAIA Solver Pro β Autonomous Multi-Tool Agent | |
| Engine: DeepSeek API (deepseek-chat) | |
| Designed for the GAIA Benchmark with CSV output (Asynchronous/Concurrent Execution) | |
| """ | |
| # Import operating system utilities (for file paths and environment variables) | |
| import os | |
| # Import regular expressions library for text manipulation and string cleaning | |
| import re | |
| # Import JSON parsing library for handling structured data | |
| import json | |
| # Import time module to measure task execution duration | |
| import time | |
| # Import random module for jittered retry backoff delays | |
| import random | |
| # Import shutil for safe archive extraction (streaming member writes) | |
| import shutil | |
| # Import queue module for thread-safe worker pooling in parallel execution | |
| import queue | |
| # Import subprocess for running ffmpeg/ffprobe (video frame extraction) | |
| import subprocess | |
| # Import threading module for the shared vision-model lock | |
| import threading | |
| # Import HTTP requests library to make API calls and download files | |
| import requests | |
| # Import pandas for data processing, CSV exporting, and Excel reading | |
| import pandas as pd | |
| # Import PIL Image for handling image files when working with visual models | |
| from PIL import Image | |
| # Import thread pool executor tools for concurrent multithreaded processing | |
| from concurrent.futures import ThreadPoolExecutor | |
| # Import core agent tools and framework wrappers from the smolagents framework | |
| from smolagents import CodeAgent, OpenAIServerModel, DuckDuckGoSearchTool, VisitWebpageTool, tool | |
| # Import dotenv to load environment variables from a local .env file | |
| from dotenv import load_dotenv | |
| # Load secret environment variables (like API keys) into os.environ | |
| load_dotenv() | |
| # βββ DeepSeek API Configuration ββββββββββββββββββββββββββββββββββββββ | |
| # Set default endpoint URL where the GAIA benchmark questions & files are hosted | |
| DEFAULT_API_URL = os.getenv("GAIA_API_URL", "https://agents-course-unit4-scoring.hf.space") | |
| # Retrieve the DeepSeek API key from environment variables | |
| DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") | |
| # Set base URL for the DeepSeek API (OpenAI-compatible endpoint) | |
| DEEPSEEK_API_BASE = os.getenv("DEEPSEEK_API_BASE", "https://api.deepseek.com/v1") | |
| # Specify the main LLM model name used for agent reasoning and execution | |
| AGENT_MODEL = os.getenv("DEEPSEEK_AGENT_MODEL", "deepseek-chat") | |
| # Specify the LLM model name used for formatting the final string answer | |
| CLEANING_MODEL = os.getenv("DEEPSEEK_CLEANING_MODEL", "deepseek-chat") | |
| # Number of parallel worker agents used by the pipeline | |
| NUM_WORKERS = int(os.getenv("GAIA_NUM_WORKERS", "4")) | |
| # Maximum tokens allowed for the answer-cleaning pass (raised so long | |
| # comma-separated list Answers are not truncated) | |
| CLEANING_MAX_TOKENS = int(os.getenv("CLEANING_MAX_TOKENS", "1024")) | |
| # Two-stage per-Task timeout (Part B, decisions B1-B4): at the SOFT deadline | |
| # the Agent is stopped and a best-effort Answer is synthesized from its partial | |
| # Worklog (status "forced"); at the HARD deadline the Task is terminated and | |
| # recorded as "timeout". Hard is enforced as a total-time ceiling: the synthesis | |
| # call is clamped to the remaining hard-soft budget, so a Task ends no later | |
| # than ~hard seconds after it starts (a hard < soft config yields a zero | |
| # synthesis budget, i.e. a plain timeout). | |
| TASK_SOFT_TIMEOUT_SEC = float(os.getenv("GAIA_TASK_SOFT_TIMEOUT", "300")) | |
| TASK_HARD_TIMEOUT_SEC = float(os.getenv("GAIA_TASK_HARD_TIMEOUT", "360")) | |
| # Bounds the forced-answer synthesis call so soft + synthesis stays under the | |
| # hard cap (the worker also clamps it to the remaining hard-soft budget). | |
| FORCE_ANSWER_TIMEOUT_SEC = float(os.getenv("GAIA_FORCE_ANSWER_TIMEOUT", "45")) | |
| # Local vision model used by the 'inspect_image' tool for image attachments | |
| VISION_MODEL_ID = os.getenv("VISION_MODEL_ID", "Qwen/Qwen2-VL-2B-Instruct") | |
| # βββ Multimodal config (ADR-0002: local-only) βββββββββββββββββββββββββ | |
| # Local compute device for the multimodal models (Qwen vision + faster-whisper). | |
| # One of: auto | cpu | mps | cuda (default 'auto'). 'auto' probes torch for an | |
| # accelerator β Apple MPS first, then CUDA β and falls back to 'cpu'. Note: | |
| # faster-whisper clamps mps->cpu because CTranslate2 has no MPS backend (see | |
| # _whisper_device_config). | |
| LOCAL_DEVICE = os.getenv("LOCAL_DEVICE", "auto").strip().lower() | |
| # Local audio model used by the 'transcribe_audio' tool (faster-whisper). | |
| # Lightweight by default; use a larger model (e.g. 'small'/'medium') for | |
| # better accuracy at the cost of speed and memory. | |
| WHISPER_MODEL = os.getenv("WHISPER_MODEL", "tiny") | |
| # Set USE_WHISPER_API=1 to prefer the hosted Whisper API (OpenAI or | |
| # OpenRouter) over the local faster-whisper model. The API path is also used | |
| # as a documented fallback when the local model fails and a key is configured. | |
| USE_WHISPER_API = os.getenv("USE_WHISPER_API", "").strip().lower() in ("1", "true", "yes", "on") | |
| # Number of evenly-spaced frames the 'extract_video_frames' tool samples from | |
| # a video; each frame is fed to the local vision model via 'inspect_image'. | |
| VIDEO_FRAMES_COUNT = int(os.getenv("VIDEO_FRAMES_COUNT", "4")) | |
| # βββ Answer bundle & Run outputs (ticket 03) ββββββββββββββββββββββββββββ | |
| # The committed artifact the Space submits from: a JSON file keyed by task_id, | |
| # each entry holding {question, level, file_name, answer, worklog, timestamp}. | |
| # Generated by local Runs and committed to the repo (per the spec's Answer | |
| # bundle contract / ADR-0001). It is deliberately NOT gitignored. | |
| ANSWER_BUNDLE_PATH = os.getenv( | |
| "ANSWER_BUNDLE_PATH", | |
| os.path.join(os.path.dirname(os.path.abspath(__file__)), "answer_bundle.json"), | |
| ) | |
| # Local Run results CSV output path (configurable so tests and custom Runs can | |
| # write elsewhere without touching the repo's gaia_results.csv) | |
| RESULTS_CSV_PATH = os.getenv("GAIA_RESULTS_CSV", "gaia_results.csv") | |
| # βββ Prompt for Final Answer Cleaning ββββββββββββββββββββββββββββββββββ | |
| # System prompt template that instructs LLM how to format outputs strictly for benchmark submission | |
| FINAL_ANSWER_PROMPT = """You are an expert assistant in generating final answers with the highest possible precision. | |
| Given a question and an initial answer, your task is to generate the clean final answer. | |
| **Rules:** | |
| - The final answer must be a number and/or text string OR as few words as possible OR a comma-separated list. | |
| - If a number is requested, DO NOT use commas to separate thousands NOR units like USD, $, percentage, or % unless specified. | |
| - If a string is requested, DO NOT use articles or abbreviations, write digits in plain text unless specified. | |
| - If a comma-separated list is requested, apply the previous rules depending on whether it is a number or a string. | |
| - If the final answer is a number, use a number, not a word. | |
| - If the final answer is a string, start with a capital letter. | |
| - If the final answer is a comma-separated list of numbers, put a space after each comma. | |
| - If the final answer is a comma-separated list of strings, put a space after each comma and start with a lowercase letter. | |
| - DO NOT add content that is not in the initial answer. | |
| **Examples:** | |
| Question: What is the largest city in California? | |
| Initial Answer: The largest city in California is Los Angeles. | |
| Final Answer: Los Angeles | |
| Question: How many 'r's are in strawberry? | |
| Initial Answer: There are 3 'r's in strawberry. | |
| Final Answer: 3 | |
| Question: {question} | |
| Initial Answer: {answer} | |
| Final Answer: """ | |
| # System prompt template for the forced-answer path (Part B): when a Task hits | |
| # the soft deadline, a best-effort final Answer is synthesized from the Agent's | |
| # partial reasoning trace so something real is still submitted (status "forced"). | |
| FORCED_ANSWER_PROMPT = """You are an expert assistant that must produce a best-effort final answer from an INCOMPLETE reasoning trace, because the agent hit its time limit. | |
| **Rules:** | |
| - Use ONLY evidence present in the trace; do not invent facts. | |
| - If the trace already contains a candidate answer or concrete data, prefer it. | |
| - If there is not enough evidence, give the most reasonable concise answer you can. | |
| - Apply the same formatting rules as a normal benchmark answer: a number and/or short text string, or a comma-separated list; no units/percentages unless specified; no extra commentary. | |
| QUESTION: | |
| {question} | |
| PARTIAL REASONING TRACE: | |
| {trace} | |
| FINAL ANSWER: """ | |
| # Tuples of prefixes commonly outputted by reasoners to strip during cleanup | |
| ANSWER_PREFIXES = ( | |
| "Final Answer:", | |
| "FINAL ANSWER:", | |
| "Answer:", | |
| "Respuesta Final:", | |
| ) | |
| def _extract_youtube_video_id(youtube_url: str) -> str | None: | |
| """Extracts a YouTube video ID from watch, short, embed, or live URLs.""" | |
| # Import URL parsing components inside function scope | |
| from urllib.parse import parse_qs, urlparse | |
| # Parse the input YouTube URL string into structured URL components | |
| parsed = urlparse(youtube_url) | |
| # Get domain host name in lowercase (e.g., youtube.com or youtu.be) | |
| host = (parsed.netloc or "").lower() | |
| # Get the URL path string (e.g., /watch or /shorts/xyz) | |
| path = parsed.path or "" | |
| # Check if the URL is a short link (youtu.be/ID) | |
| if "youtu.be" in host: | |
| # Extract ID from the path segment | |
| video_id = path.strip("/").split("/")[0] | |
| return video_id or None | |
| # Check if URL belongs to standard youtube.com domains | |
| if "youtube.com" in host: | |
| # Try extracting query parameter 'v' from ?v=VIDEO_ID | |
| query_id = parse_qs(parsed.query).get("v", [None])[0] | |
| if query_id: | |
| return query_id | |
| # Check path-based formats like /shorts/, /embed/, /live/ | |
| for prefix in ("/shorts/", "/embed/", "/live/"): | |
| if path.startswith(prefix): | |
| video_id = path[len(prefix):].split("/")[0] | |
| return video_id or None | |
| # Fallback: regex search for standard 11-character video IDs | |
| match = re.search(r"(?:v=|youtu\.be/)([a-zA-Z0-9_-]{11})", youtube_url) | |
| # Return matched ID if found, otherwise None | |
| return match.group(1) if match else None | |
| def _get_transcript_text(transcript_data) -> str: | |
| """Normalizes different versions of youtube_transcript_api into a single string.""" | |
| # Convert transcript object to raw list of dictionaries if method exists | |
| if hasattr(transcript_data, "to_raw_data"): | |
| segments = transcript_data.to_raw_data() | |
| else: | |
| segments = transcript_data | |
| # List to hold individual text lines | |
| parts = [] | |
| # Loop over every subtitle segment retrieved | |
| for segment in segments: | |
| # Extract text property or dictionary key depending on API version return type | |
| if hasattr(segment, "text"): | |
| text = segment.text | |
| elif isinstance(segment, dict): | |
| text = segment.get("text", "") | |
| else: | |
| text = str(segment) | |
| # Trim leading and trailing whitespace | |
| text = str(text).strip() | |
| # If segment contains text, append it to collector list | |
| if text: | |
| parts.append(text) | |
| # Combine all segments into a single space-delimited text paragraph | |
| return " ".join(parts).strip() | |
| # Define local folder path where all attachment files are stored (relative to this script) | |
| LOCAL_FILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "files") | |
| # Folder where the 'extract_video_frames' tool writes sampled frames during a | |
| # Run (kept under files/ so it stays out of version control) | |
| VIDEO_FRAMES_DIR = os.getenv( | |
| "VIDEO_FRAMES_DIR", | |
| os.path.join(LOCAL_FILES_DIR, "_video_frames"), | |
| ) | |
| def _get_local_task_file(task_id: str, file_name: str) -> str: | |
| """Locates a pre-downloaded file in the local files directory. | |
| Checks both original file_name and task_id-based filenames. | |
| """ | |
| if not os.path.exists(LOCAL_FILES_DIR): | |
| return "" | |
| # Strategy 1: Check by exact file_name if present | |
| if file_name and str(file_name).strip(): | |
| safe_name = os.path.basename(file_name) | |
| p1 = os.path.join(LOCAL_FILES_DIR, safe_name) | |
| if os.path.exists(p1): | |
| return p1 | |
| # Strategy 2: Check by task_id + extension from file_name (e.g., 2b3ef98c-....mp3) | |
| if task_id: | |
| if file_name and "." in file_name: | |
| ext = file_name.lower().split(".")[-1] | |
| p2 = os.path.join(LOCAL_FILES_DIR, f"{task_id}.{ext}") | |
| if os.path.exists(p2): | |
| return p2 | |
| # Strategy 3: Check by task_id alone | |
| p3 = os.path.join(LOCAL_FILES_DIR, task_id) | |
| if os.path.exists(p3): | |
| return p3 | |
| # Strategy 4: Search for any file in the folder starting with task_id | |
| for existing_file in os.listdir(LOCAL_FILES_DIR): | |
| if existing_file.startswith(task_id): | |
| return os.path.join(LOCAL_FILES_DIR, existing_file) | |
| return "" | |
| def _normalize_answer_for_submission(answer: str | None) -> str: | |
| """Ensures only the clean answer is processed by removing thinking tags.""" | |
| # Handle None value safely and strip whitespace from answer string | |
| text = "" if answer is None else str(answer).strip() | |
| # Strip <think>...</think> reasoning blocks from DeepSeek R1 models using regex | |
| text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip() | |
| # Clean orphaned closing tags if leftover | |
| text = re.sub(r'</think>', '', text).strip() | |
| # Clean orphaned opening tags if leftover | |
| text = re.sub(r'<think>', '', text).strip() | |
| # Iterate over standard answer prefixes and remove them if present | |
| for prefix in ANSWER_PREFIXES: | |
| if text.startswith(prefix): | |
| text = text[len(prefix):].strip() | |
| # Strip residual code ticks and bold asterisks surrounding answer | |
| text = text.strip("`*") | |
| # Return normalized clean text string | |
| return text | |
| # Sentinel placeholders used to interpolate values into FINAL_ANSWER_PROMPT. | |
| # Swapping the template's {question}/{answer} markers for these control-char | |
| # sentinels first means a Question or Answer that itself contains the literal | |
| # substrings "{question}"/"{answer}" cannot be double-substituted. | |
| _PROMPT_QUESTION_SENTINEL = "\x00GAIA_QUESTION\x00" | |
| _PROMPT_ANSWER_SENTINEL = "\x00GAIA_ANSWER\x00" | |
| def _build_cleaning_prompt(question: str | None, answer: str | None) -> str: | |
| """Builds the final-answer cleaning prompt. | |
| Interpolates the Question and raw Answer via string replacement (never | |
| ``str.format``), so Answers containing ``{`` or ``}`` (e.g. JSON or sets) | |
| do not crash the cleaning pass. | |
| """ | |
| prompt = ( | |
| FINAL_ANSWER_PROMPT | |
| .replace("{question}", _PROMPT_QUESTION_SENTINEL) | |
| .replace("{answer}", _PROMPT_ANSWER_SENTINEL) | |
| .replace(_PROMPT_QUESTION_SENTINEL, "" if question is None else str(question)) | |
| .replace(_PROMPT_ANSWER_SENTINEL, "" if answer is None else str(answer)) | |
| ) | |
| return prompt | |
| # Sentinels for the forced-answer prompt (Part B); same rationale as the | |
| # cleaning prompt: a partial trace containing "{"/"}" (code, JSON) must not | |
| # crash the synthesis call or double-substitute. | |
| _FORCE_QUESTION_SENTINEL = "\x00GAIA_FORCE_QUESTION\x00" | |
| _FORCE_TRACE_SENTINEL = "\x00GAIA_FORCE_TRACE\x00" | |
| def _build_forced_answer_prompt(question: str | None, partial_trace: str | None) -> str: | |
| """Builds the forced-answer synthesis prompt. | |
| Interpolates the Question and partial reasoning trace via string | |
| replacement (never ``str.format``) so traces containing ``{``/``}`` do not | |
| crash the synthesis call. | |
| """ | |
| return ( | |
| FORCED_ANSWER_PROMPT | |
| .replace("{question}", _FORCE_QUESTION_SENTINEL) | |
| .replace("{trace}", _FORCE_TRACE_SENTINEL) | |
| .replace(_FORCE_QUESTION_SENTINEL, "" if question is None else str(question)) | |
| .replace(_FORCE_TRACE_SENTINEL, "" if partial_trace is None else str(partial_trace)) | |
| ) | |
| def _chat_answer(model_id: str, prompt: str, *, description: str, | |
| max_tokens: int | None = None) -> str: | |
| """Runs a zero-temperature DeepSeek chat completion and strips it to a bare answer. | |
| Shared by the answer-cleaning pass and the forced-answer synthesis (Part B): | |
| both need the same retried, `<think>`-stripped, prefix-stripped text. Raises | |
| on failure β callers decide whether to fall back (cleaning) or treat it as | |
| a timeout (forced synthesis). | |
| """ | |
| import openai | |
| client = openai.OpenAI(base_url=DEEPSEEK_API_BASE, api_key=DEEPSEEK_API_KEY) | |
| def _request(): | |
| return client.chat.completions.create( | |
| model=model_id, | |
| messages=[{"role": "user", "content": prompt}], | |
| max_tokens=max_tokens if max_tokens is not None else CLEANING_MAX_TOKENS, | |
| temperature=0.0, # Deterministic zero-temperature for consistent answers | |
| ) | |
| completion = _call_with_retry(_request, description=description) | |
| # Extract response text content from LLM choice completion | |
| text = completion.choices[0].message.content | |
| text = "" if text is None else str(text).strip() | |
| # Remove internal reasoning <think> blocks if present in output | |
| text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip() | |
| # Strip prefixes, formatting marks, and code blocks from the output | |
| for prefix in [*ANSWER_PREFIXES, "**", "```"]: | |
| if text.startswith(prefix): | |
| text = text[len(prefix):].strip() | |
| # Return the string without trailing formatting chars | |
| return text.rstrip("*`") | |
| # βββ Retry-with-backoff βββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Transient model/network failures (429 rate limits, connection drops, | |
| # timeouts, 5xx) must not fail a Task and cost a point. smolagents retries | |
| # rate-limit errors internally, but not network errors; the answer-cleaning | |
| # call uses the raw OpenAI client and has no built-in retry at all. These | |
| # helpers give both paths a uniform retry-with-backoff. | |
| _RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504} | |
| # Message markers that indicate a transient failure when the exception type | |
| # cannot be classified directly (e.g. wrapped/unknown errors). | |
| _RETRYABLE_MESSAGE_MARKERS = ( | |
| "429", | |
| "rate limit", | |
| "rate_limit", | |
| "too many requests", | |
| "500", | |
| "502", | |
| "503", | |
| "504", | |
| "internal server error", | |
| "service unavailable", | |
| "connection", | |
| "timeout", | |
| "timed out", | |
| "temporarily unavailable", | |
| ) | |
| def _is_retryable_error(exc: BaseException) -> bool: | |
| """Returns True when ``exc`` is a transient model/network failure worth retrying.""" | |
| try: | |
| from openai import APIConnectionError, APITimeoutError, RateLimitError, APIStatusError | |
| retryable_classes = (APIConnectionError, APITimeoutError, RateLimitError) | |
| except Exception: | |
| retryable_classes = () | |
| APIStatusError = None | |
| # OpenAI SDK transient error classes | |
| if retryable_classes and isinstance(exc, retryable_classes): | |
| return True | |
| if APIStatusError is not None and isinstance(exc, APIStatusError): | |
| status = getattr(exc, "status_code", None) | |
| if status is not None: | |
| return status in _RETRYABLE_STATUS_CODES | |
| # requests-level errors: retry connection drops and timeouts, and HTTP | |
| # 5xx/429 surfaced by raise_for_status(), but not permanent 4xx client errors | |
| if isinstance(exc, requests.exceptions.HTTPError): | |
| status = getattr(exc.response, "status_code", None) | |
| if status is not None: | |
| return status in _RETRYABLE_STATUS_CODES | |
| return False | |
| if isinstance(exc, (requests.exceptions.ConnectionError, requests.exceptions.Timeout)): | |
| return True | |
| # Fallback heuristic: recognizable markers in the message text | |
| message = str(exc).lower() | |
| return any(marker in message for marker in _RETRYABLE_MESSAGE_MARKERS) | |
| def _call_with_retry(fn, *, max_attempts: int = 4, base_delay: float = 1.0, | |
| max_delay: float = 30.0, jitter: float = 0.5, | |
| description: str = "call"): | |
| """Calls ``fn``, retrying transient errors with exponential backoff + jitter. | |
| Args: | |
| fn: The callable to invoke (must be re-invocable). | |
| max_attempts: Total attempts before giving up (default 4). | |
| base_delay: Base backoff seconds, doubled after each failure. | |
| max_delay: Ceiling on the backoff delay. | |
| jitter: +/- fraction of the delay applied as random jitter. | |
| description: Human-readable label used in retry log messages. | |
| Returns: | |
| Whatever ``fn`` returns on success. | |
| Raises: | |
| The last exception raised by ``fn`` once retries are exhausted or the | |
| error is not transient. | |
| """ | |
| attempt = 0 | |
| while True: | |
| attempt += 1 | |
| try: | |
| return fn() | |
| except Exception as exc: | |
| if attempt >= max_attempts or not _is_retryable_error(exc): | |
| raise | |
| delay = min(max_delay, base_delay * (2 ** (attempt - 1))) | |
| delay *= 1.0 + jitter * (2.0 * random.random() - 1.0) | |
| print(f"β οΈ {description} failed (attempt {attempt}/{max_attempts}): {exc}; " | |
| f"retrying in {max(0.0, delay):.1f}s") | |
| time.sleep(max(0.0, delay)) | |
| def _run_with_timeout(fn, timeout: float): | |
| """Runs ``fn`` on a daemon helper thread, bounding its runtime to ``timeout``. | |
| Python cannot forcibly kill a thread blocked in a call (e.g. a hung HTTP | |
| request), so the call runs on a short-lived **daemon** thread that the | |
| caller joins with a timeout. If the call is still alive when the timeout | |
| elapses, ``TimeoutError`` is raised and the caller moves on; the wedged | |
| helper thread cannot block the worker or the process at exit (daemon). | |
| Returns: | |
| Whatever ``fn`` returns on success. | |
| Raises: | |
| ``TimeoutError`` if ``fn`` does not finish within ``timeout`` seconds. | |
| Any exception raised by ``fn`` otherwise. | |
| """ | |
| result_box = {} | |
| def _target(): | |
| try: | |
| result_box["value"] = fn() | |
| except Exception as exc: | |
| result_box["error"] = exc | |
| helper = threading.Thread(target=_target, daemon=True) | |
| helper.start() | |
| helper.join(timeout) | |
| if helper.is_alive(): | |
| raise TimeoutError(f"Task exceeded {timeout:.0f}s") | |
| if "error" in result_box: | |
| raise result_box["error"] | |
| return result_box["value"] | |
| # βββ Worklog capture & Answer bundle (ticket 03) ββββββββββββββββββββββββ | |
| # A Worklog is the step-by-step trace of an Agent's Run β its reasoning steps | |
| # (thought), tool calls, and observations. Each local Run serializes the full | |
| # trace plus a per-tool summary with timing into a structured Worklog, and | |
| # writes a committed Answer bundle keyed by task_id so the Space can submit | |
| # without re-solving (ADR-0001 / spec's Answer bundle contract). | |
| def _now_iso() -> str: | |
| """Returns the current UTC time in ISO-8601 (seconds) format.""" | |
| return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) | |
| def _step_duration_sec(step) -> float: | |
| """Returns a step's duration in seconds from its smolagents Timing, or 0.0.""" | |
| timing = getattr(step, "timing", None) | |
| if timing is None: | |
| return 0.0 | |
| duration = getattr(timing, "duration", None) | |
| if duration is None: | |
| return 0.0 | |
| try: | |
| return round(float(duration), 3) | |
| except (TypeError, ValueError): | |
| return 0.0 | |
| def _iter_action_steps(steps): | |
| """Yields only the Agent Action steps (skipping Task/Planning steps).""" | |
| for step in steps: | |
| # Action steps carry a step_number; Task/Planning steps do not. | |
| if getattr(step, "step_number", None) is not None: | |
| yield step | |
| def _coerce_thought(model_output) -> str: | |
| """Coerces a step's model_output (str or list-of-dicts) into a single string.""" | |
| if model_output is None: | |
| return "" | |
| if isinstance(model_output, str): | |
| return model_output | |
| if isinstance(model_output, (list, tuple)): | |
| parts = [] | |
| for item in model_output: | |
| if isinstance(item, dict): | |
| parts.append(str(item.get("text", item))) | |
| else: | |
| parts.append(str(item)) | |
| return "\n".join(parts) | |
| return str(model_output) | |
| def _coerce_observations(observations) -> str: | |
| """Coerces a step's observations (str or list) into a single string.""" | |
| if observations is None: | |
| return "" | |
| if isinstance(observations, (list, tuple)): | |
| return "\n".join(str(o) for o in observations) | |
| return str(observations) | |
| def _coerce_tool_arguments(arguments): | |
| """Coerces a ToolCall's arguments into a JSON-serializable value. | |
| smolagents ToolCall.arguments is usually already a dict, but older/newer | |
| paths can carry a JSON string; parse it so the Worklog is stable JSON. | |
| """ | |
| if arguments is None: | |
| return "" | |
| if isinstance(arguments, str): | |
| try: | |
| return json.loads(arguments) | |
| except (ValueError, TypeError): | |
| return arguments | |
| return arguments | |
| def _serialize_action_step(step) -> dict: | |
| """Serializes one Agent Action step into a JSON-safe Worklog trace entry.""" | |
| tool_calls = [] | |
| for call in getattr(step, "tool_calls", None) or []: | |
| tool_calls.append({ | |
| "name": getattr(call, "name", "") or "unknown", | |
| "arguments": _coerce_tool_arguments(getattr(call, "arguments", None)), | |
| }) | |
| entry = { | |
| "step_number": getattr(step, "step_number", None), | |
| "type": "final" if getattr(step, "is_final_answer", False) else "action", | |
| "thought": _coerce_thought(getattr(step, "model_output", None)), | |
| "tool_calls": tool_calls, | |
| "observations": _coerce_observations(getattr(step, "observations", None)), | |
| "code_action": getattr(step, "code_action", None) or "", | |
| "duration_sec": _step_duration_sec(step), | |
| } | |
| error = getattr(step, "error", None) | |
| if error is not None: | |
| entry["error"] = str(error) | |
| action_output = getattr(step, "action_output", None) | |
| if action_output is not None: | |
| entry["action_output"] = str(action_output)[:2000] | |
| return entry | |
| def _serialize_tool_summary(steps) -> list[dict]: | |
| """Computes a per-tool summary (call count + timing) from Agent steps. | |
| Each step's duration is split evenly across the tools called in that step | |
| (smolagents only exposes per-step timing), so per-tool totals do not | |
| double-count time. Sorted by total time, then name, for a stable order. | |
| """ | |
| per_tool: dict[str, dict] = {} | |
| for step in _iter_action_steps(steps): | |
| tool_calls = getattr(step, "tool_calls", None) or [] | |
| if not tool_calls: | |
| continue | |
| share = _step_duration_sec(step) / len(tool_calls) | |
| for call in tool_calls: | |
| name = getattr(call, "name", "") or "unknown" | |
| bucket = per_tool.setdefault(name, {"calls": 0, "total_sec": 0.0}) | |
| bucket["calls"] += 1 | |
| bucket["total_sec"] += share | |
| summary = [] | |
| for name in sorted(per_tool, key=lambda n: (-per_tool[n]["total_sec"], n)): | |
| bucket = per_tool[name] | |
| total = round(bucket["total_sec"], 3) | |
| calls = bucket["calls"] | |
| summary.append({ | |
| "name": name, | |
| "calls": calls, | |
| "total_sec": total, | |
| "avg_sec": round(total / calls, 3) if calls else 0.0, | |
| }) | |
| return summary | |
| def _serialize_worklog(steps, *, status: str = "completed") -> dict: | |
| """Serializes the Agent's recorded steps into a structured Worklog. | |
| The Worklog holds the full trace (thought / tool call / observation per | |
| step), a per-tool summary with timing, and the total Run duration for the | |
| Task. ``status`` is "completed" on success, "forced" when the Answer was | |
| synthesized from a partial trace at the soft deadline, or "error" on | |
| failure. | |
| """ | |
| action_steps = list(_iter_action_steps(steps)) | |
| return { | |
| "steps": [_serialize_action_step(s) for s in action_steps], | |
| "tool_summary": _serialize_tool_summary(action_steps), | |
| "total_duration_sec": round(sum(_step_duration_sec(s) for s in action_steps), 3), | |
| "status": status, | |
| } | |
| def _make_bundle_entry(task_id: str, question: str, level, file_name: str, | |
| answer: str, worklog: dict, timestamp: str | None = None) -> dict: | |
| """Builds one Answer bundle entry, keyed by ``task_id``. | |
| The entry carries the Task's final Answer and its structured Worklog, plus | |
| the Question/Level/file_name so bundled submissions are self-describing and | |
| consistent with the results CSV (spec's Answer bundle contract). | |
| """ | |
| return { | |
| "task_id": task_id, | |
| "question": question, | |
| "level": level if level is not None else "?", | |
| "file_name": file_name if file_name else "None", | |
| "answer": answer, | |
| "worklog": worklog, | |
| "timestamp": timestamp or _now_iso(), | |
| } | |
| def _load_answer_bundle(path: str | None = None) -> dict: | |
| """Loads the committed Answer bundle from ``path`` (default ANSWER_BUNDLE_PATH). | |
| Returns ``{}`` when the bundle is absent or unreadable, so a Run degrades | |
| to a full solve rather than crashing. | |
| """ | |
| path = path or ANSWER_BUNDLE_PATH | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| data = json.load(f) | |
| if isinstance(data, dict): | |
| return data | |
| except (OSError, ValueError, TypeError): | |
| pass | |
| return {} | |
| def _save_answer_bundle(bundle: dict, path: str | None = None) -> str: | |
| """Atomically writes the Answer bundle to ``path`` (default ANSWER_BUNDLE_PATH). | |
| Writes to a sibling temp file and renames over the target, so a crash | |
| mid-write cannot corrupt the committed bundle. | |
| """ | |
| path = path or ANSWER_BUNDLE_PATH | |
| os.makedirs(os.path.dirname(path) or ".", exist_ok=True) | |
| tmp_path = f"{path}.tmp" | |
| with open(tmp_path, "w", encoding="utf-8") as f: | |
| json.dump(bundle, f, indent=2, ensure_ascii=False) | |
| os.replace(tmp_path, path) | |
| return path | |
| # βββ Local device resolution (Part A: device auto-selection) ββββββββββ | |
| # A tiny lazy-import seam so the device resolver is unit-testable without | |
| # torch installed (tests inject a fake module). | |
| def _import_torch(): | |
| """Lazily imports and returns the ``torch`` module (or raises ImportError).""" | |
| import torch | |
| return torch | |
| _VALID_LOCAL_DEVICES = ("cpu", "mps", "cuda") | |
| def _is_accelerator(local_device: str) -> bool: | |
| """True if ``local_device`` is a torch accelerator (mps or cuda).""" | |
| return local_device in ("mps", "cuda") | |
| def _resolve_local_device() -> str: | |
| """Resolves the local compute device for the multimodal models. | |
| Reads the module-level ``LOCAL_DEVICE`` setting ('auto' | 'cpu' | 'mps' | | |
| 'cuda', default 'auto'): | |
| - An explicit ``cpu``/``mps``/``cuda`` is returned as-is (no torch probe). | |
| - ``auto`` (or any unrecognized value) probes torch for an accelerator, | |
| preferring Apple MPS, then CUDA, and finally falls back to ``cpu``. If | |
| torch is unavailable, ``cpu`` is returned. | |
| """ | |
| if LOCAL_DEVICE in _VALID_LOCAL_DEVICES: | |
| return LOCAL_DEVICE | |
| try: | |
| torch = _import_torch() | |
| if torch.backends.mps.is_available(): | |
| return "mps" | |
| if torch.cuda.is_available(): | |
| return "cuda" | |
| except Exception: | |
| pass | |
| return "cpu" | |
| def _whisper_device_config(local_device: str): | |
| """Maps a resolved local device to faster-whisper's ``(device, compute_type)``. | |
| CTranslate2 (faster-whisper's backend) accepts only ``"cpu"`` or ``"cuda"`` | |
| β there is no MPS backend β so ``mps`` is clamped to ``cpu``. ``compute_type`` | |
| is ``"int8"`` on cpu (lightweight) and ``"float16"`` on cuda. | |
| """ | |
| if local_device == "cuda": | |
| return "cuda", "float16" | |
| return "cpu", "int8" | |
| def _vision_load_config(torch, local_device: str): | |
| """Returns ``(torch_dtype, device_map)`` for the Qwen vision model. | |
| ``torch_dtype`` is ``float16`` on accelerators (mps/cuda) to halve memory | |
| and ``float32`` on cpu. ``device_map='auto'`` is used only for cuda, because | |
| transformers' device_map has no MPS backend. | |
| """ | |
| dtype = torch.float16 if _is_accelerator(local_device) else torch.float32 | |
| device_map = "auto" if local_device == "cuda" else None | |
| return dtype, device_map | |
| # βββ Agent Tools ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Shared faster-whisper model singleton (loaded once, reused across workers) | |
| _whisper_model = None | |
| _whisper_lock = threading.Lock() | |
| def _transcribe_audio_local(audio_path: str) -> str: | |
| """Transcribes ``audio_path`` with the local faster-whisper model. | |
| The model (configurable via the WHISPER_MODEL setting, default 'tiny') is | |
| loaded once and reused across worker threads. Returns the transcript text, | |
| or an ``"Error: ..."`` string on failure so the Agent can react without | |
| crashing the Run (matching the other tools). | |
| """ | |
| global _whisper_model | |
| try: | |
| # Import the lightweight local model only when the tool is first used | |
| from faster_whisper import WhisperModel | |
| # Load the shared model once (double-checked locking, like the vision model) | |
| if _whisper_model is None: | |
| with _whisper_lock: | |
| if _whisper_model is None: | |
| # Route through the shared local-device resolver. faster-whisper | |
| # has no MPS backend, so mps is clamped to cpu (int8); cuda uses | |
| # float16. See _whisper_device_config. | |
| device, compute_type = _whisper_device_config(_resolve_local_device()) | |
| _whisper_model = WhisperModel(WHISPER_MODEL, device=device, compute_type=compute_type) | |
| # Beam size 1 keeps latency low for the Agent loop | |
| segments, _info = _whisper_model.transcribe(audio_path, beam_size=1) | |
| parts = [ | |
| segment.text.strip() | |
| for segment in segments | |
| if segment.text and segment.text.strip() | |
| ] | |
| transcript = " ".join(parts).strip() | |
| if not transcript: | |
| return f"Error: Local transcription returned no text for '{audio_path}'." | |
| return transcript | |
| except Exception as e: | |
| return f"Error: Local transcription failed (model '{WHISPER_MODEL}'): {e}" | |
| def _transcribe_audio_api(audio_path: str) -> str: | |
| """Transcribes ``audio_path`` with the hosted Whisper API (documented fallback). | |
| Requires ``OPENAI_API_KEY`` (whisper-1) or ``OPENROUTER_API_KEY`` | |
| (openai/whisper-large-v3-turbo). Returns the transcript text, or an | |
| ``"Error: ..."`` string on failure. | |
| """ | |
| try: | |
| # Import openai library inside tool execution logic | |
| import openai | |
| # Check for OpenAI or OpenRouter API keys (for Whisper transcription only; | |
| # separate from the DeepSeek agent config since DeepSeek has no audio API) | |
| api_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY") | |
| # Select base API endpoint depending on which service key is set | |
| base_url = "https://api.openai.com/v1" if os.getenv("OPENAI_API_KEY") else "https://openrouter.ai/api/v1" | |
| # Choose Whisper model based on API provider | |
| model = "whisper-1" if os.getenv("OPENAI_API_KEY") else "openai/whisper-large-v3-turbo" | |
| # Return warning message if neither API key is present | |
| if not api_key: | |
| return "Error: Audio transcription fallback requires configuring OPENAI_API_KEY or OPENROUTER_API_KEY." | |
| # Instantiate OpenAI client with configured API host & key | |
| client = openai.OpenAI(base_url=base_url, api_key=api_key) | |
| # Open target audio file in binary reading mode | |
| with open(audio_path, "rb") as audio_file: | |
| # Request audio transcription from Whisper model endpoint | |
| transcription = client.audio.transcriptions.create( | |
| model=model, | |
| file=audio_file, | |
| ) | |
| # Return transcript text content | |
| return transcription.text | |
| except Exception as e: | |
| # Return error string if transcription fails | |
| return f"Error transcribing audio via API: {e}" | |
| def transcribe_audio(audio_path: str) -> str: | |
| """Transcribes an audio file (MP3, WAV, M4A, etc.). | |
| Uses the lightweight local faster-whisper model (configurable via the | |
| WHISPER_MODEL setting, default 'tiny'). If USE_WHISPER_API=1, or the local | |
| model fails and an API key is configured, it falls back to the hosted | |
| Whisper API (OpenAI or OpenRouter) as a documented fallback. | |
| Args: | |
| audio_path: The local path to the audio file (e.g., 'downloads/file.mp3'). | |
| """ | |
| if not USE_WHISPER_API: | |
| # Local-first per ADR-0002; fall back to the API only when the local | |
| # model fails AND a hosted key is configured | |
| local = _transcribe_audio_local(audio_path) | |
| if not local.startswith("Error:"): | |
| return local | |
| if os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY"): | |
| return _transcribe_audio_api(audio_path) | |
| return local | |
| # Explicit opt-in to the hosted API | |
| return _transcribe_audio_api(audio_path) | |
| def get_youtube_transcript(youtube_url: str) -> str: | |
| """Gets the official transcript/subtitles of a YouTube video. | |
| Args: | |
| youtube_url: The full URL of the YouTube video (e.g., 'https://www.youtube.com/watch?v=...'). | |
| """ | |
| try: | |
| # Import YouTube transcript API library | |
| from youtube_transcript_api import YouTubeTranscriptApi | |
| # Extract video ID from input URL string using helper function | |
| video_id = _extract_youtube_video_id(youtube_url) | |
| if not video_id: | |
| return f"Error: Could not extract video ID from URL: {youtube_url}" | |
| # Preference list of subtitle languages to query | |
| preferred_languages = ['en', 'es', 'fr', 'de'] | |
| try: | |
| # Attempt direct fetching of preferred language subtitles | |
| transcript = YouTubeTranscriptApi.get_transcript(video_id, languages=preferred_languages) | |
| except Exception: | |
| # Fallback: list available transcripts and fetch best matching language | |
| transcript_list = YouTubeTranscriptApi.list_transcripts(video_id) | |
| transcript = transcript_list.find_transcript(preferred_languages).fetch() | |
| # Normalize fetched transcript object into single text string | |
| return _get_transcript_text(transcript) | |
| except Exception as e: | |
| # Return error description string if retrieval fails | |
| return f"Error obtaining YouTube transcript: {e}." | |
| def _video_frame_timestamps(duration: float, num_frames: int) -> list[float]: | |
| """Computes N evenly-spaced frame timestamps within a video's duration. | |
| Timestamps are the centers of N equal slices of ``[0, duration]``, so the | |
| first and last frames are never the exact first/last instant (often a | |
| frozen title card or a black frame). Clamped to ``[0, duration]``. | |
| Args: | |
| duration: The video duration in seconds. | |
| num_frames: The number of evenly-spaced frames to sample (values below | |
| 1 are clamped to 1). | |
| Returns: | |
| A list of timestamps in seconds, in ascending order. | |
| """ | |
| n = max(1, int(num_frames)) | |
| if duration <= 0: | |
| return [0.0] | |
| step = duration / n | |
| return [round((i + 0.5) * step, 3) for i in range(n)] | |
| def _probe_video_duration(video_path: str) -> float: | |
| """Returns the duration (seconds) of a video file via ffprobe.""" | |
| result = subprocess.run( | |
| ["ffprobe", "-v", "error", "-show_entries", "format=duration", | |
| "-of", "default=noprint_wrappers=1:nokey=1", video_path], | |
| capture_output=True, text=True, timeout=60, | |
| ) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"ffprobe failed: {result.stderr.strip()}") | |
| return float(result.stdout.strip()) | |
| def _resolve_frame_count(num_frames) -> int: | |
| """Coerces an LLM-supplied frame count to a valid positive int. | |
| The Agent calls the tool with whatever it inferred, so ``num_frames`` may | |
| be None, a float-string ("5.5"), or a word. Anything that is not a | |
| positive int falls back to the VIDEO_FRAMES_COUNT setting rather than | |
| failing the whole tool call. | |
| """ | |
| try: | |
| n = int(num_frames) | |
| if n > 0: | |
| return n | |
| except (TypeError, ValueError): | |
| pass | |
| return VIDEO_FRAMES_COUNT | |
| def _extract_frames_from_video(video_path: str, num_frames: int, job_id: str, | |
| out_dir: str | None = None) -> list[str]: | |
| """Probes ``video_path`` and extracts N evenly-spaced JPEG frames. | |
| Frames are written as ``frame_{job_id}_NN.jpg`` into ``out_dir`` (defaults | |
| to the video-frames folder) and returned as absolute paths. Requires | |
| ffmpeg/ffprobe on PATH. Raises on failure so the caller can clean up any | |
| partial files. | |
| """ | |
| out_dir = out_dir or VIDEO_FRAMES_DIR | |
| os.makedirs(out_dir, exist_ok=True) | |
| duration = _probe_video_duration(video_path) | |
| timestamps = _video_frame_timestamps(duration, num_frames) | |
| frame_paths = [] | |
| for i, ts in enumerate(timestamps, start=1): | |
| frame_path = os.path.join(out_dir, f"frame_{job_id}_{i:02d}.jpg") | |
| subprocess.run( | |
| ["ffmpeg", "-y", "-ss", f"{ts:.3f}", "-i", video_path, | |
| "-frames:v", "1", "-q:v", "2", frame_path], | |
| capture_output=True, text=True, timeout=180, check=True, | |
| ) | |
| frame_paths.append(frame_path) | |
| return frame_paths | |
| def _cleanup_job_artifacts(job_id: str) -> None: | |
| """Removes any partial files for ``job_id`` from the video-frames folder. | |
| Covers the downloaded video (and its ``.part``/``.ytdl`` temp files, which | |
| share the ``video_{job_id}`` prefix) plus any frames already written before | |
| a failure, so a failed extraction never orphans files on disk. | |
| """ | |
| if not os.path.isdir(VIDEO_FRAMES_DIR): | |
| return | |
| for name in os.listdir(VIDEO_FRAMES_DIR): | |
| if name.startswith(f"video_{job_id}") or name.startswith(f"frame_{job_id}"): | |
| try: | |
| os.remove(os.path.join(VIDEO_FRAMES_DIR, name)) | |
| except OSError: | |
| pass | |
| def extract_video_frames(video_url: str, num_frames: int | None = None) -> str: | |
| """Downloads a video and extracts N evenly-spaced frames to local files. | |
| Use this for the VISUAL content of a video (charts, diagrams, on-screen | |
| text, people/objects). For the SPOKEN content use 'get_youtube_transcript' | |
| instead. The sampled frames are written to the video-frames folder and can | |
| be read one-by-one with 'inspect_image'. Requires yt-dlp and | |
| ffmpeg/ffprobe on PATH. | |
| Args: | |
| video_url: The full URL of the video (e.g., 'https://www.youtube.com/watch?v=...'). | |
| num_frames: Number of evenly-spaced frames to extract (defaults to the | |
| VIDEO_FRAMES_COUNT setting, usually 4). | |
| """ | |
| job_id = None | |
| try: | |
| import yt_dlp | |
| n = _resolve_frame_count(num_frames) | |
| os.makedirs(VIDEO_FRAMES_DIR, exist_ok=True) | |
| job_id = str(int(time.time() * 1000)) | |
| base_prefix = os.path.join(VIDEO_FRAMES_DIR, f"video_{job_id}") | |
| # Download the video. Bounding the height keeps the download small and | |
| # fast; noplaylist avoids pulling an entire playlist for one clip. | |
| ydl_opts = { | |
| "format": "bv*[height<=720]+ba/b[height<=720]/b", | |
| "outtmpl": base_prefix + ".%(ext)s", | |
| "noplaylist": True, | |
| "quiet": True, | |
| "no_warnings": True, | |
| "merge_output_format": "mp4", | |
| } | |
| with yt_dlp.YoutubeDL(ydl_opts) as ydl: | |
| ydl.extract_info(video_url, download=True) | |
| # Locate the actual downloaded file (the final extension depends on the | |
| # stream/merge chosen by yt-dlp, so glob for our prefix) | |
| downloaded = [ | |
| os.path.join(VIDEO_FRAMES_DIR, f) | |
| for f in os.listdir(VIDEO_FRAMES_DIR) | |
| if f.startswith(os.path.basename(base_prefix)) | |
| ] | |
| if not downloaded: | |
| return f"Error: Download completed but no video file was found for {video_url}" | |
| video_path = downloaded[0] | |
| try: | |
| # Probe the duration, then sample N evenly-spaced frame timestamps | |
| frame_paths = _extract_frames_from_video(video_path, n, job_id) | |
| finally: | |
| # The source video is no longer needed once frames are extracted | |
| try: | |
| os.remove(video_path) | |
| except OSError: | |
| pass | |
| lines = [f"Extracted {len(frame_paths)} evenly-spaced frames from the video:"] | |
| lines += [f" - {p}" for p in frame_paths] | |
| lines.append("Inspect each frame with 'inspect_image' to read its visual content.") | |
| return "\n".join(lines) | |
| except Exception as e: | |
| # Clean up this job's partial artifacts so failures never orphan files | |
| if job_id: | |
| _cleanup_job_artifacts(job_id) | |
| return f"Error extracting video frames: {e}" | |
| def inspect_pdf(pdf_path: str) -> str: | |
| """Extracts all text from a PDF file in a structured format. | |
| Args: | |
| pdf_path: The local path to the PDF file (e.g., 'downloads/document.pdf'). | |
| """ | |
| try: | |
| # Import pdfplumber library for text extraction | |
| import pdfplumber | |
| # Container to accumulate text page by page | |
| texts = [] | |
| # Open PDF file in pdfplumber handle context | |
| with pdfplumber.open(pdf_path) as pdf: | |
| # Iterate through each page of document with index | |
| for i, page in enumerate(pdf.pages): | |
| # Extract text content from current page | |
| content = page.extract_text() | |
| if content: | |
| # Append formatted header with page content | |
| texts.append(f"--- Page {i+1} ---\n{content}") | |
| # Join extracted page texts and truncate to 60,000 chars to avoid prompt context blowup | |
| return "\n".join(texts)[:60000] | |
| except Exception as e: | |
| # Return error message if reading PDF fails | |
| return f"Error reading PDF: {e}" | |
| def inspect_excel(file_path: str) -> str: | |
| """Reads an Excel file (.xlsx, .xls) and returns a summary of its sheets. | |
| Args: | |
| file_path: The local path to the Excel file (e.g., 'downloads/data.xlsx'). | |
| """ | |
| try: | |
| # Load Excel workbook structure into pandas ExcelFile reader | |
| xl = pd.ExcelFile(file_path) | |
| # Start summary list with sheet names found | |
| summary = [f"Sheets found: {xl.sheet_names}"] | |
| # Loop through each sheet name inside workbook | |
| for sheet in xl.sheet_names: | |
| # Read first 20 rows of current sheet into DataFrame | |
| df = pd.read_excel(xl, sheet_name=sheet).head(20) | |
| # Format sheet header and sample string table into summary | |
| summary.append(f"\n--- Sheet: {sheet} (Sample) ---\n{df.to_string()}") | |
| # Return joined summary string capped at 50,000 characters max | |
| return "\n".join(summary)[:50000] | |
| except Exception as e: | |
| # Return error message string if Excel read fails | |
| return f"Error reading Excel: {e}" | |
| def read_file_as_text(file_path: str) -> str: | |
| """Reads a plain text file like CSV, JSON, TXT, etc. | |
| Args: | |
| file_path: The local path to the text file (e.g., 'downloads/report.csv'). | |
| """ | |
| try: | |
| # Open target text file with UTF-8 encoding, replacing invalid characters | |
| with open(file_path, "r", encoding="utf-8", errors="replace") as f: | |
| content = f.read() | |
| # Truncate content to 50,000 characters if file is too large | |
| if len(content) > 50000: | |
| content = content[:50000] + "\n...[CONTENT TRUNCATED]..." | |
| # Return file text contents | |
| return content | |
| except Exception as e: | |
| # Return error message string if text read fails | |
| return f"Error reading file: {e}" | |
| # Shared vision model singletons (loaded once, reused across all worker threads) | |
| _vision_model = None | |
| _vision_processor = None | |
| _vision_lock = threading.Lock() | |
| def inspect_image(image_path: str, question: str = "") -> str: | |
| """Extracts the content of an image using a local vision model (Qwen2-VL-2B) plus OCR. | |
| Use this tool for image attachments (.png, .jpg, .jpeg, etc.). Pass the task | |
| question so the model focuses on the exact detail needed for the answer. | |
| Args: | |
| image_path: The local path to the image file (e.g., 'downloads/image.png'). | |
| question: The task question to focus the extraction on (optional). | |
| """ | |
| global _vision_model, _vision_processor | |
| try: | |
| # Import heavy libraries only when the tool is first invoked | |
| torch = _import_torch() | |
| from transformers import AutoProcessor | |
| # Resolve the correct auto class (newer transformers name, with older fallback) | |
| try: | |
| from transformers import AutoModelForImageTextToText as _AutoVL | |
| except ImportError: | |
| from transformers import AutoModelForVision2Seq as _AutoVL | |
| # Shared local-device resolver (LOCAL_DEVICE env: auto|cpu|mps|cuda) | |
| device = _resolve_local_device() | |
| # ββ Load the shared model + processor once (double-checked locking) ββ | |
| if _vision_model is None: | |
| with _vision_lock: | |
| if _vision_model is None: | |
| dtype, device_map = _vision_load_config(torch, device) | |
| _vision_processor = AutoProcessor.from_pretrained(VISION_MODEL_ID) | |
| _vision_model = _AutoVL.from_pretrained( | |
| VISION_MODEL_ID, | |
| torch_dtype=dtype, | |
| device_map=device_map, | |
| ) | |
| _vision_model.eval() | |
| if device == "cpu": | |
| _vision_model.to("cpu") | |
| elif device == "mps": | |
| _vision_model.to("mps") | |
| # ββ Open the image and normalize to RGB ββ | |
| image = Image.open(image_path).convert("RGB") | |
| # ββ Build a question-directed prompt for the VLM ββ | |
| if question and str(question).strip(): | |
| prompt_text = ( | |
| "Look carefully at this image and answer the following question. " | |
| "Extract the exact text, numbers, names, or details shown in the image " | |
| "that are needed to answer. Be precise and do not guess.\n" | |
| f"Question: {question}" | |
| ) | |
| else: | |
| prompt_text = ( | |
| "Describe the contents of this image in detail. " | |
| "Include all visible text, numbers, labels, and names exactly as shown." | |
| ) | |
| # ββ Run inference (serialized across worker threads) ββ | |
| with _vision_lock: | |
| try: | |
| # Preferred path: chat template (Qwen2-VL style) | |
| messages = [{ | |
| "role": "user", | |
| "content": [ | |
| {"type": "image"}, | |
| {"type": "text", "text": prompt_text}, | |
| ], | |
| }] | |
| text = _vision_processor.apply_chat_template( | |
| messages, tokenize=False, add_generation_prompt=True | |
| ) | |
| inputs = _vision_processor( | |
| text=[text], images=[image], return_tensors="pt" | |
| ) | |
| except (AttributeError, NotImplementedError): | |
| # Fallback path: plain image + text inputs | |
| inputs = _vision_processor( | |
| images=image, text=prompt_text, return_tensors="pt" | |
| ) | |
| # Move inputs to the compute device (accelerators only) | |
| if _is_accelerator(device): | |
| inputs = { | |
| k: v.to(device) for k, v in inputs.items() | |
| if isinstance(v, torch.Tensor) | |
| } | |
| with torch.no_grad(): | |
| outputs = _vision_model.generate( | |
| **inputs, | |
| max_new_tokens=512, | |
| do_sample=False, | |
| ) | |
| # Decode only the newly generated tokens | |
| input_len = inputs["input_ids"].shape[1] | |
| generated = _vision_processor.batch_decode( | |
| outputs[:, input_len:], skip_special_tokens=True | |
| ) | |
| vlm_text = " ".join(generated).strip() | |
| # ββ Best-effort OCR pass for literal text ββ | |
| ocr_text = "" | |
| try: | |
| import pytesseract | |
| ocr_text = pytesseract.image_to_string(image).strip() | |
| if len(ocr_text) > 3000: | |
| ocr_text = ocr_text[:3000] + "\n...[OCR TRUNCATED]..." | |
| except Exception: | |
| ocr_text = "" # OCR unavailable (e.g., Tesseract binary not installed) | |
| # ββ Combine results and cap the total length ββ | |
| result = f"VISION MODEL OUTPUT:\n{vlm_text}" | |
| if ocr_text: | |
| result += f"\n\nOCR TEXT:\n{ocr_text}" | |
| return result[:8000] | |
| except Exception as e: | |
| return f"Error analyzing image: {e}" | |
| def inspect_docx(file_path: str) -> str: | |
| """Extracts all text (paragraphs and tables) from a Word .docx file. | |
| Args: | |
| file_path: The local path to the .docx file (e.g., 'downloads/document.docx'). | |
| """ | |
| try: | |
| # Import python-docx library inside tool execution logic | |
| import docx | |
| # Open the Word document | |
| document = docx.Document(file_path) | |
| # Container for extracted text parts | |
| parts = [] | |
| # Collect non-empty paragraphs | |
| for para in document.paragraphs: | |
| text = para.text.strip() | |
| if text: | |
| parts.append(text) | |
| # Collect table contents row by row | |
| for t_idx, table in enumerate(document.tables): | |
| parts.append(f"\n--- Table {t_idx + 1} ---") | |
| for row in table.rows: | |
| cells = [cell.text.strip() for cell in row.cells] | |
| parts.append(" | ".join(cells)) | |
| # Return joined text capped at 50,000 characters | |
| return "\n".join(parts)[:50000] | |
| except Exception as e: | |
| # Return error message if .docx reading fails | |
| return f"Error reading .docx: {e}" | |
| def inspect_pptx(file_path: str) -> str: | |
| """Extracts all text from each slide of a PowerPoint .pptx file. | |
| Args: | |
| file_path: The local path to the .pptx file (e.g., 'downloads/presentation.pptx'). | |
| """ | |
| try: | |
| # Import python-pptx library inside tool execution logic | |
| from pptx import Presentation | |
| # Open the PowerPoint presentation | |
| prs = Presentation(file_path) | |
| # Container for extracted slide text | |
| parts = [] | |
| # Loop over every slide with its index | |
| for s_idx, slide in enumerate(prs.slides): | |
| slide_texts = [] | |
| # Iterate shapes (text boxes, tables, etc.) in the slide | |
| for shape in slide.shapes: | |
| if hasattr(shape, "text") and shape.text.strip(): | |
| slide_texts.append(shape.text.strip()) | |
| if slide_texts: | |
| # Append formatted slide header with its text lines | |
| parts.append(f"--- Slide {s_idx + 1} ---\n" + "\n".join(slide_texts)) | |
| # Return joined slide text capped at 50,000 characters | |
| return "\n\n".join(parts)[:50000] | |
| except Exception as e: | |
| # Return error message if .pptx reading fails | |
| return f"Error reading .pptx: {e}" | |
| def _safe_extract_all(zf, extract_dir: str) -> tuple[list[str], list[str]]: | |
| """Extracts every member of an open ``zipfile.ZipFile`` without path traversal. | |
| Never uses ``extractall()``: each member's resolved destination is checked | |
| to stay inside ``extract_dir``, absolute paths and ``..`` traversal are | |
| rejected, and members are streamed out as plain files so symlink entries | |
| cannot be followed outside the extraction folder. | |
| Returns: | |
| A ``(extracted, skipped)`` pair: the names of members written, and the | |
| names of unsafe members that were rejected. | |
| """ | |
| base = os.path.realpath(extract_dir) | |
| os.makedirs(base, exist_ok=True) | |
| extracted: list[str] = [] | |
| skipped: list[str] = [] | |
| for member in zf.infolist(): | |
| # Normalize Windows separators and reject absolute/escaping paths | |
| member_path = member.filename.replace("\\", "/") | |
| if member_path.startswith("/") or ".." in member_path.split("/"): | |
| skipped.append(member.filename) | |
| continue | |
| target = os.path.realpath(os.path.join(base, member_path)) | |
| # Guard: resolved target must stay inside the extraction folder | |
| if not (target == base or target.startswith(base + os.sep)): | |
| skipped.append(member.filename) | |
| continue | |
| if member.is_dir(): | |
| os.makedirs(target, exist_ok=True) | |
| extracted.append(member.filename) | |
| continue | |
| # Stream the member out as a plain file (never follow symlinks) | |
| os.makedirs(os.path.dirname(target) or base, exist_ok=True) | |
| with zf.open(member, "r") as src, open(target, "wb") as dst: | |
| shutil.copyfileobj(src, dst) | |
| extracted.append(member.filename) | |
| return extracted, skipped | |
| def extract_zip(zip_path: str, extract_dir: str = "") -> str: | |
| """Lists and extracts the contents of a .zip archive into a folder. | |
| Args: | |
| zip_path: The local path to the .zip file (e.g., 'downloads/files.zip'). | |
| extract_dir: The folder to extract into (defaults to the files directory). | |
| """ | |
| try: | |
| # Import zipfile library inside tool execution logic | |
| import zipfile | |
| # Default extraction folder next to the attachment files | |
| if not extract_dir: | |
| extract_dir = os.path.join(LOCAL_FILES_DIR, "_extracted") | |
| # Open the archive and extract entries safely (no extractall / zip-slip) | |
| with zipfile.ZipFile(zip_path, "r") as zf: | |
| extracted, skipped = _safe_extract_all(zf, extract_dir) | |
| # Build summary listing every extracted entry | |
| summary = [f"Extracted {len(extracted)} entries to '{extract_dir}':"] | |
| for name in extracted: | |
| summary.append(f" - {name}") | |
| # Surface any rejected unsafe entries so the Agent knows they were skipped | |
| if skipped: | |
| summary.append(f"\nβ οΈ Skipped {len(skipped)} unsafe entries (path traversal):") | |
| for name in skipped: | |
| summary.append(f" - {name}") | |
| # Return listing capped at 20,000 characters | |
| return "\n".join(summary)[:20000] | |
| except Exception as e: | |
| # Return error message if .zip extraction fails | |
| return f"Error extracting .zip: {e}" | |
| # βββ Main Agent Class ββββββββββββββββββββββββββββββββββββββββββββββ | |
| class _RetryingOpenAIServerModel(OpenAIServerModel): | |
| """OpenAIServerModel that retries transient model/network errors with backoff. | |
| smolagents already retries rate-limit errors internally; this wrapper adds | |
| retries for connection/timeout/5xx failures so a transient network blip | |
| cannot fail a Task. The Agent's step memory is preserved across retries | |
| (unlike retrying the whole ``agent.run``), so no tool side-effects repeat. | |
| """ | |
| def generate(self, messages, **kwargs): | |
| # Zero-arg super() only works directly in a method body β it is NOT | |
| # propagated into nested lambdas (no __class__ cell), so capture it | |
| # here and let the retry lambda call the bound base method. | |
| base = super() | |
| return _call_with_retry( | |
| lambda: base.generate(messages, **kwargs), | |
| description="model call", | |
| ) | |
| class GAIASolverAgent: | |
| def __init__(self, *, multimodal: bool = True): | |
| """Builds the ReAct solver Agent. | |
| Args: | |
| multimodal: When True (default, local harness), the full toolset is | |
| registered, including the local vision model (inspect_image), | |
| local audio transcription (transcribe_audio), and video frame | |
| sampling (extract_video_frames). When False (live-on-Space | |
| fallback), only text/web + document tools are registered, so no | |
| heavy local model is ever loaded β per ADR-0002 the Space | |
| fallback is text/web-only (no GPU, no hosted key). | |
| """ | |
| # Validate that DEEPSEEK_API_KEY is available before proceeding | |
| if not DEEPSEEK_API_KEY: | |
| raise ValueError("β οΈ The environment variable 'DEEPSEEK_API_KEY' is not configured.") | |
| # Initialize retrying model pointing to configured API base and model | |
| self.main_model = _RetryingOpenAIServerModel( | |
| model_id=AGENT_MODEL, | |
| api_base=DEEPSEEK_API_BASE, | |
| api_key=DEEPSEEK_API_KEY, | |
| ) | |
| # Text/web-only base + document tools, present in both modes | |
| tools = [ | |
| DuckDuckGoSearchTool(), # Web search tool | |
| VisitWebpageTool(), # Web scraping/browsing tool | |
| get_youtube_transcript, # YouTube transcript extraction tool (spoken content) | |
| inspect_pdf, # PDF text extraction tool | |
| inspect_excel, # Excel sheet inspector tool | |
| read_file_as_text, # General text/CSV/JSON reader tool | |
| inspect_docx, # Word .docx text extraction tool | |
| inspect_pptx, # PowerPoint .pptx text extraction tool | |
| extract_zip, # Zip archive extraction tool | |
| ] | |
| if multimodal: | |
| # Local-model tools (ADR-0002): vision, audio, and video frames. | |
| # Excluded from the Space fallback so Qwen2-VL / faster-whisper are | |
| # never loaded on the constrained free Space. | |
| tools += [ | |
| transcribe_audio, # Audio transcription tool (local faster-whisper) | |
| extract_video_frames, # Video frame-sampling tool (visual content) | |
| inspect_image, # Image vision tool (Qwen2-VL-2B + OCR) | |
| ] | |
| # Instantiate CodeAgent from smolagents framework | |
| self.agent = CodeAgent( | |
| tools=tools, | |
| model=self.main_model, | |
| # Explicit Python module imports authorized for code-execution agent actions | |
| additional_authorized_imports=[ | |
| # ββ Standard Python Libraries ββ | |
| "sys", "os", "pathlib", "glob", "shutil", "tempfile", | |
| "hashlib", "base64", "math", "statistics", "random", | |
| "re", "string", "datetime", "time", "json", "csv", | |
| "xml", "html", "urllib", "urllib.parse", "io", "copy", | |
| "collections", "itertools", "functools", "operator", | |
| "typing", "dataclasses", "unicodedata", "wave", "zipfile", | |
| "tarfile", "sqlite3", "uuid", "ast", "inspect", | |
| # ββ Data Science & Math ββ | |
| "numpy", "pandas", "scipy", "scipy.stats", "scipy.optimize", | |
| "matplotlib", "matplotlib.pyplot", "seaborn", "sympy", | |
| "sklearn", "stat", | |
| # ββ File & Image Processing ββ | |
| "PIL", "PIL.Image", "PIL.ImageDraw", "cv2", | |
| "openpyxl", "xlrd", "pdfplumber", "PyPDF2", "pypdf", | |
| "fitz", "docx", "pptx", | |
| # ββ Web, Scraping & Networking ββ | |
| "requests", "bs4", "lxml", "queue" | |
| ], | |
| # Set upper limit on agent reasoning steps per task execution run | |
| max_steps=25, | |
| ) | |
| def _clean_answer(self, question: str, raw_answer: str) -> str: | |
| """Uses the DeepSeek model to structure the clean final answer synchronously per thread.""" | |
| try: | |
| # Build the cleaning prompt via replacement interpolation (safe for | |
| # Answers/Questions containing braces) and raise the token budget so | |
| # long comma-separated list Answers are not truncated | |
| prompt = _build_cleaning_prompt(question, raw_answer) | |
| return _chat_answer(CLEANING_MODEL, prompt, description="answer cleaning") | |
| except Exception: | |
| # Fallback to standard normalization regex if API formatting call fails | |
| return _normalize_answer_for_submission(raw_answer) | |
| def __call__(self, task_id: str, question: str, file_name: str) -> str: | |
| """Solves a Task and returns just the final Answer (see ``solve``).""" | |
| return self.solve(task_id, question, file_name)[0] | |
| def solve(self, task_id: str, question: str, file_name: str, | |
| file_path: str | None = None) -> tuple[str, dict]: | |
| """Solves a Task and returns ``(final_answer, worklog)``. | |
| ``worklog`` is the structured serialization of this Agent's recorded | |
| ReAct steps (thought / tool call / observation per step) plus a per-tool | |
| summary with timing, captured fresh from the Agent's memory after the | |
| run. On failure it returns an ``Error: ...`` Answer and an empty Worklog | |
| so the Run continues; the Task stays out of the Answer bundle and is | |
| re-solved on the next Run. | |
| ``file_path`` is the local path to the Task's Attachment. When omitted, | |
| the pre-downloaded file is located via ``_get_local_task_file`` (local | |
| harness behavior); when provided (Space live fallback, where the | |
| Attachment was just downloaded from the server), it is used directly. | |
| """ | |
| # Ensure the Worklog only reflects THIS Task. Agent instances are reused | |
| # across Tasks in the worker pool, and run() resets memory only once it | |
| # actually starts, so clear it before any work can fail (a failure before | |
| # run() would otherwise serialize the previous Task's stale trace). | |
| try: | |
| self.agent.memory.reset() | |
| except Exception: | |
| pass # fresh agent: memory is already empty | |
| try: | |
| # Locate the Attachment: an explicitly-provided download path (Space | |
| # live fallback) wins; otherwise look for the pre-downloaded file in | |
| # the local files directory (local harness) | |
| if file_path is None: | |
| file_path = _get_local_task_file(task_id, file_name) | |
| # Construct agent task prompt | |
| prompt = f"TASK: {question}\n\n" | |
| prompt += "### FORMATTING RULES:\n" | |
| prompt += "- If you need to write or run Python code, you MUST wrap the code block strictly inside <code>\n[your python code]\n</code> tags.\n" | |
| prompt += "- Ensure there is a newline after the opening tag and before the closing tag.\n\n" | |
| if file_path: | |
| ext = file_path.lower().split('.')[-1] | |
| if ext in ('png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'): | |
| prompt += f"πΌοΈ IMAGE saved at '{file_path}'. Use 'inspect_image' with the task question to extract its content.\n\n" | |
| elif ext in ('mp3', 'wav', 'm4a', 'ogg', 'flac'): | |
| prompt += f"ποΈ AUDIO saved at '{file_path}'. Use 'transcribe_audio' (local faster-whisper).\n\n" | |
| elif ext in ('pdf',): | |
| prompt += f"π PDF saved at '{file_path}'. Use 'inspect_pdf' or Python libraries like pdfplumber/fitz.\n\n" | |
| elif ext in ('xlsx', 'xls'): | |
| prompt += f"π EXCEL saved at '{file_path}'. Use 'inspect_excel' or Python pandas.\n\n" | |
| elif ext in ('docx',): | |
| prompt += f"π WORD saved at '{file_path}'. Use 'inspect_docx' to extract text and tables.\n\n" | |
| elif ext in ('pptx',): | |
| prompt += f"π½οΈ POWERPOINT saved at '{file_path}'. Use 'inspect_pptx' to extract slide text.\n\n" | |
| elif ext in ('zip',): | |
| prompt += f"ποΈ ZIP ARCHIVE saved at '{file_path}'. Use 'extract_zip' to list and extract its contents.\n\n" | |
| else: | |
| prompt += f"π FILE saved at '{file_path}'. Use 'read_file_as_text' or open with Python.\n\n" | |
| elif file_name and str(file_name).strip(): | |
| prompt += f"β οΈ Note: Attachment '{file_name}' was specified but not found locally in files directory.\n\n" | |
| if "youtube.com" in question.lower() or "youtu.be" in question.lower(): | |
| prompt += ( | |
| "πΉ YOUTUBE: Use 'get_youtube_transcript' for the SPOKEN content, " | |
| "and 'extract_video_frames' to sample frames if the answer depends " | |
| "on VISUAL content (charts, diagrams, on-screen text). Use whichever " | |
| "the Question needs β or both.\n\n" | |
| ) | |
| prompt += "CRITICAL INSTRUCTIONS:\n1. Answer concisely.\n2. Provide the exact data required.\n" | |
| # Run agent loop (text-only; image attachments are handled via the | |
| # 'inspect_image' tool). Transient model/network failures are retried | |
| # inside _RetryingOpenAIServerModel.generate, which preserves the | |
| # Agent's step memory (retrying the whole loop would repeat tool | |
| # side-effects) | |
| raw_answer = self.agent.run(prompt) | |
| raw_answer_str = str(raw_answer).strip() | |
| raw_answer_str = re.sub(r'<think>.*?</think>', '', raw_answer_str, flags=re.DOTALL).strip() | |
| final_answer = _normalize_answer_for_submission( | |
| self._clean_answer(question, raw_answer_str) | |
| ) | |
| # Capture the Agent's recorded steps (thought / tool call / | |
| # observation) into the structured Worklog before anything else | |
| # can mutate this Agent's memory | |
| worklog = _serialize_worklog(self.agent.memory.steps, status="completed") | |
| return final_answer, worklog | |
| except Exception as e: | |
| # Keep the partial trace gathered before the failure so the Worklog | |
| # still shows how far the Agent got (status "error" + the reason) | |
| memory = getattr(self.agent, "memory", None) | |
| steps = memory.steps if memory is not None else [] | |
| worklog = _serialize_worklog(steps, status="error") | |
| worklog["error"] = str(e) | |
| return f"Error: {e}", worklog | |
| # βββ Forced-answer synthesis helpers (Part B) βββββββββββββββββββββββββ | |
| # When a Task hits the soft deadline, its partial Worklog is snapshotted and a | |
| # best-effort Answer is synthesized from it (decisions B1-B4) so something real | |
| # is still submitted with status "forced". | |
| def _format_partial_trace(worklog: dict) -> str: | |
| """Formats a (partial) Worklog into a compact readable trace for synthesis.""" | |
| lines = [] | |
| for step in worklog.get("steps", []): | |
| thought = (step.get("thought") or "").strip() | |
| if thought: | |
| lines.append(f"Thought: {thought}") | |
| for call in step.get("tool_calls") or []: | |
| name = call.get("name", "unknown") | |
| args = _coerce_tool_arguments(call.get("arguments", "")) | |
| lines.append(f"Tool: {name}({json.dumps(args, ensure_ascii=False)})") | |
| obs = (step.get("observations") or "").strip() | |
| if obs: | |
| lines.append(f"Observation: {obs[:2000]}") | |
| return "\n".join(lines) | |
| def _synthesize_forced_answer(question: str, partial_trace: str) -> str: | |
| """Synthesizes a best-effort final Answer from a partial reasoning trace. | |
| Uses the cleaning-model path (DeepSeek, retried via ``_call_with_retry``) | |
| with the forced-answer prompt. The caller bounds this call (via | |
| ``_run_with_timeout``) so the total stays under the hard cap. Raises on | |
| model failure β the worker then records the Task as ``timeout`` (decision | |
| B2: a failed synthesis is NOT submitted). | |
| """ | |
| prompt = _build_forced_answer_prompt(question, partial_trace) | |
| text = _chat_answer(CLEANING_MODEL, prompt, description="forced answer") | |
| if not text: | |
| raise ValueError("Forced-answer synthesis returned an empty answer") | |
| return _normalize_answer_for_submission(text) | |
| def _forced_answer_budget() -> float: | |
| """Seconds available for forced-answer synthesis before the hard cap. | |
| Clamped to ``FORCE_ANSWER_TIMEOUT_SEC`` and to the remaining hard-soft | |
| budget; 0 (or negative) when the hard cap leaves no room, which means the | |
| Task must be recorded as a plain timeout instead. | |
| """ | |
| remaining = TASK_HARD_TIMEOUT_SEC - TASK_SOFT_TIMEOUT_SEC | |
| return max(0.0, min(FORCE_ANSWER_TIMEOUT_SEC, remaining)) | |
| def _snapshot_partial_worklog(agent) -> dict: | |
| """Best-effort snapshot of an Agent's partial ReAct trace at the soft deadline. | |
| The wedged ``solve`` daemon helper thread may still be mutating | |
| ``agent.agent.memory.steps`` while this runs, so the read is deliberately | |
| racy: it serializes whatever steps are visible and, if the in-flight | |
| mutation makes serialization fail, falls back to an empty trace (the | |
| synthesis then produces a best-effort Answer from the Question alone). | |
| """ | |
| try: | |
| memory = getattr(getattr(agent, "agent", None), "memory", None) | |
| steps = memory.steps if memory is not None else [] | |
| return _serialize_worklog(steps, status="forced") | |
| except Exception: | |
| worklog = _serialize_worklog([], status="forced") | |
| worklog["error"] = "Partial Worklog unavailable at the soft deadline" | |
| return worklog | |
| # βββ Asynchronous Threaded Console Script Execution ββββββββββββββββββββββ | |
| def _make_result(idx, task, answer, elapsed, *, worklog=None, source="live", | |
| status="completed", timestamp=None): | |
| """Builds a structured result record for a Task. | |
| ``source`` is "live" (solved by the Agent this Run) or "bundle" (submitted | |
| from the committed Answer bundle). ``status`` is "completed", "forced" | |
| (Answer synthesized from a partial trace at the soft deadline), "error", | |
| or "timeout". The record stays consistent with the Answer bundle entry for | |
| the same Task (same task_id / answer / worklog / timestamp). | |
| """ | |
| return { | |
| "index": idx + 1, | |
| "task_id": task.get("task_id", "???"), | |
| "level": task.get("Level", "?"), | |
| "file_name": task.get("file_name", "") or "None", | |
| "question": task.get("question", ""), | |
| "answer": answer, | |
| "execution_time_sec": round(elapsed, 2), | |
| "source": source, | |
| "status": status, | |
| "timestamp": timestamp or _now_iso(), | |
| "worklog": worklog, | |
| } | |
| def _make_bundled_result(idx, task, entry): | |
| """Builds a result record for a Task submitted from the Answer bundle. | |
| The bundle is authoritative for what was actually submitted (Answer, | |
| Worklog, timestamp, and the bundled Question/Level/file_name); the server's | |
| Task metadata is used only as a fallback for missing fields. | |
| """ | |
| result = _make_result( | |
| idx, task, entry.get("answer", ""), 0.0, | |
| worklog=entry.get("worklog"), source="bundle", status="completed", | |
| timestamp=entry.get("timestamp", _now_iso()), | |
| ) | |
| result["level"] = entry.get("level", result["level"]) | |
| result["file_name"] = entry.get("file_name", result["file_name"]) | |
| result["question"] = entry.get("question", result["question"]) | |
| return result | |
| def _timeout_result(idx, task, t0) -> dict: | |
| """Builds the hard-cap timeout result record (not submitted, decision B2/B3).""" | |
| return _make_result( | |
| idx, task, | |
| f"Error: Task timed out after {TASK_HARD_TIMEOUT_SEC:.0f}s", | |
| time.time() - t0, worklog=None, source="live", status="timeout", | |
| ) | |
| def _run_task(agent, idx, task) -> tuple[dict, bool]: | |
| """Solves one Task under the two-stage soft/hard timeout. | |
| Returns ``(result, replace_agent)`` where ``result`` is the structured | |
| result record (status ``completed``, ``forced``, ``timeout``, or ``error``) | |
| and ``replace_agent`` is True only when the Agent's ``solve`` was | |
| interrupted at the soft deadline β its daemon helper thread may still be | |
| wedged, so it must not be reused (the caller swaps in a fresh Agent). | |
| The two-stage budget (decisions B1-B4): | |
| - Within ``TASK_SOFT_TIMEOUT_SEC`` the normal result is used. | |
| - Otherwise the partial Worklog is snapshotted (best-effort β the wedged | |
| thread may still be mutating ``agent.memory.steps``) and a best-effort | |
| Answer is synthesized from it, bounded by ``_forced_answer_budget()`` so | |
| the total stays under the hard cap. Synthesis success β status ``forced`` | |
| (submitted); failure or hard cap β status ``timeout`` (not submitted). | |
| """ | |
| t0 = time.time() | |
| try: | |
| answer, worklog = _run_with_timeout( | |
| lambda: agent.solve( | |
| task.get("task_id", "???"), | |
| task.get("question", ""), | |
| task.get("file_name", ""), | |
| ), | |
| TASK_SOFT_TIMEOUT_SEC, | |
| ) | |
| return ( | |
| _make_result(idx, task, answer, time.time() - t0, | |
| worklog=worklog, source="live", | |
| status=worklog.get("status", "completed")), | |
| False, | |
| ) | |
| except TimeoutError: | |
| # Soft deadline reached. The wedged Agent must always be discarded | |
| # (replace_agent=True), whether the synthesis succeeds (forced) or not | |
| # (timeout). | |
| budget = _forced_answer_budget() | |
| if budget <= 0: | |
| # No room left before the hard cap: record a plain timeout. | |
| return _timeout_result(idx, task, t0), True | |
| try: | |
| partial_worklog = _snapshot_partial_worklog(agent) | |
| answer = _run_with_timeout( | |
| lambda: _synthesize_forced_answer( | |
| task.get("question", ""), | |
| _format_partial_trace(partial_worklog), | |
| ), | |
| budget, | |
| ) | |
| return ( | |
| _make_result(idx, task, answer, time.time() - t0, | |
| worklog=partial_worklog, source="live", | |
| status="forced"), | |
| True, | |
| ) | |
| except Exception: | |
| # Forced-answer synthesis failed or hit the hard cap: record a | |
| # plain timeout (not submitted, per decision B2/B3). | |
| return _timeout_result(idx, task, t0), True | |
| except Exception as e: | |
| # A throwing Task must not lose the worker: record an error Answer so | |
| # the Run still completes for every Task. The Agent is still reusable. | |
| return ( | |
| _make_result(idx, task, f"Error: {e}", time.time() - t0, | |
| worklog=None, source="live", status="error"), | |
| False, | |
| ) | |
| def run_pipeline_and_save_csv(force: bool = False): | |
| """Fetches questions, solves them, and writes the Answer bundle + results CSV. | |
| Args: | |
| force: When True, re-solve every Task from scratch, ignoring the | |
| committed Answer bundle. When False (default), Tasks already solved | |
| in the bundle are submitted from it without calling the Agent; | |
| only Tasks missing from the bundle are solved live. Every Task that | |
| solves successfully is recorded into the Answer bundle, so the next | |
| Run solves strictly fewer Tasks. | |
| """ | |
| print("Base: Connecting to the GAIA server to fetch questions...") | |
| try: | |
| # Send HTTP GET request to download questions dataset JSON from server, | |
| # retrying transient network failures with backoff | |
| def _fetch_questions(): | |
| response = requests.get(f"{DEFAULT_API_URL}/questions", timeout=15) | |
| response.raise_for_status() | |
| return response.json() | |
| questions_data = _call_with_retry(_fetch_questions, description="questions fetch") | |
| # If question list is empty, exit pipeline | |
| if not questions_data: | |
| print("β οΈ No questions were retrieved from the server.") | |
| return | |
| except Exception as e: | |
| # Print error message and cancel run if question retrieval fails | |
| print(f"β Error downloading the questions dataset: {e}") | |
| return | |
| total = len(questions_data) | |
| print(f"π {total} questions loaded.") | |
| # Load the committed Answer bundle (empty on the first Run) and classify | |
| # Tasks: bundled Tasks are submitted from the bundle; the rest are solved | |
| # live below (re-Runs only solve Tasks missing from the bundle, per the | |
| # spec / ADR-0001). | |
| bundle = _load_answer_bundle() | |
| bundled_ids = set(bundle) | |
| completed_results = [] | |
| pending = [] | |
| for i, task in enumerate(questions_data): | |
| task_id = task.get("task_id") | |
| if not task_id: | |
| continue | |
| if not force and task_id in bundled_ids: | |
| completed_results.append(_make_bundled_result(i, task, bundle[task_id])) | |
| else: | |
| pending.append((i, task)) | |
| bundled_count = len(completed_results) | |
| solved_count = len(pending) | |
| if bundled_count: | |
| print(f"π¦ {bundled_count}/{total} Tasks already in the Answer bundle β " | |
| f"submitting from the bundle (Agent not called).") | |
| if solved_count: | |
| print(f"π Solving {solved_count}/{total} Tasks live (missing from the bundle) " | |
| f"with up to {NUM_WORKERS} parallel agents (DeepSeek)...") | |
| # ββ Solve the pending Tasks with the Agent pool (lazy: no Agent is built | |
| # ββ unless there is actually something to solve, so a fully-bundled Run | |
| # ββ does not need a DeepSeek key) βββββββββββββββββββββββββββββββββββββ | |
| if pending: | |
| try: | |
| agents = [GAIASolverAgent() for _ in range(min(NUM_WORKERS, len(pending)))] | |
| except Exception as e: | |
| print(f"β Error creating the agent pool: {e}") | |
| # Record an error row per pending Task so the Run still completes | |
| for i, task in pending: | |
| completed_results.append( | |
| _make_result(i, task, f"Error: {e}", 0.0, | |
| worklog=None, source="live", status="error") | |
| ) | |
| else: | |
| # Create thread-safe queue to manage available GAIASolverAgent | |
| # instances across threads | |
| agent_queue = queue.Queue() | |
| for a in agents: | |
| agent_queue.put(a) # Put each worker agent into queue | |
| def _resolve(idx, task): | |
| """Worker thread task execution wrapper.""" | |
| # Pop an available agent instance from queue (blocking if all are busy) | |
| my_agent = agent_queue.get() | |
| t0 = time.time() | |
| replaced = False | |
| try: | |
| # Delegate the two-stage soft/hard timeout (and forced-answer | |
| # synthesis at the soft deadline) to _run_task | |
| result, must_replace = _run_task(my_agent, idx, task) | |
| replaced = must_replace | |
| if must_replace: | |
| # The Agent may still be wedged in a hung call on a | |
| # daemon helper thread. Never reuse a possibly-corrupted | |
| # Agent: drop it and put a fresh one in its place so the | |
| # pool stays at full strength | |
| try: | |
| agent_queue.put(GAIASolverAgent()) | |
| except Exception as e: | |
| print(f"β οΈ Could not replace a timed-out Agent: {e}") | |
| return result | |
| except Exception as e: | |
| # A throwing Task must not lose the worker: record an error | |
| # Answer for this Task so the Run still completes for every | |
| # Task | |
| return _make_result(idx, task, f"Error: {e}", time.time() - t0, | |
| worklog=None, source="live", status="error") | |
| finally: | |
| # Always return the Agent to the pool (unless it was | |
| # replaced above) so no worker is ever lost and the queue | |
| # can never deadlock, even when a Task throws | |
| if not replaced: | |
| agent_queue.put(my_agent) | |
| print(f"\nπ Processing {len(pending)} Tasks asynchronously in parallel...") | |
| # Initialize ThreadPoolExecutor with maximum configured thread workers | |
| executor = ThreadPoolExecutor(max_workers=NUM_WORKERS) | |
| futures = { | |
| executor.submit(_resolve, i, task): (i, task) | |
| for i, task in pending | |
| } | |
| total_tasks = len(futures) | |
| completed = 0 | |
| try: | |
| # Consume results in submission order. Each worker enforces its | |
| # own soft/hard timeout internally, so every future resolves in | |
| # bounded time | |
| for future, (i, task) in futures.items(): | |
| try: | |
| res = future.result() | |
| except Exception as e: | |
| # Unexpected executor-level failure (worker already | |
| # handles Task-level errors); still record an Answer | |
| res = _make_result(i, task, f"Error: {e}", 0.0, | |
| worklog=None, source="live", status="error") | |
| completed += 1 | |
| completed_results.append(res) # Store result record | |
| # Fold a successfully solved Task into the Answer bundle so | |
| # the next Run submits it without re-solving. Completed and | |
| # forced Tasks (a synthesized soft-deadline Answer that IS | |
| # submitted β decision B3) are folded; error/timeout Tasks | |
| # stay out and are retried next Run. | |
| if res["status"] in ("completed", "forced"): | |
| bundle[res["task_id"]] = _make_bundle_entry( | |
| task_id=res["task_id"], | |
| question=res["question"], | |
| level=res["level"], | |
| file_name=res["file_name"], | |
| answer=res["answer"], | |
| worklog=res["worklog"], | |
| timestamp=res["timestamp"], | |
| ) | |
| # Print live task progress log to stdout | |
| print(f"β [{completed}/{total_tasks}] Task #{res['index']} " | |
| f"({res['source']}) {res['status']} in " | |
| f"{res['execution_time_sec']}s | Answer: {res['answer']}") | |
| finally: | |
| # Worker threads finish their current Task within | |
| # TASK_SOFT_TIMEOUT_SEC + FORCE_ANSWER_TIMEOUT_SEC, so | |
| # shutdown(wait=True) completes promptly; wedged daemon helper | |
| # threads cannot block the process at exit | |
| executor.shutdown(wait=True, cancel_futures=True) | |
| # Re-sort results list by original dataset question index ordering | |
| completed_results = sorted(completed_results, key=lambda x: x["index"]) | |
| # Keep the bundle consistent with this Run and the served Question set: | |
| # (1) a force re-solve that now FAILS a previously-bundled Task must not | |
| # leave a stale entry behind (the next non-forced Run would resubmit the | |
| # old Answer while this Run's CSV shows an error) β drop it so it is | |
| # retried. A forced (soft-deadline) result is kept, not dropped: it was | |
| # already folded above and IS submitted and scored (decision B3), so it | |
| # must not be treated like an error/timeout; (2) Tasks the server no | |
| # longer serves are pruned so the bundle stays consistent with the results | |
| # CSV and never outgrows the served set. | |
| if force: | |
| for res in completed_results: | |
| if res["source"] == "live" and res["status"] not in ("completed", "forced"): | |
| bundle.pop(res["task_id"], None) | |
| served_ids = {t.get("task_id") for t in questions_data if t.get("task_id")} | |
| for task_id in list(bundle): | |
| if task_id not in served_ids: | |
| bundle.pop(task_id, None) | |
| # Write the committed Answer bundle (JSON keyed by task_id) so the Space | |
| # can submit without re-solving, then the results CSV | |
| _save_answer_bundle(bundle, ANSWER_BUNDLE_PATH) | |
| csv_columns = [ | |
| "index", "task_id", "level", "file_name", "question", "answer", | |
| "execution_time_sec", "source", "status", "timestamp", | |
| ] | |
| df_output = pd.DataFrame(completed_results, columns=csv_columns) | |
| df_output.to_csv(RESULTS_CSV_PATH, index=False, encoding="utf-8") | |
| # Print completion summary message and target output paths | |
| print(f"\n{'β'*50}") | |
| print(f"π PROCESS COMPLETED SUCCESSFULLY") | |
| print(f"πΎ Answer bundle ({len(bundle)} entries) saved to: {os.path.abspath(ANSWER_BUNDLE_PATH)}") | |
| print(f"πΎ Results saved to: {os.path.abspath(RESULTS_CSV_PATH)}") | |
| print(f"{'β'*50}\n") | |
| # Python script entry point check | |
| if __name__ == "__main__": | |
| import argparse | |
| parser = argparse.ArgumentParser( | |
| description="Run the local GAIA solver and write the committed Answer " | |
| "bundle + results CSV." | |
| ) | |
| parser.add_argument( | |
| "--force", action="store_true", | |
| help="Force a full re-solve, ignoring the committed Answer bundle.", | |
| ) | |
| args = parser.parse_args() | |
| # Execute full pipeline when script is run directly from command line | |
| run_pipeline_and_save_csv(force=args.force) |