diff --git a/agent.py b/agent.py index 79a3846..d1d2ca0 100644 --- a/agent.py +++ b/agent.py @@ -66,25 +66,32 @@ def decide(analysis: dict) -> dict: UNIT_SYSTEM = """ -Eres un analista experto en Meta Ads. Analiza las métricas del conjunto de anuncios o anuncio indicado. +Eres un analista experto en Meta Ads. Analiza las métricas del conjunto de anuncios indicado. USA SIEMPRE € como unidad de moneda. Responde SIEMPRE en español. Si el conjunto tiene cost_cap_eur (cap de coste), compara el CPL actual con ese cap e indica si está por encima, dentro o por debajo del límite, y cuánto margen queda (o cuánto se supera). Responde SOLO con JSON válido (sin markdown): -{ - "evaluacion": "resumen del rendimiento en 2 frases usando €", - "recomendacion": "una acción concreta y específica para mejorar" -} +{"evaluacion": "resumen del rendimiento en 2 frases usando €", "recomendacion": "una acción concreta"} +""" + +AD_SYSTEM = """ +Eres un analista experto en Meta Ads. Analiza las métricas del anuncio indicado. +USA SIEMPRE € como unidad de moneda. Responde SIEMPRE en español. +Responde SOLO con JSON válido (sin markdown): +{"evaluacion": "resumen del rendimiento en 2 frases usando €", "recomendacion": "una acción concreta", "accion": "PAUSE o MAINTAIN"} +Usa "accion": "PAUSE" solo si: el anuncio ha gastado más de 5€ con 0 leads, o su CPL es más del doble del objetivo sin signos de mejora. +En caso contrario usa "accion": "MAINTAIN". """ def analyze_unit(metrics: dict, level: str = "adset") -> dict: - """Análisis rápido de un conjunto de anuncios o anuncio individual (solo análisis, sin acción).""" + """Análisis rápido de un conjunto de anuncios o anuncio individual.""" nivel = "conjunto de anuncios" if level == "adset" else "anuncio" + system = AD_SYSTEM if level == "ad" else UNIT_SYSTEM response = client.messages.create( model="claude-haiku-4-5-20251001", - max_tokens=200, - system=UNIT_SYSTEM, + max_tokens=250, + system=system, messages=[{ "role": "user", "content": f"Analiza este {nivel} de Meta Ads:\n" + json.dumps(metrics, ensure_ascii=False), diff --git a/meta_ads_client.py b/meta_ads_client.py index 19c67f6..a6559a2 100644 --- a/meta_ads_client.py +++ b/meta_ads_client.py @@ -236,6 +236,10 @@ class MetaAdsClient: campaign = Campaign(campaign_id) campaign.api_update(params={"daily_budget": daily_budget_cents}) + def pause_ad(self, ad_id: str): + ad = Ad(fbid=ad_id) + ad.api_update(params={"status": Ad.Status.paused}) + def pause_campaign(self, campaign_id: str): """Pause a campaign.""" campaign = Campaign(campaign_id) diff --git a/run.py b/run.py index deb4c1a..268d5e8 100644 --- a/run.py +++ b/run.py @@ -63,7 +63,10 @@ def _execute_action(meta: MetaAdsClient, action: dict): parameter = float(action.get("parameter") or 1.0) if action_type == "PAUSE": - meta.pause_campaign(cid) + if cid.startswith("ad:"): + meta.pause_ad(cid[3:]) + else: + meta.pause_campaign(cid) elif action_type in ("REDUCE_BUDGET", "INCREASE_BUDGET"): try: @@ -244,8 +247,25 @@ def run(): try: for ad_m in meta.get_yesterday_ad_metrics(cid)[:5]: result = analyze_unit(ad_m, "ad") - ads_detail.append({**ad_m, **result}) - print(f" [Ad] {ad_m['name'][:45]} — {result.get('evaluacion','')[:60]}") + ad_entry = {**ad_m, **result} + # Propose ad pause if Claude recommends it + if result.get("accion") == "PAUSE": + try: + ad_row = baserow.save_action({ + "campaign_id": f"ad:{ad_m['id']}", + "campaign_name": ad_m["name"], + "action_type": "PAUSE", + "parameter": 1.0, + "justification": result.get("recomendacion", ""), + "advice": result.get("evaluacion", ""), + "confidence": 0.8, + }) + ad_entry["row_id"] = ad_row["id"] + except Exception as e: + errors.append(f"Ad action {ad_m['name']}: {e}") + ads_detail.append(ad_entry) + action_tag = " ⛔PAUSE" if result.get("accion") == "PAUSE" else "" + print(f" [Ad] {ad_m['name'][:45]} — {result.get('evaluacion','')[:60]}{action_tag}") except Exception as e: errors.append(f"Ads {metrics['name']}: {e}") diff --git a/slack_notifier.py b/slack_notifier.py index 4d07e7c..d77725c 100644 --- a/slack_notifier.py +++ b/slack_notifier.py @@ -1,8 +1,16 @@ """Slack notifier — Web API (Bot Token) con botones interactivos.""" from datetime import datetime +import unicodedata import requests import config + +def _table_name(name: str, width: int) -> str: + """Strip diacritics so accented chars don't break Slack's monospace alignment.""" + normalized = unicodedata.normalize("NFKD", name) + ascii_name = "".join(c for c in normalized if not unicodedata.combining(c)) + return ascii_name[:width] + _SLACK_API = "https://slack.com/api" _STRATEGY_LABELS = { @@ -57,6 +65,43 @@ def update_message(channel: str, ts: str, text: str): _post("chat.update", channel=channel, ts=ts, text=text, blocks=[]) +def _ad_action_blocks(ads: list) -> list: + """Genera bloques Slack con botón de pausa para anuncios que Claude recomienda pausar.""" + blocks = [] + for ad in ads: + if not ad.get("row_id"): + continue + name = ad["name"] + ev = ad.get("evaluacion", "") + rec = ad.get("recomendacion", "") + text = f"⛔ *{name[:55]}*" + if ev: + text += f"\n_{ev}_" + if rec: + text += f"\n→ {rec}" + text += "\n⚠️ *El anuncio será pausado en Meta Ads si se aprueba*" + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}}) + blocks.append({ + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "⛔ Pausar anuncio"}, + "style": "danger", + "value": f"approve:{ad['row_id']}", + "action_id": f"approve_{ad['row_id']}", + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "❌ Rechazar"}, + "value": f"reject:{ad['row_id']}", + "action_id": f"reject_{ad['row_id']}", + }, + ], + }) + return blocks + + def _adset_ad_table(items: list, label: str, show_bid: bool = False) -> str: """Genera tabla monoespaciada de adsets o anuncios para Slack.""" if not items: @@ -64,13 +109,13 @@ def _adset_ad_table(items: list, label: str, show_bid: bool = False) -> str: lines = [f"*{label}*"] lines.append("```") if show_bid: - lines.append(f"{'Nombre':<32} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5} {'Cap':>7}") - lines.append("─" * 66) + lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5} {'Cap':>7}") + lines.append("─" * 79) else: - lines.append(f"{'Nombre':<32} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5}") - lines.append("─" * 58) + lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5}") + lines.append("─" * 71) for it in items: - name = it["name"][:32] + name = _table_name(it["name"], 45) leads_str = f"{it['leads']:>5}" if it["leads"] > 0 else " —" cpl_str = f"{it['cpl']:>5.2f}€" if it["leads"] > 0 else " —" if show_bid: @@ -79,7 +124,7 @@ def _adset_ad_table(items: list, label: str, show_bid: bool = False) -> str: else: cap_str = "" lines.append( - f"{name:<32} {it['spend']:>5.0f}€ {leads_str} {cpl_str} {it['ctr']:>4.1f}%{cap_str}" + f"{name:<45} {it['spend']:>5.0f}€ {leads_str} {cpl_str} {it['ctr']:>4.1f}%{cap_str}" ) lines.append("```") # Evaluations below the table @@ -87,7 +132,7 @@ def _adset_ad_table(items: list, label: str, show_bid: bool = False) -> str: ev = it.get("evaluacion", "") rec = it.get("recomendacion", "") if ev or rec: - lines.append(f"• *{it['name'][:40]}*") + lines.append(f"• *{it['name'][:55]}*") if ev: lines.append(f" _{ev}_") if rec: @@ -294,13 +339,15 @@ def send_daily_report( detail_parts.append(ad_table) if detail_parts: combined = "\n\n".join(detail_parts) - # Split into chunks if too long (Slack block text limit: 3000 chars) for chunk in [combined[i:i+2900] for i in range(0, len(combined), 2900)]: blocks.append({ "type": "section", "text": {"type": "mrkdwn", "text": chunk}, }) + # Botones de pausa por anuncio (solo los que Claude recomienda pausar) + blocks.extend(_ad_action_blocks(ads)) + blocks.append({"type": "divider"}) if len(blocks) >= 45: # Safety margin before Slack's 50-block limit