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), "排查原因,评估调整策略")