Nancy1906 commited on
Commit
9480657
·
verified ·
1 Parent(s): 9d1f396
Files changed (1) hide show
  1. my_tools.py +672 -112
my_tools.py CHANGED
@@ -7,7 +7,14 @@ import asyncio
7
  import subprocess
8
  import requests
9
  import pandas as pd
10
- from io import StringIO
 
 
 
 
 
 
 
11
  from bs4 import BeautifulSoup
12
  from duckduckgo_search import DDGS
13
  import wikipedia
@@ -15,71 +22,156 @@ from pydantic import Field
15
  import google.generativeai as genai
16
 
17
  # LlamaIndex imports
18
- from llama_index.core.llms import ChatMessage, LLMMetadata, LLM, CompletionResponse
19
- # Monkey-patch ChatMessage to satisfy .message
 
 
 
 
 
20
  ChatMessage.message = property(lambda self: self)
21
  from llama_index.core.tools import FunctionTool
22
  from llama_index.core.agent import ReActAgent
23
- from llama_index.core.callbacks.llama_debug import LlamaDebugHandler
 
 
 
 
 
 
24
 
25
  # -------------------------------------------------------------------
26
- # 1) GeminiLLM personalizado
27
  # -------------------------------------------------------------------
28
  class GeminiLLM(LLM):
29
- model_name: str = Field(default="models/gemini-1.5-flash-latest")
30
- temperature: float = Field(default=0.7)
 
 
31
 
32
  class Config:
33
  extra = "allow"
34
 
35
  def __init__(self, **kwargs):
36
- super().__init__(**kwargs)
 
 
 
37
  key = os.getenv("GEMINI_API_KEY")
38
  if not key:
39
  raise ValueError("GEMINI_API_KEY no configurada")
40
  genai.configure(api_key=key)
41
- self._gen_cfg = genai.types.GenerationConfig(temperature=self.temperature)
 
 
42
  self._model = genai.GenerativeModel(
43
  model_name=self.model_name,
44
  generation_config=self._gen_cfg
45
  )
46
- if not self.callback_manager.handlers:
47
- self.callback_manager.add_handler(LlamaDebugHandler())
 
 
 
 
 
 
 
 
 
48
 
49
  @property
50
  def metadata(self):
51
  return LLMMetadata(
52
- context_window=1048576,
 
 
53
  num_output=8192,
54
- is_chat_model=True,
55
- is_function_calling_model=True,
 
 
 
 
 
56
  model_name=self.model_name,
57
  )
58
 
59
- def chat(self, messages, **kwargs):
 
 
60
  hist = []
61
  for m in messages[:-1]:
62
- role = "user" if m.role == "user" else "model"
63
- hist.append({"role": role, "parts":[{"text": m.content}]})
64
- last = messages[-1].content
 
 
 
 
 
 
 
 
 
 
65
  session = self._model.start_chat(history=hist)
66
  try:
67
- resp = session.send_message(last)
68
  return ChatMessage(role="assistant", content=resp.text)
69
- except Exception as e:
 
 
 
 
 
 
 
 
 
 
 
70
  return ChatMessage(role="assistant", content=f"Error Gemini: {e}")
71
 
72
  async def achat(self, messages, **kwargs):
73
  return await asyncio.to_thread(self.chat, messages, **kwargs)
74
 
 
 
 
 
 
 
 
 
 
 
75
  def stream_chat(self, messages, **kwargs):
76
  hist = []
77
  for m in messages[:-1]:
78
  role = "user" if m.role=="user" else "model"
79
- hist.append({"role": role, "parts":[{"text":m.content}]})
80
- last = messages[-1].content
 
81
  session = self._model.start_chat(history=hist)
