Fix ad analysis: exclude paused ads, dual 3d/7d window, clearer Slack labels

- meta_ads_client: filter currently-paused ads from analysis (they have
  historic spend in the window but shouldn't get PAUSE recommendations)
- run.py: fetch both 3d and 7d ad metrics; merge cpl_3d/cpl_7d into each ad
- agent.py AD_SYSTEM: base PAUSE on 7d window (more stable for low-volume ads);
  treat high cpl_3d with acceptable cpl_7d as statistical noise
- slack_notifier: campaign header shows yesterday's spend/leads explicitly;
  ad table shows CPL(3d) and CPL(7d) side by side; labels include time period

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Manuel 2026-06-16 16:32:54 +02:00
parent 184b9ca4a5
commit c46baad502
5 changed files with 75 additions and 22 deletions

View File

@ -84,11 +84,18 @@ Responde SOLO con JSON válido (sin markdown):
AD_SYSTEM = """
Eres un analista experto en Meta Ads. Analiza las métricas del anuncio indicado.
Los datos corresponden a los últimos 3 días (ventana estándar de análisis).
Los datos incluyen dos ventanas temporales:
- cpl_3d / leads_3d / spend_3d: últimos 3 días (puede ser volátil con poco volumen)
- cpl_7d / leads_7d: últimos 7 días (más estable, úsala como referencia principal)
Los campos spend/leads/cpl sin sufijo corresponden a 7 días.
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 gasto supera 3 veces el CPL objetivo (campo max_cpl en los datos; si no existe, usa 15) con 0 conversiones, o si el CPL supera el doble del objetivo Y el gasto ya alcanza 3 veces el CPL objetivo.
{"evaluacion": "resumen del rendimiento en 2 frases usando €, mencionando diferencia 3d/7d si es relevante", "recomendacion": "una acción concreta", "accion": "PAUSE o MAINTAIN"}
Reglas para "accion": "PAUSE":
- SOLO si leads_7d == 0 Y spend de 7 días supera 3 veces el max_cpl (o 15 si no existe max_cpl).
- Si cpl_3d es alto pero cpl_7d está dentro del objetivo, usa "MAINTAIN" (ruido estadístico de período corto).
- Si el anuncio tiene leads en 7 días aunque el CPL sea alto, usa "MAINTAIN" y recomienda optimizar.
En cualquier otro caso usa "accion": "MAINTAIN".
"""

View File

@ -146,6 +146,18 @@ class MetaAdsClient:
"leads": int(leads),
"cpl": cpl,
})
# For ads, exclude currently paused ads (they may have historic spend in the window)
if level == "ad":
try:
active_ads = Campaign(campaign_id).get_ads(
fields=["id"],
params={"effective_status": ["ACTIVE"]},
)
active_ids = {a["id"] for a in active_ads}
result = [r for r in result if r["id"] in active_ids]
except Exception:
pass
return sorted(result, key=lambda x: -x["spend"])
def get_yesterday_metrics(self) -> dict:

20
run.py
View File

@ -271,10 +271,24 @@ def run():
except Exception as e:
errors.append(f"Adsets {metrics['name']}: {e}")
# ── Ad analysis ────────────────────────────────────────────────────
# ── Ad analysis (3d + 7d merged) ───────────────────────────────────
ads_detail = []
try:
for ad_m in meta.get_period_ad_metrics(cid, days=3)[:5]:
ads_3d = {a["id"]: a for a in meta.get_period_ad_metrics(cid, days=3)}
ads_7d = {a["id"]: a for a in meta.get_period_ad_metrics(cid, days=7)}
ordered_ids = list(dict.fromkeys(
[a["id"] for a in sorted(ads_7d.values(), key=lambda x: -x["spend"])] +
[a["id"] for a in sorted(ads_3d.values(), key=lambda x: -x["spend"])]
))[:5]
for ad_id in ordered_ids:
a3 = ads_3d.get(ad_id, {})
a7 = ads_7d.get(ad_id, {})
ad_m = dict(a7) if a7 else dict(a3)
ad_m["cpl_3d"] = a3.get("cpl", 0.0)
ad_m["leads_3d"] = a3.get("leads", 0)
ad_m["spend_3d"] = a3.get("spend", 0.0)
ad_m["cpl_7d"] = a7.get("cpl", 0.0)
ad_m["leads_7d"] = a7.get("leads", 0)
ad_m["max_cpl"] = max_cpl
result = analyze_unit(ad_m, "ad")
ad_entry = {**ad_m, **result}
@ -303,6 +317,8 @@ def run():
"name": metrics["name"],
"vertical": vertical,
"margin": margin,
"spend_1d": metrics["spend"],
"leads_1d": metrics["leads"],
"adsets": adsets_detail,
"ads": ads_detail,
"bid_config": campaign_bid,

