Nancy1906 commited on
Commit
190c392
·
verified ·
1 Parent(s): 6736add
Files changed (1) hide show
  1. my_tools.py +179 -120
my_tools.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import os
2
  import math
3
  import time
@@ -5,7 +7,7 @@ import asyncio
5
  import subprocess
6
  import requests
7
  import pandas as pd
8
- from io import StringIO, BytesIO
9
  from bs4 import BeautifulSoup
10
  from duckduckgo_search import DDGS
11
  import wikipedia
@@ -19,16 +21,17 @@ from llama_index.core.agent import ReActAgent
19
  from llama_index.core.callbacks.llama_debug import LlamaDebugHandler
20
 
21
  # -------------------------------------------------------------------
22
- # 1) GeminiLLM personalizado
23
  # -------------------------------------------------------------------
24
- # Monkey-patch para exponer .message al objeto completo, tal como espera LlamaIndex
25
  ChatMessage.message = property(lambda self: self)
 
 
 
 
26
  class GeminiLLM(LLM):
27
  model_name: str = Field(default="models/gemini-1.5-flash-latest")
28
  temperature: float = Field(default=0.0)
29
 
30
- # Atributos para el modelo y config de generación.
31
- # Pydantic los ignorará si no son Fields y Config.extra = "allow" (lo cual tienes)
32
  _model: object = None
33
  _gen_cfg: object = None
34
 
@@ -36,78 +39,58 @@ class GeminiLLM(LLM):
36
  extra = "allow"
37
 
38
  def __init__(self, **kwargs):
39
- super().__init__(**kwargs) # Pydantic procesa campos y kwargs
40
-
41
- # --- INICIO DE LA CORRECCIÓN PARA FieldInfo ---
42
- # Obtener el valor resuelto de model_name explícitamente
43
- # Primero, intentar con el atributo de instancia (que Pydantic debería haber establecido)
44
- actual_model_name = self.model_name
45
-
46
- # Si sigue siendo un FieldInfo (o no es un string), obtener el valor default del campo
47
  if not isinstance(actual_model_name, str):
48
- # Acceder a la definición del campo de la clase para obtener su default
49
- # self.__fields__ es un dict de los campos Pydantic de la clase
50
  model_field_definition = self.__fields__.get("model_name")
51
  if model_field_definition and hasattr(model_field_definition, 'default'):
52
  actual_model_name = model_field_definition.default
53
-
54
- # Como última salvaguarda, si todo falla, usar un string literal (no ideal)
55
  if not isinstance(actual_model_name, str):
56
- # print("ADVERTENCIA: model_name no se pudo resolver a un string, usando valor literal.")
57
- actual_model_name = "models/gemini-1.5-flash-latest"
58
-
59
- # Lo mismo para temperature, aunque es menos probable que sea un FieldInfo aquí
60
  actual_temperature = self.temperature
61
  if not isinstance(actual_temperature, (float, int)):
62
  temp_field_definition = self.__fields__.get("temperature")
63
  if temp_field_definition and hasattr(temp_field_definition, 'default'):
64
  actual_temperature = temp_field_definition.default
65
  if not isinstance(actual_temperature, (float, int)):
66
- # print("ADVERTENCIA: temperature no se pudo resolver a un float, usando 0.0.")
67
  actual_temperature = 0.0
68
- # --- FIN DE LA CORRECCIÓN PARA FieldInfo ---
69
 
70
  key = os.getenv("GEMINI_API_KEY")
71
  if not key:
72
- raise ValueError("GEMINI_API_KEY no configurada")
73
  genai.configure(api_key=key)
74
-
75
  self._gen_cfg = genai.types.GenerationConfig(temperature=actual_temperature)
76
  self._model = genai.GenerativeModel(
77
- model_name=actual_model_name, # Usar el valor de string resuelto
78
  generation_config=self._gen_cfg
79
  )
80
-
81
  if self.callback_manager is None:
82
  from llama_index.core.callbacks.base import CallbackManager
83
  self.callback_manager = CallbackManager([])
84
-
85
  if not self.callback_manager.handlers:
86
  self.callback_manager.add_handler(LlamaDebugHandler())
87
 
88
  @property
89
  def metadata(self):
90
- # También asegurar que model_name es un string aquí
91
  actual_model_name_meta = self.model_name
92
  if not isinstance(actual_model_name_meta, str):
93
  model_field_def_meta = self.__fields__.get("model_name")
94
  if model_field_def_meta and hasattr(model_field_def_meta, 'default'):
95
  actual_model_name_meta = model_field_def_meta.default
