Compare commits

...

10 Commits

Author SHA1 Message Date
c025a5f828 Downgrade Claude calls to sonnet-4-6 for cost efficiency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 10:00:58 +02:00
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
Francisco Monteagudo
bf945f2d75
Add SLACK_WEBHOOK_URL to environment variables 2026-06-05 10:27:18 +02:00
d82e604a15 Add attr_cursoid + attr_utm_source as 4th lead attribution path
For PMX/Search campaigns, match leads by course number and UTM source
when direct campaign ID attribution is missing. Fixes campaigns like
fco_pmx_436 (2→48 leads) and fco_pmx_1525 (0→84 leads).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:31:03 +02:00
b30a075c59 Fix leadform attribution: sum PMX + leadform conversions for margin analysis
- Add get_monthly_metrics_all() bulk method to GoogleAdsClient
- Use bulk fetch in first pass instead of N individual API calls
- Pre-compute leadform conversions per course number
- For PMX campaigns with leadform companion, leads_grupo = PMX conv + leadform conv
- fco_pmx_60 now correctly shows 334 leads at 61% margin (was negative)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:53:45 +02:00
a94c18c13c Add daily portfolio analysis and weekly strategic report
- agent.py: portfolio_daily_analysis() for daily Slack block,
  weekly_strategic_analysis() for deep weekly report
- run.py: call portfolio analysis before Slack send
- slack_reporter.py: add strategic diagnosis block at end of daily report
- weekly_report.py: standalone weekly report script
- .github/workflows/weekly.yml: runs Mondays at 9am (CEST)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:22:37 +02:00
aa9225d338 Add PPLMedio, CPAMedio, CosteMes, ConvMes to GAMes record
Recalculated each day from fco_ campaign totals:
- CosteMes / ConvMes: from Google Ads monthly accumulated data
- PPLMedio: weighted average by Airtable leads
- CPAMedio: CosteMes / ConvMes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:03:03 +02:00
a489a08785 Fix margin consistency: use MetricasDiarias for sumatorio cost
Both the monthly summary and the daily table now derive coste
from MetricasDiarias entries, so totals match exactly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:46:07 +02:00
624f5e484d Slack daily margin table: show only sumatorio margin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 11:48:43 +02:00
bdc0d5ede3 Add GAMes table and daily margin table in Slack
- GAMes: new Airtable table aggregating daily fco_ metrics (coste, ingreso_sum, ingreso_lxp, leads, leads_lake)
- run.py: accumulate fco_ daily aggregate and write to GAMes each run
- slack_reporter.py: replace sparkline with daily margin % table (Sumatorio + LeadsxPPL per day)
- backfill_games_mayo.py: populated GAMes with all 17 existing May days

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 11:44:18 +02:00
9 changed files with 664 additions and 20 deletions