View File

@ -124,6 +124,8 @@ def main():
"name": name,
"vertical": vertical,
"margin": margin,
"spend_1d": spend,
"leads_1d": leads,
"adsets": adsets,
"ads": ads,
"bid_config": {},

View File

@ -105,13 +105,16 @@ def _ad_action_blocks(ads: list) -> list:
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, show_7d: bool = False) -> str:
"""Genera tabla monoespaciada de adsets o anuncios para Slack."""
if not items:
return ""
lines = [f"*{label}*"]
lines.append("```")
if show_bid:
if show_7d:
lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL(3d)':>8} {'CPL(7d)':>8} {'CTR':>5}")
lines.append("" * 82)
elif show_bid:
lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5} {'Cap':>7}")
lines.append("" * 79)
else:
@ -120,15 +123,26 @@ def _adset_ad_table(items: list, label: str, show_bid: bool = False) -> str:
for it in items:
name = _table_name(it["name"], 45)
leads_str = f"{it['leads']:>5}" if it["leads"] > 0 else ""
if show_7d:
cpl_3d = it.get("cpl_3d", 0.0)
cpl_7d = it.get("cpl_7d", it.get("cpl", 0.0))
cpl_3d_str = f"{cpl_3d:>6.2f}" if cpl_3d > 0 else ""
cpl_7d_str = f"{cpl_7d:>6.2f}" if cpl_7d > 0 else ""
lines.append(
f"{name:<45} {it['spend']:>5.0f}{leads_str} {cpl_3d_str:>8} {cpl_7d_str:>8} {it['ctr']:>4.1f}%"
)
elif show_bid:
cpl_str = f"{it['cpl']:>5.2f}" if it["leads"] > 0 else ""
if show_bid:
cost_cap = it.get("cost_cap_eur")
cap_str = f" {cost_cap:>5.2f}" if cost_cap else " Auto"
else:
cap_str = ""
lines.append(
f"{name:<45} {it['spend']:>5.0f}{leads_str} {cpl_str} {it['ctr']:>4.1f}%{cap_str}"
)
else:
cpl_str = f"{it['cpl']:>5.2f}" if it["leads"] > 0 else ""
lines.append(
f"{name:<45} {it['spend']:>5.0f}{leads_str} {cpl_str} {it['ctr']:>4.1f}%"
)
lines.append("```")
# Evaluations below the table
for it in items:
@ -303,6 +317,8 @@ def send_daily_report(
name = detail["name"]
vertical = detail["vertical"]
margin = detail["margin"]
spend_1d = detail.get("spend_1d", 0.0)
leads_1d = detail.get("leads_1d", 0)
adsets = detail.get("adsets", [])
ads = detail.get("ads", [])
bid_cfg = detail.get("bid_config", {})
@ -319,8 +335,8 @@ def send_daily_report(
header_text = (
f"{emoji} *{name}*\n"
f"Vertical: _{vertical}_ | Margen: *{m_str}* | "
f"Estrategia: `{strategy_label}` | Presupuesto: {budget_str}\n"
f"Vertical: _{vertical}_ | Ayer: *{spend_1d:.0f}€ / {leads_1d} leads* | "
f"Margen: *{m_str}* | Estrategia: `{strategy_label}` | Presupuesto: {budget_str}\n"
f"*Decisión: {alabel}*"
)
if action:
@ -361,8 +377,8 @@ def send_daily_report(
})
# Tabla adsets + anuncios
adset_table = _adset_ad_table(adsets, "Conjuntos de anuncios", show_bid=True)
ad_table = _adset_ad_table(ads, "Anuncios")
adset_table = _adset_ad_table(adsets, "Conjuntos de anuncios (últimos 3 días)", show_bid=True)
ad_table = _adset_ad_table(ads, "Anuncios (últimos 7 días)", show_7d=True)
combined = "\n\n".join(filter(None, [adset_table, ad_table]))
for chunk in [combined[i:i+2900] for i in range(0, len(combined), 2900)]:
camp_blocks.append({