dyandun Claude Sonnet 4.6 commited on
Commit
8bb8847
·
0 Parent(s):

feat: estructura base del sistema multiagente monitor-ml-aceitess

Browse files

- Framework de agentes: runAgent() con loop tool-calling, subAgentTool() para delegación jerárquica
- Orquestador con clasificación de intents en dos niveles (keywords + LLM Haiku fallback)
- Agentes especialistas: price_monitor (precios palma) y demand_monitor (ventas/demanda)
- Agent Lab: meta-agente de auto-mejora con autonomía reactiva/proactiva
- Integración Telegram via webhook (python-telegram-bot)
- Cliente Supabase + schema SQL completo (agentes, ML, ventas, research, audit log)
- app.py: HuggingFace Space con Gradio dashboard + FastAPI webhook endpoint
- Cron GitHub Actions diario (03:00 UTC): scraping, scoring, Agent Lab, reporte Telegram
- Diseñado para recibir datos reales en Excel (aceites, mantecas) en Fase 2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

.env.example ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # === Anthropic ===
2
+ ANTHROPIC_API_KEY=sk-ant-...
3
+
4
+ # === Telegram ===
5
+ TELEGRAM_BOT_TOKEN= # Obtenido de @BotFather
6
+ TELEGRAM_WEBHOOK_URL= # URL pública del HuggingFace Space, ej: https://user-monitor-ml-aceitess.hf.space
7
+
8
+ # === Supabase ===
9
+ SUPABASE_URL=https://xxxx.supabase.co
10
+ SUPABASE_KEY=eyJ... # anon/service_role key
11
+
12
+ # === App ===
13
+ APP_ENV=development # development | production
14
+ LOG_LEVEL=INFO
.github/workflows/daily_research.yml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Daily Research Pipeline
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "0 3 * * *" # 03:00 UTC = 22:00 Ecuador (UTC-5)
6
+ workflow_dispatch: # Permite ejecutar manualmente desde GitHub Actions
7
+
8
+ jobs:
9
+ research:
10
+ runs-on: ubuntu-latest
11
+ timeout-minutes: 15
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.11"
20
+ cache: "pip"
21
+
22
+ - name: Install dependencies
23
+ run: pip install -r requirements.txt
24
+
25
+ - name: Run research pipeline
26
+ env:
27
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
28
+ SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
29
+ SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
30
+ TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
31
+ TELEGRAM_ADMIN_CHAT_ID: ${{ secrets.TELEGRAM_ADMIN_CHAT_ID }}
32
+ run: python -m cron.research_pipeline
.gitignore ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Variables de entorno — NUNCA subir a git
2
+ .env
3
+ *.env
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.pyo
9
+ .Python
10
+ *.egg-info/
11
+ dist/
12
+ build/
13
+ .eggs/
14
+ .pytest_cache/
15
+
16
+ # Entornos virtuales
17
+ venv/
18
+ .venv/
19
+ env/
20
+
21
+ # Datos crudos y modelos (pueden ser grandes)
22
+ data/raw/*
23
+ data/processed/*
24
+ models/*.pkl
25
+ models/*.joblib
26
+ models/*.h5
27
+ !data/raw/.gitkeep
28
+ !data/processed/.gitkeep
29
+
30
+ # IDE
31
+ .vscode/
32
+ .idea/
33
+ *.swp
34
+
35
+ # OS
36
+ .DS_Store
37
+ Thumbs.db
CLAUDE.md ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # monitor-ml-aceitess
2
+
3
+ Sistema multiagente de monitoreo ML para producción y venta de aceites comestibles.
4
+ Desplegado en HuggingFace Spaces. Interfaz vía Telegram bot.
5
+
6
+ ## Stack
7
+
8
+ - **Runtime**: Python 3.11+
9
+ - **Framework agentes**: Anthropic SDK (claude-haiku-4-5 para clasificación, claude-sonnet-4-6 para agentes)
10
+ - **Interfaz**: Telegram bot (webhook) + Gradio dashboard
11
+ - **Base de datos**: Supabase (PostgreSQL)
12
+ - **Despliegue**: HuggingFace Spaces (Gradio + FastAPI)
13
+ - **Cron**: GitHub Actions
14
+
15
+ ## Estructura
16
+
17
+ ```
18
+ agents/
19
+ base_agent.py # runAgent() — loop de tool-calling
20
+ orchestrator.py # Clasificación de intents + routing
21
+ agent_lab.py # Meta-agente de auto-mejora
22
+ specialists/
23
+ price_monitor.py # Monitoreo modelo forecasting de precios (aceite de palma)
24
+ demand_monitor.py # Monitoreo modelo forecasting de demanda/ventas
25
+ tools/
26
+ sub_agent_tool.py # subAgentTool() — delegación jerárquica
27
+ telegram/
28
+ bot.py # Webhook handler + comandos
29
+ database/
30
+ supabase_client.py # Cliente Supabase
31
+ schema.sql # Schema completo
32
+ data/
33
+ raw/ # Datasets crudos (FRED, World Bank, Kaggle)
34
+ processed/ # Datasets procesados
35
+ models/ # Modelos ML entrenados (.pkl, etc.)
36
+ cron/
37
+ research_pipeline.py # Pipeline diario de investigación (GitHub Actions)
38
+ app.py # Entry point HuggingFace Space
39
+ ```
40
+
41
+ ## Fuentes de datos
42
+
43
+ - **Precio mundial aceite de palma**: FRED (PPOILUSDM), World Bank Pink Sheet
44
+ - **Ecuador producción/precios**: CFN Ficha Sectorial, ANCUPA, USDA FAS PSD Online
45
+ - **Ventas FMCG**: Kaggle FMCG Daily Sales 2022–2024
46
+ - **Futuro**: base de datos plana Excel del negocio real (aceites, mantecas, otros productos)
47
+
48
+ ## Agentes especialistas
49
+
50
+ | Agente | Modelo | Función |
51
+ |--------|--------|---------|
52
+ | price_monitor | sonnet-4-6 | Monitorea modelo de forecasting de precios de palma |
53
+ | demand_monitor | sonnet-4-6 | Monitorea modelo de forecasting de demanda/ventas |
54
+ | agent_lab | sonnet-4-6 | Meta-agente: lee configs, aplica mejoras reactivas, encola proactivas |
55
+ | orchestrator | haiku-4-5 | Clasifica intents y enruta al especialista correcto |
56
+
57
+ ## Autonomía de Agent Lab
58
+
59
+ - **REACTIVA** (auto-aplica): corrección de errores, anti-patrones en prompts, memorias de feedback
60
+ - **PROACTIVA** (requiere aprobación vía Telegram): nuevas herramientas, restructuración, cambio de modelo
61
+
62
+ ## Comandos de desarrollo
63
+
64
+ ```bash
65
+ # Instalar dependencias
66
+ pip install -r requirements.txt
67
+
68
+ # Copiar y configurar variables de entorno
69
+ cp .env.example .env
70
+
71
+ # Ejecutar localmente
72
+ python app.py
73
+
74
+ # Aplicar schema a Supabase
75
+ # Ejecutar database/schema.sql en el SQL Editor de Supabase
76
+ ```
77
+
78
+ ## Control de versiones
79
+
80
+ Después de cada cambio relevante: commit + push a GitHub inmediatamente.
81
+ Formato: `<tipo>: <descripción en imperativo>`
agents/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from agents.base_agent import run_agent
2
+ from agents.orchestrator import Orchestrator
3
+
4
+ __all__ = ["run_agent", "Orchestrator"]
agents/agent_lab.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agent_lab.py
3
+ ------------
4
+ Meta-agente de auto-mejora. Cada noche (via GitHub Actions cron):
5
+ 1. Lee los findings de research recientes
6
+ 2. Revisa las configs de todos los agentes
7
+ 3. Aplica mejoras REACTIVAS automáticamente
8
+ 4. Encola mejoras PROACTIVAS para aprobación humana vía Telegram
9
+
10
+ Autonomía:
11
+ REACTIVA (auto-aplica):
12
+ - Corrección de errores recurrentes en tool calls
13
+ - Anti-patrones en system prompts
14
+ - Memorias de feedback basadas en errores claros
15
+
16
+ PROACTIVA (requiere aprobación):
17
+ - Agregar nuevas herramientas a agentes
18
+ - Restructurar system prompts significativamente
19
+ - Cambiar modelo (haiku ↔ sonnet)
20
+ - Agregar nuevos sub-agentes
21
+ """
22
+
23
+ import logging
24
+ from agents.base_agent import run_agent, MODEL_SMART
25
+ from database.supabase_client import get_supabase
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ SYSTEM_PROMPT = """Eres Agent Lab, el meta-agente de mejora continua del sistema monitor-ml-aceitess.
30
+
31
+ Tu misión diaria:
32
+ 1. Consultar los findings de investigación recientes (relevancia > 0.5)
33
+ 2. Leer la configuración de cada agente del sistema
34
+ 3. Generar recomendaciones específicas y accionables
35
+ 4. Auto-aplicar las REACTIVAS, encolar las PROACTIVAS para aprobación humana
36
+
37
+ ## Autonomía
38
+
39
+ ### REACTIVA (aplica inmediatamente, sin pedir permiso):
40
+ - Corrección de patrones de error en tool calls
41
+ - Mejora de instrucciones poco claras en system prompts
42
+ - Adición de memorias de feedback basadas en errores documentados
43
+
44
+ ### PROACTIVA (encola para aprobación humana):
45
+ - Agregar nuevas herramientas a agentes existentes
46
+ - Restructuración significativa de system prompts
47
+ - Cambio de modelo tier (haiku → sonnet o viceversa)
48
+ - Nuevos agentes especialistas
49
+ - Adopción de técnicas de research con score > 0.8
50
+
51
+ ## Principios
52
+ - Toda recomendación debe tener un finding de research como respaldo (trazabilidad)
53
+ - Cada config de agente tiene un version_hash; no re-apliques si no cambió
54
+ - Monitorea skill bloat: alerta si un agente tiene 5+ herramientas, recomienda split en 8+
55
+ - Sé conservador: menos cambios con más impacto es mejor que muchos cambios pequeños
56
+
57
+ Responde en español."""
58
+
59
+ TOOLS = [
60
+ {
61
+ "name": "list_agents",
62
+ "description": "Lista todos los agentes registrados en el sistema con su nombre y descripción.",
63
+ "input_schema": {"type": "object", "properties": {}},
64
+ },
65
+ {
66
+ "name": "get_agent_config",
67
+ "description": "Lee la configuración completa de un agente: system prompt, herramientas, skills, version hash.",
68
+ "input_schema": {
69
+ "type": "object",
70
+ "properties": {
71
+ "agent_name": {"type": "string", "description": "Nombre del agente."}
72
+ },
73
+ "required": ["agent_name"],
74
+ },
75
+ },
76
+ {
77
+ "name": "search_research",
78
+ "description": "Busca findings de investigación recientes filtrados por score mínimo de relevancia.",
79
+ "input_schema": {
80
+ "type": "object",
81
+ "properties": {
82
+ "min_score": {
83
+ "type": "number",
84
+ "description": "Score mínimo de relevancia (0.0 a 1.0).",
85
+ "default": 0.5,
86
+ },
87
+ "limit": {
88
+ "type": "integer",
89
+ "description": "Máximo de resultados.",
90
+ "default": 20,
91
+ },
92
+ },
93
+ },
94
+ },
95
+ {
96
+ "name": "create_recommendation",
97
+ "description": "Crea una recomendación de mejora para un agente. Tipo 'reactive' se auto-aplica; 'proactive' espera aprobación humana.",
98
+ "input_schema": {
99
+ "type": "object",
100
+ "properties": {
101
+ "type": {"type": "string", "enum": ["reactive", "proactive"]},
102
+ "category": {"type": "string", "description": "Categoría: 'prompt', 'tool', 'model', 'skill', 'memory'"},
103
+ "target_agent": {"type": "string"},
104
+ "title": {"type": "string"},
105
+ "description": {"type": "string"},
106
+ "rationale": {"type": "string", "description": "Justificación basada en research o evidencia."},
107
+ "priority": {"type": "integer", "default": 5, "description": "1 (baja) a 10 (crítica)"},
108
+ },
109
+ "required": ["type", "category", "target_agent", "title", "description", "rationale"],
110
+ },
111
+ },
112
+ {
113
+ "name": "apply_reactive_fix",
114
+ "description": "Aplica inmediatamente una mejora reactiva a un agente (corrección de prompt, memoria de feedback, etc.).",
115
+ "input_schema": {
116
+ "type": "object",
117
+ "properties": {
118
+ "recommendation_id": {"type": "string"},
119
+ "agent_name": {"type": "string"},
120
+ "change_type": {"type": "string", "enum": ["prompt_fix", "memory_update", "skill_update"]},
121
+ "before_state": {"type": "string"},
122
+ "after_state": {"type": "string"},
123
+ },
124
+ "required": ["recommendation_id", "agent_name", "change_type", "after_state"],
125
+ },
126
+ },
127
+ {
128
+ "name": "check_skill_bloat",
129
+ "description": "Verifica si algún agente tiene demasiadas herramientas (alerta en 5+, split recomendado en 8+).",
130
+ "input_schema": {"type": "object", "properties": {}},
131
+ },
132
+ ]
133
+
134
+
135
+ def _list_agents() -> dict:
136
+ try:
137
+ db = get_supabase()
138
+ result = db.table("agents").select("name, description, updated_at").execute()
139
+ if result.data:
140
+ return {"agents": result.data}
141
+ # Fallback: agentes hardcoded si aún no hay DB
142
+ return {
143
+ "agents": [
144
+ {"name": "price_monitor", "description": "Monitoreo forecasting de precios"},
145
+ {"name": "demand_monitor", "description": "Monitoreo forecasting de demanda"},
146
+ {"name": "agent_lab", "description": "Meta-agente de mejora"},
147
+ {"name": "orchestrator", "description": "Clasificación de intents y routing"},
148
+ ]
149
+ }
150
+ except Exception as e:
151
+ return {"error": str(e)}
152
+
153
+
154
+ def _get_agent_config(agent_name: str) -> dict:
155
+ try:
156
+ db = get_supabase()
157
+ result = (
158
+ db.table("agents")
159
+ .select("*")
160
+ .eq("name", agent_name)
161
+ .execute()
162
+ )
163
+ if result.data:
164
+ return result.data[0]
165
+ return {"message": f"Agente '{agent_name}' no encontrado en la base de datos."}
166
+ except Exception as e:
167
+ return {"error": str(e)}
168
+
169
+
170
+ def _search_research(min_score: float = 0.5, limit: int = 20) -> dict:
171
+ try:
172
+ db = get_supabase()
173
+ result = (
174
+ db.table("agent_lab_research")
175
+ .select("title, summary, relevance_score, source_url, tags, created_at")
176
+ .gte("relevance_score", min_score)
177
+ .order("relevance_score", desc=True)
178
+ .limit(limit)
179
+ .execute()
180
+ )
181
+ if result.data:
182
+ return {"findings": result.data, "count": len(result.data)}
183
+ return {"message": "No hay findings de research aún. Ejecutar el cron de investigación primero."}
184
+ except Exception as e:
185
+ return {"error": str(e)}
186
+
187
+
188
+ def _create_recommendation(
189
+ type: str,
190
+ category: str,
191
+ target_agent: str,
192
+ title: str,
193
+ description: str,
194
+ rationale: str,
195
+ priority: int = 5,
196
+ ) -> dict:
197
+ try:
198
+ db = get_supabase()
199
+ result = db.table("agent_lab_recommendations").insert({
200
+ "type": type,
201
+ "category": category,
202
+ "target_agent": target_agent,
203
+ "title": title,
204
+ "description": description,
205
+ "rationale": rationale,
206
+ "priority": priority,
207
+ "status": "pending",
208
+ }).execute()
209
+ return {"success": True, "id": result.data[0]["id"] if result.data else None}
210
+ except Exception as e:
211
+ return {"error": str(e)}
212
+
213
+
214
+ def _apply_reactive_fix(
215
+ recommendation_id: str,
216
+ agent_name: str,
217
+ change_type: str,
218
+ after_state: str,
219
+ before_state: str = "",
220
+ ) -> dict:
221
+ try:
222
+ db = get_supabase()
223
+ # Registrar en audit log
224
+ db.table("agent_lab_changes").insert({
225
+ "recommendation_id": recommendation_id,
226
+ "change_type": "auto",
227
+ "target_agent": agent_name,
228
+ "description": f"Fix reactivo: {change_type}",
229
+ "before_state": {"content": before_state},
230
+ "after_state": {"content": after_state},
231
+ }).execute()
232
+
233
+ # Marcar recomendación como aplicada
234
+ db.table("agent_lab_recommendations").update(
235
+ {"status": "applied"}
236
+ ).eq("id", recommendation_id).execute()
237
+
238
+ return {"success": True, "applied": change_type, "agent": agent_name}
239
+ except Exception as e:
240
+ return {"error": str(e)}
241
+
242
+
243
+ def _check_skill_bloat() -> dict:
244
+ try:
245
+ db = get_supabase()
246
+ result = db.table("agents").select("name, tools").execute()
247
+ if not result.data:
248
+ return {"message": "No hay agentes registrados en DB aún."}
249
+
250
+ report = []
251
+ for agent in result.data:
252
+ tools = agent.get("tools") or []
253
+ count = len(tools) if isinstance(tools, list) else 0
254
+ status = "ok"
255
+ if count >= 8:
256
+ status = "split_recommended"
257
+ elif count >= 5:
258
+ status = "flag"
259
+ report.append({"agent": agent["name"], "tool_count": count, "status": status})
260
+
261
+ return {"bloat_report": report}
262
+ except Exception as e:
263
+ return {"error": str(e)}
264
+
265
+
266
+ TOOL_HANDLERS = {
267
+ "list_agents": _list_agents,
268
+ "get_agent_config": _get_agent_config,
269
+ "search_research": _search_research,
270
+ "create_recommendation": _create_recommendation,
271
+ "apply_reactive_fix": _apply_reactive_fix,
272
+ "check_skill_bloat": _check_skill_bloat,
273
+ }
274
+
275
+
276
+ class AgentLabAgent:
277
+ """Meta-agente de mejora continua del sistema."""
278
+
279
+ def run(self, message: str, session_history: list | None = None) -> tuple[str, list]:
280
+ return run_agent(
281
+ system_prompt=SYSTEM_PROMPT,
282
+ user_message=message,
283
+ tools=TOOLS,
284
+ tool_handlers=TOOL_HANDLERS,
285
+ model=MODEL_SMART,
286
+ conversation_history=session_history,
287
+ )
288
+
289
+ def run_daily_cycle(self) -> str:
290
+ """
291
+ Ejecuta el ciclo completo de mejora diaria.
292
+ Llamado por el cron de GitHub Actions.
293
+ """
294
+ logger.info("[AgentLab] iniciando ciclo diario de mejora")
295
+ response, _ = self.run(
296
+ "Ejecuta el ciclo diario completo: "
297
+ "1) Revisa findings de research con score > 0.5. "
298
+ "2) Lista todos los agentes y revisa sus configs. "
299
+ "3) Genera recomendaciones específicas. "
300
+ "4) Aplica las reactivas. "
301
+ "5) Verifica skill bloat. "
302
+ "6) Resume qué cambió y qué queda pendiente de aprobación."
303
+ )
304
+ logger.info("[AgentLab] ciclo diario completado")
305
+ return response
agents/base_agent.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ base_agent.py
3
+ -------------
4
+ Core del framework de agentes. Implementa runAgent() con loop de tool-calling
5
+ contra la API de Anthropic, memoria de conversación por sesión, y soporte para
6
+ delegación jerárquica vía subAgentTool().
7
+ """
8
+
9
+ import logging
10
+ from typing import Callable, Any
11
+ import anthropic
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Modelos disponibles
16
+ MODEL_FAST = "claude-haiku-4-5-20251001" # Clasificación, tareas simples
17
+ MODEL_SMART = "claude-sonnet-4-6" # Agentes especialistas, Agent Lab
18
+
19
+
20
+ def run_agent(
21
+ system_prompt: str,
22
+ user_message: str,
23
+ tools: list[dict],
24
+ tool_handlers: dict[str, Callable[..., Any]],
25
+ model: str = MODEL_SMART,
26
+ conversation_history: list[dict] | None = None,
27
+ max_iterations: int = 15,
28
+ ) -> tuple[str, list[dict]]:
29
+ """
30
+ Ejecuta un agente con loop de tool-calling hasta obtener respuesta final.
31
+
32
+ Args:
33
+ system_prompt: Instrucciones del agente.
34
+ user_message: Mensaje del usuario para esta llamada.
35
+ tools: Lista de definiciones de herramientas (formato Anthropic).
36
+ tool_handlers: Dict {nombre_tool: función_handler}.
37
+ model: Modelo a usar (default: sonnet-4-6).
38
+ conversation_history: Historial previo de la sesión (se modifica in-place).
39
+ max_iterations: Límite de ciclos para evitar loops infinitos.
40
+
41
+ Returns:
42
+ (respuesta_final: str, historial_actualizado: list)
43
+ """
44
+ client = anthropic.Anthropic()
45
+
46
+ messages = conversation_history if conversation_history is not None else []
47
+ messages.append({"role": "user", "content": user_message})
48
+
49
+ for iteration in range(max_iterations):
50
+ logger.debug(f"[run_agent] iteración {iteration + 1}, modelo={model}")
51
+
52
+ response = client.messages.create(
53
+ model=model,
54
+ max_tokens=4096,
55
+ system=system_prompt,
56
+ tools=tools if tools else [],
57
+ messages=messages,
58
+ )
59
+
60
+ # Agregar respuesta del asistente al historial
61
+ messages.append({"role": "assistant", "content": response.content})
62
+
63
+ # Sin tool calls → respuesta final
64
+ if response.stop_reason == "end_turn":
65
+ final_text = _extract_text(response.content)
66
+ logger.debug(f"[run_agent] respuesta final obtenida en iteración {iteration + 1}")
67
+ return final_text, messages
68
+
69
+ # Procesar tool calls
70
+ if response.stop_reason == "tool_use":
71
+ tool_results = []
72
+
73
+ for block in response.content:
74
+ if block.type != "tool_use":
75
+ continue
76
+
77
+ logger.debug(f"[run_agent] ejecutando tool: {block.name}")
78
+ handler = tool_handlers.get(block.name)
79
+
80
+ if handler:
81
+ try:
82
+ result = handler(**block.input)
83
+ except Exception as e:
84
+ result = f"Error ejecutando {block.name}: {str(e)}"
85
+ logger.error(f"[run_agent] error en tool {block.name}: {e}")
86
+ else:
87
+ result = f"Herramienta '{block.name}' no encontrada."
88
+ logger.warning(f"[run_agent] tool no encontrada: {block.name}")
89
+
90
+ tool_results.append({
91
+ "type": "tool_result",
92
+ "tool_use_id": block.id,
93
+ "content": str(result),
94
+ })
95
+
96
+ messages.append({"role": "user", "content": tool_results})
97
+
98
+ logger.warning(f"[run_agent] límite de iteraciones alcanzado ({max_iterations})")
99
+ return "Se alcanzó el límite de iteraciones sin obtener respuesta final.", messages
100
+
101
+
102
+ def _extract_text(content_blocks: list) -> str:
103
+ """Extrae el texto de los bloques de contenido de la respuesta."""
104
+ parts = []
105
+ for block in content_blocks:
106
+ if hasattr(block, "text"):
107
+ parts.append(block.text)
108
+ return "\n".join(parts).strip()
agents/orchestrator.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ orchestrator.py
3
+ ---------------
4
+ Punto de entrada único del sistema. Clasifica el intent del mensaje entrante
5
+ y lo enruta al agente especialista correcto.
6
+
7
+ Clasificación en dos niveles (según el PDF):
8
+ 1. Fast path: keyword matching (gratis, ~0ms). Cubre ~70% de los mensajes.
9
+ 2. Slow path: LLM fallback con Haiku si el score de keywords es insuficiente.
10
+ """
11
+
12
+ import logging
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from agents.base_agent import run_agent, MODEL_FAST
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ SCORE_THRESHOLD = 0.6 # Score mínimo para rutear por keywords
20
+
21
+
22
+ @dataclass
23
+ class AgentRoute:
24
+ """Definición de un agente disponible para routing."""
25
+ name: str
26
+ description: str
27
+ keywords: list[str]
28
+ weight: float = 1.0
29
+
30
+
31
+ # Registro de agentes y sus palabras clave
32
+ AGENT_REGISTRY: list[AgentRoute] = [
33
+ AgentRoute(
34
+ name="price_monitor",
35
+ description="Monitorea el modelo de forecasting de precios del aceite de palma. Analiza tendencias, alertas de precio, drift del modelo y métricas de forecasting.",
36
+ keywords=[
37
+ "precio", "price", "palma", "palm", "cotizaci", "commodity",
38
+ "tendencia", "trend", "forecast", "pron\u00f3stico", "pronostico",
39
+ "fred", "world bank", "imf", "mercado", "mercados", "coste", "costo",
40
+ "barrel", "tonel", "usd", "d\u00f3lar", "dolar",
41
+ ],
42
+ weight=1.2,
43
+ ),
44
+ AgentRoute(
45
+ name="demand_monitor",
46
+ description="Monitorea el modelo de forecasting de demanda y ventas. Analiza volúmenes, inventario, canales, regiones y métricas del modelo.",
47
+ keywords=[
48
+ "demanda", "demand", "venta", "sales", "inventario", "inventory",
49
+ "stock", "pedido", "order", "canal", "channel", "regi\u00f3n", "region",
50
+ "producci\u00f3n", "produccion", "production", "distribuci\u00f3n", "distribucion",
51
+ "aceite", "manteca", "liter", "litro", "kilo", "kg", "unidad",
52
+ "fmcg", "sku", "producto", "product",
53
+ ],
54
+ weight=1.2,
55
+ ),
56
+ AgentRoute(
57
+ name="agent_lab",
58
+ description="Meta-agente que gestiona mejoras del sistema. Revisa recomendaciones pendientes, aplica fixes y reporta el estado del sistema.",
59
+ keywords=[
60
+ "mejora", "improve", "update", "actualizar", "recomendar", "recomendaci\u00f3n",
61
+ "recomendacion", "lab", "agente", "agent", "config", "configuraci\u00f3n",
62
+ "configuracion", "sistema", "system", "fix", "arreglar", "optimizar",
63
+ "pendiente", "pending", "aprobar", "approve", "rechazar", "reject",
64
+ ],
65
+ weight=1.0,
66
+ ),
67
+ ]
68
+
69
+ ORCHESTRATOR_SYSTEM_PROMPT = """Eres el clasificador de intents del sistema monitor-ml-aceitess.
70
+ Tu única tarea es determinar a qué agente debe ir el mensaje del usuario.
71
+
72
+ Agentes disponibles:
73
+ - price_monitor: preguntas sobre precios del aceite de palma, forecasting de precios, tendencias de mercado
74
+ - demand_monitor: preguntas sobre ventas, demanda, inventario, producción, canales de distribución
75
+ - agent_lab: gestión del sistema de agentes, recomendaciones, mejoras, estado del sistema
76
+
77
+ Responde ÚNICAMENTE con el nombre exacto del agente (price_monitor, demand_monitor, o agent_lab).
78
+ Sin explicación. Sin texto adicional."""
79
+
80
+
81
+ class Orchestrator:
82
+ """
83
+ Enruta mensajes al agente especialista correcto.
84
+ Usa keyword matching primero; si el score es bajo, usa Haiku como fallback.
85
+ """
86
+
87
+ def __init__(self):
88
+ self._invocation_counts: dict[str, int] = {r.name: 0 for r in AGENT_REGISTRY}
89
+
90
+ def classify(self, message: str) -> str:
91
+ """
92
+ Clasifica el intent del mensaje y devuelve el nombre del agente destino.
93
+ """
94
+ # Fast path: keyword matching
95
+ agent_name, score = self._keyword_match(message)
96
+
97
+ if score >= SCORE_THRESHOLD:
98
+ logger.info(f"[Orchestrator] fast path → {agent_name} (score={score:.2f})")
99
+ self._invocation_counts[agent_name] += 1
100
+ return agent_name
101
+
102
+ # Slow path: LLM fallback
103
+ logger.info(f"[Orchestrator] score bajo ({score:.2f}), usando LLM fallback")
104
+ agent_name = self._llm_classify(message)
105
+ self._invocation_counts[agent_name] += 1
106
+ return agent_name
107
+
108
+ def _keyword_match(self, message: str) -> tuple[str, float]:
109
+ """
110
+ Calcula score por palabras clave para cada agente.
111
+ Devuelve (mejor_agente, score).
112
+ """
113
+ text = message.lower()
114
+ best_agent = AGENT_REGISTRY[0].name
115
+ best_score = 0.0
116
+
117
+ for route in AGENT_REGISTRY:
118
+ matches = sum(1 for kw in route.keywords if kw in text)
119
+ if route.keywords:
120
+ score = (matches / len(route.keywords)) * route.weight * 10
121
+ # Normalizar a [0, 1]
122
+ score = min(score, 1.0)
123
+ else:
124
+ score = 0.0
125
+
126
+ if score > best_score:
127
+ best_score = score
128
+ best_agent = route.name
129
+
130
+ return best_agent, best_score
131
+
132
+ def _llm_classify(self, message: str) -> str:
133
+ """
134
+ Usa claude-haiku para clasificar el intent cuando keywords no alcanzan el threshold.
135
+ """
136
+ try:
137
+ response, _ = run_agent(
138
+ system_prompt=ORCHESTRATOR_SYSTEM_PROMPT,
139
+ user_message=message,
140
+ tools=[],
141
+ tool_handlers={},
142
+ model=MODEL_FAST,
143
+ max_iterations=1,
144
+ )
145
+ agent_name = response.strip().lower()
146
+
147
+ valid_names = {r.name for r in AGENT_REGISTRY}
148
+ if agent_name in valid_names:
149
+ logger.info(f"[Orchestrator] LLM fallback → {agent_name}")
150
+ return agent_name
151
+
152
+ logger.warning(f"[Orchestrator] LLM devolvió nombre inválido: '{agent_name}', usando demand_monitor")
153
+ except Exception as e:
154
+ logger.error(f"[Orchestrator] error en LLM fallback: {e}")
155
+
156
+ return "demand_monitor" # Default seguro
157
+
158
+ def get_stats(self) -> dict:
159
+ """Devuelve estadísticas de invocaciones por agente."""
160
+ return dict(self._invocation_counts)
agents/specialists/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from agents.specialists.price_monitor import PriceMonitorAgent
2
+ from agents.specialists.demand_monitor import DemandMonitorAgent
3
+
4
+ __all__ = ["PriceMonitorAgent", "DemandMonitorAgent"]
agents/specialists/demand_monitor.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ demand_monitor.py
3
+ -----------------
4
+ Agente especialista: monitoreo del modelo de forecasting de demanda/ventas.
5
+
6
+ Cubre:
7
+ - Volúmenes de venta (aceites, mantecas, otros productos)
8
+ - Inventario y stock
9
+ - Métricas del modelo de demanda
10
+ - Importación futura de datos Excel del negocio real
11
+
12
+ Herramientas:
13
+ - get_demand_metrics Métricas del modelo de demanda
14
+ - get_sales_summary Resumen de ventas por período
15
+ - get_inventory_status Estado de inventario actual
16
+ - import_excel_data Importa datos planos desde Excel (futuro negocio real)
17
+ """
18
+
19
+ import logging
20
+ from agents.base_agent import run_agent, MODEL_SMART
21
+ from database.supabase_client import get_supabase
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ SYSTEM_PROMPT = """Eres el agente monitor del modelo de forecasting de demanda y ventas.
26
+
27
+ Tu rol:
28
+ - Reportar el estado del modelo de predicción de demanda de productos
29
+ - Analizar patrones de ventas por producto, canal y región
30
+ - Alertar sobre desviaciones entre demanda prevista y real
31
+ - Monitorear niveles de inventario y riesgo de desabasto
32
+ - En el futuro, analizar datos reales de ventas del negocio (Excel)
33
+
34
+ El negocio es una empresa ecuatoriana que produce y vende:
35
+ - Aceites comestibles (palma, girasol, soya, maíz)
36
+ - Mantecas
37
+ - Otros productos derivados
38
+
39
+ Los datos actuales provienen del dataset FMCG Kaggle como proxy.
40
+ En el futuro se usarán datos reales en formato Excel plano.
41
+
42
+ Cuando reportes, incluye números específicos y comparaciones con períodos anteriores.
43
+ Responde siempre en español."""
44
+
45
+ TOOLS = [
46
+ {
47
+ "name": "get_demand_metrics",
48
+ "description": "Obtiene las métricas del modelo de forecasting de demanda (MAE, RMSE, MAPE, sesgo).",
49
+ "input_schema": {
50
+ "type": "object",
51
+ "properties": {
52
+ "last_n_runs": {
53
+ "type": "integer",
54
+ "description": "Ejecuciones recientes a consultar.",
55
+ "default": 5,
56
+ }
57
+ },
58
+ },
59
+ },
60
+ {
61
+ "name": "get_sales_summary",
62
+ "description": "Resumen de ventas agrupado por período, producto o canal.",
63
+ "input_schema": {
64
+ "type": "object",
65
+ "properties": {
66
+ "period": {
67
+ "type": "string",
68
+ "enum": ["week", "month", "quarter"],
69
+ "description": "Período de agrupación.",
70
+ "default": "month",
71
+ },
72
+ "group_by": {
73
+ "type": "string",
74
+ "enum": ["product", "channel", "region", "none"],
75
+ "description": "Dimensión de agrupación adicional.",
76
+ "default": "none",
77
+ },
78
+ },
79
+ },
80
+ },
81
+ {
82
+ "name": "get_inventory_status",
83
+ "description": "Estado actual del inventario por producto y nivel de riesgo de desabasto.",
84
+ "input_schema": {"type": "object", "properties": {}},
85
+ },
86
+ {
87
+ "name": "import_excel_data",
88
+ "description": "Importa datos de ventas desde un archivo Excel plano. Usar cuando el usuario suba datos reales del negocio.",
89
+ "input_schema": {
90
+ "type": "object",
91
+ "properties": {
92
+ "file_path": {
93
+ "type": "string",
94
+ "description": "Ruta al archivo Excel (.xlsx o .xls).",
95
+ },
96
+ "sheet_name": {
97
+ "type": "string",
98
+ "description": "Nombre de la hoja a importar (default: primera hoja).",
99
+ "default": "Sheet1",
100
+ },
101
+ },
102
+ "required": ["file_path"],
103
+ },
104
+ },
105
+ ]
106
+
107
+
108
+ def _get_demand_metrics(last_n_runs: int = 5) -> dict:
109
+ try:
110
+ db = get_supabase()
111
+ result = (
112
+ db.table("ml_model_runs")
113
+ .select("run_date, metrics, status, notes")
114
+ .eq("model_id", _get_demand_model_id())
115
+ .order("run_date", desc=True)
116
+ .limit(last_n_runs)
117
+ .execute()
118
+ )
119
+ if result.data:
120
+ return {"runs": result.data, "count": len(result.data)}
121
+ return {"message": "No hay ejecuciones del modelo de demanda registradas. Pendiente de entrenamiento (Fase 2)."}
122
+ except Exception as e:
123
+ return {"error": str(e)}
124
+
125
+
126
+ def _get_sales_summary(period: str = "month", group_by: str = "none") -> dict:
127
+ try:
128
+ db = get_supabase()
129
+ query = db.table("sales_data").select(
130
+ "date, product_name, product_category, quantity, total_amount, channel, region"
131
+ )
132
+
133
+ from datetime import datetime, timedelta
134
+ period_days = {"week": 7, "month": 30, "quarter": 90}
135
+ since = (datetime.utcnow() - timedelta(days=period_days.get(period, 30))).isoformat()
136
+ query = query.gte("date", since).order("date", desc=True)
137
+ result = query.execute()
138
+
139
+ if not result.data:
140
+ return {
141
+ "message": f"No hay datos de ventas para el período '{period}'. "
142
+ "Los datos reales se cargarán en Fase 2 vía Excel o dataset Kaggle."
143
+ }
144
+
145
+ total_amount = sum(r.get("total_amount", 0) or 0 for r in result.data)
146
+ total_qty = sum(r.get("quantity", 0) or 0 for r in result.data)
147
+ return {
148
+ "period": period,
149
+ "group_by": group_by,
150
+ "total_records": len(result.data),
151
+ "total_amount_usd": round(total_amount, 2),
152
+ "total_quantity": round(total_qty, 2),
153
+ }
154
+ except Exception as e:
155
+ return {"error": str(e)}
156
+
157
+
158
+ def _get_inventory_status() -> dict:
159
+ try:
160
+ db = get_supabase()
161
+ result = db.table("inventory_status").select("*").execute()
162
+ if result.data:
163
+ return {"inventory": result.data}
164
+ return {"message": "Tabla de inventario vacía. Se poblará en Fase 2 con datos reales."}
165
+ except Exception as e:
166
+ return {"message": "Inventario no disponible aún. Pendiente Fase 2.", "error": str(e)}
167
+
168
+
169
+ def _import_excel_data(file_path: str, sheet_name: str = "Sheet1") -> dict:
170
+ """
171
+ Importa datos de ventas desde Excel plano.
172
+ Diseñado para la base de datos real del negocio (aceites, mantecas, etc.).
173
+ """
174
+ try:
175
+ import pandas as pd
176
+
177
+ df = pd.read_excel(file_path, sheet_name=sheet_name)
178
+ logger.info(f"[demand_monitor] Excel cargado: {len(df)} filas, columnas: {list(df.columns)}")
179
+
180
+ # Normalización básica de columnas comunes
181
+ col_map = {
182
+ "fecha": "date", "date": "date",
183
+ "producto": "product_name", "product": "product_name",
184
+ "categoria": "product_category", "category": "product_category",
185
+ "cantidad": "quantity", "qty": "quantity",
186
+ "precio": "unit_price", "price": "unit_price",
187
+ "total": "total_amount", "monto": "total_amount",
188
+ "canal": "channel", "region": "region", "región": "region",
189
+ }
190
+ df.columns = [col_map.get(c.lower().strip(), c.lower().strip()) for c in df.columns]
191
+
192
+ records = df.to_dict(orient="records")
193
+
194
+ db = get_supabase()
195
+ # Insertar en lotes de 100
196
+ batch_size = 100
197
+ inserted = 0
198
+ for i in range(0, len(records), batch_size):
199
+ batch = records[i : i + batch_size]
200
+ db.table("sales_data").upsert(batch).execute()
201
+ inserted += len(batch)
202
+
203
+ return {
204
+ "success": True,
205
+ "rows_imported": inserted,
206
+ "columns_detected": list(df.columns),
207
+ "file": file_path,
208
+ }
209
+ except Exception as e:
210
+ logger.error(f"[demand_monitor] error importando Excel: {e}")
211
+ return {"error": str(e)}
212
+
213
+
214
+ def _get_demand_model_id() -> str:
215
+ try:
216
+ db = get_supabase()
217
+ result = db.table("ml_models").select("id").eq("type", "demand_forecast").execute()
218
+ if result.data:
219
+ return result.data[0]["id"]
220
+ except Exception:
221
+ pass
222
+ return "00000000-0000-0000-0000-000000000001"
223
+
224
+
225
+ TOOL_HANDLERS = {
226
+ "get_demand_metrics": _get_demand_metrics,
227
+ "get_sales_summary": _get_sales_summary,
228
+ "get_inventory_status": _get_inventory_status,
229
+ "import_excel_data": _import_excel_data,
230
+ }
231
+
232
+
233
+ class DemandMonitorAgent:
234
+ """Agente de monitoreo del modelo de forecasting de demanda y ventas."""
235
+
236
+ def run(self, message: str, session_history: list | None = None) -> tuple[str, list]:
237
+ return run_agent(
238
+ system_prompt=SYSTEM_PROMPT,
239
+ user_message=message,
240
+ tools=TOOLS,
241
+ tool_handlers=TOOL_HANDLERS,
242
+ model=MODEL_SMART,
243
+ conversation_history=session_history,
244
+ )
agents/specialists/price_monitor.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ price_monitor.py
3
+ ----------------
4
+ Agente especialista: monitoreo del modelo de forecasting de precios
5
+ del aceite de palma (datos FRED / World Bank).
6
+
7
+ Herramientas disponibles:
8
+ - get_latest_price_metrics Últimas métricas del modelo (MAE, RMSE, MAPE)
9
+ - get_price_history Serie histórica de precios
10
+ - detect_price_anomalies Detecta anomalías en predicciones recientes
11
+ - get_model_drift_status Estado de drift del modelo
12
+ """
13
+
14
+ import logging
15
+ from agents.base_agent import run_agent, MODEL_SMART
16
+ from database.supabase_client import get_supabase
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ SYSTEM_PROMPT = """Eres el agente monitor del modelo de forecasting de precios del aceite de palma.
21
+
22
+ Tu rol:
23
+ - Reportar el estado actual del modelo de predicción de precios
24
+ - Detectar y alertar sobre anomalías, drift de datos o degradación de métricas
25
+ - Explicar las tendencias de precio y su impacto en el negocio
26
+ - Sugerir acciones cuando el modelo necesita reentrenamiento
27
+
28
+ El negocio es una empresa ecuatoriana de producción y venta de aceites comestibles y mantecas.
29
+ El precio mundial del aceite de palma (USD/tonelada métrica) es el insumo principal del modelo.
30
+ Fuentes de datos: FRED (PPOILUSDM), World Bank Pink Sheet, CFN Ecuador, ANCUPA.
31
+
32
+ Cuando reportes métricas, sé específico con números. Cuando detectes problemas, prioriza claridad.
33
+ Responde siempre en español."""
34
+
35
+ # Definiciones de herramientas
36
+ TOOLS = [
37
+ {
38
+ "name": "get_latest_price_metrics",
39
+ "description": "Obtiene las últimas métricas de rendimiento del modelo de forecasting de precios (MAE, RMSE, MAPE, R²).",
40
+ "input_schema": {
41
+ "type": "object",
42
+ "properties": {
43
+ "last_n_runs": {
44
+ "type": "integer",
45
+ "description": "Número de ejecuciones recientes a consultar (default: 5).",
46
+ "default": 5,
47
+ }
48
+ },
49
+ },
50
+ },
51
+ {
52
+ "name": "get_price_history",
53
+ "description": "Obtiene la serie histórica de precios del aceite de palma y las predicciones del modelo.",
54
+ "input_schema": {
55
+ "type": "object",
56
+ "properties": {
57
+ "months": {
58
+ "type": "integer",
59
+ "description": "Meses hacia atrás a consultar (default: 12).",
60
+ "default": 12,
61
+ }
62
+ },
63
+ },
64
+ },
65
+ {
66
+ "name": "detect_price_anomalies",
67
+ "description": "Analiza las predicciones recientes y detecta anomalías o desviaciones inusuales.",
68
+ "input_schema": {"type": "object", "properties": {}},
69
+ },
70
+ {
71
+ "name": "get_model_drift_status",
72
+ "description": "Evalúa si hay drift en los datos de entrada o en la distribución de predicciones del modelo.",
73
+ "input_schema": {"type": "object", "properties": {}},
74
+ },
75
+ ]
76
+
77
+
78
+ # Handlers de herramientas
79
+ def _get_latest_price_metrics(last_n_runs: int = 5) -> dict:
80
+ """Consulta métricas del modelo en Supabase."""
81
+ try:
82
+ db = get_supabase()
83
+ result = (
84
+ db.table("ml_model_runs")
85
+ .select("run_date, metrics, status, notes")
86
+ .eq("model_id", _get_price_model_id())
87
+ .order("run_date", desc=True)
88
+ .limit(last_n_runs)
89
+ .execute()
90
+ )
91
+ if result.data:
92
+ return {"runs": result.data, "count": len(result.data)}
93
+ return {"message": "No hay ejecuciones registradas aún. El modelo no ha sido entrenado todavía."}
94
+ except Exception as e:
95
+ logger.error(f"[price_monitor] error consultando métricas: {e}")
96
+ return {"error": str(e), "message": "No se pudieron obtener las métricas. Verifica la conexión a Supabase."}
97
+
98
+
99
+ def _get_price_history(months: int = 12) -> dict:
100
+ """Consulta histórico de precios en Supabase."""
101
+ try:
102
+ db = get_supabase()
103
+ from datetime import datetime, timedelta
104
+ since = (datetime.utcnow() - timedelta(days=months * 30)).isoformat()
105
+ result = (
106
+ db.table("price_data")
107
+ .select("date, actual_price, predicted_price, source")
108
+ .gte("date", since)
109
+ .order("date", desc=False)
110
+ .execute()
111
+ )
112
+ if result.data:
113
+ return {"records": result.data, "count": len(result.data), "months": months}
114
+ return {"message": f"No hay datos de precios para los últimos {months} meses. Pendiente de cargar datos de FRED/World Bank."}
115
+ except Exception as e:
116
+ return {"error": str(e)}
117
+
118
+
119
+ def _detect_price_anomalies() -> dict:
120
+ """Detección simple de anomalías en predicciones recientes."""
121
+ try:
122
+ db = get_supabase()
123
+ result = (
124
+ db.table("price_data")
125
+ .select("date, actual_price, predicted_price")
126
+ .order("date", desc=True)
127
+ .limit(30)
128
+ .execute()
129
+ )
130
+ if not result.data:
131
+ return {"message": "No hay datos suficientes para detectar anomalías."}
132
+
133
+ anomalies = []
134
+ for row in result.data:
135
+ if row.get("actual_price") and row.get("predicted_price"):
136
+ error_pct = abs(row["actual_price"] - row["predicted_price"]) / row["actual_price"] * 100
137
+ if error_pct > 15: # Umbral: 15% de error
138
+ anomalies.append({
139
+ "date": row["date"],
140
+ "actual": row["actual_price"],
141
+ "predicted": row["predicted_price"],
142
+ "error_pct": round(error_pct, 2),
143
+ })
144
+ return {"anomalies": anomalies, "total_checked": len(result.data)}
145
+ except Exception as e:
146
+ return {"error": str(e)}
147
+
148
+
149
+ def _get_model_drift_status() -> dict:
150
+ """Estado de drift del modelo (placeholder hasta tener modelo real)."""
151
+ try:
152
+ db = get_supabase()
153
+ result = (
154
+ db.table("ml_models")
155
+ .select("name, metrics, last_evaluated")
156
+ .eq("type", "price_forecast")
157
+ .execute()
158
+ )
159
+ if result.data:
160
+ return {"model": result.data[0]}
161
+ return {"message": "Modelo de forecasting de precios aún no registrado. Fase 2 del proyecto."}
162
+ except Exception as e:
163
+ return {"error": str(e)}
164
+
165
+
166
+ def _get_price_model_id() -> str:
167
+ """Obtiene el ID del modelo de precios en Supabase."""
168
+ try:
169
+ db = get_supabase()
170
+ result = db.table("ml_models").select("id").eq("type", "price_forecast").execute()
171
+ if result.data:
172
+ return result.data[0]["id"]
173
+ except Exception:
174
+ pass
175
+ return "00000000-0000-0000-0000-000000000000" # Placeholder
176
+
177
+
178
+ TOOL_HANDLERS = {
179
+ "get_latest_price_metrics": _get_latest_price_metrics,
180
+ "get_price_history": _get_price_history,
181
+ "detect_price_anomalies": _detect_price_anomalies,
182
+ "get_model_drift_status": _get_model_drift_status,
183
+ }
184
+
185
+
186
+ class PriceMonitorAgent:
187
+ """Agente de monitoreo del modelo de forecasting de precios."""
188
+
189
+ def run(self, message: str, session_history: list | None = None) -> tuple[str, list]:
190
+ return run_agent(
191
+ system_prompt=SYSTEM_PROMPT,
192
+ user_message=message,
193
+ tools=TOOLS,
194
+ tool_handlers=TOOL_HANDLERS,
195
+ model=MODEL_SMART,
196
+ conversation_history=session_history,
197
+ )
app.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py
3
+ ------
4
+ Entry point del HuggingFace Space.
5
+ Combina:
6
+ - FastAPI: webhook de Telegram (/telegram-webhook)
7
+ - Gradio: dashboard de monitoreo (montado en /)
8
+
9
+ Para ejecutar localmente:
10
+ python app.py
11
+
12
+ Para configurar el webhook de Telegram (una sola vez):
13
+ python telegram/bot.py --setup
14
+ """
15
+
16
+ import os
17
+ import asyncio
18
+ import logging
19
+ from contextlib import asynccontextmanager
20
+
21
+ from dotenv import load_dotenv
22
+ load_dotenv()
23
+
24
+ import gradio as gr
25
+ from fastapi import FastAPI, Request, Response
26
+ import uvicorn
27
+
28
+ # ── Agentes ───────────────────────────────────────────────
29
+ from agents.orchestrator import Orchestrator
30
+ from agents.specialists.price_monitor import PriceMonitorAgent
31
+ from agents.specialists.demand_monitor import DemandMonitorAgent
32
+ from agents.agent_lab import AgentLabAgent
33
+ from telegram.bot import TelegramBot
34
+
35
+ logging.basicConfig(
36
+ level=os.environ.get("LOG_LEVEL", "INFO"),
37
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
38
+ )
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # ── Inicialización de agentes ─────────────────────────────
42
+ orchestrator = Orchestrator()
43
+ agent_map = {
44
+ "price_monitor": PriceMonitorAgent(),
45
+ "demand_monitor": DemandMonitorAgent(),
46
+ "agent_lab": AgentLabAgent(),
47
+ }
48
+ telegram_bot = TelegramBot(orchestrator=orchestrator, agent_map=agent_map)
49
+
50
+ # ── FastAPI ───────────────────────────────────────────────
51
+ @asynccontextmanager
52
+ async def lifespan(app: FastAPI):
53
+ logger.info("monitor-ml-aceitess iniciado")
54
+ yield
55
+ logger.info("monitor-ml-aceitess detenido")
56
+
57
+ fastapi_app = FastAPI(
58
+ title="monitor-ml-aceitess",
59
+ description="Sistema multiagente de monitoreo ML para aceites comestibles",
60
+ lifespan=lifespan,
61
+ )
62
+
63
+
64
+ @fastapi_app.post("/telegram-webhook")
65
+ async def telegram_webhook(request: Request):
66
+ """Recibe updates de Telegram vía webhook."""
67
+ try:
68
+ data = await request.json()
69
+ asyncio.create_task(telegram_bot.process_update(data))
70
+ return Response(content="ok", status_code=200)
71
+ except Exception as e:
72
+ logger.error(f"[webhook] error: {e}")
73
+ return Response(content="error", status_code=500)
74
+
75
+
76
+ @fastapi_app.get("/health")
77
+ def health():
78
+ """Endpoint de salud para UptimeRobot."""
79
+ return {"status": "ok", "service": "monitor-ml-aceitess"}
80
+
81
+
82
+ # ── Gradio Dashboard ──────────────────────────────────────
83
+ def build_dashboard() -> gr.Blocks:
84
+ with gr.Blocks(
85
+ title="Monitor ML — Aceites",
86
+ theme=gr.themes.Soft(),
87
+ css=".gradio-container { max-width: 900px; margin: auto; }",
88
+ ) as demo:
89
+
90
+ gr.Markdown("# Monitor ML — Aceites Comestibles")
91
+ gr.Markdown(
92
+ "Sistema multiagente para monitoreo de modelos de forecasting de "
93
+ "precios y demanda de aceites comestibles (Ecuador)."
94
+ )
95
+
96
+ with gr.Tabs():
97
+
98
+ # ── Tab: Chat con agentes ──────────────────────
99
+ with gr.TabItem("Chat con Agentes"):
100
+ gr.Markdown("Consulta directamente a los agentes especialistas.")
101
+
102
+ chatbot = gr.Chatbot(height=400, label="Conversación")
103
+ with gr.Row():
104
+ msg_input = gr.Textbox(
105
+ placeholder="Ej: ¿Cuál es el estado del modelo de precios?",
106
+ label="Mensaje",
107
+ scale=4,
108
+ )
109
+ send_btn = gr.Button("Enviar", variant="primary", scale=1)
110
+
111
+ session_state = gr.State([])
112
+
113
+ def chat(message: str, history: list, session: list):
114
+ if not message.strip():
115
+ return history, session, ""
116
+
117
+ agent_name = orchestrator.classify(message)
118
+ agent = agent_map.get(agent_name)
119
+
120
+ if agent:
121
+ response, updated_session = agent.run(message, session[-20:])
122
+ else:
123
+ response = f"Agente '{agent_name}' no disponible."
124
+ updated_session = session
125
+
126
+ history.append((message, f"[{agent_name}] {response}"))
127
+ return history, updated_session, ""
128
+
129
+ send_btn.click(
130
+ chat,
131
+ inputs=[msg_input, chatbot, session_state],
132
+ outputs=[chatbot, session_state, msg_input],
133
+ )
134
+ msg_input.submit(
135
+ chat,
136
+ inputs=[msg_input, chatbot, session_state],
137
+ outputs=[chatbot, session_state, msg_input],
138
+ )
139
+
140
+ # ── Tab: Estado del sistema ────────────────────
141
+ with gr.TabItem("Estado del Sistema"):
142
+ gr.Markdown("### Agentes registrados")
143
+
144
+ def get_system_status():
145
+ stats = orchestrator.get_stats()
146
+ rows = [[name, count, "✅ activo"] for name, count in stats.items()]
147
+ return rows
148
+
149
+ status_table = gr.Dataframe(
150
+ headers=["Agente", "Consultas", "Estado"],
151
+ value=get_system_status(),
152
+ interactive=False,
153
+ )
154
+ refresh_btn = gr.Button("Actualizar")
155
+ refresh_btn.click(get_system_status, outputs=status_table)
156
+
157
+ # ── Tab: Recomendaciones Agent Lab ─────────────
158
+ with gr.TabItem("Recomendaciones Agent Lab"):
159
+ gr.Markdown(
160
+ "Recomendaciones proactivas generadas por Agent Lab "
161
+ "que requieren aprobación humana."
162
+ )
163
+
164
+ def get_recommendations():
165
+ try:
166
+ from database.supabase_client import get_supabase
167
+ db = get_supabase()
168
+ result = (
169
+ db.table("agent_lab_recommendations")
170
+ .select("title, target_agent, type, status, priority, created_at")
171
+ .order("created_at", desc=True)
172
+ .limit(20)
173
+ .execute()
174
+ )
175
+ if result.data:
176
+ return [[
177
+ r["title"], r["target_agent"], r["type"],
178
+ r["status"], r["priority"], r["created_at"][:10]
179
+ ] for r in result.data]
180
+ except Exception as e:
181
+ logger.warning(f"No se pudieron cargar recomendaciones: {e}")
182
+ return [["Sin datos", "—", "—", "—", "—", "—"]]
183
+
184
+ rec_table = gr.Dataframe(
185
+ headers=["Título", "Agente", "Tipo", "Estado", "Prioridad", "Fecha"],
186
+ value=get_recommendations(),
187
+ interactive=False,
188
+ )
189
+ refresh_rec_btn = gr.Button("Actualizar")
190
+ refresh_rec_btn.click(get_recommendations, outputs=rec_table)
191
+
192
+ # ── Tab: Info ──────────────────────────────────
193
+ with gr.TabItem("Acerca de"):
194
+ gr.Markdown("""
195
+ ## monitor-ml-aceitess
196
+
197
+ Sistema multiagente basado en Claude (Anthropic) para monitoreo de modelos ML
198
+ aplicados a la producción y venta de aceites comestibles en Ecuador.
199
+
200
+ ### Agentes
201
+ | Agente | Rol |
202
+ |--------|-----|
203
+ | `price_monitor` | Forecasting de precios del aceite de palma (FRED/World Bank) |
204
+ | `demand_monitor` | Forecasting de demanda y ventas (aceites, mantecas) |
205
+ | `agent_lab` | Meta-agente de auto-mejora continua |
206
+ | `orchestrator` | Clasificación de intents y routing |
207
+
208
+ ### Fuentes de datos
209
+ - **Precio palma**: FRED (PPOILUSDM), World Bank Pink Sheet, CFN Ecuador
210
+ - **Ventas**: FMCG Kaggle (proxy) → datos reales del negocio en Excel (Fase 2)
211
+ - **Ecuador**: ANCUPA, USDA FAS PSD Online
212
+
213
+ ### Arquitectura
214
+ Desplegado en HuggingFace Spaces (Gradio + FastAPI).
215
+ Interfaz principal vía Telegram bot (webhook).
216
+ Base de datos: Supabase.
217
+ Cron diario de research: GitHub Actions.
218
+ """)
219
+
220
+ return demo
221
+
222
+
223
+ # ── Montar Gradio sobre FastAPI ───────────────────────────
224
+ dashboard = build_dashboard()
225
+ app = gr.mount_gradio_app(fastapi_app, dashboard, path="/")
226
+
227
+
228
+ # ── Punto de entrada local ────────────────────────────────
229
+ if __name__ == "__main__":
230
+ port = int(os.environ.get("PORT", 7860))
231
+ uvicorn.run(
232
+ "app:app",
233
+ host="0.0.0.0",
234
+ port=port,
235
+ reload=os.environ.get("APP_ENV") == "development",
236
+ )
cron/__init__.py ADDED
File without changes
cron/research_pipeline.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ research_pipeline.py
3
+ --------------------
4
+ Pipeline diario de investigación. Ejecutado por GitHub Actions cada noche.
5
+
6
+ Pasos:
7
+ 1. SCRAPE — Fetch RSS/HTML de 6 fuentes de AI research
8
+ 2. DEDUP — Filtrar URLs ya procesadas en DB
9
+ 3. SCORE — Haiku evalúa relevancia para el dominio (0.0 a 1.0)
10
+ 4. SAVE — Guardar findings con score > 0.3
11
+ 5. BLOAT — Verificar skill bloat en agentes
12
+ 6. IMPROVE — Agent Lab ejecuta ciclo completo de mejora
13
+ 7. REPORT — Enviar resumen vía Telegram
14
+
15
+ Uso:
16
+ python -m cron.research_pipeline
17
+ python -m cron.research_pipeline --dry-run (sin escribir a DB)
18
+ """
19
+
20
+ import os
21
+ import sys
22
+ import asyncio
23
+ import logging
24
+ import argparse
25
+ from datetime import datetime
26
+
27
+ import httpx
28
+ from dotenv import load_dotenv
29
+
30
+ load_dotenv()
31
+
32
+ logging.basicConfig(
33
+ level=logging.INFO,
34
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
35
+ )
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # Fuentes de research
39
+ RESEARCH_SOURCES = [
40
+ {"name": "Anthropic Blog", "url": "https://www.anthropic.com/news", "tier": "anthropic"},
41
+ {"name": "Anthropic Cookbook","url": "https://github.com/anthropics/anthropic-cookbook/commits/main.atom", "tier": "anthropic"},
42
+ {"name": "Simon Willison", "url": "https://simonwillison.net/atom/entries/", "tier": "community"},
43
+ {"name": "Lilian Weng", "url": "https://lilianweng.github.io/index.xml", "tier": "research"},
44
+ {"name": "LangChain Blog", "url": "https://blog.langchain.dev/rss/", "tier": "ecosystem"},
45
+ {"name": "HuggingFace Blog", "url": "https://huggingface.co/blog/feed.xml", "tier": "ecosystem"},
46
+ ]
47
+
48
+ RELEVANCE_PROMPT = """Eres un evaluador de relevancia para un sistema de agentes de monitoreo ML
49
+ aplicado a la industria de aceites comestibles en Ecuador.
50
+
51
+ El sistema usa:
52
+ - Claude (Anthropic) como LLM
53
+ - Agentes especialistas con tool-calling
54
+ - Forecasting de precios de aceite de palma y demanda de ventas
55
+ - Python, Supabase, HuggingFace Spaces, Telegram
56
+
57
+ Evalúa este artículo con un score de 0.0 a 1.0 de relevancia para MEJORAR el sistema:
58
+ - >0.8: Específico y directamente aplicable (nueva técnica de agentes, mejora de tool-calling, etc.)
59
+ - >0.5: Relevante pero genérico (mejores prácticas de LLM, patrones de agentes)
60
+ - <0.3: Irrelevante para este sistema
61
+
62
+ Responde SOLO con el número (ej: 0.75). Sin texto adicional.
63
+
64
+ Artículo:
65
+ Título: {title}
66
+ Resumen: {summary}"""
67
+
68
+
69
+ async def scrape_rss(url: str) -> list[dict]:
70
+ """Obtiene entradas de un feed RSS/Atom."""
71
+ try:
72
+ import xml.etree.ElementTree as ET
73
+
74
+ async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
75
+ response = await client.get(url)
76
+ response.raise_for_status()
77
+
78
+ root = ET.fromstring(response.text)
79
+ ns = {"atom": "http://www.w3.org/2005/Atom"}
80
+
81
+ entries = []
82
+
83
+ # Atom feed
84
+ for entry in root.findall(".//atom:entry", ns):
85
+ title_el = entry.find("atom:title", ns)
86
+ link_el = entry.find("atom:link", ns)
87
+ summary_el = entry.find("atom:summary", ns) or entry.find("atom:content", ns)
88
+
89
+ if title_el is not None and link_el is not None:
90
+ entries.append({
91
+ "title": title_el.text or "",
92
+ "url": link_el.get("href", ""),
93
+ "summary": (summary_el.text or "")[:500] if summary_el is not None else "",
94
+ })
95
+
96
+ # RSS 2.0 feed
97
+ if not entries:
98
+ for item in root.findall(".//item"):
99
+ title_el = item.find("title")
100
+ link_el = item.find("link")
101
+ desc_el = item.find("description")
102
+
103
+ if title_el is not None and link_el is not None:
104
+ entries.append({
105
+ "title": title_el.text or "",
106
+ "url": link_el.text or "",
107
+ "summary": (desc_el.text or "")[:500] if desc_el is not None else "",
108
+ })
109
+
110
+ return entries[:10] # Máximo 10 por fuente
111
+
112
+ except Exception as e:
113
+ logger.warning(f"Error scrapeando {url}: {e}")
114
+ return []
115
+
116
+
117
+ async def score_relevance(title: str, summary: str) -> float:
118
+ """Usa Haiku para evaluar relevancia del artículo."""
119
+ import anthropic
120
+ try:
121
+ client = anthropic.Anthropic()
122
+ response = client.messages.create(
123
+ model="claude-haiku-4-5-20251001",
124
+ max_tokens=10,
125
+ messages=[{
126
+ "role": "user",
127
+ "content": RELEVANCE_PROMPT.format(title=title, summary=summary[:300]),
128
+ }],
129
+ )
130
+ score_text = response.content[0].text.strip()
131
+ return min(max(float(score_text), 0.0), 1.0)
132
+ except Exception as e:
133
+ logger.warning(f"Error evaluando relevancia: {e}")
134
+ return 0.0
135
+
136
+
137
+ async def run_pipeline(dry_run: bool = False):
138
+ """Ejecuta el pipeline completo de investigación."""
139
+ logger.info("=== Iniciando pipeline de research ===")
140
+ start_time = datetime.utcnow()
141
+
142
+ from database.supabase_client import get_supabase
143
+ db = get_supabase() if not dry_run else None
144
+
145
+ # 1. SCRAPE
146
+ logger.info("PASO 1: Scraping de fuentes...")
147
+ all_entries = []
148
+ for source in RESEARCH_SOURCES:
149
+ entries = await scrape_rss(source["url"])
150
+ for e in entries:
151
+ e["source_tier"] = source["tier"]
152
+ e["source_name"] = source["name"]
153
+ all_entries.extend(entries)
154
+ logger.info(f" {source['name']}: {len(entries)} entradas")
155
+
156
+ logger.info(f"Total entradas scraped: {len(all_entries)}")
157
+
158
+ # 2. DEDUP
159
+ logger.info("PASO 2: Deduplicación...")
160
+ if db:
161
+ existing = db.table("agent_lab_research").select("source_url").execute()
162
+ existing_urls = {r["source_url"] for r in (existing.data or [])}
163
+ all_entries = [e for e in all_entries if e["url"] not in existing_urls]
164
+ logger.info(f"Entradas nuevas después de dedup: {len(all_entries)}")
165
+
166
+ # 3. SCORE + 4. SAVE
167
+ logger.info("PASO 3-4: Scoring y guardado...")
168
+ saved = 0
169
+ for entry in all_entries:
170
+ score = await score_relevance(entry["title"], entry["summary"])
171
+ logger.debug(f" Score {score:.2f}: {entry['title'][:60]}")
172
+
173
+ if score > 0.3:
174
+ if not dry_run and db:
175
+ db.table("agent_lab_research").insert({
176
+ "source_url": entry["url"],
177
+ "source_tier": entry["source_tier"],
178
+ "title": entry["title"],
179
+ "summary": entry["summary"],
180
+ "relevance_score": round(score, 2),
181
+ "tags": [entry["source_tier"]],
182
+ }).execute()
183
+ saved += 1
184
+
185
+ logger.info(f"Findings guardados (score > 0.3): {saved}")
186
+
187
+ # 5. BLOAT CHECK
188
+ logger.info("PASO 5: Verificando skill bloat...")
189
+ if db:
190
+ agents = db.table("agents").select("name, tools").execute()
191
+ for agent in (agents.data or []):
192
+ tool_count = len(agent.get("tools") or [])
193
+ if tool_count >= 5:
194
+ logger.warning(f" BLOAT: {agent['name']} tiene {tool_count} herramientas")
195
+
196
+ # 6. AGENT LAB
197
+ logger.info("PASO 6: Ejecutando ciclo de Agent Lab...")
198
+ if not dry_run:
199
+ from agents.agent_lab import AgentLabAgent
200
+ lab = AgentLabAgent()
201
+ lab_report = lab.run_daily_cycle()
202
+ logger.info(f"Agent Lab completado: {lab_report[:200]}...")
203
+ else:
204
+ lab_report = "[dry-run: Agent Lab omitido]"
205
+
206
+ # 7. REPORT
207
+ elapsed = (datetime.utcnow() - start_time).seconds
208
+ report = (
209
+ f"📊 *Reporte diario Agent Lab*\n\n"
210
+ f"• Fuentes scrapeadas: {len(RESEARCH_SOURCES)}\n"
211
+ f"• Entradas procesadas: {len(all_entries)}\n"
212
+ f"• Findings guardados: {saved}\n"
213
+ f"• Duración: {elapsed}s\n\n"
214
+ f"*Resumen Agent Lab:*\n{lab_report[:500]}"
215
+ )
216
+
217
+ await send_telegram_report(report)
218
+ logger.info("=== Pipeline completado ===")
219
+ return report
220
+
221
+
222
+ async def send_telegram_report(message: str):
223
+ """Envía el reporte al bot de Telegram."""
224
+ token = os.environ.get("TELEGRAM_BOT_TOKEN")
225
+ chat_id = os.environ.get("TELEGRAM_ADMIN_CHAT_ID")
226
+
227
+ if not token or not chat_id:
228
+ logger.info(f"[Report] (Telegram no configurado)\n{message}")
229
+ return
230
+
231
+ try:
232
+ async with httpx.AsyncClient() as client:
233
+ await client.post(
234
+ f"https://api.telegram.org/bot{token}/sendMessage",
235
+ json={"chat_id": chat_id, "text": message, "parse_mode": "Markdown"},
236
+ )
237
+ logger.info("Reporte enviado por Telegram")
238
+ except Exception as e:
239
+ logger.error(f"Error enviando reporte Telegram: {e}")
240
+
241
+
242
+ if __name__ == "__main__":
243
+ parser = argparse.ArgumentParser(description="Pipeline diario de research")
244
+ parser.add_argument("--dry-run", action="store_true", help="Ejecutar sin escribir a DB")
245
+ args = parser.parse_args()
246
+
247
+ asyncio.run(run_pipeline(dry_run=args.dry_run))
data/processed/.gitkeep ADDED
File without changes
data/raw/.gitkeep ADDED
File without changes
database/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from database.supabase_client import get_supabase
2
+
3
+ __all__ = ["get_supabase"]
database/schema.sql ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- ============================================================
2
+ -- schema.sql — monitor-ml-aceitess
3
+ -- Ejecutar en el SQL Editor de Supabase (una sola vez)
4
+ -- ============================================================
5
+
6
+ -- ─── Extensiones ──────────────────────────────────────────
7
+ CREATE EXTENSION IF NOT EXISTS "pgcrypto";
8
+
9
+ -- ─── Agentes ──────────────────────────────────────────────
10
+ CREATE TABLE IF NOT EXISTS agents (
11
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
12
+ name VARCHAR(100) UNIQUE NOT NULL,
13
+ description TEXT,
14
+ system_prompt TEXT NOT NULL DEFAULT '',
15
+ model VARCHAR(100) DEFAULT 'claude-sonnet-4-6',
16
+ tools JSONB DEFAULT '[]',
17
+ skills JSONB DEFAULT '[]',
18
+ memory JSONB DEFAULT '{}',
19
+ version_hash VARCHAR(64),
20
+ created_at TIMESTAMPTZ DEFAULT NOW(),
21
+ updated_at TIMESTAMPTZ DEFAULT NOW()
22
+ );
23
+
24
+ -- ─── Sesiones de conversación ─────────────────────────────
25
+ CREATE TABLE IF NOT EXISTS sessions (
26
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
27
+ telegram_user_id BIGINT NOT NULL,
28
+ agent_name VARCHAR(100),
29
+ history JSONB DEFAULT '[]',
30
+ created_at TIMESTAMPTZ DEFAULT NOW(),
31
+ updated_at TIMESTAMPTZ DEFAULT NOW()
32
+ );
33
+ CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (telegram_user_id);
34
+
35
+ -- ─── Modelos ML ───────────────────────────────────────────
36
+ CREATE TABLE IF NOT EXISTS ml_models (
37
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
38
+ name VARCHAR(100) UNIQUE NOT NULL,
39
+ type VARCHAR(50) NOT NULL, -- 'price_forecast' | 'demand_forecast'
40
+ description TEXT,
41
+ metrics JSONB DEFAULT '{}', -- MAE, RMSE, MAPE, R², etc.
42
+ last_evaluated TIMESTAMPTZ,
43
+ created_at TIMESTAMPTZ DEFAULT NOW()
44
+ );
45
+
46
+ -- Insertar modelos base
47
+ INSERT INTO ml_models (name, type, description) VALUES
48
+ ('palm_price_forecast', 'price_forecast', 'Forecasting de precio mundial aceite de palma (FRED/World Bank)'),
49
+ ('sales_demand_forecast', 'demand_forecast', 'Forecasting de demanda y ventas de aceites y mantecas')
50
+ ON CONFLICT (name) DO NOTHING;
51
+
52
+ -- ─── Ejecuciones de modelos ML ────────────────────────────
53
+ CREATE TABLE IF NOT EXISTS ml_model_runs (
54
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
55
+ model_id UUID REFERENCES ml_models(id) ON DELETE CASCADE,
56
+ run_date TIMESTAMPTZ DEFAULT NOW(),
57
+ metrics JSONB NOT NULL DEFAULT '{}',
58
+ data_source VARCHAR(100),
59
+ rows_processed INTEGER,
60
+ status VARCHAR(20) DEFAULT 'success' CHECK (status IN ('success', 'failed', 'warning')),
61
+ notes TEXT
62
+ );
63
+ CREATE INDEX IF NOT EXISTS idx_model_runs_model ON ml_model_runs (model_id, run_date DESC);
64
+
65
+ -- ─── Serie de precios (aceite de palma) ───────────────────
66
+ CREATE TABLE IF NOT EXISTS price_data (
67
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
68
+ date DATE NOT NULL,
69
+ actual_price DECIMAL(10,2), -- USD/tonelada métrica
70
+ predicted_price DECIMAL(10,2),
71
+ source VARCHAR(50), -- 'FRED' | 'WorldBank' | 'CFN' | 'ANCUPA'
72
+ currency VARCHAR(3) DEFAULT 'USD',
73
+ unit VARCHAR(20) DEFAULT 'USD/MT',
74
+ created_at TIMESTAMPTZ DEFAULT NOW(),
75
+ UNIQUE (date, source)
76
+ );
77
+ CREATE INDEX IF NOT EXISTS idx_price_data_date ON price_data (date DESC);
78
+
79
+ -- ─── Datos de ventas ──────────────────────────────────────
80
+ -- Diseñado para recibir datos reales del negocio (Excel plano en el futuro)
81
+ CREATE TABLE IF NOT EXISTS sales_data (
82
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
83
+ source_id UUID, -- referencia a data_sources
84
+ date DATE NOT NULL,
85
+ product_name VARCHAR(200),
86
+ product_category VARCHAR(100), -- 'aceite', 'manteca', 'otros'
87
+ quantity DECIMAL(10,2),
88
+ unit_price DECIMAL(10,2),
89
+ total_amount DECIMAL(10,2),
90
+ currency VARCHAR(3) DEFAULT 'USD',
91
+ region VARCHAR(100),
92
+ channel VARCHAR(50), -- 'retail', 'wholesale', 'ecommerce'
93
+ metadata JSONB DEFAULT '{}', -- columnas extra del Excel original
94
+ created_at TIMESTAMPTZ DEFAULT NOW()
95
+ );
96
+ CREATE INDEX IF NOT EXISTS idx_sales_date ON sales_data (date DESC);
97
+ CREATE INDEX IF NOT EXISTS idx_sales_category ON sales_data (product_category);
98
+
99
+ -- ─── Inventario ──���────────────────────────────────────────
100
+ CREATE TABLE IF NOT EXISTS inventory_status (
101
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
102
+ product_name VARCHAR(200) NOT NULL,
103
+ product_category VARCHAR(100),
104
+ current_stock DECIMAL(10,2),
105
+ unit VARCHAR(20) DEFAULT 'kg',
106
+ reorder_point DECIMAL(10,2),
107
+ risk_level VARCHAR(20) DEFAULT 'ok' CHECK (risk_level IN ('ok', 'low', 'critical')),
108
+ updated_at TIMESTAMPTZ DEFAULT NOW()
109
+ );
110
+
111
+ -- ─── Fuentes de datos ─────────────────────────────────────
112
+ CREATE TABLE IF NOT EXISTS data_sources (
113
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
114
+ name VARCHAR(100) NOT NULL,
115
+ type VARCHAR(50), -- 'kaggle' | 'fred' | 'world_bank' | 'excel_import' | 'ancupa'
116
+ description TEXT,
117
+ url TEXT,
118
+ last_updated TIMESTAMPTZ,
119
+ rows_count INTEGER,
120
+ columns JSONB DEFAULT '[]',
121
+ metadata JSONB DEFAULT '{}',
122
+ created_at TIMESTAMPTZ DEFAULT NOW()
123
+ );
124
+
125
+ -- Insertar fuentes conocidas
126
+ INSERT INTO data_sources (name, type, description, url) VALUES
127
+ ('FRED PPOILUSDM', 'fred', 'Precio mundial aceite de palma mensual (IMF vía FRED)', 'https://fred.stlouisfed.org/series/PPOILUSDM'),
128
+ ('World Bank Pink Sheet', 'world_bank', 'Commodities price data mensual desde 1960', 'https://thedocs.worldbank.org/en/doc/18675f1d1639c7a34d463f59263ba0a2-0050012025/world-bank-commodities-price-data-the-pink-sheet'),
129
+ ('FMCG Daily Sales 2022-2024', 'kaggle', 'Ventas diarias FMCG sintéticas (proxy hasta tener datos reales)', 'https://www.kaggle.com/datasets/beatafaron/fmcg-daily-sales-data-to-2022-2024'),
130
+ ('CFN Ficha Sectorial Palma 2024', 'cfn_ecuador', 'Precios productor y estadísticas palma africana Ecuador 2024', 'https://www.cfn.fin.ec/wp-content/uploads/2024/07/Ficha-Sectorial-Palma-Africana.pdf'),
131
+ ('ANCUPA Ecuador', 'ancupa', 'Estadísticas nacionales palma aceitera Ecuador desde 1994', 'http://ancupa.com/estadisticas/')
132
+ ON CONFLICT DO NOTHING;
133
+
134
+ -- ─── Agent Lab: Research ──────────────────────────────────
135
+ CREATE TABLE IF NOT EXISTS agent_lab_research (
136
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
137
+ source_url TEXT NOT NULL UNIQUE,
138
+ source_tier VARCHAR(50), -- 'anthropic' | 'huggingface' | 'langchain' | etc.
139
+ title TEXT NOT NULL,
140
+ summary TEXT,
141
+ relevance_score DECIMAL(3,2) DEFAULT 0.00 CHECK (relevance_score BETWEEN 0 AND 1),
142
+ tags JSONB DEFAULT '[]',
143
+ created_at TIMESTAMPTZ DEFAULT NOW()
144
+ );
145
+ CREATE INDEX IF NOT EXISTS idx_research_score ON agent_lab_research (relevance_score DESC);
146
+
147
+ -- ─── Agent Lab: Recomendaciones ───────────────────────────
148
+ CREATE TABLE IF NOT EXISTS agent_lab_recommendations (
149
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
150
+ type VARCHAR(20) NOT NULL CHECK (type IN ('reactive', 'proactive')),
151
+ category VARCHAR(100), -- 'prompt' | 'tool' | 'model' | 'skill' | 'memory'
152
+ target_agent VARCHAR(100) NOT NULL,
153
+ title TEXT NOT NULL,
154
+ description TEXT,
155
+ rationale TEXT,
156
+ status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'applied')),
157
+ priority INTEGER DEFAULT 5 CHECK (priority BETWEEN 1 AND 10),
158
+ created_at TIMESTAMPTZ DEFAULT NOW(),
159
+ updated_at TIMESTAMPTZ DEFAULT NOW()
160
+ );
161
+ CREATE INDEX IF NOT EXISTS idx_recommendations_status ON agent_lab_recommendations (status, type);
162
+
163
+ -- ─── Agent Lab: Audit Log ─────────────────────────────────
164
+ CREATE TABLE IF NOT EXISTS agent_lab_changes (
165
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
166
+ recommendation_id UUID REFERENCES agent_lab_recommendations(id),
167
+ change_type VARCHAR(20) CHECK (change_type IN ('auto', 'approved')),
168
+ target_agent VARCHAR(100),
169
+ description TEXT,
170
+ before_state JSONB,
171
+ after_state JSONB,
172
+ created_at TIMESTAMPTZ DEFAULT NOW()
173
+ );
174
+
175
+ -- ─── Triggers: updated_at automático ─────────────────────
176
+ CREATE OR REPLACE FUNCTION update_updated_at()
177
+ RETURNS TRIGGER AS $$
178
+ BEGIN
179
+ NEW.updated_at = NOW();
180
+ RETURN NEW;
181
+ END;
182
+ $$ LANGUAGE plpgsql;
183
+
184
+ CREATE TRIGGER trg_agents_updated_at
185
+ BEFORE UPDATE ON agents
186
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at();
187
+
188
+ CREATE TRIGGER trg_sessions_updated_at
189
+ BEFORE UPDATE ON sessions
190
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at();
191
+
192
+ CREATE TRIGGER trg_recommendations_updated_at
193
+ BEFORE UPDATE ON agent_lab_recommendations
194
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at();
database/supabase_client.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ supabase_client.py
3
+ ------------------
4
+ Cliente Supabase singleton. Se inicializa una sola vez con las variables de entorno.
5
+ """
6
+
7
+ import os
8
+ import logging
9
+ from functools import lru_cache
10
+ from supabase import create_client, Client
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @lru_cache(maxsize=1)
16
+ def get_supabase() -> Client:
17
+ """
18
+ Devuelve el cliente Supabase (singleton).
19
+ Llama a esta función en cualquier lugar del proyecto para acceder a la DB.
20
+ """
21
+ url = os.environ.get("SUPABASE_URL")
22
+ key = os.environ.get("SUPABASE_KEY")
23
+
24
+ if not url or not key:
25
+ raise RuntimeError(
26
+ "Faltan variables de entorno SUPABASE_URL y/o SUPABASE_KEY. "
27
+ "Copia .env.example a .env y completa los valores."
28
+ )
29
+
30
+ client = create_client(url, key)
31
+ logger.info("[Supabase] cliente inicializado correctamente")
32
+ return client
requirements.txt ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core
2
+ anthropic>=0.40.0
3
+ gradio>=4.44.0
4
+ fastapi>=0.115.0
5
+ uvicorn>=0.32.0
6
+ python-dotenv>=1.0.0
7
+
8
+ # Telegram
9
+ python-telegram-bot>=21.0.0
10
+
11
+ # Supabase
12
+ supabase>=2.9.0
13
+
14
+ # Data & ML
15
+ pandas>=2.2.0
16
+ numpy>=1.26.0
17
+ scikit-learn>=1.5.0
18
+ statsmodels>=0.14.0
19
+ openpyxl>=3.1.0 # Lectura de archivos Excel (futuro: datos reales del negocio)
20
+ xlrd>=2.0.1
21
+
22
+ # HTTP
23
+ httpx>=0.27.0
24
+ aiohttp>=3.10.0
25
+
26
+ # Utilidades
27
+ python-dateutil>=2.9.0
28
+ pydantic>=2.9.0
telegram/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from telegram.bot import TelegramBot
2
+
3
+ __all__ = ["TelegramBot"]
telegram/bot.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ bot.py
3
+ ------
4
+ Integración con Telegram via webhook.
5
+ El Space de HuggingFace expone el endpoint POST /telegram-webhook.
6
+ Telegram envía cada mensaje a esa URL.
7
+
8
+ Setup inicial (una sola vez):
9
+ python telegram/bot.py --setup
10
+
11
+ Esto registra el webhook en Telegram apuntando a tu Space URL.
12
+ """
13
+
14
+ import os
15
+ import logging
16
+ import asyncio
17
+ import argparse
18
+ from typing import Any
19
+
20
+ import httpx
21
+ from telegram import Update, Bot
22
+ from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
27
+ WEBHOOK_URL = os.environ.get("TELEGRAM_WEBHOOK_URL", "")
28
+
29
+
30
+ class TelegramBot:
31
+ """
32
+ Maneja mensajes de Telegram y los enruta al orquestador de agentes.
33
+ """
34
+
35
+ def __init__(self, orchestrator=None, agent_map: dict | None = None):
36
+ """
37
+ Args:
38
+ orchestrator: instancia de Orchestrator para clasificar intents
39
+ agent_map: dict {nombre_agente: instancia_agente}
40
+ """
41
+ self.orchestrator = orchestrator
42
+ self.agent_map = agent_map or {}
43
+ self.sessions: dict[int, list] = {} # user_id → historial de conversación
44
+
45
+ async def process_update(self, update_data: dict) -> str | None:
46
+ """
47
+ Procesa un update recibido desde el webhook de Telegram.
48
+ Devuelve la respuesta generada o None si no hay nada que responder.
49
+ """
50
+ try:
51
+ bot = Bot(token=TOKEN)
52
+ update = Update.de_json(update_data, bot)
53
+
54
+ if not update.message or not update.message.text:
55
+ return None
56
+
57
+ user_id = update.message.from_user.id
58
+ user_name = update.message.from_user.first_name or "usuario"
59
+ text = update.message.text.strip()
60
+
61
+ logger.info(f"[TelegramBot] mensaje de {user_name} ({user_id}): {text[:60]}")
62
+
63
+ # Comandos especiales
64
+ if text.startswith("/"):
65
+ response = await self._handle_command(text, user_id, user_name)
66
+ else:
67
+ response = await self._handle_message(text, user_id)
68
+
69
+ if response:
70
+ await bot.send_message(
71
+ chat_id=update.message.chat_id,
72
+ text=response,
73
+ parse_mode="Markdown",
74
+ )
75
+
76
+ return response
77
+
78
+ except Exception as e:
79
+ logger.error(f"[TelegramBot] error procesando update: {e}")
80
+ return None
81
+
82
+ async def _handle_message(self, text: str, user_id: int) -> str:
83
+ """Enruta el mensaje al agente correcto y devuelve la respuesta."""
84
+ if not self.orchestrator:
85
+ return "Sistema no inicializado. Contacta al administrador."
86
+
87
+ # Clasificar intent
88
+ agent_name = self.orchestrator.classify(text)
89
+ agent = self.agent_map.get(agent_name)
90
+
91
+ if not agent:
92
+ return f"Agente '{agent_name}' no disponible en este momento."
93
+
94
+ # Obtener/crear historial de sesión
95
+ session_history = self.sessions.get(user_id, [])
96
+
97
+ # Ejecutar agente
98
+ response, updated_history = agent.run(text, session_history)
99
+
100
+ # Guardar historial (últimos 20 mensajes para no crecer infinito)
101
+ self.sessions[user_id] = updated_history[-20:]
102
+
103
+ return response
104
+
105
+ async def _handle_command(self, command: str, user_id: int, user_name: str) -> str:
106
+ """Maneja comandos especiales de Telegram."""
107
+ cmd = command.split()[0].lower()
108
+
109
+ if cmd == "/start":
110
+ return (
111
+ f"Hola {user_name}! 👋\n\n"
112
+ "Soy el monitor de ML para aceites comestibles.\n\n"
113
+ "Puedo ayudarte con:\n"
114
+ "• Precios del aceite de palma\n"
115
+ "• Forecasting de demanda y ventas\n"
116
+ "• Estado del sistema de agentes\n\n"
117
+ "¿Qué quieres consultar?"
118
+ )
119
+
120
+ elif cmd == "/help":
121
+ return (
122
+ "*Comandos disponibles:*\n\n"
123
+ "/start — Bienvenida\n"
124
+ "/help — Esta ayuda\n"
125
+ "/status — Estado del sistema\n"
126
+ "/pending — Recomendaciones pendientes de aprobación\n"
127
+ "/reset — Limpiar historial de conversación\n\n"
128
+ "*Consultas en lenguaje natural:*\n"
129
+ "• ¿Cuál es el precio actual del aceite de palma?\n"
130
+ "• ¿Cómo están las ventas del último mes?\n"
131
+ "• ¿Hay alguna anomalía en el modelo?"
132
+ )
133
+
134
+ elif cmd == "/status":
135
+ stats = self.orchestrator.get_stats() if self.orchestrator else {}
136
+ lines = ["*Estado del sistema:* ✅\n"]
137
+ lines.append("*Agentes activos:*")
138
+ for name, count in stats.items():
139
+ lines.append(f" • {name}: {count} consultas")
140
+ return "\n".join(lines)
141
+
142
+ elif cmd == "/pending":
143
+ return await self._get_pending_recommendations()
144
+
145
+ elif cmd == "/reset":
146
+ self.sessions.pop(user_id, None)
147
+ return "Historial de conversación limpiado. ¿En qué puedo ayudarte?"
148
+
149
+ elif cmd.startswith("/approve_"):
150
+ rec_id = cmd.replace("/approve_", "")
151
+ return await self._approve_recommendation(rec_id)
152
+
153
+ elif cmd.startswith("/reject_"):
154
+ rec_id = cmd.replace("/reject_", "")
155
+ return await self._reject_recommendation(rec_id)
156
+
157
+ return f"Comando '{cmd}' no reconocido. Usa /help para ver los disponibles."
158
+
159
+ async def _get_pending_recommendations(self) -> str:
160
+ """Muestra las recomendaciones proactivas pendientes de aprobación."""
161
+ try:
162
+ from database.supabase_client import get_supabase
163
+ db = get_supabase()
164
+ result = (
165
+ db.table("agent_lab_recommendations")
166
+ .select("id, title, target_agent, priority, created_at")
167
+ .eq("status", "pending")
168
+ .eq("type", "proactive")
169
+ .order("priority", desc=True)
170
+ .limit(10)
171
+ .execute()
172
+ )
173
+
174
+ if not result.data:
175
+ return "No hay recomendaciones pendientes de aprobación."
176
+
177
+ lines = [f"*Recomendaciones pendientes ({len(result.data)}):*\n"]
178
+ for rec in result.data:
179
+ short_id = rec["id"][:8]
180
+ lines.append(
181
+ f"📋 *{rec['title']}*\n"
182
+ f" Agente: {rec['target_agent']} | Prioridad: {rec['priority']}\n"
183
+ f" /approve_{short_id} | /reject_{short_id}\n"
184
+ )
185
+ return "\n".join(lines)
186
+
187
+ except Exception as e:
188
+ return f"Error consultando recomendaciones: {e}"
189
+
190
+ async def _approve_recommendation(self, rec_id_prefix: str) -> str:
191
+ try:
192
+ from database.supabase_client import get_supabase
193
+ db = get_supabase()
194
+ result = (
195
+ db.table("agent_lab_recommendations")
196
+ .select("id, title")
197
+ .ilike("id", f"{rec_id_prefix}%")
198
+ .execute()
199
+ )
200
+ if not result.data:
201
+ return f"Recomendación {rec_id_prefix} no encontrada."
202
+
203
+ rec = result.data[0]
204
+ db.table("agent_lab_recommendations").update(
205
+ {"status": "approved"}
206
+ ).eq("id", rec["id"]).execute()
207
+
208
+ return f"✅ Recomendación aprobada: *{rec['title']}*\nSe aplicará en el próximo ciclo de Agent Lab."
209
+ except Exception as e:
210
+ return f"Error aprobando recomendación: {e}"
211
+
212
+ async def _reject_recommendation(self, rec_id_prefix: str) -> str:
213
+ try:
214
+ from database.supabase_client import get_supabase
215
+ db = get_supabase()
216
+ result = (
217
+ db.table("agent_lab_recommendations")
218
+ .select("id, title")
219
+ .ilike("id", f"{rec_id_prefix}%")
220
+ .execute()
221
+ )
222
+ if not result.data:
223
+ return f"Recomendación {rec_id_prefix} no encontrada."
224
+
225
+ rec = result.data[0]
226
+ db.table("agent_lab_recommendations").update(
227
+ {"status": "rejected"}
228
+ ).eq("id", rec["id"]).execute()
229
+
230
+ return f"❌ Recomendación rechazada: *{rec['title']}*"
231
+ except Exception as e:
232
+ return f"Error rechazando recomendación: {e}"
233
+
234
+
235
+ async def setup_webhook():
236
+ """Registra el webhook de Telegram apuntando al Space de HuggingFace."""
237
+ if not TOKEN or not WEBHOOK_URL:
238
+ print("ERROR: Configura TELEGRAM_BOT_TOKEN y TELEGRAM_WEBHOOK_URL en .env")
239
+ return
240
+
241
+ webhook_endpoint = f"{WEBHOOK_URL.rstrip('/')}/telegram-webhook"
242
+ url = f"https://api.telegram.org/bot{TOKEN}/setWebhook"
243
+
244
+ async with httpx.AsyncClient() as client:
245
+ resp = await client.post(url, json={"url": webhook_endpoint})
246
+ data = resp.json()
247
+
248
+ if data.get("ok"):
249
+ print(f"✅ Webhook registrado: {webhook_endpoint}")
250
+ else:
251
+ print(f"❌ Error: {data}")
252
+
253
+
254
+ if __name__ == "__main__":
255
+ parser = argparse.ArgumentParser()
256
+ parser.add_argument("--setup", action="store_true", help="Registrar webhook en Telegram")
257
+ args = parser.parse_args()
258
+
259
+ if args.setup:
260
+ asyncio.run(setup_webhook())
tools/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from tools.sub_agent_tool import create_sub_agent_tool, SubAgentRunner
2
+
3
+ __all__ = ["create_sub_agent_tool", "SubAgentRunner"]
tools/sub_agent_tool.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ sub_agent_tool.py
3
+ -----------------
4
+ Patrón subAgentTool(): permite que cualquier agente delegue tareas a otro agente
5
+ como si fuera una llamada a herramienta. Es la base de la jerarquía de agentes.
6
+
7
+ Uso:
8
+ # Definir la herramienta para el agente padre
9
+ tool_def = create_sub_agent_tool(
10
+ agent_name="price_monitor",
11
+ description="Monitorea el modelo de forecasting de precios de aceite de palma."
12
+ )
13
+
14
+ # Crear el handler que ejecuta el sub-agente
15
+ runner = SubAgentRunner(agent_name="price_monitor", system_prompt=PRICE_MONITOR_PROMPT)
16
+ handler = runner.as_handler()
17
+ """
18
+
19
+ import logging
20
+ from typing import Callable
21
+ from agents.base_agent import run_agent, MODEL_SMART
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def create_sub_agent_tool(agent_name: str, description: str) -> dict:
27
+ """
28
+ Crea la definición de herramienta Anthropic para invocar un sub-agente.
29
+
30
+ Args:
31
+ agent_name: Nombre del agente (usado como nombre de la tool).
32
+ description: Descripción de lo que hace el agente (el modelo usará esto para decidir cuándo llamarlo).
33
+
34
+ Returns:
35
+ Definición de herramienta en formato Anthropic.
36
+ """
37
+ return {
38
+ "name": f"call_{agent_name}",
39
+ "description": description,
40
+ "input_schema": {
41
+ "type": "object",
42
+ "properties": {
43
+ "task": {
44
+ "type": "string",
45
+ "description": "Descripción detallada de la tarea a delegar al sub-agente.",
46
+ }
47
+ },
48
+ "required": ["task"],
49
+ },
50
+ }
51
+
52
+
53
+ class SubAgentRunner:
54
+ """
55
+ Encapsula un sub-agente listo para ser llamado como handler de herramienta.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ agent_name: str,
61
+ system_prompt: str,
62
+ tools: list[dict] | None = None,
63
+ tool_handlers: dict[str, Callable] | None = None,
64
+ model: str = MODEL_SMART,
65
+ ):
66
+ self.agent_name = agent_name
67
+ self.system_prompt = system_prompt
68
+ self.tools = tools or []
69
+ self.tool_handlers = tool_handlers or {}
70
+ self.model = model
71
+
72
+ def as_handler(self) -> Callable[[str], str]:
73
+ """
74
+ Devuelve una función que puede usarse como handler en tool_handlers del agente padre.
75
+ """
76
+ def handler(task: str) -> str:
77
+ logger.info(f"[SubAgentRunner] llamando a '{self.agent_name}' con tarea: {task[:80]}...")
78
+ response, _ = run_agent(
79
+ system_prompt=self.system_prompt,
80
+ user_message=task,
81
+ tools=self.tools,
82
+ tool_handlers=self.tool_handlers,
83
+ model=self.model,
84
+ )
85
+ return response
86
+
87
+ handler.__name__ = f"call_{self.agent_name}"
88
+ return handler