- 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).
495 lines
20 KiB
Python
495 lines
20 KiB
Python
import json
|
||
import base64
|
||
import requests
|
||
import anthropic
|
||
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.
|
||
El capping mensual admitido por curso es un recurso COMPARTIDO entre Meta Ads y Google Ads
|
||
para el mismo curso: cuando se alcanza, hay que dejar de comprar leads en TODOS los canales,
|
||
no solo en Meta.
|
||
|
||
Recibirás un análisis ya calculado con estos campos clave:
|
||
- urgencia: PAUSAR | SPRINT | ACELERAR | FRENAR | EN_RITMO (señal principal de decisión)
|
||
- rentable: true/false (cpa_actual <= cpa_maximo)
|
||
- ritmo: positivo = adelantado sobre el ritmo del capping, negativo = atrasado
|
||
- margen, leads_restantes, dias_restantes, capping, ppl, cpa_maximo, cpa_actual
|
||
- alerta_tracking: true si hay discrepancia grande entre los leads contados por Meta y los
|
||
de Leads Lake (Airtable) — puede indicar leads fantasma o un problema de tracking/píxel
|
||
- status_meta: estado actual de la campaña en Meta (ACTIVE / PAUSED)
|
||
|
||
REGLAS DE DECISIÓN (según urgencia):
|
||
1. urgencia=PAUSAR (capping mensual ya consumido) → action=PAUSE siempre, sin excepción.
|
||
2. urgencia=SPRINT (atrasado respecto al cap y quedan pocos días de mes) → action=INCREASE_BUDGET,
|
||
parameter entre 1.3 y 1.5.
|
||
3. urgencia=ACELERAR y rentable=true → action=INCREASE_BUDGET, parameter entre 1.1 y 1.25.
|
||
4. urgencia=ACELERAR y rentable=false → action=MAINTAIN (no subir presupuesto perdiendo margen).
|
||
5. urgencia=FRENAR (muy adelantado sobre el ritmo del cap) → action=REDUCE_BUDGET,
|
||
parameter entre 0.75 y 0.9.
|
||
6. urgencia=EN_RITMO y rentable=true → action=MAINTAIN.
|
||
7. urgencia=EN_RITMO y rentable=false → action=REDUCE_BUDGET, parameter=0.85.
|
||
8. Si capping=0 (sin límite definido este mes), ignora el ritmo respecto al cap y decide
|
||
solo por rentabilidad (rentable/cpa_actual vs cpa_maximo).
|
||
9. Si alerta_tracking=true, menciónalo explícitamente en "alert" sea cual sea la acción elegida.
|
||
10. Nunca propongas INCREASE_BUDGET si status_meta=PAUSED — indica en "advice" que hay que
|
||
reactivar la campaña manualmente primero.
|
||
|
||
USA SIEMPRE € como unidad de moneda en justification/advice. Responde SIEMPRE en español en esos campos.
|
||
Responde SOLO con JSON válido, sin texto adicional ni markdown:
|
||
{
|
||
"action": "PAUSE | REDUCE_BUDGET | INCREASE_BUDGET | MAINTAIN",
|
||
"parameter": 1.0,
|
||
"justification": "explicación breve en español usando €",
|
||
"advice": "acción concreta y específica a realizar",
|
||
"alert": "texto crítico si lo hay, null si no",
|
||
"confidence": 0.0
|
||
}
|
||
"""
|
||
|
||
|
||
def decide(analysis: dict) -> dict:
|
||
response = client.messages.create(
|
||
model="claude-haiku-4-5-20251001",
|
||
max_tokens=400,
|
||
system=DECIDE_SYSTEM,
|
||
messages=[{
|
||
"role": "user",
|
||
"content": (
|
||
"Analyze this Meta Ads campaign and return the decision as JSON:\n\n"
|
||
+ json.dumps(analysis, ensure_ascii=False, indent=2)
|
||
),
|
||
}],
|
||
)
|
||
raw = response.content[0].text.strip()
|
||
clean = raw.replace("```json", "").replace("```", "").strip()
|
||
try:
|
||
return json.loads(clean)
|
||
except json.JSONDecodeError:
|
||
import re as _re
|
||
m = _re.search(r"\{.*\}", clean, _re.DOTALL)
|
||
if m:
|
||
try:
|
||
return json.loads(m.group())
|
||
except json.JSONDecodeError:
|
||
pass
|
||
return {
|
||
"action": "MAINTAIN",
|
||
"parameter": 1.0,
|
||
"justification": "Error parsing agent response.",
|
||
"advice": "",
|
||
"alert": f"Invalid JSON: {raw[:200]}",
|
||
"confidence": 0.0,
|
||
}
|
||
|
||
|
||
UNIT_SYSTEM = """
|
||
Eres un analista experto en Meta Ads para cursos de formación. Analiza las métricas del conjunto
|
||
de anuncios indicado. Los datos corresponden a los últimos 3 días (ventana estándar de análisis).
|
||
ppl es el precio por lead del curso; cpa_maximo es el coste por lead máximo rentable (PPL × 0.70).
|
||
USA SIEMPRE € como unidad de moneda. Responde SIEMPRE en español.
|
||
Compara el CPL actual (spend/leads) con cpa_maximo e indica si el conjunto es rentable, cuánto
|
||
margen queda (o cuánto se supera). Si además tiene cost_cap_eur (cap de coste de Meta), menciona
|
||
también si el CPL está dentro de ese cap — son dos límites independientes, no los confundas.
|
||
Responde SOLO con JSON válido (sin markdown):
|
||
{"evaluacion": "resumen del rendimiento en 2 frases usando €, comparando CPL con cpa_maximo (PPL)", "recomendacion": "una acción concreta"}
|
||
"""
|
||
|
||
AD_SYSTEM = """
|
||
Eres un analista experto en Meta Ads para cursos de formación. Analiza las métricas del anuncio indicado.
|
||
Los datos incluyen dos ventanas temporales:
|
||
- cpl_3d / leads_3d / spend_3d: últimos 3 días (puede ser volátil con poco volumen)
|
||
- cpl_7d / leads_7d: últimos 7 días (más estable, úsala como referencia principal)
|
||
Los campos spend/leads/cpl sin sufijo corresponden a 7 días.
|
||
cpa_maximo es el coste por lead máximo rentable para el curso (PPL × 0.70).
|
||
USA SIEMPRE € como unidad de moneda. Responde SIEMPRE en español.
|
||
Responde SOLO con JSON válido (sin markdown):
|
||
{"evaluacion": "resumen del rendimiento en 2 frases usando €, mencionando diferencia 3d/7d si es relevante", "recomendacion": "una acción concreta", "accion": "PAUSE o MAINTAIN"}
|
||
|
||
Reglas para "accion": "PAUSE":
|
||
- SOLO si leads_7d == 0 Y el gasto de 7 días supera 3 veces cpa_maximo (o el PPL si no hay
|
||
cpa_maximo, o 15€ si tampoco hay PPL).
|
||
- Si cpl_3d es alto pero cpl_7d está dentro del objetivo, usa "MAINTAIN" (ruido estadístico de período corto).
|
||
- Si el anuncio tiene leads en 7 días aunque el CPL sea alto, usa "MAINTAIN" y recomienda optimizar.
|
||
En cualquier otro caso usa "accion": "MAINTAIN".
|
||
"""
|
||
|
||
|
||
def analyze_unit(metrics: dict, level: str = "adset") -> dict:
|
||
"""Análisis rápido de un conjunto de anuncios o anuncio individual."""
|
||
nivel = "conjunto de anuncios" if level == "adset" else "anuncio"
|
||
system = AD_SYSTEM if level == "ad" else UNIT_SYSTEM
|
||
response = client.messages.create(
|
||
model="claude-haiku-4-5-20251001",
|
||
max_tokens=250,
|
||
system=system,
|
||
messages=[{
|
||
"role": "user",
|
||
"content": f"Analiza este {nivel} de Meta Ads:\n" + json.dumps(metrics, ensure_ascii=False),
|
||
}],
|
||
)
|
||
raw = response.content[0].text.strip()
|
||
import re
|
||
clean = re.sub(r"```json\s*", "", raw)
|
||
clean = re.sub(r"```\s*", "", clean).strip()
|
||
clean = clean.replace("“", '"').replace("”", '"') # normalize smart quotes
|
||
# Strategy 1: direct parse
|
||
try:
|
||
return json.loads(clean)
|
||
except json.JSONDecodeError:
|
||
pass
|
||
# Strategy 2: extract first JSON object by brace boundaries
|
||
start, end = clean.find("{"), clean.rfind("}")
|
||
if start != -1 and end > start:
|
||
try:
|
||
return json.loads(clean[start:end + 1])
|
||
except json.JSONDecodeError:
|
||
pass
|
||
# Strategy 3: extract fields individually with regex
|
||
ev_m = re.search(r'"evaluacion"\s*:\s*"((?:[^"\\]|\\.)*)"', clean)
|
||
rec_m = re.search(r'"recomendacion"\s*:\s*"((?:[^"\\]|\\.)*)"', clean)
|
||
if ev_m or rec_m:
|
||
return {
|
||
"evaluacion": ev_m.group(1) if ev_m else "",
|
||
"recomendacion": rec_m.group(1) if rec_m else "",
|
||
}
|
||
return {"evaluacion": clean[:150], "recomendacion": ""}
|
||
|
||
|
||
CREATIVE_SYSTEM = """
|
||
IDIOMA: Responde SIEMPRE en español. Todos los campos del JSON deben estar en español.
|
||
|
||
Eres un experto en análisis de creatividades de Meta Ads.
|
||
Analiza la imagen publicitaria y devuelve SOLO JSON válido sin markdown:
|
||
{
|
||
"score": 7.5,
|
||
"analysis": "análisis conciso en español: mensaje, diseño, CTA, atractivo visual",
|
||
"recommendations": "mejoras concretas en español para mejorar CTR y conversiones"
|
||
}
|
||
Score 1-10: 1-3 crítico, 4-5 bajo, 6-7 aceptable, 8-9 bueno, 10 excelente.
|
||
"""
|
||
|
||
|
||
def analyze_creative(image_url: str, ad_name: str) -> dict:
|
||
try:
|
||
resp = requests.get(image_url, timeout=15)
|
||
resp.raise_for_status()
|
||
image_data = base64.standard_b64encode(resp.content).decode("utf-8")
|
||
media_type = resp.headers.get("content-type", "image/jpeg").split(";")[0]
|
||
except Exception as e:
|
||
return {"score": 0, "analysis": f"Failed to download image: {e}", "recommendations": ""}
|
||
|
||
try:
|
||
response = client.messages.create(
|
||
model="claude-sonnet-4-6",
|
||
max_tokens=900,
|
||
system=CREATIVE_SYSTEM,
|
||
messages=[{
|
||
"role": "user",
|
||
"content": [
|
||
{
|
||
"type": "image",
|
||
"source": {
|
||
"type": "base64",
|
||
"media_type": media_type,
|
||
"data": image_data,
|
||
},
|
||
},
|
||
{
|
||
"type": "text",
|
||
"text": f'Ad name: "{ad_name}". Analyze this creative.',
|
||
},
|
||
],
|
||
}],
|
||
)
|
||
raw = response.content[0].text.strip()
|
||
clean = raw.replace("```json", "").replace("```", "").strip()
|
||
return json.loads(clean)
|
||
except json.JSONDecodeError:
|
||
return {"score": 0, "analysis": "Error parsing creative analysis.", "recommendations": ""}
|
||
except Exception as e:
|
||
return {"score": 0, "analysis": f"Creative analysis failed: {e}", "recommendations": ""}
|
||
|
||
|
||
CREATIVE_DEEP_SYSTEM = """
|
||
IDIOMA: Responde SIEMPRE en español. Todos los campos del JSON deben estar en español.
|
||
|
||
Eres un experto en análisis de creatividades de Meta Ads con conocimiento de neuromarketing y diseño persuasivo.
|
||
Recibirás una imagen publicitaria junto con sus métricas de rendimiento reales.
|
||
|
||
Evalúa considerando:
|
||
1. CALIDAD VISUAL: claridad del mensaje, jerarquía visual, CTA, copy, atractivo y relevancia
|
||
2. CORRELACIÓN CON RENDIMIENTO: ¿el CTR y CPL reales son consistentes con la calidad visual?
|
||
3. SEÑAL DE FATIGA: si CTR 3d < CTR 7d × 0.75 indica saturación de audiencia
|
||
4. RECOMENDACIONES: mejoras concretas y priorizadas para mejorar CTR y conversiones
|
||
|
||
Devuelve SOLO JSON válido sin markdown (todos los textos en español):
|
||
{
|
||
"score": 7.5,
|
||
"analysis": "análisis conciso en español: qué funciona, qué no, correlación con rendimiento real",
|
||
"recommendations": "mejoras concretas en español en orden de impacto esperado",
|
||
"fatigue": false,
|
||
"fatigue_reason": null
|
||
}
|
||
|
||
Score 1-10: 1-3 crítico (pausar), 4-5 bajo, 6-7 aceptable, 8-9 bueno, 10 excelente.
|
||
Si el anuncio tiene buen rendimiento real (CPL bajo, CTR alto) pero diseño mediocre, sube el score.
|
||
Si el diseño parece bueno pero el rendimiento es pobre, baja el score y explica la desconexión.
|
||
"""
|
||
|
||
CREATIVE_COMPARE_SYSTEM = """
|
||
IDIOMA: Responde SIEMPRE en español. Todos los campos del JSON deben estar en español.
|
||
|
||
Eres un experto en análisis comparativo de creatividades de Meta Ads.
|
||
Recibirás varios anuncios del mismo adset con sus imágenes y métricas de rendimiento.
|
||
Evalúa cuál funciona mejor considerando tanto calidad visual como rendimiento real.
|
||
|
||
Devuelve SOLO JSON válido sin markdown (todos los textos en español):
|
||
{
|
||
"winner": "nombre exacto del anuncio ganador",
|
||
"ranking": [
|
||
{"name": "nombre completo del anuncio", "rank": 1, "reason": "razón en español"}
|
||
],
|
||
"insights": "observación comparativa clave en español: ¿qué diferencia visualmente al ganador del resto?"
|
||
}
|
||
"""
|
||
|
||
|
||
def _download_image(image_url) -> tuple | None:
|
||
"""Returns (base64_data, media_type) or None. Accepts str or list of URLs (tries in order)."""
|
||
urls = [image_url] if isinstance(image_url, str) else image_url
|
||
for url in urls:
|
||
if not url:
|
||
continue
|
||
try:
|
||
resp = requests.get(url, timeout=15)
|
||
resp.raise_for_status()
|
||
content_type = resp.headers.get("content-type", "image/jpeg")
|
||
if not content_type.startswith("image/"):
|
||
continue
|
||
data = base64.standard_b64encode(resp.content).decode("utf-8")
|
||
return data, content_type.split(";")[0]
|
||
except Exception:
|
||
continue
|
||
return None
|
||
|
||
|
||
def _parse_json_response(raw: str) -> dict:
|
||
import re
|
||
clean = re.sub(r"```json\s*", "", raw.strip())
|
||
clean = re.sub(r"```\s*", "", clean).strip()
|
||
try:
|
||
return json.loads(clean)
|
||
except json.JSONDecodeError:
|
||
start, end = clean.find("{"), clean.rfind("}")
|
||
if start != -1 and end > start:
|
||
try:
|
||
return json.loads(clean[start:end + 1])
|
||
except json.JSONDecodeError:
|
||
pass
|
||
return {}
|
||
|
||
|
||
def analyze_creative_deep(image_url: str, ad_name: str, metrics: dict) -> dict:
|
||
"""Deep creative analysis combining visual quality with performance data and fatigue detection."""
|
||
_default = {"score": 0, "analysis": "", "recommendations": "", "fatigue": False, "fatigue_reason": None}
|
||
|
||
downloaded = _download_image(image_url)
|
||
if not downloaded:
|
||
return {**_default, "analysis": "Error descargando imagen."}
|
||
image_data, media_type = downloaded
|
||
|
||
ctr_7d = metrics.get("ctr_7d", 0)
|
||
ctr_3d = metrics.get("ctr_3d", 0)
|
||
fatigue_hint = ""
|
||
if ctr_7d > 0 and ctr_3d > 0 and ctr_3d < ctr_7d * 0.75:
|
||
fatigue_hint = f"\n⚠️ SEÑAL DE FATIGA DETECTADA: CTR cayó de {ctr_7d:.2f}% (7d) a {ctr_3d:.2f}% (3d) — posible saturación"
|
||
|
||
context = (
|
||
f'Anuncio: "{ad_name}"\n\n'
|
||
f"Métricas reales:\n"
|
||
f"- 7 días: gasto {metrics.get('spend_7d', 0):.0f}€, "
|
||
f"{metrics.get('leads_7d', 0)} leads, "
|
||
f"CPL {metrics.get('cpl_7d', 0):.2f}€, "
|
||
f"CTR {ctr_7d:.2f}%\n"
|
||
f"- 3 días: gasto {metrics.get('spend_3d', 0):.0f}€, "
|
||
f"{metrics.get('leads_3d', 0)} leads, "
|
||
f"CPL {metrics.get('cpl_3d', 0):.2f}€, "
|
||
f"CTR {ctr_3d:.2f}%\n"
|
||
f"- Coste por lead máximo rentable (cpa_maximo): {metrics.get('cpa_maximo', 0):.2f}€"
|
||
f"{fatigue_hint}\n\n"
|
||
f"Analiza esta creatividad:"
|
||
)
|
||
|
||
try:
|
||
response = client.messages.create(
|
||
model="claude-sonnet-4-6",
|
||
max_tokens=1400,
|
||
system=CREATIVE_DEEP_SYSTEM,
|
||
messages=[{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "image", "source": {"type": "base64", "media_type": media_type, "data": image_data}},
|
||
{"type": "text", "text": context},
|
||
],
|
||
}],
|
||
)
|
||
result = _parse_json_response(response.content[0].text)
|
||
if not result:
|
||
return {**_default, "analysis": "Error parseando respuesta."}
|
||
result.setdefault("fatigue", False)
|
||
result.setdefault("fatigue_reason", None)
|
||
return result
|
||
except Exception as e:
|
||
return {**_default, "analysis": f"Error en análisis: {e}"}
|
||
|
||
|
||
def compare_adset_creatives(ads: list) -> dict:
|
||
"""Compare up to 4 ads within the same adset. ads: list of analyzed ad dicts."""
|
||
_default = {"winner": "", "ranking": [], "insights": "Sin datos suficientes para comparar."}
|
||
|
||
top_ads = sorted(ads, key=lambda x: -x.get("spend_7d", 0))[:4]
|
||
content_blocks = []
|
||
|
||
for i, ad in enumerate(top_ads, 1):
|
||
downloaded = _download_image(ad["image_url"])
|
||
if downloaded:
|
||
image_data, media_type = downloaded
|
||
content_blocks.append({
|
||
"type": "image",
|
||
"source": {"type": "base64", "media_type": media_type, "data": image_data},
|
||
})
|
||
fatigue_note = f" ⚠️ Fatiga: {ad.get('fatigue_reason','')}" if ad.get("fatigue") else ""
|
||
content_blocks.append({
|
||
"type": "text",
|
||
"text": (
|
||
f"Anuncio {i}: \"{ad['ad_name']}\"\n"
|
||
f"Score: {ad.get('score', 0):.1f}/10 | "
|
||
f"CTR 7d: {ad.get('ctr_7d', 0):.2f}% | "
|
||
f"CPL 7d: {ad.get('cpl_7d', 0):.2f}€ | "
|
||
f"Leads 7d: {ad.get('leads_7d', 0)} | "
|
||
f"Gasto 7d: {ad.get('spend_7d', 0):.0f}€"
|
||
f"{fatigue_note}"
|
||
),
|
||
})
|
||
|
||
if not content_blocks:
|
||
return _default
|
||
|
||
try:
|
||
response = client.messages.create(
|
||
model="claude-sonnet-4-6",
|
||
max_tokens=900,
|
||
system=CREATIVE_COMPARE_SYSTEM,
|
||
messages=[{"role": "user", "content": content_blocks}],
|
||
)
|
||
result = _parse_json_response(response.content[0].text)
|
||
return result if result else _default
|
||
except Exception as e:
|
||
return {**_default, "insights": f"Error en comparativa: {e}"}
|