Sfoglia il codice sorgente

添加注释、项目结构文档

wangyunpeng 1 settimana fa
parent
commit
aaff64ab16
33 ha cambiato i file con 1198 aggiunte e 0 eliminazioni
  1. 312 0
      examples/auto_put_ad/PROJECT_STRUCTURE.md
  2. 343 0
      examples/auto_put_ad_mini/ONBOARDING.md
  3. 420 0
      examples/auto_put_ad_mini/PROJECT_STRUCTURE.md
  4. 13 0
      examples/auto_put_ad_mini/analyze_snapshot.py
  5. 10 0
      examples/auto_put_ad_mini/configure_creation_accounts.py
  6. 1 0
      examples/auto_put_ad_mini/execute_creation_apply.py
  7. 2 0
      examples/auto_put_ad_mini/execute_creation_once.py
  8. 1 0
      examples/auto_put_ad_mini/execute_once.py
  9. 1 0
      examples/auto_put_ad_mini/get_chat_id.py
  10. 1 0
      examples/auto_put_ad_mini/quick_analysis.py
  11. 1 0
      examples/auto_put_ad_mini/regenerate_metrics.py
  12. 3 0
      examples/auto_put_ad_mini/run.py
  13. 2 0
      examples/auto_put_ad_mini/scan_creative_reviews.py
  14. 5 0
      examples/auto_put_ad_mini/sync_ad_status.py
  15. 7 0
      examples/auto_put_ad_mini/sync_feishu_account_config.py
  16. 7 0
      examples/auto_put_ad_mini/tools/audience_grant.py
  17. 1 0
      examples/auto_put_ad_mini/tools/creative_creation.py
  18. 2 0
      examples/auto_put_ad_mini/tools/creative_material_usage.py
  19. 1 0
      examples/auto_put_ad_mini/tools/creative_metrics.py
  20. 12 0
      examples/auto_put_ad_mini/tools/creative_review.py
  21. 1 0
      examples/auto_put_ad_mini/tools/creative_roi_calculator.py
  22. 2 0
      examples/auto_put_ad_mini/tools/execution_engine.py
  23. 1 0
      examples/auto_put_ad_mini/tools/feishu_doc.py
  24. 17 0
      examples/auto_put_ad_mini/tools/guardrails.py
  25. 2 0
      examples/auto_put_ad_mini/tools/im_approval_ad_creation.py
  26. 1 0
      examples/auto_put_ad_mini/tools/im_approval_creation.py
  27. 8 0
      examples/auto_put_ad_mini/tools/material_recall.py
  28. 2 0
      examples/auto_put_ad_mini/tools/portfolio_metrics.py
  29. 1 0
      examples/auto_put_ad_mini/tools/scene_spec.py
  30. 10 0
      examples/auto_put_ad_mini/tools/video_feature_query.py
  31. 2 0
      examples/auto_put_ad_mini/tools/video_recall.py
  32. 2 0
      examples/auto_put_ad_mini/tools/video_risk.py
  33. 4 0
      examples/auto_put_ad_mini/utils/log_capture.py

+ 312 - 0
examples/auto_put_ad/PROJECT_STRUCTURE.md

@@ -0,0 +1,312 @@
+# auto_put_ad — 项目结构文档
+
+> **定位**: 腾讯广告自动化投放多 Agent 协作系统
+> **最后更新**: 2026-07-16
+
+---
+
+## 一、项目概述
+
+`auto_put_ad` 是一个基于 Reson Agent 框架的多 Agent 广告投放系统,面向腾讯广告 Marketing API v3.0。系统由 **8 个专业 sub-agent** 组成,通过主 Agent 统一调度,覆盖预算/出价优化、受众定向、创意策略、监控告警、系统运维、数据分析等投放全链路。
+
+### 当前成熟度
+
+| Agent | 状态 | 说明 |
+|-------|------|------|
+| **main** | ✅ 就绪 | 任务分发中枢 |
+| **budget** | ✅ 就绪 | 预算约束下的出价调整 |
+| **execute** | ✅ 就绪 | 审批后执行 API 操作 |
+| **audience** | 🟡 部分就绪 | 定向推荐可用,A/B 测试待实现 |
+| **data_analyst** | 🟡 部分就绪 | 基础查询可用,深度分析待实现 |
+| **creative** | 🔴 未就绪 | 创意策略框架已搭建 |
+| **monitor** | 🔴 未就绪 | 监控规则已定义,实时检测待实现 |
+| **system_ops** | 🔴 未就绪 | API 运维操作框架已搭建 |
+
+> **与 auto_put_ad_mini 的关系**: `auto_put_ad_mini` 是本项目中「监控调控 Agent」角色的独立落地版本,实际已演进出更完整的决策 + 创建双业务线。本项目当前的核心可用链路是 **budget → execute** 出价调整。
+
+---
+
+## 二、目录结构
+
+```
+examples/auto_put_ad/
+│
+├── run.py                          # 🔵 分析入口:交互式 Agent CLI
+├── run_execute.py                  # 🟢 执行入口:审批后执行 API 操作
+├── config.py                       # 运行配置(3 套 RunConfig + 工具白名单)
+├── task.prompt                     # 主 Agent 任务描述
+├── presets.json                    # 8 个 sub-agent 预设定义
+├── odps_module.py                  # ODPS(MaxCompute)数据仓库客户端
+├── test_budget.py                  # 出价调整独立测试脚本
+│
+├── README.md                       # 项目概览
+├── TODO.md                         # 任务追踪(P0/P1/P2)
+├── BID_ADJUSTMENT_README.md        # 出价调整系统深度文档
+├── EXECUTION_GUIDE.md              # 生产部署与执行指南
+├── IMPLEMENTATION_SUMMARY.md       # 实现总结
+│
+├── configs/
+│   └── budget_strategy_v1.json     # 策略配置(阈值/决策矩阵/出价边界/保护规则)
+│
+├── docs/
+│   ├── budget_decision_framework.md    # 预算决策框架(scale_ratio、2×2 矩阵、保护机制)
+│   ├── budget_strategy_detail.md       # 预算策略详解
+│   └── 腾讯广告智能投放策略研究-参考.md    # 行业策略参考
+│
+├── prompts/                        # 各 Agent 系统提示词
+│   ├── main.prompt                 # 主调度 Agent
+│   ├── budget.prompt               # 预算/出价优化专家
+│   ├── execute.prompt              # 执行 Agent
+│   ├── audience.prompt             # 受众定向专家
+│   ├── creative.prompt             # 创意/素材专家
+│   ├── monitor.prompt              # 监控告警
+│   └── system_ops.prompt           # API 运维操作
+│
+├── skills/                         # 领域知识(注入 Agent 上下文)
+│   ├── ad_domain.md                # 腾讯广告 3.0 平台知识
+│   ├── budget_strategy.md          # 预算/出价决策知识
+│   ├── audience_strategy.md        # 受众定向策略
+│   ├── creative_strategy.md        # 创意生命周期管理
+│   └── monitor_rules.md            # 异常检测规则
+│
+└── tools/                          # 自定义工具(@tool 装饰器)
+    ├── __init__.py
+    ├── ad_api.py                   # 腾讯广告 API v3.0 封装(13 个工具)
+    ├── data_query.py               # ODPS 数据查询(3 个工具)
+    ├── budget_calc.py              # 出价计算引擎(6 个工具)
+    ├── audience_tools.py           # 受众定向工具(2 个工具)
+    ├── monitor_tools.py            # 监控工具(2 个工具)
+    ├── execute_agent.py            # 执行编排(1 个工具)
+    └── strategy_config.py          # 策略配置管理(3 个工具)
+```
+
+---
+
+## 三、入口点与执行流程
+
+### 3.1 两个入口
+
+| 入口 | 文件 | 用途 | 模型 | 工具范围 |
+|------|------|------|------|---------|
+| **分析入口** | `run.py` | 交互式分析,制定出价调整方案 | `qwen/qwen3.5-plus-02-15` | ANALYSIS_TOOLS(只读+计算) |
+| **执行入口** | `run_execute.py` | 读取已审批的方案,执行 API 操作 | `qwen/qwen3.5-plus-02-15` (temp=0.1) | EXECUTION_TOOLS(含写操作) |
+
+### 3.2 分析流程(run.py)
+
+```
+用户输入:"今天小程序预算 10 万"
+        │
+        ▼
+┌──────────────────────────────────────┐
+│  Main Agent (投放决策中枢)             │
+│  System Prompt: prompts/main.prompt  │
+│  Skills: ad_domain.md                │
+│  Tools: ANALYSIS_TOOLS + agent tool  │
+└──────────────────────────────────────┘
+        │ 理解意图 → 分派 sub-agent
+        ▼
+┌──────────────────────────────────────┐
+│  Budget Agent (预算出价Agent)          │
+│  System Prompt: prompts/budget.prompt│
+│  Skills: ad_domain, budget_strategy  │
+│                                      │
+│  7 步工作流:                          │
+│  ① load_strategy_config()  加载策略配置│
+│  ② get_account_summary()   拉 ODPS 数据│
+│  ③ 战略判断(周末?促销?是否调阈值)     │
+│  ④ compute_budget_thresholds() 分位数  │
+│  ⑤ classify_ads()          2×2 分类   │
+│  ⑥ compute_bid_adjustment() 出价方案   │
+│  ⑦ 展示方案 → 等待用户确认              │
+└──────────────────────────────────────┘
+        │ 用户确认后
+        ▼
+  bid_adjustment_execute() → 批量 API 调用
+        │
+        ▼
+  输出: outputs/adjustment_plan_YYYYMMDD_HHMMSS.xlsx
+```
+
+### 3.3 执行流程(run_execute.py)
+
+```
+用户输入:"执行最新方案"
+        │
+        ▼
+  自动发现 outputs/ 下最新的 Excel 方案文件
+        │
+        ▼
+┌──────────────────────────────────────┐
+│  Execute Agent (投放执行系统)           │
+│  System Prompt: prompts/execute.prompt│
+│  Skills: ad_domain, budget_strategy,  │
+│          monitor_rules                │
+│  Tools: EXECUTION_TOOLS               │
+└──────────────────────────────────────┘
+        │
+        ▼
+  execute_adjustment_plan():
+    ① 加载方案(Excel → 决策列表)
+    ② 验证:冷启动跳过 / 赔付保护 / 出价边界 / 15% 振幅上限
+    ③ dry_run=True → 仅输出模拟报告
+    ④ dry_run=False → 执行:
+       a. 出价调整(increase/decrease)→ ad_update()
+       b. 广告关停(close)→ ad_batch_update_status()
+       c. 执行后监控 → monitor_check_metrics()
+    ⑤ 输出执行报告
+```
+
+### 3.4 数据流架构
+
+```
+ODPS (MaxCompute)                    腾讯广告 API (api.e.qq.com/v3.0)
+├── creative_detail                   ├── /adgroups/update
+├── ad_put_tencent_ad                 ├── /adgroups/add
+├── ad_put_tencent_creative_day       └── /dynamic_creatives/add
+├── ad_put_tencent_creative_data_day
+└── ad_put_tencent_account
+        │                                      ▲
+        ▼                                      │
+┌──────────────┐    ┌──────────────┐    ┌──────────────┐
+│ data_query.py│ →  │ budget_calc  │ →  │  ad_api.py   │
+│ (ODPS SQL)   │    │ (计算引擎)    │    │ (API 调用)    │
+└──────────────┘    └──────────────┘    └──────────────┘
+        │                   │
+        ▼                   ▼
+   pandas DataFrame    决策矩阵查找 → [action, ratio]
+        效率分 = fission0_count / cost
+        百分位阈值 (P70/P30 ROI, P50 消耗)
+        2×2 分类 (ROI × 消耗)
+```
+
+---
+
+## 四、核心架构设计
+
+### 4.1 策略驱动架构
+
+所有决策参数外置在 `configs/budget_strategy_v1.json`,LLM 可在运行时通过 `update_strategy_config()` 调整策略(如周末放宽 ROI 阈值),变更自动版本化管理:
+
+```json
+{
+  "thresholds": {
+    "roi_high_percentile": 0.70,
+    "roi_low_percentile": 0.30,
+    "cost_mid_percentile": 0.50,
+    "valid_ad_min_open_count": 100
+  },
+  "decision_matrix": {
+    "aggressive_scale_down": {
+      "high_high": ["keep", 0.0],
+      "mid_high": ["decrease", -0.10],
+      "low_low":  ["close", 0.0]
+    }
+    // ... 5 种策略 × 6 个象限
+  },
+  "protection": {
+    "cold_start": { "enabled": true, "hours": 48, "min_conversions": 6 },
+    "compensation": { "enabled": true, "min_conversions": 6, "cpa_deviation": 0.20 }
+  }
+}
+```
+
+### 4.2 决策框架(5 步)
+
+| 步骤 | 操作 | 产出 |
+|------|------|------|
+| ① **全局策略** | `scale_ratio = 今日预算 ÷ 昨日消耗` | aggressive_scale_down / moderate_scale_down / maintain / moderate_scale_up / aggressive_scale_up |
+| ② **样本过滤** | 排除 `open_count < 100` 的广告 | 有效广告池 |
+| ③ **分位数计算** | ROI P70/P30 + 消耗 P50 | 动态阈值(非固定值) |
+| ④ **2×2 分类** | ROI 高/中/低 × 消耗 高/低 | 6 个象限 |
+| ⑤ **矩阵查找** | 策略 × 象限 → [action, ratio] | keep / increase / decrease / close / observe |
+
+### 4.3 保护机制(按优先级)
+
+| 优先级 | 机制 | 规则 |
+|--------|------|------|
+| 🔴 最高 | 冷启动保护 | 创建 < 48h 或转化 < 6 → 强制 observe |
+| 🟠 高 | 赔付保护 | 转化 3-5 次 → 改为 observe,等积累到 6 次申请赔付 |
+| 🟡 中 | 出价边界 | 截断到 [10, 10000] 分 |
+| 🟢 低 | 振幅上限 | 单次调整 ≤ 15%;超过需拆阶梯执行(间隔 ≥ 2h) |
+
+### 4.4 Multi-Agent 协作
+
+```
+presets.json 定义了 8 个 sub-agent:
+
+main ──分派──▶ budget     ← 当前可用
+         ├──▶ audience    ← 部分可用
+         ├──▶ creative    ← 未就绪
+         ├──▶ system_ops  ← 未就绪
+         ├──▶ monitor     ← 未就绪
+         └──▶ data_analyst← 部分可用
+
+execute ←── 独立入口,不通过 main 调度
+```
+
+---
+
+## 五、工具清单
+
+### ad_api.py — 腾讯广告 API 封装(13 个工具)
+
+| 工具 | API 端点 | 说明 |
+|------|---------|------|
+| `ad_create` | POST /adgroups/add | 创建广告 |
+| `ad_update` | POST /adgroups/update | 更新广告(出价/状态) |
+| `ad_batch_update_status` | POST /adgroups/update (batch) | 批量更新广告状态 |
+| `ad_get_list` | GET /adgroups/get | 查询广告列表 |
+| `ad_get_report` | GET /daily_reports/get | 广告报表 |
+| `creative_create` | POST /dynamic_creatives/add | 创建动态创意 |
+| `creative_get_list` | GET /dynamic_creatives/get | 查询创意列表 |
+| `creative_update` | POST /dynamic_creatives/update | 更新创意 |
+| `creative_get_report` | GET /daily_reports/get | 创意报表 |
+| `asset_get_list` | GET /assets/get | 查询资产列表 |
+| `audience_get_list` | GET /custom_audiences/get | 查询人群包 |
+| `account_get_info` | GET /advertiser/get | 查询账户信息 |
+
+### data_query.py — ODPS 数据查询(3 个工具)
+
+| 工具 | 说明 |
+|------|------|
+| `data_query` | 通用 ODPS SQL 查询(含模板:creative_detail, account_summary, ad_detail 等) |
+| `data_aggregate` | 数据聚合查询 |
+| `get_ad_current_status` | 查询广告当前状态(出价/预算/定向) |
+
+### budget_calc.py — 出价计算引擎(6 个工具)
+
+| 工具 | 说明 |
+|------|------|
+| `get_ad_performance` | 拉取广告昨日效率数据 |
+| `get_account_summary` | 拉取账户汇总数据 |
+| `compute_budget_thresholds` | 计算分位数阈值(P70/P30/P50) |
+| `classify_ads` | 2×2 象限分类(ROI × 消耗) |
+| `compute_bid_adjustment` | 计算出价调整方案 |
+| `bid_adjustment_execute` | 批量执行出价调整 |
+
+---
+
+## 六、配置说明
+
+`config.py` 定义了三套 `RunConfig`:
+
+| 配置 | 用途 | Temperature | Max Iterations |
+|------|------|-------------|----------------|
+| `MAIN_CONFIG` | 主分析 Agent | 0.3 | 50 |
+| `BUDGET_CONFIG` | 预算/出价 sub-agent | 0.3 | 30 |
+| `EXECUTE_CONFIG` | 执行 Agent | 0.1 | 50 |
+
+工具白名单:
+
+- `ANALYSIS_TOOLS`: 只读查询 + 计算工具(不含 ad_create/ad_update 等写操作)
+- `EXECUTION_TOOLS`: 全量工具(含所有写操作)
+
+---
+
+## 七、关键设计原则
+
+1. **策略外置**: 决策参数在 JSON 配置文件中,LLM 可运行时调整并版本化管理
+2. **两段式执行**: 分析(run.py)与执行(run_execute.py)分离,中间有人工审批环节
+3. **保护优先**: 冷启动 → 赔付 → 边界 → 振幅,四层保护从高到低依次生效
+4. **用出价控制消耗**: oCPM 模式下通过 bid 调整影响 eCPM → 竞价胜率 → 消耗量,而非直接设 day_amount
+5. **非线性认知**: 降价效果放大(-10% 出价 ≈ -15~25% 消耗),需警惕"掉量悬崖"

