diff --git a/add_daily_metrics_table.py b/add_daily_metrics_table.py new file mode 100644 index 0000000..0509c78 --- /dev/null +++ b/add_daily_metrics_table.py @@ -0,0 +1,95 @@ +""" +One-time script: adds the 'daily_metrics' table to the EXISTING +meta_optimizer_formacion database in Baserow (created by setup_baserow.py). + +Un registro por día con los totales agregados de todo el bloque Formación +(spend, leads_meta, leads_at, ing_meta, ing_at, margin, margin_pct). Se +persiste cada día al ejecutar run.py para no depender de poder volver a +pedirle a Meta el histórico diario más adelante (la API no garantiza +retención ilimitada), y para que el dashboard pueda leerlo sin llamar a +Meta/Airtable cada vez. + +Usage: + python add_daily_metrics_table.py +""" +import os +import sys +import requests +from dotenv import load_dotenv + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +load_dotenv() + +BASE_URL = os.environ.get("BASEROW_URL", "").rstrip("/") +EMAIL = os.environ.get("BASEROW_EMAIL", "") +PASSWORD = os.environ.get("BASEROW_PASSWORD", "") +DB_NAME = "meta_optimizer_formacion" + +if not BASE_URL or not EMAIL or not PASSWORD: + print("Error: BASEROW_URL, BASEROW_EMAIL and BASEROW_PASSWORD must be set in .env") + sys.exit(1) + +auth = requests.post(f"{BASE_URL}/api/user/token-auth/", + json={"email": EMAIL, "password": PASSWORD}, timeout=10) +if not auth.ok: + print(f"Auth error: {auth.text}") + sys.exit(1) +JWT = auth.json()["access_token"] +HEADERS = {"Authorization": f"JWT {JWT}", "Content-Type": "application/json"} + + +def api(method, path, **kwargs): + resp = requests.request(method, f"{BASE_URL}/api{path}", headers=HEADERS, **kwargs) + if not resp.ok: + print(f" API error {resp.status_code} {method} {path}: {resp.text[:300]}") + resp.raise_for_status() + return resp.json() + + +db_id = None +for ws in api("GET", "/workspaces/"): + for app in api("GET", f"/applications/workspace/{ws['id']}/"): + if app.get("name") == DB_NAME: + db_id = app["id"] + break + if db_id: + break + +if not db_id: + print(f"Error: no se encontró la base '{DB_NAME}'. Ejecuta setup_baserow.py primero.") + sys.exit(1) + +print(f"Database: {DB_NAME} (id={db_id})") + +existing_tables = api("GET", f"/database/tables/database/{db_id}/") +if any(t["name"] == "daily_metrics" for t in existing_tables): + print("La tabla 'daily_metrics' ya existe. Nada que hacer.") + sys.exit(0) + +t = api("POST", f"/database/tables/database/{db_id}/", json={"name": "daily_metrics"}) +table_id = t["id"] +print(f"Table: daily_metrics (id={table_id})") + +primary_id = api("GET", f"/database/fields/table/{table_id}/")[0]["id"] +api("PATCH", f"/database/fields/{primary_id}/", json={"name": "date", "type": "text"}) +print(" ~ primary field: date") + +for f in [ + {"name": "spend", "type": "number", "number_decimal_places": 2}, + {"name": "leads_meta", "type": "number"}, + {"name": "leads_at", "type": "number"}, + {"name": "ing_meta", "type": "number", "number_decimal_places": 2}, + {"name": "ing_at", "type": "number", "number_decimal_places": 2}, + {"name": "margin", "type": "number", "number_decimal_places": 2, "number_negative": True}, + {"name": "margin_pct", "type": "number", "number_decimal_places": 1, "number_negative": True}, +]: + api("POST", f"/database/fields/table/{table_id}/", json=f) + print(f" + {f['name']}") + +print(f""" +{'='*50} + Añade esto a tu .env: + BASEROW_TABLE_DAILY_METRICS={table_id} +{'='*50} +""") diff --git a/agent.py b/agent.py index 8d875b1..12cbdd0 100644 --- a/agent.py +++ b/agent.py @@ -6,6 +6,108 @@ import config client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY) +PORTFOLIO_SYSTEM = """ +Eres un experto en marketing de performance para una agencia de generación de leads en formación. +Recibes datos agregados del portfolio de campañas de Meta Ads (RoiFormacion_*). +Responde siempre en español, de forma concisa y accionable. Sin markdown, sin bullet symbols especiales, usa guiones simples (-). +""" + + +def _classify_type(curso: str) -> str: + c = curso.lower() + if "leadads" in c or "leadsads" in c: + return "leadform" + if "_web" in c: + return "landing" + return "otro" + + +def portfolio_daily_analysis(collected: list) -> str: + """Análisis estratégico diario del portfolio RoiFormacion_. Devuelve texto plano para Slack.""" + from datetime import datetime + now = datetime.now() + + tipos: dict = {} + leadform_detail = [] + alertas_tracking = 0 + campañas_perdida = 0 + + for item in collected: + t = _classify_type(item["campaign"]["curso"]) + m = item["metrics"] + a = item["analysis"] + cost = m.get("cost", 0) + conv = a["conversiones_meta"] + ppl = item["campaign"]["ppl"] + rev = a["revenue_estimado"] + margen_pct = round((rev - cost) / rev * 100, 1) if rev > 0 else 0.0 + + if t not in tipos: + tipos[t] = {"campañas": 0, "inversion": 0.0, "conversiones": 0, "ingreso": 0.0} + tipos[t]["campañas"] += 1 + tipos[t]["inversion"] += cost + tipos[t]["conversiones"] += conv + tipos[t]["ingreso"] += rev + + if a.get("alerta_tracking"): + alertas_tracking += 1 + if rev > 0 and cost > rev: + campañas_perdida += 1 + + if t == "leadform": + leadform_detail.append({ + "curso": item["campaign"]["curso"][:40], + "cpa_meta": round(cost / conv, 2) if conv > 0 else None, + "conv_meta": int(conv), + "conv_airtable": item["leads"], + "margen_pct": margen_pct, + }) + + resumen_tipos = {} + for t, d in tipos.items(): + cpa = round(d["inversion"] / d["conversiones"], 2) if d["conversiones"] > 0 else None + ing = d["ingreso"] + margen = round((ing - d["inversion"]) / ing * 100, 1) if ing > 0 else 0.0 + resumen_tipos[t] = { + "campañas": d["campañas"], + "inversion": round(d["inversion"], 2), + "conversiones": int(d["conversiones"]), + "cpa_medio": cpa, + "margen_pct": margen, + } + + data = { + "fecha": now.strftime("%d/%m/%Y"), + "dia_del_mes": now.day, + "campañas_totales": len(collected), + "campañas_en_perdida": campañas_perdida, + "alertas_tracking": alertas_tracking, + "rendimiento_por_tipo": resumen_tipos, + "detalle_leadform": leadform_detail, + } + + try: + response = client.messages.create( + model="claude-sonnet-4-6", + max_tokens=800, + system=PORTFOLIO_SYSTEM, + messages=[{ + "role": "user", + "content": ( + "Analiza estos datos del portfolio y proporciona:\n" + "1. Diagnóstico en 2 frases\n" + "2. Problemas principales (máx 3, con guión)\n" + "3. Acciones prioritarias (máx 3, muy concretas, con guión)\n" + "Si hay campañas leadform, evalúa específicamente su situación.\n\n" + f"{json.dumps(data, ensure_ascii=False, indent=2)}" + ), + }], + ) + return response.content[0].text.strip() + except Exception as e: + return f"Error generando análisis: {e}" + + DECIDE_SYSTEM = """ Eres un experto en optimización de campañas de Meta Ads para cursos de formación. Modelo de negocio: Ingreso = leads_entregados × PPL. Margen = (Ingreso - Gasto) / Ingreso. diff --git a/airtable_client.py b/airtable_client.py index 7fabfde..1e5fbc3 100644 --- a/airtable_client.py +++ b/airtable_client.py @@ -8,10 +8,16 @@ creates/updates "Meta Ads Campaigns" and "MetaCampaignMes", the Meta-specific tables that sit alongside "Google Ads Campaigns" / "GACampaignMes". """ import re -from datetime import datetime +from datetime import datetime, timedelta from pyairtable import Api import config +# Los leads de Meta llegan a Leads Lake por dos vías, ambas confirmadas 100% +# atribuibles a Meta (fbclid presente, 0% gclid) aunque la web las etiqueta +# distinto: 'Lead ads' = leadform nativo de Meta (attr_referer = nombre de +# campaña); 'landingpage' = clic a landing (attr_referer = URL con fbclid). +META_UTM_SOURCES = ("Lead ads", "landingpage") + MESES_ES = { 1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril", 5: "Mayo", 6: "Junio", 7: "Julio", 8: "Agosto", @@ -108,13 +114,10 @@ class AirtableClient: def get_leads_this_month_meta(self, campaign_name: str, as_of_date: str = None) -> tuple[int, list[str]]: """ - Leads acumulados en el mes atribuidos a un curso vía Meta Lead Ads, - hasta as_of_date (YYYY-MM-DD) inclusive, o hasta hoy si no se indica - (as_of_date lo usa backfill.py para reconstruir el estado histórico - del mes en una fecha pasada). - Los leads de Meta ya llegan a Leads Lake con attr_utm_source='Lead ads' - y attr_cursoid resuelto (confirmado con datos reales) — a diferencia de - Google, aquí solo hay una vía de atribución, no cinco. + Leads acumulados en el mes atribuidos a un curso vía Meta (leadform + + landing), hasta as_of_date (YYYY-MM-DD) inclusive, o hasta hoy si no se + indica (as_of_date lo usa backfill.py para reconstruir el estado + histórico del mes en una fecha pasada). """ course_num = extract_cursoid(campaign_name) if not course_num: @@ -122,8 +125,9 @@ class AirtableClient: ref = datetime.strptime(as_of_date, "%Y-%m-%d") if as_of_date else datetime.now() mes_inicio = f"{ref.year}-{ref.month:02d}-01" fin_clause = f",{{creado}}<'{(ref).strftime('%Y-%m-%d')}T23:59:59.999Z'" if as_of_date else "" + utm_clause = "OR(" + ",".join(f"{{attr_utm_source}}='{s}'" for s in META_UTM_SOURCES) + ")" formula = ( - f"AND({{attr_utm_source}}='Lead ads'," + f"AND({utm_clause}," f"{{attr_cursoid}}='{course_num}'," f"{{creado}}>='{mes_inicio}'{fin_clause})" ) @@ -131,6 +135,35 @@ class AirtableClient: ids = [r["id"] for r in records] return len(ids), ids + def get_meta_leads_bulk(self, date_from: str, date_to: str) -> list[dict]: + """ + Todos los leads de Meta (leadform + landing) creados en [date_from, date_to] + (inclusive), sin restringir a un curso concreto — una sola llamada bulk + para poder agregar por día y/o por curso en el informe (igual patrón que + get_leads_by_campaign_on_date en leads-optimizer). + Devuelve [{"cursoid": str, "date": "YYYY-MM-DD", "utm_source": str}]. + """ + next_day = (datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d") + utm_clause = "OR(" + ",".join(f"{{attr_utm_source}}='{s}'" for s in META_UTM_SOURCES) + ")" + formula = f"AND({utm_clause},{{creado}}>='{date_from}',{{creado}}<'{next_day}')" + records = self.leads.all(formula=formula, fields=["attr_cursoid", "attr_utm_source", "creado"]) + result = [] + for r in records: + f = r["fields"] + cursoid = f.get("attr_cursoid") + if cursoid is None: + continue + cursoid_text = str(int(cursoid)) if isinstance(cursoid, (int, float)) else str(cursoid).strip() + creado = f.get("creado", "") + if not cursoid_text or not creado: + continue + result.append({ + "cursoid": cursoid_text, + "date": creado[:10], + "utm_source": f.get("attr_utm_source", ""), + }) + return result + # ------------------------------------------------------------------ # # Meta Ads Campaigns (catálogo) # # ------------------------------------------------------------------ # @@ -187,7 +220,11 @@ class AirtableClient: updated.append({"name": mc["name"], "id": cid, "changes": changes}) for i in range(0, len(to_create), 10): - self.campaigns.batch_create(to_create[i:i + 10]) + new_records = self.campaigns.batch_create(to_create[i:i + 10]) + for r in new_records: + cid = str(r["fields"].get("CampaignID", "")).strip() + if cid: + at_by_cid[cid] = r for i in range(0, len(to_update), 10): batch = [{"id": rid, "fields": changes} for rid, changes in to_update[i:i + 10]] self.campaigns.batch_update(batch) diff --git a/baserow_client.py b/baserow_client.py index 0008f60..a306755 100644 --- a/baserow_client.py +++ b/baserow_client.py @@ -202,6 +202,39 @@ class BaserowClient: rows = self._get_rows(config.BASEROW_TABLE_SNAPSHOTS) return sorted({r["run_date"] for r in rows if r.get("run_date")}, reverse=True) + # ── daily_metrics (totales agregados del bloque Formación, uno por día) ──── + # Se persisten para no depender de poder re-pedirle a Meta el histórico + # diario más adelante, y para que el dashboard los lea sin llamar a la API. + + def save_daily_metrics(self, row: dict) -> dict: + existing = self._get_rows( + config.BASEROW_TABLE_DAILY_METRICS, + {"filter__date__equal": row["date"]}, + ) + for r in existing: + try: + self._delete_row(config.BASEROW_TABLE_DAILY_METRICS, r["id"]) + except Exception: + pass + return self._create_row(config.BASEROW_TABLE_DAILY_METRICS, { + "date": row["date"], + "spend": float(row.get("spend", 0)), + "leads_meta": int(row.get("leads_meta", 0)), + "leads_at": int(row.get("leads_at", 0)), + "ing_meta": float(row.get("ing_meta", 0)), + "ing_at": float(row.get("ing_at", 0)), + "margin": float(row.get("margin", 0)), + "margin_pct": float(row.get("margin_pct", 0)), + }) + + def get_daily_metrics(self, date_from: str = None, date_to: str = None) -> list: + rows = self._get_rows(config.BASEROW_TABLE_DAILY_METRICS) + if date_from: + rows = [r for r in rows if r.get("date", "") >= date_from] + if date_to: + rows = [r for r in rows if r.get("date", "") <= date_to] + return sorted(rows, key=lambda r: r.get("date", "")) + # ── execution_logs ──────────────────────────────────────────────────────── def save_execution_log(self, log: dict) -> dict: diff --git a/config.py b/config.py index 1e5db7c..d0ad261 100644 --- a/config.py +++ b/config.py @@ -16,10 +16,11 @@ ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"] BASEROW_URL = os.environ["BASEROW_URL"] BASEROW_TOKEN = os.environ["BASEROW_TOKEN"] -BASEROW_TABLE_ACTIONS = int(os.environ["BASEROW_TABLE_ACTIONS"]) -BASEROW_TABLE_CREATIVES = int(os.environ["BASEROW_TABLE_CREATIVES"]) -BASEROW_TABLE_LOGS = int(os.environ["BASEROW_TABLE_LOGS"]) -BASEROW_TABLE_SNAPSHOTS = int(os.environ["BASEROW_TABLE_SNAPSHOTS"]) +BASEROW_TABLE_ACTIONS = int(os.environ["BASEROW_TABLE_ACTIONS"]) +BASEROW_TABLE_CREATIVES = int(os.environ["BASEROW_TABLE_CREATIVES"]) +BASEROW_TABLE_LOGS = int(os.environ["BASEROW_TABLE_LOGS"]) +BASEROW_TABLE_SNAPSHOTS = int(os.environ["BASEROW_TABLE_SNAPSHOTS"]) +BASEROW_TABLE_DAILY_METRICS = int(os.environ["BASEROW_TABLE_DAILY_METRICS"]) # Airtable (misma base que leads-optimizer) — negocio: PPL, capping, Cursos, Familias AIRTABLE_TOKEN = os.environ["AIRTABLE_TOKEN"] diff --git a/dashboard.py b/dashboard.py index cbaa2f0..3e9b01a 100644 --- a/dashboard.py +++ b/dashboard.py @@ -96,6 +96,26 @@ def _load_data(date_from: str, date_to: str): return daily_rows, campaign_metrics +@st.cache_data(ttl=300, show_spinner="Cargando métricas diarias (Baserow)...") +def _load_daily_metrics(date_from: str, date_to: str): + """Totales diarios persistidos por run.py (Leads Meta vs Leads Airtable) — + no depende de volver a pedirle el histórico a Meta.""" + rows = BaserowClient().get_daily_metrics(date_from, date_to) + return [ + { + "date": r["date"], + "spend": float(r.get("spend") or 0), + "leads_meta": int(r.get("leads_meta") or 0), + "leads_at": int(r.get("leads_at") or 0), + "ing_meta": float(r.get("ing_meta") or 0), + "ing_at": float(r.get("ing_at") or 0), + "margin": float(r.get("margin") or 0), + "margin_pct": float(r.get("margin_pct") or 0), + } + for r in rows + ] + + @st.cache_data(ttl=300, show_spinner="Cargando detalle de campaña...") def _load_detail(campaign_id: str, date_from: str, date_to: str): meta = MetaAdsClient() @@ -182,59 +202,52 @@ with tab1: if d_from_1 > d_to_1: st.error("La fecha inicio debe ser anterior a la fecha fin.") else: + try: + daily_totals = _load_daily_metrics(d_from_1.strftime("%Y-%m-%d"), d_to_1.strftime("%Y-%m-%d")) + except Exception as e: + st.error(f"Error cargando daily_metrics de Baserow: {e}") + daily_totals = [] try: daily_rows, _cm1 = _load_data(d_from_1.strftime("%Y-%m-%d"), d_to_1.strftime("%Y-%m-%d")) except Exception as e: st.error(f"Error cargando datos de Meta API: {e}") daily_rows = [] - _daily: dict = {} - for row in daily_rows: - ppl = ppl_lookup.get(extract_cursoid(row["campaign_name"]) or "", 0) - margin = round(row["leads"] * ppl - 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 + total_spend = sum(d["spend"] for d in daily_totals) + total_leads_m = sum(d["leads_meta"] for d in daily_totals) + total_leads_at = sum(d["leads_at"] for d in daily_totals) + total_ing_m = sum(d["ing_meta"] for d in daily_totals) + total_margin = total_ing_m - total_spend + total_pct = round(total_margin / total_ing_m * 100, 1) if total_ing_m > 0 else 0.0 - daily_totals = [ - { - "date": dt, - "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 dt, d in sorted(_daily.items()) - ] - - total_spend = sum(d["spend"] for d in daily_totals) - total_leads = sum(d["leads"] for d in daily_totals) - total_cpl = round(total_spend / total_leads, 2) if total_leads > 0 else 0.0 - total_margin = sum(d["margin"] for d in daily_totals) - - k1, k2, k3, k4 = st.columns(4) - k1.metric("Gasto total", _eur(total_spend)) - k2.metric("Leads totales", f"{total_leads:,}") - k3.metric("CPL medio", _eur(total_cpl)) - k4.metric("Margen total", _margin(total_margin)) + k1, k2, k3, k4, k5 = st.columns(5) + k1.metric("Gasto total", _eur(total_spend)) + k2.metric("Leads Meta", f"{total_leads_m:,}") + k3.metric("Leads Airtable", f"{total_leads_at:,}") + k4.metric("Margen (Meta)", _margin(total_margin)) + k5.metric("% Margen", f"{total_pct:+.1f}%") st.divider() if not daily_totals: - st.info("Sin datos para el período seleccionado.") + st.info("Sin datos persistidos para el período seleccionado — ejecuta run.py o amplía el rango.") else: df_daily = pd.DataFrame([ { - "Día": d["date"][8:10] + "/" + d["date"][5:7], - "Gasto": _eur(d["spend"]), - "Leads": d["leads"], - "CPL": _eur(d["cpl"]), - "Margen": _margin(d["margin"]), - "Est": _status(d["leads"], d["spend"]), + "Día": d["date"][8:10] + "/" + d["date"][5:7], + "L. AT": d["leads_at"], + "L. Meta": d["leads_meta"], + "Gasto": _eur(d["spend"]), + "€ AT": _eur(d["ing_at"]), + "€ Meta": _eur(d["ing_meta"]), + "Margen": _margin(d["margin"]), + "% Margen": f"{d['margin_pct']:+.1f}%", + "Est": _status(d["leads_meta"], d["spend"]), } for d in daily_totals ]) st.dataframe(df_daily, use_container_width=True, hide_index=True) + st.caption("L. AT = leads Airtable (leadform + landing) · L. Meta = conversión propia de Meta · " + "€ AT / € Meta = leads × PPL de cada fuente · el margen oficial usa el tracking de Meta.") st.subheader("Desglose por campaña") day_opts = [d["date"] for d in reversed(daily_totals)] diff --git a/run.py b/run.py index ab30707..07756c9 100644 --- a/run.py +++ b/run.py @@ -9,7 +9,7 @@ from datetime import datetime, timedelta import config from meta_ads_client import MetaAdsClient from airtable_client import AirtableClient, extract_cursoid -from agent import decide, analyze_unit +from agent import decide, analyze_unit, portfolio_daily_analysis from baserow_client import BaserowClient import analyzer import slack_notifier @@ -139,38 +139,59 @@ def run(): print(f"→ MetaCampaignMes: {mcm_sync['created']} creadas, {mcm_sync['updated']} actualizadas.\n") mcm_by_meta_cid = {r["meta_campaign_id"]: r for r in airtable.get_active_metacampaignmes()} - # ── Monthly daily totals (per-campaign rows → agregado por familia) ──────── + # ── Monthly daily totals: Leads Meta (tracking propio) vs Leads Airtable ─── + # (leadform + landing, ambos confirmados 100% atribuibles a Meta) ────────── print(f"→ Fetching monthly daily totals for {config.META_CAMPAIGN_PREFIX}...") daily_rows = meta.get_daily_campaign_rows(month_start, yesterday) + daily_at_leads = airtable.get_meta_leads_bulk(month_start, yesterday) + print(f" ✓ {len(daily_rows)} filas Meta, {len(daily_at_leads)} leads Airtable este mes.\n") + _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 + 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": 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()}, + "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)} days with data.\n") + # ── Persistir daily_totals en Baserow ─────────────────────────────────────── + # No solo para el dashboard: si Meta llegase a limitar el acceso al + # histórico diario más adelante, este es el único registro que sobreviviría. + errors: list = [] + print("→ Guardando daily_metrics en Baserow...") + daily_metrics_saved = 0 + for d in daily_totals: + try: + baserow.save_daily_metrics(d) + daily_metrics_saved += 1 + except Exception as e: + errors.append(f"daily_metrics {d['date']}: {e}") + print(f" ✓ {daily_metrics_saved}/{len(daily_totals)} días guardados.\n") + # ── Yesterday metrics (contexto 1d para el informe) ──────────────────────── print(f"→ Fetching yesterday metrics ({config.META_CAMPAIGN_PREFIX} only, spend > 0)...") metrics_yesterday = meta.get_yesterday_metrics() @@ -187,10 +208,9 @@ def run(): actions_proposed_list = [] campaign_details = {} # {cid: {familia, margin, adsets, ads, ...}} - familias = {} # {familia: {spend, leads, margin}} + collected = [] # para el diagnóstico estratégico (agent.portfolio_daily_analysis) advice_updates = [] # [(mcm_id, consejo, criticidad, log)] final_leads_updates = [] # [(mcm_id, leads_entregados)] - errors = [] for mc in active_campaigns: cid, name = mc["id"], mc["name"] @@ -377,11 +397,13 @@ def run(): except Exception as e: errors.append(f"Snapshot {name}: {e}") - # ── Familia aggregation ────────────────────────────────────────────── - f = familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0}) - f["spend"] += m1.get("spend", 0.0) - f["leads"] += m1.get("leads", 0) - f["margin"] += margin_eur + # ── Para el diagnóstico estratégico global (agent.portfolio_daily_analysis) ─ + collected.append({ + "campaign": {"curso": name, "ppl": ppl}, + "metrics": {"cost": mmes.get("spend", 0.0)}, + "analysis": analysis, + "leads": leads_entregados, + }) # ── MetaCampaignMes: consejo/criticidad/log + leads confirmados ─────── mcm = mcm_by_meta_cid.get(cid) @@ -396,6 +418,53 @@ def run(): if final_leads_updates: airtable.batch_update_metacampaignmes_final_leads(final_leads_updates) + # ── Resumen y contraste por curso: Meta vs Airtable, leadform vs landing ─── + # (agregado por CursoID, no por campaña literal — un curso puede tener a la + # vez una campaña _leadads y otra _web, y Airtable no distingue con certeza + # a cuál de las dos pertenece un lead 'landingpage'). + print("→ Calculando resumen y contraste por curso...") + + 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, + } + + name_by_cid = {mc["id"]: mc["name"] for mc in meta_campaigns} + 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 + print(f" ✓ {len(curso_summary)} cursos con actividad este mes.\n") + + # ── Diagnóstico estratégico global (Claude) ───────────────────────────────── + print("→ Generando diagnóstico estratégico...") + try: + portfolio_text = portfolio_daily_analysis(collected) + except Exception as e: + portfolio_text = None + errors.append(f"Portfolio analysis: {e}") + print(" ✓ Diagnóstico listo.\n") + # ── Top 10 best and worst (por CPL de ayer) ───────────────────────────────── with_leads = [m for m in metrics_yesterday.values() if m["leads"] > 0] best_10 = sorted(with_leads, key=lambda x: x["cpl"])[:10] @@ -417,9 +486,9 @@ def run(): actions=actions_proposed_list, campaigns_analyzed=len(active_campaigns), mode="DRY_RUN" if config.DRY_RUN else "PRODUCTION", - familias=familias, campaign_details=campaign_details, - monthly_familias=monthly_familias, + curso_summary=curso_summary, + portfolio_analysis_text=portfolio_text, ) except Exception as e: print(f" Warning: Slack notification failed: {e}") diff --git a/send_slack_report.py b/send_slack_report.py index dcc0675..f854fe9 100644 --- a/send_slack_report.py +++ b/send_slack_report.py @@ -1,4 +1,5 @@ -"""Re-send a day's Slack report from Baserow snapshots (sin llamar a Meta por campaña).""" +"""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) @@ -21,41 +22,82 @@ def main(): baserow = BaserowClient() airtable = AirtableClient() - ppl_lookup, _, familia_lookup = airtable.build_campaign_lookups(as_of_date=run_date) + ppl_lookup, cap_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...") + # ── 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 = {} - 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 + 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": 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()}, + "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: @@ -86,8 +128,6 @@ def main(): # 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", "") @@ -96,7 +136,6 @@ def main(): 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: @@ -118,7 +157,6 @@ def main(): "ads": ads, "bid_config": {}, } - metrics_all[cid] = {"name": name, "spend": spend, "leads": leads, "cpl": cpl} if action_type != "MAINTAIN": actions.append({ @@ -132,31 +170,18 @@ def main(): "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 ────────────────────────────────────────────────────────────────── + # ── 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=best_10, - worst_campaigns=worst_10, + best_campaigns=[], + worst_campaigns=[], actions=actions, campaigns_analyzed=len(snapshots), mode="DRY_RUN", - familias=familias, campaign_details=campaign_details, - monthly_familias=monthly_familias, + curso_summary=curso_summary, + portfolio_analysis_text=None, ) if ts: print(f" ✓ Mensaje enviado (ts={ts})") diff --git a/slack_notifier.py b/slack_notifier.py index b86a4e8..f50e087 100644 --- a/slack_notifier.py +++ b/slack_notifier.py @@ -81,36 +81,6 @@ def update_message(channel: str, ts: str, text: str): _post("chat.update", channel=channel, ts=ts, text=text, blocks=[]) -def _ad_action_blocks(ads: list) -> list: - """Genera bloques Slack con botón de pausa para anuncios que Claude recomienda pausar.""" - blocks = [] - for ad in ads: - if not ad.get("row_id"): - continue - name = ad["name"] - text = f"⛔ *{name[:80]}* _(0 leads · 7d)_" - blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}}) - 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']}", - }, - ], - }) - return 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: @@ -153,12 +123,98 @@ def _adset_ad_table(items: list, label: str, show_bid: bool = False, show_7d: bo return "\n".join(lines) -def _familia_status(margin: float, has_issues: bool, no_data: bool) -> str: - if no_data: - return "⚪" - if has_issues: - return "🚨" if margin < 0 else "⚠️" - return "✅" if margin >= 0 else "⚠️" +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( @@ -168,131 +224,67 @@ def send_daily_report( actions: list, campaigns_analyzed: int, mode: str = "DRY_RUN", - familias: dict = None, campaign_details: dict = None, - monthly_familias: dict = None, + curso_summary: dict = None, + portfolio_analysis_text: str = None, ) -> str | None: - """Envía el informe diario consolidado. Devuelve el ts del mensaje.""" + """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() - prefix = config.META_CAMPAIGN_PREFIX 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 {} - - # Group ALL campaigns by familia - by_familia: dict = {} - for cid, detail in details_map.items(): - act = action_map.get(detail["name"]) - by_familia.setdefault(detail["familia"], []).append((cid, detail, act)) - - def _has_issues(camp_list): - return any( - (act and act["action_type"] != "MAINTAIN") or - any(ad.get("accion") == "PAUSE" and ad.get("row_id") - for ad in detail.get("ads", [])) - for _, detail, act in camp_list - ) - - # Sort familias: issues first (by margin asc), then OK (by margin desc) - def _familia_sort_key(item): - f, cl = item - f_data = (familias or {}).get(f, {}) - margin = f_data.get("margin", 0) - has_iss = _has_issues(cl) - return (0 if has_iss else 1, margin if has_iss else -margin) - - sorted_familias = sorted(by_familia.items(), key=_familia_sort_key) + 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})"}, + "text": {"type": "plain_text", "text": f"Meta Optimizer Formación — {date_label} ({mode_label})"}, }, ] - # Monthly profitability table if daily_totals: - f_order = ( - sorted(monthly_familias.keys(), key=lambda f: -monthly_familias[f]["margin"]) - if monthly_familias else [] - ) - cw = 7 - hdr = f"{'Día':<5} {'Gasto':>6} {'Leads':>5} {'CPL':>7}" - for f in f_order: - hdr += f" {f[:6]:>{cw}}" - hdr += " Est" - sep = "─" * len(hdr) - lines = [hdr, sep] - total_spend = total_leads = total_margin = 0.0 - total_f = {f: 0.0 for f in f_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 - f_day = d.get("f_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 f in f_order: - fm = f_day.get(f, 0.0) - total_f[f] += fm - fm_s = (f"+{fm:.0f}€" if fm >= 0 else f"{fm:.0f}€") if round(fm) != 0 else " —" - row += f" {fm_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 f in f_order: - tf = total_f[f] - tf_s = f"+{tf:.0f}€" if tf >= 0 else f"{tf:.0f}€" - total_row += f" {tf_s:>{cw}}" - lines.append(total_row) + 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"*Rentabilidad {month_name}*\n```" + "\n".join(lines) + "```", + "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": "divider"}) - - # Familia scorecard - if familias: - lines = [f"{'':>2} {'Familia':<20} {'Gasto':>6} {'Leads':>5} {'CPL':>7} {'Margen':>9}"] - lines.append("─" * 56) - for f, cl in sorted_familias: - data = (familias or {}).get(f, {}) - f_leads = data.get("leads", 0) - f_spend = data.get("spend", 0) - f_cpl = round(f_spend / f_leads, 2) if f_leads > 0 else 0.0 - f_m = data.get("margin", 0) - m_sign = f"+{f_m:.0f}€" if f_m >= 0 else f"{f_m:.0f}€" - st = _familia_status(f_m, _has_issues(cl), f_leads == 0 and f_spend == 0) - lines.append( - f"{st} {f[:20]:<20} {f_spend:>5.0f}€ {f_leads:>5} {f_cpl:>6.2f}€ {m_sign:>9}" - ) - blocks.append({ - "type": "section", - "text": {"type": "mrkdwn", - "text": "*Resumen · ayer*\n```" + "\n".join(lines) + "```"}, - }) - blocks.append({ "type": "context", - "elements": [{"type": "mrkdwn", - "text": f"{campaigns_analyzed} campañas analizadas — detalle por familia a continuación"}], + "elements": [{"type": "mrkdwn", "text": f"{campaigns_analyzed} campañas analizadas"}], }) result = _post( @@ -303,39 +295,53 @@ def send_daily_report( ) ts = result.get("ts") - # ── One message per familia ────────────────────────────────────────────── - for f, camp_list in sorted_familias: - f_data = (familias or {}).get(f, {}) - f_spend = f_data.get("spend", 0) - f_leads = f_data.get("leads", 0) - f_cpl = round(f_spend / f_leads, 2) if f_leads > 0 else 0.0 - f_margin = f_data.get("margin", 0) - m_str = f"+{f_margin:.0f}€" if f_margin >= 0 else f"{f_margin:.0f}€" - has_iss = _has_issues(camp_list) - st = _familia_status(f_margin, has_iss, f_leads == 0 and f_spend == 0) + # ── 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") - f_blocks: list = [ - { - "type": "header", - "text": {"type": "plain_text", "text": f"{st} {f.upper()}"}, - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"{f_spend:.0f}€ · {f_leads} leads · CPL {f_cpl:.2f}€ · Margen {m_str}", - }, - }, - {"type": "divider"}, - ] + # ── 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", + ) - for i, (cid, detail, act) in enumerate( - sorted(camp_list, key=lambda x: -x[1].get("spend_1d", 0)) - ): + # ── 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: - f_blocks.append({"type": "divider"}) + 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"] @@ -354,85 +360,94 @@ def send_daily_report( atype = act["action_type"] if act else "MAINTAIN" cemoji, alabel = _ACTION_DISPLAY.get(atype, ("⚪", atype)) - if atype == "MAINTAIN" and not any( - ad.get("accion") == "PAUSE" and ad.get("row_id") for ad in ads - ): - # Compact header for clean campaigns - f_blocks.append({ - "type": "section", - "text": { - "type": "mrkdwn", - "text": ( - f"{cemoji} *{name}*\n" - f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · " - f"Margen: {m_str2} · {u_emoji} {urgencia} · Cap mes: {cap_str}" - + (f" · `{strat_label}`" if strategy else "") - + (f" · {budget:.0f}€/día" if budget else "") - ), - }, + 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']}", + }, + ], }) - # Still show adset breakdown for context - 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)]: - f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}}) - else: - # Full block for campaigns with action or ad pauses - camp_text = ( - f"{cemoji} *{name}*\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 "") + - f"\n*{alabel}*" - ) - 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]}" - f_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: - f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": effect}}) - f_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)}}) - # Adset table (top 3) — only for non-MAINTAIN - if atype != "MAINTAIN" and 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)]: - f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}}) - - # Ad pause buttons - f_blocks.extend(_ad_action_blocks(ads)) + # 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=f_blocks, - text=f"{f.upper()} · {f_spend:.0f}€ · {f_leads} leads", + blocks=c_blocks, + text=f"Campañas {batch_start + 1}–{batch_start + len(batch)} de {len(sorted_campaigns)}", ) return ts