Просмотр исходного кода

feat(auto_put_ad_mini): 优化决策表格显示

## 改进内容

1. **按7日均消耗降序排列**
   - 消耗高的广告排在前面,更需要关注
   - 方便运营优先处理高消耗低ROI广告(止损优先)
   - 零消耗广告自动排到最后

2. **优化零消耗广告表达**
   - 改进前:7日均消耗=0.00元,几乎无活动
   - 改进后:7日几乎无消耗,长期无活动(更自然)
   - 低消耗(0.01-1.0元)显示具体数值

3. **保留source列**
   - 保留规则判断和智能判断标识
   - 方便区分决策来源

## 技术实现

- 临时添加cost_7d_avg字段用于排序
- 从metrics_csv获取消耗数据
- 排序后删除该字段(不保存到CSV)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
刘立冬 3 недель назад
Родитель
Сommit
fab26b1ac0
1 измененных файлов с 165 добавлено и 48 удалено
  1. 165 48
      examples/auto_put_ad_mini/tools/ad_decision.py

+ 165 - 48
examples/auto_put_ad_mini/tools/ad_decision.py

@@ -43,10 +43,37 @@ from config import (
     BID_CEILING_YUAN,
     BID_CEILING_YUAN,
     COLD_START_DAYS,
     COLD_START_DAYS,
     CAUTIOUS_DAYS,
     CAUTIOUS_DAYS,
+    ROI_LOW_FACTOR,
 )
 )
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# ═══════════════════════════════════════════
+# 策略参数动态加载(阈值不写死在代码中)
+# ═══════════════════════════════════════════
+
+STRATEGY_PARAMS_FILE = _MINI_DIR / "strategy_params.json"
+
+def _load_strategy_params():
+    """从json文件加载策略参数,如不存在则使用config.py默认值"""
+    import json
+
+    if STRATEGY_PARAMS_FILE.exists():
+        try:
+            with open(STRATEGY_PARAMS_FILE) as f:
+                data = json.load(f)
+                return data.get("params", {})
+        except Exception as e:
+            logger.warning(f"加载strategy_params.json失败,使用config.py默认值: {e}")
+
+    # 使用config.py默认值
+    return {
+        "ROI_LOW_FACTOR": ROI_LOW_FACTOR,
+        "BID_DOWN_ROI_FACTOR": BID_DOWN_ROI_FACTOR,
+        "BID_UP_ROI_FACTOR": BID_UP_ROI_FACTOR,
+        "BID_UP_LOW_SPEND_FACTOR": BID_UP_LOW_SPEND_FACTOR,
+    }
+
 
 
 # ═══════════════════════════════════════════
 # ═══════════════════════════════════════════
 # 辅助函数
 # 辅助函数
