meta-optimizer-formacion/slack_notifier.py
José Manuel Gómez 921000c47e Make approval buttons project-aware for the shared Slack app
meta-optimizer (Viviful) and this project share one Slack bot/app, but a
Slack app only supports a single Interactivity Request URL — so a single
approval_server must be able to tell which project's Baserow a button click
belongs to. Button values now carry a project tag ("approve:formacion:<id>"
instead of bare "approve:<id>"); approval_server.py parses it and routes
accordingly, with a registry slot to add other projects (e.g. "viviful") via
their Baserow REST credentials without importing their code.

Confirmed via api.slack.com/apps that the current Interactivity Request URL
points at a dead ngrok tunnel (ERR_NGROK_3200) — no approval server is
actually reachable in production today, for either project. Deployment is
being handled separately as part of the general app rollout.
2026-07-09 15:10:30 +02:00

592 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"
# El bot de Slack es compartido con meta-optimizer (Viviful) — solo puede haber
# UNA Interactivity Request URL por app, así que un mismo approval_server debe
# poder distinguir a qué proyecto pertenece cada acción. El valor del botón
# incluye este tag: "approve:formacion:<row_id>" en vez de "approve:<row_id>".
PROJECT_TAG = "formacion"
def _btn_value(verb: str, row_id) -> str:
return f"{verb}:{PROJECT_TAG}:{row_id}"
_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:
# Las tablas monoespaciadas usan abreviaturas tipo "CPL.AT" que Slack puede
# confundir con un dominio (.AT = Austria) y expandir como link preview
# (ver caso real: "CPL.AT" → unfurl de cpl.at). Desactivar unfurl siempre,
# como defensa adicional a renombrar las abreviaturas problemáticas.
if method == "chat.postMessage":
payload.setdefault("unfurl_links", False)
payload.setdefault("unfurl_media", False)
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 _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} {'PPL':>6} {'Margen':>8} {'Cap':>7}")
lines.append("" * 95)
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:>6.2f}" if cost_cap else " Auto"
ppl = it.get("ppl", 0) or 0
margin = it["leads"] * ppl - it["spend"]
ppl_str = f"{ppl:>5.2f}" if ppl else ""
margin_str = f"{margin:>+7.2f}" if ppl else ""
lines.append(
f"{name:<45} {it['spend']:>5.0f}{leads_str} {cpl_str} {it['ctr']:>4.1f}% "
f"{ppl_str} {margin_str} {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 _marg(v: float) -> str:
v_int = round(v)
return ("+" if v_int >= 0 else "") + f"{v_int:,.0f}".replace(",", ".")
def _pct(v: float) -> str:
return ("+" if v >= 0 else "") + f"{v:.1f}%"
def _curso_label(cs: dict, width: int = 26) -> str:
names = cs.get("campaigns", [])
if not names:
return "?"
label = names[0]
if len(names) > 1:
label += f" (+{len(names) - 1})"
return _table_name(label, width)
CURSO_TABLE_TOP_N = 25
def _curso_summary_blocks(curso_summary: dict) -> list[dict]:
"""
Resumen y contraste por curso: PPL, CPL según Meta vs según Airtable, y el
desglose de leads de Airtable por vía (LF=leadform nativo, Land=landing page)
— ambas 100% atribuibles a Meta, confirmado con datos reales (fbclid en todos
los 'landingpage', cero 'gclid'). Δ = leads_meta - leads_airtable_total
(discrepancia de tracking, +N = Meta ve más que Airtable).
"""
if not curso_summary:
return []
rows = sorted(curso_summary.items(), key=lambda kv: -kv[1]["spend"])
overflow = max(0, len(rows) - CURSO_TABLE_TOP_N)
rows = rows[:CURSO_TABLE_TOP_N]
hdr = f"{'Cod':>5} {'Curso':<26} {'PPL':>6} {'L·Meta':>6} {'L·LF':>5} {'L·Land':>6} {'L·AT':>5} {'CPL·Meta':>8} {'CPL·AT':>7} {'Δ':>4}"
sep = "" * len(hdr)
lines = [hdr, sep]
for cursoid, cs in rows:
label = _curso_label(cs)
cpl_meta_s = f"{cs['cpl_meta']:.2f}" if cs["cpl_meta"] else ""
cpl_at_s = f"{cs['cpl_at']:.2f}" if cs["cpl_at"] else ""
lines.append(
f"{cursoid:>5} {label:<26} {cs['ppl']:>5.2f}{cs['leads_meta']:>6} "
f"{cs['leads_at_leadform']:>5} {cs['leads_at_landing']:>6} {cs['leads_at_total']:>5} "
f"{cpl_meta_s:>8} {cpl_at_s:>7} {cs['discrepancia']:>+4}"
)
footer = f"\n_+{overflow} cursos más con menos gasto, no mostrados_" if overflow else ""
text = (
"*Resumen y contraste por curso — Meta vs Airtable* "
"_(LF=leadform nativo · Land=landing page, ambas atribuibles a Meta · "
"Δ=leads_metaleads_airtable)_\n```\n" + "\n".join(lines) + "\n```" + footer
)
return [{"type": "section", "text": {"type": "mrkdwn", "text": chunk}}
for chunk in [text[j:j + 2900] for j in range(0, len(text), 2900)]]
def _daily_table_block(daily_totals: list, month_name: str) -> dict | None:
"""Tabla diaria Leads Airtable vs Leads Meta, coste, ingreso×PPL de cada
fuente, y margen (calculado sobre el tracking propio de Meta, igual
convención que leads-optimizer usa con Google Ads; Airtable se muestra en
paralelo para contrastar discrepancias de tracking, no sustituye el margen oficial)."""
if not daily_totals:
return None
hdr = f"{'Día':<5} {'L·AT':>5} {'L·Meta':>6} {'Coste':>7} {'€·AT':>7} {'€·Meta':>7} {'Margen':>9} {'%':>7}"
sep = "" * len(hdr)
lines = [hdr, sep]
tot = {"spend": 0.0, "leads_at": 0, "leads_meta": 0, "ing_at": 0.0, "ing_meta": 0.0}
for d in daily_totals:
day = d["date"][8:10] + "/" + d["date"][5:7]
for k in tot:
tot[k] += d.get(k, 0)
lines.append(
f"{day:<5} {d['leads_at']:>5} {d['leads_meta']:>6} "
f"{d['spend']:>6.0f}{d['ing_at']:>6.0f}{d['ing_meta']:>6.0f}"
f"{_marg(d['margin']):>9} {_pct(d['margin_pct']):>7}"
)
lines.append(sep)
tot_margin = tot["ing_meta"] - tot["spend"]
tot_pct = round(tot_margin / tot["ing_meta"] * 100, 1) if tot["ing_meta"] > 0 else 0.0
lines.append(
f"{'TOT':<5} {tot['leads_at']:>5} {tot['leads_meta']:>6} "
f"{tot['spend']:>6.0f}{tot['ing_at']:>6.0f}{tot['ing_meta']:>6.0f}"
f"{_marg(tot_margin):>9} {_pct(tot_pct):>7}"
)
text = (
f"*Métricas por día — {month_name}* "
"_(L·AT=leads Airtable · L·Meta=conversión propia Meta · €·AT/€·Meta=leads×PPL de cada fuente)_\n"
"```\n" + "\n".join(lines) + "\n```"
)
return {"type": "section", "text": {"type": "mrkdwn", "text": text}}
def send_daily_report(
daily_totals: list,
best_campaigns: list,
worst_campaigns: list,
actions: list,
campaigns_analyzed: int,
mode: str = "DRY_RUN",
campaign_details: dict = None,
curso_summary: dict = None,
portfolio_analysis_text: str = None,
) -> str | None:
"""Envía el informe diario consolidado (un único bloque de Formación,
sin agrupar por familia). Devuelve el ts del primer mensaje."""
now = datetime.now()
date_label = now.strftime("%d/%m/%Y")
month_name = now.strftime("%B %Y").capitalize()
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 {}
campaigns = [(cid, detail, action_map.get(detail["name"])) for cid, detail in details_map.items()]
# ── Message 1: Dashboard ─────────────────────────────────────────────────
blocks: list = [
{
"type": "header",
"text": {"type": "plain_text", "text": f"Meta Optimizer Formación — {date_label} ({mode_label})"},
},
]
if daily_totals:
tot_spend = sum(d["spend"] for d in daily_totals)
tot_leads_m = sum(d["leads_meta"] for d in daily_totals)
tot_leads_at = sum(d["leads_at"] for d in daily_totals)
tot_ing_m = sum(d["ing_meta"] for d in daily_totals)
tot_ing_at = sum(d["ing_at"] for d in daily_totals)
margen_m = tot_ing_m - tot_spend
margen_at = tot_ing_at - tot_spend
pct_m = round(margen_m / tot_ing_m * 100, 1) if tot_ing_m > 0 else 0.0
pct_at = round(margen_at / tot_ing_at * 100, 1) if tot_ing_at > 0 else 0.0
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"📊 *RESUMEN {month_name.upper()}*\n"
f"Inversión: *{tot_spend:,.0f}€* | Leads Meta: *{int(tot_leads_m)}* | "
f"Leads Airtable: *{int(tot_leads_at)}*\n"
f"Ingreso según Meta: *{tot_ing_m:,.0f}€* | Margen: *{_marg(margen_m)}* ({_pct(pct_m)})\n"
f"Ingreso según Airtable: *{tot_ing_at:,.0f}€* | Margen: *{_marg(margen_at)}* ({_pct(pct_at)})\n"
f"_El margen oficial usa el tracking propio de Meta; Airtable se muestra en paralelo "
f"para contrastar discrepancias de tracking._"
).replace(",", "."),
},
})
blocks.append({"type": "divider"})
daily_block = _daily_table_block(daily_totals, month_name)
if daily_block:
blocks.append(daily_block)
else:
blocks.append({
"type": "section",
"text": {"type": "mrkdwn", "text": "_Sin datos del mes en curso aún._"},
})
blocks.append({
"type": "context",
"elements": [{"type": "mrkdwn", "text": f"{campaigns_analyzed} campañas analizadas"}],
})
result = _post(
"chat.postMessage",
channel=config.SLACK_CHANNEL_ID,
blocks=blocks,
text=f"Meta Optimizer Formación — {date_label}",
)
ts = result.get("ts")
# ── Message 2: resumen y contraste por curso ───────────────────────────────
curso_blocks = _curso_summary_blocks(curso_summary or {})
if curso_blocks:
_post("chat.postMessage", channel=config.SLACK_CHANNEL_ID, blocks=curso_blocks,
text="Resumen y contraste por curso")
# ── Message 3: diagnóstico estratégico ──────────────────────────────────────
if portfolio_analysis_text:
_post(
"chat.postMessage",
channel=config.SLACK_CHANNEL_ID,
blocks=[
{"type": "header", "text": {"type": "plain_text", "text": "🤖 Diagnóstico estratégico"}},
{"type": "section", "text": {"type": "mrkdwn", "text": portfolio_analysis_text[:2950]}},
],
text="Diagnóstico estratégico",
)
# ── Mensajes 4..N: tarjetas por campaña, en lotes (sin agrupar por familia) ─
def _has_pause_ads(detail):
return any(ad.get("accion") == "PAUSE" and ad.get("row_id") for ad in detail.get("ads", []))
def _priority_key(item):
_, detail, act = item
urgencia = detail.get("urgencia", "EN_RITMO")
atype = act["action_type"] if act else "MAINTAIN"
if urgencia in ("PAUSAR", "SPRINT"):
p = 0
elif atype != "MAINTAIN" or _has_pause_ads(detail):
p = 1
else:
p = 2
return (p, -detail.get("spend_1d", 0))
sorted_campaigns = sorted(campaigns, key=_priority_key)
BATCH_SIZE = 6
for batch_start in range(0, len(sorted_campaigns), BATCH_SIZE):
batch = sorted_campaigns[batch_start:batch_start + BATCH_SIZE]
c_blocks: list = []
for i, (cid, detail, act) in enumerate(batch):
if i > 0:
c_blocks.append({"type": "divider"})
name = detail["name"]
familia = detail.get("familia", "")
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))
camp_text = (
f"{cemoji} *{name}*" + (f" _{familia}_" if familia and familia != "Sin familia" else "") + "\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 "")
)
if atype != "MAINTAIN":
camp_text += f"\n*{alabel}*"
if act and act.get("justification"):
camp_text += f" — _{act['justification'][:160]}_"
if act and act.get("advice"):
camp_text += f"\n💡 {act['advice'][:160]}"
if act and act.get("alert"):
camp_text += f"\n:warning: {act['alert'][:130]}"
c_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:
c_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": effect}})
c_blocks.append({
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "✅ Aprobar"},
"style": "primary",
"value": _btn_value("approve", act["row_id"]),
"action_id": f"approve_{PROJECT_TAG}_{act['row_id']}",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "❌ Rechazar"},
"style": "danger",
"value": _btn_value("reject", act["row_id"]),
"action_id": f"reject_{PROJECT_TAG}_{act['row_id']}",
},
],
})
# Adsets: tabla + recomendación de cada uno
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)]:
c_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}})
rec_lines = [
f"• _{_table_name(a['name'], 40)}_: {a['recomendacion'][:110]}"
for a in adsets[:3] if a.get("recomendacion")
]
if rec_lines:
c_blocks.append({"type": "section", "text": {"type": "mrkdwn",
"text": "*Recomendaciones (conjuntos):*\n" + "\n".join(rec_lines)}})
# Anuncios: recomendaciones de los marcados para pausa + botón
pause_ads = [a for a in ads if a.get("accion") == "PAUSE" and a.get("row_id")]
for ad in pause_ads:
rec = ad.get("recomendacion") or "Sin leads en 7 días."
c_blocks.append({
"type": "section",
"text": {"type": "mrkdwn", "text": f"⛔ *{ad['name'][:80]}*\n{rec[:150]}"},
})
c_blocks.append({
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "⛔ Pausar anuncio"},
"style": "danger",
"value": _btn_value("approve", ad["row_id"]),
"action_id": f"approve_{PROJECT_TAG}_{ad['row_id']}",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "❌ Rechazar"},
"value": _btn_value("reject", ad["row_id"]),
"action_id": f"reject_{PROJECT_TAG}_{ad['row_id']}",
},
],
})
_post(
"chat.postMessage",
channel=config.SLACK_CHANNEL_ID,
blocks=c_blocks,
text=f"Campañas {batch_start + 1}{batch_start + len(batch)} de {len(sorted_campaigns)}",
)
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)