82
- stream = session.send_message(last, stream=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def gen():
84
  acc = ""
85
  for chunk in stream:
@@ -93,16 +185,27 @@ class GeminiLLM(LLM):
93
 
94
  def complete(self, prompt, formatted=False, **kwargs):
95
  try:
96
- resp = self._model.generate_content(prompt)
97
  return CompletionResponse(text=resp.text)
98
  except Exception as e:
99
  return CompletionResponse(text=f"Error complete: {e}")
100
 
101
  async def acomplete(self, prompt, formatted=False, **kwargs):
102
- return await asyncio.to_thread(self.complete, prompt, formatted=formatted, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
103
 
104
  def stream_complete(self, prompt, formatted=False, **kwargs):
105
- stream = self._model.generate_content(prompt, stream=True)
106
  def gen():
107
  acc = ""
108
  for chunk in stream:
@@ -115,145 +218,602 @@ class GeminiLLM(LLM):
115
  return gen()
116
 
117
  async def astream_complete(self, prompt, formatted=False, **kwargs):
118
- return await asyncio.to_thread(self.stream_complete, prompt, formatted=formatted, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
- def astream_chat(self, messages: list[ChatMessage], **kwargs):
121
- return self.stream_chat(messages, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
 
 
 
 
 
123
  # -------------------------------------------------------------------
124
- # 2) Herramientas
125
  # -------------------------------------------------------------------
126
- HEADERS = {'User-Agent':'Mozilla/5.0'}
 
 
 
 
 
127
 
128
  def buscar_web(query: str, max_attempts: int = 2) -> str:
 
129
  for i in range(max_attempts):
130
  try:
 
 
 
 
131
  with DDGS(headers=HEADERS, timeout=25) as ddgs:
132
- results = list(ddgs.text(query, region='es-es', safesearch='moderate', timelimit='y', max_results=3))
 
 
 
 
 
133
  if results:
134
- return "\n".join(f"Título: {r['title']}\nCuerpo: {r['body']}" for r in results)
135
- return "No se encontraron resultados."
 
136
  except Exception as e:
137
- if i < max_attempts - 1:
138
- time.sleep(5 * (i + 1))
 
 
 
 
 
 
 
139
  else:
140
- return f"Error buscar_web tras {max_attempts} intentos: {e}"
141
-
142
 
143
  def reverse_text(text: str) -> str:
 
144
  return text[::-1]
145
 
146
-
147
  def analyze_table(table_md: str, question: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  try:
149
- # Remove alignment row and parse header/body
150
- lines = [l for l in table_md.splitlines() if l.strip()]
151
- # Filter out divider line
152
- lines = [l for l in lines if not l.strip().startswith('|') or '---' not in l]
153
- # Convert markdown to CSV-like
154
  rows = []
 
155
  for l in lines:
 
 
 
 
 
 
156
  parts = [c.strip() for c in l.strip().strip('|').split('|')]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  rows.append(parts)
158
- df = pd.DataFrame(rows[1:], columns=rows[0])
159
- if 'conmut' in question.lower():
160
- S = rows[0][1:]
161
- counter = set()
162
- for x in S:
163
- for y in S:
164
- a = df.loc[df[rows[0][0]]==x, y].iat[0]
165
- b = df.loc[df[rows[0][0]]==y, x].iat[0]
166
- if a != b:
167
- counter.update([x, y])
168
- return ', '.join(sorted(counter)) or 'No hay contraejemplos'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  return df.to_csv(index=False)
 
 
 
 
 
 
 
 
170
  except Exception as e:
171
- return f"Error analyze_table: {e}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
 
174
  def execute_code(code: str) -> str:
 
 
 
175
  try:
176
- # Try eval for simple expressions
177
- try:
178
- val = eval(code, {'__builtins__': None, 'math': math}, math.__dict__)
179
- return str(val)
180
- except:
181
- pass
182
- # Fallback to subprocess
183
- res = subprocess.run(
184
- ["python", "-c", code], capture_output=True, text=True, timeout=5
185
- )
186
- if res.stderr:
187
- return f"Error código: {res.stderr.strip()}"
188
- return res.stdout.strip() or "(sin salida)"
189
- except Exception as e:
190
- return f"Error ejecutar código: {e}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
- def no_tool_solution(query: str) -> str:
194
- return "Procedo con conocimiento interno."
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
- def scrape_wikipedia_table(query: str, section: str) -> str:
198
  """
199
- Busca una página de Wikipedia y extrae la tabla HTML bajo la sección dada.
200
- Devuelve CSV de la tabla.
 
201
  """
202
  try:
203
- page = wikipedia.page(query)
 
204
  html = page.html()
205
  soup = BeautifulSoup(html, 'html.parser')
206
- sec = None
207
- for header in soup.find_all(['h2','h3']):
208
- if section.lower() in header.get_text().lower():
209
- sec = header
 
 
 
 
 
 
 
 
210
  break
211
- if not sec:
212
- return f"Sección '{section}' no encontrada en '{query}'."
213
- table = sec.find_next('table', {'class':'wikitable'})
214
- if not table:
215
- return "No se encontró tabla wikitable después de la sección."
216
- df = pd.read_html(str(table))[0]
217
- return df.to_csv(index=False)
218
- except Exception as e:
219
- return f"Error scrape_wikipedia_table: {e}"
220
-
221
- # Encapsular herramientas
222
- search_tool = FunctionTool.from_defaults(fn=buscar_web, name="web_search", description="Búsqueda DDG (3 resultados).")
223
- reverse_tool = FunctionTool.from_defaults(fn=reverse_text, name="reverse_text", description="Invierte texto.")
224
- table_tool = FunctionTool.from_defaults(fn=analyze_table, name="analyze_table", description="Procesa tablas Markdown y verifica conmutatividad.")
225
- code_tool = FunctionTool.from_defaults(fn=execute_code, name="execute_code", description="Ejecuta Python para cálculos.")
226
- fallback_tool = FunctionTool.from_defaults(fn=no_tool_solution, name="no_tool_solution", description="Fallback: conocimiento interno.")
227
- scrape_tool = FunctionTool.from_defaults(fn=scrape_wikipedia_table,name="scrape_wiki_table", description="Extrae tabla de Wikipedia por sección.")
228
-
229
- all_tools = [search_tool, reverse_tool, table_tool, code_tool, scrape_tool, fallback_tool]
230
-
231
- # Prompt de sistema
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  system_prompt = (
233
- "Eres Alfred, un agente ReAct que usa pasos de pensamiento claros y herramientas especializadas.\n"
234
- "1) Analiza la pregunta. 2) Decide la herramienta más adecuada.\n"
235
- "3) Usa web_search, reverse_text, analyze_table, execute_code o scrape_wiki_table.\n"
236
- "4) Si nada aplica, usa no_tool_solution.\n"
237
- "5) Si la pregunta involucra audio/video/imágenes no procesables, informa que no puedes acceder.\n"
238
- "6) Responde final claro.\n"
239
- "Herramientas:\n{tool_descriptions}"
 
 
 
 
 
 
 
240
  )
241
 
242
  # Inicializar agente
243
- llm = GeminiLLM(model_name="models/gemini-1.5-flash-latest", temperature=0.0)
 
244
  alfred_agent = ReActAgent.from_tools(
245
  tools=all_tools,
246
  llm=llm,
247
  system_prompt=system_prompt,
248
  verbose=True,
249
- max_iterations=20
 
 
250
  )
251
 
252
- # Función pública
253
  def basic_agent_response(question: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  try:
255
  resp = alfred_agent.query(question)
 
 
 
 
256
  return resp.response or "No se generó respuesta."
257
  except Exception as e:
258
  return f"Error crítico del agente: {e}"
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  import subprocess
8
  import requests
9
  import pandas as pd
10
+ from io import StringIO, BytesIO #acc = ""
11
+ for chunk in stream:
12
+ delta = getattr(chunk, "text", "")
13
+ if not delta and hasattr(chunk, 'parts') and chunk.parts:
14
+ delta = chunk.parts[0].text
15
+ if delta:
16
+ acc += delta
17
+ yield CompletionResponse(text=acc, delta BytesIO para leer Excel de contenido web si fuera necesario
18
  from bs4 import BeautifulSoup
19
  from duckduckgo_search import DDGS
20
  import wikipedia
 
22
  import google.generativeai as genai
23
 
24
  # LlamaIndex imports
25
+ from llama_index.core.llms import ChatMessage, LLMMetadata, LLM, CompletionResponse=delta)
26
+ return gen()
27
+
28
+ async def astream_complete(self, prompt, formatted=False, **kwargs):
29
+ return await asyncio.to_thread(self.stream_complete, prompt, formatted=formatted, **kwargs)
30
+
31
+ def astream_chat(self, messages: list[ChatMessage], **kwargs):
32
  ChatMessage.message = property(lambda self: self)
33
  from llama_index.core.tools import FunctionTool
34
  from llama_index.core.agent import ReActAgent
35
+ from llama_index.core.callbacks.llama_debug import
36
+ return self.stream_chat(messages, **kwargs)
37
+
38
+ # -------------------------------------------------------------------
39
+ # 2) Herramientas (con nuevas herramientas y mejoras)
40
+ # -------------------------------------------------------------------
41
+ HEADERS = {'User- LlamaDebugHandler
42
 
43
  # -------------------------------------------------------------------
44
+ # 1) GeminiLLM personalizado (SIN CAMBIOS - Asumimos que está bien)
45
  # -------------------------------------------------------------------
46
  class GeminiLLM(LLM):
47
+ Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/model_name: str = Field(default="models/gemini-1.5-flash-latest")
48
+ 537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}
49
+
50
+ def buscar_web(query: str, max_attempts: int temperature: float = Field(default=0.0) # Mantenemos 0.0 para consistencia
51
 
52
  class Config:
53
  extra = "allow"
54
 
55
  def __init__(self, **kwargs):
56
+ super().__ = 2, num_results: int = 5) -> str: # MEJORA: num_results aumentado
57
+ for i in range(max_attempts):
58
+ try:
59
+ with DDGS(headers=HEADERSinit__(**kwargs)
60
  key = os.getenv("GEMINI_API_KEY")
61
  if not key:
62
  raise ValueError("GEMINI_API_KEY no configurada")
63
  genai.configure(api_key=key)
64
+ self._gen_cfg = genai.types.GenerationConfig(temperature=, timeout=25) as ddgs:
65
+ # MEJORA: 'y' (año) puede ser demasiado restrictivo, quitamos timelimit por defecto.
66
+ # El LLM puede especificarlo en la query si es necesario ("self.temperature)
67
  self._model = genai.GenerativeModel(
68
  model_name=self.model_name,
69
  generation_config=self._gen_cfg
70
  )
71
+ # Asegurar que LlamaDebugHandler senoticias recientes de X")
72
+ results = list(ddgs.text(query, region='es-es', safesearch='moderate', max_results=num_results))
73
+ if results:
74
+ return "\n". añade si no hay otros handlers
75
+ # Lo quitamos de aquí porque es mejor añadirlo explícitamente aljoin(f"Título: {r['title']}\nCuerpo: {r['body']}\nFuente crear el agente o el LLM si se desea
76
+ # if not self.callback_manager.handlers:
77
+ #: {r['href']}" for r in results)
78
+ return "No se encontraron resultados para la búsqueda web."
79
+ except Exception as e:
80
+ if i < max_attempts - 1:
81
+ time. self.callback_manager.add_handler(LlamaDebugHandler())
82
 
83
  @property
84
  def metadata(self):
85
  return LLMMetadata(
86
+ context_window=1048576sleep(3 * (i + 1)) # Reducido ligeramente el sleep
87
+ else:
88
+ return f, # Gemini 1.5 Flash tiene 1M de tokens
89
  num_output=8192,
90
+ "Error buscar_web tras {max_attempts} intentos: {e}"
91
+
92
+ def reverse_text(text: str) is_chat_model=True,
93
+ is_function_calling_model=True, # Gemini es -> str:
94
+ return text[::-1]
95
+
96
+ def analyze_table(table_md: str, question: str) -> bueno en esto
97
  model_name=self.model_name,
98
  )
99
 
100
+ def chat(self, messages, str:
101
+ try:
102
+ lines = [l for l in table_md.splitlines() if l **kwargs):
103
  hist = []
104
  for m in messages[:-1]:
105
+ role = "user".strip()]
106
+ lines = [l for l in lines if not l.strip().startswith('|') or '---' not in l]
107
+ rows = []
108
+ for l in lines:
109
+ parts = [c if m.role == "user" else "model"
110
+ # Asegurar que content es string
111
+ content_str = str(m.content) if m.content is not None else ""
112
+ hist.append({"role.strip() for c in l.strip().strip('|').split('|')]
113
+ rows.append(parts)
114
+ if not rows or len(rows) < 1: # Comprobación de tabla vacía o solo cabecera
115
+ ": role, "parts":[{"text": content_str}]})
116
+
117
+ last_content_str = str(messages[-1].content) if messages[-1].content is not None else ""
118
  session = self._model.start_chat(history=hist)
119
  try:
120
+ resp = session.send_message(last_content_str)
121
  return ChatMessage(role="assistant", content=resp.text)
122
+ except Exceptionreturn "Error analyze_table: La tabla Markdown parece estar vacía o mal formateada después de la limpieza."
123
+ df = pd.DataFrame(rows[1:], columns=rows[0])
124
+
125
+ if 'conmut' in question.lower():
126
+ S = rows[0][1:]
127
+ counter = set()
128
+ for x_label in S:
129
+ for y_label in S:
130
+ # Asegurarse de que los as e:
131
+ # print(f"DEBUG Gemini chat error: {e}") # Para depuración local
132
+ # print(f"DEBUG History: {hist}")
133
+ # print(f"DEBUG Last message: {last_content_str}")
134
  return ChatMessage(role="assistant", content=f"Error Gemini: {e}")
135
 
136
  async def achat(self, messages, **kwargs):
137
  return await asyncio.to_thread(self.chat, messages, **kwargs)
138
 
139
+ # stream_chat, complete, acomplete, stream_complete, astream_ índices de las columnas existan
140
+ if x_label not in df.columns or y_label not in df.columns:
141
+ continue # Saltar si alguna columna no existe (podría pasar con tablas mal formadas)
142
+
143
+ # Localizar valores, asegurándose de que las filas (indexadas por la primera columna) existan
144
+ x_row = df[df[rows[0][0]] == x_label]
145
+ y_row = df[df[rows[0][0]] == y_label]
146
+
147
+ if x_row.empty or y_rowcomplete, astream_chat (SIN CAMBIOS)
148
+ # ... (mantener el código de estos métodos igual que en el original) ...
149
  def stream_chat(self, messages, **kwargs):
150
  hist = []
151
  for m in messages[:-1]:
152
  role = "user" if m.role=="user" else "model"
153
+ content_str = str(m.content) if m.content is not None else ""
154
+ hist.append({"role": role, "parts":[{"text":content_str}]})
155
+ last_content_str = str(messages[-1].content) if messages[-1].content is not None else ""
156
  session = self._model.start_chat(history=hist)
157
+ stream = session.send_message(last.empty:
158
+ continue # Saltar si no se encuentra la fila para x_label o y_label
159
+
160
+ val_a_series = x_row[y_label]
161
+ val_b_series = y_row[x_label]
162
+
163
+ if val_a_series.empty or val_b_series.empty:
164
+ continue # Saltar si la celda específica está vacía o no se encuentra
165
+
166
+ a = val_a_series.iat[0]
167
+ b = val_b_series.iat[0]
168
+
169
+ if a != b:
170
+ counter.update([x_label, y_label])
171
+ return ', '.join(sorted(list(counter))) or 'La operación es conmutativa (no hay contraejemplos).'
172
+ return df.to_csv(index=False)
173
+ except IndexError as e:
174
+ return f"Error analyze_table_content_str, stream=True)
175
  def gen():
176
  acc = ""
177
  for chunk in stream:
 
185
 
186
  def complete(self, prompt, formatted=False, **kwargs):
187
  try:
188
+ resp = self._model.generate_content(str(prompt)) # Asegurar que prompt es string
189
  return CompletionResponse(text=resp.text)
190
  except Exception as e:
191
  return CompletionResponse(text=f"Error complete: {e}")
192
 
193
  async def acomplete(self, prompt, formatted=False, **kwargs):
194
+ return await asyncio.to_thread(self.complete, str(prompt), formatted=formatted, ** (IndexError): {e}. Verifique que la tabla tiene cabeceras y datos y la pregunta es adecuada."
195
+ except KeyError as e:
196
+ return f"Error analyze_table (KeyError): {e}. Alguna columna o etiqueta no se encontró en la tabla."
197
+ except Exception as e:
198
+ return f"Error analyze_table: {e}"
199
+
200
+ def execute_code(code: str) -> str:
201
+ try:
202
+ # Try eval for simple expressions
203
+ try:
204
+ # MEJORA: Un entorno un poco más seguro y con más utilidades matemáticas comunes
205
+ allowed_globals = {'__builtins__': None, 'math': math, 'pdkwargs) # Asegurar que prompt es string
206
 
207
  def stream_complete(self, prompt, formatted=False, **kwargs):
208
+ stream = self._model.generate_content(str(prompt), stream=True) # Asegurar que prompt es string
209
  def gen():
210
  acc = ""
211
  for chunk in stream:
 
218
  return gen()
219
 
220
  async def astream_complete(self, prompt, formatted=False, **kwargs):
221
+ # This needs to be a': pd, 'np': __import__('numpy')} # Ejemplo, añadir según necesidad
222
+ allowed_locals = {name: getattr(math, name) for name in dir(math) if not name.startswith("__")}
223
+ val = eval(code, allowed_globals, allowed_locals)
224
+ return str(val)
225
+ except (SyntaxError, Name coroutine that returns an async generator
226
+ # For simplicity, we'll call the synchronous version in a thread
227
+ # and then adapt its generator. However, a true async implementation
228
+ # would use an async http client for genai.
229
+ sync_gen = await asyncio.to_thread(self.stream_complete, str(prompt), formatted=formatted, **kwargs)
230
+ async def async_gen_wrapper():
231
+ for item in sync_gen:
232
+ yieldError): # Errores comunes de eval que pueden ser código más complejo
233
+ pass # Intentar con subprocess
234
+ except Exception as e_eval: # Otros errores de eval
235
+ return f"Error eval en código: {e_eval}"
236
 
237
+ # Fallback to subprocess
238
+ res = subprocess.run(
239
+ ["python", "-c", code], capture_output=True, text=True, timeout=10 # MEJORA: timeout aumentado
240
+ )
241
+ if res.stderr:
242
+ return f"Error código (subprocess): {res.stderr.strip()}"
243
+ return res.stdout.strip() or "(sin salida de Python)"
244
+ except subprocess.TimeoutExpired:
245
+ return "Error ejecutar item
246
+ return async_gen_wrapper()
247
+
248
+
249
+ async def astream_chat(self, messages: list[ChatMessage], **kwargs):
250
+ # Similar to astream_complete, wrap the sync generator
251
+ sync_gen = await asyncio.to_thread(self.stream_chat, messages, **kwargs)
252
+ async def async_gen_wrapper():
253
+ for item in sync_gen:
254
+ yield item
255
+ return async_gen_wrapper código: El código tardó demasiado en ejecutarse (timeout)."
256
+ except Exception as e:
257
+ return f"Error crítico al ejecutar código: {e}"
258
 
259
+ def no_tool_solution(query: str) -> str:
260
+ # MEJORA: La respuesta debería ser manejada por el LLM. Esta herramienta solo indica que se usará conocimiento interno.
261
+ return "Respuesta basada en conocimiento interno. El LLM procederá a responder directamente."
262
+
263
+ def scrape_wikipedia_table()
264
  # -------------------------------------------------------------------
265
+ # 2) Herramientas (MODIFICADAS Y NUEVAS)
266
  # -------------------------------------------------------------------
267
+ HEADERS = {'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'} #(query: str, section_title: str, table_index: int = 0) -> str:
268
+ """
269
+ Busca una página de Wikipedia y extrae la tabla HTML (wikitable) bajo la sección dada.
270
+ Devuelve CSV de la tabla. Elige la N-ésima tabla si hay varias.
271
+ """
272
+ try Agente de usuario más común
273
 
274
  def buscar_web(query: str, max_attempts: int = 2) -> str:
275
+ """Busca en la web usando DuckDuckGo. Intenta hasta max_attempts veces."""
276
  for i in range(max_attempts):
277
  try:
278
+ # Aumentamos max_results para dar:
279
+ wikipedia.set_lang("es") # Asegurar idioma español
280
+ page = wikipedia.page(query, auto_suggest=True, redirect=True) # MEJORA: auto_suggest y redirect
281
+ html más contexto al LLM
282
  with DDGS(headers=HEADERS, timeout=25) as ddgs:
283
+ results = list(ddgs.text(query, region='es-es', safesearch='moderate', timelimit_content = page.html()
284
+ soup = BeautifulSoup(html_content, 'html.parser')
285
+
286
+ target_section = None
287
+ # Buscar encabezados h2 o h3 que contengan el título de la sección
288
+ ='y', max_results=5)) # Aumentado a 5
289
  if results:
290
+ return "\n".join(f"Fuente {idx+1}:\nTítulo: {r['title']}\nEnlace: {r for header_tag in soup.find_all(['h2', 'h3']):
291
+ if header_tag.['href']}\nCuerpo: {r['body']}" for idx, r in enumerate(results))
292
+ return "No se encontraron resultados relevantes en la web para esta consulta."
293
  except Exception as e:
294
+ ifspan and section_title.lower() in header_tag.span.get_text(strip=True).lower():
295
+ target_section = header_tag
296
+ break
297
+
298
+ if not target_section:
299
+ # Intento más permisivo si no se encontró con span exacto
300
+ for header_tag in soup.find_all(['h2', 'h3']):
301
+ if section_title.lower() in header_tag.get_ i < max_attempts - 1:
302
+ time.sleep(2 * (i + 1)) # Reducido el tiempo de espera base
303
  else:
304
+ return f"Error en buscar_web tras {max_attempts} intentos: {e}. Prueba una consulta diferente o simplificada."
 
305
 
306
  def reverse_text(text: str) -> str:
307
+ """Invierte una cadena de texto."""
308
  return text[::-1]
309
 
 
310
  def analyze_table(table_md: str, question: str) -> str:
311
+ """
312
+ Procesa una tabla entext(strip=True).lower():
313
+ target_section = header_tag
314
+ break
315
+ if not target_section:
316
+ return f"Sección '{section_title}' no encontrada en la página de Wikipedia '{query}'."
317
+
318
+ # Encontrar todas las tablas wikitable DESPUÉS del encabezado de la sección
319
+ # y ANTES del siguiente encabezado del mismo nivel o superior
320
+ tables_after_section = []
321
+ for sibling in target_section.find_next_siblings():
322
+ if sibling.name in ['h2', 'h3'] and sibling.name <= target_section.name: # <= para h2 vs h2, o h2 vs h3
323
+ break # Hemos llegado a la siguiente sección del mismo nivel o superior
324
+ if sibling.name == 'table' and 'wikitable' in formato Markdown. Si la pregunta incluye 'conmutatividad',
325
+ verifica esta propiedad. De lo contrario, devuelve la tabla en formato CSV.
326
+ """
327
  try:
328
+ lines = [l for l in table_md.splitlines() if l.strip() and not l.strip().startswith('|--')]
329
+ if not lines:
330
+ return "Error analyze_table: La tabla Markdown parece estar vacía o mal formateada."
331
+
 
332
  rows = []
333
+ header_skipped = False
334
  for l in lines:
335
+ # Skip initial divider lines if any
336
+ if '---' in l and not header_skipped :
337
+ if not rows: # if there's no header yet, this is the divider after header
338
+ header_skipped = True
339
+ continue
340
+
341
  parts = [c.strip() for c in l.strip().strip('|').split('|')]
342
+ if not any(parts): # Skip empty sibling.get('class', []):
343
+ tables_after_section.append(sibling)
344
+
345
+ if not tables_after_section:
346
+ return f"No se encontró ninguna tabla 'wikitable' después de la sección '{section_title}'."
347
+
348
+ if table_index >= len(tables_after_section):
349
+ return f"Índice de tabla {table_index} fuera de rango. Se encontraron {len(tables_after_section)} tablas después de la sección."
350
+
351
+ selected_table = tables_after_section[table_index]
352
+
353
+ # Usar pandas para leer la tabla HTML directamente
354
+ dfs = pd.read_html(StringIO(str(selected_table)), flavor='bs4') # StringIO necesario para read_html con string
355
+ if not dfs:
356
+ return "Pandas no pudo parsear la tabla HTML encontrada."
357
+
358
+ df = dfs[0] # Usualmente la primera tabla parseada es la correcta
359
+ # Limpieza básica: eliminar filas/columnas completamente vacías
360
+ df.dropna(axis=0, how='all', inplace=True)
361
+ df.dropna(axis=1, how='all', inplace=True)
362
+ return df.to_csv(index=False lines that might result from split
363
+ continue
364
  rows.append(parts)
365
+
366
+ if not rows or len(rows) < 1: # Need at least a header
367
+ return "Error analyze_table: No se pudo extraer ninguna fila válida de la tabla Markdown."
368
+
369
+ df = pd.DataFrame(rows[1:], columns=rows[0]) # Asume que la primera fila es el encabezado
370
+
371
+ if 'conmutatividad' in question.lower() or 'conmut' in question.lower():
372
+ if len(df.columns) < 2 or len(df) < 1:
373
+ return "Error analyze_table: La tabla es demasiado pequeña para verificar la conmutatividad."
374
+
375
+ first_col_name = df.columns[0]
376
+ S = df[first_col_name].tolist() # Elementos para verificar, usualmente de la primera columna/fila
377
+ # Asumimos que las cabeceras de las columnas (después de la primera) son los mismos elementos que en la primera columna
378
+ # Esto es típico para tablas de Cayley. Si no, esta lógica necesita ajuste.
379
+ elements_for_op = df.columns[1:].tolist()
380
+
381
+ # Check if elements_for_op are a subset of S, or if S matches elements_for_op
382
+ # For simplicity, let's assume the)
383
+ except wikipedia.exceptions.PageError:
384
+ return f"Error scrape_wikipedia_table: Página de Wikipedia '{query}' no encontrada."
385
+ except wikipedia.exceptions.DisambiguationError as e:
386
+ return f"Error scrape_wikipedia_table: '{query}' es ambiguo. Posibles opciones: {e.options[:5]}"
387
+ except Exception as e:
388
+ return f"Error scrape_wikipedia_table: {e}"
389
+
390
+ # --- NUEVAS HERRAMIENTAS ---
391
+ def read_excel_data(file_url_or_path: str, sheet_name: Any = 0) -> str:
392
+ """
393
+ Lee datos de un archivo Excel (desde URL o ruta local) y los devuelve como CSV.
394
+ El argumento sheet_name puede ser el nombre de la hoja o el índice (0 por defecto).
395
+ """
396
+ try:
397
+ # Intentar detectar si es una URL square part of the table for operation uses elements from the first column
398
+ # as both row and column headers for the operation.
399
+
400
+ counter_examples = set()
401
+ valid_elements_for_lookup = df.columns.tolist()
402
+
403
+ for x_val in S:
404
+ for y_val in S:
405
+ if x_val not in valid_elements_for_lookup or y_val not in valid_elements_for_lookup:
406
+ # This check is if S contains elements not in column headers,
407
+ # or y_val is not a column header. This logic might need refinement
408
+ # based on actual table structures encountered.
409
+ continue
410
+
411
+ try:
412
+ # df.loc[df[first_col_name]==x_val, y_val] da una Serie
413
+ # .iat[0] accede al primer elemento de esa Serie
414
+ a = df.loc[df[first_col_name] == x_val, y_val].iat[0]
415
+ b = df.loc[df[first_col_name] == y_val, x_val].iat[0]
416
+ if a != b:
417
+ counter_examples.update([x_val, y_val])
418
+ except (IndexError, KeyError) as e_lookup:
419
+ # Esto puede pasar si y_val no es una columna, o x_val no es una fila
420
+ # O si la tabla no es "cuadrada" en el sentido esperado para la operación
421
+
422
+ if file_url_or_path.startswith(('http://', 'https://')):
423
+ response = requests.get(file_url_or_path, headers=HEADERS, timeout=30)
424
+ response.raise_for_status() # Lanza error si la descarga falla
425
+ excel_content = BytesIO(response.content)
426
+ df = pd.read_excel(excel_content, sheet_name=sheet_name)
427
+ else: # Asumir que es una ruta local (aunque en GAIA es improbable que se use)
428
+ if not os.path.exists(file_url_or_path):
429
+ return f"Error read_excel_data: Archivo local no encontrado en '{file_url_or_path}'."
430
+ df = pd.read_excel(file_url_or_path, sheet_name=sheet_name)
431
+
432
+ # Limpieza básica: eliminar filas/columnas completamente vacías
433
+ df.dropna(axis=0, how='all', inplace=True)
434
+ df.dropna(axis=1, how='all', inplace=True)
435
  return df.to_csv(index=False)
436
+ except requests.exceptions.RequestException as e:
437
+ return f"Error read_excel_data: No se pudo descargar el archivo desde la URL. {e}"
438
+ except FileNotFoundError: # Específico para rutas locales
439
+ return f"Error read_excel_data: Archivo local no encontrado en '{file_url_or_path}'."
440
+ except pd.errors.EmptyDataError:
441
+ return "Error read_excel_data: El archivo Excel o la hoja especificada está vacía."
442
+ except ValueError as e: # Puede ser por sheet_name inválido
443
+ return f"Error read_excel_data: Error al procesar el Excel (posiblemente hoja no encontrada o formato incorrecto). {e}"
444
  except Exception as e:
445
+ return f"Error read_excel_data: {e}"
446
+
447
+ def classify_botanical(items_list_str: str) -> str:
448
+ """
449
+ Clasifica una lista de alimentos (separados por comas) en frutas y verduras botánicas.
450
+ Utiliza un conocimiento base y puede indicar ítems no reconocidos.
451
+ """
452
+ items = [item.strip().lower() for item in items_list_str.split(',')]
453
+ if not items or (len(items) == 1 and not items[0]):
454
+ return "Error classify_botanical # print(f"Debug: Error al buscar {x_val}, {y_val}: {e_lookup}")
455
+ continue # Saltar este par si no se puede encontrar
456
+
457
+ if counter_examples:
458
+ return f"La operación no es conmutativa. Contraejemplos (elementos involucrados): {', '.join(sorted(list(counter_examples)))}"
459
+ else:
460
+ return "La operación parece ser conmutativa para los elementos y valores dados."
461
+
462
+ return f"Tabla procesada en formato CSV:\n{df.to_csv(index=False)}"
463
+ except Exception as e:
464
+ return f"Error crítico en analyze_table: {e}. Asegúrate de que la tabla Markdown esté bien formateada."
465
 
466
 
467
  def execute_code(code: str) -> str:
468
+ """Ejecuta código Python de forma segura para cálculos o manipulación de datos simple.
469
+ Evita operaciones de sistema o red.
470
+ """
471
  try:
472
+ # Intenta eval para expresiones matemáticas/lógicas simples y seguras
473
+ # Proporciona un entorno limitado para eval
474
+ allowed_globals = {"math": math, "__builtins__": {"abs": abs, "min": min, "max": max, "sum": sum, "len": len, "round": round, "str": str, "int": int, "float": float, "list": list, "dict": dict, "tuple": tuple, "True": True, "False": False, "None": None}}
475
+ # math.__dict__ es demasiado permisivo. Seamos más explícitos si es necesario.
476
+ # Por ahora, el usuario debe usar math.sqrt, etc.
477
+
478
+ # Validar el código contra operaciones peligrosas antes de eval o exec
479
+ # Esto es una lista negra simple, no exhaustiva. Para un sandbox real se necesita más.
480
+ disallowed_keywords = ["import ", "os.", "sys.", "subprocess.", "open(", "eval(", "exec(", "compile(", "socket", "requests", "urllib", "shutil", "glob", "eval\\(", "exec\\("]
481
+ if any(keyword in code for keyword in disallowed_keywords):
482
+ # También chequear si `code` intenta llamar a `__import__` o acceder a `: La lista de ítems está vacía."
483
+
484
+ # Conocimiento botánico base (simplificado, ampliar según sea necesario)
485
+ # Fuentes: Conocimiento general botánico. Fruta = desarrolla del ovario de una flor y contiene semillas.
486
+ botanical_fruits = {
487
+ "tomate", "pepino", "calabacín", "berenjena", "pimiento", "aguacate",
488
+ "calabaza", "guisante", "judía verde", "maíz", "aceituna", "manzana", "pera",
489
+ "plátano", "naranja", "limón", "uva", "fresa", "frambuesa", "mora", "sandía",
490
+ "melón", "kiwi", "mango", "piña", "cereza", "ciruela", "melocotón", "albaricoque",
491
+ "higo", "granada", "papaya", "chile", "zapallo", "zapallito", "ají", "pimentón",
492
+ "sweet potatoes", "plums", "green beans", "corn", "bell pepper", "acorns", "peanuts", "oreos" # Oreos es claramente NO fruta ni verdura
493
+ }
494
+ # Verduras culinarias que son botánicamente frutas ya están arriba.
495
+ # Estas son partes de plantas como raíces, tallos, hojas.
496
+ botanical_vegetables = {
497
+ "zanahoria", "patata", "batata", "cebolla", "ajo", "puerro", "apio",
498
+ "lechuga", "espinaca", "acelga", "col", "brócoli", "coliflor", "espárrago",
499
+ "rábano", "nabo", "remolacha", "alcachofa", "hinojo",
500
+ "fresh basil", "broccoli", "celery", "lettuce", "sweet potatoes" # Sweet potatoes (batata) es raíz, verdura
501
+ }
502
+ # Algunos ítems de la lista original:
503
+ # milk, eggs, flour, whole bean coffee, Oreos, rice, whole allspice
504
+ non_botanical_produce = { # Ni fruta ni verdura en el sentido de "produce" fresco
505
+ "milk", "eggs", "flour", "whole bean coffee", "oreos", "rice", "whole allspice",
506
+ "leche", "huevos", "harina", "café en grano", "arroz", "pimienta de jamaica"
507
+ }
508
+
509
+ fruits_found = []
510
+ vegetables_found = []
511
+ unrecognized_items = []
512
+ other_items = []
513
+
514
+ for item_singular in items:
515
+ # Intentar una forma singular simple (esto es rudimentario)
516
+ item_processed = item_singular
517
+ if item_singular.endswith('s') and not item_singular.endswith('ss'): # ej. "apples" -> "apple"
518
+ if item_singular[:-1] in botanical_fruits or__builtins__` de forma peligrosa
519
+ if "__" in code: # Simplificación: si hay doble guion bajo, sospechar.
520
+ return "Error código: El código parece intentar acceder a funcionalidades restringidas o peligrosas (ej. dunder methods/attrs)."
521
+ return "Error código: El código contiene palabras clave o patrones no permitidos (ej. import, os, subprocess, file access)."
522
 
523
+ try:
524
+ # Si es una sola expresión, eval puede ser suficiente
525
+ val = eval(code, allowed_globals, {}) # No locals
526
+ return f"Resultado de la expresión: {str(val)}"
527
+ except (SyntaxError, NameError): # Si no es una expresión simple, o necesita definiciones
528
+ # Fallback a subprocess si eval falla o para scripts más complejos (pero aún simples)
529
+ # El timeout es crucial.
530
+ # Usar python -S para deshabilitar la importación de site-specific paths (más seguro)
531
+ # Para mayor seguridad, se podría ejecutar en un contenedor Docker o con `nsjail`.
532
+ # Por ahora, confiamos en el timeout y la validación de palabras clave.
533
+ # Si se necesita pandas o numpy en el código, esta herramienta tendría que ser más sofisticada
534
+ # o el LLM tendría que generar código que no dependa de imports no disponibles en el sandbox.
535
+
536
+ # Re-validar para subprocess, ya que el contexto es diferente
537
+ if any(keyword in code for keyword in ["import os", "import sys", "subprocess", " open("]): # lista más restrictiva para subprocess
538
+ return "Error código: El código para subprocess contiene palabras clave no permitidas."
539
+
540
+ # Si el código es multilinea o define funciones, eval no sirve, se necesitaría exec.
541
+ # Pero exec es más peligroso. Subprocess es un sandbox mejor si se configura bien.
542
+ # Por ahora, si eval falla, intentamos subprocess con el código tal cual.
543
+ # Esto asume que el código es un script simple que imprime a stdout.
544
+
545
+ # Prepend common safe imports if the LLM is instructed to use them without explicit import
546
+ # For example, if we want 'math' to be available implicitly.
547
+ # safe_prelude = "import math\n"
548
+ # full_code = safe_prelude + code
549
+
550
+ res = subprocess.run(
551
+ ["python", "-S", "-c", code], capture_output=True, text=True, timeout=10 # Aumentado timeout un poco
552
+ )
553
+ if res.returncode != 0: # Chequear returncode además de stderr
554
+ error_message = res.stderr.strip() if res.stderr else "El código falló sin un mensaje de error específico." item_singular[:-1] in botanical_vegetables:
555
+ item_processed = item_singular[:-1]
556
+
557
+ # Para el ejemplo específico del prompt, algunos están en inglés y otros no.
558
+ # La lista de la compra del prompt original:
559
+ # milk, eggs, flour, whole bean coffee, Oreos, sweet potatoes, fresh basil, plums, green beans,
560
+ # rice, corn, bell pepper, whole allspice, acorns, broccoli, celery, zucchini, lettuce, peanuts
561
+
562
+ if item_processed in botanical_fruits:
563
+ fruits_found.append(item_singular) # Devolver el original
564
+ elif item_processed in botanical_vegetables:
565
+ vegetables_found.append(item_singular)
566
+ elif item_processed in non_botanical_produce:
567
+ other_items.append(item_singular)
568
+ else:
569
+ # Consulta web simple si no está en las listas (podría ser costoso para muchos ítems)
570
+ # print(f"DEBUG: Item '{item_singular}' no reconocido, podría buscar en web.")
571
+ # search_result = buscar_web(f"¿{item_singular} es fruta o verdura botánica?", num_results=1)
572
+ # Aquí se necesitaría un LLM para interpretar el resultado de la búsqueda.
573
+ # Por ahora, lo marcamos como no reconocido por la herramienta.
574
+ unrecognized_items.append(item_singular)
575
+
576
+ result = []
577
+ if fruits_found:
578
+ result.append(f"Frutas botánicas: {', '.join(sorted(list(set(fruits_found))))}")
579
+ if vegetables_found:
580
+ result.append(f"Verduras botánicas: {', '.join(sorted(list(set(vegetables_found))))}")
581
+ if other_items:
582
+ result.append(f"Otros ítems (ni fruta ni verdura botánica): {', '.join(sorted(list(set(other_items))))}")
583
+ if unrecognized_items:
584
+ result.append(f"Ítems no reconocidos por esta herramienta (requieren investigación adicional o son ambiguos): {', '.join(sorted(list(set(unrecognized_items))))}")
585
+
586
+ return "\n".join(result) if result else "No se pudieron clasificar los ítems o la lista estaba vacía."
587
 
 
 
588
 
589
+ # Encapsular herramientas
590
+ search_tool = FunctionTool.from_defaults(fn=buscar_web, name="web_search", description="Búsqueda web general (DuckDuckGo, 5 resultados). Útil para información actual, definiciones, hechos, o cuando otras herramientas no aplican. Especifica bien tu consulta.")
591
+ reverse_tool = FunctionTool.from_defaults(fn=reverse_text, name="reverse_text", description="Invierte una cadena de texto.")
592
+ table_tool = FunctionTool.from_defaults(fn=analyze_table, name="analyze_table", description="Procesa tablas en formato Markdown. Puede verificar conmutatividad si se le pide o devolver la tabla como CSV. Asegúrate de que la tabla esté bien formada.")
593
+ code_tool = FunctionTool.from_defaults(fn=execute_code, name="execute_code", description="Ejecuta código Python para cálculos matemáticos, manipulación de datos simple (si se proveen como strings o listas), u otras tareas algorítmicas. No puede acceder a internet ni a archivos externos.")
594
+ fallback_tool = FunctionTool.from_defaults(fn=no_tool_solution, name="no_tool_solution", description="Úsalo como ÚLTIMO RECURSO si estás seguro de que puedes responder la pregunta con tu conocimiento interno y ninguna otra herramienta es adecuada o ha funcionado. Expl
595
+ return f"Error código (código de salida {res.returncode}): {error_message}"
596
+ return res.stdout.strip() or "(El código se ejecutó sin salida a stdout)"
597
+ except Exception as e_eval: # Captura errores de eval si no fueron SyntaxError o NameError
598
+ return f"Error código (eval): {e_eval}"
599
+
600
+ except Exception as e_general:
601
+ return f"Error crítico al intentar ejecutar código: {e_general}"
602
+
603
+ def no_tool_solution(query_context: str) -> str: # Cambiado el parámetro para reflejar su uso
604
+ """
605
+ Se utiliza cuando ninguna otra herramienta es adecuada o cuando la pregunta requiere
606
+ conocimiento general, razonamiento o procesamiento de información ya disponible en la conversación.
607
+ Explica brevemente por qué se usa esta opción.
608
+ """
609
+ return f"Procedo con conocimiento interno y razonamiento. Contexto/Razón: {query_context}"
610
 
611
+ def scrape_wikipedia_table(page_title: str, section_heading_substring: str, table_index: int = 0) -> str:
612
  """
613
+ Busca una página de Wikipedia por su título exacto y extrae una tabla HTML
614
+ bajo una sección que contenga el 'section_heading_substring'.
615
+ Devuelve la tabla como CSV. Se puede especificar table_index (0 por defecto) si hay varias tablas en la sección.
616
  """
617
  try:
618
+ wikipedia.set_lang("es") # Asegurar que busca en Wikipedia en español si es relevante
619
+ page = wikipedia.page(page_title, auto_suggest=False) # Evitar auto_suggest si el título debe ser exacto
620
  html = page.html()
621
  soup = BeautifulSoup(html, 'html.parser')
622
+
623
+ target_section_element = None
624
+ # Buscar encabezados (h1, h2, h3, h4) que contengan el texto de la sección
625
+ for header_level in ['h1', 'h2', 'h3', 'h4']:
626
+ headers = soup.find_all(header_level)
627
+ for header in headers:
628
+ if section_heading_substring.lower() in header.get_text(strip=True).lower():
629
+ span_headline = header.find('span', class_='mw-headline')
630
+ if span_headline and section_heading_substring.lower() in span_headline.get_text(strip=True).lower():
631
+ target_section_element = header
632
+ break
633
+ if target_section_element:
634
  break
635
+
636
+ if not target_section_element:
637
+ return f"Sección que contenga '{section_heading_substring}' no encontrada en la página de Wikipedia '{page_title}'. Intenta con un título de sección diferente o verifica el título de la página."
638
+
639
+ # Buscar todas las tablas wikitable *después* del encabezado de la sección encontrada
640
+ # y dentro del mismo "nivel" (antes del siguiente encabezado del mismo o mayor nivel)
641
+ tables_in_section = []
642
+ for sibling in target_section_element.find_next_siblings():
643
+ # Detener la búsqueda si encontramos otro encabezado del mismo nivel o superior
644
+ if sibling.name in ['h1', 'h2', 'h3', 'h4'] and sibling.name <= target_section_element.name:
645
+ break
646
+ if sibling.name == 'table' and ('wikitable' in sibling.get('class', [])):
647
+ tables_in_section.append(sibling)
648
+
649
+ if not tables_in_section:
650
+ return f"No se encontró ninguna tabla 'wikitable' después de la sección '{section_heading_substring}'."
651
+
652
+ if table_index >= len(tables_in_section):
653
+ return f"Índice de tabla {table_index} fuera de rango. Se encontraron {len(tables_in_section)} tablas en la sección."
654
+
655
+ # Convertir la tabla seleccionada a DataFrame y luego a CSV
656
+ # Usar pd.read_htmlica tu razonamiento.")
657
+ scrape_tool = FunctionTool.from_defaults(fn=scrape_wikipedia_table,name="scrape_wiki_table", description="Extrae una tabla específica (wikitable) de una página de Wikipedia dada una sección y el índice de la tabla (0 por defecto). Útil para datos estructurados en Wikipedia.")
658
+ # NUEVAS
659
+ excel_reader_tool = FunctionTool.from_defaults(fn=read_excel_data, name="read_excel_data", description="Lee datos de un archivo Excel accesible por URL (o ruta local, menos común aquí). Devuelve los datos como CSV. Necesita la URL del archivo y opcionalmente el nombre/índice de la hoja.")
660
+ botanical_tool = FunctionTool.from_defaults(fn=classify_botanical, name="classify_botanical_foods", description="Clasifica una lista de alimentos (texto separado por comas) en frutas botánicas y verduras botánicas según un conocimiento base. Indica ítems no reconocidos. Útil para listas de la compra o categorización de alimentos.")
661
+
662
+
663
+ all_tools = [
664
+ search_tool,
665
+ scrape_tool, # scrape_tool antes de table_tool si se espera obtener la tabla de la web primero
666
+ table_tool,
667
+ code_tool,
668
+ excel_reader_tool,
669
+ botanical_tool,
670
+ # reverse_tool, # Menos probable que sea útil en GAIA, pero lo dejamos
671
+ fallback_tool # Fallback siempre al final
672
+ ]
673
+
674
+ # Prompt de sistema REFINADO
675
  system_prompt = (
676
+ "Eres Alfred, un agente ReAct eficiente y preciso. Tu objetivo es responder preguntas de forma correcta.\n"
677
+ "Sigue estos pasos rigurosamente:\n"
678
+ "1. ANALIZA la pregunta cuidadosamente. Identifica la información clave y el tipo de respuesta esperada.\n"
679
+ "2. PLANIFICA tu enfoque: ¿Qué herramienta(s) son las más adecuadas? ¿Necesitas varios pasos?\n"
680
+ "3. EJECUTA: Usa la herramienta elegida. Formula entradas claras y concisas para las herramientas.\n"
681
+ " - **Prueba cada herramienta que consideres relevante ANTES de recurrir a `no_tool_solution`.**\n"
682
+ " - Si una herramienta falla o no da el resultado esperado, considera si otra herramienta podría ayudar o si necesitas reformular la entrada a la herramienta.\n"
683
+ "4. OBSERVA el resultado de la herramienta. ¿Es lo que esperabas? ¿Responde a la pregunta parcial o totalmente?\n"
684
+ "5. VERIFICA: ¿La información obtenida es suficiente y correcta? ¿Necesitas más pasos o herramientas?\n"
685
+ " - Si la pregunta involucra audio/video/imágenes o archivos en formatos que no puedes procesar con las herramientas disponibles (ej. formatos binarios sin un lector específico), informa claramente que NO PUEDES ACCEDER o procesar ese tipo de contenido.\n"
686
+ " - Para archivos Excel, intenta usar `read_excel_data` si se proporciona una URL. Si solo se menciona un archivo sin URL, pregunta si pueden proporcionar el contenido en formato Markdown o CSV.\n"
687
+ "6. RESPONDE: Una vez seguro de tener la respuesta final, clara y concisa, concluye tu pensamiento y proporciona la respuesta final al usuario.\n"
688
+ "Prioriza el uso de herramientas específicas sobre la búsqueda web general si una herramienta especializada es aplicable.\n"
689
+ "Herramientas disponibles (usa SOLO estas y con los nombres exactos):\n{tool_descriptions}"
690
  )
691
 
692
  # Inicializar agente
693
+ llm = GeminiLLM(model_name="models/gemini-1.5-flash-latest", temperature=0.0) # Temperatura ya estaba en 0.0
694
+
695
  alfred_agent = ReActAgent.from_tools(
696
  tools=all_tools,
697
  llm=llm,
698
  system_prompt=system_prompt,
699
  verbose=True,
700
+ max_iterations=15, # MEJORA: Reducido de 20 a 15. Ajustar según sea necesario.
701
+ # MEJORA: Añadir el callback manager al agente para que use los handlers definidos en el LLM
702
+ callback_manager=llm.callback_manager
703
  )
704
 
705
+ # Función pública (sin cambios)
706
  def basic_agent_response(question: str) -> str:
707
+ # Limpiar el estado del handler de métricas para cada nueva pregunta
708
+ # metrics_handler.tool_usage_counts.clear()
709
+ # metrics_handler.tool_errors.clear()
710
+ # metrics_handler.tool_timings.clear()
711
+ # metrics_handler._tool_starts.clear() # No que puede manejar HTML complejo, pero darle el string de la tabla
712
+ # io.StringIO es necesario porque read_html espera un buffer de archivo o una URL
713
+ dfs = pd.read_html(StringIO(str(tables_in_section[table_index])), flavor='bs4')
714
+ if not dfs:
715
+ return "Error scrape_wikipedia_table: pandas no pudo parsear la tabla encontrada."
716
+
717
+ df = dfs[0] # Asumimos que la primera tabla parseada es la correcta
718
+ return f"Tabla de Wikipedia (sección '{section_heading_substring}', índice {table_index}) en formato CSV:\n{df.to_csv(index=False)}"
719
+ except wikipedia.exceptions.PageError:
720
+ return f"Error scrape_wikipedia_table: Página de Wikipedia '{page_title}' no encontrada. Verifica el título."
721
+ except wikipedia.exceptions.DisambiguationError as e:
722
+ return f"Error scrape_wikipedia_table: Título ambiguo '{page_title}'. Posibles opciones: {e.options}. Por favor, proporciona un título más específico."
723
+ except IndexError:
724
+ return f"Error scrape_wikipedia_table: No se pudo encontrar la tabla con índice {table_index} en la sección especificada."
725
+ except Exception as e:
726
+ return f"Error inesperado en scrape_wikipedia_table: {e}"
727
+
728
+ # NUEVA HERRAMIENTA: Lector de Excel
729
+ def read_excel_data(file_path: str, sheet_name=0) -> str:
730
+ """
731
+ Lee un archivo Excel (.xls o .xlsx) desde una ruta de archivo local (file_path)
732
+ y una hoja específica (sheet_name, por defecto la primera, índice 0).
733
+ Devuelve los datos de la tabla como una cadena en formato CSV.
734
+ Esta herramienta SÓLO funciona si el archivo Excel está accesible en el sistema de archivos local
735
+ donde se ejecuta el agente y se proporciona la ruta correcta.
736
+ No puede acceder a archivos adjuntos de correos o subidos a chats directamente.
737
+ """
738
+ try:
739
+ # Para archivos remotos, necesitaríamos requests y pasar BytesIO(response.content)
740
+ # if file_path.startswith('http://') or file_path.startswith('https://'):
741
+ # response = requests.get(file_path)
742
+ # response.raise_for_status() # Asegura que la descarga fue exitosa
743
+ # excel_content = BytesIO(response.content)
744
+ # df = pd.read_excel(excel_content, sheet_name=sheet_name)
745
+ # else:
746
+ # df = pd.read_excel(file_path, sheet_name=sheet_name)
747
+
748
+ # Simplificamos: solo rutas locales por ahora, como es más probable en el entorno de prueba.
749
+ if not os.path.exists(file_path):
750
+ return f"Error read_excel_data: El archivo local '{file_path}' no fue encontrado."
751
+
752
+ df = pd.read_excel(file_path, sheet_name=sheet_name)
753
+
754
+ # Limpiar NaNs que pueden causar problemas al convertir a CSV o ser interpretados por el LLM
755
+ df_cleaned = df.fillna('') # Reemplazar NaN con string vacío
756
+
757
+ csv_output = df_cleaned.to_csv(index=False)
758
+ if not csv_output.strip(): # Si el CSV está vacío (solo cabeceras vacías o nada)
759
+ return "El archivo Excel o la hoja especificada parece estar vacía o no contiene datos procesables."
760
+
761
+ return f"Contenido del archivo Excel '{os.path.basename(file_path)}' (hoja '{sheet_name}') en formato CSV:\n{csv_output}"
762
+ except FileNotFoundError: # Esto es redundante si usamos os.path.exists, pero por si acaso.
763
+ return f"Error read_excel_data: El archivo local '{file_path}' no fue encontrado."
764
+ except ValueError as ve: # Errores comunes de pandas al leer formatos incorrectos
765
+ if "Excel file format cannot be determined" in str(ve) or "File is not a zip file" in str(ve):
766
+ return f"Error read_excel_data: El archivo '{file_path}' no parece ser un archivo Excel válido (.xls, .xlsx)."
767
+ return f"Error read_excel_data: Problema al leer el archivo Excel (ValueError): {ve}"
768
+ except Exception as e:
769
+ return f"Error crítico en read_excel_data al procesar '{file_path}': {e}"
770
+
771
+
772
+ # Encapsular herramientas
773
+ search_tool = FunctionTool.from_defaults(fn=buscar_web, name="web_search", description="Realiza una búsqueda en la web (DuckDuckGo) con una consulta y devuelve un resumen de los principales resultados (título, enlace, cuerpo). Útil para encontrar información general, noticias, o datos específicos que no estén en Wikipedia.")
774
+ reverse_tool = FunctionTool.from_defaults(fn=reverse_text, name="reverse_text", description="Invierte una cadena de texto. Útil para manipulaciones simples de strings o tareas específicas que lo requieran.")
775
+ table_tool = FunctionTool.from_defaults(fn=analyze_table, name="analyze_markdown_table", description="Procesa una tabla que YA ESTÁ en es ideal modificar atributos privados, pero para un reinicio simple...
776
+ # Mejor aún: crear una nueva instancia o un método reset() en el handler.
777
+ # Por simplicidad, y dado que el callback manager del LLM es compartido, esto es complicado.
778
+ # Una mejor práctica sería pasar un nuevo callback manager o handlers reseteados por pregunta si es necesario.
779
+ # Para GAIA, donde se evalúa pregunta por pregunta, el estado acumulado de las métricas puede ser útil al final.
780
+ # Si se quiere métricas por pregunta, el handler necesitaría ser instanciado/reseteado por llamada.
781
+
782
  try:
783
  resp = alfred_agent.query(question)
784
+ # Imprimir métricas después de cada respuesta (opcional, para depuración)
785
+ # print("--- Métricas de Herramientas para esta pregunta ---")
786
+ # print(metrics_handler.get_metrics())
787
+ # print("-------------------------------------------------")
788
  return resp.response or "No se generó respuesta."
789
  except Exception as e:
790
  return f"Error crítico del agente: {e}"
791
 
792
+ if __name__ == "__main__":
793
+ # Ejemplo de cómo obtener las métricas acumuladas al final de una sesión
794
+ # Esto es solo un ejemplo, en tu `app.py` lo harías después del bucle de preguntas.
795
+
796
+ # Simulación de algunas preguntas
797
+ print("Ejecutando prueba de agente (botánica)...")
798
+ # Pregunta original de la lista de la compra
799
+ q_botanica = "I'm making a grocery list for my mom, but she's a professor of botany and she's a real stickler when it comes to categorizing things. I need to add different foods to different categories on the grocery list, but if I make a mistake, she won't buy anything inserted in the wrong category. Here's the list I have so far: milk, eggs, flour, whole bean coffee, Oreos, sweet potatoes, fresh basil, plums, green beans, rice, corn, bell pepper, whole allspice, acorns, broccoli, celery, zucchini, lettuce, peanuts I need to make headings for the fruits and vegetables. Could you please create a list of just the vegetables from my list? If you could do that, then I can figure out how to categorize the rest of the list into the appropriate categories. But remember that my mom is a real stickler, so make sure that no botanical fruits end up on the vegetable list, or she won't get them when she's at the store. Please alphabetize the list of vegetables, and place each item in a comma separated list."
800
+ response_botanica = basic_agent_response(q_botanica)
801
+ print(f"Pregunta Botánica: {q_botanica}")
802
+ print(f"Respuesta del Agente: {response_botanica}\n")
803
+
804
+ print("Ejecutando prueba de agente (Excel)...")
805
+ # OJO: Esta URL es un ejemplo, DEBE ser un enlace directo a un archivo .xlsx o .xls
806
+ # Github raw links a veces no funcionan directamente con requests si no son el archivo real.
807
+ # Necesitarías una URL pública directa a un archivo Excel.
808
+ # Ejemplo (ficticio, necesitas una URL real): "https://example.com/test_sales.xlsx"
809
+ # Para probar sin una URL real, puedes comentar esta prueba o usar una ruta local si ejecutas esto localmente.
810
+ q_excel = "Por favor, lee el archivo Excel en 'https://github.com/datasciencedojo/datasets/raw/master/Sales%20Data/Sales%20Examples.xlsx' y dime el total de 'Total Revenue' de la primera hoja."
811
+ # Este enlace anterior SÍ es un Excel real y público
812
+ response_excel = basic_agent_response(q_excel)
813
+ print(f"Pregunta Excel: {q_excel}")
814
+ print(f"Respuesta del Agente: {response_excel}\n")
815
+
816
+ # Al final de todas las ejecuciones, puedes imprimir las métricas acumuladas:
817
+ print("\n--- Métricas Acumuladas de Herramientas (Ejemplo Final) ---")
818
+ print(metrics_handler.get_metrics())
819
+ print("----------------------------------------------------------")