From f0d7f65c1a652c7db5411fa423ff8a13f5e9f16d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Manuel=20G=C3=B3mez?= Date: Fri, 17 Apr 2026 12:53:25 +0200 Subject: [PATCH] Initial commit --- .claude/settings.local.json | 7 +++ .env.example | 13 +++++ .gitignore | 3 + README.md | 78 ++++++++++++++++++++++++++ agent.py | 67 ++++++++++++++++++++++ airtable_client.py | 43 ++++++++++++++ analyzer.py | 64 +++++++++++++++++++++ config.py | 24 ++++++++ get_refresh_token.py | 36 ++++++++++++ google_ads_client.py | 108 ++++++++++++++++++++++++++++++++++++ optimizer.py | 33 +++++++++++ requirements.txt | 4 ++ run.py | 98 ++++++++++++++++++++++++++++++++ 13 files changed, 578 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 agent.py create mode 100644 airtable_client.py create mode 100644 analyzer.py create mode 100644 config.py create mode 100644 get_refresh_token.py create mode 100644 google_ads_client.py create mode 100644 optimizer.py create mode 100644 requirements.txt create mode 100644 run.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..aa182c9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(pip install *)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1bbfb01 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cff5543 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..2bdbd28 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..13cc6e4 --- /dev/null +++ b/agent.py @@ -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, + } diff --git a/airtable_client.py b/airtable_client.py new file mode 100644 index 0000000..6de3b95 --- /dev/null +++ b/airtable_client.py @@ -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) diff --git a/analyzer.py b/analyzer.py new file mode 100644 index 0000000..32f036d --- /dev/null +++ b/analyzer.py @@ -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, + } diff --git a/config.py b/config.py new file mode 100644 index 0000000..c189aa4 --- /dev/null +++ b/config.py @@ -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 diff --git a/get_refresh_token.py b/get_refresh_token.py new file mode 100644 index 0000000..c87942e --- /dev/null +++ b/get_refresh_token.py @@ -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}") diff --git a/google_ads_client.py b/google_ads_client.py new file mode 100644 index 0000000..1156cac --- /dev/null +++ b/google_ads_client.py @@ -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 diff --git a/optimizer.py b/optimizer.py new file mode 100644 index 0000000..01e707f --- /dev/null +++ b/optimizer.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9ea372a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +anthropic==0.95.0 +pyairtable==3.3.0 +google-ads==30.0.0 +python-dotenv==1.2.2 diff --git a/run.py b/run.py new file mode 100644 index 0000000..e1c6588 --- /dev/null +++ b/run.py @@ -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()