Mahynlo commited on
Commit
7038b94
·
1 Parent(s): 0abe794

Mejoras profesionales: logging, prompt optimizado, temperature 0.0, mejor limpieza de respuestas

Browse files
Files changed (3) hide show
  1. agents.py +204 -254
  2. app.py +6 -5
  3. model.py +68 -86
agents.py CHANGED
@@ -1,330 +1,280 @@
1
  """
2
- Agent class para resolver tareas GAIA usando Gemini.
 
 
 
 
3
  """
4
 
5
  import re
6
  from typing import Optional, List, Any
7
- from model import GeminiModel
 
 
 
 
 
 
 
 
 
 
8
 
9
 
10
  class Agent:
11
  """
12
- Agente para resolver tareas del benchmark GAIA usando Google Gemini.
13
  """
14
-
15
  def __init__(
16
  self,
17
  model: GeminiModel,
18
  tools: Optional[List[Any]] = None,
19
- verbose: bool = False
 
20
  ):
21
  """
22
  Inicializa el agente.
23
-
24
  Args:
25
- model: Modelo Gemini a usar
26
- tools: Lista de herramientas disponibles (opcional)
27
- verbose: Si True, imprime información de debug
 
28
  """
29
  self.model = model
30
  self.tools = tools or []
31
  self.verbose = verbose
32
-
33
- # Prompt optimizado para GAIA benchmark
34
- self.system_prompt = """You are an expert AI assistant specialized in solving GAIA benchmark tasks with precision.
35
-
36
- CRITICAL FORMATTING RULES (EXACT MATCHING REQUIRED):
37
-
38
- 1. NUMBERS:
39
- - Write as plain digits: 42 (not 42.0 or 42,000)
40
- - NO commas in numbers: 1000000 (not 1,000,000)
41
- - NO units unless explicitly requested: 42 (not $42 or 42%)
42
- - Use Arabic numerals: 9 (not nine)
43
-
44
- 2. STRINGS:
45
- - Lowercase preferred: paris (not Paris)
46
- - NO articles: paris (not "the paris" or "a paris")
47
- - NO abbreviations: san francisco (not SF or S.F.)
48
- - Write digits in plain text unless specified
49
-
50
- 3. LISTS:
51
- - Comma-separated: apple,orange,banana
52
- - NO brackets: apple,orange (not [apple,orange])
53
- - NO quotes: apple,orange (not "apple","orange")
54
-
55
- 4. CURRENCY (only if explicitly requested):
56
- - Use symbol: $40.00
57
- - Follow requested format exactly
58
-
59
- 5. DATES:
60
- - Follow exact format requested in question
61
-
62
- YOUR RESPONSE STRUCTURE:
63
- 1. Think step by step (max 5 sentences)
64
- 2. If files are provided, USE THE CONTENT
65
- 3. End with: FINAL ANSWER: [exact answer ON SAME LINE]
66
-
67
- 🚨 CRITICAL FINAL ANSWER RULES 🚨:
68
- - "FINAL ANSWER:" MUST be the LAST line of your response
69
- - Put ONLY the answer on the SAME LINE after "FINAL ANSWER:"
70
- - NEVER write ANYTHING after the answer (no periods, explanations, nothing!)
71
- - The answer must be on ONE line only
72
-
73
- ✅ CORRECT Examples:
74
- FINAL ANSWER: 42
75
- FINAL ANSWER: paris
76
- FINAL ANSWER: apple,banana,orange
77
-
78
- ❌ WRONG Examples (DO NOT DO THIS):
79
- FINAL ANSWER: The answer is 42.
80
- FINAL ANSWER: 42
81
- This is because...
82
- FINAL ANSWER: I need to listen to...
83
-
84
- IMPORTANT: GAIA uses exact string matching. Be precise!"""
85
-
86
  def __call__(self, question: str, files: Optional[List[str]] = None) -> str:
87
- """
88
- Interfaz principal para resolver una pregunta.
89
-
90
- Args:
91
- question: La pregunta a responder
92
- files: Lista opcional de rutas de archivos asociados
93
-
94
- Returns:
95
- str: La respuesta limpia y formateada
96
- """
97
  if self.verbose:
98
- print(f"\n{'='*60}")
99
- print(f"📋 Pregunta: {question[:100]}...")
100
  if files:
101
- print(f"📎 Archivos: {files}")
102
-
103
  answer = self.answer_question(question, files)
104
-
105
  if self.verbose:
106
- print(f"✅ Respuesta: {answer}")
107
- print(f"{'='*60}\n")
108
-
109
  return answer
110
-
111
  def answer_question(self, question: str, files: Optional[List[str]] = None) -> str:
112
- """
113
- Procesa la pregunta y genera una respuesta.
114
-
115
- Args:
116
- question: La pregunta a responder
117
- files: Lista opcional de archivos
118
-
119
- Returns:
120
- str: Respuesta limpia
121
- """
122
  try:
123
- # Construir contexto
124
  context = self._build_context(question, files)
125
-
126
- # Construir prompt completo
127
- full_prompt = f"{self.system_prompt}\n\n{context}"
128
-
129
  if self.verbose:
130
- print(f"🤖 Llamando a Gemini...")
131
-
132
- # Llamar al modelo con configuración optimizada
133
  response = self.model.generate_simple(
134
- full_prompt,
135
- temperature=0.1, # Balance: determinismo pero no robotico
136
- max_tokens=500 # Limitado para forzar concisión
137
  )
138
-
139
- # Limpiar y formatear respuesta
 
 
140
  clean = self._clean_answer(response)
141
-
 
 
 
 
142
  return clean
143
-
144
  except Exception as e:
145
- error_msg = f"ERROR: {str(e)}"
146
- print(f" {error_msg}")
147
- return error_msg
148
-
149
  def _build_context(self, question: str, files: Optional[List[str]] = None) -> str:
150
  """
151
  Construye el contexto para el prompt, procesando archivos si existen.
152
-
153
- Args:
154
- question: La pregunta
155
- files: Lista opcional de archivos/URLs
156
-
157
- Returns:
158
- str: Contexto formateado con contenido de archivos
159
  """
160
- context_parts = [f"TASK: {question}"]
161
-
162
- # Procesar archivos si existen
163
- if files and len(files) > 0:
164
- context_parts.append("\n📁 FILES PROVIDED:")
165
-
166
- from tools import read_image_text, read_excel_file, read_audio_file
167
- import requests
168
-
169
  for file_url in files:
170
  try:
171
  file_lower = file_url.lower()
