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

fix(auto_put_ad_mini): 统一 ad_age_days 计算口径 + 前置过滤 DELETED/SUSPEND + prompt 约束优化

核心修复:
- ad_decision.py: ad_age_days 不再用 datetime.now() 重算,直接复用 metrics CSV 中基于 end_dt 的值,
  解决三层架构(get_ads_for_review / apply_decisions / guardrails)年龄差 1 天导致冷启动保护失效
- data_query.py: _merge_one_day 增加 ad_filter_cache 状态补充 + 前置过滤 DELETED/SUSPEND(数据量 -71%)
- ad_decision.py: normal-running scan 增加 configured_status 检查(纵深防御)

其他改进:
- system.prompt: 禁止 LLM 输出 hold/observe/scale_up 数量 + 正例对话去除"扩量"
- decision_strategy.md: §四改为 5 步决策思考法 + 增加 4 种 action 的 reason 合格样本
- execute_once.py: 硬编码日期 → 动态 T-1
- execution_engine.py: 白名单仅限执行阶段过滤,不阻断分析
- config.py: +时区配置 +白名单从环境变量/文件读取
- guardrails.py: 数据新鲜度检查支持时区
- 所有 tools: ctx 参数默认值改为 None,支持脚本直接调用

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
刘立冬 3 недель назад
Родитель
Сommit
385fea3ed6

+ 26 - 0
examples/auto_put_ad_mini/.env.example

@@ -34,3 +34,29 @@ FEISHU_OPERATOR_CHAT_ID=oc_88e0a1970a7de02eb5ac225a8b0cedea
 # ========================================
 # QWEN_API_KEY=xxx
 # OPEN_ROUTER_API_KEY=xxx
+
+# ========================================
+# 生产环境配置(海外部署)
+# ========================================
+
+# 白名单账户(逗号分隔)
+WHITELIST_ENABLED=true
+WHITELIST_ACCOUNTS=80769799,71305011
+
+# 代理配置(海外访问腾讯 API)
+# HTTP_PROXY=http://proxy-server:port
+# HTTPS_PROXY=http://proxy-server:port
+
+# 时区配置(默认 UTC,可选 Asia/Shanghai)
+TZ=UTC
+
+# 执行开关(生产环境谨慎开启)
+EXECUTION_ENABLED=false
+
+# API 端点(可选覆盖)
+# TENCENT_AD_BASE_URL=https://api.e.qq.com/v3.0
+
+# APScheduler 定时调度(使用 server.py 时)
+# CRON_SCHEDULE=0 2 * * *  # 每天凌晨2点UTC
+# RUN_ON_STARTUP=false     # 启动时是否立即执行
+# PORT=8080                # FastAPI 服务端口

+ 41 - 0
examples/auto_put_ad_mini/config.py

