meta-optimizer/send_slack_report.py
José Manuel Gómez 8fb0b69896 Add Baserow persistence, Slack interactive reports, Streamlit dashboard, and full analysis pipeline
- Migrate from Airtable to Baserow: BaserowClient with snapshots, actions, creatives, logs
- Claude agents (Haiku for decisions/units, Sonnet for creatives) with cost_cap_eur vs CPL comparison
- Slack bot with colored action emojis, effect text before approval buttons, 500-char justifications
- Streamlit dashboard with date-range navigation, campaign drill-down (adsets/ads), Histórico tab
- Approval server (FastAPI + ngrok) for Slack button callbacks
- backfill.py for historical snapshot regeneration with Claude re-analysis
- Margin fix: 0-lead campaigns contribute -spend (not 0) to margin
- CTR fix: Meta returns CTR as percentage already, removed *100
- Parameter fix: pass decision parameter to Slack action for correct budget effect display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:30:13 +02:00

174 lines
6.9 KiB
Python

"""Re-send yesterday's Slack report from Baserow snapshots."""
import json
from datetime import datetime, timedelta
import config
from meta_ads_client import MetaAdsClient
from baserow_client import BaserowClient
import slack_notifier
def _extract_vertical(name: str) -> str:
prefix = config.META_CAMPAIGN_PREFIX
rest = name[len(prefix):].lstrip("_")
parts = rest.split("_")
start = 1 if parts and parts[0].isdigit() else 0
return parts[start].lower() if start < len(parts) else "otros"
def main():
run_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
print(f"Reenviando informe para {run_date}...")
meta = MetaAdsClient()
baserow = BaserowClient()
# ── Vertical CPL targets ──────────────────────────────────────────────────
vertical_cpls: dict = {}
try:
for v in baserow.get_all_verticals():
name = (v.get("Nombre") or "").strip().lower()
cpl = float(v.get("target_cpl") or 0)
if name and cpl:
vertical_cpls[name] = cpl
print(f" ✓ Verticales: {vertical_cpls}")
except Exception as e:
print(f" ⚠ No se pudieron cargar verticales: {e}")
# ── Monthly daily totals ──────────────────────────────────────────────────
print("Obteniendo datos mensuales de Meta...")
daily_rows = meta.get_monthly_daily_totals()
_daily: dict = {}
for row in daily_rows:
v = _extract_vertical(row["campaign_name"])
target = vertical_cpls.get(v, config.META_TARGET_CPL)
margin = (
round((target - row["spend"] / row["leads"]) * row["leads"], 2)
if row["leads"] > 0 else round(-row["spend"], 2)
)
d = _daily.setdefault(row["date"], {"spend": 0.0, "leads": 0, "margin": 0.0})
d["spend"] += row["spend"]
d["leads"] += row["leads"]
d["margin"] += margin
daily_totals = [
{
"date": date,
"spend": round(d["spend"], 2),
"leads": int(d["leads"]),
"cpl": round(d["spend"] / d["leads"], 2) if d["leads"] > 0 else 0.0,
"margin": round(d["margin"], 2),
}
for date, d in sorted(_daily.items())
]
print(f"{len(daily_totals)} días con datos")
# ── Load proposed actions (to get parameter values) ──────────────────────
action_params: dict = {} # campaign_name → parameter
try:
all_actions = baserow._get_rows(config.BASEROW_TABLE_ACTIONS, {
"filter__proposed_at__equal": run_date,
})
for a in all_actions:
cname = a.get("campaign_name", "")
param = a.get("parameter")
if cname and param:
action_params[cname] = float(param)
print(f"{len(action_params)} parámetros de acción cargados")
except Exception as e:
print(f" Aviso: no se pudieron cargar parámetros de acción: {e}")
# ── Load snapshots from Baserow ───────────────────────────────────────────
print(f"Cargando snapshots de Baserow para {run_date}...")
snapshots = baserow.get_snapshots_for_date(run_date)
print(f"{len(snapshots)} snapshots encontrados")
if not snapshots:
print("ERROR: No hay snapshots en Baserow para esta fecha. "
"Ejecuta run.py primero.")
return
# ── Reconstruct data structures ───────────────────────────────────────────
campaign_details: dict = {}
actions: list = []
verticals: dict = {}
metrics_all: dict = {}
for snap in snapshots:
cid = snap.get("campaign_id") or snap.get("campaign_name", "")
name = snap["campaign_name"]
vertical = snap.get("vertical") or _extract_vertical(name)
margin = float(snap.get("margin") or 0)
spend = float(snap.get("spend") or 0)
leads = int(snap.get("leads") or 0)
cpl = float(snap.get("cpl") or 0)
action_type = snap.get("action_type") or "MAINTAIN"
try:
adsets = json.loads(snap.get("adsets_json") or "[]")
except Exception:
adsets = []
try:
ads = json.loads(snap.get("ads_json") or "[]")
except Exception:
ads = []
campaign_details[cid] = {
"name": name,
"vertical": vertical,
"margin": margin,
"adsets": adsets,
"ads": ads,
"bid_config": {},
}
metrics_all[cid] = {"name": name, "spend": spend, "leads": leads, "cpl": cpl}
if action_type != "MAINTAIN":
actions.append({
"campaign_name": name,
"action_type": action_type,
"justification": snap.get("justification") or "",
"advice": "",
"alert": "",
"confidence": 0.8,
"cpl": cpl,
"parameter": action_params.get(name, 1.0),
"row_id": snap["id"],
})
max_cpl = vertical_cpls.get(vertical, config.META_TARGET_CPL)
if vertical not in verticals:
verticals[vertical] = {"spend": 0.0, "leads": 0, "margin": 0.0, "target_cpl": max_cpl}
verticals[vertical]["spend"] += spend
verticals[vertical]["leads"] += leads
verticals[vertical]["margin"] += margin
# ── Best / worst ──────────────────────────────────────────────────────────
with_leads = [m for m in metrics_all.values() if m["leads"] > 0]
best_10 = sorted(with_leads, key=lambda x: x["cpl"])[:10]
worst_10 = sorted(
list(metrics_all.values()),
key=lambda x: (x["leads"] > 0, -x["cpl"] if x["leads"] > 0 else 0),
)[:10]
# ── Send ──────────────────────────────────────────────────────────────────
print("Enviando a Slack...")
ts = slack_notifier.send_daily_report(
daily_totals=daily_totals,
best_campaigns=best_10,
worst_campaigns=worst_10,
actions=actions,
target_cpl=config.META_TARGET_CPL,
campaigns_analyzed=len(snapshots),
mode="DRY_RUN",
verticals=verticals,
campaign_details=campaign_details,
)
if ts:
print(f" ✓ Mensaje enviado (ts={ts})")
else:
print(" ✗ Error al enviar (revisa token y canal)")
if __name__ == "__main__":
main()