Fix creative-analysis JSON truncation bug; add Meta/Airtable split to dashboard

- analyze_creative_deep was truncating mid-JSON ~50-75% of the time
  (max_tokens=700 too low once the analysis text runs long), silently
  falling back to score=0/"Error parseando respuesta." Confirmed via
  stop_reason=max_tokens on real API calls. Raised to 1400, and bumped
  analyze_creative/compare_adset_creatives (600→900) as the same risk
  applies there. Verified 6/6 clean parses after the fix (was failing
  ~3/4 before).
- Dashboard tabs "Campañas", "Familias", and the per-day breakdown in
  "Por día" still showed a single Meta-only Leads/CPL number; added the
  Leads/CPL según Airtable (leadform+landing) columns alongside, matching
  the Slack report so the dashboard never shows a different figure than
  what's already trusted from the daily report.
- Validated both end-to-end with real data: dashboard via Streamlit's
  AppTest headless runner (0 exceptions across all 5 tabs), creative
  analyzer via a live run against one campaign (real scores, fatigue
  detection, comparison, Baserow save, Slack send all confirmed).
This commit is contained in:
Jose Manuel 2026-07-09 16:47:13 +02:00
parent 921000c47e
commit 0016809ece
2 changed files with 102 additions and 40 deletions

View File

