فهرست منبع

feat(auto_put_ad_mini): 年龄保护架构重构+新增scale_up决策

主要修改:
1. 年龄保护提升为第一优先级(硬约束层)
   - 冷启动期(≤3天):直接排除所有评估
   - 早期成长期(4-7天):清除不允许的候选标志(roi_low/decay_signal/bid_down_candidate)
   - 成熟期(>7天):正常评估

2. 新增 scale_up 决策(扩量建议)
   - 触发条件:>7天+稳定≥7天+消耗>1000元+ROI≥90%均值
   - 执行方式:tier=0(仅建议,不调用API)

3. 文案优化
   - 规则判断理由:"无异常信号" → "保持当前出价"
   - 年龄保护转换:"原建议" → "LLM建议...年龄保护规则..."

预期效果:
- 兜底检查触发次数从26次降至0次
- token消耗减少约40%(冷启动期广告不进入LLM)
- 决策逻辑更清晰(硬约束 → 业务逻辑 → 兜底检查)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
刘立冬 3 هفته پیش
والد
کامیت
dde291f941
41فایلهای تغییر یافته به همراه8595 افزوده شده و 22 حذف شده
  1. 242 0
      examples/auto_put_ad_mini/API_TEST_GUIDE.md
  2. 168 0
      examples/auto_put_ad_mini/APPROVAL_FLOW_UPDATE.md
  3. 1139 0
      examples/auto_put_ad_mini/ARCHITECTURE.md
  4. 266 0
      examples/auto_put_ad_mini/TEST_RESULT.md
  5. 37 0
      examples/auto_put_ad_mini/analyze_dimensions.py
  6. 494 0
      examples/auto_put_ad_mini/analyze_snapshot.py
  7. 305 0
      examples/auto_put_ad_mini/docs/strategy_review_2026-04-16.md
  8. 88 0
      examples/auto_put_ad_mini/get_chat_id.py
  9. 3 0
      examples/auto_put_ad_mini/prompts/system.prompt
  10. 87 0
      examples/auto_put_ad_mini/quick_analysis.py
  11. 69 0
      examples/auto_put_ad_mini/regenerate_metrics.py
  12. 212 0
      examples/auto_put_ad_mini/run_decision_test.py
  13. 136 0
      examples/auto_put_ad_mini/skills/ad_domain.md
  14. 58 2
      examples/auto_put_ad_mini/skills/roi_strategy.md
  15. 12 0
      examples/auto_put_ad_mini/strategy_params.json
  16. 108 0
      examples/auto_put_ad_mini/test_analysis_0415.py
  17. 379 0
      examples/auto_put_ad_mini/test_api_simple.py
  18. 107 0
      examples/auto_put_ad_mini/test_approval_flow_e2e.py
  19. 60 0
      examples/auto_put_ad_mini/test_chat_send.py
  20. 197 0
      examples/auto_put_ad_mini/test_compute_signal_scores.py
  21. 212 0
      examples/auto_put_ad_mini/test_e2e_full_flow.py
  22. 144 0
      examples/auto_put_ad_mini/test_feishu_approval.py
  23. 49 0
      examples/auto_put_ad_mini/test_feishu_import.py
  24. 103 0
      examples/auto_put_ad_mini/test_real_approval_flow.py
  25. 132 0
      examples/auto_put_ad_mini/test_send_with_sheet.py
  26. 164 0
      examples/auto_put_ad_mini/test_send_with_sheet_simple.py
  27. 309 0
      examples/auto_put_ad_mini/test_strategy_upgrade.py
  28. 248 0
      examples/auto_put_ad_mini/test_tencent_api.py
  29. 74 15
      examples/auto_put_ad_mini/tools/ad_decision.py
  30. 418 0
      examples/auto_put_ad_mini/tools/creative_metrics.py
  31. 4 4
      examples/auto_put_ad_mini/tools/execution_engine.py
  32. 2 1
      examples/auto_put_ad_mini/tools/im_approval.py
  33. 503 0
      examples/auto_put_ad_mini/tools/portfolio_metrics.py
  34. 372 0
      examples/auto_put_ad_mini/tools/posterior_collector.py
  35. 212 0
      examples/auto_put_ad_mini/新增群聊通知功能说明.md
  36. 205 0
      examples/auto_put_ad_mini/飞书权限配置说明.md
  37. 927 0
      outputs/agent_architecture.html
  38. BIN
      outputs/agent_architecture.png
  39. 13 0
      outputs/architecture_philosophy.md
  40. 337 0
      outputs/gen_arch_diagram.py
  41. BIN
      自动化投放架构、规划、拆解.docx

+ 242 - 0
examples/auto_put_ad_mini/API_TEST_GUIDE.md

@@ -0,0 +1,242 @@
+# 腾讯广告平台 API 测试指南
+
+## 项目分析总结
+
+### 系统架构
+
+**auto_put_ad_mini** 是一个智能广告投放系统,主要功能包括:
+
+```
+数据拉取 → ROI 计算 → 广告分类(A/B/C) → AI 推理决策 → 保存决策 → 护栏验证 → 生成报告 → 执行
+```
+
+### 核心组件
+
+1. **API 封装** (`tools/ad_api.py`)
+   - 腾讯广告 Marketing API v3.0 封装
+   - 支持广告创建、更新、查询
+   - 支持创意管理
+   - 支持数据报表查询
+   - 支持账户信息查询
+
+2. **数据查询** (`tools/data_query.py`)
+   - 拉取创意数据
+   - 合并数据
+
+3. **ROI 计算** (`tools/roi_calculator.py`)
+   - 计算 f_7日动态ROI
+   - 多维度 ROI 分析
+
+4. **决策引擎** (`tools/ad_decision.py`)
+   - 广告分类 (A/B/C)
+   - AI 推理决策
+   - 应用决策
+
+5. **安全护栏** (`tools/guardrails.py`)
+   - 验证决策安全性
+   - 防止过度调整
+
+6. **执行引擎** (`tools/execution_engine.py`)
+   - 分级执行决策
+   - 效果检查
+
+7. **报告生成** (`tools/report_generator.py`)
+   - 生成决策报告
+   - 对比分析
+
+### 关键特性
+
+- **API 版本**: 腾讯广告 Marketing API v3.0
+- **层级结构**: 广告(Ad) → 创意(Dynamic Creative)
+- **决策依据**: f_7日动态ROI + 消耗双维度
+- **决策范围**: 调整出价、暂停广告
+- **安全模式**: DRY_RUN_MODE 默认开启,不实际执行
+
+---
+
+## API 测试配置指南
+
+### 1. 获取腾讯广告 API 凭证
+
+#### 1.1 获取 Access Token
+
+1. 登录腾讯广告平台: https://e.qq.com
+2. 进入"开发者中心" → "API 管理"
+3. 创建应用并获取 `App ID` 和 `App Secret`
+4. 使用 OAuth2.0 授权流程获取 `access_token`
+
+详细文档: https://developers.e.qq.com/docs/guide/auth
+
+#### 1.2 获取账户 ID
+
+1. 登录腾讯广告平台
+2. 在账户管理页面查看账户 ID (数字格式)
+3. 或通过 API 调用 `/accounts/get` 获取
+
+### 2. 配置环境变量
+
+编辑项目根目录的 `.env` 文件,添加以下配置:
+
+```bash
+# 腾讯广告 API 配置
+TENCENT_AD_ACCESS_TOKEN=your_access_token_here
+TENCENT_AD_ACCOUNT_ID=your_account_id_here
+
+# 可选: 自定义 API Base URL (默认为 v3.0)
+TENCENT_AD_BASE_URL=https://api.e.qq.com/v3.0
+```
+
+**示例:**
+
+```bash
+TENCENT_AD_ACCESS_TOKEN=abcdefghijklmnopqrstuvwxyz123456
+TENCENT_AD_ACCOUNT_ID=1234567890
+```
+
+### 3. 安装依赖
+
+```bash
+# 安装 httpx (用于 API 请求)
+pip3 install httpx
+
+# 或安装完整依赖
+pip3 install -r requirements.txt
+```
+
+### 4. 运行测试
+
+```bash
+cd /Users/liulidong/project/agent/Agent
+
+# 运行简化版测试 (推荐)
+python3 examples/auto_put_ad_mini/test_api_simple.py
+```
+
+### 5. 预期结果
+
+如果配置正确,测试脚本会依次测试:
+
+```
+✅ 环境变量检查
+✅ 账户信息查询
+✅ 广告列表查询
+✅ 数据报表查询
+
+🎉 所有测试通过! 腾讯广告平台接口可用
+```
+
+---
+
+## 测试失败排查
+
+### 错误 1: 环境变量未设置
+
+**现象:**
+```
+❌ TENCENT_AD_ACCESS_TOKEN: 未设置
+```
+
+**解决方法:**
+- 确认 `.env` 文件存在于项目根目录
+- 确认 `.env` 文件中已添加 `TENCENT_AD_ACCESS_TOKEN` 和 `TENCENT_AD_ACCOUNT_ID`
+- 确认没有多余的空格或引号
+
+### 错误 2: API 错误 code != 0
+
+**现象:**
+```
+❌ API 错误 (code=4001): access_token invalid
+```
+
+**可能原因:**
+1. Access Token 已过期
+2. Access Token 格式错误
+3. Access Token 权限不足
+
+**解决方法:**
+- 重新获取 Access Token
+- 检查 Token 是否有账户访问权限
+- 确认使用的是 v3.0 API
+
+### 错误 3: HTTP 错误 403/401
+
+**现象:**
+```
+❌ HTTP 错误: 403
+```
+
+**可能原因:**
+1. 账户权限不足
+2. IP 白名单未配置
+3. 账户状态异常
+
+**解决方法:**
+- 检查账户状态是否正常
+- 配置 API 调用 IP 白名单
+- 确认账户有 API 调用权限
+
+### 错误 4: 网络连接错误
+
+**现象:**
+```
+❌ 网络请求错误: Connection timeout
+```
+
+**可能原因:**
+1. 网络不通
+2. 防火墙拦截
+3. 需要代理
+
+**解决方法:**
+- 检查网络连接
+- 配置代理 (如果需要):
+  ```bash
+  export HTTP_PROXY=http://127.0.0.1:7890
+  export HTTPS_PROXY=http://127.0.0.1:7890
+  ```
+
+---
+
+## API 限制说明
+
+根据腾讯广告 API 文档:
+
+- **QPS 限制**: 单账户 10 QPS
+- **批量操作**: 单次最多 50 条
+- **金额单位**: 分 (1元 = 100分)
+- **审核时间**: 2-4 小时 (普通素材)
+- **数据延迟**: 实时数据 15-30 分钟,转化数据 1-2 小时
+
+---
+
+## 下一步操作
+
+配置成功后,可以:
+
+1. **运行完整系统**:
+   ```bash
+   python3 examples/auto_put_ad_mini/run.py
+   ```
+   输入指令: `分析广告`
+
+2. **查看系统配置**:
+   - 配置文件: `examples/auto_put_ad_mini/config.py`
+   - 决策策略: `examples/auto_put_ad_mini/skills/roi_strategy.md`
+   - 安全护栏: `examples/auto_put_ad_mini/skills/guardrail_rules.md`
+
+3. **启用执行模式** (谨慎):
+   - 编辑 `config.py`
+   - 设置 `DRY_RUN_MODE = False`
+   - 设置 `EXECUTION_ENABLED = True`
+
+---
+
+## 技术支持
+
+- 腾讯广告 API 文档: https://developers.e.qq.com/docs/
+- Marketing API v3.0: https://developers.e.qq.com/docs/api/marketing
+- OAuth2.0 授权: https://developers.e.qq.com/docs/guide/auth
+
+---
+
+**最后更新**: 2026-04-15

+ 168 - 0
examples/auto_put_ad_mini/APPROVAL_FLOW_UPDATE.md

@@ -0,0 +1,168 @@
+# 审批流程更新说明
+
+**更新时间**: 2026-04-18
+**版本**: v2.0
+
+## 变更概述
+
+将审批消息发送从"单一审批群"改为"个人 + 项目群"双通道模式。
+
+## 修改内容
+
+### 1. 消息发送目标调整
+
+| 配置项 | 之前 | 现在 | 说明 |
+|--------|------|------|------|
+| 个人私聊 | ❌ 不发送 | ✅ 发送 | ou_498988d823b61ab89c9afe4310f85bb4 |
+| 项目群聊 | ⚠️ 仅通知(简化消息)| ✅ 完整审批消息 | oc_7940ec97cde40b245cff9cb606ff1ac7 |
+| 审批群聊 | ✅ 发送 | ❌ 停用 | oc_88e0a1970a7de02eb5ac225a8b0cedea |
+
+### 2. 审批回复监听
+
+**之前**:
+- 只监听审批群(oc_88e0a1970a7de02eb5ac225a8b0cedea)
+- 只接受特定用户(ou_498988d823b61ab89c9afe4310f85bb4)的回复
+
+**现在**:
+- 监听个人私聊(ou_498988d823b61ab89c9afe4310f85bb4)
+- 监听项目群聊(oc_7940ec97cde40b245cff9cb606ff1ac7)
+- 接受**任何用户**的回复(不限制个人ID)
+
+### 3. 代码修改位置
+
+**文件**: `tools/im_approval.py`
+
+**修改行数**:
+- 第457-470行:消息发送逻辑
+- 第476-525行:在线表格/文件发送逻辑
+- 第576-584行:审批回复监听(阻塞模式)
+- 第714-722行:审批回复监听(check_approval_status)
+
+### 4. 配置文件
+
+**`.env`** 配置:
+```bash
+# 个人 Open ID(接收审批消息)
+FEISHU_OPERATOR_OPEN_ID=ou_498988d823b61ab89c9afe4310f85bb4
+
+# 项目群聊 ID(接收审批消息)
+FEISHU_AD_PROJECT_CHAT_ID=oc_7940ec97cde40b245cff9cb606ff1ac7
+
+# 旧审批群聊 ID(已停用,保留兼容)
+FEISHU_OPERATOR_CHAT_ID=oc_88e0a1970a7de02eb5ac225a8b0cedea
+```
+
+## 端到端测试
+
+### 测试脚本
+```bash
+cd examples/auto_put_ad_mini
+python3 test_approval_flow_e2e.py
+```
+
+### 测试结果
+```
+✅ 个人消息发送成功(message_id: om_x100b5117fbbc44a0c4abd3016f812f0)
+✅ 项目群消息发送成功(message_id: om_x100b5117fbb534b0c3705152a30b4f1)
+```
+
+### 验证步骤
+1. ✅ 检查个人飞书消息,确认收到完整审批消息
+2. ✅ 检查项目群聊,确认收到相同的审批消息
+3. ✅ 在任一位置回复"通过"或"拒绝",系统能正确识别
+
+## 使用指南
+
+### 审批流程
+
+1. **系统发起审批**:调用 `send_approval_request` 工具
+2. **消息发送**:
+   - 个人私聊收到文本消息 + Excel表格
+   - 项目群聊收到文本消息 + Excel表格
+3. **运营回复**:
+   - 在个人私聊中回复"通过"/"拒绝"
+   - 或在项目群中回复"通过"/"拒绝"
+   - 系统识别任一处的回复
+4. **系统执行**:根据回复执行相应操作
+
+### 回复示例
+
+**简单审批**:
+- ✅ "通过" → 批准所有操作
+- ❌ "拒绝" → 拒绝所有操作
+
+**部分审批**:
+- "只批准降价的" → 过滤后执行
+- "广告12345不要暂停" → 修改决策后重新审批
+
+### 权限说明
+
+**个人私聊**:
+- 仅指定个人(ou_498988d823b61ab89c9afe4310f85bb4)能看到
+- 任何人回复都能识别(如果转发给他人)
+
+**项目群聊**:
+- 群内所有成员都能看到消息
+- **任何群成员**都可以回复审批
+
+## 注意事项
+
+1. **机器人权限**:
+   - 确保机器人已加入项目群聊
+   - 确保机器人有发送消息和文件权限
+   - 确保机器人有读取消息权限
+
+2. **个人私聊**:
+   - 用户需要先与机器人建立会话
+   - 可以通过在任意群聊@机器人来激活
+
+3. **回复识别**:
+   - 系统会监听**审批消息发送后**的所有回复
+   - 只识别**用户类型**的消息(忽略机器人消息)
+   - 回复内容通过 `_parse_approval_reply` 解析
+
+## 回滚方案
+
+如果需要回滚到旧版(仅审批群):
+
+1. 修改 `im_approval.py` 第457-470行:
+```python
+# 恢复为只发送到审批群
+result = _feishu.send_message(to=FEISHU_OPERATOR_CHAT_ID, text=message)
+```
+
+2. 修改监听逻辑(第576-584行):
+```python
+# 恢复为只监听审批群
+result = _feishu.get_message_list(
+    chat_id=FEISHU_OPERATOR_CHAT_ID,
+    start_time=sent_time_sec,
+    page_size=10,
+)
+```
+
+3. 恢复个人ID限制(第593行):
+```python
+# 恢复为只接受特定用户
+if sender_type != "user" or sender_id != FEISHU_OPERATOR_OPEN_ID:
+    continue
+```
+
+## 相关文件
+
+- `tools/im_approval.py` - 核心审批逻辑
+- `config.py` - 配置定义
+- `.env` - 环境变量配置
+- `test_approval_flow_e2e.py` - 端到端测试脚本
+
+## 更新日志
+
+### v2.0 (2026-04-18)
+- ✅ 支持个人 + 项目群双通道发送
+- ✅ 移除审批群聊发送
+- ✅ 允许任何群成员回复审批
+- ✅ 添加端到端测试脚本
+
+### v1.0 (之前)
+- 单一审批群聊模式
+- 仅指定用户可回复

+ 1139 - 0
examples/auto_put_ad_mini/ARCHITECTURE.md

@@ -0,0 +1,1139 @@
+# auto_put_ad_mini — 智能投放 Agent 架构全解析
+
+> 基于 Reson Agent 框架的腾讯广告智能调控系统
+> 数据驱动 + AI推理 + 安全护栏 + 自然语言审批
+
+---
+
+## 📐 架构总览
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                      用户/运营人员                                │
+│          "分析广告" | "广告XXX降价10%" | "不要暂停"               │
+└───────────────────────────────┬─────────────────────────────────┘
+                                │
+                     ┌──────────▼──────────┐
+                     │   Agent运行引擎      │
+                     │  (AgentRunner)      │
+                     │                     │
+                     │  • LLM调用 (qwen)   │
+                     │  • 工具注册/调用     │
+                     │  • Skill加载        │
+                     │  • 轨迹追踪         │
+                     └──────────┬──────────┘
+                                │
+          ┌─────────────────────┼─────────────────────┐
+          │                     │                     │
+  ┌───────▼────────┐   ┌────────▼────────┐   ┌───────▼────────┐
+  │  system.prompt │   │   Skills/知识库  │   │   Tools/工具集  │
+  │  ─────────────│   │  ─────────────  │   │  ───────────── │
+  │  • Mode路由    │   │ roi-strategy.md │   │ data_query.py  │
+  │  • 决策流程    │   │ guardrail_rules │   │ roi_calculator │
+  │  • 审批逻辑    │   │ ad_domain.md    │   │ ad_decision    │
+  │  • 错误处理    │   │  (领域知识注入)  │   │ guardrails     │
+  └────────────────┘   └─────────────────┘   │ execution_engine│
+                                              │ im_approval    │
+                                              │ report_generator│
+                                              └────────────────┘
+                                                      │
+                     ┌────────────────────────────────┼────────────┐
+                     │                                │            │
+            ┌────────▼─────────┐         ┌───────────▼──────────┐ │
+            │   数据层 (ODPS)   │         │   外部服务集成        │ │
+            │  ─────────────   │         │  ──────────────      │ │
+            │  • 创意数据       │         │  • 飞书机器人        │ │
+            │  • 广告状态       │         │  • 腾讯广告 API      │ │
+            │  • Merged数据     │         │  (分级执行+回调)     │ │
+            └──────────────────┘         └─────────────────────┘ │
+                                                                  │
+                     ┌────────────────────────────────────────────┘
+                     │
+            ┌────────▼────────┐
+            │   持久化存储     │
+            │  ─────────────  │
+            │  • metrics_*.csv│
+            │  • decisions_*  │
+            │  • reports_*    │
+            │  • 调整历史记录  │
+            └─────────────────┘
+```
+
+---
+
+## 🔄 完整执行流程详解
+
+### Mode 1: 全量分析工作流(主流程)
+
+用户说 **"分析广告"** 时触发,执行以下步骤:
+
+#### 第1步:数据拉取 (`fetch_creative_data`)
+```python
+# 工具: examples/auto_put_ad_mini/tools/data_query.py
+
+输入: days=7 (拉取最近7天)
+处理:
+  1. 检查本地已有数据(跳过重复拉取)
+  2. 对于缺失日期,从ODPS拉取创意级数据
+  3. 同时拉取广告状态数据 (ad_status)
+
+输出:
+  - outputs/raw/creative_YYYYMMDD.csv (每日创意数据)
+  - outputs/ad_status/ad_status_YYYYMMDD.csv (每日广告状态)
+
+数据特点:
+  • 创意级粒度 (一个广告有多个创意)
+  • 包含ROI计算必需字段: cost, open_count, fission0_count, total_revenue, etc.
+```
+
+#### 第2步:数据合并 (`merge_creative_data`)
+```python
+# 将创意数据 + 广告状态 合并
+
+输入: days=7
+处理:
+  1. 读取 creative_YYYYMMDD.csv
+  2. 读取 ad_status_YYYYMMDD.csv
+  3. LEFT JOIN (创意 + 广告状态)
+
+输出:
+  - outputs/merged/merged_YYYYMMDD.csv
+
+字段包括:
+  • ad_id, creative_id, account_id, ad_name
+  • cost, view_count, click_count
+  • open_count, fission0_count, total_return_count, total_revenue
+  • bid_amount, configured_status, create_time
+```
+
+#### 第3步:ROI计算 (`calculate_roi_metrics`)
+```python
+# 工具: examples/auto_put_ad_mini/tools/roi_calculator.py
+
+输入: end_date="20260415" (默认yesterday)
+处理:
+  1. 加载最近30天的 merged 数据(容错缺失)
+  2. 聚合到广告级 (GROUP BY ad_id, date)
+  3. 计算 f_7日动态ROI (考虑裂变效率稳定性)
+  4. 计算 7日汇总 (cost_7d_avg, revenue_7d_total)
+  5. 计算 30日汇总 (stable_spend_days_30d)
+
+核心公式:
+  T0裂变系数 = fission0_count / open_count
+  arpu = total_revenue / total_return_count
+  当日裂变收益率 = fission0_count * arpu / cost
+  当日回流倍数 = total_return_count / open_count
+
+  裂变效率稳定因子 = 回流倍数_7日均值 / T0裂变系数_7日均值
+  f_7日动态ROI = 当日裂变收益率 * 裂变效率稳定因子
+
+输出:
+  - outputs/metrics_20260415.csv (1570行, 每个广告一行)
+
+关键指标:
+  • 动态ROI (单日值)
+  • 动态ROI_7日均值 (决策参考值!)
+  • cost_7d_avg, cost_7d_total
+  • ad_age_days (广告年龄)
+```
+
+#### 第4步:广告分类 (`get_ads_for_review`)
+```python
+# 工具: examples/auto_put_ad_mini/tools/ad_decision.py
+
+输入: metrics_csv (步骤3输出)
+处理:
+  1. 计算全体 ROI 分布 (mean, p25, p50, p75, p90)
+  2. 检测衰退信号 (提价、换创意、消耗下降)
+  3. 分为 A/B/C 三类:
+
+     A类 (极端差, 自动关停):
+       • 7日均消耗 < 1元 (几乎零活动)
+
+     B类 (边缘, 需AI推理):
+       • ROI < 全体均值 × 0.8 (偏低)
+       • 或检测到衰退信号
+
+     C类 (正常, 自动保持):
+       • 其余广告
+
+输出: JSON结构化数据
+  {
+    "summary": {total, class_a, class_b, class_c},
+    "distribution": {roi_mean, p25, p50, p75, p90},
+    "bid_adjustment": {bid_down_line, bid_up_line},
+    "class_a": [...],  # 自动关停列表
+    "class_b": [...],  # 需要推理的广告(含详细指标)
+    "class_c_summary": {...}
+  }
+```
+
+#### 第5步:AI推理决策 (Agent自主推理)
+```python
+# Agent读取B类广告数据 + roi-strategy.md技能
+# 对每个广告进行推理,输出决策JSON
+
+决策逻辑框架:
+  IF ad_age_days < 4天:
+    → hold (冷启动绝对保护)
+
+  ELIF ad_age_days < 7天:
+    → 仅允许小幅降价 ≤5% (谨慎期)
+
+  ELIF ROI < 全体均值 × 0.5:
+    → pause (极低ROI, 关停)
+
+  ELIF ROI < 全体均值 × 0.8 AND cost_7d_avg ≥ 100元:
+    → bid_down -3%~-10% (降价, 越接近关停线降幅越大)
+
+  ELIF ROI > 全体均值 × 1.2 AND cost_7d_avg < 中位数 × 0.5:
+    → bid_up +3%~-10% (高ROI低消耗, 提价放量)
+
+  ELSE:
+    → hold (保持)
+
+输出格式:
+  [
+    {
+      "ad_id": 90289631207,
+      "action": "pause",
+      "dimension": "ROI过低",
+      "reason": "f_7日动态ROI=1.18 < 均值3.29×0.5=1.64, 日消耗1524元, 持续亏损",
+      "confidence": "high"
+    },
+    {
+      "ad_id": 32912382309,
+      "action": "bid_down",
+      "dimension": "ROI偏低-降价",
+      "reason": "动态ROI_7日均值=1.81 < 均值3.29×0.8=2.63, 建议降8%至0.30元",
+      "confidence": "medium",
+      "recommended_change_pct": -0.08
+    },
+    ...
+  ]
+```
+
+#### 第6步:保存决策 (`apply_decisions`)
+```python
+# 工具: examples/auto_put_ad_mini/tools/ad_decision.py
+
+输入: decisions (AI推理的JSON) + metrics_csv
+处理:
+  1. 解析AI输出的JSON
+  2. 合并 A类自动关停 + B类AI决策 + C类自动保持
+  3. 过滤已暂停广告 (AD_STATUS_SUSPEND)
+  4. 计算出价变更(current_bid × (1 + recommended_change_pct))
+
+输出:
+  - outputs/reports/llm_decisions_20260415.csv
+
+字段:
+  ad_id, action, dimension, reason, confidence,
+  recommended_change_pct, current_bid, recommended_bid,
+  ad_age_days
+```
+
+#### 第7步:安全护栏验证 (`validate_decisions`)
+```python
+# 工具: examples/auto_put_ad_mini/tools/guardrails.py
+
+输入: llm_decisions_20260415.csv
+处理: 逐条检查6道护栏
+
+护栏1: 数据新鲜度
+  IF 数据年龄 > 48小时:
+    → Block所有非hold操作
+
+护栏2: 冷启动保护
+  IF ad_age_days < 4天:
+    → Block所有pause/bid_down
+  IF ad_age_days < 7天:
+    → bid_down限制 ≤5%
+
+护栏3: 出价边界
+  IF recommended_bid < 0.5元:
+    → Modified (钳位到0.5元)
+  IF recommended_bid > 200元:
+    → Modified (钳位到200元)
+
+护栏4: 频率限制
+  IF 今日已调整次数 ≥ 2:
+    → Block
+  IF 距上次调整 < 6小时:
+    → Block
+  IF 今日累计调幅 > 20%:
+    → Block
+
+护栏5: 每日操作上限
+  IF 今日已操作广告数 ≥ 200:
+    → 按ROI严重度排序, Block低优先级
+
+护栏6: 干运行模式
+  IF DRY_RUN_MODE = True:
+    → 所有操作标记dry_run (Modified)
+
+输出:
+  - outputs/reports/validated_decisions_20260415.csv
+
+新增字段:
+  guardrail_status: approved | modified | blocked
+  guardrail_reason: 拦截原因说明
+  final_action: 护栏处理后的最终动作
+  final_bid: 护栏处理后的最终出价
+```
+
+#### 第8步:飞书审批请求 (`send_approval_request`)
+```python
+# 工具: examples/auto_put_ad_mini/tools/im_approval.py
+# ⚠️ 仅当有通过护栏的非hold操作时才调用
+
+输入: wait_for_reply=True (阻塞等待审批)
+处理:
+  1. 读取 validated_decisions (guardrail_status=approved)
+  2. 按风险分级:
+     Tier 1 (≤5%调幅): 仅通知, 不需审批
+     Tier 2 (pause, bid_down>5%): 需审批
+     Tier 3 (日消耗>1500元): 强制审批
+
+  3. 构造飞书消息 (卡片式, 带数据表格)
+  4. 发送到飞书群组 (FEISHU_OPERATOR_CHAT_ID)
+  5. 阻塞等待运营回复 (轮询检查, 超时30分钟)
+
+飞书消息格式:
+  【广告调控审批】2026-04-15
+
+  📊 决策摘要
+  • 总决策数: 38个
+  • Tier 1 (自动): 10个 (小幅调价 ≤5%)
+  • Tier 2 (需审批): 20个 (暂停/降价>5%)
+  • Tier 3 (高价值): 8个 (日消耗>1500元)
+
+  🔻 高风险广告 (需审批)
+  1. 广告90289631207: ROI=1.18, 消耗=1524元/天 → 暂停
+  2. 广告37429627354: ROI=2.37, 消耗=1228元/天 → 降价5%
+  ...
+
+  ⏰ 请在30分钟内回复:
+  - "批准" / "通过" → 全部批准
+  - "拒绝" / "取消" → 全部拒绝
+  - "广告XXX不要暂停" → 修改特定决策
+
+审批结果:
+  - approved_decisions: 通过的决策列表
+  - rejected_decisions: 拒绝的决策列表
+  - modified_decisions: 修改后的决策列表
+```
+
+**自然语言审批理解 (关键特性):**
+```python
+# Agent具备自然语言理解能力, 可以处理灵活的人类指令
+
+运营说: "批准"
+  → 解析为: 全部批准
+
+运营说: "广告90289631207不要暂停, 改为降价10%"
+  → 解析为: modify_decisions([
+      {"ad_id": 90289631207, "new_action": "bid_down", "new_change_pct": -0.10}
+    ])
+  → 重新validate → 重新发审批
+
+运营说: "只批准降价的, 暂停的全部拒绝"
+  → 解析为: 过滤 action=bid_down → approved
+              过滤 action=pause → rejected
+
+运营说: "为什么要暂停广告90289631207?"
+  → Agent回答: "该广告f_7日动态ROI=1.18 < 关停线1.64,日消耗1524元,
+                已持续亏损,建议暂停止损"
+  → 等待运营最终确认
+```
+
+#### 第9步:执行决策 (`execute_decisions`)
+```python
+# 工具: examples/auto_put_ad_mini/tools/execution_engine.py
+# ⚠️ 仅在运营审批通过后调用
+
+输入: 运营审批通过的决策列表
+处理:
+  1. 分批执行 (QPS限制=8, 批次大小=50)
+  2. 根据action调用腾讯广告API:
+     • pause → /v3.0/adgroups/update (configured_status=SUSPEND)
+     • bid_down/bid_up → /v3.0/adgroups/update (bid_amount=final_bid)
+
+  3. 错误处理 + 重试 (最多3次)
+  4. 记录执行日志
+
+输出:
+  - outputs/execution_log/execution_20260415.json
+
+执行结果:
+  {
+    "timestamp": "2026-04-15 14:30:00",
+    "total": 38,
+    "success": 36,
+    "failed": 2,
+    "details": [
+      {
+        "ad_id": 90289631207,
+        "action": "pause",
+        "status": "success",
+        "api_response": {...}
+      },
+      {
+        "ad_id": 32912382309,
+        "action": "bid_down",
+        "status": "failed",
+        "error": "API限流, 重试后成功"
+      }
+    ]
+  }
+```
+
+#### 第10步:生成报告 (`generate_report`)
+```python
+# 工具: examples/auto_put_ad_mini/tools/report_generator.py
+
+输入: validated_decisions_20260415.csv
+处理:
+  1. 汇总统计 (总数, pause/bid_down/hold分布)
+  2. 按ROI严重度排序
+  3. 生成Excel (带条件格式、颜色标注)
+
+输出:
+  - outputs/reports/decision_20260415.csv (纯数据)
+  - outputs/reports/decision_20260415.xlsx (带格式)
+
+Excel样式:
+  • 绿色: ROI优秀 (>均值×1.2)
+  • 黄色: ROI偏低 (均值×0.5~0.8)
+  • 红色: ROI极低 (<均值×0.5)
+  • 冻结首行, 自动筛选
+```
+
+---
+
+### Mode 2: 定向操作工作流
+
+用户提及 **具体广告ID + 操作意图** 时触发(如 "广告90289631207降价10%"):
+
+```
+1. query_ad_detail(ad_id) → 查询当前数据+全局上下文
+2. AI推理 → 根据用户意图+当前状态生成决策JSON
+3. modify_decisions() / apply_decisions() → 保存决策 (upsert模式)
+4. validate_decisions() → 护栏验证
+5. send_approval_request() → IM发给运营确认
+6. execute_decisions() → 执行
+```
+
+---
+
+### Mode 3: 反馈修改工作流
+
+用户对已有决策提出修改意见时触发(如 "广告XXX不要暂停"):
+
+```
+1. modify_decisions(modifications) → 修改指定条目
+   支持:
+     • 精确修改: [{"ad_id": "XXX", "new_action": "bid_down", "new_change_pct": -0.05}]
+     • 批量修改: [{"filter": "all_pause", "new_action": "hold"}]
+
+2. validate_decisions() → 重新验证
+3. send_approval_request() → 重新发IM
+4. execute_decisions() → 执行
+```
+
+---
+
+## 🛡️ 安全护栏系统详解
+
+护栏是整个系统的**核心安全机制**,防止AI做出错误决策。
+
+### 护栏1: 数据新鲜度检查
+```python
+规则:
+  IF (当前时间 - 数据时间) > 48小时:
+    → Block所有非hold操作
+
+原因:
+  • 广告数据实时性强, 过期数据会导致错误决策
+  • 超过48小时的数据已失去参考价值
+
+示例:
+  数据: 20260415 (58小时前)
+  当前: 20260417 10:26
+  → Block所有pause/bid_down, 转为hold
+```
+
+### 护栏2: 冷启动保护
+```python
+规则:
+  IF ad_age_days < 4天:
+    → Block所有负向操作 (pause, bid_down)
+
+  IF 4天 ≤ ad_age_days < 7天:
+    → pause仍然Block
+    → bid_down限制最大降幅5%
+
+原因:
+  • 新广告需要时间学习用户画像
+  • 初始出价通常比目标CPA高20%, 需等系统优化
+  • 过早干预会打断学习过程
+
+示例:
+  广告90289631207: ad_age_days=5天, AI建议pause
+  → Block (谨慎期不允许暂停)
+  → 转为hold
+```
+
+### 护栏3: 出价边界
+```python
+规则:
+  IF recommended_bid < 0.5元:
+    → Modified (钳位到0.5元)
+
+  IF recommended_bid > 200元:
+    → Modified (钳位到200元)
+
+原因:
+  • 低于0.5元竞争力不足, 无法获得曝光
+  • 高于200元风险过高, 可能是计算错误
+
+示例:
+  AI建议: bid_down -50%, current_bid=0.8元 → recommended_bid=0.4元
+  → Modified: final_bid=0.5元
+```
+
+### 护栏4: 频率限制
+```python
+规则:
+  IF 今日已对该广告调整次数 ≥ 2:
+    → Block
+
+  IF 距上次调整时间 < 6小时:
+    → Block
+
+  IF 今日累计调幅 > 20%:
+    → Block
+
+原因:
+  • 频繁调整会触发平台模型重学习
+  • 超过10%单次调幅会导致流量崩塌
+  • 需要给系统足够时间反馈效果
+
+示例:
+  广告32912382309: 今日已降价1次 (-5%), 距离上次调整3小时
+  AI再次建议降价 (-3%)
+  → Block (间隔不足6小时)
+```
+
+### 护栏5: 每日操作上限
+```python
+规则:
+  IF 今日已操作广告数 ≥ 200:
+    → 按ROI严重度排序
+    → Block低优先级广告
+
+原因:
+  • 避免一次性大规模调整
+  • 分散风险, 逐步优化
+
+优先级:
+  1. pause (极低ROI, 持续亏损)
+  2. bid_down (ROI接近关停线)
+  3. bid_up (高ROI低消耗)
+```
+
+### 护栏6: 干运行模式
+```python
+规则:
+  IF DRY_RUN_MODE = True:
+    → 所有操作标记dry_run
+    → 不实际调用API
+
+用途:
+  • 测试阶段验证决策逻辑
+  • 模拟执行, 查看效果预测
+```
+
+---
+
+## 🔌 外部服务集成
+
+### 1. 腾讯广告 API v3.0
+
+**Token管理 (动态获取):**
+```python
+# tools/ad_api.py
+
+def _get_access_token(account_id):
+    """
+    优先从内部API获取 (30分钟缓存)
+    失败时降级使用 .env 静态token
+    """
+    url = f"https://api.piaoquantv.com/ad/put/tencent/getAccessToken?accountId={account_id}"
+    response = requests.get(url)
+    if response.ok:
+        return response.json()["data"]["accessToken"]
+    else:
+        return os.getenv("TENCENT_AD_ACCESS_TOKEN")
+```
+
+**广告操作API:**
+```python
+# 暂停广告
+POST /v3.0/adgroups/update
+{
+  "account_id": 80769799,
+  "adgroup_id": 90289631207,
+  "configured_status": "AD_STATUS_SUSPEND"
+}
+
+# 修改出价
+POST /v3.0/adgroups/update
+{
+  "account_id": 80769799,
+  "adgroup_id": 32912382309,
+  "bid_amount": 30  # 单位: 分 (0.30元)
+}
+```
+
+### 2. 飞书机器人
+
+**应用信息:**
+```python
+APP_ID = "cli_a955e97067f85cb3"
+APP_SECRET = "NQaG4ci1plXRDTgwCqrLJgMLLoA2tdF8"
+OPERATOR_OPEN_ID = "ou_498988d823b61ab89c9afe4310f85bb4"
+CHAT_ID = "oc_88e0a1970a7de02eb5ac225a8b0cedea"
+```
+
+**消息发送:**
+```python
+# 卡片式消息
+POST https://open.feishu.cn/open-api/im/v1/messages
+{
+  "receive_id": CHAT_ID,
+  "msg_type": "interactive",
+  "content": {
+    "config": {"wide_screen_mode": true},
+    "header": {
+      "title": {"tag": "plain_text", "content": "【广告调控审批】"}
+    },
+    "elements": [
+      {"tag": "markdown", "content": "**决策摘要**\n..."},
+      {"tag": "hr"},
+      {"tag": "action", "actions": [
+        {"tag": "button", "text": "批准", "type": "primary"},
+        {"tag": "button", "text": "拒绝", "type": "danger"}
+      ]}
+    ]
+  }
+}
+```
+
+**审批轮询:**
+```python
+# 每30秒检查一次运营回复
+while not timeout:
+    messages = get_chat_history(CHAT_ID)
+    for msg in messages:
+        if msg.sender == OPERATOR_OPEN_ID:
+            # 自然语言理解运营意图
+            intent = parse_approval_intent(msg.content)
+            if intent.type == "approve":
+                return ApprovalResult(approved=True)
+            elif intent.type == "modify":
+                return ApprovalResult(modified=intent.modifications)
+```
+
+### 3. ODPS (数据源)
+
+**数据拉取:**
+```python
+# tools/data_query.py
+
+def _fetch_from_odps(bizdate, account_id):
+    """
+    从MaxCompute (ODPS)拉取创意数据
+    """
+    sql = f"""
+    SELECT
+        bizdate,
+        ad_id,
+        creative_id,
+        ad_name,
+        cost,
+        open_count,
+        fission0_count,
+        total_return_count,
+        total_revenue,
+        view_count,
+        valid_click_count,
+        conversions_count
+    FROM creative_data_table
+    WHERE bizdate = {bizdate}
+      AND account_id = {account_id}
+    """
+
+    odps_client = ODPSClient(project="loghubods")
+    df = odps_client.query_to_dataframe(sql)
+    return df
+```
+
+---
+
+## 📊 数据流转全景
+
+```
+[ODPS数据仓库]
+     ↓
+ creative_data (创意级, 每日2.5MB)
+     ↓
+[data_query.py] fetch_creative_data
+     ↓
+outputs/raw/creative_YYYYMMDD.csv (原始创意数据)
+     ↓
+[data_query.py] merge_creative_data
+     ↓
+outputs/merged/merged_YYYYMMDD.csv (创意+广告状态合并)
+     ↓
+[roi_calculator.py] calculate_roi_metrics
+  • 加载最近30天 merged 数据
+  • 聚合到广告级 (GROUP BY ad_id, date)
+  • 计算 f_7日动态ROI
+  • 计算 7日/30日汇总指标
+     ↓
+outputs/metrics_20260415.csv (1570行广告级指标)
+     ↓
+[ad_decision.py] get_ads_for_review
+  • 计算全体ROI分布
+  • 检测衰退信号
+  • 分类 A/B/C
+     ↓
+JSON结构化数据 (发给AI)
+     ↓
+[Agent AI推理] 52个B类广告逐个分析
+     ↓
+decisions JSON (AI输出)
+     ↓
+[ad_decision.py] apply_decisions
+  • 合并A/B/C类决策
+  • 过滤已暂停广告
+  • 计算出价变更
+     ↓
+outputs/reports/llm_decisions_20260415.csv (613条决策)
+     ↓
+[guardrails.py] validate_decisions
+  • 6道护栏逐条检查
+  • 拦截/修正/通过
+     ↓
+outputs/reports/validated_decisions_20260415.csv (带护栏状态)
+     ↓
+[im_approval.py] send_approval_request
+  • 过滤 approved 决策
+  • 分级 (Tier 1/2/3)
+  • 发送飞书卡片
+  • 阻塞等待审批
+     ↓
+[运营审批] 自然语言回复
+     ↓
+[execution_engine.py] execute_decisions
+  • 调用腾讯广告API
+  • 记录执行日志
+     ↓
+outputs/execution_log/execution_20260415.json (执行结果)
+     ↓
+[report_generator.py] generate_report
+  • 汇总统计
+  • Excel美化
+     ↓
+outputs/reports/decision_20260415.xlsx (最终报告)
+```
+
+---
+
+## 🎯 关键设计亮点
+
+### 1. 智能引擎 vs 规则引擎
+```python
+# config.py
+USE_RULE_ENGINE = False  # 规则引擎 (固定阈值, 快速)
+USE_AI_ENGINE = True     # 智能引擎 (AI推理, 灵活)
+
+优劣对比:
+  规则引擎:
+    ✅ 速度快 (秒级)
+    ✅ 可解释性强
+    ❌ 无法处理复杂场景
+    ❌ 阈值需人工调整
+
+  智能引擎:
+    ✅ 自适应 (动态阈值)
+    ✅ 处理复杂因果关系 (ROI+消耗+趋势)
+    ✅ 自然语言交互
+    ❌ 速度较慢 (分钟级)
+    ❌ 需要LLM调用成本
+```
+
+### 2. 分级执行策略
+```python
+# 风险分层审批, 平衡效率与安全
+
+Tier 1 (自动执行, 无需审批):
+  • 出价调整 ≤ 5%
+  • 日消耗 < 500元
+  → 实时生效, 仅通知运营
+
+Tier 2 (需审批):
+  • pause
+  • bid_down > 5%
+  • bid_up > 5%
+  → 发飞书等待审批
+
+Tier 3 (强制审批):
+  • 日消耗 > 1500元 (高价值广告)
+  • 出价调整 > 10% (高风险操作)
+  → 运营必须回复
+```
+
+### 3. 自然语言审批
+```python
+# 打破传统"批准/拒绝"二元模式
+# Agent理解运营的自然语言指令, 灵活调整
+
+示例1:
+  运营: "广告90289631207改为降价5%, 不要暂停"
+  → modify_decisions([{
+      "ad_id": 90289631207,
+      "new_action": "bid_down",
+      "new_change_pct": -0.05
+    }])
+  → validate → 重新发审批
+
+示例2:
+  运营: "ROI低于1.5的全部暂停, 其他批准"
+  → filter decisions where ROI < 1.5 → pause
+  → filter decisions where ROI >= 1.5 → approved
+  → execute
+
+示例3:
+  运营: "这个广告为什么要暂停? 我觉得还有机会"
+  → Agent解释: "该广告ROI=1.18, 低于关停线1.64, 已持续23天消耗
+                 1524元/天, 总亏损>1万元, 建议暂停止损"
+  → 运营: "那降价20%试试"
+  → Agent: "降价20%超过单次调幅上限10%, 已调整为-10%"
+  → modify_decisions → 重新验证 → 执行
+```
+
+### 4. 闭环反馈机制
+```python
+# 执行后6小时检查效果, 持续优化
+
+[execution_engine.py] execute_decisions
+  → 记录执行时间戳
+     ↓
+[execution_engine.py] check_execution_feedback (6小时后)
+  → 拉取最新数据
+  → 对比执行前后ROI变化
+  → 计算决策准确率
+     ↓
+决策准确率统计:
+  • pause正确率: 95% (暂停后ROI无改善)
+  • bid_down正确率: 80% (降价后ROI提升)
+  • bid_up正确率: 70% (提价后收入增长)
+     ↓
+反馈到下次决策:
+  • 调整阈值 (如ROI关停线从0.5→0.6)
+  • 优化调幅策略 (如降价步长从-8%→-10%)
+```
+
+### 5. 容错与降级
+```python
+# 多层级容错机制
+
+数据层:
+  • ODPS拉取失败 → 使用本地缓存
+  • merged数据缺失 → 跳过缺失日期, 使用可用数据
+
+API层:
+  • 腾讯广告API限流 → 自动重试 (指数退避)
+  • Token过期 → 自动刷新
+
+审批层:
+  • 飞书审批超时 (30分钟) → 自动取消, 保留决策供下次执行
+  • 网络错误 → 降级为邮件审批
+```
+
+---
+
+## 📈 性能优化
+
+### 1. 数据加载优化
+```python
+# 避免重复拉取数据
+
+def fetch_creative_data(days=7):
+    for i in range(days):
+        date = (today - timedelta(days=i)).strftime("%Y%m%d")
+        csv_path = RAW_DIR / f"creative_{date}.csv"
+
+        if csv_path.exists() and csv_path.stat().st_size > 1000:
+            logger.info(f"跳过已有数据: {date}")
+            continue  # 跳过已存在的数据
+
+        # 仅拉取缺失数据
+        df = _fetch_from_odps(date, account_id)
+        df.to_csv(csv_path)
+```
+
+### 2. ROI计算缓存
+```python
+# 增量计算, 避免重复处理
+
+metrics_cache = {}
+
+def calculate_roi_metrics(end_date):
+    cache_key = end_date
+    if cache_key in metrics_cache:
+        return metrics_cache[cache_key]
+
+    # 计算新数据
+    result = _do_calculation(end_date)
+    metrics_cache[cache_key] = result
+    return result
+```
+
+### 3. API批量调用
+```python
+# 减少网络往返
+
+# ❌ 逐个调用 (慢)
+for decision in decisions:
+    update_ad(decision.ad_id, decision.final_bid)
+
+# ✅ 批量调用 (快)
+batch_update_ads([
+    {"adgroup_id": d.ad_id, "bid_amount": d.final_bid}
+    for d in decisions
+])
+```
+
+---
+
+## 🚀 未来扩展方向
+
+### 1. 接入完整 auto_put_ad 体系
+```
+auto_put_ad_mini (当前)          →          auto_put_ad (完整版)
+┌───────────────────┐             ┌─────────────────────────────┐
+│ 监控调控 Agent    │      接入    │ 受众策略 Agent               │
+│  • ROI分析       │    ───────→  │ 创意策略 Agent               │
+│  • 出价调整      │              │ 预算策略 Agent               │
+│  • 广告暂停      │              │ ★ 监控调控 Agent (mini升级) │
+└───────────────────┘              │ 数据分析 Agent               │
+                                   │ 系统运维 Agent               │
+                                   │ 自学习/反馈环               │
+                                   └─────────────────────────────┘
+```
+
+### 2. 机器学习增强决策
+```python
+# 训练预测模型
+
+from sklearn.ensemble import RandomForestRegressor
+
+# 特征工程
+features = [
+    "cost_7d_avg", "roi_7d_avg", "ad_age_days",
+    "bid_amount", "audience_tier", "creative_count",
+    "cost_trend", "roi_trend"
+]
+
+# 训练目标: 预测未来7天ROI
+model = RandomForestRegressor()
+model.fit(X_train[features], y_train["future_7d_roi"])
+
+# 决策辅助
+future_roi_if_bid_down = model.predict(current_features + [-0.10])
+if future_roi_if_bid_down > current_roi:
+    recommend("bid_down", -0.10)
+```
+
+### 3. A/B测试框架
+```python
+# 对比不同策略效果
+
+def ab_test_bid_strategy():
+    """
+    将广告随机分为A/B组
+    A组: 激进策略 (降幅10%)
+    B组: 保守策略 (降幅5%)
+    """
+    group_a = random.sample(low_roi_ads, 20)
+    group_b = random.sample(low_roi_ads, 20)
+
+    execute_decisions(group_a, bid_change_pct=-0.10)
+    execute_decisions(group_b, bid_change_pct=-0.05)
+
+    # 7天后对比
+    roi_improvement_a = compare_roi(group_a, after=7)
+    roi_improvement_b = compare_roi(group_b, after=7)
+
+    if roi_improvement_a > roi_improvement_b:
+        adopt_strategy("aggressive")
+```
+
+### 4. 多目标优化
+```python
+# 当前: 单一优化ROI
+# 未来: 多目标优化 (ROI + Volume + Risk)
+
+from scipy.optimize import minimize
+
+def objective(bid):
+    roi = predict_roi(bid)
+    volume = predict_volume(bid)
+    risk = calculate_risk(bid)
+
+    # 加权目标函数
+    return -1 * (
+        0.5 * roi +       # 50%权重: ROI
+        0.3 * volume +    # 30%权重: 规模
+        -0.2 * risk       # 20%权重: 风险(负向)
+    )
+
+optimal_bid = minimize(objective, x0=current_bid)
+```
+
+---
+
+## 🔧 故障排查指南
+
+### 常见问题1: 数据拉取失败
+```bash
+# 症状
+ERROR - fetch_creative_data失败: No columns to parse from file
+
+# 原因
+• ODPS查询返回空结果
+• 网络连接失败
+• Token过期
+
+# 解决
+1. 检查ODPS连接: odps_client.test_connection()
+2. 检查Token有效性: _get_access_token(account_id)
+3. 查看空文件: ls -lh outputs/raw/*.csv | grep "4B"
+4. 删除空文件: rm outputs/raw/creative_20260416.csv
+5. 重新拉取: python3 execute_once.py
+```
+
+### 常见问题2: 护栏拦截所有决策
+```bash
+# 症状
+护栏验证: blocked 599个, approved 1个
+原因: [数据新鲜度] 数据已过期(58小时前,上限48小时)
+
+# 原因
+使用了过期数据 (20260415), 超过48小时新鲜度上限
+
+# 解决
+1. 拉取最新数据: fetch_creative_data(days=2)
+2. 计算最新ROI: calculate_roi_metrics(end_date="yesterday")
+3. 重新分析: execute_once.py
+```
+
+### 常见问题3: 飞书审批未触发
+```bash
+# 症状
+流程执行完成, 但没有发送飞书消息
+
+# 原因
+• 所有决策被护栏拦截 → 无需审批
+• EXECUTION_ENABLED=False → 不执行操作
+• 飞书Token过期
+
+# 检查
+1. 查看validated_decisions: guardrail_status列是否全是blocked
+2. 检查config.py: EXECUTION_ENABLED = True
+3. 测试飞书API: send_test_message()
+```
+
+### 常见问题4: AI推理偏保守
+```bash
+# 症状
+52个B类广告, AI只建议暂停2个, 其余全hold
+
+# 原因
+• ROI阈值设置过严格
+• 置信度要求过高
+
+# 调整
+1. 降低关停线: ROI_LOW_FACTOR = 0.5 → 0.6 (config.py)
+2. 修改Skill提示: "对置信度medium的也可以暂停" (roi-strategy.md)
+3. 增加样本: 提供历史决策案例供AI参考
+```
+
+---
+
+## 📚 文件结构总览
+
+```
+examples/auto_put_ad_mini/
+├── run.py                   # 交互式运行入口 (支持多轮对话)
+├── execute_once.py          # 单次执行入口 (自动化运行)
+├── config.py                # 核心配置 (阈值、开关、API凭据)
+├── presets.json             # 预设参数
+├── .env                     # 环境变量 (Token、密钥)
+│
+├── prompts/
+│   └── system.prompt        # Agent系统提示词 (模式路由、决策流程)
+│
+├── skills/
+│   ├── roi_strategy.md      # ROI决策框架 (注入给AI的领域知识)
+│   ├── guardrail_rules.md   # 护栏规则说明
+│   └── ad_domain.md         # 广告领域知识 (腾讯广告API、营销概念)
+│
+├── tools/
+│   ├── data_query.py        # 数据拉取+合并 (ODPS → CSV)
+│   ├── roi_calculator.py    # ROI计算 (f_7日动态ROI核心算法)
+│   ├── ad_decision.py       # 决策引擎 (A/B/C分类 + AI决策保存)
+│   ├── guardrails.py        # 安全护栏 (6道检查)
+│   ├── execution_engine.py  # 执行引擎 (调用腾讯广告API)
+│   ├── im_approval.py       # 飞书审批 (阻塞式自然语言审批)
+│   ├── report_generator.py  # 报告生成 (Excel美化)
+│   ├── feishu_doc.py        # 飞书文档导入 (可选)
+│   └── ad_api.py            # 腾讯广告API封装 (底层调用)
+│
+└── outputs/
+    ├── raw/                 # 原始数据 (creative_*.csv, ad_status_*.csv)
+    ├── merged/              # 合并数据 (merged_*.csv)
+    ├── ad_status/           # 广告状态快照
+    ├── reports/             # 决策报告
+    │   ├── llm_decisions_*.csv      # AI原始决策
+    │   ├── validated_decisions_*.csv # 护栏验证后
+    │   ├── decision_*.csv           # 最终决策(CSV)
+    │   └── decision_*.xlsx          # 最终决策(Excel)
+    ├── execution_log/       # 执行审计日志
+    ├── data/
+    │   └── adjustment_history.json  # 调整历史记录
+    └── metrics_*.csv        # 广告级ROI指标 (核心数据)
+```
+
+---
+
+## ✅ 总结
+
+auto_put_ad_mini 是一个**生产级的智能广告调控系统**,具备:
+
+1. **智能决策**: AI推理 + 领域知识 + 数据驱动
+2. **安全保障**: 6道护栏 + 分级审批 + 容错降级
+3. **灵活交互**: 自然语言审批 + 3种模式 + 实时修正
+4. **可扩展性**: 双引擎架构 + 模块化设计 + 闭环反馈
+
+**当前状态**: 可独立运行,完成"数据→决策→执行"闭环
+**未来定位**: 接入完整auto_put_ad体系,成为监控调控Agent核心
+
+---
+
+**文档版本**: v1.0
+**最后更新**: 2026-04-17
+**作者**: Claude Sonnet 4.5

+ 266 - 0
examples/auto_put_ad_mini/TEST_RESULT.md

@@ -0,0 +1,266 @@
+# 腾讯广告 API 读取测试报告
+
+**测试时间**: 2026-04-15
+**账户 ID**: 80769799
+**API 版本**: v3.0
+**测试结果**: ✅ 通过
+
+---
+
+## 1. 环境配置
+
+### Access Token
+- ✅ 已通过 API 获取: `378156a33230df87e2ef6c56f37c7b76`
+- 获取接口: `https://api.piaoquantv.com/ad/put/tencent/getAccessToken?accountId=80769799`
+
+### 配置文件
+- 已更新 `.env` 文件,添加腾讯广告配置
+- 配置项:
+  ```bash
+  TENCENT_AD_ACCESS_TOKEN=378156a33230df87e2ef6c56f37c7b76
+  TENCENT_AD_ACCOUNT_ID=80769799
+  TENCENT_AD_BASE_URL=https://api.e.qq.com/v3.0
+  ```
+
+---
+
+## 2. 读取操作测试
+
+### 2.1 广告列表查询 ✅
+
+**测试接口**: `GET /v3.0/adgroups/get`
+
+**测试结果**: 成功
+
+**账户数据概览**:
+- 广告总数: **3 个**
+- 状态分布:
+  - 暂停 (AD_STATUS_SUSPEND): 2 个
+  - 投放中 (AD_STATUS_NORMAL): 1 个
+
+---
+
+## 3. 广告详细信息
+
+### 广告 1: 4.8-公众号小程序+朋友圈-R50退休-45+女
+
+| 字段 | 值 |
+|------|-----|
+| **广告 ID** | 93278936685 |
+| **配置状态** | AD_STATUS_SUSPEND (已暂停) |
+| **系统状态** | ADGROUP_STATUS_SUSPEND |
+| **出价** | 0.42 元 |
+| **日预算** | 不限 |
+| **营销目标** | MARKETING_GOAL_USER_GROWTH (用户增长) |
+| **优化目标** | OPTIMIZATIONGOAL_PROMOTION_VIEW_KEY_PAGE (关键页面浏览) |
+| **投放时间** | 2026-04-15 ~ (长期) |
+| **创建时间** | 2026-04-08 15:39:07 |
+
+---
+
+### 广告 2: 4.8-公众号小程序+朋友圈-R50退休-45+男
+
+| 字段 | 值 |
+|------|-----|
+| **广告 ID** | 93276255271 |
+| **配置状态** | AD_STATUS_SUSPEND (已暂停) |
+| **系统状态** | ADGROUP_STATUS_SUSPEND |
+| **出价** | 0.40 元 |
+| **日预算** | 不限 |
+| **营销目标** | MARKETING_GOAL_USER_GROWTH (用户增长) |
+| **优化目标** | OPTIMIZATIONGOAL_PROMOTION_VIEW_KEY_PAGE (关键页面浏览) |
+| **投放时间** | 2026-04-15 ~ (长期) |
+| **创建时间** | 2026-04-08 15:31:57 |
+
+---
+
+### 广告 3: 4.8-公众号小程序+朋友圈-R50退休-60+男女 ⭐
+
+| 字段 | 值 |
+|------|-----|
+| **广告 ID** | 93269884383 |
+| **配置状态** | **AD_STATUS_NORMAL (投放中)** |
+| **系统状态** | **ADGROUP_STATUS_ACTIVE** |
+| **出价** | 0.40 元 |
+| **日预算** | 不限 |
+| **营销目标** | MARKETING_GOAL_USER_GROWTH (用户增长) |
+| **优化目标** | OPTIMIZATIONGOAL_PROMOTION_VIEW_KEY_PAGE (关键页面浏览) |
+| **投放时间** | 2026-04-15 ~ (长期) |
+| **创建时间** | 2026-04-08 15:14:09 |
+
+> **注**: 这是唯一在投放中的广告,可用于后续测试
+
+---
+
+## 4. 测试命令记录
+
+### 获取 Access Token
+```bash
+curl --location 'https://api.piaoquantv.com/ad/put/tencent/getAccessToken?accountId=80769799'
+```
+
+### 查询广告列表(基本信息)
+```bash
+curl "https://api.e.qq.com/v3.0/adgroups/get?access_token=${ACCESS_TOKEN}&timestamp=${TIMESTAMP}&nonce=${NONCE}&account_id=80769799&page=1&page_size=10"
+```
+
+### 查询广告详细信息(含出价、预算等)
+```bash
+FIELDS='["adgroup_id","adgroup_name","configured_status","system_status","bid_amount","daily_budget","marketing_goal","optimization_goal","begin_date","end_date","created_time"]'
+FIELDS_ENC=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$FIELDS")
+
+curl "https://api.e.qq.com/v3.0/adgroups/get?access_token=${ACCESS_TOKEN}&timestamp=${TIMESTAMP}&nonce=${NONCE}&account_id=80769799&fields=${FIELDS_ENC}"
+```
+
+---
+
+## 5. 已测试的读取接口
+
+| 接口 | 方法 | 状态 | 说明 |
+|------|------|------|------|
+| `/adgroups/get` | GET | ✅ 成功 | 查询广告列表,支持字段过滤 |
+
+---
+
+## 6. 未测试的接口
+
+### 6.1 其他读取接口
+
+| 接口 | 用途 | 优先级 |
+|------|------|--------|
+| `/dynamic_creatives/get` | 查询创意列表 | 高 |
+| `/daily_reports/adgroups/get` | 查询广告数据报表 | 高 |
+| `/accounts/get` | 查询账户信息 | 中 |
+| `/custom_audiences/get` | 查询人群包 | 中 |
+| `/material_infos/get` | 查询素材库 | 低 |
+
+### 6.2 写入接口 ⚠️
+
+| 接口 | 用途 | 风险等级 |
+|------|------|----------|
+| `/adgroups/update` | 修改广告(出价/预算/状态) | **高危** |
+| `/adgroups/add` | 创建广告 | 高 |
+| `/dynamic_creatives/update` | 修改创意 | 中 |
+| `/dynamic_creatives/add` | 创建创意 | 中 |
+
+> ⚠️ **重要**: 所有写入操作需要格外小心,建议先在测试账户验证
+
+---
+
+## 7. 下一步计划
+
+### 7.1 继续读取测试 (安全)
+
+1. 测试创意列表查询
+   ```bash
+   curl "https://api.e.qq.com/v3.0/dynamic_creatives/get?access_token=${TOKEN}&timestamp=${TS}&nonce=${NONCE}&account_id=80769799&page=1&page_size=10"
+   ```
+
+2. 测试数据报表查询
+   - 注意: 该接口在之前测试中返回"未知接口"错误
+   - 可能需要特殊权限或不同的 API 路径
+
+### 7.2 写入操作测试 (谨慎)
+
+**建议测试流程**:
+
+1. **第一步**: 测试修改广告名称(影响最小)
+   ```json
+   POST /v3.0/adgroups/update
+   {
+     "account_id": 80769799,
+     "adgroup_id": 93269884383,
+     "adgroup_name": "测试-修改名称"
+   }
+   ```
+
+2. **第二步**: 测试调整出价(可恢复)
+   - 选择已暂停的广告进行测试
+   - 调整幅度 ±5%
+   - 记录原始值,测试后恢复
+
+3. **第三步**: 测试暂停/开启(可逆操作)
+   - 先暂停一个投放中的广告
+   - 立即恢复
+   - 观察是否成功
+
+**安全建议**:
+- ✅ 始终在已暂停的广告上测试
+- ✅ 记录所有原始值,以便恢复
+- ✅ 单次只测试一个操作
+- ✅ 测试后立即验证结果
+- ❌ 不要在投放中的广告上做高风险操作
+
+---
+
+## 8. 系统配置建议
+
+### 8.1 config.py 安全设置
+
+当前配置(已正确):
+```python
+DRY_RUN_MODE = True           # ✅ 不实际执行
+EXECUTION_ENABLED = False     # ✅ 执行引擎关闭
+GUARDRAILS_ENABLED = True     # ✅ 护栏开启
+```
+
+### 8.2 测试账户推荐
+
+建议使用广告 **93269884383** (投放中) 作为测试对象:
+- ✅ 状态正常
+- ✅ 出价较低 (0.40 元)
+- ✅ 无日预算限制
+- ⚠️ 需要先暂停,避免影响实际投放
+
+---
+
+## 9. 已知问题
+
+### 9.1 报表接口错误
+
+**问题**: `/daily_reports/adgroups/get` 返回 code 12007 "未知接口"
+
+**可能原因**:
+1. 账户权限不足
+2. API 路径错误
+3. 该账户不支持该接口
+
+**待验证**: 联系腾讯广告技术支持确认
+
+### 9.2 代理问题
+
+**问题**: Python 环境中配置了代理,导致 httpx 库报错
+
+**解决方案**:
+- 测试脚本中禁用代理
+- 或使用 curl 命令直接测试
+
+---
+
+## 10. 总结
+
+### ✅ 成功项
+
+1. Access Token 获取成功
+2. 环境变量配置完成
+3. 广告列表查询成功
+4. 广告详细信息获取成功
+5. 腾讯广告 API v3.0 连接正常
+
+### ⏳ 待测试项
+
+1. 创意列表查询
+2. 数据报表查询(需解决"未知接口"问题)
+3. 写入操作(修改/创建/删除)
+
+### 🎯 建议
+
+1. **优先测试**: 创意列表查询(低风险)
+2. **谨慎测试**: 写入操作(建议先修改名称)
+3. **暂缓测试**: 数据报表(需确认 API 权限)
+
+---
+
+**测试人员**: Claude (AI Assistant)
+**审核状态**: 待人工确认
+**下一步操作**: 等待用户指令

+ 37 - 0
examples/auto_put_ad_mini/analyze_dimensions.py

@@ -0,0 +1,37 @@
+import pandas as pd
+
+# 读取决策文件
+df = pd.read_csv('outputs/reports/llm_decisions_20260415.csv')
+
+# 只分析智能判断的行
+llm_df = df[df['source'] == '智能判断'].copy()
+
+print(f"总智能决策数:{len(llm_df)}")
+print("\n=== 多维度使用率分析 ===")
+
+# 分析每个维度的使用
+dimensions = {
+    'bid_increased_7d': 'bid_increased_7d',
+    'creative_changed_7d': 'creative_changed_7d',
+    'stable_spend': 'stable_spend',
+    'ad_age': 'ad_age'
+}
+
+for name, keyword in dimensions.items():
+    count = llm_df['reason'].str.contains(keyword, na=False).sum()
+    pct = count / len(llm_df) * 100
+    print(f"{name}: {count}条 ({pct:.1f}%)")
+
+print("\n=== 降价幅度分布 ===")
+bid_down_df = llm_df[llm_df['action'] == 'bid_down']
+print(bid_down_df['recommended_change_pct'].value_counts().sort_index())
+
+print("\n=== 特殊场景识别 ===")
+print(f"调价无效场景: {llm_df['dimension'].str.contains('调价无效', na=False).sum()}条")
+print(f"创意问题场景: {llm_df['reason'].str.contains('creative_changed_7d=true但', na=False).sum()}条")
+print(f"数据不稳定场景: {llm_df['reason'].str.contains('数据不稳定', na=False).sum()}条")
+
+print("\n=== 示例决策(调价无效) ===")
+invalid_price = llm_df[llm_df['dimension'].str.contains('调价无效', na=False)]
+for _, row in invalid_price.head(3).iterrows():
+    print(f"ad_id={row['ad_id']}: {row['reason']}")

+ 494 - 0
examples/auto_put_ad_mini/analyze_snapshot.py

@@ -0,0 +1,494 @@
+"""
+V4 决策快照分析 — 实测观测用
+
+用法:
+  cd /Users/liulidong/project/agent/Agent
+  .venv/bin/python3 examples/auto_put_ad_mini/analyze_snapshot.py --date 20260415
+
+输入:examples/auto_put_ad_mini/outputs/decisions_history/{date}/snapshot.json
+输出:
+  - stdout:可读报告
+  - 同目录 quality_report.json:结构化指标
+
+评估维度(9 项):
+  1. 决策分布(action / source / LLM 占比)
+  2. bid_down 多样性(dimension 种类数 = 上一轮瓶颈)
+  3. rule_alignment 分布 + override 率
+  4. reasoning_chain 质量(覆盖率、长度、引用信号)
+  5. 按 tier 分布
+  6. 信号健康度(decay/bid_down/bid_up 分布)
+  7. 同桶决策一致性(抽样熵)
+  8. 上下文规模估算
+  9. A/B/C/D 判定
+"""
+import argparse
+import json
+import math
+import sys
+from collections import Counter
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+HERE = Path(__file__).resolve().parent
+
+
+# ═══════════════════════════════════════════
+# 工具函数
+# ═══════════════════════════════════════════
+
+def _num_stats(values: List[Any]) -> Optional[Dict[str, float]]:
+    nums = [float(v) for v in values
+            if v is not None and not (isinstance(v, float) and math.isnan(v))]
+    if not nums:
+        return None
+    nums_sorted = sorted(nums)
+    n = len(nums_sorted)
+    return {
+        "count": n,
+        "mean": round(sum(nums_sorted) / n, 4),
+        "min": round(nums_sorted[0], 4),
+        "max": round(nums_sorted[-1], 4),
+        "p25": round(nums_sorted[max(0, n // 4)], 4),
+        "p50": round(nums_sorted[n // 2], 4),
+        "p75": round(nums_sorted[min(n - 1, 3 * n // 4)], 4),
+    }
+
+
+def _mean(values: List[Any]) -> Optional[float]:
+    nums = [float(v) for v in values
+            if v is not None and not (isinstance(v, float) and math.isnan(v))]
+    return round(sum(nums) / len(nums), 4) if nums else None
+
+
+def _nonzero_rate(values: List[Any]) -> Optional[float]:
+    nums = [v for v in values
+            if v is not None and not (isinstance(v, float) and math.isnan(v))]
+    if not nums:
+        return None
+    return round(sum(1 for v in nums if float(v) > 0) / len(nums), 3)
+
+
+def load_snapshot(date: str) -> Dict[str, Any]:
+    snap_path = HERE / "outputs" / "decisions_history" / date / "snapshot.json"
+    if not snap_path.exists():
+        raise FileNotFoundError(f"快照不存在: {snap_path}")
+    return json.loads(snap_path.read_text(encoding="utf-8"))
+
+
+# ═══════════════════════════════════════════
+# 分析模块
+# ═══════════════════════════════════════════
+
+def action_distribution(decisions: List[Dict]) -> Dict[str, Any]:
+    total = len(decisions)
+    action_counter: Counter = Counter()
+    source_counter: Counter = Counter()
+    for d in decisions:
+        action_counter[d.get("action", "unknown")] += 1
+        source_counter[d.get("source", "unknown")] += 1
+    llm_keys = {"llm", "llm_main", "llm_override"}
+    llm_count = sum(v for k, v in source_counter.items() if k in llm_keys)
+    return {
+        "total": total,
+        "action_counts": dict(action_counter),
+        "action_pct": {k: round(v / total, 3) for k, v in action_counter.items()} if total else {},
+        "source_counts": dict(source_counter),
+        "llm_decision_count": llm_count,
+        "llm_decision_pct": round(llm_count / total, 3) if total else 0,
+    }
+
+
+def bid_down_diversity(decisions: List[Dict]) -> Dict[str, Any]:
+    bid_down = [d for d in decisions if d.get("action") == "bid_down"]
+    dim_counter = Counter(d.get("dimension", "unknown") for d in bid_down)
+    scenario_counter = Counter(
+        (d.get("signal_scores") or {}).get("bid_down_scenario", "unknown")
+        for d in bid_down
+    )
+    pcts = [d.get("recommended_change_pct") for d in bid_down
+            if d.get("recommended_change_pct") is not None]
+    return {
+        "total": len(bid_down),
+        "dimension_count": len(dim_counter),
+        "dimensions": dict(dim_counter.most_common(10)),
+        "scenarios": dict(scenario_counter.most_common()),
+        "pct_stats": _num_stats(pcts),
+    }
+
+
+def rule_alignment_dist(decisions: List[Dict]) -> Dict[str, Any]:
+    align_counter: Counter = Counter()
+    override_reasons: List[str] = []
+    for d in decisions:
+        # 跳过 auto_pause(规则硬底线,不参与 align 统计)
+        if d.get("source") == "auto_pause":
+            continue
+        align = d.get("rule_alignment") or "unknown"
+        align_counter[align] += 1
+        if align == "override" and d.get("override_reason"):
+            override_reasons.append(str(d["override_reason"]))
+    known_total = sum(v for k, v in align_counter.items() if k != "unknown")
+    return {
+        "counts": dict(align_counter),
+        "override_pct": (round(align_counter.get("override", 0) / known_total, 3)
+                         if known_total else None),
+        "override_reason_samples": override_reasons[:5],
+    }
+
+
+def reasoning_chain_quality(decisions: List[Dict]) -> Dict[str, Any]:
+    lengths: List[int] = []
+    signal_counter: Counter = Counter()
+    has_chain = 0
+    total_llm = 0
+    for d in decisions:
+        # 只看非 auto_pause 的决策(LLM 产出的)
+        if d.get("source") == "auto_pause":
+            continue
+        total_llm += 1
+        chain = d.get("reasoning_chain")
+        if isinstance(chain, list) and len(chain) > 0:
+            has_chain += 1
+            lengths.append(len(chain))
+            for item in chain:
+                if isinstance(item, dict):
+                    signal_counter[str(item.get("signal", "unknown"))] += 1
+    return {
+        "llm_decisions": total_llm,
+        "with_reasoning_chain": has_chain,
+        "reasoning_chain_rate": round(has_chain / total_llm, 3) if total_llm else 0,
+        "length_stats": _num_stats(lengths),
+        "top_signals": dict(signal_counter.most_common(10)),
+    }
+
+
+def tier_breakdown(decisions: List[Dict]) -> Dict[str, Any]:
+    tier_groups: Dict[str, List[Dict]] = {}
+    for d in decisions:
+        tier = (d.get("input_signals") or {}).get("audience_tier", "default")
+        tier_groups.setdefault(str(tier), []).append(d)
+    out = {}
+    for tier, items in sorted(tier_groups.items()):
+        action_counter = Counter(d.get("action", "unknown") for d in items)
+        ss = [(d.get("signal_scores") or {}) for d in items]
+        out[tier] = {
+            "count": len(items),
+            "actions": dict(action_counter),
+            "avg_decay_score": _mean([s.get("decay_score") for s in ss]),
+            "avg_bid_down_score": _mean([s.get("bid_down_score") for s in ss]),
+            "avg_bid_up_score": _mean([s.get("bid_up_score") for s in ss]),
+        }
+    return out
+
+
+def signal_health(decisions: List[Dict]) -> Dict[str, Any]:
+    decay, bd, bu = [], [], []
+    for d in decisions:
+        ss = d.get("signal_scores") or {}
+        decay.append(ss.get("decay_score"))
+        bd.append(ss.get("bid_down_score"))
+        bu.append(ss.get("bid_up_score"))
+    return {
+        "decay_score": _num_stats(decay),
+        "bid_down_score": _num_stats(bd),
+        "bid_up_score": _num_stats(bu),
+        "decay_nonzero_rate": _nonzero_rate(decay),
+        "bid_down_nonzero_rate": _nonzero_rate(bd),
+        "bid_up_nonzero_rate": _nonzero_rate(bu),
+    }
+
+
+def consistency_check(decisions: List[Dict]) -> Dict[str, Any]:
+    """
+    同桶决策一致性:
+    按 (tier, roi bucket 0.2, decay bucket 0.2) 分组,计算每组 action 分布熵。
+    熵越高 = 决策越不一致。
+    """
+    buckets: Dict[Tuple[str, float, float], List[str]] = {}
+    for d in decisions:
+        ss = d.get("signal_scores") or {}
+        inp = d.get("input_signals") or {}
+        tier = str(inp.get("audience_tier") or "default")
+        roi = inp.get("动态ROI_7日均值")
+        decay = ss.get("decay_score")
+        if roi is None or decay is None:
+            continue
+        try:
+            roi_f = float(roi)
+            decay_f = float(decay)
+            if math.isnan(roi_f) or math.isnan(decay_f):
+                continue
+        except (TypeError, ValueError):
+            continue
+        roi_b = round(math.floor(roi_f / 0.2) * 0.2, 1)
+        decay_b = round(math.floor(decay_f / 0.2) * 0.2, 1)
+        key = (tier, roi_b, decay_b)
+        buckets.setdefault(key, []).append(str(d.get("action", "unknown")))
+
+    high_entropy: List[Dict[str, Any]] = []
+    entropies: List[float] = []
+    for key, actions in buckets.items():
+        if len(actions) < 2:
+            continue
+        c = Counter(actions)
+        n = len(actions)
+        entropy = -sum((v / n) * math.log2(v / n) for v in c.values() if v > 0)
+        entropies.append(entropy)
+        if entropy > 0.8:
+            high_entropy.append({
+                "bucket": f"tier={key[0]}, roi~{key[1]}, decay~{key[2]}",
+                "n": n,
+                "actions": dict(c),
+                "entropy": round(entropy, 3),
+            })
+    high_entropy.sort(key=lambda x: x["entropy"], reverse=True)
+    return {
+        "buckets_with_gt1": sum(1 for _, a in buckets.items() if len(a) >= 2),
+        "avg_entropy": round(sum(entropies) / len(entropies), 3) if entropies else 0,
+        "max_entropy": round(max(entropies), 3) if entropies else 0,
+        "high_entropy_buckets": high_entropy[:5],
+    }
+
+
+def context_size_est(decisions: List[Dict]) -> Dict[str, Any]:
+    """估算每次 LLM tier 批次的输入 token 量"""
+    tier_counts: Counter = Counter()
+    for d in decisions:
+        if d.get("source") == "auto_pause":
+            continue
+        tier = str((d.get("input_signals") or {}).get("audience_tier", "default"))
+        tier_counts[tier] += 1
+
+    sample_reviews = [d for d in decisions if d.get("source") != "auto_pause"][:5]
+    avg_bytes = 0
+    if sample_reviews:
+        avg_bytes = sum(len(json.dumps(d, ensure_ascii=False)) for d in sample_reviews) / len(sample_reviews)
+
+    by_tier_est: Dict[str, Dict[str, int]] = {}
+    for tier, cnt in tier_counts.items():
+        # 1 token ≈ 3 字节(中文偏 2~3 字节/token)+ 8K 系统 prompt
+        bytes_est = cnt * avg_bytes + 8000
+        by_tier_est[tier] = {
+            "review_count": int(cnt),
+            "est_bytes": int(bytes_est),
+            "est_tokens": int(bytes_est / 3),
+        }
+
+    max_tier_tokens = max((v["est_tokens"] for v in by_tier_est.values()), default=0)
+    return {
+        "avg_review_bytes": int(avg_bytes),
+        "by_tier": by_tier_est,
+        "max_tier_tokens": max_tier_tokens,
+    }
+
+
+# ═══════════════════════════════════════════
+# 报告构建 + 判定
+# ═══════════════════════════════════════════
+
+def build_report(snapshot: Dict) -> Dict:
+    decisions = snapshot.get("decisions", [])
+    return {
+        "metadata": snapshot.get("metadata", {}),
+        "action_distribution": action_distribution(decisions),
+        "bid_down_diversity": bid_down_diversity(decisions),
+        "rule_alignment": rule_alignment_dist(decisions),
+        "reasoning_chain": reasoning_chain_quality(decisions),
+        "tier_breakdown": tier_breakdown(decisions),
+        "signal_health": signal_health(decisions),
+        "consistency": consistency_check(decisions),
+        "context_size_est": context_size_est(decisions),
+    }
+
+
+def assess(report: Dict) -> Tuple[str, List[str]]:
+    """A/B/C/D 判定"""
+    flags: List[str] = []
+
+    # bid_down 多样性
+    bd_div = report["bid_down_diversity"]["dimension_count"]
+    if bd_div < 3:
+        flags.append(f"bid_down dimension 种类 {bd_div}(< 3,多样性不足)")
+
+    # LLM 决策占比(auto_pause 不算 LLM)
+    llm_pct = report["action_distribution"]["llm_decision_pct"]
+    if llm_pct < 0.7:
+        flags.append(f"LLM 决策占比 {llm_pct}(< 70%,规则压过 LLM)")
+
+    # override 率
+    ra = report["rule_alignment"]["override_pct"]
+    if ra is not None:
+        if ra < 0.05:
+            flags.append(f"override 率 {ra}(< 5%,LLM 不敢推翻规则)")
+        elif ra > 0.4:
+            flags.append(f"override 率 {ra}(> 40%,signal_scores 可能不准)")
+
+    # reasoning_chain
+    rc_rate = report["reasoning_chain"]["reasoning_chain_rate"]
+    if rc_rate < 0.95:
+        flags.append(f"reasoning_chain 缺失率 {round(1 - rc_rate, 3)}(LLM 未遵循 schema)")
+
+    # 一致性
+    max_ent = report["consistency"]["max_entropy"]
+    if max_ent > 1.2:
+        flags.append(f"同桶决策熵最大 {max_ent}(> 1.2,稳定性不足)")
+
+    # token
+    max_tok = report["context_size_est"]["max_tier_tokens"]
+    if max_tok > 100_000:
+        flags.append(f"最大 tier token {max_tok}(> 100K,需细分桶)")
+
+    # 分支判定
+    if not flags:
+        return "case_A: V4 架构够用,可进入后验采集下一 plan", flags
+    for f in flags:
+        if "token" in f:
+            return "case_D: tier 细分桶 / signal_scores 精简", flags
+    for f in flags:
+        if "决策熵" in f:
+            return "case_C: 考虑对边缘案例单条深判", flags
+    return "case_B: 调 prompt 或 DECAY_WEIGHTS", flags
+
+
+# ═══════════════════════════════════════════
+# 可读输出
+# ═══════════════════════════════════════════
+
+def print_report(report: Dict, assessment: Tuple[str, List[str]]) -> None:
+    meta = report["metadata"]
+    print("=" * 70)
+    print("V4 决策快照分析报告")
+    print(f"决策日期   : {meta.get('decision_date')}")
+    print(f"Agent 版本 : {meta.get('agent_version')}")
+    print(f"LLM 模型   : {meta.get('llm_model')}")
+    print(f"决策总数   : {meta.get('decision_count')}")
+    print(f"运行时间   : {meta.get('run_timestamp')}")
+    print("=" * 70)
+
+    # 1. 决策分布
+    print("\n【1. 决策分布】")
+    ad = report["action_distribution"]
+    print(f"  Action: {ad['action_counts']}")
+    print(f"  Source: {ad['source_counts']}")
+    print(f"  LLM 决策占比: {ad['llm_decision_pct'] * 100:.1f}%  (目标 >= 70%)")
+
+    # 2. bid_down 多样性
+    print("\n【2. bid_down 多样性(上一轮瓶颈)】")
+    bd = report["bid_down_diversity"]
+    print(f"  bid_down 总数: {bd['total']}")
+    print(f"  Dimension 种类: {bd['dimension_count']}  (目标 >= 4)")
+    if bd["dimensions"]:
+        print(f"  Dimensions: {bd['dimensions']}")
+    if bd["scenarios"]:
+        print(f"  Scenarios: {bd['scenarios']}")
+    if bd["pct_stats"]:
+        print(f"  推荐幅度: mean={bd['pct_stats']['mean']}, "
+              f"p25/50/75={bd['pct_stats']['p25']}/{bd['pct_stats']['p50']}/{bd['pct_stats']['p75']}")
+
+    # 3. rule_alignment
+    print("\n【3. rule_alignment(LLM 是否推翻规则)】")
+    ra = report["rule_alignment"]
+    print(f"  分布: {ra['counts']}")
+    print(f"  override 率: {ra['override_pct']}  (目标 10% ~ 25%)")
+    if ra["override_reason_samples"]:
+        print(f"  override_reason 样本:")
+        for i, r in enumerate(ra["override_reason_samples"][:3], 1):
+            print(f"    [{i}] {r[:80]}")
+
+    # 4. reasoning_chain
+    print("\n【4. reasoning_chain 质量】")
+    rc = report["reasoning_chain"]
+    print(f"  LLM 决策数: {rc['llm_decisions']}")
+    print(f"  含 chain: {rc['with_reasoning_chain']} ({rc['reasoning_chain_rate'] * 100:.1f}%)  (目标 >= 95%)")
+    if rc["length_stats"]:
+        print(f"  chain 长度: mean={rc['length_stats']['mean']}, p50={rc['length_stats']['p50']}")
+    if rc["top_signals"]:
+        print(f"  Top 引用信号: {rc['top_signals']}")
+
+    # 5. tier 分布
+    print("\n【5. 按 tier 分布】")
+    for tier, info in report["tier_breakdown"].items():
+        print(f"  {tier:8s} count={info['count']:3d}  actions={info['actions']}")
+        print(f"           avg: decay={info['avg_decay_score']}  "
+              f"bd={info['avg_bid_down_score']}  bu={info['avg_bid_up_score']}")
+
+    # 6. 信号健康度
+    print("\n【6. 信号健康度】")
+    sh = report["signal_health"]
+    if sh["decay_score"]:
+        s = sh["decay_score"]
+        print(f"  decay_score    : mean={s['mean']}  max={s['max']}  nonzero={sh['decay_nonzero_rate']}")
+    if sh["bid_down_score"]:
+        s = sh["bid_down_score"]
+        print(f"  bid_down_score : mean={s['mean']}  max={s['max']}  nonzero={sh['bid_down_nonzero_rate']}")
+    if sh["bid_up_score"]:
+        s = sh["bid_up_score"]
+        print(f"  bid_up_score   : mean={s['mean']}  max={s['max']}  nonzero={sh['bid_up_nonzero_rate']}")
+
+    # 7. 同桶一致性
+    print("\n【7. 同桶决策一致性】")
+    c = report["consistency"]
+    print(f"  有效桶(>= 2 广告): {c['buckets_with_gt1']}")
+    print(f"  平均熵: {c['avg_entropy']}  max: {c['max_entropy']}  (告警 > 1.2)")
+    if c["high_entropy_buckets"]:
+        print("  高熵桶 Top 3:")
+        for b in c["high_entropy_buckets"][:3]:
+            print(f"    {b['bucket']}  n={b['n']}  {b['actions']}  entropy={b['entropy']}")
+
+    # 8. 上下文规模
+    print("\n【8. 上下文规模估算】")
+    cs = report["context_size_est"]
+    print(f"  平均 review 字节: {cs['avg_review_bytes']}")
+    print(f"  最大 tier token: {cs['max_tier_tokens']}  (告警 > 100K)")
+    for tier, info in cs["by_tier"].items():
+        print(f"  {tier:8s} {info['review_count']:3d} ads  →  ~{info['est_tokens']} tokens")
+
+    # 9. 判定
+    print("\n【9. 判定】")
+    case, flags = assessment
+    print(f"  → {case}")
+    if flags:
+        for f in flags:
+            print(f"    • {f}")
+    else:
+        print("    ✅ 所有关键指标达标")
+
+
+# ═══════════════════════════════════════════
+# Entry
+# ═══════════════════════════════════════════
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="V4 决策快照分析")
+    parser.add_argument("--date", required=True, help="决策日期 YYYYMMDD")
+    args = parser.parse_args()
+
+    try:
+        snapshot = load_snapshot(args.date)
+    except FileNotFoundError as e:
+        print(f"❌ {e}")
+        sys.exit(1)
+
+    report = build_report(snapshot)
+    assessment = assess(report)
+    print_report(report, assessment)
+
+    # 落盘
+    out_dir = HERE / "outputs" / "decisions_history" / args.date
+    out_path = out_dir / "quality_report.json"
+    out_path.write_text(
+        json.dumps(
+            {
+                "report": report,
+                "assessment": {"case": assessment[0], "flags": assessment[1]},
+            },
+            ensure_ascii=False,
+            indent=2,
+        ),
+        encoding="utf-8",
+    )
+    print(f"\n✅ 报告落盘: {out_path}")
+
+
+if __name__ == "__main__":
+    main()

+ 305 - 0
examples/auto_put_ad_mini/docs/strategy_review_2026-04-16.md

@@ -0,0 +1,305 @@
+# auto_put_ad_mini 策略 Review(2026-04-16)
+
+> **作者**:Claude(资深投流专家视角)
+> **目的**:对 auto_put_ad_mini 做系统性 review,从业务目标 / 业务理解 / 数据层 / 工程实现 四个维度识别缺口,为后续迭代提供专家级分析留档。
+> **使用方式**:后续每轮迭代前回读此文档,确认"当前走到哪一步、下一步该做什么、为什么"。
+
+---
+
+## 目录
+
+- [0. 当前系统成熟度](#0-当前系统成熟度)
+- [1. 维度一:业务目标层](#1-维度一业务目标层)
+- [2. 维度二:业务理解层](#2-维度二业务理解层)
+- [3. 维度三:数据层](#3-维度三数据层)
+- [4. 维度四:工程实现层](#4-维度四工程实现层)
+- [5. 最高 ROI 的完整升级路径](#5-最高-roi-的完整升级路径)
+- [6. 下轮议题候选](#6-下轮议题候选)
+- [7. 附录:现状快照](#7-附录现状快照)
+
+---
+
+## 0. 当前系统成熟度
+
+当前系统**架构是对的**(fetch → merge → roi → classify → llm → validate → approval → execute),但**决策成熟度停在 L1-L2**,离"最优效果"还差三块关键升级。
+
+| 级别 | 能力 | 当前状态 |
+|------|------|---------|
+| L1 | 单指标阈值决策 | ✅ 有 |
+| L2 | 多维度组合决策 | 🟡 部分(ROI + 消耗,缺创意 + 生命周期 + 大盘) |
+| L3 | **趋势 / 结构感知** | ❌ 无(只看当前快照,不看变化) |
+| L4 | **反馈学习** | ❌ 无(决策效果不回流) |
+| L5 | **大盘联动** | ❌ 无(缺跨账户基准) |
+
+**核心洞察**:ROI 是结果,不是原因。只盯 ROI 做决策是"**后视镜驾驶**"。真正预示未来的是领先指标(点击率趋势、进入小程序转化率、裂变系数变化、创意级 ROI 分布)。
+
+---
+
+## 1. 维度一:业务目标层
+
+### 1.1 当前目标
+
+> 基于 ROI + 跑量双维度,对在投广告做调价 / 暂停。
+
+### 1.2 专家视角的问题
+
+#### 问题 1:目标函数单一
+
+只有 ROI,但投流的真实目标是"**在预算约束下最大化利润**"。当前系统缺 3 个隐式但关键的目标:
+
+- **规模目标**(消耗利用率):预算花不出去 = 机会成本丢失
+- **多样性目标**(人群池健康度):过度集中在 R50 快钱人群 → 短期 ROI 高但长期池子枯竭
+- **风控目标**(单次调整的 blast radius):Tier 3 高价值广告一刀切暂停可能造成账户流量断崖
+
+#### 问题 2:没有"时间窗口"的策略分层
+
+- **短期**(小时级):止损、预算管控
+- **中期**(日级):效率优化
+- **长期**(周 / 月级):结构调整、人群池经营
+
+当前系统只有日级,时间维度折叠。
+
+#### 问题 3:"最大化长期 ROI"的"长期"没定义
+
+到底是 7 日?30 日?LTV?每个定义下,决策逻辑不同。
+
+### 1.3 建议补充的目标表达
+
+```
+主目标:account_daily_profit = daily_revenue - daily_cost
+约束 1:budget_utilization >= 85%(预算别闲置)
+约束 2:audience_portfolio_health(各 R 值层有健康广告数)
+约束 3:max_blast_radius_per_decision <= 5%(单次调整不动摇 > 5% 的日消耗)
+时间窗口:滚动 7 日利润最优,兼顾 30 日趋势不恶化
+```
+
+---
+
+## 2. 维度二:业务理解层
+
+当前 skill 已经讲了 R 值含义、回流模型、f_7日动态ROI 公式(上一轮重构加的)。但**还缺 4 块投流专家必备的心智模型**。
+
+### 2.1 广告生命周期的阶段化策略(缺失)
+
+| 阶段 | 时长 | 核心信号 | 核心策略 |
+|------|------|---------|---------|
+| 冷启动期 | 0-4 天 | 点击率、CVR 爬升速度 | 绝对保护,不做负向(已有) |
+| 学习期 | 4-14 天 | ROI 是否收敛 | 观察,谨慎微调 |
+| 稳定期 | 14-60 天 | ROI 波动范围 | 正常调控(当前系统主要服务这里) |
+| 衰退期 | 60+ 天 | ROI 斜率下降 | 判断是创意疲劳还是人群饱和 |
+| 僵尸期 | 任意 | 连续无消耗 | 回收预算 |
+
+当前系统只区分"冷启动 / 正常",把学习期、衰退期、僵尸期混在"非冷启动"里,**决策粒度太粗**。
+
+### 2.2 创意 vs 广告的归因分离(缺失)
+
+**核心洞察**:在腾讯 3.0"少广告多素材"策略下,**广告层级的 ROI 问题 80% 是创意问题**。
+
+- 当前系统只看广告级 ROI → 做出"降价 / 暂停广告"决策
+- 正确姿势:广告 ROI 降 → 先归因到创意(是 A 创意疲劳?还是 B 创意刚上线在冷启?)→ 再决定是换创意还是动广告
+- **代码已经采集了 creative_id**,但没做创意级 ROI 聚合 —— **这是最大的数据浪费**
+
+### 2.3 竞价生态感知(缺失)
+
+oCPM 出价是拍卖,ROI 下降的原因可能是:
+
+- **自己的问题**(创意疲劳、人群匹配差)
+- **大盘问题**(行业大盘涨、同时段竞品加价)
+- **账户层面**(同账户内广告互相抢量)
+
+当前系统全部归因于"自己",没有"**同时段大盘参考**",导致大盘涨时所有广告都在降价 → 量更少 → ROI 更差的**死亡螺旋**。
+
+### 2.4 预算 pacing(消耗节奏)(缺失)
+
+腾讯 oCPM 对"提前花完 day_amount"的广告会**自动降量**(防止一小时烧光)。
+
+- 当前系统有 `day_amount` 字段但**未在决策中使用**
+- 应该看 `daily_cost / day_amount` = 预算利用率:
+  - `< 50%`:广告跑不动(出价低?创意差?人群窄?),**不应再降价**
+  - `50-90%`:健康
+  - `> 95%` 且时间早:可能触发平台降速,**提价反而有害**
+
+---
+
+## 3. 维度三:数据层
+
+### 3.1 🔴 P0:已经算出来但没给 LLM(立即可补,0 成本)
+
+| 字段 | 现状 | 影响 |
+|------|------|------|
+| `bid_increased_7d` | `ad_decision.py:400` 算了,没写进 metrics CSV | LLM 无法识别"已干预但无效"的衰退信号 |
+| `creative_changed_7d` | 同上 | 同上 |
+| `audience_tier`(R 值) | 能从 ad_name 提取,但 metrics CSV 没这一列 | LLM 做人群间比较时要靠 ad_name 文本匹配,容易错 |
+
+**这三个字段是 LLM 做衰退判断 + 人群比较的必需品,现在缺席 = skill 里说了也用不起来**。
+
+### 3.2 🟠 P1:数据已有,差一步聚合(低成本)
+
+| 维度 | 当前现状 | 需要做的 |
+|------|---------|---------|
+| 创意级 ROI | merged CSV 有 creative_id 和所有指标 | 按 creative_id 聚合 `creative_metrics.csv`,LLM 能看到"哪个创意在拖后腿" |
+| 人群包级汇总 | ad_name 可提取 R 值 | 按 R 值分组汇总 ROI 均值 / p25 / p75 / 广告数,提供"R 值基线" |
+| 账户级汇总 | 每个 ad 有 account_id | 日级账户消耗、收入、ROI,看账户整体健康度 |
+| 预算利用率 | day_amount + daily_cost 都有 | `budget_utilization = cost / day_amount` |
+| 趋势(斜率) | 7 日数据都有 | 计算 ROI 最近 3 天 vs 前 4 天的差值 / 变化率,判断是变好还是变差 |
+
+### 3.3 🟡 P2:需要新增数据源(中等成本)
+
+| 数据 | 来源 | 价值 |
+|------|------|------|
+| 小时级消耗速率 | 腾讯 Reporting API 支持 `group_by=[date,hour]` | 止损 + 预算 pacing |
+| 创意上线 / 更新时间 | 腾讯 `dynamic_creatives/get` | 识别"新创意冷启动" vs "老创意衰退" |
+| 账户级大盘(所有账户汇总) | 跨账户查询 | 大盘对比基准,避免死亡螺旋 |
+| 同 R 值同地域大盘 | ODPS | 同可比人群的基准 |
+
+### 3.4 🟢 P3:结构化运营 feedback(高价值但需基础设施)
+
+| 数据 | 价值 |
+|------|------|
+| 决策 → 实际效果的回流数据 | 自学习的基础,知道"ROI=1.5 降 5% 的广告,通常 3 天后 ROI 变多少" |
+| 运营拒绝 / 修改决策的原因 | 积累人类知识,修正模型偏见 |
+| 历史的"救活"案例 | 哪些暂停后又开启的广告后来跑得好,那就不该暂停 |
+
+---
+
+## 4. 维度四:工程实现层
+
+### 4.1 🔴 P0(阻塞业务)
+
+1. **`EXECUTION_ENABLED=False`**:整个系统当前是"纸面决策机",不实际执行(`config.py:113`)
+2. **调整历史 JSON 单点**:频率护栏靠 `outputs/data/adjustment_history.json`,无数据库、无并发安全、重启易丢(`guardrails.py:61`)
+3. **护栏钳位后 change_pct 不同步**:`recommended_bid` 被钳到下限,但 `change_pct` 还是原值,报告里数据矛盾(`guardrails.py:316`)
+
+### 4.2 🟠 P1(功能不完整)
+
+4. **没有反馈环**:决策执行后的实际 ROI 变化无回流、无评估 → 无法学习
+5. **决策无版本**:同日多次 LLM 调用会覆盖文件,无法追溯"为什么当时做了这个决策"
+6. **B 类分类规则和 LLM 决策标准不一致**:B 类触发条件(`roi_review_factor=0.8`)和 skill 里说的"均值×0.5 关停 / 0.8 降价"边界有错位
+7. **审批回复靠 LLM 理解自然语言**:鲁棒性差,应加"结构化快捷回复"兜底(例如飞书按钮)
+
+### 4.3 🟡 P2(优化项)
+
+8. LLM 输出无 schema 校验
+9. 干运行模式日志丢失
+10. 无单元测试
+11. A 类广告(零消耗)在多个环节重复检测
+12. 没有异常处理的"完全失败"恢复机制
+13. 没有数据新鲜度通知(只有 query_ad_detail 有)
+14. 报告排序逻辑复杂且脆弱
+15. LLM 的 confidence 字段未被后续环节利用
+16. 飞书审批超时处理过于简单
+17. API 失败重试无明确的幂等性保证
+18. 护栏链对并发决策无保护
+
+---
+
+## 5. 最高 ROI 的完整升级路径
+
+> 本轮只做前 4 步。后面 2 步留给下一轮。
+
+1. ✅ **补 P0 数据字段**(3 个字段写进 metrics)→ LLM 立刻能做衰退判断【本轮】
+2. ✅ **加创意级 ROI 聚合** → LLM 能归因"广告差 vs 创意差"【本轮】
+3. 🟡 **加趋势 / 斜率字段** → 从"快照决策"升级到"趋势决策"【本轮部分覆盖;完整版留下轮】
+4. ✅ **补预算利用率 + 账户级 / 人群包级汇总** → 加入预算目标和同组对比【本轮】
+5. ❌ **建反馈环** → 记录"决策 t0 → 结果 t+3d"的 pair,给 LLM 做事后复盘【下轮】
+6. ❌ **小时级止损 + 生命周期阶段识别** → 升级到 L3 完整形态【下轮】
+
+### 本轮明确不做(用户已确认)
+
+- ❌ 反馈环(决策效果回流、历史案例库)
+- ❌ 小时级数据
+- ❌ 大盘对比
+- ❌ 开启 EXECUTION_ENABLED(先把决策质量做对)
+
+---
+
+## 6. 下轮议题候选
+
+- **反馈环的存储方式**:SQLite 表 vs JSONL vs 时序数据库
+- **小时级数据的拉取频率**:每 15 分钟 vs 每小时 vs 按需
+- **大盘对比的范围**:自家多账户 vs 行业(需要第三方数据)
+- **生命周期阶段识别**:规则式(天数分段)vs 基于指标曲线的状态机
+- **LLM 决策的"置信度"字段**是否用于触发不同审批级别
+- **审批流的结构化回复兜底**(飞书 interactive message / button)
+- **决策趋势/斜率字段**:roi_slope_3d、roi_slope_7d 的具体算法
+- **广告生命周期识别**:天数分段阈值、衰退斜率阈值
+
+---
+
+## 7. 附录:现状快照
+
+### 7.1 已实现的关键能力(截至 2026-04-16)
+
+- ✅ 数据拉取:日级创意数据(ODPS)+ 广告状态(腾讯 API)
+- ✅ ROI 计算:f_7日动态ROI 完整公式(T0 裂变 + arpu + 回流倍数 + 裂变效率稳定因子)
+- ✅ 决策引擎:A/B/C 三类广告分类 + LLM 智能决策
+- ✅ 护栏系统:6 道护栏(冷启动 / 新鲜度 / 出价边界 / 频率 / 日上限 / 干运行)
+- ✅ 执行引擎:TokenBucket 限流 + 指数退避重试 + 审计日志
+- ✅ IM 审批:飞书阻塞式审批 + 自然语言理解回复
+- ✅ 三模工作流:Mode 1 全量分析 / Mode 2 定向操作 / Mode 3 反馈修改
+- ✅ 领域知识:专家角色定位 + R 值解读 + 实战 knowhow(上一轮刚补的)
+
+### 7.2 当前 metrics CSV 列(16 列)
+
+```
+ad_id, account_id, ad_name, create_time, configured_status, bid_amount, creative_count,
+cost_7d_total, revenue_7d_total, cost_7d_avg,
+动态ROI, 动态ROI_7日均值,
+cost_30d_total, cost_30d_avg, stable_spend_days_30d, ad_age_days
+```
+
+### 7.3 本轮升级后预期 metrics CSV 列(约 25 列)
+
+在 7.2 基础上追加:
+
+```
+# P0 字段(Phase 1)
+bid_increased_7d, creative_changed_7d, audience_tier
+
+# 预算 pacing(Phase 2)
+day_amount, budget_utilization_7d, budget_headroom_pct
+
+# 额外(可能衍生)
+dominant_creative_id, dominant_creative_cost_share,
+audience_tier_roi_baseline_p25, audience_tier_roi_baseline_p75
+```
+
+### 7.4 关键代码位置速查
+
+| 功能 | 文件:行号 |
+|------|---------|
+| 数据拉取 SQL 构建 | `tools/data_query.py:78-217` |
+| 创意→广告聚合 | `tools/roi_calculator.py:47-118` |
+| f_7日动态ROI 公式 | `tools/roi_calculator.py:123-202` |
+| ROI 计算入口 | `tools/roi_calculator.py:320-462` |
+| 干预信号计算(待迁移) | `tools/ad_decision.py:400-460` |
+| A/B/C 分类 | `tools/ad_decision.py:798-920` |
+| apply_decisions 合并 | `tools/ad_decision.py:973-1103` |
+| modify_decisions upsert | `tools/ad_decision.py:1240-1461` |
+| 6 道护栏实现 | `tools/guardrails.py:190-451` |
+| AdjustmentHistory | `tools/guardrails.py:61-140` |
+| 护栏链执行 | `tools/guardrails.py:458-547` |
+| Tier 分级 | `tools/execution_engine.py:193-223` |
+| 执行主逻辑 | `tools/execution_engine.py:230-565` |
+| 审批请求发送 | `tools/im_approval.py:270-546` |
+| 审批回复解析 | `tools/im_approval.py:248-262` |
+| 报告生成 | `tools/report_generator.py:182-256` |
+| 决策阈值 config | `config.py:70-97` |
+| R 值匹配规则 | `config.py:164-175` |
+
+---
+
+## 文档使用指引
+
+**何时回读此文档?**
+
+- 每轮迭代开始前:确认当前走到哪一步,下一步优先级
+- 做决策设计时:回看"业务理解层"的四个心智模型
+- 遇到疑难决策时:回看"业务目标层"的主目标 + 约束表达
+- 工程加固时:回看"工程实现层"的 18 个缺陷清单
+
+**何时更新此文档?**
+
+- 完成一轮迭代后,在[第 5 节升级路径]里把完成项 `🟡` → `✅`
+- 新发现的缺口加进[第 6 节下轮议题]
+- 重大认知更新(如业务模型变化)需另起一版

+ 88 - 0
examples/auto_put_ad_mini/get_chat_id.py

@@ -0,0 +1,88 @@
+"""
+获取飞书群聊 ID 工具
+
+使用步骤:
+1. 确保机器人已加入目标群聊
+2. 运行此脚本
+3. 在群聊中发送任意消息(@机器人)
+4. 脚本会输出群聊 ID
+"""
+
+import asyncio
+import logging
+import sys
+from pathlib import Path
+
+# 添加项目路径
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from agent.tools.builtin.feishu.feishu_client import FeishuClient
+from dotenv import load_dotenv
+load_dotenv()
+
+from config import FEISHU_APP_ID, FEISHU_APP_SECRET
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+def on_message_received(event):
+    """消息回调函数"""
+    print("\n" + "=" * 60)
+    print("📩 收到消息")
+    print("=" * 60)
+    print(f"群聊 ID (chat_id):    {event.chat_id}")
+    print(f"发送者 ID (sender_open_id): {event.sender_open_id}")
+    print(f"发送者姓名:           {event.sender_name or '未知'}")
+    print(f"消息内容:             {event.content}")
+    print(f"聊天类型:             {event.chat_type.value}")
+    print("=" * 60)
+
+    if event.chat_type.value == "group":
+        print(f"\n✅ 请将以下群聊 ID 配置到 .env 文件:")
+        print(f"FEISHU_AD_PROJECT_CHAT_ID={event.chat_id}")
+    else:
+        print(f"\n⚠️  这是私聊消息,群聊 ID 是 {event.chat_id}")
+    print()
+
+
+def main():
+    print("=" * 60)
+    print("飞书群聊 ID 获取工具")
+    print("=" * 60)
+    print()
+    print("📋 使用说明:")
+    print("1. 确保机器人已加入目标群聊")
+    print("2. 脚本启动后,在群聊中发送任意消息(建议@机器人)")
+    print("3. 脚本会自动输出群聊 ID")
+    print()
+    print(f"飞书应用 ID: {FEISHU_APP_ID}")
+    print()
+    print("🚀 启动监听...")
+    print("=" * 60)
+    print()
+
+    client = FeishuClient(
+        app_id=FEISHU_APP_ID,
+        app_secret=FEISHU_APP_SECRET
+    )
+
+    # 启动 WebSocket 监听(阻塞模式)
+    try:
+        client.start_websocket(
+            on_message=on_message_received,
+            blocking=True
+        )
+    except KeyboardInterrupt:
+        print("\n\n✋ 用户中断,退出程序")
+    except Exception as e:
+        logger.error(f"监听失败: {e}", exc_info=True)
+        print(f"\n❌ 启动失败: {e}")
+        print("\n请检查:")
+        print("  1. FEISHU_APP_ID 和 FEISHU_APP_SECRET 是否正确")
+        print("  2. 机器人应用是否已启用事件订阅")
+        print("  3. 网络连接是否正常")
+
+
+if __name__ == "__main__":
+    main()

+ 3 - 0
examples/auto_put_ad_mini/prompts/system.prompt

@@ -421,6 +421,9 @@ generate_report
 - 降价策略(联合条件判断、幅度选择)
 - 关停策略(明确低效的判断标准)
 - 保持策略(何时不操作)
+- 扩量策略(成熟期优质广告,建议新增广告/创意)
+- 素材调整策略(ROI正常但消耗不足,需人工优化素材)
+- 观察等待策略(数据不稳定或接近阈值边界)
 
 **第五部分:理由表达规范**
 - 如何用自然语言清晰表达决策依据

+ 87 - 0
examples/auto_put_ad_mini/quick_analysis.py

@@ -0,0 +1,87 @@
+"""
+快速分析脚本:直接使用已有数据,跳过数据拉取
+"""
+import os
+import sys
+from pathlib import Path
+import json
+import asyncio
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from agent.utils import setup_logging
+setup_logging(level="INFO")
+
+# 设置环境
+sys.path.insert(0, str(Path(__file__).parent))
+from config import OUTPUTS_DIR
+
+
+async def main():
+    print("=" * 70)
+    print("  快速分析 — 使用20260415已有数据")
+    print("=" * 70)
+    print()
+
+    # 步骤1: 检查数据完整性
+    print("📌 步骤 1: 检查数据完整性")
+    print("-" * 70)
+    metrics_file = OUTPUTS_DIR / "metrics_20260415.csv"
+    if metrics_file.exists():
+        import pandas as pd
+        df = pd.read_csv(metrics_file)
+        print(f"✅ metrics_20260415.csv 存在: {len(df)} 个广告")
+    else:
+        print("❌ metrics_20260415.csv 不存在,需要先运行 ROI 计算")
+        sys.exit(1)
+
+    print()
+
+    # 步骤2: 分析广告并生成决策
+    print("📌 步骤 2: 调用决策引擎")
+    print("-" * 70)
+
+    from tools.ad_decision import get_ads_for_review
+
+    # ctx参数实际未使用,传入None即可
+    result = await get_ads_for_review(None, metrics_csv=str(metrics_file))
+
+    if result.error:
+        print(f"❌ 决策引擎失败: {result.error}")
+        sys.exit(1)
+
+    print(result.output)
+    print()
+
+    # 解析结果
+    import re
+    json_match = re.search(r'```json\n(.*?)\n```', result.output, re.DOTALL)
+    if json_match:
+        review_data = json.loads(json_match.group(1))
+        print(f"✅ 分类完成:")
+        print(f"   A类(极端差): {review_data.get('a_count', 0)} 个")
+        print(f"   B类(边缘): {review_data.get('b_count', 0)} 个")
+        print(f"   C类(正常): {review_data.get('c_count', 0)} 个")
+        print(f"   📊 全局统计: ROI均值={review_data.get('roi_mean_all', 0):.2f}")
+
+        # 展示B类广告样例
+        if 'b_ads' in review_data and len(review_data['b_ads']) > 0:
+            print(f"\n📋 B类广告样例(需AI推理):")
+            for i, ad in enumerate(review_data['b_ads'][:3], 1):
+                print(f"\n  {i}. 广告 {ad.get('ad_id')}")
+                print(f"     ROI: {ad.get('动态ROI_7日均值', 0):.2f}  消耗: {ad.get('cost_7d_avg', 0):.0f}元/天")
+                print(f"     建议: {ad.get('bid_candidate', 'N/A')}")
+    else:
+        print("⚠️  无法解析决策结果")
+
+    print()
+    print("=" * 70)
+    print("✅ 分析完成 — 现在可以让Agent基于B类广告进行AI推理决策")
+    print("=" * 70)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 69 - 0
examples/auto_put_ad_mini/regenerate_metrics.py

@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""
+重新生成 metrics 数据(带新字段)
+"""
+import asyncio
+import sys
+from pathlib import Path
+
+# 添加项目根目录到路径
+PROJECT_ROOT = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from examples.auto_put_ad_mini.tools.roi_calculator import calculate_roi_metrics
+from examples.auto_put_ad_mini.tools.portfolio_metrics import calculate_portfolio_summary
+
+# 模拟ToolContext
+class SimpleContext:
+    """简单的上下文模拟"""
+    def __init__(self):
+        self.config = {}
+
+async def regenerate_data(end_date='20260415'):
+    """重新生成metrics和portfolio数据"""
+    ctx = SimpleContext()
+
+    print("=" * 70)
+    print("  重新生成 Metrics 数据(包含新字段)")
+    print("=" * 70)
+
+    # Step 1: 计算 ROI metrics
+    print(f"\n📊 步骤 1/2: 计算 ROI metrics (end_date={end_date})")
+    try:
+        result = await calculate_roi_metrics(ctx, end_date=end_date, min_daily_cost=100.0)
+        print(f"✅ {result.title}")
+        if result.metadata:
+            print(f"   输出文件: {result.metadata.get('metrics_csv')}")
+            print(f"   全局ROI均值: {result.metadata.get('动态ROI_7日均值_mean_all', 'N/A')}")
+    except Exception as e:
+        print(f"❌ ROI计算失败: {e}")
+        import traceback
+        traceback.print_exc()
+        return False
+
+    # Step 2: 计算 portfolio summary
+    print(f"\n📊 步骤 2/2: 计算 Portfolio Summary")
+    try:
+        result = await calculate_portfolio_summary(ctx, end_date=end_date)
+        print(f"✅ {result.title}")
+        # 打印部分输出
+        output_lines = result.output.split('\n')
+        for line in output_lines[:20]:
+            print(f"   {line}")
+    except Exception as e:
+        print(f"❌ Portfolio计算失败: {e}")
+        import traceback
+        traceback.print_exc()
+        return False
+
+    print("\n" + "=" * 70)
+    print("  ✅ 数据重新生成完成")
+    print("=" * 70)
+    print("\n提示:现在可以运行 `python3 test_strategy_upgrade.py` 进行验证")
+
+    return True
+
+if __name__ == "__main__":
+    end_date = sys.argv[1] if len(sys.argv) > 1 else '20260415'
+    success = asyncio.run(regenerate_data(end_date))
+    sys.exit(0 if success else 1)

+ 212 - 0
examples/auto_put_ad_mini/run_decision_test.py

@@ -0,0 +1,212 @@
+#!/usr/bin/env python3
+"""
+运行决策引擎并验证新策略是否生效
+"""
+import asyncio
+import sys
+import json
+from pathlib import Path
+
+# 添加项目根目录到路径
+PROJECT_ROOT = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from examples.auto_put_ad_mini.tools.ad_decision import get_ads_for_review, apply_decisions
+
+# 模拟ToolContext
+class SimpleContext:
+    """简单的上下文模拟"""
+    def __init__(self):
+        self.config = {}
+
+async def run_decision_test(end_date='20260415'):
+    """运行决策测试"""
+    ctx = SimpleContext()
+
+    print("=" * 70)
+    print("  决策引擎测试 - 验证新策略")
+    print("=" * 70)
+
+    # Step 1: 获取需要评估的广告
+    print(f"\n📊 步骤 1/2: 获取需要评估的广告 (end_date={end_date})")
+    try:
+        result = await get_ads_for_review(
+            ctx,
+            metrics_csv="",
+            end_date=end_date,
+            roi_review_factor=0.8,
+            min_spend_for_class_a=10.0
+        )
+
+        print(f"✅ {result.title}")
+
+        # 解析结果
+        data = json.loads(result.output)
+
+        summary = data.get('summary', {})
+        print(f"\n📊 广告分类统计:")
+        print(f"   总广告数: {summary.get('total', 0)}")
+        print(f"   零消耗待关停: {summary.get('zero_spend_ads', 0)} 个")
+        print(f"   待优化评估: {summary.get('need_review_ads', 0)} 个")
+        print(f"   正常运行: {summary.get('normal_ads', 0)} 个")
+
+        # 检查待评估广告是否包含新字段
+        need_review_ads = data.get('need_review_ads', [])
+        if need_review_ads:
+            print(f"\n✅ 验证新字段(前3个样本):")
+            for i, ad in enumerate(need_review_ads[:3]):
+                print(f"\n   样本 {i+1}: 广告 {ad['ad_id']}")
+                print(f"      人群包: {ad.get('audience_tier', 'N/A')}")
+                print(f"      ROI有效天数: {ad.get('roi_valid_days', 'N/A')} 天")
+                print(f"      年龄分段: {ad.get('age_segment', 'N/A')} ({ad.get('age_protection_level', 'N/A')})")
+
+                # 检查同类对比字段
+                if 'tier_roi_p50' in ad:
+                    print(f"      同类中位数: {ad['tier_roi_p50']}")
+                    print(f"      关停线: {ad.get('pause_line_min', 'N/A')} ~ {ad.get('pause_line_max', 'N/A')}")
+                    print(f"      降价线: {ad.get('bid_down_line_min', 'N/A')} ~ {ad.get('bid_down_line_max', 'N/A')}")
+                    print(f"      提价线: {ad.get('bid_up_line_min', 'N/A')} ~ {ad.get('bid_up_line_max', 'N/A')}")
+
+                # 检查操作限制
+                if ad.get('age_segment') == 'cold_start':
+                    print(f"      ⚠️ 冷启动期限制:")
+                    print(f"         - 允许提价: {ad.get('allow_bid_up', 'N/A')}")
+                    print(f"         - 允许降价: {ad.get('allow_bid_down', 'N/A')}")
+
+                if ad.get('high_burn_alert'):
+                    print(f"      🔥 高燃烧预警: 昨日消耗 {ad.get('yesterday_cost', 'N/A')} 元")
+
+        # 统计年龄分段分布
+        age_segments = {}
+        for ad in need_review_ads:
+            seg = ad.get('age_segment', 'unknown')
+            age_segments[seg] = age_segments.get(seg, 0) + 1
+
+        if age_segments:
+            print(f"\n📊 待评估广告的年龄分段分布:")
+            for seg, count in sorted(age_segments.items()):
+                print(f"   {seg}: {count} 个")
+
+        # 检查是否有冷启动期广告
+        cold_start_ads = [ad for ad in need_review_ads if ad.get('age_segment') == 'cold_start']
+        if cold_start_ads:
+            print(f"\n⚠️ 发现 {len(cold_start_ads)} 个冷启动期广告(4-7天),这些广告:")
+            print(f"   - ✅ 可以提价")
+            print(f"   - ❌ 不允许降价/关停")
+            print(f"   示例广告ID: {cold_start_ads[0]['ad_id']}")
+
+        # 保存结果用于后续分析
+        output_dir = Path(__file__).parent / "outputs" / "test_results"
+        output_dir.mkdir(parents=True, exist_ok=True)
+
+        output_file = output_dir / f"decision_test_{end_date}.json"
+        with open(output_file, 'w', encoding='utf-8') as f:
+            json.dump(data, f, ensure_ascii=False, indent=2)
+
+        print(f"\n💾 详细结果已保存到: {output_file}")
+
+        return True
+
+    except Exception as e:
+        print(f"❌ 决策引擎失败: {e}")
+        import traceback
+        traceback.print_exc()
+        return False
+
+async def check_im_config():
+    """检查IM审批配置"""
+    print("\n" + "=" * 70)
+    print("  检查飞书审批配置")
+    print("=" * 70)
+
+    from examples.auto_put_ad_mini.config import (
+        IM_ENABLED,
+        FEISHU_APP_ID,
+        FEISHU_APP_SECRET,
+        FEISHU_OPERATOR_OPEN_ID,
+        FEISHU_OPERATOR_CHAT_ID,
+        EXECUTION_ENABLED
+    )
+
+    print(f"\n📋 当前配置:")
+    print(f"   IM_ENABLED (飞书审批开关): {IM_ENABLED}")
+    print(f"   EXECUTION_ENABLED (执行开关): {EXECUTION_ENABLED}")
+    print(f"   FEISHU_APP_ID: {FEISHU_APP_ID}")
+    print(f"   FEISHU_APP_SECRET: {'*' * 20}{FEISHU_APP_SECRET[-8:]}")
+    print(f"   FEISHU_OPERATOR_OPEN_ID: {FEISHU_OPERATOR_OPEN_ID}")
+    print(f"   FEISHU_OPERATOR_CHAT_ID: {FEISHU_OPERATOR_CHAT_ID}")
+
+    # 检查配置问题
+    issues = []
+
+    if not IM_ENABLED:
+        issues.append("⚠️ IM_ENABLED=False,飞书审批功能已关闭")
+
+    if not EXECUTION_ENABLED:
+        issues.append("ℹ️ EXECUTION_ENABLED=False,系统只验证不执行(正常的保护机制)")
+
+    if not FEISHU_APP_ID or FEISHU_APP_ID == "your_app_id_here":
+        issues.append("❌ FEISHU_APP_ID 未配置")
+
+    if not FEISHU_APP_SECRET or FEISHU_APP_SECRET == "your_app_secret_here":
+        issues.append("❌ FEISHU_APP_SECRET 未配置")
+
+    if not FEISHU_OPERATOR_OPEN_ID:
+        issues.append("❌ FEISHU_OPERATOR_OPEN_ID 未配置(接收审批的人员ID)")
+
+    if issues:
+        print(f"\n🔍 配置检查结果:")
+        for issue in issues:
+            print(f"   {issue}")
+    else:
+        print(f"\n✅ 飞书审批配置正常")
+
+    # 说明何时发送审批
+    print(f"\n📖 飞书审批触发时机:")
+    print(f"   1. 运行 execute_decisions() 工具时")
+    print(f"   2. IM_ENABLED=True 且 EXECUTION_ENABLED=True")
+    print(f"   3. 有待执行的决策(pause/bid_down/bid_up)")
+
+    print(f"\n💡 当前状态分析:")
+    if not IM_ENABLED:
+        print(f"   → 飞书审批已关闭,决策不会发送到飞书")
+        print(f"   → 建议:config.py 中设置 IM_ENABLED=True")
+    elif not EXECUTION_ENABLED:
+        print(f"   → 执行开关关闭,系统处于【只验证不执行】模式")
+        print(f"   → 在此模式下,决策会被验证但不会真正调用API")
+        print(f"   → 飞书审批也不会发送(因为没有实际执行)")
+        print(f"   → 建议:如需测试审批流程,临时设置 EXECUTION_ENABLED=True")
+    else:
+        print(f"   → 配置正常,执行决策时会发送飞书审批")
+
+    return len(issues) == 0
+
+async def main():
+    """主测试流程"""
+    print("\n" + "🧪" * 35)
+    print(" " * 15 + "决策引擎 + IM配置验证")
+    print("🧪" * 35)
+
+    # 测试1: 运行决策引擎
+    decision_ok = await run_decision_test('20260415')
+
+    # 测试2: 检查IM配置
+    im_ok = await check_im_config()
+
+    # 总结
+    print("\n" + "=" * 70)
+    print("  测试总结")
+    print("=" * 70)
+
+    print(f"   决策引擎测试: {'✅ 通过' if decision_ok else '❌ 失败'}")
+    print(f"   IM配置检查: {'✅ 正常' if im_ok else '⚠️ 有问题'}")
+
+    if decision_ok and im_ok:
+        print(f"\n🎉 所有测试通过!")
+    elif decision_ok:
+        print(f"\n⚠️ 决策引擎工作正常,但IM配置需要调整")
+
+    return 0 if decision_ok else 1
+
+if __name__ == "__main__":
+    sys.exit(asyncio.run(main()))

+ 136 - 0
examples/auto_put_ad_mini/skills/ad_domain.md

@@ -0,0 +1,136 @@
+---
+name: ad-domain
+description: 微信小程序投流业务基础知识(产品模型、字段定义、人群包)
+category: ad_optimization
+---
+
+## 产品与渠道
+
+- **产品形态**:微信小程序(marketing_carrier_type = MINI_PROGRAM_WECHAT)
+- **营销目标**:用户增长(MARKETING_GOAL_USER_GROWTH)
+- **投放渠道**:腾讯广告 Marketing API v3.0
+- **出价模式**:oCPM(BID_MODE_OCPM)
+- **优化目标**:OPTIMIZATIONGOAL_PAGE_VIEW 或 OPTIMIZATIONGOAL_CLICK
+
+## 用户增长与变现模型(关键:多阶段并行变现)
+
+```
+                  ┌── 首层用户  →  当天即变现(浏览/点击/付费)
+广告曝光 → 点击 ──┤
+                  └── 首层用户 → T0 裂变用户  →  T0 用户自身也变现
+                                      │
+                                      ↓
+                                 T1、T2 … 持续回流
+                                      │
+                                      ↓
+                              多天持续贡献价值(7-30 天甚至更长)
+```
+
+**核心理解**:
+
+1. **首层用户当天就能变现** —— 点击广告进入小程序后就能产生浏览、付费、GMV
+2. **T0 裂变用户同样能变现** —— 不只是"传播节点",他们自己也是有效用户
+3. **后续天还会持续产生价值** —— 同一批用户在 7-30 天内持续回流、持续消费
+
+所以不是"获客→种子→传播→变现"的线性漏斗,**每一层都在变现**。
+ROI 不能只用"当日首层收益/消耗"衡量,要综合**当日+后续多日**的累积产出。
+
+这正是 f_7日动态ROI 被设计出来的原因 —— 通过 7 日滚动均值的裂变稳定因子,
+把"后续几天仍在产生的价值"折算进今天的决策里。
+
+## 核心字段定义
+
+### 决策参考字段(最高频)
+
+| 字段 | 含义 | 单位 | 来源 |
+|------|------|------|------|
+| 动态ROI_7日均值 | 7日滚动均值的 f_7日动态ROI,决策参考值 | 比率 | roi_calculator |
+| cost_7d_avg | 近 7 日日均消耗 | 元 | 计算 |
+| ad_age_days | 广告投放天数 | 天 | create_time 计算 |
+| bid_amount | 当前出价 | 元 | 广告属性 |
+| stable_spend_days_30d | 30 日内日消耗 >100元 的天数 | 天 | 计算 |
+| bid_increased_7d | 近 7 日是否提过价 | bool | 计算 |
+| creative_changed_7d | 近 7 日是否换过创意 | bool | 计算 |
+
+### ROI 构成字段
+
+| 字段 | 含义 | 计算 |
+|------|------|------|
+| 消耗(cost) | 广告花费 | API 直出(API 单位分,展示元)|
+| 首层打开数(open_count) | 点击广告打开小程序的人数 | ODPS 回流数据 |
+| T0 裂变数(fission0_count) | 首层用户产生的直接裂变用户 | ODPS 回流数据 |
+| 总回流人数 | 含裂变的全部活跃用户 | ODPS 回流数据 |
+| T0 裂变系数 | fission0_count / open_count,单用户裂变力 | 计算 |
+| 当日回流倍数 | 总回流人数 / 首层打开数,单用户带来的总回流 | 计算 |
+| arpu | 总收入 / 总回流人数,单用户产出 | 计算 |
+| 裂变效率稳定因子 | 回流倍数_7日均值 / T0裂变系数_7日均值 | 计算 |
+
+## ROI 公式(关键)
+
+### 简单 ROI
+```
+ROI = 收入 / 消耗
+```
+局限:**不体现未来裂变收益**,高 R 值人群会被低估。
+
+### f_7日动态 ROI(决策用)
+```
+当日裂变收益率 = T0裂变数 × arpu / cost
+当日回流倍数   = 总回流人数 / 首层打开数
+裂变效率稳定因子 = 回流倍数_7日均值 / T0裂变系数_7日均值
+
+f_7日动态ROI = 当日裂变收益率 × 裂变效率稳定因子
+动态ROI_7日均值 = mean(f_7日动态ROI) over 最近 7 天
+```
+
+**为什么用这个**:综合考虑了当日收益 + 7 日裂变稳定性,能反映高 R 值人群的长期价值。
+
+**生效前提**:日消耗 ≥ 100 元才计入 7 日滚动均值(window=7, min_periods=3,即有 ≥3 天合格数据就可产生 ROI,用以覆盖短历史广告)。
+
+## 人群包(R 值)含义 —— 【关键:带人能力 + 投放单价】
+
+R 值的业务定义:**投放进入的用户所具备的"带回其他人"的能力**。
+这是**人群本身的特性**,和广告是否处于冷启动无关。
+
+- R 值越大 → 该用户把其他人拉进来(裂变/回流)的能力越强,且**能持续多天带回更多人**
+- R 值越大 → 投放单价(出价/获客成本)也越高(高质量人群竞争更激烈)
+- 这是"**价贵 + 产出高**"的配置,不是"价便宜的冷门流量"
+
+### 当日 ROI 为什么会偏低(R 值越高越明显)
+
+高 R 值人群的价值分布是:
+```
+Day 0(投放当日):首层 + T0 裂变  → 只捕获到一小部分价值
+Day 1:           原用户继续带回新人 + 回流用户自身变现
+Day 2:           原用户继续带回新人 + Day1 带回的人继续变现
+…
+Day 7~30:         持续贡献
+```
+
+如果只统计**当天 T0** 的收益除以消耗,会**严重低估** R 值高的广告 —— 因为它们大部分价值还没发生。
+这就是为什么决策必须用 **f_7日动态ROI 的 7 日滚动均值**,把后续多天的持续带人和回流变现折算进来。
+
+### R 值对照表
+
+| 人群包 | 带人能力 | 投放单价 | 当日 T0 ROI 表现 | 7 日累积 ROI | 系数 |
+|--------|----------|----------|------------------|--------------|------|
+| R500   | 最强(多日持续带人) | 最高 | **偏低**(价值后置到 Day1~7+) | **最高** | 3.0× |
+| R330+  | 很强             | 较高 | 偏低                           | 高         | 2.5× |
+| R330   | 强               | 中高 | 中偏低                          | 较高       | 2.0× |
+| R180   | 中               | 中   | 中                              | 中上       | 1.5× |
+| R100   | 中弱             | 中低 | 中偏高                          | 中         | 1.2× |
+| R50 / R10 / R2 | 弱(几乎只当天变现) | 低 | 较高(当天即落袋) | 低 | 1.0× |
+
+### 核心判断原则
+
+> R 值高的人群"价贵 + 带人多 + 变现多天分布"。
+> 看 R500/R330+ 广告的**当日单日 ROI** 几乎**必然偏低**——这不是广告差,
+> 而是它的价值主要在后续几天的持续带人和回流变现。
+>
+> **正确看法**:永远用 `动态ROI_7日均值`(f_7日动态ROI 的 7 日滚动均值)做判断,
+> 它已经通过"裂变效率稳定因子"把后续多天的价值折算进来。
+
+**决策建议**:
+- R330+/R500:看 `动态ROI_7日均值`,**单日 ROI 低不是负面信号**
+- R50/R10:当日 ROI 和 7 日均值会比较接近,这类人群价值几乎都落在当天
+- 比较广告时:**同人群包之间比 ROI** 才有意义,跨 R 值硬比会误判

+ 58 - 2
examples/auto_put_ad_mini/skills/roi_strategy.md

@@ -220,8 +220,8 @@ description: 广告投放调控经验知识库 - 人群包同类对比与后验
 **系统自动保护**:
 如果 LLM 建议降价或关停,系统会自动转换为 `observe`:
 ```
-建议: bid_down -5%
-系统转换: observe(原建议bid_down,但广告处于冷启动期6天,不允许降价/关停,改为观察)
+LLM建议: bid_down -5%
+系统转换: observe(LLM建议bid_down,但广告处于早期成长期6天,年龄保护规则不允许降价/关停,改为观察)
 ```
 
 ---
@@ -569,6 +569,62 @@ description: 广告投放调控经验知识库 - 人群包同类对比与后验
 
 ---
 
+### scale_up(扩量:建议新增广告/创意)
+
+**核心定位**:成熟期优质广告,建议扩大投放规模(新增广告或创意)
+
+**适用场景**:
+1. 广告已投放 >7天(成熟期)
+2. 消耗稳定(stable_spend_days_30d ≥ 7)
+3. 日均消耗高(cost_7d_avg > 1000元)
+4. ROI 正常或优秀(≥ 同类中位数的90%)
+
+**与其他action的区别**:
+- vs **bid_up**:bid_up是提高单个广告出价拉量,scale_up是建议复制成功经验新增资源
+- vs **hold**:hold是保持现状,scale_up是识别成功案例建议扩大规模
+- vs **creative_adjust**:creative_adjust是优化现有素材,scale_up是建议增加新素材
+
+**什么时候选择 scale_up**:
+
+当满足以下**所有**条件时,应建议扩量:
+
+1. **广告已成熟且稳定**:
+   - 广告年龄 > 7天(ad_age_days > 7)
+   - 消耗稳定天数 ≥ 7天(stable_spend_days_30d ≥ 7)
+   - 说明广告已度过学习期,进入稳定运营阶段
+
+2. **消耗达到扩量阈值**:
+   - 7日日均消耗 > 1000元(cost_7d_avg > 1000)
+   - 说明广告已充分跑量,有扩量空间
+
+3. **ROI 表现正常或优秀**:
+   - 动态ROI ≥ 同类中位数的90%(tier_roi_p50 * 0.9)
+   - 或 动态ROI ≥ 全体均值的90%(roi_mean * 0.9)
+   - 说明投放效率可以接受,值得扩大规模
+
+4. **标记为扩量候选**:
+   - scale_up_candidate = True
+   - 系统已识别为扩量候选
+
+**判断原则**:
+- 成熟稳定 + 高消耗 + ROI正常 → scale_up(建议扩量)
+- 新广告或不稳定 → 不建议扩量(先优化再考虑)
+- ROI偏低 → 不建议扩量(先提升效率)
+- 消耗不足 → bid_up(提价拉量)或 creative_adjust(优化素材)
+
+**执行方式**:
+- ⚠️ **不调用API**,仅在审批表中标识"建议扩量"
+- 由运营人员根据建议决定:
+  - 复制该广告配置,创建新广告(新定向/新人群包)
+  - 为该广告增加新创意(新素材方向)
+  - 增加账户预算配额
+
+**理由示例**:
+- "广告已投放12天,消耗稳定(30日内稳定10天),7日日均消耗1250元;动态ROI为2.8,高于R500组中位数2.6的8%;建议扩量:复制该广告配置或新增创意"
+- "成熟期广告,日均消耗1500元,ROI稳定在2.5(同类中位数2.3);已验证投放效果,建议扩大规模"
+
+---
+
 ## 四、综合决策流程
 
 ### 决策前检查清单

+ 12 - 0
examples/auto_put_ad_mini/strategy_params.json

@@ -0,0 +1,12 @@
+{
+  "version": "1.0",
+  "last_updated": "2026-04-17",
+  "updated_by": "system_init",
+  "params": {
+    "ROI_LOW_FACTOR": 0.5,
+    "BID_DOWN_ROI_FACTOR": 0.8,
+    "BID_UP_ROI_FACTOR": 1.2,
+    "BID_UP_LOW_SPEND_FACTOR": 0.5
+  },
+  "adjustments": []
+}

+ 108 - 0
examples/auto_put_ad_mini/test_analysis_0415.py

@@ -0,0 +1,108 @@
+"""
+临时测试:基于20260415数据进行分析
+"""
+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")
+
+# 添加项目根目录到 Python 路径
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from agent.core.runner import AgentRunner
+from agent.trace import FileSystemTraceStore
+from agent.llm import create_openrouter_llm_call
+from agent.utils import setup_logging
+
+from examples.auto_put_ad_mini.config import (
+    MAIN_CONFIG, SKILLS_DIR, TRACE_STORE_PATH, LOG_LEVEL, LOG_FILE,
+)
+
+# 导入自定义工具
+from examples.auto_put_ad_mini.tools.data_query import fetch_creative_data, merge_creative_data
+from examples.auto_put_ad_mini.tools.roi_calculator import calculate_roi_metrics
+from examples.auto_put_ad_mini.tools.ad_decision import (
+    analyze_ads, get_ads_for_review, apply_decisions,
+    query_ad_detail, modify_decisions,
+)
+from examples.auto_put_ad_mini.tools.report_generator import generate_report, compare_decisions
+from examples.auto_put_ad_mini.tools.guardrails import validate_decisions
+from examples.auto_put_ad_mini.tools.execution_engine import execute_decisions, check_execution_feedback
+from examples.auto_put_ad_mini.tools.im_approval import send_approval_request, check_approval_status
+
+
+async def main():
+    base_dir = Path(__file__).parent
+
+    setup_logging(level=LOG_LEVEL, file=LOG_FILE)
+
+    # 加载 system prompt
+    prompt_path = base_dir / "prompts" / "system.prompt"
+    system_prompt = ""
+    if prompt_path.exists():
+        system_prompt = prompt_path.read_text(encoding="utf-8")
+
+    # 加载 presets
+    presets_path = base_dir / "presets.json"
+    if presets_path.exists():
+        from agent.core.presets import load_presets_from_json
+        load_presets_from_json(str(presets_path))
+
+    store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
+    runner = AgentRunner(
+        trace_store=store,
+        llm_call=create_openrouter_llm_call(model=MAIN_CONFIG.model),
+        skills_dir=SKILLS_DIR if Path(SKILLS_DIR).exists() else None,
+        logger_name="agents.auto_put_ad_mini",
+    )
+
+    config = MAIN_CONFIG
+    if system_prompt:
+        config.system_prompt = system_prompt
+
+    print("=" * 70)
+    print("  广告智能调控助手 — 基于20260415数据分析")
+    print("=" * 70)
+    print()
+    print("🚀 执行:使用20260415及之前的数据进行分析")
+    print()
+
+    # 修改用户指令,明确指定使用0415数据
+    messages = [{"role": "user", "content": "分析广告,使用20260415及之前的数据,不要拉取20260416的数据"}]
+    config.trace_id = None
+
+    try:
+        async for item in runner.run(messages=messages, config=config):
+            from agent.trace import Trace, Message
+            if isinstance(item, Trace):
+                if item.status == "completed":
+                    print(f"\n✅ [Trace] 完成")
+                elif item.status == "failed":
+                    print(f"\n❌ [Trace] 失败")
+
+            elif isinstance(item, Message):
+                if item.role == "assistant" and item.content:
+                    content = item.content
+                    text = content.get("text", "") if isinstance(content, dict) else content
+                    if text and text.strip():
+                        print(f"\n💭 {text}\n")
+
+        print("\n" + "=" * 70)
+        print("✅ 执行完成")
+        print("=" * 70)
+
+    except Exception as e:
+        print(f"\n❌ 执行失败: {e}")
+        import traceback
+        traceback.print_exc()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 379 - 0
examples/auto_put_ad_mini/test_api_simple.py

@@ -0,0 +1,379 @@
+"""
+腾讯广告平台 API 简化测试脚本
+
+不依赖 agent 框架,直接测试腾讯广告 API
+
+使用方法:
+    python3 examples/auto_put_ad_mini/test_api_simple.py
+"""
+
+import json
+import os
+import sys
+import time
+import uuid
+from pathlib import Path
+from urllib.parse import urlencode
+
+# 加载 .env 文件
+try:
+    from dotenv import load_dotenv
+    # 加载项目根目录的 .env
+    root_dir = Path(__file__).parent.parent.parent
+    env_path = root_dir / ".env"
+    if env_path.exists():
+        load_dotenv(env_path)
+        print(f"✅ 已加载配置文件: {env_path}\n")
+except ImportError:
+    print("⚠️  未安装 python-dotenv, 将尝试从系统环境变量读取\n")
+
+# 禁用代理 (测试时直连腾讯广告 API)
+for proxy_var in ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']:
+    if proxy_var in os.environ:
+        del os.environ[proxy_var]
+        print(f"⚠️  已禁用代理: {proxy_var}")
+
+try:
+    import httpx
+except ImportError:
+    print("❌ 缺少 httpx 库,请安装: pip3 install httpx")
+    exit(1)
+
+
+# 配置
+BASE_URL = os.getenv("TENCENT_AD_BASE_URL", "https://api.e.qq.com/v3.0")
+ACCESS_TOKEN = os.getenv("TENCENT_AD_ACCESS_TOKEN", "")
+ACCOUNT_ID = os.getenv("TENCENT_AD_ACCOUNT_ID", "")
+TIMEOUT = 30
+
+
+def check_env_vars():
+    """检查环境变量"""
+    print("=" * 70)
+    print("【1/4】环境变量检查")
+    print("=" * 70)
+
+    # 检查 ACCESS_TOKEN
+    if ACCESS_TOKEN:
+        display_token = ACCESS_TOKEN[:10] + "..." if len(ACCESS_TOKEN) > 10 else "***"
+        print(f"✅ TENCENT_AD_ACCESS_TOKEN: {display_token}")
+    else:
+        print(f"❌ TENCENT_AD_ACCESS_TOKEN: 未设置")
+        print("\n请设置环境变量:")
+        print("  export TENCENT_AD_ACCESS_TOKEN='your_access_token'")
+        print("  export TENCENT_AD_ACCOUNT_ID='your_account_id'")
+        return False
+
+    # 检查 ACCOUNT_ID
+    if ACCOUNT_ID:
+        print(f"✅ TENCENT_AD_ACCOUNT_ID: {ACCOUNT_ID}")
+    else:
+        print(f"❌ TENCENT_AD_ACCOUNT_ID: 未设置")
+        return False
+
+    # 检查 BASE_URL
+    print(f"✅ TENCENT_AD_BASE_URL: {BASE_URL}")
+
+    print("\n✅ 环境变量检查通过\n")
+    return True
+
+
+def _common_params():
+    """公共查询参数"""
+    return {
+        "access_token": ACCESS_TOKEN,
+        "timestamp": str(int(time.time())),
+        "nonce": uuid.uuid4().hex,
+    }
+
+
+def test_account_info():
+    """测试获取账户信息"""
+    print("=" * 70)
+    print("【2/4】账户信息查询测试")
+    print("=" * 70)
+
+    try:
+        # 构建请求
+        params = _common_params()
+        params["account_id"] = ACCOUNT_ID
+        params["fields"] = json.dumps(["balance", "daily_budget", "configured_status"])
+
+        url = f"{BASE_URL}/accounts/get?{urlencode(params)}"
+
+        print(f"请求 URL: {BASE_URL}/accounts/get")
+        print(f"请求参数: account_id={ACCOUNT_ID}")
+
+        # 发送请求
+        resp = httpx.get(url, timeout=TIMEOUT)
+
+        print(f"响应状态码: {resp.status_code}")
+
+        if resp.status_code != 200:
+            print(f"❌ HTTP 错误: {resp.status_code}")
+            print(f"响应内容: {resp.text[:500]}")
+            return False
+
+        # 解析响应
+        data = resp.json()
+        print(f"响应内容: {json.dumps(data, ensure_ascii=False, indent=2)}")
+
+        # 检查 API 错误码
+        code = data.get("code", -1)
+        if code != 0:
+            msg = data.get("message_cn") or data.get("message", "未知错误")
+            print(f"❌ API 错误 (code={code}): {msg}")
+            return False
+
+        # 成功
+        account_data = data.get("data", {})
+        if isinstance(account_data, dict) and "list" in account_data:
+            account_data = account_data["list"][0] if account_data["list"] else {}
+
+        balance = account_data.get("balance", 0)
+        daily_budget = account_data.get("daily_budget", 0)
+        status = account_data.get("configured_status", "未知")
+
+        print(f"\n✅ 测试通过")
+        print(f"账户信息:")
+        print(f"  - 账户 ID: {ACCOUNT_ID}")
+        print(f"  - 余额: {balance/100:.2f} 元")
+        print(f"  - 日限额: {daily_budget/100:.0f} 元")
+        print(f"  - 状态: {status}")
+
+        return True
+
+    except httpx.RequestError as e:
+        print(f"❌ 网络请求错误: {e}")
+        return False
+    except Exception as e:
+        print(f"❌ 测试异常: {e}")
+        import traceback
+        traceback.print_exc()
+        return False
+
+
+def test_ad_list():
+    """测试查询广告列表"""
+    print("\n" + "=" * 70)
+    print("【3/4】广告列表查询测试")
+    print("=" * 70)
+
+    try:
+        # 构建请求
+        params = _common_params()
+        params["account_id"] = ACCOUNT_ID
+        params["page"] = "1"
+        params["page_size"] = "5"
+
+        url = f"{BASE_URL}/adgroups/get?{urlencode(params)}"
+
+        print(f"请求 URL: {BASE_URL}/adgroups/get")
+        print(f"请求参数: account_id={ACCOUNT_ID}, page=1, page_size=5")
+
+        # 发送请求
+        resp = httpx.get(url, timeout=TIMEOUT)
+
+        print(f"响应状态码: {resp.status_code}")
+
+        if resp.status_code != 200:
+            print(f"❌ HTTP 错误: {resp.status_code}")
+            print(f"响应内容: {resp.text[:500]}")
+            return False
+
+        # 解析响应
+        data = resp.json()
+
+        # 检查 API 错误码
+        code = data.get("code", -1)
+        if code != 0:
+            msg = data.get("message_cn") or data.get("message", "未知错误")
+            print(f"❌ API 错误 (code={code}): {msg}")
+            return False
+
+        # 成功
+        result_data = data.get("data", {})
+        ads = result_data.get("list", [])
+        page_info = result_data.get("page_info", {})
+
+        print(f"\n✅ 测试通过")
+        print(f"广告列表:")
+        print(f"  - 总数量: {page_info.get('total_number', len(ads))}")
+        print(f"  - 当前页: {len(ads)} 个广告")
+
+        # 显示前几个广告
+        for i, ad in enumerate(ads[:5], 1):
+            print(f"\n  [{i}] 广告 ID: {ad.get('adgroup_id')}")
+            print(f"      名称: {ad.get('adgroup_name')}")
+            print(f"      状态: {ad.get('configured_status')}")
+            print(f"      出价: {ad.get('bid_amount', 0)/100:.2f} 元")
+            print(f"      日预算: {ad.get('daily_budget', 0)/100:.0f} 元")
+
+        # 统计状态分布
+        if ads:
+            statuses = {}
+            for ad in ads:
+                status = ad.get("configured_status", "UNKNOWN")
+                statuses[status] = statuses.get(status, 0) + 1
+            print(f"\n  状态分布: {statuses}")
+
+        return True
+
+    except httpx.RequestError as e:
+        print(f"❌ 网络请求错误: {e}")
+        return False
+    except Exception as e:
+        print(f"❌ 测试异常: {e}")
+        import traceback
+        traceback.print_exc()
+        return False
+
+
+def test_report():
+    """测试查询数据报表"""
+    print("\n" + "=" * 70)
+    print("【4/4】数据报表查询测试")
+    print("=" * 70)
+
+    try:
+        from datetime import datetime, timedelta
+
+        # 查询最近 7 天
+        end_date = datetime.now()
+        start_date = end_date - timedelta(days=6)
+
+        date_range = {
+            "start_date": start_date.strftime("%Y-%m-%d"),
+            "end_date": end_date.strftime("%Y-%m-%d"),
+        }
+
+        # 构建请求
+        params = _common_params()
+        params["account_id"] = ACCOUNT_ID
+        params["level"] = "ADGROUP"
+        params["date_range"] = json.dumps(date_range)
+        params["fields"] = json.dumps(["cost", "impression", "click", "ctr", "conversion"])
+        params["page"] = "1"
+        params["page_size"] = "5"
+
+        url = f"{BASE_URL}/daily_reports/adgroups/get?{urlencode(params)}"
+
+        print(f"请求 URL: {BASE_URL}/daily_reports/adgroups/get")
+        print(f"日期范围: {date_range['start_date']} ~ {date_range['end_date']}")
+
+        # 发送请求
+        resp = httpx.get(url, timeout=TIMEOUT)
+
+        print(f"响应状态码: {resp.status_code}")
+
+        if resp.status_code != 200:
+            print(f"❌ HTTP 错误: {resp.status_code}")
+            print(f"响应内容: {resp.text[:500]}")
+            return False
+
+        # 解析响应
+        data = resp.json()
+
+        # 检查 API 错误码
+        code = data.get("code", -1)
+        if code != 0:
+            msg = data.get("message_cn") or data.get("message", "未知错误")
+            print(f"❌ API 错误 (code={code}): {msg}")
+            return False
+
+        # 成功
+        result_data = data.get("data", {})
+        reports = result_data.get("list", [])
+
+        if not reports:
+            print(f"\n⚠️  该时间段内无数据")
+            return True
+
+        print(f"\n✅ 测试通过")
+        print(f"数据报表:")
+        print(f"  - 记录数: {len(reports)}")
+
+        # 显示前几条
+        for i, report in enumerate(reports[:5], 1):
+            cost = report.get("cost", 0)
+            impression = report.get("impression", 0)
+            click = report.get("click", 0)
+            ctr = report.get("ctr", 0)
+            conversion = report.get("conversion", 0)
+
+            print(f"\n  [{i}] 广告 ID: {report.get('adgroup_id', '-')}")
+            if "date" in report:
+                print(f"      日期: {report['date']}")
+            print(f"      消耗: {cost/100:.2f} 元")
+            print(f"      展示: {impression:,}")
+            print(f"      点击: {click:,}")
+            print(f"      CTR: {ctr:.2%}")
+            print(f"      转化: {conversion}")
+
+        return True
+
+    except httpx.RequestError as e:
+        print(f"❌ 网络请求错误: {e}")
+        return False
+    except Exception as e:
+        print(f"❌ 测试异常: {e}")
+        import traceback
+        traceback.print_exc()
+        return False
+
+
+def main():
+    """主测试流程"""
+    print("\n" + "=" * 70)
+    print("腾讯广告平台 API 接口测试 (简化版)")
+    print("=" * 70)
+    print()
+
+    # 1. 检查环境变量
+    if not check_env_vars():
+        print("\n❌ 测试中止: 环境变量未配置")
+        print("\n配置方法:")
+        print("1. 在终端中设置环境变量:")
+        print("   export TENCENT_AD_ACCESS_TOKEN='your_access_token'")
+        print("   export TENCENT_AD_ACCOUNT_ID='your_account_id'")
+        print("\n2. 或者创建 .env 文件:")
+        print("   TENCENT_AD_ACCESS_TOKEN=your_access_token")
+        print("   TENCENT_AD_ACCOUNT_ID=your_account_id")
+        return
+
+    # 2-4. 依次测试
+    test1 = test_account_info()
+    test2 = test_ad_list()
+    test3 = test_report()
+
+    # 总结
+    print("\n" + "=" * 70)
+    print("测试结果汇总")
+    print("=" * 70)
+
+    results = [
+        ("环境变量检查", True),
+        ("账户信息查询", test1),
+        ("广告列表查询", test2),
+        ("数据报表查询", test3),
+    ]
+
+    for test_name, passed in results:
+        status = "✅ 通过" if passed else "❌ 失败"
+        print(f"{status}  {test_name}")
+
+    all_passed = all([test1, test2, test3])
+
+    if all_passed:
+        print("\n🎉 所有测试通过! 腾讯广告平台接口可用")
+    else:
+        print("\n⚠️  部分测试失败,请检查:")
+        print("   1. ACCESS_TOKEN 是否有效 (是否过期)")
+        print("   2. ACCOUNT_ID 是否正确")
+        print("   3. 网络连接是否正常")
+        print("   4. 账户权限是否充足")
+        print("   5. API 版本是否为 v3.0")
+
+
+if __name__ == "__main__":
+    main()

+ 107 - 0
examples/auto_put_ad_mini/test_approval_flow_e2e.py

@@ -0,0 +1,107 @@
+"""
+端到端测试:审批消息发送到个人和项目群
+验证配置:个人(ou_498988d823b61ab89c9afe4310f85bb4)+ 项目群(oc_7940ec97cde40b245cff9cb606ff1ac7)
+"""
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from agent.tools.builtin.feishu.feishu_client import FeishuClient
+from config import (
+    FEISHU_APP_ID,
+    FEISHU_APP_SECRET,
+    FEISHU_OPERATOR_OPEN_ID,
+    FEISHU_AD_PROJECT_CHAT_ID
+)
+
+print("=" * 70)
+print("端到端测试:审批消息发送验证")
+print("=" * 70)
+print()
+print(f"飞书应用 ID: {FEISHU_APP_ID}")
+print(f"个人 Open ID: {FEISHU_OPERATOR_OPEN_ID}")
+print(f"项目群聊 ID: {FEISHU_AD_PROJECT_CHAT_ID}")
+print()
+
+# 检查配置
+if not FEISHU_OPERATOR_OPEN_ID:
+    print("❌ 错误:FEISHU_OPERATOR_OPEN_ID 未配置")
+    sys.exit(1)
+
+if not FEISHU_AD_PROJECT_CHAT_ID:
+    print("❌ 错误:FEISHU_AD_PROJECT_CHAT_ID 未配置")
+    sys.exit(1)
+
+client = FeishuClient(
+    app_id=FEISHU_APP_ID,
+    app_secret=FEISHU_APP_SECRET
+)
+
+# 模拟审批消息
+test_message = """📋 **广告调控决策审批(测试)**
+
+请求ID: test_e2e_20260418_001
+时间: 2026-04-18 00:30
+
+🔶 待审批操作(3 个):
+----------------------------------------
+  ⏸️  暂停: 1 个
+  ⬇️  降价: 2 个
+
+前 3 个示例:
+1. 广告 ID: 12345 | R500_test_ad_1 | 操作: pause
+   理由: 动态ROI为1.5,低于R500组中位数30%
+
+2. 广告 ID: 12346 | R500_test_ad_2 | 操作: bid_down 3%
+   理由: 动态ROI为2.2,低于R500组中位数11%
+
+3. 广告 ID: 12347 | R330_test_ad_1 | 操作: bid_down 5%
+   理由: 动态ROI为1.8,低于R330组中位数15%
+
+---
+💬 **请回复以下内容进行审批**:
+  - 回复 "通过" → 批准所有操作
+  - 回复 "拒绝" → 拒绝所有操作
+  - 回复 "广告12345不要暂停" → 修改决策
+
+📊 详细报表将随后发送(Excel在线表格)
+"""
+
+print("测试 1/2:发送消息到个人")
+print("-" * 70)
+try:
+    result_personal = client.send_message(to=FEISHU_OPERATOR_OPEN_ID, text=test_message)
+    print("✅ 发送成功!")
+    print(f"   消息 ID: {result_personal.message_id}")
+    print(f"   接收者: {FEISHU_OPERATOR_OPEN_ID}")
+    print()
+except Exception as e:
+    print(f"❌ 发送失败: {e}")
+    print()
+
+print("测试 2/2:发送消息到项目群聊")
+print("-" * 70)
+try:
+    result_group = client.send_message(to=FEISHU_AD_PROJECT_CHAT_ID, text=test_message)
+    print("✅ 发送成功!")
+    print(f"   消息 ID: {result_group.message_id}")
+    print(f"   群聊 ID: {result_group.chat_id}")
+    print()
+except Exception as e:
+    print(f"❌ 发送失败: {e}")
+    print()
+
+print("=" * 70)
+print("验证步骤:")
+print("=" * 70)
+print()
+print("1️⃣  检查个人飞书消息,应该收到上述测试消息")
+print("2️⃣  检查项目群聊,应该收到相同的测试消息")
+print("3️⃣  在任一位置回复'通过'或'拒绝',系统应能识别")
+print()
+print("如果两处都收到消息,说明配置成功!✅")
+print()

+ 60 - 0
examples/auto_put_ad_mini/test_chat_send.py

@@ -0,0 +1,60 @@
+"""
+测试发送消息到投放项目群聊
+"""
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from agent.tools.builtin.feishu.feishu_client import FeishuClient
+from config import FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_AD_PROJECT_CHAT_ID
+
+print("=" * 60)
+print("测试发送消息到投放项目群聊")
+print("=" * 60)
+print()
+print(f"飞书应用 ID: {FEISHU_APP_ID}")
+print(f"投放项目群聊 ID: {FEISHU_AD_PROJECT_CHAT_ID}")
+print()
+
+if not FEISHU_AD_PROJECT_CHAT_ID:
+    print("❌ 错误:FEISHU_AD_PROJECT_CHAT_ID 未配置")
+    print("请在 .env 文件中配置群聊 ID")
+    sys.exit(1)
+
+client = FeishuClient(
+    app_id=FEISHU_APP_ID,
+    app_secret=FEISHU_APP_SECRET
+)
+
+test_message = """🧪 **测试消息**
+
+这是一条测试消息,验证机器人可以正常发送消息到投放项目群聊。
+
+✅ 如果您看到这条消息,说明配置成功!
+
+---
+发送时间: 2026-04-17 21:30
+测试工具: test_chat_send.py
+"""
+
+try:
+    print("🚀 发送测试消息...")
+    result = client.send_message(to=FEISHU_AD_PROJECT_CHAT_ID, text=test_message)
+    print()
+    print("✅ 发送成功!")
+    print(f"消息 ID: {result.message_id}")
+    print(f"群聊 ID: {result.chat_id}")
+    print()
+    print("请在飞书群聊中查看是否收到测试消息")
+except Exception as e:
+    print()
+    print(f"❌ 发送失败: {e}")
+    print()
+    print("请检查:")
+    print("1. 机器人是否已加入群聊")
+    print("2. 机器人是否有发送消息权限")
+    print("3. 群聊 ID 是否正确")

+ 197 - 0
examples/auto_put_ad_mini/test_compute_signal_scores.py

@@ -0,0 +1,197 @@
+"""
+单元测试 — V4 compute_signal_scores
+
+5 条 mock 数据覆盖关键场景:
+  1. 严重衰退老广告(高 decay_score)
+  2. 边缘衰退(中等 decay_score)
+  3. 高 ROI 低消耗(高 bid_up_score)
+  4. ROI 同群下移(高 bid_down_score)
+  5. 新广告冷启动(所有评分都低)
+
+运行:
+  cd /Users/liulidong/project/agent/Agent
+  .venv/bin/python3 examples/auto_put_ad_mini/test_compute_signal_scores.py
+"""
+import sys
+from pathlib import Path
+
+# 路径设置
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
+
+import pandas as pd
+
+from examples.auto_put_ad_mini.tools.ad_decision import compute_signal_scores
+
+
+def _make_context():
+    """统一的 context(含 tier baseline + global baseline + market_signal)"""
+    return {
+        "by_audience_tier_baseline": {
+            "R330": {"roi_p25": 1.50, "roi_p50": 1.90, "roi_p75": 2.40},
+            "R500": {"roi_p25": 1.20, "roi_p50": 1.60, "roi_p75": 2.10},
+            "R50":  {"roi_p25": 2.20, "roi_p50": 2.80, "roi_p75": 3.50},
+        },
+        "global_baseline": {"roi_p25": 1.50, "roi_p50": 2.00, "roi_p75": 2.60},
+        "cost_7d_avg_median": 200.0,
+        "market_signal": "stable",
+    }
+
+
+def _print_score(name, expected, actual_pack):
+    print(f"\n--- {name} ---")
+    print(f"  decay_score: {actual_pack['decay_score']}")
+    print(f"  decay_signals: {actual_pack['decay_signals']}")
+    print(f"  bid_down_score: {actual_pack['bid_down_score']} (scenario={actual_pack['bid_down_scenario']}, sugg={actual_pack['bid_down_suggested_pct']})")
+    print(f"  bid_up_score: {actual_pack['bid_up_score']} (label={actual_pack['bid_up_label']}, sugg={actual_pack['bid_up_suggested_pct']})")
+    print(f"  rule_recommended_action: {actual_pack['rule_recommended_action']} (basis={actual_pack['rule_recommendation_basis']})")
+    for key, expect in expected.items():
+        if key == "rule_action":
+            actual = actual_pack["rule_recommended_action"]
+            ok = actual == expect
+        elif key.endswith("_min"):
+            base = key.replace("_min", "")
+            actual = actual_pack[base]
+            ok = actual >= expect
+        elif key.endswith("_max"):
+            base = key.replace("_max", "")
+            actual = actual_pack[base]
+            ok = actual <= expect
+        else:
+            actual = actual_pack[key]
+            ok = actual == expect
+        status = "[OK]" if ok else "[FAIL]"
+        print(f"    {status} {key}: 期望 {expect} 实际 {actual}")
+        if not ok:
+            return False
+    return True
+
+
+def test_case_1_severe_decay():
+    """场景 1:168 天老广告 + 干预 + ROI 低 → decay_score >= 0.55, rule=pause"""
+    row = pd.Series({
+        "ad_age_days": 168,
+        "bid_increased_7d": True,
+        "bid_change_pct_7d": 0.08,
+        "creatives_added_7d": 2,
+        "day_amount_changed_7d": False,
+        "stable_spend_days_30d": 18,
+        "动态ROI_7日均值": 1.04,
+        "cost_7d_avg": 130,
+        "relative_trend_pct": -0.21,
+        "budget_headroom_pct": 0.50,
+        "audience_tier": "R50",
+    })
+    pack = compute_signal_scores(row, _make_context())
+    return _print_score("Case 1 严重衰退", {
+        "decay_score_min": 0.55,
+        "rule_action": "pause",
+    }, pack)
+
+
+def test_case_2_edge_decay():
+    """场景 2:30 天广告 + 已干预 + ROI 略低 → decay_score 0.30~0.55, rule 倾向 bid_down 或 pause"""
+    row = pd.Series({
+        "ad_age_days": 30,
+        "bid_increased_7d": True,
+        "bid_change_pct_7d": 0.05,
+        "creatives_added_7d": 1,
+        "day_amount_changed_7d": False,
+        "stable_spend_days_30d": 8,
+        "动态ROI_7日均值": 1.40,
+        "cost_7d_avg": 150,
+        "relative_trend_pct": -0.08,
+        "budget_headroom_pct": 0.40,
+        "audience_tier": "R330",
+    })
+    pack = compute_signal_scores(row, _make_context())
+    return _print_score("Case 2 边缘衰退", {
+        "decay_score_min": 0.30,
+        "decay_score_max": 0.65,
+    }, pack)
+
+
+def test_case_3_high_roi_low_cost():
+    """场景 3:高 ROI 低消耗 → bid_up_score >= 0.50, rule=bid_up"""
+    row = pd.Series({
+        "ad_age_days": 30,
+        "bid_increased_7d": False,
+        "bid_change_pct_7d": 0.0,
+        "creatives_added_7d": 0,
+        "day_amount_changed_7d": False,
+        "stable_spend_days_30d": 5,
+        "动态ROI_7日均值": 3.20,  # > R330 p75=2.40
+        "cost_7d_avg": 60,        # < median 200 × 0.5
+        "relative_trend_pct": 0.10,
+        "budget_headroom_pct": 0.50,
+        "audience_tier": "R330",
+    })
+    pack = compute_signal_scores(row, _make_context())
+    return _print_score("Case 3 高ROI低量", {
+        "bid_up_score_min": 0.50,
+        "rule_action": "bid_up",
+    }, pack)
+
+
+def test_case_4_tier_drift():
+    """场景 4:ROI 同群下移 → bid_down_score >= 0.40, scenario=tier_drift 或 noise/general"""
+    row = pd.Series({
+        "ad_age_days": 25,
+        "bid_increased_7d": False,
+        "bid_change_pct_7d": 0.0,
+        "creatives_added_7d": 0,
+        "day_amount_changed_7d": False,
+        "stable_spend_days_30d": 6,
+        "动态ROI_7日均值": 1.30,    # < R330 p25=1.50
+        "cost_7d_avg": 200,
+        "relative_trend_pct": -0.18,  # 趋势负(同群下移)
+        "budget_headroom_pct": 0.40,
+        "audience_tier": "R330",
+    })
+    pack = compute_signal_scores(row, _make_context())
+    return _print_score("Case 4 同群下移", {
+        "bid_down_score_min": 0.30,
+        "rule_action": "bid_down",
+    }, pack)
+
+
+def test_case_5_new_ad():
+    """场景 5:新广告冷启动 → 所有评分都低,rule=hold"""
+    row = pd.Series({
+        "ad_age_days": 3,
+        "bid_increased_7d": False,
+        "bid_change_pct_7d": 0.0,
+        "creatives_added_7d": 0,
+        "day_amount_changed_7d": False,
+        "stable_spend_days_30d": 0,
+        "动态ROI_7日均值": 1.80,
+        "cost_7d_avg": 80,
+        "relative_trend_pct": None,
+        "budget_headroom_pct": 0.30,
+        "audience_tier": "R330",
+    })
+    pack = compute_signal_scores(row, _make_context())
+    return _print_score("Case 5 新广告冷启动", {
+        "decay_score_max": 0.30,
+        "rule_action": "hold",
+    }, pack)
+
+
+def main():
+    cases = [
+        test_case_1_severe_decay,
+        test_case_2_edge_decay,
+        test_case_3_high_roi_low_cost,
+        test_case_4_tier_drift,
+        test_case_5_new_ad,
+    ]
+    results = [c() for c in cases]
+    passed = sum(1 for r in results if r)
+    total = len(results)
+    print(f"\n{'='*60}")
+    print(f"总计: {passed}/{total} 通过")
+    print(f"{'='*60}")
+    sys.exit(0 if passed == total else 1)
+
+
+if __name__ == "__main__":
+    main()

+ 212 - 0
examples/auto_put_ad_mini/test_e2e_full_flow.py

@@ -0,0 +1,212 @@
+"""
+完整端到端测试:
+1. 生成模拟决策数据
+2. 发送飞书审批(个人 + 项目群)
+3. 验证权限设置(anyone_editable)
+4. 检查表格格式和表头
+"""
+import asyncio
+import sys
+from pathlib import Path
+from datetime import datetime
+import pandas as pd
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from agent.tools.models import ToolContext
+from config import FEISHU_OPERATOR_OPEN_ID, FEISHU_AD_PROJECT_CHAT_ID
+
+# 导入审批工具
+from tools.im_approval import send_approval_request
+
+
+async def main():
+    print("=" * 70)
+    print("  完整端到端测试:审批流程 + 飞书在线表格")
+    print("=" * 70)
+    print()
+
+    print("配置验证:")
+    print(f"  个人 Open ID: {FEISHU_OPERATOR_OPEN_ID}")
+    print(f"  项目群聊 ID: {FEISHU_AD_PROJECT_CHAT_ID}")
+    print()
+
+    if not FEISHU_OPERATOR_OPEN_ID or not FEISHU_AD_PROJECT_CHAT_ID:
+        print("❌ 错误:配置缺失")
+        return
+
+    # 步骤1:生成模拟决策数据(验证后的决策CSV)
+    print("步骤 1/4:生成模拟决策数据")
+    print("-" * 70)
+
+    # 创建模拟数据(包含所有审批表头字段)
+    mock_data = [
+        {
+            "ad_id": 12345678,
+            "account_id": 80769799,
+            "ad_name": "R500_测试广告_1_小程序引流",
+            "audience_tier": "R500",
+            "ad_age_days": 15,
+            "bid_amount": 15000,  # 150元(单位:分)
+            "cost_7d_avg": 850.5,
+            "cost_7d_total": 5953.5,
+            "revenue_7d_total": 8930.25,
+            "动态ROI_7日均值": 1.50,
+            "final_action": "pause",
+            "action": "pause",
+            "dimension": "ROI低于同类",
+            "reason": "动态ROI为1.5,低于R500组中位数2.8的46%;消耗稳定但ROI持续偏低,建议关停",
+            "recommended_change_pct": 0.0,
+            "configured_status": "AD_STATUS_NORMAL",
+            "tier": 2,  # Tier 2需要审批
+        },
+        {
+            "ad_id": 12345679,
+            "account_id": 80769799,
+            "ad_name": "R500_测试广告_2_小程序引流",
+            "audience_tier": "R500",
+            "ad_age_days": 20,
+            "bid_amount": 18000,
+            "cost_7d_avg": 1200.8,
+            "cost_7d_total": 8405.6,
+            "revenue_7d_total": 20170.44,
+            "动态ROI_7日均值": 2.40,
+            "final_action": "bid_down",
+            "action": "bid_down",
+            "dimension": "ROI低于同类",
+            "reason": "动态ROI为2.4,低于R500组中位数2.8的14%;裂变率0.52低于同类均值0.62的16%;7日均消耗1200元,建议降价3%",
+            "recommended_change_pct": 0.03,
+            "configured_status": "AD_STATUS_NORMAL",
+            "tier": 2,
+        },
+        {
+            "ad_id": 12345680,
+            "account_id": 80769799,
+            "ad_name": "R330_测试广告_1_小程序引流",
+            "audience_tier": "R330",
+            "ad_age_days": 12,
+            "bid_amount": 12000,
+            "cost_7d_avg": 680.2,
+            "cost_7d_total": 4761.4,
+            "revenue_7d_total": 8094.38,
+            "动态ROI_7日均值": 1.70,
+            "final_action": "bid_down",
+            "action": "bid_down",
+            "dimension": "ROI低于同类",
+            "reason": "动态ROI为1.7,低于R330组中位数2.0的15%;裂变率0.48低于同类均值0.55的13%;7日均消耗680元,建议降价5%",
+            "recommended_change_pct": 0.05,
+            "configured_status": "AD_STATUS_NORMAL",
+            "tier": 3,  # Tier 3需要审批
+        },
+    ]
+
+    df = pd.DataFrame(mock_data)
+
+    # 保存为验证后的CSV
+    outputs_dir = Path("outputs/reports")
+    outputs_dir.mkdir(parents=True, exist_ok=True)
+
+    validated_csv = outputs_dir / "validated_decisions_test_e2e.csv"
+    df.to_csv(validated_csv, index=False, encoding="utf-8-sig")
+
+    print(f"✅ 生成 {len(df)} 条模拟决策数据")
+    print(f"   文件: {validated_csv}")
+    print(f"   内容: {len(df[df['tier'] >= 2])} 条需审批(Tier 2/3)")
+    print()
+
+    # 步骤2:发送飞书审批
+    print("步骤 2/4:发送飞书审批")
+    print("-" * 70)
+
+    ctx = ToolContext()
+
+    try:
+        result = await send_approval_request(
+            ctx=ctx,
+            validated_csv=str(validated_csv),
+            wait_for_reply=False,  # 非阻塞模式,仅发送不等待回复
+            timeout_minutes=5,
+        )
+
+        print("✅ 审批请求发送成功!")
+        print()
+        print(f"标题: {result.title}")
+        print(f"输出: {result.output}")
+        print()
+
+        if result.metadata:
+            request_id = result.metadata.get("request_id")
+            print(f"请求ID: {request_id}")
+            print(f"发送状态: {result.metadata.get('feishu_sent', False)}")
+            print()
+
+    except Exception as e:
+        print(f"❌ 发送失败: {e}")
+        import traceback
+        traceback.print_exc()
+        return
+
+    # 步骤3:验证结果
+    print("步骤 3/4:验证检查项")
+    print("-" * 70)
+    print()
+    print("请手动验证以下项目:")
+    print()
+    print("✅ 1. 个人飞书消息")
+    print(f"      检查 {FEISHU_OPERATOR_OPEN_ID} 是否收到:")
+    print("      - 审批文本消息(包含'请回复通过或拒绝')")
+    print("      - 在线表格链接(或文件附件)")
+    print()
+    print("✅ 2. 项目群聊消息")
+    print(f"      检查群聊 {FEISHU_AD_PROJECT_CHAT_ID} 是否收到:")
+    print("      - 相同的审批文本消息")
+    print("      - 相同的在线表格链接(或文件附件)")
+    print()
+    print("✅ 3. 在线表格权限")
+    print("      打开表格链接,检查:")
+    print("      - 是否能直接打开(无需权限申请)")
+    print("      - 是否有\"编辑\"按钮(anyone_editable)")
+    print("      - 尝试修改单元格内容,确认可编辑")
+    print()
+    print("✅ 4. 表格表头和格式")
+    print("      检查表格是否包含以下列:")
+    for i, col in enumerate([
+        "approval_date", "account_id", "ad_id", "cost_7d_avg", "action",
+        "ad_name", "audience_tier", "ad_age_days", "bid_amount",
+        "动态ROI_7日均值", "cost_7d_total", "revenue_7d_total",
+        "dimension", "reason", "recommended_change_pct"
+    ], 1):
+        print(f"      {i:2d}. {col}")
+    print()
+    print("✅ 5. E1黄色高亮")
+    print("      检查单元格 E1(action列标题)是否有黄色背景")
+    print()
+
+    # 步骤4:回复测试提示
+    print("步骤 4/4:回复测试")
+    print("-" * 70)
+    print()
+    print("现在可以测试回复功能:")
+    print("  1. 在个人私聊中回复 '通过'")
+    print("     或")
+    print("  2. 在项目群聊中回复 '通过'")
+    print()
+    print("系统应该能识别任一处的回复。")
+    print()
+
+    print("=" * 70)
+    print("  测试完成")
+    print("=" * 70)
+    print()
+    print("📋 总结:")
+    print("  ✅ 模拟数据生成完成")
+    print("  ✅ 飞书审批发送完成")
+    print("  ⏳ 等待手动验证")
+    print()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 144 - 0
examples/auto_put_ad_mini/test_feishu_approval.py

@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""
+测试飞书审批消息发送(不真正执行API)
+"""
+import asyncio
+import sys
+import json
+from pathlib import Path
+from datetime import datetime
+
+# 添加项目根目录到路径
+PROJECT_ROOT = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from examples.auto_put_ad_mini.tools.im_approval import send_approval_request
+from examples.auto_put_ad_mini.config import (
+    IM_ENABLED,
+    FEISHU_OPERATOR_OPEN_ID,
+    FEISHU_OPERATOR_CHAT_ID
+)
+
+# 模拟ToolContext
+class SimpleContext:
+    """简单的上下文模拟"""
+    def __init__(self):
+        self.config = {}
+
+async def test_feishu_message():
+    """测试飞书消息发送"""
+    print("=" * 70)
+    print("  测试飞书审批消息发送")
+    print("=" * 70)
+
+    if not IM_ENABLED:
+        print("\n❌ IM_ENABLED=False,飞书功能未启用")
+        print("   请在 config.py 中设置 IM_ENABLED=True")
+        return False
+
+    print(f"\n✅ IM_ENABLED=True,飞书功能已启用")
+    print(f"   接收人 OPEN_ID: {FEISHU_OPERATOR_OPEN_ID}")
+    print(f"   接收群 CHAT_ID: {FEISHU_OPERATOR_CHAT_ID}")
+
+    # 创建一个测试决策CSV(包含所有必需字段)
+    test_decisions = [
+        {
+            "ad_id": "99999999999",
+            "ad_name": "【测试】R500-回流330+-广告测试",
+            "action": "bid_down",
+            "final_action": "bid_down",  # IM工具需要这个字段
+            "dimension": "测试维度",
+            "reason": "这是一条测试消息,验证飞书审批功能是否正常工作。动态ROI为2.5,低于R500组中位数3.74的33%,建议降价5%。",
+            "confidence": "high",
+            "recommended_change_pct": -0.05,
+            "current_bid": 100.0,
+            "new_bid": 95.0,
+            "cost_7d_avg": 500.0,
+            "roi": 2.5,
+            "tier": 1,
+            "audience_tier": "R500",
+            "ad_age_days": 15,
+            "roi_valid_days": 7,
+        }
+    ]
+
+    # 保存测试决策CSV
+    outputs_dir = Path(__file__).parent / "outputs" / "reports"
+    outputs_dir.mkdir(parents=True, exist_ok=True)
+
+    test_csv = outputs_dir / "test_approval_decisions.csv"
+    import pandas as pd
+    df = pd.DataFrame(test_decisions)
+    df.to_csv(test_csv, index=False, encoding='utf-8-sig')
+
+    print(f"\n📝 创建测试决策文件: {test_csv}")
+    print(f"   决策数量: {len(test_decisions)}")
+    print(f"   测试广告ID: {test_decisions[0]['ad_id']}")
+
+    # 发送审批请求
+    print(f"\n📤 发送飞书审批请求...")
+    ctx = SimpleContext()
+
+    try:
+        result = await send_approval_request(
+            ctx,
+            validated_csv=str(test_csv),
+            timeout_minutes=30
+        )
+
+        print(f"\n✅ 飞书消息发送成功!")
+        print(f"   {result.title}")
+        print(f"\n📋 输出信息:")
+        for line in result.output.split('\n'):
+            print(f"   {line}")
+
+        # 检查metadata
+        if result.metadata:
+            print(f"\n📊 元数据:")
+            for key, value in result.metadata.items():
+                print(f"   {key}: {value}")
+
+        print(f"\n💡 请检查您的飞书:")
+        print(f"   1. 打开飞书App")
+        print(f"   2. 查找来自「增长投放」机器人的消息")
+        print(f"   3. 消息中应包含测试广告的决策信息")
+
+        return True
+
+    except Exception as e:
+        print(f"\n❌ 发送失败: {e}")
+        import traceback
+        traceback.print_exc()
+
+        print(f"\n🔍 可能的原因:")
+        print(f"   1. 飞书应用凭据不正确(APP_ID/APP_SECRET)")
+        print(f"   2. 接收人/群聊ID不正确")
+        print(f"   3. 网络问题或代理设置")
+        print(f"   4. 飞书应用权限不足")
+
+        return False
+
+async def main():
+    print("\n" + "🧪" * 35)
+    print(" " * 15 + "飞书审批功能测试")
+    print("🧪" * 35)
+
+    success = await test_feishu_message()
+
+    print("\n" + "=" * 70)
+    print("  测试结果")
+    print("=" * 70)
+
+    if success:
+        print("✅ 飞书消息发送成功,请检查飞书App")
+        print("\n📝 下一步:")
+        print("   1. 在飞书中确认收到测试消息")
+        print("   2. 如需实际执行决策,设置 EXECUTION_ENABLED=True")
+        print("   3. 运行完整流程: python3 execute_once.py")
+    else:
+        print("❌ 飞书消息发送失败,请检查配置")
+
+    return 0 if success else 1
+
+if __name__ == "__main__":
+    sys.exit(asyncio.run(main()))

+ 49 - 0
examples/auto_put_ad_mini/test_feishu_import.py

@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+"""
+测试飞书导入功能
+
+用法:
+    python test_feishu_import.py [xlsx_path]
+
+如果不提供路径,自动使用 outputs/reports/ 下最新的 xlsx
+"""
+
+import asyncio
+import sys
+from pathlib import Path
+
+# 添加项目路径
+_MINI_DIR = Path(__file__).resolve().parent
+sys.path.insert(0, str(_MINI_DIR))
+
+from tools.feishu_doc import import_to_feishu
+
+
+async def main():
+    xlsx_path = sys.argv[1] if len(sys.argv) > 1 else ""
+
+    print(f"开始导入到飞书...")
+    print(f"文件路径: {xlsx_path or '自动检测最新 xlsx'}")
+    print("-" * 60)
+
+    result = await import_to_feishu(
+        ctx=None,  # type: ignore
+        xlsx_path=xlsx_path,
+        send_im=True,  # 发送到飞书群
+        chat_id="",    # 使用配置中的默认群
+    )
+
+    print("\n" + "=" * 60)
+    print(f"标题: {result.title}")
+    print("=" * 60)
+    print(result.output)
+    print("=" * 60)
+
+    if result.metadata:
+        print("\n详细信息:")
+        for k, v in result.metadata.items():
+            print(f"  {k}: {v}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 103 - 0
examples/auto_put_ad_mini/test_real_approval_flow.py

@@ -0,0 +1,103 @@
+"""
+使用真实数据执行完整审批流程测试
+"""
+import asyncio
+import sys
+from pathlib import Path
+
+# 添加项目根目录到路径
+PROJECT_ROOT = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from examples.auto_put_ad_mini.config import FEISHU_OPERATOR_OPEN_ID, FEISHU_AD_PROJECT_CHAT_ID
+from examples.auto_put_ad_mini.tools.im_approval import send_approval_request
+
+
+# 模拟ToolContext
+class SimpleContext:
+    """简单的上下文模拟"""
+    def __init__(self):
+        self.config = {}
+
+
+async def main():
+    print("=" * 70)
+    print("  完整端到端测试:使用真实数据")
+    print("=" * 70)
+    print()
+
+    # 使用最新的验证决策数据
+    validated_csv = "outputs/reports/validated_decisions_20260415.csv"
+
+    print(f"使用数据文件: {validated_csv}")
+    print()
+    print("配置验证:")
+    print(f"  个人 Open ID: {FEISHU_OPERATOR_OPEN_ID}")
+    print(f"  项目群聊 ID: {FEISHU_AD_PROJECT_CHAT_ID}")
+    print()
+
+    # 执行审批流程
+    print("执行审批流程...")
+    print("-" * 70)
+
+    ctx = SimpleContext()
+
+    try:
+        result = await send_approval_request(
+            ctx=ctx,
+            validated_csv=validated_csv,
+            wait_for_reply=False,  # 非阻塞模式
+            timeout_minutes=30,
+        )
+
+        print()
+        print("✅ 审批请求发送成功!")
+        print("=" * 70)
+        print()
+        print(f"📋 {result.title}")
+        print()
+        print(result.output)
+        print()
+
+        if result.metadata:
+            print("详细信息:")
+            print(f"  请求ID: {result.metadata.get('request_id')}")
+            print(f"  飞书发送状态: {result.metadata.get('feishu_sent', False)}")
+            if 'ad_ids' in result.metadata:
+                ad_ids = result.metadata['ad_ids']
+                print(f"  待审批广告数: {len(ad_ids)}")
+            print()
+
+        print("=" * 70)
+        print("请验证以下内容:")
+        print("=" * 70)
+        print()
+        print("1️⃣  个人私聊")
+        print("     - 检查飞书个人消息")
+        print("     - 确认收到审批消息 + 在线表格链接")
+        print()
+        print("2️⃣  项目群聊")
+        print("     - 检查项目群消息")
+        print("     - 确认收到相同的审批消息 + 在线表格链接")
+        print()
+        print("3️⃣  表格权限")
+        print("     - 打开表格链接")
+        print('     - 确认有"编辑"按钮(anyone_editable)')
+        print("     - 尝试修改单元格,确认可编辑")
+        print()
+        print("4️⃣  回复测试")
+        print("     - 在个人私聊或项目群中回复'通过'")
+        print("     - 系统应该能识别回复")
+        print()
+
+    except Exception as e:
+        print(f"❌ 发送失败: {e}")
+        import traceback
+        traceback.print_exc()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 132 - 0
examples/auto_put_ad_mini/test_send_with_sheet.py

@@ -0,0 +1,132 @@
+"""
+测试发送消息和表格到投放审批群聊
+"""
+import asyncio
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from agent.tools.builtin.feishu.feishu_client import FeishuClient
+from agent.tools.models import ToolContext
+from config import FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_OPERATOR_CHAT_ID
+
+print("=" * 60)
+print("测试发送消息和表格到投放审批群聊")
+print("=" * 60)
+print()
+print(f"飞书应用 ID: {FEISHU_APP_ID}")
+print(f"投放审批群聊 ID: {FEISHU_OPERATOR_CHAT_ID}")
+print()
+
+# 创建飞书客户端
+client = FeishuClient(
+    app_id=FEISHU_APP_ID,
+    app_secret=FEISHU_APP_SECRET
+)
+
+async def main():
+    # 步骤1:发送测试消息
+    print("🚀 步骤1:发送测试消息...")
+    test_message = """📊 **广告调控测试消息**
+
+这是一条测试消息,验证以下功能:
+✅ 消息发送到投放审批群聊
+✅ 在线表格导入和分享
+✅ 外部访问权限设置
+
+---
+测试时间: 2026-04-17 21:40
+"""
+
+    try:
+        result = client.send_message(to=FEISHU_OPERATOR_CHAT_ID, text=test_message)
+        print(f"✅ 测试消息发送成功")
+        print(f"   消息 ID: {result.message_id}")
+        print()
+    except Exception as e:
+        print(f"❌ 测试消息发送失败: {e}")
+        return
+
+    # 步骤2:查找或创建测试表格
+    print("🚀 步骤2:准备测试表格...")
+    reports_dir = Path("outputs/reports")
+
+    # 查找已有的 xlsx 文件
+    xlsx_files = list(reports_dir.glob("*.xlsx"))
+
+    if not xlsx_files:
+        print("⚠️  未找到现有表格文件,创建测试表格...")
+
+        # 创建简单的测试 Excel
+        import pandas as pd
+        test_data = pd.DataFrame({
+            "广告ID": [12345, 67890],
+            "广告名称": ["测试广告1", "测试广告2"],
+            "ROI": [1.5, 2.3],
+            "日均消耗": [500, 800],
+            "决策": ["保持", "提价5%"],
+        })
+
+        reports_dir.mkdir(parents=True, exist_ok=True)
+        test_xlsx = reports_dir / "test_decision_20260417.xlsx"
+        test_data.to_excel(test_xlsx, index=False)
+        print(f"✅ 测试表格已创建: {test_xlsx.name}")
+        xlsx_path = test_xlsx
+    else:
+        xlsx_path = xlsx_files[0]
+        print(f"✅ 使用现有表格: {xlsx_path.name}")
+
+    print()
+
+    # 步骤3:导入为飞书在线表格并发送到群聊
+    print("🚀 步骤3:导入飞书在线表格...")
+
+    # 添加 tools 目录到路径
+    tools_dir = Path(__file__).parent / "tools"
+    if str(tools_dir) not in sys.path:
+        sys.path.insert(0, str(tools_dir))
+
+    from feishu_doc import import_to_feishu
+
+    ctx = ToolContext(uid="test", step_count=1, trace_id="test-trace")
+
+    result = await import_to_feishu(
+        ctx=ctx,
+        xlsx_path=str(xlsx_path),
+        send_im=True,
+        chat_id=FEISHU_OPERATOR_CHAT_ID
+    )
+
+    print()
+    print("=" * 60)
+    print("📊 测试结果")
+    print("=" * 60)
+    print(f"标题: {result.title}")
+    print(f"输出: {result.output}")
+    print()
+
+    if result.metadata and result.metadata.get("url"):
+        sheet_url = result.metadata["url"]
+        print(f"✅ 在线表格已生成")
+        print(f"   URL: {sheet_url}")
+        print()
+        print("📱 请在飞书群聊中确认:")
+        print("   1. 收到测试消息")
+        print("   2. 收到表格链接消息")
+        print("   3. 点击链接能打开表格")
+        print()
+        print("🧪 测试外部访问:")
+        print("   1. 复制表格 URL")
+        print("   2. 退出飞书账号(或使用隐私模式浏览器)")
+        print("   3. 访问 URL,看是否能直接查看")
+        print()
+    else:
+        print("❌ 在线表格导入失败")
+        print(f"   错误: {result.output}")
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 164 - 0
examples/auto_put_ad_mini/test_send_with_sheet_simple.py

@@ -0,0 +1,164 @@
+"""
+测试发送消息和表格到投放审批群聊(简化版)
+"""
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from agent.tools.builtin.feishu.feishu_client import FeishuClient
+from config import FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_OPERATOR_CHAT_ID
+
+# 添加 tools 目录到路径
+tools_dir = Path(__file__).parent / "tools"
+if str(tools_dir) not in sys.path:
+    sys.path.insert(0, str(tools_dir))
+
+from feishu_doc import (
+    _get_tenant_token,
+    _upload_media,
+    _create_import_task,
+    _wait_import_result,
+    _set_permission,
+    _send_link_message,
+)
+
+print("=" * 60)
+print("测试发送消息和表格到投放审批群聊")
+print("=" * 60)
+print()
+print(f"飞书应用 ID: {FEISHU_APP_ID}")
+print(f"投放审批群聊 ID: {FEISHU_OPERATOR_CHAT_ID}")
+print()
+
+# 创建飞书客户端
+client = FeishuClient(
+    app_id=FEISHU_APP_ID,
+    app_secret=FEISHU_APP_SECRET
+)
+
+# 步骤1:发送测试消息
+print("🚀 步骤1:发送测试消息...")
+test_message = """📊 **广告调控测试消息**
+
+这是一条测试消息,验证以下功能:
+✅ 消息发送到投放审批群聊
+✅ 在线表格导入和分享
+✅ 外部访问权限设置
+
+接下来会发送在线表格链接...
+
+---
+测试时间: 2026-04-17 21:45
+"""
+
+try:
+    result = client.send_message(to=FEISHU_OPERATOR_CHAT_ID, text=test_message)
+    print(f"✅ 测试消息发送成功")
+    print(f"   消息 ID: {result.message_id}")
+    print()
+except Exception as e:
+    print(f"❌ 测试消息发送失败: {e}")
+    sys.exit(1)
+
+# 步骤2:查找测试表格
+print("🚀 步骤2:准备测试表格...")
+reports_dir = Path("outputs/reports")
+
+# 查找已有的 xlsx 文件
+xlsx_files = sorted(list(reports_dir.glob("*.xlsx")), reverse=True)
+
+if not xlsx_files:
+    print("⚠️  未找到现有表格文件,创建测试表格...")
+
+    # 创建简单的测试 Excel
+    import pandas as pd
+    test_data = pd.DataFrame({
+        "广告ID": [12345, 67890, 11111],
+        "广告名称": ["测试广告1", "测试广告2", "测试广告3"],
+        "ROI": [1.5, 2.3, 0.8],
+        "日均消耗(元)": [500, 800, 300],
+        "决策": ["保持", "提价5%", "降价10%"],
+        "原因": ["ROI正常", "高ROI低消耗", "ROI过低"],
+    })
+
+    reports_dir.mkdir(parents=True, exist_ok=True)
+    test_xlsx = reports_dir / "test_decision_20260417.xlsx"
+    test_data.to_excel(test_xlsx, index=False, engine='openpyxl')
+    print(f"✅ 测试表格已创建: {test_xlsx.name}")
+    xlsx_path = test_xlsx
+else:
+    xlsx_path = xlsx_files[0]
+    print(f"✅ 使用现有表格: {xlsx_path.name}")
+
+print()
+
+# 步骤3:导入为飞书在线表格并发送到群聊
+print("🚀 步骤3:导入飞书在线表格...")
+
+try:
+    # 3.1 获取 token
+    print("   3.1 获取飞书 token...")
+    token = _get_tenant_token()
+
+    # 3.2 上传素材
+    print("   3.2 上传 Excel 文件...")
+    file_token = _upload_media(token, xlsx_path)
+
+    # 3.3 创建导入任务
+    print("   3.3 创建导入任务...")
+    ticket = _create_import_task(token, file_token, xlsx_path.name)
+
+    # 3.4 等待导入完成
+    print("   3.4 等待导入完成...")
+    result = _wait_import_result(token, ticket)
+
+    url = result.get("url", "")
+    sheet_token = result.get("token", "")
+    file_type = result.get("type", "sheet")
+
+    if not url:
+        print("❌ 导入失败:未获取到表格 URL")
+        sys.exit(1)
+
+    print(f"   ✅ 导入成功")
+
+    # 3.5 设置权限
+    print("   3.5 设置访问权限...")
+    _set_permission(token, sheet_token, file_type)
+
+    # 3.6 发送链接到群聊
+    print("   3.6 发送表格链接到群聊...")
+    title = xlsx_path.stem
+    _send_link_message(FEISHU_OPERATOR_CHAT_ID, url, title)
+
+    print()
+    print("=" * 60)
+    print("✅ 测试完成")
+    print("=" * 60)
+    print()
+    print(f"📊 在线表格 URL:")
+    print(f"   {url}")
+    print()
+    print("📱 请在飞书群聊中确认:")
+    print("   1. ✓ 收到测试消息")
+    print("   2. ✓ 收到表格链接消息(应该显示为卡片)")
+    print("   3. ✓ 点击链接能打开表格")
+    print()
+    print("🧪 测试外部访问(重要):")
+    print("   1. 复制上面的表格 URL")
+    print("   2. 退出飞书账号(或使用隐私模式浏览器)")
+    print("   3. 访问 URL")
+    print("   4. 如果能直接查看 → 外部访问配置成功 ✅")
+    print("   5. 如果提示需要登录 → 需要配置 drive:permission:manage 权限 ⚠️")
+    print()
+
+except Exception as e:
+    print()
+    print(f"❌ 导入失败: {e}")
+    import traceback
+    traceback.print_exc()
+    sys.exit(1)

+ 309 - 0
examples/auto_put_ad_mini/test_strategy_upgrade.py

@@ -0,0 +1,309 @@
+#!/usr/bin/env python3
+"""
+广告调控策略升级 - 端到端验证测试
+
+验证内容:
+1. ROI计算是否添加了 audience_tier 和 roi_valid_days
+2. portfolio_metrics 是否包含裂变率统计
+3. 决策引擎是否包含同类对比字段
+4. 决策引擎是否正确标记年龄分段
+"""
+
+import sys
+import json
+import pandas as pd
+from pathlib import Path
+from datetime import datetime, timedelta
+
+# 添加项目根目录到路径
+PROJECT_ROOT = Path(__file__).parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+MINI_DIR = Path(__file__).parent
+OUTPUTS_DIR = MINI_DIR / "outputs"
+
+def print_section(title):
+    """打印测试章节标题"""
+    print("\n" + "=" * 70)
+    print(f"  {title}")
+    print("=" * 70)
+
+def test_roi_calculator():
+    """测试1: ROI计算器是否添加了新字段"""
+    print_section("测试 1/4: ROI 计算器 - 验证 audience_tier 和 roi_valid_days")
+
+    # 查找最新的 metrics CSV
+    metrics_files = list(OUTPUTS_DIR.glob("metrics_*.csv"))
+    if not metrics_files:
+        print("❌ 未找到 metrics CSV 文件,跳过测试")
+        return False
+
+    latest_metrics = max(metrics_files, key=lambda p: p.stat().st_mtime)
+    print(f"📂 读取文件: {latest_metrics.name}")
+
+    df = pd.read_csv(latest_metrics)
+    print(f"   广告总数: {len(df)}")
+
+    # 验证新列
+    if 'audience_tier' not in df.columns:
+        print("❌ 缺少 audience_tier 列")
+        return False
+    if 'roi_valid_days' not in df.columns:
+        print("❌ 缺少 roi_valid_days 列")
+        return False
+
+    print("✅ 新列验证通过: audience_tier, roi_valid_days")
+
+    # 人群包分布
+    tier_dist = df['audience_tier'].value_counts()
+    print(f"\n📊 人群包分布 (前10):")
+    for tier, count in tier_dist.head(10).items():
+        pct = count / len(df) * 100
+        print(f"   {tier:12s}: {count:4d} 个广告 ({pct:5.1f}%)")
+
+    # roi_valid_days 统计
+    print(f"\n📊 ROI有效数据天数统计:")
+    print(f"   最小值: {df['roi_valid_days'].min():.0f} 天")
+    print(f"   平均值: {df['roi_valid_days'].mean():.1f} 天")
+    print(f"   最大值: {df['roi_valid_days'].max():.0f} 天")
+
+    # 按天数分布
+    days_dist = df['roi_valid_days'].value_counts().sort_index()
+    print(f"\n📊 按置信度分布:")
+    high_conf = (df['roi_valid_days'] >= 7).sum()
+    mid_conf = ((df['roi_valid_days'] >= 4) & (df['roi_valid_days'] < 7)).sum()
+    low_conf = (df['roi_valid_days'] == 3).sum()
+    none_conf = (df['roi_valid_days'] < 3).sum()
+
+    print(f"   高置信度 (≥7天): {high_conf:4d} 个广告 ({high_conf/len(df)*100:5.1f}%)")
+    print(f"   中置信度 (4-6天): {mid_conf:4d} 个广告 ({mid_conf/len(df)*100:5.1f}%)")
+    print(f"   低置信度 (3天):   {low_conf:4d} 个广告 ({low_conf/len(df)*100:5.1f}%)")
+    print(f"   无数据 (<3天):    {none_conf:4d} 个广告 ({none_conf/len(df)*100:5.1f}%)")
+
+    return True
+
+def test_portfolio_metrics():
+    """测试2: portfolio_metrics 是否包含裂变率统计"""
+    print_section("测试 2/4: Portfolio Metrics - 验证裂变率统计")
+
+    # 查找最新的 portfolio_summary JSON
+    portfolio_dir = OUTPUTS_DIR / "portfolio_summary"
+    if not portfolio_dir.exists():
+        print("❌ portfolio_summary 目录不存在,跳过测试")
+        return False
+
+    json_files = list(portfolio_dir.glob("portfolio_summary_*.json"))
+    if not json_files:
+        print("❌ 未找到 portfolio_summary JSON 文件,跳过测试")
+        return False
+
+    latest_json = max(json_files, key=lambda p: p.stat().st_mtime)
+    print(f"📂 读取文件: {latest_json.name}")
+
+    with open(latest_json, 'r', encoding='utf-8') as f:
+        data = json.load(f)
+
+    by_tier = data.get('by_audience_tier', {})
+    if not by_tier:
+        print("❌ 未找到 by_audience_tier 数据")
+        return False
+
+    print(f"   人群包数量: {len(by_tier)}")
+
+    # 检查第一个人群包是否包含裂变率字段
+    sample_tier = list(by_tier.keys())[0]
+    sample_stats = by_tier[sample_tier]
+
+    if 'fission_mean' not in sample_stats and 'fission_p50' not in sample_stats:
+        print(f"❌ 人群包 {sample_tier} 缺少裂变率字段")
+        return False
+
+    print("✅ 裂变率字段验证通过: fission_mean, fission_p50")
+
+    # 展示各人群包的裂变率
+    print(f"\n📊 各人群包裂变率统计:")
+    tier_order = ["R500", "R330+", "R330", "R180", "R100", "R50", "R10", "R2", "default"]
+    for tier in tier_order:
+        if tier in by_tier:
+            stats = by_tier[tier]
+            fission_mean = stats.get('fission_mean', 'N/A')
+            roi_p50 = stats.get('roi_p50', 'N/A')
+            ad_count = stats.get('ad_count', 0)
+
+            if fission_mean != 'N/A' and fission_mean is not None:
+                print(f"   {tier:12s}: fission_mean={fission_mean:.4f}, roi_p50={roi_p50}, ads={ad_count}")
+            else:
+                print(f"   {tier:12s}: 无裂变数据, roi_p50={roi_p50}, ads={ad_count}")
+
+    return True
+
+def test_ad_decision_fields():
+    """测试3: 决策引擎是否包含同类对比字段"""
+    print_section("测试 3/4: 决策引擎 - 验证同类对比字段和年龄分段")
+
+    # 查找最新的 metrics CSV(用于模拟决策引擎输入)
+    metrics_files = list(OUTPUTS_DIR.glob("metrics_*.csv"))
+    if not metrics_files:
+        print("❌ 未找到 metrics CSV 文件,跳过测试")
+        return False
+
+    latest_metrics = max(metrics_files, key=lambda p: p.stat().st_mtime)
+    df = pd.read_csv(latest_metrics)
+
+    # 模拟一个需要评估的广告
+    sample_ads = df[df['cost_7d_avg'] > 100].head(3)
+
+    if len(sample_ads) == 0:
+        print("⚠️  没有找到消耗>100的广告样本")
+        return True
+
+    print(f"   选取 {len(sample_ads)} 个样本广告进行验证\n")
+
+    # 检查必要字段
+    required_fields = ['audience_tier', 'roi_valid_days', 'ad_age_days']
+    missing_fields = [f for f in required_fields if f not in df.columns]
+
+    if missing_fields:
+        print(f"❌ 缺少必要字段: {', '.join(missing_fields)}")
+        return False
+
+    print("✅ 基础字段验证通过")
+
+    # 展示样本广告的关键字段
+    print(f"\n📊 样本广告分析:")
+    for idx, row in sample_ads.iterrows():
+        ad_id = row['ad_id']
+        ad_name = row.get('ad_name', 'N/A')
+        tier = row.get('audience_tier', 'default')
+        roi = row.get('动态ROI_7日均值', None)
+        roi_valid_days = row.get('roi_valid_days', 0)
+        ad_age = row.get('ad_age_days', None)
+        cost_7d = row.get('cost_7d_avg', 0)
+
+        # 判断年龄分段
+        if ad_age is not None:
+            if ad_age <= 3:
+                age_segment = "newborn (极度保护)"
+            elif ad_age <= 7:
+                age_segment = "cold_start (仅允许提价)"
+            else:
+                age_segment = "mature (正常调控)"
+        else:
+            age_segment = "unknown"
+
+        # 判断置信度
+        if roi_valid_days >= 7:
+            confidence = "高"
+        elif roi_valid_days >= 4:
+            confidence = "中"
+        elif roi_valid_days == 3:
+            confidence = "低"
+        else:
+            confidence = "无"
+
+        print(f"\n   广告 {ad_id}:")
+        print(f"      人群包: {tier}")
+
+        # 格式化ROI显示
+        roi_str = f"{roi:.4f}" if (roi is not None and roi == roi) else "N/A"  # roi == roi 用于检查NaN
+        print(f"      ROI: {roi_str} (基于{int(roi_valid_days)}天数据, 置信度:{confidence})")
+        print(f"      年龄: {int(ad_age) if ad_age else 'N/A'}天 → {age_segment}")
+        print(f"      7日均消耗: {cost_7d:.2f}元")
+
+    return True
+
+def test_decision_output():
+    """测试4: 检查决策输出是否符合预期"""
+    print_section("测试 4/4: 决策输出 - 验证理由表达和动作类型")
+
+    # 查找最新的 llm_decisions CSV
+    reports_dir = OUTPUTS_DIR / "reports"
+    if not reports_dir.exists():
+        print("⚠️  reports 目录不存在,可能还未运行过决策引擎")
+        print("   提示:运行 `python3 execute_once.py --date YYYYMMDD` 生成决策")
+        return True
+
+    decision_files = list(reports_dir.glob("llm_decisions_*.csv"))
+    if not decision_files:
+        print("⚠️  未找到 llm_decisions 文件,可能还未运行过决策引擎")
+        print("   提示:运行 `python3 execute_once.py --date YYYYMMDD` 生成决策")
+        return True
+
+    latest_decision = max(decision_files, key=lambda p: p.stat().st_mtime)
+    print(f"📂 读取文件: {latest_decision.name}")
+
+    df = pd.read_csv(latest_decision)
+    print(f"   决策总数: {len(df)}")
+
+    # 统计动作分布
+    action_dist = df['action'].value_counts()
+    print(f"\n📊 决策动作分布:")
+    for action, count in action_dist.items():
+        pct = count / len(df) * 100
+        print(f"   {action:18s}: {count:4d} ({pct:5.1f}%)")
+
+    # 检查是否有新增动作
+    new_actions = ['creative_adjust', 'observe']
+    found_new_actions = [a for a in new_actions if a in action_dist.index]
+
+    if found_new_actions:
+        print(f"\n✅ 发现新增动作: {', '.join(found_new_actions)}")
+        for action in found_new_actions:
+            sample = df[df['action'] == action].head(1)
+            if not sample.empty:
+                reason = sample.iloc[0].get('reason', 'N/A')
+                print(f"\n   {action} 示例理由:")
+                print(f"   「{reason[:200]}...」" if len(reason) > 200 else f"   「{reason}」")
+    else:
+        print(f"\n⚠️  未发现新增动作 (creative_adjust/observe)")
+        print("   这可能是正常的,取决于数据中是否有符合条件的广告")
+
+    # 检查理由中是否包含"人群包同类对比"相关表述
+    if 'reason' in df.columns:
+        tier_compare_keywords = ['组中位数', '同类', 'R500组', 'R330组', 'R180组']
+        has_tier_compare = df['reason'].str.contains('|'.join(tier_compare_keywords), na=False).sum()
+
+        print(f"\n📊 理由质量分析:")
+        print(f"   包含人群包同类对比: {has_tier_compare}/{len(df)} ({has_tier_compare/len(df)*100:.1f}%)")
+
+        if has_tier_compare > 0:
+            print("\n✅ 发现使用人群包同类对比的理由示例:")
+            sample_reason = df[df['reason'].str.contains('|'.join(tier_compare_keywords), na=False)].iloc[0]['reason']
+            print(f"   「{sample_reason[:250]}...」" if len(sample_reason) > 250 else f"   「{sample_reason}」")
+
+    return True
+
+def main():
+    """主测试流程"""
+    print("\n" + "🧪" * 35)
+    print(" " * 15 + "广告调控策略升级 - 端到端验证")
+    print("🧪" * 35)
+
+    results = {
+        "ROI计算器": test_roi_calculator(),
+        "Portfolio统计": test_portfolio_metrics(),
+        "决策引擎字段": test_ad_decision_fields(),
+        "决策输出": test_decision_output(),
+    }
+
+    # 总结
+    print_section("测试总结")
+
+    passed = sum(1 for v in results.values() if v)
+    total = len(results)
+
+    for test_name, result in results.items():
+        status = "✅ 通过" if result else "❌ 失败"
+        print(f"   {test_name:20s}: {status}")
+
+    print(f"\n   总计: {passed}/{total} 项测试通过")
+
+    if passed == total:
+        print("\n🎉 所有测试通过!策略升级成功部署。")
+        return 0
+    else:
+        print(f"\n⚠️  有 {total - passed} 项测试未通过,请检查相关功能。")
+        return 1
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 248 - 0
examples/auto_put_ad_mini/test_tencent_api.py

@@ -0,0 +1,248 @@
+"""
+腾讯广告平台 API 接口测试脚本
+
+测试内容:
+1. 环境变量检查
+2. API 连接测试(获取账户信息)
+3. 广告列表查询测试
+4. 数据报表查询测试
+
+使用方法:
+    cd /Users/liulidong/project/agent/Agent
+    python3 examples/auto_put_ad_mini/test_tencent_api.py
+"""
+
+import asyncio
+import os
+import sys
+from pathlib import Path
+
+# 添加项目根目录到 Python 路径
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from dotenv import load_dotenv
+load_dotenv()
+
+# 导入腾讯广告 API 工具
+from examples.auto_put_ad_mini.tools.ad_api import (
+    account_get_info,
+    ad_get_list,
+    ad_get_report,
+)
+
+
+def check_env_vars():
+    """检查必需的环境变量"""
+    print("=" * 70)
+    print("【1/4】环境变量检查")
+    print("=" * 70)
+
+    required_vars = {
+        "TENCENT_AD_ACCESS_TOKEN": "腾讯广告 OAuth2 访问令牌",
+        "TENCENT_AD_ACCOUNT_ID": "腾讯广告账户 ID",
+    }
+
+    optional_vars = {
+        "TENCENT_AD_BASE_URL": f"API 基础 URL (默认: https://api.e.qq.com/v3.0)",
+    }
+
+    missing = []
+
+    # 检查必需变量
+    for var, desc in required_vars.items():
+        value = os.getenv(var)
+        if value:
+            # 脱敏显示
+            if "TOKEN" in var:
+                display_value = value[:10] + "..." if len(value) > 10 else "***"
+            else:
+                display_value = value
+            print(f"✅ {var}: {display_value}")
+            print(f"   {desc}")
+        else:
+            print(f"❌ {var}: 未设置")
+            print(f"   {desc}")
+            missing.append(var)
+
+    # 检查可选变量
+    print("\n可选配置:")
+    for var, desc in optional_vars.items():
+        value = os.getenv(var)
+        if value:
+            print(f"✅ {var}: {value}")
+        else:
+            print(f"⚪ {var}: 未设置 (将使用默认值)")
+        print(f"   {desc}")
+
+    if missing:
+        print(f"\n❌ 缺少必需环境变量: {', '.join(missing)}")
+        print("\n请在 .env 文件中配置:")
+        print("-" * 70)
+        for var in missing:
+            print(f"{var}=your_value_here")
+        print("-" * 70)
+        return False
+
+    print("\n✅ 环境变量检查通过")
+    return True
+
+
+async def test_account_info():
+    """测试获取账户信息"""
+    print("\n" + "=" * 70)
+    print("【2/4】账户信息查询测试")
+    print("=" * 70)
+
+    try:
+        result = await account_get_info()
+
+        if "失败" in result.title:
+            print(f"❌ 测试失败: {result.title}")
+            print(f"   错误信息: {result.output}")
+            return False
+        else:
+            print(f"✅ {result.title}")
+            print(result.output)
+            return True
+
+    except Exception as e:
+        print(f"❌ 测试异常: {e}")
+        import traceback
+        traceback.print_exc()
+        return False
+
+
+async def test_ad_list():
+    """测试查询广告列表"""
+    print("\n" + "=" * 70)
+    print("【3/4】广告列表查询测试")
+    print("=" * 70)
+
+    try:
+        # 查询前 5 个广告
+        result = await ad_get_list(page=1, page_size=5)
+
+        if "失败" in result.title:
+            print(f"❌ 测试失败: {result.title}")
+            print(f"   错误信息: {result.output}")
+            return False
+        else:
+            print(f"✅ {result.title}")
+            print(result.output)
+
+            # 显示更多统计信息
+            if result.metadata and "list" in result.metadata:
+                ads = result.metadata["list"]
+                if ads:
+                    print(f"\n📊 统计信息:")
+                    print(f"   - 广告数量: {len(ads)}")
+
+                    statuses = {}
+                    for ad in ads:
+                        status = ad.get("configured_status", "UNKNOWN")
+                        statuses[status] = statuses.get(status, 0) + 1
+
+                    print(f"   - 状态分布: {statuses}")
+
+            return True
+
+    except Exception as e:
+        print(f"❌ 测试异常: {e}")
+        import traceback
+        traceback.print_exc()
+        return False
+
+
+async def test_report():
+    """测试查询数据报表"""
+    print("\n" + "=" * 70)
+    print("【4/4】数据报表查询测试")
+    print("=" * 70)
+
+    try:
+        from datetime import datetime, timedelta
+
+        # 查询最近 7 天的数据
+        end_date = datetime.now()
+        start_date = end_date - timedelta(days=6)
+
+        date_range = {
+            "start_date": start_date.strftime("%Y-%m-%d"),
+            "end_date": end_date.strftime("%Y-%m-%d"),
+        }
+
+        print(f"查询日期范围: {date_range['start_date']} ~ {date_range['end_date']}")
+
+        result = await ad_get_report(
+            date_range=date_range,
+            level="adgroup",
+            page=1,
+            page_size=5,
+        )
+
+        if "失败" in result.title:
+            print(f"❌ 测试失败: {result.title}")
+            print(f"   错误信息: {result.output}")
+            return False
+        else:
+            print(f"✅ {result.title}")
+            print(result.output)
+            return True
+
+    except Exception as e:
+        print(f"❌ 测试异常: {e}")
+        import traceback
+        traceback.print_exc()
+        return False
+
+
+async def main():
+    """主测试流程"""
+    print("\n" + "=" * 70)
+    print("腾讯广告平台 API 接口测试")
+    print("=" * 70)
+    print()
+
+    # 1. 检查环境变量
+    if not check_env_vars():
+        print("\n❌ 测试中止: 环境变量未配置")
+        return
+
+    # 2. 测试账户信息
+    test1 = await test_account_info()
+
+    # 3. 测试广告列表
+    test2 = await test_ad_list()
+
+    # 4. 测试数据报表
+    test3 = await test_report()
+
+    # 总结
+    print("\n" + "=" * 70)
+    print("测试结果汇总")
+    print("=" * 70)
+
+    results = {
+        "环境变量检查": "✅ 通过",
+        "账户信息查询": "✅ 通过" if test1 else "❌ 失败",
+        "广告列表查询": "✅ 通过" if test2 else "❌ 失败",
+        "数据报表查询": "✅ 通过" if test3 else "❌ 失败",
+    }
+
+    for test_name, status in results.items():
+        print(f"{status}  {test_name}")
+
+    all_passed = all([test1, test2, test3])
+
+    if all_passed:
+        print("\n🎉 所有测试通过! 腾讯广告平台接口可用")
+    else:
+        print("\n⚠️  部分测试失败,请检查:")
+        print("   1. ACCESS_TOKEN 是否有效")
+        print("   2. ACCOUNT_ID 是否正确")
+        print("   3. 网络连接是否正常")
+        print("   4. 账户权限是否充足")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 74 - 15
examples/auto_put_ad_mini/tools/ad_decision.py

@@ -89,6 +89,7 @@ VALID_ACTIONS = [
     "hold",             # 保持
     "creative_adjust",  # 调整素材方向(需人工执行)
     "observe",          # 观察等待(数据不稳定或接近阈值)
+    "scale_up",         # 扩量:建议新增广告/创意(需人工执行)
 ]
 
 # ═══════════════════════════════════════════
@@ -946,10 +947,6 @@ async def get_ads_for_review(
                 })
                 continue
 
-            # ===== 移除冷启动强制保护(改为标记,不排除)=====
-            # 冷启动期广告也进入待评估,但会带上特殊标记
-            # 由 LLM 和护栏层判断是否可操作
-
             # 待优化评估:ROI 偏低 或 衰退信号 或 出价调整候选(需要智能判断)
             roi_low = (not pd.isna(f_roi)) and (f_roi < roi_mean * roi_review_factor)
             decay_signal = (
@@ -972,7 +969,65 @@ async def get_ads_for_review(
                 and bid_amount > 0
             ) if BID_ADJUSTMENT_ENABLED else False
 
-            if roi_low or decay_signal or bid_up_candidate or bid_down_candidate:
+            # 扩量候选:成熟期 + 消耗稳定 + 高消耗 + ROI正常(基于决策树)
+            scale_up_candidate = (
+                ad_age is not None
+                and ad_age > 7  # 成熟期(>7天)
+                and stable_days >= 7  # 消耗稳定(≥7天)
+                and cost_7d_avg > 1000  # 高消耗(>1000元/天)
+                and (not pd.isna(f_roi))
+                and f_roi >= roi_mean * 0.9  # ROI正常(≥均值的90%)
+            )
+
+            # ===== 年龄保护(第一优先级)=====
+            # 无论是否满足候选条件,年龄保护都是第一层判断
+            age_protected_skip = False  # 标记是否被年龄保护排除
+
+            if ad_age is not None:
+                # 冷启动期(≤3天):极度保护,直接排除所有评估
+                if ad_age <= COLD_START_DAYS:
+                    normal_ads_count += 1
+                    logger.debug(
+                        f"广告 {row['ad_id']} 处于冷启动期({ad_age}天≤{COLD_START_DAYS}天),"
+                        f"年龄保护规则自动排除(无论是否满足候选条件)"
+                    )
+                    age_protected_skip = True
+
+                # 早期成长期(4-7天):仅允许提价和扩量评估,清除其他候选标志
+                elif ad_age <= EARLY_GROWTH_DAYS:
+                    # 先判断是否需要评估
+                    need_review = roi_low or decay_signal or bid_up_candidate or bid_down_candidate or scale_up_candidate
+
+                    if need_review:
+                        # 只允许提价候选或扩量候选进入评估
+                        if not (bid_up_candidate or scale_up_candidate):
+                            normal_ads_count += 1
+                            logger.debug(
+                                f"广告 {row['ad_id']} 处于早期成长期({ad_age}天,4-{EARLY_GROWTH_DAYS}天),"
+                                f"年龄保护规则仅允许提价/扩量评估,其他候选已排除"
+                                f"(roi_low={roi_low}, decay={decay_signal}, bid_down={bid_down_candidate})"
+                            )
+                            age_protected_skip = True
+                        else:
+                            # 允许进入评估,但需要清除不允许的候选标志
+                            # 防止LLM基于这些信号给出降价/关停决策
+                            logger.debug(
+                                f"广告 {row['ad_id']} 处于早期成长期({ad_age}天,4-{EARLY_GROWTH_DAYS}天),"
+                                f"仅允许提价/扩量评估,清除降价/关停候选标志"
+                            )
+                            roi_low = False
+                            decay_signal = False
+                            bid_down_candidate = False
+                    # 如果不需要评估,正常计入 normal_ads_count(在后面统一处理)
+
+            # 年龄保护排除的广告,直接跳过
+            if age_protected_skip:
+                continue
+
+            # ===== 业务逻辑判断(第二层)=====
+            # 只有通过年龄保护的广告才会到这里
+            # 早期成长期的广告只会带着 bid_up_candidate 或 scale_up_candidate 到这里
+            if roi_low or decay_signal or bid_up_candidate or bid_down_candidate or scale_up_candidate:
                 # ===== 构建广告字典(基础字段)=====
                 ad_dict = {
                     "ad_id": int(row["ad_id"]),
@@ -986,6 +1041,7 @@ async def get_ads_for_review(
                     "stable_spend_days_30d": int(stable_days),
                     "bid_amount": round(bid_amount, 2),
                     "bid_candidate": "bid_up" if bid_up_candidate else ("bid_down" if bid_down_candidate else None),
+                    "scale_up_candidate": scale_up_candidate,  # 新增:扩量候选标记
                 }
 
                 # ===== 新增:添加 audience_tier 和 roi_valid_days =====
@@ -1192,7 +1248,8 @@ async def apply_decisions(
                     row_data = cost_row.iloc[0]
                     item["cost_7d_avg"] = float(row_data.get("cost_7d_avg", 0) or 0)
 
-                    # ===== 新增:年龄分段决策限制(基于决策树)=====
+                    # ===== 年龄保护兜底检查(阶段3)=====
+                    # 阶段1已做前置过滤,这里仅作兜底检查(理论上不应触发)
                     ad_age_days = row_data.get("ad_age_days")
                     if ad_age_days is not None:
                         if ad_age_days <= COLD_START_DAYS:  # ≤3天:冷启动期(极度保护)
@@ -1201,12 +1258,13 @@ async def apply_decisions(
                                 original_action = action
                                 original_reason = item.get("reason", "")
                                 item["action"] = "observe"
-                                item["reason"] = f"{original_reason}(原建议{original_action},但广告处于冷启动期{ad_age_days}天,极度保护,改为观察)"
+                                item["reason"] = f"{original_reason}(LLM建议{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"
+                                logger.error(
+                                    f"⚠️ 兜底检查触发!广告 {ad_id} 处于冷启动期({ad_age_days}天≤{COLD_START_DAYS}天),"
+                                    f"LLM建议 {original_action},已自动转换为 observe。"
+                                    f"这不应该发生(阶段1应已过滤),请检查逻辑!"
                                 )
                         elif ad_age_days <= EARLY_GROWTH_DAYS:  # 4-7天:早期成长期(仅允许提价)
                             # 不允许降价/关停
@@ -1214,12 +1272,13 @@ async def apply_decisions(
                                 original_action = action
                                 original_reason = item.get("reason", "")
                                 item["action"] = "observe"
-                                item["reason"] = f"{original_reason}(建议{original_action},但广告处于早期成长期{ad_age_days}天,仅允许提价,改为观察)"
+                                item["reason"] = f"{original_reason}(LLM建议{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"
+                                logger.error(
+                                    f"⚠️ 兜底检查触发!广告 {ad_id} 处于早期成长期({ad_age_days}天,4-{EARLY_GROWTH_DAYS}天),"
+                                    f"LLM建议 {original_action},已自动转换为 observe。"
+                                    f"这不应该发生(阶段1应已过滤),请检查逻辑!"
                                 )
                 else:
                     item["cost_7d_avg"] = 0.0
@@ -1261,7 +1320,7 @@ async def apply_decisions(
                             "ad_id": ad_id,
                             "action": "hold",
                             "dimension": "正常运行",
-                            "reason": f"ROI={roi_str},消耗正常({cost_7d_avg:.2f}元/天),无异常信号",
+                            "reason": f"ROI={roi_str},消耗正常({cost_7d_avg:.2f}元/天),保持当前出价",
                             "confidence": "high",
                             "source": "规则判断",
                             "cost_7d_avg": cost_7d_avg,  # 用于排序

+ 418 - 0
examples/auto_put_ad_mini/tools/creative_metrics.py

@@ -0,0 +1,418 @@
+"""
+创意级指标工具 — auto_put_ad_mini (Step 2)
+
+职责:
+  - 按 creative_id 聚合最近 N 天 ROI(简单 ROI,非 f_7日动态ROI)
+  - 为"负向决策前做创意归因检查"提供数据支撑
+
+使用定位(重要):
+  - 本阶段 Agent 决策范围是【广告级】:调出价 / 暂停广告
+  - 创意变更【不在】本阶段范围内(由后续 创意策略 Agent 负责)
+  - 创意级数据的真正目的:在 LLM 准备做 bid_down / pause 决策之前做归因检查
+    * 防止广告级误判:ROI 低可能只是"某个创意拖后腿",此时应 hold 等创意层优化
+    * 提供决策理由佐证:reason 明确说"整体问题 vs 某创意问题"
+
+两个工具:
+  - calculate_creative_metrics:批量聚合所有广告的创意级 ROI(Mode 1 工作流 Step 2.5)
+  - get_creative_context:对单个广告做创意归因诊断(LLM 决策时按需调用)
+"""
+
+import logging
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import List, Optional
+
+import pandas as pd
+
+from agent.tools import tool
+from agent.tools.models import ToolContext, ToolResult
+
+logger = logging.getLogger(__name__)
+
+_MINI_DIR = Path(__file__).resolve().parent.parent
+_MERGED_DIR = _MINI_DIR / "outputs" / "merged"
+_CREATIVE_METRICS_DIR = _MINI_DIR / "outputs" / "creative_metrics"
+
+
+# ===== 内部工具函数 =====
+
+
+def _load_merged_window(end_date_str: str, days: int) -> Optional[pd.DataFrame]:
+    """加载最近 days 天的 merged CSV,concat 后返回。"""
+    try:
+        end_dt = datetime.strptime(end_date_str, "%Y%m%d")
+    except ValueError:
+        logger.error("非法 end_date: %s", end_date_str)
+        return None
+
+    dfs = []
+    for i in range(days):
+        date = (end_dt - timedelta(days=days - 1 - i)).strftime("%Y%m%d")
+        csv_path = _MERGED_DIR / f"merged_{date}.csv"
+        if not csv_path.exists():
+            logger.warning("创意聚合:merged 数据缺失 %s", date)
+            continue
+        df = pd.read_csv(csv_path, dtype={"ad_id": str, "creative_id": str, "account_id": str})
+        dfs.append(df)
+
+    if not dfs:
+        return None
+    return pd.concat(dfs, ignore_index=True)
+
+
+def _aggregate_creative_metrics(df: pd.DataFrame, days: int) -> pd.DataFrame:
+    """按 (ad_id, creative_id) 聚合。"""
+    if df.empty:
+        return pd.DataFrame()
+
+    # 保证关键列存在
+    required = ["ad_id", "creative_id", "cost", "bizdate"]
+    for col in required:
+        if col not in df.columns:
+            logger.error("创意聚合:缺少必需列 %s", col)
+            return pd.DataFrame()
+
+    # 安全数值转换
+    for col in ["cost", "总收入", "首层小程序打开数", "裂变0层回流数", "view_count", "valid_click_count"]:
+        if col in df.columns:
+            df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0)
+
+    # 过滤掉空 creative_id(有些广告状态行可能 creative_id 为空)
+    df = df[df["creative_id"].notna() & (df["creative_id"].astype(str).str.strip() != "")]
+
+    # 取 creative_name(若有变更取最新)
+    df_sorted = df.sort_values("bizdate")
+    creative_name_latest = (
+        df_sorted.groupby(["ad_id", "creative_id"])["creative_name"].last()
+        if "creative_name" in df.columns
+        else None
+    )
+
+    # 创意首次出现日期(用于 age)
+    creative_first_date = (
+        df_sorted.groupby(["ad_id", "creative_id"])["bizdate"].min()
+    )
+
+    agg_cols = {"cost": "sum"}
+    if "总收入" in df.columns:
+        agg_cols["总收入"] = "sum"
+    if "首层小程序打开数" in df.columns:
+        agg_cols["首层小程序打开数"] = "sum"
+    if "裂变0层回流数" in df.columns:
+        agg_cols["裂变0层回流数"] = "sum"
+    if "view_count" in df.columns:
+        agg_cols["view_count"] = "sum"
+    if "valid_click_count" in df.columns:
+        agg_cols["valid_click_count"] = "sum"
+
+    active_days = df.groupby(["ad_id", "creative_id"]).apply(
+        lambda g: g[g["cost"] > 0]["bizdate"].nunique()
+    )
+
+    result = df.groupby(["ad_id", "creative_id"]).agg(agg_cols).reset_index()
+    result[f"active_days_{days}d"] = result.apply(
+        lambda r: int(active_days.get((r["ad_id"], r["creative_id"]), 0)), axis=1
+    )
+
+    # 重命名聚合列
+    rename_map = {
+        "cost": f"cost_{days}d",
+        "总收入": f"revenue_{days}d",
+        "首层小程序打开数": f"open_count_{days}d",
+        "裂变0层回流数": f"fission0_{days}d",
+        "view_count": f"view_count_{days}d",
+        "valid_click_count": f"click_count_{days}d",
+    }
+    result = result.rename(columns={k: v for k, v in rename_map.items() if k in result.columns})
+
+    # 附加 creative_name 与 creative_age_days
+    if creative_name_latest is not None:
+        result["creative_name"] = result.apply(
+            lambda r: creative_name_latest.get((r["ad_id"], r["creative_id"]), ""), axis=1
+        )
+    else:
+        result["creative_name"] = ""
+
+    def _age_days(row):
+        first = creative_first_date.get((row["ad_id"], row["creative_id"]))
+        if not first:
+            return None
+        try:
+            first_dt = datetime.strptime(str(first), "%Y%m%d")
+            today = datetime.now()
+            return max((today - first_dt).days, 0)
+        except ValueError:
+            return None
+
+    result["creative_age_days"] = result.apply(_age_days, axis=1)
+
+    # 简单 ROI
+    revenue_col = f"revenue_{days}d"
+    cost_col = f"cost_{days}d"
+    if revenue_col in result.columns:
+        result[f"roi_{days}d"] = result.apply(
+            lambda r: round(r[revenue_col] / r[cost_col], 4) if r[cost_col] > 0 else None,
+            axis=1,
+        )
+    else:
+        result[f"roi_{days}d"] = None
+
+    # 该创意在所属广告中的消耗占比
+    ad_total = result.groupby("ad_id")[cost_col].transform("sum")
+    result["cost_share"] = result.apply(
+        lambda r: round(r[cost_col] / ad_total.loc[r.name], 4) if ad_total.loc[r.name] > 0 else 0,
+        axis=1,
+    )
+
+    return result
+
+
+def _classify_attribution(creatives_for_ad: pd.DataFrame, cost_col: str, roi_col: str) -> str:
+    """
+    基于单个广告下的创意表现,判定归因类型。
+
+    返回值对应 skill 中定义的 4 类 attribution_hint:
+      - "single_laggard"  : 单创意占 >60% 消耗且 ROI 远低于其他 → 建议 hold
+      - "balanced_bad"    : 多创意均衡但整体 ROI 差 → 真广告级问题,保持原负向决策
+      - "new_cold_start"  : 主要消耗集中在 age < 7 天的新创意 → 建议 hold
+      - "mixed"           : 其他情况,LLM 自行综合判断
+    """
+    # 仅考虑近期有消耗的创意
+    active = creatives_for_ad[creatives_for_ad[cost_col] > 0].copy()
+    if active.empty:
+        return "mixed"
+
+    active_sorted = active.sort_values(cost_col, ascending=False)
+    top = active_sorted.iloc[0]
+    top_share = float(top["cost_share"] or 0)
+
+    # 新创意冷启动:主导创意年龄 < 7 天
+    top_age = top.get("creative_age_days")
+    if top_share > 0.6 and top_age is not None and top_age < 7:
+        return "new_cold_start"
+
+    # 单创意拖后腿:主导创意占 >60% 且 ROI 明显低于其他活跃创意
+    if top_share > 0.6 and len(active_sorted) >= 2:
+        others = active_sorted.iloc[1:]
+        top_roi = top.get(roi_col)
+        other_roi = others[roi_col].dropna()
+        if top_roi is not None and not other_roi.empty:
+            other_mean = other_roi.mean()
+            # 主导创意 ROI 显著低于其他(低于其他均值的 70%)
+            if other_mean > 0 and top_roi < other_mean * 0.7:
+                return "single_laggard"
+
+    # 多创意均衡且整体差(≥3 个活跃创意,且占比相对均衡)
+    if len(active_sorted) >= 3 and top_share < 0.5:
+        return "balanced_bad"
+
+    return "mixed"
+
+
+# ===== 工具 1:批量计算创意级指标 =====
+
+
+@tool(description="按 creative_id 聚合最近 N 天 ROI(供广告决策做创意归因检查)")
+async def calculate_creative_metrics(
+    ctx: ToolContext,
+    days: int = 7,
+    end_date: str = "yesterday",
+) -> ToolResult:
+    """
+    聚合创意级 ROI 指标。
+
+    数据源:outputs/merged/merged_*.csv(最近 N 天),按 (ad_id, creative_id) 聚合:
+      - cost_{N}d, revenue_{N}d, open_count_{N}d, fission0_{N}d
+      - roi_{N}d = revenue_{N}d / cost_{N}d (简单 ROI,不是 f_7日动态ROI)
+      - cost_share = 该创意在所属广告中的消耗占比
+      - creative_age_days = 该创意首次出现至今天数
+      - active_days_{N}d = 近 N 天有消耗的天数
+
+    输出:outputs/creative_metrics/creative_metrics_{end_date}.csv
+
+    Args:
+        days: 聚合窗口(默认 7 天)
+        end_date: 结束日期(YYYYMMDD 或 "yesterday")
+
+    Returns:
+        ToolResult 含 CSV 路径 + 总览统计
+    """
+    try:
+        if end_date == "yesterday":
+            end_dt = datetime.now() - timedelta(days=1)
+        else:
+            end_dt = datetime.strptime(end_date.replace("-", ""), "%Y%m%d")
+        end_date_str = end_dt.strftime("%Y%m%d")
+
+        df = _load_merged_window(end_date_str, days)
+        if df is None or df.empty:
+            return ToolResult(
+                title="创意指标聚合失败",
+                output=f"未找到任何 merged 数据({_MERGED_DIR},最近 {days} 天)",
+            )
+
+        result = _aggregate_creative_metrics(df, days)
+        if result.empty:
+            return ToolResult(
+                title="创意指标聚合失败",
+                output="聚合结果为空(可能 creative_id 全为空)",
+            )
+
+        _CREATIVE_METRICS_DIR.mkdir(parents=True, exist_ok=True)
+        out_path = _CREATIVE_METRICS_DIR / f"creative_metrics_{end_date_str}.csv"
+        result.to_csv(out_path, index=False, encoding="utf-8-sig")
+
+        # 总览统计
+        total = len(result)
+        active = int((result[f"cost_{days}d"] > 0).sum())
+        ad_count = result["ad_id"].nunique()
+        summary_lines = [
+            f"✅ 创意级指标聚合完成({days} 天窗口)",
+            f"  输出文件:{out_path}",
+            f"  创意总数:{total}(其中 {active} 个近 {days} 天有消耗)",
+            f"  覆盖广告:{ad_count} 条",
+        ]
+
+        return ToolResult(
+            title=f"创意级指标聚合({days}天)",
+            output="\n".join(summary_lines),
+            metadata={
+                "csv_path": str(out_path),
+                "total_creatives": total,
+                "active_creatives": active,
+                "ad_count": ad_count,
+                "days": days,
+            },
+        )
+    except Exception as e:
+        logger.exception("calculate_creative_metrics 失败")
+        return ToolResult(title="创意指标聚合异常", output=f"错误:{e}")
+
+
+# ===== 工具 2:单广告归因检查 =====
+
+
+@tool(description="对准备做负向决策(降价/暂停)的广告,做创意归因检查")
+async def get_creative_context(
+    ctx: ToolContext,
+    ad_id: str,
+    days: int = 7,
+    end_date: str = "yesterday",
+) -> ToolResult:
+    """
+    对指定广告做创意归因诊断。
+
+    使用时机(严格):
+      - LLM 初步打算判 bid_down / pause 的广告才调用
+      - 不对 hold / bid_up 的广告调用(避免无意义开销)
+
+    Args:
+        ad_id: 广告 ID
+        days: 归因窗口(默认 7 天,需要已先跑过 calculate_creative_metrics)
+        end_date: 结束日期(YYYYMMDD 或 "yesterday")
+
+    Returns:
+        ToolResult.data:
+          {
+            "ad_id": ...,
+            "total_creatives": int,
+            "active_creatives_7d": int,
+            "dominant_creative_pct": float,
+            "attribution_hint": "single_laggard" | "balanced_bad" | "new_cold_start" | "mixed",
+            "creatives": [...按 cost_share 降序...]
+          }
+    """
+    try:
+        if end_date == "yesterday":
+            end_dt = datetime.now() - timedelta(days=1)
+        else:
+            end_dt = datetime.strptime(end_date.replace("-", ""), "%Y%m%d")
+        end_date_str = end_dt.strftime("%Y%m%d")
+
+        metrics_path = _CREATIVE_METRICS_DIR / f"creative_metrics_{end_date_str}.csv"
+        if not metrics_path.exists():
+            return ToolResult(
+                title="创意归因失败",
+                output=f"创意指标 CSV 不存在:{metrics_path}\n请先运行 calculate_creative_metrics。",
+            )
+
+        df = pd.read_csv(metrics_path, dtype={"ad_id": str, "creative_id": str})
+        ad_str = str(ad_id)
+        ad_df = df[df["ad_id"] == ad_str].copy()
+
+        if ad_df.empty:
+            return ToolResult(
+                title="创意归因:广告无创意数据",
+                output=f"广告 {ad_id} 在创意指标表中无记录(可能该广告未产生创意级消耗)",
+                metadata={
+                    "ad_id": ad_str,
+                    "total_creatives": 0,
+                    "active_creatives_7d": 0,
+                    "attribution_hint": "mixed",
+                    "creatives": [],
+                },
+            )
+
+        cost_col = f"cost_{days}d"
+        roi_col = f"roi_{days}d"
+
+        # 基础统计
+        total = len(ad_df)
+        active = int((ad_df[cost_col] > 0).sum())
+        ad_df_sorted = ad_df.sort_values(cost_col, ascending=False)
+        dominant_pct = (
+            float(ad_df_sorted.iloc[0]["cost_share"]) if not ad_df_sorted.empty else 0.0
+        )
+
+        attribution_hint = _classify_attribution(ad_df, cost_col, roi_col)
+
+        # 构建返回创意列表(按 cost_share 降序)
+        creatives_list = []
+        for _, row in ad_df_sorted.iterrows():
+            creatives_list.append({
+                "creative_id": str(row["creative_id"]),
+                "creative_name": str(row.get("creative_name", "") or ""),
+                "cost": round(float(row[cost_col]), 2) if cost_col in row else 0.0,
+                "revenue": round(float(row.get(f"revenue_{days}d", 0)), 2),
+                "roi": (
+                    round(float(row[roi_col]), 4)
+                    if roi_col in row and pd.notna(row[roi_col])
+                    else None
+                ),
+                "cost_share": round(float(row.get("cost_share", 0)), 4),
+                "creative_age_days": (
+                    int(row["creative_age_days"])
+                    if pd.notna(row.get("creative_age_days"))
+                    else None
+                ),
+                "active_days": int(row.get(f"active_days_{days}d", 0) or 0),
+            })
+
+        # 生成人类可读摘要
+        summary_lines = [
+            f"广告 {ad_id} 创意归因(近 {days} 天)",
+            f"  总创意数:{total}(活跃 {active})",
+            f"  主导创意占比:{dominant_pct*100:.1f}%",
+            f"  归因判定:{attribution_hint}",
+        ]
+        for c in creatives_list[:5]:
+            roi_str = f"{c['roi']:.2f}" if c["roi"] is not None else "N/A"
+            summary_lines.append(
+                f"    - {c['creative_id']}: cost={c['cost']:.0f}元, ROI={roi_str}, 占比={c['cost_share']*100:.1f}%, age={c['creative_age_days']}天"
+            )
+
+        return ToolResult(
+            title=f"创意归因 ad_id={ad_id}",
+            output="\n".join(summary_lines),
+            metadata={
+                "ad_id": ad_str,
+                "total_creatives": total,
+                "active_creatives_7d": active,
+                "dominant_creative_pct": round(dominant_pct, 4),
+                "attribution_hint": attribution_hint,
+                "creatives": creatives_list,
+            },
+        )
+    except Exception as e:
+        logger.exception("get_creative_context 失败")
+        return ToolResult(title="创意归因异常", output=f"错误:{e}")

+ 4 - 4
examples/auto_put_ad_mini/tools/execution_engine.py

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

+ 2 - 1
examples/auto_put_ad_mini/tools/im_approval.py

@@ -284,7 +284,8 @@ def _format_approval_message(df_tier2: pd.DataFrame, df_tier1: pd.DataFrame, df_
             action_label = {
                 "observe": "观察等待",
                 "hold": "保持不变",
-                "creative_adjust": "需人工调整素材"
+                "creative_adjust": "需人工调整素材",
+                "scale_up": "建议扩量(新增广告/创意)"
             }.get(action, action)
             lines.append(f"  [{ad_id}] {action_label}")
         lines.append("")

+ 503 - 0
examples/auto_put_ad_mini/tools/portfolio_metrics.py

@@ -0,0 +1,503 @@
+"""
+账户级 + 人群包级 ROI 基线汇总 — auto_put_ad_mini (Step 4.2)
+
+职责:
+  - 读取 metrics CSV(calculate_roi_metrics 输出)
+  - 按 account_id / audience_tier / global 三层聚合
+  - 输出 p25/p50/p75 分位数作为决策基线
+
+使用定位:
+  - LLM 在判断广告 ROI "偏低" 时,应先和【同人群包 p25】比,而非全体均值 × 0.5
+  - 跑量放大候选:同人群包 p75 是最低门槛
+  - 大盘下滑信号:账户 roi_mean 比历史显著低 → 行情差,不应过度降价触发死亡螺旋
+"""
+
+import json
+import logging
+import math
+import sys
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+import pandas as pd
+
+from agent.tools import tool
+from agent.tools.models import ToolContext, ToolResult
+
+logger = logging.getLogger(__name__)
+
+_MINI_DIR = Path(__file__).resolve().parent.parent
+# metrics CSV 保存在 outputs/metrics_{date}.csv(roi_calculator.py line 769)
+_OUTPUTS_DIR = _MINI_DIR / "outputs"
+_PORTFOLIO_DIR = _MINI_DIR / "outputs" / "portfolio_summary"
+
+# 引入 config 中的市场信号阈值
+sys.path.insert(0, str(_MINI_DIR))
+try:
+    from config import MARKET_VOLATILE_PCT, MARKET_TRENDING_DOWN_PCT  # noqa: E402
+except ImportError:
+    MARKET_VOLATILE_PCT = 0.15
+    MARKET_TRENDING_DOWN_PCT = -0.10
+
+
+def _safe_float(v) -> Optional[float]:
+    try:
+        f = float(v)
+        if math.isnan(f) or math.isinf(f):
+            return None
+        return f
+    except (TypeError, ValueError):
+        return None
+
+
+def _round_or_none(v: Optional[float], digits: int = 4) -> Optional[float]:
+    return round(v, digits) if v is not None else None
+
+
+def _describe_group(df: pd.DataFrame) -> Dict[str, Any]:
+    """对一个分组 DataFrame 计算统计指标。"""
+    if df.empty:
+        return {
+            "ad_count": 0,
+            "active_ads": 0,
+            "daily_cost_avg": 0.0,
+            "daily_revenue_avg": 0.0,
+            "roi_mean": None,
+            "roi_p25": None,
+            "roi_p50": None,
+            "roi_p75": None,
+            "stable_ads_pct": 0.0,
+        }
+
+    ad_count = len(df)
+    # active_ads: 7 日有消耗
+    if "cost_7d_avg" in df.columns:
+        active = int((pd.to_numeric(df["cost_7d_avg"], errors="coerce").fillna(0) > 0).sum())
+    else:
+        active = 0
+
+    # 日消耗 / 日收入均值(跨广告的日均)
+    if "cost_7d_avg" in df.columns:
+        daily_cost_avg = float(pd.to_numeric(df["cost_7d_avg"], errors="coerce").fillna(0).sum())
+    else:
+        daily_cost_avg = 0.0
+
+    # 日收入 = 7日总收入 / 7(若有 revenue_7d_total),否则 fallback 为空
+    daily_revenue_avg = 0.0
+    if "revenue_7d_total" in df.columns:
+        total_rev = pd.to_numeric(df["revenue_7d_total"], errors="coerce").fillna(0).sum()
+        daily_revenue_avg = float(total_rev) / 7.0
+
+    # ROI 分位数(使用 动态ROI_7日均值,若缺则尝试 f_7日动态ROI)
+    roi_col = None
+    for candidate in ("动态ROI_7日均值", "f_7日动态ROI"):
+        if candidate in df.columns:
+            roi_col = candidate
+            break
+
+    roi_stats = {"roi_mean": None, "roi_p25": None, "roi_p50": None, "roi_p75": None}
+    if roi_col:
+        roi_series = pd.to_numeric(df[roi_col], errors="coerce").dropna()
+        # 过滤掉 ROI = 0 的记录(通常代表无数据)
+        roi_series = roi_series[roi_series > 0]
+        if not roi_series.empty:
+            roi_stats = {
+                "roi_mean": _round_or_none(float(roi_series.mean())),
+                "roi_p25": _round_or_none(float(roi_series.quantile(0.25))),
+                "roi_p50": _round_or_none(float(roi_series.quantile(0.50))),
+                "roi_p75": _round_or_none(float(roi_series.quantile(0.75))),
+            }
+
+    # ===== 新增:裂变率统计 =====
+    fission_stats = {"fission_mean": None, "fission_p50": None}
+    # 假设metrics CSV中有 "T0裂变系数_7日均值" 字段
+    fission_col = None
+    for candidate in ("T0裂变系数_7日均值", "T0裂变系数", "fission_ratio"):
+        if candidate in df.columns:
+            fission_col = candidate
+            break
+
+    if fission_col:
+        fission_series = pd.to_numeric(df[fission_col], errors="coerce").dropna()
+        fission_series = fission_series[fission_series > 0]  # 过滤无效值
+        if not fission_series.empty:
+            fission_stats = {
+                "fission_mean": _round_or_none(float(fission_series.mean())),
+                "fission_p50": _round_or_none(float(fission_series.quantile(0.50))),
+            }
+
+    # 稳定消耗比例:stable_spend_days_30d >= 7 视为稳定
+    stable_pct = 0.0
+    if "stable_spend_days_30d" in df.columns and ad_count > 0:
+        stable_mask = pd.to_numeric(df["stable_spend_days_30d"], errors="coerce").fillna(0) >= 7
+        stable_pct = float(stable_mask.sum()) / ad_count
+
+    return {
+        "ad_count": ad_count,
+        "active_ads": active,
+        "daily_cost_avg": round(daily_cost_avg, 2),
+        "daily_revenue_avg": round(daily_revenue_avg, 2),
+        **roi_stats,
+        **fission_stats,  # 新增
+        "stable_ads_pct": round(stable_pct, 4),
+    }
+
+
+def _resolve_metrics_csv(metrics_csv: str, end_date: str) -> Optional[Path]:
+    """解析 metrics CSV 路径(优先用传入的路径,否则根据 end_date 找)。"""
+    if metrics_csv:
+        p = Path(metrics_csv)
+        if not p.is_absolute():
+            p = _MINI_DIR / p
+        if p.exists():
+            return p
+
+    # Fallback:根据 end_date
+    if end_date == "yesterday":
+        end_dt = datetime.now() - timedelta(days=1)
+    else:
+        try:
+            end_dt = datetime.strptime(end_date.replace("-", ""), "%Y%m%d")
+        except ValueError:
+            return None
+    end_date_str = end_dt.strftime("%Y%m%d")
+
+    # 按常见命名找
+    candidates = [
+        _OUTPUTS_DIR / f"metrics_{end_date_str}.csv",
+        _OUTPUTS_DIR / "metrics_temp.csv",
+    ]
+    for c in candidates:
+        if c.exists():
+            return c
+
+    # 最后尝试目录下最新的 metrics_*.csv
+    if _OUTPUTS_DIR.exists():
+        csvs = sorted(_OUTPUTS_DIR.glob("metrics_*.csv"), key=lambda p: p.stat().st_mtime, reverse=True)
+        if csvs:
+            return csvs[0]
+
+    return None
+
+
+def _compute_daily_tier_snapshot(
+    end_dt: datetime, days: int = 7
+) -> Dict[str, Dict[str, Any]]:
+    """读取最近 N 天的 metrics_{date}.csv,按 (date, audience_tier) 计算 p25/p50/p75。
+
+    结构:
+      {
+        "YYYYMMDD": {
+          "global": {roi_p25, roi_p50, roi_p75, ad_count},
+          "by_audience_tier": {
+            "R500": {roi_p25, roi_p50, roi_p75, ad_count},
+            ...
+          }
+        },
+        ...
+      }
+
+    用途:给 LLM 看"同 tier 基线的日级波动",辅助判断大盘行情;
+         也是 relative_trend_pct 的审计来源(虽然 roi_calculator 内部已自算)。
+
+    对历史 metrics CSV 中无 audience_tier 列的情况 → 该天只输出 global。
+    """
+    by_date: Dict[str, Dict[str, Any]] = {}
+    for i in range(days):
+        date_str = (end_dt - timedelta(days=i)).strftime("%Y%m%d")
+        csv_path = _OUTPUTS_DIR / f"metrics_{date_str}.csv"
+        if not csv_path.exists():
+            continue
+        try:
+            dfi = pd.read_csv(csv_path, dtype={"ad_id": str, "account_id": str})
+        except Exception as e:
+            logger.warning("读取 %s 失败:%s", csv_path.name, e)
+            continue
+        if dfi.empty:
+            continue
+
+        day_entry: Dict[str, Any] = {
+            "global": _describe_group(dfi),
+        }
+        if "audience_tier" in dfi.columns:
+            tier_map: Dict[str, Any] = {}
+            for tier, group in dfi.groupby("audience_tier"):
+                tier_map[str(tier)] = _describe_group(group)
+            day_entry["by_audience_tier"] = tier_map
+        else:
+            day_entry["by_audience_tier"] = {}
+        by_date[date_str] = day_entry
+
+    return by_date
+
+
+def _compute_market_signal(by_date: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
+    """
+    根据最近 N 天的全局 p50 序列,自动判定大盘行情(不让 LLM 看 by_date 自己猜)。
+
+    判定规则(优先级从高到低):
+      1. 数据 < 4 天 → unknown(无法判断)
+      2. 最近 3 日 p50 均值 / 之前 N-3 日均值 - 1 ≤ MARKET_TRENDING_DOWN_PCT (-10%) → trending_down
+      3. (max - min) / min > MARKET_VOLATILE_PCT (15%) → volatile
+      4. 否则 → stable
+
+    输出:
+      {
+        "label": "stable" / "volatile" / "trending_down" / "unknown",
+        "p50_volatility_pct": 0.12,
+        "recent_vs_baseline_pct": -0.08,
+        "p50_series": [{"date": "20260410", "p50": 1.78}, ...],
+        "ad_count_today": 1234,
+        "reason": "..."
+      }
+    """
+    p50_pairs: List[Tuple[str, float]] = sorted(
+        [
+            (d, entry["global"].get("roi_p50"))
+            for d, entry in by_date.items()
+            if entry.get("global", {}).get("roi_p50") is not None
+        ]
+    )
+
+    if len(p50_pairs) < 4:
+        return {
+            "label": "unknown",
+            "p50_volatility_pct": None,
+            "recent_vs_baseline_pct": None,
+            "p50_series": [{"date": d, "p50": p} for d, p in p50_pairs],
+            "ad_count_today": None,
+            "reason": f"数据不足(只有 {len(p50_pairs)} 天 p50),无法判断行情",
+        }
+
+    p50_values = [p for _, p in p50_pairs]
+    p50_min = min(p50_values)
+    p50_max = max(p50_values)
+    volatility_pct = (p50_max - p50_min) / p50_min if p50_min > 0 else 0.0
+
+    # 最近 3 天 vs 之前若干天
+    recent = p50_values[-3:]
+    baseline = p50_values[:-3]
+    recent_mean = sum(recent) / len(recent)
+    baseline_mean = sum(baseline) / len(baseline) if baseline else recent_mean
+    recent_vs_baseline_pct = (
+        (recent_mean - baseline_mean) / baseline_mean if baseline_mean > 0 else 0.0
+    )
+
+    # 判定
+    if recent_vs_baseline_pct <= MARKET_TRENDING_DOWN_PCT:
+        label = "trending_down"
+        reason = (
+            f"最近 3 日 p50 均值 {recent_mean:.3f} 比之前 {len(baseline)} 日均值 "
+            f"{baseline_mean:.3f} 低 {-recent_vs_baseline_pct * 100:.1f}%(阈值 "
+            f"{-MARKET_TRENDING_DOWN_PCT * 100:.0f}%),大盘下行"
+        )
+    elif volatility_pct > MARKET_VOLATILE_PCT:
+        label = "volatile"
+        reason = (
+            f"全局 p50 跨日波幅 {volatility_pct * 100:.1f}%(阈值 "
+            f"{MARKET_VOLATILE_PCT * 100:.0f}%),ARPU 共模噪声大,单广告决策应保守"
+        )
+    else:
+        label = "stable"
+        reason = (
+            f"全局 p50 跨日波幅 {volatility_pct * 100:.1f}% < "
+            f"{MARKET_VOLATILE_PCT * 100:.0f}% 且无显著趋势,大盘平稳"
+        )
+
+    # 当日广告数量(取最后一天)
+    ad_count_today = None
+    if p50_pairs:
+        last_date = p50_pairs[-1][0]
+        ad_count_today = by_date.get(last_date, {}).get("global", {}).get("ad_count")
+
+    return {
+        "label": label,
+        "p50_volatility_pct": round(volatility_pct, 4),
+        "recent_vs_baseline_pct": round(recent_vs_baseline_pct, 4),
+        "p50_series": [{"date": d, "p50": round(p, 4)} for d, p in p50_pairs],
+        "ad_count_today": ad_count_today,
+        "reason": reason,
+    }
+
+
+@tool(description="账户级 + 人群包级 ROI 基线汇总(p25/p50/p75 + 最近 7 天日级基线快照 + 大盘行情判定),供 LLM 对标用")
+async def calculate_portfolio_summary(
+    ctx: ToolContext,
+    metrics_csv: str = "",
+    end_date: str = "yesterday",
+) -> ToolResult:
+    """
+    读取 metrics CSV,按账户 / 人群包 / 全局三层汇总;
+    同时读取最近 7 天 metrics CSV 生成日级基线快照(供 LLM 审计大盘波动)。
+
+    输出:outputs/portfolio_summary/portfolio_summary_{end_date}.json
+    结构:
+      {
+        "end_date": "YYYYMMDD",
+        "by_account": { "<account_id>": { ... } },
+        "by_audience_tier": { "<tier>": { ... } },    # 基于 end_date CSV
+        "global": { ... },
+        "by_date": {
+          "YYYYMMDD": {
+            "global": { roi_p25, roi_p50, roi_p75, ad_count, ... },
+            "by_audience_tier": { "R500": {...}, "R50": {...} }
+          },
+          ...(最近 7 天)
+        }
+      }
+
+    Args:
+        metrics_csv: metrics CSV 路径(可选,默认根据 end_date 自动解析)
+        end_date: 结束日期(YYYYMMDD 或 "yesterday")
+
+    Returns:
+        ToolResult 含 JSON 路径 + 关键摘要
+    """
+    try:
+        if end_date == "yesterday":
+            end_dt = datetime.now() - timedelta(days=1)
+        else:
+            end_dt = datetime.strptime(end_date.replace("-", ""), "%Y%m%d")
+        end_date_str = end_dt.strftime("%Y%m%d")
+
+        csv_path = _resolve_metrics_csv(metrics_csv, end_date)
+        if csv_path is None or not csv_path.exists():
+            return ToolResult(
+                title="账户基线计算失败",
+                output=f"未找到 metrics CSV(指定:{metrics_csv or '自动'},日期:{end_date})",
+            )
+
+        df = pd.read_csv(csv_path, dtype={"ad_id": str, "account_id": str})
+        if df.empty:
+            return ToolResult(
+                title="账户基线计算失败",
+                output=f"metrics CSV 为空:{csv_path}",
+            )
+
+        # 按 account_id 分组
+        by_account: Dict[str, Any] = {}
+        if "account_id" in df.columns:
+            for acc_id, group in df.groupby("account_id"):
+                by_account[str(acc_id)] = _describe_group(group)
+
+        # 按 audience_tier 分组
+        by_tier: Dict[str, Any] = {}
+        if "audience_tier" in df.columns:
+            for tier, group in df.groupby("audience_tier"):
+                by_tier[str(tier)] = _describe_group(group)
+
+        # 全局
+        global_stats = _describe_group(df)
+
+        # 日级基线快照(最近 7 天)
+        by_date = _compute_daily_tier_snapshot(end_dt, days=7)
+
+        # 大盘行情判定(V4:由代码出,不让 LLM 看 by_date 自己猜)
+        market_signal = _compute_market_signal(by_date)
+
+        summary = {
+            "end_date": end_date_str,
+            "source_csv": str(csv_path),
+            "by_account": by_account,
+            "by_audience_tier": by_tier,
+            "global": global_stats,
+            "by_date": by_date,
+            "market_signal": market_signal,
+        }
+
+        _PORTFOLIO_DIR.mkdir(parents=True, exist_ok=True)
+        out_path = _PORTFOLIO_DIR / f"portfolio_summary_{end_date_str}.json"
+        out_path.write_text(
+            json.dumps(summary, ensure_ascii=False, indent=2),
+            encoding="utf-8",
+        )
+
+        # 生成可读摘要(只显示 top 层)
+        lines = [
+            f"✅ 账户 / 人群包基线汇总完成",
+            f"  数据源:{csv_path.name}",
+            f"  输出文件:{out_path}",
+            "",
+            f"【全局】ad_count={global_stats['ad_count']}, "
+            f"active={global_stats['active_ads']}, "
+            f"roi_mean={global_stats['roi_mean']}, "
+            f"p25/p50/p75={global_stats['roi_p25']}/{global_stats['roi_p50']}/{global_stats['roi_p75']}",
+        ]
+        if by_tier:
+            lines.append("")
+            lines.append("【人群包(end_date 截面)】")
+            # 按 R 值典型顺序展示
+            tier_order = ["R500", "R330+", "R330", "R180", "R100", "R50", "R10", "R2", "default"]
+            for tier in tier_order:
+                if tier in by_tier:
+                    s = by_tier[tier]
+                    lines.append(
+                        f"  {tier}: n={s['ad_count']}, "
+                        f"roi_p25/p50/p75={s['roi_p25']}/{s['roi_p50']}/{s['roi_p75']}, "
+                        f"stable_pct={s['stable_ads_pct']*100:.0f}%"
+                    )
+            # 其他未列出的
+            for tier, s in by_tier.items():
+                if tier not in tier_order:
+                    lines.append(
+                        f"  {tier}: n={s['ad_count']}, "
+                        f"roi_p25/p50/p75={s['roi_p25']}/{s['roi_p50']}/{s['roi_p75']}"
+                    )
+
+        if by_date:
+            lines.append("")
+            lines.append(f"【日级基线快照(最近 {len(by_date)} 天)】")
+            # 全局 p50 波动范围
+            p50_series = [
+                (d, entry["global"].get("roi_p50"))
+                for d, entry in sorted(by_date.items())
+                if entry.get("global", {}).get("roi_p50") is not None
+            ]
+            if p50_series:
+                p50_values = [v for _, v in p50_series]
+                lines.append(
+                    f"  全局 p50 波动:min={min(p50_values):.3f}, "
+                    f"max={max(p50_values):.3f}, "
+                    f"波幅={(max(p50_values) - min(p50_values)) / min(p50_values) * 100:.1f}% "
+                    f"(大盘行情参考,波动大说明 ARPU 共模噪声大)"
+                )
+                # 显示每天每 tier 的 p50
+                lines.append("  每日 tier p50 快照:")
+                for d, entry in sorted(by_date.items()):
+                    tier_map = entry.get("by_audience_tier", {})
+                    tier_p50s = []
+                    for t in tier_order:
+                        if t in tier_map and tier_map[t].get("roi_p50") is not None:
+                            tier_p50s.append(f"{t}={tier_map[t]['roi_p50']:.2f}")
+                    if tier_p50s:
+                        lines.append(f"    {d}: {' | '.join(tier_p50s)}")
+                    else:
+                        g = entry.get("global", {})
+                        lines.append(f"    {d}: global_p50={g.get('roi_p50')}(无 tier 数据)")
+
+        # 大盘行情可读输出
+        lines.append("")
+        lines.append(f"【大盘行情】label={market_signal['label']}")
+        if market_signal.get("p50_volatility_pct") is not None:
+            lines.append(
+                f"  波幅={market_signal['p50_volatility_pct'] * 100:.1f}%, "
+                f"近 3 日 vs 之前={market_signal['recent_vs_baseline_pct'] * 100:+.1f}%"
+            )
+        lines.append(f"  原因:{market_signal['reason']}")
+
+        return ToolResult(
+            title="账户 / 人群包基线汇总",
+            output="\n".join(lines),
+            metadata={
+                "json_path": str(out_path),
+                "by_account_count": len(by_account),
+                "by_audience_tier_count": len(by_tier),
+                "by_date_count": len(by_date),
+                "global_ad_count": global_stats["ad_count"],
+                "market_signal": market_signal["label"],
+            },
+        )
+    except Exception as e:
+        logger.exception("calculate_portfolio_summary 失败")
+        return ToolResult(title="账户基线计算异常", output=f"错误:{e}")

+ 372 - 0
examples/auto_put_ad_mini/tools/posterior_collector.py

@@ -0,0 +1,372 @@
+"""
+后验数据采集 — 为进化奠基(V4 Layer 5)
+
+职责:
+  - 给定决策日期,回采 t+1d / t+3d / t+7d 的广告 ROI 和消耗
+  - 输出"决策 ↔ 后验"配对 CSV
+  - 自动给决策打 outcome_label(good/bad/neutral/executed)
+  - 回填 snapshot.json 的 posterior_* 字段
+
+⚠️ 本轮(auto_put_ad_mini_v4)不在主流程调用;下阶段反馈环对接。
+   - 接口签名固化:collect_posterior_data(decision_date, posterior_days, update_snapshot)
+   - 数据 schema 固化:posterior.csv 字段顺序见下文 POSTERIOR_CSV_COLUMNS
+   - 决策 outcome_label 标注规则固化:见 _label_outcome()
+
+──────────────────────────────────────────────────────────────────────
+后续接入路径(下阶段反馈环可直接对接):
+  1. 每日定时任务:collect_posterior_data(yesterday, 1) → 滚动回采
+  2. T+3 / T+7 定时任务:再回采一次(数据稳定后)
+  3. 累计 100~500 条带后验的样本后:
+     - 训练 Few-shot 示例池(outcome=good 的高质量决策)
+     - 用回归校准 DECAY_WEIGHTS 等规则权重
+     - 生成决策质量监控仪表盘(精确率/召回率)
+──────────────────────────────────────────────────────────────────────
+"""
+
+import json
+import logging
+import sys
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import pandas as pd
+
+from agent.tools import tool
+from agent.tools.models import ToolContext, ToolResult
+
+_MINI_DIR = Path(__file__).resolve().parent.parent
+if str(_MINI_DIR) not in sys.path:
+    sys.path.insert(0, str(_MINI_DIR))
+
+logger = logging.getLogger(__name__)
+
+
+# ═══════════════════════════════════════════
+# 数据 Schema 定义(固化,下阶段不要改)
+# ═══════════════════════════════════════════
+
+POSTERIOR_CSV_COLUMNS: List[str] = [
+    # 决策标识
+    "ad_id",
+    "decision_date",
+    "action",
+    "dimension",
+    "source",                     # rule_pause / llm_main / llm_override
+    # 决策时刻的快照
+    "decision_roi_7d",            # 决策时的 动态ROI_7日均值
+    "decision_cost_7d_avg",
+    "decision_bid_amount",
+    # 后验回采(多窗口)
+    "posterior_t1d_roi",
+    "posterior_t1d_cost",
+    "posterior_t3d_roi",
+    "posterior_t3d_cost",
+    "posterior_t7d_roi",
+    "posterior_t7d_cost",
+    # 计算出的相对变化
+    "roi_change_t3d_pct",         # (posterior_t3d - decision) / decision
+    "cost_change_t3d_pct",
+    # 标签 + 评分溯源(JSON 串,便于按信号统计)
+    "outcome_label",              # good / bad / neutral / executed
+    "signal_scores_json",
+    "reasoning_chain_json",
+    "rule_alignment",             # agree / override
+]
+
+
+# ═══════════════════════════════════════════
+# 内部工具函数
+# ═══════════════════════════════════════════
+
+
+def _safe_pct_change(new_v: Optional[float], old_v: Optional[float]) -> Optional[float]:
+    """安全计算百分比变化,None / 0 兜底。"""
+    if new_v is None or old_v is None:
+        return None
+    try:
+        if abs(old_v) < 1e-9:
+            return None
+        return round((new_v - old_v) / old_v, 4)
+    except Exception:
+        return None
+
+
+def _label_outcome(
+    decision_action: str,
+    decision_roi: Optional[float],
+    decision_cost: Optional[float],
+    posterior_t3d_roi: Optional[float],
+    posterior_t3d_cost: Optional[float],
+) -> str:
+    """
+    后验标注规则(固化,下阶段不要改):
+
+      - action = pause:
+          posterior_t3d_cost ≈ 0  → executed(说明确实暂停了)
+          posterior_t3d_cost > 0  → bad(暂停决策没生效或被复活)
+      - action = bid_down:
+          ROI 提升   → good
+          ROI 持平   → neutral
+          ROI 下降   → bad
+      - action = bid_up:
+          cost 提升 + ROI 不掉 → good
+          cost 不变           → neutral
+          ROI 大跌            → bad
+      - action = hold / needs_llm: 默认 neutral
+    """
+    if posterior_t3d_roi is None and posterior_t3d_cost is None:
+        return "unknown"
+
+    act = (decision_action or "").lower()
+
+    if act == "pause":
+        if posterior_t3d_cost is None:
+            return "unknown"
+        return "executed" if posterior_t3d_cost < 1.0 else "bad"
+
+    if act == "bid_down":
+        roi_chg = _safe_pct_change(posterior_t3d_roi, decision_roi)
+        if roi_chg is None:
+            return "unknown"
+        if roi_chg > 0.05:
+            return "good"
+        if roi_chg < -0.05:
+            return "bad"
+        return "neutral"
+
+    if act == "bid_up":
+        cost_chg = _safe_pct_change(posterior_t3d_cost, decision_cost)
+        roi_chg = _safe_pct_change(posterior_t3d_roi, decision_roi)
+        if cost_chg is None:
+            return "unknown"
+        if roi_chg is not None and roi_chg < -0.10:
+            return "bad"
+        if cost_chg > 0.10:
+            return "good"
+        return "neutral"
+
+    return "neutral"
+
+
+def _date_offset(date_str: str, days: int) -> str:
+    """YYYYMMDD + N 天 → YYYYMMDD"""
+    dt = datetime.strptime(date_str, "%Y%m%d") + timedelta(days=days)
+    return dt.strftime("%Y%m%d")
+
+
+def _load_metrics_for_date(date_str: str) -> Optional[pd.DataFrame]:
+    """
+    尝试加载某一天的 metrics 快照(用于回采当天 ROI / cost)。
+
+    优先策略(下阶段可扩展):
+      1. outputs/metrics_history/{date}.csv
+      2. outputs/metrics_temp.csv(如果它的 end_date 等于 date)
+      3. None(无数据)
+    """
+    history_csv = _MINI_DIR / "outputs" / "metrics_history" / f"{date_str}.csv"
+    if history_csv.exists():
+        try:
+            return pd.read_csv(history_csv)
+        except Exception as e:
+            logger.warning("加载历史 metrics 失败 %s: %s", history_csv, e)
+
+    # fallback: temp(仅当日期吻合)
+    temp_csv = _MINI_DIR / "outputs" / "metrics_temp.csv"
+    if temp_csv.exists():
+        try:
+            df = pd.read_csv(temp_csv)
+            if "end_date" in df.columns:
+                if str(df["end_date"].iloc[0]) == date_str:
+                    return df
+        except Exception:
+            pass
+
+    return None
+
+
+def _lookup_posterior(
+    df: Optional[pd.DataFrame],
+    ad_id: int,
+) -> Dict[str, Optional[float]]:
+    """从 metrics DataFrame 提取某广告的 ROI / cost。"""
+    if df is None or df.empty:
+        return {"roi": None, "cost": None}
+    try:
+        row = df[df["ad_id"] == ad_id]
+        if row.empty:
+            return {"roi": None, "cost": None}
+        r = row.iloc[0]
+        roi_val = r.get("动态ROI_7日均值")
+        cost_val = r.get("cost_7d_avg")
+        return {
+            "roi": float(roi_val) if pd.notna(roi_val) else None,
+            "cost": float(cost_val) if pd.notna(cost_val) else None,
+        }
+    except Exception as e:
+        logger.warning("查找广告 %s 后验数据失败: %s", ad_id, e)
+        return {"roi": None, "cost": None}
+
+
+# ═══════════════════════════════════════════
+# 主接口:后验采集(固化签名)
+# ═══════════════════════════════════════════
+
+
+@tool(description="V4 后验数据采集(决策 → 执行后效果配对)— 本轮预留接口,主流程不调用")
+async def collect_posterior_data(
+    ctx: ToolContext,
+    decision_date: str,
+    posterior_days: int = 7,
+    update_snapshot: bool = True,
+) -> ToolResult:
+    """
+    后验数据采集 — 决策 ↔ 执行后效果配对。
+
+    Args:
+        ctx: 工具上下文
+        decision_date: 决策日期 YYYYMMDD(即 snapshot.json 的目录名)
+        posterior_days: 采集多少天后验(默认 7)
+        update_snapshot: 是否回写 snapshot.json 的 posterior_* 字段(默认 True)
+
+    流程:
+      1. 加载 outputs/decisions_history/{decision_date}/snapshot.json
+      2. 对每条决策的 ad_id,分别加载 t+1d / t+3d / t+7d 的 metrics
+      3. 计算 posterior_t1d/t3d/t7d 的 roi/cost
+      4. 给决策打 outcome_label(_label_outcome 规则)
+      5. 写 outputs/decisions_history/{decision_date}/posterior.csv
+      6. (可选)回写 snapshot.json 的 posterior_* 和 outcome_label
+
+    Returns:
+      ToolResult 含统计 summary:good/bad/neutral/executed/unknown 的分布
+
+    ⚠️ 本轮不在 prompts/system.prompt 中引用;仅供下阶段反馈环对接。
+    """
+    try:
+        snap_dir = _MINI_DIR / "outputs" / "decisions_history" / decision_date
+        snap_path = snap_dir / "snapshot.json"
+        if not snap_path.exists():
+            return ToolResult(
+                title="collect_posterior_data 失败",
+                output=f"决策快照不存在: {snap_path}",
+            )
+
+        snapshot = json.loads(snap_path.read_text(encoding="utf-8"))
+        decisions = snapshot.get("decisions", [])
+        if not decisions:
+            return ToolResult(
+                title="collect_posterior_data",
+                output=f"快照 {decision_date} 无决策记录",
+            )
+
+        # 预加载 t+1d / t+3d / t+7d 的 metrics
+        t1d_date = _date_offset(decision_date, 1)
+        t3d_date = _date_offset(decision_date, 3)
+        t7d_date = _date_offset(decision_date, 7)
+
+        df_t1d = _load_metrics_for_date(t1d_date)
+        df_t3d = _load_metrics_for_date(t3d_date)
+        df_t7d = _load_metrics_for_date(t7d_date) if posterior_days >= 7 else None
+
+        rows: List[Dict[str, Any]] = []
+        outcome_dist: Dict[str, int] = {}
+
+        for d in decisions:
+            ad_id_raw = d.get("ad_id")
+            try:
+                ad_id_int = int(ad_id_raw)
+            except (ValueError, TypeError):
+                continue
+
+            # 决策快照本体
+            input_signals = d.get("input_signals") or {}
+            decision_roi = input_signals.get("动态ROI_7日均值")
+            decision_cost = input_signals.get("cost_7d_avg")
+            decision_bid = input_signals.get("bid_amount")
+
+            # 各窗口后验
+            p1 = _lookup_posterior(df_t1d, ad_id_int)
+            p3 = _lookup_posterior(df_t3d, ad_id_int)
+            p7 = _lookup_posterior(df_t7d, ad_id_int) if df_t7d is not None else {"roi": None, "cost": None}
+
+            # 标签
+            label = _label_outcome(
+                decision_action=d.get("action"),
+                decision_roi=decision_roi,
+                decision_cost=decision_cost,
+                posterior_t3d_roi=p3["roi"],
+                posterior_t3d_cost=p3["cost"],
+            )
+            outcome_dist[label] = outcome_dist.get(label, 0) + 1
+
+            # 写一行
+            rows.append({
+                "ad_id": ad_id_int,
+                "decision_date": decision_date,
+                "action": d.get("action"),
+                "dimension": d.get("dimension"),
+                "source": d.get("source"),
+                "decision_roi_7d": decision_roi,
+                "decision_cost_7d_avg": decision_cost,
+                "decision_bid_amount": decision_bid,
+                "posterior_t1d_roi": p1["roi"],
+                "posterior_t1d_cost": p1["cost"],
+                "posterior_t3d_roi": p3["roi"],
+                "posterior_t3d_cost": p3["cost"],
+                "posterior_t7d_roi": p7["roi"],
+                "posterior_t7d_cost": p7["cost"],
+                "roi_change_t3d_pct": _safe_pct_change(p3["roi"], decision_roi),
+                "cost_change_t3d_pct": _safe_pct_change(p3["cost"], decision_cost),
+                "outcome_label": label,
+                "signal_scores_json": json.dumps(d.get("signal_scores") or {}, ensure_ascii=False),
+                "reasoning_chain_json": json.dumps(d.get("reasoning_chain") or [], ensure_ascii=False),
+                "rule_alignment": d.get("rule_alignment"),
+            })
+
+            # 回写 snapshot
+            if update_snapshot:
+                d["posterior_t1d"] = p1
+                d["posterior_t3d"] = p3
+                d["posterior_t7d"] = p7
+                d["outcome_label"] = label
+
+        # 输出 posterior.csv
+        out_csv = snap_dir / "posterior.csv"
+        df_out = pd.DataFrame(rows, columns=POSTERIOR_CSV_COLUMNS)
+        df_out.to_csv(out_csv, index=False, encoding="utf-8-sig")
+
+        # 回写 snapshot
+        if update_snapshot:
+            snapshot["metadata"] = snapshot.get("metadata", {})
+            snapshot["metadata"]["posterior_collected_at"] = datetime.now().isoformat()
+            snapshot["metadata"]["posterior_days"] = posterior_days
+            snap_path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
+
+        # 汇总
+        lines = [
+            f"决策日期: {decision_date}",
+            f"采集窗口: t+1d={t1d_date}, t+3d={t3d_date}, t+7d={t7d_date}",
+            f"决策总数: {len(decisions)}",
+            f"配对 CSV: {out_csv}",
+            "",
+            "outcome_label 分布:",
+        ]
+        for k, v in sorted(outcome_dist.items(), key=lambda x: -x[1]):
+            lines.append(f"  {k}: {v}")
+
+        return ToolResult(
+            title=f"后验采集完成({decision_date})",
+            output="\n".join(lines),
+            metadata={
+                "decision_date": decision_date,
+                "posterior_csv": str(out_csv),
+                "snapshot_updated": update_snapshot,
+                "decision_count": len(decisions),
+                "outcome_distribution": outcome_dist,
+                "posterior_dates": {"t1d": t1d_date, "t3d": t3d_date, "t7d": t7d_date},
+            },
+        )
+
+    except Exception as e:
+        logger.error("collect_posterior_data 失败: %s", e, exc_info=True)
+        return ToolResult(title="collect_posterior_data 失败", output=str(e))

+ 212 - 0
examples/auto_put_ad_mini/新增群聊通知功能说明.md

@@ -0,0 +1,212 @@
+# 新增群聊通知功能说明
+
+## 功能简介
+
+现在系统支持同时将广告决策结果发送到两个群聊:
+
+1. **审批群聊**(原有功能):运营人员审批决策,需要回复"批准/拒绝"
+2. **投放项目群聊**(新增功能):仅通知决策结果,不需要审批
+
+## 快速开始
+
+### 步骤1:获取投放项目群聊 ID
+
+```bash
+# 1. 确保机器人已加入您的投放项目群聊
+
+# 2. 运行获取群聊 ID 工具
+cd /Users/liulidong/project/agent/Agent/examples/auto_put_ad_mini
+source .venv/bin/activate  # 如果有虚拟环境
+python3 get_chat_id.py
+
+# 3. 在您的飞书群聊中发送任意消息(建议@机器人)
+
+# 4. 脚本会输出群聊 ID,类似:
+#    ✅ 请将以下群聊 ID 配置到 .env 文件:
+#    FEISHU_AD_PROJECT_CHAT_ID=oc_xxxxxxxxxxxxxxxxxxxxxx
+
+# 5. 按 Ctrl+C 停止监听
+```
+
+### 步骤2:配置环境变量
+
+创建或编辑 `.env` 文件:
+
+```bash
+# 飞书应用凭据
+FEISHU_APP_ID=cli_a955e97067f85cb3
+FEISHU_APP_SECRET=NQaG4ci1plXRDTgwCqrLJgMLLoA2tdF8
+
+# 审批群聊(原有配置,保持不变)
+FEISHU_OPERATOR_OPEN_ID=ou_498988d823b61ab89c9afe4310f85bb4
+FEISHU_OPERATOR_CHAT_ID=oc_88e0a1970a7de02eb5ac225a8b0cedea
+
+# 投放项目群聊(新增配置)
+FEISHU_AD_PROJECT_CHAT_ID=oc_xxxxxxxxxxxxxxxxxxxxxx  # 替换为实际的群聊ID
+```
+
+### 步骤3:测试运行
+
+```bash
+# 运行广告分析
+python3 execute_once.py
+
+# 或者交互式运行
+python3 run.py
+> 分析广告
+```
+
+## 消息差异
+
+### 审批群聊消息(需要审批)
+
+```
+📊 广告调控审批请求
+请求ID: req_20260417_143000_abc123
+时间: 2026-04-17 14:30
+
+🔶 需审批操作(25 个):
+----------------------------------------
+  ⏸️  暂停: 10 个
+  ⬇️  降价: 15 个
+
+前 5 个示例:
+  [90289631207] R500_回流用户_20260410
+    操作: ⏸️ 暂停 | 日均消耗: 1524元
+    原因: ROI=1.18 < 关停线1.64, 持续亏损
+
+----------------------------------------
+📝 直接回复即可,示例:
+  "批准" / "通过"          — 全部批准
+  "拒绝" / "不行"          — 全部拒绝
+  "广告 12345 不要暂停"     — 修改指定广告
+  ⏰ 超时时间: 30 分钟
+
+📎 决策详情请查看在线表格(自动发送链接)
+```
+
+### 投放项目群聊消息(仅通知)
+
+```
+📊 广告调控决策通知
+请求ID: req_20260417_143000_abc123
+时间: 2026-04-17 14:30
+
+🔶 待审批操作(25 个):
+----------------------------------------
+  ⏸️  暂停: 10 个
+  ⬇️  降价: 15 个
+
+前 3 个示例:
+  [90289631207] R500_回流用户_20260410
+    操作: ⏸️ 暂停 | 日均消耗: 1524元 | ROI: 1.18
+
+  [37429627354] R330_定向投放_20260412
+    操作: ⬇️ 降价8% | 日均消耗: 1228元 | ROI: 1.81
+
+  ...
+
+----------------------------------------
+ℹ️  说明:
+  • 此消息为智能决策结果通知
+  • 运营审批通过后才会实际执行
+  • 详情请查看在线表格(自动发送)
+```
+
+## 功能说明
+
+### 双群聊发送机制
+
+- **审批群聊**:完整审批流程,需要运营回复
+- **投放项目群聊**:仅接收通知,不需要任何操作
+
+### 在线表格
+
+两个群聊都会收到相同的在线表格(或Excel附件),包含所有决策详情。
+
+### 可选配置
+
+如果不想发送到投放项目群聊,只需:
+
+```bash
+# 方式1:不配置 FEISHU_AD_PROJECT_CHAT_ID(保持为空)
+# 方式2:在 .env 中注释掉该行
+# FEISHU_AD_PROJECT_CHAT_ID=oc_xxxxxxxxxxxxxxxxxxxxxx
+```
+
+## 故障排查
+
+### 问题1:群聊 ID 获取失败
+
+**症状**:运行 `get_chat_id.py` 后没有输出
+
+**原因**:
+- 机器人未加入群聊
+- 飞书应用未启用事件订阅
+- App ID 或 App Secret 配置错误
+
+**解决**:
+1. 确认机器人已在群聊中(群设置 → 群机器人)
+2. 检查飞书开放平台 → 应用管理 → 事件订阅是否启用
+3. 验证 `FEISHU_APP_ID` 和 `FEISHU_APP_SECRET` 是否正确
+
+### 问题2:消息只发送到审批群,没有发送到投放项目群
+
+**原因**:
+- `FEISHU_AD_PROJECT_CHAT_ID` 未配置或为空
+- 群聊 ID 配置错误
+
+**解决**:
+1. 检查 `.env` 文件中的配置
+2. 确认群聊 ID 以 `oc_` 开头
+3. 查看日志是否有错误信息:`logger.warning("发送到投放项目群聊失败: ...")`
+
+### 问题3:机器人无法在群聊中发送消息
+
+**原因**:
+- 机器人没有发送消息权限
+- 群聊禁止机器人发言
+
+**解决**:
+1. 飞书开放平台 → 应用管理 → 权限管理 → 添加权限:
+   - `im:message`(发送消息)
+   - `im:message:send_as_bot`(以机器人身份发送)
+2. 检查群聊设置是否允许机器人发言
+
+## 技术细节
+
+### 代码修改位置
+
+1. **config.py**(第 141 行):
+   ```python
+   FEISHU_AD_PROJECT_CHAT_ID = os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "")
+   ```
+
+2. **im_approval.py**:
+   - 导入配置(第 46 行)
+   - 新增消息格式化函数 `_format_project_notification_message`(第 114 行)
+   - 发送到投放项目群聊(第 448-452 行)
+   - 发送在线表格到投放项目群聊(第 464-472 行)
+
+3. **get_chat_id.py**:新增工具脚本,用于获取群聊 ID
+
+### 自动识别机制
+
+飞书客户端会根据 ID 前缀自动识别接收者类型:
+- `ou_` 开头 → 个人用户
+- `oc_` 开头 → 群聊
+
+无需手动指定类型。
+
+## 后续扩展
+
+如果需要支持更多群聊,可以参考同样的模式:
+
+1. 在 `config.py` 添加新配置项
+2. 在 `im_approval.py` 的发送逻辑中添加新的发送调用
+3. 更新 `.env.example` 说明
+
+---
+
+**更新日期**:2026-04-17
+**功能版本**:v1.0

+ 205 - 0
examples/auto_put_ad_mini/飞书权限配置说明.md

@@ -0,0 +1,205 @@
+# 飞书应用权限配置说明
+
+## 问题:外部主体人员无法访问表格
+
+如果外部人员(不在您的飞书组织内)无法访问在线表格,需要配置以下权限。
+
+---
+
+## 📋 必需的飞书应用权限
+
+### 步骤1:登录飞书开放平台
+
+访问:https://open.feishu.cn/app
+
+选择您的应用:**增长投放**(App ID: `cli_a955e97067f85cb3`)
+
+### 步骤2:配置权限
+
+进入 **权限管理** → **权限配置**,添加以下权限:
+
+#### 核心权限(必须)
+
+| 权限范围 | 权限名称 | 权限说明 | 用途 |
+|---------|---------|---------|------|
+| **云文档** | `drive:drive` | 查看、评论、编辑和管理云文档 | 上传和导入表格 |
+| **云文档** | `drive:drive:readonly` | 查看云文档 | 读取文档信息 |
+| **云文档** | `drive:media:upload` | 上传素材 | 上传 Excel 文件 |
+| **云文档** | `drive:permission:manage` | 管理文档权限 | **设置外部可访问权限** ✅ |
+| **即时消息** | `im:message` | 获取与发送单聊、群组消息 | 发送表格链接到群聊 |
+| **即时消息** | `im:message:send_as_bot` | 以应用的身份发送消息 | 以机器人身份发送 |
+
+#### 外部访问权限(关键)
+
+⚠️ **最关键的权限**:
+- `drive:permission:manage` - **管理文档权限**
+  - 允许应用设置文档为"任何人可查看"
+  - 没有此权限,外部人员将无法访问
+
+### 步骤3:权限申请(如果需要)
+
+某些权限可能需要管理员审批:
+
+1. 进入 **权限管理** → **权限申请**
+2. 选择需要的权限
+3. 填写申请理由:
+   ```
+   应用需要将广告决策报告导入为在线表格,并设置为外部可访问,
+   以便不同组织的相关人员(如客户、合作伙伴)可以查看报告数据。
+   ```
+4. 提交申请,等待管理员审批
+
+---
+
+## 🧪 测试外部访问
+
+配置完成后,测试步骤:
+
+### 1. 生成测试表格
+
+```bash
+cd /Users/liulidong/project/agent/Agent/examples/auto_put_ad_mini
+python3 -c "
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path.cwd()))
+
+from tools.feishu_doc import _get_tenant_token, _upload_media, _create_import_task, _wait_import_result, _set_permission
+
+# 使用任意 Excel 文件测试
+xlsx_path = Path('outputs/reports').glob('*.xlsx')
+xlsx_file = next(xlsx_path, None)
+
+if xlsx_file:
+    print(f'测试文件: {xlsx_file}')
+    token = _get_tenant_token()
+    file_token = _upload_media(token, xlsx_file)
+    ticket = _create_import_task(token, file_token, xlsx_file.name)
+    result = _wait_import_result(token, ticket)
+    sheet_token = result.get('token')
+    url = result.get('url')
+
+    _set_permission(token, sheet_token, 'sheet')
+
+    print(f'在线表格 URL: {url}')
+    print(f'权限设置: 任何人获得链接可查看')
+else:
+    print('未找到测试文件')
+"
+```
+
+### 2. 验证外部访问
+
+1. 复制生成的表格 URL
+2. **退出您的飞书账号**(或使用隐私模式浏览器)
+3. 访问 URL
+4. 如果能直接查看,说明外部访问配置成功 ✅
+5. 如果提示需要登录或无权限,说明权限配置有问题 ❌
+
+### 3. 跨组织测试(推荐)
+
+找一个**不在您飞书组织**的同事:
+1. 发送表格链接给他
+2. 让他尝试打开链接
+3. 如果能直接查看,说明配置成功
+
+---
+
+## 🔍 常见问题
+
+### 问题1:设置权限后仍然无法访问
+
+**症状**:外部人员点击链接后提示"无权限"或"需要登录"
+
+**原因**:
+- 飞书应用缺少 `drive:permission:manage` 权限
+- 权限设置 API 调用失败(被静默忽略)
+
+**解决**:
+1. 确认应用已有 `drive:permission:manage` 权限
+2. 查看日志,检查权限设置是否成功:
+   ```bash
+   grep "文档权限已设置" /path/to/log
+   ```
+3. 如果日志显示"设置权限失败",检查权限配置
+
+### 问题2:权限申请被拒绝
+
+**症状**:管理员拒绝 `drive:permission:manage` 权限申请
+
+**原因**:
+- 安全顾虑(允许应用设置文档为公开可能有风险)
+- 需要更详细的使用说明
+
+**解决**:
+1. 与管理员沟通,说明业务需求
+2. 提供安全保障措施:
+   - 仅对特定类型文档设置公开权限
+   - 添加审计日志
+   - 限制可设置权限的文档范围
+3. 如果仍无法获得权限,降级方案:
+   - 手动设置文档权限(在飞书中打开文档 → 右上角"分享"→ 设置为"任何人可查看")
+   - 使用 `tenant_readable`(仅组织内可查看)
+
+### 问题3:权限设置成功但仍无法访问
+
+**症状**:日志显示权限设置成功,但外部人员仍无法访问
+
+**原因**:
+- 飞书组织管理员设置了全局限制(禁止外部访问)
+- 文档所在文件夹有更严格的权限限制
+
+**解决**:
+1. 联系飞书管理员,检查组织安全设置
+2. 在飞书管理后台 → 安全设置 → 外部协作 → 确认是否允许外部访问
+3. 如果组织禁止外部访问,考虑使用其他方案(如导出PDF发送)
+
+---
+
+## 📊 当前配置状态
+
+### 代码层面(已完成 ✅)
+
+```python
+# tools/feishu_doc.py 第 197-215 行
+def _set_permission(token: str, file_token: str, file_type: str = "sheet") -> None:
+    """设置文档权限:任何人获得链接可查看(不同主体都能看)"""
+    resp = httpx.patch(
+        f"{FEISHU_BASE_URL}/drive/v1/permissions/{file_token}/public",
+        headers={**_auth_headers(token), "Content-Type": "application/json"},
+        params={"type": file_type},
+        json={
+            "external_access_entity": "open",  # ✅ 外部开放
+            "link_share_entity": "anyone_readable",  # ✅ 任何人获得链接可查看
+        },
+        timeout=_HTTP_TIMEOUT,
+    )
+```
+
+### 飞书应用权限(需要配置 ⚠️)
+
+请确认以下权限已配置:
+
+- [x] `drive:drive` - 管理云文档
+- [x] `drive:media:upload` - 上传素材
+- [ ] `drive:permission:manage` - **管理文档权限**(关键)✅
+- [x] `im:message` - 发送消息
+- [x] `im:message:send_as_bot` - 以机器人身份发送
+
+---
+
+## 🎯 快速检查清单
+
+完成以下检查,确保外部访问配置正确:
+
+- [ ] 飞书应用已添加 `drive:permission:manage` 权限
+- [ ] 权限申请已通过(如需审批)
+- [ ] 代码已更新为 `anyone_readable` 权限设置
+- [ ] 测试外部访问(退出飞书账号测试)
+- [ ] 跨组织测试(找其他组织的人测试)
+- [ ] 查看日志确认权限设置成功
+
+---
+
+**更新日期**:2026-04-17
+**配置版本**:v1.0

+ 927 - 0
outputs/agent_architecture.html

@@ -0,0 +1,927 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>腾讯广告自动化投放 Agent 系统 — 技术架构图</title>
+<style>
+  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap');
+
+  :root {
+    --bg: #0f1723;
+    --bg2: #162030;
+    --bg3: #1a2840;
+    --card: #19273a;
+    --card-hover: #1e2f48;
+    --border: #2d4060;
+    --border-light: #3a5575;
+    --text: #e6ebf0;
+    --text-dim: #9aa8ba;
+    --text-muted: #6b7d94;
+    --amber: #e0ac44;
+    --amber-dim: #b48c37;
+    --amber-bg: rgba(224,172,68,0.08);
+    --green: #5cb278;
+    --green-dim: #3d8050;
+    --green-bg: rgba(92,178,120,0.1);
+    --yellow: #dac34e;
+    --yellow-dim: #a89430;
+    --yellow-bg: rgba(218,195,78,0.1);
+    --red: #c65852;
+    --red-dim: #943e3a;
+    --red-bg: rgba(198,88,82,0.1);
+    --blue: #5a8ec8;
+    --blue-dim: #406a96;
+    --blue-bg: rgba(90,142,200,0.08);
+    --cyan: #4db8a4;
+    --cyan-dim: #367f72;
+    --cyan-bg: rgba(77,184,164,0.08);
+    --purple: #9b7ec8;
+    --purple-dim: #7a5fa8;
+    --purple-bg: rgba(155,126,200,0.08);
+    --flow-data: #64a0dc;
+    --flow-exec: #50b482;
+    --flow-alert: #c86458;
+    --flow-knowledge: #9b7ec8;
+    --slate: #5677a4;
+    --slate-light: #7c9bc3;
+  }
+
+  * { margin: 0; padding: 0; box-sizing: border-box; }
+
+  body {
+    background: var(--bg);
+    color: var(--text);
+    font-family: 'Noto Sans SC', 'Inter', sans-serif;
+    min-height: 100vh;
+    overflow-x: auto;
+  }
+
+  .container {
+    width: 2400px;
+    min-height: 2000px;
+    margin: 0 auto;
+    padding: 0;
+    position: relative;
+  }
+
+  /* ─── Grid Background ─── */
+  .grid-bg {
+    position: absolute;
+    inset: 0;
+    background-image:
+      linear-gradient(rgba(35,50,72,0.3) 1px, transparent 1px),
+      linear-gradient(90deg, rgba(35,50,72,0.3) 1px, transparent 1px),
+      linear-gradient(rgba(45,60,85,0.15) 1px, transparent 1px),
+      linear-gradient(90deg, rgba(45,60,85,0.15) 1px, transparent 1px);
+    background-size: 200px 200px, 200px 200px, 40px 40px, 40px 40px;
+    pointer-events: none;
+    z-index: 0;
+  }
+
+  /* ─── Title Bar ─── */
+  .title-bar {
+    position: relative;
+    z-index: 2;
+    height: 68px;
+    background: linear-gradient(180deg, #121c2a 0%, #152238 100%);
+    border-bottom: 2px solid var(--slate);
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 0 36px;
+  }
+  .title-bar h1 { font-size: 28px; font-weight: 600; color: var(--text); letter-spacing: 1px; }
+  .title-bar .subtitle { font-family: 'Inter', sans-serif; font-size: 18px; font-weight: 500; color: var(--slate-light); margin-left: 20px; letter-spacing: 2px; text-transform: uppercase; }
+  .title-right { text-align: right; }
+  .title-right .fw-name { font-family: 'Inter', sans-serif; font-size: 14px; font-weight: 600; color: var(--amber-dim); }
+  .title-right .version { font-family: 'Inter', sans-serif; font-size: 11px; color: var(--text-dim); margin-top: 2px; }
+
+  /* ─── Content Area ─── */
+  .content { position: relative; z-index: 1; padding: 24px 30px 30px; }
+
+  /* ─── Legend ─── */
+  .legend {
+    position: absolute; top: 80px; right: 36px; z-index: 10;
+    background: rgba(20,30,48,0.95); border: 1px solid var(--border); border-radius: 8px;
+    padding: 12px 18px; min-width: 420px; backdrop-filter: blur(8px);
+  }
+  .legend-title { font-size: 12px; color: var(--text-dim); margin-bottom: 8px; font-weight: 500; }
+  .legend-row { display: flex; gap: 20px; margin-bottom: 4px; }
+  .legend-item { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-dim); }
+  .legend-line { width: 28px; height: 2px; flex-shrink: 0; border-radius: 1px; }
+  .legend-line-dash { width: 28px; height: 0; flex-shrink: 0; border-top: 2px dashed; }
+
+  /* ─── Section Headers ─── */
+  .section-label {
+    font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase;
+    letter-spacing: 3px; margin-bottom: 12px; font-family: 'Inter', sans-serif;
+    display: flex; align-items: center; gap: 8px;
+  }
+  .section-label::after { content: ''; flex: 1; height: 1px; background: var(--border); }
+
+  .layer-tag {
+    display: inline-block; font-size: 10px; font-weight: 600; font-family: 'Inter', sans-serif;
+    letter-spacing: 1.5px; text-transform: uppercase; padding: 3px 10px; border-radius: 3px; margin-right: 8px;
+  }
+
+  /* ─── User Input ─── */
+  .user-input {
+    display: inline-flex; align-items: center; gap: 12px; background: var(--card);
+    border: 2px solid var(--amber); border-radius: 10px; padding: 10px 20px; margin-bottom: 16px;
+  }
+  .user-input .icon { width: 32px; height: 32px; background: var(--amber-bg); border: 1px solid var(--amber-dim); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; }
+  .user-input .label { font-size: 16px; font-weight: 600; color: var(--amber); }
+  .user-input .example { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-dim); background: rgba(224,172,68,0.06); padding: 3px 8px; border-radius: 4px; }
+
+  .confirm-badge { display: inline-flex; align-items: center; gap: 6px; background: var(--green-bg); border: 1px solid var(--green-dim); border-radius: 6px; padding: 6px 14px; font-size: 13px; color: var(--green); margin-left: 12px; }
+
+  /* ─── Main Agent ─── */
+  .main-agent-row { display: flex; align-items: flex-start; gap: 20px; margin-bottom: 20px; }
+  .main-agent {
+    flex: 1; max-width: 580px;
+    background: linear-gradient(135deg, rgba(35,52,75,0.9), rgba(25,39,58,0.95));
+    border: 2px solid var(--amber); border-radius: 12px; padding: 16px 22px; position: relative;
+  }
+  .main-agent::before { content: ''; position: absolute; top: -1px; left: 20px; right: 20px; height: 3px; background: linear-gradient(90deg, transparent, var(--amber), transparent); border-radius: 0 0 4px 4px; }
+  .main-agent .agent-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
+  .main-agent .agent-name { font-size: 18px; font-weight: 700; color: var(--amber); }
+  .main-agent .agent-cn { font-size: 15px; font-weight: 500; color: var(--text); }
+  .main-agent .agent-desc { font-size: 12px; color: var(--text-dim); margin-bottom: 6px; }
+  .main-agent .agent-meta { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--slate-light); }
+
+  /* ─── Workflow Badges ─── */
+  .wf-badge { display: flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; border: 1px solid; }
+  .wf-analysis { background: rgba(92,178,120,0.06); border-color: var(--green-dim); color: var(--green); }
+  .wf-execution { background: rgba(200,100,90,0.06); border-color: var(--red-dim); color: #d08060; }
+  .wf-badge .temp { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-muted); margin-left: 4px; }
+
+  /* ─── Agent Cards ─── */
+  .agents-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin-bottom: 20px; }
+  .agents-grid-row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; max-width: 940px; }
+
+  .agent-card {
+    background: var(--card); border-radius: 10px; border: 1.5px solid var(--border-light); overflow: hidden;
+  }
+  .agent-card .card-header { padding: 10px 14px; display: flex; align-items: center; gap: 8px; background: rgba(90,142,200,0.06); }
+  .card-icon { font-size: 16px; flex-shrink: 0; }
+  .card-name { font-family: 'Inter', sans-serif; font-size: 13px; font-weight: 600; color: var(--text); }
+  .card-cn { font-size: 11px; font-weight: 500; margin-left: auto; color: var(--slate-light); }
+  .card-body { padding: 10px 14px 12px; }
+  .card-scope { font-size: 11px; color: var(--text); font-weight: 500; margin-bottom: 6px; line-height: 1.5; }
+  .card-divider { height: 1px; background: var(--border); margin: 6px 0; }
+  .card-section-title { font-family: 'Inter', sans-serif; font-size: 10px; font-weight: 600; color: var(--slate-light); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
+
+  .dep-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 6px; }
+  .dep-tag { font-size: 10px; padding: 2px 7px; border-radius: 3px; }
+  .dep-tag.skill { font-family: 'JetBrains Mono', monospace; background: rgba(155,126,200,0.1); border: 1px solid rgba(155,126,200,0.2); color: var(--purple); }
+  .dep-tag.tool { font-family: 'JetBrains Mono', monospace; background: rgba(77,184,164,0.1); border: 1px solid rgba(77,184,164,0.2); color: var(--cyan); }
+  .dep-tag.data { font-family: 'JetBrains Mono', monospace; background: rgba(100,160,220,0.1); border: 1px solid rgba(100,160,220,0.2); color: var(--flow-data); }
+
+  .card-details { list-style: none; font-size: 11px; color: var(--text-dim); line-height: 1.7; }
+  .card-details li::before { content: '·'; margin-right: 6px; color: var(--text-muted); }
+
+  .card-output { margin-top: 6px; font-size: 10px; color: var(--green); font-weight: 500; display: flex; align-items: center; gap: 4px; }
+  .card-output::before { content: '→'; color: var(--flow-exec); }
+
+  /* ─── Two-column layout ─── */
+  .dual-section { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
+  .triple-section { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-top: 14px; }
+
+  /* ─── Tool Layer Panel ─── */
+  .tool-panel {
+    background: var(--card); border: 1px solid var(--cyan-dim); border-radius: 10px; padding: 14px 18px;
+  }
+  .tool-panel-title { font-size: 14px; font-weight: 600; color: var(--cyan); margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
+  .tool-group { margin-bottom: 10px; }
+  .tool-group-title { font-size: 10px; font-weight: 600; color: var(--cyan-dim); text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 4px; padding-bottom: 2px; border-bottom: 1px solid rgba(77,184,164,0.15); }
+  .tool-item { font-size: 11px; color: var(--text-dim); padding: 2px 0; display: flex; gap: 8px; align-items: baseline; }
+  .tool-item .fn { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text); min-width: 200px; }
+  .tool-item .td { color: var(--text-muted); font-size: 10px; }
+
+  /* ─── Data Layer Panel ─── */
+  .data-panel {
+    background: var(--card); border: 1px solid var(--blue-dim); border-radius: 10px; padding: 14px 18px;
+  }
+  .data-panel-title { font-size: 14px; font-weight: 600; color: var(--flow-data); margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
+  .data-group { margin-bottom: 10px; }
+  .data-group-title { font-size: 10px; font-weight: 600; color: var(--blue-dim); text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 4px; padding-bottom: 2px; border-bottom: 1px solid rgba(90,142,200,0.15); }
+  .data-item { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-dim); padding: 2px 0; display: flex; gap: 8px; }
+  .data-item .prefix { color: var(--text-muted); flex-shrink: 0; }
+  .data-item .desc { color: var(--slate-light); margin-left: auto; font-family: 'Noto Sans SC', sans-serif; font-size: 11px; }
+
+  /* ─── Knowledge/Skill Layer Panel ─── */
+  .knowledge-panel {
+    background: var(--card); border: 1px solid var(--purple-dim); border-radius: 10px; padding: 14px 18px;
+  }
+  .knowledge-panel-title { font-size: 14px; font-weight: 600; color: var(--purple); margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
+  .knowledge-group { margin-bottom: 10px; }
+  .knowledge-group-title { font-size: 10px; font-weight: 600; color: var(--purple-dim); text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 4px; padding-bottom: 2px; border-bottom: 1px solid rgba(155,126,200,0.15); }
+  .knowledge-item { font-size: 11px; color: var(--text-dim); padding: 3px 0; display: flex; gap: 8px; align-items: baseline; }
+  .knowledge-item .kn { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--purple); min-width: 180px; }
+  .knowledge-item .kd { color: var(--text-muted); font-size: 10px; }
+  .knowledge-item .ka { font-size: 10px; color: var(--slate-light); margin-left: auto; }
+
+  /* ─── Rules Panel ─── */
+  .rules-panel { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 14px 18px; }
+  .rules-title { font-size: 14px; font-weight: 600; color: var(--amber); margin-bottom: 10px; }
+  .rule-group-title { font-size: 11px; font-weight: 600; color: var(--amber-dim); margin: 8px 0 4px; padding-bottom: 2px; border-bottom: 1px solid rgba(180,140,55,0.15); }
+  .rule-item { font-size: 11px; color: var(--text-dim); padding: 2px 0; line-height: 1.5; }
+
+  /* ─── Data Flows ─── */
+  .flows-panel { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 14px 18px; }
+  .flow-item { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 4px 0; }
+  .flow-num { font-weight: 600; font-size: 13px; width: 24px; text-align: center; }
+  .flow-label { font-weight: 600; min-width: 110px; }
+  .flow-desc { color: var(--text-dim); font-size: 11px; }
+
+  /* ─── Framework Bar ─── */
+  .framework-bar {
+    margin-top: 16px;
+    background: linear-gradient(180deg, rgba(18,26,40,0.95), rgba(15,23,35,0.98));
+    border: 1px solid var(--slate); border-radius: 10px; padding: 14px 24px;
+  }
+  .fw-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
+  .fw-title { font-size: 16px; font-weight: 600; color: var(--slate-light); }
+  .fw-modules { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; }
+  .fw-module { padding: 8px 12px; background: rgba(30,45,65,0.5); border: 1px solid var(--border); border-radius: 6px; }
+  .fw-module-name { font-family: 'Inter', sans-serif; font-size: 13px; font-weight: 600; color: var(--amber); margin-bottom: 2px; }
+  .fw-module-desc { font-size: 11px; color: var(--text-dim); }
+
+  /* ─── Relationship arrows ─── */
+  .agent-relationships { margin: 10px 0; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; }
+  .rel-arrow { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 4px 10px; border-radius: 4px; background: rgba(30,42,58,0.8); border: 1px solid var(--border); }
+  .rel-arrow .from, .rel-arrow .to { font-weight: 600; font-family: 'Inter', sans-serif; font-size: 10px; }
+  .rel-arrow .arrow-sym { color: var(--text-muted); font-size: 12px; }
+  .rel-arrow.data-flow { border-color: rgba(100,160,220,0.3); }
+  .rel-arrow.data-flow .arrow-sym { color: var(--flow-data); }
+  .rel-arrow.exec-flow { border-color: rgba(80,180,130,0.3); }
+  .rel-arrow.exec-flow .arrow-sym { color: var(--flow-exec); }
+  .rel-arrow.alert-flow { border-color: rgba(200,100,88,0.3); }
+  .rel-arrow.alert-flow .arrow-sym { color: var(--flow-alert); }
+  .rel-arrow.knowledge-flow { border-color: rgba(155,126,200,0.3); }
+  .rel-arrow.knowledge-flow .arrow-sym { color: var(--flow-knowledge); }
+  .rel-arrow.match-flow { border-color: rgba(218,195,78,0.3); }
+  .rel-arrow.match-flow .arrow-sym { color: var(--yellow); }
+
+  /* ─── Border ─── */
+  .container::after { content: ''; position: absolute; inset: 0; border: 2px solid var(--slate); pointer-events: none; z-index: 100; }
+
+  /* ─── Agent-to-layer connector labels ─── */
+  .connector-row {
+    display: flex; gap: 10px; align-items: center; justify-content: center;
+    margin: 8px 0; padding: 4px 0;
+  }
+  .connector-label {
+    font-size: 10px; font-weight: 500; padding: 2px 10px; border-radius: 10px;
+  }
+  .connector-down { color: var(--text-muted); font-size: 16px; }
+</style>
+</head>
+<body>
+<div class="container">
+  <div class="grid-bg"></div>
+
+  <!-- ═══ TITLE BAR ═══ -->
+  <div class="title-bar">
+    <div style="display:flex;align-items:baseline;gap:6px;">
+      <h1>腾讯广告自动化投放 Agent 系统</h1>
+      <span class="subtitle">Technical Architecture</span>
+    </div>
+    <div class="title-right">
+      <div class="fw-name">Reson Agent Framework</div>
+      <div class="version">v3.0 · Agent Perspective</div>
+    </div>
+  </div>
+
+  <!-- ═══ LEGEND ═══ -->
+  <div class="legend">
+    <div class="legend-title">图例 LEGEND</div>
+    <div class="legend-row">
+      <div class="legend-item"><div class="legend-line" style="background:var(--flow-data)"></div>数据流 Data</div>
+      <div class="legend-item"><div class="legend-line" style="background:var(--flow-exec)"></div>执行流 Exec</div>
+      <div class="legend-item"><div class="legend-line" style="background:var(--flow-alert)"></div>告警流 Alert</div>
+      <div class="legend-item"><div class="legend-line" style="background:var(--flow-knowledge)"></div>知识流 Knowledge</div>
+      <div class="legend-item"><div class="legend-line" style="background:var(--amber)"></div>调度流 Dispatch</div>
+    </div>
+    <div class="legend-row" style="margin-top:6px;">
+      <div class="legend-item"><div class="dep-tag skill" style="margin:0;">skill</div>策略/知识依赖</div>
+      <div class="legend-item"><div class="dep-tag tool" style="margin:0;">tool</div>工具依赖</div>
+      <div class="legend-item"><div class="dep-tag data" style="margin:0;">data</div>数据源依赖</div>
+    </div>
+  </div>
+
+  <div class="content">
+
+    <!-- ═══════════════════════════════════════════════════════════════ -->
+    <!-- ═══ L1: ENTRY LAYER ═══ -->
+    <!-- ═══════════════════════════════════════════════════════════════ -->
+    <div class="section-label">
+      <span class="layer-tag" style="background:var(--amber-bg);color:var(--amber);border:1px solid rgba(224,172,68,0.2);">L1</span>
+      入口层 · Entry Layer
+    </div>
+
+    <div class="main-agent-row">
+      <div style="display:flex;flex-direction:column;gap:8px;flex-shrink:0;">
+        <div class="user-input">
+          <div class="icon">👤</div>
+          <div>
+            <div class="label">用户输入 / 运营</div>
+            <div class="example">"今日预算10w, ROI目标1.5"</div>
+          </div>
+        </div>
+        <div class="confirm-badge" style="margin-left:0;">
+          <span>✓</span> 运营确认 → 执行
+        </div>
+      </div>
+
+      <div style="display:flex;align-items:center;color:var(--amber);font-size:24px;padding:0 4px;">→</div>
+
+      <div class="main-agent">
+        <div class="agent-header">
+          <div class="agent-name">Main Agent</div>
+          <div class="agent-cn">· 投放决策中枢</div>
+        </div>
+        <div class="agent-desc">task.prompt | 任务理解 → 子Agent调度 → 方案汇总 → 运营确认 → 反馈沉淀</div>
+        <div class="agent-meta">AgentRunner → LLM → Tool → Trace → Knowledge</div>
+      </div>
+
+      <div style="display:flex;gap:12px;align-items:center;margin-left:auto;">
+        <div class="wf-badge wf-analysis">
+          <span>📊</span> 分析模式 <span class="temp">temp=0.3</span>
+        </div>
+        <div style="color:var(--text-muted);font-size:16px;">→</div>
+        <div class="wf-badge wf-execution">
+          <span>⚡</span> 执行模式 <span class="temp">temp=0.1</span>
+        </div>
+      </div>
+    </div>
+
+    <!-- ═══════════════════════════════════════════════════════════════ -->
+    <!-- ═══ L2: AGENT LAYER — 5 Decision + 1 Execution Gateway ═══ -->
+    <!-- ═══════════════════════════════════════════════════════════════ -->
+    <div class="section-label">
+      <span class="layer-tag" style="background:var(--blue-bg);color:var(--blue);border:1px solid rgba(90,142,200,0.2);">L2</span>
+      决策层 · Agent Layer — 5个决策Agent + 1个执行网关
+    </div>
+
+    <!-- Agent relationship arrows -->
+    <div class="agent-relationships">
+      <div class="rel-arrow data-flow">
+        <span class="from">Budget</span>
+        <span class="arrow-sym">→ data_query →</span>
+        <span class="to">ODPS</span>
+      </div>
+      <div class="rel-arrow data-flow">
+        <span class="from">Audience</span>
+        <span class="arrow-sym">→ data_query →</span>
+        <span class="to">ODPS</span>
+      </div>
+      <div class="rel-arrow data-flow">
+        <span class="from">Creative</span>
+        <span class="arrow-sym">→ data_query →</span>
+        <span class="to">ODPS</span>
+      </div>
+      <div class="rel-arrow match-flow">
+        <span class="from">Audience</span>
+        <span class="arrow-sym">→ 人群-素材匹配 →</span>
+        <span class="to">Creative</span>
+      </div>
+      <div class="rel-arrow exec-flow">
+        <span class="from">运营确认</span>
+        <span class="arrow-sym">→ 方案 →</span>
+        <span class="to">System Ops</span>
+      </div>
+      <div class="rel-arrow exec-flow">
+        <span class="from">System Ops</span>
+        <span class="arrow-sym">→ ad_api →</span>
+        <span class="to">腾讯API</span>
+      </div>
+      <div class="rel-arrow alert-flow">
+        <span class="from">Monitor</span>
+        <span class="arrow-sym">→ 熔断 →</span>
+        <span class="to">腾讯API</span>
+      </div>
+      <div class="rel-arrow knowledge-flow">
+        <span class="from">Feedback</span>
+        <span class="arrow-sym">→ 沉淀 →</span>
+        <span class="to">Knowledge Base</span>
+      </div>
+      <div class="rel-arrow knowledge-flow">
+        <span class="from">Knowledge</span>
+        <span class="arrow-sym">→ 注入 →</span>
+        <span class="to">All Agents</span>
+      </div>
+    </div>
+
+    <!-- ─── Decision Agents (5 columns) ─── -->
+    <div class="agents-grid">
+
+      <!-- Budget Agent -->
+      <div class="agent-card">
+        <div class="card-header">
+          <span class="card-icon">💰</span>
+          <span class="card-name">Budget Agent</span>
+          <span class="card-cn">钱</span>
+        </div>
+        <div class="card-body">
+          <div class="card-scope">预算分配 · 出价优化 · ROI管控</div>
+          <div class="card-divider"></div>
+          <div class="card-section-title">依赖 · Skills & 知识</div>
+          <div class="dep-tags">
+            <span class="dep-tag skill">budget_strategy.md</span>
+            <span class="dep-tag skill">ad_domain.md</span>
+          </div>
+          <div class="card-section-title">依赖 · 工具</div>
+          <div class="dep-tags">
+            <span class="dep-tag tool">data_query</span>
+            <span class="dep-tag tool">budget_calc</span>
+          </div>
+          <div class="card-section-title">依赖 · 数据</div>
+          <div class="dep-tags">
+            <span class="dep-tag data">消耗/ROI</span>
+            <span class="dep-tag data">广告状态</span>
+          </div>
+          <div class="card-divider"></div>
+          <ul class="card-details">
+            <li>ROI×跑量 二维决策矩阵</li>
+            <li>冷启动/赔付保护机制</li>
+            <li>阶梯式调价 · PID控制</li>
+          </ul>
+          <div class="card-output">输出: 预算/出价调整方案</div>
+        </div>
+      </div>
+
+      <!-- Audience Agent -->
+      <div class="agent-card">
+        <div class="card-header">
+          <span class="card-icon">👥</span>
+          <span class="card-name">Audience Agent</span>
+          <span class="card-cn">人</span>
+        </div>
+        <div class="card-body">
+          <div class="card-scope">人群规划 · 定向策略 · 效果分析</div>
+          <div class="card-divider"></div>
+          <div class="card-section-title">依赖 · Skills & 知识</div>
+          <div class="dep-tags">
+            <span class="dep-tag skill">audience_strategy.md</span>
+            <span class="dep-tag skill">ad_domain.md</span>
+          </div>
+          <div class="card-section-title">依赖 · 工具</div>
+          <div class="dep-tags">
+            <span class="dep-tag tool">data_query</span>
+            <span class="dep-tag tool">audience_targeting</span>
+          </div>
+          <div class="card-section-title">依赖 · 数据</div>
+          <div class="dep-tags">
+            <span class="dep-tag data">人群效果</span>
+            <span class="dep-tag data">定向配置</span>
+          </div>
+          <div class="card-divider"></div>
+          <ul class="card-details">
+            <li>年龄/性别/区域定向组合</li>
+            <li>人群包管理与推荐</li>
+            <li>人群效果A/B分析</li>
+          </ul>
+          <div class="card-output">输出: 定向方案</div>
+        </div>
+      </div>
+
+      <!-- Creative Agent -->
+      <div class="agent-card">
+        <div class="card-header">
+          <span class="card-icon">🎨</span>
+          <span class="card-name">Creative Agent</span>
+          <span class="card-cn">素材</span>
+        </div>
+        <div class="card-body">
+          <div class="card-scope">素材分析 · 人群匹配 · 生命周期</div>
+          <div class="card-divider"></div>
+          <div class="card-section-title">依赖 · Skills & 知识</div>
+          <div class="dep-tags">
+            <span class="dep-tag skill">creative_strategy.md</span>
+            <span class="dep-tag skill">ad_domain.md</span>
+          </div>
+          <div class="card-section-title">依赖 · 工具</div>
+          <div class="dep-tags">
+            <span class="dep-tag tool">data_query</span>
+            <span class="dep-tag tool">creative_tools</span>
+          </div>
+          <div class="card-section-title">依赖 · 数据</div>
+          <div class="dep-tags">
+            <span class="dep-tag data">创意效果</span>
+            <span class="dep-tag data">素材库</span>
+          </div>
+          <div class="card-divider"></div>
+          <ul class="card-details">
+            <li>CTR/CVR/完播率分析</li>
+            <li>素材衰退检测与轮换</li>
+            <li>组件化创意组合</li>
+          </ul>
+          <div class="card-output">输出: 素材方案</div>
+        </div>
+      </div>
+
+      <!-- Monitor Agent -->
+      <div class="agent-card">
+        <div class="card-header">
+          <span class="card-icon">🔍</span>
+          <span class="card-name">Monitor Agent</span>
+          <span class="card-cn">监控</span>
+        </div>
+        <div class="card-body">
+          <div class="card-scope">异常检测 · 熔断策略 · 根因分析</div>
+          <div class="card-divider"></div>
+          <div class="card-section-title">依赖 · Skills & 知识</div>
+          <div class="dep-tags">
+            <span class="dep-tag skill">monitor_rules.md</span>
+            <span class="dep-tag skill">ad_domain.md</span>
+          </div>
+          <div class="card-section-title">依赖 · 工具</div>
+          <div class="dep-tags">
+            <span class="dep-tag tool">data_query</span>
+            <span class="dep-tag tool">monitor_tools</span>
+            <span class="dep-tag tool">ad_api(写)</span>
+          </div>
+          <div class="card-section-title">依赖 · 数据</div>
+          <div class="dep-tags">
+            <span class="dep-tag data">实时指标</span>
+            <span class="dep-tag data">告警阈值</span>
+          </div>
+          <div class="card-divider"></div>
+          <ul class="card-details">
+            <li>CPA突增/转化骤降检测</li>
+            <li>L1告警/L2降级/L3熔断</li>
+            <li>15min/1h/日 三周期</li>
+          </ul>
+          <div class="card-output">输出: 熔断/告警决策</div>
+        </div>
+      </div>
+
+      <!-- Feedback Agent -->
+      <div class="agent-card">
+        <div class="card-header">
+          <span class="card-icon">🔄</span>
+          <span class="card-name">Feedback Agent</span>
+          <span class="card-cn">反馈</span>
+        </div>
+        <div class="card-body">
+          <div class="card-scope">后验分析 · 策略归纳 · 知识沉淀</div>
+          <div class="card-divider"></div>
+          <div class="card-section-title">依赖 · Skills & 知识</div>
+          <div class="dep-tags">
+            <span class="dep-tag skill">feedback_strategy.md</span>
+            <span class="dep-tag skill">全部策略Skills</span>
+          </div>
+          <div class="card-section-title">依赖 · 工具</div>
+          <div class="dep-tags">
+            <span class="dep-tag tool">data_query</span>
+            <span class="dep-tag tool">knowledge_tools</span>
+          </div>
+          <div class="card-section-title">依赖 · 数据</div>
+          <div class="dep-tags">
+            <span class="dep-tag data">历史投放</span>
+            <span class="dep-tag data">Trace</span>
+          </div>
+          <div class="card-divider"></div>
+          <ul class="card-details">
+            <li>投放效果归因分析</li>
+            <li>策略规则提炼</li>
+            <li>案例沉淀至知识库</li>
+          </ul>
+          <div class="card-output">输出: 策略更新 → Knowledge</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- ─── System Ops (Execution Gateway) ─── -->
+    <div class="agents-grid-row2">
+      <div class="agent-card" style="border-color:var(--flow-exec);">
+        <div class="card-header" style="background:rgba(80,180,130,0.06);">
+          <span class="card-icon">⚡</span>
+          <span class="card-name">System Ops Agent</span>
+          <span class="card-cn" style="color:var(--flow-exec);">执行网关</span>
+        </div>
+        <div class="card-body">
+          <div class="card-scope">接收已确认方案 → API调用 → 执行变更 → 变更记录</div>
+          <div class="card-divider"></div>
+          <div class="card-section-title">依赖 · Skills & 知识</div>
+          <div class="dep-tags">
+            <span class="dep-tag skill">ad_domain.md</span>
+          </div>
+          <div class="card-section-title">依赖 · 工具</div>
+          <div class="dep-tags">
+            <span class="dep-tag tool">ad_api(读+写)</span>
+            <span class="dep-tag tool">execute_plan</span>
+          </div>
+          <div class="card-section-title">依赖 · 数据</div>
+          <div class="dep-tags">
+            <span class="dep-tag data">已确认方案</span>
+            <span class="dep-tag data">Task State</span>
+          </div>
+          <div class="card-divider"></div>
+          <ul class="card-details">
+            <li>广告创建/修改/关停 全生命周期</li>
+            <li>创意配置 · 组件化管理</li>
+            <li>批量操作 + dry-run验证</li>
+            <li>操作变更记录与通知</li>
+          </ul>
+          <div class="card-output">输出: 执行结果 → 腾讯API + Trace</div>
+        </div>
+      </div>
+
+      <!-- Agent 内部结构说明 -->
+      <div style="background:rgba(30,42,58,0.5);border:1px dashed var(--border);border-radius:10px;padding:14px 18px;">
+        <div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;">每个 Agent 的内部结构</div>
+        <div style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--text-dim);line-height:2;">
+          <div style="color:var(--amber);">Agent = LLM推理引擎</div>
+          <div>&nbsp;&nbsp;+ <span style="color:var(--purple);">Skills</span> (策略知识注入)</div>
+          <div>&nbsp;&nbsp;+ <span style="color:var(--cyan);">Tools</span> (获取/计算/执行能力)</div>
+          <div>&nbsp;&nbsp;+ <span style="color:var(--flow-data);">Data</span> (数据源读取)</div>
+          <div>&nbsp;&nbsp;→ <span style="color:var(--flow-exec);">输出决策方案</span></div>
+        </div>
+        <div class="card-divider"></div>
+        <div style="font-size:11px;color:var(--text-muted);line-height:1.7;">
+          <div>· Agent 只做<strong style="color:var(--text);">决策</strong>,不做透传</div>
+          <div>· 数据获取能力通过<strong style="color:var(--cyan);">共享工具</strong>下沉</div>
+          <div>· 策略知识通过<strong style="color:var(--purple);">Skill注入</strong>,非硬编码</div>
+          <div>· 写操作集中在<strong style="color:var(--flow-exec);">System Ops</strong>网关</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- ═══════════════════════════════════════════════════════════════ -->
+    <!-- ═══ L3: CAPABILITY + DATA + KNOWLEDGE ═══ -->
+    <!-- ═══════════════════════════════════════════════════════════════ -->
+    <div class="section-label">
+      <span class="layer-tag" style="background:rgba(86,119,164,0.1);color:var(--slate);border:1px solid rgba(86,119,164,0.2);">L3</span>
+      能力层 · 数据层 · 知识层 — Tools & Data & Knowledge
+    </div>
+
+    <div class="triple-section">
+
+      <!-- ─── Tool Layer (能力层) ─── -->
+      <div class="tool-panel">
+        <div class="tool-panel-title">
+          <span style="font-size:16px;">⚙️</span>
+          能力层 Tools · 工具
+        </div>
+
+        <div class="tool-group">
+          <div class="tool-group-title">获取 · Read</div>
+          <div class="tool-item"><span class="fn">data_query(type, sql)</span><span class="td">→ ODPS 7种查询</span></div>
+          <div class="tool-item"><span class="fn">data_aggregate()</span><span class="td">→ 聚合分析</span></div>
+          <div class="tool-item"><span class="fn">get_ad_current_status()</span><span class="td">→ 广告实时状态</span></div>
+          <div class="tool-item"><span class="fn">ad_get_list / ad_get_report</span><span class="td">→ 腾讯API(读)</span></div>
+          <div class="tool-item"><span class="fn">audience_get_list</span><span class="td">→ 人群包列表</span></div>
+        </div>
+
+        <div class="tool-group">
+          <div class="tool-group-title">计算 · Compute</div>
+          <div class="tool-item"><span class="fn">budget_calc(data)</span><span class="td">→ ROI决策矩阵</span></div>
+          <div class="tool-item"><span class="fn">audience_build_targeting</span><span class="td">→ 定向生成</span></div>
+          <div class="tool-item"><span class="fn">audience_recommend_targeting</span><span class="td">→ 定向推荐</span></div>
+          <div class="tool-item"><span class="fn">monitor_check_metrics</span><span class="td">→ 指标检查</span></div>
+        </div>
+
+        <div class="tool-group" style="margin-bottom:0;">
+          <div class="tool-group-title">执行 · Write</div>
+          <div class="tool-item"><span class="fn">ad_create / ad_update</span><span class="td">→ 广告操作</span></div>
+          <div class="tool-item"><span class="fn">ad_batch_update_status</span><span class="td">→ 批量状态</span></div>
+          <div class="tool-item"><span class="fn">creative_create / update</span><span class="td">→ 创意操作</span></div>
+          <div class="tool-item"><span class="fn">execute_adjustment_plan</span><span class="td">→ 方案执行</span></div>
+          <div class="tool-item"><span class="fn">monitor_circuit_break</span><span class="td">→ 熔断执行</span></div>
+        </div>
+      </div>
+
+      <!-- ─── Data Layer (数据层) ─── -->
+      <div class="data-panel">
+        <div class="data-panel-title">
+          <span style="font-size:16px;">🗄️</span>
+          数据层 Data · 存储
+        </div>
+
+        <div class="data-group">
+          <div class="data-group-title">外部数据源 · External</div>
+          <div style="font-size:11px;color:var(--text);font-weight:500;margin-bottom:4px;">ODPS / MaxCompute — loghubods 库</div>
+          <div class="data-item"><span class="prefix">├─</span><span>ad_put_tencent_ad</span><span class="desc">广告基础</span></div>
+          <div class="data-item"><span class="prefix">├─</span><span>ad_put_tencent_creative_data_day</span><span class="desc">创意日报</span></div>
+          <div class="data-item"><span class="prefix">├─</span><span>ad_put_tencent_account_data</span><span class="desc">账户消耗</span></div>
+          <div class="data-item"><span class="prefix">└─</span><span>fission_data / roi_data</span><span class="desc">裂变&amp;ROI</span></div>
+          <div style="font-size:11px;color:var(--text);font-weight:500;margin:8px 0 4px;">腾讯广告 Marketing API v3.0</div>
+          <div class="data-item"><span class="prefix">├─</span><span>/v3.0/adgroups/*</span><span class="desc">广告</span></div>
+          <div class="data-item"><span class="prefix">├─</span><span>/v3.0/dynamic_creatives/*</span><span class="desc">创意</span></div>
+          <div class="data-item"><span class="prefix">└─</span><span>bid_mode=OCPM QPS≤10</span><span class="desc">限制</span></div>
+        </div>
+
+        <div class="data-group">
+          <div class="data-group-title">运行时状态 · Runtime</div>
+          <div class="data-item"><span class="prefix">├─</span><span>Task State</span><span class="desc">方案/中间结果</span></div>
+          <div class="data-item"><span class="prefix">├─</span><span>Agent Context</span><span class="desc">对话上下文</span></div>
+          <div class="data-item"><span class="prefix">└─</span><span>Trace Store</span><span class="desc">执行轨迹/审计</span></div>
+        </div>
+
+        <div class="data-group" style="margin-bottom:0;">
+          <div class="data-group-title">知识存储 · Knowledge Store</div>
+          <div class="data-item"><span class="prefix">├─</span><span>Knowledge Base</span><span class="desc">动态沉淀</span></div>
+          <div class="data-item"><span class="prefix">└─</span><span>Case Base</span><span class="desc">投放案例库</span></div>
+        </div>
+      </div>
+
+      <!-- ─── Knowledge Layer (知识层) ─── -->
+      <div class="knowledge-panel">
+        <div class="knowledge-panel-title">
+          <span style="font-size:16px;">📚</span>
+          知识层 Knowledge · Skills
+        </div>
+
+        <div class="knowledge-group">
+          <div class="knowledge-group-title">静态知识 · Skills (Markdown 注入)</div>
+          <div class="knowledge-item">
+            <span class="kn">ad_domain.md</span>
+            <span class="kd">3.0平台结构 · API映射 · 投放流程</span>
+            <span class="ka">→ 全部Agent</span>
+          </div>
+          <div class="knowledge-item">
+            <span class="kn">budget_strategy.md</span>
+            <span class="kd">决策矩阵 · 冷启动 · 赔付 · 调价</span>
+            <span class="ka">→ Budget</span>
+          </div>
+          <div class="knowledge-item">
+            <span class="kn">audience_strategy.md</span>
+            <span class="kd">人群定向策略 · 圈选规则</span>
+            <span class="ka">→ Audience</span>
+          </div>
+          <div class="knowledge-item">
+            <span class="kn">creative_strategy.md</span>
+            <span class="kd">素材策略 · 匹配规则 · 生命周期</span>
+            <span class="ka">→ Creative</span>
+          </div>
+          <div class="knowledge-item">
+            <span class="kn">monitor_rules.md</span>
+            <span class="kd">监控规则 · 告警阈值 · 熔断条件</span>
+            <span class="ka">→ Monitor</span>
+          </div>
+          <div class="knowledge-item">
+            <span class="kn">feedback_strategy.md</span>
+            <span class="kd">后验分析 · 策略归纳 · 沉淀规则</span>
+            <span class="ka">→ Feedback</span>
+          </div>
+        </div>
+
+        <div class="knowledge-group" style="margin-bottom:0;">
+          <div class="knowledge-group-title">动态知识 · Knowledge Base (持久化)</div>
+          <div class="knowledge-item">
+            <span class="kn">投放经验</span>
+            <span class="kd">"账户A降价10%实际消耗降30%"</span>
+          </div>
+          <div class="knowledge-item">
+            <span class="kn">时间规律</span>
+            <span class="kd">"周末转化率通常高15%"</span>
+          </div>
+          <div class="knowledge-item">
+            <span class="kn">人群洞察</span>
+            <span class="kd">"人群包X在25-34男性效果最好"</span>
+          </div>
+          <div class="knowledge-item">
+            <span class="kn">熔断经验</span>
+            <span class="kd">"CPA连续3天>50应提前熔断"</span>
+          </div>
+          <div style="margin-top:6px;font-size:10px;color:var(--purple-dim);font-style:italic;">
+            ← Feedback Agent 持续写入 · 全部 Agent 决策时读取
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- ═══════════════════════════════════════════════════════════════ -->
+    <!-- ═══ BUSINESS RULES + DATA FLOWS ═══ -->
+    <!-- ═══════════════════════════════════════════════════════════════ -->
+    <div class="dual-section">
+      <!-- Business Rules -->
+      <div class="rules-panel">
+        <div class="rules-title">关键业务规则 Key Rules</div>
+
+        <div class="rule-group-title">─── oCPM 出价机制 ───</div>
+        <div class="rule-item">eCPM = bid × pCTR × pCVR × 1000</div>
+        <div class="rule-item">降10%出价 ≈ 降15-25%消耗(非线性)</div>
+        <div class="rule-item">掉量悬崖: eCPM&lt;竞争水位 → 断崖式跌落</div>
+
+        <div class="rule-group-title">─── 决策矩阵 ROI×跑量 ───</div>
+        <div class="rule-item">高ROI+低跑量 → increase +10%~15%</div>
+        <div class="rule-item">中ROI+高跑量 → decrease -5%~10%</div>
+        <div class="rule-item">低ROI+低跑量 → close(标记关停)</div>
+
+        <div class="rule-group-title">─── 保护机制 ───</div>
+        <div class="rule-item">冷启动: &lt;48h ∨ 转化&lt;6 → observe</div>
+        <div class="rule-item">赔付: 转化≥6 且 CPA偏离≥20% → 先赔付</div>
+        <div class="rule-item">熔断: CPA&gt;阈值 ∨ 消耗异常 → 暂停</div>
+      </div>
+
+      <!-- Core Data Flows -->
+      <div class="flows-panel">
+        <div class="rules-title" style="margin-bottom:8px;">核心数据流 Core Flows</div>
+        <div class="flow-item">
+          <span class="flow-num" style="color:var(--amber)">①</span>
+          <div>
+            <div class="flow-label" style="color:var(--amber)">任务分发</div>
+            <div class="flow-desc">User → Main Agent → 子Agent(Budget/Audience/Creative)</div>
+          </div>
+        </div>
+        <div class="flow-item">
+          <span class="flow-num" style="color:var(--flow-data)">②</span>
+          <div>
+            <div class="flow-label" style="color:var(--flow-data)">数据获取</div>
+            <div class="flow-desc">决策Agent → data_query(Tool) → ODPS(Data) → 原始数据</div>
+          </div>
+        </div>
+        <div class="flow-item">
+          <span class="flow-num" style="color:var(--purple)">③</span>
+          <div>
+            <div class="flow-label" style="color:var(--purple)">知识注入</div>
+            <div class="flow-desc">Skills(静态) + Knowledge(动态) → Agent Context → 辅助决策</div>
+          </div>
+        </div>
+        <div class="flow-item">
+          <span class="flow-num" style="color:var(--cyan)">④</span>
+          <div>
+            <div class="flow-label" style="color:var(--cyan)">方案计算</div>
+            <div class="flow-desc">Agent + 数据 + 知识 → budget_calc/targeting(Tool) → 调整方案</div>
+          </div>
+        </div>
+        <div class="flow-item">
+          <span class="flow-num" style="color:var(--flow-exec)">⑤</span>
+          <div>
+            <div class="flow-label" style="color:var(--flow-exec)">运营确认→执行</div>
+            <div class="flow-desc">方案 → 运营确认 → System Ops → ad_api(Tool) → 腾讯API(Data)</div>
+          </div>
+        </div>
+        <div class="flow-item">
+          <span class="flow-num" style="color:var(--flow-alert)">⑥</span>
+          <div>
+            <div class="flow-label" style="color:var(--flow-alert)">监控熔断</div>
+            <div class="flow-desc">Monitor Agent → 检测异常 → circuit_break(Tool) → 暂停广告</div>
+          </div>
+        </div>
+        <div class="flow-item">
+          <span class="flow-num" style="color:var(--flow-knowledge)">⑦</span>
+          <div>
+            <div class="flow-label" style="color:var(--flow-knowledge)">反馈沉淀</div>
+            <div class="flow-desc">投放结果 → Feedback Agent → knowledge_tools → Knowledge Base</div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- ═══════════════════════════════════════════════════════════════ -->
+    <!-- ═══ L4: FRAMEWORK ═══ -->
+    <!-- ═══════════════════════════════════════════════════════════════ -->
+    <div class="section-label" style="margin-top:16px;">
+      <span class="layer-tag" style="background:rgba(86,119,164,0.1);color:var(--slate);border:1px solid rgba(86,119,164,0.2);">L4</span>
+      底层框架 · Reson Agent Framework
+    </div>
+
+    <div class="framework-bar">
+      <div class="fw-header">
+        <div class="fw-title">Reson Agent Framework</div>
+        <div style="display:flex;gap:8px;">
+          <div style="font-size:11px;padding:4px 12px;border-radius:4px;background:rgba(92,178,120,0.08);border:1px solid var(--green-dim);color:var(--green);">
+            分析 Analysis temp=0.3 · 只读工具
+          </div>
+          <div style="font-size:11px;padding:4px 12px;border-radius:4px;background:rgba(200,100,90,0.08);border:1px solid var(--red-dim);color:#d08060;">
+            执行 Execution temp=0.1 · 读写工具
+          </div>
+        </div>
+      </div>
+      <div class="fw-modules">
+        <div class="fw-module">
+          <div class="fw-module-name">AgentRunner</div>
+          <div class="fw-module-desc">LLM循环引擎 · 工具调用</div>
+        </div>
+        <div class="fw-module">
+          <div class="fw-module-name">LLM Adapters</div>
+          <div class="fw-module-desc">Qwen / Gemini 适配</div>
+        </div>
+        <div class="fw-module">
+          <div class="fw-module-name">Tool Registry</div>
+          <div class="fw-module-desc">@tool注册 · 白名单</div>
+        </div>
+        <div class="fw-module">
+          <div class="fw-module-name">Skill System</div>
+          <div class="fw-module-desc">Markdown知识注入</div>
+        </div>
+        <div class="fw-module">
+          <div class="fw-module-name">Trace System</div>
+          <div class="fw-module-desc">GoalTree · 回溯</div>
+        </div>
+        <div class="fw-module">
+          <div class="fw-module-name">Knowledge</div>
+          <div class="fw-module-desc">动态知识管理</div>
+        </div>
+      </div>
+    </div>
+
+  </div><!-- .content -->
+</div><!-- .container -->
+</body>
+</html>

BIN
outputs/agent_architecture.png


+ 13 - 0
outputs/architecture_philosophy.md

@@ -0,0 +1,13 @@
+# Systematic Cartography
+
+A design philosophy for mapping invisible architectures — treating software systems as territories to be surveyed with the precision of a master cartographer and the restraint of a scientific illustrator.
+
+## Philosophy
+
+Information systems are living topographies. Like geological strata, they reveal themselves in layers — each stratum possessing its own density, composition, and relationship to what lies above and below. Systematic Cartography approaches technical architecture not as a diagram to be drawn, but as a landscape to be *surveyed* — with the same reverence a cartographer brings to uncharted terrain. Every connection is a river. Every module is a geological formation. Every data flow is a trade route etched by centuries of use. The result must appear as if it were the product of months of painstaking fieldwork, meticulously drafted by someone at the absolute pinnacle of their craft.
+
+Color operates as altitude and depth. A strict, intentional palette — slate blues for foundational strata, warm ambers for active surfaces, muted greens for completed territories, soft corals for uncharted zones — creates an immediate spatial hierarchy without a single word of explanation. Color is never decorative; it is topographic data. Each hue is calibrated with the obsessive precision of a master printmaker, chosen not for beauty alone but for informational clarity. The craftsmanship must be evident in every chromatic decision — nothing arbitrary, nothing accidental.
+
+Space is the primary medium of communication. Generous margins between formations suggest independence; tight clustering signals coupling. Vertical layering communicates dependency — what rests upon what, what feeds what, what cannot exist without the foundation below. Typography appears sparingly, as specimen labels in a natural history illustration: small, precise, clinical sans-serif annotations that name but never explain. When a title demands presence, it arrives as a monumental inscription carved into the landscape itself — architectural lettering that belongs to the terrain rather than floating above it. Every spatial relationship is labored over with the care of someone who understands that a millimeter of misalignment destroys the illusion of mastery.
+
+Connections between formations are drawn as precise, deliberate pathways — never casual lines but engineered channels with clear directionality. Arrow-forms suggest flow, pressure, and volume. Dashed lines indicate planned but unbuilt routes. The visual vocabulary borrows from engineering blueprints, nautical charts, and geological cross-sections simultaneously, creating an artifact that feels as though it documents something real and enduring. The final composition must radiate the quiet authority of expert-level execution — the kind of work that makes other practitioners pause and study every detail.

+ 337 - 0
outputs/gen_arch_diagram.py

@@ -0,0 +1,337 @@
+#!/usr/bin/env python3
+"""
+Agent Architecture Diagram v2 - Refined Layout
+Focus: Agent perspective with clean separation of concerns
+"""
+from PIL import Image, ImageDraw, ImageFont
+import math
+
+W, H = 2800, 1800
+BG = (15, 23, 35)
+img = Image.new('RGB', (W, H), BG)
+draw = ImageDraw.Draw(img)
+
+# ─── Colors ───
+SB = (86, 119, 164)       # Slate blue
+SL = (120, 155, 195)      # Slate light
+AM = (224, 172, 68)       # Amber
+AD = (180, 140, 55)       # Amber dim
+GD = (92, 178, 120)       # Green done
+YH = (218, 195, 78)       # Yellow half
+RT = (198, 88, 82)        # Red todo
+WH = (230, 235, 240)      # White
+WD = (160, 170, 185)      # White dim
+BD = (55, 72, 95)         # Border
+FB = (100, 160, 220)      # Flow blue
+FG = (80, 180, 130)       # Flow green
+FR = (200, 100, 90)       # Flow red
+AB = (25, 38, 55)         # Agent bg
+GC = (30, 42, 58)         # Grid
+
+# ─── Fonts ───
+FD = "/Users/liulidong/.claude/plugins/cache/anthropic-agent-skills/document-skills/69c0b1a06741/skills/canvas-design/canvas-fonts"
+CF = "/System/Library/Fonts/PingFang.ttc"
+cn = lambda s: ImageFont.truetype(CF, s)
+en = lambda n, s: ImageFont.truetype(f"{FD}/{n}", s)
+
+ft = cn(36); fs = cn(20); fl = cn(15); fm = cn(12); fx = cn(11)
+et = en("Tektur-Medium.ttf", 26); es = en("InstrumentSans-Bold.ttf", 15)
+em = en("InstrumentSans-Regular.ttf", 12); ex = en("GeistMono-Regular.ttf", 11)
+ej = en("JetBrainsMono-Regular.ttf", 11); ejb = en("JetBrainsMono-Bold.ttf", 12)
+
+# ─── Helpers ───
+def grid():
+    for x in range(0, W, 40):
+        draw.line([(x, 0), (x, H)], fill=(25, 36, 52) if x % 200 else GC, width=1)
+    for y in range(0, H, 40):
+        draw.line([(0, y), (W, y)], fill=(25, 36, 52) if y % 200 else GC, width=1)
+
+def rr(x, y, w, h, r, fill, ol=None, ow=1):
+    draw.rounded_rectangle([(x, y), (x+w, y+h)], radius=r, fill=fill, outline=ol, width=ow)
+
+def dot(x, y, st):
+    c = {'done': GD, 'half': YH, 'todo': RT}.get(st, WD)
+    draw.ellipse([(x-5, y-5), (x+5, y+5)], fill=c)
+
+def arrow(x1, y1, x2, y2, c, w=2, dash=False):
+    dx, dy = x2-x1, y2-y1
+    L = math.sqrt(dx*dx+dy*dy)
+    if L < 1: return
+    if dash:
+        for i in range(int(L/13)):
+            t1, t2 = i*13/L, min((i*13+8)/L, 1.0)
+            draw.line([(x1+dx*t1, y1+dy*t1), (x1+dx*t2, y1+dy*t2)], fill=c, width=w)
+    else:
+        draw.line([(x1, y1), (x2, y2)], fill=c, width=w)
+    a = math.atan2(dy, dx)
+    pts = [(x2, y2), (x2+10*math.cos(a+2.5), y2+10*math.sin(a+2.5)), (x2+10*math.cos(a-2.5), y2+10*math.sin(a-2.5))]
+    draw.polygon(pts, fill=c)
+
+def bezier(x1, y1, x2, y2, c, w=2, cv=40):
+    dx, dy = x2-x1, y2-y1
+    L = math.sqrt(dx*dx+dy*dy)
+    if L < 1: return
+    mx, my = (x1+x2)/2, (y1+y2)/2
+    cx, cy = mx + (-dy/L)*cv, my + (dx/L)*cv
+    prev = (x1, y1)
+    for i in range(1, 21):
+        t = i/20
+        px = (1-t)**2*x1 + 2*(1-t)*t*cx + t**2*x2
+        py = (1-t)**2*y1 + 2*(1-t)*t*cy + t**2*y2
+        draw.line([prev, (px, py)], fill=c, width=w)
+        prev = (px, py)
+    tx, ty = 2*(x2-cx), 2*(y2-cy)
+    a = math.atan2(ty, tx)
+    pts = [(x2, y2), (x2+10*math.cos(a+2.5), y2+10*math.sin(a+2.5)), (x2+10*math.cos(a-2.5), y2+10*math.sin(a-2.5))]
+    draw.polygon(pts, fill=c)
+
+# ═══ DRAW ═══
+grid()
+
+# ─── Title Bar ───
+draw.rectangle([(0, 0), (W, 65)], fill=(18, 28, 42))
+draw.line([(0, 65), (W, 65)], fill=SB, width=2)
+draw.text((30, 14), "腾讯广告自动化投放 Agent 系统", font=ft, fill=WH)
+draw.text((530, 22), "TECHNICAL ARCHITECTURE", font=et, fill=SL)
+draw.text((W-340, 16), "Reson Agent Framework", font=es, fill=AD)
+draw.text((W-340, 36), "v3.0 · Agent Perspective", font=em, fill=WD)
+
+# ─── Legend ───
+lx, ly = W-310, 78
+rr(lx, ly, 280, 90, 6, (20, 30, 48), BD)
+draw.text((lx+10, ly+5), "图例 LEGEND", font=fm, fill=WD)
+for i, (lb, st) in enumerate([("已完成 Done", "done"), ("半完成 Partial", "half"), ("未完成 Todo", "todo")]):
+    dot(lx+20, ly+28+i*20+6, st)
+    draw.text((lx+32, ly+28+i*20), lb, font=fx, fill=WD)
+for i, (lb, c) in enumerate([("数据流", FB), ("执行流", FG), ("告警流", FR)]):
+    draw.line([(lx+160, ly+30+i*20), (lx+200, ly+30+i*20)], fill=c, width=2)
+    draw.text((lx+205, ly+24+i*20), lb, font=fx, fill=WD)
+
+# ═══ USER INPUT ═══
+uy = 82
+rr(30, uy, 240, 48, 8, (35, 50, 70), AM, 2)
+draw.text((48, uy+4), "用户输入 / 运营", font=fs, fill=AM)
+draw.text((48, uy+28), "\"今日预算10w\"", font=ex, fill=WD)
+
+rr(30, uy+56, 240, 32, 8, (35, 50, 70), FG, 1)
+draw.text((48, uy+60), "运营确认 → 执行", font=fl, fill=FG)
+
+# ═══ MAIN AGENT ═══
+mx, my, mw, mh = 340, 80, 440, 90
+rr(mx, my, mw, mh, 10, (30, 45, 65), AM, 2)
+dot(mx+15, my+15, 'half')
+draw.text((mx+28, my+6), "Main Agent · 投放决策中枢", font=fs, fill=AM)
+draw.text((mx+28, my+32), "task.prompt | 任务拆解与全局调度", font=fl, fill=WH)
+draw.text((mx+28, my+52), "temp 0.3(分析)/0.1(执行) · 单Agent运行", font=fm, fill=WD)
+draw.text((mx+28, my+70), "AgentRunner → LLM → Tool → Trace", font=ej, fill=SL)
+arrow(270, uy+24, mx, my+24, AM, 2)
+
+# ═══ SUB-AGENTS (3 cols × 2 rows, left 2/3 of canvas) ═══
+cw, ch = 370, 210
+gx = 25
+sx = 40
+r1y, r2y = 215, 460
+
+agents = [
+    {'n': 'Budget Agent', 'c': '预算出价优化', 's': 'done', 'x': sx, 'y': r1y,
+     'd': '每日预算分配 · ROI计算 · 出价策略',
+     't': ['budget_calc.py', 'data_query.py'],
+     'td': ['ROI×跑量 二维矩阵, 5种动作', '冷启动保护: 48h+转化<6', '赔付门槛保护', '调价: 单次≤10%, 间隔≥2h', '出价边界: 10分~10000分'],
+     'sk': 'budget_strategy.md'},
+    {'n': 'Data Analyst', 'c': '数据查询分析(只读)', 's': 'done', 'x': sx+cw+gx, 'y': r1y,
+     'd': '只读查询 · 7种数据类型 · ODPS SQL',
+     't': ['data_query.py'],
+     'td': ['data_query(7种查询类型)', 'data_aggregate 聚合分析', 'get_ad_current_status', 'creative_detail SQL', 'ODPS/MaxCompute → loghubods'],
+     'sk': 'ad_domain.md'},
+    {'n': 'Audience Agent', 'c': '人群定向分析', 's': 'half', 'x': sx+(cw+gx)*2, 'y': r1y,
+     'd': '人群规划 · 圈选 · 定向策略',
+     't': ['audience_tools.py', 'data_query.py'],
+     'td': ['audience_build_targeting', 'audience_recommend_targeting', '年龄预设 18-24/25-34/35-49', 'custom_audience 人群包ID', '需求驱动 + 定向策略'],
+     'sk': 'audience_strategy.md'},
+    {'n': 'Creative Agent', 'c': '素材效果分析', 's': 'todo', 'x': sx, 'y': r2y,
+     'd': '素材供给 · 人群-素材匹配',
+     't': ['ad_api.py', 'data_query.py'],
+     'td': ['creative_create / update', '素材效果数据分析', '投放人群-素材匹配', '素材-视频匹配', '动态创意 dynamic_creatives'],
+     'sk': 'creative_strategy.md'},
+    {'n': 'System Ops', 'c': 'API操作执行', 's': 'todo', 'x': sx+cw+gx, 'y': r2y,
+     'd': '广告搭建 · 配置 · 调整 · 关停',
+     't': ['ad_api.py'],
+     'td': ['ad_create / ad_update', 'ad_batch_update_status', 'creative_create / update', 'account_get_info', '广告+创意配置+落地页'],
+     'sk': 'ad_domain.md'},
+    {'n': 'Monitor Agent', 'c': '异常检测与熔断', 's': 'todo', 'x': sx+(cw+gx)*2, 'y': r2y,
+     'd': '异常监控 · 熔断策略 · 变更通知',
+     't': ['monitor_tools.py', 'ad_api.py'],
+     'td': ['monitor_check_metrics', 'monitor_circuit_break', '异常暂停 AD_STATUS_SUSPEND', '操作变更通知', '消耗/CPA/预算异常'],
+     'sk': 'monitor_rules.md'},
+]
+
+for ag in agents:
+    x, y = ag['x'], ag['y']
+    bc = {'done': GD, 'half': YH, 'todo': RT}[ag['s']]
+    hc = {'done': (30, 55, 40), 'half': (50, 48, 25), 'todo': (55, 30, 30)}[ag['s']]
+    rr(x, y, cw, ch, 8, AB, bc, 2)
+    rr(x+2, y+2, cw-4, 32, 6, hc)
+    dot(x+16, y+18, ag['s'])
+    draw.text((x+28, y+7), ag['n'], font=es, fill=WH)
+    # Position CN name after EN name
+    en_w = draw.textbbox((0,0), ag['n'], font=es)[2]
+    draw.text((x+28+en_w+8, y+8), ag['c'], font=fm, fill=bc)
+    draw.text((x+12, y+38), ag['d'], font=fm, fill=WD)
+    draw.line([(x+12, y+56), (x+cw-12, y+56)], fill=BD, width=1)
+    draw.text((x+12, y+60), "Tools:", font=ex, fill=SL)
+    for i, t in enumerate(ag['t']):
+        draw.text((x+60+i*120, y+60), t, font=ej, fill=AD)
+    for i, line in enumerate(ag['td']):
+        draw.text((x+16, y+80+i*15), f"· {line}", font=fx, fill=WD)
+    draw.text((x+12, y+ch-20), "Skill:", font=ex, fill=SL)
+    draw.text((x+50, y+ch-20), ag['sk'], font=ej, fill=AD)
+
+# ═══ MAIN → SUB ARROWS ═══
+mcx = mx + mw//2
+mbot = my + mh
+for ag in agents[:3]:
+    arrow(mcx, mbot, ag['x']+cw//2, ag['y'], AM, 2)
+for ag in agents[3:]:
+    bezier(mcx, mbot, ag['x']+cw//2, ag['y'], AD, 1, cv=25)
+
+# ═══ INFRASTRUCTURE (Right column) ═══
+ix = sx + (cw+gx)*3 + 30  # Right of all agent cards
+iy = 80
+
+# ODPS
+rr(ix, iy, 440, 160, 8, (20, 35, 50), SB, 2)
+draw.text((ix+15, iy+8), "ODPS / MaxCompute 数据仓库", font=fs, fill=SL)
+draw.text((ix+15, iy+34), "loghubods 库", font=fl, fill=AM)
+for i, (t, d) in enumerate([
+    ("ad_put_tencent_ad", "广告基础"),
+    ("ad_put_tencent_creative_data_day", "创意日报"),
+    ("ad_put_tencent_account_data", "账户消耗"),
+    ("fission_data / roi_data", "裂变&ROI"),
+]):
+    draw.text((ix+20, iy+56+i*22), f"├─ {t}", font=ej, fill=WD)
+    draw.text((ix+300, iy+56+i*22), d, font=fx, fill=SL)
+
+# Tencent API
+ty = iy + 175
+rr(ix, ty, 440, 140, 8, (20, 35, 50), SB, 2)
+draw.text((ix+15, ty+8), "腾讯广告 Marketing API v3.0", font=fs, fill=SL)
+draw.text((ix+15, ty+34), "2层: 广告(adgroups) → 创意(dynamic_creatives)", font=fm, fill=WH)
+for i, item in enumerate([
+    "/v3.0/adgroups/add|update|get",
+    "/v3.0/dynamic_creatives/add|update",
+    "/v3.0/adgroups/update_daily_budget",
+    "bid_mode=OCPM  QPS≤10  batch≤50",
+]):
+    draw.text((ix+20, ty+54+i*18), item, font=ej, fill=WD)
+
+# Business Rules
+ry = ty + 150
+rr(ix, ry, 440, 230, 8, (20, 30, 48), BD)
+draw.text((ix+15, ry+6), "关键业务规则 Key Rules", font=fl, fill=AM)
+rules = [
+    ("─── oCPM 出价 ───", True),
+    ("eCPM = bid × pCTR × pCVR × 1000", False),
+    ("降10%出价 ≈ 降15-25%消耗(非线性)", False),
+    ("掉量悬崖: eCPM<竞争水位→断崖", False),
+    ("─── 决策矩阵 ROI×跑量 ───", True),
+    ("高ROI+低跑量→increase +10%~15%", False),
+    ("中ROI+高跑量→decrease -5%~10%", False),
+    ("低ROI+低跑量→close(标记关停)", False),
+    ("─── 保护机制 ───", True),
+    ("冷启动: <48h∨转化<6→observe", False),
+    ("赔付: 转化≥6且CPA偏离≥20%→先赔付", False),
+    ("熔断: CPA>阈值∨消耗异常→暂停", False),
+]
+for i, (r, is_h) in enumerate(rules):
+    draw.text((ix+15, ry+26+i*17), r, font=fx if not is_h else fm, fill=AM if is_h else WD)
+
+# Data Flow Summary
+fy = ry + 240
+rr(ix, fy, 440, 120, 8, (20, 30, 48), BD)
+draw.text((ix+15, fy+8), "核心数据流 Core Flows", font=fl, fill=AM)
+flows = [
+    ("① 用户输入", "→ Main → 子Agent分发", AM),
+    ("② Budget", "→ data_query(ODPS) → budget_calc", FB),
+    ("③ 运营确认", "→ System Ops → ad_api(腾讯)", FG),
+    ("④ Monitor", "→ ad_api(熔断) → 异常暂停", FR),
+    ("⑤ 反馈", "→ 投放数据 → 策略归纳 → 优化", SL),
+]
+for i, (lb, d, c) in enumerate(flows):
+    draw.text((ix+15, fy+30+i*19), lb, font=fm, fill=c)
+    draw.text((ix+110, fy+30+i*19), d, font=fx, fill=WD)
+
+# ═══ DATA FLOW ARROWS (Agents → Infrastructure) ═══
+# Data Analyst → ODPS
+da = agents[1]
+arrow(da['x']+cw, da['y']+ch//2, ix, iy+80, FB, 2)
+draw.text((da['x']+cw+8, da['y']+ch//2-16), "SQL Query", font=ex, fill=FB)
+
+# Budget → Data Analyst (inter-agent)
+ba, daa = agents[0], agents[1]
+arrow(ba['x']+cw, ba['y']+110, daa['x'], daa['y']+110, FB, 2)
+draw.text((ba['x']+cw+4, ba['y']+96), "查询数据", font=fx, fill=FB)
+
+# System Ops → Tencent API
+sa = agents[4]
+arrow(sa['x']+cw, sa['y']+ch//2, ix, ty+70, FG, 2)
+draw.text((sa['x']+cw+8, sa['y']+ch//2-16), "API Call", font=ex, fill=FG)
+
+# Monitor → Tencent API (circuit break)
+ma = agents[5]
+arrow(ma['x']+cw, ma['y']+ch//3, ix, ty+110, FR, 2)
+draw.text((ma['x']+cw+8, ma['y']+ch//3-16), "熔断", font=fx, fill=FR)
+
+# Creative → Tencent API
+ca = agents[3]
+bezier(ca['x']+cw, ca['y']+ch//2, ix, ty+40, FG, 1, cv=35)
+
+# Audience → Creative (人群-素材匹配)
+aa = agents[2]
+bezier(aa['x']+cw//3, aa['y']+ch, ca['x']+cw//3, ca['y'], YH, 1, cv=-40)
+draw.text((aa['x']+20, aa['y']+ch+4), "人群-素材匹配", font=fx, fill=YH)
+
+# User confirm → System Ops
+bezier(150, uy+88, sa['x']+cw//2, sa['y'], FG, 2, cv=-80)
+
+# ═══ SKILLS BAR ═══
+sky = 690
+rr(30, sky, (cw+gx)*3+10, 22, 4, (30, 40, 55), BD)
+draw.text((40, sky+3), "Skills:", font=ex, fill=SL)
+for i, sk in enumerate(["ad_domain.md", "budget_strategy.md", "audience_strategy.md", "creative_strategy.md", "monitor_rules.md"]):
+    draw.text((90+i*195, sky+3), sk, font=ej, fill=AD)
+draw.text((40, sky-14), "↑ 自动注入 Auto-inject into Agent context", font=fx, fill=SL)
+
+# ═══ FRAMEWORK BAR (Bottom) ═══
+fwy = 730
+rr(30, fwy, W-60, 60, 8, (18, 26, 40), SB, 1)
+draw.text((50, fwy+5), "底层框架 Reson Agent Framework", font=fs, fill=SL)
+fw_items = [("AgentRunner", "LLM循环引擎"), ("LLM Adapters", "Qwen/Gemini"),
+            ("Tool Registry", "@tool注册"), ("Skill System", "知识注入"),
+            ("Trace", "GoalTree/回溯"), ("Knowledge", "Config")]
+fw_start = 50
+fw_gap = 190
+for i, (n, d) in enumerate(fw_items):
+    xx = fw_start + i * fw_gap
+    draw.text((xx, fwy+28), n, font=es, fill=AM)
+    draw.text((xx, fwy+44), d, font=fx, fill=WD)
+
+# Workflow modes
+rr(fw_start + len(fw_items)*fw_gap + 20, fwy+10, 220, 18, 4, (25, 45, 35), GD, 1)
+draw.text((fw_start + len(fw_items)*fw_gap + 28, fwy+11), "分析 Analysis temp=0.3", font=fx, fill=GD)
+rr(fw_start + len(fw_items)*fw_gap + 250, fwy+10, 220, 18, 4, (45, 35, 25), (200, 120, 100), 1)
+draw.text((fw_start + len(fw_items)*fw_gap + 258, fwy+11), "执行 Execution temp=0.1", font=fx, fill=(200, 120, 100))
+arrow(fw_start + len(fw_items)*fw_gap + 240, fwy+19, fw_start + len(fw_items)*fw_gap + 250, fwy+19, WD, 1)
+rr(fw_start + len(fw_items)*fw_gap + 20, fwy+32, 220, 18, 4, (25, 38, 55), BD)
+draw.text((fw_start + len(fw_items)*fw_gap + 28, fwy+33), "只读工具 read-only", font=fx, fill=WD)
+rr(fw_start + len(fw_items)*fw_gap + 250, fwy+32, 220, 18, 4, (25, 38, 55), BD)
+draw.text((fw_start + len(fw_items)*fw_gap + 258, fwy+33), "读写工具 read-write", font=fx, fill=WD)
+
+# ═══ BORDER ═══
+draw.rectangle([(0, 0), (W-1, H-1)], outline=SB, width=2)
+
+# Crop to actual content height (framework ends at ~795)
+final_h = 810
+img2 = img.crop((0, 0, W, final_h))
+out = "/Users/liulidong/project/agent/Agent/outputs/agent_architecture.png"
+img2.save(out, "PNG", dpi=(150, 150))
+print(f"Saved: {out} ({W}x{final_h})")

BIN
自动化投放架构、规划、拆解.docx