- Rename table abbreviations that Slack misread as domains: 'CPL.AT' was auto-unfurled as a link to the .AT (Austria) TLD, posting an unrelated business's ad copy into the channel. Switch '.' to '·' in all L/€/CPL abbreviations, and set unfurl_links=False/unfurl_media=False on every postMessage as defense in depth. - Pass ppl/cpa_maximo into adset analysis too (ads already had it) so Claude's adset evaluation compares CPL against the course's PPL-derived rentability threshold, not just Meta's own bid cost cap. - Add PPL and Margen columns to the adset numeric table in Slack.
582 lines
25 KiB
Python
582 lines
25 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:
|
||
# 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_meta−leads_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)
|