meta-optimizer-formacion/slack_notifier.py
José Manuel Gómez 769d86c896 Unified Formación report: leadform+landing leads, AT/Meta daily table, per-curso contrast, strategic diagnosis
- Broaden Airtable lead counting to attr_utm_source IN ('Lead ads','landingpage')
  — the 'landingpage' leads (100% fbclid, 0% gclid) were being missed entirely,
  undercounting real leads for '_web' suffixed campaigns and skewing
  capping/pacing decisions since yesterday's first production run.
- Add airtable_client.get_meta_leads_bulk() for day/curso-level aggregation.
- Drop per-familia Slack sectioning in favor of a single Formación block,
  chunked by campaign batches instead.
- Add daily AT-vs-Meta table, per-curso PPL/CPL contrast table (leadform vs
  landing breakdown), and a Claude-generated portfolio strategic diagnosis
  (ported from leads-optimizer's portfolio_daily_analysis).
- Persist daily aggregate totals to a new Baserow table (daily_metrics) so
  the dashboard and future reports don't depend on Meta's historical API
  access remaining available indefinitely.
- Surface adset/ad-level recommendations in the campaign cards instead of
  only numeric tables.
2026-07-09 11:02:19 +02:00

570 lines
24 KiB
Python
Raw 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"
_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 _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 _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": 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']}",
},
],
})
# 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": 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']}",
},
],
})
_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)