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 = """
|
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),
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
24
run.py
24
run.py
@ -63,6 +63,9 @@ 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":
|
||||||
|
if cid.startswith("ad:"):
|
||||||
|
meta.pause_ad(cid[3:])
|
||||||
|
else:
|
||||||
meta.pause_campaign(cid)
|
meta.pause_campaign(cid)
|
||||||
|
|
||||||
elif action_type in ("REDUCE_BUDGET", "INCREASE_BUDGET"):
|
elif action_type in ("REDUCE_BUDGET", "INCREASE_BUDGET"):
|
||||||
@ -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}")
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user