leads-optimizer/agent.py
José Manuel Gómez 3028123c81 Switch all Claude calls to claude-opus-4-8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:54:05 +02:00

255 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-opus-4-8",
max_tokens=500,
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-opus-4-8",
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-opus-4-8",
max_tokens=400,
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,
}