@@ -816,6 +843,9 @@ async def get_ads_for_review(
         min_spend_for_class_a: 7日均消耗低于此值(元)→ A 类(默认 1.0)
         min_spend_for_class_a: 7日均消耗低于此值(元)→ A 类(默认 1.0)
     """
     """
     try:
     try:
+        # 加载策略参数(动态阈值,不写死在代码中)
+        params = _load_strategy_params()
+
         if not metrics_csv:
         if not metrics_csv:
             metrics_csv = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
             metrics_csv = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
 
 
@@ -855,10 +885,10 @@ async def get_ads_for_review(
         cost_series = df["cost_7d_avg"].dropna()
         cost_series = df["cost_7d_avg"].dropna()
         cost_median = float(cost_series.median()) if len(cost_series) > 0 else 0.0
         cost_median = float(cost_series.median()) if len(cost_series) > 0 else 0.0
 
 
-        # 分类
-        class_a = []
-        class_b = []
-        class_c_count = 0
+        # 分类(业务语言)
+        zero_spend_ads = []      # 零消耗待关停
+        need_review_ads = []     # 待优化评估
+        normal_ads_count = 0     # 正常运行
 
 
         for _, row in df.iterrows():
         for _, row in df.iterrows():
             cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
             cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
@@ -869,16 +899,22 @@ async def get_ads_for_review(
             stable_days = float(row.get("stable_spend_days_30d", 0) or 0)
             stable_days = float(row.get("stable_spend_days_30d", 0) or 0)
             bid_amount = float(row.get("bid_amount", 0) or 0)
             bid_amount = float(row.get("bid_amount", 0) or 0)
 
 
-            # A 类:几乎零消耗
+            # 零消耗待关停:7日均消耗 < 1元,几乎无活动
             if cost_7d_avg < min_spend_for_class_a:
             if cost_7d_avg < min_spend_for_class_a:
-                class_a.append({
+                zero_spend_ads.append({
                     "ad_id": int(row["ad_id"]),
                     "ad_id": int(row["ad_id"]),
                     "ad_name": str(row.get("ad_name", "")),
                     "ad_name": str(row.get("ad_name", "")),
                     "cost_7d_avg": round(cost_7d_avg, 2),
                     "cost_7d_avg": round(cost_7d_avg, 2),
                 })
                 })
                 continue
                 continue
 
 
-            # B 类:ROI 偏低 或 衰退信号 或 出价调整候选
+            # 冷启动保护(前置判断):广告年龄 ≤ 4天,直接归类为正常运行(hold)
+            # 不进入LLM推理,节省token,避免护栏拦截
+            if ad_age is not None and ad_age <= COLD_START_DAYS:
+                normal_ads_count += 1
+                continue
+
+            # 待优化评估:ROI 偏低 或 衰退信号 或 出价调整候选(需要智能判断)
             roi_low = (not pd.isna(f_roi)) and (f_roi < roi_mean * roi_review_factor)
             roi_low = (not pd.isna(f_roi)) and (f_roi < roi_mean * roi_review_factor)
             decay_signal = (
             decay_signal = (
                 stable_days >= 7
                 stable_days >= 7
@@ -888,20 +924,20 @@ async def get_ads_for_review(
             # 出价调整候选:高ROI低量(提价)或 ROI偏低(降价)
             # 出价调整候选:高ROI低量(提价)或 ROI偏低(降价)
             bid_up_candidate = (
             bid_up_candidate = (
                 (not pd.isna(f_roi))
                 (not pd.isna(f_roi))
-                and f_roi > roi_mean * BID_UP_ROI_FACTOR
-                and cost_7d_avg < cost_median * BID_UP_LOW_SPEND_FACTOR
+                and f_roi > roi_mean * params["BID_UP_ROI_FACTOR"]
+                and cost_7d_avg < cost_median * params["BID_UP_LOW_SPEND_FACTOR"]
                 and bid_amount > 0
                 and bid_amount > 0
             ) if BID_ADJUSTMENT_ENABLED else False
             ) if BID_ADJUSTMENT_ENABLED else False
             bid_down_candidate = (
             bid_down_candidate = (
                 (not pd.isna(f_roi))
                 (not pd.isna(f_roi))
-                and f_roi < roi_mean * BID_DOWN_ROI_FACTOR
-                and f_roi >= roi_mean * 0.5
+                and f_roi < roi_mean * params["BID_DOWN_ROI_FACTOR"]
+                and f_roi >= roi_mean * params["ROI_LOW_FACTOR"]
                 and cost_7d_avg >= 100
                 and cost_7d_avg >= 100
                 and bid_amount > 0
                 and bid_amount > 0
             ) if BID_ADJUSTMENT_ENABLED else False
             ) if BID_ADJUSTMENT_ENABLED else False
 
 
             if roi_low or decay_signal or bid_up_candidate or bid_down_candidate:
             if roi_low or decay_signal or bid_up_candidate or bid_down_candidate:
-                class_b.append({
+                need_review_ads.append({
                     "ad_id": int(row["ad_id"]),
                     "ad_id": int(row["ad_id"]),
                     "ad_name": str(row.get("ad_name", "")),
                     "ad_name": str(row.get("ad_name", "")),
                     "动态ROI_7日均值": round(float(f_roi), 4) if not pd.isna(f_roi) else None,
                     "动态ROI_7日均值": round(float(f_roi), 4) if not pd.isna(f_roi) else None,
@@ -916,15 +952,16 @@ async def get_ads_for_review(
                 })
                 })
                 continue
                 continue
 
 
-            class_c_count += 1
+            # 正常运行:ROI 正常且无异常信号
+            normal_ads_count += 1
 
 
         import json
         import json
         result = {
         result = {
             "summary": {
             "summary": {
                 "total": len(df),
                 "total": len(df),
-                "class_a": len(class_a),
-                "class_b": len(class_b),
-                "class_c": class_c_count,
+                "zero_spend_ads": len(zero_spend_ads),
+                "need_review_ads": len(need_review_ads),
+                "normal_ads": normal_ads_count,
             },
             },
             "distribution": {
             "distribution": {
                 "roi_mean": round(roi_mean, 4),
                 "roi_mean": round(roi_mean, 4),
@@ -936,24 +973,34 @@ async def get_ads_for_review(
             },
             },
             "bid_adjustment": {
             "bid_adjustment": {
                 "enabled": BID_ADJUSTMENT_ENABLED,
                 "enabled": BID_ADJUSTMENT_ENABLED,
-                "bid_down_line": round(roi_mean * BID_DOWN_ROI_FACTOR, 4),
-                "bid_up_line": round(roi_mean * BID_UP_ROI_FACTOR, 4),
-                "low_spend_line": round(cost_median * BID_UP_LOW_SPEND_FACTOR, 2),
+                "bid_down_line": round(roi_mean * params["BID_DOWN_ROI_FACTOR"], 4),
+                "bid_up_line": round(roi_mean * params["BID_UP_ROI_FACTOR"], 4),
+                "low_spend_line": round(cost_median * params["BID_UP_LOW_SPEND_FACTOR"], 2),
             },
             },
-            "class_a": class_a,
-            "class_b": class_b,
+            "thresholds_used": {
+                "ROI_LOW_FACTOR": params["ROI_LOW_FACTOR"],
+                "BID_DOWN_ROI_FACTOR": params["BID_DOWN_ROI_FACTOR"],
+                "BID_UP_ROI_FACTOR": params["BID_UP_ROI_FACTOR"],
+                "BID_UP_LOW_SPEND_FACTOR": params["BID_UP_LOW_SPEND_FACTOR"],
+                "roi_mean": round(roi_mean, 4),
+                "pause_line": round(roi_mean * params["ROI_LOW_FACTOR"], 4),
+                "bid_down_line": round(roi_mean * params["BID_DOWN_ROI_FACTOR"], 4),
+                "bid_up_line": round(roi_mean * params["BID_UP_ROI_FACTOR"], 4),
+            },
+            "zero_spend_ads": zero_spend_ads,
+            "need_review_ads": need_review_ads,
         }
         }
 
 
         output_json = json.dumps(result, ensure_ascii=False, indent=2)
         output_json = json.dumps(result, ensure_ascii=False, indent=2)
 
 
         return ToolResult(
         return ToolResult(
-            title=f"待评估广告(A类:{len(class_a)} B类:{len(class_b)} C类:{class_c_count})",
+            title=f"广告分类(零消耗:{len(zero_spend_ads)} 待评估:{len(need_review_ads)} 正常:{normal_ads_count})",
             output=output_json,
             output=output_json,
             metadata={
             metadata={
                 "total": len(df),
                 "total": len(df),
-                "class_a": len(class_a),
-                "class_b": len(class_b),
-                "class_c": class_c_count,
+                "zero_spend_ads": len(zero_spend_ads),
+                "need_review_ads": len(need_review_ads),
+                "normal_ads": normal_ads_count,
                 "roi_mean": roi_mean,
                 "roi_mean": roi_mean,
                 "end_date": end_date,
                 "end_date": end_date,
             },
             },
@@ -969,7 +1016,7 @@ async def get_ads_for_review(
 # ═══════════════════════════════════════════
 # ═══════════════════════════════════════════
 
 
 
 
-@tool(description="智能引擎:接收LLM的决策列表,保存为结构化结果")
+@tool(description="智能引擎:接收LLM的决策列表,合并A/C类自动决策,保存为结构化结果")
 async def apply_decisions(
 async def apply_decisions(
     ctx: ToolContext,
     ctx: ToolContext,
     decisions: str,
     decisions: str,
@@ -977,14 +1024,19 @@ async def apply_decisions(
     metrics_csv: str = "",
     metrics_csv: str = "",
 ) -> ToolResult:
 ) -> ToolResult:
     """
     """
-    接收 LLM 的决策,合并 A 类广告(自动关停),保存到 llm_decisions_{date}.csv。
+    接收 LLM 的决策,合并 A 类广告(自动关停)和 C 类广告(自动保持),保存到 llm_decisions_{date}.csv。
+
+    决策分类:
+      - 零消耗待关停:7日均消耗 < 1元,几乎无活动 → 规则判断自动关停
+      - 待优化评估:ROI 偏低、衰退信号、出价调整候选 → 智能判断
+      - 正常运行:ROI 正常且无异常信号 → 规则判断自动保持
 
 
     Args:
     Args:
-        decisions: JSON 字符串,LLM 输出的决策列表
-                   格式:[{"ad_id": 123, "action": "pause"/"hold",
+        decisions: JSON 字符串,LLM 输出的"待优化评估"类广告决策列表
+                   格式:[{"ad_id": 123, "action": "pause"/"hold"/"bid_up"/"bid_down",
                            "dimension": "...", "reason": "...", "confidence": "high"/"medium"/"low"}]
                            "dimension": "...", "reason": "...", "confidence": "high"/"medium"/"low"}]
         end_date: 结束日期
         end_date: 结束日期
-        metrics_csv: ROI 指标 CSV 路径(用于获取 A 类广告)
+        metrics_csv: ROI 指标 CSV 路径(用于获取 A/C 类广告)
     """
     """
     import json
     import json
 
 
@@ -1001,8 +1053,8 @@ async def apply_decisions(
         if not isinstance(llm_list, list):
         if not isinstance(llm_list, list):
             return ToolResult(title="apply_decisions 失败", output="decisions 必须是 JSON 数组")
             return ToolResult(title="apply_decisions 失败", output="decisions 必须是 JSON 数组")
 
 
-        # 加载 A 类广告(自动关停
-        a_class_rows = []
+        # 加载零消耗待关停广告(规则判断
+        zero_spend_rows = []
         if not metrics_csv:
         if not metrics_csv:
             metrics_csv = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
             metrics_csv = str(_MINI_DIR / "outputs" / "metrics_temp.csv")
 
 
@@ -1011,29 +1063,87 @@ async def apply_decisions(
             for _, row in df_metrics.iterrows():
             for _, row in df_metrics.iterrows():
                 cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
                 cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
                 if cost_7d_avg < 1.0:
                 if cost_7d_avg < 1.0:
-                    a_class_rows.append({
+                    # 优化reason表达:避免"0.00元"显示,改用"几乎无消耗"
+                    if cost_7d_avg == 0:
+                        reason_text = "7日几乎无消耗,长期无活动"
+                    else:
+                        reason_text = f"7日均消耗={cost_7d_avg:.2f}元,长期低消耗"
+                    zero_spend_rows.append({
                         "ad_id": int(row["ad_id"]),
                         "ad_id": int(row["ad_id"]),
                         "action": "pause",
                         "action": "pause",
                         "dimension": "长期零消耗",
                         "dimension": "长期零消耗",
-                        "reason": f"7日均消耗={cost_7d_avg:.2f}元,几乎无活动",
+                        "reason": reason_text,
                         "confidence": "high",
                         "confidence": "high",
-                        "source": "class_a_auto",
+                        "source": "规则判断",
+                        "cost_7d_avg": cost_7d_avg,  # 用于排序
                     })
                     })
         except Exception as e:
         except Exception as e:
-            logger.warning("加载 A 类广告失败(跳过): %s", e)
+            logger.warning("加载零消耗待关停广告失败(跳过): %s", e)
 
 
-        # 合并 LLM 决策(标注来源)
+        # 合并 LLM 决策(标注来源 + 添加cost_7d_avg用于排序
         for item in llm_list:
         for item in llm_list:
-            item["source"] = "llm"
+            item["source"] = "智能判断"
+            ad_id = item.get("ad_id")
+            # 从metrics中获取cost_7d_avg
+            try:
+                cost_row = df_metrics[df_metrics["ad_id"] == ad_id]
+                if not cost_row.empty:
+                    item["cost_7d_avg"] = float(cost_row.iloc[0].get("cost_7d_avg", 0) or 0)
+                else:
+                    item["cost_7d_avg"] = 0.0
+            except:
+                item["cost_7d_avg"] = 0.0
+
+        # 加载正常运行广告(规则判断)
+        normal_running_rows = []
+        try:
+            # 收集零消耗和待评估的 ad_id
+            zero_spend_ad_ids = {row["ad_id"] for row in zero_spend_rows}
+            need_review_ad_ids = {item["ad_id"] for item in llm_list}
+
+            # 正常运行 = 所有广告 - 零消耗 - 待评估
+            for _, row in df_metrics.iterrows():
+                ad_id = int(row["ad_id"])
+                if ad_id not in zero_spend_ad_ids and ad_id not in need_review_ad_ids:
+                    cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
+                    f_roi = row.get("动态ROI_7日均值")
+                    ad_age_days = row.get("ad_age_days")
+
+                    # 冷启动保护:广告年龄 ≤ 4天
+                    if ad_age_days is not None and ad_age_days <= COLD_START_DAYS:
+                        roi_str = f"{f_roi:.2f}" if not pd.isna(f_roi) else "数据不足"
+                        normal_running_rows.append({
+                            "ad_id": ad_id,
+                            "action": "hold",
+                            "dimension": "冷启动保护",
+                            "reason": f"广告年龄{ad_age_days}天 ≤ {COLD_START_DAYS}天(冷启动期),ROI={roi_str},消耗{cost_7d_avg:.2f}元/天,保持观察",
+                            "confidence": "high",
+                            "source": "规则判断",
+                            "cost_7d_avg": cost_7d_avg,  # 用于排序
+                        })
+                    else:
+                        # 正常运行
+                        roi_str = f"{f_roi:.2f}" if not pd.isna(f_roi) else "数据不足"
+                        normal_running_rows.append({
+                            "ad_id": ad_id,
+                            "action": "hold",
+                            "dimension": "正常运行",
+                            "reason": f"ROI={roi_str},消耗正常({cost_7d_avg:.2f}元/天),无异常信号",
+                            "confidence": "high",
+                            "source": "规则判断",
+                            "cost_7d_avg": cost_7d_avg,  # 用于排序
+                        })
+        except Exception as e:
+            logger.warning("加载正常运行广告失败(跳过): %s", e)
 
 
-        all_decisions = a_class_rows + llm_list
+        all_decisions = zero_spend_rows + llm_list + normal_running_rows
 
 
         if not all_decisions:
         if not all_decisions:
             return ToolResult(title="apply_decisions", output="无决策数据")
             return ToolResult(title="apply_decisions", output="无决策数据")
 
 
         df_out = pd.DataFrame(all_decisions)
         df_out = pd.DataFrame(all_decisions)
 
 
-        # 过滤:如果决策是 pause,但广告已经是 AD_STATUS_SUSPEND,则排除
+        # 过滤:已经是 AD_STATUS_SUSPEND 的广告不应出现在决策表中(已暂停无需再决策)
         ad_status_path = _MINI_DIR / "outputs" / "ad_status" / f"ad_status_{end_date}.csv"
         ad_status_path = _MINI_DIR / "outputs" / "ad_status" / f"ad_status_{end_date}.csv"
         if ad_status_path.exists():
         if ad_status_path.exists():
             try:
             try:
@@ -1042,11 +1152,9 @@ async def apply_decisions(
                     df_status[df_status["ad_status"] == "AD_STATUS_SUSPEND"]["ad_id"].tolist()
                     df_status[df_status["ad_status"] == "AD_STATUS_SUSPEND"]["ad_id"].tolist()
                 )
                 )
 
 
-                # 过滤掉已经暂停的广告(仅针对 action=pause 的决策
+                # 过滤掉所有已暂停的广告(不论决策是什么,已暂停的广告不应出现在决策表中
                 before_count = len(df_out)
                 before_count = len(df_out)
-                df_out = df_out[
-                    ~((df_out["action"] == "pause") & (df_out["ad_id"].isin(suspended_ads)))
-                ]
+                df_out = df_out[~df_out["ad_id"].isin(suspended_ads)]
                 filtered_count = before_count - len(df_out)
                 filtered_count = before_count - len(df_out)
                 if filtered_count > 0:
                 if filtered_count > 0:
                     logger.info(f"过滤掉 {filtered_count} 个已暂停广告(AD_STATUS_SUSPEND)")
                     logger.info(f"过滤掉 {filtered_count} 个已暂停广告(AD_STATUS_SUSPEND)")
@@ -1058,10 +1166,18 @@ async def apply_decisions(
             if col not in df_out.columns:
             if col not in df_out.columns:
                 df_out[col] = ""
                 df_out[col] = ""
         # 数值列用 None 而非空字符串,避免 float("") 异常
         # 数值列用 None 而非空字符串,避免 float("") 异常
-        for col in ["recommended_change_pct", "current_bid", "recommended_bid"]:
+        for col in ["recommended_change_pct", "current_bid", "recommended_bid", "cost_7d_avg"]:
             if col not in df_out.columns:
             if col not in df_out.columns:
                 df_out[col] = None
                 df_out[col] = None
 
 
+        # 按7日均消耗降序排列(消耗高的广告排在前面,更需要关注)
+        if "cost_7d_avg" in df_out.columns:
+            df_out["cost_7d_avg"] = pd.to_numeric(df_out["cost_7d_avg"], errors="coerce").fillna(0)
+            df_out = df_out.sort_values("cost_7d_avg", ascending=False).reset_index(drop=True)
+
+        # 删除cost_7d_avg列(仅用于排序,不保存到最终文件)
+        df_out = df_out.drop(columns=["cost_7d_avg"], errors="ignore")
+
         # 保存
         # 保存
         reports_dir = _MINI_DIR / "outputs" / "reports"
         reports_dir = _MINI_DIR / "outputs" / "reports"
         reports_dir.mkdir(parents=True, exist_ok=True)
         reports_dir.mkdir(parents=True, exist_ok=True)
@@ -1075,8 +1191,8 @@ async def apply_decisions(
 
 
         output_parts = [
         output_parts = [
             f"智能引擎决策已保存: {out_path}",
             f"智能引擎决策已保存: {out_path}",
-            f"  关停: {pause_count} 个(含A类自动关停: {len(a_class_rows)} 个)",
-            f"  保持: {hold_count} 个",
+            f"  关停: {pause_count} 个(含零消耗待关停: {len(zero_spend_rows)} 个)",
+            f"  保持: {hold_count} 个(含正常运行: {len(normal_running_rows)} 个)",
         ]
         ]
         if bid_up_count > 0:
         if bid_up_count > 0:
             output_parts.append(f"  提价: {bid_up_count} 个")
             output_parts.append(f"  提价: {bid_up_count} 个")
@@ -1093,7 +1209,8 @@ async def apply_decisions(
                 "hold": int(hold_count),
                 "hold": int(hold_count),
                 "bid_up": int(bid_up_count),
                 "bid_up": int(bid_up_count),
                 "bid_down": int(bid_down_count),
                 "bid_down": int(bid_down_count),
-                "class_a_auto": len(a_class_rows),
+                "zero_spend_ads": len(zero_spend_rows),
+                "normal_running_ads": len(normal_running_rows),
                 "end_date": end_date,
                 "end_date": end_date,
             },
             },
         )
         )