Initial commit
This commit is contained in:
commit
f0d7f65c1a
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pip install *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
.env.example
Normal file
13
.env.example
Normal file
@ -0,0 +1,13 @@
|
||||
# Airtable
|
||||
AIRTABLE_TOKEN=your_airtable_personal_access_token
|
||||
AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX
|
||||
|
||||
# Google Ads
|
||||
GOOGLE_ADS_DEVELOPER_TOKEN=your_developer_token
|
||||
GOOGLE_ADS_CLIENT_ID=your_oauth_client_id
|
||||
GOOGLE_ADS_CLIENT_SECRET=your_oauth_client_secret
|
||||
GOOGLE_ADS_REFRESH_TOKEN=your_refresh_token
|
||||
GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890
|
||||
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-XXXXXXXXXXXXXXXXXXXX
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
78
README.md
Normal file
78
README.md
Normal file
@ -0,0 +1,78 @@
|
||||
# Leads Optimizer — Formación
|
||||
|
||||
Agente de optimización automática de campañas Google Ads para generación de leads de formación.
|
||||
Cruza datos de Airtable (leads reales) con métricas de Google Ads y decide ajustes de presupuesto.
|
||||
|
||||
---
|
||||
|
||||
## Campos requeridos en Airtable
|
||||
|
||||
### Tabla: "Google Ads Campaigns"
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------------------|---------|--------------------------------------|
|
||||
| Curso | Text | Nombre del curso |
|
||||
| GoogleCampaignID | Number | ID de campaña en Google Ads |
|
||||
| PPL | Number | Precio por lead (€) |
|
||||
| CapTotalMes | Number | Capping mensual de leads |
|
||||
| CPAMaximo | Number | CPA máximo tolerable (€) |
|
||||
| Activa | Boolean | TRUE para incluir en el análisis |
|
||||
|
||||
### Tabla: "Leads Lake"
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------------------|---------|--------------------------------------|
|
||||
| GoogleCampaignID | Text | ID de campaña de origen |
|
||||
| FechaEntrada | Date | Fecha del lead (formato YYYY-MM-DD) |
|
||||
|
||||
---
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
```bash
|
||||
export AIRTABLE_TOKEN=pat_xxxxxxxxxxxx
|
||||
export AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX
|
||||
|
||||
export GOOGLE_ADS_DEVELOPER_TOKEN=xxxx
|
||||
export GOOGLE_ADS_CLIENT_ID=xxxx.apps.googleusercontent.com
|
||||
export GOOGLE_ADS_CLIENT_SECRET=xxxx
|
||||
export GOOGLE_ADS_REFRESH_TOKEN=xxxx
|
||||
export GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 # sin guiones
|
||||
|
||||
export ANTHROPIC_API_KEY=sk-ant-xxxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Instalación y ejecución
|
||||
|
||||
```bash
|
||||
# Instalar dependencias
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Ejecutar en modo DRY RUN (recomendado para empezar)
|
||||
# DRY_RUN = True en config.py → solo muestra decisiones, no aplica cambios
|
||||
python run.py
|
||||
|
||||
# Cuando estés seguro, cambiar DRY_RUN = False en config.py
|
||||
python run.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lógica de urgencia
|
||||
|
||||
| Urgencia | Condición | Acción típica |
|
||||
|-------------|--------------------------------------------------------|-----------------------|
|
||||
| PAUSAR | leads >= capping | Pausa campaña |
|
||||
| SPRINT | ritmo muy atrasado + quedan ≤ 5 días | +30-50% presupuesto |
|
||||
| ACELERAR | ritmo atrasado > 15 puntos vs ratio del mes | +10-25% presupuesto |
|
||||
| FRENAR | ritmo adelantado > 15 puntos vs ratio del mes | -10-25% presupuesto |
|
||||
| EN_RITMO | dentro del margen esperado | Mantener |
|
||||
|
||||
---
|
||||
|
||||
## Automatización con cron
|
||||
|
||||
```bash
|
||||
# Ejecutar cada día a las 8:00
|
||||
0 8 * * * cd /ruta/leads-optimizer && python run.py >> logs/optimizer.log 2>&1
|
||||
```
|
||||
67
agent.py
Normal file
67
agent.py
Normal file
@ -0,0 +1,67 @@
|
||||
import json
|
||||
import anthropic
|
||||
import config
|
||||
|
||||
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
|
||||
|
||||
SYSTEM_PROMPT = """
|
||||
Eres un agente experto en optimización de campañas de generación de leads para centros de formación.
|
||||
Cada campaña corresponde a un curso concreto con un PPL (precio por lead) fijo acordado con los centros compradores.
|
||||
|
||||
MODELO DE NEGOCIO:
|
||||
- Ingreso = leads_entregados × PPL
|
||||
- Margen = (Ingreso - Gasto Google Ads) / Ingreso
|
||||
- El objetivo es maximizar leads dentro del capping mensual manteniendo margen positivo.
|
||||
- El CPA máximo ya refleja el margen mínimo aceptable.
|
||||
|
||||
REGLAS DE DECISIÓN:
|
||||
1. urgencia=PAUSAR → accion=PAUSAR siempre. El capping está lleno, seguir gastando destruye margen.
|
||||
2. urgencia=SPRINT → accion=AUMENTAR_PRESUPUESTO con parametro entre 1.3 y 1.5. Quedan pocos días y leads por entregar.
|
||||
3. urgencia=ACELERAR y campaña rentable → accion=AUMENTAR_PRESUPUESTO con parametro entre 1.1 y 1.25.
|
||||
4. urgencia=ACELERAR y campaña NO rentable → accion=MANTENER o revisar keywords (no gastar más si no convierte).
|
||||
5. urgencia=FRENAR → accion=REDUCIR_PRESUPUESTO con parametro entre 0.75 y 0.9.
|
||||
6. urgencia=EN_RITMO y rentable → accion=MANTENER.
|
||||
7. urgencia=EN_RITMO y NO rentable → accion=REDUCIR_PRESUPUESTO con parametro 0.85.
|
||||
8. alerta_tracking=true → añadir alerta sobre discrepancia de tracking aunque la acción sea otra.
|
||||
|
||||
Devuelve ÚNICAMENTE un JSON válido con esta estructura exacta, sin texto adicional ni markdown:
|
||||
{
|
||||
"accion": "PAUSAR | REDUCIR_PRESUPUESTO | AUMENTAR_PRESUPUESTO | MANTENER",
|
||||
"parametro": 1.0,
|
||||
"nuevo_budget_diario": 0.0,
|
||||
"justificacion": "explicación breve en español",
|
||||
"alerta": "texto si hay algo crítico, null si no hay",
|
||||
"confianza": 0.0
|
||||
}
|
||||
|
||||
El campo nuevo_budget_diario = budget_diario_actual × parametro (calcula tú el valor final).
|
||||
"""
|
||||
|
||||
|
||||
def decide(analysis: dict) -> dict:
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=400,
|
||||
system=SYSTEM_PROMPT,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Analiza esta campaña y devuelve la decisión en JSON:\n\n"
|
||||
f"{json.dumps(analysis, ensure_ascii=False, indent=2)}"
|
||||
)
|
||||
}]
|
||||
)
|
||||
raw = response.content[0].text.strip()
|
||||
clean = raw.replace("```json", "").replace("```", "").strip()
|
||||
try:
|
||||
return json.loads(clean)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback seguro si el modelo no devuelve JSON limpio
|
||||
return {
|
||||
"accion": "MANTENER",
|
||||
"parametro": 1.0,
|
||||
"nuevo_budget_diario": analysis.get("budget_diario_actual", 0),
|
||||
"justificacion": "Error parseando respuesta del agente. Revisión manual recomendada.",
|
||||
"alerta": f"JSON inválido recibido: {raw[:200]}",
|
||||
"confianza": 0.0,
|
||||
}
|
||||
43
airtable_client.py
Normal file
43
airtable_client.py
Normal file
@ -0,0 +1,43 @@
|
||||
from pyairtable import Api
|
||||
from datetime import datetime
|
||||
import config
|
||||
|
||||
|
||||
class AirtableClient:
|
||||
def __init__(self):
|
||||
self.api = Api(config.AIRTABLE_TOKEN)
|
||||
self.leads = self.api.table(config.AIRTABLE_BASE_ID, config.LEADS_TABLE)
|
||||
self.campaigns = self.api.table(config.AIRTABLE_BASE_ID, config.CAMPAIGNS_TABLE)
|
||||
|
||||
def get_active_campaigns(self) -> list[dict]:
|
||||
"""Lee todas las campañas activas desde 'Google Ads Campaigns'."""
|
||||
records = self.campaigns.all(formula="{Activa}=TRUE()")
|
||||
result = []
|
||||
for r in records:
|
||||
f = r["fields"]
|
||||
result.append({
|
||||
"airtable_id": r["id"],
|
||||
"curso": f.get("Campaign Name", "Sin nombre"),
|
||||
"google_campaign_id": str(f.get("CampaignID", "")).strip(),
|
||||
"ppl": float(f.get("PPL", 0)),
|
||||
"capping_mensual": int(f.get("CapTotalMes", 0)),
|
||||
"cpa_maximo": float(f.get("CPAMaximo", 0)),
|
||||
})
|
||||
return [c for c in result if c["google_campaign_id"]] # descartar sin ID
|
||||
|
||||
def get_leads_this_month(self, google_campaign_id: str) -> int:
|
||||
"""
|
||||
Cuenta todos los leads del mes actual para una campaña.
|
||||
Todos los leads en Airtable están validados, sin filtro de estado.
|
||||
"""
|
||||
now = datetime.now()
|
||||
mes_inicio = f"{now.year}-{now.month:02d}-01"
|
||||
|
||||
formula = (
|
||||
f"AND("
|
||||
f"{{GoogleCampaignID}}='{google_campaign_id}',"
|
||||
f"{{Fecha}}>='{mes_inicio}'"
|
||||
f")"
|
||||
)
|
||||
records = self.leads.all(formula=formula)
|
||||
return len(records)
|
||||
64
analyzer.py
Normal file
64
analyzer.py
Normal file
@ -0,0 +1,64 @@
|
||||
from datetime import datetime
|
||||
import calendar
|
||||
|
||||
|
||||
def analyze(campaign_config: dict, leads_entregados: int, ads_metrics: dict) -> dict:
|
||||
now = datetime.now()
|
||||
dias_mes = calendar.monthrange(now.year, now.month)[1]
|
||||
dia_actual = now.day
|
||||
ratio_mes = dia_actual / dias_mes
|
||||
|
||||
capping = campaign_config["capping_mensual"]
|
||||
ppl = campaign_config["ppl"]
|
||||
cpa_max = campaign_config["cpa_maximo"]
|
||||
gasto = ads_metrics.get("cost", 0)
|
||||
conversiones_google = ads_metrics.get("conversions", 0)
|
||||
|
||||
ratio_leads = leads_entregados / capping if capping > 0 else 0
|
||||
cpa_actual = gasto / leads_entregados if leads_entregados > 0 else 0
|
||||
revenue = leads_entregados * ppl
|
||||
margen = (revenue - gasto) / revenue if revenue > 0 else 0
|
||||
leads_restantes = capping - leads_entregados
|
||||
dias_restantes = dias_mes - dia_actual
|
||||
ritmo = ratio_leads - ratio_mes # positivo = adelantado, negativo = atrasado
|
||||
|
||||
# Urgencia
|
||||
if ratio_leads >= 1.0:
|
||||
urgencia = "PAUSAR"
|
||||
elif ratio_leads < ratio_mes - 0.15 and dias_restantes <= 5:
|
||||
urgencia = "SPRINT"
|
||||
elif ritmo < -0.15:
|
||||
urgencia = "ACELERAR"
|
||||
elif ritmo > 0.15:
|
||||
urgencia = "FRENAR"
|
||||
else:
|
||||
urgencia = "EN_RITMO"
|
||||
|
||||
# Discrepancia entre leads Airtable y conversiones Google
|
||||
discrepancia = abs(conversiones_google - leads_entregados)
|
||||
|
||||
return {
|
||||
"curso": campaign_config["curso"],
|
||||
"campaign_id": campaign_config["google_campaign_id"],
|
||||
"ppl": ppl,
|
||||
"cpa_maximo": cpa_max,
|
||||
"capping": capping,
|
||||
"leads_entregados": leads_entregados,
|
||||
"leads_restantes": leads_restantes,
|
||||
"dias_restantes": dias_restantes,
|
||||
"ratio_leads": round(ratio_leads, 3),
|
||||
"ratio_mes": round(ratio_mes, 3),
|
||||
"ritmo": round(ritmo, 3),
|
||||
"urgencia": urgencia,
|
||||
"cpa_actual": round(cpa_actual, 2),
|
||||
"rentable": cpa_actual <= cpa_max if cpa_actual > 0 else True,
|
||||
"margen": round(margen, 3),
|
||||
"revenue_estimado": round(revenue, 2),
|
||||
"gasto_acumulado": round(gasto, 2),
|
||||
"budget_diario_actual": ads_metrics.get("budget_daily", 0),
|
||||
"ctr": ads_metrics.get("ctr", 0),
|
||||
"clicks": ads_metrics.get("clicks", 0),
|
||||
"conversiones_google": conversiones_google,
|
||||
"discrepancia_tracking": discrepancia,
|
||||
"alerta_tracking": discrepancia > 10,
|
||||
}
|
||||
24
config.py
Normal file
24
config.py
Normal file
@ -0,0 +1,24 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Airtable
|
||||
AIRTABLE_TOKEN = os.environ["AIRTABLE_TOKEN"]
|
||||
AIRTABLE_BASE_ID = os.environ["AIRTABLE_BASE_ID"]
|
||||
LEADS_TABLE = "Leads Lake"
|
||||
CAMPAIGNS_TABLE = "Google Ads Campaigns"
|
||||
|
||||
# Google Ads
|
||||
GOOGLE_ADS_DEVELOPER_TOKEN = os.environ["GOOGLE_ADS_DEVELOPER_TOKEN"]
|
||||
GOOGLE_ADS_CLIENT_ID = os.environ["GOOGLE_ADS_CLIENT_ID"]
|
||||
GOOGLE_ADS_CLIENT_SECRET = os.environ["GOOGLE_ADS_CLIENT_SECRET"]
|
||||
GOOGLE_ADS_REFRESH_TOKEN = os.environ["GOOGLE_ADS_REFRESH_TOKEN"]
|
||||
GOOGLE_ADS_LOGIN_CUSTOMER_ID = os.environ["GOOGLE_ADS_LOGIN_CUSTOMER_ID"]
|
||||
|
||||
# Anthropic
|
||||
ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"]
|
||||
|
||||
# Operación
|
||||
DRY_RUN = True # True = solo sugiere, no aplica cambios en Google Ads
|
||||
MARGEN_MINIMO = 0.40 # 40% mínimo de margen sobre PPL
|
||||
36
get_refresh_token.py
Normal file
36
get_refresh_token.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""
|
||||
Ejecuta este script una sola vez para obtener el Refresh Token de Google Ads.
|
||||
Requiere tener CLIENT_ID y CLIENT_SECRET en el .env.
|
||||
|
||||
Uso:
|
||||
python get_refresh_token.py
|
||||
"""
|
||||
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
load_dotenv()
|
||||
|
||||
CLIENT_ID = os.environ["GOOGLE_ADS_CLIENT_ID"]
|
||||
CLIENT_SECRET = os.environ["GOOGLE_ADS_CLIENT_SECRET"]
|
||||
|
||||
SCOPES = ["https://www.googleapis.com/auth/adwords"]
|
||||
|
||||
client_config = {
|
||||
"installed": {
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": CLIENT_SECRET,
|
||||
"redirect_uris": ["urn:ietf:wg:oauth:2.0:oob", "http://localhost"],
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
}
|
||||
}
|
||||
|
||||
flow = InstalledAppFlow.from_client_config(client_config, scopes=SCOPES)
|
||||
credentials = flow.run_local_server(port=0)
|
||||
|
||||
print("\n✓ Refresh Token obtenido:")
|
||||
print(credentials.refresh_token)
|
||||
print("\nPégalo en tu .env como:")
|
||||
print(f"GOOGLE_ADS_REFRESH_TOKEN={credentials.refresh_token}")
|
||||
108
google_ads_client.py
Normal file
108
google_ads_client.py
Normal file
@ -0,0 +1,108 @@
|
||||
from google.ads.googleads.client import GoogleAdsClient as GAdsClient
|
||||
from google.ads.googleads.errors import GoogleAdsException
|
||||
import config
|
||||
|
||||
|
||||
class GoogleAdsClient:
|
||||
def __init__(self):
|
||||
self.client = GAdsClient.load_from_dict({
|
||||
"developer_token": config.GOOGLE_ADS_DEVELOPER_TOKEN,
|
||||
"client_id": config.GOOGLE_ADS_CLIENT_ID,
|
||||
"client_secret": config.GOOGLE_ADS_CLIENT_SECRET,
|
||||
"refresh_token": config.GOOGLE_ADS_REFRESH_TOKEN,
|
||||
"login_customer_id": config.GOOGLE_ADS_LOGIN_CUSTOMER_ID,
|
||||
"use_proto_plus": True,
|
||||
})
|
||||
self.customer_id = config.GOOGLE_ADS_LOGIN_CUSTOMER_ID
|
||||
|
||||
def get_campaign_metrics(self, campaign_id: str) -> dict:
|
||||
"""Métricas del mes en curso para una campaña concreta."""
|
||||
ga_service = self.client.get_service("GoogleAdsService")
|
||||
query = f"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign_budget.amount_micros,
|
||||
campaign_budget.resource_name,
|
||||
metrics.cost_micros,
|
||||
metrics.conversions,
|
||||
metrics.clicks,
|
||||
metrics.impressions,
|
||||
metrics.ctr
|
||||
FROM campaign
|
||||
WHERE campaign.id = {campaign_id}
|
||||
AND segments.date DURING THIS_MONTH
|
||||
"""
|
||||
try:
|
||||
response = ga_service.search(customer_id=self.customer_id, query=query)
|
||||
for row in response:
|
||||
m = row.metrics
|
||||
return {
|
||||
"campaign_id": campaign_id,
|
||||
"name": row.campaign.name,
|
||||
"status": row.campaign.status.name,
|
||||
"budget_daily": row.campaign_budget.amount_micros / 1_000_000,
|
||||
"budget_resource_name": row.campaign_budget.resource_name,
|
||||
"cost": m.cost_micros / 1_000_000,
|
||||
"conversions": m.conversions,
|
||||
"clicks": m.clicks,
|
||||
"impressions": m.impressions,
|
||||
"ctr": round(m.ctr * 100, 2),
|
||||
}
|
||||
except GoogleAdsException as e:
|
||||
print(f" ❌ Error Google Ads para campaña {campaign_id}: {e}")
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def set_campaign_budget(self, budget_resource_name: str, new_daily_budget: float):
|
||||
"""Ajusta el presupuesto diario de una campaña."""
|
||||
if config.DRY_RUN:
|
||||
print(f" [DRY RUN] Nuevo presupuesto diario → {new_daily_budget:.2f}€")
|
||||
return True
|
||||
|
||||
try:
|
||||
budget_service = self.client.get_service("CampaignBudgetService")
|
||||
campaign_budget = self.client.get_type("CampaignBudget")
|
||||
campaign_budget.resource_name = budget_resource_name
|
||||
campaign_budget.amount_micros = int(new_daily_budget * 1_000_000)
|
||||
|
||||
operation = self.client.get_type("CampaignBudgetOperation")
|
||||
operation.update = campaign_budget
|
||||
operation.update_mask.paths.append("amount_micros")
|
||||
|
||||
budget_service.mutate_campaign_budgets(
|
||||
customer_id=self.customer_id,
|
||||
operations=[operation]
|
||||
)
|
||||
return True
|
||||
except GoogleAdsException as e:
|
||||
print(f" ❌ Error ajustando presupuesto: {e}")
|
||||
return False
|
||||
|
||||
def pause_campaign(self, campaign_id: str):
|
||||
"""Pausa una campaña."""
|
||||
if config.DRY_RUN:
|
||||
print(f" [DRY RUN] Pausar campaña {campaign_id}")
|
||||
return True
|
||||
|
||||
try:
|
||||
campaign_service = self.client.get_service("CampaignService")
|
||||
campaign = self.client.get_type("Campaign")
|
||||
campaign.resource_name = campaign_service.campaign_path(
|
||||
self.customer_id, campaign_id
|
||||
)
|
||||
campaign.status = self.client.enums.CampaignStatusEnum.PAUSED
|
||||
|
||||
operation = self.client.get_type("CampaignOperation")
|
||||
operation.update = campaign
|
||||
operation.update_mask.paths.append("status")
|
||||
|
||||
campaign_service.mutate_campaigns(
|
||||
customer_id=self.customer_id,
|
||||
operations=[operation]
|
||||
)
|
||||
return True
|
||||
except GoogleAdsException as e:
|
||||
print(f" ❌ Error pausando campaña: {e}")
|
||||
return False
|
||||
33
optimizer.py
Normal file
33
optimizer.py
Normal file
@ -0,0 +1,33 @@
|
||||
import config
|
||||
|
||||
|
||||
def apply_decision(campaign: dict, decision: dict, metrics: dict, gads_client) -> bool:
|
||||
"""
|
||||
Aplica la decisión del agente sobre Google Ads.
|
||||
En DRY_RUN solo imprime lo que haría.
|
||||
"""
|
||||
accion = decision.get("accion", "MANTENER")
|
||||
nuevo_budget = decision.get("nuevo_budget_diario", 0)
|
||||
budget_resource = metrics.get("budget_resource_name", "")
|
||||
|
||||
if accion == "PAUSAR":
|
||||
print(f" ⛔ ACCIÓN: Pausar campaña")
|
||||
return gads_client.pause_campaign(campaign["google_campaign_id"])
|
||||
|
||||
elif accion in ("AUMENTAR_PRESUPUESTO", "REDUCIR_PRESUPUESTO"):
|
||||
if nuevo_budget <= 0:
|
||||
print(f" ⚠️ Presupuesto calculado inválido ({nuevo_budget}), omitiendo.")
|
||||
return False
|
||||
print(f" 💰 ACCIÓN: {accion} → {nuevo_budget:.2f}€/día")
|
||||
if not budget_resource:
|
||||
print(f" ⚠️ Sin budget_resource_name, no se puede aplicar.")
|
||||
return False
|
||||
return gads_client.set_campaign_budget(budget_resource, nuevo_budget)
|
||||
|
||||
elif accion == "MANTENER":
|
||||
print(f" ✅ ACCIÓN: Mantener sin cambios")
|
||||
return True
|
||||
|
||||
else:
|
||||
print(f" ❓ Acción desconocida: {accion}")
|
||||
return False
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
anthropic==0.95.0
|
||||
pyairtable==3.3.0
|
||||
google-ads==30.0.0
|
||||
python-dotenv==1.2.2
|
||||
98
run.py
Normal file
98
run.py
Normal file
@ -0,0 +1,98 @@
|
||||
from airtable_client import AirtableClient
|
||||
from google_ads_client import GoogleAdsClient
|
||||
from analyzer import analyze
|
||||
from agent import decide
|
||||
from optimizer import apply_decision
|
||||
import config
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
ICONOS = {
|
||||
"PAUSAR": "⛔",
|
||||
"SPRINT": "🚀",
|
||||
"ACELERAR": "📈",
|
||||
"FRENAR": "📉",
|
||||
"EN_RITMO": "✅",
|
||||
}
|
||||
|
||||
|
||||
def run():
|
||||
print(f"\n{'='*55}")
|
||||
print(f" LEADS OPTIMIZER — {datetime.now().strftime('%d/%m/%Y %H:%M')}")
|
||||
print(f" Modo: {'DRY RUN (sin cambios)' if config.DRY_RUN else '⚡ PRODUCCIÓN'}")
|
||||
print(f"{'='*55}\n")
|
||||
|
||||
at = AirtableClient()
|
||||
gads = GoogleAdsClient()
|
||||
|
||||
campaigns = at.get_active_campaigns()
|
||||
print(f"→ {len(campaigns)} campañas activas encontradas\n")
|
||||
|
||||
resumen = []
|
||||
|
||||
for campaign in campaigns:
|
||||
cid = campaign["google_campaign_id"]
|
||||
print(f"{'─'*55}")
|
||||
print(f"📚 {campaign['curso']}")
|
||||
print(f" Campaign ID: {cid} | PPL: {campaign['ppl']}€ | Cap: {campaign['capping_mensual']} leads")
|
||||
|
||||
# 1. Leads reales desde Airtable
|
||||
leads = at.get_leads_this_month(cid)
|
||||
|
||||
# 2. Métricas de Google Ads
|
||||
metrics = gads.get_campaign_metrics(cid)
|
||||
if not metrics:
|
||||
print(f" ⚠️ Sin métricas en Google Ads, omitiendo.\n")
|
||||
continue
|
||||
|
||||
# 3. Análisis
|
||||
analysis = analyze(campaign, leads, metrics)
|
||||
icono = ICONOS.get(analysis["urgencia"], "❓")
|
||||
|
||||
print(f" Leads mes: {leads}/{campaign['capping_mensual']} "
|
||||
f"({analysis['ratio_leads']*100:.0f}% cap) | "
|
||||
f"Ratio mes: {analysis['ratio_mes']*100:.0f}%")
|
||||
print(f" CPA actual: {analysis['cpa_actual']}€ | "
|
||||
f"CPA máximo: {analysis['cpa_maximo']}€ | "
|
||||
f"Margen: {analysis['margen']*100:.0f}%")
|
||||
print(f" Urgencia: {icono} {analysis['urgencia']} | "
|
||||
f"Rentable: {'✅' if analysis['rentable'] else '❌'}")
|
||||
|
||||
if analysis["alerta_tracking"]:
|
||||
print(f" 🚨 ALERTA TRACKING: {analysis['discrepancia_tracking']} leads de diferencia "
|
||||
f"entre Airtable ({leads}) y Google Ads ({int(analysis['conversiones_google'])})")
|
||||
|
||||
# 4. Decisión del agente
|
||||
decision = decide(analysis)
|
||||
print(f" Decisión: {decision['accion']} "
|
||||
f"(confianza: {decision['confianza']*100:.0f}%)")
|
||||
print(f" Justificación: {decision['justificacion']}")
|
||||
|
||||
if decision.get("alerta"):
|
||||
print(f" 🚨 {decision['alerta']}")
|
||||
|
||||
# 5. Aplicar
|
||||
apply_decision(campaign, decision, metrics, gads)
|
||||
|
||||
resumen.append({
|
||||
"curso": campaign["curso"],
|
||||
"urgencia": analysis["urgencia"],
|
||||
"accion": decision["accion"],
|
||||
"leads": f"{leads}/{campaign['capping_mensual']}",
|
||||
"cpa": analysis["cpa_actual"],
|
||||
"margen": f"{analysis['margen']*100:.0f}%",
|
||||
})
|
||||
|
||||
print()
|
||||
|
||||
# Resumen final
|
||||
print(f"{'='*55}")
|
||||
print("RESUMEN FINAL")
|
||||
print(f"{'='*55}")
|
||||
for r in resumen:
|
||||
print(f" {r['curso'][:35]:<35} | {r['urgencia']:<12} | {r['accion']}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Loading…
x
Reference in New Issue
Block a user