Spaces:
Sleeping
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 +14 -0
- .github/workflows/daily_research.yml +32 -0
- .gitignore +37 -0
- CLAUDE.md +81 -0
- agents/__init__.py +4 -0
- agents/agent_lab.py +305 -0
- agents/base_agent.py +108 -0
- agents/orchestrator.py +160 -0
- agents/specialists/__init__.py +4 -0
- agents/specialists/demand_monitor.py +244 -0
- agents/specialists/price_monitor.py +197 -0
- app.py +236 -0
- cron/__init__.py +0 -0
- cron/research_pipeline.py +247 -0
- data/processed/.gitkeep +0 -0
- data/raw/.gitkeep +0 -0
- database/__init__.py +3 -0
- database/schema.sql +194 -0
- database/supabase_client.py +32 -0
- requirements.txt +28 -0
- telegram/__init__.py +3 -0
- telegram/bot.py +260 -0
- tools/__init__.py +3 -0
- tools/sub_agent_tool.py +88 -0
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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>`
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from agents.base_agent import run_agent
|
| 2 |
+
from agents.orchestrator import Orchestrator
|
| 3 |
+
|
| 4 |
+
__all__ = ["run_agent", "Orchestrator"]
|
|
@@ -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
|
|
@@ -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()
|
|
@@ -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)
|
|
@@ -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"]
|
|
@@ -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 |
+
)
|
|
@@ -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 |
+
)
|
|
@@ -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 |
+
)
|
|
File without changes
|
|
@@ -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))
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from database.supabase_client import get_supabase
|
| 2 |
+
|
| 3 |
+
__all__ = ["get_supabase"]
|
|
@@ -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();
|
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from telegram.bot import TelegramBot
|
| 2 |
+
|
| 3 |
+
__all__ = ["TelegramBot"]
|
|
@@ -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())
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from tools.sub_agent_tool import create_sub_agent_tool, SubAgentRunner
|
| 2 |
+
|
| 3 |
+
__all__ = ["create_sub_agent_tool", "SubAgentRunner"]
|
|
@@ -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
|