96
  if not isinstance(actual_model_name_meta, str):
97
- actual_model_name_meta = "models/gemini-1.5-flash-latest" # Fallback
98
-
99
  return LLMMetadata(
100
  context_window=1048576,
101
  num_output=8192,
102
  is_chat_model=True,
103
  is_function_calling_model=True,
104
- model_name=actual_model_name_meta, # Usar el valor de string resuelto
105
  )
106
 
107
- # ... (todos los demás métodos: chat, achat, stream_complete, astream_complete, stream_chat, astream_chat, complete, acomplete)
108
- # DEBEN ESTAR EXACTAMENTE COMO EN TU ÚLTIMA VERSIÓN FUNCIONAL DEL CÓDIGO QUE ME PEGASTE.
109
- # Los copio de tu último fragmento para asegurar consistencia:
110
-
111
  def chat(self, messages: list[ChatMessage], **kwargs):
112
  hist = []
113
  for m in messages[:-1]:
@@ -180,20 +163,27 @@ class GeminiLLM(LLM):
180
  async def acomplete(self, prompt: str, formatted=False, **kwargs):
181
  return await asyncio.to_thread(self.complete, prompt, formatted=formatted, **kwargs)
182
 
183
- # --- Fin de la clase GeminiLLM ---
184
  # -------------------------------------------------------------------
185
- # 2) Herramientas
186
  # -------------------------------------------------------------------
187
  HEADERS = {'User-Agent': 'Mozilla/5.0'}
188
 
189
- # Web search tool
190
  def buscar_web(query: str, max_attempts: int = 2, num_results: int = 5) -> str:
 
 
 
191
  for i in range(max_attempts):
192
  try:
193
  with DDGS(headers=HEADERS, timeout=25) as ddgs:
194
  results = list(ddgs.text(query, region='es-es', safesearch='moderate', max_results=num_results))
195
  if results:
196
- return "\n".join(f"Fuente {idx+1}: Título: {r['title']}\nEnlace: {r.get('href','N/A')}\nCuerpo: {r['body']}" for idx, r in enumerate(results))
 
 
 
 
 
 
197
  return "No se encontraron resultados relevantes."
198
  except Exception as e:
199
  if i < max_attempts - 1:
@@ -201,15 +191,19 @@ def buscar_web(query: str, max_attempts: int = 2, num_results: int = 5) -> str:
201
  else:
202
  return f"Error buscar_web tras {max_attempts} intentos: {e}"
203
 
204
- # Reverse text tool
205
  def reverse_text(text: str) -> str:
 
206
  return text[::-1]
207
 
208
- # Analyze markdown table
209
  def analyze_table(table_md: str, question: str) -> str:
 
 
 
 
210
  try:
 
211
  lines = [l for l in table_md.splitlines() if l.strip() and '---' not in l]
212
- rows = [ [c.strip() for c in l.strip().strip('|').split('|')] for l in lines ]
213
  if len(rows) < 2:
214
  return "Tabla Markdown mal formateada o vacía."
215
  df = pd.DataFrame(rows[1:], columns=rows[0])
@@ -219,7 +213,7 @@ def analyze_table(table_md: str, question: str) -> str:
219
  for x in cols:
220
  for y in cols:
221
  try:
222
- if df.loc[df[rows[0][0]]==x, y].iat[0] != df.loc[df[rows[0][0]]==y, x].iat[0]:
223
  counter.update([x, y])
224
  except:
225
  continue
@@ -228,15 +222,18 @@ def analyze_table(table_md: str, question: str) -> str:
228
  except Exception as e:
229
  return f"Error analyze_table: {e}"
230
 
231
- # Execute code tool
232
  def execute_code(code: str) -> str:
 
 
 
233
  try:
234
  allowed_globals = {'__builtins__': None, 'math': math}
235
  try:
236
  val = eval(code, allowed_globals, {})
237
  return str(val)
238
  except:
239
- res = subprocess.run(["python", "-S", "-c", code], capture_output=True, text=True, timeout=10)
 
240
  if res.returncode != 0:
241
  return f"Error código: {res.stderr.strip()}"
242
  return res.stdout.strip() or "(sin salida)"
@@ -245,10 +242,13 @@ def execute_code(code: str) -> str:
245
  except Exception as e:
246
  return f"Error crítico: {e}"
247
 
248
- # Read Excel tool
249
  def read_excel_data(file_path: str, sheet_name=0) -> str:
 
 
 
 
250
  try:
251
- if file_path.startswith(('http://','https://')):
252
  resp = requests.get(file_path, headers=HEADERS, timeout=30)
253
  resp.raise_for_status()
