# ========================================================== # agent.py — Flat routing, single model path for all questions # ========================================================== import os import re import sys import json import tempfile import subprocess from pathlib import Path from urllib.parse import urlparse from dotenv import load_dotenv from langgraph.graph import START, StateGraph, MessagesState from langgraph.prebuilt import ToolNode, tools_condition from langchain_groq import ChatGroq from langchain_core.tools import tool from langchain_core.messages import SystemMessage from langchain_community.document_loaders import WikipediaLoader from langchain_community.tools import DuckDuckGoSearchResults import requests from bs4 import BeautifulSoup import pandas as pd try: from youtube_transcript_api import YouTubeTranscriptApi except Exception: YouTubeTranscriptApi = None load_dotenv() # ========================================================== # OBSERVABILITY GLOBALS # ========================================================== LAST_MODEL_USED = "N/A" LAST_MODEL_FALLBACK = "No" LAST_MODEL_ERROR = "None" # ========================================================== # TOOLS # ========================================================== @tool def wiki_search(query: str) -> str: """Search Wikipedia for encyclopedic facts, biographies, history, and stable knowledge.""" try: docs = WikipediaLoader(query=query, load_max_docs=2).load() if not docs: return "No Wikipedia results found. Try web_search instead." return "\n\n".join(d.page_content[:2000] for d in docs) except Exception as e: return f"Wikipedia unavailable: {type(e).__name__}. Try web_search instead." @tool def web_search(query: str) -> str: """Search the web and return compact title/url/snippet results.""" try: result = DuckDuckGoSearchResults( max_results=5, output_format="list", ).run(query) return result if result else "No results found. Try a different query." except Exception as e: return f"Web search unavailable: {type(e).__name__}. Try wiki_search instead." @tool def fetch_page(url: str) -> str: """Fetch and extract readable text from a URL.""" try: r = requests.get(url, timeout=12, headers={"User-Agent": "Mozilla/5.0"}) r.raise_for_status() soup = BeautifulSoup(r.text, "html.parser") for tag in soup(["script", "style", "nav", "footer", "header"]): tag.decompose() text = soup.get_text(separator="\n", strip=True) return text[:8000] if text else "Page appears empty." except Exception as e: return f"Could not fetch page: {type(e).__name__}." def _youtube_video_id(text: str) -> str | None: patterns = [ r"(?:v=|youtu\.be/|shorts/|embed/)([A-Za-z0-9_-]{11})", r"^([A-Za-z0-9_-]{11})$", ] for pattern in patterns: match = re.search(pattern, text) if match: return match.group(1) return None @tool def youtube_transcript(video_url_or_id: str) -> str: """Get available YouTube captions/transcript for a video URL or video id.""" if YouTubeTranscriptApi is None: return "YouTube transcript library is unavailable." video_id = _youtube_video_id(video_url_or_id) if not video_id: return "Could not identify a YouTube video id." try: rows = YouTubeTranscriptApi.get_transcript(video_id, languages=["en", "en-US", "en-GB"]) except Exception: try: rows = YouTubeTranscriptApi.get_transcript(video_id) except Exception as e: return f"Transcript unavailable: {type(e).__name__}. Use web_search for quotes or descriptions." lines = [] for row in rows[:220]: start = int(float(row.get("start", 0))) text = " ".join(str(row.get("text", "")).split()) if text: lines.append(f"{start}s: {text}") return "\n".join(lines)[:7000] if lines else "Transcript is empty." def _download_to_temp(source: str) -> tuple[Path | None, str]: if re.fullmatch(r"[0-9a-fA-F-]{36}", source.strip()): source = f"https://agents-course-unit4-scoring.hf.space/files/{source.strip()}" if not source.startswith(("http://", "https://")): return None, "Input must be a URL or benchmark task_id." try: r = requests.get(source, timeout=25, headers={"User-Agent": "Mozilla/5.0"}) if r.status_code == 404: return None, "No attached file found for this task." r.raise_for_status() except Exception as e: return None, f"Download failed: {type(e).__name__}." parsed = urlparse(source) suffix = Path(parsed.path).suffix content_type = r.headers.get("content-type", "").lower() if not suffix: if "spreadsheet" in content_type or "excel" in content_type: suffix = ".xlsx" elif "csv" in content_type: suffix = ".csv" elif "pdf" in content_type: suffix = ".pdf" elif "python" in content_type or "text" in content_type: suffix = ".txt" elif "image" in content_type: suffix = "." + content_type.split("/")[-1].split(";")[0] else: cd = r.headers.get("content-disposition", "") match = re.search(r'filename="?([^";]+)', cd) suffix = Path(match.group(1)).suffix if match else ".bin" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f: f.write(r.content) return Path(f.name), f"Downloaded {len(r.content)} bytes as {suffix or '.bin'}." def _read_text_file(path: Path) -> str: for enc in ("utf-8", "latin-1"): try: return path.read_text(encoding=enc)[:9000] except Exception: continue return "Could not decode text file." @tool def inspect_file(source: str) -> str: """Download and inspect an attached benchmark file or URL. Input can be a task_id or URL.""" path, status = _download_to_temp(source) if path is None: return status try: suffix = path.suffix.lower() if suffix in {".py", ".txt", ".md", ".json", ".html", ".csv"}: if suffix == ".csv": df = pd.read_csv(path) return _summarize_dataframe(df, "csv") text = _read_text_file(path) if suffix == ".py": run = subprocess.run( [sys.executable, str(path)], capture_output=True, text=True, timeout=20, ) stdout = run.stdout.strip() stderr = run.stderr.strip() return f"{status}\nPython stdout:\n{stdout[:3000] or '(empty)'}\nPython stderr:\n{stderr[:1000] or '(empty)'}\n\nCode preview:\n{text[:4000]}" return f"{status}\n{text}" if suffix in {".xlsx", ".xls"}: xl = pd.ExcelFile(path) parts = [status, f"Workbook sheets: {', '.join(xl.sheet_names)}"] for sheet in xl.sheet_names[:4]: df = xl.parse(sheet) parts.append(_summarize_dataframe(df, sheet)) return "\n\n".join(parts)[:9000] if suffix == ".pdf": try: from pypdf import PdfReader reader = PdfReader(str(path)) text = "\n".join((p.extract_text() or "") for p in reader.pages[:8]) return f"{status}\nPDF pages: {len(reader.pages)}\n{text[:8500] or 'No extractable text.'}" except Exception as e: return f"{status}\nPDF extraction unavailable: {type(e).__name__}." if suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif"}: return f"{status}\nImage file detected. If the question requires visual reasoning, use any visible description in the question and web_search; this runtime has no vision model." if suffix in {".mp3", ".wav", ".m4a", ".ogg", ".flac"}: return f"{status}\nAudio file detected. This runtime has no local speech-to-text model; use web_search if the recording is from public material." return f"{status}\nUnsupported file type: {suffix or 'unknown'}." except Exception as e: return f"{status}\nInspection failed: {type(e).__name__}: {e}" finally: try: path.unlink(missing_ok=True) except Exception: pass def _summarize_dataframe(df: pd.DataFrame, name: str) -> str: rows, cols = df.shape df = df.dropna(how="all") preview = df.head(12).to_string(index=False) numeric = df.select_dtypes(include="number") sums = numeric.sum(numeric_only=True).to_dict() sums_text = json.dumps({str(k): round(float(v), 4) for k, v in sums.items()}, ensure_ascii=True) cols_text = ", ".join(map(str, df.columns)) food_hint = "" lower_cols = {str(c).lower(): c for c in df.columns} category_col = next((c for key, c in lower_cols.items() if any(x in key for x in ["category", "type", "item type"])), None) sales_col = next((c for key, c in lower_cols.items() if any(x in key for x in ["sales", "revenue", "amount", "total"])), None) if category_col is not None and sales_col is not None: try: grouped = df.groupby(category_col)[sales_col].sum(numeric_only=True).to_dict() food_hint = "\nGrouped sums: " + json.dumps({str(k): round(float(v), 2) for k, v in grouped.items()}, ensure_ascii=True) except Exception: pass return f"Sheet/table {name}: {rows} rows x {cols} cols\nColumns: {cols_text}\nNumeric sums: {sums_text}{food_hint}\nPreview:\n{preview}" @tool def run_python(code: str) -> str: """Execute Python code and return stdout. Use for calculations, counting, data processing.""" fname = None try: with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: f.write(code) fname = f.name p = subprocess.run( [sys.executable, fname], capture_output=True, text=True, timeout=20 ) out = p.stdout.strip() err = p.stderr.strip() if out: return out if err: return f"Error: {err}" return "Script ran but produced no output." except subprocess.TimeoutExpired: return "Script timed out after 20 seconds." except Exception as e: return f"Execution failed: {type(e).__name__}." finally: if fname: try: Path(fname).unlink(missing_ok=True) except Exception: pass @tool def reverse_text(text: str) -> str: """Reverse a string character by character.""" return text[::-1] TOOLS = [wiki_search, web_search, fetch_page, youtube_transcript, inspect_file, run_python, reverse_text] # ========================================================== # MODELS — primary + ordered fallback chain # ========================================================== def _llm(name: str) -> ChatGroq: return ChatGroq( model=name, api_key=os.getenv("GROQ_API_KEY"), temperature=0, max_tokens=768, timeout=45, max_retries=1, ) # All questions use the same primary model. # Fallback chain kicks in only on errors (rate limits, timeouts, etc.) MODEL_PRIMARY = _llm("qwen/qwen3-32b") MODEL_FALLBACK = _llm("llama-3.3-70b-versatile") MODEL_LAST = _llm("llama-3.1-8b-instant") FALLBACK_CHAIN = [MODEL_PRIMARY, MODEL_FALLBACK, MODEL_LAST] # ========================================================== # SYSTEM PROMPT — single prompt for all question types # ========================================================== SYSTEM_PROMPT = """You are a precise benchmark task solver. ## Goal Produce the exact correct answer — nothing more, nothing less. ## Tool use - Use wiki_search for historical facts, biographies, science, geography. - Use web_search for recent events, specific articles, prices, or anything time-sensitive. - Use fetch_page when a URL is provided or a search result points to a relevant page. - Use youtube_transcript first for YouTube questions. - Use inspect_file whenever the question says attached file, attached image, spreadsheet, audio, Python code, or provides a task file URL. - Use run_python for any arithmetic, counting, sorting, or data transformation. - Use reverse_text only when asked to reverse a string. - Do not inspect a task file unless the question mentions an attachment, image, audio, spreadsheet, code file, or file URL. - For YouTube questions, if transcript is unavailable, search the exact video id plus the specific requested phrase/object. - Prefer tools over guessing. Use compact searches. Stop as soon as you have a confident answer. ## Answer format rules 1. Output the raw value only — no explanation, no preamble. 2. If asked for a first name, output only the first name. 3. If asked for a surname, output only the surname. 4. Numbers: digits only unless units were explicitly requested. 5. Lists: comma-separated on one line. 6. If you cannot find the answer after searching, output: N/A ## Required final line Always end your response with exactly: FINAL ANSWER: """ # ========================================================== # INVOKE — with fallback chain # ========================================================== def invoke(messages: list) -> object: global LAST_MODEL_USED, LAST_MODEL_FALLBACK, LAST_MODEL_ERROR LAST_MODEL_FALLBACK = "No" LAST_MODEL_ERROR = "None" seen: set[str] = set() first = True for model in FALLBACK_CHAIN: key = model.model_name if key in seen: continue seen.add(key) try: LAST_MODEL_USED = key if not first: LAST_MODEL_FALLBACK = "Yes" first = False return model.bind_tools(TOOLS).invoke(messages) except Exception as e: LAST_MODEL_ERROR = str(e) continue raise RuntimeError(f"All models failed. Last error: {LAST_MODEL_ERROR}") # ========================================================== # GRAPH NODES # ========================================================== def assistant(state: MessagesState) -> dict: messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"] result = invoke(messages) return {"messages": [result]} def build_graph(): g = StateGraph(MessagesState) g.add_node("assistant", assistant) g.add_node("tools", ToolNode(TOOLS)) g.add_edge(START, "assistant") g.add_conditional_edges("assistant", tools_condition) g.add_edge("tools", "assistant") return g.compile() # ========================================================== # ANSWER EXTRACTION + CLEANING # ========================================================== def _clean_answer(raw: str) -> str: """Normalise the extracted answer string.""" answer = raw.strip() # Strip trailing punctuation that the model sometimes adds answer = answer.rstrip(".,;:") # Collapse internal whitespace / newlines answer = " ".join(answer.split()) # Remove common LLM filler prefixes the regex sometimes captures for prefix in ( "the answer is", "answer is", "answer:", "it is", "it's", "that is", "this is", ): if answer.lower().startswith(prefix): answer = answer[len(prefix):].strip() return answer def extract_final_answer(output: dict) -> str: msgs = output.get("messages", []) for m in reversed(msgs): txt = getattr(m, "content", "") if isinstance(txt, str): match = re.search(r"FINAL ANSWER:\s*(.+)", txt, re.I | re.S) if match: raw = match.group(1).strip()[:300] return _clean_answer(raw) return "N/A" def extract_tools_used(output: dict) -> list[str]: msgs = output.get("messages", []) seen: set[str] = set() tools: list[str] = [] for m in msgs: name = getattr(m, "name", None) if name and name not in seen: tools.append(name) seen.add(name) for tc in getattr(m, "tool_calls", []) or []: n = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None) if n and n not in seen: tools.append(n) seen.add(n) return tools def get_last_trace() -> dict: return { "model": LAST_MODEL_USED, "fallback": LAST_MODEL_FALLBACK, "model_error": LAST_MODEL_ERROR, }