+ 343 - 0
examples/auto_put_ad_mini/ONBOARDING.md

@@ -0,0 +1,343 @@
+# 🚀 新人上手指南 — auto_put_ad_mini
+
+> **阅读时间**: 15 分钟
+> **前提**: 已经有 GIT_CHANGELOG.md(项目根目录),建议先快速浏览它的目录了解演进历史
+> **最后更新**: 2026-07-06
+
+---
+
+## 1. 这个项目是干什么的?
+
+一句话:**基于 ROI 数据,自动判断腾讯广告该提价、降价还是关停,然后把决策发给运营审批,审批通过后自动执行。**
+
+```
+数据拉取 → ROI 计算 → 决策引擎 → 飞书审批 → API 执行
+  (ODPS)    (Python)   (LLM+规则)   (飞书IM)    (腾讯广告API)
+```
+
+两个独立的业务线共享同一套代码库:
+
+| 业务线 | 做什么 | 入口文件 |
+|--------|--------|---------|
+| **广告调控**(主) | 分析已投放广告的 ROI,决定是否提价/降价/关停 | `execute_once.py` |
+| **广告创建**(副) | 给新账户自动创建广告 + 挂创意 | `execute_creation_once.py` |
+
+---
+
+## 2. 先跑起来
+
+### 2.1 环境准备
+
+```bash
+cd /path/to/Agent
+
+# 创建虚拟环境(只需要一次)
+python3 -m venv .venv
+source .venv/bin/activate
+pip3 install -r requirements.txt
+pip3 install -r examples/auto_put_ad_mini/requirements.txt
+
+# 复制环境变量模板
+cp .env.template .env
+# 编辑 .env,填入必填的 API key(至少需要 TENCENT_AD_* 和 OPENROUTER_API_KEY)
+```
+
+### 2.2 执行调控流程(最主要的功能)
+
+```bash
+cd examples/auto_put_ad_mini
+
+# 完整 10 步 pipeline(数据拉取 → 决策 → 审批 → 执行)
+.venv/bin/python3 execute_once.py --date 20260705
+
+# 常用参数:
+#   --date YYYYMMDD   指定分析日期(默认昨天)
+#   --dry-run         只分析不执行
+#   --skip-approval   跳过飞书审批,直接执行
+```
+
+### 2.3 执行创建流程
+
+```bash
+# 单次创建(找缺创意的广告 → 召回素材 → 创建 → 审批 → 执行)
+.venv/bin/python3 execute_creation_once.py --date 20260705
+
+# 批量创建(给多个账户创建广告)
+.venv/bin/python3 execute_creation_apply.py --date 20260705
+```
+
+### 2.4 交互式模式(Agent 对话)
+
+```bash
+.venv/bin/python3 run.py
+# 进入对话,可以自然语言问"今天哪些广告需要调整?"
+```
+
+---
+
+## 3. 核心文件速查
+
+### 3.1 改了就能生效的文件(运营/产品可改)
+
+| 文件 | 改什么 | 何时改 |
+|------|--------|--------|
+| `config.py` | ROI 阈值、出价边界、审批超时等参数 | 调策略参数 |
+| `skills/*.md` | 决策规则、平台约束、后验经验 | 改业务规则 |
+| `prompts/system.prompt` | LLM 行为、输出格式、执行顺序 | 调 Agent 行为 |
+| `db/schema.sql` | 数据库表结构 | 加新表/新字段 |
+
+### 3.2 代码文件(改了会改变行为)
+
+| 文件 | 职责 | 代码量 |
+|------|------|--------|
+| `tools/ad_decision.py` | **决策引擎** — 候选标记 + LLM 评估 | 84KB(最大) |
+| `tools/roi_calculator.py` | **ROI 计算** — 动态 ROI (7日均值)、人群包基线 | 25KB |
+| `tools/im_approval.py` | **飞书审批** — 发消息 + 轮询回复 + Excel 附件 | 53KB |
+| `tools/execution_engine.py` | **执行引擎** — 调腾讯 API + 审计日志 | 40KB |
+| `tools/guardrails.py` | **安全护栏** — 频率/边界/方向检查 | 37KB |
+| `tools/ad_api.py` | **腾讯 API 封装** — GET/POST 统一入口 | 31KB |
+| `tools/data_query.py` | **数据拉取** — ODPS 查询 + 合并 | 23KB |
+| `tools/creative_creation.py` | **创意创建** — POST /dynamic_creatives/add | 37KB |
+| `tools/material_recall.py` | **素材召回** — batchByText + 评分排序 | 24KB |
+| `tools/creative_review.py` | **创意审核** — 扫描审核结果 | 21KB |
+
+### 3.3 业务知识(Skills,LLM 自动加载)
+
+| 文件 | 内容 |
+|------|------|
+| `skills/ad_domain.md` | 裂变模型、R 值、ROI 公式、数据字段定义 |
+| `skills/platform_rules.md` | 腾讯平台硬约束(oCPM 学习期、调价上限 30% 等) |
+| `skills/decision_strategy.md` | 决策框架:5 种候选标记、三级年龄保护、7 种 action |
+| `skills/posterior_wisdom.md` | 后验经验入口(生产后动态补充) |
+
+---
+
+## 4. 决策流程详解(最重要的一节)
+
+### 4.1 10 步 Pipeline
+
+```
+Step 0: 同步广告状态     — sync_ad_status.py(从腾讯 API 拉最新状态)
+Step 1: 拉取创意数据     — fetch_creative_data(ODPS)
+Step 2: 合并创意数据     — merge_creative_data
+Step 3: 计算 ROI 指标    — calculate_roi_metrics(动态 ROI (7日均值))
+Step 4: 计算人群包基线   — calculate_portfolio_summary(各人群包的 P50 等)
+Step 5: 获取待评估广告   — get_ads_for_review(年龄保护 + 候选标记)
+Step 6: LLM 决策         — 主 Agent 调用(规则标记 + LLM 判断)
+Step 7: 应用决策         — apply_decisions(合并 metrics 字段 + 护栏检查)
+Step 8: 生成报告         — generate_report(Excel + CSV)
+Step 9: 飞书审批 + 执行  — send_approval_request → 等回复 → execute_decisions
+```
+
+### 4.2 决策的三层架构
+
+```
+┌─────────────────────────────┐
+│  第1层:规则引擎(硬约束)     │  ← Python 代码,不做 LLM 调用
+│  - 年龄保护(≤3天不评估)     │
+│  - 零消耗识别(自动关停候选)  │
+│  - 候选信号计算               │
+└──────────────┬──────────────┘
+               ↓
+┌─────────────────────────────┐
+│  第2层:LLM 智能评估          │  ← Claude Sonnet 4.5
+│  - 多维度推理(ROI+裂变+CTR) │
+│  - 输出 action + reason       │
+└──────────────┬──────────────┘
+               ↓
+┌─────────────────────────────┐
+│  第3层:护栏兜底检查          │  ← Python 代码,LLM 输出后执行
+│  - 方向一致性(bid_down必须<0)│
+│  - 频率限制(同广告6h内不重复)│
+│  - 边界检查(出价下限0.05元)  │
+└─────────────────────────────┘
+```
+
+### 4.3 7 种决策类型
+
+| Action | 含义 | 是否调用 API | 审批要求 |
+|--------|------|:----------:|:------:|
+| `pause` | 暂停广告 | ✅ | 必须审批 |
+| `bid_down` | 降价 3%-5% | ✅ | 必须审批 |
+| `bid_up` | 提价 5%-10% | ✅ | 自动执行 |
+| `scale_up` | 建议扩量 | ❌ | 参考信息 |
+| `creative_adjust` | 建议换素材 | ❌ | 参考信息 |
+| `observe` | 观察等待 | ❌ | 参考信息 |
+| `hold` | 保持不变 | ❌ | 不展示 |
+
+### 4.4 核心概念:动态 ROI (7日均值)
+
+```
+对每一天(日消耗 ≥ 100 元才参与计算):
+  裂变收益率 = T0裂变数 × arpu / 当日消耗
+  回流倍数   = 总回流人数 / 首层打开数
+
+7 日滚动均值(至少 3 天合格数据):
+  裂变效率稳定因子 = 回流倍数_7日均值 / T0裂变系数_7日均值
+
+动态 ROI (7日均值) = 当日裂变收益率 × 裂变效率稳定因子
+```
+
+**决策时用"动态 ROI (7日均值) 均值"(多天的均值),而非单日值。**
+
+---
+
+## 5. 数据流
+
+### 5.1 数据来源
+
+```
+ODPS(阿里 MaxCompute)
+  ↓ fetch_creative_data
+CSV 文件 → outputs/raw/creative_raw_{date}.csv
+  ↓ merge_creative_data
+合并后 CSV → outputs/raw/creative_merged_{date}.csv
+  ↓ calculate_roi_metrics
+Metrics CSV → outputs/metrics_{date}.csv
+  ↓ get_ads_for_review + LLM
+Decisions CSV → outputs/reports/decisions_{date}.csv
+  ↓ generate_report
+审批 Excel → outputs/reports/approval_table_{date}.xlsx
+```
+
+### 5.2 数据库
+
+MySQL(`db/schema.sql`)存储:
+- `account_whitelist` — 账户白名单 + feedback_id + 用户 token
+- `ad_creation_account_config` — 广告创建配置(人群包、出价区间等)
+- `ad_delivery_template` — 投放模板(版位、地域、时段等)
+- `creative_creation_task` — 创意创建任务记录
+- `creative_review_result` — 创意审核结果
+- `creative_rejection_fact` — 审核拒绝原因
+- `creative_material_usage` — 素材使用记录(7 天去重)
+- `adjustment_history` — 调价历史(护栏用)
+
+---
+
+## 6. 关键配置项
+
+### 6.1 决策阈值(config.py)
+
+```python
+ROI_LOW_FACTOR = 0.75        # 动态ROI < 渠道P50×0.75 → 关停
+BID_DOWN_ROI_FACTOR = 0.90   # 动态ROI < 渠道P50×0.90 → 降价
+BID_UP_ROI_FACTOR = 1.05     # 动态ROI > 渠道P50×1.05 → 提价
+COLD_START_DAYS = 3          # ≤3天冷启动(完全不干预)
+EARLY_GROWTH_DAYS = 7        # 4-7天早期成长(只能提价)
+```
+
+### 6.2 出价边界
+
+```python
+BID_FLOOR_YUAN = 0.05        # 出价下限(分)
+BID_CEILING_YUAN = 1.00      # 出价上限(元)
+BID_UP_MIN_PCT = 0.05        # 提价最小 5%
+BID_UP_MAX_PCT = 0.10        # 提价最大 10%
+BID_DOWN_MIN_PCT = 0.03      # 降价最小 3%
+BID_DOWN_MAX_PCT = 0.05      # 降价最大 5%
+```
+
+### 6.3 环境变量(.env)
+
+必需的环境变量(至少本地开发):`OPENROUTER_API_KEY`、数据库连接信息(`DB_HOST` 等)、腾讯广告 API 的 `TENCENT_AD_*` 系列。
+
+完整列表见 `.env.example`。
+
+---
+
+## 7. 常见调试场景
+
+### 7.1 "我想看看今天 LLM 做了什么决策"
+
+```bash
+# 查看 decisions CSV
+cat outputs/reports/decisions_20260706.csv | head -20
+
+# 查看日志
+grep "LLM" outputs/decision_log_20260706.log
+```
+
+### 7.2 "决策不对,我想改规则"
+
+1. 如果是**参数值不对** → 改 `config.py` 中的阈值
+2. 如果是**判断逻辑不对** → 改 `skills/decision_strategy.md`(改 LLM 的推理指导)
+3. 如果是**硬约束不对** → 改 `tools/guardrails.py` 或 `tools/ad_decision.py`
+
+### 7.3 "飞书审批没反应"
+
+```bash
+# 检查飞书 token 是否有效
+.venv/bin/python3 -c "
+from examples.auto_put_ad_mini.tools.im_approval import _get_tenant_access_token
+print(_get_tenant_access_token()[:20])
+"
+
+# 手动发一条测试消息
+.venv/bin/python3 test_chat_send.py
+```
+
+### 7.4 "想看某个广告为什么被标记为关停候选"
+
+```bash
+# 找到对应日期的 metrics CSV
+grep "广告ID" outputs/metrics_20260706.csv
+
+# 检查年龄保护逻辑是否生效
+grep "年龄保护" outputs/decision_log_20260706.log
+```
+
+---
+
+## 8. 工程约定(重要!)
+
+### 8.1 不要猜测
+
+项目 CLAUDE.md 中明确规定:**禁止凭"常识 / 文档框架 / 命名规律"推断腾讯 API 的字段名、枚举值、参数结构。**
+
+正确做法:
+1. 拉真实数据反推(`/adgroups/get` 查已跑通的广告 JSON)
+2. 读现有代码(`tools/ad_api.py` 中已封装参数)
+3. 反问用户
+
+### 8.2 Git 规范
+
+- **不自动 commit / push** — 等用户明确说
+- Commit message: `feat(xxx): ...` / `fix(xxx): ...` 一行标题,不加 `Co-Authored-By` / `Generated with`
+- 不修改 `agent/` 目录(框架源码)
+
+### 8.3 Python 环境
+
+- 始终用 `.venv/bin/python3` 执行脚本
+- 用 `pip3` 安装依赖
+- 数据库配置优先从环境变量读取
+
+---
+
+## 9. 相关文档索引
+
+| 文档 | 位置 | 内容 |
+|------|------|------|
+| 完整变更日志 | `../../GIT_CHANGELOG.md` | 77 个 commit 的详细变更记录 |
+| 架构文档 | `ARCHITECTURE.html` | 可视化架构图 |
+| 新模块架构 | `ARCHITECTURE_NEW_MODULES.html` | 创建模块架构 |
+| 生产自动化 | `PRODUCTION_AUTOMATION.md` | 生产环境部署说明 |
+| 部署指南 | `DEPLOYMENT.md` | Docker + K8s 部署 |
+| 调度指南 | `SCHEDULER_GUIDE.md` | CronJob 配置 |
+| K8s 配置 | `k8s/` | deployment、cronjob、secret 等 |
+| 审批流更新 | `APPROVAL_FLOW_UPDATE.md` | 审批协议变更记录 |
+
+---
+
+## 10. 快速问答
+
+**Q: 改了个 config.py 参数,要重启什么?**
+A: 如果是 cronjob 模式,等下次 cron 触发自动生效。如果是手动执行,直接重新 `python3 execute_once.py`。
+
+**Q: 如何在本地测试而不调真实 API?**
+A: 设置 `DRY_RUN_MODE=True` 在 config.py 中,或者用 `--dry-run` 参数。
+
+**Q: auto_put_ad 和 auto_put_ad_mini 的关系?**
+A: `auto_put_ad` 是多 Agent 架构的 Budget Domain(预算分配),是独立系统。`auto_put_ad_mini` 是单 Agent 的广告调控系统,当前分支的主力项目。两者共享 `auto_put_ad/tools/ad_api.py` 的腾讯 API 封装。
+
+**Q: 创建模块的代码在哪?**
+A: 在 `auto_put_ad_mini/tools/` 下,以 `ad_creation.py`、`creative_creation.py`、`material_recall.py` 等命名。入口是 `execute_creation_once.py` 和 `execute_creation_apply.py`。