254
  df = pd.read_excel(BytesIO(resp.content), sheet_name=sheet_name)
@@ -262,21 +262,26 @@ def read_excel_data(file_path: str, sheet_name=0) -> str:
262
  return f"Error read_excel_data: {e}"
263
 
264
  def classify_botanical(items_list_str: str) -> str:
265
- # Mapas de traducción para términos en inglés → español
 
 
 
266
  mapping = {
267
  "tomato": "tomate", "pepper": "pimiento", "bell pepper": "pimiento",
268
  "cucumber": "pepino", "eggplant": "berenjena", "zucchini": "calabacín",
269
  "avocado": "aguacate", "squash": "calabaza", "pea": "guisante", "corn": "maíz",
270
- "bean": "judía", "green beans": "judía verde", "sweet potato": "batata",
 
271
  }
272
- fruits = {"tomate","pepino","calabacín","berenjena","pimiento","aguacate","calabaza","guisante","judía verde","maíz"}
273
- vegetables = {"zanahoria","patata","batata","cebolla","ajo","puerro","apio","lechuga","espinaca","brócoli","apio","brócoli","lechuga"}
274
- # Convertir todo a minúsculas y mapear sinónimos
275
  items = []
276
  for raw in items_list_str.split(','):
277
  itm = raw.strip().lower()
278
  itm_es = mapping.get(itm, itm)
279
  items.append(itm_es)
 
280
  vegs = [i for i in items if i in vegetables]
281
  fruits_found = [i for i in items if i in fruits]
282
  others = [i for i in items if i not in fruits and i not in vegetables]
@@ -286,116 +291,170 @@ def classify_botanical(items_list_str: str) -> str:
286
  f"Otros: {', '.join(sorted(set(others)))}"
287
  )
288
 
289
- # Wikipedia table scraper
290
  def scrape_wikipedia_table(page_title: str, section: str, table_index: int = 0) -> str:
 
 
 
 
291
  try:
292
  wikipedia.set_lang("es")
293
  page = wikipedia.page(page_title, auto_suggest=False)
294
  soup = BeautifulSoup(page.html(), 'html.parser')
295
- header = next((h for h in soup.find_all(['h2','h3']) if section.lower() in h.get_text(strip=True).lower()), None)
 
 
 
296
  if not header:
297
  return f"Sección '{section}' no encontrada en '{page_title}'"
298
  tables = []
299
  for sib in header.find_next_siblings():
300
- if sib.name in ['h2','h3']: break
301
- if sib.name=='table' and 'wikitable' in sib.get('class',[]): tables.append(sib)
302
- if table_index>=len(tables): return f"Tabla índice {table_index} fuera de rango (solo {len(tables)} tablas)."
 
 
 
303
  df = pd.read_html(str(tables[table_index]))[0]
304
  return df.to_csv(index=False)
305
  except Exception as e:
306
  return f"Error scrape_wiki_table: {e}"
307
 
308
- # Wrap tools
309
- search_tool = FunctionTool.from_defaults(fn=buscar_web, name="web_search", description="Búsqueda DuckDuckGo.")
310
- reverse_tool = FunctionTool.from_defaults(fn=reverse_text, name="reverse_text", description="Invierte texto.")
311
- table_tool = FunctionTool.from_defaults(fn=analyze_table, name="analyze_markdown_table", description="Procesa tabla Markdown.")
312
- code_tool = FunctionTool.from_defaults(fn=execute_code, name="execute_code", description="Ejecuta Python.")
313
- excel_tool = FunctionTool.from_defaults(fn=read_excel_data, name="read_excel_data", description="Lee Excel.")
314
- botanical_tool = FunctionTool.from_defaults(fn=classify_botanical, name="classify_botanical_foods", description="Clasifica botánicamente alimentos.")
315
- scrape_tool = FunctionTool.from_defaults(fn=scrape_wikipedia_table, name="scrape_wiki_table", description="Scrapea tabla Wikipedia.")
316
- fallback_tool= FunctionTool.from_defaults(fn=lambda q: "Procedo con conocimiento interno.", name="no_tool_solution", description="Fallback.")
317
- all_tools = [search_tool, scrape_tool, table_tool, code_tool, excel_tool, botanical_tool, reverse_tool, fallback_tool]
318
-
319
- all_tools = [search_tool, scrape_tool, table_tool, code_tool, excel_tool, botanical_tool, reverse_tool, fallback_tool]
320
-
321
- # Construir descripciones de herramientas
322
- # CORRECCIÓN AQUÍ: Acceder a name y description a través de metadata
323
- #tool_descriptions = "\n".join([
324
- #f"{t.metadata.name}: {t.metadata.description}" # Cambiado t.name a t.metadata.name y t.tool_config.description a t.metadata.description
325
- #for t in all_tools
326
- #])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  tool_descriptions = "\n".join([
328
  f"{t.metadata.name}: {t.metadata.description} "
