meta-optimizer-formacion/slack_notifier.py
José Manuel Gómez 9239e2f67f Initial scaffold: Meta Optimizer for RoiFormacion campaigns
Ports meta-optimizer's Meta Ads execution/approval/creative-analysis layer
(agent.py, meta_ads_client.py, baserow_client.py, slack_notifier.py,
approval_server.py) and replaces the per-vertical CPL model with the
PPL + monthly-capping-per-course model already used by leads-optimizer,
via a new airtable_client.py that shares Cursos/Familias/CentroCurso/
CursoMes/Leads Lake with that project and adds Meta Ads Campaigns /
MetaCampaignMes alongside its Google Ads Campaigns / GACampaignMes.
2026-07-07 16:53:03 +02:00

555 lines
22 KiB
Python

"""Slack notifier — Web API (Bot Token) con botones interactivos.
Agrupa por Familia (campo de Airtable/Cursos) en vez de por vertical, y
muestra PPL/capping mensual en vez de un CPL objetivo único: cada curso tiene
su propio PPL, así que ya no hay un número de "objetivo" comparable entre
campañas de la misma familia.
"""
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 = {
"LOWEST_COST_WITHOUT_CAP": "Menor coste",
"LOWEST_COST_WITH_BID_CAP": "Cap. puja",
"COST_CAP": "Cap. coste",
"MINIMUM_ROAS": "ROAS mín.",
}
_ACTION_DISPLAY = {
"INCREASE_BUDGET": ("🟢", "AUMENTAR PRESUPUESTO"),
"REDUCE_BUDGET": ("🔴", "REDUCIR PRESUPUESTO"),
"PAUSE": ("", "PAUSAR CAMPAÑA"),
"MAINTAIN": ("", "MANTENER"),
}
# Solo estas acciones ejecutan algo real en Meta API → botones
_ACTIONABLE = {"INCREASE_BUDGET", "REDUCE_BUDGET", "PAUSE"}
_URGENCIA_EMOJI = {
"PAUSAR": "",
"SPRINT": "🚨",
"ACELERAR": "🟢",
"FRENAR": "🔴",
"EN_RITMO": "",
}
def _effect_text(action: dict, budget: float | None) -> str:
"""Texto que describe exactamente qué ocurrirá si se aprueba la acción."""
atype = action.get("action_type", "")
param = float(action.get("parameter") or 1.0)
if atype == "PAUSE":
return "⚠️ *La campaña será pausada en Meta Ads*"
if atype in ("INCREASE_BUDGET", "REDUCE_BUDGET"):
pct = round((param - 1) * 100)
if pct == 0:
return "" # parameter not available, skip misleading text
sign = "+" if pct >= 0 else ""
if budget:
new_b = round(budget * param, 2)
return f"📊 Presupuesto diario: *{budget:.0f}€ → {new_b:.0f}€* ({sign}{pct}%)"
return f"📊 Ajuste de presupuesto: *{sign}{pct}%*"
return ""
def _post(method: str, **payload) -> dict:
resp = requests.post(
f"{_SLACK_API}/{method}",
headers={"Authorization": f"Bearer {config.SLACK_BOT_TOKEN}"},
json=payload,
timeout=10,
)
data = resp.json()
if not data.get("ok"):
raise RuntimeError(f"Slack {method} error: {data.get('error')}{data.get('response_metadata', {}).get('messages', '')}")
return data
def update_message(channel: str, ts: str, text: str):
"""Reemplaza un mensaje con texto plano tras aprobación/rechazo."""
_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"]
text = f"⛔ *{name[:80]}* _(0 leads · 7d)_"
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, 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_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:
lines.append(f"{'Nombre':<45} {'Gasto':>6} {'Leads':>5} {'CPL':>6} {'CTR':>5}")
lines.append("" * 71)
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 ""
cost_cap = it.get("cost_cap_eur")
cap_str = f" {cost_cap:>5.2f}" if cost_cap else " Auto"
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("```")
return "\n".join(lines)
def _familia_status(margin: float, has_issues: bool, no_data: bool) -> str:
if no_data:
return ""
if has_issues:
return "🚨" if margin < 0 else "⚠️"
return "" if margin >= 0 else "⚠️"
def send_daily_report(
daily_totals: list,
best_campaigns: list,
worst_campaigns: list,
actions: list,
campaigns_analyzed: int,
mode: str = "DRY_RUN",
familias: dict = None,
campaign_details: dict = None,
monthly_familias: dict = None,
) -> str | None:
"""Envía el informe diario consolidado. Devuelve el ts del mensaje."""
now = datetime.now()
date_label = now.strftime("%d/%m/%Y")
month_name = now.strftime("%B %Y").capitalize()
prefix = config.META_CAMPAIGN_PREFIX
mode_label = "DRY RUN" if mode == "DRY_RUN" else "PRODUCCIÓN"
action_map = {a["campaign_name"]: a for a in actions}
details_map = campaign_details or {}
# Group ALL campaigns by familia
by_familia: dict = {}
for cid, detail in details_map.items():
act = action_map.get(detail["name"])
by_familia.setdefault(detail["familia"], []).append((cid, detail, act))
def _has_issues(camp_list):
return any(
(act and act["action_type"] != "MAINTAIN") or
any(ad.get("accion") == "PAUSE" and ad.get("row_id")
for ad in detail.get("ads", []))
for _, detail, act in camp_list
)
# Sort familias: issues first (by margin asc), then OK (by margin desc)
def _familia_sort_key(item):
f, cl = item
f_data = (familias or {}).get(f, {})
margin = f_data.get("margin", 0)
has_iss = _has_issues(cl)
return (0 if has_iss else 1, margin if has_iss else -margin)
sorted_familias = sorted(by_familia.items(), key=_familia_sort_key)
# ── Message 1: Dashboard ─────────────────────────────────────────────────
blocks: list = [
{
"type": "header",
"text": {"type": "plain_text",
"text": f"Meta Optimizer Formación — {date_label} ({mode_label})"},
},
]
# Monthly profitability table
if daily_totals:
f_order = (
sorted(monthly_familias.keys(), key=lambda f: -monthly_familias[f]["margin"])
if monthly_familias else []
)
cw = 7
hdr = f"{'Día':<5} {'Gasto':>6} {'Leads':>5} {'CPL':>7}"
for f in f_order:
hdr += f" {f[:6]:>{cw}}"
hdr += " Est"
sep = "" * len(hdr)
lines = [hdr, sep]
total_spend = total_leads = total_margin = 0.0
total_f = {f: 0.0 for f in f_order}
for d in daily_totals:
day = d["date"][8:10] + "/" + d["date"][5:7]
margin = d.get("margin", 0.0)
total_spend += d["spend"]
total_leads += d["leads"]
total_margin += margin
f_day = d.get("f_margins", {})
icon = "" if d["leads"] > 0 else ("" if d["spend"] > 0 else "")
row = f"{day:<5} {d['spend']:>5.0f}{d['leads']:>5} {d['cpl']:>6.2f}"
for f in f_order:
fm = f_day.get(f, 0.0)
total_f[f] += fm
fm_s = (f"+{fm:.0f}" if fm >= 0 else f"{fm:.0f}") if round(fm) != 0 else ""
row += f" {fm_s:>{cw}}"
row += f" {icon}"
lines.append(row)
lines.append(sep)
total_row = f"{'TOTAL':<5} {total_spend:>5.0f}{int(total_leads):>5} {'':>7}"
for f in f_order:
tf = total_f[f]
tf_s = f"+{tf:.0f}" if tf >= 0 else f"{tf:.0f}"
total_row += f" {tf_s:>{cw}}"
lines.append(total_row)
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*Rentabilidad {month_name}*\n```" + "\n".join(lines) + "```",
},
})
else:
blocks.append({
"type": "section",
"text": {"type": "mrkdwn", "text": "_Sin datos del mes en curso aún._"},
})
blocks.append({"type": "divider"})
# Familia scorecard
if familias:
lines = [f"{'':>2} {'Familia':<20} {'Gasto':>6} {'Leads':>5} {'CPL':>7} {'Margen':>9}"]
lines.append("" * 56)
for f, cl in sorted_familias:
data = (familias or {}).get(f, {})
f_leads = data.get("leads", 0)
f_spend = data.get("spend", 0)
f_cpl = round(f_spend / f_leads, 2) if f_leads > 0 else 0.0
f_m = data.get("margin", 0)
m_sign = f"+{f_m:.0f}" if f_m >= 0 else f"{f_m:.0f}"
st = _familia_status(f_m, _has_issues(cl), f_leads == 0 and f_spend == 0)
lines.append(
f"{st} {f[:20]:<20} {f_spend:>5.0f}{f_leads:>5} {f_cpl:>6.2f}{m_sign:>9}"
)
blocks.append({
"type": "section",
"text": {"type": "mrkdwn",
"text": "*Resumen · ayer*\n```" + "\n".join(lines) + "```"},
})
blocks.append({
"type": "context",
"elements": [{"type": "mrkdwn",
"text": f"{campaigns_analyzed} campañas analizadas — detalle por familia a continuación"}],
})
result = _post(
"chat.postMessage",
channel=config.SLACK_CHANNEL_ID,
blocks=blocks,
text=f"Meta Optimizer Formación — {date_label}",
)
ts = result.get("ts")
# ── One message per familia ──────────────────────────────────────────────
for f, camp_list in sorted_familias:
f_data = (familias or {}).get(f, {})
f_spend = f_data.get("spend", 0)
f_leads = f_data.get("leads", 0)
f_cpl = round(f_spend / f_leads, 2) if f_leads > 0 else 0.0
f_margin = f_data.get("margin", 0)
m_str = f"+{f_margin:.0f}" if f_margin >= 0 else f"{f_margin:.0f}"
has_iss = _has_issues(camp_list)
st = _familia_status(f_margin, has_iss, f_leads == 0 and f_spend == 0)
f_blocks: list = [
{
"type": "header",
"text": {"type": "plain_text", "text": f"{st} {f.upper()}"},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"{f_spend:.0f}€ · {f_leads} leads · CPL {f_cpl:.2f}€ · Margen {m_str}",
},
},
{"type": "divider"},
]
for i, (cid, detail, act) in enumerate(
sorted(camp_list, key=lambda x: -x[1].get("spend_1d", 0))
):
if i > 0:
f_blocks.append({"type": "divider"})
name = detail["name"]
spend_1d = detail.get("spend_1d", 0.0)
leads_1d = detail.get("leads_1d", 0)
margin = detail["margin"]
m_str2 = f"+{margin:.2f}" if margin >= 0 else f"{margin:.2f}"
urgencia = detail.get("urgencia", "EN_RITMO")
u_emoji = _URGENCIA_EMOJI.get(urgencia, "")
leads_mes = detail.get("leads_mes", 0)
capping = detail.get("capping", 0)
cap_str = f"{leads_mes}/{capping}" if capping else f"{leads_mes}/∞"
adsets = detail.get("adsets", [])
ads = detail.get("ads", [])
bid_cfg = detail.get("bid_config", {})
budget = bid_cfg.get("daily_budget_eur")
strategy = bid_cfg.get("bid_strategy", "")
strat_label = _STRATEGY_LABELS.get(strategy, strategy or "")
atype = act["action_type"] if act else "MAINTAIN"
cemoji, alabel = _ACTION_DISPLAY.get(atype, ("", atype))
if atype == "MAINTAIN" and not any(
ad.get("accion") == "PAUSE" and ad.get("row_id") for ad in ads
):
# Compact header for clean campaigns
f_blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"{cemoji} *{name}*\n"
f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · "
f"Margen: {m_str2} · {u_emoji} {urgencia} · Cap mes: {cap_str}"
+ (f" · `{strat_label}`" if strategy else "")
+ (f" · {budget:.0f}€/día" if budget else "")
),
},
})
# Still show adset breakdown for context
if adsets:
tbl = _adset_ad_table(adsets[:3], "Conjuntos (3 días)", show_bid=True)
if tbl:
for chunk in [tbl[j:j+2900] for j in range(0, len(tbl), 2900)]:
f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}})
else:
# Full block for campaigns with action or ad pauses
camp_text = (
f"{cemoji} *{name}*\n"
f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · Margen: {m_str2} · "
f"{u_emoji} {urgencia} · Cap mes: {cap_str}"
+ (f" · `{strat_label}`" if strategy else "")
+ (f" · {budget:.0f}€/día" if budget else "") +
f"\n*{alabel}*"
)
if act and act.get("justification"):
camp_text += f" — _{act['justification'][:160]}_"
if act and act.get("alert"):
camp_text += f"\n:warning: {act['alert'][:130]}"
f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": camp_text}})
# Approve/Reject buttons
if act and atype in _ACTIONABLE:
effect = _effect_text(act, budget)
if effect:
f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": effect}})
f_blocks.append({
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "✅ Aprobar"},
"style": "primary",
"value": f"approve:{act['row_id']}",
"action_id": f"approve_{act['row_id']}",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "❌ Rechazar"},
"style": "danger",
"value": f"reject:{act['row_id']}",
"action_id": f"reject_{act['row_id']}",
},
],
})
# Adset table (top 3) — only for non-MAINTAIN
if atype != "MAINTAIN" and adsets:
tbl = _adset_ad_table(adsets[:3], "Conjuntos (3 días)", show_bid=True)
if tbl:
for chunk in [tbl[j:j+2900] for j in range(0, len(tbl), 2900)]:
f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}})
# Ad pause buttons
f_blocks.extend(_ad_action_blocks(ads))
_post(
"chat.postMessage",
channel=config.SLACK_CHANNEL_ID,
blocks=f_blocks,
text=f"{f.upper()} · {f_spend:.0f}€ · {f_leads} leads",
)
return ts
def _score_emoji(score: float) -> str:
if score >= 8: return "🟢"
if score >= 6: return "🟡"
if score >= 4: return "🟠"
return "🔴"
def _brief_action(score: float, fatigue: bool) -> str:
if score == 0: return "sin imagen"
if fatigue: return "renovar urgente"
if score >= 8: return "mantener"
if score >= 6: return "optimizar"
if score >= 4: return "renovar"
return "reemplazar"
def send_creative_analysis_report(all_results: dict) -> None:
"""Envía scorecard compacto de creatividades a Slack. Un mensaje por campaña."""
now = datetime.now()
date_label = now.strftime("%d/%m/%Y %H:%M")
total_ads = sum(len(as_d["ads"]) for c in all_results.values() for as_d in c["adsets"].values())
total_fatigue = sum(
1 for c in all_results.values()
for as_d in c["adsets"].values()
for ad in as_d["ads"] if ad.get("fatigue")
)
scored = [
ad.get("score", 0) for c in all_results.values()
for as_d in c["adsets"].values()
for ad in as_d["ads"] if ad.get("score", 0) > 0
]
avg_score = round(sum(scored) / len(scored), 1) if scored else 0.0
summary = f"*{len(all_results)} campañas* · *{total_ads} anuncios* · score medio *{avg_score}/10*"
if total_fatigue:
summary += f"\n⚠️ *{total_fatigue} con fatiga creativa detectada*"
_post(
"chat.postMessage",
channel=config.SLACK_CHANNEL_ID,
blocks=[
{"type": "header", "text": {"type": "plain_text", "text": f"Creatividades — {date_label}"}},
{"type": "section", "text": {"type": "mrkdwn", "text": summary}},
],
text=f"Creatividades — {date_label}",
)
# ── One message per campaign ──────────────────────────────────────────────
for cid, camp_data in all_results.items():
camp_name = camp_data["name"]
adsets = camp_data.get("adsets", {})
if not adsets:
continue
def _flush(buf: list) -> list:
if len(buf) > 1:
try:
_post("chat.postMessage", channel=config.SLACK_CHANNEL_ID,
blocks=buf, text=camp_name)
except RuntimeError as e:
print(f" [WARN] Slack: {e}")
return [{"type": "header", "text": {"type": "plain_text", "text": f"{camp_name} (cont.)"}}]
blocks: list = [{"type": "header", "text": {"type": "plain_text", "text": camp_name}}]
for as_data in adsets.values():
adset_name = as_data["name"]
ads = as_data["ads"]
ads_sorted = sorted(ads, key=lambda x: -x.get("score", 0))
# Compact monospace table — one line per ad
lines = [
f"*{adset_name}* _({len(ads)} anuncios)_",
"```",
f"{'Nombre':<33} {'Sc':>4} {'CTR':>5} {'CPL':>6} Acción",
"" * 63,
]
for ad in ads_sorted:
name = _table_name(ad["ad_name"], 33)
score = ad.get("score", 0)
ctr = ad.get("ctr_7d", 0)
cpl = ad.get("cpl_7d", 0)
fat = "" if ad.get("fatigue") else " "
action = _brief_action(score, ad.get("fatigue", False))
cpl_s = f"{cpl:.2f}" if cpl > 0 else ""
sc_s = f"{score:.1f}" if score > 0 else ""
lines.append(f"{name:<33}{fat} {sc_s:>4} {ctr:>4.1f}% {cpl_s:>6} {action}")
lines.append("```")
winner = as_data.get("comparison", {}) or {}
if winner.get("winner"):
lines.append(f"🏆 _{winner['winner'][:70]}_")
ab = [{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}]
if len(blocks) + len(ab) > 48:
blocks = _flush(blocks)
blocks.extend(ab)
_flush(blocks)
def send_execution_summary(log: dict):
"""Resumen plano de ejecución (fallback)."""
mode_label = "DRY RUN" if log.get("mode") == "DRY_RUN" else "PRODUCCIÓN"
text = (
f":bar_chart: *Meta Optimizer Formación — Resumen diario* ({mode_label})\n"
f"• Campañas analizadas: {log.get('campaigns_analyzed', 0)}\n"
f"• Acciones propuestas: {log.get('actions_proposed', 0)}\n"
f"• Acciones ejecutadas: {log.get('actions_executed', 0)}\n"
f"• Duración: {log.get('duration_seconds', 0):.1f}s"
)
_post("chat.postMessage", channel=config.SLACK_CHANNEL_ID, text=text)