Some checks failed
Weekly Strategic Report / run (push) Has been cancelled
- analyzer.py: use abs() for tracking discrepancy so both directions alert - slack_reporter.py: daily table adds L.AT/L.GA/Coste/€.AT/€.GA columns; margin uses Google Ads conversions × PPL; split monthly rankings into separate blocks; fix stale margen field - run.py: leads_grupo always uses Google Ads conversions; tracking alert shows direction (over/under attribution) - agent.py: increase decide() max_tokens 700→750 to fix JSON truncation for long campaign names Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
255 lines
11 KiB
Python
255 lines
11 KiB
Python
import json
|
||
from datetime import datetime
|
||
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 Google Ads (solo campañas fco_).
|
||
Responde siempre en español, de forma concisa y accionable. Sin markdown, sin bullet symbols especiales, usa guiones simples (-).
|
||
"""
|
||
|
||
WEEKLY_SYSTEM = """
|
||
Eres un consultor senior de marketing de performance especializado en generación de leads para formación.
|
||
Recibes el análisis semanal del portfolio de campañas de Google Ads (solo campañas fco_).
|
||
Tu análisis debe ser estratégico, comparando la semana actual con la anterior, identificando tendencias y proponiendo acciones concretas.
|
||
Responde siempre en español. Sin markdown, sin bullet symbols especiales, usa guiones simples (-).
|
||
"""
|
||
|
||
SYSTEM_PROMPT = """
|
||
Eres un agente experto en optimización de campañas de generación de leads para centros de formación.
|
||
Cada campaña corresponde a un curso concreto con un PPL (precio por lead) fijo acordado con los centros compradores.
|
||
|
||
MODELO DE NEGOCIO:
|
||
- Ingreso = leads_entregados × PPL
|
||
- Margen = (Ingreso - Gasto Google Ads) / Ingreso
|
||
- El objetivo es maximizar leads dentro del capping mensual manteniendo margen positivo.
|
||
- El CPA máximo ya refleja el margen mínimo aceptable.
|
||
|
||
ESTADO DE LA CAMPAÑA:
|
||
- El campo status_google indica el estado actual en Google Ads: ENABLED (activa) o PAUSED (pausada).
|
||
- Nunca recomiendes reactivar una campaña si status_google = ENABLED (ya está activa).
|
||
- Si status_google = PAUSED y la acción es AUMENTAR_PRESUPUESTO, menciona en el consejo que primero hay que reactivarla.
|
||
|
||
CAPPING:
|
||
- Si capping = 0, significa que no hay límite de leads configurado para este mes. No menciones el capping en el consejo ni en la justificación. Toma la decisión basándote únicamente en rentabilidad y ritmo.
|
||
|
||
REGLAS DE DECISIÓN:
|
||
1. urgencia=PAUSAR → accion=PAUSAR siempre. El capping está lleno, seguir gastando destruye margen.
|
||
2. urgencia=SPRINT → accion=AUMENTAR_PRESUPUESTO con parametro entre 1.3 y 1.5. Quedan pocos días y leads por entregar.
|
||
3. urgencia=ACELERAR y campaña rentable → accion=AUMENTAR_PRESUPUESTO con parametro entre 1.1 y 1.25.
|
||
4. urgencia=ACELERAR y campaña NO rentable → accion=MANTENER o revisar keywords (no gastar más si no convierte).
|
||
5. urgencia=FRENAR → accion=REDUCIR_PRESUPUESTO con parametro entre 0.75 y 0.9.
|
||
6. urgencia=EN_RITMO y rentable → accion=MANTENER.
|
||
7. urgencia=EN_RITMO y NO rentable → accion=REDUCIR_PRESUPUESTO con parametro 0.85.
|
||
8. alerta_tracking=true → añadir alerta sobre discrepancia de tracking aunque la acción sea otra.
|
||
|
||
Devuelve ÚNICAMENTE un JSON válido con esta estructura exacta, sin texto adicional ni markdown:
|
||
{
|
||
"accion": "PAUSAR | REDUCIR_PRESUPUESTO | AUMENTAR_PRESUPUESTO | MANTENER",
|
||
"parametro": 1.0,
|
||
"nuevo_budget_diario": 0.0,
|
||
"justificacion": "explicación breve del porqué de la decisión",
|
||
"consejo": "acción concreta y específica que debería tomar el gestor (keywords, pujas, anuncios, configuración, etc.)",
|
||
"alerta": "texto si hay algo crítico, null si no hay",
|
||
"confianza": 0.0
|
||
}
|
||
|
||
El campo nuevo_budget_diario = budget_diario_actual × parametro (calcula tú el valor final).
|
||
El campo consejo debe ser accionable y específico: qué revisar, qué cambiar, qué hacer a continuación.
|
||
"""
|
||
|
||
|
||
def _classify_type(curso: str) -> str:
|
||
c = curso.lower()
|
||
if "_leadform" in c:
|
||
return "leadform"
|
||
if "_pmx" in c or "pmx_" in c:
|
||
return "pmx"
|
||
if "search" in c:
|
||
return "search"
|
||
return "otro"
|
||
|
||
|
||
def portfolio_daily_analysis(collected: list) -> str:
|
||
"""Análisis estratégico diario del portfolio fco_. Devuelve texto plano para Slack."""
|
||
from datetime import datetime
|
||
now = datetime.now()
|
||
fco = [i for i in collected if i["campaign"]["curso"].lower().startswith("fco_")]
|
||
|
||
tipos: dict = {}
|
||
leadforms_detail = []
|
||
alertas_tracking = 0
|
||
campañas_perdida = 0
|
||
|
||
for item in fco:
|
||
t = _classify_type(item["campaign"]["curso"])
|
||
m = item["metrics"]
|
||
a = item["analysis"]
|
||
cost = m.get("cost", 0)
|
||
conv = a["conversiones_google"]
|
||
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":
|
||
leadforms_detail.append({
|
||
"curso": item["campaign"]["curso"][:40],
|
||
"cpa_google": round(cost / conv, 2) if conv > 0 else None,
|
||
"conv_google": 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(fco),
|
||
"campañas_en_perdida": campañas_perdida,
|
||
"alertas_tracking": alertas_tracking,
|
||
"rendimiento_por_tipo": resumen_tipos,
|
||
"detalle_leadforms": leadforms_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}"
|
||
|
||
|
||
def weekly_strategic_analysis(games_md_this: dict, games_md_prev_week: dict,
|
||
collected: list, mes_nombre: str) -> str:
|
||
"""
|
||
Análisis estratégico semanal profundo.
|
||
games_md_this: MetricasDiarias de GAMes de los últimos 7 días (esta semana).
|
||
games_md_prev_week: MetricasDiarias de GAMes de los 7 días anteriores.
|
||
collected: lista de campañas del optimizer.
|
||
"""
|
||
def _week_summary(md: dict) -> dict:
|
||
coste = ing = leads = leads_lake = 0.0
|
||
for v in md.values():
|
||
coste += v.get("coste", 0)
|
||
ing += v.get("ingreso_sum", 0)
|
||
leads += v.get("leads", 0)
|
||
leads_lake += v.get("leads_lake", 0)
|
||
margen = round((ing - coste) / ing * 100, 1) if ing > 0 else 0.0
|
||
cpa = round(coste / leads, 2) if leads > 0 else None
|
||
return {"coste": round(coste, 2), "ingreso": round(ing, 2),
|
||
"leads_google": int(leads), "leads_airtable": int(leads_lake),
|
||
"margen_pct": margen, "cpa": cpa}
|
||
|
||
fco = [i for i in collected if i["campaign"]["curso"].lower().startswith("fco_")]
|
||
|
||
# Top 5 peores por CPA del mes
|
||
peores = sorted(
|
||
[{"curso": i["campaign"]["curso"][:40],
|
||
"cpa": i["analysis"]["cpa_actual"],
|
||
"conv": int(i["analysis"]["conversiones_google"]),
|
||
"margen_pct": round(i["analysis"]["margen"] * 100, 1)}
|
||
for i in fco if i["analysis"]["cpa_actual"] > 0],
|
||
key=lambda x: x["cpa"], reverse=True
|
||
)[:5]
|
||
|
||
data = {
|
||
"mes": mes_nombre,
|
||
"semana_actual": _week_summary(games_md_this),
|
||
"semana_anterior": _week_summary(games_md_prev_week),
|
||
"top5_peor_cpa_mes": peores,
|
||
"leadforms_activos": sum(1 for i in fco if "_leadform" in i["campaign"]["curso"].lower()),
|
||
"campañas_totales": len(fco),
|
||
}
|
||
|
||
try:
|
||
response = client.messages.create(
|
||
model="claude-sonnet-4-6",
|
||
max_tokens=900,
|
||
system=WEEKLY_SYSTEM,
|
||
messages=[{
|
||
"role": "user",
|
||
"content": (
|
||
"Genera el informe estratégico semanal con:\n"
|
||
"1. Resumen ejecutivo (3 frases comparando esta semana con la anterior)\n"
|
||
"2. Tendencias clave detectadas (máx 4, con guión)\n"
|
||
"3. Situación campañas leadform y qué hacer con ellas\n"
|
||
"4. Acciones estratégicas prioritarias para la próxima semana (máx 4, muy concretas, con guión)\n"
|
||
"5. Una frase de conclusión sobre si el portfolio va en la dirección correcta\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 semanal: {e}"
|
||
|
||
|
||
def decide(analysis: dict) -> dict:
|
||
response = client.messages.create(
|
||
model="claude-sonnet-4-6",
|
||
max_tokens=750,
|
||
system=SYSTEM_PROMPT,
|
||
messages=[{
|
||
"role": "user",
|
||
"content": (
|
||
f"Analiza esta campaña y devuelve la decisión en JSON:\n\n"
|
||
f"{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:
|
||
# Fallback seguro si el modelo no devuelve JSON limpio
|
||
return {
|
||
"accion": "MANTENER",
|
||
"parametro": 1.0,
|
||
"nuevo_budget_diario": analysis.get("budget_diario_actual", 0),
|
||
"justificacion": "Error parseando respuesta del agente. Revisión manual recomendada.",
|
||
"alerta": f"JSON inválido recibido: {raw[:200]}",
|
||
"confianza": 0.0,
|
||
}
|