329
  + {
330
- "classify_botanical_foods": "(Ejemplo: classify_botanical_foods('zanahoria, manzana, tomate'))",
331
- "read_excel_data": "(Ejemplo: read_excel_data('ventas.xlsx', sheet_name=0))",
332
- "analyze_markdown_table": "(Ejemplo: analyze_markdown_table('| A | B |\\n|---|---|\\n|1|2|', '¿Es conmut?'))",
333
- "web_search": "(Ejemplo: web_search('¿Quién ganó el Clásico 2025?'))",
334
- "scrape_wiki_table": "(Ejemplo: scrape_wiki_table('Lionel Messi', 'Carrera', 0))",
335
- "reverse_text": "(Ejemplo: reverse_text('hola'))",
336
- "execute_code": "(Ejemplo: execute_code('2+2'))",
337
  }.get(t.metadata.name, "")
338
  for t in all_tools
339
  ])
340
 
341
- # Prompt de sistema
 
 
342
  system_prompt = f"""
343
  Eres Alfred, un agente ReAct eficiente y preciso. Tu objetivo es responder correctamente usando las herramientas disponibles.
344
- Sigue este flujo para cada pregunta:
345
- 1. LEE la pregunta y analiza palabras clave.
346
- - Si ves “lista” de “alimentos” o “categorizar” cosas botánicamente, usa classify_botanical_foods(items_coma_sep).
347
- - Si ves referencia a un “archivo Excel” o “attached Excel file”, usa read_excel_data(ruta_o_URL).
348
- - Si ves una tabla Markdown(columnas/filas con pipes), usa analyze_markdown_table(tabla_md, pregunta).
349
- - Si necesitas buscar información en la web, usa web_search(query).
350
- - Si hay que raspar una tabla en Wikipedia, usa scrape_wiki_table(título_página, sección, índice_tabla).
351
- - Si debes invertir texto, usa reverse_text(texto).
352
- - Si debes ejecutar código Python directamente, usa execute_code(código_python).
353
-
354
- 2. Selecciona la HERRAMIENTA adecuada y construye el input:
355
- Ejemplos:
356
- - Pregunta: “Por favor, clasifica esta lista de verduras y frutas: tomate, zanahoria, manzana,…”
357
- → TOOL CALL: classify_botanical_foods("tomate, zanahoria, manzana,…")
358
- - Pregunta: “Adjunto un Excel con ventas, ¿cuál fue la suma de las ventas de comida sin bebidas?”
359
- → TOOL CALL: read_excel_data("ruta/o/URL/al/archivo.xlsx")
360
-
361
  3. EJECUTA la herramienta y observa el resultado.
362
- 4. VERIFICA que la salida tenga sentido con la pregunta (si algo falla, vuelve a intentarlo).
363
- 5. RESPONDE de forma clara y concisa usando la salida de la herramienta.
364
 
365
- Herramientas disponibles (úsa solo estos nombres exactos):
366
  {tool_descriptions}
367
  """
368
 
369
- # Inicializar agente
370
- llm = GeminiLLM() # LlamaDebugHandler se añade dentro de GeminiLLM si no hay handlers
 
 
371
  alfred_agent = ReActAgent.from_tools(
372
  tools=all_tools,
373
  llm=llm,
374
  system_prompt=system_prompt,
375
  verbose=True,
376
- max_iterations=25,
377
- callback_manager=llm.callback_manager, # Asegura que el agente usa el callback manager del LLM
378
- handle_parsing_errors=True, # que reintente si el LLM genera JSON malformado
379
  )
380
 
381
  def basic_agent_response(question: str) -> str:
 
 
 
 
382
  try:
383
-
384
- # Si detectamos “Excel” en el texto, extraemos la ruta (SAIA suele anotar algo como “attached Excel file”)
385
- if "attached Excel" in question.lower() or "archivo excel" in question.lower():
386
- # Supongamos que SAIA añade algo como “see attached Excel file” sin ruta en la pregunta.
387
- # Podemos devolver un mensaje especial que indique al usuario que suba la ruta.
388
- # Pero en la práctica, SAIA pasa el path en un campo aparte; aquí solo forzamos a usar read_excel_data:
389
- # (En muchos casos, SAIA evalúa que invoques la herramienta correctamente)
390
- # Podemos invocar read_excel_data sin parámetros y devolver un placeholder:
391
  return read_excel_data("data/attached.xlsx")
