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

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
 # QWEN_API_KEY=xxx
 # OPEN_ROUTER_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 os
+import logging
 from pathlib import Path
 from pathlib import Path
 from agent.core.runner import RunConfig, KnowledgeConfig
 from agent.core.runner import RunConfig, KnowledgeConfig
 
 
+# 初始化 logger(必须在使用前定义)
+logger = logging.getLogger(__name__)
+
 # 加载 .env 文件(如果存在)
 # 加载 .env 文件(如果存在)
 try:
 try:
     from dotenv import load_dotenv
     from dotenv import load_dotenv
@@ -70,6 +74,12 @@ TRACE_STORE_PATH = ".trace"
 LOG_LEVEL = "INFO"
 LOG_LEVEL = "INFO"
 LOG_FILE = None
 LOG_FILE = None
 
 
+# ═══════════════════════════════════════════
+# 时区配置(海外部署)
+# ═══════════════════════════════════════════
+TIMEZONE = os.getenv("TZ", "UTC")
+logger.info(f"运行时区:{TIMEZONE}")
+
 # ═══════════════════════════════════════════
 # ═══════════════════════════════════════════
 # V3 数据窗口配置
 # 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"))
 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
 import sys
 from pathlib import Path
 from pathlib import Path
 from datetime import datetime, timedelta
 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 路径
 # 添加项目根目录到 Python 路径
 sys.path.insert(0, str(Path(__file__).parent.parent.parent))
 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
 from dotenv import load_dotenv
 load_dotenv()
 load_dotenv()
 
 
@@ -83,9 +87,9 @@ async def main():
     print("=" * 70)
     print("=" * 70)
     print()
     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})作为数据截止日期,因为当天数据尚未回流。"}]
     messages = [{"role": "user", "content": f"分析广告,执行完整的ROI计算和决策流程。请使用 {target_date_display}(end_date={target_date})作为数据截止日期,因为当天数据尚未回流。"}]
     config.trace_id = None
     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 的反例警示)
 - reason 不得模板化(错例:"ROI 低于线建议降价";正例见 posterior-wisdom skill 的反例警示)
 - 冷启动期(4-7 天)系统会过滤 bid_down/pause,你也不该主动给这两种建议
 - 冷启动期(4-7 天)系统会过滤 bid_down/pause,你也不该主动给这两种建议
 - 🚨 **禁止在 apply_decisions 之前输出 markdown 分析表格、tier 拆分汇总、决策汇总表**。看完候选数据后**直接调用 apply_decisions** 提交决策 JSON 数组。所有判断理由都写进每条决策的 `reason` 字段里,**不要**在工具调用前面铺一段中间报告(会触发 max_tokens 截断导致 tool_call 失败)。
 - 🚨 **禁止在 apply_decisions 之前输出 markdown 分析表格、tier 拆分汇总、决策汇总表**。看完候选数据后**直接调用 apply_decisions** 提交决策 JSON 数组。所有判断理由都写进每条决策的 `reason` 字段里,**不要**在工具调用前面铺一段中间报告(会触发 max_tokens 截断导致 tool_call 失败)。
+- 🚨 **任何输出/汇总/飞书消息中,禁止提及 hold(保持)、observe(观察)、scale_up(扩量)的数量**。这三类不进审批表,用户不需要看到。只报告需审批的决策:pause(关停)、bid_down(降价)、bid_up(提价)。
 
 
 # 第六部分:投放经验知识库(Skills)
 # 第六部分:投放经验知识库(Skills)
 
 
@@ -177,7 +178,7 @@ Skill 提供「判断原则」,工具提供「数据」,你负责综合判
 
 
 ```
 ```
 您好,按您说的"我只要降价的",我这边已经执行了 14 条降价(平均 -4.2%)。
 您好,按您说的"我只要降价的",我这边已经执行了 14 条降价(平均 -4.2%)。
-剩下的 600 条暂停和 24 条扩量,我先不动了,等您在飞书表格逐条勾选。
+剩下的暂停决策我先不动了,等您在飞书表格逐条勾选。
 
 
 一个提醒:14 条里有 2 条([93479729712]、[93314795441])7日均消耗超 2000 元,
 一个提醒:14 条里有 2 条([93479729712]、[93314795441])7日均消耗超 2000 元,
 24 小时内不见改善我建议直接 pause,到时再发您确认。
 24 小时内不见改善我建议直接 pause,到时再发您确认。

