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

fix(auto_put_ad_mini): 修复冷启动期定义和observe分类bug

核心修复:
1. 冷启动期定义错误修复
   - 修改前:COLD_START_DAYS = 7(≤7天都算冷启动)
   - 修改后:COLD_START_DAYS = 3(≤3天冷启动) + EARLY_GROWTH_DAYS = 7(4-7天早期成长期)
   - 对齐决策树:3-7天允许提价(满足条件时)

2. observe 动作错误归类为"自动执行"
   - 修改前:observe → tier=1(自动执行)❌
   - 修改后:observe/hold/creative_adjust → tier=0(无需操作)✅
   - 审批消息从"已自动执行"改为"无需操作(observe观察等待)"

3. 年龄分段逻辑统一
   - ad_decision.py: 早期成长期(4-7天)禁止 bid_down/pause
   - guardrails.py: 冷启动期(≤3天)禁止所有操作,早期成长期仅允许提价
   - execution_engine.py: tier 分类增加 tier=0(无需操作)

4. 审批流程优化
   - im_approval.py: 分离 tier0(无需操作)和 tier1(小幅调价)
   - 审批消息清晰显示:observe=观察等待,hold=保持不变

5. execute_once.py 去掉硬编码日期
   - 允许系统自动使用 yesterday 数据

验证结果:
- 26 个早期成长期广告(4-7天)成功转为 observe
- 审批消息正确显示"无需操作(283个)"
- tier 分类:0=无需操作,1=小幅调价,2/3=需审批

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
刘立冬 2 недель назад
Родитель
Сommit
829579274d

+ 9 - 4
examples/auto_put_ad_mini/config.py

@@ -93,10 +93,15 @@ BID_CHANGE_MIN_PCT = 0.03       # 最小调幅 3%
 BID_CHANGE_MAX_PCT = 0.10       # 最大单次调幅 10%
 BID_FLOOR_YUAN = 0.50           # 出价下限(元)
 BID_CEILING_YUAN = 200.00       # 出价上限(元)
-AD_AGE_NEWBORN = 3              # 新生期(≤3天):极度保护
-COLD_START_DAYS = 7             # 冷启动期(4-7天):谨慎调控,仅允许提价
-CAUTIOUS_DAYS = 7               # 谨慎期(与COLD_START_DAYS相同,保留兼容)
-AD_AGE_MATURE = 7               # 成熟期(>7天):正常调控
+
+# 广告年龄分段(基于决策树图片)
+COLD_START_DAYS = 3             # 冷启动期(≤3天):极度保护,几乎不干预
+EARLY_GROWTH_DAYS = 7           # 早期成长期(4-7天):可提价放量(满足ROI+消耗条件)
+AD_AGE_MATURE = 7               # 成熟期(>7天):全面调控
+
+# 兼容性(已废弃)
+AD_AGE_NEWBORN = COLD_START_DAYS   # 兼容旧代码
+CAUTIOUS_DAYS = EARLY_GROWTH_DAYS  # 兼容旧代码
 
 # 高燃烧预警配置
 HIGH_BURN_AGE_THRESHOLD = 3     # 广告年龄>3天才检查

+ 2 - 2
examples/auto_put_ad_mini/execute_once.py

@@ -81,8 +81,8 @@ async def main():
     print("=" * 70)
     print()
 
-    # 明确指示使用已有数据,跳过0416数据拉取
-    messages = [{"role": "user", "content": "分析广告。重要:使用已有的20260415数据不要拉取20260416的数据。直接从calculate_roi_metrics开始执行。"}]
+    # 让Agent自动决定使用哪天的数据(默认yesterday)
+    messages = [{"role": "user", "content": "分析广告,执行完整的ROI计算和决策流程。"}]
     config.trace_id = None
 
     step_count = 0

+ 46 - 33
examples/auto_put_ad_mini/tools/ad_decision.py

