diff --git a/src/agentic_curator.py b/src/agentic_curator.py index 96506a2e..42cc5e41 100644 --- a/src/agentic_curator.py +++ b/src/agentic_curator.py @@ -7,6 +7,7 @@ import random from typing import List, Dict, Set, Optional from src.config import GEMINI_API_KEY, GH_TOKEN, TARGET_REPO, NUBENETES_CATEGORIES from src.gitops_manager import RepositoryController +from src.gemini_utils import call_gemini_with_retry # Silenciar advertencias de XML/HTML import warnings @@ -34,8 +35,6 @@ async def evaluate_extracted_assets(raw_assets: List[Dict]) -> Dict[str, Dict]: print("[!] ERROR CRÍTICO: GEMINI_API_KEY no encontrada en el entorno.") return {a["url"]: {"status": "FILTERED", "reason": "Configuración: API KEY faltante"} for a in raw_assets} - api_url = f"https://generativelanguage.googleapis.com/v1/models/gemini-1.5-flash:generateContent?key={GEMINI_API_KEY}" - memory_file = "src/memory/health_learning.json" domain_blacklist = set() if os.path.exists(memory_file): @@ -45,73 +44,49 @@ async def evaluate_extracted_assets(raw_assets: List[Dict]) -> Dict[str, Dict]: domain_blacklist = set(memory_data.get("blacklisted_domains", [])) except: pass - async with httpx.AsyncClient() as client: - for asset in raw_assets: - domain = asset['url'].split("//")[-1].split("/")[0] - if domain in domain_blacklist: - evaluations[asset["url"]] = {"status": "FILTERED", "reason": "Dominio en lista negra de reputación"} - continue + for asset in raw_assets: + domain = asset['url'].split("//")[-1].split("/")[0] + if domain in domain_blacklist: + evaluations[asset["url"]] = {"status": "FILTERED", "reason": "Dominio en lista negra de reputación"} + continue - web_content = await _deep_fetch_content(asset['url']) - context = asset.get('context', asset.get('description', 'Sin contexto adicional')) - - prompt = ( - "Actúas como Ingeniero Curador Senior de 'nubenetes/awesome-kubernetes'.\n" - "Tu misión es catalogar contenido TÉCNICO sobre Kubernetes y Cloud Native compartido por el usuario.\n" - "REGLA DE ORO: Si el enlace está en el feed, es porque el usuario lo considera útil. NO lo descartes a menos que sea ruido total.\n\n" - f"Categorías válidas: {', '.join(NUBENETES_CATEGORIES)}.\n\n" - "INSTRUCCIONES:\n" - "1. YOUTUBE: Acepta videos técnicos o tutoriales. Categorízalos.\n" - "2. RESUMEN: Crea un resumen conciso (1 frase). Usa prioritariamente el 'Contexto' (que es el post de X).\n" - "3. ASIGNACIÓN: Si es sobre Model Context Protocol (MCP), asígnalo a 'ai-agents-mcp'. Si es técnico pero no sabes dónde, usa 'kubernetes-tools'.\n\n" - f"URL: {asset['url']}\nContexto de X: {context}\nContenido Web Extraído: {web_content[:1500]}\n\n" - "Evalúa (1-100):\n" - "- >80: Recurso excepcional (🌟).\n" - "- >1: Aceptar (si es técnico o útil).\n\n" - "Responde SOLAMENTE un JSON: {\"impact_score\": int, \"categories\": [\"cat1\"], \"title\": \"...\", \"desc\": \"...\", \"rejection_reason\": \"... (si aplica)\"}" - ) + web_content = await _deep_fetch_content(asset['url']) + context = asset.get('context', asset.get('description', 'Sin contexto adicional')) + + prompt = ( + "Actúas como Ingeniero Curador Senior de 'nubenetes/awesome-kubernetes'.\n" + "Tu misión es catalogar contenido TÉCNICO sobre Kubernetes y Cloud Native compartido por el usuario.\n" + "REGLA DE ORO: Si el enlace está en el feed, es porque el usuario lo considera útil. NO lo descartes a menos que sea ruido total.\n\n" + f"Categorías válidas: {', '.join(NUBENETES_CATEGORIES)}.\n\n" + "INSTRUCCIONES:\n" + "1. YOUTUBE: Acepta videos técnicos o tutoriales. Categorízalos.\n" + "2. RESUMEN: Crea un resumen conciso (1 frase). Usa prioritariamente el 'Contexto' (que es el post de X).\n" + "3. ASIGNACIÓN: Si es sobre Model Context Protocol (MCP), asígnalo a 'ai-agents-mcp'. Si es técnico pero no sabes dónde, usa 'kubernetes-tools'.\n\n" + f"URL: {asset['url']}\nContexto de X: {context}\nContenido Web Extraído: {web_content[:1500]}\n\n" + "Evalúa (1-100):\n" + "- >80: Recurso excepcional (🌟).\n" + "- >1: Aceptar (si es técnico o útil).\n\n" + "Responde SOLAMENTE un JSON: {\"impact_score\": int, \"categories\": [\"cat1\"], \"title\": \"...\", \"desc\": \"...\", \"rejection_reason\": \"... (si aplica)\"}" + ) - # Reintento exponencial simple - for attempt in range(3): - try: - response = await client.post(api_url, json={"contents": [{"parts": [{"text": prompt}]}]}, timeout=35) - if response.status_code == 200: - text_resp = response.json()['candidates'][0]['content']['parts'][0]['text'] - match = re.search(r'\{.*\}', text_resp, re.DOTALL) - if match: - data = json.loads(match.group(0)) - score = data.get("impact_score", 50) - valid_cats = [c for c in data.get("categories", []) if c in NUBENETES_CATEGORIES] - - if score < 1: - evaluations[asset["url"]] = {"status": "FILTERED", "reason": data.get("rejection_reason", "Bajo impacto técnico")} - elif not valid_cats: - evaluations[asset["url"]] = {"status": "FILTERED", "reason": "No se encontró categoría técnica válida"} - else: - evaluations[asset["url"]] = { - "status": "INCLUDED", "title": data["title"], "description": data["desc"], - "category": valid_cats[0], "impact_score": score, "is_exceptional": score > 80 - } - break - else: - evaluations[asset["url"]] = {"status": "FILTERED", "reason": "IA retornó formato inválido"} - break - elif response.status_code == 429: # Rate limit - await asyncio.sleep(2 ** attempt + random.random()) - continue - else: - # Si da 404, podría ser que el modelo flash no esté disponible en esa región, probamos con alias genérico - if response.status_code == 404 and "gemini-1.5-flash" in api_url: - api_url = f"https://generativelanguage.googleapis.com/v1/models/gemini-pro:generateContent?key={GEMINI_API_KEY}" - continue - evaluations[asset["url"]] = {"status": "FILTERED", "reason": f"Error API Gemini: {response.status_code}"} - break - except Exception as e: - if attempt == 2: - evaluations[asset["url"]] = {"status": "FILTERED", "reason": f"Error procesamiento: {str(e)[:50]}"} - await asyncio.sleep(1) + try: + data = await call_gemini_with_retry(prompt) + score = data.get("impact_score", 50) + valid_cats = [c for c in data.get("categories", []) if c in NUBENETES_CATEGORIES] - await asyncio.sleep(0.5) # Evitar saturar la API + if score < 1: + evaluations[asset["url"]] = {"status": "FILTERED", "reason": data.get("rejection_reason", "Bajo impacto técnico")} + elif not valid_cats: + evaluations[asset["url"]] = {"status": "FILTERED", "reason": "No se encontró categoría técnica válida"} + else: + evaluations[asset["url"]] = { + "status": "INCLUDED", "title": data["title"], "description": data["desc"], + "category": valid_cats[0], "impact_score": score, "is_exceptional": score > 80 + } + except Exception as e: + evaluations[asset["url"]] = {"status": "FILTERED", "reason": f"Error Gemini: {str(e)[:50]}"} + + await asyncio.sleep(0.5) # Evitar saturar la API if domain_blacklist: try: @@ -131,7 +106,6 @@ class AgenticCurator: async def decide_smart_injection(self, markdown_content: str, asset: Dict) -> str: """Usa Gemini para decidir dónde y cómo inyectar el enlace dentro del markdown.""" - api_url = f"https://generativelanguage.googleapis.com/v1/models/gemini-1.5-flash:generateContent?key={GEMINI_API_KEY}" lines = markdown_content.splitlines() structure = "\n".join([l for l in lines if l.startswith("#")]) @@ -148,22 +122,21 @@ class AgenticCurator: ) try: - async with httpx.AsyncClient() as client: - resp = await client.post(api_url, json={"contents": [{"parts": [{"text": prompt}]}]}, timeout=30) - if resp.status_code == 200: - data = json.loads(re.search(r'\{.*\}', resp.json()['candidates'][0]['content']['parts'][0]['text'], re.DOTALL).group(0)) - header = data.get("header") - new_line = data.get("formatted_line") - if header and new_line: - new_lines = [] - inserted = False - for line in lines: - new_lines.append(line) - if not inserted and header.lower() in line.lower() and line.strip().startswith("#"): - new_lines.append(new_line) - inserted = True - if inserted: return "\n".join(new_lines) - except: pass + data = await call_gemini_with_retry(prompt) + header = data.get("header") + new_line = data.get("formatted_line") + if header and new_line: + new_lines = [] + inserted = False + for line in lines: + new_lines.append(line) + if not inserted and header.lower() in line.lower() and line.strip().startswith("#"): + new_lines.append(new_line) + inserted = True + if inserted: return "\n".join(new_lines) + except Exception as e: + print(f"[!] Error en decide_smart_injection: {e}") + pass return self._manual_fallback_injection(markdown_content, asset) def _manual_fallback_injection(self, content: str, asset: Dict) -> str: diff --git a/src/autonomous_discovery.py b/src/autonomous_discovery.py index be017fbb..a9263a49 100644 --- a/src/autonomous_discovery.py +++ b/src/autonomous_discovery.py @@ -3,6 +3,7 @@ import json import httpx import re from src.config import GEMINI_API_KEY, NUBENETES_CATEGORIES +from src.gemini_utils import call_gemini_with_retry async def fetch_github_trending_cloud_native() -> list[dict]: queries = [ @@ -35,7 +36,6 @@ async def discover_trending_assets() -> list[dict]: return [] # Intentar clasificar con Gemini vía REST - api_url = f"https://generativelanguage.googleapis.com/v1/models/gemini-1.5-flash:generateContent?key={GEMINI_API_KEY}" prompt = ( "Analiza estos repositorios y selecciona los 4 mejores.\n" f"Categorías: {', '.join(NUBENETES_CATEGORIES)}\n" @@ -44,14 +44,9 @@ async def discover_trending_assets() -> list[dict]: ) try: - async with httpx.AsyncClient() as client: - response = await client.post(api_url, json={"contents": [{"parts": [{"text": prompt}]}]}, timeout=30) - if response.status_code == 200: - text_resp = response.json()['candidates'][0]['content']['parts'][0]['text'] - match = re.search(r'\[.*\]', text_resp, re.DOTALL) - if match: - results = json.loads(match.group(0)) - return [res for res in results if res.get("category") in NUBENETES_CATEGORIES] + results = await call_gemini_with_retry(prompt) + if isinstance(results, list): + return [res for res in results if res.get("category") in NUBENETES_CATEGORIES] except Exception as e: print(f"[~] Gemini REST falló, usando clasificación heurística: {e}") diff --git a/src/config.py b/src/config.py index 25a2e73a..d716fd39 100644 --- a/src/config.py +++ b/src/config.py @@ -17,6 +17,10 @@ if GEMINI_API_KEY and not os.getenv("GOOGLE_API_KEY"): GH_TOKEN = os.getenv("GH_TOKEN") +# Gemini Configuration (May 2026) +GEMINI_API_VERSION = "v1beta" +GEMINI_MODELS = ["gemini-2.5-flash", "gemini-3.1-flash-lite", "gemini-1.5-flash"] + TARGET_REPO = "nubenetes/awesome-kubernetes" NUBENETES_CATEGORIES = [ diff --git a/src/gemini_utils.py b/src/gemini_utils.py new file mode 100644 index 00000000..46abb787 --- /dev/null +++ b/src/gemini_utils.py @@ -0,0 +1,57 @@ +import httpx +import asyncio +import random +import json +import re +from src.config import GEMINI_API_KEY, GEMINI_API_VERSION, GEMINI_MODELS + +async def call_gemini_with_retry(prompt: str, response_format: str = "json", max_retries: int = 3): + """ + Llama a la API de Gemini con rotación de modelos y reintentos ante 404 o 429. + """ + if not GEMINI_API_KEY: + raise ValueError("GEMINI_API_KEY no configurada.") + + async with httpx.AsyncClient() as client: + for model in GEMINI_MODELS: + api_url = f"https://generativelanguage.googleapis.com/{GEMINI_API_VERSION}/models/{model}:generateContent?key={GEMINI_API_KEY}" + + for attempt in range(max_retries): + try: + payload = {"contents": [{"parts": [{"text": prompt}]}]} + response = await client.post(api_url, json=payload, timeout=35) + + if response.status_code == 200: + try: + text_resp = response.json()['candidates'][0]['content']['parts'][0]['text'] + if response_format == "json": + match = re.search(r'\{.*\}|\[.*\]', text_resp, re.DOTALL) + if match: + return json.loads(match.group(0)) + raise ValueError("No se encontró JSON en la respuesta") + return text_resp + except (KeyError, IndexError, json.JSONDecodeError) as e: + print(f"[!] Error parseando respuesta de {model}: {e}") + break # Probar siguiente modelo si el formato es basura + + elif response.status_code == 404: + print(f"[!] Modelo {model} no encontrado (404) en {api_url}. Probando siguiente...") + break # Ir al siguiente modelo en GEMINI_MODELS + + elif response.status_code == 429: + wait = (2 ** attempt) + random.random() + print(f"[*] Rate limit (429) para {model}. Reintentando en {wait:.2f}s...") + await asyncio.sleep(wait) + continue + + else: + print(f"[!] Error {response.status_code} de Gemini ({model}): {response.text[:200]}") + break # Probar siguiente modelo + + except Exception as e: + print(f"[!] Excepción llamando a {model}: {e}") + if attempt == max_retries - 1: + break + await asyncio.sleep(1) + + raise Exception("Todos los modelos de Gemini fallaron o no están disponibles.")