172
-
173
- # Procesar imágenes con OCR
174
  if any(ext in file_lower for ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp']):
175
  if self.verbose:
176
- print(f" 📷 Procesando imagen: {file_url}")
177
- text = read_image_text(file_url)
 
 
 
 
178
  if text and text.strip():
179
- context_parts.append(f"\n🖼️ IMAGE CONTENT from {file_url}:")
180
- context_parts.append(f"{text.strip()}")
181
  else:
182
- context_parts.append(f"\n⚠️ Could not extract text from image: {file_url}")
183
-
184
- # Procesar archivos Excel
185
  elif any(ext in file_lower for ext in ['.xlsx', '.xls']):
186
  if self.verbose:
187
- print(f" 📊 Procesando Excel: {file_url}")
188
- content = read_excel_file(file_url)
189
- context_parts.append(f"\n📊 EXCEL DATA from {file_url}:")
190
- context_parts.append(content)
191
-
192
- # Procesar archivos de audio (limitado)
 
 
193
  elif any(ext in file_lower for ext in ['.mp3', '.wav', '.ogg', '.m4a']):
194
  if self.verbose:
195
- print(f" 🎵 Detectado audio: {file_url}")
196
- info = read_audio_file(file_url)
197
- context_parts.append(f"\n🎵 AUDIO FILE:")
198
- context_parts.append(info)
199
-
200
- # Procesar archivos de texto
 
 
201
  elif any(ext in file_lower for ext in ['.txt', '.csv', '.json', '.py', '.md']):
202
  if self.verbose:
203
- print(f" 📄 Procesando archivo texto: {file_url}")
204
- response = requests.get(file_url, timeout=30)
205
- response.raise_for_status()
206
- content = response.text[:5000] # Primeros 5000 caracteres
207
- context_parts.append(f"\n📄 FILE CONTENT from {file_url}:")
208
- context_parts.append(f"{content}")
209
-
 
 
 
210
  else:
211
- # Archivo de tipo desconocido
212
- context_parts.append(f"\n📎 File available: {file_url}")
213
-
214
  except Exception as e:
215
- if self.verbose:
216
- print(f" Error procesando {file_url}: {str(e)}")
217
- context_parts.append(f"\n⚠️ Could not process file: {file_url}")
218
-
219
- # Detectar texto invertido (reversed text) - común en GAIA
 
220
  if self._is_reversed_text(question):
221
  reversed_q = question[::-1]
222
- context_parts.append(f"\n⚠️ REVERSED TEXT DETECTED!")
223
- context_parts.append(f"Original text: {question}")
224
- context_parts.append(f"Actual question: {reversed_q}")
225
- context_parts.append("Answer the reversed version in NORMAL text.")
226
-
227
  return "\n".join(context_parts)
228
-
229
  def _is_reversed_text(self, text: str) -> bool:
230
  """
231
- Detecta si el texto está invertido.
232
-
233
- Args:
234
- text: Texto a analizar
235
-
236
- Returns:
237
- bool: True si parece estar invertido
238
  """
239
- # Heurística: texto invertido suele empezar con "." o contener patrones invertidos
 
 
240
  indicators = [
241
- text.strip().startswith("."),
242
- "?rewsna" in text.lower(),
243
- "?noitseuq" in text.lower(),
244
- ".rewsna eht sa" in text.lower()
 
 
245
  ]
246
  return any(indicators)
247
-
248
  def _clean_answer(self, response: str) -> str:
249
  """
250
- Limpia y formatea la respuesta según reglas GAIA.
251
-
252
- Args:
253
- response: Respuesta cruda del modelo
254
-
255
- Returns:
256
- str: Respuesta limpia
257
  """
258
- # Extraer respuesta final si hay marcador "FINAL ANSWER:"
259
- if "FINAL ANSWER:" in response.upper():
260
- # Buscar case-insensitive
 
 
 
 
 
 
261
  parts = re.split(r'FINAL ANSWER:\s*', response, flags=re.IGNORECASE)
262
- if len(parts) > 1:
263
- # Tomar solo lo que viene después de FINAL ANSWER:
264
- after_marker = parts[-1].strip()
265
-
266
- # Tomar solo la MISMA LÍNEA (cortar en el primer salto de línea)
267
- # Esto evita que el modelo escriba explicaciones largas después
268
- first_line = after_marker.split('\n')[0].strip()
269
-
270
- # Si la primera línea es sospechosamente larga (>200 chars),
271
- # probablemente el modelo no siguió instrucciones - cortar agresivamente
272
- if len(first_line) > 200:
273
- # Buscar patrones comunes de fin de respuesta
274
- for delimiter in ['. ', '? ', '! ', ', because', ', which', ', and', ', so']:
275
- if delimiter in first_line[:200]:
276
- first_line = first_line.split(delimiter)[0].strip()
277
- break
278
- else:
279
- # Si no hay delimitadores, tomar primeros 150 caracteres
280
- first_line = first_line[:150].strip()
281
-
282
- response = first_line if first_line else after_marker
283
-
284
- # Remover prefijos comunes
285
- prefixes = [
286
- "The answer is:", "Answer:", "Final Answer:",
287
- "The final answer is:", "=>", "Result:",
288
- "Output:", "Solution:", "I need to"
289
- ]
290
- for prefix in prefixes:
291
- if response.lower().startswith(prefix.lower()):
292
- response = response[len(prefix):].strip()
293
-
294
- # Limpiar comillas y espacios
295
- response = response.strip(" '\"")
296
-
297
- # Remover punto final si no es parte de la respuesta
298
- # (solo si la respuesta es larga o contiene espacios)
299
- if response.endswith("."):
300
- # No remover si parece un decimal o número con punto
301
- if not response.replace(".", "").replace(",", "").replace(" ", "").isdigit():
302
- # Solo remover si hay espacios o es muy larga
303
- if " " in response or len(response) > 20:
304
- response = response.rstrip(".")
305
-
306
- # Manejar respuestas invertidas - invertir de vuelta
307
- if self._is_reversed_text(response):
308
- response = response[::-1]
309
-
310
- # Remover corchetes de listas si existen
311
- response = response.strip("[]")
312
-
313
- return response.strip()
314
 
315
 
316
  def create_agent(model_id: str = "gemini/gemini-2.0-flash-exp", verbose: bool = False, **kwargs) -> Agent:
317
  """
318
- Factory function para crear un agente con Gemini.
319
-
320
- Args:
321
- model_id: ID del modelo Gemini a usar
322
- verbose: Si True, imprime información de debug
323
- **kwargs: Argumentos adicionales para el modelo
324
-
325
- Returns:
326
- Agent: Instancia del agente configurado
327
  """
328
  from model import get_model
329
  model = get_model(model_id, **kwargs)
330
- return Agent(model=model, verbose=verbose)
 
1
  """
2
+ Agent class para resolver tareas GAIA.
3
+
4
+ Basado en tu versión original (inspirado en chiarapaglioni/GAIA-agents),
5
+ con mejoras para compatibilidad con la evaluación GAIA (exact match),
6
+ robustez en descarga/procesado de archivos y limpieza de respuesta.
7
  """
8
 
9
  import re
10
  from typing import Optional, List, Any
11
+ from io import BytesIO
12
+ import requests
13
+ import logging
14
+
15
+ # Ajusta el nivel de logging si quieres más/menos detalle
16
+ logger = logging.getLogger(__name__)
17
+ logger.setLevel(logging.INFO)
18
+
19
+ # Asumimos que `model.py` expone get_model() y una clase modelo con método generate_simple(prompt, **kwargs)
20
+ # e.g., model = get_model("gemini/...")
21
+ from model import GeminiModel # type: ignore
22
 
23
 
24
  class Agent:
25
  """
26
+ Agente para resolver tareas del benchmark GAIA.
27
  """
28
+
29
  def __init__(
30
  self,
31
  model: GeminiModel,
32
  tools: Optional[List[Any]] = None,
33
+ verbose: bool = False,
34
+ normalize_to_lowercase: bool = False
35
  ):
36
  """
37
  Inicializa el agente.
 
38
  Args:
39
+ model: Modelo (adaptador) que implemente generate_simple(prompt, **kwargs)
40
+ tools: Lista de herramientas (opcional)
41
+ verbose: Si True, imprime info de debug
42
+ normalize_to_lowercase: Si True, normaliza la salida a minúsculas (cuidado: puede romper nombres)
43
  """
44
  self.model = model
45
  self.tools = tools or []
46
  self.verbose = verbose
47
+ self.normalize_to_lowercase = normalize_to_lowercase
48
+
49
+ # Prompt mejorado: pedir SOLO la respuesta final sin prefijos ni explicaciones.
50
+ # NO exigir "FINAL ANSWER:" para evitar que el sistema agregue un prefijo que rompa exact-match.
51
+ self.system_prompt = (
52
+ "You are an expert AI assistant specialized in solving GAIA benchmark tasks with precision.\n\n"
53
+ "IMPORTANT - OUTPUT RULES (GAIA EXACT MATCH):\n"
54
+ " - Return ONLY the final answer, nothing else. No explanations, no commentary, no prefixes.\n"
55
+ " - The answer must be on a single line (no line breaks) and must match the expected format exactly.\n"
56
+ " - Do NOT add 'Final answer', 'Answer:', or any label.\n"
57
+ " - If the question expects a list, return comma-separated values with no brackets or quotes (apple,orange).\n"
58
+ " - If the question expects a number, return digits only (e.g. 42).\n"
59
+ " - If the question expects a string, return it exactly (case-sensitive unless you configured normalization).\n\n"
60
+ "Follow any file content provided and use it as context. Think briefly if needed, but output only the final line."
61
+ )
62
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  def __call__(self, question: str, files: Optional[List[str]] = None) -> str:
 
 
 
 
 
 
 
 
 
 
64
  if self.verbose:
65
+ logger.info("\n" + "=" * 60)
66
+ logger.info(f"📋 Pregunta: {question[:200]}")
67
  if files:
68
+ logger.info(f"📎 Archivos: {files}")
69
+
70
  answer = self.answer_question(question, files)
71
+
72
  if self.verbose:
73
+ logger.info(f"✅ Respuesta final: {answer}")
74
+ logger.info("=" * 60 + "\n")
75
+
76
  return answer
77
+
78
  def answer_question(self, question: str, files: Optional[List[str]] = None) -> str:
 
 
 
 
 
 
 
 
 
 
79
  try:
 
80
  context = self._build_context(question, files)
81
+
82
+ full_prompt = f"{self.system_prompt}\n\nTASK: {question}\n\nCONTEXT:\n{context}\n\nAnswer now:"
 
 
83
  if self.verbose:
84
+ logger.info("🤖 Llamando al modelo con prompt optimizado...")
85
+
 
86
  response = self.model.generate_simple(
87
+ full_prompt,
88
+ temperature=0.0, # determinismo preferible para exact-match
89
+ max_tokens=256
90
  )
91
+
92
+ if self.verbose:
93
+ logger.info(f"🔍 Respuesta bruta del modelo (truncada 1000 chars): {response[:1000]!r}")
94
+
95
  clean = self._clean_answer(response)
96
+
97
+ # Normalización opcional (configurable)
98
+ if self.normalize_to_lowercase and isinstance(clean, str):
99
+ clean = clean.lower()
100
+
101
  return clean
102
+
103
  except Exception as e:
104
+ logger.exception("Error al resolver la pregunta:")
105
+ return f"ERROR: {str(e)}"
106
+
 
107
  def _build_context(self, question: str, files: Optional[List[str]] = None) -> str:
108
  """
109
  Construye el contexto para el prompt, procesando archivos si existen.
 
 
 
 
 
 
 
110
  """
111
+ context_parts = []
112
+ # Incluir (breve) instrucción/metadata
113
+ context_parts.append(f"QUESTION_RAW: {question}")
114
+
115
+ if files:
116
+ context_parts.append("FILES_CONTENT_START")
117
+ # import tools aquí (asumimos que existen funciones en tools.py)
118
+ from tools import read_image_text, read_excel_file, read_audio_file # type: ignore
119
+
120
  for file_url in files:
121
  try:
122
  file_lower = file_url.lower()
 
 
123
  if any(ext in file_lower for ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp']):
124
  if self.verbose:
125
+ logger.info(f" 📷 Procesando imagen: {file_url}")
126
+ try:
127
+ text = read_image_text(file_url)
128
+ except Exception as e:
129
+ logger.warning(f" OCR error: {e}")
130
+ text = ""
131
  if text and text.strip():
132
+ context_parts.append(f"IMAGE_TEXT_FROM {file_url}:\n{text.strip()}")
 
133
  else:
134
+ context_parts.append(f"IMAGE_NO_TEXT_EXTRACTED_FROM {file_url}")
135
+
 
136
  elif any(ext in file_lower for ext in ['.xlsx', '.xls']):
137
  if self.verbose:
138
+ logger.info(f" 📊 Procesando Excel: {file_url}")
139
+ try:
140
+ content = read_excel_file(file_url)
141
+ context_parts.append(f"EXCEL_FROM {file_url}:\n{content}")
142
+ except Exception as e:
143
+ logger.warning(f" Excel read error: {e}")
144
+ context_parts.append(f"EXCEL_READ_ERROR {file_url}")
145
+
146
  elif any(ext in file_lower for ext in ['.mp3', '.wav', '.ogg', '.m4a']):
147
  if self.verbose:
148
+ logger.info(f" 🎵 Procesando audio: {file_url}")
149
+ try:
150
+ info = read_audio_file(file_url)
151
+ context_parts.append(f"AUDIO_TRANSCRIPT_FROM {file_url}:\n{info}")
152
+ except Exception as e:
153
+ logger.warning(f" Audio read error: {e}")
154
+ context_parts.append(f"AUDIO_READ_ERROR {file_url}")
155
+
156
  elif any(ext in file_lower for ext in ['.txt', '.csv', '.json', '.py', '.md']):
157
  if self.verbose:
158
+ logger.info(f" 📄 Procesando texto: {file_url}")
159
+ try:
160
+ r = requests.get(file_url, timeout=15)
161
+ r.raise_for_status()
162
+ content = r.text[:5000] # limitar
163
+ context_parts.append(f"TEXT_FILE_FROM {file_url}:\n{content}")
164
+ except Exception as e:
165
+ logger.warning(f" Text download error: {e}")
166
+ context_parts.append(f"TEXT_READ_ERROR {file_url}")
167
+
168
  else:
169
+ # Unknown type -> only include url
170
+ context_parts.append(f"FILE_AVAILABLE: {file_url}")
171
+
172
  except Exception as e:
173
+ logger.warning(f" ❌ Error procesando {file_url}: {e}")
174
+ context_parts.append(f"FILE_PROCESS_ERROR {file_url}")
175
+
176
+ context_parts.append("FILES_CONTENT_END")
177
+
178
+ # Detectar texto invertido (heurística básica)
179
  if self._is_reversed_text(question):
180
  reversed_q = question[::-1]
181
+ context_parts.append("NOTE: detected reversed text in the question.")
182
+ context_parts.append(f"REVERSED_ORIGINAL: {question}")
183
+ context_parts.append(f"REVERSED_INTERPRETATION: {reversed_q}")
184
+
 
185
  return "\n".join(context_parts)
186
+
187
  def _is_reversed_text(self, text: str) -> bool:
188
  """
189
+ Detecta si el texto está invertido. Heurística simple.
 
 
 
 
 
 
190
  """
191
+ if not text:
192
+ return False
193
+ s = text.strip()
194
  indicators = [
195
+ s.startswith("."),
196
+ "?rewsna" in s.lower(),
197
+ "?noitseuq" in s.lower(),
198
+ ".rewsna eht sa" in s.lower(),
199
+ # si tiene muchas letras no alfabeticas al inicio
200
+ (len(s) > 3 and not s[0].isalnum())
201
  ]
202
  return any(indicators)
203
+
204
  def _clean_answer(self, response: str) -> str:
205
  """
206
+ Limpia la respuesta del modelo y extrae lo que consideramos la respuesta final.
207
+ Reglas:
208
+ - Si el modelo incluyó "FINAL ANSWER:" (case-insensitive), respetar lo que sigue.
209
+ - Si no, tomar la última línea no vacía o la línea más corta <= 200 chars, con heurística.
210
+ - Quitar comillas y espacios en los extremos.
 
 
211
  """
212
+ if not response:
213
+ return ""
214
+
215
+ # Normalize line endings and split
216
+ lines = [ln.strip() for ln in response.replace("\r", "").split("\n")]
217
+
218
+ # Buscar marcador FINAL ANSWER: (case-insensitive)
219
+ joined_upper = response.upper()
220
+ if "FINAL ANSWER:" in joined_upper:
221
  parts = re.split(r'FINAL ANSWER:\s*', response, flags=re.IGNORECASE)
222
+ after = parts[-1].strip()
223
+ # tomar solo la primera línea después del marcador
224
+ candidate = after.splitlines()[0].strip()
225
+ candidate = self._postprocess_candidate(candidate)
226
+ return candidate
227
+
228
+ # Si no hay marcador, filtrar líneas no vacías
229
+ nonempty = [ln for ln in lines if ln]
230
+ if not nonempty:
231
+ return ""
232
+
233
+ # Heurística:
234
+ # 1) Si alguna línea es corta y no contiene ':' (probable respuesta), usar la línea m��s corta <=200
235
+ short_lines = [ln for ln in nonempty if len(ln) <= 200 and ':' not in ln]
236
+ if short_lines:
237
+ # preferir la última línea corta (suele ser la respuesta)
238
+ candidate = short_lines[-1].strip()
239
+ return self._postprocess_candidate(candidate)
240
+
241
+ # 2) Si todo lo anterior falla, usar la última línea no vacía
242
+ candidate = nonempty[-1]
243
+ return self._postprocess_candidate(candidate)
244
+
245
+ def _postprocess_candidate(self, candidate: str) -> str:
246
+ """
247
+ Limpieza final: quitar comillas, corchetes, puntos finales innecesarios.
248
+ """
249
+ if not candidate:
250
+ return ""
251
+
252
+ # Remove enclosing quotes/brackets
253
+ candidate = candidate.strip()
254
+ candidate = candidate.strip('\'"')
255
+ candidate = candidate.strip("[](){}")
256
+
257
+ # Remove trailing period if it's not numeric decimal
258
+ if candidate.endswith("."):
259
+ candidate_core = candidate[:-1]
260
+ # no quitar si parece decimal (e.g., "3.14")
261
+ if not re.match(r'^\d+(\.\d+)?$', candidate_core):
262
+ candidate = candidate_core
263
+
264
+ # Trim spaces
265
+ candidate = candidate.strip()
266
+
267
+ # If normalize_to_lowercase flag is set, lower-case here (this can be optional)
268
+ if self.normalize_to_lowercase:
269
+ candidate = candidate.lower()
270
+
271
+ return candidate
 
 
272
 
273
 
274
  def create_agent(model_id: str = "gemini/gemini-2.0-flash-exp", verbose: bool = False, **kwargs) -> Agent:
275
  """
276
+ Factory para crear un agente.
 
 
 
 
 
 
 
 
277
  """
278
  from model import get_model
279
  model = get_model(model_id, **kwargs)
280
+ return Agent(model=model, verbose=verbose, normalize_to_lowercase=kwargs.get("normalize_to_lowercase", False))
app.py CHANGED
@@ -88,8 +88,9 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
88
 
89
  print(f"✅ Recibidas {len(questions_data)} preguntas")
90
 
91
- # Para testing, descomentar para limitar a 5 preguntas:
92
- # questions_data = questions_data[:5]
 
93
 
94
  except Exception as e:
95
  error_msg = f"❌ Error al obtener preguntas: {str(e)}"
@@ -114,10 +115,10 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
114
  submissions.append(result["submission"])
115
  logs.append(result["log"])
116
 
117
- # Delay entre preguntas para evitar rate limits (Gemini free tier: ~1 req/seg)
118
  if i < len(questions_data): # No esperar después de la última
119
- print(f"⏳ Esperando 2 segundos antes de la siguiente pregunta...")
120
- time.sleep(2)
121
 
122
  if not submissions:
123
  return "⚠️ No se generaron respuestas.", pd.DataFrame(logs)
 
88
 
89
  print(f"✅ Recibidas {len(questions_data)} preguntas")
90
 
91
+ # TESTING: Limitar a solo 3 preguntas para evitar rate limits
92
+ questions_data = questions_data[:3]
93
+ print(f"⚠️ [TESTING MODE] Limitado a {len(questions_data)} preguntas")
94
 
95
  except Exception as e:
96
  error_msg = f"❌ Error al obtener preguntas: {str(e)}"
 
115
  submissions.append(result["submission"])
116
  logs.append(result["log"])
117
 
118
+ # Delay entre preguntas para evitar rate limits (Gemini free tier: muy limitado)
119
  if i < len(questions_data): # No esperar después de la última
120
+ print(f"⏳ Esperando 5 segundos antes de la siguiente pregunta...")
121
+ time.sleep(5) # Aumentado de 2 a 5 segundos
122
 
123
  if not submissions:
124
  return "⚠️ No se generaron respuestas.", pd.DataFrame(logs)
model.py CHANGED
@@ -1,54 +1,51 @@
1
  """
2
- Model wrapper para usar Google Gemini via LiteLLM.
3
- Similar a chiarapaglioni/GAIA-agents pero simplificado.
4
  """
5
 
6
  import os
7
  import time
8
  import re
9
- from typing import Any, Optional
10
  from functools import lru_cache
 
11
 
12
  try:
13
  from litellm import completion, RateLimitError
14
  LITELLM_AVAILABLE = True
15
  except ImportError:
16
  LITELLM_AVAILABLE = False
17
- print("⚠️ LiteLLM no instalado. Instala con: pip install litellm")
 
 
 
 
18
 
19
 
20
  class GeminiModel:
21
- """Wrapper para Gemini usando LiteLLM con manejo de rate limits."""
22
-
23
- def __init__(self, model_id: str = "gemini/gemini-2.0-flash-exp", api_key: Optional[str] = None, max_retries: int = 3):
24
- """
25
- Inicializa el modelo Gemini.
26
-
27
- Args:
28
- model_id: ID del modelo Gemini (con prefijo gemini/)
29
- api_key: API key de Google (GEMINI_API_KEY del env si no se proporciona)
30
- max_retries: Número máximo de reintentos en caso de rate limit (default: 3)
31
- """
32
  if not LITELLM_AVAILABLE:
33
  raise ImportError("LiteLLM es requerido. Instala con: pip install litellm")
34
-
35
  self.model_id = model_id
36
  self.api_key = api_key or os.getenv("GEMINI_API_KEY")
37
  self.max_retries = max_retries
38
-
 
39
  if not self.api_key:
40
- raise ValueError("GEMINI_API_KEY no encontrada en variables de entorno")
41
-
42
  def __call__(self, messages, **kwargs):
43
  """
44
  Llama al modelo con manejo de rate limits.
45
-
46
- Args:
47
- messages: Lista de mensajes en formato OpenAI/LiteLLM
48
- **kwargs: Argumentos adicionales (temperature, max_tokens, etc.)
49
-
50
- Returns:
51
- str: Respuesta del modelo
52
  """
53
  for attempt in range(self.max_retries):
54
  try:
@@ -56,79 +53,64 @@ class GeminiModel:
56
  model=self.model_id,
57
  messages=messages,
58
  api_key=self.api_key,
59
- **kwargs
60
  )
61
- return response.choices[0].message.content
62
-
 
63
  except RateLimitError as e:
64
- error_str = str(e)
65
-
66
- # Si ya es el último intento, no esperar más
67
- if attempt >= self.max_retries - 1:
68
- print(f"❌ Rate limit excedido después de {self.max_retries} intentos")
 
 
69
  return "ERROR: Rate limit exceeded"
70
-
71
- print(f"⚠️ RateLimitError (intento {attempt + 1}/{self.max_retries})")
72
-
73
- # Intentar extraer tiempo de espera del error
74
- match = re.search(r'"retryDelay": ?"(\d+)s"', error_str)
75
- retry_seconds = int(match.group(1)) if match else 60 # Default 60s
76
-
77
- print(f"💤 Esperando {retry_seconds} segundos antes de reintentar...")
78
- time.sleep(retry_seconds + 2) # +2 segundos de buffer
79
-
80
  except Exception as e:
81
- if attempt == self.max_retries - 1:
82
- print(f" Error después de {self.max_retries} intentos: {e}")
83
- raise
84
-
85
- print(f"⚠️ Error en intento {attempt + 1}/{self.max_retries}: {e}")
86
- time.sleep(5)
87
-
88
- # Si llegamos aquí, se agotaron los reintentos
89
- error_msg = f"Rate limit excedido después de {self.max_retries} reintentos."
90
- print(f"❌ {error_msg}")
91
- raise Exception(error_msg)
92
-
93
- def generate_simple(self, prompt: str, **kwargs) -> str:
 
 
94
  """
95
- Helper para generar respuesta desde un prompt simple.
96
-
97
- Args:
98
- prompt: Texto del prompt
99
- **kwargs: Argumentos adicionales
100
-
101
- Returns:
102
- str: Respuesta generada
103
  """
104
- messages = [{"role": "user", "content": prompt}]
 
 
 
105
  return self(messages, **kwargs)
106
 
107
 
108
- @lru_cache(maxsize=1)
109
  def get_gemini_model(model_id: str = "gemini/gemini-2.0-flash-exp", **kwargs) -> GeminiModel:
110
- """
111
- Factory function con cache para obtener instancia del modelo Gemini.
112
-
113
- Args:
114
- model_id: ID del modelo Gemini
115
- **kwargs: Argumentos adicionales
116
-
117
- Returns:
118
- GeminiModel: Instancia del modelo con cache
119
- """
120
  return GeminiModel(model_id=model_id, **kwargs)
121
 
122
 
123
  def get_model(model_id: str = "gemini/gemini-2.0-flash-exp", **kwargs) -> GeminiModel:
124
  """
125
- Función principal para obtener modelo.
126
-
127
- Args:
128
- model_id: ID del modelo (por defecto Gemini Flash)
129
- **kwargs: Argumentos adicionales
130
-
131
- Returns:
132
- GeminiModel: Instancia del modelo
133
  """
134
- return get_gemini_model(model_id, **kwargs)
 
 
 
 
 
1
  """
2
+ Model wrapper para usar Google Gemini u otros modelos vía LiteLLM.
3
+ Optimizado para ejecución en Hugging Face Spaces (sin bloqueos prolongados).
4
  """
5
 
6
  import os
7
  import time
8
  import re
9
+ import logging
10
  from functools import lru_cache
11
+ from typing import Optional
12
 
13
  try:
14
  from litellm import completion, RateLimitError
15
  LITELLM_AVAILABLE = True
16
  except ImportError:
17
  LITELLM_AVAILABLE = False
18
+ print("⚠️ LiteLLM no instalado. Instala con: pip install litellm")
19
+
20
+ # Configurar logging
21
+ logger = logging.getLogger(__name__)
22
+ logger.setLevel(logging.INFO)
23
 
24
 
25
  class GeminiModel:
26
+ """Wrapper universal para modelos soportados por LiteLLM (por defecto Gemini)."""
27
+
28
+ def __init__(
29
+ self,
30
+ model_id: str = "gemini/gemini-2.0-flash-exp",
31
+ api_key: Optional[str] = None,
32
+ max_retries: int = 3,
33
+ retry_base_delay: int = 10,
34
+ ):
 
 
35
  if not LITELLM_AVAILABLE:
36
  raise ImportError("LiteLLM es requerido. Instala con: pip install litellm")
37
+
38
  self.model_id = model_id
39
  self.api_key = api_key or os.getenv("GEMINI_API_KEY")
40
  self.max_retries = max_retries
41
+ self.retry_base_delay = retry_base_delay
42
+
43
  if not self.api_key:
44
+ raise ValueError("⚠️ GEMINI_API_KEY no encontrada en variables de entorno")
45
+
46
  def __call__(self, messages, **kwargs):
47
  """
48
  Llama al modelo con manejo de rate limits.
 
 
 
 
 
 
 
49
  """
50
  for attempt in range(self.max_retries):
51
  try:
 
53
  model=self.model_id,
54
  messages=messages,
55
  api_key=self.api_key,
56
+ **kwargs,
57
  )
