Add ad-level pause actions via Baserow and Slack

- AD_SYSTEM prompt for per-ad PAUSE/MAINTAIN decisions (>5€ spend with 0 leads)
- pause_ad() in MetaAdsClient for individual ad pausing via Meta API
- Ad PAUSE actions saved to Baserow with "ad:{id}" campaign_id prefix
- _ad_action_blocks() in Slack notifier renders pause buttons per ad
- _table_name() strips diacritics (NFKD) for monospace column alignment
- Wider name column (45 chars) in adset/ad tables to reduce truncation
- Guard in _effect_text to suppress "+0% budget adjustment" when no change
- _execute_action handles "ad:" prefix to route pause to correct API method

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Manuel 2026-06-07 21:48:16 +02:00
parent 8fb0b69896
commit e5c0c9f6a6
4 changed files with 97 additions and 19 deletions

View File

@ -66,25 +66,32 @@ def decide(analysis: dict) -> dict:
UNIT_SYSTEM = """ 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. 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á 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). 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): Responde SOLO con JSON válido (sin markdown):
{ {"evaluacion": "resumen del rendimiento en 2 frases usando €", "recomendacion": "una acción concreta"}
"evaluacion": "resumen del rendimiento en 2 frases usando €", """
"recomendacion": "una acción concreta y específica para mejorar"
} 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: 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" nivel = "conjunto de anuncios" if level == "adset" else "anuncio"
system = AD_SYSTEM if level == "ad" else UNIT_SYSTEM
response = client.messages.create( response = client.messages.create(
model="claude-haiku-4-5-20251001", model="claude-haiku-4-5-20251001",
max_tokens=200, max_tokens=250,
system=UNIT_SYSTEM, system=system,
messages=[{ messages=[{
"role": "user", "role": "user",
"content": f"Analiza este {nivel} de Meta Ads:\n" + json.dumps(metrics, ensure_ascii=False), "content": f"Analiza este {nivel} de Meta Ads:\n" + json.dumps(metrics, ensure_ascii=False),

View File

@ -236,6 +236,10 @@ class MetaAdsClient:
campaign = Campaign(campaign_id) campaign = Campaign(campaign_id)
campaign.api_update(params={"daily_budget": daily_budget_cents}) 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): def pause_campaign(self, campaign_id: str):
"""Pause a campaign.""" """Pause a campaign."""
campaign = Campaign(campaign_id) campaign = Campaign(campaign_id)

26
run.py
View File

@ -63,7 +63,10 @@ def _execute_action(meta: MetaAdsClient, action: dict):
parameter = float(action.get("parameter") or 1.0) parameter = float(action.get("parameter") or 1.0)
if action_type == "PAUSE": 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"): elif action_type in ("REDUCE_BUDGET", "INCREASE_BUDGET"):
try: try:
@ -244,8 +247,25 @@ def run():
try: try:
for ad_m in meta.get_yesterday_ad_metrics(cid)[:5]: for ad_m in meta.get_yesterday_ad_metrics(cid)[:5]:
result = analyze_unit(ad_m, "ad") result = analyze_unit(ad_m, "ad")
ads_detail.append({**ad_m, **result}) ad_entry = {**ad_m, **result}
print(f" [Ad] {ad_m['name'][:45]}{result.get('evaluacion','')[:60]}") # 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: except Exception as e:
errors.append(f"Ads {metrics['name']}: {e}") errors.append(f"Ads {metrics['name']}: {e}")

View File

@ -1,8 +1,16 @@
"""Slack notifier — Web API (Bot Token) con botones interactivos.""" """Slack notifier — Web API (Bot Token) con botones interactivos."""
from datetime import datetime from datetime import datetime
import unicodedata
import requests import requests
import config 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" _SLACK_API = "https://slack.com/api"
_STRATEGY_LABELS = { _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=[]) _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: def _adset_ad_table(items: list, label: str, show_bid: bool = False) -> str:
"""Genera tabla monoespaciada de adsets o anuncios para Slack.""" """Genera tabla monoespaciada de adsets o anuncios para Slack."""
if not items: if not items:
@ -64,13 +109,13 @@ def _adset_ad_table(items: list, label: str, show_bid: bool = False) -> str:
lines = [f"*{label}*"] lines = [f"*{label}*"]
lines.append("```") lines.append("```")
if show_bid: if show_bid:
lines.append(f"{'Nombre':<32} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5} {'Cap':>7}") lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5} {'Cap':>7}")
lines.append("" * 66) lines.append("" * 79)
else: else:
lines.append(f"{'Nombre':<32} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5}") lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5}")
lines.append("" * 58) lines.append("" * 71)
for it in items: 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 "" leads_str = f"{it['leads']:>5}" if it["leads"] > 0 else ""
cpl_str = f"{it['cpl']:>5.2f}" if it["leads"] > 0 else "" cpl_str = f"{it['cpl']:>5.2f}" if it["leads"] > 0 else ""
if show_bid: if show_bid:
@ -79,7 +124,7 @@ def _adset_ad_table(items: list, label: str, show_bid: bool = False) -> str:
else: else:
cap_str = "" cap_str = ""
lines.append( 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("```") lines.append("```")
# Evaluations below the table # 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", "") ev = it.get("evaluacion", "")
rec = it.get("recomendacion", "") rec = it.get("recomendacion", "")
if ev or rec: if ev or rec:
lines.append(f"• *{it['name'][:40]}*") lines.append(f"• *{it['name'][:55]}*")
if ev: if ev:
lines.append(f" _{ev}_") lines.append(f" _{ev}_")
if rec: if rec:
@ -294,13 +339,15 @@ def send_daily_report(
detail_parts.append(ad_table) detail_parts.append(ad_table)
if detail_parts: if detail_parts:
combined = "\n\n".join(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)]: for chunk in [combined[i:i+2900] for i in range(0, len(combined), 2900)]:
blocks.append({ blocks.append({
"type": "section", "type": "section",
"text": {"type": "mrkdwn", "text": chunk}, "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"}) blocks.append({"type": "divider"})
if len(blocks) >= 45: # Safety margin before Slack's 50-block limit if len(blocks) >= 45: # Safety margin before Slack's 50-block limit