+ 420 - 0
examples/auto_put_ad_mini/PROJECT_STRUCTURE.md

@@ -0,0 +1,420 @@
+# auto_put_ad_mini — 项目结构文档
+
+> **定位**: 微信小程序投流 — 面向 ROI 的广告粒度自动调控 + 自动创建投放系统
+> **最后更新**: 2026-07-16
+
+---
+
+## 一、项目概述
+
+`auto_put_ad_mini` 是腾讯广告自动化投放系统的**核心业务单元**,专注微信小程序投流场景(`MARKETING_CARRIER_TYPE_MINI_PROGRAM_WECHAT`)。
+
+项目已从最初的"数据→决策→执行"单向链路,演进出**两条独立但共存的生产线**:
+
+| 业务线 | 入口 | 频率 | 职责 |
+|--------|------|------|------|
+| **Flow A: 决策调控** | `execute_once.py` | 每日 1 次 (UTC 02:00) | 分析 ROI + 跑量数据 → LLM 推理 → 出价调整/暂停广告 |
+| **Flow B: 创建投放** | `execute_creation_once.py` | 每日 1 次 (UTC 02:30) | 创建新广告 + 准备动态创意 → 提交腾讯审核 |
+| **Flow C: 审核扫描** | `scan_creative_reviews.py` | 每 2 小时 | 扫描创意审核结果 → 记录拒审事实 |
+
+**核心决策架构**: 规则标记候选(Python)→ LLM 综合判断(Claude Sonnet 4.5)→ 护栏兜底(Python),三层递进。
+
+---
+
+## 二、目录结构
+
+```
+examples/auto_put_ad_mini/
+│
+├── 🔵 核心入口
+│   ├── run.py                          # 交互式 Agent REPL
+│   ├── execute_once.py                 # Flow A 主入口:决策调控(10 步 pipeline)
+│   ├── execute_creation_once.py        # Flow B 主入口:创建投放(Module A + B)
+│   ├── execute_creation_apply.py       # Flow B Phase 3:独立提交创意到腾讯
+│   ├── run_full_analysis.py            # 仅数据准备步骤(无 LLM,用于调试)
+│   ├── fetch_data.py                   # 独立数据拉取脚本
+│   └── merge_data.py                   # 独立数据合并脚本
+│
+├── 🟢 生产服务
+│   ├── server.py                       # FastAPI + APScheduler 生产服务器
+│   ├── schedule.sh                     # Shell Cron 包装脚本
+│   ├── k8s/                            # Kubernetes 部署清单
+│   │   ├── deployment.yaml             # Deployment 模式(APScheduler)
+│   │   ├── cronjob.yaml                # Flow A CronJob
+│   │   ├── cronjob_creation.yaml       # Flow B CronJob
+│   │   ├── cronjob_creative_review.yaml # Flow C CronJob
+│   │   ├── configmap.yaml / secret.yaml / pvc.yaml
+│   │   └── namespace.yaml / network-policy.yaml
+│   └── .env / .env.example             # 环境变量
+│
+├── 🟡 配置与技能
+│   ├── config.py                       # 核心业务配置(~1023 行)
+│   ├── presets.json                    # 预设参数
+│   ├── strategy_params.json            # 策略参数
+│   ├── whitelist.json                  # 账户白名单(可被 DB 覆盖)
+│   ├── prompts/
+│   │   └── system.prompt               # Agent 系统提示词
+│   └── skills/                         # 领域知识(注入 LLM 上下文)
+│       ├── ad_domain.md                # 裂变模型 / R 值 / ROI 公式
+│       ├── decision_strategy.md        # 决策框架 / 候选标记 / 7 种 action
+│       ├── platform_rules.md           # 腾讯平台硬约束
+│       └── posterior_wisdom.md         # 后验经验积累
+│
+├── 🟣 数据库层
+│   └── db/
+│       ├── __init__.py                 # 公开 API
+│       ├── connection.py               # MySQL 连接(pymysql)
+│       ├── config.py                   # 系统配置 + 白名单 CRUD(5 分钟缓存)
+│       └── schema.sql                  # 12 张表 + 默认种子数据
+│
+├── 🟠 工具层(31 个文件)
+│   └── tools/
+│       ├── _names.py                   # 字段名常量 + 禁止词黑名单
+│       ├── ad_api.py                   # 腾讯广告 API v3.0 封装
+│       ├── odps_module.py              # ODPS 数据源模块
+│       ├── data_query.py               # ODPS 数据拉取 + 合并
+│       │
+│       ├── ── Flow A 核心 ──
+│       ├── roi_calculator.py           # 动态 ROI (7日均值) 计算器
+│       ├── portfolio_metrics.py        # 人群包级汇总(tier P50 等)
+│       ├── creative_roi_calculator.py  # 创意级动态 ROI
+│       ├── ad_decision.py              # 决策引擎(候选标记 + 三级分类)
+│       ├── guardrails.py               # 护栏验证(频率/边界/方向)
+│       ├── execution_engine.py         # 执行引擎(API 调用 + 审计日志)
+│       ├── im_approval.py              # 飞书审批(发送 + 轮询回复)
+│       ├── report_generator.py         # 报告生成(Excel + CSV)
+│       ├── posterior_collector.py      # 后验数据采集
+│       └── feishu_doc.py               # 飞书文档操作
+│       │
+│       ├── ── Flow B 核心 ──
+│       ├── ad_creation.py              # Module A:广告创建
+│       ├── creative_creation.py        # Module B:创意准备
+│       ├── creative_material_usage.py  # 素材使用追踪
+│       ├── creative_metrics.py         # 创意级指标
+│       ├── creative_review.py          # 创意审核结果扫描
+│       ├── material_recall.py          # 素材召回(batchByText)
+│       ├── video_recall.py             # 视频内容列表获取
+│       ├── video_risk.py               # 视频风险标签检查
+│       ├── video_feature_query.py      # 视频元素特征查询
+│       ├── landing_plan.py             # 落地页方案创建(xcx/save)
+│       ├── scene_spec.py               # 场景规格 / 微信版位
+│       ├── audience_grant.py           # 人群包授权验证
+│       ├── im_approval_ad_creation.py  # 广告创建审批
+│       ├── im_approval_creation.py     # 创意创建审批
+│       └── sls_setup.py                # SLS 日志上报
+│
+├── 🔴 辅助脚本
+│   ├── sync_ad_status.py               # 同步已删除广告状态
+│   ├── sync_feishu_account_config.py   # 同步飞书账户配置到 DB
+│   ├── scan_creative_reviews.py        # Flow C 入口
+│   ├── configure_creation_accounts.py  # 批量写入账户三元组配置
+│   ├── quick_analysis.py               # 快速分析(使用已有数据)
+│   ├── regenerate_metrics.py           # 从已有 CSV 重新生成指标
+│   ├── analyze_snapshot.py             # 决策质量分析(9 维度评估)
+│   ├── analyze_dimensions.py           # 维度使用率分析
+│   ├── verify_decision.py              # 决策验证
+│   └── get_chat_id.py                  # 获取飞书群聊 ID
+│
+├── 📁 数据与文档
+│   ├── data/tencent_constants/         # 静态参考数据
+│   ├── shared/reports/                 # 共享报告产物
+│   ├── utils/log_capture.py            # 日志捕获
+│   ├── docs/                           # 分析文档
+│   │   ├── strategy_review_2026-04-16.md  # 专家级策略 Review
+│   │   └── 不走LLM的规则盘点.md            # 绕过 LLM 的规则清单
+│   └── doc/                            # 设计文档
+│
+└── 🧪 测试文件(~25 个)
+    ├── test_e2e_full_flow.py           # 端到端全流程测试
+    ├── test_approval_flow_e2e.py       # 审批流程端到端测试
+    ├── test_real_approval_flow.py      # 真实审批流程测试
+    ├── test_single_ad.py               # 单广告测试
+    ├── test_tencent_api.py             # API 连通性测试
+    └── ...(其余 20+ 个专项测试)
+```
+
+---
+
+## 三、Flow A: 决策调控流程(execute_once.py)
+
+### 3.1 10 步 Pipeline
+
+```
+Step 0: sync_ad_status          ← 同步腾讯侧已删除的广告状态
+    │
+Step 1: fetch_creative_data     ← ODPS 拉创意原始数据 + 广告状态快照
+    │                               输出: outputs/raw/creative_{date}.csv
+    │                                     outputs/ad_status/ad_status_{date}.csv
+Step 2: merge_creative_data     ← 合并创意数据 + 广告状态
+    │                               输出: outputs/merged/
+    │
+Step 3: calculate_roi_metrics   ← 计算动态 ROI (7日均值)
+    │                               输出: outputs/metrics/metrics_{date}.csv
+    │
+Step 3.5: calculate_creative_roi ← 创意级动态 ROI(pause 候选的二次细化依据)
+    │
+Step 4: calculate_portfolio_summary ← 人群包级汇总 (tier P25/P50/P75)
+    │
+Step 5: get_ads_for_review      ← 三级分类 + 5 个候选标记计算
+    │                               ├── 零消耗待关停 (规则)
+    │                               ├── 待优化评估 (LLM)
+    │                               └── 正常运行 (规则)
+    │
+Step 6: [LLM 决策]              ← Claude Sonnet 4.5 综合推理
+    │                              注入 skills/*.md 领域知识
+    │                              输出: 每个广告的 action + reason
+    │
+Step 7: apply_decisions         ← 保存决策 + 合并 metrics 字段
+    │                              输出: outputs/reports/llm_decisions_{date}.csv
+    │
+Step 8: generate_report         ← 生成 Excel + CSV 报告
+    │
+Step 9: send_approval_request   ← 飞书 IM 审批(发送 + 阻塞等待回复)
+    │                              含 Excel 附件 + 统计摘要
+    │
+Step 9.5: check_approval_status ← 轮询审批状态(超时 120 分钟)
+    │
+Step 10: execute_decisions      ← 执行审批通过的决策
+                                   调用 execution_engine → ad_api
+                                   写入 audit JSONL 日志
+```
+
+### 3.2 核心决策架构(三层)
+
+```
+┌─────────────────────────────────────────────────────┐
+│ Layer 1: 规则引擎 (ad_decision.py, Python)           │
+│                                                     │
+│  ① 年龄保护:冷启动 ≤3天 → 排除                    │
+│             早期成长 4-7天 → 仅允许 bid_up          │
+│             成熟期 >7天 → 全面调控                  │
+│                                                     │
+│  ② 零消耗检测:近 N 天消耗=0 → 规则关停             │
+│                                                     │
+│  ③ 5 个候选标记计算:                              │
+│     ├── roi_low          动态ROI < P50 × 0.75      │
+│     ├── bid_down_candidate ROI < P50 × 0.90        │
+│     ├── bid_up_candidate   ROI > P50 × 1.05 (仅冷启)│
+│     ├── decay_signal       裂变率持续下降            │
+│     └── high_burn          消耗异常高 + 低ROI        │
+└─────────────────────────────────────────────────────┘
+        │ 标记完的广告列表 → 喂给 LLM
+        ▼
+┌─────────────────────────────────────────────────────┐
+│ Layer 2: LLM 推理 (Claude Sonnet 4.5)               │
+│                                                     │
+│  注入 skills/*.md 领域知识:                        │
+│  ├── ad_domain.md        裂变模型、R值、ROI公式     │
+│  ├── decision_strategy.md 7 种 action、权衡原则      │
+│  ├── platform_rules.md    oCPM学习期、调价上限       │
+│  └── posterior_wisdom.md  历史经验教训               │
+│                                                     │
+│  输出 7 种 action:                                 │
+│  pause | bid_down | bid_up | scale_up |             │
+│  creative_adjust | observe | hold                   │
+│                                                     │
+│  Reason 规则:纯中文、禁止变量名、解释业务逻辑        │
+└─────────────────────────────────────────────────────┘
+        │ LLM 决策 → 写入 decisions CSV
+        ▼
+┌─────────────────────────────────────────────────────┐
+│ Layer 3: 护栏验证 (guardrails.py, Python)            │
+│                                                     │
+│  ① 方向检查:bid_down 是否合理(ROI 真的低?)       │
+│  ② 频率检查:6h 内不能重复调同一广告                 │
+│  ③ 边界检查:出价必须在 [0.05, 1.00] 元区间          │
+│  ④ 日累计检查:当日累计调幅 ≤ 20%                   │
+│  ⑤ 兜底检查:触发次数应为 0(有则说明规则层有漏)     │
+│  ⑥ DRY_RUN 模式:True → 仅记录不执行                │
+└─────────────────────────────────────────────────────┘
+```
+
+### 3.3 ROI 计算公式
+
+```
+对每一天(日消耗 ≥ 100 元才参与计算):
+  T0裂变系数 = T0裂变数 / 首层打开数
+  arpu       = 总收入 / 总回流人数
+  当日裂变收益率 = T0裂变数 × arpu / cost
+  当日回流倍数   = 总回流人数 / 首层打开数
+
+7日滚动均值(min_periods=3):
+  动态 ROI (7日均值) = 当日裂变收益率 × (回流倍数_7日均值 / T0裂变系数_7日均值)
+```
+
+**决策使用规则**:
+- **广告级决策**:使用单广告的动态 ROI (7日均值)
+- **人群包评估**:使用人群包整体的动态 ROI (7日均值) 均值
+- **关停阈值**:动态 ROI < 渠道 P50 × 0.75 → 触发 roi_low 标记
+
+---
+
+## 四、Flow B: 创建投放流程(execute_creation_once.py)
+
+### 4.1 多阶段 Pipeline
+
+```
+Phase -1: sync_feishu_account_config   ← 飞书配置 → DB (account + template)
+    │
+Phase 0 (Module A): 创建广告
+    │  ① enumerate_new_ad_candidates   按账户枚举候选
+    │     ├── 指纹去重:audience × 素材 组合
+    │     ├── 按 tier ROI 排序
+    │     └── 目标:每账户 ADS_PER_ACCOUNT=2 个在投广告
+    │  ② 飞书审批广告创建方案
+    │  ③ POST /adgroups/add
+    │     固定参数:marketing_goal=USER_GROWTH, bid_mode=OCPM
+    │               targeting: age 45-66, 特定区域, 6:00-23:00 投放
+    │               day_amount: 200 CNY
+    │
+Phase 1 (Module B): 准备创意
+    │  ① find_ads_needing_creatives   找缺少创意的广告
+    │     目标:每广告 TARGET_CREATIVES_PER_AD=4 个创意
+    │  ② 对每个广告:
+    │     ├── video_recall          拉取视频内容列表
+    │     ├── video_risk            风险标签检查
+    │     ├── material_recall       batchByText 召回(sim ≥ 0.8)
+    │     ├── material 排序         按消耗降序
+    │     ├── 7 日去重              同素材同人群包不重复
+    │     └── landing_plan          xcx/save 落地页方案
+    │  ③ 输出: creation_pending_{date}.json
+    │
+Phase 2: 飞书审批
+    │  发送创意候选列表 → 等待运营审批
+    │  config: CREATION_APPROVAL_REQUIRED=True
+    │
+Phase 3: 提交创意 (execute_creation_apply.py)
+       ① 读取已审批的 pending records
+       ② POST /dynamic_creatives/add
+       ③ 写入 creative_creation_task 表
+       ④ 飞书通知执行结果
+```
+
+### 4.2 关键配置
+
+| 参数 | 值 | 说明 |
+|------|-----|------|
+| `ADS_PER_ACCOUNT` | 2 | 每账户目标在投广告数 |
+| `TARGET_CREATIVES_PER_AD` | 4 | 每广告目标创意数 |
+| `CREATION_APPROVAL_REQUIRED` | True | 创意是否需要审批 |
+| `RECALL_SIM_THRESHOLD` | 0.8 | 素材召回相似度阈值 |
+| `RECALL_DAYS` | 180 | 素材召回时间窗口 |
+| `MAX_LANDING_ATTEMPTS_PER_AD` | 100 | 每广告落地页创建最大尝试次数 |
+| `VIDEO_RISK_MAX_ALLOWED_LEVEL` | 5 | 视频风险等级上限 |
+
+---
+
+## 五、Flow C: 审核扫描(scan_creative_reviews.py)
+
+```
+每 2 小时执行一次 (k8s CronJob):
+  creative_review.scan_pending_reviews()
+    ├── 读 DB: creative_creation_task (review_status=pending)
+    ├── 调 API: GET /dynamic_creatives/get (查审核结果)
+    ├── 写 DB: creative_review_result (审核结果)
+    └── 写 DB: creative_rejection_fact (拒审事实,供后续召回排除)
+```
+
+---
+
+## 六、入口点全景
+
+| 入口 | 类型 | 用途 | 谁来调用 |
+|------|------|------|---------|
+| `run.py` | 交互 REPL | 开发调试、人工分析 | 开发者手动 |
+| `execute_once.py` | 自动化脚本 | Flow A 决策调控 | K8s CronJob / server.py |
+| `execute_creation_once.py` | 自动化脚本 | Flow B 创建投放 | K8s CronJob |
+| `execute_creation_apply.py` | 可独立执行 | Flow B Phase 3 独立提交 | 手动或 import |
+| `run_full_analysis.py` | 调试工具 | 仅数据准备(无 LLM) | 开发者手动 |
+| `fetch_data.py` | 独立工具 | 仅拉取 ODPS 数据 | 开发者手动 |
+| `merge_data.py` | 独立工具 | 仅合并数据 | 开发者手动 |
+| `server.py` | 常驻服务 | 生产 APScheduler 调度 | K8s Deployment |
+| `scan_creative_reviews.py` | 自动化脚本 | Flow C 审核扫描 | K8s CronJob(每 2h) |
+| `sync_ad_status.py` | 辅助脚本 | 同步广告状态 | execute_once.py Step 0 |
+| `sync_feishu_account_config.py` | 辅助脚本 | 同步飞书账户配置 | execute_creation_once.py Phase -1 |
+| `configure_creation_accounts.py` | 运维工具 | 批量写入账户配置 | 运维手动 |
+
+---
+
+## 七、部署架构
+
+### 7.1 两种部署模式
+
+| 模式 | 文件 | 调度方式 | 优点 |
+|------|------|---------|------|
+| **Deployment + APScheduler** | `k8s/deployment.yaml` + `server.py` | APScheduler 读取 DB `cron_schedule` | HTTP 健康检查、手动触发 API、单 Pod |
+| **Kubernetes CronJob** | `k8s/cronjob*.yaml` | K8s CronJob | 简单、K8s 原生重试 |
+
+### 7.2 时间线
+
+```
+UTC 02:00 (北京时间 10:00)  →  Flow A: 决策调控
+UTC 02:30 (北京时间 10:30)  →  Flow B: 创建投放(与 A 间隔 30 分钟避免 API 争用)
+每 2 小时                    →  Flow C: 创意审核扫描
+```
+
+### 7.3 动态配置(DB 驱动)
+
+| 配置项 | DB 表 | 默认值 | 说明 |
+|--------|-------|--------|------|
+| `cron_schedule` | `system_config` | `0 2 * * *` | 调度表达式 |
+| `execution_enabled` | `system_config` | `false` | 是否启用实际执行 |
+| `run_on_startup` | `system_config` | `false` | 启动时是否执行一次 |
+| `whitelist_enabled` | `system_config` | `true` | 白名单机制开关 |
+| `roi_low_factor` | `system_config` | `0.75` | 关停线系数 |
+
+优先级:DB > 环境变量 > 代码默认值
+
+---
+
+## 八、数据库表(12 张)
+
+| 表名 | 用途 | Flow |
+|------|------|------|
+| `account_whitelist` | 账户白名单 | A/B |
+| `ad_delivery_template` | 投放模板(出价/定向/预算) | B |
+| `ad_creation_account_config` | 账户 × 人群包 × 出价配置 | B |
+| `brand_asset_template` | 品牌资产模板 | B |
+| `feedback_asset_template` | 反馈资产模板 | B |
+| `system_config` | 系统配置(KV) | A/B |
+| `decision_history` | 决策历史 | A |
+| `creative_creation_task` | 创意创建任务 | B/C |
+| `creative_review_result` | 创意审核结果 | C |
+| `creative_rejection_fact` | 创意拒审事实 | C |
+| `video_element_feature_cache` | 视频特征缓存 | B |
+| `creative_material_usage` | 素材使用记录(7 日去重) | B |
+
+---
+
+## 九、与 auto_put_ad 的关系
+
+```
+auto_put_ad (完整体系)              auto_put_ad_mini (当前生产)
+┌──────────────────────┐           ┌──────────────────────────┐
+│ 受众策略 Agent        │           │                          │
+│ 创意策略 Agent        │           │ Flow A: 决策调控          │
+│ 预算策略 Agent ───────┼──出价──▶  │   (规则→LLM→护栏→执行)    │
+│ 监控调控 Agent ───────┼──决策──▶  │                          │
+│ 数据分析 Agent        │           │ Flow B: 创建投放          │
+│ 系统运维 Agent        │           │   (广告+创意+审批+提交)   │
+│ 自学习/反馈环         │           │                          │
+└──────────────────────┘           │ Flow C: 审核扫描          │
+                                   └──────────────────────────┘
+```
+
+- `auto_put_ad_mini` 最初是 `auto_put_ad` 中「监控调控 Agent」的独立落地版
+- 实际演进中,mini 发展出了更完整的决策三层架构和独立的创建子系统
+- 两者共享 `tools/ad_api.py` 的腾讯广告 API 封装思路和 `skills/` 领域知识
+- mini 的 `ad_decision.py` 三层架构可作为大项目后续迭代的参考
+
+---
+
+## 十、关键设计原则
+
+1. **三层决策架构**: 规则标记 → LLM 推理 → 护栏兜底,各层职责清晰,LLM 只做"综合判断"不做"规则计算"
+2. **两条业务线解耦**: 决策调控和创建投放使用独立的入口/CronJob/审批流,避免互相阻塞
+3. **DB 驱动配置**: 关键开关(执行启用/白名单/调度表达式)在 DB 中,无需重启即可调整
+4. **人工审批嵌入**: 执行前必须通过飞书 IM 审批(可配置关闭),含 Excel 附件 + 统计摘要
+5. **7 日去重保护**: 素材使用记录保留 7 天,防止同一素材重复提交审核
+6. **年龄分层保护**: ≤3 天冷启动零干预、4-7 天仅允许提价、>7 天全面调控
+7. **提降分离**: 提价 5%-10%、降价 3%-5%,降价更保守(因为 oCPM 模式下降价效果非线放大)