35
.github/workflows/weekly.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: Weekly Strategic Report
on:
schedule:
- cron: '0 7 * * 1' # Lunes 9:00 AM hora española (CEST/UTC+2)
workflow_dispatch:
jobs:
run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run weekly report
env:
AIRTABLE_TOKEN: ${{ secrets.AIRTABLE_TOKEN }}
AIRTABLE_BASE_ID: ${{ secrets.AIRTABLE_BASE_ID }}
GOOGLE_ADS_DEVELOPER_TOKEN: ${{ secrets.GOOGLE_ADS_DEVELOPER_TOKEN }}
GOOGLE_ADS_CLIENT_ID: ${{ secrets.GOOGLE_ADS_CLIENT_ID }}
GOOGLE_ADS_CLIENT_SECRET: ${{ secrets.GOOGLE_ADS_CLIENT_SECRET }}
GOOGLE_ADS_REFRESH_TOKEN: ${{ secrets.GOOGLE_ADS_REFRESH_TOKEN }}
GOOGLE_ADS_LOGIN_CUSTOMER_ID: ${{ secrets.GOOGLE_ADS_LOGIN_CUSTOMER_ID }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: python weekly_report.py

View File

@ -38,6 +38,8 @@ export GOOGLE_ADS_REFRESH_TOKEN=xxxx
export GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 # sin guiones
export ANTHROPIC_API_KEY=sk-ant-xxxx
export SLACK_WEBHOOK_URL=xxx
```
---

179
agent.py
View File

@ -1,9 +1,23 @@
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.
@ -48,9 +62,172 @@ El campo consejo debe ser accionable y específico: qué revisar, qué cambiar,
"""
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=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-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-20250514",
model="claude-sonnet-4-6",
max_tokens=400,
system=SYSTEM_PROMPT,
messages=[{

View File

@ -14,6 +14,7 @@ class AirtableClient:
self.centrocurso = self.api.table(config.AIRTABLE_BASE_ID, "CentroCurso")
self.cursomes = self.api.table(config.AIRTABLE_BASE_ID, "CursoMes")
self.gacampaignmes = self.api.table(config.AIRTABLE_BASE_ID, "GACampaignMes")
self.games = self.api.table(config.AIRTABLE_BASE_ID, "GAMes")
MESES_ES = {
1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril",
@ -338,24 +339,45 @@ class AirtableClient:
def get_leads_this_month_gads(self, campaign_id: str, campaign_name: str = "") -> tuple[int, list[str]]:
"""
Leads del mes actual para una campaña de Google Ads.
Cubre tres vías de atribución:
Cubre cuatro vías de atribución:
1. GACampaignID / GoogleCampaignID (leads web normales)
2. gad_campaignid en attr_referer (UTM web)
3. attr_referer = campaign_name con UserAgent Google-Ads-Notifications (Lead Form)
4. attr_cursoid = course_num AND attr_utm_source = 'pmx'|'google' (atribución por curso)
"""
now = datetime.now()
mes_inicio = f"{now.year}-{now.month:02d}-01"
leadform_clause = (
f"AND({{attr_referer}}='{campaign_name}',{{UserAgent del visitante}}='Google-Ads-Notifications')"
if campaign_name else "FALSE()"
)
# Extraer número de curso y tipo de fuente del nombre de campaña
curso_clause = "FALSE()"
m = re.search(r'fco_(?:search|pmx)_(\d+)', campaign_name, re.IGNORECASE)
if m:
course_num = m.group(1)
if "pmx" in campaign_name.lower() and "_leadform" not in campaign_name.lower():
utm_source = "pmx"
elif "search" in campaign_name.lower():
utm_source = "google"
else:
utm_source = None
if utm_source:
curso_clause = (
f"AND({{attr_cursoid}}='{course_num}',"
f"{{attr_utm_source}}='{utm_source}')"
)
formula = (
f"AND("
f"OR("
f"{{GACampaignID}}='{campaign_id}',"
f"FIND(',{campaign_id},',',' & {{GoogleCampaignID}} & ','),"
f"FIND('gad_campaignid={campaign_id}',{{attr_referer}}),"
f"{leadform_clause}"
f"{leadform_clause},"
f"{curso_clause}"
f"),"
f"{{creado}}>='{mes_inicio}'"
f")"
@ -478,6 +500,28 @@ class AirtableClient:
for i in range(0, len(batch), 10):
self.gacampaignmes.batch_update(batch[i:i+10])
# ── GAMes ──────────────────────────────────────────────────────────────── #
def get_or_create_games_record(self, mes: int, anio: int) -> str:
records = self.games.all(formula=f"AND({{Mes}}='{mes}',{{Año}}='{anio}')")
if records:
return records[0]["id"]
r = self.games.create({"Mes": str(mes), "Año": str(anio)})
return r["id"]
def update_games_metricas(self, record_id: str, metricas_json: str, totales: dict = None) -> None:
fields = {"MetricasDiarias": metricas_json}
if totales:
fields.update(totales)
self.games.update(record_id, fields)
def get_games_metricas(self, record_id: str) -> dict:
r = self.games.get(record_id)
try:
return json.loads(r["fields"].get("MetricasDiarias") or "{}")
except (json.JSONDecodeError, TypeError):
return {}
def batch_update_gacampaignmes_advice(self, updates: list[tuple]) -> None:
"""
Actualiza en lote los campos 'Consejo', 'Criticidad' y 'Log' de GACampaignMes.

73
backfill_games_mayo.py Normal file
View File

@ -0,0 +1,73 @@
"""
Script one-off: crea el registro de mayo 2026 en GAMes y rellena MetricasDiarias
con el agregado diario de todas las campañas fco_ a partir de GACampaignMes.
Ejecutar una sola vez:
python backfill_games_mayo.py
"""
import json
from airtable_client import AirtableClient
at = AirtableClient()
MES = 5
ANIO = 2026
print("Cargando registros GACampaignMes de mayo (campañas fco_)...")
records = at.gacampaignmes.all(
formula="AND({Mes}='5',{Año}='2026')",
fields=["CampaignID", "MetricasDiarias", "Campaign Name (from CampaignID)"],
)
campaigns_records = at.campaigns.all(fields=["CampaignID", "PPL"])
at_id_to_info = {
r["id"]: {
"gid": str(r["fields"].get("CampaignID", "")).strip(),
"ppl": float(r["fields"].get("PPL", 0) or 0),
}
for r in campaigns_records
}
# Agregar métricas por día para campañas fco_
daily_agg: dict[str, dict] = {}
for r in records:
at_cids = r["fields"].get("CampaignID", [])
if not at_cids:
continue
info = at_id_to_info.get(at_cids[0], {})
ppl = info.get("ppl", 0)
campaign_names = r["fields"].get("Campaign Name (from CampaignID)", [])
campaign_name = (campaign_names[0] if campaign_names else "").lower()
if not campaign_name.startswith("fco_"):
continue
try:
md = json.loads(r["fields"].get("MetricasDiarias") or "{}")
except (json.JSONDecodeError, TypeError):
md = {}
for day_str, vals in md.items():
if day_str not in daily_agg:
daily_agg[day_str] = {"coste": 0.0, "ingreso_sum": 0.0, "ingreso_lxp": 0.0, "leads": 0, "leads_lake": 0}
daily_agg[day_str]["coste"] += vals.get("coste", 0)
daily_agg[day_str]["ingreso_sum"] += vals.get("ingreso", 0)
daily_agg[day_str]["ingreso_lxp"] += vals.get("leads_lake", 0) * ppl
daily_agg[day_str]["leads"] += int(vals.get("leads", 0))
daily_agg[day_str]["leads_lake"] += int(vals.get("leads_lake", 0))
print(f" > Dias agregados: {sorted(daily_agg.keys())}")
# Redondear
for d in daily_agg:
daily_agg[d] = {k: round(v, 2) if isinstance(v, float) else v for k, v in daily_agg[d].items()}
print("Creando/obteniendo registro GAMes de mayo 2026...")
games_rid = at.get_or_create_games_record(MES, ANIO)
print(f" > Record ID: {games_rid}")
print("Guardando MetricasDiarias en GAMes...")
at.update_games_metricas(games_rid, json.dumps(daily_agg, ensure_ascii=False))
print(f"OK Backfill GAMes completado: {len(daily_agg)} dias guardados.")

View File

@ -133,6 +133,66 @@ class GoogleAdsClient:
print(f" ❌ Error obteniendo métricas mensuales: {e}")
return result
def get_monthly_metrics_all(self) -> dict:
"""
Métricas del mes en curso para TODAS las campañas en una sola query.
Retorna dict {campaign_id: {cost, conversions, clicks, impressions, ctr,
status, budget_daily, budget_resource_name, name}}.
"""
ga_service = self.client.get_service("GoogleAdsService")
query = """
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign_budget.amount_micros,
campaign_budget.resource_name,
metrics.cost_micros,
metrics.conversions,
metrics.clicks,
metrics.impressions
FROM campaign
WHERE campaign.status != 'REMOVED'
AND segments.date DURING THIS_MONTH
"""
raw: dict = {}
try:
response = ga_service.search(customer_id=self.customer_id, query=query)
for row in response:
cid = str(row.campaign.id)
if cid not in raw:
raw[cid] = {
"name": row.campaign.name,
"status": row.campaign.status.name,
"budget_daily": row.campaign_budget.amount_micros / 1_000_000,
"budget_resource_name": row.campaign_budget.resource_name,
"cost": 0.0, "conversions": 0.0, "clicks": 0, "impressions": 0,
}
m = row.metrics
raw[cid]["cost"] += m.cost_micros / 1_000_000
raw[cid]["conversions"] += m.conversions
raw[cid]["clicks"] += m.clicks
raw[cid]["impressions"] += m.impressions
except GoogleAdsException as e:
print(f" ❌ Error obteniendo métricas mensuales bulk: {e}")
result = {}
for cid, d in raw.items():
imp = d["impressions"]
result[cid] = {
"campaign_id": cid,
"name": d["name"],
"status": d["status"],
"budget_daily": round(d["budget_daily"], 2),
"budget_resource_name": d["budget_resource_name"],
"cost": round(d["cost"], 2),
"conversions": d["conversions"],
"clicks": d["clicks"],
"impressions": imp,
"ctr": round(d["clicks"] / imp * 100, 2) if imp > 0 else 0.0,
}
return result
def get_campaign_metrics(self, campaign_id: str) -> dict:
"""Métricas del mes en curso para una campaña concreta (acumulado mensual)."""
ga_service = self.client.get_service("GoogleAdsService")

79
run.py
View File

@ -8,7 +8,7 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_bufferin
from airtable_client import AirtableClient
from google_ads_client import GoogleAdsClient
from analyzer import analyze
from agent import decide
from agent import decide, portfolio_daily_analysis
from optimizer import apply_decision
from slack_reporter import build_and_send
import config
@ -156,6 +156,15 @@ def run():
if "_leadform" in c["curso"].lower() and _course_num(c["curso"])
}
# Precomputar conversiones Google de cada campaña leadform por número de curso
leadform_conv_by_course: dict[str, int] = {}
for c in campaigns:
if "_leadform" in c["curso"].lower():
num = _course_num(c["curso"])
if num:
lf_conv = int(monthly_metrics.get(c["google_campaign_id"], {}).get("conversions", 0))
leadform_conv_by_course[num] = leadform_conv_by_course.get(num, 0) + lf_conv
# === PRIMERA PASADA: recopilar datos de todas las campañas ===
collected = []
skipped = []
@ -167,7 +176,7 @@ def run():
leads, lead_ids = at.get_leads_this_month_gads(cid, campaign["curso"])
at.update_gacampaignmes_leads_lake(campaign["airtable_id"], lead_ids)
metrics = gads.get_campaign_metrics(cid)
metrics = monthly_metrics.get(cid)
if not metrics:
skipped.append(f"[{cid}] {campaign['curso']}")
continue
@ -181,13 +190,20 @@ def run():
google_status,
))
# Para PMX con companion Search: usar conversiones Google como leads de análisis
course_num = _course_num(campaign["curso"])
is_pmx_with_companion = (
"pmx" in campaign["curso"].lower()
and course_num in courses_with_both
)
leads_grupo = int(metrics.get("conversions", 0)) if is_pmx_with_companion else leads
is_pmx = "pmx" in campaign["curso"].lower() and "_leadform" not in campaign["curso"].lower()
# PMX con companion Search: conversiones Google del PMX (search fluye hacia PMX)
is_pmx_with_companion = is_pmx and course_num in courses_with_both
# PMX con companion Leadform: conv PMX + conv leadform (leads no llegan a Airtable)
is_pmx_with_leadform = is_pmx and course_num in courses_with_leadform
if is_pmx_with_leadform:
leads_grupo = int(metrics.get("conversions", 0)) + leadform_conv_by_course.get(course_num, 0)
elif is_pmx_with_companion:
leads_grupo = int(metrics.get("conversions", 0))
else:
leads_grupo = leads
analysis = analyze(campaign, leads_grupo, metrics)
decision = decide(analysis)
@ -200,6 +216,7 @@ def run():
"analysis": analysis,
"decision": decision,
"today_metrics": today_metrics.get(cid, {}),
"is_pmx_with_leadform": is_pmx_with_leadform,
})
# Actualizar status en GACampaignMes y Google Ads Campaigns
@ -219,6 +236,8 @@ def run():
resumen = []
advice_updates = [] # (gcm_record_id, consejo, criticidad) para batch update final
metricas_updates = [] # {airtable_id, metricas_json} para MetricasDiarias
games_agg: dict = {} # dia_hoy → {coste, ingreso_sum, ingreso_lxp, leads, leads_lake}
games_mes = {"coste_mes": 0.0, "conv_mes": 0, "ppl_leads": 0.0, "leads_total": 0}
ayer = datetime.now() - timedelta(days=1)
dia_hoy = ayer.strftime("%d")
cambio_mes = ayer.month != datetime.now().month
@ -291,8 +310,9 @@ def run():
is_leadform = "_leadform" in campaign["curso"].lower()
if is_leadform:
log_text += " | LEADFORM: leads capturados directamente en Google (sin visitar la web) — no llegan a Airtable"
elif course_num in courses_with_leadform:
log_text += " | ⚠️ LEADFORM COMPANION: existe una campaña _leadform activa para este curso — parte de las conversiones de Google pueden provenir de leads capturados directamente en Google"
elif item.get("is_pmx_with_leadform"):
lf_conv = leadform_conv_by_course.get(course_num, 0)
log_text += f" | LEADFORM COMPANION: {lf_conv} conv leadform sumadas a leads_grupo para este curso"
# Métricas diarias: coste hoy, ingreso (conversiones × PPL) y margen
coste_hoy = round(today_m.get("cost", 0), 2)
@ -306,6 +326,21 @@ def run():
leads_lake_hoy = leads_yesterday.get(cid, 0)
md[dia_hoy] = {"coste": coste_hoy, "ingreso": ingreso_hoy, "margen": margen_hoy, "leads": int(conv_hoy), "leads_lake": leads_lake_hoy}
campaign["metricas_diarias"] = json.dumps(md, ensure_ascii=False)
if campaign["curso"].lower().startswith("fco_"):
if dia_hoy not in games_agg:
games_agg[dia_hoy] = {"coste": 0.0, "ingreso_sum": 0.0, "ingreso_lxp": 0.0, "leads": 0, "leads_lake": 0}
games_agg[dia_hoy]["coste"] += coste_hoy
games_agg[dia_hoy]["ingreso_sum"] += ingreso_hoy
games_agg[dia_hoy]["ingreso_lxp"] += leads_lake_hoy * campaign["ppl"]
games_agg[dia_hoy]["leads"] += int(conv_hoy)
games_agg[dia_hoy]["leads_lake"] += leads_lake_hoy
# Totales mes (fuente: Google Ads acumulado mensual)
games_mes["coste_mes"] += metrics.get("cost", 0)
games_mes["conv_mes"] += int(analysis["conversiones_google"])
# PPLMedio ponderado por leads Airtable
games_mes["ppl_leads"] += campaign["ppl"] * leads
games_mes["leads_total"] += leads
metricas_updates.append({
"airtable_id": campaign["airtable_id"],
"metricas_json": json.dumps(md, ensure_ascii=False),
@ -355,6 +390,26 @@ def run():
at.batch_update_metricas_diarias(metricas_updates)
print(" ✓ MetricasDiarias actualizado.")
# Actualizar GAMes con el agregado diario fco_
if games_agg:
print("→ Actualizando GAMes...")
games_rid = at.get_or_create_games_record(ayer.month, ayer.year)
games_md = at.get_games_metricas(games_rid)
for d, vals in games_agg.items():
games_md[d] = {k: round(v, 2) if isinstance(v, float) else v for k, v in vals.items()}
coste_mes = round(games_mes["coste_mes"], 2)
conv_mes = games_mes["conv_mes"]
ppl_medio = round(games_mes["ppl_leads"] / games_mes["leads_total"], 2) if games_mes["leads_total"] > 0 else 0.0
cpa_medio = round(coste_mes / conv_mes, 2) if conv_mes > 0 else 0.0
totales = {
"CosteMes": coste_mes,
"ConvMes": conv_mes,
"PPLMedio": ppl_medio,
"CPAMedio": cpa_medio,
}
at.update_games_metricas(games_rid, json.dumps(games_md, ensure_ascii=False), totales)
print(" ✓ GAMes actualizado.")
# Snapshot diario: ConvLeadsLakeMesFinal + ConvLeadsLakeMesGrupo
# PMX con companion Search → Grupo = conversiones Google (ya calculado en leads_grupo)
final_leads_data = [
@ -386,9 +441,11 @@ def run():
print()
# Enviar resumen a Slack
print("→ Generando análisis estratégico del portfolio...")
portfolio_text = portfolio_daily_analysis(collected)
print("→ Enviando resumen a Slack...")
prev_month_metricas = at.get_metricas_diarias_prev_month() if (cambio_mes or datetime.now().day <= 5) else {}
build_and_send(collected, config.DRY_RUN, prev_month_metricas)
build_and_send(collected, config.DRY_RUN, prev_month_metricas, portfolio_text)
print(" ✓ Resumen enviado a Slack.")

View File

@ -63,7 +63,8 @@ def _curso(name: str, max_len: int = 40) -> str:
return name[:max_len] + ("" if len(name) > max_len else "")
def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = None) -> None:
def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = None,
portfolio_analysis_text: str = None) -> None:
if not config.SLACK_WEBHOOK_URL:
print(" ⚠️ SLACK_WEBHOOK_URL no configurada, omitiendo envío.")
return
@ -91,11 +92,15 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N
margen_leads_ppl = 0.0
pct_leads_ppl = 0.0
else:
ing_sumatorio = round(sum(
sum(d.get("ingreso", 0) for d in _parse_metricas(item["campaign"].get("metricas_diarias", "{}")).values())
for item in fco
), 2)
margen_sumatorio = round(ing_sumatorio - inv_total, 2)
ing_sumatorio = 0.0
coste_sumatorio = 0.0
for item in fco:
for d in _parse_metricas(item["campaign"].get("metricas_diarias", "{}")).values():
ing_sumatorio += d.get("ingreso", 0)
coste_sumatorio += d.get("coste", 0)
ing_sumatorio = round(ing_sumatorio, 2)
coste_sumatorio = round(coste_sumatorio, 2)
margen_sumatorio = round(ing_sumatorio - coste_sumatorio, 2)
margen_leads_ppl = round(ing_leads_ppl - inv_total, 2)
pct_sumatorio = round(margen_sumatorio / ing_sumatorio * 100, 1) if ing_sumatorio > 0 else 0.0
pct_leads_ppl = round(margen_leads_ppl / ing_leads_ppl * 100, 1) if ing_leads_ppl > 0 else 0.0
@ -151,6 +156,49 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N
worst_month = month_rows[:TOP_N]
best_month = list(reversed(month_rows[-TOP_N:]))
# ── Tabla de márgenes diarios ─────────────────────────────────────────────
daily_totals: dict[int, dict] = {}
for item in fco:
ppl = item["campaign"].get("ppl", 0)
md = _parse_metricas(item["campaign"].get("metricas_diarias", "{}"))
for day_str, vals in md.items():
try:
d = int(day_str)
except ValueError:
continue
if d not in daily_totals:
daily_totals[d] = {"coste": 0.0, "ingreso_sum": 0.0}
daily_totals[d]["coste"] += vals.get("coste", 0)
daily_totals[d]["ingreso_sum"] += vals.get("ingreso", 0)
margin_table_block = None
if daily_totals and not primer_dia_mes:
rows = ["Día Margen € %"]
total_coste = total_ing = 0.0
for d in sorted(daily_totals):
coste = daily_totals[d]["coste"]
ing_sum = daily_totals[d]["ingreso_sum"]
margen = ing_sum - coste
pct = round(margen / ing_sum * 100, 1) if ing_sum > 0 else 0.0
total_coste += coste
total_ing += ing_sum
s_eur = ("+" if margen >= 0 else "") + f"{margen:,.0f}".replace(",", ".")
s_pct = ("+" if pct >= 0 else "") + f"{pct:.1f}%"
rows.append(f"{d:02d} {s_eur:>9} {s_pct:>7}")
total_margen = total_ing - total_coste
total_pct = round(total_margen / total_ing * 100, 1) if total_ing > 0 else 0.0
s_tot_eur = ("+" if total_margen >= 0 else "") + f"{total_margen:,.0f}".replace(",", ".")
s_tot_pct = ("+" if total_pct >= 0 else "") + f"{total_pct:.1f}%"
rows.append("" * 24)
rows.append(f"TOT {s_tot_eur:>9} {s_tot_pct:>7}")
margin_table_block = {
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"📅 *MÁRGENES POR DÍA — {MESES_ES[now.month].upper()}*\n```\n" + "\n".join(rows) + "\n```",
},
}
# ── Alertas ──────────────────────────────────────────────────────────────
alerts = [
r for r in month_rows
@ -232,6 +280,10 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N
)
return "\n".join(lines)
if margin_table_block:
blocks.append({"type": "divider"})
blocks.append(margin_table_block)
blocks.append({"type": "divider"})
blocks.append({
"type": "section",
@ -264,6 +316,16 @@ def build_and_send(collected: list, dry_run: bool, prev_month_metricas: dict = N
],
})
if portfolio_analysis_text:
blocks.append({"type": "divider"})
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"🤖 *DIAGNÓSTICO ESTRATÉGICO*\n{portfolio_analysis_text}",
},
})
payload = {"blocks": blocks}
try:
resp = requests.post(config.SLACK_WEBHOOK_URL, json=payload, timeout=10)

134
weekly_report.py Normal file
View File

@ -0,0 +1,134 @@
"""
Informe estratégico semanal de Leads Optimizer.
Se ejecuta los lunes via GitHub Actions y envía un análisis profundo a Slack.
"""
import json
import sys
import os
import requests
from datetime import datetime, timedelta
import config
from airtable_client import AirtableClient
from google_ads_client import GoogleAdsClient
from agent import weekly_strategic_analysis
from analyzer import analyze
from slack_reporter import MESES_ES
MESES_ES_LOCAL = MESES_ES
def _get_week_days(offset_weeks: int = 0) -> list[str]:
"""Devuelve los 7 días YYYY-MM-DD de la semana offset_weeks atrás (0=esta, 1=anterior)."""
today = datetime.now().date()
monday = today - timedelta(days=today.weekday()) - timedelta(weeks=offset_weeks)
return [(monday + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(7)]
def _filter_games_md_by_days(games_md: dict, days: list[str]) -> dict:
"""Filtra MetricasDiarias de GAMes a los días indicados (formato 'DD')."""
day_keys = {d[8:10] for d in days}
return {k: v for k, v in games_md.items() if k in day_keys}
def run_weekly():
now = datetime.now()
print(f"\n{'='*55}")
print(f" INFORME SEMANAL — {now.strftime('%d/%m/%Y %H:%M')}")
print(f"{'='*55}\n")
at = AirtableClient()
gads = GoogleAdsClient()
# Obtener MetricasDiarias de GAMes del mes en curso
mes, anio = now.month, now.year
print("Cargando GAMes...")
games_rid = at.get_or_create_games_record(mes, anio)
games_md = at.get_games_metricas(games_rid)
# Calcular semanas
this_week_days = _get_week_days(0)
prev_week_days = _get_week_days(1)
games_this = _filter_games_md_by_days(games_md, this_week_days)
games_prev = _filter_games_md_by_days(games_md, prev_week_days)
# Si la semana anterior cruza con el mes pasado, intentar cargar ese GAMes también
prev_month_days = [d for d in prev_week_days if d[:7] != now.strftime("%Y-%m")]
if prev_month_days:
prev_mes = mes - 1 if mes > 1 else 12
prev_anio = anio if mes > 1 else anio - 1
prev_records = at.games.all(formula=f"AND({{Mes}}='{prev_mes}',{{Año}}='{prev_anio}')")
if prev_records:
try:
prev_md = json.loads(prev_records[0]["fields"].get("MetricasDiarias") or "{}")
except Exception:
prev_md = {}
extra = _filter_games_md_by_days(prev_md, prev_month_days)
games_prev.update(extra)
# Cargar métricas de campañas activas para el análisis de portfolio
print("Cargando campañas activas...")
campaigns = at.get_active_gacampaignmes()
monthly_metrics = gads.get_monthly_metrics_all()
collected = []
for campaign in campaigns:
cid = campaign["google_campaign_id"]
metrics = monthly_metrics.get(cid, {"cost": 0, "conversions": 0, "clicks": 0,
"impressions": 0, "ctr": 0, "budget_daily": 0,
"status": "UNKNOWN"})
leads = campaign.get("conv_leads_lake_mes", 0)
analysis = analyze(campaign, leads, metrics)
collected.append({"campaign": campaign, "metrics": metrics,
"analysis": analysis, "leads": leads})
print("Generando análisis estratégico semanal con IA...")
mes_nombre = MESES_ES_LOCAL.get(mes, str(mes))
analysis_text = weekly_strategic_analysis(games_this, games_prev, collected, mes_nombre)
# Construir mensaje Slack
if not config.SLACK_WEBHOOK_URL:
print("SLACK_WEBHOOK_URL no configurada.")
print("\n--- ANÁLISIS ---\n")
print(analysis_text)
return
this_range = f"{this_week_days[0][8:10]}/{this_week_days[0][5:7]}{this_week_days[-1][8:10]}/{this_week_days[-1][5:7]}"
prev_range = f"{prev_week_days[0][8:10]}/{prev_week_days[0][5:7]}{prev_week_days[-1][8:10]}/{prev_week_days[-1][5:7]}"
blocks = [
{
"type": "header",
"text": {"type": "plain_text",
"text": f"INFORME SEMANAL — {now.strftime('%d/%m/%Y')}"},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"_Semana actual: {this_range} | Semana anterior: {prev_range}_",
},
},
{"type": "divider"},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"🤖 *ANÁLISIS ESTRATÉGICO SEMANAL*\n{analysis_text}",
},
},
]
try:
resp = requests.post(config.SLACK_WEBHOOK_URL, json={"blocks": blocks}, timeout=10)
if resp.status_code != 200:
print(f"⚠️ Slack respondió {resp.status_code}: {resp.text[:200]}")
else:
print("✓ Informe semanal enviado a Slack.")
except Exception as e:
print(f"⚠️ Error enviando a Slack: {e}")
if __name__ == "__main__":
run_weekly()