58
+ content = response.choices[0].message.content
59
+ return content.strip()
60
+
61
  except RateLimitError as e:
62
+ delay = self._parse_retry_delay(str(e))
63
+ if attempt < self.max_retries - 1:
64
+ wait_time = min(delay, self.retry_base_delay * (attempt + 1))
65
+ logger.warning(f"⏳ Rate limit ({self.model_id}), reintentando en {wait_time}s...")
66
+ time.sleep(wait_time)
67
+ else:
68
+ logger.error(f"❌ Rate limit después de {self.max_retries} intentos.")
69
  return "ERROR: Rate limit exceeded"
70
+
 
 
 
 
 
 
 
 
 
71
  except Exception as e:
72
+ if attempt < self.max_retries - 1:
73
+ logger.warning(f"⚠️ Error intento {attempt + 1}/{self.max_retries}: {e}")
74
+ time.sleep(2 * (attempt + 1))
75
+ continue
76
+ logger.error(f"Error fatal en {self.model_id}: {e}")
77
+ raise
78
+
79
+ return "ERROR: Maximum retries exceeded"
80
+
81
+ def _parse_retry_delay(self, error_str: str) -> int:
82
+ """Extrae tiempo sugerido de espera desde un error RateLimit."""
83
+ match = re.search(r'"retryDelay": ?"(\d+)s"', error_str)
84
+ return int(match.group(1)) if match else 10
85
+
86
+ def generate_simple(self, prompt: str, system: Optional[str] = None, **kwargs) -> str:
87
  """
88
+ Helper para prompts simples.
89
+ Permite un 'system prompt' opcional.
 
 
 
 
 
 
90
  """