+ 13 - 0
examples/auto_put_ad_mini/analyze_snapshot.py

@@ -37,6 +37,7 @@ HERE = Path(__file__).resolve().parent
 # ═══════════════════════════════════════════
 
 def _num_stats(values: List[Any]) -> Optional[Dict[str, float]]:
+    """计算数值列表的统计量(count/mean/min/max/p25/p50/p75),无有效数值时返回 None"""
     nums = [float(v) for v in values
             if v is not None and not (isinstance(v, float) and math.isnan(v))]
     if not nums:
@@ -55,12 +56,14 @@ def _num_stats(values: List[Any]) -> Optional[Dict[str, float]]:
 
 
 def _mean(values: List[Any]) -> Optional[float]:
+    """计算数值列表的均值(忽略 None/NaN),无有效数值时返回 None"""
     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]:
+    """计算数值列表中大于 0 的占比(忽略 None/NaN),无有效数值时返回 None"""
     nums = [v for v in values
             if v is not None and not (isinstance(v, float) and math.isnan(v))]
     if not nums:
@@ -69,6 +72,7 @@ def _nonzero_rate(values: List[Any]) -> Optional[float]:
 
 
 def load_snapshot(date: str) -> Dict[str, Any]:
+    """加载指定日期的决策快照 snapshot.json,不存在时抛出 FileNotFoundError"""
     snap_path = HERE / "outputs" / "decisions_history" / date / "snapshot.json"
     if not snap_path.exists():
         raise FileNotFoundError(f"快照不存在: {snap_path}")
