meta-optimizer-formacion/send_slack_report.py
José Manuel Gómez 9239e2f67f Initial scaffold: Meta Optimizer for RoiFormacion campaigns
Ports meta-optimizer's Meta Ads execution/approval/creative-analysis layer
(agent.py, meta_ads_client.py, baserow_client.py, slack_notifier.py,
approval_server.py) and replaces the per-vertical CPL model with the
PPL + monthly-capping-per-course model already used by leads-optimizer,
via a new airtable_client.py that shares Cursos/Familias/CentroCurso/
CursoMes/Leads Lake with that project and adds Meta Ads Campaigns /
MetaCampaignMes alongside its Google Ads Campaigns / GACampaignMes.
2026-07-07 16:53:03 +02:00

169 lines
6.8 KiB
Python

"""Re-send a day's Slack report from Baserow snapshots (sin llamar a Meta por campaña)."""
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
import json
from datetime import datetime
import config
from meta_ads_client import MetaAdsClient
from airtable_client import AirtableClient, extract_cursoid
from baserow_client import BaserowClient
import slack_notifier
def main():
run_date = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y-%m-%d")
print(f"Reenviando informe para {run_date}...")
meta = MetaAdsClient()
baserow = BaserowClient()
airtable = AirtableClient()
ppl_lookup, _, familia_lookup = airtable.build_campaign_lookups(as_of_date=run_date)
# ── Monthly daily totals (fresh de Meta, no se persisten por campaña) ──────
print("Obteniendo datos mensuales de Meta...")
month_start = f"{run_date[:7]}-01"
daily_rows = meta.get_daily_campaign_rows(month_start, run_date)
_daily: dict = {}
monthly_familias: dict = {}
for row in daily_rows:
cursoid = extract_cursoid(row["campaign_name"]) or ""
familia = familia_lookup.get(cursoid, "Sin familia")
ppl = ppl_lookup.get(cursoid, 0)
margin = round(row["leads"] * ppl - row["spend"], 2)
d = _daily.setdefault(row["date"], {"spend": 0.0, "leads": 0, "margin": 0.0, "f_margins": {}})
d["spend"] += row["spend"]
d["leads"] += row["leads"]
d["margin"] += margin
d["f_margins"][familia] = d["f_margins"].get(familia, 0.0) + margin
mf = monthly_familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
mf["spend"] += row["spend"]
mf["leads"] += row["leads"]
mf["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),
"f_margins": {f: round(m, 0) for f, m in d["f_margins"].items()},
}
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 ───────────────────────────────────────────
# Nota: urgencia/leads_mes/capping no se persisten en daily_snapshots, así
# que al reenviar desde snapshots esos campos salen con su valor por
# defecto (slack_notifier ya los trata con .get(...)).
campaign_details: dict = {}
actions: list = []
familias: dict = {}
metrics_all: dict = {}
for snap in snapshots:
cid = snap.get("campaign_id") or snap.get("campaign_name", "")
name = snap["campaign_name"]
familia = snap.get("familia") or familia_lookup.get(extract_cursoid(name) or "", "Sin familia")
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,
"familia": familia,
"margin": margin,
"spend_1d": spend,
"leads_1d": leads,
"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,
"parameter": action_params.get(name, 1.0),
"row_id": snap["id"],
})
f = familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
f["spend"] += spend
f["leads"] += leads
f["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,
campaigns_analyzed=len(snapshots),
mode="DRY_RUN",
familias=familias,
campaign_details=campaign_details,
monthly_familias=monthly_familias,
)
if ts:
print(f" ✓ Mensaje enviado (ts={ts})")
else:
print(" ✗ Error al enviar (revisa token y canal)")
if __name__ == "__main__":
main()