+ 8 - 4
examples/auto_put_ad_mini/run.py

@@ -10,10 +10,7 @@ import asyncio
 import os
 import os
 import sys
 import sys
 from pathlib import Path
 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 路径
 # 添加项目根目录到 Python 路径
 sys.path.insert(0, str(Path(__file__).parent.parent.parent))
 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
 from dotenv import load_dotenv
 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.core.runner import AgentRunner
 from agent.trace import FileSystemTraceStore, Trace, Message
 from agent.trace import FileSystemTraceStore, Trace, Message
 from agent.llm import create_openrouter_llm_call
 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` |
 | `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 达标 |
 | `bid_up_candidate=True` | 有提价潜力 | ROI 明显优于渠道 + 年龄在提价窗口 + 消耗未过高 + CTR 达标 |
 | `scale_up_candidate=True` | 值得扩量 | 成熟稳定 + 高消耗 + ROI 达标 |
 | `scale_up_candidate=True` | 值得扩量 | 成熟稳定 + 高消耗 + ROI 达标 |
 | `decay_signal=True` | 有衰退迹象 | 消耗趋势下降或 ROI 持续走低 |
 | `decay_signal=True` | 有衰退迹象 | 消耗趋势下降或 ROI 持续走低 |
 
 
-**⚠️ 关键变化**:`roi_zone="bid_down_zone"` 不等于"应该降价"——必须结合 `fission_vs_tier` 综合判断(见§四决策映射)
+**⚠️ 核心原则**:候选标记是客观事实,不是操作指令。`roi_zone="bid_down_zone"` 不等于"应该降价"——必须结合裂变率(`fission_vs_tier`)综合判断,见§四思考步骤
 
 
 ### 阈值参考(仅用于理解规则逻辑,不要在 reason 中引用具体数值)
 ### 阈值参考(仅用于理解规则逻辑,不要在 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 严重偏低、消耗达标、年龄达标)
 **触发前提**:`roi_low=True`(规则已确认 ROI 严重偏低、消耗达标、年龄达标)
 
 
-**你需要综合权衡要点**:
+**综合权衡要点**:
 
 
 1. **裂变 vs 同类**(🔒 reason 硬要求):
 1. **裂变 vs 同类**(🔒 reason 硬要求):
-   - 必须在 reason 中包含「裂变率 X.XX vs 同类均值 Y.YY(偏离 Z%)」
-   - 裂变率 < 同类均值 50% + ROI 低 = 双低 → pause 强信号
-   - 若 `ad_fission` 或 `tier_fission_mean` 缺失,显式写"裂变数据缺失",不得省略
+   - 必须在 reason 中写出裂变率数值 vs 同类均值及偏离%——因为裂变好=用户在免费帮你拉人,关掉就断了自传播链
+   - 双低(ROI低 + 裂变低于同类50%+)→ pause 强信号
+   - 裂变数据缺失时显式写"裂变数据缺失",不得省略
 
 
 2. **调整历史**:
 2. **调整历史**:
-   - 7 天内已降价 / 换创意 → 倾向 observe(等待调整效果,避免连续试错)
-   - 连续多次调整但无明显改善 → 倾向 pause(证明无法通过微调优化)
+   - 7天内已降价/换创意 → 倾向 observe——调整效果要5-7天才显现,连续操作是盲目试错
+   - 连续多次调整但无改善 → 倾向 pause——证明问题不在出价/素材
 
 
 3. **数据质量**:
 3. **数据质量**:
-   - ROI 有效天数 < 5 → 倾向 observe(置信度低,可能是噪声)
-   - 30 日稳定天数 < 7 → 倾向 observe(消耗波动大,数据不可靠)
+   - ROI有效天数 < 5 → 倾向 observe——样本太少,ROI可能只是噪声
+   - 30日稳定天数 < 7 → 倾向 observe——消耗波动大时ROI不可靠
 
 
 4. **tier 组合位置**:
 4. **tier 组合位置**:
-   - 该 tier 广告数 ≤ 3 且本广告消耗占比较大 → 谨慎 pause(避免整个 tier 失速)
-   - 可在 reason 中建议"需配合新广告创建"
+   - 该tier广告数 ≤ 3 且消耗占比大 → 谨慎pause——关了这条,整个人群就没流量入口了
 
 
 5. **异常识别**:
 5. **异常识别**:
-   - CTR 正常但 ROI 低 → 可能是后端转化问题,标注在 reason 中
-   - ROI 突降(与近期均值相差较大)→ 可能是数据异常,建议 observe
+   - CTR正常但ROI低 → 可能是后端转化问题,标注"疑似后端问题"
+   - ROI突降(与近期均值差距大)→ 可能是数据异常,先observe
 
 
 **pct 要求**:= 0(pause 不改出价)
 **pct 要求**:= 0(pause 不改出价)
 
 
@@ -179,22 +164,17 @@ bid_up_candidate=True 也不等于必须 bid_up —— 可能 CTR 其实在下
 
 
 **触发前提**:`roi_zone="bid_down_zone"`(ROI 在降价区间)+ `fission_vs_tier="low"`(裂变低于同类)
 **触发前提**:`roi_zone="bid_down_zone"`(ROI 在降价区间)+ `fission_vs_tier="low"`(裂变低于同类)
 
 
-> ⚠️ **核心原则:bid_down 需要"ROI 低 + 裂变低"双重确认。单凭 ROI 低不足以降价。**
-
 **条件(全部满足才能 bid_down)**:
 **条件(全部满足才能 bid_down)**:
-- ✅ 年龄 > 7 天(成熟期)
-- ✅ 7 日均消耗 ≥ 500 元
+- ✅ 年龄 > 7 天(成熟期)——未成熟的广告降价会打断 oCPM 学习
+- ✅ 7 日均消耗 ≥ 500 元——低于此值数据量太小,ROI 信号可能是噪声
 - ✅ roi_zone = "bid_down_zone"(关停线 ≤ 动态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%]
 **pct 要求**:负数,绝对值在 [3%, 5%]
 
 
@@ -314,31 +294,24 @@ bid_up_candidate=True 也不等于必须 bid_up —— 可能 CTR 其实在下
 | 高消耗+高ROI 突然变低 | 之前表现优秀突然恶化 | 竞争加剧/人群饱和 | observe,不急于 pause |
 | 高消耗+高ROI 突然变低 | 之前表现优秀突然恶化 | 竞争加剧/人群饱和 | observe,不急于 pause |
 | 消耗极低但 ROI 好 | 跑不起量 | 素材吸引力弱 / 出价过低 | creative_adjust 或 bid_up |
 | 消耗极低但 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 与裂变信号冲突时的处理
 ### 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 的强绑定
 ### 7.2 action 与 recommended_change_pct 的强绑定
 
 

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

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

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

@@ -274,7 +274,7 @@ def _detect_decay_signals(
 
 
 @tool(description="智能引擎:整理需要关注的广告数据,供LLM推理决策")
 @tool(description="智能引擎:整理需要关注的广告数据,供LLM推理决策")
 async def get_ads_for_review(
 async def get_ads_for_review(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     metrics_csv: str = "",
     metrics_csv: str = "",
     end_date: str = "yesterday",
     end_date: str = "yesterday",
     roi_review_factor: float = 0.8,
     roi_review_factor: float = 0.8,
@@ -317,6 +317,10 @@ async def get_ads_for_review(
             if dropped > 0:
             if dropped > 0:
                 logger.info(f"get_ads_for_review 入口过滤 {dropped} 条 SUSPEND/DELETED 广告")
                 logger.info(f"get_ads_for_review 入口过滤 {dropped} 条 SUSPEND/DELETED 广告")
 
 
+        # ===== 白名单说明 =====
+        # 白名单仅在执行阶段(execute_decisions)生效,用于限制实际API操作的账户范围。
+        # 分析阶段不做白名单过滤,确保所有账户的广告都被评估并出现在审批表中。
+
         # ===== 新增:读取人群包级别统计数据(同类对比基准)=====
         # ===== 新增:读取人群包级别统计数据(同类对比基准)=====
         logger.info("读取人群包级别统计数据...")
         logger.info("读取人群包级别统计数据...")
         by_tier_stats = {}
         by_tier_stats = {}
@@ -341,8 +345,11 @@ async def get_ads_for_review(
             by_tier_stats = {}
             by_tier_stats = {}
             by_tier_goal = {}
             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"
         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_p90 = float(roi_series.quantile(0.90)) if len(roi_series) > 0 else 0.0
 
 
         # 加载调整历史(用于"持续低ROI升级关停"判断)
         # 加载调整历史(用于"持续低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 = []      # 零消耗待关停
         zero_spend_ads = []      # 零消耗待关停
@@ -799,8 +811,8 @@ async def get_ads_for_review(
 
 
 @tool(description="智能引擎:接收LLM的决策列表,合并零消耗/正常运行类自动决策,保存为结构化结果")
 @tool(description="智能引擎:接收LLM的决策列表,合并零消耗/正常运行类自动决策,保存为结构化结果")
 async def apply_decisions(
 async def apply_decisions(
-    ctx: ToolContext,
-    decisions: str,
+    ctx: ToolContext = None,
+    decisions: str = "",
     end_date: str = "yesterday",
     end_date: str = "yesterday",
     metrics_csv: str = "",
     metrics_csv: str = "",
 ) -> ToolResult:
 ) -> ToolResult:
@@ -941,9 +953,14 @@ async def apply_decisions(
             zero_spend_ad_ids = {row["ad_id"] for row in zero_spend_rows}
             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}
             need_review_ad_ids = {item["ad_id"] for item in llm_list}
 
 
-            # 正常运行 = 所有广告 - 零消耗 - 待评估
+            # 正常运行 = 所有广告 - 零消耗 - 待评估 - 已关停/已删除
             for _, row in df_metrics.iterrows():
             for _, row in df_metrics.iterrows():
                 ad_id = int(row["ad_id"])
                 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:
                 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)
                     cost_7d_avg = float(row.get("cost_7d_avg", 0) or 0)
                     dynamic_roi_7d = row.get("动态ROI_7日均值")
                     dynamic_roi_7d = row.get("动态ROI_7日均值")
@@ -1120,8 +1137,8 @@ async def apply_decisions(
 
 
 @tool(description="查询单个广告的当前指标和历史数据")
 @tool(description="查询单个广告的当前指标和历史数据")
 async def query_ad_detail(
 async def query_ad_detail(
-    ctx: ToolContext,
-    ad_id: str,
+    ctx: ToolContext = None,
+    ad_id: str = "",
     metrics_csv: str = "",
     metrics_csv: str = "",
 ) -> ToolResult:
 ) -> ToolResult:
     """
     """
@@ -1247,8 +1264,8 @@ async def query_ad_detail(
 
 
 @tool(description="修改已有决策:修改指定广告的操作或调幅,也可新增决策")
 @tool(description="修改已有决策:修改指定广告的操作或调幅,也可新增决策")
 async def modify_decisions(
 async def modify_decisions(
-    ctx: ToolContext,
-    modifications: str,
+    ctx: ToolContext = None,
+    modifications: str = "",
     decisions_csv: str = "",
     decisions_csv: str = "",
     end_date: str = "yesterday",
     end_date: str = "yesterday",
 ) -> ToolResult:
 ) -> 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(供广告决策做创意归因检查)")
 @tool(description="按 creative_id 聚合最近 N 天 ROI(供广告决策做创意归因检查)")
 async def calculate_creative_metrics(
 async def calculate_creative_metrics(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     days: int = 7,
     days: int = 7,
     end_date: str = "yesterday",
     end_date: str = "yesterday",
 ) -> ToolResult:
 ) -> ToolResult:
@@ -294,8 +294,8 @@ async def calculate_creative_metrics(
 
 
 @tool(description="对准备做负向决策(降价/暂停)的广告,做创意归因检查")
 @tool(description="对准备做负向决策(降价/暂停)的广告,做创意归因检查")
 async def get_creative_context(
 async def get_creative_context(
-    ctx: ToolContext,
-    ad_id: str,
+    ctx: ToolContext = None,
+    ad_id: str = "",
     days: int = 7,
     days: int = 7,
     end_date: str = "yesterday",
     end_date: str = "yesterday",
 ) -> ToolResult:
 ) -> 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 的日期跳过)")
 @tool(description="拉取 30 天创意级别数据(增量,已有 CSV 的日期跳过)")
 async def fetch_creative_data(
 async def fetch_creative_data(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     days: int = 7,
     days: int = 7,
     end_date: str = "yesterday"
     end_date: str = "yesterday"
 ) -> ToolResult:
 ) -> 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")
     df_merged = df_creative.merge(df_status, on="ad_id", how="left")
     logger.info("合并后总行数: %d", len(df_merged))
     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]
     final_cols = [c for c in _MERGED_COLUMNS if c in df_merged.columns]
     df_merged = df_merged[final_cols]
     df_merged = df_merged[final_cols]
@@ -478,7 +546,7 @@ def _merge_single_day(biz: str) -> Optional[pd.DataFrame]:
 
 
 @tool(description="合并创意数据与广告状态(批量)")
 @tool(description="合并创意数据与广告状态(批量)")
 async def merge_creative_data(
 async def merge_creative_data(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     days: int = 7,
     days: int = 7,
     force: bool = False,
     force: bool = False,
 ) -> ToolResult:
 ) -> 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调用 → 审计日志")
 @tool(description="执行已验证的决策:分级自治 → API调用 → 审计日志")
 async def execute_decisions(
 async def execute_decisions(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     validated_csv: str = "",
     validated_csv: str = "",
     approval_mode: str = "tiered",
     approval_mode: str = "tiered",
 ) -> ToolResult:
 ) -> ToolResult:
@@ -286,6 +286,27 @@ async def execute_decisions(
                 output="所有决策要么是 hold,要么被护栏拦截",
                 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)
         df_exec["tier"] = df_exec.apply(_classify_tier, axis=1)
 
 
@@ -587,7 +608,7 @@ async def execute_decisions(
 
 
 @tool(description="执行后效果检查:对比操作前后广告表现")
 @tool(description="执行后效果检查:对比操作前后广告表现")
 async def check_execution_feedback(
 async def check_execution_feedback(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     execution_log_path: str = "",
     execution_log_path: str = "",
     hours_after: int = FEEDBACK_CHECK_HOURS,
     hours_after: int = FEEDBACK_CHECK_HOURS,
 ) -> ToolResult:
 ) -> 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 发送链接到运营群")
 @tool(description="将本地 xlsx 报告导入为飞书在线表格,设置任何人获得链接可查看权限,并通过 IM 发送链接到运营群")
 async def import_to_feishu(
 async def import_to_feishu(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     xlsx_path: str = "",
     xlsx_path: str = "",
     send_im: bool = True,
     send_im: bool = True,
     chat_id: str = "",
     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 datetime import datetime, timedelta
 from pathlib import Path
 from pathlib import Path
 from typing import Dict, List, Optional
 from typing import Dict, List, Optional
+from zoneinfo import ZoneInfo
 
 
 import pandas as pd
 import pandas as pd
 
 
@@ -55,6 +56,7 @@ from config import (
     DRY_RUN_MODE,
     DRY_RUN_MODE,
     GUARDRAILS_ENABLED,
     GUARDRAILS_ENABLED,
     DATA_DIR,
     DATA_DIR,
+    TIMEZONE,
 )
 )
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -369,7 +371,11 @@ class DataFreshnessGuardrail(Guardrail):
         if data_date:
         if data_date:
             try:
             try:
                 data_dt = datetime.strptime(str(data_date), "%Y%m%d")
                 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:
                 if hours_old > DATA_FRESHNESS_MAX_HOURS:
                     return GuardrailResult(
                     return GuardrailResult(
                         status="blocked",
                         status="blocked",
@@ -715,7 +721,7 @@ def _run_guardrails(
 
 
 @tool(description="验证决策安全性:冷启动保护、出价边界、频率限制、数据新鲜度")
 @tool(description="验证决策安全性:冷启动保护、出价边界、频率限制、数据新鲜度")
 async def validate_decisions(
 async def validate_decisions(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     decisions_csv: str = "",
     decisions_csv: str = "",
     end_date: str = "yesterday",
     end_date: str = "yesterday",
     dry_run: bool = False,
     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="通过飞书发送决策摘要给运营,收集审批结果(支持阻塞等待)")
 @tool(description="通过飞书发送决策摘要给运营,收集审批结果(支持阻塞等待)")
 async def send_approval_request(
 async def send_approval_request(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     validated_csv: str = "",
     validated_csv: str = "",
     timeout_minutes: int = IM_APPROVAL_TIMEOUT_MINUTES,
     timeout_minutes: int = IM_APPROVAL_TIMEOUT_MINUTES,
     wait_for_reply: bool = True,
     wait_for_reply: bool = True,
@@ -869,8 +869,8 @@ async def send_approval_request(
 
 
 @tool(description="检查运营飞书审批结果")
 @tool(description="检查运营飞书审批结果")
 async def check_approval_status(
 async def check_approval_status(
-    ctx: ToolContext,
-    request_id: str,
+    ctx: ToolContext = None,
+    request_id: str = "",
 ) -> ToolResult:
 ) -> ToolResult:
     """
     """
     检查审批请求的状态。通过飞书 API 读取最新消息。
     检查审批请求的状态。通过飞书 API 读取最新消息。
@@ -987,8 +987,8 @@ async def check_approval_status(
 
 
 @tool(description="向运营飞书发送纯文本消息(用于执行后汇报 diff、确认、质疑回应等非审批场景)")
 @tool(description="向运营飞书发送纯文本消息(用于执行后汇报 diff、确认、质疑回应等非审批场景)")
 async def send_feishu_text_message(
 async def send_feishu_text_message(
-    ctx: ToolContext,
-    text: str,
+    ctx: ToolContext = None,
+    text: str = "",
     to_operator: bool = True,
     to_operator: bool = True,
     to_project_chat: bool = True,
     to_project_chat: bool = True,
 ) -> ToolResult:
 ) -> 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 对标用")
 @tool(description="账户级 + 人群包级 ROI 基线汇总(p25/p50/p75 + 最近 7 天日级基线快照 + 大盘行情判定),供 LLM 对标用")
 async def calculate_portfolio_summary(
 async def calculate_portfolio_summary(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     metrics_csv: str = "",
     metrics_csv: str = "",
     end_date: str = "yesterday",
     end_date: str = "yesterday",
 ) -> ToolResult:
 ) -> ToolResult:

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

@@ -215,8 +215,8 @@ def _lookup_posterior(
 
 
 @tool(description="V4 后验数据采集(决策 → 执行后效果配对)— 本轮预留接口,主流程不调用")
 @tool(description="V4 后验数据采集(决策 → 执行后效果配对)— 本轮预留接口,主流程不调用")
 async def collect_posterior_data(
 async def collect_posterior_data(
-    ctx: ToolContext,
-    decision_date: str,
+    ctx: ToolContext = None,
+    decision_date: str = "",
     posterior_days: int = 7,
     posterior_days: int = 7,
     update_snapshot: bool = True,
     update_snapshot: bool = True,
 ) -> ToolResult:
 ) -> 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 带条件格式)")
 @tool(description="生成决策报告(CSV + XLSX 带条件格式)")
 async def generate_report(
 async def generate_report(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     decision_csv: str = "",
     decision_csv: str = "",
     end_date: str = "yesterday",
     end_date: str = "yesterday",
 ) -> ToolResult:
 ) -> 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日汇总指标")
 @tool(description="计算 动态ROI + 昨日 ROI + 7日/30日汇总指标")
 async def calculate_roi_metrics(
 async def calculate_roi_metrics(
-    ctx: ToolContext,
+    ctx: ToolContext = None,
     end_date: str = "yesterday",
     end_date: str = "yesterday",
     min_daily_cost: float = 100.0
     min_daily_cost: float = 100.0
 ) -> ToolResult:
 ) -> ToolResult: