import json
import os
from typing import List, Optional
import pandas as pd
import pytesseract
import requests
from dotenv import load_dotenv
from langchain_community.document_loaders import WikipediaLoader
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.tools import tool
from langchain_groq import ChatGroq
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
from PIL import Image
from code_interpreter import CodeInterpreter
from extract_answer import extract_final_answer
from files_util import format_user_message
load_dotenv()
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
PROMPT_PATH = os.path.join(ROOT_DIR, "system_prompt.txt")
MAX_TOOL_ROUNDS = 8
RECURSION_LIMIT = 20
_interpreter = None
def get_system_prompt() -> str:
with open(PROMPT_PATH, "r", encoding="utf-8") as handle:
return handle.read()
def get_interpreter() -> CodeInterpreter:
global _interpreter
if _interpreter is None:
_interpreter = CodeInterpreter()
return _interpreter
@tool
def wiki_search(query: str) -> str:
"""Search Wikipedia and return up to 2 page excerpts.
Args:
query: Search query, preferably a proper name or article title.
"""
docs = WikipediaLoader(query=query, load_max_docs=2).load()
if not docs:
return "No Wikipedia results."
chunks = []
for doc in docs:
source = doc.metadata.get("source", "")
chunks.append(f'\n{doc.page_content}\n')
return "\n\n---\n\n".join(chunks)
@tool
def web_search(query: str) -> str:
"""Search the public web via Tavily and return up to 3 results.
Args:
query: Search query. Be specific; include years, full names, and distinctive terms.
"""
results = TavilySearchResults(max_results=3).invoke(query)
if not results:
return "No web results."
chunks = []
for doc in results:
chunks.append(
f'\n'
f'{doc.get("content", "")}\n'
)
return "\n\n---\n\n".join(chunks)
@tool
def execute_python(code: str) -> str:
"""Run Python for calculation, pandas/Excel/CSV analysis, dates, and string parsing.
Args:
code: Complete Python source. Print the values you need. Use absolute file paths from the question.
"""
result = get_interpreter().execute_code(code, language="python")
lines = []
if result["status"] == "success":
lines.append("Python execution succeeded.")
if result.get("stdout"):
lines.append("stdout:\n" + result["stdout"].strip())
if result.get("stderr"):
lines.append("stderr:\n" + result["stderr"].strip())
if result.get("result") is not None:
lines.append("result:\n" + str(result["result"]).strip())
for df_info in result.get("dataframes") or []:
preview = pd.DataFrame(df_info["head"])
lines.append(f"DataFrame {df_info['name']} shape={df_info['shape']}\n{preview}")
else:
lines.append("Python execution failed.")
if result.get("stderr"):
lines.append(result["stderr"].strip())
return "\n\n".join(lines)[:12000]
@tool
def download_file_from_url(url: str, filename: Optional[str] = None) -> str:
"""Download a file from an http(s) URL to a local path and return that path.
Args:
url: Direct download URL.
filename: Optional filename to use in the temp directory.
"""
from urllib.parse import urlparse
import tempfile
import uuid
try:
if not filename:
filename = os.path.basename(urlparse(url).path) or f"download_{uuid.uuid4().hex[:8]}"
filepath = os.path.join(tempfile.gettempdir(), filename)
response = requests.get(url, timeout=60, stream=True)
response.raise_for_status()
with open(filepath, "wb") as handle:
for chunk in response.iter_content(chunk_size=8192):
handle.write(chunk)
return f"Downloaded to {filepath}"
except Exception as exc:
return f"Download failed: {exc}"
@tool
def read_file(file_path: str, max_chars: int = 8000) -> str:
"""Read a local text, CSV, Excel, JSON, or PDF file and return a preview.
Args:
file_path: Absolute path on disk.
max_chars: Truncate text-like previews to this many characters.
"""
if not os.path.exists(file_path):
return f"File not found: {file_path}"
ext = os.path.splitext(file_path)[1].lower()
try:
if ext in {".csv"}:
df = pd.read_csv(file_path)
return (
f"CSV rows={len(df)} cols={list(df.columns)}\n"
f"{df.head(20).to_string()}\n\n{df.describe(include='all')}"
)[:max_chars]
if ext in {".xlsx", ".xls"}:
df = pd.read_excel(file_path)
return (
f"Excel rows={len(df)} cols={list(df.columns)}\n"
f"{df.head(20).to_string()}\n\n{df.describe(include='all')}"
)[:max_chars]
if ext == ".pdf":
try:
import fitz
except ImportError:
return "PyMuPDF is not installed; use execute_python if available."
doc = fitz.open(file_path)
text = "\n".join(page.get_text() for page in doc)
doc.close()
return text[:max_chars] or "PDF had no extractable text."
with open(file_path, "r", encoding="utf-8", errors="replace") as handle:
return handle.read(max_chars)
except Exception as exc:
return f"Failed to read {file_path}: {exc}"
@tool
def extract_text_from_image(image_path: str) -> str:
"""OCR text from a local image with Tesseract.
Args:
image_path: Absolute path to a jpg/png/webp/gif/bmp file.
"""
try:
image = Image.open(image_path)
text = pytesseract.image_to_string(image)
return text.strip() or "OCR returned no text."
except Exception as exc:
return f"OCR failed: {exc}"
TOOLS = [
web_search,
wiki_search,
execute_python,
download_file_from_url,
read_file,
extract_text_from_image,
]
def _tool_signature(message) -> Optional[str]:
calls = getattr(message, "tool_calls", None) or []
if not calls:
return None
parts = []
for call in calls:
name = call.get("name") if isinstance(call, dict) else getattr(call, "name", "")
args = call.get("args") if isinstance(call, dict) else getattr(call, "args", {})
parts.append(f"{name}:{json.dumps(args, sort_keys=True, default=str)}")
return "|".join(parts)
def _should_force_final(messages) -> bool:
tool_round_messages = [m for m in messages if getattr(m, "tool_calls", None)]
if len(tool_round_messages) >= MAX_TOOL_ROUNDS:
return True
if len(tool_round_messages) >= 2:
if _tool_signature(tool_round_messages[-1]) == _tool_signature(tool_round_messages[-2]):
return True
return False
def require_env(name: str) -> str:
value = os.getenv(name, "").strip()
if value:
return value
raise RuntimeError(
f"Missing {name}. "
"Local: put it in `.env`. "
"Hugging Face Space: Settings → Variables and secrets → New secret."
)
def build_graph(provider: str = "groq", use_retriever: bool = False):
"""Compile the tool-calling graph. Retriever is off unless explicitly enabled."""
if provider != "groq":
raise ValueError("Only provider='groq' is supported after the refactor.")
api_key = require_env("GROQ_API_KEY")
llm = ChatGroq(model="qwen/qwen3-32b", temperature=0, api_key=api_key)
llm_with_tools = llm.bind_tools(TOOLS)
retriever_store = _build_optional_retriever() if use_retriever else None
def maybe_example(state: MessagesState):
if retriever_store is None:
return {}
question = ""
for message in state["messages"]:
if isinstance(message, HumanMessage):
question = message.content
break
hits = retriever_store.similarity_search(question, k=1)
if not hits:
return {}
return {
"messages": [
HumanMessage(
content=(
"Optional similar example for format only. "
"Do not copy if it is a different question.\n\n"
f"{hits[0].page_content}"
)
)
]
}
def assistant(state: MessagesState):
messages = state["messages"]
if _should_force_final(messages):
stop = SystemMessage(
content=(
"Stop calling tools. Using only the information already in this "
"conversation, reply with one line: FINAL ANSWER: "
)
)
return {"messages": [llm.invoke(messages + [stop])]}
return {"messages": [llm_with_tools.invoke(messages)]}
builder = StateGraph(MessagesState)
builder.add_node("maybe_example", maybe_example)
builder.add_node("assistant", assistant)
builder.add_node("tools", ToolNode(TOOLS))
builder.add_edge(START, "maybe_example")
builder.add_edge("maybe_example", "assistant")
builder.add_conditional_edges("assistant", tools_condition)
builder.add_edge("tools", "assistant")
return builder.compile()
def _build_optional_retriever():
url = os.environ.get("SUPABASE_URL")
key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
if not url or not key:
print("Retriever requested but SUPABASE_* env vars are missing; skipping.")
return None
from langchain_community.vectorstores import SupabaseVectorStore
from langchain_huggingface import HuggingFaceEmbeddings
from supabase.client import create_client
embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-mpnet-base-v2")
client = create_client(url, key)
return SupabaseVectorStore(
client=client,
embedding=embeddings,
table_name="documents2",
query_name="match_documents_2",
)
def run_agent(
question: str,
file_paths: Optional[List[str]] = None,
graph=None,
use_retriever: bool = False,
) -> dict:
"""Run one question and return raw last message plus extracted FINAL ANSWER."""
if graph is None:
graph = build_graph(use_retriever=use_retriever)
payload = format_user_message(question, file_paths)
result = graph.invoke(
{
"messages": [
SystemMessage(content=get_system_prompt()),
HumanMessage(content=payload),
]
},
{"recursion_limit": RECURSION_LIMIT},
)
last = result["messages"][-1]
raw = last.content if hasattr(last, "content") else str(last)
if isinstance(raw, list):
raw = "".join(
part.get("text", "") if isinstance(part, dict) else str(part) for part in raw
)
return {
"raw": raw,
"final": extract_final_answer(raw),
"messages": result["messages"],
}
if __name__ == "__main__":
demo = "When was a picture of St. Thomas Aquinas first added to the Wikipedia page on the Principle of double effect?"
output = run_agent(demo)
print("RAW:\n", output["raw"])
print("FINAL:", output["final"])