@@ -8,9 +8,13 @@
   - 三级分类:零消耗待关停(规则)+ 待优化评估(智能)+ 正常运行(规则)
 """
 import os
+import logging
 from pathlib import Path
 from agent.core.runner import RunConfig, KnowledgeConfig
 
+# 初始化 logger(必须在使用前定义)
+logger = logging.getLogger(__name__)
+
 # 加载 .env 文件(如果存在)
 try:
     from dotenv import load_dotenv
@@ -70,6 +74,12 @@ TRACE_STORE_PATH = ".trace"
 LOG_LEVEL = "INFO"
 LOG_FILE = None
 
+# ═══════════════════════════════════════════
+# 时区配置(海外部署)
+# ═══════════════════════════════════════════
+TIMEZONE = os.getenv("TZ", "UTC")
+logger.info(f"运行时区:{TIMEZONE}")
+
 # ═══════════════════════════════════════════
 # V3 数据窗口配置
 # ═══════════════════════════════════════════
@@ -159,6 +169,37 @@ FEISHU_AD_PROJECT_CHAT_ID = os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "")
 # 腾讯广告默认账户(测试账户)
 TENCENT_AD_ACCOUNT_ID = int(os.getenv("TENCENT_AD_ACCOUNT_ID", "80769799"))
 
+# ═══════════════════════════════════════════
+# 账户白名单配置
+# ═══════════════════════════════════════════
+
+# 白名单模式开关
+WHITELIST_ENABLED = os.getenv("WHITELIST_ENABLED", "true").lower() == "true"
+
+# 白名单账户列表(从环境变量或配置文件读取)
+WHITELIST_ACCOUNTS = []
+_whitelist_str = os.getenv("WHITELIST_ACCOUNTS", "")
+if _whitelist_str:
+    # 格式:逗号分隔,如 "80769799,71305011"
+    WHITELIST_ACCOUNTS = [int(x.strip()) for x in _whitelist_str.split(",") if x.strip()]
+else:
+    # 兜底:从文件读取(可选)
+    _whitelist_file = Path(__file__).parent / "whitelist.json"
+    if _whitelist_file.exists():
+        import json
+        with open(_whitelist_file) as f:
+            whitelist_data = json.load(f)
+            WHITELIST_ACCOUNTS = whitelist_data.get("accounts", [])
+
+# 向后兼容:单账户模式
+if not WHITELIST_ACCOUNTS:
+    WHITELIST_ACCOUNTS = [TENCENT_AD_ACCOUNT_ID]
+
+logger.info(
+    f"白名单配置:{'启用' if WHITELIST_ENABLED else '禁用'},"
+    f"账户数={len(WHITELIST_ACCOUNTS)},列表={WHITELIST_ACCOUNTS[:5]}..."
+)
+
 # ═══════════════════════════════════════════
 # 输出路径配置
 # ═══════════════════════════════════════════

+ 11 - 7
examples/auto_put_ad_mini/execute_once.py

@@ -6,14 +6,18 @@ import os
 import sys
 from pathlib import Path
 from datetime import datetime, timedelta
-
-# 代理设置
-os.environ.setdefault("HTTP_PROXY", "http://127.0.0.1:29758")
-os.environ.setdefault("HTTPS_PROXY", "http://127.0.0.1:29758")
+import logging
 
 # 添加项目根目录到 Python 路径
 sys.path.insert(0, str(Path(__file__).parent.parent.parent))
 
+# 代理配置(从环境变量读取,海外部署时通过 Docker 环境变量注入)
+logger = logging.getLogger(__name__)
+http_proxy = os.getenv("HTTP_PROXY")
+https_proxy = os.getenv("HTTPS_PROXY")
+if http_proxy or https_proxy:
+    logger.info(f"使用代理:HTTP={http_proxy}, HTTPS={https_proxy}")
+
 from dotenv import load_dotenv
 load_dotenv()
 
@@ -83,9 +87,9 @@ async def main():
     print("=" * 70)
     print()
 
-    # 使用 20260420 数据
-    target_date = "20260420"
-    target_date_display = "2026-04-20"
+    # 自动取 T-1(昨天)作为数据截止日期,避免硬编码
+    target_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+    target_date_display = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
     messages = [{"role": "user", "content": f"分析广告,执行完整的ROI计算和决策流程。请使用 {target_date_display}(end_date={target_date})作为数据截止日期,因为当天数据尚未回流。"}]
     config.trace_id = None
 

+ 2 - 1
examples/auto_put_ad_mini/prompts/system.prompt

@@ -150,6 +150,7 @@ Step 10: generate_report          ← 生成报告
 - reason 不得模板化(错例:"ROI 低于线建议降价";正例见 posterior-wisdom skill 的反例警示)
 - 冷启动期(4-7 天)系统会过滤 bid_down/pause,你也不该主动给这两种建议
 - 🚨 **禁止在 apply_decisions 之前输出 markdown 分析表格、tier 拆分汇总、决策汇总表**。看完候选数据后**直接调用 apply_decisions** 提交决策 JSON 数组。所有判断理由都写进每条决策的 `reason` 字段里,**不要**在工具调用前面铺一段中间报告(会触发 max_tokens 截断导致 tool_call 失败)。
+- 🚨 **任何输出/汇总/飞书消息中,禁止提及 hold(保持)、observe(观察)、scale_up(扩量)的数量**。这三类不进审批表,用户不需要看到。只报告需审批的决策:pause(关停)、bid_down(降价)、bid_up(提价)。
 
 # 第六部分:投放经验知识库(Skills)
 
@@ -177,7 +178,7 @@ Skill 提供「判断原则」,工具提供「数据」,你负责综合判
 
 ```
 您好,按您说的"我只要降价的",我这边已经执行了 14 条降价(平均 -4.2%)。
-剩下的 600 条暂停和 24 条扩量,我先不动了,等您在飞书表格逐条勾选。
+剩下的暂停决策我先不动了,等您在飞书表格逐条勾选。
 
 一个提醒:14 条里有 2 条([93479729712]、[93314795441])7日均消耗超 2000 元,
 24 小时内不见改善我建议直接 pause,到时再发您确认。

+ 8 - 4
examples/auto_put_ad_mini/run.py

@@ -10,10 +10,7 @@ import asyncio
 import os
 import sys
 from pathlib import Path
-
-# 代理设置
-os.environ.setdefault("HTTP_PROXY", "http://127.0.0.1:29758")
-os.environ.setdefault("HTTPS_PROXY", "http://127.0.0.1:29758")
+import logging
 
 # 添加项目根目录到 Python 路径
 sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@@ -21,6 +18,13 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
 from dotenv import load_dotenv
 load_dotenv()
 
+# 代理配置(从环境变量读取,海外部署时通过 Docker 环境变量注入)
+logger = logging.getLogger(__name__)
+http_proxy = os.getenv("HTTP_PROXY")
+https_proxy = os.getenv("HTTPS_PROXY")
+if http_proxy or https_proxy:
+    logger.info(f"使用代理:HTTP={http_proxy}, HTTPS={https_proxy}")
+
 from agent.core.runner import AgentRunner
 from agent.trace import FileSystemTraceStore, Trace, Message
 from agent.llm import create_openrouter_llm_call

+ 73 - 86
examples/auto_put_ad_mini/skills/decision_strategy.md

@@ -67,12 +67,12 @@ bid_up_candidate=True 也不等于必须 bid_up —— 可能 CTR 其实在下
 | 字段 | 含义 | 规则已检测的内容 |
 |---------|------|----------------|
 | `roi_zone` | ROI 所在区间(客观事实) | `below_pause_line` / `bid_down_zone` / `above_bid_up_line` / `normal` |
-| `fission_vs_tier` | 裂变率与同类对比 | `high`(≥同类110%) / `normal` / `low`(<同类90%) / `unknown`(数据缺失) |
+| `fission_vs_tier` | 裂变率与同人群包均值对比 | `high`(≥同人群包均值110%) / `normal` / `low`(<90%) / `unknown`(数据缺失) |
 | `bid_up_candidate=True` | 有提价潜力 | ROI 明显优于渠道 + 年龄在提价窗口 + 消耗未过高 + CTR 达标 |
 | `scale_up_candidate=True` | 值得扩量 | 成熟稳定 + 高消耗 + ROI 达标 |
 | `decay_signal=True` | 有衰退迹象 | 消耗趋势下降或 ROI 持续走低 |
 
-**⚠️ 关键变化**:`roi_zone="bid_down_zone"` 不等于"应该降价"——必须结合 `fission_vs_tier` 综合判断(见§四决策映射)
+**⚠️ 核心原则**:候选标记是客观事实,不是操作指令。`roi_zone="bid_down_zone"` 不等于"应该降价"——必须结合裂变率(`fission_vs_tier`)综合判断,见§四思考步骤
 
 ### 阈值参考(仅用于理解规则逻辑,不要在 reason 中引用具体数值)
 
@@ -82,18 +82,6 @@ bid_up_candidate=True 也不等于必须 bid_up —— 可能 CTR 其实在下
 
 (以上数值可能随运营策略调整,**以规则输出的候选标记为准**,不要硬记数值)
 
-### 你看到的数据
-
-每条候选广告到你手里时,已携带完整上下文:
-- 候选标记(上述 5 个 bool)
-- 核心指标(动态ROI、7日均消耗、昨日消耗、广告年龄、创意数等)
-- 渠道基准(channel_roi_p50)
-- 同类基准(tier_fission_mean、tier 内广告数/消耗统计)
-- 调整历史(7天内是否提价/降价/换创意)
-- 数据质量(roi_valid_days、stable_spend_days_30d)
-
-**你的工作是综合这些信息做判断,而不是只看候选标记就机械输出 action。**
-
 ---
 
 ## 三、年龄策略
@@ -110,33 +98,31 @@ bid_up_candidate=True 也不等于必须 bid_up —— 可能 CTR 其实在下
 
 ---
 
-## 四、信号 → action 映射
+## 四、决策思考步骤
 
-```
-收到候选广告(附带 roi_zone / fission_vs_tier / bid_up_candidate 等客观信号)
-  │
-  ├─ roi_zone = "below_pause_line"(ROI 严重偏低)
-  │   └─ 综合权衡后 → pause / observe / hold(§五.1)
-  │
-  ├─ roi_zone = "bid_down_zone"(ROI 在降价区间)
-  │   ├─ fission_vs_tier = "low"    → 双低确认 → bid_down 3%-5%(§五.2)
-  │   ├─ fission_vs_tier = "normal" → observe(ROI 低但裂变正常,需观察)
-  │   ├─ fission_vs_tier = "high"   → observe / hold(裂变优秀,ROI 低可能暂时)
-  │   └─ fission_vs_tier = "unknown" → observe(数据不足不决策)
-  │
-  ├─ bid_up_candidate = True
-  │   └─ 综合权衡后 → bid_up / observe / hold(§五.3)
-  │
-  ├─ scale_up_candidate = True
-  │   └─ 综合权衡后 → scale_up / observe / hold(§五.4)
-  │
-  └─ 以上都不满足 → hold 或 observe
-```
+面对每条候选广告,按以下顺序思考,不要跳步:
+
+**第 1 步:ROI 在什么位置?**
+跟渠道P50 比,属于哪个区间:严重偏低(below_pause_line)/ 偏低(bid_down_zone)/ 正常 / 偏高(above_bid_up_line)?
 
-**关键理念**:
-- `roi_zone` 和 `fission_vs_tier` 是客观事实,不是操作建议
-- `roi_zone="bid_down_zone"` **不等于**"应该降价"——必须结合裂变率综合判断
-- 候选标记是规则层的"推荐",不是"命令"。你需要综合权衡后做最终判断
+**第 2 步:裂变表现如何?**
+`fission_vs_tier` 跟同类均值比是 high / normal / low / unknown?
+注意:fission 在本业务中指"用户裂变率"(viral coefficient),即用户帮你免费拉新人的能力。tier 指同 R 值人群包。
+
+**第 3 步:ROI 和裂变的组合说明什么?**
+- 双低(ROI低 + 裂变低)= 真的不行,止损信号
+- ROI低但裂变好 = 有后劲,用户在帮你免费拉人,ROI 可能回升
+- ROI低但裂变数据缺失 = 不确定,先观察
+- ROI正常/高 + 有提价/扩量候选 = 考虑放量
+
+**第 4 步:有没有干扰因素?**
+- 7天内调过价或换过创意?→ 效果还没显现,先等
+- 数据够不够?(ROI有效天数、稳定消耗天数)→ 不够就先观察
+- 是 tier 里最后几条广告?→ 关了整个人群就没量了
+- ROI 是突降还是持续低?→ 突降可能是数据异常
+
+**第 5 步:综合判断 → 选 action + 写理由**
+结合以上 4 步的结论,选择最合适的 action(§五详解),写出包含 5 元组的 reason(§七规范)。
 
 ---
 
@@ -146,28 +132,27 @@ bid_up_candidate=True 也不等于必须 bid_up —— 可能 CTR 其实在下
 
 **触发前提**:`roi_low=True`(规则已确认 ROI 严重偏低、消耗达标、年龄达标)
 
-**你需要综合权衡要点**:
+**综合权衡要点**:
 
 1. **裂变 vs 同类**(🔒 reason 硬要求):
-   - 必须在 reason 中包含「裂变率 X.XX vs 同类均值 Y.YY(偏离 Z%)」
-   - 裂变率 < 同类均值 50% + ROI 低 = 双低 → pause 强信号
-   - 若 `ad_fission` 或 `tier_fission_mean` 缺失,显式写"裂变数据缺失",不得省略
+   - 必须在 reason 中写出裂变率数值 vs 同类均值及偏离%——因为裂变好=用户在免费帮你拉人,关掉就断了自传播链
+   - 双低(ROI低 + 裂变低于同类50%+)→ pause 强信号
+   - 裂变数据缺失时显式写"裂变数据缺失",不得省略
 
 2. **调整历史**:
-   - 7 天内已降价 / 换创意 → 倾向 observe(等待调整效果,避免连续试错)
-   - 连续多次调整但无明显改善 → 倾向 pause(证明无法通过微调优化)
+   - 7天内已降价/换创意 → 倾向 observe——调整效果要5-7天才显现,连续操作是盲目试错
+   - 连续多次调整但无改善 → 倾向 pause——证明问题不在出价/素材
 
 3. **数据质量**:
-   - ROI 有效天数 < 5 → 倾向 observe(置信度低,可能是噪声)
-   - 30 日稳定天数 < 7 → 倾向 observe(消耗波动大,数据不可靠)
+   - ROI有效天数 < 5 → 倾向 observe——样本太少,ROI可能只是噪声
+   - 30日稳定天数 < 7 → 倾向 observe——消耗波动大时ROI不可靠
 
 4. **tier 组合位置**:
-   - 该 tier 广告数 ≤ 3 且本广告消耗占比较大 → 谨慎 pause(避免整个 tier 失速)
-   - 可在 reason 中建议"需配合新广告创建"
+   - 该tier广告数 ≤ 3 且消耗占比大 → 谨慎pause——关了这条,整个人群就没流量入口了
 
 5. **异常识别**:
-   - CTR 正常但 ROI 低 → 可能是后端转化问题,标注在 reason 中
-   - ROI 突降(与近期均值相差较大)→ 可能是数据异常,建议 observe
+   - CTR正常但ROI低 → 可能是后端转化问题,标注"疑似后端问题"
+   - ROI突降(与近期均值差距大)→ 可能是数据异常,先observe
 
 **pct 要求**:= 0(pause 不改出价)
 
@@ -179,22 +164,17 @@ bid_up_candidate=True 也不等于必须 bid_up —— 可能 CTR 其实在下
 
 **触发前提**:`roi_zone="bid_down_zone"`(ROI 在降价区间)+ `fission_vs_tier="low"`(裂变低于同类)
 
-> ⚠️ **核心原则:bid_down 需要"ROI 低 + 裂变低"双重确认。单凭 ROI 低不足以降价。**
-
 **条件(全部满足才能 bid_down)**:
-- ✅ 年龄 > 7 天(成熟期)
-- ✅ 7 日均消耗 ≥ 500 元
+- ✅ 年龄 > 7 天(成熟期)——未成熟的广告降价会打断 oCPM 学习
+- ✅ 7 日均消耗 ≥ 500 元——低于此值数据量太小,ROI 信号可能是噪声
 - ✅ roi_zone = "bid_down_zone"(关停线 ≤ 动态ROI < 降价线)
-- ✅ **fission_vs_tier = "low"**(裂变率低于同类均值 10%+)—— 核心条件
-
-**⚠️ 即使 ROI 在降价区间,以下场景禁止降价**:
-- ❌ fission_vs_tier = "high"(裂变优秀)→ 改 observe 或 hold
-- ❌ fission_vs_tier = "normal"(裂变正常)→ 改 observe
-- ❌ fission_vs_tier = "unknown"(数据缺失)→ 改 observe
-- ❌ 近 7 天已降过价 → 改 observe(等效果显现)
-- ❌ 近 7 天换过创意 → 改 observe(等数据稳定)
+- ✅ fission_vs_tier = "low"(裂变率低于同类)——**核心条件,双低确认的"第二低"**
 
-**业务逻辑**:裂变率高 = 用户自传播能力强 = 长期 ROI 潜力大。降价会减少曝光、降低 oCPM 学习效率、浪费优质广告潜力。
+**即使 ROI 在降价区间,以下场景不降价**:
+- 裂变好或正常(fission_vs_tier = high/normal)→ observe——裂变好=用户在帮你免费拉人,降价会断裂传播链,长期损失更大
+- 裂变数据缺失(fission_vs_tier = unknown)→ observe——数据不足不做不可逆决策
+- 近 7 天已降过价 → observe——降价效果要 5-7 天显现,连续降是盲目操作
+- 近 7 天换过创意 → observe——新素材数据还没稳定
 
 **pct 要求**:负数,绝对值在 [3%, 5%]
 
@@ -314,31 +294,24 @@ bid_up_candidate=True 也不等于必须 bid_up —— 可能 CTR 其实在下
 | 高消耗+高ROI 突然变低 | 之前表现优秀突然恶化 | 竞争加剧/人群饱和 | observe,不急于 pause |
 | 消耗极低但 ROI 好 | 跑不起量 | 素材吸引力弱 / 出价过低 | creative_adjust 或 bid_up |
 
-### 4. 不确定时的默认策略
+### 4. 优先级原则
 
-当多个信号冲突、判断困难时
+两条规则,适用场景不同
 
+**信号不确定时 → 保守优先**(不确定就少动):
 ```
-保守优先:observe > hold > bid_down > pause
+observe > hold > bid_down > pause
 ```
+适用于:数据不够、信号冲突、刚调过价。宁可多观察一天,不要误杀。
 
-- **不确定就选更保守的** —— observe/hold 优于 pause/bid_down
-- **能用 creative_adjust 解决的问题,不要用 pause** —— 保留 oCPM 学习资产
-- **降价和关停之间,优先关停** —— 干净的止损,不要用"大幅降价"代替
-
-### 5. action 选择优先级
-
-当多个 action 都"合理"时,按以下优先级取:
-
-**止损方向**:
+**信号明确时 → 果断执行**(确定了就干脆):
 ```
-pause(明确低效)> bid_down(有改善空间)> creative_adjust(素材问题)> observe(待稳定)> hold(无异常)
+止损:pause > bid_down > creative_adjust > observe > hold
+放量:bid_up > scale_up
 ```
+适用于:双低确认(ROI低+裂变低)、连续调整无效、数据充分。确定要止损就直接关停,不要用大幅降价代替。
 
-**放量方向**:
-```
-bid_up(冷启动期优质)> scale_up(成熟期优质)
-```
+**不冲突**:先判断信号是否明确,再选对应的优先级链。
 
 ### 6. ROI 与裂变信号冲突时的处理
 
@@ -378,14 +351,28 @@ bid_up(冷启动期优质)> scale_up(成熟期优质)
 ```
 
 **合格样本**:
-> "动态 ROI 为 1.62,低于渠道P50 2.50 的 35%;7 天内已提价但 ROI 仍低迷,广告已投放 9 天、7 日日均消耗 4438 元属于高消耗;综合判断调价无效,建议关停释放预算"
+
+> **pause 样本**:"动态 ROI 为 1.62,低于渠道P50 2.50 的 35%;裂变率 0.18 低于同类均值 0.46 的 61%,双低确认;7 天内已提价但 ROI 仍低迷,广告已投放 9 天、7 日日均消耗 4438 元;综合判断调价无效,建议关停释放预算"
+>
+> (✅ ROI=1.62 / 渠道P50 2.50 / -35% / 裂变+已提价+年龄+消耗 / 关停释放预算)
+
+> **bid_down 样本**:"动态 ROI 为 2.08,低于渠道P50 2.50 的 17%,处于降价区间;裂变率 0.31 低于同类均值 0.46 的 33%,双低确认;投放 12 天,7 日日均消耗 826 元,近 7 天未调价;建议降价 3% 优化成本"
+>
+> (✅ ROI+基准+偏离+裂变双低+年龄消耗+降3%)
+
+> **observe 样本**:"动态 ROI 为 1.95,低于渠道P50 2.50 的 22%,处于降价区间;但裂变率 0.58 高于同类均值 0.46 的 26%,用户传播能力强,ROI 有回升潜力;投放 11 天,7 日日均消耗 1203 元;建议观察而非降价,避免断裂裂变链"
+>
+> (✅ ROI低但裂变好 → 覆写规则建议,给出充分理由)
+
+> **bid_up 样本**:"动态 ROI 为 3.41,高于渠道P50 2.50 的 36%;投放 5 天处于早期成长期,7 日日均消耗 312 元偏低;裂变率 0.52 高于同类均值 0.46 的 13%;建议提价 8% 增加曝光拿量"
 >
-> (✅ 元素齐全:ROI=1.62 / 对比渠道P50 2.50 / 偏离 -35% / 辅助信号=已提价+年龄+消耗 / 建议=关停释放预算)
+> (✅ ROI+基准+偏离+年龄消耗裂变+建议提价8%
 
 **不合格样本**:
-> ❌ "ROI 低于关停线,建议关停"(缺元素 1/2/3/4,只有行动)
-> ❌ "动态ROI=1.62 < pause_line(1.66), bid_increased_7d=true"(用英文变量名,违反硬约束)
-> ❌ "ROI 不好,建议降价"(缺数值、基准、偏离%、辅助信号)
+> ❌ "ROI 低于关停线,建议关停"(缺 ROI 数值、基准、偏离%、辅助信号,只有行动)
+> ❌ "动态ROI=1.62 < pause_line(1.66), bid_increased_7d=true"(用英文变量名,违反术语约定)
+> ❌ "ROI 不好,建议降价"(缺全部元素)
+> ❌ "ROI=2.18,消耗正常,保持当前出价"(模板化 hold,缺对比基准和偏离%,"消耗正常"不是有效辅助信号)
 
 ### 7.2 action 与 recommended_change_pct 的强绑定
 

+ 0 - 1
examples/auto_put_ad_mini/skills/platform_rules.md

@@ -77,7 +77,6 @@ description: 做任何决策前先过一遍本规则——腾讯广告平台的
 ## 5. 小程序场景特殊性
 
 - **优化目标优先 PAGE_VIEW**(页面浏览),比"点击"更接近真实转化路径
-- **投放 < 3 天的广告 ROI 必然被低估**:成本侧已跑完,收入侧还没开始回流
 
 **决策含义**:
 - 对短龄广告的低 ROI 要特别谨慎 —— 这是系统性低估,不是真实信号

+ 29 - 12
examples/auto_put_ad_mini/tools/ad_decision.py

@@ -274,7 +274,7 @@ def _detect_decay_signals(
 
 @tool(description="智能引擎:整理需要关注的广告数据,供LLM推理决策")
 async def get_ads_for_review(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     metrics_csv: str = "",
     end_date: str = "yesterday",
     roi_review_factor: float = 0.8,
@@ -317,6 +317,10 @@ async def get_ads_for_review(
             if dropped > 0:
                 logger.info(f"get_ads_for_review 入口过滤 {dropped} 条 SUSPEND/DELETED 广告")
 
+        # ===== 白名单说明 =====
+        # 白名单仅在执行阶段(execute_decisions)生效,用于限制实际API操作的账户范围。
+        # 分析阶段不做白名单过滤,确保所有账户的广告都被评估并出现在审批表中。
+
         # ===== 新增:读取人群包级别统计数据(同类对比基准)=====
         logger.info("读取人群包级别统计数据...")
         by_tier_stats = {}
@@ -341,8 +345,11 @@ async def get_ads_for_review(
             by_tier_stats = {}
             by_tier_goal = {}
 
-        # 计算广告年龄
-        df["ad_age_days"] = df["create_time"].apply(_calculate_ad_age_days)
+        # 广告年龄:优先使用 metrics CSV 中已有的 ad_age_days(基于 end_dt 计算,与 ROI 数据口径一致)
+        # ⚠️ 不再用 datetime.now() 重新计算,避免与 roi_calculator 的 end_dt 基准差 1 天
+        if "ad_age_days" not in df.columns or df["ad_age_days"].isna().all():
+            logger.warning("metrics CSV 缺少 ad_age_days 列,使用 datetime.now() 兜底计算")
+            df["ad_age_days"] = df["create_time"].apply(_calculate_ad_age_days)
 
         # 检测衰退信号
         raw_dir = _MINI_DIR / "outputs" / "raw"
@@ -367,8 +374,13 @@ async def get_ads_for_review(
         roi_p90 = float(roi_series.quantile(0.90)) if len(roi_series) > 0 else 0.0
 
         # 加载调整历史(用于"持续低ROI升级关停"判断)
-        from tools.guardrails import AdjustmentHistory
-        adjustment_history = AdjustmentHistory()
+        try:
+            from examples.auto_put_ad_mini.tools.guardrails import AdjustmentHistory
+            adjustment_history = AdjustmentHistory()
+        except ImportError:
+            from types import SimpleNamespace
+            adjustment_history = SimpleNamespace(was_recently_adjusted=lambda *a, **kw: False)
+            logger.warning("guardrails.AdjustmentHistory 导入失败,跳过调整历史检查")
 
         # 分类(业务语言)
         zero_spend_ads = []      # 零消耗待关停
@@ -799,8 +811,8 @@ async def get_ads_for_review(
 
 @tool(description="智能引擎:接收LLM的决策列表,合并零消耗/正常运行类自动决策,保存为结构化结果")
 async def apply_decisions(
-    ctx: ToolContext,
-    decisions: str,
+    ctx: ToolContext = None,
+    decisions: str = "",
     end_date: str = "yesterday",
     metrics_csv: str = "",
 ) -> ToolResult:
@@ -941,9 +953,14 @@ async def apply_decisions(
             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"])
+                # ⚠️ 与零消耗扫描保持一致:跳过 SUSPEND/DELETED 广告
+                # (含 cache enrichment 从 NORMAL 覆盖过来的历史状态)
+                ad_status = str(row.get("configured_status", "")).upper()
+                if ad_status in ("AD_STATUS_SUSPEND", "AD_STATUS_DELETED", "SUSPEND", "DELETED"):
+                    continue
                 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)
                     dynamic_roi_7d = row.get("动态ROI_7日均值")
@@ -1120,8 +1137,8 @@ async def apply_decisions(
 
 @tool(description="查询单个广告的当前指标和历史数据")
 async def query_ad_detail(
-    ctx: ToolContext,
-    ad_id: str,
+    ctx: ToolContext = None,
+    ad_id: str = "",
     metrics_csv: str = "",
 ) -> ToolResult:
     """
