meta-optimizer-formacion/send_slack_report.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

194 lines
7.7 KiB
Python

"""Re-send a day's Slack report (tabla diaria/resumen por curso frescos de
Meta+Airtable; tarjetas por campaña reconstruidas desde snapshots de Baserow)."""
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, cap_lookup, familia_lookup = airtable.build_campaign_lookups(as_of_date=run_date)
# ── Monthly daily totals: Leads Meta vs Leads Airtable (fresco, no se persiste) ─
print("Obteniendo datos mensuales de Meta y Airtable...")
month_start = f"{run_date[:7]}-01"
daily_rows = meta.get_daily_campaign_rows(month_start, run_date)
daily_at_leads = airtable.get_meta_leads_bulk(month_start, run_date)
_daily: dict = {}
for row in daily_rows:
cursoid = extract_cursoid(row["campaign_name"]) or ""
ppl = ppl_lookup.get(cursoid, 0)
d = _daily.setdefault(row["date"], {
"spend": 0.0, "leads_meta": 0, "leads_at": 0, "ing_meta": 0.0, "ing_at": 0.0,
})
d["spend"] += row["spend"]
d["leads_meta"] += row["leads"]
d["ing_meta"] += row["leads"] * ppl
for lead in daily_at_leads:
ppl = ppl_lookup.get(lead["cursoid"], 0)
d = _daily.setdefault(lead["date"], {
"spend": 0.0, "leads_meta": 0, "leads_at": 0, "ing_meta": 0.0, "ing_at": 0.0,
})
d["leads_at"] += 1
d["ing_at"] += ppl
daily_totals = [
{
"date": date,
"spend": round(d["spend"], 2),
"leads_meta": int(d["leads_meta"]),
"leads_at": int(d["leads_at"]),
"ing_meta": round(d["ing_meta"], 2),
"ing_at": round(d["ing_at"], 2),
"margin": round(d["ing_meta"] - d["spend"], 2),
"margin_pct": round((d["ing_meta"] - d["spend"]) / d["ing_meta"] * 100, 1) if d["ing_meta"] > 0 else 0.0,
}
for date, d in sorted(_daily.items())
]
print(f"{len(daily_totals)} días con datos")
# ── Resumen y contraste por curso (mismo cálculo que run.py) ────────────────
monthly_metrics_meta = meta.get_campaign_metrics(month_start, run_date)
name_by_cid = {}
for row in meta.get_all_campaigns():
name_by_cid[row["id"]] = row["name"]
def _new_curso_entry(cid_: str) -> dict:
return {
"campaigns": [], "familia": familia_lookup.get(cid_, "Sin familia"),
"ppl": ppl_lookup.get(cid_, 0), "spend": 0.0, "leads_meta": 0,
"leads_at_leadform": 0, "leads_at_landing": 0,
}
curso_summary: dict = {}
for mcid, m in monthly_metrics_meta.items():
name = name_by_cid.get(mcid, mcid)
cursoid = extract_cursoid(name) or ""
if not cursoid:
continue
cs = curso_summary.setdefault(cursoid, _new_curso_entry(cursoid))
cs["campaigns"].append(name)
cs["spend"] += m.get("spend", 0.0)
cs["leads_meta"] += m.get("leads", 0)
for lead in daily_at_leads:
cs = curso_summary.setdefault(lead["cursoid"], _new_curso_entry(lead["cursoid"]))
if lead["utm_source"] == "Lead ads":
cs["leads_at_leadform"] += 1
else:
cs["leads_at_landing"] += 1
for cursoid, cs in curso_summary.items():
leads_at_total = cs["leads_at_leadform"] + cs["leads_at_landing"]
cs["leads_at_total"] = leads_at_total
cs["cpl_meta"] = round(cs["spend"] / cs["leads_meta"], 2) if cs["leads_meta"] > 0 else 0.0
cs["cpl_at"] = round(cs["spend"] / leads_at_total, 2) if leads_at_total > 0 else 0.0
cs["discrepancia"] = cs["leads_meta"] - leads_at_total
# ── 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 = []
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)
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": {},
}
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"],
})
# ── Send (sin diagnóstico estratégico: reenviar no vuelve a llamar a Claude) ─
print("Enviando a Slack...")
ts = slack_notifier.send_daily_report(
daily_totals=daily_totals,
best_campaigns=[],
worst_campaigns=[],
actions=actions,
campaigns_analyzed=len(snapshots),
mode="DRY_RUN",
campaign_details=campaign_details,
curso_summary=curso_summary,
portfolio_analysis_text=None,
)
if ts:
print(f" ✓ Mensaje enviado (ts={ts})")
else:
print(" ✗ Error al enviar (revisa token y canal)")
if __name__ == "__main__":
main()