From 3afc1c25c183c9de29a906d8c60ced09f7971e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Manuel=20G=C3=B3mez?= Date: Tue, 16 Jun 2026 21:38:36 +0200 Subject: [PATCH] Simplify Slack report: vertical-first hierarchical structure Replace one-message-per-campaign with: - Message 1: monthly table + vertical scorecard + actions + todo-en-orden - One message per vertical that has non-MAINTAIN actions or ad PAUSEs - Verticals with no issues are summarised in a single line in message 1 - Remove top-10 best/worst lists (noise) - Adset detail (top 3) only shown for non-MAINTAIN campaigns - Recommendations and approve/reject buttons are now front and centre Co-Authored-By: Claude Sonnet 4.6 --- slack_notifier.py | 284 +++++++++++++++++++++++++++++----------------- 1 file changed, 181 insertions(+), 103 deletions(-) diff --git a/slack_notifier.py b/slack_notifier.py index 781bb2d..2ff970f 100644 --- a/slack_notifier.py +++ b/slack_notifier.py @@ -157,6 +157,16 @@ def _adset_ad_table(items: list, label: str, show_bid: bool = False, show_7d: bo return "\n".join(lines) +def _vertical_status_emoji(v_cpl: float, v_obj: float, has_action: bool) -> str: + if v_cpl == 0 or v_obj == 0: + return "⚪" + if v_cpl <= v_obj: + return "⚠️" if has_action else "✅" + if v_cpl <= v_obj * 1.3: + return "⚠️" + return "❌" + + def send_daily_report( daily_totals: list, best_campaigns: list, @@ -175,8 +185,34 @@ def send_daily_report( month_name = now.strftime("%B %Y").capitalize() prefix = config.META_CAMPAIGN_PREFIX mode_label = "DRY RUN" if mode == "DRY_RUN" else "PRODUCCIÓN" - target_str = f"{target_cpl:.2f}€" if target_cpl > 0 else "—" + action_map = {a["campaign_name"]: a for a in actions} + details_map = campaign_details or {} + name_to_cid = {d["name"]: cid for cid, d in details_map.items()} + + # ── Classify campaigns: issues vs OK ───────────────────────────────────── + camps_issues: dict = {} # name → (cid, detail, action_or_None) + camps_ok: dict = {} # name → (cid, detail) + + for cid, detail in details_map.items(): + name = detail["name"] + act = action_map.get(name) + has_action = bool(act and act["action_type"] != "MAINTAIN") + has_ad_pause = any( + ad.get("accion") == "PAUSE" and ad.get("row_id") + for ad in detail.get("ads", []) + ) + if has_action or has_ad_pause: + camps_issues[name] = (cid, detail, act) + else: + camps_ok[name] = (cid, detail) + + # Group issues by vertical + by_vertical_issues: dict = {} + for name, (cid, detail, act) in camps_issues.items(): + by_vertical_issues.setdefault(detail["vertical"], []).append((cid, detail, act)) + + # ── Message 1: Executive dashboard ─────────────────────────────────────── blocks: list = [ { "type": "header", @@ -185,35 +221,30 @@ def send_daily_report( }, ] - # ── Rentabilidad mensual con margen por vertical ───────────────────────── + # Monthly profitability table if daily_totals: - # Orden de verticales: por margen mensual acumulado (mejor primero) v_order = ( sorted(monthly_verticals.keys(), key=lambda v: -monthly_verticals[v]["margin"]) if monthly_verticals else [] ) - cw = 7 # ancho de cada columna de vertical - - # Cabecera + cw = 7 header = f"{'Día':<5} {'Gasto':>6} {'Leads':>5} {'CPL':>7}" for v in v_order: header += f" {v[:6]:>{cw}}" header += " Est" sep = "─" * len(header) - lines = [header, sep] total_spend = total_leads = total_margin = 0.0 total_v = {v: 0.0 for v in v_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 - v_day = d.get("v_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}€" + v_day = d.get("v_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 v in v_order: vm = v_day.get(v, 0.0) total_v[v] += vm @@ -221,15 +252,13 @@ def send_daily_report( row += f" {vm_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 v in v_order: - tv = total_v[v] + tv = total_v[v] tv_s = f"+{tv:.0f}€" if tv >= 0 else f"{tv:.0f}€" total_row += f" {tv_s:>{cw}}" lines.append(total_row) - blocks.append({ "type": "section", "text": { @@ -243,11 +272,12 @@ def send_daily_report( "text": {"type": "mrkdwn", "text": "_Sin datos del mes en curso aún._"}, }) - # ── Resumen por vertical ────────────────────────────────────────────────── + blocks.append({"type": "divider"}) + + # Vertical scorecard if verticals: - blocks.append({"type": "divider"}) - lines = [f"{'Vertical':<16} {'Gasto':>7} {'Leads':>5} {'CPL':>7} {'Obj':>7} {'Margen':>9}"] - lines.append("─" * 57) + lines = [f"{'Vertical':<16} {'Gasto':>6} {'Leads':>5} {'CPL':>7} {'Obj':>7} {'Margen':>9}"] + lines.append("─" * 58) for v, data in sorted(verticals.items(), key=lambda x: -x[1]["margin"]): v_leads = data["leads"] v_spend = data["spend"] @@ -256,51 +286,93 @@ def send_daily_report( v_obj = data.get("target_cpl", 0) m_sign = f"+{v_m:.0f}€" if v_m >= 0 else f"{v_m:.0f}€" obj_str = f"{v_obj:.2f}€" if v_obj else " —" - lines.append(f"{v:<16} {v_spend:>6.0f}€ {v_leads:>5} {v_cpl:>6.2f}€ {obj_str:>7} {m_sign:>9}") - blocks.append({ - "type": "section", - "text": {"type": "mrkdwn", - "text": "*Resumen por vertical (ayer)*\n```" + "\n".join(lines) + "```"}, - }) - - blocks.append({"type": "divider"}) - - # ── Top 10 mejores ──────────────────────────────────────────────────────── - if best_campaigns: - lines = [] - for i, m in enumerate(best_campaigns, 1): + has_act = v in by_vertical_issues + st = _vertical_status_emoji(v_cpl, v_obj, has_act) lines.append( - f"{i}. *{m['name'][:46]}* CPL: {m['cpl']:.2f}€ | {m['leads']} leads | {m['spend']:.0f}€" + f"{st} {v:<14} {v_spend:>5.0f}€ {v_leads:>5} {v_cpl:>6.2f}€ {obj_str:>7} {m_sign:>9}" ) blocks.append({ "type": "section", - "text": {"type": "mrkdwn", "text": "*Top 10 mejores (ayer)*\n" + "\n".join(lines)}, + "text": {"type": "mrkdwn", + "text": "*Verticales · ayer*\n```" + "\n".join(lines) + "```"}, }) - blocks.append({"type": "divider"}) - # ── Top 10 peores ───────────────────────────────────────────────────────── - if worst_campaigns: - lines = [] - for i, m in enumerate(worst_campaigns, 1): - if m["leads"] == 0: - lines.append(f"{i}. *{m['name'][:46]}* 0 leads | {m['spend']:.0f}€ gastado") - else: - lines.append( - f"{i}. *{m['name'][:46]}* CPL: {m['cpl']:.2f}€ | {m['leads']} leads | {m['spend']:.0f}€" - ) + # Actions section + if actions: + blocks.append({"type": "divider"}) blocks.append({ "type": "section", - "text": {"type": "mrkdwn", "text": "*Top 10 peores (ayer)*\n" + "\n".join(lines)}, + "text": {"type": "mrkdwn", "text": "*🚨 Acciones recomendadas*"}, + }) + for act in actions: + atype = act["action_type"] + emoji, alabel = _ACTION_DISPLAY.get(atype, ("⚪", atype)) + cid = name_to_cid.get(act["campaign_name"]) + bid_cfg = details_map.get(cid, {}).get("bid_config", {}) if cid else {} + budget = bid_cfg.get("daily_budget_eur") + text = f"{emoji} *{act['campaign_name'][:60]}* → *{alabel}*" + if act.get("justification"): + text += f"\n_{act['justification'][:200]}_" + if act.get("alert"): + text += f"\n:warning: {act['alert'][:150]}" + effect = _effect_text(act, budget) + if effect: + text += f"\n{effect}" + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}}) + if atype in _ACTIONABLE: + 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']}", + }, + ], + }) + + # Ad pause count notice + total_ad_pauses = sum( + 1 for _, detail, _ in camps_issues.values() + for ad in detail.get("ads", []) + if ad.get("accion") == "PAUSE" and ad.get("row_id") + ) + if total_ad_pauses > 0: + noun = "anuncio" if total_ad_pauses == 1 else "anuncios" + blocks.append({ + "type": "context", + "elements": [{"type": "mrkdwn", + "text": f"⛔ {total_ad_pauses} {noun} recomendados para pausar — ver detalle por vertical"}], + }) + + # "Todo en orden" summary + if camps_ok: + blocks.append({"type": "divider"}) + ok_lines = [] + for name, (_, detail) in sorted(camps_ok.items(), key=lambda x: x[1][1]["vertical"]): + v = detail["vertical"] + leads_1d = detail.get("leads_1d", 0) + spend_1d = detail.get("spend_1d", 0.0) + ok_lines.append(f"✅ *{name}* · {spend_1d:.0f}€ / {leads_1d} leads ayer") + blocks.append({ + "type": "section", + "text": {"type": "mrkdwn", "text": "*Todo en orden*\n" + "\n".join(ok_lines)}, }) - blocks.append({"type": "divider"}) blocks.append({ "type": "context", - "elements": [{"type": "mrkdwn", - "text": f"{campaigns_analyzed} campañas analizadas — detalle por campaña a continuación"}], + "elements": [{"type": "mrkdwn", "text": f"{campaigns_analyzed} campañas analizadas"}], }) - # ── Enviar resumen ──────────────────────────────────────────────────────── result = _post( "chat.postMessage", channel=config.SLACK_CHANNEL_ID, @@ -309,92 +381,98 @@ def send_daily_report( ) ts = result.get("ts") - # ── Un mensaje por campaña (sin límite de bloques) ──────────────────────── - if campaign_details: - action_map = {a["campaign_name"]: a for a in actions} + # ── Messages 2-N: one per vertical with issues ──────────────────────────── + for v, camp_list in sorted(by_vertical_issues.items()): + v_data = (verticals or {}).get(v, {}) + v_spend = v_data.get("spend", 0) + v_leads = v_data.get("leads", 0) + v_cpl = round(v_spend / v_leads, 2) if v_leads > 0 else 0.0 + v_obj = v_data.get("target_cpl", 0) + v_margin = v_data.get("margin", 0) + m_str = f"+{v_margin:.0f}€" if v_margin >= 0 else f"{v_margin:.0f}€" + obj_str = f" · obj {v_obj:.2f}€" if v_obj else "" - for cid, detail in campaign_details.items(): + v_blocks: list = [{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + f"📊 *{v.upper()}* · {v_spend:.0f}€ · {v_leads} leads · " + f"CPL {v_cpl:.2f}€{obj_str} · Margen {m_str}" + ), + }, + }] + + for cid, detail, act in sorted(camp_list, key=lambda x: -x[1].get("spend_1d", 0)): name = detail["name"] - vertical = detail["vertical"] - margin = detail["margin"] 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}€" adsets = detail.get("adsets", []) ads = detail.get("ads", []) bid_cfg = detail.get("bid_config", {}) - m_str = f"+{margin:.2f}€" if margin >= 0 else f"{margin:.2f}€" - action = action_map.get(name) + budget = bid_cfg.get("daily_budget_eur") + strategy = bid_cfg.get("bid_strategy", "") + strat_label = _STRATEGY_LABELS.get(strategy, strategy or "—") - strategy = bid_cfg.get("bid_strategy", "") - strategy_label = _STRATEGY_LABELS.get(strategy, strategy or "—") - budget = bid_cfg.get("daily_budget_eur") - budget_str = f"{budget:.0f}€/día" if budget else "—" - - atype = action["action_type"] if action else "MAINTAIN" + atype = act["action_type"] if act else "MAINTAIN" emoji, alabel = _ACTION_DISPLAY.get(atype, ("⚪", atype)) - header_text = ( + camp_text = ( f"{emoji} *{name}*\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}*" + f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · Margen: {m_str2} · " + f"`{strat_label}`" + (f" · {budget:.0f}€/día" if budget else "") + + f"\n*{alabel}*" ) - if action: - header_text += f"\n_{action.get('justification', '')[:500]}_" - if action.get("alert"): - header_text += f"\n:warning: {action['alert']}" + 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]}" - camp_blocks: list = [ - {"type": "section", "text": {"type": "mrkdwn", "text": header_text}}, - ] + v_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": camp_text}}) - # Botones de acción a nivel campaña - if action and atype in _ACTIONABLE: - effect = _effect_text(action, budget) + # Approve/Reject buttons for campaign action + if act and atype in _ACTIONABLE: + effect = _effect_text(act, budget) if effect: - camp_blocks.append({ - "type": "section", - "text": {"type": "mrkdwn", "text": effect}, - }) - camp_blocks.append({ + v_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": effect}}) + v_blocks.append({ "type": "actions", "elements": [ { "type": "button", "text": {"type": "plain_text", "text": "✅ Aprobar"}, "style": "primary", - "value": f"approve:{action['row_id']}", - "action_id": f"approve_{action['row_id']}", + "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:{action['row_id']}", - "action_id": f"reject_{action['row_id']}", + "value": f"reject:{act['row_id']}", + "action_id": f"reject_{act['row_id']}", }, ], }) - # Tabla adsets + 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({ - "type": "section", - "text": {"type": "mrkdwn", "text": chunk}, - }) + # Adset table — top 3, only for non-MAINTAIN campaigns + if atype != "MAINTAIN" and adsets: + adset_table = _adset_ad_table(adsets[:3], "Conjuntos de anuncios (3 días)", show_bid=True) + if adset_table: + for chunk in [adset_table[i:i+2900] for i in range(0, len(adset_table), 2900)]: + v_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}}) - # Botones de pausa por anuncio - camp_blocks.extend(_ad_action_blocks(ads)) + # Ad pause buttons + v_blocks.extend(_ad_action_blocks(ads)) - _post( - "chat.postMessage", - channel=config.SLACK_CHANNEL_ID, - blocks=camp_blocks, - text=name, - ) + _post( + "chat.postMessage", + channel=config.SLACK_CHANNEL_ID, + blocks=v_blocks, + text=f"Detalle vertical: {v}", + ) return ts