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