392
-
393
  resp = alfred_agent.query(question)
394
  if hasattr(resp, 'response') and resp.response is not None:
395
  return str(resp.response)
396
  elif resp is not None:
397
  return str(resp)
398
- return "No se generó una respuesta válida."
 
399
  except Exception as e:
400
  return f"Error crítico del agente: {e}"
401
 
 
 
 
 
1
+ # my_tools.py
2
+
3
  import os
4
  import math
5
  import time
 
7
  import subprocess
8
  import requests
9
  import pandas as pd
10
+ from io import BytesIO
11
  from bs4 import BeautifulSoup
12
  from duckduckgo_search import DDGS
13
  import wikipedia
 
21
  from llama_index.core.callbacks.llama_debug import LlamaDebugHandler
22
 
23
  # -------------------------------------------------------------------
24
+ # 1) MONKEY-PATCH PARA ChatMessage (por requerimiento de LlamaIndex)
25
  # -------------------------------------------------------------------
 
26
  ChatMessage.message = property(lambda self: self)
27
+
28
+ # -------------------------------------------------------------------
29
+ # 2) Clase GeminiLLM personalizada
30
+ # -------------------------------------------------------------------
31
  class GeminiLLM(LLM):
32
  model_name: str = Field(default="models/gemini-1.5-flash-latest")
33
  temperature: float = Field(default=0.0)
34
 
 
 
35
  _model: object = None
36
  _gen_cfg: object = None
37
 
 
39
  extra = "allow"
40
 
41
  def __init__(self, **kwargs):
42
+ super().__init__(**kwargs)
43
+ # Resolver FieldInfo si es necesario
44
+ actual_model_name = self.model_name
 
 
 
 
 
45
  if not isinstance(actual_model_name, str):
 
 
46
  model_field_definition = self.__fields__.get("model_name")
47
  if model_field_definition and hasattr(model_field_definition, 'default'):
48
  actual_model_name = model_field_definition.default
 
 
49
  if not isinstance(actual_model_name, str):
50
+ actual_model_name = "models/gemini-1.5-flash-latest"
51
+
 
 
52
  actual_temperature = self.temperature
53
  if not isinstance(actual_temperature, (float, int)):
54
  temp_field_definition = self.__fields__.get("temperature")
55
  if temp_field_definition and hasattr(temp_field_definition, 'default'):
56
  actual_temperature = temp_field_definition.default
57
  if not isinstance(actual_temperature, (float, int)):
 
58
  actual_temperature = 0.0
 
59
 
60
  key = os.getenv("GEMINI_API_KEY")
61
  if not key:
62
+ raise ValueError("GEMINI_API_KEY no configurada en variables de entorno")
63
  genai.configure(api_key=key)
64
+
65
  self._gen_cfg = genai.types.GenerationConfig(temperature=actual_temperature)
66
  self._model = genai.GenerativeModel(
67
+ model_name=actual_model_name,
68
  generation_config=self._gen_cfg
69
  )
70
+
71
  if self.callback_manager is None:
72
  from llama_index.core.callbacks.base import CallbackManager
73
  self.callback_manager = CallbackManager([])
 
74
  if not self.callback_manager.handlers:
75
  self.callback_manager.add_handler(LlamaDebugHandler())
76
 
77
  @property
78
  def metadata(self):
 
79
  actual_model_name_meta = self.model_name
80
  if not isinstance(actual_model_name_meta, str):
81
  model_field_def_meta = self.__fields__.get("model_name")
82
  if model_field_def_meta and hasattr(model_field_def_meta, 'default'):
83
  actual_model_name_meta = model_field_def_meta.default
84
  if not isinstance(actual_model_name_meta, str):
85
+ actual_model_name_meta = "models/gemini-1.5-flash-latest"
 
86
  return LLMMetadata(
87
  context_window=1048576,
88
  num_output=8192,
89
  is_chat_model=True,
90
  is_function_calling_model=True,
91
+ model_name=actual_model_name_meta,
92
  )
93
 
 
 
 
 
94
  def chat(self, messages: list[ChatMessage], **kwargs):
95
  hist = []
96
  for m in messages[:-1]:
 
163
  async def acomplete(self, prompt: str, formatted=False, **kwargs):
164
  return await asyncio.to_thread(self.complete, prompt, formatted=formatted, **kwargs)
165
 
 
166
  # -------------------------------------------------------------------
167
+ # 3) HERRAMIENTAS PERSONALIZADAS
168
  # -------------------------------------------------------------------
169
  HEADERS = {'User-Agent': 'Mozilla/5.0'}
170
 
 
171
  def buscar_web(query: str, max_attempts: int = 2, num_results: int = 5) -> str:
172
+ """
173
+ Usa DuckDuckGo (vía duckduckgo_search) para devolver hasta 'num_results' resultados.
174
+ """
175
  for i in range(max_attempts):
176
  try:
177
  with DDGS(headers=HEADERS, timeout=25) as ddgs:
178
  results = list(ddgs.text(query, region='es-es', safesearch='moderate', max_results=num_results))
179
  if results:
180
+ salida = []
181
+ for idx, r in enumerate(results):
182
+ titulo = r.get('title', 'Sin título')
183
+ enlace = r.get('href', 'N/A')
184
+ cuerpo = r.get('body', '')
185
+ salida.append(f"Fuente {idx+1}: Título: {titulo}\nEnlace: {enlace}\nCuerpo: {cuerpo}")
186
+ return "\n\n".join(salida)
187
  return "No se encontraron resultados relevantes."
188
  except Exception as e:
189
  if i < max_attempts - 1:
 
191
  else:
192
  return f"Error buscar_web tras {max_attempts} intentos: {e}"
193
 
 
194
  def reverse_text(text: str) -> str:
195
+ """Invierte el orden de los caracteres en 'text'."""
196
  return text[::-1]
197
 
 
198
  def analyze_table(table_md: str, question: str) -> str:
199
+ """
200
+ Recibe una tabla en Markdown (con pipes y separadores) y, si la pregunta menciona 'conmut',
201
+ verifica la conmutatividad de la matriz; en otro caso, devuelve el CSV equivalente.
202
+ """
203
  try:
204
+ # Quitar líneas de separación y vacías
205
  lines = [l for l in table_md.splitlines() if l.strip() and '---' not in l]
206
+ rows = [[c.strip() for c in l.strip().strip('|').split('|')] for l in lines]
207
  if len(rows) < 2:
208
  return "Tabla Markdown mal formateada o vacía."
209
  df = pd.DataFrame(rows[1:], columns=rows[0])
 
213
  for x in cols:
214
  for y in cols:
215
  try:
216
+ if df.loc[df[rows[0][0]] == x, y].iat[0] != df.loc[df[rows[0][0]] == y, x].iat[0]:
217
  counter.update([x, y])
218
  except:
219
  continue
 
222
  except Exception as e:
223
  return f"Error analyze_table: {e}"
224
 
 
225
  def execute_code(code: str) -> str:
226
+ """
227
+ Primero intenta evaluar con eval() en un entorno protegido; si falla, invoca un subproceso 'python -c'.
228
+ """
229
  try:
230
  allowed_globals = {'__builtins__': None, 'math': math}
231
  try:
232
  val = eval(code, allowed_globals, {})
233
  return str(val)
234
  except:
235
+ res = subprocess.run(["python", "-S", "-c", code],
236
+ capture_output=True, text=True, timeout=10)
237
  if res.returncode != 0:
238
  return f"Error código: {res.stderr.strip()}"
239
  return res.stdout.strip() or "(sin salida)"
 
242
  except Exception as e:
243
  return f"Error crítico: {e}"
244
 
 
245
  def read_excel_data(file_path: str, sheet_name=0) -> str:
246
+ """
247
+ Si file_path empieza con 'http', descarga el contenido y lee con pandas.
248
+ Si es una ruta local, lee directamente. Devuelve el CSV.
249
+ """
250
  try:
251
+ if file_path.startswith(('http://', 'https://')):
252
  resp = requests.get(file_path, headers=HEADERS, timeout=30)
253
  resp.raise_for_status()
254
  df = pd.read_excel(BytesIO(resp.content), sheet_name=sheet_name)
 
262
  return f"Error read_excel_data: {e}"
263
 
264
  def classify_botanical(items_list_str: str) -> str:
265
+ """
266
+ Clasifica botánicamente una lista de alimentos (en inglés o español) en Verduras, Frutas u Otros.
267
+ """
268
+ # Mapeo inglés → español
269
  mapping = {
270
  "tomato": "tomate", "pepper": "pimiento", "bell pepper": "pimiento",
271
  "cucumber": "pepino", "eggplant": "berenjena", "zucchini": "calabacín",
272
  "avocado": "aguacate", "squash": "calabaza", "pea": "guisante", "corn": "maíz",
273
+ "bean": "judía", "beans": "judía", "green beans": "judía verde", "sweet potato": "batata",
274
+ "whole bean coffee": "café", "rice": "arroz", "oregano": "orégano"
275
  }
276
+ fruits = {"tomate", "pepino", "calabacín", "berenjena", "pimiento", "aguacate", "calabaza", "guisante", "judía verde", "maíz"}
277
+ vegetables = {"zanahoria", "patata", "batata", "cebolla", "ajo", "puerro", "apio", "lechuga", "espinaca", "brócoli", "pepino", "pepino"}
278
+
279
  items = []
280
  for raw in items_list_str.split(','):
281
  itm = raw.strip().lower()
282
  itm_es = mapping.get(itm, itm)
283
  items.append(itm_es)
284
+
285
  vegs = [i for i in items if i in vegetables]
286
  fruits_found = [i for i in items if i in fruits]
287
  others = [i for i in items if i not in fruits and i not in vegetables]
 
291
  f"Otros: {', '.join(sorted(set(others)))}"
292
  )
293
 
 
294
  def scrape_wikipedia_table(page_title: str, section: str, table_index: int = 0) -> str:
295
+ """
296
+ Busca una sección en una página de Wikipedia y extrae la tabla indicada (por índice).
297
+ Devuelve el CSV.
298
+ """
299
  try:
300
  wikipedia.set_lang("es")
301
  page = wikipedia.page(page_title, auto_suggest=False)
302
  soup = BeautifulSoup(page.html(), 'html.parser')
303
+ header = next(
304
+ (h for h in soup.find_all(['h2', 'h3']) if section.lower() in h.get_text(strip=True).lower()),
305
+ None
306
+ )
307
  if not header:
308
  return f"Sección '{section}' no encontrada en '{page_title}'"
309
  tables = []
310
  for sib in header.find_next_siblings():
311
+ if sib.name in ['h2', 'h3']:
312
+ break
313
+ if sib.name == 'table' and 'wikitable' in sib.get('class', []):
314
+ tables.append(sib)
315
+ if table_index >= len(tables):
316
+ return f"Tabla índice {table_index} fuera de rango (solo {len(tables)} tablas)."
317
  df = pd.read_html(str(tables[table_index]))[0]
318
  return df.to_csv(index=False)
319
  except Exception as e:
320
  return f"Error scrape_wiki_table: {e}"
321
 
322
+ # -------------------------------------------------------------------
323
+ # 4) ENVUELTORES DE HERRAMIENTAS (FunctionTool)
324
+ # -------------------------------------------------------------------
325
+ search_tool = FunctionTool.from_defaults(
326
+ fn=buscar_web,
327
+ name="web_search",
328
+ description="Búsqueda DuckDuckGo (máximo 5 resultados)."
329
+ )
330
+ reverse_tool = FunctionTool.from_defaults(
331
+ fn=reverse_text,
332
+ name="reverse_text",
333
+ description="Invierte el texto recibido."
334
+ )
335
+ table_tool = FunctionTool.from_defaults(
336
+ fn=analyze_table,
337
+ name="analyze_markdown_table",
338
+ description="Procesa tabla Markdown y verifica conmutatividad si se menciona 'conmut'."
339
+ )
340
+ code_tool = FunctionTool.from_defaults(
341
+ fn=execute_code,
342
+ name="execute_code",
343
+ description="Ejecuta código Python de forma segura."
344
+ )
345
+ excel_tool = FunctionTool.from_defaults(
346
+ fn=read_excel_data,
347
+ name="read_excel_data",
348
+ description="Lee un archivo Excel (local o URL) y devuelve CSV."
349
+ )
350
+ botanical_tool = FunctionTool.from_defaults(
351
+ fn=classify_botanical,
352
+ name="classify_botanical_foods",
353
+ description="Clasifica botánicamente una lista de alimentos."
354
+ )
355
+ scrape_tool = FunctionTool.from_defaults(
356
+ fn=scrape_wikipedia_table,
357
+ name="scrape_wiki_table",
358
+ description="Extrae tabla de sección específica de Wikipedia."
359
+ )
360
+ fallback_tool = FunctionTool.from_defaults(
361
+ fn=lambda q: "Procedo con conocimiento interno.",
362
+ name="no_tool_solution",
363
+ description="Respuesta genérica por conocimiento interno si todo lo demás falla."
364
+ )
365
+
366
+ all_tools = [
367
+ search_tool,
368
+ scrape_tool,
369
+ table_tool,
370
+ code_tool,
371
+ excel_tool,
372
+ botanical_tool,
373
+ reverse_tool,
374
+ fallback_tool
375
+ ]
376
+
377
+ # -------------------------------------------------------------------
378
+ # 5) DESCRIPCIONES DE HERRAMIENTAS (con ejemplos)
379
+ # -------------------------------------------------------------------
380
  tool_descriptions = "\n".join([
381
  f"{t.metadata.name}: {t.metadata.description} "
382
  + {
383
+ "classify_botanical_foods": "(Ej: classify_botanical_foods('zanahoria, pepino, tomate'))",
384
+ "read_excel_data": "(Ej: read_excel_data('ventas.xlsx', sheet_name=0))",
385
+ "analyze_markdown_table": "(Ej: analyze_markdown_table('| A | B |\\n|---|---|\\n|1|2|', '¿Es conmut?'))",
386
+ "web_search": "(Ej: web_search('¿Quién ganó la Champions 2025?'))",
387
+ "scrape_wiki_table": "(Ej: scrape_wiki_table('Lionel Messi', 'Carrera', 0))",
388
+ "reverse_text": "(Ej: reverse_text('Hola'))",
389
+ "execute_code": "(Ej: execute_code('5*7'))",
390
  }.get(t.metadata.name, "")
391
  for t in all_tools
392
  ])