91
+ messages = []
92
+ if system:
93
+ messages.append({"role": "system", "content": system})
94
+ messages.append({"role": "user", "content": prompt})
95
  return self(messages, **kwargs)
96
 
97
 
98
+ @lru_cache(maxsize=2)
99
  def get_gemini_model(model_id: str = "gemini/gemini-2.0-flash-exp", **kwargs) -> GeminiModel:
100
+ """Factory con cache para Gemini."""
 
 
 
 
 
 
 
 
 
101
  return GeminiModel(model_id=model_id, **kwargs)
102
 
103
 
104
  def get_model(model_id: str = "gemini/gemini-2.0-flash-exp", **kwargs) -> GeminiModel:
105
  """
106
+ Factory principal. Permite usar distintos modelos:
107
+ - gemini/gemini-2.0-flash-exp (por defecto)
108
+ - openai/gpt-4o-mini
109
+ - anthropic/claude-3-haiku
110
+ - mistral/mistral-tiny, etc.
 
 
 
111
  """
112
+ if "gemini" in model_id:
113
+ return get_gemini_model(model_id, **kwargs)
114
+ else:
115
+ # Usa API_KEY genérica de LiteLLM (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.)
116
+ return GeminiModel(model_id=model_id, api_key=kwargs.get("api_key"))