@ -287,7 +287,7 @@ def analyze_creative(image_url: str, ad_name: str) -> dict:
try: try:
response = client.messages.create( response = client.messages.create(
model="claude-sonnet-4-6", model="claude-sonnet-4-6",
max_tokens=600, max_tokens=900,
system=CREATIVE_SYSTEM, system=CREATIVE_SYSTEM,
messages=[{ messages=[{
"role": "user", "role": "user",
@ -429,7 +429,7 @@ def analyze_creative_deep(image_url: str, ad_name: str, metrics: dict) -> dict:
try: try:
response = client.messages.create( response = client.messages.create(
model="claude-sonnet-4-6", model="claude-sonnet-4-6",
max_tokens=700, max_tokens=1400,
system=CREATIVE_DEEP_SYSTEM, system=CREATIVE_DEEP_SYSTEM,
messages=[{ messages=[{
"role": "user", "role": "user",
@ -484,7 +484,7 @@ def compare_adset_creatives(ads: list) -> dict:
try: try:
response = client.messages.create( response = client.messages.create(
model="claude-sonnet-4-6", model="claude-sonnet-4-6",
max_tokens=600, max_tokens=900,
system=CREATIVE_COMPARE_SYSTEM, system=CREATIVE_COMPARE_SYSTEM,
messages=[{"role": "user", "content": content_blocks}], messages=[{"role": "user", "content": content_blocks}],
) )

View File

@ -96,6 +96,27 @@ 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 leads de Airtable...")
def _load_at_leads_by_cursoid(date_from: str, date_to: str) -> dict:
"""{cursoid: nº leads en Leads Lake (leadform+landing) en el rango}."""
leads = AirtableClient().get_meta_leads_bulk(date_from, date_to)
counts: dict = {}
for l in leads:
counts[l["cursoid"]] = counts.get(l["cursoid"], 0) + 1
return counts
@st.cache_data(ttl=300, show_spinner="Cargando leads de Airtable por día...")
def _load_at_leads_by_day_cursoid(date_from: str, date_to: str) -> dict:
"""{(date, cursoid): nº leads en Leads Lake (leadform+landing)}."""
leads = AirtableClient().get_meta_leads_bulk(date_from, date_to)
counts: dict = {}
for l in leads:
key = (l["date"], l["cursoid"])
counts[key] = counts.get(key, 0) + 1
return counts
@st.cache_data(ttl=300, show_spinner="Cargando métricas diarias (Baserow)...") @st.cache_data(ttl=300, show_spinner="Cargando métricas diarias (Baserow)...")
def _load_daily_metrics(date_from: str, date_to: str): def _load_daily_metrics(date_from: str, date_to: str):
"""Totales diarios persistidos por run.py (Leads Meta vs Leads Airtable) — """Totales diarios persistidos por run.py (Leads Meta vs Leads Airtable) —
@ -258,28 +279,38 @@ with tab1:
key="t1_day", key="t1_day",
) )
if selected_day: if selected_day:
try:
at_leads_day = _load_at_leads_by_day_cursoid(day_opts[-1], day_opts[0])
except Exception:
at_leads_day = {}
day_camp: dict = {} day_camp: dict = {}
for row in daily_rows: for row in daily_rows:
if row["date"] != selected_day: if row["date"] != selected_day:
continue continue
k = row["campaign_name"] k = row["campaign_name"]
if k not in day_camp: if k not in day_camp:
ppl = ppl_lookup.get(extract_cursoid(k) or "", 0) cursoid = extract_cursoid(k) or ""
ppl = ppl_lookup.get(cursoid, 0)
day_camp[k] = {"name": k, "familia": _familia_of(k, familia_lookup), day_camp[k] = {"name": k, "familia": _familia_of(k, familia_lookup),
"spend": 0.0, "leads": 0, "ppl": ppl} "spend": 0.0, "leads": 0, "ppl": ppl, "cursoid": cursoid}
day_camp[k]["spend"] += row["spend"] day_camp[k]["spend"] += row["spend"]
day_camp[k]["leads"] += row["leads"] day_camp[k]["leads"] += row["leads"]
camp_rows = [] camp_rows = []
for c in sorted(day_camp.values(), key=lambda x: -x["spend"]): for c in sorted(day_camp.values(), key=lambda x: -x["spend"]):
cpl = round(c["spend"] / c["leads"], 2) if c["leads"] > 0 else 0.0 cpl = round(c["spend"] / c["leads"], 2) if c["leads"] > 0 else 0.0
margin = round(c["leads"] * c["ppl"] - c["spend"], 2) leads_at = at_leads_day.get((selected_day, c["cursoid"]), 0)
cpl_at = round(c["spend"] / leads_at, 2) if leads_at > 0 else 0.0
margin = round(c["leads"] * c["ppl"] - c["spend"], 2)
camp_rows.append({ camp_rows.append({
"Campaña": c["name"], "Campaña": c["name"],
"Familia": c["familia"], "Familia": c["familia"],
"Gasto": _eur(c["spend"]), "Gasto": _eur(c["spend"]),
"Leads": c["leads"], "Leads Meta": c["leads"],
"CPL": _eur(cpl) if c["leads"] > 0 else "", "Leads AT": leads_at,
"CPL Meta": _eur(cpl) if c["leads"] > 0 else "",
"CPL AT": _eur(cpl_at) if leads_at > 0 else "",
"PPL": _eur(c["ppl"]) if c["ppl"] else "", "PPL": _eur(c["ppl"]) if c["ppl"] else "",
"Margen": _margin(margin), "Margen": _margin(margin),
}) })
@ -301,6 +332,11 @@ with tab2:
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}")
campaign_metrics_2 = {} campaign_metrics_2 = {}
try:
at_leads_2 = _load_at_leads_by_cursoid(d_from_2.strftime("%Y-%m-%d"), d_to_2.strftime("%Y-%m-%d"))
except Exception as e:
st.error(f"Error cargando leads de Airtable: {e}")
at_leads_2 = {}
fam_opts_2 = ["Todas"] + sorted({_familia_of(m["name"], familia_lookup) for m in campaign_metrics_2.values()}) fam_opts_2 = ["Todas"] + sorted({_familia_of(m["name"], familia_lookup) for m in campaign_metrics_2.values()})
sel_fam_2 = col_fam_2.selectbox("Familia", fam_opts_2, key="t2_fam") sel_fam_2 = col_fam_2.selectbox("Familia", fam_opts_2, key="t2_fam")
@ -316,22 +352,29 @@ with tab2:
else: else:
camp_rows = [] camp_rows = []
for cid, m in sorted(campaign_metrics_2.items(), key=lambda x: -x[1]["spend"]): for cid, m in sorted(campaign_metrics_2.items(), key=lambda x: -x[1]["spend"]):
ppl = ppl_lookup.get(extract_cursoid(m["name"]) or "", 0) cursoid = extract_cursoid(m["name"]) or ""
margin = round(m["leads"] * ppl - m["spend"], 2) ppl = ppl_lookup.get(cursoid, 0)
leads_at = at_leads_2.get(cursoid, 0)
cpl_at = round(m["spend"] / leads_at, 2) if leads_at > 0 else 0.0
margin = round(m["leads"] * ppl - m["spend"], 2)
camp_rows.append({ camp_rows.append({
"Campaña": m["name"], "Campaña": m["name"],
"Familia": _familia_of(m["name"], familia_lookup), "Familia": _familia_of(m["name"], familia_lookup),
"Gasto": _eur(m["spend"]), "Gasto": _eur(m["spend"]),
"Leads": m["leads"], "Leads Meta": m["leads"],
"CPL": _eur(m["cpl"]) if m["leads"] > 0 else "", "Leads AT": leads_at,
"PPL": _eur(ppl) if ppl else "", "CPL Meta": _eur(m["cpl"]) if m["leads"] > 0 else "",
"Margen": _margin(margin), "CPL AT": _eur(cpl_at) if leads_at > 0 else "",
"CTR": f"{m['ctr']:.1f}%", "PPL": _eur(ppl) if ppl else "",
"_cid": cid, "Margen": _margin(margin),
"CTR": f"{m['ctr']:.1f}%",
"_cid": cid,
}) })
df_camps = pd.DataFrame([{k: v for k, v in r.items() if k != "_cid"} for r in camp_rows]) df_camps = pd.DataFrame([{k: v for k, v in r.items() if k != "_cid"} for r in camp_rows])
st.dataframe(df_camps, use_container_width=True, hide_index=True) st.dataframe(df_camps, use_container_width=True, hide_index=True)
st.caption("Leads AT = leads en Leads Lake (leadform + landing) del curso completo — si el curso "
"tiene más de una campaña activa, el mismo total aparece en cada una.")
st.subheader("Detalle de campaña") st.subheader("Detalle de campaña")
camp_id_map = {r["Campaña"]: r["_cid"] for r in camp_rows} camp_id_map = {r["Campaña"]: r["_cid"] for r in camp_rows}
@ -398,32 +441,51 @@ with tab3:
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}")
campaign_metrics_3 = {} campaign_metrics_3 = {}
try:
at_leads_3 = _load_at_leads_by_cursoid(d_from_3.strftime("%Y-%m-%d"), d_to_3.strftime("%Y-%m-%d"))
except Exception as e:
st.error(f"Error cargando leads de Airtable: {e}")
at_leads_3 = {}
# Agregar primero por curso (único), luego por familia — si un curso
# tiene 2 campañas activas no se duplican sus leads de Airtable.
curso_agg: dict = {}
for cid, m in campaign_metrics_3.items():
cursoid = extract_cursoid(m["name"]) or ""
ca = curso_agg.setdefault(cursoid, {
"spend": 0.0, "leads_meta": 0,
"familia": familia_lookup.get(cursoid, "Sin familia"),
"ppl": ppl_lookup.get(cursoid, 0),
})
ca["spend"] += m["spend"]
ca["leads_meta"] += m["leads"]
familias_3: dict = {} familias_3: dict = {}
for cid, m in campaign_metrics_3.items(): for cursoid, ca in curso_agg.items():
fam = _familia_of(m["name"], familia_lookup) leads_at = at_leads_3.get(cursoid, 0)
ppl = ppl_lookup.get(extract_cursoid(m["name"]) or "", 0) margin = ca["leads_meta"] * ca["ppl"] - ca["spend"]
margin = round(m["leads"] * ppl - m["spend"], 2) f = familias_3.setdefault(ca["familia"], {"spend": 0.0, "leads_meta": 0, "leads_at": 0, "margin": 0.0})
if fam not in familias_3: f["spend"] += ca["spend"]
familias_3[fam] = {"spend": 0.0, "leads": 0, "margin": 0.0} f["leads_meta"] += ca["leads_meta"]
familias_3[fam]["spend"] += m["spend"] f["leads_at"] += leads_at
familias_3[fam]["leads"] += m["leads"] f["margin"] += margin
familias_3[fam]["margin"] += margin
if not familias_3: if not familias_3:
st.info("Sin datos de familias.") st.info("Sin datos de familias.")
else: else:
fam_rows = [] fam_rows = []
for fam, data in sorted(familias_3.items(), key=lambda x: -x[1]["margin"]): for fam, data in sorted(familias_3.items(), key=lambda x: -x[1]["margin"]):
f_leads = data["leads"] f_spend = data["spend"]
f_spend = data["spend"] cpl_meta = round(f_spend / data["leads_meta"], 2) if data["leads_meta"] > 0 else 0.0
f_cpl = round(f_spend / f_leads, 2) if f_leads > 0 else 0.0 cpl_at = round(f_spend / data["leads_at"], 2) if data["leads_at"] > 0 else 0.0
fam_rows.append({ fam_rows.append({
"Familia": fam, "Familia": fam,
"Gasto": _eur(f_spend), "Gasto": _eur(f_spend),
"Leads": f_leads, "Leads Meta": data["leads_meta"],
"CPL": _eur(f_cpl), "Leads AT": data["leads_at"],
"Margen": _margin(data["margin"]), "CPL Meta": _eur(cpl_meta) if data["leads_meta"] > 0 else "",
"CPL AT": _eur(cpl_at) if data["leads_at"] > 0 else "",
"Margen": _margin(data["margin"]),
}) })
st.dataframe(pd.DataFrame(fam_rows), use_container_width=True, hide_index=True) st.dataframe(pd.DataFrame(fam_rows), use_container_width=True, hide_index=True)