393
 
394
+ # -------------------------------------------------------------------
395
+ # 6) PROMPT DE SISTEMA MEJORADO
396
+ # -------------------------------------------------------------------
397
  system_prompt = f"""
398
  Eres Alfred, un agente ReAct eficiente y preciso. Tu objetivo es responder correctamente usando las herramientas disponibles.
399
+ Sigue este flujo en cada pregunta:
400
+ 1. LEE la pregunta y analiza palabras clave:
401
+ - Si menciona “lista” de “alimentos” o “categorizar” botánicamente, llama:
402
+ classify_botanical_foods(<lista_coma_sep>).
403
+ - Si mencionaarchivo Excelo “Excel adjunto”, llama:
404
+ read_excel_data(<ruta_o_URL>).
405
+ - Si ves una tabla Markdown”, llama:
406
+ analyze_markdown_table(<tabla_md>, <pregunta>).
407
+ - Si necesitas información general de la web, llama:
408
+ web_search(<consulta>).
409
+ - Si necesitas raspar tablas de Wikipedia, llama:
410
+ scrape_wiki_table(<título>, <sección>, <índice_tabla>).
411
+ - Si hay que invertir texto, llama:
412
+ reverse_text(<texto>).
413
+ - Si hay que ejecutar código Python, llama:
414
+ execute_code(<código>).
415
+ 2. GENERA el “TOOL CALL” con la entrada correcta.
416
  3. EJECUTA la herramienta y observa el resultado.
417
+ 4. VERIFICA que el resultado responda bien la pregunta. Si no, intenta otro paso.
418
+ 5. RESPONDE de forma clara y concisa usando la salida obtenida.
419
 
420
+ Herramientas disponibles (USAR EXÁCTAMENTE estos nombres):
421
  {tool_descriptions}
422
  """
423
 
424
+ # -------------------------------------------------------------------
425
+ # 7) INICIALIZAR EL AGENTE ReActAgent
426
+ # -------------------------------------------------------------------
427
+ llm = GeminiLLM()
428
  alfred_agent = ReActAgent.from_tools(
429
  tools=all_tools,
430
  llm=llm,
431
  system_prompt=system_prompt,
432
  verbose=True,
433
+ max_iterations=25, # Más iteraciones para razonamiento multi-paso
434
+ callback_manager=llm.callback_manager,
435
+ handle_parsing_errors=True # Para que reintente si la llamada a herramienta sale malformada
436
  )
437
 
438
  def basic_agent_response(question: str) -> str:
439
+ """
440
+ Si detecta “Excel adjunto”, asume que SAIA inyecta el path y fuerza read_excel_data.
441
+ De lo contrario, usa ReActAgent.query().
442
+ """
443
  try:
444
+ # Forzar uso de read_excel_data si aparece Excel en la pregunta
445
+ if "attached excel" in question.lower() or "archivo excel" in question.lower():
446
+ # En el entorno SAIA normalmente inyectan la ruta real; aquí usamos un placeholder.
 
 
 
 
 
447
  return read_excel_data("data/attached.xlsx")
 
448
  resp = alfred_agent.query(question)
449
  if hasattr(resp, 'response') and resp.response is not None:
450
  return str(resp.response)
451
  elif resp is not None:
452
  return str(resp)
453
+ else:
454
+ return "No se generó una respuesta válida."
455
  except Exception as e:
456
  return f"Error crítico del agente: {e}"
457
 
458
+ # --- FIN DE my_tools.py ---
459
+
460
+