@@ -1247,8 +1264,8 @@ async def query_ad_detail(
 
 @tool(description="修改已有决策:修改指定广告的操作或调幅,也可新增决策")
 async def modify_decisions(
-    ctx: ToolContext,
-    modifications: str,
+    ctx: ToolContext = None,
+    modifications: str = "",
     decisions_csv: str = "",
     end_date: str = "yesterday",
 ) -> ToolResult:

+ 3 - 3
examples/auto_put_ad_mini/tools/creative_metrics.py

@@ -214,7 +214,7 @@ def _classify_attribution(creatives_for_ad: pd.DataFrame, cost_col: str, roi_col
 
 @tool(description="按 creative_id 聚合最近 N 天 ROI(供广告决策做创意归因检查)")
 async def calculate_creative_metrics(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     days: int = 7,
     end_date: str = "yesterday",
 ) -> ToolResult:
@@ -294,8 +294,8 @@ async def calculate_creative_metrics(
 
 @tool(description="对准备做负向决策(降价/暂停)的广告,做创意归因检查")
 async def get_creative_context(
-    ctx: ToolContext,
-    ad_id: str,
+    ctx: ToolContext = None,
+    ad_id: str = "",
     days: int = 7,
     end_date: str = "yesterday",
 ) -> ToolResult:

+ 70 - 2
examples/auto_put_ad_mini/tools/data_query.py

@@ -297,7 +297,7 @@ def _fetch_ad_status(bizdate: str) -> Optional[pd.DataFrame]:
 
 @tool(description="拉取 30 天创意级别数据(增量,已有 CSV 的日期跳过)")
 async def fetch_creative_data(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     days: int = 7,
     end_date: str = "yesterday"
 ) -> ToolResult:
@@ -463,6 +463,74 @@ def _merge_single_day(biz: str) -> Optional[pd.DataFrame]:
     df_merged = df_creative.merge(df_status, on="ad_id", how="left")
     logger.info("合并后总行数: %d", len(df_merged))
 
+    # ===== 历史状态补充:用缓存中的 DELETED/SUSPEND 覆盖当日缺失状态 =====
+    cache_path = _MINI_DIR / "outputs" / "ad_filter_cache.json"
+    if cache_path.exists() and "ad_status" in df_merged.columns:
+        import json
+        try:
+            with open(cache_path, encoding="utf-8") as _fc:
+                _cache = json.load(_fc)
+
+            skip_ids = _cache.get("skip_ad_ids", {})
+            deleted_ids = set(str(x) for x in skip_ids.get("deleted_history", []))
+            suspend_ids = set(str(x) for x in skip_ids.get("suspend", []))
+            decided_ids = set(str(x) for x in skip_ids.get("decided_pause", []))
+
+            ad_id_str = df_merged["ad_id"].astype(str)
+            # 当日状态为空(LEFT JOIN 未匹配)→ 用缓存填充
+            null_mask = df_merged["ad_status"].isna() | (df_merged["ad_status"] == "")
+            filled_null = 0
+            if null_mask.any():
+                for ids, status in [
+                    (deleted_ids, "AD_STATUS_DELETED"),
+                    (suspend_ids, "AD_STATUS_SUSPEND"),
+                ]:
+                    match = null_mask & ad_id_str.isin(ids)
+                    if match.any():
+                        df_merged.loc[match, "ad_status"] = status
+                        filled_null += match.sum()
+
+            # 当日状态为 NORMAL 但历史为 DELETED → 标记为 DELETED
+            normal_mask = df_merged["ad_status"] == "AD_STATUS_NORMAL"
+            overridden = 0
+            if normal_mask.any():
+                match_deleted = normal_mask & ad_id_str.isin(deleted_ids)
+                if match_deleted.any():
+                    df_merged.loc[match_deleted, "ad_status"] = "AD_STATUS_DELETED"
+                    overridden += match_deleted.sum()
+
+                # 已决策pause的零消耗广告 → 标记为 SUSPEND(视同已暂停)
+                match_decided = normal_mask & ad_id_str.isin(decided_ids)
+                if match_decided.any():
+                    df_merged.loc[match_decided, "ad_status"] = "AD_STATUS_SUSPEND"
+                    overridden += match_decided.sum()
+
+            if filled_null > 0 or overridden > 0:
+                logger.info(
+                    "历史状态补充:填充空状态 %d 条,覆盖 NORMAL→DELETED/SUSPEND %d 条",
+                    filled_null, overridden,
+                )
+        except Exception as e:
+            logger.warning("加载历史状态缓存失败(跳过): %s", e)
+
+    # ===== 前置过滤:在数据源头直接移除 DELETED/SUSPEND,不干扰后续流程 =====
+    if "ad_status" in df_merged.columns:
+        excluded_mask = df_merged["ad_status"].isin(
+            {"AD_STATUS_DELETED", "AD_STATUS_SUSPEND", "DELETED", "SUSPEND"}
+        )
+        n_excluded = excluded_mask.sum()
+        if n_excluded > 0:
+            # 按状态分别统计
+            status_breakdown = (
+                df_merged.loc[excluded_mask, "ad_status"]
+                .value_counts().to_dict()
+            )
+            df_merged = df_merged[~excluded_mask].copy()
+            logger.info(
+                "前置过滤:移除 %d 条 DELETED/SUSPEND 广告(%s),剩余 %d 条 NORMAL",
+                n_excluded, status_breakdown, len(df_merged),
+            )
+
     # 按指定列顺序输出(只保留存在的列,保持顺序)
     final_cols = [c for c in _MERGED_COLUMNS if c in df_merged.columns]
     df_merged = df_merged[final_cols]
@@ -478,7 +546,7 @@ def _merge_single_day(biz: str) -> Optional[pd.DataFrame]:
 
 @tool(description="合并创意数据与广告状态(批量)")
 async def merge_creative_data(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     days: int = 7,
     force: bool = False,
 ) -> ToolResult:

+ 23 - 2
examples/auto_put_ad_mini/tools/execution_engine.py

@@ -232,7 +232,7 @@ def _classify_tier(row: pd.Series) -> int:
 
 @tool(description="执行已验证的决策:分级自治 → API调用 → 审计日志")
 async def execute_decisions(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     validated_csv: str = "",
     approval_mode: str = "tiered",
 ) -> ToolResult:
@@ -286,6 +286,27 @@ async def execute_decisions(
                 output="所有决策要么是 hold,要么被护栏拦截",
             )
 
+        # ===== 白名单过滤(仅在执行阶段生效)=====
+        # 分析阶段覆盖所有账户,执行阶段仅操作白名单账户
+        from config import WHITELIST_ENABLED, WHITELIST_ACCOUNTS
+        if WHITELIST_ENABLED:
+            if "account_id" in df_exec.columns:
+                non_whitelist = df_exec[~df_exec["account_id"].isin(WHITELIST_ACCOUNTS)]
+                if not non_whitelist.empty:
+                    logger.info(
+                        f"白名单过滤:跳过 {len(non_whitelist)} 个非白名单账户的决策,"
+                        f"仅执行白名单账户。跳过账户: {non_whitelist['account_id'].unique().tolist()[:10]}"
+                    )
+                    df_exec = df_exec[df_exec["account_id"].isin(WHITELIST_ACCOUNTS)].copy()
+                    if df_exec.empty:
+                        return ToolResult(
+                            title="白名单过滤后无可执行决策",
+                            output=f"所有决策均属于非白名单账户,无需执行API操作。"
+                                   f"白名单账户: {WHITELIST_ACCOUNTS[:5]}"
+                        )
+            else:
+                logger.warning("白名单检查:数据中缺少 account_id 列,跳过检查")
+
         # 分级
         df_exec["tier"] = df_exec.apply(_classify_tier, axis=1)
 
@@ -587,7 +608,7 @@ async def execute_decisions(
 
 @tool(description="执行后效果检查:对比操作前后广告表现")
 async def check_execution_feedback(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     execution_log_path: str = "",
     hours_after: int = FEEDBACK_CHECK_HOURS,
 ) -> ToolResult:

+ 1 - 1
examples/auto_put_ad_mini/tools/feishu_doc.py

@@ -266,7 +266,7 @@ def _send_link_message(chat_id: str, url: str, title: str, preamble: str = ""):
 
 @tool(description="将本地 xlsx 报告导入为飞书在线表格,设置任何人获得链接可查看权限,并通过 IM 发送链接到运营群")
 async def import_to_feishu(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     xlsx_path: str = "",
     send_im: bool = True,
     chat_id: str = "",

+ 8 - 2
examples/auto_put_ad_mini/tools/guardrails.py

@@ -22,6 +22,7 @@ from dataclasses import dataclass, field
 from datetime import datetime, timedelta
 from pathlib import Path
 from typing import Dict, List, Optional
+from zoneinfo import ZoneInfo
 
 import pandas as pd
 
@@ -55,6 +56,7 @@ from config import (
     DRY_RUN_MODE,
     GUARDRAILS_ENABLED,
     DATA_DIR,
+    TIMEZONE,
 )
 
 logger = logging.getLogger(__name__)
@@ -369,7 +371,11 @@ class DataFreshnessGuardrail(Guardrail):
         if data_date:
             try:
                 data_dt = datetime.strptime(str(data_date), "%Y%m%d")
-                hours_old = (datetime.now() - data_dt).total_seconds() / 3600
+                # 使用时区感知的 datetime(支持海外部署)
+                now = datetime.now(ZoneInfo(TIMEZONE))
+                # data_dt 需要本地化到相同时区
+                data_dt_aware = data_dt.replace(tzinfo=ZoneInfo(TIMEZONE))
+                hours_old = (now - data_dt_aware).total_seconds() / 3600
                 if hours_old > DATA_FRESHNESS_MAX_HOURS:
                     return GuardrailResult(
                         status="blocked",
@@ -715,7 +721,7 @@ def _run_guardrails(
 
 @tool(description="验证决策安全性:冷启动保护、出价边界、频率限制、数据新鲜度")
 async def validate_decisions(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     decisions_csv: str = "",
     end_date: str = "yesterday",
     dry_run: bool = False,

+ 5 - 5
examples/auto_put_ad_mini/tools/im_approval.py

@@ -392,7 +392,7 @@ def _parse_approval_reply(content: str, all_ad_ids: List[int]) -> Dict:
 
 @tool(description="通过飞书发送决策摘要给运营,收集审批结果(支持阻塞等待)")
 async def send_approval_request(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     validated_csv: str = "",
     timeout_minutes: int = IM_APPROVAL_TIMEOUT_MINUTES,
     wait_for_reply: bool = True,
@@ -869,8 +869,8 @@ async def send_approval_request(
 
 @tool(description="检查运营飞书审批结果")
 async def check_approval_status(
-    ctx: ToolContext,
-    request_id: str,
+    ctx: ToolContext = None,
+    request_id: str = "",
 ) -> ToolResult:
     """
     检查审批请求的状态。通过飞书 API 读取最新消息。
@@ -987,8 +987,8 @@ async def check_approval_status(
 
 @tool(description="向运营飞书发送纯文本消息(用于执行后汇报 diff、确认、质疑回应等非审批场景)")
 async def send_feishu_text_message(
-    ctx: ToolContext,
-    text: str,
+    ctx: ToolContext = None,
+    text: str = "",
     to_operator: bool = True,
     to_project_chat: bool = True,
 ) -> ToolResult:

+ 1 - 1
examples/auto_put_ad_mini/tools/portfolio_metrics.py

@@ -350,7 +350,7 @@ def _compute_market_signal(by_date: Dict[str, Dict[str, Any]]) -> Dict[str, Any]
 
 @tool(description="账户级 + 人群包级 ROI 基线汇总(p25/p50/p75 + 最近 7 天日级基线快照 + 大盘行情判定),供 LLM 对标用")
 async def calculate_portfolio_summary(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     metrics_csv: str = "",
     end_date: str = "yesterday",
 ) -> ToolResult:

+ 2 - 2
examples/auto_put_ad_mini/tools/posterior_collector.py

@@ -215,8 +215,8 @@ def _lookup_posterior(
 
 @tool(description="V4 后验数据采集(决策 → 执行后效果配对)— 本轮预留接口,主流程不调用")
 async def collect_posterior_data(
-    ctx: ToolContext,
-    decision_date: str,
+    ctx: ToolContext = None,
+    decision_date: str = "",
     posterior_days: int = 7,
     update_snapshot: bool = True,
 ) -> ToolResult:

+ 1 - 1
examples/auto_put_ad_mini/tools/report_generator.py

@@ -180,7 +180,7 @@ def _write_xlsx_with_format(df: pd.DataFrame, path: Path) -> None:
 
 @tool(description="生成决策报告(CSV + XLSX 带条件格式)")
 async def generate_report(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     decision_csv: str = "",
     end_date: str = "yesterday",
 ) -> ToolResult:

+ 1 - 1
examples/auto_put_ad_mini/tools/roi_calculator.py

@@ -375,7 +375,7 @@ def _calculate_30d_summary(ad_df: pd.DataFrame) -> pd.DataFrame:
 
 @tool(description="计算 动态ROI + 昨日 ROI + 7日/30日汇总指标")
 async def calculate_roi_metrics(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     end_date: str = "yesterday",
     min_daily_cost: float = 100.0
 ) -> ToolResult: