Spaces:
Sleeping
Sleeping
File size: 13,249 Bytes
2834b30 b3f9415 6d5c14c 2834b30 b3f9415 2834b30 3b7ebef b3f9415 2834b30 b3f9415 2834b30 b3f9415 2834b30 b3f9415 2834b30 b3f9415 2834b30 b3f9415 2834b30 b3f9415 2834b30 b3f9415 2834b30 b3f9415 2834b30 b3f9415 2834b30 3b7ebef 6d5c14c 3b7ebef 6d5c14c 2834b30 b3f9415 2834b30 b3f9415 2834b30 b3f9415 2834b30 b3f9415 2834b30 6d5c14c 2834b30 6d5c14c 3b7ebef 6d5c14c 3b7ebef 6d5c14c 3b7ebef 6d5c14c 2834b30 b3f9415 2834b30 b3f9415 2834b30 8df4ab3 2834b30 8df4ab3 2834b30 6d5c14c b3f9415 2834b30 6d5c14c 2834b30 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 | """
GAIA Agent with Multi-Modal File Processing and Hybrid Retrieval.
This module defines a LangGraph agent that can:
1. Retrieve similar questions using Hybrid Search (Vector + BM25) and Reranking
2. Process files using tools (PDF, XLSX, MP3, etc.)
3. Answer questions using web search, calculator, and other tools
"""
import os
import re
import bm25s
import requests
from pathlib import Path
from dotenv import load_dotenv
from langgraph.graph import START, END, StateGraph
from langgraph.prebuilt import tools_condition, ToolNode
from sentence_transformers import SentenceTransformer, CrossEncoder
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.tools import tool
from supabase.client import Client, create_client
from utils import load_config, load_prompt, init_bm25_index, reciprocal_rank_fusion
from tools import tools_list
from states import AgentState
load_dotenv()
config = load_config()
# Environment details and others
hf_key = os.getenv("HF_INFERENCE_KEY")
supabase_url = os.getenv("SUPABASE_URL")
supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
# ============================================
# Model & Embeddings Setup
# ============================================
enable_keyword_search = config["retrievers"]["enable_keyword_search"]
enable_vector_search = config["retrievers"]["enable_vector_search"]
# BM25 Retriever
bm25_retriever, bm25_corpus, bm25_ids = None, None, None
if enable_keyword_search:
bm25_retriever, bm25_corpus, bm25_ids = init_bm25_index(corpus_file=config["data"])
bm25_id_to_text = {}
if bm25_corpus and bm25_ids:
bm25_id_to_text = dict(zip(bm25_ids, bm25_corpus))
embeddings, supabase = None, None
if enable_vector_search:
# Embeddings for Vector Search
embeddings = SentenceTransformer(model_name_or_path=config["models"]["embeddings"]["model_name"], cache_folder=config["models"]["cache_folder"])
# Supabase Vector Store
supabase: Client = create_client(supabase_url, supabase_key)
# Reranker Model (ModernBERT Cross-Encoder)
reranker = CrossEncoder(config["models"]["reranker"]["model_name"], cache_folder=config["models"]["cache_folder"])
# LLM for Agent
llm = HuggingFaceEndpoint(
repo_id=config["models"]["llm"]["model_name"],
temperature=config["models"]["llm"]["parameters"]["temperature"],
repetition_penalty=config["models"]["llm"]["parameters"]["repetition_penalty"],
provider=config["models"]["llm"]["parameters"]["provider"],
huggingfacehub_api_token=hf_key
)
agent_llm = ChatHuggingFace(llm=llm)
agent_with_tools = agent_llm.bind_tools(tools_list)
_system_prompt = load_prompt("prompts/prompt.yaml")
_thinking_enabled = config["models"]["llm"]["parameters"].get("thinking_enabled", True)
@tool
def emit_final_answer(answer: str) -> str:
"""Emit the final answer to the GAIA question in the strict scoring format.
Args:
answer: The raw answer value only.
Numbers: plain digits, no commas, no units, no symbols (write '1000000', not '1,000,000' or '$50').
Strings: no articles ('a', 'an', 'the'), no markdown, no surrounding quotes, no trailing punctuation.
Lists: comma-separated with no extra spaces, in the order requested by the question.
"""
return answer
formatter_llm = agent_llm.bind_tools([emit_final_answer], tool_choice="emit_final_answer")
# ============================================
# Graph Nodes
# ============================================
def file_downloader_node(state: AgentState) -> AgentState:
"""
Download the task file from the scoring API if one is associated with the question.
Saves to a local directory and stores the path in state.
"""
print("--- FILE DOWNLOADER NODE ---")
file_name = state.get("file_name", "")
task_id = state.get("task_id", "")
if not file_name or not task_id:
return {"file_path": ""}
safe_name = Path(file_name).name
if not safe_name:
print(f"File download skipped: invalid file_name '{file_name}'")
return {"file_path": ""}
save_dir = Path(config["api"]["files_dir"]) / task_id
save_dir.mkdir(parents=True, exist_ok=True)
local_path = save_dir / safe_name
if local_path.exists():
print(f"File already cached: {local_path}")
return {"file_path": str(local_path)}
file_url = f"{config['api']['base_url']}/files/{task_id}"
try:
response = requests.get(file_url, timeout=30)
response.raise_for_status()
if not response.content:
print(f"File download failed ({file_url}): empty response body")
return {"file_path": ""}
local_path.write_bytes(response.content)
print(f"Downloaded: {safe_name} → {local_path}")
return {"file_path": str(local_path)}
except Exception as e:
print(f"File download failed ({file_url}): {e}")
return {"file_path": ""}
def retriever_node(state: AgentState) -> AgentState:
"""
Hybrid Search Node: Retrieve docs via Vector Search + BM25, combine with RRF.
"""
print("--- RETRIEVER NODE ---")
messages = state.get("messages", [])
if not messages:
return {"retrieved_docs": []}
question_content = messages[0].content
if not enable_vector_search and not enable_keyword_search:
print("No retrieval method enabled.")
return {"retrieved_docs": []}
# 1. Vector Search
vector_docs = []
if supabase and embeddings:
try:
response = supabase.rpc(
config["retrievers"]["vector_store"]["query"],
{"query_embedding": embeddings.encode(question_content).tolist(),
"match_count": config["retrievers"]["vector_store"]["k"],
"match_threshold": config["retrievers"]["vector_store"]["threshold"]
}
).execute()
vector_docs = response.data
except Exception as e:
print(f"Vector search error: {e}")
# 2. BM25 Search
bm25_docs = []
if bm25_retriever and bm25_corpus and bm25_ids:
try:
query_tokens = bm25s.tokenize([question_content], stopwords="en")
results, scores = bm25_retriever.retrieve(query_tokens, k=config["retrievers"]["bm25"]["k"])
indices = results[0]
for i, idx in enumerate(indices):
content = bm25_corpus[idx]
task_id = bm25_ids[idx]
score = scores[0][i]
bm25_dict = {"content":content, "metadata": {"source": "bm25_search", "task_id": task_id, "score": score}}
bm25_docs.append(bm25_dict)
except Exception as e:
print(f"BM25 search error: {e}")
# 3. RRF Fusion
final_candidates = []
if vector_docs and bm25_docs:
fused = reciprocal_rank_fusion([vector_docs, bm25_docs])
final_candidates = [id for id, doc, score in fused]
else:
final_candidates = vector_docs + bm25_docs
final_candidates = [doc["metadata"]["task_id"] for doc in final_candidates]
top_candidates = final_candidates[:20]
return {"retrieved_docs": top_candidates}
def reranker_node(state: AgentState) -> AgentState:
"""
Reranker Node: Re-order candidates using Cross-Encoder and return top 3.
"""
print("--- RERANKER NODE ---")
candidates = state.get("retrieved_docs", [])
messages = state.get("messages", [])
if not candidates or not messages:
return {"messages": []}
question = messages[0].content
# Deduplicate candidates — candidates are task_id strings; resolve to text via corpus lookup
unique_candidates = []
seen_content = set()
for task_id in candidates:
text = bm25_id_to_text.get(task_id)
if text and text not in seen_content:
unique_candidates.append(text)
seen_content.add(text)
if not unique_candidates:
return {"messages": []}
pairs = [[question, doc_text] for doc_text in unique_candidates]
try:
scores = reranker.predict(pairs)
scored_docs = sorted(
zip(unique_candidates, scores),
key=lambda x: x[1],
reverse=True
)
top_k = config["retrievers"]["final_rrf_k"]
top_results = scored_docs[:top_k]
context_str = "Here are similar questions and answers for reference:\n\n"
for i, (doc_text, score) in enumerate(top_results):
context_str += f"--- Example {i+1} (Score: {score:.2f}) ---\n{doc_text}\n\n"
context_message = HumanMessage(content=context_str)
return {"messages": [context_message]}
except Exception as e:
print(f"Reranker error: {e}")
if unique_candidates:
fallback_msg = HumanMessage(content=f"Reference (Fallback):\n{unique_candidates[0]}")
return {"messages": [fallback_msg]}
return {"messages": []}
def processor_node(state: AgentState) -> AgentState:
"""
Processor Node: Main LLM agent that answers the question.
"""
prompt_content = _system_prompt.content + ("" if _thinking_enabled else "\n/no_think")
system_prompt = SystemMessage(content=prompt_content)
messages = state.get("messages", [])
file_name = state.get("file_name", "")
file_path = state.get("file_path", "")
full_messages = [system_prompt]
if file_name:
if file_path:
file_msg = HumanMessage(
content=f"Note: A file named '{file_name}' is associated with this question. It is available at path: {file_path}"
)
else:
file_msg = HumanMessage(
content=f"Note: A file named '{file_name}' is associated with this question, but it could not be downloaded."
)
full_messages.append(file_msg)
full_messages.extend(messages)
response = agent_with_tools.invoke(full_messages)
return {"messages": [response]}
def formatter_node(state: AgentState) -> AgentState:
"""Extract and reformat the solver's answer into a strict GAIA-compliant value."""
print("--- FORMATTER NODE ---")
messages = state.get("messages", [])
if not messages:
return {"final_answer": ""}
question = ""
for m in messages:
if isinstance(m, HumanMessage):
question = m.content
break
solver_output = messages[-1].content or ""
prompt = [
SystemMessage(content=(
"You extract the final answer from an agent's reasoning and apply the GAIA "
"formatting rules exactly. You MUST call the `emit_final_answer` tool with "
"the extracted value. If the agent never produced an answer, call it with an "
"empty string."
)),
HumanMessage(content=(
f"Question:\n{question}\n\n"
f"Agent reasoning and conclusion:\n{solver_output}\n\n"
"Extract the final answer and call emit_final_answer."
)),
]
try:
result = formatter_llm.invoke(prompt)
for tc in getattr(result, "tool_calls", None) or []:
if tc.get("name") == "emit_final_answer":
return {"final_answer": str(tc.get("args", {}).get("answer", "")).strip()}
# Model returned text instead of calling the tool — regex over its content.
content = result.content or ""
match = re.search(r'FINAL ANSWER:\s*(.*)', content, re.DOTALL | re.IGNORECASE)
return {"final_answer": (match.group(1).strip() if match else content.strip())}
except Exception as e:
print(f"Formatter error: {e}")
match = re.search(r'FINAL ANSWER:\s*(.*)', solver_output, re.DOTALL | re.IGNORECASE)
return {"final_answer": (match.group(1).strip() if match else solver_output.strip())}
# ============================================
# Graph Construction
# ============================================
def agent_graph():
"""
Build and compile the agent graph.
"""
workflow = StateGraph(AgentState)
# Add nodes
workflow.add_node("file_downloader_node", file_downloader_node)
workflow.add_node("retriever_node", retriever_node)
workflow.add_node("reranker_node", reranker_node)
workflow.add_node("processor_node", processor_node)
workflow.add_node("tools", ToolNode(tools_list))
workflow.add_node("formatter_node", formatter_node)
# Add edges
workflow.add_edge(START, "file_downloader_node")
workflow.add_edge("file_downloader_node", "retriever_node")
workflow.add_edge("retriever_node", "reranker_node")
workflow.add_edge("reranker_node", "processor_node")
workflow.add_edge("tools", "processor_node")
workflow.add_conditional_edges(
"processor_node",
tools_condition,
{"tools": "tools", END: "formatter_node"},
)
workflow.add_edge("formatter_node", END)
compiled = workflow.compile()
return compiled.with_config({"recursion_limit": config["graph"]["recursion_limit"]})
|