Spaces:
Runtime error
Runtime error
File size: 15,944 Bytes
ebb46dc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 | from __future__ import annotations
"""
P3 Benchmark 标注 + 诊断规则引擎。
Benchmark 全部从 Google Sheets「KPI与Benchmark」+「历史实绩」动态读取,
不做硬编码 fallback。读取不到时明确标记为 "no_benchmark"。
诊断规则来源: P3-投放数据分析框架.md
"""
import logging
from typing import Any
log = logging.getLogger("ad-agent")
L1_THRESHOLDS = {
"budget_pacing": (90.0, 110.0),
"revenue_vs_target": (80.0, 100.0),
}
def _rate_value(
value: float | None,
red_threshold: float,
green_threshold: float,
higher_is_better: bool,
) -> str:
if value is None or value != value:
return "grey"
if higher_is_better:
if value >= green_threshold:
return "green"
if value <= red_threshold:
return "red"
return "yellow"
else:
if value <= green_threshold:
return "green"
if value >= red_threshold:
return "red"
return "yellow"
# ────────────────────────────────────────────────
# Benchmark 加载(纯 Sheets 驱动)
# ────────────────────────────────────────────────
def load_benchmarks(p3_config: dict) -> dict[str, dict]:
"""
从 p3_config(已从 Google Sheets 加载)中构建 benchmark 表。
返回格式:
{objective_group: {metric: (red_threshold, green_threshold, higher_is_better)}}
如果 Sheets 中没有对应数据,该 objective_group 的 benchmarks 就是空 dict,
后续 apply_benchmarks 会标记 "no_benchmark" 而不是虚假的红黄绿。
"""
benchmarks: dict[str, dict] = {}
kpi_cfg = p3_config.get("kpi_config", {})
chain = kpi_cfg.get("benchmark_chain", {})
reference = kpi_cfg.get("reference_2025", {})
historical = p3_config.get("historical", {})
# 从 benchmark_chain 提取 ROAS / CPA 等阈值
us_roas = chain.get("ROAS", {}).get("us_example")
us_cpa = chain.get("CPA", {}).get("us_example")
us_cpc = chain.get("CPC", {}).get("us_example")
us_cpm = chain.get("CPM", {}).get("us_example")
us_cpl = chain.get("CPL", {}).get("us_example")
# Purchase 类 — 根据 ROAS / CPA 倒推
if us_roas and us_roas > 0:
benchmarks["Purchase_Cold"] = {
"ROAS": (us_roas * 0.6, us_roas, True),
}
benchmarks["Purchase_ASC"] = {
"ROAS": (us_roas * 0.8, us_roas * 1.5, True),
}
benchmarks["Purchase_RMKT"] = {
"ROAS": (us_roas * 1.2, us_roas * 2.5, True),
}
if us_cpa and us_cpa > 0:
benchmarks.setdefault("Purchase_Cold", {})["CPA"] = (us_cpa * 2, us_cpa, False)
# Lead — CPL
if us_cpl and us_cpl > 0:
benchmarks["Lead"] = {
"CPL": (us_cpl * 2, us_cpl, False),
"LeadRate": (0.01, 0.03, True),
}
# 通用 CPC / CPM — 从 chain 中取
if us_cpc and us_cpc > 0:
benchmarks["Linkclick"] = {
"CPC": (us_cpc * 2, us_cpc, False),
"CTR": (0.008, 0.015, True),
}
if us_cpm and us_cpm > 0:
benchmarks["Pageview"] = {
"CPM": (us_cpm * 2, us_cpm, False),
}
# 从历史实绩中补充平台级 benchmark
us_roas_by_type = historical.get("us_campaign_roas", {})
for campaign_type, info in us_roas_by_type.items():
avg = info.get("avg_roas")
if avg and avg > 0:
key = f"_hist_{campaign_type}"
benchmarks[key] = {"ROAS": (avg * 0.6, avg, True)}
loaded_groups = [k for k in benchmarks if not k.startswith("_hist_")]
if loaded_groups:
log.info(f"从 Sheets 加载了 {len(loaded_groups)} 组 Benchmark: {loaded_groups}")
else:
log.warning("Sheets 中未读取到任何 Benchmark 数据,指标将标记为 no_benchmark")
return benchmarks
def load_platform_benchmarks(p3_config: dict) -> dict[str, dict]:
"""
从历史实绩中构建平台级 benchmark。
读取不到则返回空 dict,后续标记 "no_benchmark"。
"""
platform_bm: dict[str, dict] = {}
historical = p3_config.get("historical", {})
ca_cf = historical.get("ca_channel_funnel", {})
for key, info in ca_cf.items():
parts = key.split("_", 1)
if len(parts) < 2:
continue
platform = parts[0]
if platform not in platform_bm:
platform_bm[platform] = {}
cpc = info.get("cpc")
ctr = info.get("ctr")
cpm = info.get("cpm")
if cpc and cpc > 0:
platform_bm[platform]["CPC"] = (cpc * 2, cpc, False)
if ctr and ctr > 0:
platform_bm[platform]["CTR"] = (ctr * 0.5, ctr, True)
if cpm and cpm > 0:
platform_bm[platform]["CPM"] = (cpm * 2, cpm, False)
if platform_bm:
log.info(f"从 Sheets 加载了 {len(platform_bm)} 个平台 Benchmark")
else:
log.warning("Sheets 中未读取到平台级 Benchmark,平台指标将标记为 no_benchmark")
return platform_bm
def apply_benchmarks(
data: list[dict], benchmarks: dict, level: str,
platform_benchmarks: dict | None = None,
) -> list[dict]:
"""
给 L1-L4 数据标注 red/yellow/green。
没有 benchmark 数据的指标标记为 "no_benchmark"。
"""
for row in data:
ratings: dict[str, str] = {}
if level == "L1":
bp = row.get("budget_pacing")
rvt = row.get("revenue_vs_target")
if bp is None and rvt is None:
ratings["_status"] = "no_benchmark"
else:
if bp is not None:
lo, hi = L1_THRESHOLDS["budget_pacing"]
if bp < lo:
ratings["budget_pacing"] = "yellow"
elif bp > hi:
ratings["budget_pacing"] = "red"
else:
ratings["budget_pacing"] = "green"
if rvt is not None:
lo, hi = L1_THRESHOLDS["revenue_vs_target"]
if rvt < lo:
ratings["revenue_vs_target"] = "red"
elif rvt >= hi:
ratings["revenue_vs_target"] = "green"
else:
ratings["revenue_vs_target"] = "yellow"
elif level == "L2":
obj_group = row.get("objective_group", "")
bm = benchmarks.get(obj_group, {})
if not bm:
ratings["_status"] = "no_benchmark"
else:
for metric, (red_t, green_t, hib) in bm.items():
val = row.get(metric)
ratings[metric] = _rate_value(val, red_t, green_t, hib)
elif level == "L3":
plat = row.get("platform", "")
bm = (platform_benchmarks or {}).get(plat, {})
if not bm:
ratings["_status"] = "no_benchmark"
else:
for metric, (red_t, green_t, hib) in bm.items():
val = row.get(metric.lower(), row.get(metric))
ratings[metric] = _rate_value(val, red_t, green_t, hib)
elif level == "L4":
obj_group = row.get("objective_group", "")
bm = benchmarks.get(obj_group, {})
if not bm:
ratings["_status"] = "no_benchmark"
else:
for metric, (red_t, green_t, hib) in bm.items():
val = row.get(metric, row.get(metric.lower()))
ratings[metric] = _rate_value(val, red_t, green_t, hib)
row["ratings"] = ratings
return data
# ────────────────────────────────────────────────
# 诊断规则引擎
# ────────────────────────────────────────────────
def run_diagnostics(p3_result: dict, p3_config: dict) -> list[dict[str, str]]:
"""
基于 P3 框架诊断规则表,输出诊断结论列表。
每条: {"level": "red/yellow/green", "title": str, "detail": str, "action": str}
"""
diags: list[dict[str, str]] = []
has_benchmarks = any(
row.get("ratings", {}).get("_status") != "no_benchmark"
for layer in ("L1", "L2", "L3", "L4")
for row in p3_result.get(layer, [])
)
if not has_benchmarks:
diags.append({
"level": "yellow", "layer": "系统",
"title": "Benchmark 数据未加载",
"detail": "未能从 Google Sheets 读取 Benchmark 配置,无法进行红黄绿标注",
"action": "检查 credentials.json 或 GOOGLE_CREDENTIALS 环境变量是否配置正确",
})
# L1 诊断
for row in p3_result.get("L1", []):
mkt = row.get("market", "?")
bp = row.get("budget_pacing")
if bp is not None:
if bp > 110:
diags.append({
"level": "red", "layer": "L1",
"title": f"{mkt} 预算超支",
"detail": f"Budget Pacing {bp:.0f}%,已超出预算 {bp - 100:.0f}%",
"action": "审查近期增投,需要审批或调减预算",
})
elif bp < 90:
diags.append({
"level": "yellow", "layer": "L1",
"title": f"{mkt} 预算花慢",
"detail": f"Budget Pacing {bp:.0f}%,存在机会成本",
"action": "排查是否有 campaign 暂停或受众耗尽",
})
elif row.get("budget") is None:
diags.append({
"level": "yellow", "layer": "L1",
"title": f"{mkt} 月度预算未配置",
"detail": "未能从 Sheets 月度目标中读取到该市场的 DM 预算",
"action": "在 Sheets「月度目标」中填写该月预算和目标",
})
rvt = row.get("revenue_vs_target")
if rvt is not None and rvt < 80:
diags.append({
"level": "red", "layer": "L1",
"title": f"{mkt} 营收不达标",
"detail": f"Revenue vs Target {rvt:.0f}%,距目标差距较大",
"action": "排查哪个平台/漏斗拖后腿,考虑调整投放策略",
})
# L2 诊断
l2_map = {r["objective_group"]: r for r in p3_result.get("L2", [])}
for row in p3_result.get("L2", []):
obj = row.get("objective_group", "")
ratings = row.get("ratings", {})
for metric, color in ratings.items():
if metric.startswith("_"):
continue
if color == "red":
diags.append({
"level": "red", "layer": "L2",
"title": f"{obj} {metric} 表现差",
"detail": f"{metric} = {row.get(metric, '?')},低于 Benchmark",
"action": _l2_action(obj, metric),
})
lead = l2_map.get("Lead", {})
lead_ratings = lead.get("ratings", {})
if lead_ratings.get("CPL") == "red" and lead_ratings.get("LeadRate") == "red":
diags.append({
"level": "red", "layer": "L2",
"title": "留邮漏斗异常",
"detail": "CPL 高 + 留邮率低,落地页或 Offer 不够吸引",
"action": "换 Offer(折扣码?内容?)、优化表单、检查落地页体验",
})
purchase_cold = l2_map.get("Purchase_Cold", {})
cpa_chg = purchase_cold.get("CPA_chg")
consideration_objs = [r for r in p3_result.get("L2", []) if r.get("funnel") == "Consideration"]
mid_spend_down = any(
r.get("spend_share", 0) < 15 or (r.get("clicks", 0) == 0)
for r in consideration_objs
)
if cpa_chg and cpa_chg > 10 and mid_spend_down:
diags.append({
"level": "red", "layer": "L2",
"title": "漏斗断裂风险",
"detail": "下层 CPA 上升,中层流量不足",
"action": "加中层预算补量,确保下层有足够意向用户",
})
# L3 诊断
l3_rows = p3_result.get("L3", [])
if len(l3_rows) >= 2:
for row in l3_rows:
ss = row.get("spend_share", 0)
rs = row.get("revenue_share", 0)
plat = row.get("platform", "")
if ss > 30 and rs < ss * 0.5:
diags.append({
"level": "yellow", "layer": "L3",
"title": f"{plat} 预算效率错配",
"detail": f"Spend Share {ss:.0f}% 但 Revenue Share 仅 {rs:.0f}%",
"action": f"评估是否需要从 {plat} 减预算,挪到高效平台",
})
roas_chg = row.get("roas_chg")
if roas_chg is not None and roas_chg < -15:
diags.append({
"level": "yellow", "layer": "L3",
"title": f"{plat} ROAS 下降",
"detail": f"ROAS 环比 {roas_chg:+.1f}%",
"action": f"排查 {plat} 素材/受众变化,考虑更新创意",
})
cpc_chg = row.get("cpc_chg")
if cpc_chg is not None and cpc_chg > 20:
diags.append({
"level": "yellow", "layer": "L3",
"title": f"{plat} CPC 飙升",
"detail": f"CPC 环比 {cpc_chg:+.1f}%",
"action": "检查受众重叠、竞争环境和素材新鲜度",
})
# L4 诊断(Top 异常 campaign)
for row in p3_result.get("L4", [])[:20]:
ratings = row.get("ratings", {})
spend = row.get("spend", 0)
roas = row.get("roas", 0)
name = row.get("campaign_name", "?")
roas_chg = row.get("roas_chg")
if spend > 500 and any(v == "red" for k, v in ratings.items() if not k.startswith("_")):
diags.append({
"level": "red", "layer": "L4",
"title": "高花费低效 Campaign",
"detail": f"{name[:40]}... | Spend ${spend:,.0f}, ROAS {roas:.2f}",
"action": "立即减预算,拆解是素材问题还是 targeting 问题",
})
if roas_chg is not None and roas_chg < -20 and spend > 200:
diags.append({
"level": "yellow", "layer": "L4",
"title": "Campaign 效率恶化",
"detail": f"{name[:40]}... | ROAS 环比 {roas_chg:+.1f}%",
"action": "连续下降则减预算或暂停,查素材疲劳/受众耗尽",
})
seen = set()
unique = []
for d in diags:
key = d["title"]
if key not in seen:
seen.add(key)
unique.append(d)
level_order = {"red": 0, "yellow": 1, "green": 2}
unique.sort(key=lambda d: level_order.get(d["level"], 9))
return unique
def _l2_action(objective_group: str, metric: str) -> str:
actions = {
("Lead", "CPL"): "换 Offer、优化落地页表单、收紧 Targeting",
("Lead", "LeadRate"): "检查落地页、Offer 吸引力",
("Purchase_Cold", "CPA"): "审视受众/素材,暂停低效计划",
("Purchase_Cold", "ROAS"): "查素材和 Targeting,考虑减预算",
("Purchase_RMKT", "ROAS"): "检查再营销频次,是否池耗尽",
("ATC", "CostPerATC"): "检查商品竞争力、价格策略",
("Pageview", "CPM"): "换素材、扩受众,降低竞争成本",
("Linkclick", "CPC"): "优化素材 CTR,扩展受众",
}
return actions.get((objective_group, metric), "排查原因,评估调整策略")
|