@@ -41,10 +41,9 @@ from config import (
     BID_CHANGE_MAX_PCT,
     BID_FLOOR_YUAN,
     BID_CEILING_YUAN,
-    AD_AGE_NEWBORN,
-    COLD_START_DAYS,
-    CAUTIOUS_DAYS,
-    AD_AGE_MATURE,
+    COLD_START_DAYS,       # ≤3天:冷启动期(极度保护)
+    EARLY_GROWTH_DAYS,     # 4-7天:早期成长期(可提价)
+    AD_AGE_MATURE,         # >7天:成熟期(全面调控)
     HIGH_BURN_AGE_THRESHOLD,
     HIGH_BURN_COST_THRESHOLD,
     ROI_LOW_FACTOR,
@@ -300,7 +299,7 @@ class BidDownDimension(DecisionDimension):
     触发条件:
       - ROI 在 均值×0.5 ~ 均值×0.8 之间(偏低但非极低)
       - 日消耗 ≥ 100 元(数据有统计意义)
-      - 非冷启动期(> COLD_START_DAYS 天)
+      - 非冷启动期(> {COLD_START_DAYS} 天,即≥4天)
       - 有出价数据(bid_amount > 0)
 
     降幅计算:
@@ -1021,22 +1020,22 @@ async def get_ads_for_review(
                 ad_dict["bid_up_line_min"] = round(tier_roi_p50 * 1.05, 4) if tier_roi_p50 else None
                 ad_dict["bid_up_line_max"] = round(tier_roi_p50 * 1.10, 4) if tier_roi_p50 else None
 
-                # ===== 新增:年龄分段标签 =====
+                # ===== 新增:年龄分段标签(基于决策树图片)=====
                 if ad_age is not None:
-                    if ad_age <= AD_AGE_NEWBORN:  # ≤3天
-                        ad_dict["age_segment"] = "newborn"
-                        ad_dict["age_protection_level"] = "极度保护"
-                        ad_dict["allow_bid_down"] = False
-                        ad_dict["allow_bid_up"] = False
-                    elif ad_age <= COLD_START_DAYS:  # 4-7天
+                    if ad_age <= COLD_START_DAYS:  # ≤3天:冷启动期
                         ad_dict["age_segment"] = "cold_start"
-                        ad_dict["age_protection_level"] = "仅允许提价"
+                        ad_dict["age_protection_level"] = "极度保护(冷启动期)"
+                        ad_dict["allow_bid_down"] = False  # 不允许降价
+                        ad_dict["allow_bid_up"] = False    # 不允许提价
+                    elif ad_age <= EARLY_GROWTH_DAYS:  # 4-7天:早期成长期
+                        ad_dict["age_segment"] = "early_growth"
+                        ad_dict["age_protection_level"] = "仅允许提价(早期成长期)"
                         ad_dict["allow_bid_down"] = False  # 不允许降价
-                        ad_dict["allow_bid_up"] = True     # 允许提价
-                        ad_dict["max_bid_down_pct"] = 0.05  # 最大降价5%(虽然不允许,但保留字段)
-                    else:  # >7天
+                        ad_dict["allow_bid_up"] = True     # 允许提价(满足ROI+消耗条件时)
+                        ad_dict["max_bid_down_pct"] = 0     # 不允许降价
+                    else:  # >7天:成熟期
                         ad_dict["age_segment"] = "mature"
-                        ad_dict["age_protection_level"] = "正常调控"
+                        ad_dict["age_protection_level"] = "正常调控(成熟期)"
                         ad_dict["allow_bid_down"] = True
                         ad_dict["allow_bid_up"] = True
                         ad_dict["max_bid_down_pct"] = 0.05  # 最大降价5%(决策树上限)
@@ -1193,21 +1192,35 @@ async def apply_decisions(
                     row_data = cost_row.iloc[0]
                     item["cost_7d_avg"] = float(row_data.get("cost_7d_avg", 0) or 0)
 
-                    # ===== 新增:冷启动期决策限制 =====
+                    # ===== 新增:年龄分段决策限制(基于决策树)=====
                     ad_age_days = row_data.get("ad_age_days")
-                    if ad_age_days is not None and ad_age_days <= COLD_START_DAYS:
-                        # 冷启动期(4-7天)不允许降价/关停
-                        if action in ["bid_down", "pause"]:
-                            original_action = action
-                            original_reason = item.get("reason", "")
-                            item["action"] = "observe"
-                            item["reason"] = f"{original_reason}(原建议{original_action},但广告处于冷启动期{ad_age_days}天,不允许降价/关停,改为观察)"
-                            item["confidence"] = "low"
-                            item["recommended_change_pct"] = None  # 清除调整幅度
-                            logger.warning(
-                                f"广告 {ad_id} 处于冷启动期({ad_age_days}天),"
-                                f"LLM建议 {original_action},已自动转换为 observe"
-                            )
+                    if ad_age_days is not None:
+                        if ad_age_days <= COLD_START_DAYS:  # ≤3天:冷启动期(极度保护)
+                            # 所有操作都改为observe
+                            if action in ["bid_down", "pause", "bid_up"]:
+                                original_action = action
+                                original_reason = item.get("reason", "")
+                                item["action"] = "observe"
+                                item["reason"] = f"{original_reason}(原建议{original_action},但广告处于冷启动期{ad_age_days}天,极度保护,改为观察)"
+                                item["confidence"] = "low"
+                                item["recommended_change_pct"] = None
+                                logger.warning(
+                                    f"广告 {ad_id} 处于冷启动期({ad_age_days}天≤{COLD_START_DAYS}天),"
+                                    f"LLM建议 {original_action},已自动转换为 observe"
+                                )
+                        elif ad_age_days <= EARLY_GROWTH_DAYS:  # 4-7天:早期成长期(仅允许提价)
+                            # 不允许降价/关停
+                            if action in ["bid_down", "pause"]:
+                                original_action = action
+                                original_reason = item.get("reason", "")
+                                item["action"] = "observe"
+                                item["reason"] = f"{original_reason}(原建议{original_action},但广告处于早期成长期{ad_age_days}天,仅允许提价,改为观察)"
+                                item["confidence"] = "low"
+                                item["recommended_change_pct"] = None
+                                logger.warning(
+                                    f"广告 {ad_id} 处于早期成长期({ad_age_days}天,4-{EARLY_GROWTH_DAYS}天),"
+                                    f"LLM建议 {original_action},已自动转换为 observe"
+                                )
                 else:
                     item["cost_7d_avg"] = 0.0
             except Exception as e:
@@ -1229,14 +1242,14 @@ async def apply_decisions(
                     f_roi = row.get("动态ROI_7日均值")
                     ad_age_days = row.get("ad_age_days")
 
-                    # 冷启动保护:广告年龄 ≤ 4天
+                    # 冷启动保护:广告年龄 ≤ 3天(基于决策树)
                     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}元/天,保持观察",
+                            "reason": f"广告年龄{ad_age_days}天 ≤ {COLD_START_DAYS}天(冷启动期),ROI={roi_str},消耗{cost_7d_avg:.2f}元/天,极度保护",
                             "confidence": "high",
                             "source": "规则判断",
                             "cost_7d_avg": cost_7d_avg,  # 用于排序

+ 6 - 3
examples/auto_put_ad_mini/tools/execution_engine.py

@@ -194,13 +194,16 @@ def _classify_tier(row: pd.Series) -> int:
     """
     自治级别分类。
 
-    Tier 1 (自动执行): hold 或 bid 调幅 ≤ 5%
+    Tier 0 (无操作): hold, observe, creative_adjust(不调用API)
+    Tier 1 (自动执行): bid 调幅 ≤ 5%
     Tier 2 (需审批):   pause 或 bid 调幅 > 5%
     Tier 3 (高价值阻断): 日消耗 > 1500 元的高价值广告
     """
     action = row.get("final_action", row.get("action", "hold"))
-    if action == "hold":
-        return 0  # 无操作
+
+    # Tier 0: 无需操作的动作
+    if action in ("hold", "observe", "creative_adjust"):
+        return 0  # 无操作(observe=观察,creative_adjust=需人工执行)
 
     cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
     change_pct = row.get("recommended_change_pct", 0)

+ 9 - 26
examples/auto_put_ad_mini/tools/guardrails.py

@@ -201,41 +201,24 @@ class ColdStartGuardrail(Guardrail):
         if ad_age is None or action == "hold":
             return GuardrailResult(status="approved", reason="")
 
-        # 绝对保护期
-        if ad_age < COLD_START_DAYS:
-            if action in ("pause", "bid_down"):
+        # 冷启动期(≤3天):极度保护,禁止所有操作
+        if ad_age <= COLD_START_DAYS:
+            if action in ("pause", "bid_down", "bid_up"):
                 return GuardrailResult(
                     status="blocked",
-                    reason=f"冷启动绝对保护期({ad_age}天 < {COLD_START_DAYS}天),禁止{action}",
+                    reason=f"冷启动期({ad_age}天 ≤ {COLD_START_DAYS}天),极度保护,禁止{action}",
                     modified_action="hold",
                 )
 
-        # 谨慎期
-        elif ad_age < CAUTIOUS_DAYS:
-            if action == "pause":
+        # 早期成长期(4-7天):仅允许提价
+        elif ad_age <= CAUTIOUS_DAYS:
+            # 早期成长期(4-7天):仅允许提价
+            if action in ("pause", "bid_down"):
                 return GuardrailResult(
                     status="blocked",
-                    reason=f"谨慎期({ad_age}天 < {CAUTIOUS_DAYS}天),禁止暂停",
+                    reason=f"早期成长期({ad_age}天,4-{CAUTIOUS_DAYS}天),仅允许提价,禁止{action}",
                     modified_action="hold",
                 )
-            elif action == "bid_down":
-                change_pct = row.get("recommended_change_pct", 0)
-                if isinstance(change_pct, str):
-                    try:
-                        change_pct = float(change_pct)
-                    except ValueError:
-                        change_pct = 0
-                if abs(change_pct) > 0.05:
-                    # 修正为最大 5%
-                    current_bid = float(row.get("current_bid", 0) or 0)
-                    if current_bid > 0:
-                        new_bid = round(current_bid * 0.95, 2)
-                        return GuardrailResult(
-                            status="modified",
-                            reason=f"谨慎期限制降幅≤5%,从{abs(change_pct)*100:.1f}%修正为5%",
-                            modified_change_pct=-0.05,
-                            modified_bid=new_bid,
-                        )
 
         return GuardrailResult(status="approved", reason="")
 

+ 43 - 20
examples/auto_put_ad_mini/tools/im_approval.py

@@ -178,9 +178,16 @@ def _format_project_notification_message(df_tier2: pd.DataFrame, df_tier1: pd.Da
             lines.append(f"  ...还有 {total_count - 3} 个(查看在线表格)")
             lines.append("")
 
-    # 已自动执行操作
-    if not df_tier1.empty:
-        lines.append(f"✅ 已自动执行({len(df_tier1)} 个)")
+    # 无需操作 + 自动执行
+    tier0_count = len(df_tier0) if not df_tier0.empty else 0
+    tier1_count = len(df_tier1) if not df_tier1.empty else 0
+    if tier0_count > 0 or tier1_count > 0:
+        status_parts = []
+        if tier0_count > 0:
+            status_parts.append(f"{tier0_count}个无需操作(observe/hold)")
+        if tier1_count > 0:
+            status_parts.append(f"{tier1_count}个自动执行(小幅调价)")
+        lines.append(f"ℹ️  {' + '.join(status_parts)}")
         lines.append("")
 
     lines.extend([
@@ -194,7 +201,7 @@ def _format_project_notification_message(df_tier2: pd.DataFrame, df_tier1: pd.Da
     return "\n".join(lines)
 
 
-def _format_approval_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, request_id: str) -> str:
+def _format_approval_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, df_tier0: pd.DataFrame, request_id: str) -> str:
     lines = [
         "📊 广告调控审批请求",
         f"请求ID: {request_id}",
@@ -268,13 +275,28 @@ def _format_approval_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, req
                 lines.append(f"    原因: {reason}")
                 lines.append("")
 
-    # Tier 1: 已自动执行(通知)
+    # Tier 0: 无需操作(observe/hold/creative_adjust)
+    if not df_tier0.empty:
+        lines.append(f"ℹ️  无需操作({len(df_tier0)} 个,仅通知):")
+        for _, row in df_tier0.iterrows():
+            ad_id = row.get("ad_id", "")
+            action = row.get("final_action", row.get("action", ""))
+            action_label = {
+                "observe": "观察等待",
+                "hold": "保持不变",
+                "creative_adjust": "需人工调整素材"
+            }.get(action, action)
+            lines.append(f"  [{ad_id}] {action_label}")
+        lines.append("")
+
+    # Tier 1: 小幅调价(自动执行)
     if not df_tier1.empty:
-        lines.append(f"✅ 已自动执行({len(df_tier1)} 个,仅通知):")
+        lines.append(f"✅ 自动执行({len(df_tier1)} 个小幅调价):")
         for _, row in df_tier1.iterrows():
             ad_id = row.get("ad_id", "")
             action = row.get("final_action", row.get("action", ""))
-            lines.append(f"  [{ad_id}] {action}")
+            change_pct = row.get("recommended_change_pct", 0)
+            lines.append(f"  [{ad_id}] {action} {change_pct:+.1%}")
         lines.append("")
 
     # 回复指令
@@ -416,28 +438,27 @@ async def send_approval_request(
         from execution_engine import _classify_tier
         df["tier"] = df.apply(_classify_tier, axis=1)
 
-        # 分类:需执行操作 vs hold(参考)
-        df_active = df[df["final_action"] != "hold"].copy()
-        df_hold = df[df["final_action"] == "hold"].copy()
+        # 按tier分类
+        df_tier0 = df[df["tier"] == 0].copy()  # observe, hold, creative_adjust(无需操作)
+        df_tier1 = df[df["tier"] == 1].copy()  # 小幅调价(自动执行)
+        df_tier2_3 = df[df["tier"] >= 2].copy()  # 暂停、大幅调价(需审批)
 
-        if df_active.empty and df_hold.empty:
+        if df.empty:
             return ToolResult(title="send_approval_request", output="无决策数据")
 
-        df_tier1 = df_active[df_active["tier"] == 1] if not df_active.empty else pd.DataFrame()
-        df_tier2_3 = df_active[df_active["tier"] >= 2] if not df_active.empty else pd.DataFrame()
-
-        # 合并 Tier2/3 + hold(供运营参考)
-        df_for_review = pd.concat([df_tier2_3, df_hold], ignore_index=True) if not df_hold.empty else df_tier2_3
+        # 合并需审批的和无需操作的(供运营参考)
+        df_for_review = pd.concat([df_tier2_3, df_tier0], ignore_index=True) if not df_tier0.empty else df_tier2_3
 
         if df_tier2_3.empty:
+            total_no_op = len(df_tier0) + len(df_tier1)
             return ToolResult(
                 title="无需审批",
-                output=f"所有 {len(df_tier1)} 个操作均为 Tier 1(自动执行),无需审批",
+                output=f"共 {total_no_op} 个决策:{len(df_tier0)}个无需操作(observe/hold)+ {len(df_tier1)}个自动执行(小幅调价),无需审批",
             )
 
         # 生成审批请求
         request_id = f"req_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
-        message = _format_approval_message(df_tier2_3, df_tier1, request_id)
+        message = _format_approval_message(df_tier2_3, df_tier1, df_tier0, request_id)
 
         # 保存请求状态
         tier2_ad_ids = df_tier2_3["ad_id"].astype(int).tolist()
@@ -541,7 +562,8 @@ async def send_approval_request(
                 output=(
                     f"审批请求 {request_id} 已{'通过飞书发送' if feishu_sent else '保存到文件(飞书发送失败)'}\n"
                     f"  待审批: {len(tier2_ad_ids)} 个广告\n"
-                    f"  已自动执行: {len(df_tier1)} 个广告\n"
+                    f"  无需操作: {len(df_tier0)} 个广告(observe/hold)\n"
+                    f"  自动执行: {len(df_tier1)} 个广告(小幅调价)\n"
                     f"  超时时间: {timeout_minutes} 分钟\n"
                     f"  消息备份: {msg_path}\n\n"
                     f"使用 check_approval_status(request_id='{request_id}') 检查审批结果"
@@ -549,7 +571,8 @@ async def send_approval_request(
                 metadata={
                     "request_id": request_id,
                     "pending_count": len(tier2_ad_ids),
-                    "auto_count": len(df_tier1),
+                    "tier0_count": len(df_tier0),
+                    "tier1_count": len(df_tier1),
                     "feishu_sent": feishu_sent,
                     "msg_path": str(msg_path),
                 },