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:
parent
8fb0b69896
commit
e5c0c9f6a6
23
agent.py
23
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),
|
||||
|
||||
@ -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)
|
||||
|
||||
26
run.py
26
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}")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user