@@ -80,6 +84,7 @@ def load_snapshot(date: str) -> Dict[str, Any]:
 # ═══════════════════════════════════════════
 
 def action_distribution(decisions: List[Dict]) -> Dict[str, Any]:
+    """统计决策的 action / source 分布及 LLM 决策占比"""
     total = len(decisions)
     action_counter: Counter = Counter()
     source_counter: Counter = Counter()
@@ -99,6 +104,7 @@ def action_distribution(decisions: List[Dict]) -> Dict[str, Any]:
 
 
 def bid_down_diversity(decisions: List[Dict]) -> Dict[str, Any]:
+    """分析 bid_down 决策的多样性:dimension 种类、scenario 分布及推荐幅度统计"""
     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(
@@ -117,6 +123,7 @@ def bid_down_diversity(decisions: List[Dict]) -> Dict[str, Any]:
 
 
 def rule_alignment_dist(decisions: List[Dict]) -> Dict[str, Any]:
+    """统计 rule_alignment 分布与 override 率(跳过 auto_pause),附 override 原因样本"""
     align_counter: Counter = Counter()
     override_reasons: List[str] = []
     for d in decisions:
@@ -137,6 +144,7 @@ def rule_alignment_dist(decisions: List[Dict]) -> Dict[str, Any]:
 
 
 def reasoning_chain_quality(decisions: List[Dict]) -> Dict[str, Any]:
+    """评估 LLM 决策的 reasoning_chain 质量:覆盖率、长度统计与高频引用信号"""
     lengths: List[int] = []
     signal_counter: Counter = Counter()
     has_chain = 0
@@ -163,6 +171,7 @@ def reasoning_chain_quality(decisions: List[Dict]) -> Dict[str, Any]:
 
 
 def tier_breakdown(decisions: List[Dict]) -> Dict[str, Any]:
+    """按 audience_tier 分组统计决策数、action 分布及各信号分均值"""
     tier_groups: Dict[str, List[Dict]] = {}
     for d in decisions:
         tier = (d.get("input_signals") or {}).get("audience_tier", "default")
@@ -182,6 +191,7 @@ def tier_breakdown(decisions: List[Dict]) -> Dict[str, Any]:
 
 
 def signal_health(decisions: List[Dict]) -> Dict[str, Any]:
+    """统计 decay/bid_down/bid_up 三类信号分的分布与非零率,评估信号健康度"""
     decay, bd, bu = [], [], []
     for d in decisions:
         ss = d.get("signal_scores") or {}
@@ -287,6 +297,7 @@ def context_size_est(decisions: List[Dict]) -> Dict[str, Any]:
 # ═══════════════════════════════════════════
 
 def build_report(snapshot: Dict) -> Dict:
+    """汇总各分析模块,构建完整的结构化质量报告"""
     decisions = snapshot.get("decisions", [])
     return {
         "metadata": snapshot.get("metadata", {}),
@@ -355,6 +366,7 @@ def assess(report: Dict) -> Tuple[str, List[str]]:
 # ═══════════════════════════════════════════
 
 def print_report(report: Dict, assessment: Tuple[str, List[str]]) -> None:
+    """将质量报告与判定结果格式化输出到 stdout"""
     meta = report["metadata"]
     print("=" * 70)
     print("V4 决策快照分析报告")
@@ -459,6 +471,7 @@ def print_report(report: Dict, assessment: Tuple[str, List[str]]) -> None:
 # ═══════════════════════════════════════════
 
 def main() -> None:
+    """脚本入口:加载快照、生成质量报告、打印并落盘 quality_report.json"""
     parser = argparse.ArgumentParser(description="V4 决策快照分析")
     parser.add_argument("--date", required=True, help="决策日期 YYYYMMDD")
     args = parser.parse_args()

+ 10 - 0
examples/auto_put_ad_mini/configure_creation_accounts.py

@@ -44,6 +44,7 @@ class AccountConfigInput:
 
 
 def _bid_to_fen(raw: str) -> int:
+    """将出价文本(元,可带"元"后缀)转换为分(int),四舍五入到分"""
     text = str(raw).strip().replace("元", "")
     if not text:
         raise ValueError("bid 为空")
@@ -52,6 +53,10 @@ def _bid_to_fen(raw: str) -> int:
 
 
 def parse_bid(raw: str) -> tuple[int, int, int | None]:
+    """解析出价文本,返回 (bid_min_fen, bid_max_fen, bid_amount_fen)。
+
+    支持固定值 "0.35" 或范围 "0.28-0.31";范围模式 bid_amount 为 None。
+    """
     text = str(raw).strip()
     if "-" in text:
         left, right = text.split("-", 1)
@@ -65,6 +70,7 @@ def parse_bid(raw: str) -> tuple[int, int, int | None]:
 
 
 def parse_budget_fen(raw: object) -> int | None:
+    """解析预算文本(元)为分;空/不限制/不限/- 返回 None(使用模板默认预算)"""
     text = str(raw or "").strip()
     if not text or text in {"不限制", "不限", "-"}:
         return None
@@ -73,6 +79,7 @@ def parse_budget_fen(raw: object) -> int | None:
 
 
 def _read_inputs(args: argparse.Namespace) -> list[AccountConfigInput]:
+    """从 CSV 文件或命令行参数读取并校验账户配置输入"""
     rows: list[AccountConfigInput] = []
     if args.csv:
         with open(args.csv, newline="", encoding="utf-8-sig") as f:
@@ -109,6 +116,7 @@ def _read_inputs(args: argparse.Namespace) -> list[AccountConfigInput]:
 
 
 def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> None:
+    """将账户配置写入 DB(account_whitelist + ad_creation_account_config),存在则更新"""
     from db.connection import get_connection
 
     source_account_id = None if row.audience_name == "泛人群" else DEFAULT_SOURCE_ACCOUNT_ID
@@ -252,6 +260,7 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
 
 
 def set_account_automation_enabled(account_id: int, enabled: bool, dry_run: bool = False) -> None:
+    """启用/禁用账户的自动化投放开关(更新 ad_creation_account_config.enabled)"""
     if dry_run:
         print(f"[dry-run] set account={account_id} enabled={enabled}")
         return
@@ -276,6 +285,7 @@ def set_account_automation_enabled(account_id: int, enabled: bool, dry_run: bool
 
 
 def main() -> int:
+    """命令行入口:读取输入(CSV 或单账户参数)并逐条写入账户配置"""
     parser = argparse.ArgumentParser()
     parser.add_argument("--account-id", type=int)
     parser.add_argument("--audience-name")

+ 1 - 0
examples/auto_put_ad_mini/execute_creation_apply.py

@@ -267,6 +267,7 @@ def _strip_body_for_json(records: list[dict]) -> list[dict]:
 
 
 def write_summary(summary: dict, output_dir: Path) -> Path:
+    """把 Phase 3 执行 summary 落盘为 creation_run_{date}_{ts}.json(剥离长 body 字段),返回文件路径。"""
     output_dir.mkdir(parents=True, exist_ok=True)
     now = now_in_timezone()
     date_str = now.strftime("%Y%m%d")

+ 2 - 0
examples/auto_put_ad_mini/execute_creation_once.py

@@ -76,6 +76,7 @@ logger = logging.getLogger("execute_creation_once")
 
 
 def _setup_logging() -> None:
+    """配置日志格式与级别,挂载 SLS 上报(配置缺失时自动降级)。"""
     logging.basicConfig(
         level=logging.INFO,
         format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
@@ -601,6 +602,7 @@ def run_once() -> dict:
 
 
 def main() -> int:
+    """模块 B 创意搭建子系统主入口:初始化日志 → 运行主循环 → 输出汇总。"""
     _setup_logging()
     logger.info("=" * 60)
     logger.info("模块 B 创意搭建子系统 — 主循环启动")

+ 1 - 0
examples/auto_put_ad_mini/execute_once.py

@@ -49,6 +49,7 @@ except ImportError:
 
 
 async def main():
+    """广告智能调控主流程:数据拉取 → ROI计算 → 候选筛选 → AI推理 → 护栏验证 → 报告生成。"""
     base_dir = Path(__file__).parent
 
     setup_logging(level=LOG_LEVEL, file=LOG_FILE)

+ 1 - 0
examples/auto_put_ad_mini/get_chat_id.py

@@ -47,6 +47,7 @@ def on_message_received(event):
 
 
 def main():
+    """启动飞书 WebSocket 监听,等待群聊消息以获取 chat_id"""
     print("=" * 60)
     print("飞书群聊 ID 获取工具")
     print("=" * 60)

+ 1 - 0
examples/auto_put_ad_mini/quick_analysis.py

@@ -21,6 +21,7 @@ from config import OUTPUTS_DIR
 
 
 async def main():
+    """快速分析入口:检查已有 metrics 数据完整性,调用决策引擎并展示分类结果"""
     print("=" * 70)
     print("  快速分析 — 使用20260415已有数据")
     print("=" * 70)

+ 1 - 0
examples/auto_put_ad_mini/regenerate_metrics.py

@@ -17,6 +17,7 @@ from examples.auto_put_ad_mini.tools.portfolio_metrics import calculate_portfoli
 class SimpleContext:
     """简单的上下文模拟"""
     def __init__(self):
+        """初始化空配置字典。"""
         self.config = {}
 
 async def regenerate_data(end_date='20260415'):

+ 3 - 0
examples/auto_put_ad_mini/run.py

@@ -80,6 +80,7 @@ async def init_project_env(messages=None):
 
 
 def _load_system_prompt(base_dir: Path) -> str:
+    """读取 prompts/system.prompt 系统提示词,不存在则返回空字符串"""
     prompt_path = base_dir / "prompts" / "system.prompt"
     if prompt_path.exists():
         return prompt_path.read_text(encoding="utf-8")
@@ -87,6 +88,7 @@ def _load_system_prompt(base_dir: Path) -> str:
 
 
 def _load_presets(base_dir: Path):
+    """加载 presets.json 预设参数(若存在)"""
     presets_path = base_dir / "presets.json"
     if presets_path.exists():
         from agent.core.presets import load_presets_from_json
@@ -94,6 +96,7 @@ def _load_presets(base_dir: Path):
 
 
 async def main():
+    """交互式主入口:初始化 Agent 后循环读取用户指令并流式输出执行过程"""
     base_dir = Path(__file__).parent
 
     setup_logging(level=LOG_LEVEL, file=LOG_FILE)

+ 2 - 0
examples/auto_put_ad_mini/scan_creative_reviews.py

@@ -27,6 +27,7 @@ logger = logging.getLogger("scan_creative_reviews")
 
 
 def _setup_logging() -> None:
+    """初始化日志配置:设置格式、降噪第三方 logger,并尝试挂载 SLS 上报。"""
     logging.basicConfig(
         level=os.getenv("LOG_LEVEL", "INFO"),
         format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
@@ -43,6 +44,7 @@ def _setup_logging() -> None:
 
 
 def main() -> int:
+    """扫描待审核创意的腾讯官方审核结果并回写 DB,返回退出码(0=成功,1=有错误)。"""
     _setup_logging()
     lookback_hours = int(os.getenv("CREATIVE_REVIEW_LOOKBACK_HOURS", "72"))
     limit = int(os.getenv("CREATIVE_REVIEW_SCAN_LIMIT", "1000"))

+ 5 - 0
examples/auto_put_ad_mini/sync_ad_status.py

@@ -43,6 +43,7 @@ logger = logging.getLogger("sync_ad_status")
 
 
 def _setup_logging() -> None:
+    """初始化日志配置:INFO 级别输出到 stdout。"""
     logging.basicConfig(
         level=logging.INFO,
         format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
@@ -87,6 +88,10 @@ def _fetch_api_ad_ids(account_id: int, page_size: int) -> Set[int]:
 
 
 def main() -> int:
+    """对比本地 ad_status CSV 与腾讯 API 广告清单,标记被删除广告并回写 CSV。
+
+    返回退出码:0=成功(含部分账号成功),2=全部账号失败或 CSV 缺失。
+    """
     _setup_logging()
 
     parser = argparse.ArgumentParser(description="同步腾讯广告删除状态到本地 ad_status CSV")

+ 7 - 0
examples/auto_put_ad_mini/sync_feishu_account_config.py

@@ -61,6 +61,7 @@ REQUIRED_COLUMNS = {
 
 
 def _plain_value(value: Any) -> str:
+    """将飞书单元格值(可能是富文本 list/dict)转为纯文本字符串"""
     if value is None:
         return ""
     if isinstance(value, list):
@@ -75,6 +76,7 @@ def _plain_value(value: Any) -> str:
 
 
 def _fetch_rows(spreadsheet_token: str, sheet_id: str, cell_range: str) -> list[list[Any]]:
+    """读取飞书表格指定范围的原始行数据"""
     token = _get_tenant_token()
     url = (
         f"{FEISHU_BASE_URL}/sheets/v2/spreadsheets/"
@@ -89,6 +91,7 @@ def _fetch_rows(spreadsheet_token: str, sheet_id: str, cell_range: str) -> list[
 
 
 def _header_map(rows: list[list[Any]]) -> dict[str, int]:
+    """解析表头行,返回 列名→列索引 映射;缺少必需列时抛异常"""
     if len(rows) <= HEADER_ROW_INDEX:
         raise RuntimeError("飞书配置表缺少表头行")
     headers = [_plain_value(v) for v in rows[HEADER_ROW_INDEX]]
@@ -100,10 +103,12 @@ def _header_map(rows: list[list[Any]]) -> dict[str, int]:
 
 
 def _cell(row: list[Any], idx: int) -> str:
+    """安全取行内指定列的纯文本值,越界返回空字符串"""
     return _plain_value(row[idx]) if idx < len(row) else ""
 
 
 def _parse_account_id(raw: str) -> int | None:
+    """解析账户 ID 文本为 int;空值返回 None,非法值抛 ValueError"""
     text = raw.strip()
     if not text:
         return None
@@ -121,6 +126,7 @@ def sync_from_feishu(
     dry_run: bool = False,
     strict: bool = True,
 ) -> dict[str, int]:
+    """从飞书「自动化账户」表同步账户配置到 DB,返回统计信息(enabled/disabled/skipped/errors)"""
     rows = _fetch_rows(spreadsheet_token, sheet_id, cell_range)
     mapping = _header_map(rows)
 
@@ -173,6 +179,7 @@ def sync_from_feishu(
 
 
 def main() -> int:
+    """命令行入口:解析参数并执行同步,存在错误行且未放行时返回 1"""
     parser = argparse.ArgumentParser()
     parser.add_argument("--spreadsheet-token", default=DEFAULT_SPREADSHEET_TOKEN)
     parser.add_argument("--sheet-id", default=DEFAULT_SHEET_ID)

+ 7 - 0
examples/auto_put_ad_mini/tools/audience_grant.py

@@ -27,6 +27,7 @@ AUDIENCE_NAME_ALIASES = {
 
 
 def _audience_lookup_names(audience_name: str) -> list[str]:
+    """返回人群包名称及其别名列表(用于兼容业务配置名与腾讯侧真实命名)。"""
     names = [audience_name]
     for alias in AUDIENCE_NAME_ALIASES.get(audience_name, []):
         if alias and alias not in names:
@@ -35,6 +36,7 @@ def _audience_lookup_names(audience_name: str) -> list[str]:
 
 
 def _fetch_custom_audiences(account_id: int) -> list[dict]:
+    """分页拉取指定账户下的全部自定义人群包列表。"""
     out: list[dict] = []
     page = 1
     while True:
@@ -53,6 +55,7 @@ def _fetch_custom_audiences(account_id: int) -> list[dict]:
 
 
 def _is_audience_visible(account_id: int, audience_id: int) -> bool:
+    """判断指定人群包在目标账户是否可见。"""
     return any(
         int(item.get("audience_id") or 0) == int(audience_id)
         for item in _fetch_custom_audiences(account_id)
@@ -60,6 +63,7 @@ def _is_audience_visible(account_id: int, audience_id: int) -> bool:
 
 
 def _pick_source_audience(source_account_id: int, audience_name: str) -> dict:
+    """在源账户中按名称匹配度挑选可用(SUCCESS + ONLINE)的人群包,找不到则抛错。"""
     items = _fetch_custom_audiences(source_account_id)
     usable = [
         item for item in items
@@ -103,6 +107,7 @@ def _pick_source_audience(source_account_id: int, audience_name: str) -> dict:
 
 
 def _load_account_config(account_id: int) -> Optional[dict]:
+    """从 ad_creation_account_config 表加载账户的人群包配置(仅 enabled=TRUE)。"""
     from db.connection import get_connection
 
     conn = get_connection()
@@ -130,6 +135,7 @@ def _write_audience_state(
     status: str,
     verified: bool = False,
 ) -> None:
+    """将人群包解析/授权状态回写到 ad_creation_account_config 和 account_whitelist 表。"""
     from db.connection import get_connection
 
     verified_sql = "audience_verified_at=NOW()," if verified else ""
@@ -170,6 +176,7 @@ def _grant_audience_to_account(
     audience_id: int,
     grant_business_id: int,
 ) -> None:
+    """调用 audience_grant_relations/add 将源账户人群包授权给目标账户,失败抛错。"""
     body = {
         "account_id": source_account_id,
         "audience_id_list": [int(audience_id)],

+ 1 - 0
examples/auto_put_ad_mini/tools/creative_creation.py

@@ -57,6 +57,7 @@ logger = logging.getLogger(__name__)
 
 
 def _landing_category_values(v: LandingVideo) -> set[str]:
+    """解析承接视频的内容品类,返回去重后的品类标签集合。"""
     raw = v.category or ""
     return {part.strip() for part in raw.replace(",", ",").split(",") if part.strip()}
 

+ 2 - 0
examples/auto_put_ad_mini/tools/creative_material_usage.py

@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS creative_material_usage (
 
 
 def ensure_usage_table() -> None:
+    """确保 creative_material_usage 表存在(不存在则创建)。"""
     from db.connection import get_connection
 
     conn = get_connection()
@@ -193,6 +194,7 @@ def update_material_usage_status(
 
 
 def merge_used_material_ids(*sets: Iterable[str]) -> set[str]:
+    """合并多个素材 ID 集合,统一转字符串并去重(过滤空值)。"""
     out: set[str] = set()
     for values in sets:
         out.update(str(v) for v in values if str(v))

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

@@ -134,6 +134,7 @@ def _aggregate_creative_metrics(df: pd.DataFrame, days: int) -> pd.DataFrame:
         result["creative_name"] = ""
 
     def _age_days(row):
+        """计算创意年龄(首次出现日期到今天的天数),无数据或解析失败返回 None。"""
         first = creative_first_date.get((row["ad_id"], row["creative_id"]))
         if not first:
             return None

+ 12 - 0
examples/auto_put_ad_mini/tools/creative_review.py

@@ -104,6 +104,7 @@ class ParsedReviewResult:
 
 
 def _dedupe(values: Iterable[str]) -> list[str]:
+    """字符串列表去重,保留首次出现顺序,跳过空字符串。"""
     out: list[str] = []
     seen: set[str] = set()
     for raw in values:
@@ -116,21 +117,25 @@ def _dedupe(values: Iterable[str]) -> list[str]:
 
 
 def _status_is_rejected(status: str) -> bool:
+    """判断审核状态是否为拒绝(REJECT 或 DENIED)。"""
     upper = (status or "").upper()
     return "REJECT" in upper or "DENIED" in upper
 
 
 def _status_is_pending(status: str) -> bool:
+    """判断审核状态是否为待审(PENDING 或 REVIEWING)。"""
     upper = (status or "").upper()
     return "PENDING" in upper or "REVIEWING" in upper
 
 
 def _status_is_approved(status: str) -> bool:
+    """判断审核状态是否为通过(PASS 或 APPROVED 或 NORMAL)。"""
     upper = (status or "").upper()
     return "PASS" in upper or "APPROVED" in upper or "NORMAL" in upper
 
 
 def _component_type(detail: dict) -> str:
+    """从审核明细中提取组件类型(component_type)。"""
     component = detail.get("component_info") or {}
     if isinstance(component, dict):
         return str(component.get("component_type") or "")
@@ -138,6 +143,7 @@ def _component_type(detail: dict) -> str:
 
 
 def _collect_element_facts(result: dict) -> tuple[list[str], list[RejectionFact], bool, bool]:
+    """从审核结果中提取元素级拒绝事实,返回 (拒绝消息列表, 拒绝事实列表, 是否有待审, 是否有通过)。"""
     messages: list[str] = []
     facts: list[RejectionFact] = []
     has_pending = False
@@ -177,6 +183,7 @@ def _collect_element_facts(result: dict) -> tuple[list[str], list[RejectionFact]
 
 
 def _collect_site_facts(result: dict) -> tuple[list[str], list[RejectionFact], bool, bool]:
+    """从审核结果中提取版位级拒绝事实,返回 (拒绝消息列表, 拒绝事实列表, 是否有待审, 是否有通过)。"""
     messages: list[str] = []
     facts: list[RejectionFact] = []
     has_pending = False
@@ -218,6 +225,7 @@ def _collect_site_facts(result: dict) -> tuple[list[str], list[RejectionFact], b
 
 
 def _collect_compose_facts(result: dict) -> tuple[list[str], list[RejectionFact]]:
+    """从审核结果中提取组件组合级拒绝事实,返回 (拒绝消息列表, 拒绝事实列表)。"""
     messages: list[str] = []
     facts: list[RejectionFact] = []
     for item in result.get("reject_component_compose_info_list") or []:
@@ -310,6 +318,7 @@ def group_review_tasks(
 
 
 def ensure_review_tables() -> None:
+    """确保审核相关的数据库表(creative_creation_task / creative_review_result / creative_rejection_fact)已创建。"""
     from db.connection import get_connection
 
     conn = get_connection()
@@ -400,6 +409,7 @@ VALUES (%s, %s, 0, %s, %s, %s, %s, 'submit_failed', 'failed', %s, %s)
 
 
 def load_pending_review_tasks(lookback_hours: int = 72, limit: int = 1000) -> list[dict]:
+    """从数据库中加载待审核的创意任务列表(最近 N 小时内提交且状态为 submitted/pending/unknown)。"""
     ensure_review_tables()
     from db.connection import get_connection
 
@@ -427,6 +437,7 @@ def fetch_dynamic_creative_review_results(
     account_id: int,
     creative_ids: list[int],
 ) -> list[dict]:
+    """调腾讯 API 批量查询动态创意的审核结果。"""
     if not creative_ids:
         return []
     from tools.ad_api import _check, _get
@@ -443,6 +454,7 @@ def fetch_dynamic_creative_review_results(
 
 
 def save_review_result(account_id: int, raw_result: dict) -> ParsedReviewResult:
+    """解析并持久化一条创意的审核结果,同步更新 creation_task 状态与 rejection_fact 明细。"""
     ensure_review_tables()
     parsed = parse_review_result(raw_result)
     if parsed.dynamic_creative_id is None:

+ 1 - 0
examples/auto_put_ad_mini/tools/creative_roi_calculator.py

@@ -209,6 +209,7 @@ def _build_creative_summary(
     )
 
     def _age(row):
+        """计算创意年龄(首次出现日期到 end_date 的天数),解析失败返回 None。"""
         try:
             first_dt = datetime.strptime(str(row["first_date"]), "%Y%m%d")
             return max((end_dt - first_dt).days, 0)

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

@@ -250,12 +250,14 @@ class AuditLogger:
         self._path = self._log_dir / f"exec_{today}.jsonl"
 
     def log(self, entry: Dict):
+        """追加一条审计记录(自动补时间戳,JSONL 格式写入)。"""
         entry["ts"] = datetime.now().isoformat()
         with open(self._path, "a", encoding="utf-8") as f:
             f.write(json.dumps(entry, ensure_ascii=False) + "\n")
 
     @property
     def path(self) -> Path:
+        """当前审计日志文件路径。"""
         return self._path
 
 

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

@@ -89,6 +89,7 @@ def _get_tenant_token() -> str:
 
 
 def _auth_headers(token: str) -> Dict[str, str]:
+    """构造飞书 API 请求的认证头。"""
     return {"Authorization": f"Bearer {token}"}
 
 

+ 17 - 0
examples/auto_put_ad_mini/tools/guardrails.py

@@ -82,6 +82,7 @@ class AdjustmentHistory:
         self._load()
 
     def _load(self):
+        """从 JSON 文件加载调整历史,失败时使用空记录。"""
         if self._path.exists():
             try:
                 self._data = json.loads(self._path.read_text(encoding="utf-8"))
@@ -90,6 +91,7 @@ class AdjustmentHistory:
                 self._data = {}
 
     def _save(self):
+        """将调整历史持久化到 JSON 文件。"""
         self._path.parent.mkdir(parents=True, exist_ok=True)
         self._path.write_text(
             json.dumps(self._data, ensure_ascii=False, indent=2),
@@ -181,6 +183,7 @@ class CreativePauseHistory:
         self._load()
 
     def _load(self):
+        """从 JSON 文件加载创意 pause 历史,失败时使用空记录。"""
         if self._path.exists():
             try:
                 self._data = json.loads(self._path.read_text(encoding="utf-8"))
@@ -189,6 +192,7 @@ class CreativePauseHistory:
                 self._data = {}
 
     def _save(self):
+        """将创意 pause 历史持久化到 JSON 文件。"""
         self._path.parent.mkdir(parents=True, exist_ok=True)
         self._path.write_text(
             json.dumps(self._data, ensure_ascii=False, indent=2),
@@ -197,9 +201,11 @@ class CreativePauseHistory:
 
     @staticmethod
     def _key(ad_id, creative_id) -> str:
+        """构造 'ad_id:creative_id' 形式的记录键。"""
         return f"{ad_id}:{creative_id}"
 
     def get_last_pause_ts(self, ad_id, creative_id) -> Optional[datetime]:
+        """获取某创意最近一次 pause 的时间,无记录返回 None。"""
         record = self._data.get(self._key(ad_id, creative_id))
         if not record:
             return None
@@ -212,12 +218,14 @@ class CreativePauseHistory:
         return None
 
     def was_paused_within(self, ad_id, creative_id, days: int) -> bool:
+        """判断某创意在最近 days 天内是否被 pause 过(用于速率限制)。"""
         last = self.get_last_pause_ts(ad_id, creative_id)
         if last is None:
             return False
         return (datetime.now() - last) < timedelta(days=days)
 
     def record_pause(self, ad_id, creative_id, action: str = "pause"):
+        """记录一次创意级 pause 事件(仅保留最近 30 天历史)。"""
         key = self._key(ad_id, creative_id)
         now = datetime.now().isoformat()
         if key not in self._data:
@@ -259,6 +267,7 @@ class Guardrail(ABC):
     @property
     @abstractmethod
     def name(self) -> str:
+        """护栏名称(用于日志和 guardrail_reason 前缀)。"""
         pass
 
     @abstractmethod
@@ -321,6 +330,7 @@ class ActionConsistencyGuardrail(Guardrail):
             return None
 
     def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        """检查 action/pct/裂变信号是否自洽,冲突时改写为 hold/observe。"""
         action = str(row.get("action", "hold")).strip()
 
         # pct 标准化(None/NaN/非数字 → 0)
@@ -394,6 +404,7 @@ class ColdStartGuardrail(Guardrail):
         return "冷启动保护"
 
     def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        """按广告年龄分层拦截:冷启动期禁止所有操作,早期成长期仅允许提价。"""
         action = row.get("action", "hold")
         ad_age = row.get("ad_age_days")
 
@@ -435,6 +446,7 @@ class DataFreshnessGuardrail(Guardrail):
         return "数据新鲜度"
 
     def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        """校验数据日期新鲜度,超过上限小时数则阻断所有操作。"""
         action = row.get("action", "hold")
         if action == "hold":
             return GuardrailResult(status="approved", reason="")
@@ -473,6 +485,7 @@ class BidBoundaryGuardrail(Guardrail):
         return "出价边界"
 
     def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        """检查建议出价是否越界,越界时钳位到 [下限, 上限] 并重算调幅。"""
         action = row.get("action", "hold")
         if action not in ("bid_up", "bid_down"):
             return GuardrailResult(status="approved", reason="")
@@ -519,6 +532,7 @@ class BidRangeGuardrail(Guardrail):
         return "调幅范围"
 
     def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        """检查调幅是否超出允许范围,超出时钳位并重算出价。"""
         action = row.get("action", "hold")
         if action not in ("bid_up", "bid_down"):
             return GuardrailResult(status="approved", reason="")
@@ -569,6 +583,7 @@ class RateLimitGuardrail(Guardrail):
         return "频率限制"
 
     def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        """基于调整历史限频:每日次数、最小间隔、日累计调幅超限时阻断或缩减。"""
         action = row.get("action", "hold")
         if action not in ("bid_up", "bid_down", "pause"):
             return GuardrailResult(status="approved", reason="")
@@ -644,6 +659,7 @@ class DailyOpsCapGuardrail(Guardrail):
         self._approved_count = 0
 
     def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        """检查今日操作总量(历史记录 + 本批已放行数),超上限则阻断。"""
         action = row.get("action", "hold")
         if action == "hold":
             return GuardrailResult(status="approved", reason="")
@@ -673,6 +689,7 @@ class DryRunGuardrail(Guardrail):
         return "干运行模式"
 
     def check(self, row: pd.Series, history: AdjustmentHistory) -> GuardrailResult:
+        """干运行模式下将所有非 hold 操作标记为 modified(不实际执行)。"""
         action = row.get("action", "hold")
         if action == "hold":
             return GuardrailResult(status="approved", reason="")

+ 2 - 0
examples/auto_put_ad_mini/tools/im_approval_ad_creation.py

@@ -65,6 +65,7 @@ VALID_ACTIONS = ("approve", "reject", "hold")
 
 
 def _color_for_col(col_idx: int) -> str:
+    """按列号返回所属分组的 header 背景色(ARGB),未匹配时返回白色。"""
     for (lo, hi), color in GROUP_COLORS:
         if lo <= col_idx <= hi:
             return color
@@ -72,6 +73,7 @@ def _color_for_col(col_idx: int) -> str:
 
 
 def _format_site_set_cn(site_set: list) -> str:
+    """把版位枚举列表转成中文名称,用顿号拼接(未知枚举原样保留)。"""
     cn_map = {
         "SITE_SET_WECHAT": "微信公众号",
         "SITE_SET_WECHAT_PLUGIN": "微信插件",

+ 1 - 0
examples/auto_put_ad_mini/tools/im_approval_creation.py

@@ -77,6 +77,7 @@ VALID_ACTIONS = ("approve", "reject", "hold")
 
 
 def _color_for_col(col_idx: int) -> str:
+    """按列号返回所属分组的 header 背景色(ARGB),未匹配时返回白色。"""
     for (lo, hi), color in GROUP_COLORS:
         if lo <= col_idx <= hi:
             return color

+ 8 - 0
examples/auto_put_ad_mini/tools/material_recall.py

@@ -121,11 +121,13 @@ class RecallQuery:
 
     @property
     def strategy_name(self) -> str:
+        """返回召回策略名称(解构选题 或 元素维度-点类型)。"""
         if self.element_dimension == "解构选题":
             return "解构选题"
         return f"{self.element_dimension}-{self.point_type}"
 
     def to_hit(self) -> dict:
+        """把召回 query 转成命中记录 dict(写入素材的 recall_hit_queries)。"""
         return {
             "strategy": self.strategy_name,
             "element_dimension": self.element_dimension,
@@ -341,6 +343,7 @@ def _call_batch_by_text(
 
 
 def _as_float(value, default: float = 0.0) -> float:
+    """安全转 float,None 或非法值返回 default。"""
     try:
         if value is None:
             return default
@@ -350,6 +353,7 @@ def _as_float(value, default: float = 0.0) -> float:
 
 
 def _as_int(value, default: int = 0) -> int:
+    """安全转 int,None 或非法值返回 default。"""
     try:
         if value is None:
             return default
@@ -427,6 +431,7 @@ def _sort_materials_by_policy(materials: List[Material]) -> List[Material]:
 
 
 def _material_rank(material: Material) -> tuple:
+    """生成素材排序键(与 _sort_materials_by_policy 同序:消耗优先)。"""
     return (
         material.cost is not None,
         material.cost or 0,
@@ -439,6 +444,7 @@ def _material_rank(material: Material) -> tuple:
 
 
 def _feature_attr(feature, name: str, default=""):
+    """兼容 dict 和对象两种 feature,统一取属性值。"""
     if isinstance(feature, dict):
         return feature.get(name, default)
     return getattr(feature, name, default)
@@ -508,6 +514,7 @@ def _call_batch_for_recall_query(
     deconstruct_boost: float,
     source_labels: List[str],
 ) -> tuple[RecallQuery, List[dict]]:
+    """对单个召回 query 调 batchByText,返回 (query, 原始 items)。供并行调用。"""
     items = _call_batch_by_text(
         query_text=query.query_text,
         config_codes=[query.config_code],
@@ -528,6 +535,7 @@ def _call_batch_for_recall_query(
 
 
 def _merge_materials_by_policy(query_materials: List[tuple[RecallQuery, List[Material]]]) -> List[Material]:
+    """多路召回结果按 material_id 去重合并(保留排序更优者),并记录命中 query,最后按策略排序。"""
     by_mid: dict[str, Material] = {}
     for query, materials in query_materials:
         hit = query.to_hit()

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

@@ -43,6 +43,7 @@ except ImportError:
 
 
 def _safe_float(v) -> Optional[float]:
+    """安全转换为 float,NaN / inf / 转换失败时返回 None。"""
     try:
         f = float(v)
         if math.isnan(f) or math.isinf(f):
@@ -53,6 +54,7 @@ def _safe_float(v) -> Optional[float]:
 
 
 def _round_or_none(v: Optional[float], digits: int = 4) -> Optional[float]:
+    """四舍五入到指定位数,None 时原样返回 None。"""
     return round(v, digits) if v is not None else None
 
 

+ 1 - 0
examples/auto_put_ad_mini/tools/scene_spec.py

@@ -30,6 +30,7 @@ _TTL_SECONDS = 3600
 
 
 def _now() -> float:
+    """返回当前 Unix 时间戳(秒),用于缓存 TTL 判断。"""
     return time.time()
 
 

+ 10 - 0
examples/auto_put_ad_mini/tools/video_feature_query.py

@@ -49,15 +49,18 @@ CREATE TABLE IF NOT EXISTS video_element_feature_cache (
 
 
 def _quote_sql_string(value: str) -> str:
+    """把字符串转义并加单引号,用于拼 ODPS SQL 字面量。"""
     return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
 
 
 def _chunks(items: list[int], size: int) -> Iterable[list[int]]:
+    """把列表按 size 切成批次,逐批 yield。"""
     for i in range(0, len(items), size):
         yield items[i:i + size]
 
 
 def _get_odps_client():
+    """获取 loghubods 项目的 ODPS client(动态注入 tools 目录到 sys.path)。"""
     import sys
 
     tools_dir = _MINI_DIR / "tools"
@@ -69,6 +72,10 @@ def _get_odps_client():
 
 
 def _latest_partition_dt(client) -> str:
+    """获取 dwd_video_element_contribution_analysis 表的最新分区 dt(YYYYMMDD)。
+
+    先走表元数据 get_max_partition,失败时降级用 MAX_PT 查询。
+    """
     table_name = "dwd_video_element_contribution_analysis"
     try:
         table = client._odps.get_table(table_name)
@@ -111,6 +118,7 @@ def _query_odps_rows(client, sql: str, columns: list[str]) -> list[dict]:
 
 
 def _ensure_cache_table() -> None:
+    """确保 MySQL 缓存表 video_element_feature_cache 存在且结构、索引符合预期(幂等迁移)。"""
     from db.connection import get_connection
 
     conn = get_connection()
@@ -166,6 +174,7 @@ ADD UNIQUE KEY uk_dt_video_point_element
 
 
 def _read_cache(video_ids: list[int], dt: str) -> tuple[dict[int, list[VideoElementFeature]], set[int]]:
+    """按 dt 从缓存表批量读特征。返回 (video_id→特征列表, 已缓存的 video_id 集合,含负缓存)。"""
     if not video_ids:
         return {}, set()
     from db.connection import get_connection
@@ -210,6 +219,7 @@ def _write_cache(
     miss_video_ids: Iterable[int],
     dt: str,
 ) -> None:
+    """把 ODPS 查到的特征和无特征负缓存(is_miss=True)批量写入缓存表(upsert)。"""
     rows = [
         (
             feature.video_id,

+ 2 - 0
examples/auto_put_ad_mini/tools/video_recall.py

@@ -50,6 +50,7 @@ PIAOQUANTV_HOT_FALLBACK_SOURCE = os.getenv("PIAOQUANTV_HOT_FALLBACK_SOURCE", "ho
 
 
 def _load_video_crowd_package_map() -> dict[str, str]:
+    """从环境变量 VIDEO_RECALL_CROWD_PACKAGE_MAP 加载人群包映射,非法或未配置时用默认映射。"""
     raw = os.getenv("VIDEO_RECALL_CROWD_PACKAGE_MAP", "").strip()
     if raw:
         try:
@@ -74,6 +75,7 @@ def map_crowd_package_for_video_recall(crowd_package: str) -> str:
 
 
 def _normalize_category(raw) -> str:
+    """把 category 字段规范化成字符串(list 用逗号拼接,None 返回空串)。"""
     if raw is None:
         return ""
     if isinstance(raw, list):

+ 2 - 0
examples/auto_put_ad_mini/tools/video_risk.py

@@ -37,6 +37,7 @@ class VideoRiskResult:
     raw_tag_ids: list[str]
 
     def to_record_fields(self) -> dict[str, Any]:
+        """转成可合并进候选记录的落表字段 dict。"""
         return {
             "landing_risk_passed": self.passed,
             "landing_risk_level": self.max_level,
@@ -48,6 +49,7 @@ class VideoRiskResult:
 
 
 def _normalize_tag_ids(data: Any) -> list[str]:
+    """把接口返回的 data 规范化成字符串 tag id 列表。"""
     if data is None:
         return []
     if isinstance(data, list):

+ 4 - 0
examples/auto_put_ad_mini/utils/log_capture.py

@@ -17,6 +17,7 @@ class ContextBufferHandler(logging.Handler):
     """将日志写入上下文变量的缓冲区"""
 
     def emit(self, record: logging.LogRecord):
+        """将日志记录格式化后写入当前上下文的缓冲区(无缓冲区则忽略)"""
         buffer = log_buffer_var.get()
         if buffer is not None:
             msg = self.format(record)
@@ -37,14 +38,17 @@ class LogCapture:
     """日志捕获上下文管理器"""
 
     def __init__(self):
+        """初始化独立的日志缓冲区"""
         self.buffer = StringIO()
         self.token = None
 
     def __enter__(self):
+        """进入上下文:将缓冲区绑定到当前上下文变量"""
         self.token = log_buffer_var.set(self.buffer)
         return self
 
     def __exit__(self, exc_type, exc_val, exc_tb):
+        """退出上下文:恢复上下文变量到之前的状态"""
         log_buffer_var.reset(self.token)
 
     def get_logs(self) -> str: