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.
This commit is contained in:
parent
f8cbd5f29e
commit
769d86c896
95
add_daily_metrics_table.py
Normal file
95
add_daily_metrics_table.py
Normal file
@ -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}
|
||||||
|
""")
|
||||||
102
agent.py
102
agent.py
@ -6,6 +6,108 @@ import config
|
|||||||
|
|
||||||
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
|
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 = """
|
DECIDE_SYSTEM = """
|
||||||
Eres un experto en optimización de campañas de Meta Ads para cursos de formación.
|
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.
|
Modelo de negocio: Ingreso = leads_entregados × PPL. Margen = (Ingreso - Gasto) / Ingreso.
|
||||||
|
|||||||
@ -8,10 +8,16 @@ creates/updates "Meta Ads Campaigns" and "MetaCampaignMes", the Meta-specific
|
|||||||
tables that sit alongside "Google Ads Campaigns" / "GACampaignMes".
|
tables that sit alongside "Google Ads Campaigns" / "GACampaignMes".
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from pyairtable import Api
|
from pyairtable import Api
|
||||||
import config
|
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 = {
|
MESES_ES = {
|
||||||
1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril",
|
1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril",
|
||||||
5: "Mayo", 6: "Junio", 7: "Julio", 8: "Agosto",
|
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]]:
|
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,
|
Leads acumulados en el mes atribuidos a un curso vía Meta (leadform +
|
||||||
hasta as_of_date (YYYY-MM-DD) inclusive, o hasta hoy si no se indica
|
landing), hasta as_of_date (YYYY-MM-DD) inclusive, o hasta hoy si no se
|
||||||
(as_of_date lo usa backfill.py para reconstruir el estado histórico
|
indica (as_of_date lo usa backfill.py para reconstruir el estado
|
||||||
del mes en una fecha pasada).
|
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.
|
|
||||||
"""
|
"""
|
||||||
course_num = extract_cursoid(campaign_name)
|
course_num = extract_cursoid(campaign_name)
|
||||||
if not course_num:
|
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()
|
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"
|
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 ""
|
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 = (
|
formula = (
|
||||||
f"AND({{attr_utm_source}}='Lead ads',"
|
f"AND({utm_clause},"
|
||||||
f"{{attr_cursoid}}='{course_num}',"
|
f"{{attr_cursoid}}='{course_num}',"
|
||||||
f"{{creado}}>='{mes_inicio}'{fin_clause})"
|
f"{{creado}}>='{mes_inicio}'{fin_clause})"
|
||||||
)
|
)
|
||||||
@ -131,6 +135,35 @@ class AirtableClient:
|
|||||||
ids = [r["id"] for r in records]
|
ids = [r["id"] for r in records]
|
||||||
return len(ids), ids
|
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) #
|
# Meta Ads Campaigns (catálogo) #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@ -187,7 +220,11 @@ class AirtableClient:
|
|||||||
updated.append({"name": mc["name"], "id": cid, "changes": changes})
|
updated.append({"name": mc["name"], "id": cid, "changes": changes})
|
||||||
|
|
||||||
for i in range(0, len(to_create), 10):
|
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):
|
for i in range(0, len(to_update), 10):
|
||||||
batch = [{"id": rid, "fields": changes} for rid, changes in to_update[i:i + 10]]
|
batch = [{"id": rid, "fields": changes} for rid, changes in to_update[i:i + 10]]
|
||||||
self.campaigns.batch_update(batch)
|
self.campaigns.batch_update(batch)
|
||||||
|
|||||||
@ -202,6 +202,39 @@ class BaserowClient:
|
|||||||
rows = self._get_rows(config.BASEROW_TABLE_SNAPSHOTS)
|
rows = self._get_rows(config.BASEROW_TABLE_SNAPSHOTS)
|
||||||
return sorted({r["run_date"] for r in rows if r.get("run_date")}, reverse=True)
|
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 ────────────────────────────────────────────────────────
|
# ── execution_logs ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def save_execution_log(self, log: dict) -> dict:
|
def save_execution_log(self, log: dict) -> dict:
|
||||||
|
|||||||
@ -16,10 +16,11 @@ ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"]
|
|||||||
BASEROW_URL = os.environ["BASEROW_URL"]
|
BASEROW_URL = os.environ["BASEROW_URL"]
|
||||||
BASEROW_TOKEN = os.environ["BASEROW_TOKEN"]
|
BASEROW_TOKEN = os.environ["BASEROW_TOKEN"]
|
||||||
|
|
||||||
BASEROW_TABLE_ACTIONS = int(os.environ["BASEROW_TABLE_ACTIONS"])
|
BASEROW_TABLE_ACTIONS = int(os.environ["BASEROW_TABLE_ACTIONS"])
|
||||||
BASEROW_TABLE_CREATIVES = int(os.environ["BASEROW_TABLE_CREATIVES"])
|
BASEROW_TABLE_CREATIVES = int(os.environ["BASEROW_TABLE_CREATIVES"])
|
||||||
BASEROW_TABLE_LOGS = int(os.environ["BASEROW_TABLE_LOGS"])
|
BASEROW_TABLE_LOGS = int(os.environ["BASEROW_TABLE_LOGS"])
|
||||||
BASEROW_TABLE_SNAPSHOTS = int(os.environ["BASEROW_TABLE_SNAPSHOTS"])
|
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 (misma base que leads-optimizer) — negocio: PPL, capping, Cursos, Familias
|
||||||
AIRTABLE_TOKEN = os.environ["AIRTABLE_TOKEN"]
|
AIRTABLE_TOKEN = os.environ["AIRTABLE_TOKEN"]
|
||||||
|
|||||||
85
dashboard.py
85
dashboard.py
@ -96,6 +96,26 @@ def _load_data(date_from: str, date_to: str):
|
|||||||
return daily_rows, campaign_metrics
|
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...")
|
@st.cache_data(ttl=300, show_spinner="Cargando detalle de campaña...")
|
||||||
def _load_detail(campaign_id: str, date_from: str, date_to: str):
|
def _load_detail(campaign_id: str, date_from: str, date_to: str):
|
||||||
meta = MetaAdsClient()
|
meta = MetaAdsClient()
|
||||||
@ -182,59 +202,52 @@ with tab1:
|
|||||||
if d_from_1 > d_to_1:
|
if d_from_1 > d_to_1:
|
||||||
st.error("La fecha inicio debe ser anterior a la fecha fin.")
|
st.error("La fecha inicio debe ser anterior a la fecha fin.")
|
||||||
else:
|
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:
|
try:
|
||||||
daily_rows, _cm1 = _load_data(d_from_1.strftime("%Y-%m-%d"), d_to_1.strftime("%Y-%m-%d"))
|
daily_rows, _cm1 = _load_data(d_from_1.strftime("%Y-%m-%d"), d_to_1.strftime("%Y-%m-%d"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
st.error(f"Error cargando datos de Meta API: {e}")
|
st.error(f"Error cargando datos de Meta API: {e}")
|
||||||
daily_rows = []
|
daily_rows = []
|
||||||
|
|
||||||
_daily: dict = {}
|
total_spend = sum(d["spend"] for d in daily_totals)
|
||||||
for row in daily_rows:
|
total_leads_m = sum(d["leads_meta"] for d in daily_totals)
|
||||||
ppl = ppl_lookup.get(extract_cursoid(row["campaign_name"]) or "", 0)
|
total_leads_at = sum(d["leads_at"] for d in daily_totals)
|
||||||
margin = round(row["leads"] * ppl - row["spend"], 2)
|
total_ing_m = sum(d["ing_meta"] for d in daily_totals)
|
||||||
d = _daily.setdefault(row["date"], {"spend": 0.0, "leads": 0, "margin": 0.0})
|
total_margin = total_ing_m - total_spend
|
||||||
d["spend"] += row["spend"]
|
total_pct = round(total_margin / total_ing_m * 100, 1) if total_ing_m > 0 else 0.0
|
||||||
d["leads"] += row["leads"]
|
|
||||||
d["margin"] += margin
|
|
||||||
|
|
||||||
daily_totals = [
|
k1, k2, k3, k4, k5 = st.columns(5)
|
||||||
{
|
k1.metric("Gasto total", _eur(total_spend))
|
||||||
"date": dt,
|
k2.metric("Leads Meta", f"{total_leads_m:,}")
|
||||||
"spend": round(d["spend"], 2),
|
k3.metric("Leads Airtable", f"{total_leads_at:,}")
|
||||||
"leads": int(d["leads"]),
|
k4.metric("Margen (Meta)", _margin(total_margin))
|
||||||
"cpl": round(d["spend"] / d["leads"], 2) if d["leads"] > 0 else 0.0,
|
k5.metric("% Margen", f"{total_pct:+.1f}%")
|
||||||
"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))
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
if not daily_totals:
|
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:
|
else:
|
||||||
df_daily = pd.DataFrame([
|
df_daily = pd.DataFrame([
|
||||||
{
|
{
|
||||||
"Día": d["date"][8:10] + "/" + d["date"][5:7],
|
"Día": d["date"][8:10] + "/" + d["date"][5:7],
|
||||||
"Gasto": _eur(d["spend"]),
|
"L. AT": d["leads_at"],
|
||||||
"Leads": d["leads"],
|
"L. Meta": d["leads_meta"],
|
||||||
"CPL": _eur(d["cpl"]),
|
"Gasto": _eur(d["spend"]),
|
||||||
"Margen": _margin(d["margin"]),
|
"€ AT": _eur(d["ing_at"]),
|
||||||
"Est": _status(d["leads"], d["spend"]),
|
"€ 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
|
for d in daily_totals
|
||||||
])
|
])
|
||||||
st.dataframe(df_daily, use_container_width=True, hide_index=True)
|
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")
|
st.subheader("Desglose por campaña")
|
||||||
day_opts = [d["date"] for d in reversed(daily_totals)]
|
day_opts = [d["date"] for d in reversed(daily_totals)]
|
||||||
|
|||||||
127
run.py
127
run.py
@ -9,7 +9,7 @@ from datetime import datetime, timedelta
|
|||||||
import config
|
import config
|
||||||
from meta_ads_client import MetaAdsClient
|
from meta_ads_client import MetaAdsClient
|
||||||
from airtable_client import AirtableClient, extract_cursoid
|
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
|
from baserow_client import BaserowClient
|
||||||
import analyzer
|
import analyzer
|
||||||
import slack_notifier
|
import slack_notifier
|
||||||
@ -139,38 +139,59 @@ def run():
|
|||||||
print(f"→ MetaCampaignMes: {mcm_sync['created']} creadas, {mcm_sync['updated']} actualizadas.\n")
|
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()}
|
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}...")
|
print(f"→ Fetching monthly daily totals for {config.META_CAMPAIGN_PREFIX}...")
|
||||||
daily_rows = meta.get_daily_campaign_rows(month_start, yesterday)
|
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 = {}
|
_daily: dict = {}
|
||||||
monthly_familias: dict = {}
|
|
||||||
for row in daily_rows:
|
for row in daily_rows:
|
||||||
cursoid = extract_cursoid(row["campaign_name"]) or ""
|
cursoid = extract_cursoid(row["campaign_name"]) or ""
|
||||||
familia = familia_lookup.get(cursoid, "Sin familia")
|
|
||||||
ppl = ppl_lookup.get(cursoid, 0)
|
ppl = ppl_lookup.get(cursoid, 0)
|
||||||
margin = round(row["leads"] * ppl - row["spend"], 2)
|
d = _daily.setdefault(row["date"], {
|
||||||
d = _daily.setdefault(row["date"], {"spend": 0.0, "leads": 0, "margin": 0.0, "f_margins": {}})
|
"spend": 0.0, "leads_meta": 0, "leads_at": 0, "ing_meta": 0.0, "ing_at": 0.0,
|
||||||
d["spend"] += row["spend"]
|
})
|
||||||
d["leads"] += row["leads"]
|
d["spend"] += row["spend"]
|
||||||
d["margin"] += margin
|
d["leads_meta"] += row["leads"]
|
||||||
d["f_margins"][familia] = d["f_margins"].get(familia, 0.0) + margin
|
d["ing_meta"] += row["leads"] * ppl
|
||||||
mf = monthly_familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
|
for lead in daily_at_leads:
|
||||||
mf["spend"] += row["spend"]
|
ppl = ppl_lookup.get(lead["cursoid"], 0)
|
||||||
mf["leads"] += row["leads"]
|
d = _daily.setdefault(lead["date"], {
|
||||||
mf["margin"] += margin
|
"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 = [
|
daily_totals = [
|
||||||
{
|
{
|
||||||
"date": date,
|
"date": date,
|
||||||
"spend": round(d["spend"], 2),
|
"spend": round(d["spend"], 2),
|
||||||
"leads": int(d["leads"]),
|
"leads_meta": int(d["leads_meta"]),
|
||||||
"cpl": round(d["spend"] / d["leads"], 2) if d["leads"] > 0 else 0.0,
|
"leads_at": int(d["leads_at"]),
|
||||||
"margin": round(d["margin"], 2),
|
"ing_meta": round(d["ing_meta"], 2),
|
||||||
"f_margins": {f: round(m, 0) for f, m in d["f_margins"].items()},
|
"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())
|
for date, d in sorted(_daily.items())
|
||||||
]
|
]
|
||||||
print(f" ✓ {len(daily_totals)} days with data.\n")
|
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) ────────────────────────
|
# ── Yesterday metrics (contexto 1d para el informe) ────────────────────────
|
||||||
print(f"→ Fetching yesterday metrics ({config.META_CAMPAIGN_PREFIX} only, spend > 0)...")
|
print(f"→ Fetching yesterday metrics ({config.META_CAMPAIGN_PREFIX} only, spend > 0)...")
|
||||||
metrics_yesterday = meta.get_yesterday_metrics()
|
metrics_yesterday = meta.get_yesterday_metrics()
|
||||||
@ -187,10 +208,9 @@ def run():
|
|||||||
|
|
||||||
actions_proposed_list = []
|
actions_proposed_list = []
|
||||||
campaign_details = {} # {cid: {familia, margin, adsets, ads, ...}}
|
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)]
|
advice_updates = [] # [(mcm_id, consejo, criticidad, log)]
|
||||||
final_leads_updates = [] # [(mcm_id, leads_entregados)]
|
final_leads_updates = [] # [(mcm_id, leads_entregados)]
|
||||||
errors = []
|
|
||||||
|
|
||||||
for mc in active_campaigns:
|
for mc in active_campaigns:
|
||||||
cid, name = mc["id"], mc["name"]
|
cid, name = mc["id"], mc["name"]
|
||||||
@ -377,11 +397,13 @@ def run():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"Snapshot {name}: {e}")
|
errors.append(f"Snapshot {name}: {e}")
|
||||||
|
|
||||||
# ── Familia aggregation ──────────────────────────────────────────────
|
# ── Para el diagnóstico estratégico global (agent.portfolio_daily_analysis) ─
|
||||||
f = familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
|
collected.append({
|
||||||
f["spend"] += m1.get("spend", 0.0)
|
"campaign": {"curso": name, "ppl": ppl},
|
||||||
f["leads"] += m1.get("leads", 0)
|
"metrics": {"cost": mmes.get("spend", 0.0)},
|
||||||
f["margin"] += margin_eur
|
"analysis": analysis,
|
||||||
|
"leads": leads_entregados,
|
||||||
|
})
|
||||||
|
|
||||||
# ── MetaCampaignMes: consejo/criticidad/log + leads confirmados ───────
|
# ── MetaCampaignMes: consejo/criticidad/log + leads confirmados ───────
|
||||||
mcm = mcm_by_meta_cid.get(cid)
|
mcm = mcm_by_meta_cid.get(cid)
|
||||||
@ -396,6 +418,53 @@ def run():
|
|||||||
if final_leads_updates:
|
if final_leads_updates:
|
||||||
airtable.batch_update_metacampaignmes_final_leads(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) ─────────────────────────────────
|
# ── Top 10 best and worst (por CPL de ayer) ─────────────────────────────────
|
||||||
with_leads = [m for m in metrics_yesterday.values() if m["leads"] > 0]
|
with_leads = [m for m in metrics_yesterday.values() if m["leads"] > 0]
|
||||||
best_10 = sorted(with_leads, key=lambda x: x["cpl"])[:10]
|
best_10 = sorted(with_leads, key=lambda x: x["cpl"])[:10]
|
||||||
@ -417,9 +486,9 @@ def run():
|
|||||||
actions=actions_proposed_list,
|
actions=actions_proposed_list,
|
||||||
campaigns_analyzed=len(active_campaigns),
|
campaigns_analyzed=len(active_campaigns),
|
||||||
mode="DRY_RUN" if config.DRY_RUN else "PRODUCTION",
|
mode="DRY_RUN" if config.DRY_RUN else "PRODUCTION",
|
||||||
familias=familias,
|
|
||||||
campaign_details=campaign_details,
|
campaign_details=campaign_details,
|
||||||
monthly_familias=monthly_familias,
|
curso_summary=curso_summary,
|
||||||
|
portfolio_analysis_text=portfolio_text,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" Warning: Slack notification failed: {e}")
|
print(f" Warning: Slack notification failed: {e}")
|
||||||
|
|||||||
@ -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 sys
|
||||||
import io
|
import io
|
||||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
|
||||||
@ -21,41 +22,82 @@ def main():
|
|||||||
baserow = BaserowClient()
|
baserow = BaserowClient()
|
||||||
airtable = AirtableClient()
|
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) ──────
|
# ── Monthly daily totals: Leads Meta vs Leads Airtable (fresco, no se persiste) ─
|
||||||
print("Obteniendo datos mensuales de Meta...")
|
print("Obteniendo datos mensuales de Meta y Airtable...")
|
||||||
month_start = f"{run_date[:7]}-01"
|
month_start = f"{run_date[:7]}-01"
|
||||||
daily_rows = meta.get_daily_campaign_rows(month_start, run_date)
|
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 = {}
|
_daily: dict = {}
|
||||||
monthly_familias: dict = {}
|
|
||||||
for row in daily_rows:
|
for row in daily_rows:
|
||||||
cursoid = extract_cursoid(row["campaign_name"]) or ""
|
cursoid = extract_cursoid(row["campaign_name"]) or ""
|
||||||
familia = familia_lookup.get(cursoid, "Sin familia")
|
|
||||||
ppl = ppl_lookup.get(cursoid, 0)
|
ppl = ppl_lookup.get(cursoid, 0)
|
||||||
margin = round(row["leads"] * ppl - row["spend"], 2)
|
d = _daily.setdefault(row["date"], {
|
||||||
d = _daily.setdefault(row["date"], {"spend": 0.0, "leads": 0, "margin": 0.0, "f_margins": {}})
|
"spend": 0.0, "leads_meta": 0, "leads_at": 0, "ing_meta": 0.0, "ing_at": 0.0,
|
||||||
d["spend"] += row["spend"]
|
})
|
||||||
d["leads"] += row["leads"]
|
d["spend"] += row["spend"]
|
||||||
d["margin"] += margin
|
d["leads_meta"] += row["leads"]
|
||||||
d["f_margins"][familia] = d["f_margins"].get(familia, 0.0) + margin
|
d["ing_meta"] += row["leads"] * ppl
|
||||||
mf = monthly_familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
|
for lead in daily_at_leads:
|
||||||
mf["spend"] += row["spend"]
|
ppl = ppl_lookup.get(lead["cursoid"], 0)
|
||||||
mf["leads"] += row["leads"]
|
d = _daily.setdefault(lead["date"], {
|
||||||
mf["margin"] += margin
|
"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 = [
|
daily_totals = [
|
||||||
{
|
{
|
||||||
"date": date,
|
"date": date,
|
||||||
"spend": round(d["spend"], 2),
|
"spend": round(d["spend"], 2),
|
||||||
"leads": int(d["leads"]),
|
"leads_meta": int(d["leads_meta"]),
|
||||||
"cpl": round(d["spend"] / d["leads"], 2) if d["leads"] > 0 else 0.0,
|
"leads_at": int(d["leads_at"]),
|
||||||
"margin": round(d["margin"], 2),
|
"ing_meta": round(d["ing_meta"], 2),
|
||||||
"f_margins": {f: round(m, 0) for f, m in d["f_margins"].items()},
|
"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())
|
for date, d in sorted(_daily.items())
|
||||||
]
|
]
|
||||||
print(f" ✓ {len(daily_totals)} días con datos")
|
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) ──────────────────────
|
# ── Load proposed actions (to get parameter values) ──────────────────────
|
||||||
action_params: dict = {} # campaign_name → parameter
|
action_params: dict = {} # campaign_name → parameter
|
||||||
try:
|
try:
|
||||||
@ -86,8 +128,6 @@ def main():
|
|||||||
# defecto (slack_notifier ya los trata con .get(...)).
|
# defecto (slack_notifier ya los trata con .get(...)).
|
||||||
campaign_details: dict = {}
|
campaign_details: dict = {}
|
||||||
actions: list = []
|
actions: list = []
|
||||||
familias: dict = {}
|
|
||||||
metrics_all: dict = {}
|
|
||||||
|
|
||||||
for snap in snapshots:
|
for snap in snapshots:
|
||||||
cid = snap.get("campaign_id") or snap.get("campaign_name", "")
|
cid = snap.get("campaign_id") or snap.get("campaign_name", "")
|
||||||
@ -96,7 +136,6 @@ def main():
|
|||||||
margin = float(snap.get("margin") or 0)
|
margin = float(snap.get("margin") or 0)
|
||||||
spend = float(snap.get("spend") or 0)
|
spend = float(snap.get("spend") or 0)
|
||||||
leads = int(snap.get("leads") or 0)
|
leads = int(snap.get("leads") or 0)
|
||||||
cpl = float(snap.get("cpl") or 0)
|
|
||||||
action_type = snap.get("action_type") or "MAINTAIN"
|
action_type = snap.get("action_type") or "MAINTAIN"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -118,7 +157,6 @@ def main():
|
|||||||
"ads": ads,
|
"ads": ads,
|
||||||
"bid_config": {},
|
"bid_config": {},
|
||||||
}
|
}
|
||||||
metrics_all[cid] = {"name": name, "spend": spend, "leads": leads, "cpl": cpl}
|
|
||||||
|
|
||||||
if action_type != "MAINTAIN":
|
if action_type != "MAINTAIN":
|
||||||
actions.append({
|
actions.append({
|
||||||
@ -132,31 +170,18 @@ def main():
|
|||||||
"row_id": snap["id"],
|
"row_id": snap["id"],
|
||||||
})
|
})
|
||||||
|
|
||||||
f = familias.setdefault(familia, {"spend": 0.0, "leads": 0, "margin": 0.0})
|
# ── Send (sin diagnóstico estratégico: reenviar no vuelve a llamar a Claude) ─
|
||||||
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...")
|
print("Enviando a Slack...")
|
||||||
ts = slack_notifier.send_daily_report(
|
ts = slack_notifier.send_daily_report(
|
||||||
daily_totals=daily_totals,
|
daily_totals=daily_totals,
|
||||||
best_campaigns=best_10,
|
best_campaigns=[],
|
||||||
worst_campaigns=worst_10,
|
worst_campaigns=[],
|
||||||
actions=actions,
|
actions=actions,
|
||||||
campaigns_analyzed=len(snapshots),
|
campaigns_analyzed=len(snapshots),
|
||||||
mode="DRY_RUN",
|
mode="DRY_RUN",
|
||||||
familias=familias,
|
|
||||||
campaign_details=campaign_details,
|
campaign_details=campaign_details,
|
||||||
monthly_familias=monthly_familias,
|
curso_summary=curso_summary,
|
||||||
|
portfolio_analysis_text=None,
|
||||||
)
|
)
|
||||||
if ts:
|
if ts:
|
||||||
print(f" ✓ Mensaje enviado (ts={ts})")
|
print(f" ✓ Mensaje enviado (ts={ts})")
|
||||||
|
|||||||
@ -81,36 +81,6 @@ def update_message(channel: str, ts: str, text: str):
|
|||||||
_post("chat.update", channel=channel, ts=ts, text=text, blocks=[])
|
_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:
|
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."""
|
"""Genera tabla monoespaciada de adsets o anuncios para Slack."""
|
||||||
if not items:
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _familia_status(margin: float, has_issues: bool, no_data: bool) -> str:
|
def _marg(v: float) -> str:
|
||||||
if no_data:
|
v_int = round(v)
|
||||||
return "⚪"
|
return ("+" if v_int >= 0 else "") + f"{v_int:,.0f}€".replace(",", ".")
|
||||||
if has_issues:
|
|
||||||
return "🚨" if margin < 0 else "⚠️"
|
|
||||||
return "✅" if margin >= 0 else "⚠️"
|
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(
|
def send_daily_report(
|
||||||
@ -168,131 +224,67 @@ def send_daily_report(
|
|||||||
actions: list,
|
actions: list,
|
||||||
campaigns_analyzed: int,
|
campaigns_analyzed: int,
|
||||||
mode: str = "DRY_RUN",
|
mode: str = "DRY_RUN",
|
||||||
familias: dict = None,
|
|
||||||
campaign_details: dict = None,
|
campaign_details: dict = None,
|
||||||
monthly_familias: dict = None,
|
curso_summary: dict = None,
|
||||||
|
portfolio_analysis_text: str = None,
|
||||||
) -> 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()
|
now = datetime.now()
|
||||||
date_label = now.strftime("%d/%m/%Y")
|
date_label = now.strftime("%d/%m/%Y")
|
||||||
month_name = now.strftime("%B %Y").capitalize()
|
month_name = now.strftime("%B %Y").capitalize()
|
||||||
prefix = config.META_CAMPAIGN_PREFIX
|
|
||||||
mode_label = "DRY RUN" if mode == "DRY_RUN" else "PRODUCCIÓN"
|
mode_label = "DRY RUN" if mode == "DRY_RUN" else "PRODUCCIÓN"
|
||||||
|
|
||||||
action_map = {a["campaign_name"]: a for a in actions}
|
action_map = {a["campaign_name"]: a for a in actions}
|
||||||
details_map = campaign_details or {}
|
details_map = campaign_details or {}
|
||||||
|
campaigns = [(cid, detail, action_map.get(detail["name"])) for cid, detail in details_map.items()]
|
||||||
# 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)
|
|
||||||
|
|
||||||
# ── Message 1: Dashboard ─────────────────────────────────────────────────
|
# ── Message 1: Dashboard ─────────────────────────────────────────────────
|
||||||
blocks: list = [
|
blocks: list = [
|
||||||
{
|
{
|
||||||
"type": "header",
|
"type": "header",
|
||||||
"text": {"type": "plain_text",
|
"text": {"type": "plain_text", "text": f"Meta Optimizer Formación — {date_label} ({mode_label})"},
|
||||||
"text": f"Meta Optimizer Formación — {date_label} ({mode_label})"},
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Monthly profitability table
|
|
||||||
if daily_totals:
|
if daily_totals:
|
||||||
f_order = (
|
tot_spend = sum(d["spend"] for d in daily_totals)
|
||||||
sorted(monthly_familias.keys(), key=lambda f: -monthly_familias[f]["margin"])
|
tot_leads_m = sum(d["leads_meta"] for d in daily_totals)
|
||||||
if monthly_familias else []
|
tot_leads_at = sum(d["leads_at"] for d in daily_totals)
|
||||||
)
|
tot_ing_m = sum(d["ing_meta"] for d in daily_totals)
|
||||||
cw = 7
|
tot_ing_at = sum(d["ing_at"] for d in daily_totals)
|
||||||
hdr = f"{'Día':<5} {'Gasto':>6} {'Leads':>5} {'CPL':>7}"
|
margen_m = tot_ing_m - tot_spend
|
||||||
for f in f_order:
|
margen_at = tot_ing_at - tot_spend
|
||||||
hdr += f" {f[:6]:>{cw}}"
|
pct_m = round(margen_m / tot_ing_m * 100, 1) if tot_ing_m > 0 else 0.0
|
||||||
hdr += " Est"
|
pct_at = round(margen_at / tot_ing_at * 100, 1) if tot_ing_at > 0 else 0.0
|
||||||
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)
|
|
||||||
blocks.append({
|
blocks.append({
|
||||||
"type": "section",
|
"type": "section",
|
||||||
"text": {
|
"text": {
|
||||||
"type": "mrkdwn",
|
"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:
|
else:
|
||||||
blocks.append({
|
blocks.append({
|
||||||
"type": "section",
|
"type": "section",
|
||||||
"text": {"type": "mrkdwn", "text": "_Sin datos del mes en curso aún._"},
|
"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({
|
blocks.append({
|
||||||
"type": "context",
|
"type": "context",
|
||||||
"elements": [{"type": "mrkdwn",
|
"elements": [{"type": "mrkdwn", "text": f"{campaigns_analyzed} campañas analizadas"}],
|
||||||
"text": f"{campaigns_analyzed} campañas analizadas — detalle por familia a continuación"}],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
result = _post(
|
result = _post(
|
||||||
@ -303,39 +295,53 @@ def send_daily_report(
|
|||||||
)
|
)
|
||||||
ts = result.get("ts")
|
ts = result.get("ts")
|
||||||
|
|
||||||
# ── One message per familia ──────────────────────────────────────────────
|
# ── Message 2: resumen y contraste por curso ───────────────────────────────
|
||||||
for f, camp_list in sorted_familias:
|
curso_blocks = _curso_summary_blocks(curso_summary or {})
|
||||||
f_data = (familias or {}).get(f, {})
|
if curso_blocks:
|
||||||
f_spend = f_data.get("spend", 0)
|
_post("chat.postMessage", channel=config.SLACK_CHANNEL_ID, blocks=curso_blocks,
|
||||||
f_leads = f_data.get("leads", 0)
|
text="Resumen y contraste por curso")
|
||||||
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)
|
|
||||||
|
|
||||||
f_blocks: list = [
|
# ── Message 3: diagnóstico estratégico ──────────────────────────────────────
|
||||||
{
|
if portfolio_analysis_text:
|
||||||
"type": "header",
|
_post(
|
||||||
"text": {"type": "plain_text", "text": f"{st} {f.upper()}"},
|
"chat.postMessage",
|
||||||
},
|
channel=config.SLACK_CHANNEL_ID,
|
||||||
{
|
blocks=[
|
||||||
"type": "section",
|
{"type": "header", "text": {"type": "plain_text", "text": "🤖 Diagnóstico estratégico"}},
|
||||||
"text": {
|
{"type": "section", "text": {"type": "mrkdwn", "text": portfolio_analysis_text[:2950]}},
|
||||||
"type": "mrkdwn",
|
],
|
||||||
"text": f"{f_spend:.0f}€ · {f_leads} leads · CPL {f_cpl:.2f}€ · Margen {m_str}",
|
text="Diagnóstico estratégico",
|
||||||
},
|
)
|
||||||
},
|
|
||||||
{"type": "divider"},
|
|
||||||
]
|
|
||||||
|
|
||||||
for i, (cid, detail, act) in enumerate(
|
# ── Mensajes 4..N: tarjetas por campaña, en lotes (sin agrupar por familia) ─
|
||||||
sorted(camp_list, key=lambda x: -x[1].get("spend_1d", 0))
|
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:
|
if i > 0:
|
||||||
f_blocks.append({"type": "divider"})
|
c_blocks.append({"type": "divider"})
|
||||||
|
|
||||||
name = detail["name"]
|
name = detail["name"]
|
||||||
|
familia = detail.get("familia", "")
|
||||||
spend_1d = detail.get("spend_1d", 0.0)
|
spend_1d = detail.get("spend_1d", 0.0)
|
||||||
leads_1d = detail.get("leads_1d", 0)
|
leads_1d = detail.get("leads_1d", 0)
|
||||||
margin = detail["margin"]
|
margin = detail["margin"]
|
||||||
@ -354,85 +360,94 @@ def send_daily_report(
|
|||||||
atype = act["action_type"] if act else "MAINTAIN"
|
atype = act["action_type"] if act else "MAINTAIN"
|
||||||
cemoji, alabel = _ACTION_DISPLAY.get(atype, ("⚪", atype))
|
cemoji, alabel = _ACTION_DISPLAY.get(atype, ("⚪", atype))
|
||||||
|
|
||||||
if atype == "MAINTAIN" and not any(
|
camp_text = (
|
||||||
ad.get("accion") == "PAUSE" and ad.get("row_id") for ad in ads
|
f"{cemoji} *{name}*" + (f" _{familia}_" if familia and familia != "Sin familia" else "") + "\n"
|
||||||
):
|
f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · Margen: {m_str2} · "
|
||||||
# Compact header for clean campaigns
|
f"{u_emoji} {urgencia} · Cap mes: {cap_str}"
|
||||||
f_blocks.append({
|
+ (f" · `{strat_label}`" if strategy else "")
|
||||||
"type": "section",
|
+ (f" · {budget:.0f}€/día" if budget else "")
|
||||||
"text": {
|
)
|
||||||
"type": "mrkdwn",
|
if atype != "MAINTAIN":
|
||||||
"text": (
|
camp_text += f"\n*{alabel}*"
|
||||||
f"{cemoji} *{name}*\n"
|
if act and act.get("justification"):
|
||||||
f"Ayer: {spend_1d:.0f}€ / {leads_1d} leads · "
|
camp_text += f" — _{act['justification'][:160]}_"
|
||||||
f"Margen: {m_str2} · {u_emoji} {urgencia} · Cap mes: {cap_str}"
|
if act and act.get("advice"):
|
||||||
+ (f" · `{strat_label}`" if strategy else "")
|
camp_text += f"\n💡 {act['advice'][:160]}"
|
||||||
+ (f" · {budget:.0f}€/día" if budget else "")
|
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
|
# Adsets: tabla + recomendación de cada uno
|
||||||
if act and atype in _ACTIONABLE:
|
if adsets:
|
||||||
effect = _effect_text(act, budget)
|
tbl = _adset_ad_table(adsets[:3], "Conjuntos (3 días)", show_bid=True)
|
||||||
if effect:
|
if tbl:
|
||||||
f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": effect}})
|
for chunk in [tbl[j:j+2900] for j in range(0, len(tbl), 2900)]:
|
||||||
f_blocks.append({
|
c_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}})
|
||||||
"type": "actions",
|
rec_lines = [
|
||||||
"elements": [
|
f"• _{_table_name(a['name'], 40)}_: {a['recomendacion'][:110]}"
|
||||||
{
|
for a in adsets[:3] if a.get("recomendacion")
|
||||||
"type": "button",
|
]
|
||||||
"text": {"type": "plain_text", "text": "✅ Aprobar"},
|
if rec_lines:
|
||||||
"style": "primary",
|
c_blocks.append({"type": "section", "text": {"type": "mrkdwn",
|
||||||
"value": f"approve:{act['row_id']}",
|
"text": "*Recomendaciones (conjuntos):*\n" + "\n".join(rec_lines)}})
|
||||||
"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']}",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
# Adset table (top 3) — only for non-MAINTAIN
|
# Anuncios: recomendaciones de los marcados para pausa + botón
|
||||||
if atype != "MAINTAIN" and adsets:
|
pause_ads = [a for a in ads if a.get("accion") == "PAUSE" and a.get("row_id")]
|
||||||
tbl = _adset_ad_table(adsets[:3], "Conjuntos (3 días)", show_bid=True)
|
for ad in pause_ads:
|
||||||
if tbl:
|
rec = ad.get("recomendacion") or "Sin leads en 7 días."
|
||||||
for chunk in [tbl[j:j+2900] for j in range(0, len(tbl), 2900)]:
|
c_blocks.append({
|
||||||
f_blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": chunk}})
|
"type": "section",
|
||||||
|
"text": {"type": "mrkdwn", "text": f"⛔ *{ad['name'][:80]}*\n{rec[:150]}"},
|
||||||
# Ad pause buttons
|
})
|
||||||
f_blocks.extend(_ad_action_blocks(ads))
|
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(
|
_post(
|
||||||
"chat.postMessage",
|
"chat.postMessage",
|
||||||
channel=config.SLACK_CHANNEL_ID,
|
channel=config.SLACK_CHANNEL_ID,
|
||||||
blocks=f_blocks,
|
blocks=c_blocks,
|
||||||
text=f"{f.upper()} · {f_spend:.0f}€ · {f_leads} leads",
|
text=f"Campañas {batch_start + 1}–{batch_start + len(batch)} de {len(sorted_campaigns)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
return ts
|
return ts
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user