Procházet zdrojové kódy

Merge remote-tracking branch 'origin/agent_auto_ad_put_ad_adjust_0509_creative' into agent_auto_ad_put_ad_adjust_0509_creative

# Conflicts:
#	examples/auto_put_ad_mini/configure_creation_accounts.py
#	examples/auto_put_ad_mini/execute_creation_once.py
#	examples/auto_put_ad_mini/sync_feishu_account_config.py
wangyunpeng před 6 dny
rodič
revize
68fe876e2d
67 změnil soubory, kde provedl 11606 přidání a 310 odebrání
  1. 30 3
      AGENTS.md
  2. 6 5
      Dockerfile.auto_put_ad_mini
  3. 30 0
      Dockerfile.tencent_realtime_control
  4. 46 59
      docker-compose.yml
  5. 30 2
      examples/auto_put_ad_mini/.env.example
  6. 9 0
      examples/auto_put_ad_mini/CREATIVE_CREATION_TODO.md
  7. 138 12
      examples/auto_put_ad_mini/PRODUCTION_AUTOMATION.md
  8. 71 20
      examples/auto_put_ad_mini/config.py
  9. 497 0
      examples/auto_put_ad_mini/configs/material_creative_patterns_seed.json
  10. 160 12
      examples/auto_put_ad_mini/configure_creation_accounts.py
  11. 7 0
      examples/auto_put_ad_mini/db/connection.py
  12. 210 1
      examples/auto_put_ad_mini/db/schema.sql
  13. 166 0
      examples/auto_put_ad_mini/debug_generate_ai_material.py
  14. 123 0
      examples/auto_put_ad_mini/debug_select_creative_patterns.py
  15. 134 0
      examples/auto_put_ad_mini/docs/ad_automation_platform_architecture_plan_2026-07-25.md
  16. 211 0
      examples/auto_put_ad_mini/docs/ai_material_strategy_plan_2026-07-07.md
  17. 282 0
      examples/auto_put_ad_mini/docs/material_strategy_learning_db_design_2026-07-08.md
  18. 163 0
      examples/auto_put_ad_mini/docs/unified_services_deployment.md
  19. 36 0
      examples/auto_put_ad_mini/execute_creation_apply.py
  20. 435 57
      examples/auto_put_ad_mini/execute_creation_once.py
  21. 118 0
      examples/auto_put_ad_mini/import_material_strategy_learning.py
  22. 65 0
      examples/auto_put_ad_mini/prompts/ai_cover_copy.md
  23. 58 0
      examples/auto_put_ad_mini/prompts/ai_generated_material.md
  24. 14 0
      examples/auto_put_ad_mini/prompts/ai_pattern_selector.md
  25. 22 0
      examples/auto_put_ad_mini/prompts/ai_sanitize_video_description.md
  26. 157 0
      examples/auto_put_ad_mini/run_daily_service.py
  27. 27 0
      examples/auto_put_ad_mini/seed_material_creative_patterns.py
  28. 177 0
      examples/auto_put_ad_mini/sql/high_consumption_materials_30d.sql
  29. 138 5
      examples/auto_put_ad_mini/sync_feishu_account_config.py
  30. 55 0
      examples/auto_put_ad_mini/test_ad_creation_status.py
  31. 68 0
      examples/auto_put_ad_mini/test_ai_generated_materials.py
  32. 75 0
      examples/auto_put_ad_mini/test_im_approval_creation.py
  33. 177 0
      examples/auto_put_ad_mini/test_landing_video_dedupe.py
  34. 71 0
      examples/auto_put_ad_mini/test_video_recall_pagination.py
  35. 141 0
      examples/auto_put_ad_mini/tools/account_material_strategy.py
  36. 30 2
      examples/auto_put_ad_mini/tools/ad_api.py
  37. 42 13
      examples/auto_put_ad_mini/tools/ad_creation.py
  38. 1196 0
      examples/auto_put_ad_mini/tools/ai_generated_material.py
  39. 228 0
      examples/auto_put_ad_mini/tools/ai_material_review.py
  40. 313 93
      examples/auto_put_ad_mini/tools/creative_creation.py
  41. 217 1
      examples/auto_put_ad_mini/tools/creative_material_usage.py
  42. 1 0
      examples/auto_put_ad_mini/tools/creative_review.py
  43. 126 0
      examples/auto_put_ad_mini/tools/delivery_config.py
  44. 18 9
      examples/auto_put_ad_mini/tools/im_approval_ad_creation.py
  45. 91 11
      examples/auto_put_ad_mini/tools/im_approval_creation.py
  46. 1078 0
      examples/auto_put_ad_mini/tools/material_strategy_learning.py
  47. 61 0
      examples/auto_put_ad_mini/tools/video_feature_query.py
  48. 85 5
      examples/auto_put_ad_mini/tools/video_recall.py
  49. 38 0
      examples/tencent_realtime_control/.env.example
  50. 4 0
      examples/tencent_realtime_control/.gitignore
  51. 203 0
      examples/tencent_realtime_control/README.md
  52. 1 0
      examples/tencent_realtime_control/__init__.py
  53. 201 0
      examples/tencent_realtime_control/feishu_command_service.py
  54. 607 0
      examples/tencent_realtime_control/feishu_notifier.py
  55. 60 0
      examples/tencent_realtime_control/fetch_daily_hourly_cpm.py
  56. 18 0
      examples/tencent_realtime_control/init_db.py
  57. 74 0
      examples/tencent_realtime_control/odps_source.py
  58. 94 0
      examples/tencent_realtime_control/operator_commands.py
  59. 531 0
      examples/tencent_realtime_control/operator_control.py
  60. 55 0
      examples/tencent_realtime_control/realtime_config.py
  61. 6 0
      examples/tencent_realtime_control/requirements.txt
  62. 66 0
      examples/tencent_realtime_control/run_control_service.py
  63. 785 0
      examples/tencent_realtime_control/run_once.py
  64. 112 0
      examples/tencent_realtime_control/run_scheduler.py
  65. 105 0
      examples/tencent_realtime_control/schema.sql
  66. 551 0
      examples/tencent_realtime_control/storage.py
  67. 462 0
      examples/tencent_realtime_control/tencent_client.py

+ 30 - 3
AGENTS.md

@@ -11,11 +11,27 @@
 - 每日自动流程应根据腾讯侧当前状态自行判断是否需要创建广告或补创意。
 - 飞书配置行关闭时,只代表不再新建广告或补创意,不能因此暂停、删除或改动既有广告。
 - 尽量保持幂等。重复运行时应跳过已完成的工作,只补缺口。
+- 当前新建广告统一使用每天 `06:00-20:00` 的投放时段,由 `ad_delivery_template.time_series_json` 管理;修改全局时段时要同时保持代码默认值与数据库启用模板一致。
+- 飞书「自动化账户」表按 `日期` 作为配置生效批次;主流程只处理当天日期的行,其他日期不覆盖或禁用已有账户配置。`年龄` 支持如 `30-66+`; `地域` 支持中文省市或 `不限`。
+
+## 生产服务架构
+
+- 生产使用同一个完整 `ad-put-agent` 镜像启动两个容器:`ad-control-service` 和 `ad-daily-service`。
+- `ad-control-service` 负责唯一飞书 WebSocket、运营暂停/停止/恢复和每 10 分钟实时 CPM 调控。
+- 实时调控账户范围是历史 `ad_creation_account_config` 与启用白名单的交集;飞书关闭创建配置只停止新建/补创意,不能让既有广告退出实时管理。
+- `ad-daily-service` 每天 10:30 调用现有广告/创意创建流程,每 2 小时扫描腾讯创意正式审核结果。
+- 旧 `server.py`、`execute_once.py` 和 `run.py` 的模型调控链路不再作为生产入口,但暂不删除历史代码。
+- 飞书生产控制命令使用确定性解析,不能让模型直接决定账户范围或执行腾讯写操作。
+- `暂停` 表示仅暂停到下一投放日 06:00;`停止` 表示持续停止;`恢复` 只解除运营暂停,不能解除仍生效的 CPM 暂停。
+- 所有飞书触发的腾讯写操作都必须二次确认。群聊命令必须来自配置群并 @机器人,发送人必须在允许列表。
+- 运营暂停状态和 CPM 暂停状态必须分开持久化。原本人工暂停的广告不能被系统认领或自动开启;腾讯后台人工重新开启时以人工操作为准。
+- 实时控制和飞书写操作必须共用 MySQL advisory lock,并执行腾讯写后回读校验。
+- `RTC_APPLY_ENABLED` 默认关闭;生产切换前必须先完成 dry-run。日级 ROI 和预算节奏当前只保留职责边界,不能自动执行。
 
 ## 模块 B 创意创建规则
 
 - 目标是单广告最终合格创意数,不是单次生成的 pending 行数。
-- 当前目标:每条广告最终有效创意不少于 4 个。
+- 当前目标:每条广告最终有效创意不少于 8 个。
 - `DENIED` 创意不计入有效创意。
 - 每次创意准备中,每个视频来源最多获取并尝试 100 条视频。
 - 先尝试 primary 视频来源。
@@ -47,16 +63,26 @@
 - 当前素材硬筛只看相似度:`score >= 0.8`。
 - 曝光、CTR、ROI 只作为审批展示和兜底排序参考,不作为硬筛。
 - 素材通过相似度筛选后,默认按历史消耗 `cost` 倒序选择。
-- 落地页视频不进历史排重库;当前运行内同一广告的同一 `landing_video_id` 默认最多进入本轮审批候选 2 次。
+- 落地页视频去重按素材来源拆池。历史素材链路同一 `crowd_package + landing_video_id` 默认最多 1 次。
+- AI 生成素材链路和历史素材链路互不占用 landing 去重名额;AI 链路同一 `crowd_package + landing_video_id` 默认最多 1 次。
+- 落地页视频跨轮近期排重只能基于已提交腾讯、已执行或已有明确投放结果的记录。
+- 未提交腾讯的 `prepared` / `pending` 记录不能进入跨轮排重;它们只能用于当前运行内去重,或作为中断后可复用的恢复缓存。
+- 同一广告内同一 `landing_video_id` 仍保留限频保护,默认最多 1 次。
 - 素材需要跨账户/跨天排重,默认按同人群包下近期使用过的 `material_id` 排除。
 - 当前运行内的审批候选需要做轻量展示去重:同一 `crowd_package + material_id` 只进入本轮审批表一次;该去重只存在内存,不写入历史排重库。
+- 当前 `prepare_one_creative_for_ad` 会真实上传图片并创建 `xcx/save` 落地计划,默认不能直接并行执行;如需并行,必须先拆分为纯候选选择和副作用串行确认两段。
+- `videoContentList` 每个 source 默认最多读取 3 页,每页 100 条,按 `video_id` 去重合并。
 
 ## 视频召回人群包映射
 
 - 腾讯投放、人群包授权、落地计划仍使用账户配置的人群包。
 - 内容服务 `videoContentList` 的 `crowdPackage` 可以有独立映射。
-- 当前默认映射:`cell*year*商业` 获取视频时映射为 `wx*商业`。
+- 当前默认映射:
+  - `cell*year*商业` 获取视频时映射为 `wx*商业`
+  - `回流330以上人群` 获取视频时映射为 `R_330+`
 - `source=hot` 只能改变内容服务的视频来源,不能改变映射后的召回人群包。
+- 内容服务 `source` 也可以按人群包做独立映射。
+- 当前默认没有 source 覆盖;`回流330以上人群` 获取视频时使用 `.env` 中的 `prior`。
 
 ## 腾讯人群包和资产规则
 
@@ -77,6 +103,7 @@
 - 修改范围要收敛在当前生产链路相关模块内。
 - 运营可能需要调整的生产行为,应提供配置项。
 - 属于账户、人群包、预算、出价、品牌、监测链接的值,优先进入飞书或 DB,不要写死在代码里。
+- 运行 `examples/auto_put_ad_mini` 下的 Python 脚本时,默认使用仓库根目录 `.venv/bin/python`;不要直接使用系统 `python3`,避免缺少 FastAPI、PyODPS、OSS 等项目依赖导致误判。
 - 端到端执行前,必须意识到可能产生外部副作用:腾讯广告/创意创建、图片上传、DataNexus 监测链接创建、人群包授权、`xcx/save` 落地计划创建。
 - 端到端流程中断时,已经完成的外部副作用不会自动回滚。
 - 行为、环境变量或运营流程变化时,要同步更新面向生产的文档。

+ 6 - 5
Dockerfile.auto_put_ad_mini

@@ -5,7 +5,7 @@ WORKDIR /app
 # 安装系统依赖(使用阿里云镜像源)
 RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources \
     && apt-get update \
-    && apt-get install -y --no-install-recommends curl \
+    && apt-get install -y --no-install-recommends curl tzdata \
     && rm -rf /var/lib/apt/lists/*
 
 # 复制依赖文件:Agent 框架依赖 + 业务特有依赖
@@ -23,6 +23,7 @@ COPY agent/ ./agent/
 COPY im-client/ ./im-client/
 COPY examples/auto_put_ad/ ./examples/auto_put_ad/
 COPY examples/auto_put_ad_mini/ ./examples/auto_put_ad_mini/
+COPY examples/tencent_realtime_control/ ./examples/tencent_realtime_control/
 
 # 设置工作目录到业务目录(参考成功案例)
 WORKDIR /app/examples/auto_put_ad_mini
@@ -32,8 +33,8 @@ RUN mkdir -p outputs/{raw,ad_status,reports,execution_log,data}
 
 # 设置环境变量
 ENV PYTHONUNBUFFERED=1 \
-    TZ=UTC \
-    PYTHONPATH=/app
+    TZ=Asia/Shanghai \
+    PYTHONPATH=/app:/app/examples/auto_put_ad_mini:/app/examples/tencent_realtime_control
 
 # 暴露端口
 EXPOSE 8080
@@ -42,5 +43,5 @@ EXPOSE 8080
 HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
     CMD curl -f http://localhost:8080/health || python -c "import pathlib; pathlib.Path('/app/examples/auto_put_ad_mini/outputs').exists()" || exit 1
 
-# 启动命令
-CMD ["python", "server.py"]
+# Compose 在同一镜像上分别覆盖为日级服务和实时控制服务。
+CMD ["python", "run_daily_service.py"]

+ 30 - 0
Dockerfile.tencent_realtime_control

@@ -0,0 +1,30 @@
+FROM registry.cn-hangzhou.aliyuncs.com/stuuudy/python:3.13-slim
+
+WORKDIR /app
+
+RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources \
+    && apt-get update \
+    && apt-get install -y --no-install-recommends ca-certificates tzdata \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY examples/tencent_realtime_control/requirements.txt ./requirements.txt
+
+RUN pip install --no-cache-dir \
+    -i https://mirrors.aliyun.com/pypi/simple/ \
+    -r requirements.txt
+
+COPY examples/tencent_realtime_control/*.py ./examples/tencent_realtime_control/
+COPY examples/tencent_realtime_control/schema.sql ./examples/tencent_realtime_control/schema.sql
+
+WORKDIR /app/examples/tencent_realtime_control
+
+RUN mkdir -p outputs
+
+ENV PYTHONUNBUFFERED=1 \
+    TZ=Asia/Shanghai \
+    PYTHONPATH=/app/examples/tencent_realtime_control
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+    CMD python -c "from pathlib import Path; raise SystemExit(0 if b'run_scheduler.py' in Path('/proc/1/cmdline').read_bytes() else 1)"
+
+CMD ["python", "run_scheduler.py", "--apply"]

+ 46 - 59
docker-compose.yml

@@ -1,67 +1,54 @@
+x-ad-service: &ad-service
+  image: registry.cn-hangzhou.aliyuncs.com/stuuudy/ad-put-agent:${VERSION:-latest}
+  env_file:
+    - ${AD_RUNTIME_ENV_FILE:-.env}
+  environment:
+    TZ: ${TZ:-Asia/Shanghai}
+    PYTHONPATH: /app:/app/examples/auto_put_ad_mini:/app/examples/tencent_realtime_control
+  volumes:
+    - ./examples/auto_put_ad_mini/outputs:/app/examples/auto_put_ad_mini/outputs
+    - ./examples/tencent_realtime_control/outputs:/app/examples/tencent_realtime_control/outputs
+  networks:
+    - ad_network
+  restart: unless-stopped
+
 services:
-  auto_put_ad_mini:
-    image: registry.cn-hangzhou.aliyuncs.com/stuuudy/ad-put-agent:${VERSION:-latest}
+  ad_control_service:
+    <<: *ad-service
     build:
-      context: .  # 项目根目录
-      dockerfile: Dockerfile.auto_put_ad_mini  # 根目录的 Dockerfile(参考成功案例)
-    container_name: auto_put_ad_mini
-    environment:
-      # 基础配置
-      - TZ=${TZ:-Asia/Shanghai}
-      - EXECUTION_ENABLED=${EXECUTION_ENABLED:-false}
-      - WHITELIST_ENABLED=${WHITELIST_ENABLED:-true}
-      - WHITELIST_ACCOUNTS=${WHITELIST_ACCOUNTS:-80769799}
-
-      # 腾讯广告 API
-      - TENCENT_AD_ACCOUNT_ID=${TENCENT_AD_ACCOUNT_ID:-80769799}
-      - TENCENT_AD_USER_TOKEN=${TENCENT_AD_USER_TOKEN}
-      - TENCENT_AD_ACCESS_TOKEN=${TENCENT_AD_ACCESS_TOKEN:-}
-      - TENCENT_AD_BASE_URL=${TENCENT_AD_BASE_URL:-https://api.e.qq.com/v3.0}
-
-      # 飞书配置
-      - FEISHU_APP_ID=${FEISHU_APP_ID}
-      - FEISHU_APP_SECRET=${FEISHU_APP_SECRET}
-      - FEISHU_OPERATOR_OPEN_ID=${FEISHU_OPERATOR_OPEN_ID}
-      - FEISHU_OPERATOR_CHAT_ID=${FEISHU_OPERATOR_CHAT_ID}
-      - FEISHU_AD_PROJECT_CHAT_ID=${FEISHU_AD_PROJECT_CHAT_ID:-}
-
-      # 数据库配置
-      - DB_HOST=${DB_HOST:-localhost}
-      - DB_PORT=${DB_PORT:-3306}
-      - DB_USER=${DB_USER:-ad_rw}
-      - DB_PASSWORD=${DB_PASSWORD}
-      - DB_NAME=${DB_NAME:-auto_put_ad_mini}
-
-      # ODPS 数据平台(可选)
-      - ODPS_ACCESS_ID=${ODPS_ACCESS_ID:-}
-      - ODPS_ACCESS_SECRET=${ODPS_ACCESS_SECRET:-}
-      - ODPS_PROJECT=${ODPS_PROJECT:-loghubods}
-
-      # LLM API Keys
-      - QWEN_API_KEY=${QWEN_API_KEY:-}
-      - OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY:-}
-
-      # 代理配置(可选)
-      - HTTP_PROXY=${HTTP_PROXY:-}
-      - HTTPS_PROXY=${HTTPS_PROXY:-}
-
-      # APScheduler 定时任务
-      - CRON_SCHEDULE=${CRON_SCHEDULE:-0 2 * * *}
-      - RUN_ON_STARTUP=${RUN_ON_STARTUP:-false}
-      - PORT=${PORT:-8080}
-    ports:
-      - "8080:8080"
-    volumes:
-      - ./examples/auto_put_ad_mini/outputs:/app/outputs
-    networks:
-      - ad_network
-    restart: unless-stopped
+      context: .
+      dockerfile: Dockerfile.auto_put_ad_mini
+    container_name: ad-control-service
+    command:
+      - python
+      - /app/examples/tencent_realtime_control/run_control_service.py
+    healthcheck:
+      test:
+        - CMD
+        - python
+        - -c
+        - "from pathlib import Path; raise SystemExit(0 if b'run_control_service.py' in Path('/proc/1/cmdline').read_bytes() else 1)"
+      interval: 30s
+      timeout: 5s
+      retries: 3
+      start_period: 30s
+
+  ad_daily_service:
+    <<: *ad-service
+    container_name: ad-daily-service
+    command:
+      - python
+      - /app/examples/auto_put_ad_mini/run_daily_service.py
     healthcheck:
-      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
+      test:
+        - CMD
+        - python
+        - -c
+        - "from pathlib import Path; raise SystemExit(0 if b'run_daily_service.py' in Path('/proc/1/cmdline').read_bytes() else 1)"
       interval: 30s
-      timeout: 10s
+      timeout: 5s
       retries: 3
-      start_period: 40s
+      start_period: 30s
 
 networks:
   ad_network:

+ 30 - 2
examples/auto_put_ad_mini/.env.example

@@ -13,14 +13,40 @@ TENCENT_AD_ACCOUNT_ID=80769799
 # ⚠️ 重要:写操作(暂停广告/修改出价)必须配置 user_token
 # 获取方式:https://docs.qq.com/doc/DSVdkdk1LQ1hOam5n
 TENCENT_AD_USER_TOKEN=xxx
+# 可选,默认使用内部接口按 accountId 动态获取最新 user_token
+# TENCENT_AD_USER_TOKEN_API=https://api.piaoquantv.com/ad/put/tencent/getUserToken
 
 # TENCENT_AD_BASE_URL=https://api.e.qq.com/v3.0
 
+# 统一生产服务
+DAILY_CREATION_ENABLED=0
+DAILY_CREATION_HOUR=10
+DAILY_CREATION_MINUTE=30
+DAILY_REVIEW_ENABLED=0
+DAILY_REVIEW_INTERVAL_HOURS=2
+DAILY_RUN_ON_STARTUP=0
+DAILY_REVIEW_RUN_ON_STARTUP=0
+DAILY_SYNC_DELIVERY_TEMPLATE=0
+
+RTC_START_HOUR=6
+RTC_STOP_HOUR=20
+RTC_POLL_SECONDS=600
+RTC_HIGH_CPM=250
+RTC_LOW_CPM=190
+RTC_BID_UP_RATIO=1.25
+RTC_MAX_PARTITION_LAG_HOURS=2
+RTC_APPLY_ENABLED=0
+RTC_COMMAND_ENABLED=1
+RTC_COMMAND_CHAT_ID=
+RTC_COMMAND_ALLOWED_OPEN_IDS=
+RTC_COMMAND_CONFIRM_TTL_MINUTES=10
+RTC_COMMAND_WORKERS=2
+
 # ========================================
 # 飞书 IM 审批配置
 # ========================================
-FEISHU_APP_ID=cli_a955e97067f85cb3
-FEISHU_APP_SECRET=NQaG4ci1plXRDTgwCqrLJgMLLoA2tdF8
+FEISHU_APP_ID=cli_xxx
+FEISHU_APP_SECRET=xxx
 
 # 运营审批群聊(需要审批回复的群)
 FEISHU_OPERATOR_OPEN_ID=ou_498988d823b61ab89c9afe4310f85bb4
@@ -42,6 +68,8 @@ FEISHU_OPERATOR_CHAT_ID=oc_88e0a1970a7de02eb5ac225a8b0cedea
 # ========================================
 # QWEN_API_KEY=xxx
 # OPEN_ROUTER_API_KEY=xxx
+# AI 图片生成模型。当前默认使用 OpenRouter Images API 可用的 pro 图片模型。
+# OPENROUTER_IMAGE_MODEL=google/gemini-3-pro-image
 
 # ========================================
 # 数据库配置(MySQL)

+ 9 - 0
examples/auto_put_ad_mini/CREATIVE_CREATION_TODO.md

@@ -118,6 +118,15 @@
 - 风险点:飞书 server 拉图是否被 yishihui CDN 拦截待验证;如被拦,需跟 yishihui 运维协调 CDN 白名单(加 feishu.cn 或放行空 Referer)
 - 触发条件:挂创意量上百条/天时再升级 — 当前 ≤ 5 条/天,运营 5 次点击代价 < 工程协调成本
 
+### P3-7. AI 生成素材画面主体多样性优化
+- 现象:近期 AI 生成素材里背景经常是中老年人物,且几乎每张都有人物主体。
+- 初步判断:当前 prompt 多次强调目标用户为中老年、生活场景、家庭/邻里/父母子女关系,图片模型容易把"目标用户"误理解为"画面必须出现老年人"。
+- 优化方向:
+  - 区分"目标用户"和"画面主体":目标用户是 45-75 岁用户,但画面主体可以是物品、清单、场景、事件现场、道具特写、讲解场面、自然/工业过程等。
+  - 不强制人物:只有 pattern 或视频主题确实需要人物情绪/关系时才出现人物。
+  - 不强制老人形象:即使有人物,也可以是中年人、家庭成员、工作人员、邻里、讲解者或背影/手部等,不必总是正脸老人。
+  - 后续评估 prompt 是否过度绑定"真实生活化/家庭场景",避免所有素材都长成同一类家庭老人封面。
+
 ## 已知腾讯错误码 — 排查表
 
 | code | 含义 | 修复方向 |

+ 138 - 12
examples/auto_put_ad_mini/PRODUCTION_AUTOMATION.md

@@ -31,48 +31,75 @@
 
 当 `是否自动化执行=是` 时,每日主流程会扫描并处理该账户。为否时,系统不再为该账户新建广告或补创意,但不会暂停或删除既有广告。
 
+单广告预算语义:
+
+- 空白: 使用投放模板默认预算。
+- `不限制` / `不限` / `-`: 按腾讯不限预算传 `daily_budget=0`。
+- 数字: 按元读取,转换为分后传腾讯。
+
 ## 生产目标
 
 当前单广告最终有效创意目标:
 
 ```text
-TARGET_CREATIVES_PER_AD = 4
+TARGET_CREATIVES_PER_AD = 8
 ```
 
 含义:
 
-- 每条广告最终有效创意数不少于 4
+- 每条广告最终有效创意数不少于 8
 - 已有有效创意会计入目标
 - `DENIED` 创意不计入有效创意
 - 差几个补几个
 - 补充创意会先进入飞书审批,审批通过后才调用腾讯创建接口
 
+Phase 1 创意准备默认串行:
+
+```text
+CREATIVE_PREPARE_MAX_WORKERS = 1
+CREATIVE_PREPARE_TASK_BUFFER = 16
+```
+
+- `prepare_one_creative_for_ad` 当前会真实上传图片并调用 `xcx/save` 创建落地计划,不是纯候选选择函数。
+- 直接并行该函数会导致多个 worker 基于同一排重快照选中同一 landing/material,产生重复落地计划。
+- 因此生产默认保持串行。后续要并行时,应先拆成“纯候选召回/筛选并行 + 图片上传/落地计划创建串行确认”。
+- 主线程统一接收 pending record 并做最终 landing/material 排重。
+
 ## 承接视频选择
 
 每次为一条广告准备一条创意时,视频最多扫描:
 
 ```text
-primary source: 最多 100 条
-hot source: 最多 100 条
+primary source: 默认最多 3 页,每页 100 条
+hot source: 默认最多 3 页,每页 100 条
 ```
 
 执行顺序:
 
 1. 先用账户配置的人群包拉主池视频
-2. 主池 100 条经过过滤、风险审核、素材召回后仍无法产出可用创意时,再用同一个人群包拉 `source=hot`
-3. hot 池同样最多 100 条
+2. 主池最多 3 页经过过滤、风险审核、素材召回后仍无法产出可用创意时,再用同一个人群包拉 `source=hot`
+3. hot 池同样最多 3 页
 4. hot 池同样走风险审核、品类过滤、素材质量过滤
 
-注意: `source=hot` 只改变内容服务的视频来源。默认情况下 `crowdPackage` 使用当前账户配置的人群包;如果配置了视频召回映射,则 primary/hot 都使用映射后的召回人群包。
+注意: `source=hot` 只改变内容服务的视频来源。默认情况下 `crowdPackage` 使用当前账户配置的人群包;如果配置了视频召回映射,则 primary/hot 都使用映射后的召回人群包。分页结果按 `video_id` 去重合并。
 
 当前默认视频召回映射:
 
 ```text
 cell*year*商业 -> wx*商业
+回流330以上人群 -> R_330+
 ```
 
 该映射只影响 `videoContentList` 获取视频,不影响腾讯投放定向、人群包授权和 `xcx/save` 落地计划。
 
+内容服务 `source` 也可独立映射。当前默认没有 source 覆盖,会使用 `.env` 的 `PIAOQUANTV_VIDEO_SOURCE=prior`。
+
+```text
+回流330以上人群 -> crowdPackage=R_330+, source=prior
+```
+
+原因:内容服务中 330 人群包的实际参数是 `R_330+`。
+
 ## 视频过滤规则
 
 内容服务 `videoContentList` 返回的 `category` 字段会用于内容品类过滤。
@@ -151,16 +178,68 @@ RECALL_DISPLAY_K=30
 
 通过筛选后按历史消耗 `cost` 倒序选择。ROI、CTR、曝光数和相似度会进入创意审批报表,但不再作为硬筛。
 
-排重分两层:
+## AI 生成图片素材
+
+账户可通过飞书/DB 指定素材来源:
+
+- 空值或 `历史素材`:继续使用历史已投素材召回。
+- `AI生成素材`:按承接视频的 ODPS 特征生成新图片素材。
+
+AI 生成素材链路只改变“素材来源”,不改变视频筛选、人群包、出价、预算、落地计划、审批和腾讯创意创建主流程。
+当前每个视频通过 `landing_video_id` 读取本地库 `video_element_feature_cache` 中该视频最新分区的 `解构选题`,
+并使用贡献分最高的 `standard_element` 生成 1 张 `topic` 图片。
+生成图片前会先用 OpenRouter 文本模型清洗原始 `解构选题`,
+去掉转发、关注、公众号回复、加群、领取、底部提示、推广话术等互动/营销引导,同时保留核心问题、信息差、反差、情绪价值或生活提醒价值。
+清洗后的主题种子不再强制写入“中老年/老人/退休/晚年”等目标用户词;目标用户不等于画面主体。
+清洗失败或清洗后仍包含互动/营销词时,本轮跳过该视频,不使用原始 topic 兜底。
+标题节点会先生成主标题和 `highlight_terms`;图片生成阶段只能高亮这些词,不能自行选择其他高亮词。
+最终图片 prompt 由工程内模板、清洗后的动态承接视频描述、标题/高亮词、以及弱化后的 pattern `visual_direction` 拼接:
+
+- 清洗模板文件:`examples/auto_put_ad_mini/prompts/ai_sanitize_video_description.md`
+- 标题模板文件:`examples/auto_put_ad_mini/prompts/ai_cover_copy.md`
+- 模板文件:`examples/auto_put_ad_mini/prompts/ai_generated_material.md`
+- 动态描述:`解构选题.standard_element` 经文本模型清洗后的主题描述
+- 视觉方向:`material_creative_pattern.visual_rule` 只作为可选参考;如果和画面主体多样性、非默认人物、非默认老人正脸冲突,以全局视觉策略为准
+- 图片比例:`AI_IMAGE_ASPECT_RATIO`,默认 `16:9`
+- 最终输出尺寸:`AI_IMAGE_TARGET_WIDTH` x `AI_IMAGE_TARGET_HEIGHT`,默认 `1280x720`
+
+AI 图先上传到 OSS 并进入飞书人工审批。只有人工 `approve` 后,Phase 3 才会把图片上传到腾讯 `images/add` 并创建创意。
+如果账户配置 `生成失败是否回退历史素材=是`,AI 生成失败或无候选时才回退历史素材;否则本轮跳过。
+
+AI 图默认上传到:
+
+- OSS bucket:`art-pubbucket`
+- OSS 目录:`auto_put_tencent/image`
+- 对外 URL 域名:`https://rescdn.yishihui.com/`
+
+手动调试生成效果可运行:
+
+```bash
+.venv/bin/python examples/auto_put_ad_mini/debug_generate_ai_material.py --video-id 71187017 --title "视频标题"
+```
+
+调试脚本按 `video_id` 读取本地特征缓存表,只生成并上传 OSS,打印图片 URL,不会创建腾讯广告或创意。
+
+素材排重分两层:
 
 - 历史排重只读取已有明确结果的素材使用记录,按同人群包下的 `material_id` 排除。
 - 本轮审批候选做内存展示去重,同一 `crowd_package + material_id` 只进入当前审批表一次。该规则不写入历史排重库,进程结束即失效。
-- 落地页视频不进入历史排重库,但同一广告在本轮准备中同一个 `landing_video_id` 默认最多出现 2 次。
+
+落地页视频排重按素材来源拆池:
+
+- 历史素材召回链路:同一 `crowd_package + landing_video_id` 默认最多使用 1 次。
+- AI 生成素材链路:同一 `crowd_package + landing_video_id` 默认最多使用 1 次。
+- 两条链路互不占用名额。历史素材已用过的 landing,不阻止 AI 生成素材链路继续使用;AI 使用记录也不阻止历史素材链路按自己的规则判断。
+- 跨轮近期排重默认读取 `creative_material_usage` 和 `creative_creation_task`,只按同人群包最近 7 天已提交腾讯、已执行或已有明确投放结果的 `landing_video_id` 统计使用次数。
+- 未提交腾讯的 `prepared` 记录不参与跨轮排重;端到端中断重跑时会优先作为可恢复缓存复用,避免重复选视频和重复生成 AI 图。
+- 同一广告内同一 `landing_video_id` 仍保留限频保护,默认最多 1 次。
 
 可通过环境变量调整同广告落地页视频本轮限频:
 
 ```bash
-MAX_SAME_LANDING_PER_AD_IN_RUN=2
+MAX_SAME_LANDING_PER_AD_IN_RUN=1
+CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS=7
+PIAOQUANTV_VIDEO_MAX_PAGES=3
 ```
 
 ## 热门兜底配置
@@ -193,17 +272,64 @@ PIAOQUANTV_HOT_FALLBACK_ENABLED=true
 PIAOQUANTV_HOT_FALLBACK_SOURCE=hot
 LANDING_EXCLUDED_CATEGORIES=早中晚好,祝福音乐,历史名人
 VIDEO_RISK_MAX_ALLOWED_LEVEL=5
-VIDEO_RECALL_CROWD_PACKAGE_MAP={"cell*year*商业":"wx*商业"}
-MAX_SAME_LANDING_PER_AD_IN_RUN=2
+VIDEO_RECALL_CROWD_PACKAGE_MAP={"cell*year*商业":"wx*商业","回流330以上人群":"R_330+"}
+MAX_SAME_LANDING_PER_AD_IN_RUN=1
+PIAOQUANTV_VIDEO_MAX_PAGES=3
+CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS=7
+TARGET_CREATIVES_PER_AD=8
+CREATIVE_PREPARE_MAX_WORKERS=1
+CREATIVE_PREPARE_TASK_BUFFER=16
 TENCENT_AUDIENCE_SOURCE_ACCOUNT_ID=55615440
 TENCENT_AUDIENCE_GRANT_BUSINESS_ID=12312
+OPENROUTER_API_KEY=...
+OPENROUTER_TEXT_MODEL=google/gemini-3-flash-preview
+OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image
+AI_IMAGE_PATTERN_TOP_K=1
+ALIYUN_OSS_ENDPOINT=oss-xxx.aliyuncs.com
+ALIYUN_OSS_BUCKET=art-pubbucket
+ALIYUN_OSS_ACCESS_KEY_ID=...
+ALIYUN_OSS_ACCESS_KEY_SECRET=...
+AI_IMAGE_OSS_PREFIX=auto_put_tencent/image
+AI_IMAGE_PUBLIC_BASE_URL=https://rescdn.yishihui.com
+AI_IMAGE_TARGET_WIDTH=1280
+AI_IMAGE_TARGET_HEIGHT=720
+```
+
+OSS key 已迁移到本工程环境变量。生产 Docker 也应显式注入这些变量,不依赖外层参考工程。
+
+## 临时收窄运行
+
+每日主流程默认会扫描全部启用账户。应急验证或定向补量时,可以用运行时环境变量临时收窄范围,不改飞书、不改数据库配置:
+
+```bash
+CREATION_SKIP_PHASE0=1
+CREATION_ONLY_CROWD_PACKAGES=回流330以上人群
 ```
 
+含义:
+
+- `CREATION_SKIP_PHASE0=1`: 本次跳过广告创建,只做创意准备、审批和提交。
+- `CREATION_ONLY_CROWD_PACKAGES`: 本次只处理指定人群包的账户;多个值用英文逗号分隔。
+- `CREATION_ONLY_ACCOUNT_IDS`: 可选,本次只处理指定账户 ID;多个值用英文逗号分隔。
+
+这些开关只影响当次进程。生产定时任务不配置这些变量时,仍按默认全量启用账户运行。
+
+创意审批表包含独立的 `素材预览` 和 `素材链接` 两列:`素材预览` 用于图片/查看入口,`素材链接` 保留原始素材 URL 的 `HYPERLINK` 公式。当前 `决策` 列为 `AB`,审批轮询也读取 `AB` 列。
+
+素材封面默认会作为缩略图嵌入 xlsx 后再上传飞书在线表格。若需要临时关闭嵌图,可设置:
+
+```bash
+CREATION_APPROVAL_EMBED_IMAGES=0
+```
+
+飞书导入若不保留 xlsx 内嵌图片,审批表仍保留链接兜底;后续可改用飞书 Sheets 写图片 API。
+
 ## 当前已知限制
 
 - `R330` 账户如果源账户无法解析到 ONLINE/SUCCESS 人群包,Phase 0 会跳过该账户。
 - `泛人群` 主池可能返回 0,需要依赖 `source=hot` 兜底。
 - 当前素材只按相似度准入并按消耗排序;低曝光/低 CTR 素材可能进入审批表,需要运营结合报表判断。
+- AI 生成素材 v1 不做机器预审,质量和合规依赖飞书人工审批 + 腾讯审核。
 - 每次 pending creative 准备会调用 `xcx/save` 生成落地计划;端到端测试中断不会自动清理已生成的落地计划。
 
 ## 上线检查

+ 71 - 20
examples/auto_put_ad_mini/config.py

@@ -9,7 +9,7 @@
 """
 import os
 import logging
-from datetime import datetime
+from datetime import date, datetime
 from pathlib import Path
 from typing import Optional
 from zoneinfo import ZoneInfo
@@ -312,13 +312,23 @@ def get_account_creation_config(account_id: int) -> dict:
 
     conn = get_connection()
     try:
+        try:
+            from configure_creation_accounts import ensure_account_delivery_config_columns
+            ensure_account_delivery_config_columns()
+        except Exception as e:
+            logger.warning("确保账户投放配置列失败:%s", e)
         with conn.cursor() as cur:
             cur.execute(
                 """
                 SELECT c.account_id, c.enabled, c.delivery_version,
                        c.audience_name, c.audience_pack_id,
                        c.audience_tier_label, c.bid_min_fen, c.bid_max_fen,
-                       c.bid_amount_fen, c.age_min, c.age_max,
+                       c.bid_amount_fen, c.bid_scene, c.custom_cost_cap_fen,
+                       c.automatic_site_enabled, c.site_set_json AS account_site_set_json,
+                       c.location_types_json AS account_location_types_json,
+                       c.region_ids_json AS account_region_ids_json,
+                       c.age_min, c.age_max,
+                       c.config_date,
                        c.daily_budget_fen AS account_daily_budget_fen,
                        t.site_set_json, t.location_types_json, t.region_ids_json,
                        t.daily_budget_fen, t.time_series_json,
@@ -389,10 +399,20 @@ def get_account_creation_config(account_id: int) -> dict:
         )
 
     import json as _json
-    site_set = _json.loads(row["site_set_json"])
-    location_types = _json.loads(row["location_types_json"])
-    region_ids = _json.loads(row["region_ids_json"])
+    from tools.delivery_config import parse_bid_scene
+    site_set = _json.loads(row.get("account_site_set_json") or row["site_set_json"])
+    location_types = _json.loads(
+        row["account_location_types_json"]
+        if row.get("account_location_types_json") is not None
+        else row["location_types_json"]
+    )
+    region_ids = _json.loads(
+        row["account_region_ids_json"]
+        if row.get("account_region_ids_json") is not None
+        else row["region_ids_json"]
+    )
     time_series = _json.loads(row["time_series_json"])
+    automatic_site_enabled = row.get("automatic_site_enabled")
 
     return {
         "account_id": int(row["account_id"]),
@@ -403,11 +423,25 @@ def get_account_creation_config(account_id: int) -> dict:
         "bid_min_fen": int(bid_min_fen) if bid_min_fen is not None else None,
         "bid_max_fen": int(bid_max_fen) if bid_max_fen is not None else None,
         "bid_amount_fen": int(bid_amount_fen),
+        "bid_scene": parse_bid_scene(row.get("bid_scene")),
+        "custom_cost_cap_fen": (
+            int(row["custom_cost_cap_fen"])
+            if row.get("custom_cost_cap_fen") is not None else None
+        ),
         "age": [{"min": int(age_min), "max": int(age_max)}],
         "site_set": site_set,
+        "automatic_site_enabled": bool(automatic_site_enabled) if automatic_site_enabled is not None else False,
         "location_types": location_types,
         "region_ids": region_ids,
-        "daily_budget_fen": int(row.get("account_daily_budget_fen") or row["daily_budget_fen"]),
+        "config_date": (
+            row["config_date"].isoformat()
+            if row.get("config_date") is not None else None
+        ),
+        "daily_budget_fen": int(
+            row["account_daily_budget_fen"]
+            if row.get("account_daily_budget_fen") is not None
+            else row["daily_budget_fen"]
+        ),
         "time_series": time_series,
     }
 
@@ -418,8 +452,8 @@ def get_account_audience_pack(account_id: int) -> tuple[Optional[int], str]:
     return cfg["audience_pack_id"], cfg["audience_tier_label"]
 
 
-def get_creation_account_ids() -> list[int]:
-    """读取当前启用的待投放账户,作为 Phase 0 创建广告的账户范围。"""
+def get_creation_account_ids(config_date: Optional[date] = None) -> list[int]:
+    """读取指定飞书配置日期启用的账户,作为 Phase 0/1 自动化范围。"""
     try:
         from db.connection import get_connection
     except Exception as e:
@@ -428,6 +462,7 @@ def get_creation_account_ids() -> list[int]:
 
     conn = get_connection()
     try:
+        target_config_date = config_date or now_in_timezone().date()
         with conn.cursor() as cur:
             cur.execute(
                 """
@@ -439,8 +474,10 @@ def get_creation_account_ids() -> list[int]:
                  AND t.enabled = TRUE
                 WHERE c.enabled = TRUE
                   AND w.enabled = TRUE
+                  AND c.config_date = %s
                 ORDER BY c.id ASC
-                """
+                """,
+                (target_config_date,),
             )
             rows = cur.fetchall()
             return [int(r["account_id"]) for r in rows]
@@ -792,15 +829,24 @@ SITE_SET_COMBINATIONS = [
 #   · SMART_TARGETING_NONE = 已关 · SMART_TARGETING_AUTO = 智能定向中
 SMART_TARGETING_MODE = "SMART_TARGETING_MANUAL"
 
-# --- WECHAT_POSITION 定投场景(2026-06-11 用户确认:生产 5 项小程序版位)---
-# 历史:9 项 preset(来自参考广告 77868332)→ 实际生产删除重建走 3 项 → 本期补 1024797。
+# --- WECHAT_POSITION 定投场景(2026-07-06 用户确认:扩展公众号 + 小程序位置)---
+# 历史:9 项 preset(来自参考广告 77868332)→ 实际生产删除重建走 3 项 → 补 1024797
+# → 2026-07-06 扩展公众号文章/订阅号相关位置,并补发现小程序。
 # 中文映射通过 tools.scene_spec.get_wechat_position_tags(account_id) 运行时查询(进程内缓存 1h)
 #
-# 业务生效场景(全部小程序流量位):
+# 业务生效场景(公众号内容场景):
+#   1024789 公众号文章底部
+#   1024790 公众号文章中部
+#   1024791 公众号文章视频贴片
+#   1024792 订阅号消息列表
+#   2100802 公众号文章评论区
+#
+# 业务生效场景(小程序流量位):
 #   1024795 小程序激励式广告
 #   1024796 小程序插屏广告
+#   1024797 小程序封面广告
+#   2100745 发现小程序
 #   2100748 小程序原生广告
-#   1024797 小程序封面广告(2026-06-11 新增)
 #
 # 1024794 小程序 banner 广告已移除:
 #    → /adgroups/update 接口已下线,报 code=1800945
@@ -809,15 +855,18 @@ SMART_TARGETING_MODE = "SMART_TARGETING_MANUAL"
 # ⚠️ 创建后锁死(2026-06-11 实测复现):wechat_position 一旦创建,update 报 code=36840
 #    → 要新增场景必须删除重建广告,代价是丢失已挂创意 + 学习数据
 WECHAT_POSITION_TARGETED_PRESET = [
-    1024795, 1024796, 2100748, 1024797,
+    # 公众号内容场景
+    1024789, 1024790, 1024791, 1024792, 2100802,
+    # 小程序场景
+    1024795, 1024796, 1024797, 2100745, 2100748,
 ]
 
 # 1 账户广告数(2026-06-09 用户确认:2 条,一条有 wechat_position 定投,一条无定投)
 ADS_PER_ACCOUNT = 2
 
-# --- 时段 / 日期(真实样本 95205841163 反推:6:00-22:30 投放)---
+# --- 时段 / 日期(当前统一为每天 06:00-20:00)---
 # 一天 48 段 × 7 天 = 336 位字符串
-TIME_SERIES_ONE_DAY = "000000000000" + "1" * 34 + "00"   # 0:00-6:00 关 / 6:00-23:00 投 / 23:00-24:00 关
+TIME_SERIES_ONE_DAY = "0" * 12 + "1" * 28 + "0" * 8
 TIME_SERIES_DEFAULT = TIME_SERIES_ONE_DAY * 7
 # 长度自验
 assert len(TIME_SERIES_DEFAULT) == 336, f"time_series 长度错误:{len(TIME_SERIES_DEFAULT)}"
@@ -911,10 +960,12 @@ AUDIENCE_TIER_PATTERNS = [
 # 数据流:find_ads_needing_creatives → 关联点过滤 → 召回素材 → POST 创意。
 
 # --- 创意补量目标(单广告期望创意数)---
-# 2026-07-01:用户确认最终合格创意不少于 4
-# 生产阶段长期应回到 15(腾讯经验下限,MIN_CREATIVES_PER_AD)
+# 2026-07-09:用户确认最终合格创意目标调整为 8
+# 生产阶段可继续测试 15-30(腾讯经验下限,MIN_CREATIVES_PER_AD)
 # 这是 find_ads_needing_creatives 阈值 + 补量目标的**同一个语义变量**,不要拆
-TARGET_CREATIVES_PER_AD = 4
+TARGET_CREATIVES_PER_AD = int(os.getenv("TARGET_CREATIVES_PER_AD", "8"))
+CREATIVE_PREPARE_MAX_WORKERS = int(os.getenv("CREATIVE_PREPARE_MAX_WORKERS", "1"))
+CREATIVE_PREPARE_TASK_BUFFER = int(os.getenv("CREATIVE_PREPARE_TASK_BUFFER", "16"))
 
 # --- 主循环 try-fallback 限额(防无限召回)---
 # 单广告最多尝试 N 条 landing,超过即放弃此条创意(不影响广告剩余 to_add)
@@ -971,7 +1022,7 @@ RECALL_QUERY_LIMIT_PER_VIDEO = int(os.getenv("RECALL_QUERY_LIMIT_PER_VIDEO", "12
 RECALL_MIN_IMPRESSIONS = 0                        # 保留兼容配置;当前不作为硬筛。
 RECALL_MIN_CTR = 0.0                              # 保留兼容配置;当前不作为硬筛。
 RECALL_SOURCE_LABELS = ["内部素材"]               # 只要内部
-MAX_SAME_LANDING_PER_AD_IN_RUN = int(os.getenv("MAX_SAME_LANDING_PER_AD_IN_RUN", "2"))
+MAX_SAME_LANDING_PER_AD_IN_RUN = int(os.getenv("MAX_SAME_LANDING_PER_AD_IN_RUN", "1"))
 RECALL_CONFIG_CODES_FULL = [
     "VIDEO_TOPIC", "VIDEO_INSPIRATION", "VIDEO_PURPOSE",
     "VIDEO_KEYPOINT", "VIDEO_TITLE",

+ 497 - 0
examples/auto_put_ad_mini/configs/material_creative_patterns_seed.json

@@ -0,0 +1,497 @@
+[
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "pension_information_gap_big_title",
+    "pattern_name": "养老金信息差大字封面",
+    "hook_category": "pension_money,information_gap",
+    "visual_template": "explainer_person_or_plain_big_title",
+    "title_hook_rule": "围绕中老年明确利益点提出一个通俗问题,用信息差承接,标题要直接、清晰、可一眼看懂。可使用“会发生什么”“答案出来了”“很多人还不知道”等结构。",
+    "visual_rule": "强封面感构图,超大中文标题优先。可使用资料清单、账本票据、讲解现场、桌面道具或简洁背景;人物不是必需,不要默认老人正脸。",
+    "relevance_rule": "仅在视频特征包含退休、养老金、社保、补贴、工资、养老等相关主题时优先使用。",
+    "compliance_rule": "不得承诺收益,不得伪装官方政策通知,不得制造虚假紧迫,不得出现假按钮或假界面。",
+    "positive_examples": ["每个月的养老金不取会发生什么?答案出来了!"],
+    "negative_examples": ["国家发钱紧急通知", "最后一天赶紧领取"],
+    "source_run_id": "material_30d_20260607_20260706_top5000",
+    "source_material_count": 7,
+    "source_total_cost_fen": 22595635,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 10,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "pension_retirement", "score": 35, "keywords": ["养老金", "退休", "退休金", "社保", "补贴", "工资", "养老"]},
+        {"name": "information_gap", "score": 8, "keywords": ["答案", "不知道", "原来", "为什么", "会发生什么", "才知道"]}
+      ],
+      "exclude_groups": [
+        {"name": "fake_policy", "score": -35, "keywords": ["紧急通知", "官方通知", "最后一天", "国家发钱"]},
+        {"name": "medical", "score": -30, "keywords": ["医疗", "医院", "看病", "治病"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "retirement_time_anchor_reminder",
+    "pattern_name": "退休工资时间锚点提醒",
+    "hook_category": "pension_money,time_anchor,number_list,information_gap",
+    "visual_template": "explainer_person_or_plain_big_title",
+    "title_hook_rule": "使用“今年/下个月/六月开始/2026年”等时间锚点,结合退休工资、补贴、社保等中老年关心的信息,形成提醒感和信息差。表达要克制,避免假政策通知。",
+    "visual_rule": "大字标题加资料、账单、清单、日历或桌面特写。可有人物手部或弱化人物,不默认家庭讨论和老人正脸。",
+    "relevance_rule": "适合视频特征中包含退休、工资、补贴、时间变化、政策解读、注意事项等主题。",
+    "compliance_rule": "不能写成官方通告,不能承诺涨钱,不能使用紧急、马上、最后机会等强诱导。",
+    "positive_examples": ["六月开始退休工资超5000元的要留意我也是刚知道"],
+    "negative_examples": ["紧急通知退休金马上翻倍", "不看就领不到"],
+    "source_run_id": "material_30d_20260607_20260706_top5000",
+    "source_material_count": 9,
+    "source_total_cost_fen": 20250222,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 10,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "pension_retirement", "score": 28, "keywords": ["养老金", "退休", "工资", "补贴", "社保", "退休金"]},
+        {"name": "time_anchor", "score": 14, "keywords": ["今年", "明年", "下个月", "开始", "时间", "变化", "2026", "注意", "留意"]}
+      ],
+      "exclude_groups": [
+        {"name": "fake_policy", "score": -35, "keywords": ["紧急通知", "官方", "最后机会", "马上领取"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "list_ranking_explainer_cover",
+    "pattern_name": "清单/排名解释型封面",
+    "hook_category": "number_list,information_gap",
+    "visual_template": "explainer_person_or_plain_big_title",
+    "title_hook_rule": "把视频主题转成清单、排名、几类人、几个原因、几件事等结构,让用户预期点击后能得到完整解释。标题要具体,但不要夸张。",
+    "visual_rule": "封面突出数字、清单、表格、排行或对比。背景可以是资料页、物品特写、讲解现场或简单真实场景,人物不是必需。",
+    "relevance_rule": "适合视频特征中包含排名、清单、步骤、原因、类型、注意事项、对比解释等主题。",
+    "compliance_rule": "避免国家排名情绪化、涉政对立、虚假权威背书、假榜单和绝对化表达。",
+    "positive_examples": ["中国31省人均养老金排名出来了再忙也要看看"],
+    "negative_examples": ["全国唯一官方排名", "看完吓一跳"],
+    "source_run_id": "material_30d_20260607_20260706_top5000",
+    "source_material_count": 7,
+    "source_total_cost_fen": 12203499,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 9,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "list_or_ranking", "score": 30, "keywords": ["排名", "清单", "几类", "几种", "几个", "几件", "原因", "步骤", "对比", "榜"]},
+        {"name": "explainer", "score": 10, "keywords": ["解释", "答案", "为什么", "原来", "明白"]}
+      ],
+      "exclude_groups": [
+        {"name": "politics_country", "score": -25, "keywords": ["强国", "美国", "日本", "朝鲜", "国家排名"]},
+        {"name": "absolute_claim", "score": -20, "keywords": ["唯一", "100%", "官方排名"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "elder_family_life_reminder",
+    "pattern_name": "家庭晚年提醒型封面",
+    "hook_category": "family_emotion,number_list",
+    "visual_template": "elder_family_emotion_big_title",
+    "title_hook_rule": "围绕晚年生活、子女关系、老伴、兄弟姐妹、邻里相处等生活议题,生成现实提醒型标题。可以有一点悬念和情绪,但要避免两性猎奇和伦理冲突。",
+    "visual_rule": "本土生活场景或关系线索,可用家门口、桌面物件、旧照片、空椅子、人物背影或少量人物互动表达。不要默认客厅饭桌和老人正脸。",
+    "relevance_rule": "适合视频特征中包含晚年、家庭、子女、老伴、亲情、邻里、人情往来等主题。",
+    "compliance_rule": "不得低俗擦边,不得婚配违法敏感,不得哭惨卖惨,不得制造极端恐吓。",
+    "positive_examples": ["当你老了建议你少去看望兄弟姐妹3个原因很现实"],
+    "negative_examples": ["长期分居女人想老公", "夫妻晚年挤一张床"],
+    "source_run_id": "material_30d_20260607_20260706_top5000",
+    "source_material_count": 4,
+    "source_total_cost_fen": 7473737,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 9,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "elder_family", "score": 32, "keywords": ["晚年", "老人", "老年", "子女", "老伴", "家庭", "亲情", "兄弟姐妹", "邻里", "父母"]},
+        {"name": "life_reminder", "score": 10, "keywords": ["建议", "少去", "原因", "现实", "提醒", "注意"]}
+      ],
+      "exclude_groups": [
+        {"name": "sex_relationship", "score": -35, "keywords": ["分居", "床", "老公", "老婆", "试婚"]},
+        {"name": "extreme_fear", "score": -20, "keywords": ["凄惨", "吓坏", "崩溃"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "music_nostalgia_emotion_cover",
+    "pattern_name": "音乐乡愁共鸣封面",
+    "hook_category": "music_stage,family_emotion",
+    "visual_template": "stage_or_music_emotion",
+    "title_hook_rule": "围绕老歌、乡愁、亲情、回忆、听哭几代人等情绪共鸣生成标题。标题要有情感张力,但不要哭惨卖惨或过度煽动。",
+    "visual_rule": "舞台、麦克风、歌词纸、老照片、收音机、唱片、观众反应或年代感场景,大字标题突出共鸣和回忆。人物可有可无。",
+    "relevance_rule": "适合视频特征包含歌曲、音乐、乡愁、回忆、父母、儿女、年代感、情感共鸣等主题。",
+    "compliance_rule": "不得使用名人肖像,不得虚构明星背书,不得使用极端哭惨表达。",
+    "positive_examples": ["一首歌,唱哭了几代人的乡愁"],
+    "negative_examples": ["某明星亲口推荐", "不听后悔一辈子"],
+    "source_run_id": "material_30d_20260607_20260706_top5000",
+    "source_material_count": 4,
+    "source_total_cost_fen": 8337506,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 8,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "music_nostalgia", "score": 34, "keywords": ["音乐", "歌曲", "唱歌", "老歌", "乡愁", "回忆", "听哭", "舞台", "旋律", "儿女"]}
+      ],
+      "exclude_groups": [
+        {"name": "celebrity", "score": -25, "keywords": ["明星", "歌手", "名人", "某某亲口"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "life_object_attention_closeup",
+    "pattern_name": "生活物品注意提醒",
+    "hook_category": "health_life,information_gap",
+    "visual_template": "object_closeup_big_title",
+    "title_hook_rule": "围绕家中常见物品、食材、证件、手机、账本等生活细节生成注意提醒型标题。用“家里有X的注意了”“原来还有这种用法”等信息差,避免健康恐吓。",
+    "visual_rule": "物品近景 + 大字标题优先,可搭配手部、厨房、桌面、柜子、证件、手机等真实生活元素。不要把人物作为默认主体。",
+    "relevance_rule": "适合视频特征包含生活用品、食材、家庭常识、手机使用、消息辨别、日常提醒等主题。",
+    "compliance_rule": "不得写医疗疗效、慢性中毒、治病保健、专家恐吓,不得出现假按钮。",
+    "positive_examples": ["家里有红枣的注意了立马回家翻出来"],
+    "negative_examples": ["这些蔬菜不浸泡等于慢性吃毒", "专家说能治病"],
+    "source_run_id": "material_30d_20260607_20260706_top5000",
+    "source_material_count": 3,
+    "source_total_cost_fen": 6746984,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 8,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "life_object", "score": 30, "keywords": ["家里", "物品", "食材", "手机", "证件", "账本", "红枣", "蔬菜", "消息", "来电", "转账"]},
+        {"name": "common_sense", "score": 10, "keywords": ["常识", "原来", "才知道", "用法", "注意"]}
+      ],
+      "exclude_groups": [
+        {"name": "medical_fear", "score": -35, "keywords": ["治病", "疗效", "慢性中毒", "医院", "专家推荐"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "elder_life_advice_list",
+    "pattern_name": "老年生活建议清单",
+    "hook_category": "number_list,family_emotion",
+    "visual_template": "elder_family_emotion_big_title",
+    "title_hook_rule": "仅当主题本身明确包含建议、习惯、几件事、几个原因、步骤、清单等结构时,才转成“几条建议/几个习惯/几件事”的清单型标题。表达要温和、有用、有现实感,不要把抽象心态硬写成清单。",
+    "visual_rule": "用生活选择、纸面清单、路口、院子、桌面道具、背影或少量人物交流表达经验感。标题突出“建议/经验/几件事”,不默认老人正脸,不要把心理层面的放下强行画成丢弃物品。",
+    "relevance_rule": "适合视频特征明确包含老人、晚年、生活建议、习惯、人生经验、几件事、几个原因、步骤、清单等主题。若只是放下执念、清净自在、知足常乐等抽象心态,不要优先使用。",
+    "compliance_rule": "不得制造恐惧,不得把建议写成绝对真理,不得贬低老年人或家庭成员。",
+    "positive_examples": ["老年人一旦迈入八十岁高龄不妨听听这七条建议"],
+    "negative_examples": ["不这样做晚年一定凄惨", "唯一正确活法"],
+    "source_run_id": "material_30d_20260607_20260706_top5000",
+    "source_material_count": 3,
+    "source_total_cost_fen": 3970900,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 8,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "elder_advice", "score": 30, "keywords": ["建议", "习惯", "经验", "几件", "几个", "原因", "步骤", "清单", "晚年", "老人", "老年"]}
+      ],
+      "exclude_groups": [
+        {"name": "absolute_fear", "score": -25, "keywords": ["一定凄惨", "唯一", "必须", "不做就"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "self_attitude_emotion_hook",
+    "pattern_name": "自我态度情绪钩子",
+    "hook_category": "self_attitude,emotion_conflict,relationship_pressure",
+    "visual_template": "strong_subject_emotion_big_title",
+    "title_hook_rule": "围绕拒绝内耗、活出自我、看透攀比、人际压力、面子和自我和解生成标题。优先保留核心情绪和关系冲突,不要硬转成旧物、清单或退休建议。",
+    "visual_rule": "用能看出态度变化、关系压力、表达瞬间或情绪反差的画面承载主题。人物年龄、性别、身份和场景必须服务主题,不要固定主体模板。",
+    "relevance_rule": "适合视频特征包含拒绝内耗、活出自我、攀比、面子、人际关系、虚伪体面、心态、看透、焦虑、通透、人生选择等抽象情绪和自我表达主题。",
+    "compliance_rule": "不得制造极端恐吓,不得贬低特定群体,不得做低俗擦边,不得把人生建议写成唯一正确答案。",
+    "positive_examples": ["有些关系看清后人就轻松了", "别再为了面子把自己耗空"],
+    "negative_examples": ["扔掉三样旧物生活就顺了", "唯一正确活法"],
+    "source_run_id": "manual_pattern_20260709",
+    "source_material_count": 0,
+    "source_total_cost_fen": 0,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 12,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "self_attitude", "score": 38, "keywords": ["拒绝内耗", "内耗", "活出自我", "自我", "心态", "通透", "想开", "清醒", "自在", "体面"]},
+        {"name": "relationship_pressure", "score": 24, "keywords": ["人际关系", "关系", "攀比", "面子", "虚伪", "焦虑", "物质欲望", "压力", "看透"]}
+      ],
+      "exclude_groups": [
+        {"name": "concrete_policy_or_object", "score": -15, "keywords": ["养老金", "补贴", "工资", "旧鞋", "机器", "潮水", "燕子", "合体字"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "relationship_reality_gap",
+    "pattern_name": "人情关系现实反差",
+    "hook_category": "relationship_gap,family_emotion,social_observation",
+    "visual_template": "relationship_conflict_big_title",
+    "title_hook_rule": "围绕亲戚、熟人、邻里、朋友、家庭关系中的没说破、变淡、冷场、看清原因等现实反差生成标题。强调想看感,避免低俗伦理和隐私窥探。",
+    "visual_rule": "用能看出关系变化、未说破、冷场、对视、停顿或线索物件的画面承载主题。可以有人物强主体,但不要制造暴力冲突或固定家庭模板。",
+    "relevance_rule": "适合视频特征包含亲戚、熟人、邻里、朋友、人情、家庭关系、人际往来、看清关系、关系变淡、面子等主题。",
+    "compliance_rule": "不得隐私曝光,不得低俗两性,不得挑动家庭仇恨,不得使用血腥暴力或极端羞辱表达。",
+    "positive_examples": ["亲戚之间这件事很多人没说破", "有些关系变淡其实早有原因"],
+    "negative_examples": ["熟人丑事曝光", "偷听邻居秘密"],
+    "source_run_id": "manual_pattern_20260709",
+    "source_material_count": 0,
+    "source_total_cost_fen": 0,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 11,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "relationship", "score": 36, "keywords": ["亲戚", "熟人", "邻里", "邻居", "朋友", "人情", "关系", "家庭", "父母", "子女"]},
+        {"name": "reality_gap", "score": 18, "keywords": ["没说破", "看清", "变淡", "冷场", "原因", "面子", "现实", "后来才知道"]}
+      ],
+      "exclude_groups": [
+        {"name": "vulgar_privacy", "score": -35, "keywords": ["隐私", "丑事", "偷听", "出轨", "分居", "床"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "money_policy_info_gap",
+    "pattern_name": "钱款政策信息差",
+    "hook_category": "money_policy,information_gap,list_reminder",
+    "visual_template": "document_bill_big_title",
+    "title_hook_rule": "围绕养老金、退休工资、补贴、账单、清单、社保等钱款信息生成通俗提醒标题。要有信息差,但不能像官方通知或承诺到账。",
+    "visual_rule": "用钱款信息、资料线索、核对动作或讲解现场承载主题。背景避免强官方感,不要伪装政策通知。",
+    "relevance_rule": "适合视频特征包含养老金、退休工资、社保、补贴、账单、清单、时间变化、钱款核对、政策解读等主题。",
+    "compliance_rule": "不得承诺领取、到账、涨钱或收益;不得假官方通知;不得使用国家发钱、紧急、最后一天等高风险表达。",
+    "positive_examples": ["退休后这笔账很多人还没算明白", "这张清单别只看表面数字"],
+    "negative_examples": ["国家发钱马上到账", "最后一天赶紧领取"],
+    "source_run_id": "manual_pattern_20260709",
+    "source_material_count": 0,
+    "source_total_cost_fen": 0,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 12,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "money_policy", "score": 40, "keywords": ["养老金", "退休金", "退休工资", "社保", "补贴", "账单", "清单", "工资", "钱", "政策"]},
+        {"name": "info_gap", "score": 12, "keywords": ["很多人不知道", "算错", "看明白", "留意", "注意", "时间", "变化"]}
+      ],
+      "exclude_groups": [
+        {"name": "fake_policy_claim", "score": -40, "keywords": ["国家发钱", "马上到账", "领取", "紧急通知", "最后一天", "官方通知"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "object_process_reverse_hook",
+    "pattern_name": "物品流程反差钩子",
+    "hook_category": "object_process,reverse_gap,light_curiosity",
+    "visual_template": "object_process_big_title",
+    "title_hook_rule": "围绕旧物去向、机器加工、日常物品背后流程、前后变化生成反差标题。让用户想知道普通东西后来变成了什么或背后怎么回事。",
+    "visual_rule": "用对象变化、流程现场、前后反差或过程线索承载主题。人物不是必需,重点突出变化本身。",
+    "relevance_rule": "适合视频特征包含旧鞋、回收、加工、机器、流程、去向、前后变化、普通物品背后原因、日常物件反差等主题。",
+    "compliance_rule": "不得涉及假货售卖、品牌侵权、违法生产、恐吓和假新闻播报。",
+    "positive_examples": ["旧鞋进厂后竟然变成了这个", "这个小东西背后还有一道工序"],
+    "negative_examples": ["名牌包都是这样造假", "违法工厂曝光"],
+    "source_run_id": "manual_pattern_20260709",
+    "source_material_count": 0,
+    "source_total_cost_fen": 0,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 12,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "object_process", "score": 40, "keywords": ["旧鞋", "回收", "加工", "机器", "流程", "工厂", "变成", "去向", "制作", "物品"]},
+        {"name": "reverse_gap", "score": 12, "keywords": ["没想到", "竟然", "原来", "背后", "后来", "前后", "变化"]}
+      ],
+      "exclude_groups": [
+        {"name": "illegal_or_brand_risk", "score": -30, "keywords": ["假货", "仿冒", "违法", "曝光", "名牌"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "local_life_social_observation",
+    "pattern_name": "本土生活见闻观察",
+    "hook_category": "information_gap,social_observation",
+    "visual_template": "social_observation_scene_big_title",
+    "title_hook_rule": "围绕中国本土生活场景、社区见闻、乡村变化、邻里事件、城市生活观察生成见闻型标题。可以有好奇感,但不碰涉政、民族对立和国际冲突。",
+    "visual_rule": "街道、社区、院子、乡间、楼道、店铺、路口或事件现场等真实场景,用大字标题突出“原来/没想到/后来才知道”。",
+    "relevance_rule": "适合视频特征包含生活见闻、社区、乡村、邻里、城市变化、普通人故事等主题。",
+    "compliance_rule": "不得煽动地域/民族/国家对立,不得伪装新闻通告,不得使用灾难事故猎奇。",
+    "positive_examples": ["就在刚才北京街头发生意外在场的人都看傻了"],
+    "negative_examples": ["外国人跪了", "某国人都懵了", "紧急新闻"],
+    "source_run_id": "material_30d_20260607_20260706_top5000",
+    "source_material_count": 5,
+    "source_total_cost_fen": 8676078,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 8,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "local_life", "score": 28, "keywords": ["社区", "乡村", "邻里", "街头", "生活", "见闻", "普通人", "变化", "发现", "本地"]}
+      ],
+      "exclude_groups": [
+        {"name": "politics_country", "score": -35, "keywords": ["美国", "日本", "朝鲜", "印度", "强国", "国家", "民族"]},
+        {"name": "fake_news", "score": -25, "keywords": ["紧急新闻", "刚刚发生", "官方通报"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "everyday_common_sense_gap",
+    "pattern_name": "日常常识信息差",
+    "hook_category": "information_gap,life_common_sense",
+    "visual_template": "mixed_information_cover",
+    "title_hook_rule": "围绕日常生活中容易忽略的常识、误区、原因解释生成信息差标题。标题要像生活经验分享,不要像专家恐吓或医疗建议。",
+    "visual_rule": "真实生活背景 + 大字标题,可使用物品特写、对比图、资料页、讲解现场或弱化人物,但不出现假按钮和伪界面。",
+    "relevance_rule": "适合视频特征包含生活常识、原因解释、误区、普通人经验、家庭提醒等主题。",
+    "compliance_rule": "不得涉及医疗诊断/疗效承诺/健康恐吓,不得使用专家权威背书制造恐慌。",
+    "positive_examples": ["早上起来能不能空腹喝水?一番话让人明白了"],
+    "negative_examples": ["专家惊呆众人", "不这样做等于慢性中毒"],
+    "source_run_id": "material_30d_20260607_20260706_top5000",
+    "source_material_count": 5,
+    "source_total_cost_fen": 6871511,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 8,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "common_sense", "score": 30, "keywords": ["常识", "误区", "原来", "才知道", "为什么", "原因", "解释", "明白", "普通人经验"]}
+      ],
+      "exclude_groups": [
+        {"name": "medical_fear", "score": -35, "keywords": ["专家", "中毒", "治病", "医院", "疗效"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "family_story_reversal_cover",
+    "pattern_name": "家庭故事反转封面",
+    "hook_category": "family_emotion,story_reversal",
+    "visual_template": "mixed_information_cover",
+    "title_hook_rule": "围绕家庭关系、亲友往来、误会、重逢、发现真相等内容生成故事反转型标题。保持生活化和好奇感,不要走低俗伦理猎奇。",
+    "visual_rule": "用门口、院子、旧照片、桌面物件、空座位、背影、人物反应或少量互动制造故事感,不默认饭桌和老人正脸。",
+    "relevance_rule": "适合视频特征包含家庭故事、亲情、人情往来、误会、回忆、重逢、生活反转等主题。",
+    "compliance_rule": "不得涉及婚配违法、两性隐私、暴力犯罪、军警冒充、极端羞辱或恐吓。",
+    "positive_examples": ["男子结婚请生父坐主桌养父靠边 后来才知道原因"],
+    "negative_examples": ["女人长期分居想老公", "政委看到吓瘫"],
+    "source_run_id": "material_30d_20260607_20260706_top5000",
+    "source_material_count": 4,
+    "source_total_cost_fen": 6789061,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 8,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "family_story", "score": 28, "keywords": ["家庭", "亲情", "父母", "子女", "亲戚", "人情", "误会", "重逢", "回忆"]},
+        {"name": "story_reversal", "score": 14, "keywords": ["反转", "没想到", "后来", "真相", "发现"]}
+      ],
+      "exclude_groups": [
+        {"name": "sex_relationship", "score": -35, "keywords": ["分居", "试婚", "床", "情人"]},
+        {"name": "crime_or_military", "score": -30, "keywords": ["犯罪", "军籍", "政委", "部队"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "neighbor_acquaintance_discovery",
+    "pattern_name": "熟人邻里意外发现",
+    "hook_category": "social_observation,family_emotion,information_gap",
+    "visual_template": "elder_family_emotion_big_title",
+    "title_hook_rule": "围绕熟人、邻里、亲友之间的意外发现和生活反转生成标题。强调普通生活里的“没想到”,避免冲突、低俗和极端猎奇。",
+    "visual_rule": "社区广场、楼道、家门口、院子、街边或桌面线索等本土生活场景,可有少量人物互动,也可以用场景和物件制造发现感。",
+    "relevance_rule": "适合视频特征包含邻里、熟人、亲戚、朋友、社区、人情、普通人故事等主题。",
+    "compliance_rule": "不得制造谣言、隐私窥探、低俗伦理冲突或恐吓式标题。",
+    "positive_examples": ["邻居一句话让全家人突然明白了"],
+    "negative_examples": ["偷听邻居秘密", "熟人丑事曝光"],
+    "source_run_id": "material_30d_20260607_20260706_top5000",
+    "source_material_count": 2,
+    "source_total_cost_fen": 3036862,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 8,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "neighbor_acquaintance", "score": 30, "keywords": ["邻里", "邻居", "熟人", "亲戚", "朋友", "社区", "人情", "家门口"]}
+      ],
+      "exclude_groups": [
+        {"name": "privacy_or_vulgar", "score": -30, "keywords": ["秘密曝光", "偷听", "丑事", "隐私"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "direct_question_big_text",
+    "pattern_name": "直接问题纯大字封面",
+    "hook_category": "information_gap,direct_question",
+    "visual_template": "plain_background_big_text",
+    "title_hook_rule": "把视频核心问题压缩成一个直接问句或判断题,用大字呈现。适合信息点很强但画面不需要复杂场景的主题。",
+    "visual_rule": "纯色或简单真实背景 + 超大中文标题,减少复杂元素,保证手机小屏第一眼可读。",
+    "relevance_rule": "适合视频特征中有明确问题、答案、解释、原因、提醒,且不依赖复杂人物剧情的内容。",
+    "compliance_rule": "避免震惊体、命令式、绝对化、假官方、假按钮和恐吓表达。",
+    "positive_examples": ["2026年养老金还涨不涨?答案终于来了!"],
+    "negative_examples": ["不看后悔", "最后一天"],
+    "source_run_id": "material_30d_20260607_20260706_top5000",
+    "source_material_count": 4,
+    "source_total_cost_fen": 7679486,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 7,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "clear_question", "score": 24, "keywords": ["为什么", "原因", "答案", "会发生什么", "能不能", "怎么办", "要不要"]},
+        {"name": "explainer", "score": 10, "keywords": ["解释", "原来", "才知道", "明白", "提醒"]}
+      ],
+      "exclude_groups": [
+        {"name": "clickbait", "score": -25, "keywords": ["不看后悔", "最后一天", "赶紧看"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "light_curiosity_life_oddity",
+    "pattern_name": "轻猎奇生活奇闻封面",
+    "hook_category": "light_curiosity,information_gap,story_reversal",
+    "visual_template": "mixed_information_cover",
+    "title_hook_rule": "围绕真实生活里的反常识、意外发现、稀奇见闻生成轻猎奇标题。重点是“有点稀奇、想知道原因”,不要恶俗、恐吓或假新闻。",
+    "visual_rule": "真实本土场景 + 大字标题,优先用稀奇物件、过程现场、旧照片、机器、自然景象或场景反差制造好奇;人物惊讶或围观不是必需。",
+    "relevance_rule": "适合视频特征包含奇闻、没想到、发现、真相、原因、反转、稀奇、意外、普通人见闻等主题。",
+    "compliance_rule": "不得血腥暴力、违法犯罪、低俗擦边、迷信预测、假新闻播报、灾难事故猎奇或隐私曝光。",
+    "positive_examples": ["今年燕子消失了你知道是怎么回事吗"],
+    "negative_examples": ["尸体", "犯罪现场", "算命预言", "丑事曝光"],
+    "source_run_id": "material_30d_20260607_20260706_top5000",
+    "source_material_count": 5,
+    "source_total_cost_fen": 12258428,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 8,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "light_curiosity", "score": 34, "keywords": ["奇闻", "稀奇", "奇怪", "消失", "出现", "没想到", "真相", "原因", "反常识", "意外"]},
+        {"name": "story_reversal", "score": 10, "keywords": ["反转", "后来", "发现", "原来"]}
+      ],
+      "exclude_groups": [
+        {"name": "crime_violence", "score": -40, "keywords": ["尸体", "犯罪", "暴力", "血腥", "凶杀"]},
+        {"name": "superstition", "score": -35, "keywords": ["预言", "算命", "降世", "迷信"]},
+        {"name": "fake_news", "score": -25, "keywords": ["新闻播报", "紧急新闻", "官方通报"]}
+      ]
+    }
+  }
+]

+ 160 - 12
examples/auto_put_ad_mini/configure_creation_accounts.py

@@ -12,8 +12,10 @@ from __future__ import annotations
 
 import argparse
 import csv
+import json
 import sys
 from dataclasses import dataclass
+from datetime import date
 from decimal import Decimal, ROUND_HALF_UP
 from pathlib import Path
 
@@ -30,6 +32,7 @@ DEFAULT_FEEDBACK_KEY = "miniapp_click_default"
 DEFAULT_AGE_MIN = 45
 DEFAULT_AGE_MAX = 66
 DEFAULT_SOURCE_ACCOUNT_ID = 55615440
+DEFAULT_BID_SCENE = "average_cost"
 
 
 @dataclass(frozen=True)
@@ -41,6 +44,17 @@ class AccountConfigInput:
     bid_amount_fen: int | None = None
     daily_budget_fen: int | None = None
     enabled: bool = True
+    material_source: str = "history"
+    ai_fallback_to_history: bool = False
+    bid_scene: str = DEFAULT_BID_SCENE
+    custom_cost_cap_fen: int | None = None
+    automatic_site_enabled: bool | None = None
+    site_set: list[str] | None = None
+    age_min: int = DEFAULT_AGE_MIN
+    age_max: int = DEFAULT_AGE_MAX
+    location_types: list[str] | None = None
+    region_ids: list[int] | None = None
+    config_date: date | None = None
 
 
 def _bid_to_fen(raw: str) -> int:
@@ -72,12 +86,103 @@ 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 {"不限制", "不限", "-"}:
+    if not text:
         return None
+    if text in {"不限制", "不限", "-"}:
+        return 0
     # 飞书里预算按元填,腾讯 API 用分。
     return _bid_to_fen(text)
 
 
+def ensure_account_delivery_config_columns() -> None:
+    """Add account-level delivery override columns when deploying old DBs."""
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT COLUMN_NAME
+                FROM information_schema.COLUMNS
+                WHERE TABLE_SCHEMA = DATABASE()
+                  AND TABLE_NAME = 'ad_creation_account_config'
+                  AND COLUMN_NAME IN (
+                    'bid_scene', 'custom_cost_cap_fen',
+                    'automatic_site_enabled', 'site_set_json',
+                    'location_types_json', 'region_ids_json', 'config_date'
+                  )
+                """
+            )
+            existing = {row["COLUMN_NAME"] for row in (cur.fetchall() or [])}
+            if "bid_scene" not in existing:
+                cur.execute(
+                    """
+                    ALTER TABLE ad_creation_account_config
+                    ADD COLUMN bid_scene VARCHAR(50) DEFAULT NULL
+                    COMMENT '出价场景:BID_SCENE_NORMAL_AVERAGE/BID_SCENE_NORMAL_MAX'
+                    AFTER bid_amount_fen
+                    """
+                )
+            if "custom_cost_cap_fen" not in existing:
+                cur.execute(
+                    """
+                    ALTER TABLE ad_creation_account_config
+                    ADD COLUMN custom_cost_cap_fen INT DEFAULT NULL
+                    COMMENT '最大转化量控制成本(分),用于 custom_cost_cap'
+                    AFTER bid_scene
+                    """
+                )
+            if "automatic_site_enabled" not in existing:
+                cur.execute(
+                    """
+                    ALTER TABLE ad_creation_account_config
+                    ADD COLUMN automatic_site_enabled BOOLEAN DEFAULT NULL
+                    COMMENT '账户级是否开启智能版位;NULL使用模板默认手动版位'
+                    AFTER daily_budget_fen
+                    """
+                )
+            if "site_set_json" not in existing:
+                cur.execute(
+                    """
+                    ALTER TABLE ad_creation_account_config
+                    ADD COLUMN site_set_json TEXT DEFAULT NULL
+                    COMMENT '账户级投放版位覆盖JSON;NULL使用投放模板'
+                    AFTER automatic_site_enabled
+                    """
+                )
+            if "location_types_json" not in existing:
+                cur.execute(
+                    """
+                    ALTER TABLE ad_creation_account_config
+                    ADD COLUMN location_types_json TEXT DEFAULT NULL
+                    COMMENT '账户级地域类型覆盖JSON;NULL使用投放模板'
+                    AFTER site_set_json
+                    """
+                )
+            if "region_ids_json" not in existing:
+                cur.execute(
+                    """
+                    ALTER TABLE ad_creation_account_config
+                    ADD COLUMN region_ids_json MEDIUMTEXT DEFAULT NULL
+                    COMMENT '账户级地域ID覆盖JSON;NULL使用投放模板,空数组表示不限地域'
+                    AFTER location_types_json
+                    """
+                )
+            if "config_date" not in existing:
+                cur.execute(
+                    """
+                    ALTER TABLE ad_creation_account_config
+                    ADD COLUMN config_date DATE DEFAULT NULL
+                    COMMENT '飞书配置生效日期;仅当天配置参与自动创建/补创意'
+                    AFTER region_ids_json
+                    """
+                )
+        conn.commit()
+    finally:
+        conn.close()
+
+
 def _read_inputs(args: argparse.Namespace) -> list[AccountConfigInput]:
     """从 CSV 文件或命令行参数读取并校验账户配置输入"""
     rows: list[AccountConfigInput] = []
@@ -118,6 +223,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
+    from tools.account_material_strategy import ensure_account_material_strategy_columns
 
     source_account_id = None if row.audience_name == "泛人群" else DEFAULT_SOURCE_ACCOUNT_ID
     grant_status = "not_required_no_audience_pack" if row.audience_name == "泛人群" else "pending_resolve"
@@ -126,10 +232,18 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
     if dry_run:
         print(
             f"[dry-run] account={row.account_id} audience={row.audience_name} "
-            f"bid={row.bid_min_fen}-{row.bid_max_fen} source={source_account_id} status={grant_status}"
+            f"bid={row.bid_min_fen}-{row.bid_max_fen} source={source_account_id} "
+            f"status={grant_status} material_source={row.material_source} "
+            f"ai_fallback={row.ai_fallback_to_history} bid_scene={row.bid_scene} "
+            f"custom_cost_cap={row.custom_cost_cap_fen} "
+            f"automatic_site={row.automatic_site_enabled} site_set={row.site_set} "
+            f"age={row.age_min}-{row.age_max} regions={row.region_ids} "
+            f"config_date={row.config_date}"
         )
         return
 
+    ensure_account_material_strategy_columns()
+    ensure_account_delivery_config_columns()
     conn = get_connection()
     try:
         with conn.cursor() as cur:
@@ -182,21 +296,31 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
                 """
                 INSERT INTO ad_creation_account_config
                   (account_id, enabled, delivery_version, brand_key, feedback_key,
+                   material_source, ai_fallback_to_history,
                    audience_name, audience_pack_id, audience_tier_label,
-                   bid_min_fen, bid_max_fen, bid_amount_fen, daily_budget_fen,
-                   age_min, age_max, audience_source_account_id, audience_grant_status,
+                   bid_min_fen, bid_max_fen, bid_amount_fen, bid_scene,
+                   custom_cost_cap_fen,
+                   daily_budget_fen, automatic_site_enabled, site_set_json,
+                   location_types_json, region_ids_json,
+                   age_min, age_max, config_date, audience_source_account_id, audience_grant_status,
                    remark, created_by, updated_by)
                 VALUES
                   (%s, TRUE, %s, %s, %s,
+                   %s, %s,
                    %s, %s, %s,
                    %s, %s, %s, %s,
-                   %s, %s, %s, %s,
+                   %s,
+                   %s, %s, %s,
+                   %s, %s,
+                   %s, %s, %s, %s, %s,
                    %s, %s, %s)
                 ON DUPLICATE KEY UPDATE
                   enabled=TRUE,
                   delivery_version=VALUES(delivery_version),
                   brand_key=VALUES(brand_key),
                   feedback_key=VALUES(feedback_key),
+                  material_source=VALUES(material_source),
+                  ai_fallback_to_history=VALUES(ai_fallback_to_history),
                   audience_pack_id=IF(
                     ad_creation_account_config.audience_name = VALUES(audience_name),
                     ad_creation_account_config.audience_pack_id,
@@ -206,9 +330,16 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
                   bid_min_fen=VALUES(bid_min_fen),
                   bid_max_fen=VALUES(bid_max_fen),
                   bid_amount_fen=VALUES(bid_amount_fen),
+                  bid_scene=VALUES(bid_scene),
+                  custom_cost_cap_fen=VALUES(custom_cost_cap_fen),
                   daily_budget_fen=VALUES(daily_budget_fen),
+                  automatic_site_enabled=VALUES(automatic_site_enabled),
+                  site_set_json=VALUES(site_set_json),
+                  location_types_json=VALUES(location_types_json),
+                  region_ids_json=VALUES(region_ids_json),
                   age_min=VALUES(age_min),
                   age_max=VALUES(age_max),
+                  config_date=VALUES(config_date),
                   audience_source_account_id=IF(
                     ad_creation_account_config.audience_name = VALUES(audience_name),
                     ad_creation_account_config.audience_source_account_id,
@@ -233,15 +364,24 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
                     DEFAULT_DELIVERY_VERSION,
                     DEFAULT_BRAND_KEY,
                     DEFAULT_FEEDBACK_KEY,
+                    row.material_source,
+                    1 if row.ai_fallback_to_history else 0,
                     row.audience_name,
                     audience_pack_id,
                     row.audience_name,
                     row.bid_min_fen,
                     row.bid_max_fen,
                     row.bid_amount_fen,
+                    row.bid_scene,
+                    row.custom_cost_cap_fen,
                     row.daily_budget_fen,
-                    DEFAULT_AGE_MIN,
-                    DEFAULT_AGE_MAX,
+                    None if row.automatic_site_enabled is None else (1 if row.automatic_site_enabled else 0),
+                    json.dumps(row.site_set, ensure_ascii=False) if row.site_set is not None else None,
+                    json.dumps(row.location_types, ensure_ascii=False) if row.location_types is not None else None,
+                    json.dumps(row.region_ids, ensure_ascii=False) if row.region_ids is not None else None,
+                    row.age_min,
+                    row.age_max,
+                    row.config_date,
                     source_account_id,
                     grant_status,
                     "configured from account_id/audience_name/bid triple",
@@ -255,14 +395,22 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
 
     print(
         f"configured account={row.account_id} audience={row.audience_name} "
-        f"bid={row.bid_min_fen / 100:.2f}-{row.bid_max_fen / 100:.2f} status={grant_status}"
+        f"bid={row.bid_min_fen / 100:.2f}-{row.bid_max_fen / 100:.2f} "
+        f"bid_scene={row.bid_scene} custom_cost_cap={row.custom_cost_cap_fen} "
+        f"age={row.age_min}-{row.age_max} regions={row.region_ids} "
+        f"config_date={row.config_date} status={grant_status}"
     )
 
 
-def set_account_automation_enabled(account_id: int, enabled: bool, dry_run: bool = False) -> None:
+def set_account_automation_enabled(
+    account_id: int,
+    enabled: bool,
+    config_date: date | None = None,
+    dry_run: bool = False,
+) -> None:
     """启用/禁用账户的自动化投放开关(更新 ad_creation_account_config.enabled)"""
     if dry_run:
-        print(f"[dry-run] set account={account_id} enabled={enabled}")
+        print(f"[dry-run] set account={account_id} enabled={enabled} config_date={config_date}")
         return
 
     from db.connection import get_connection
@@ -273,10 +421,10 @@ def set_account_automation_enabled(account_id: int, enabled: bool, dry_run: bool
             cur.execute(
                 """
                 UPDATE ad_creation_account_config
-                SET enabled=%s, updated_by='configure_creation_accounts'
+                SET enabled=%s, config_date=%s, updated_by='configure_creation_accounts'
                 WHERE account_id=%s
                 """,
-                (1 if enabled else 0, account_id),
+                (1 if enabled else 0, config_date, account_id),
             )
         conn.commit()
     finally:

+ 7 - 0
examples/auto_put_ad_mini/db/connection.py

@@ -59,6 +59,10 @@ def get_connection():
             "  DB_PORT=3306  # 可选,默认3306"
         )
 
+    connect_timeout = int(os.getenv("DB_CONNECT_TIMEOUT", "10"))
+    read_timeout = int(os.getenv("DB_READ_TIMEOUT", "60"))
+    write_timeout = int(os.getenv("DB_WRITE_TIMEOUT", "60"))
+
     try:
         conn = pymysql.connect(
             host=host,
@@ -69,6 +73,9 @@ def get_connection():
             charset="utf8mb4",
             cursorclass=pymysql.cursors.DictCursor,  # 返回字典格式
             autocommit=True,  # 自动提交(简化事务管理)
+            connect_timeout=connect_timeout,
+            read_timeout=read_timeout,
+            write_timeout=write_timeout,
         )
         logger.debug(f"数据库连接成功: {user}@{host}:{port}/{database}")
         return conn

+ 210 - 1
examples/auto_put_ad_mini/db/schema.sql

@@ -54,15 +54,24 @@ CREATE TABLE IF NOT EXISTS ad_creation_account_config (
     delivery_version VARCHAR(100) NOT NULL COMMENT '投放版本',
     brand_key VARCHAR(100) DEFAULT NULL COMMENT '品牌资产模板key',
     feedback_key VARCHAR(100) DEFAULT NULL COMMENT '监测链接模板key',
+    material_source VARCHAR(50) NOT NULL DEFAULT 'history' COMMENT '创意素材来源:history/ai_generated',
+    ai_fallback_to_history BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'AI生成失败时是否回退历史素材',
     audience_name VARCHAR(200) NOT NULL COMMENT '人工配置的人群包名称',
     audience_pack_id BIGINT DEFAULT NULL COMMENT '系统解析/验证后的人群包ID;NULL表示泛人群',
     audience_tier_label VARCHAR(100) DEFAULT NULL COMMENT '审批/命名展示用人群标签',
     bid_min_fen INT NOT NULL COMMENT '最低出价(分)',
     bid_max_fen INT NOT NULL COMMENT '最高出价(分)',
     bid_amount_fen INT DEFAULT NULL COMMENT '固定出价(分);NULL则按范围取值',
+    bid_scene VARCHAR(50) DEFAULT NULL COMMENT '出价方式:average_cost/max_conversion',
+    custom_cost_cap_fen INT DEFAULT NULL COMMENT '最大转化量控制成本(分),用于 custom_cost_cap',
     daily_budget_fen INT DEFAULT NULL COMMENT '账户级单广告日预算(分);NULL则使用投放模板默认预算',
+    automatic_site_enabled BOOLEAN DEFAULT NULL COMMENT '账户级是否开启智能版位;NULL使用模板默认手动版位',
+    site_set_json TEXT DEFAULT NULL COMMENT '账户级投放版位覆盖JSON;NULL使用投放模板',
+    location_types_json TEXT DEFAULT NULL COMMENT '账户级地域类型覆盖JSON;NULL使用投放模板',
+    region_ids_json MEDIUMTEXT DEFAULT NULL COMMENT '账户级地域ID覆盖JSON;NULL使用投放模板,空数组表示不限地域',
     age_min INT NOT NULL COMMENT '最小年龄',
     age_max INT NOT NULL COMMENT '最大年龄',
+    config_date DATE DEFAULT NULL COMMENT '飞书配置生效日期;仅当天配置参与自动创建/补创意',
     audience_source_account_id BIGINT DEFAULT NULL COMMENT '人群包来源账户;默认可由 TENCENT_AUDIENCE_SOURCE_ACCOUNT_ID 提供',
     audience_grant_status VARCHAR(50) DEFAULT NULL COMMENT '授权/验证状态:not_required_no_audience_pack/resolved/grant_submitted/verified',
     audience_verified_at TIMESTAMP NULL DEFAULT NULL COMMENT '目标账户可见验证时间',
@@ -77,6 +86,7 @@ CREATE TABLE IF NOT EXISTS ad_creation_account_config (
     KEY idx_delivery_version (delivery_version),
     KEY idx_brand_key (brand_key),
     KEY idx_feedback_key (feedback_key),
+    KEY idx_material_source (material_source),
     KEY idx_audience_name (audience_name)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='待投放账户广告创建配置';
 
@@ -193,6 +203,7 @@ CREATE TABLE IF NOT EXISTS creative_creation_task (
     UNIQUE KEY uk_account_creative (account_id, dynamic_creative_id),
     KEY idx_review_status (review_status),
     KEY idx_submitted_at (submitted_at),
+    KEY idx_landing_submitted (landing_video_id, submitted_at),
     KEY idx_last_review_checked_at (last_review_checked_at),
     KEY idx_account_status (account_id, review_status)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意创建与审核扫描任务';
@@ -269,7 +280,7 @@ CREATE TABLE IF NOT EXISTS creative_material_usage (
     account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
     adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
     crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
-    landing_video_id BIGINT DEFAULT NULL COMMENT '承接视频ID,仅审计不默认排重',
+    landing_video_id BIGINT DEFAULT NULL COMMENT '承接视频ID,用于同人群包近期排重',
     material_id VARCHAR(100) NOT NULL COMMENT '内部素材ID',
     material_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
     dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
@@ -280,11 +291,209 @@ CREATE TABLE IF NOT EXISTS creative_material_usage (
     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
 
     KEY idx_crowd_material_created (crowd_package, material_id, created_at),
+    KEY idx_crowd_landing_created (crowd_package, landing_video_id, created_at),
     KEY idx_crowd_account_ad_material (crowd_package, account_id, adgroup_id, material_id),
     KEY idx_account_created (account_id, created_at),
     KEY idx_status_created (status, created_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意素材使用历史/占位';
 
+-- =====================================================
+-- 13. AI生成创意图片素材
+-- =====================================================
+CREATE TABLE IF NOT EXISTS ai_generated_material (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
+    adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
+    crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
+    landing_video_id BIGINT NOT NULL COMMENT '承接视频ID',
+    landing_title VARCHAR(500) DEFAULT NULL COMMENT '承接视频标题',
+    landing_category VARCHAR(200) DEFAULT NULL COMMENT '承接视频品类',
+    prompt_type VARCHAR(50) NOT NULL COMMENT '生成角度:topic',
+    prompt_text MEDIUMTEXT NOT NULL COMMENT '最终生成prompt',
+    prompt_feature_json MEDIUMTEXT DEFAULT NULL COMMENT 'prompt使用的视频特征JSON',
+    model VARCHAR(200) NOT NULL COMMENT 'OpenRouter模型',
+    oss_object_key VARCHAR(500) DEFAULT NULL COMMENT 'OSS对象key',
+    oss_url VARCHAR(1000) DEFAULT NULL COMMENT 'OSS公网URL',
+    status VARCHAR(50) NOT NULL DEFAULT 'generated' COMMENT 'generated/prepared/approved/rejected/hold/skip/posted_ok/post_failed/error',
+    approval_status VARCHAR(50) DEFAULT NULL COMMENT '人工审批状态',
+    tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
+    dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
+    ai_review_status VARCHAR(50) DEFAULT NULL COMMENT 'AI审核状态:pass/reject/hold/error',
+    ai_review_score INT DEFAULT NULL COMMENT 'AI审核评分0-100',
+    ai_review_model VARCHAR(200) DEFAULT NULL COMMENT 'AI审核模型',
+    ai_review_reason TEXT DEFAULT NULL COMMENT 'AI审核原因摘要',
+    ai_review_json MEDIUMTEXT DEFAULT NULL COMMENT 'AI审核原始JSON',
+    ai_reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT 'AI审核时间',
+    error TEXT DEFAULT NULL COMMENT '生成/上传/创建错误',
+    raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+    KEY idx_account_ad_landing_status (account_id, adgroup_id, landing_video_id, status),
+    KEY idx_crowd_created (crowd_package, created_at),
+    KEY idx_status_created (status, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成创意图片素材';
+
+-- =====================================================
+-- 14. 高消耗素材表现快照任务
+-- =====================================================
+CREATE TABLE IF NOT EXISTS material_performance_snapshot_run (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
+    window_start DATE NOT NULL COMMENT '统计窗口开始日期',
+    window_end DATE NOT NULL COMMENT '统计窗口结束日期',
+    top_n INT NOT NULL DEFAULT 5000 COMMENT '拉取TopN',
+    source VARCHAR(64) NOT NULL DEFAULT 'odps' COMMENT '数据来源',
+    sql_file VARCHAR(255) DEFAULT NULL COMMENT 'SQL文件路径',
+    row_count INT NOT NULL DEFAULT 0 COMMENT '明细行数',
+    total_cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内总消耗(分)',
+    status VARCHAR(32) NOT NULL DEFAULT 'SUCCESS' COMMENT '任务状态',
+    error_message MEDIUMTEXT DEFAULT NULL COMMENT '错误信息',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+    UNIQUE KEY uk_run_id (run_id),
+    KEY idx_window (window_start, window_end)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高消耗素材表现快照任务';
+
+-- =====================================================
+-- 15. 高消耗素材表现快照明细
+-- =====================================================
+CREATE TABLE IF NOT EXISTS material_performance_snapshot_item (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
+    rank_no INT NOT NULL COMMENT '消耗排名',
+    account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
+    ad_id BIGINT NOT NULL COMMENT '广告ID',
+    creative_id BIGINT NOT NULL COMMENT '创意ID',
+    creative_name VARCHAR(255) DEFAULT NULL COMMENT '创意名称',
+    ad_name VARCHAR(255) DEFAULT NULL COMMENT '广告名称',
+    video_id BIGINT DEFAULT NULL COMMENT '承接视频ID',
+    title VARCHAR(512) DEFAULT NULL COMMENT '素材标题',
+    image_url VARCHAR(1024) DEFAULT NULL COMMENT '素材图片URL',
+    image_hash VARCHAR(64) DEFAULT NULL COMMENT '图片内容hash',
+    crowd_package VARCHAR(255) DEFAULT NULL COMMENT '人群包名称',
+    optimization_goal VARCHAR(128) DEFAULT NULL COMMENT '优化目标',
+    bid_amount_fen BIGINT DEFAULT NULL COMMENT '出价(分)',
+    day_amount_fen BIGINT DEFAULT NULL COMMENT '日预算(分)',
+    cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '消耗(分)',
+    impressions BIGINT NOT NULL DEFAULT 0 COMMENT '曝光',
+    clicks BIGINT NOT NULL DEFAULT 0 COMMENT '点击',
+    ctr DECIMAL(10, 6) DEFAULT NULL COMMENT '点击率',
+    key_page_view_count BIGINT NOT NULL DEFAULT 0 COMMENT '关键页访问次数',
+    key_page_rate DECIMAL(10, 6) DEFAULT NULL COMMENT '关键页访问/点击',
+    conversions_count BIGINT NOT NULL DEFAULT 0 COMMENT '转化数',
+    conversion_rate DECIMAL(10, 6) DEFAULT NULL COMMENT '转化率',
+    active_days INT NOT NULL DEFAULT 0 COMMENT '有消耗天数',
+    first_dt VARCHAR(16) DEFAULT NULL COMMENT '首次投放日期',
+    last_dt VARCHAR(16) DEFAULT NULL COMMENT '最后投放日期',
+    raw_json MEDIUMTEXT DEFAULT NULL COMMENT '原始行JSON',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+    UNIQUE KEY uk_run_creative (run_id, creative_id),
+    KEY idx_creative (creative_id),
+    KEY idx_image_hash (image_hash),
+    KEY idx_video (video_id),
+    KEY idx_package_cost (crowd_package, cost_fen)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高消耗素材表现快照明细';
+
+-- =====================================================
+-- 16. 素材图片结构化标注
+-- =====================================================
+CREATE TABLE IF NOT EXISTS material_visual_annotation (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    image_hash VARCHAR(64) NOT NULL COMMENT '图片内容hash',
+    image_url VARCHAR(1024) NOT NULL COMMENT '素材图片URL',
+    annotation_version VARCHAR(64) NOT NULL COMMENT '标注版本',
+    annotator VARCHAR(64) NOT NULL COMMENT '标注来源',
+    creative_id BIGINT DEFAULT NULL COMMENT '样本创意ID',
+    visual_template VARCHAR(128) DEFAULT NULL COMMENT '视觉模板',
+    hook_category VARCHAR(255) DEFAULT NULL COMMENT '标题钩子分类',
+    title_text VARCHAR(512) DEFAULT NULL COMMENT '图片/素材标题',
+    title_length INT DEFAULT NULL COMMENT '标题长度',
+    scene_type VARCHAR(128) DEFAULT NULL COMMENT '场景类型',
+    person_type VARCHAR(128) DEFAULT NULL COMMENT '人物/主体估计',
+    has_human TINYINT DEFAULT NULL COMMENT '是否有人物',
+    text_area_level VARCHAR(32) DEFAULT NULL COMMENT '文字面积估计等级',
+    color_style VARCHAR(128) DEFAULT NULL COMMENT '颜色/调性',
+    button_like_element TINYINT NOT NULL DEFAULT 0 COMMENT '是否疑似按钮诱导',
+    fake_ui_risk TINYINT NOT NULL DEFAULT 0 COMMENT '假界面风险',
+    official_policy_risk TINYINT NOT NULL DEFAULT 0 COMMENT '官方/政策承诺风险',
+    medical_health_risk TINYINT NOT NULL DEFAULT 0 COMMENT '医疗健康风险',
+    politics_sensitive_risk TINYINT NOT NULL DEFAULT 0 COMMENT '涉政/国家情绪风险',
+    celebrity_or_history_risk TINYINT NOT NULL DEFAULT 0 COMMENT '名人/历史人物风险',
+    strong_inducement_risk TINYINT NOT NULL DEFAULT 0 COMMENT '强诱导风险',
+    greeting_blessing_risk TINYINT NOT NULL DEFAULT 0 COMMENT '早晚安/祝福风险',
+    compliance_level VARCHAR(32) NOT NULL DEFAULT 'caution' COMMENT '生成可学习等级',
+    learnable_points MEDIUMTEXT DEFAULT NULL COMMENT '可学习点',
+    avoid_points MEDIUMTEXT DEFAULT NULL COMMENT '避让点',
+    raw_annotation MEDIUMTEXT DEFAULT NULL COMMENT '原始标注JSON',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+    UNIQUE KEY uk_image_version (image_hash, annotation_version),
+    KEY idx_creative (creative_id),
+    KEY idx_template (visual_template),
+    KEY idx_compliance (compliance_level)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材图片结构化标注';
+
+-- =====================================================
+-- 17. AI生成素材可学习创意模式
+-- =====================================================
+CREATE TABLE IF NOT EXISTS material_creative_pattern (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    pattern_version VARCHAR(64) NOT NULL COMMENT '策略版本',
+    pattern_key VARCHAR(128) NOT NULL COMMENT '策略唯一键',
+    pattern_name VARCHAR(128) NOT NULL COMMENT '策略名称',
+    hook_category VARCHAR(128) NOT NULL COMMENT '标题钩子类型',
+    visual_template VARCHAR(128) NOT NULL COMMENT '视觉模板',
+    applicable_crowd_packages VARCHAR(1024) DEFAULT NULL COMMENT '适用人群包',
+    applicable_placements VARCHAR(1024) DEFAULT NULL COMMENT '适用版位',
+    target_age_min INT DEFAULT NULL COMMENT '适用年龄下限',
+    target_age_max INT DEFAULT NULL COMMENT '适用年龄上限',
+    title_hook_rule MEDIUMTEXT NOT NULL COMMENT '标题钩子规则',
+    visual_rule MEDIUMTEXT NOT NULL COMMENT '视觉规则',
+    relevance_rule MEDIUMTEXT NOT NULL COMMENT '视频相关性规则',
+    compliance_rule MEDIUMTEXT NOT NULL COMMENT '合规规则',
+    selector_config MEDIUMTEXT DEFAULT NULL COMMENT '选择器配置JSON:match/exclude/score',
+    positive_examples MEDIUMTEXT DEFAULT NULL COMMENT '正例JSON',
+    negative_examples MEDIUMTEXT DEFAULT NULL COMMENT '反例JSON',
+    source_run_id VARCHAR(64) DEFAULT NULL COMMENT '来源快照ID',
+    source_material_count INT NOT NULL DEFAULT 0 COMMENT '来源素材数',
+    source_total_cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '来源素材消耗(分)',
+    status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/APPROVED/REJECTED',
+    reviewed_by VARCHAR(64) DEFAULT NULL COMMENT '审核人',
+    reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT '审核时间',
+    enabled TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+    UNIQUE KEY uk_version_key (pattern_version, pattern_key),
+    KEY idx_status_enabled (status, enabled),
+    KEY idx_hook_template (hook_category, visual_template)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成素材可学习创意模式';
+
+-- =====================================================
+-- 18. 素材策略学习周报
+-- =====================================================
+CREATE TABLE IF NOT EXISTS material_strategy_learning_report (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    report_id VARCHAR(64) NOT NULL COMMENT '报告ID',
+    run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
+    report_version VARCHAR(64) NOT NULL COMMENT '报告版本',
+    summary MEDIUMTEXT NOT NULL COMMENT '报告摘要',
+    top_patterns MEDIUMTEXT DEFAULT NULL COMMENT '核心模式JSON',
+    risk_summary MEDIUMTEXT DEFAULT NULL COMMENT '风险摘要JSON',
+    recommended_actions MEDIUMTEXT DEFAULT NULL COMMENT '建议动作JSON',
+    report_path VARCHAR(512) DEFAULT NULL COMMENT '本地报告路径',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+    UNIQUE KEY uk_report_id (report_id),
+    KEY idx_run_id (run_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材策略学习周报';
+
 -- =====================================================
 -- 初始化数据
 -- =====================================================

+ 166 - 0
examples/auto_put_ad_mini/debug_generate_ai_material.py

@@ -0,0 +1,166 @@
+"""手动调试 AI 图片生成和 OSS 上传。
+
+默认只生成图片并上传 OSS,打印 JSON 输出;不会创建广告/创意。
+
+示例:
+  python debug_generate_ai_material.py --video-id 71187017
+需要环境变量:
+  OPENROUTER_API_KEY
+  ALIYUN_OSS_ENDPOINT
+  ALIYUN_OSS_BUCKET
+  ALIYUN_OSS_ACCESS_KEY_ID
+  ALIYUN_OSS_ACCESS_KEY_SECRET
+  AI_IMAGE_PUBLIC_BASE_URL
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+_HERE = Path(__file__).parent
+load_dotenv(_HERE / ".env")
+sys.path.insert(0, str(_HERE.parent.parent))
+sys.path.insert(0, str(_HERE))
+
+from tools.ai_generated_material import (  # noqa: E402
+    AI_IMAGE_PATTERN_PLACEMENT,
+    AI_IMAGE_PATTERN_TOP_K,
+    OPENROUTER_TEXT_MODEL,
+    OPENROUTER_IMAGE_MODEL,
+    build_ai_image_object_key,
+    build_generation_prompts,
+    build_pattern_generation_prompts,
+    generate_image_bytes,
+    insert_and_review_generated_material,
+    sanitize_video_description,
+    upload_image_to_oss,
+)
+from tools.video_recall import LandingVideo  # noqa: E402
+from tools.video_feature_query import read_cached_video_element_features  # noqa: E402
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--video-id", type=int, default=0)
+    parser.add_argument("--title", default="")
+    parser.add_argument("--category", default="")
+    parser.add_argument("--model", default=OPENROUTER_IMAGE_MODEL)
+    parser.add_argument("--text-model", default=OPENROUTER_TEXT_MODEL)
+    parser.add_argument("--limit", type=int, default=1)
+    parser.add_argument("--account-id", type=int, default=0)
+    parser.add_argument("--adgroup-id", type=int, default=0)
+    parser.add_argument("--crowd-package", default="")
+    parser.add_argument("--placement", default=AI_IMAGE_PATTERN_PLACEMENT)
+    parser.add_argument("--pattern-top-k", type=int, default=AI_IMAGE_PATTERN_TOP_K)
+    parser.add_argument("--use-pattern-selector", action="store_true")
+    parser.add_argument("--write-db", action="store_true")
+    args = parser.parse_args()
+
+    if not args.video_id:
+        raise SystemExit("必须提供 --video-id")
+    features_by_vid = read_cached_video_element_features([args.video_id])
+    features = features_by_vid.get(args.video_id) or []
+    topic = next((f.standard_element for f in features if f.element_dimension == "解构选题"), "")
+    sanitized_description = sanitize_video_description(topic) if topic else ""
+    if args.use_pattern_selector:
+        built_prompts = build_pattern_generation_prompts(
+            video_id=args.video_id,
+            title=args.title,
+            category=args.category,
+            features=features,
+            sanitized_description=sanitized_description,
+            crowd_package=args.crowd_package,
+            placement=args.placement,
+            top_k=args.pattern_top_k,
+            text_model=args.text_model,
+        )
+    else:
+        built_prompts = build_generation_prompts(
+            video_id=args.video_id,
+            title=args.title,
+            category=args.category,
+            features=features,
+            sanitized_description=sanitized_description,
+        )
+    prompts = built_prompts[: max(1, args.limit)]
+
+    outputs = []
+    landing = LandingVideo(
+        video_id=args.video_id,
+        title=args.title,
+        cover_url="",
+        video_url="",
+        score=0,
+        rov=0,
+        sim=0,
+        visit_uv=0,
+        category=args.category,
+        standard_element="",
+        category_name="",
+        demand_content_title="",
+        demand_content_topic="",
+        demand_content_id="",
+        demand_type="",
+        point_type="",
+        dimension="",
+        experiment_id="",
+        raw={},
+    )
+    for prompt in prompts:
+        image_bytes, content_type, raw = generate_image_bytes(prompt.prompt_text, model=args.model)
+        ext = ".png" if content_type == "image/png" else ".jpg"
+        object_key = build_ai_image_object_key(
+            account_id=args.account_id or "debug",
+            landing_video_id=args.video_id,
+            prompt_type=prompt.prompt_type,
+            extension=ext,
+            debug=not args.write_db,
+        )
+        oss_url = upload_image_to_oss(image_bytes, content_type, object_key)
+        asset_id = None
+        review_output = None
+        if args.write_db:
+            asset, review = insert_and_review_generated_material(
+                account_id=args.account_id,
+                adgroup_id=args.adgroup_id,
+                crowd_package=args.crowd_package or "debug",
+                landing=landing,
+                prompt=prompt,
+                model=args.model,
+                object_key=object_key,
+                oss_url=oss_url,
+                raw_response=raw,
+            )
+            asset_id = asset.id
+            review_output = {
+                "status": review.status,
+                "score": review.score,
+                "reason": review.reason,
+                "risk_tags": review.risk_tags,
+                "ocr_text": review.ocr_text,
+            }
+        outputs.append({
+            "asset_id": asset_id,
+            "ai_review": review_output,
+            "prompt_type": prompt.prompt_type,
+            "model": args.model,
+            "text_model": args.text_model,
+            "content_type": content_type,
+            "oss_url": oss_url,
+            "object_key": object_key,
+            "prompt_text": prompt.prompt_text,
+            "feature_hits": prompt.feature_hits,
+            "raw_response_id": raw.get("id"),
+        })
+
+    print(json.dumps(outputs, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 123 - 0
examples/auto_put_ad_mini/debug_select_creative_patterns.py

@@ -0,0 +1,123 @@
+"""Debug creative pattern selection for one video.
+
+This script is read-only except the video feature cache may be populated when
+ODPS lookup is enabled. It does not generate images, upload OSS files, or create
+Tencent ads.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+_HERE = Path(__file__).parent
+load_dotenv(_HERE / ".env")
+sys.path.insert(0, str(_HERE))
+
+from tools.material_strategy_learning import select_creative_patterns  # noqa: E402
+from tools.video_feature_query import (  # noqa: E402
+    fetch_video_element_features,
+    read_cached_video_element_features,
+)
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="调试单个视频的创意 pattern 选择")
+    parser.add_argument("--video-id", type=int, required=True)
+    parser.add_argument("--crowd-package", default="", help="保留展示字段,pattern选择不按人群包区分")
+    parser.add_argument("--placement", default="")
+    parser.add_argument("--top-k", type=int, default=3)
+    parser.add_argument(
+        "--include-draft",
+        action="store_true",
+        default=True,
+        help="包含 DRAFT pattern,用于上线前调试评估",
+    )
+    parser.add_argument(
+        "--approved-only",
+        action="store_true",
+        help="只看 APPROVED/enabled=1 的生产可用 pattern",
+    )
+    parser.add_argument(
+        "--cached-only",
+        action="store_true",
+        help="只读本地视频特征缓存,不查 ODPS",
+    )
+    parser.add_argument(
+        "--no-model",
+        action="store_true",
+        help="关闭模型选择,只看非语义稳定兜底排序",
+    )
+    parser.add_argument(
+        "--model",
+        default=None,
+        help="覆盖 OPENROUTER_TEXT_MODEL,例如 google/gemini-2.5-flash",
+    )
+    return parser.parse_args()
+
+
+def _feature_to_dict(feature) -> dict:
+    return {
+        "video_id": feature.video_id,
+        "dt": feature.dt,
+        "element_dimension": feature.element_dimension,
+        "point_type": feature.point_type,
+        "standard_element": feature.standard_element,
+        "contribution_score": feature.contribution_score,
+    }
+
+
+def main() -> int:
+    args = parse_args()
+    if args.cached_only:
+        feature_map = read_cached_video_element_features([args.video_id])
+    else:
+        feature_map = fetch_video_element_features([args.video_id])
+    features = feature_map.get(args.video_id) or []
+    selections = select_creative_patterns(
+        video_features=features,
+        crowd_package=args.crowd_package,
+        placement=args.placement,
+        include_draft=not args.approved_only and args.include_draft,
+        top_k=args.top_k,
+        use_model=not args.no_model,
+        model=args.model,
+    )
+    payload = {
+        "video_id": args.video_id,
+        "crowd_package": args.crowd_package,
+        "placement": args.placement,
+        "selection_mode": "model_select" if not args.no_model else "stable_fallback",
+        "feature_count": len(features),
+        "features": [_feature_to_dict(feature) for feature in features],
+        "selected_patterns": [
+            {
+                "pattern_version": item.pattern.pattern_version,
+                "pattern_key": item.pattern.pattern_key,
+                "pattern_name": item.pattern.pattern_name,
+                "status": item.pattern.status,
+                "enabled": item.pattern.enabled,
+                "score": item.score,
+                "reasons": item.reasons,
+                "penalties": item.penalties,
+                "matched_features": item.matched_features,
+                "title_hook_rule": item.pattern.title_hook_rule,
+                "visual_rule": item.pattern.visual_rule,
+                "relevance_rule": item.pattern.relevance_rule,
+                "compliance_rule": item.pattern.compliance_rule,
+                "positive_examples": item.pattern.positive_examples or [],
+                "negative_examples": item.pattern.negative_examples or [],
+            }
+            for item in selections
+        ],
+    }
+    print(json.dumps(payload, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 134 - 0
examples/auto_put_ad_mini/docs/ad_automation_platform_architecture_plan_2026-07-25.md

@@ -0,0 +1,134 @@
+# 自动投放平台统一架构
+
+> 状态:已实现
+> 日期:2026-07-25
+
+## 1. 服务边界
+
+生产只启动两个服务,共用 `ad-put-agent` 完整镜像。
+
+| 服务 | 运行方式 | 职责 |
+|---|---|---|
+| `ad-control-service` | 常驻 | 飞书控制命令、运营暂停状态、每 10 分钟 CPM 调控、次日恢复 |
+| `ad-daily-service` | 常驻调度 | 每日 10:30 广告/创意创建、每 2 小时创意审核扫描 |
+
+旧 `server.py`、`execute_once.py` 和 `run.py` 的模型调控链路不再作为生产入口。
+现有 ROI 纯计算函数保留,未来日级 ROI 服务可以复用,但本期不自动调价。
+
+## 2. 日级流程
+
+`ad-daily-service` 不重写业务流程,只调度已有脚本:
+
+```text
+每天 10:30
+  execute_creation_once.py
+    飞书配置同步
+    -> 广告缺口检查与创建
+    -> 创意缺口检查
+    -> 历史素材召回 / AI 生成
+    -> 现有飞书在线表格审批
+    -> 审批通过项提交腾讯
+
+每 2 小时
+  scan_creative_reviews.py
+    -> 查询腾讯正式审核结果
+    -> 写入审核结果和拒审事实
+```
+
+每类任务使用独立 MySQL advisory lock,防止多 Pod 重复运行。创建流程仍保持
+原有幂等、审批和副作用边界。创建、审核扫描和模板同步默认均关闭,生产必须
+通过 `DAILY_*_ENABLED` 和 `DAILY_SYNC_DELIVERY_TEMPLATE` 显式启用。
+
+## 3. 实时流程
+
+`ad-control-service` 内有两个并发入口:
+
+```text
+飞书 WebSocket
+  -> 确定性解析
+  -> 权限校验
+  -> 生成操作预览
+  -> 二次确认
+  -> 腾讯写入和回读
+
+每 10 分钟实时循环
+  -> 运营暂停状态对账
+  -> 当天暂停到期处理
+  -> 获取最新小时 CPM
+  -> CPM 决策
+  -> 合并最终广告状态和出价
+  -> 腾讯写入和回读
+  -> 动作审计与飞书通知
+```
+
+两个入口共用 `RTC_DB_LOCK_NAME`,同一时刻只允许一个流程修改腾讯广告。
+
+实时 CPM 规则保持:
+
+- `CPM > 250`:基础出价上调 25%,每天最多一次。
+- `190 <= CPM <= 250`:恢复基础出价和 CPM 策略暂停。
+- `CPM < 190`:恢复基础出价,将开始日期延后至次日,保持广告开启。
+- 默认每天 21:00:只恢复基础出价,不主动暂停广告。
+- 次日 06:00:由腾讯广告投放时段自动开始投放;实时 CPM 调控默认 12:00 开始。
+
+## 4. 飞书命令
+
+首期只接受确定性生产命令,不接入开放式模型问答:
+
+```text
+暂停全部
+暂停账户 86197363,86748325
+停止全部
+停止账户 86197363 86748325
+恢复全部
+恢复账户 86197363、86748325
+查看暂停状态
+确认 cmd_xxx
+取消 cmd_xxx
+```
+
+- `暂停`:暂停到下一投放日 06:00。
+- `停止`:持续停止,只有明确恢复或腾讯后台人工开启才解除。
+- `恢复`:解除运营暂停;如果仍有 CPM 暂停,不会误开广告。
+- 所有腾讯写命令都必须在 10 分钟内二次确认。
+- 群聊必须来自配置群并 @机器人;发送人必须在允许列表。
+- 飞书消息 ID 是幂等键,重复事件不会重复创建命令。
+- “全部”表示 `ad_creation_account_config` 历史账户与启用白名单的交集,
+  不受当前是否继续新建广告影响。
+
+## 5. 状态与优先级
+
+`realtime_control_ad_state` 分开保存:
+
+- `paused_by_strategy`:CPM 实时策略暂停。
+- `operator_pause_mode=UNTIL_NEXT_DELIVERY`:运营当天暂停。
+- `operator_pause_mode=UNTIL_MANUAL`:运营持续停止。
+
+优先级为:
+
+```text
+未到期运营暂停 > CPM 状态
+```
+
+只认领命令执行时腾讯状态为 `NORMAL` 的广告。原本人工暂停的广告只记录跳过,
+不会被后续自动恢复。腾讯后台人工重新开启运营停止广告时,系统尊重人工操作,
+清除运营暂停状态并发送通知。
+
+命令、广告明细、写前状态、目标状态、回读状态和错误分别保存到
+`operator_command`、`operator_command_item`。腾讯实时动作继续写入
+`realtime_control_action_log`。
+
+## 6. 配置所有权
+
+- 飞书/数据库:账户、人群包、出价、预算、素材来源、年龄、地域、版位。
+- 日级服务:下一投放日的广告和创意供给;未来拥有 `base_bid_fen`。
+- 实时服务:盘中状态和临时倍率,不改账户配置和长期投放日期。
+- 新建广告统一使用 `06:00-20:00`,代码默认值和启用投放模板在日级服务启动时校准。
+- 历史广告不会因本次代码发布自动批量修改投放时段。
+
+## 7. 暂不实现
+
+- 不实现自动日级 ROI 调价,只保留基础出价所有权边界。
+- 不实现预算节奏调控。
+- 不引入队列、Redis、Celery 或额外 worker。
+- 不让模型解析账户 ID 或直接执行腾讯写操作。

+ 211 - 0
examples/auto_put_ad_mini/docs/ai_material_strategy_plan_2026-07-07.md

@@ -0,0 +1,211 @@
+# AI 生成素材差异化与高消耗素材归因方案
+
+## 背景
+
+当前自动投放链路已经可以按飞书/数据库配置创建广告和创意,并支持历史素材召回与 AI 生成素材。但从近期自动化账户与人工账户表现对比看,问题不应只归因于出价或版位。人工账户在相近出价下可以起量,说明素材竞争力、素材差异化、创意数量和冷启动素材供给是更关键的变量。
+
+腾讯广告 3.0 官方最佳实践也强调:
+
+- 系统会理解广告创意和内容,并据此判断适合哪些人群和场景。
+- 3.0 不鼓励堆重复广告计划,而是鼓励少广告、多创意、多素材。
+- 素材是广告主最可控、最重要的优化变量。
+- 有效创意可用于冷启动,有可能加速效果。
+- 同计划、同版位应避免重复素材堆积,否则可能影响账户跑量效果。
+- 如无特殊需求,建议使用最大转化量投放,给系统更多机会跑出最优模型。
+- 每条广告建议准备更多创意,实测 15-30 个创意通常比少量创意更容易跑出效果。
+- 应分版位测试,并把不同版位的数据反馈给前端素材策略。
+
+## 当前判断
+
+### 不是单纯价格问题
+
+人工账户同样可以在 0.48 元左右出价下跑量,自动账户部分也已经切到最大转化量和相近控制成本,但跑量差异仍存在。因此加价可以作为冷启动探索手段,但不能替代素材策略。
+
+### 当前 AI 素材的主要短板
+
+1. Prompt 偏合规和生活化,但信息流钩子不足。
+2. 目标用户写法偏 60-75 岁,而实际投放年龄常见为 45-66 岁,可能导致画面过老、过慢、过温情。
+3. 同一个视频主要按 topic 生成,缺少按选题、关键点、目的点、灵感点的多角度素材。
+4. 生成逻辑没有按版位区分,朋友圈、公众号、小程序、内容平台使用同一风格。
+5. 同人群包多账户可能使用相似视频、相似标题、相似视觉模板,差异化不足。
+6. 当前每条广告有效创意目标不少于 4 个,这是最低可运行标准,不是 3.0 最佳实践下的理想素材供给。
+
+## 数据归因方案
+
+先拉取最近 30 天高消耗素材数据,按 `creative_id` 聚合,用于归纳人工跑量素材的共性。
+
+### 时间窗口
+
+默认使用最近 30 天,截至昨日。
+
+示例:
+
+- 当前日期:2026-07-07
+- 结束日期:2026-07-06
+- 开始日期:2026-06-07
+
+后续可扩展为 7 天、14 天、30 天多窗口对比。
+
+### 聚合字段
+
+核心指标:
+
+- `cost`:总消耗
+- `view_count`:曝光
+- `valid_click_count`:点击
+- `ctr`:点击率
+- `key_page_view_count`:关键页面访问
+- `key_page_rate`:关键页面访问 / 点击
+- `conversions_count`:转化数
+- `conversion_rate`:转化数 / 点击
+- `active_days`:有消耗天数
+- `first_dt` / `last_dt`:投放日期范围
+
+素材与上下文字段:
+
+- `account_id`
+- `ad_id`
+- `ad_name`
+- `creative_id`
+- `creative_name`
+- `video_id`
+- `title`
+- `image_url`
+- `package_name`
+- `optimization_goal`
+
+### 初步筛选口径
+
+用于人工归纳的候选池建议分三层:
+
+1. 高消耗池:`cost` 倒序 Top N。
+2. 高点击池:`view_count >= 2000` 且 `ctr` 较高。
+3. 高质量池:`clicks`、`key_page_rate`、`conversions_count` 同时较好。
+
+不要只看消耗,否则会把高出价或大流量版位误判为好素材。也不要只看 CTR,否则可能学到标题党但低质量点击。
+
+## 高消耗素材归纳维度
+
+### 标题钩子
+
+归纳标题结构,而不是复制标题。
+
+重点分类:
+
+- 时间锚点:今年、下个月、退休后、晚年、听完以后。
+- 对象明确:老人、退休人员、家里人、子女、老两口、邻里。
+- 数字清单:3 类、9 项、8 个字、31 省。
+- 信息差:很多人没弄懂、刚知道、答案出来了、原来如此。
+- 未完成感:发生了什么、你知道吗、后来才明白。
+- 情绪共鸣:说到心里、听完沉默、活明白了。
+
+### 视觉模板
+
+需要用多模态分析图片本身,单看标题不够。
+
+重点识别:
+
+- 人物年龄、性别、表情、人数。
+- 场景:家庭、社区、饭桌、院子、舞台、新闻感、奇观。
+- 道具:纸质清单、手机、账本、照片、话筒、资料。
+- 标题位置、字号、颜色、描边、是否一屏可读。
+- 是否有副标题、按钮、二维码、假界面、官方感元素。
+- 是否存在外国人物、AI 感、错字、乱码、畸形人物。
+
+### 版位适配
+
+后续应按版位总结素材风格:
+
+- 公众号/订阅号:更偏强标题、信息差、封面感。
+- 朋友圈:更偏原生内容、人物真实感、情绪共鸣。
+- 小程序流量位:标题必须更直接,首屏识别成本更低。
+- 内容平台:可测试奇观、见闻、常识、故事类素材。
+
+## Prompt 改造方向
+
+不建议直接把历史高消耗标题塞进 prompt。应该把高消耗素材抽象成结构化规则。
+
+### 新增动态变量
+
+建议 prompt 支持:
+
+- `target_age_range`:从投放配置读取,例如 45-66。
+- `crowd_package`:人群包名称。
+- `placement_context`:公众号、朋友圈、小程序、内容平台等。
+- `hook_type`:标题钩子类型。
+- `visual_template`:视觉模板。
+- `video_description`:承接视频主题描述。
+- `forbidden_recent_titles`:近期已用标题,避免重复。
+
+### 多角度生成
+
+同一个视频不应只生成 1 张 topic 图。建议按多个角度生成候选:
+
+- `info_gap_list`:信息差清单型。
+- `family_reminder`:家庭提醒型。
+- `emotion_reversal`:情绪反转型。
+- `social_observation`:社会见闻型。
+- `life_common_sense`:生活常识型。
+- `stage_resonance`:歌曲/舞台/共鸣型。
+
+### 冷启动素材组合
+
+新广告冷启动不建议完全依赖 AI 新图。更稳的组合是:
+
+- 历史已验证高消耗/高 CTR 素材风格。
+- AI 新生成差异化素材。
+- 同视频或同主题的不同 hook_type。
+- 同人群包下不同账户避免重复视频、重复标题、重复视觉模板。
+
+## 投放验证方案
+
+### 创意数量
+
+当前不少于 4 个有效创意是最低标准。建议逐步做实验:
+
+- 第一阶段:每广告 4-8 个有效创意。
+- 第二阶段:每广告 8-15 个有效创意。
+- 第三阶段:对稳定账户测试 15-30 个有效创意。
+
+每次扩量都要记录素材来源、hook_type、visual_template 和表现。
+
+### 冷启动加价
+
+加价可以作为实验,但不作为主解法。
+
+建议:
+
+- 只对素材供给充足的账户做。
+- 前 24-48 小时控制成本从 0.48 提到 0.55-0.60。
+- 达到基本曝光/点击后再回落或按数据调整。
+- 若 CTR、关键页面访问率差,不要继续靠加价硬跑。
+
+### 数据回流
+
+AI 生成素材必须记录以下字段,用于后续学习:
+
+- `prompt_version`
+- `hook_type`
+- `visual_template`
+- `model`
+- `landing_video_id`
+- `crowd_package`
+- `placement_context`
+- `title`
+- `image_url`
+- `creative_id`
+- `cost`
+- `ctr`
+- `key_page_rate`
+- `conversions_count`
+- `review_status`
+
+## 下一步
+
+1. 编写最近 30 天高消耗素材聚合 SQL。
+2. 拉取并落盘高消耗素材数据集。
+3. 先做标题和指标层面的归纳。
+4. 再抽样图片做多模态理解,归纳视觉模板。
+5. 基于归纳结果修改 AI 生成 prompt。
+6. 设计小流量 A/B:历史素材、AI 生成素材、混合素材、不同创意数量。
+

+ 282 - 0
examples/auto_put_ad_mini/docs/material_strategy_learning_db_design_2026-07-08.md

@@ -0,0 +1,282 @@
+# 高消耗素材策略学习入库与周更方案
+
+## 背景
+
+自动投放账户近期跑量弱的问题,不能只从出价、预算、版位解释。人工账户在相近出价下可以起量,说明素材竞争力、创意差异化、创意数量和冷启动素材供给是核心变量之一。
+
+当前已经完成最近 30 天高消耗素材 Top5000 拉取,并对 Top100 做了标题、视觉模板、风险标签和素材风格归纳。下一步不能只把这些结果放在本地文件里,否则每次分析都会丢上下文,也无法支撑后续每周自动更新、人工 review 和 AI 生成 prompt 的稳定迭代。
+
+本方案目标是把“素材表现快照、图片结构化标注、可学习创意模式、人工审核结论”沉淀到数据库,让后续 AI 生成素材可以读取经过审核的策略,同时保留历史版本用于回溯。
+
+## 设计原则
+
+- 高消耗素材是学习样本,不是直接复制对象。
+- 入库内容分为原始表现快照、结构化标注、策略模式三层,不要混在一张表里。
+- 每周生成新的数据快照,历史快照不覆盖。
+- 同一素材图片可以复用历史标注,避免重复下载和重复理解。
+- 策略模式必须经过人工审核后才能进入生成链路。
+- 生成素材时仍以承接视频内容为主,高消耗素材模式只提供标题钩子和视觉表达方法。
+- 合规优先级高于 CTR。历史高消耗中出现的伪按钮、假界面、强诱导、政策承诺、医疗恐吓、涉政民族情绪等模式不能直接学习。
+
+## 表设计
+
+### material_performance_snapshot_run
+
+记录每次素材表现数据拉取任务。
+
+```sql
+CREATE TABLE material_performance_snapshot_run (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT,
+  run_id VARCHAR(64) NOT NULL,
+  window_start DATE NOT NULL,
+  window_end DATE NOT NULL,
+  top_n INT NOT NULL DEFAULT 5000,
+  source VARCHAR(64) NOT NULL DEFAULT 'odps',
+  sql_file VARCHAR(255) DEFAULT NULL,
+  row_count INT NOT NULL DEFAULT 0,
+  total_cost_fen BIGINT NOT NULL DEFAULT 0,
+  status VARCHAR(32) NOT NULL DEFAULT 'SUCCESS',
+  error_message TEXT DEFAULT NULL,
+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_run_id (run_id),
+  KEY idx_window (window_start, window_end)
+);
+```
+
+### material_performance_snapshot_item
+
+记录每次快照下的创意级表现。该表是事实快照,允许同一个 `creative_id` 在不同周重复出现。
+
+```sql
+CREATE TABLE material_performance_snapshot_item (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT,
+  run_id VARCHAR(64) NOT NULL,
+  rank_no INT NOT NULL,
+  account_id BIGINT NOT NULL,
+  ad_id BIGINT NOT NULL,
+  creative_id BIGINT NOT NULL,
+  creative_name VARCHAR(255) DEFAULT NULL,
+  ad_name VARCHAR(255) DEFAULT NULL,
+  video_id BIGINT DEFAULT NULL,
+  title VARCHAR(512) DEFAULT NULL,
+  image_url VARCHAR(1024) DEFAULT NULL,
+  image_hash VARCHAR(64) DEFAULT NULL,
+  crowd_package VARCHAR(255) DEFAULT NULL,
+  optimization_goal VARCHAR(128) DEFAULT NULL,
+  bid_amount_fen BIGINT DEFAULT NULL,
+  day_amount_fen BIGINT DEFAULT NULL,
+  cost_fen BIGINT NOT NULL DEFAULT 0,
+  impressions BIGINT NOT NULL DEFAULT 0,
+  clicks BIGINT NOT NULL DEFAULT 0,
+  ctr DECIMAL(10, 6) DEFAULT NULL,
+  key_page_view_count BIGINT NOT NULL DEFAULT 0,
+  key_page_rate DECIMAL(10, 6) DEFAULT NULL,
+  conversions_count BIGINT NOT NULL DEFAULT 0,
+  conversion_rate DECIMAL(10, 6) DEFAULT NULL,
+  active_days INT NOT NULL DEFAULT 0,
+  first_dt INT DEFAULT NULL,
+  last_dt INT DEFAULT NULL,
+  raw_json MEDIUMTEXT DEFAULT NULL,
+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_run_creative (run_id, creative_id),
+  KEY idx_creative (creative_id),
+  KEY idx_image_hash (image_hash),
+  KEY idx_video (video_id),
+  KEY idx_package_cost (crowd_package, cost_fen)
+);
+```
+
+### material_visual_annotation
+
+记录素材图片的结构化理解结果。以 `image_hash + annotation_version` 做唯一键,同一图片后续可用更强模型重新标注,但不覆盖老结果。
+
+```sql
+CREATE TABLE material_visual_annotation (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT,
+  image_hash VARCHAR(64) NOT NULL,
+  image_url VARCHAR(1024) NOT NULL,
+  annotation_version VARCHAR(64) NOT NULL,
+  annotator VARCHAR(64) NOT NULL,
+  visual_template VARCHAR(128) DEFAULT NULL,
+  hook_category VARCHAR(128) DEFAULT NULL,
+  title_text VARCHAR(512) DEFAULT NULL,
+  title_length INT DEFAULT NULL,
+  scene_type VARCHAR(128) DEFAULT NULL,
+  person_type VARCHAR(128) DEFAULT NULL,
+  has_human TINYINT DEFAULT NULL,
+  text_area_level VARCHAR(32) DEFAULT NULL,
+  color_style VARCHAR(128) DEFAULT NULL,
+  button_like_element TINYINT NOT NULL DEFAULT 0,
+  fake_ui_risk TINYINT NOT NULL DEFAULT 0,
+  official_policy_risk TINYINT NOT NULL DEFAULT 0,
+  medical_health_risk TINYINT NOT NULL DEFAULT 0,
+  politics_sensitive_risk TINYINT NOT NULL DEFAULT 0,
+  celebrity_or_history_risk TINYINT NOT NULL DEFAULT 0,
+  strong_inducement_risk TINYINT NOT NULL DEFAULT 0,
+  greeting_blessing_risk TINYINT NOT NULL DEFAULT 0,
+  compliance_level VARCHAR(32) NOT NULL DEFAULT 'caution',
+  learnable_points TEXT DEFAULT NULL,
+  avoid_points TEXT DEFAULT NULL,
+  raw_annotation MEDIUMTEXT DEFAULT NULL,
+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_image_version (image_hash, annotation_version),
+  KEY idx_template (visual_template),
+  KEY idx_compliance (compliance_level)
+);
+```
+
+### material_creative_pattern
+
+沉淀可用于 AI 生成素材的“模式”。这张表不是原始素材库,而是人工审核后的策略库。
+
+```sql
+CREATE TABLE material_creative_pattern (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT,
+  pattern_version VARCHAR(64) NOT NULL,
+  pattern_key VARCHAR(128) NOT NULL,
+  pattern_name VARCHAR(128) NOT NULL,
+  hook_category VARCHAR(128) NOT NULL,
+  visual_template VARCHAR(128) NOT NULL,
+  applicable_crowd_packages VARCHAR(1024) DEFAULT NULL,
+  applicable_placements VARCHAR(1024) DEFAULT NULL,
+  target_age_min INT DEFAULT NULL,
+  target_age_max INT DEFAULT NULL,
+  title_hook_rule TEXT NOT NULL,
+  visual_rule TEXT NOT NULL,
+  relevance_rule TEXT NOT NULL,
+  compliance_rule TEXT NOT NULL,
+  positive_examples MEDIUMTEXT DEFAULT NULL,
+  negative_examples MEDIUMTEXT DEFAULT NULL,
+  source_run_id VARCHAR(64) DEFAULT NULL,
+  source_material_count INT NOT NULL DEFAULT 0,
+  source_total_cost_fen BIGINT NOT NULL DEFAULT 0,
+  status VARCHAR(32) NOT NULL DEFAULT 'DRAFT',
+  reviewed_by VARCHAR(64) DEFAULT NULL,
+  reviewed_at DATETIME DEFAULT NULL,
+  enabled TINYINT NOT NULL DEFAULT 0,
+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_version_key (pattern_version, pattern_key),
+  KEY idx_status_enabled (status, enabled),
+  KEY idx_hook_template (hook_category, visual_template)
+);
+```
+
+### material_strategy_learning_report
+
+保存每周自动生成的策略学习摘要,方便运营和研发复盘。
+
+```sql
+CREATE TABLE material_strategy_learning_report (
+  id BIGINT PRIMARY KEY AUTO_INCREMENT,
+  report_id VARCHAR(64) NOT NULL,
+  run_id VARCHAR(64) NOT NULL,
+  report_version VARCHAR(64) NOT NULL,
+  summary TEXT NOT NULL,
+  top_patterns MEDIUMTEXT DEFAULT NULL,
+  risk_summary MEDIUMTEXT DEFAULT NULL,
+  recommended_actions MEDIUMTEXT DEFAULT NULL,
+  report_path VARCHAR(512) DEFAULT NULL,
+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_report_id (report_id),
+  KEY idx_run_id (run_id)
+);
+```
+
+## 每周更新流程
+
+建议每周固定跑一次,例如每周一凌晨,窗口取最近 30 天,截至前一天。
+
+1. 拉取最近 30 天 Top5000 高消耗素材,写入 `material_performance_snapshot_run` 和 `material_performance_snapshot_item`。
+2. 对新出现的 `image_url` 下载图片并计算 `image_hash`。
+3. 如果 `image_hash + annotation_version` 已存在,复用历史标注。
+4. 如果不存在,进入图片结构化标注流程,写入 `material_visual_annotation`。
+5. 基于本周 Top100/Top500 的标题、图片、消耗、CTR、关键页访问率生成策略候选。
+6. 策略候选写入 `material_creative_pattern`,状态为 `DRAFT`。
+7. 输出周报到 `material_strategy_learning_report`,同时落盘到 `outputs/data/material_analysis_YYYYMMDD/`。
+8. 人工 review 后,将可用模式改为 `APPROVED` 并设置 `enabled=1`。
+9. AI 生成素材主流程只读取 `enabled=1` 的 pattern。
+
+## 更新与去重策略
+
+### 表现快照
+
+表现快照不更新旧数据。每周新增一个 `run_id`,这样可以追踪同一个素材从高消耗变为低消耗,或者从低消耗进入高消耗的变化。
+
+### 图片标注
+
+图片标注按 `image_hash + annotation_version` 去重。同一张图在多个账户、多个创意、多个周出现时,只需要标注一次。
+
+当标注规则或多模态模型升级时,新建 `annotation_version`,不要覆盖旧版本。
+
+### 策略模式
+
+策略模式按 `pattern_version + pattern_key` 管理。新一周可以生成新的 `pattern_version`,也可以在人工确认后沿用上一版。
+
+默认不自动替换线上启用策略。只有人工 review 后,才将新模式设为 `APPROVED/enabled=1`。
+
+## 接入 AI 生成素材的方式
+
+AI 生成素材时,读取策略模式只做三件事:
+
+- 选择标题钩子规则,例如信息差、数字清单、时间锚点、家庭提醒。
+- 选择视觉模板,例如大字封面、人物解释型、家庭讨论型、物品清单型。
+- 加入合规避让规则,避免学习历史高消耗中的风险表达。
+
+不能做的事:
+
+- 不直接复用历史高消耗标题。
+- 不直接复用历史素材图。
+- 不用策略模式替代承接视频内容。
+- 不为了 CTR 生成伪按钮、假界面、强诱导、假官方、医疗恐吓、涉政民族情绪等高风险素材。
+
+生成 prompt 的内容优先级建议:
+
+1. 腾讯审核与平台合规规则。
+2. 承接视频的 ODPS 主题、关键点、目的点、灵感点。
+3. 人群包和版位上下文。
+4. 已审核通过的标题钩子规则和视觉模板。
+5. 近期同人群包已使用标题、视频、素材的排重约束。
+
+## 当前已落盘数据
+
+- 最近 30 天 Top5000 数据:
+  - `examples/auto_put_ad_mini/outputs/data/high_consumption_materials_30d_20260707_223644.csv`
+  - `examples/auto_put_ad_mini/outputs/data/high_consumption_materials_30d_20260707_223644.json`
+  - `examples/auto_put_ad_mini/outputs/data/high_consumption_materials_30d_20260707_223644_summary.json`
+- Top100 分析目录:
+  - `examples/auto_put_ad_mini/outputs/data/material_analysis_20260708/`
+- Top100 视觉标注:
+  - `top100_visual_annotations.csv`
+  - `top100_visual_annotations.json`
+  - `top100_visual_annotation_summary.json`
+  - `top100_visual_annotation_report.md`
+
+## 实施步骤
+
+### 第一阶段:只入库结果
+
+1. 建表。
+2. 将现有 Top5000 和 Top100 标注结果导入数据库。
+3. 增加只读查询脚本,验证快照、标注、周报能查到。
+
+### 第二阶段:每周自动学习
+
+1. 将现有 SQL 拉取脚本封装为周任务。
+2. 增加图片下载和 hash 复用。
+3. 增加结构化标注产物入库。
+4. 每周生成 DRAFT 策略候选和周报。
+
+### 第三阶段:接入生成流程
+
+1. AI 生成素材读取 `APPROVED/enabled=1` 的 pattern。
+2. 按视频内容和版位选择 pattern。
+3. 生成结果记录 `pattern_version/pattern_key`。
+4. 创意投放表现回流后,评估不同 pattern 的真实消耗、CTR、关键页访问率和审核通过率。
+
+## 需要后续确认
+
+- 标注环节是否使用真实多模态模型,还是先沿用当前本地结构化规则 + 人工抽检。
+- `material_visual_annotation` 是否要记录 OCR 识别到的图片文字。
+- 每周自动生成的 DRAFT pattern 是否发送飞书审批,还是只写数据库和本地报告。
+- 生成流程一次读取多少个 pattern,以及同一人群包是否需要 pattern 使用频控。

+ 163 - 0
examples/auto_put_ad_mini/docs/unified_services_deployment.md

@@ -0,0 +1,163 @@
+# 自动投放双服务部署
+
+## 1. Jenkins 构建
+
+构建统一完整镜像,不再构建 `tencent-realtime-control` 独立镜像:
+
+```bash
+set -e
+
+REGISTRY="registry.cn-hangzhou.aliyuncs.com/stuuudy"
+IMAGE="${REGISTRY}/ad-put-agent"
+
+cd "${WORKSPACE}"
+docker build \
+  -f Dockerfile.auto_put_ad_mini \
+  -t "${IMAGE}:latest" \
+  -t "${IMAGE}:${BUILD_TIMESTAMP}" \
+  .
+docker push "${IMAGE}:latest"
+docker push "${IMAGE}:${BUILD_TIMESTAMP}"
+
+ssh -i /root/.ssh/piaoquan.pem root@8.222.142.37 \
+  "sh /home/sh/deploy.sh ${BUILD_TIMESTAMP}"
+```
+
+## 2. ECS 文件
+
+将仓库中的 `docker-compose.yml` 放到 `/home/server/docker-compose.yml`。
+当前部署兼容 ECS 已安装的 Docker Compose `2.27.0`。
+
+两个服务共用现有的 `/home/server/.env`:
+
+```bash
+chmod 600 /home/server/.env
+docker compose version
+```
+
+原有数据库、ODPS、腾讯、飞书、OSS 和 AI 环境变量全部保留。
+
+首次部署增加:
+
+```dotenv
+RTC_START_HOUR=12
+RTC_STOP_HOUR=21
+RTC_POLL_SECONDS=600
+RTC_HIGH_CPM=250
+RTC_LOW_CPM=190
+RTC_BID_UP_RATIO=1.25
+RTC_MAX_PARTITION_LAG_HOURS=2
+RTC_APPLY_ENABLED=0
+RTC_COMMAND_ENABLED=1
+RTC_COMMAND_CHAT_ID=oc_xxx
+RTC_COMMAND_ALLOWED_OPEN_IDS=ou_xxx
+RTC_COMMAND_CONFIRM_TTL_MINUTES=10
+
+DAILY_CREATION_ENABLED=0
+DAILY_CREATION_HOUR=10
+DAILY_CREATION_MINUTE=30
+DAILY_REVIEW_ENABLED=0
+DAILY_REVIEW_INTERVAL_HOURS=2
+DAILY_RUN_ON_STARTUP=0
+DAILY_REVIEW_RUN_ON_STARTUP=0
+DAILY_SYNC_DELIVERY_TEMPLATE=0
+```
+
+多个操作人使用英文逗号分隔 `RTC_COMMAND_ALLOWED_OPEN_IDS`。
+代码默认 `RTC_APPLY_ENABLED=0`,日级创建、审核扫描和模板同步也默认关闭;
+缺少显式生产配置时不会创建广告或修改腾讯。
+
+## 3. ECS 部署脚本
+
+`/home/sh/deploy.sh` 使用:
+
+```bash
+#!/bin/bash
+set -e
+
+echo "Qingqu@2019" |
+  docker login \
+    --username=stuuudys \
+    registry.cn-hangzhou.aliyuncs.com \
+    --password-stdin
+
+VERSION=${1:-latest}
+cd /home/server
+
+VERSION=${VERSION} docker compose --env-file /dev/null pull
+VERSION=${VERSION} docker compose --env-file /dev/null down --remove-orphans
+VERSION=${VERSION} docker compose --env-file /dev/null up -d
+VERSION=${VERSION} docker compose --env-file /dev/null ps
+```
+
+继续沿用部署脚本中的 `source /home/server/.env`,Compose 同时通过标准
+`env_file` 将同一个文件传给两个容器,环境变量文件和加载入口不变。
+
+## 4. 首次 dry-run
+
+保持 `RTC_APPLY_ENABLED=0` 和两个 `DAILY_*_ENABLED=0`:
+
+```bash
+cd /home/server
+docker compose --env-file /dev/null config --services
+docker compose --env-file /dev/null up -d
+docker compose --env-file /dev/null ps
+docker compose --env-file /dev/null logs --tail=200 ad_control_service
+docker compose --env-file /dev/null logs --tail=200 ad_daily_service
+```
+
+控制服务启动时自动执行实时状态表增量迁移。也可以显式验证:
+
+```bash
+docker compose --env-file /dev/null run --rm \
+  ad_control_service \
+  python /app/examples/tencent_realtime_control/init_db.py
+```
+
+执行无腾讯写入的 CPM dry-run:
+
+```bash
+docker compose --env-file /dev/null run --rm \
+  ad_control_service \
+  python /app/examples/tencent_realtime_control/run_once.py \
+  --at 2026-07-25T12:00:00 \
+  --cpm-override 220 \
+  --force-refresh
+```
+
+飞书验证:
+
+1. 在配置群 @机器人发送 `查看暂停状态`。
+2. 发送一个单账户暂停命令,确认机器人返回预览和 `cmd_xxx`。
+3. 回复确认,服务必须提示 dry-run 禁止写腾讯。
+
+## 5. 生产切换
+
+1. 确认旧 `auto_put_ad_mini/server.py` 和旧实时容器不再运行。
+2. 设置 `RTC_APPLY_ENABLED=1`,保持日级服务关闭。
+3. 重新 `docker compose up -d`,观察一个完整实时周期。
+4. 用单账户命令验证暂停、恢复、腾讯回读和飞书结果。
+5. 设置 `DAILY_REVIEW_ENABLED=1`、`DAILY_SYNC_DELIVERY_TEMPLATE=1`。
+6. 审核扫描稳定后设置 `DAILY_CREATION_ENABLED=1`。
+7. 保持 `DAILY_RUN_ON_STARTUP=0`,防止部署时额外触发完整创建。
+
+## 6. 验收与回滚
+
+验收:
+
+```bash
+docker compose --env-file /dev/null ps
+docker compose --env-file /dev/null logs --since=30m ad_control_service
+docker compose --env-file /dev/null logs --since=30m ad_daily_service
+```
+
+应确认:
+
+- 两个容器均为 healthy。
+- 实时循环每 10 分钟运行且没有双实例锁冲突。
+- 飞书只有一个 WebSocket 消费者。
+- 10:30 只出现一次创建任务。
+- 审核扫描每 2 小时最多一个实例。
+
+回滚时将 `VERSION` 改为上一个镜像版本并重新部署。数据库新增表和列可以保留,
+旧代码不会读取这些字段,不需要执行破坏性回滚。

+ 36 - 0
examples/auto_put_ad_mini/execute_creation_apply.py

@@ -38,6 +38,8 @@ while str(_HERE) in sys.path:
 sys.path.insert(0, str(_HERE))
 
 from tools.creative_creation import post_creative_with_prepared_body  # noqa: E402
+from tools.ad_api import images_add  # noqa: E402
+from tools.ai_generated_material import update_generated_material_status  # noqa: E402
 from tools.creative_review import (  # noqa: E402
     mark_creation_submit_failed,
     record_creation_submission,
@@ -189,6 +191,7 @@ def apply_pending_records(records: list[dict]) -> dict:
         if action != "approve":
             try:
                 update_material_usage_status(rec, action)
+                update_generated_material_status(rec, action)
             except Exception as e:
                 logger.warning(
                     "[apply] 更新素材 usage 状态失败 action=%s material=%s: %s",
@@ -204,11 +207,37 @@ def apply_pending_records(records: list[dict]) -> dict:
             posted_failed += 1
             try:
                 update_material_usage_status(rec, "post_failed", error=rec["error"])
+                update_generated_material_status(rec, "post_failed", error=rec["error"])
             except Exception as e:
                 logger.warning("[apply] 更新素材 usage 状态失败: %s", e)
             out_records.append(rec)
             continue
 
+        pending_image_url = str(rec.get("_pending_image_url") or "").strip()
+        if pending_image_url and not rec.get("_material_image_id"):
+            try:
+                material_image_id = images_add(int(rec["account_id"]), pending_image_url)
+                rec["_material_image_id"] = material_image_id
+                image_comp = body.get("creative_components", {}).get("image") or []
+                if image_comp:
+                    image_comp[0].setdefault("value", {})["image_id"] = material_image_id
+                update_generated_material_status(
+                    rec,
+                    "approve",
+                    tencent_image_id=material_image_id,
+                )
+            except Exception as e:
+                rec["error"] = f"ai_image_upload_failed:{e}"
+                posted_failed += 1
+                mark_creation_submit_failed(rec, rec["error"])
+                try:
+                    update_material_usage_status(rec, "post_failed", error=rec["error"])
+                    update_generated_material_status(rec, "post_failed", error=rec["error"])
+                except Exception as update_e:
+                    logger.warning("[apply] 更新素材 usage 状态失败: %s", update_e)
+                out_records.append(rec)
+                continue
+
         cid = post_creative_with_prepared_body(
             account_id=int(rec["account_id"]),
             body=body,
@@ -219,6 +248,12 @@ def apply_pending_records(records: list[dict]) -> dict:
             posted_ok += 1
             try:
                 update_material_usage_status(rec, "posted_ok", dynamic_creative_id=cid)
+                update_generated_material_status(
+                    rec,
+                    "posted_ok",
+                    dynamic_creative_id=cid,
+                    tencent_image_id=str(rec.get("_material_image_id") or ""),
+                )
             except Exception as e:
                 logger.warning("[apply] 更新素材 usage 状态失败 cid=%s: %s", cid, e)
             try:
@@ -231,6 +266,7 @@ def apply_pending_records(records: list[dict]) -> dict:
             mark_creation_submit_failed(rec, "post_failed")
             try:
                 update_material_usage_status(rec, "post_failed", error=rec["error"])
+                update_generated_material_status(rec, "post_failed", error=rec["error"])
             except Exception as e:
                 logger.warning("[apply] 更新素材 usage 状态失败: %s", e)
         out_records.append(rec)

+ 435 - 57
examples/auto_put_ad_mini/execute_creation_once.py

@@ -21,10 +21,14 @@
 开关 CREATION_APPROVAL_REQUIRED=False 时跳过 Phase 2,全 records 直接 approve。
 """
 
+import argparse
 import json
 import logging
+import os
 import sys
 import time
+from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
+from datetime import date
 from pathlib import Path
 
 _HERE = Path(__file__).parent
@@ -38,6 +42,8 @@ from config import (  # noqa: E402
     ADS_PER_ACCOUNT,
     CREATION_APPROVAL_REQUIRED,
     CREATION_APPROVAL_TIMEOUT_MINUTES,
+    CREATIVE_PREPARE_MAX_WORKERS,
+    CREATIVE_PREPARE_TASK_BUFFER,
     MAX_SAME_LANDING_PER_AD_IN_RUN,
     TARGET_CREATIVES_PER_AD,
     WHITELIST_ACCOUNTS,
@@ -59,11 +65,20 @@ from tools.ad_creation import (  # noqa: E402
 )
 from tools.audience_grant import ensure_account_audience_grant  # noqa: E402
 from tools.creative_creation import (  # noqa: E402
+    build_landing_candidate_pool,
     find_ads_needing_creatives,
     load_excluded_ad_ids_from_adjustment,
     prepare_one_creative_for_ad,
 )
-from tools.creative_material_usage import record_prepared_material_usage  # noqa: E402
+from tools.creative_material_usage import (  # noqa: E402
+    load_recent_landing_usage_counts,
+    load_recoverable_prepared_records,
+    record_prepared_material_usage,
+)
+from tools.account_material_strategy import (  # noqa: E402
+    MATERIAL_SOURCE_AI_GENERATED,
+    load_account_material_strategy,
+)
 from tools.video_recall import get_account_crowd_package  # noqa: E402
 
 from execute_creation_apply import (  # noqa: E402
@@ -74,6 +89,62 @@ from execute_creation_apply import (  # noqa: E402
 
 logger = logging.getLogger("execute_creation_once")
 
+MATERIAL_SOURCE_HISTORY = "history"
+
+
+def _env_flag(name: str, default: bool = False) -> bool:
+    raw = os.getenv(name)
+    if raw is None:
+        return default
+    return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
+
+
+def _env_csv_set(name: str) -> set[str]:
+    return {
+        item.strip()
+        for item in os.getenv(name, "").split(",")
+        if item.strip()
+    }
+
+
+def _filter_creation_accounts(accounts: list[int], phase: str) -> list[int]:
+    """Apply one-off run filters. Empty env means production default: no filter."""
+    account_filter = {
+        int(v)
+        for v in _env_csv_set("CREATION_ONLY_ACCOUNT_IDS")
+        if v.isdigit()
+    }
+    crowd_filter = _env_csv_set("CREATION_ONLY_CROWD_PACKAGES")
+    if not account_filter and not crowd_filter:
+        return accounts
+
+    selected: list[int] = []
+    for account_id in accounts:
+        if account_filter and account_id not in account_filter:
+            continue
+        if crowd_filter:
+            try:
+                crowd_package = get_account_crowd_package(account_id)
+            except Exception as e:
+                logger.warning(
+                    "[%s] account=%d 临时过滤读取 crowd_package 失败,跳过:%s",
+                    phase, account_id, e,
+                )
+                continue
+            if crowd_package not in crowd_filter:
+                continue
+        selected.append(account_id)
+
+    logger.info(
+        "[%s] 临时运行过滤 account_ids=%s crowd_packages=%s: %d -> %d",
+        phase,
+        sorted(account_filter) if account_filter else "ALL",
+        sorted(crowd_filter) if crowd_filter else "ALL",
+        len(accounts),
+        len(selected),
+    )
+    return selected
+
 
 def _setup_logging() -> None:
     """配置日志格式与级别,挂载 SLS 上报(配置缺失时自动降级)。"""
@@ -163,7 +234,10 @@ def _fetch_existing_fingerprints_for_account(account_id: int) -> set[str]:
     return fingerprints
 
 
-def phase0_create_ads(target_ads: int = ADS_PER_ACCOUNT) -> list[dict]:
+def phase0_create_ads(
+    target_ads: int = ADS_PER_ACCOUNT,
+    config_date: date | None = None,
+) -> list[dict]:
     """Phase 0:对每账户检查广告数,不足则建到 target_ads 条(模块 A,2026-06-09 P1-G)。
 
     流程:
@@ -176,10 +250,14 @@ def phase0_create_ads(target_ads: int = ADS_PER_ACCOUNT) -> list[dict]:
     from tools.ad_api import _get
     from tools.im_approval_ad_creation import run_ad_approval_workflow
 
-    creation_accounts = get_creation_account_ids()
+    creation_accounts = get_creation_account_ids(config_date=config_date)
     if not creation_accounts:
         logger.error("[phase0] 待投放账户配置为空,退出")
         return []
+    creation_accounts = _filter_creation_accounts(creation_accounts, "phase0")
+    if not creation_accounts:
+        logger.info("[phase0] 临时过滤后无待处理账户")
+        return []
 
     # Task 26:NORMAL + SUSPEND 都算占用唯一性槽位(腾讯文档:删除前历史广告占槽位)
     OCCUPIED_STATUSES = {"AD_STATUS_NORMAL", "AD_STATUS_SUSPEND"}
@@ -259,6 +337,9 @@ def phase0_create_ads(target_ads: int = ADS_PER_ACCOUNT) -> list[dict]:
                 "delivery_version": c.delivery_version,
                 "wechat_position": c.wechat_position,
                 "bid_amount_fen": c.bid_amount_fen,
+                "bid_scene": c.bid_scene,
+                "custom_cost_cap_fen": c.custom_cost_cap_fen,
+                "automatic_site_enabled": c.automatic_site_enabled,
                 "age_range": age_range,
                 "fingerprint": c.fingerprint,
                 "_request_body": body,
@@ -363,16 +444,23 @@ def _wait_created_ads_visible(created_ads: list[dict], timeout_seconds: int = 60
         logger.info("[phase0] 新建广告均已在 list 接口可见")
 
 
-def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict]:
+def phase1_prepare(
+    target_creatives: int = TARGET_CREATIVES_PER_AD,
+    config_date: date | None = None,
+) -> list[dict]:
     """Phase 1:扫账户 → 关联点过滤 → 准备 pending records(不 POST 腾讯)。
 
     Returns:
         pending_records — 每个元素是 prepare_one_creative_for_ad 返回的 dict
     """
-    creation_accounts = get_creation_account_ids()
+    creation_accounts = get_creation_account_ids(config_date=config_date)
     if not creation_accounts:
         logger.error("[phase1] 待投放账户配置为空,Phase 1 退出")
         return []
+    creation_accounts = _filter_creation_accounts(creation_accounts, "phase1")
+    if not creation_accounts:
+        logger.info("[phase1] 临时过滤后无待处理账户")
+        return []
 
     excluded_ad_ids = load_excluded_ad_ids_from_adjustment()
     logger.info("[phase1] 关联点过滤集合 size=%d", len(excluded_ad_ids))
@@ -383,7 +471,91 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
     # 但同一轮审批候选需要做展示去重,避免表格里同 crowd_package 反复出现同一素材。
     # 这个 set 只存在内存里,不写历史排重库;进程结束即失效。
     display_used_material_ids_by_crowd: dict[str, set[str]] = {}
-    # landing_video 不进历史排重库,只在同一广告本轮候选内做限频。
+    # landing_video 按素材来源拆池。历史素材和 AI 生成素材互不占用,但池内都严格去重。
+    landing_usage_counts_by_crowd: dict[str, dict[str, dict[int, int]]] = {}
+
+    def accept_pending_record(
+        rec: dict,
+        *,
+        crowd_package: str,
+        display_excluded_material_ids: set[str],
+        crowd_landing_counts: dict[str, dict[int, int]],
+        landing_counts_for_ad: dict[int, int],
+        landing_max_uses: int,
+        recovered: bool = False,
+    ) -> bool:
+        material_id = rec.get("_material_id")
+        if material_id and str(material_id) in display_excluded_material_ids:
+            logger.info(
+                "[phase1] pending 接收排重丢弃 crowd=%r material=%s%s",
+                crowd_package, material_id,
+                " recovered" if recovered else "",
+            )
+            return False
+        landing_video_id = rec.get("landing_video_id")
+        actual_material_source = (
+            MATERIAL_SOURCE_AI_GENERATED
+            if str(rec.get("material_source") or "") == MATERIAL_SOURCE_AI_GENERATED
+            or str(rec.get("_material_id") or "").startswith("ai:")
+            else MATERIAL_SOURCE_HISTORY
+        )
+        if landing_video_id is not None:
+            landing_video_id = int(landing_video_id)
+            if landing_counts_for_ad.get(landing_video_id, 0) >= MAX_SAME_LANDING_PER_AD_IN_RUN:
+                logger.info(
+                    "[phase1] pending 接收排重丢弃 adgroup=%s landing=%d 同广告已达上限%s",
+                    rec.get("adgroup_id"), landing_video_id,
+                    " recovered" if recovered else "",
+                )
+                return False
+            source_counts = crowd_landing_counts.setdefault(actual_material_source, {})
+            if source_counts.get(landing_video_id, 0) >= landing_max_uses:
+                logger.info(
+                    "[phase1] pending 接收排重丢弃 crowd=%r material_source=%s landing=%d 同人群包已达上限%s",
+                    crowd_package, actual_material_source, landing_video_id,
+                    " recovered" if recovered else "",
+                )
+                return False
+
+        pending_records.append(rec)
+        if material_id:
+            display_excluded_material_ids.add(str(material_id))
+            logger.info(
+                "[phase1] 本轮展示去重登记 crowd=%r material=%s size=%d%s",
+                crowd_package, material_id,
+                len(display_excluded_material_ids),
+                " recovered" if recovered else "",
+            )
+        if landing_video_id is not None:
+            landing_counts_for_ad[landing_video_id] = (
+                landing_counts_for_ad.get(landing_video_id, 0) + 1
+            )
+            source_counts = crowd_landing_counts.setdefault(actual_material_source, {})
+            source_counts[landing_video_id] = source_counts.get(landing_video_id, 0) + 1
+            logger.info(
+                "[phase1] 同人群包 landing 使用登记 crowd=%r material_source=%s landing=%d count=%d max=%d%s",
+                crowd_package, actual_material_source, landing_video_id,
+                source_counts[landing_video_id],
+                landing_max_uses,
+                " recovered" if recovered else "",
+            )
+            logger.info(
+                "[phase1] 本轮同广告 landing 计数 adgroup=%s landing=%d count=%d limit=%d%s",
+                rec.get("adgroup_id"), landing_video_id,
+                landing_counts_for_ad[landing_video_id],
+                MAX_SAME_LANDING_PER_AD_IN_RUN,
+                " recovered" if recovered else "",
+            )
+        if not recovered:
+            try:
+                record_prepared_material_usage(rec)
+            except Exception as e:
+                logger.warning(
+                    "[phase1] material usage 记录失败 account=%s adgroup=%s material=%s:%s",
+                    rec.get("account_id"), rec.get("adgroup_id"),
+                    rec.get("_material_id"), e,
+                )
+        return True
 
     for account_id in creation_accounts:
         logger.info("=" * 60)
@@ -418,72 +590,265 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
         display_excluded_material_ids = display_used_material_ids_by_crowd.setdefault(
             crowd_package, set(),
         )
+        if crowd_package not in landing_usage_counts_by_crowd:
+            try:
+                landing_usage_counts_by_crowd[crowd_package] = load_recent_landing_usage_counts(
+                    crowd_package,
+                )
+            except Exception as e:
+                logger.warning(
+                    "[phase1] crowd=%r 读取 landing 使用历史失败,仅使用本轮排重:%s",
+                    crowd_package, e,
+                )
+                landing_usage_counts_by_crowd[crowd_package] = {
+                    MATERIAL_SOURCE_HISTORY: {},
+                    MATERIAL_SOURCE_AI_GENERATED: {},
+                }
+            logger.info(
+                "[phase1] crowd=%r 近期 landing 使用 history=%d ai_generated=%d",
+                crowd_package,
+                len(landing_usage_counts_by_crowd[crowd_package].get(MATERIAL_SOURCE_HISTORY, {})),
+                len(landing_usage_counts_by_crowd[crowd_package].get(MATERIAL_SOURCE_AI_GENERATED, {})),
+            )
+        crowd_landing_counts = landing_usage_counts_by_crowd[crowd_package]
+        crowd_landing_counts.setdefault(MATERIAL_SOURCE_HISTORY, {})
+        crowd_landing_counts.setdefault(MATERIAL_SOURCE_AI_GENERATED, {})
+
+        try:
+            material_strategy = load_account_material_strategy(account_id)
+        except Exception as e:
+            logger.warning(
+                "[phase1] account=%d 读取素材策略失败,按 history 排重:%s",
+                account_id, e,
+            )
+            material_strategy = None
+        requested_material_source = (
+            MATERIAL_SOURCE_AI_GENERATED
+            if material_strategy is not None and material_strategy.use_ai_generated
+            else MATERIAL_SOURCE_HISTORY
+        )
+        landing_max_uses = MAX_SAME_LANDING_PER_AD_IN_RUN
+        landing_candidate_pool = None
+        logger.info(
+            "[phase1] account=%d landing 排重池=%s max_uses=%d",
+            account_id, requested_material_source, landing_max_uses,
+        )
 
         for ad in ads_after_filter:
             adgroup_id = ad["adgroup_id"]
             already_have = ad["creative_count"]
             to_add = max(0, target_creatives - already_have)
+            prepared_for_ad = 0
+            failed_prepare_for_ad = 0
+            failed_landing_ids_for_ad: set[int] = set()
             landing_counts_for_ad: dict[int, int] = {}
             logger.info(
                 "[phase1]   adgroup=%d(have=%d need=%d)",
                 adgroup_id, already_have, to_add,
             )
+            recovered_records = load_recoverable_prepared_records(
+                account_id=account_id,
+                adgroup_id=adgroup_id,
+                crowd_package=crowd_package,
+                material_source=requested_material_source,
+                limit=to_add,
+            )
+            recovered_accepted = 0
+            for rec in recovered_records:
+                if accept_pending_record(
+                    rec,
+                    crowd_package=crowd_package,
+                    display_excluded_material_ids=display_excluded_material_ids,
+                    crowd_landing_counts=crowd_landing_counts,
+                    landing_counts_for_ad=landing_counts_for_ad,
+                    landing_max_uses=landing_max_uses,
+                    recovered=True,
+                ):
+                    recovered_accepted += 1
+            if recovered_records:
+                prepared_for_ad += recovered_accepted
+                logger.info(
+                    "[phase1]   adgroup=%d 恢复未提交 prepared records=%d accepted=%d remaining=%d",
+                    adgroup_id, len(recovered_records), recovered_accepted,
+                    max(0, to_add - prepared_for_ad),
+                )
 
-            for _ in range(to_add):
-                landing_excluded_for_ad = {
-                    vid
-                    for vid, count in landing_counts_for_ad.items()
-                    if count >= MAX_SAME_LANDING_PER_AD_IN_RUN
-                }
+            def prepare_attempt(
+                *,
+                attempt_no: int,
+                excluded_material_ids_snapshot: set[str],
+                excluded_landing_ids_snapshot: set[int],
+            ) -> dict | None:
+                nonlocal landing_candidate_pool
                 try:
-                    rec = prepare_one_creative_for_ad(
+                    if landing_candidate_pool is None:
+                        landing_candidate_pool = build_landing_candidate_pool(account_id)
+                    return prepare_one_creative_for_ad(
                         account_id, adgroup_id,
-                        excluded_material_ids=display_excluded_material_ids,
-                        excluded_landing_ids=landing_excluded_for_ad,
+                        excluded_material_ids=excluded_material_ids_snapshot,
+                        excluded_landing_ids=excluded_landing_ids_snapshot,
+                        landing_candidates=landing_candidate_pool,
+                        failed_landing_ids=failed_landing_ids_for_ad,
                     )
                 except Exception as e:
                     logger.exception(
-                        "[phase1] adgroup=%d 准备失败:%s", adgroup_id, e,
+                        "[phase1] adgroup=%d attempt=%d 准备失败:%s",
+                        adgroup_id, attempt_no, e,
                     )
-                    rec = None
+                    return None
 
-                if rec:
-                    pending_records.append(rec)
-                    material_id = rec.get("_material_id")
-                    if material_id:
-                        display_excluded_material_ids.add(str(material_id))
-                        logger.info(
-                            "[phase1] 本轮展示去重登记 crowd=%r material=%s size=%d",
-                            crowd_package, material_id,
-                            len(display_excluded_material_ids),
-                        )
-                    landing_video_id = rec.get("landing_video_id")
-                    if landing_video_id is not None:
-                        landing_video_id = int(landing_video_id)
-                        landing_counts_for_ad[landing_video_id] = (
-                            landing_counts_for_ad.get(landing_video_id, 0) + 1
+            def current_excluded_landing_ids() -> set[int]:
+                landing_excluded_for_ad = {
+                    vid
+                    for vid, count in landing_counts_for_ad.items()
+                    if count >= MAX_SAME_LANDING_PER_AD_IN_RUN
+                }
+                landing_excluded_for_source = {
+                    vid
+                    for vid, count in crowd_landing_counts
+                    .get(requested_material_source, {})
+                    .items()
+                    if count >= landing_max_uses
+                }
+                return landing_excluded_for_source | landing_excluded_for_ad
+
+            remaining_to_add = max(0, to_add - prepared_for_ad)
+            max_workers = max(1, int(CREATIVE_PREPARE_MAX_WORKERS))
+            if max_workers > 1:
+                logger.warning(
+                    "[phase1] CREATIVE_PREPARE_MAX_WORKERS=%d 已配置但当前 prepare_one_creative_for_ad "
+                    "包含图片上传/xcx-save 等外部副作用,为避免重复创建落地计划,本轮强制串行执行",
+                    max_workers,
+                )
+                max_workers = 1
+            task_buffer = max(remaining_to_add, int(CREATIVE_PREPARE_TASK_BUFFER))
+            task_limit = min(max(0, remaining_to_add * 2), task_buffer)
+            if remaining_to_add > 0 and max_workers > 1:
+                logger.info(
+                    "[phase1]   adgroup=%d 并行准备 start remaining=%d workers=%d task_limit=%d",
+                    adgroup_id, remaining_to_add, max_workers, task_limit,
+                )
+                submitted = 0
+                accepted_parallel = 0
+                dropped_parallel = 0
+                failed_parallel = 0
+                started_at = time.monotonic()
+                with ThreadPoolExecutor(max_workers=max_workers) as executor:
+                    futures = {}
+
+                    def submit_next() -> None:
+                        nonlocal submitted
+                        # 已接收 + 运行中任务不超过缺口,避免并发准备产出无法追踪的多余副作用。
+                        if (
+                            submitted >= task_limit
+                            or prepared_for_ad + len(futures) >= to_add
+                        ):
+                            return
+                        submitted += 1
+                        attempt_no = submitted
+                        future = executor.submit(
+                            prepare_attempt,
+                            attempt_no=attempt_no,
+                            excluded_material_ids_snapshot=set(display_excluded_material_ids),
+                            excluded_landing_ids_snapshot=current_excluded_landing_ids(),
                         )
+                        futures[future] = attempt_no
+
+                    for _ in range(min(max_workers, task_limit)):
+                        submit_next()
+
+                    while futures and prepared_for_ad < to_add:
+                        done, _ = wait(futures, return_when=FIRST_COMPLETED)
+                        for future in done:
+                            futures.pop(future, None)
+                            rec = future.result()
+                            if not rec:
+                                failed_prepare_for_ad += 1
+                                failed_parallel += 1
+                            elif accept_pending_record(
+                                rec,
+                                crowd_package=crowd_package,
+                                display_excluded_material_ids=display_excluded_material_ids,
+                                crowd_landing_counts=crowd_landing_counts,
+                                landing_counts_for_ad=landing_counts_for_ad,
+                                landing_max_uses=landing_max_uses,
+                            ):
+                                prepared_for_ad += 1
+                                accepted_parallel += 1
+                            else:
+                                dropped_parallel += 1
+                            if prepared_for_ad < to_add:
+                                submit_next()
+                            else:
+                                break
+                    if prepared_for_ad >= to_add:
                         logger.info(
-                            "[phase1] 本轮同广告 landing 计数 adgroup=%d landing=%d count=%d limit=%d",
-                            adgroup_id, landing_video_id,
-                            landing_counts_for_ad[landing_video_id],
-                            MAX_SAME_LANDING_PER_AD_IN_RUN,
+                            "[phase1]   adgroup=%d 并行准备 target reached accepted=%d",
+                            adgroup_id, accepted_parallel,
                         )
-                    try:
-                        record_prepared_material_usage(rec)
-                    except Exception as e:
-                        logger.warning(
-                            "[phase1] material usage 记录失败 account=%d adgroup=%d material=%s:%s",
-                            account_id, adgroup_id, rec.get("_material_id"), e,
-                        )
-                else:
-                    # 2026-06-10 用户要求:单条 prepare 失败 → continue 不 break
-                    # 同广告剩余 to_add 创意还能继续试,不被一次失败拖累
-                    logger.info(
-                        "[phase1]   adgroup=%d 本条创意 prepare 失败,试下一条",
-                        adgroup_id,
+                        cancelled = 0
+                        for pending_future in futures:
+                            if pending_future.cancel():
+                                cancelled += 1
+                        if cancelled:
+                            logger.info(
+                                "[phase1]   adgroup=%d 并行准备 cancelled_pending=%d",
+                                adgroup_id, cancelled,
+                            )
+                    for future in futures:
+                        if future.cancelled():
+                            continue
+                        rec = future.result()
+                        if rec:
+                            dropped_parallel += 1
+                            logger.info(
+                                "[phase1]   adgroup=%d 并行结果丢弃:target reached material=%s landing=%s",
+                                adgroup_id, rec.get("_material_id"), rec.get("landing_video_id"),
+                            )
+                logger.info(
+                    "[phase1]   adgroup=%d 并行准备 done submitted=%d accepted=%d dropped=%d failed=%d elapsed=%.1fs",
+                    adgroup_id, submitted, accepted_parallel, dropped_parallel,
+                    failed_parallel, time.monotonic() - started_at,
+                )
+            else:
+                for attempt_no in range(1, remaining_to_add + 1):
+                    rec = prepare_attempt(
+                        attempt_no=attempt_no,
+                        excluded_material_ids_snapshot=set(display_excluded_material_ids),
+                        excluded_landing_ids_snapshot=current_excluded_landing_ids(),
                     )
 
+                    if rec:
+                        if accept_pending_record(
+                            rec,
+                            crowd_package=crowd_package,
+                            display_excluded_material_ids=display_excluded_material_ids,
+                            crowd_landing_counts=crowd_landing_counts,
+                            landing_counts_for_ad=landing_counts_for_ad,
+                            landing_max_uses=landing_max_uses,
+                        ):
+                            prepared_for_ad += 1
+                    else:
+                        failed_prepare_for_ad += 1
+                        # 2026-06-10 用户要求:单条 prepare 失败 → continue 不 break
+                        # 同广告剩余 to_add 创意还能继续试,不被一次失败拖累
+                        logger.info(
+                            "[phase1]   adgroup=%d 本条创意 prepare 失败,试下一条",
+                            adgroup_id,
+                        )
+            logger.info(
+                "[phase1]   adgroup=%d 补创意完成 target=%d have_before=%d "
+                "planned=%d prepared=%d failed_prepare=%d",
+                adgroup_id, target_creatives, already_have, to_add,
+                prepared_for_ad, failed_prepare_for_ad,
+            )
+            if prepared_for_ad < to_add:
+                logger.warning(
+                    "[phase1]   adgroup=%d 未补满:缺口=%d。详细原因见上方 "
+                    "prepare_one_creative source_summary/失败日志",
+                    adgroup_id, to_add - prepared_for_ad,
+                )
+
     logger.info("=" * 60)
     logger.info("[phase1] 准备完成,共 %d 条 pending records", len(pending_records))
     return pending_records
@@ -520,7 +885,7 @@ def phase2_approval(records: list[dict], xlsx_output_dir: Path) -> dict[int, str
     return actions
 
 
-def run_once() -> dict:
+def run_once(config_date: str | None = None) -> dict:
     """完整主循环:Phase 0 → Phase 1 → Phase 2(若开关 True)→ Phase 3。"""
     run_started = now_in_timezone().isoformat()
     sync_stats: dict = {}
@@ -531,7 +896,7 @@ def run_once() -> dict:
     try:
         from sync_feishu_account_config import sync_from_feishu
 
-        sync_stats = sync_from_feishu()
+        sync_stats = sync_from_feishu(config_date=config_date)
         logger.info("[main] Phase -1 完成: %s", sync_stats)
     except Exception as e:
         logger.exception("[main] Phase -1 同步飞书配置失败,本轮停止:%s", e)
@@ -544,17 +909,23 @@ def run_once() -> dict:
             "total": {"phase1_prepared": 0},
         }
 
+    effective_config_date = date.fromisoformat(sync_stats["config_date"])
+
     # Phase 0:模块 A 建广告(满足每账户 ADS_PER_ACCOUNT 条)
     logger.info("=" * 60)
-    logger.info("[main] Phase 0 启动 — 模块 A 检查 + 建广告")
-    created_ads = phase0_create_ads()
-    logger.info("[main] Phase 0 完成:本轮新建广告 %d 条", len(created_ads))
-    _wait_created_ads_visible(created_ads)
+    if _env_flag("CREATION_SKIP_PHASE0"):
+        logger.info("[main] CREATION_SKIP_PHASE0=True → 跳过模块 A 建广告")
+        created_ads = []
+    else:
+        logger.info("[main] Phase 0 启动 — 模块 A 检查 + 建广告")
+        created_ads = phase0_create_ads(config_date=effective_config_date)
+        logger.info("[main] Phase 0 完成:本轮新建广告 %d 条", len(created_ads))
+        _wait_created_ads_visible(created_ads)
 
     # Phase 1:模块 B 给所有广告(新+旧)补创意
     logger.info("=" * 60)
     logger.info("[main] Phase 1 启动 — 模块 B 给广告补创意")
-    pending_records = phase1_prepare()
+    pending_records = phase1_prepare(config_date=effective_config_date)
     if not pending_records:
         logger.info("[main] Phase 1 无 pending records,主循环退出")
         return {
@@ -602,6 +973,13 @@ def run_once() -> dict:
 
 
 def main() -> int:
+    parser = argparse.ArgumentParser(description="执行广告与创意创建主流程")
+    parser.add_argument(
+        "--config-date",
+        help="执行指定飞书配置日期,格式 YYYYMMDD 或 YYYY-MM-DD;默认当天",
+    )
+    args = parser.parse_args()
+
     """模块 B 创意搭建子系统主入口:初始化日志 → 运行主循环 → 输出汇总。"""
     _setup_logging()
     logger.info("=" * 60)
@@ -611,7 +989,7 @@ def main() -> int:
     logger.info("WHITELIST_ACCOUNTS            = %s", WHITELIST_ACCOUNTS)
     logger.info("=" * 60)
 
-    summary = run_once()
+    summary = run_once(config_date=args.config_date)
     t = summary.get("total") or {}
     logger.info("=" * 60)
     logger.info("[main] 主循环结束")

+ 118 - 0
examples/auto_put_ad_mini/import_material_strategy_learning.py

@@ -0,0 +1,118 @@
+"""Import local material strategy learning analysis into DB.
+
+This script is idempotent for the default run_id. It only writes internal
+learning tables and has no Tencent/Feishu side effects.
+"""
+
+from __future__ import annotations
+
+import argparse
+import logging
+import sys
+from datetime import date
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+_HERE = Path(__file__).parent
+load_dotenv(_HERE / ".env")
+sys.path.insert(0, str(_HERE))
+
+from tools.material_strategy_learning import (  # noqa: E402
+    ensure_strategy_learning_tables,
+    import_current_material_analysis,
+)
+
+
+DEFAULT_RUN_ID = "material_30d_20260607_20260706_top5000"
+DEFAULT_ANALYSIS_DIR = _HERE / "outputs/data/material_analysis_20260708"
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description="导入高消耗素材策略学习结果")
+    parser.add_argument("--run-id", default=DEFAULT_RUN_ID)
+    parser.add_argument("--window-start", default="2026-06-07")
+    parser.add_argument("--window-end", default="2026-07-06")
+    parser.add_argument(
+        "--performance-csv",
+        type=Path,
+        default=_HERE / "outputs/data/high_consumption_materials_30d_20260707_223644.csv",
+    )
+    parser.add_argument(
+        "--performance-summary-json",
+        type=Path,
+        default=_HERE / "outputs/data/high_consumption_materials_30d_20260707_223644_summary.json",
+    )
+    parser.add_argument(
+        "--visual-annotations-csv",
+        type=Path,
+        default=DEFAULT_ANALYSIS_DIR / "top100_visual_annotations.csv",
+    )
+    parser.add_argument(
+        "--visual-summary-json",
+        type=Path,
+        default=DEFAULT_ANALYSIS_DIR / "top100_visual_annotation_summary.json",
+    )
+    parser.add_argument(
+        "--report-path",
+        type=Path,
+        default=DEFAULT_ANALYSIS_DIR / "top100_visual_annotation_report.md",
+    )
+    parser.add_argument(
+        "--sql-file",
+        default="examples/auto_put_ad_mini/sql/high_consumption_materials_30d.sql",
+    )
+    parser.add_argument("--top-n", type=int, default=5000)
+    parser.add_argument("--annotation-version", default="top100_rule_v1")
+    parser.add_argument("--dry-run", action="store_true", help="只检查文件和建表,不导入数据")
+    return parser.parse_args()
+
+
+def _require_file(path: Path) -> None:
+    if not path.exists():
+        raise FileNotFoundError(f"文件不存在:{path}")
+
+
+def main() -> int:
+    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+    args = parse_args()
+
+    for path in (
+        args.performance_csv,
+        args.performance_summary_json,
+        args.visual_annotations_csv,
+        args.visual_summary_json,
+        args.report_path,
+    ):
+        _require_file(path)
+
+    ensure_strategy_learning_tables()
+    if args.dry_run:
+        print("OK: 策略学习表已确认存在,输入文件检查通过")
+        return 0
+
+    result = import_current_material_analysis(
+        run_id=args.run_id,
+        window_start=date.fromisoformat(args.window_start),
+        window_end=date.fromisoformat(args.window_end),
+        performance_csv=args.performance_csv,
+        performance_summary_json=args.performance_summary_json,
+        visual_annotations_csv=args.visual_annotations_csv,
+        visual_summary_json=args.visual_summary_json,
+        report_path=args.report_path,
+        sql_file=args.sql_file,
+        top_n=args.top_n,
+        annotation_version=args.annotation_version,
+    )
+    print(
+        "OK: 导入完成 "
+        f"run_id={result.run_id} "
+        f"snapshot_rows={result.snapshot_rows} "
+        f"annotation_rows={result.annotation_rows} "
+        f"report_rows={result.report_rows}"
+    )
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 65 - 0
examples/auto_put_ad_mini/prompts/ai_cover_copy.md

@@ -0,0 +1,65 @@
+【system】
+你是中文信息流广告封面标题策划。
+目标是为 60 岁以上用户生成更想点开的封面主标题。
+只输出 JSON,不要输出解释性正文。
+
+【user】
+请根据广告主题种子和创意 pattern 生成封面主标题。
+
+【广告主题种子】
+{{video_description}}
+
+【创意Pattern】
+{{pattern_json}}
+
+【标题目标】
+- 14-20 个汉字,通俗、直接、有信息差或想看感;超过 22 个汉字视为无效。
+- 不要副标题。
+- 不写成说明文、科普总结或官方通知。
+- 标题必须像日常中文,读起来顺口,不要像抽象词拼接、书面口号或机器翻译。
+- 不要为了迎合目标用户而滥用“老人、中老年、退休、晚年、六十、长辈”;只有主题本身强相关时才使用。
+- 标题要和主题相关,但不复刻视频脚本、镜头结构或高风险事件细节。
+- 标题必须包含“明确对象/明确关系/明确情绪冲突 + 反差/悬念/信息差”中的至少一种组合,让人知道点开后会看到什么不同寻常的内容。
+- 具象主题优先保留具体对象或过程;抽象主题优先保留核心情绪、关系或态度,不要凭空改写成无关物品、收纳整理或生活清单。
+- 数字清单只能修饰可数对象;不要把抽象心态生造为“几种/几类/几条”。
+- 如果主题是抽象心态或情绪感悟,标题要保留人能共鸣的核心矛盾,不要强行清单化。
+- 避免泛化结尾:真相让人吃惊、一番话讲透了、值得看看、让人明白了、很有道理、说到心坎里。
+
+【可选钩子角度】
+- 未说破型:说到关键处,留下原因。
+- 反差型:表面和真相不一样。
+- 清单型:几件事、几类人、几个原因。
+- 过来人型:像生活经验,但不要句句写年龄。
+- 情绪共鸣型:唱到心里、说到心坎、想起过去。
+- 奇闻反差型:普通事物背后有稀奇原因。
+- 实用提醒型:日常生活里容易忽略的提醒。
+
+【高亮词规则】
+- 输出 1-2 个 highlight_terms,必须完整出现在 selected_title 中。
+- 高亮词应是点击钩子的核心,优先选择具体对象、结果反差、数字、原因、稀奇点。
+- 不要高亮虚词、媒介词、泛化词或高风险诱导词。
+- 如果没有合适高亮词,输出空数组。
+
+【底线】
+- 不要标点;不要错别字、乱码、异体字或不自然词组。
+- 不要假官方、假系统、二维码、医疗疗效、健康恐吓、血腥暴力、迷信预测、低俗擦边、品牌仿冒联想。
+- 避免“100%”“唯一”“不看后悔”“最后一天”“紧急通知”“惊掉下巴”等强诱导。
+
+【输出JSON格式】
+{
+  "hook_angle": "未说破型/反差型/清单型/过来人型/情绪共鸣型/奇闻反差型/实用提醒型",
+  "hook_point": "一句话说明最想让用户点开的点",
+  "candidates": [
+    {
+      "title": "候选主标题",
+      "hook_angle": "钩子角度",
+      "hook_score": 90,
+      "plain_score": 90,
+      "relevance_score": 90,
+      "reason": "一句话说明"
+    }
+  ],
+  "selected_title": "最终主标题",
+  "highlight_terms": ["高亮词1", "高亮词2"],
+  "reason": "一句话说明为什么选择它"
+}

+ 58 - 0
examples/auto_put_ad_mini/prompts/ai_generated_material.md

@@ -0,0 +1,58 @@
+【任务】
+生成一张腾讯广告信息流 / 公众号投放使用的中文广告封面图。
+
+【输入】
+{{video_description}}
+
+【优先级】
+1. 使用指定主标题和指定高亮词。
+2. 服从选中的创意 Pattern。
+3. 保持与广告主题相关,并提升点击兴趣。
+4. 真实、清晰、手机小屏可读。
+5. 合规安全。
+
+【主题与画面】
+- 输入是广告主题种子,不是视频脚本或画面脚本。
+- 目标用户是 60 岁以上用户,但目标用户不等于画面主体。
+- 不强制出现人物;如果出现人物,也不强制是老人正脸。
+- 不复刻视频镜头,可以做同主题、同情绪、同问题场景的广告化转译。
+- 不要把任何场景、人物或构图当成固定模板。
+
+【视觉风格】
+- 像真实中文信息流广告封面,不要卡通、二次元、科技海报、欧美商业海报。
+- 画面必须有明确视觉 hook,第一眼就能看到一个值得停留的主体或反差,不要只是平静生活照。
+- 视觉 hook 必须由主题决定,可以是可见的反差、线索、情绪、过程、现场感、疑问感或表达瞬间。
+- 如果使用人物,人物的年龄、性别、身份和情绪都要服务主题,不要为了吸引点击而硬植入无关人物。
+- 如果不使用人物,也要让画面主体本身有足够信息量和停留感。
+- 人物不能低俗、擦边、暴露、挑逗。
+- 画面要有明确点击点:信息差、反差、清单感、奇闻感、情绪共鸣或生活提醒。
+- 由 Pattern 决定主要视觉方向;不要因为目标用户是 60 岁以上就默认画老人。
+- 人物必须是中国本土语境;不要外国人物;不要真实名人肖像或类似真实名人的脸。
+
+【标题排版】
+- 画面中必须有一个超大中文主标题。
+- 如果输入中提供了【封面标题】,必须逐字使用其中的“主标题”。
+- 不要生成副标题、小字说明、角标、引导文案或额外字幕。
+- 主标题不能出现方括号、圆括号、书名号、引号或额外符号,包括【】()《》“”。
+- 主标题最多 2 行,大白字、黑色粗描边、高对比度。
+- 只能高亮输入中提供的【高亮词】;没有高亮词时不要强行彩色高亮。
+- 高亮色可用黄色、橙色或红色,但不要整句彩色化。
+- 高亮只能改变文字颜色,不要给高亮词添加方括号、圆括号、引号、底色框、下划线或额外符号。
+- 主标题必须清晰可读,不能错字、乱码、异体字、断字、截断或不自然分词。
+
+【多样性】
+- 不同视频应有不同 scene / subject / prop / composition / color_mood。
+- 不同主题要有不同的视觉组织方式,避免反复生成同类人物照、风景照或生活照。
+- 如果人物不是主题必要元素,可以弱化人物或不用人物。
+
+【合规禁止】
+- 禁止假官方通知、假新闻播报、假系统提示、假微信界面、假聊天记录、假按钮、假红包、假弹窗、二维码、下载按钮、播放按钮伪装。
+- 禁止医疗治病、保健疗效、专家推荐药品、医院、健康恐吓。
+- 禁止违法犯罪、血腥暴力、尸体、未成年人不当情节、迷信怪力乱神。
+- 禁止商品、优惠券、卡券、课程、价格、订单、购买、下单、领券、App下载、小程序推广。
+- 禁止“100%”“唯一”“国家发钱”“不看后悔”“最后一天”“紧急通知”“赶紧看”“惊掉下巴”“看哭无数人”“亿万老人”“身价大涨”等绝对化、强诱导或高风险表达。
+- 禁止 logo、水印、乱码文字、畸形人物、AI感强。
+
+【输出规格】
+- 输出一张完整广告图。
+- 图片比例:{{aspect_ratio}}。

+ 14 - 0
examples/auto_put_ad_mini/prompts/ai_pattern_selector.md

@@ -0,0 +1,14 @@
+【任务】
+从候选广告创意 pattern 中选择最适合该视频的 pattern。
+
+【选择原则】
+- 只能从 candidate_patterns 里选择,不能创造新的 pattern_key。
+- Pattern 是增强器,不是必选模板;如果没有足够贴合的视频主题,允许不选择 pattern。
+- 优先选择和视频解构选题/关键点/目的点/灵感点语义最相关的 pattern。
+- 历史消耗和 fallback_score 只能在语义同等相关时作为参考,不能覆盖明显不匹配的主题。
+- 不要为了凑满 top_k 强行选择弱相关 pattern;匹配到几个就返回几个,没有匹配到就返回空数组。
+- 抽象主题选择能承载情绪、关系或态度的 pattern;具象主题选择能承载对象、过程或现场的 pattern。
+- 清单类 pattern 只在原始主题本身具备可列举结构时选择;不要把不可数的抽象心态硬套成清单。
+- 目标用户是 60 岁以上小程序视频产品用户,目标是提高点击和后续观看/裂变。
+- 保留轻悬念、轻猎奇、信息差,但必须规避医疗恐吓、涉政民族对立、名人历史、迷信、低俗两性、假按钮、假官方。
+- 输出必须是 JSON,不要输出解释性正文。无匹配时输出 {"selected": []}。

+ 22 - 0
examples/auto_put_ad_mini/prompts/ai_sanitize_video_description.md

@@ -0,0 +1,22 @@
+【system】
+你是广告封面生成前的主题提炼器。
+把视频解构选题提炼成一句广告主题种子。只输出一句中文,不要解释。
+
+【user】
+请把下面的原始视频选题提炼成广告封面图使用的主题种子。
+
+目标用户:60 岁以上中文信息流用户。
+
+提炼原则:
+1. 保留原始选题的核心问题、信息差、反差、情绪价值或生活提醒价值。
+2. 目标用户不等于画面主体;不要为了说明目标用户而强行写“老人、中老年、退休、晚年、长辈”。
+3. 只有原始选题本身强相关时,才可以保留年龄、退休、养老等表达。
+4. 去掉镜头结构、口播包装、字幕设计、转发关注、公众号回复、加群、领取、点击、下载等投放话术。
+5. 高风险事件只提炼成普适提醒或信息差,不要复述犯罪过程、恐吓细节、儿童危险或操作步骤。
+6. 输出 35-80 个中文字符,一句话,要让人知道“为什么想点开”,但不要像假通知或强诱导广告。
+
+禁止词:
+领取、能领、已办成、赶紧、通知、不错过、转发、关注、公众号、加群、优惠券、卡券、下单、购买、扫码、下载。
+
+原始视频选题:
+{{raw_description}}

+ 157 - 0
examples/auto_put_ad_mini/run_daily_service.py

@@ -0,0 +1,157 @@
+#!/usr/bin/env python
+"""Schedule daily creation and two-hour creative review scans."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+from apscheduler.schedulers.blocking import BlockingScheduler
+from apscheduler.triggers.cron import CronTrigger
+from apscheduler.triggers.interval import IntervalTrigger
+from dotenv import load_dotenv
+
+
+HERE = Path(__file__).resolve().parent
+ROOT = HERE.parents[1]
+RTC_DIR = ROOT / "examples" / "tencent_realtime_control"
+for path in (ROOT, HERE, RTC_DIR):
+    if str(path) not in sys.path:
+        sys.path.insert(0, str(path))
+
+load_dotenv(HERE / ".env", override=False)
+load_dotenv(Path.cwd() / ".env", override=False)
+
+from config import TIME_SERIES_DEFAULT  # noqa: E402
+from db.connection import get_connection  # noqa: E402
+from storage import advisory_lock  # noqa: E402
+
+
+logger = logging.getLogger("auto_put_ad_mini.daily_service")
+
+
+def _env_flag(name: str, default: bool) -> bool:
+    raw = os.getenv(name)
+    if raw is None:
+        return default
+    return raw.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def sync_enabled_delivery_templates() -> int:
+    """Keep enabled DB templates aligned with the code's global delivery window."""
+    serialized = json.dumps(TIME_SERIES_DEFAULT)
+    connection = get_connection()
+    try:
+        with connection.cursor() as cursor:
+            return cursor.execute(
+                """
+                UPDATE ad_delivery_template
+                SET time_series_json=%s,
+                    updated_by='ad_daily_service'
+                WHERE enabled=TRUE
+                  AND time_series_json<>%s
+                """,
+                (serialized, serialized),
+            )
+    finally:
+        connection.close()
+
+
+def _run_script(script_name: str, lock_name: str) -> None:
+    with advisory_lock(lock_name) as acquired:
+        if not acquired:
+            logger.warning("Skip %s: another instance holds %s", script_name, lock_name)
+            return
+        logger.info("Starting %s", script_name)
+        completed = subprocess.run(
+            [sys.executable, str(HERE / script_name)],
+            cwd=HERE,
+            check=False,
+        )
+        if completed.returncode:
+            raise RuntimeError(
+                f"{script_name} exited with code {completed.returncode}"
+            )
+        logger.info("Finished %s", script_name)
+
+
+def run_creation() -> None:
+    _run_script(
+        "execute_creation_once.py",
+        os.getenv("DAILY_CREATION_LOCK_NAME", "ad_daily_creation"),
+    )
+
+
+def run_creative_review() -> None:
+    _run_script(
+        "scan_creative_reviews.py",
+        os.getenv("DAILY_REVIEW_LOCK_NAME", "ad_creative_review_scan"),
+    )
+
+
+def main() -> None:
+    if _env_flag("DAILY_SYNC_DELIVERY_TEMPLATE", False):
+        changed = sync_enabled_delivery_templates()
+        logger.info("Synchronized %d enabled delivery template(s)", changed)
+
+    scheduler = BlockingScheduler(timezone="Asia/Shanghai")
+    if _env_flag("DAILY_CREATION_ENABLED", False):
+        creation_hour = int(os.getenv("DAILY_CREATION_HOUR", "10"))
+        creation_minute = int(os.getenv("DAILY_CREATION_MINUTE", "30"))
+        scheduler.add_job(
+            run_creation,
+            CronTrigger(
+                hour=creation_hour,
+                minute=creation_minute,
+                timezone="Asia/Shanghai",
+            ),
+            id="daily_creation",
+            name="广告与创意创建",
+            max_instances=1,
+            coalesce=True,
+            misfire_grace_time=int(
+                os.getenv("DAILY_CREATION_MISFIRE_GRACE_SECONDS", "3600")
+            ),
+        )
+    if _env_flag("DAILY_REVIEW_ENABLED", False):
+        scheduler.add_job(
+            run_creative_review,
+            IntervalTrigger(
+                hours=int(os.getenv("DAILY_REVIEW_INTERVAL_HOURS", "2")),
+                timezone="Asia/Shanghai",
+            ),
+            id="creative_review_scan",
+            name="腾讯创意审核扫描",
+            max_instances=1,
+            coalesce=True,
+            misfire_grace_time=1800,
+        )
+    if _env_flag("DAILY_RUN_ON_STARTUP", False):
+        scheduler.add_job(
+            run_creation,
+            id="daily_creation_startup",
+            name="启动时创建检查",
+        )
+    if _env_flag("DAILY_REVIEW_RUN_ON_STARTUP", False):
+        scheduler.add_job(
+            run_creative_review,
+            id="creative_review_startup",
+            name="启动时审核扫描",
+        )
+    logger.info(
+        "Daily service started jobs=%s",
+        [job.id for job in scheduler.get_jobs()],
+    )
+    scheduler.start()
+
+
+if __name__ == "__main__":
+    logging.basicConfig(
+        level=os.getenv("LOG_LEVEL", "INFO"),
+        format="%(asctime)s %(levelname)s %(name)s %(message)s",
+    )
+    main()

+ 27 - 0
examples/auto_put_ad_mini/seed_material_creative_patterns.py

@@ -0,0 +1,27 @@
+"""Seed initial DRAFT creative patterns for AI material planning."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+_HERE = Path(__file__).parent
+load_dotenv(_HERE / ".env")
+sys.path.insert(0, str(_HERE))
+
+from tools.material_strategy_learning import (  # noqa: E402
+    DEFAULT_PATTERN_VERSION,
+    seed_default_draft_patterns,
+)
+
+
+def main() -> int:
+    count = seed_default_draft_patterns()
+    print(f"OK: seeded {count} DRAFT patterns pattern_version={DEFAULT_PATTERN_VERSION}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 177 - 0
examples/auto_put_ad_mini/sql/high_consumption_materials_30d.sql

@@ -0,0 +1,177 @@
+-- 最近 30 天高消耗素材聚合
+-- 日期窗口:2026-06-07 ~ 2026-07-06
+-- 目标:按 dynamic creative 聚合素材表现,用于归纳高消耗/高CTR素材方法论。
+
+WITH metric_day AS (
+    SELECT  creative_id
+            ,dt
+            ,valid_click_count
+            ,view_count
+            ,cost
+            ,conversions_count
+            ,key_page_view_count
+            ,key_page_uv
+            ,thousand_display_price
+    FROM    (
+                SELECT  creative_id
+                        ,dt
+                        ,valid_click_count
+                        ,view_count
+                        ,cost
+                        ,conversions_count
+                        ,key_page_view_count
+                        ,key_page_uv
+                        ,thousand_display_price
+                        ,ROW_NUMBER() OVER (PARTITION BY creative_id,dt ORDER BY update_time DESC) AS rn
+                FROM    loghubods.ad_put_tencent_creative_data_day
+                WHERE   dt >= '2026-06-07'
+                AND     dt <= '2026-07-06'
+                AND     creative_id IS NOT NULL
+            ) t
+    WHERE   rn = 1
+),
+metric_agg AS (
+    SELECT  creative_id
+            ,MIN(dt) AS first_dt
+            ,MAX(dt) AS last_dt
+            ,COUNT(DISTINCT CASE WHEN cost > 0 THEN dt ELSE NULL END) AS active_days
+            ,SUM(cost) / 100 AS cost_yuan
+            ,SUM(view_count) AS view_count
+            ,SUM(valid_click_count) AS valid_click_count
+            ,SUM(key_page_view_count) AS key_page_view_count
+            ,SUM(key_page_uv) AS key_page_uv
+            ,SUM(conversions_count) AS conversions_count
+            ,AVG(thousand_display_price) AS avg_thousand_display_price
+    FROM    metric_day
+    GROUP BY creative_id
+),
+latest_creative AS (
+    SELECT  account_id
+            ,ad_id
+            ,creative_id
+            ,creative_name
+            ,creative_status
+            ,create_time AS creative_created_time
+    FROM    (
+                SELECT  c.account_id
+                        ,c.ad_id
+                        ,c.creative_id
+                        ,c.creative_name
+                        ,c.creative_status
+                        ,c.create_time
+                        ,ROW_NUMBER() OVER (PARTITION BY c.creative_id ORDER BY c.create_time DESC) AS rn
+                FROM    loghubods.ad_put_tencent_creative_day c
+                JOIN    metric_agg m
+                ON      c.creative_id = m.creative_id
+            ) t
+    WHERE   rn = 1
+),
+latest_component AS (
+    SELECT  creative_id
+            ,MAX(page_spec) AS page_spec
+    FROM    loghubods.ad_put_tencent_creative_components
+    WHERE   page_type = 'PAGE_TYPE_WECHAT_MINI_PROGRAM'
+    GROUP BY creative_id
+),
+latest_ad AS (
+    SELECT  ad_id
+            ,account_id
+            ,ad_name
+            ,create_time AS ad_create_time
+            ,ad_status
+            ,system_status AS ad_system_status
+            ,optimization_goal
+            ,bid_amount
+            ,day_amount
+            ,'' AS site_set
+            ,targeting
+    FROM    (
+                SELECT  a.ad_id
+                        ,a.account_id
+                        ,a.ad_name
+                        ,a.create_time
+                        ,a.ad_status
+                        ,a.system_status
+                        ,a.optimization_goal
+                        ,a.bid_amount
+                        ,a.day_amount
+                        ,a.targeting
+                        ,ROW_NUMBER() OVER (PARTITION BY a.ad_id ORDER BY a.update_time DESC) AS rn
+                FROM    loghubods.ad_put_tencent_ad a
+                JOIN    latest_creative c
+                ON      a.ad_id = c.ad_id
+            ) t
+    WHERE   rn = 1
+),
+ad_package AS (
+    SELECT  ad_id
+            ,package_id
+            ,package_name
+            ,min_people
+    FROM    (
+                SELECT  m.ad_id
+                        ,m.package_id
+                        ,p.package_name
+                        ,p.min_people
+                        ,ROW_NUMBER() OVER (PARTITION BY m.ad_id ORDER BY CAST(p.min_people AS BIGINT) ASC) AS rn
+                FROM    loghubods.ad_put_tencent_ad_package_mapping m
+                LEFT JOIN loghubods.ad_put_tencent_package p
+                ON      m.package_id = p.tencent_audience_id
+                JOIN    latest_creative c
+                ON      m.ad_id = c.ad_id
+                WHERE   m.is_delete = 0
+            ) t
+    WHERE   rn = 1
+),
+creative_analysis AS (
+    SELECT  creative_id
+            ,MAX(title) AS title
+            ,MAX(image_url) AS image_url
+    FROM    loghubods.ad_put_tencent_creative_analysis
+    GROUP BY creative_id
+)
+SELECT  c.account_id
+        ,c.ad_id
+        ,a.ad_name
+        ,m.creative_id
+        ,c.creative_name
+        ,SPLIT(SPLIT(GET_JSON_OBJECT(cp.page_spec,'$.wechat_mini_program_spec.mini_program_path'),'rootSourceId%3D')[1],'_')[3] AS video_id
+        ,ca.title
+        ,ca.image_url
+        ,p.package_id
+        ,p.package_name
+        ,p.min_people
+        ,a.optimization_goal
+        ,a.bid_amount
+        ,a.day_amount
+        ,a.site_set
+        ,a.ad_status
+        ,a.ad_system_status
+        ,c.creative_status
+        ,m.first_dt
+        ,m.last_dt
+        ,m.active_days
+        ,m.cost_yuan
+        ,m.view_count
+        ,m.valid_click_count
+        ,CASE WHEN m.view_count > 0 THEN m.valid_click_count / m.view_count ELSE NULL END AS ctr
+        ,m.key_page_view_count
+        ,CASE WHEN m.valid_click_count > 0 THEN m.key_page_view_count / m.valid_click_count ELSE NULL END AS key_page_rate
+        ,m.key_page_uv
+        ,m.conversions_count
+        ,CASE WHEN m.valid_click_count > 0 THEN m.conversions_count / m.valid_click_count ELSE NULL END AS conversion_rate
+        ,m.avg_thousand_display_price
+FROM    metric_agg m
+JOIN    latest_creative c
+ON      m.creative_id = c.creative_id
+LEFT JOIN latest_ad a
+ON      c.ad_id = a.ad_id
+LEFT JOIN latest_component cp
+ON      m.creative_id = cp.creative_id
+LEFT JOIN ad_package p
+ON      c.ad_id = p.ad_id
+LEFT JOIN creative_analysis ca
+ON      m.creative_id = ca.creative_id
+WHERE   m.cost_yuan > 0
+ORDER BY m.cost_yuan DESC
+LIMIT 5000

+ 138 - 5
examples/auto_put_ad_mini/sync_feishu_account_config.py

@@ -11,16 +11,26 @@
   - 仅当「是否自动化执行」为「是」时启用/更新账户。
   - 「否」会禁用已有的 ad_creation_account_config,不删除历史配置。
   - 「初始出价」支持固定值 0.35 或范围 0.28-0.31。
-  - 「预算(单广告)」为空/不限制/不限 时使用模板默认预算;数字按元转换为分。
+  - 「预算(单广告)」为空时使用模板默认预算;不限制/不限/- 按腾讯不限预算 0 传递;数字按元转换为分。
+  - 「素材来源」为空默认历史素材;填 AI生成素材 时走 AI 图片生成链路。
+  - 「生成失败是否回退历史素材」为空/否默认不回退。
+  - 「出价方式」为空默认稳定成本;填「最大转化量」时使用「最大转化量出价」作为控制成本。
+  - 「投放版位」为空使用投放模板默认;填 AIM+ 时使用智能版位。
+  - 「年龄」支持 30-66+ 或 45-66;为空使用 45-66。
+  - 「地域」支持省市中文名逗号分隔;填 不限 时不传地域定向;为空使用投放模板。
+  - 「日期」是飞书配置生效日期,每日只同步当天日期的行。
 """
 
 from __future__ import annotations
 
 import argparse
 import json
+import re
 import sys
+from datetime import date, datetime
 from pathlib import Path
 from typing import Any
+from zoneinfo import ZoneInfo
 
 import httpx
 from dotenv import load_dotenv
@@ -32,11 +42,23 @@ sys.path.insert(0, str(_HERE))
 
 from configure_creation_accounts import (  # noqa: E402
     AccountConfigInput,
+    DEFAULT_AGE_MAX,
+    DEFAULT_AGE_MIN,
     parse_bid,
     parse_budget_fen,
     set_account_automation_enabled,
     upsert_account_config,
 )
+from tools.account_material_strategy import (  # noqa: E402
+    normalize_material_source,
+    parse_bool_flag,
+)
+from tools.delivery_config import (  # noqa: E402
+    BID_MODE_MAX_CONVERSION,
+    parse_bid_scene,
+    parse_geo_location_config,
+    parse_placement_config,
+)
 from tools.feishu_doc import (  # noqa: E402
     FEISHU_BASE_URL,
     _auth_headers,
@@ -46,12 +68,13 @@ from tools.feishu_doc import (  # noqa: E402
 
 DEFAULT_SPREADSHEET_TOKEN = "D8f4sKEb2hfBnNttX1ucZTklnHf"
 DEFAULT_SHEET_ID = "y3uCcz"
-DEFAULT_RANGE = "A1:G300"
+DEFAULT_RANGE = "A1:N300"
 
 HEADER_ROW_INDEX = 1
 DATA_START_INDEX = 2
 
 REQUIRED_COLUMNS = {
+    "日期": "config_date",
     "账户id": "account_id",
     "人群包": "audience_name",
     "初始出价": "bid",
@@ -59,6 +82,18 @@ REQUIRED_COLUMNS = {
     "是否自动化执行": "enabled",
 }
 
+OPTIONAL_COLUMNS = {
+    "素材来源": "material_source",
+    "生成失败是否回退历史素材": "ai_fallback_to_history",
+    "出价方式": "bid_scene",
+    "最大转化量出价": "max_conversion_bid",
+    "投放版位": "placement",
+    "版位": "placement",
+    "年龄": "age",
+    "地域": "geo_location",
+    "投放地域": "geo_location",
+}
+
 
 def _plain_value(value: Any) -> str:
     """将飞书单元格值(可能是富文本 list/dict)转为纯文本字符串"""
@@ -119,18 +154,53 @@ def _parse_account_id(raw: str) -> int | None:
         raise ValueError(f"账户id非法:{raw}") from e
 
 
+def _parse_config_date(raw: str) -> date:
+    text = raw.strip().replace("/", "-")
+    for fmt in ("%Y%m%d", "%Y-%m-%d"):
+        try:
+            return datetime.strptime(text, fmt).date()
+        except ValueError:
+            continue
+    raise ValueError(f"日期非法:{raw}; 请输入 YYYYMMDD 或 YYYY-MM-DD")
+
+
+def _parse_age_range(raw: str) -> tuple[int, int]:
+    text = raw.strip()
+    if not text:
+        return DEFAULT_AGE_MIN, DEFAULT_AGE_MAX
+    matched = re.fullmatch(r"(\d{1,3})\s*[-~至]\s*(\d{1,3})\+?", text)
+    if not matched:
+        raise ValueError(f"年龄格式非法:{raw}; 示例 30-66+")
+    age_min, age_max = (int(value) for value in matched.groups())
+    if age_min > age_max:
+        raise ValueError(f"年龄范围非法:{raw}")
+    return age_min, age_max
+
+
 def sync_from_feishu(
     spreadsheet_token: str = DEFAULT_SPREADSHEET_TOKEN,
     sheet_id: str = DEFAULT_SHEET_ID,
     cell_range: str = DEFAULT_RANGE,
+    config_date: str | None = None,
     dry_run: bool = False,
     strict: bool = True,
-) -> dict[str, int]:
+) -> dict[str, Any]:
     """从飞书「自动化账户」表同步账户配置到 DB,返回统计信息(enabled/disabled/skipped/errors)"""
     rows = _fetch_rows(spreadsheet_token, sheet_id, cell_range)
     mapping = _header_map(rows)
+    target_config_date = (
+        _parse_config_date(config_date)
+        if config_date else datetime.now(ZoneInfo("Asia/Shanghai")).date()
+    )
 
-    stats = {"enabled": 0, "disabled": 0, "skipped": 0, "errors": 0}
+    stats = {
+        "config_date": target_config_date.isoformat(),
+        "enabled": 0,
+        "disabled": 0,
+        "skipped": 0,
+        "date_skipped": 0,
+        "errors": 0,
+    }
     enabled_records: list[AccountConfigInput] = []
     disabled_accounts: list[int] = []
     for row_num, row in enumerate(rows[DATA_START_INDEX:], start=DATA_START_INDEX + 1):
@@ -140,6 +210,11 @@ def sync_from_feishu(
                 stats["skipped"] += 1
                 continue
 
+            row_config_date = _parse_config_date(_cell(row, mapping["日期"]))
+            if row_config_date != target_config_date:
+                stats["date_skipped"] += 1
+                continue
+
             enabled_text = _cell(row, mapping["是否自动化执行"])
             enabled = enabled_text == "是"
             if not enabled:
@@ -150,7 +225,43 @@ def sync_from_feishu(
             audience_name = _cell(row, mapping["人群包"])
             bid_text = _cell(row, mapping["初始出价"])
             budget_text = _cell(row, mapping["预算(单广告)"])
+            material_source_text = (
+                _cell(row, mapping["素材来源"])
+                if "素材来源" in mapping else ""
+            )
+            fallback_text = (
+                _cell(row, mapping["生成失败是否回退历史素材"])
+                if "生成失败是否回退历史素材" in mapping else ""
+            )
+            bid_scene_text = (
+                _cell(row, mapping["出价方式"])
+                if "出价方式" in mapping else ""
+            )
+            max_conversion_bid_text = (
+                _cell(row, mapping["最大转化量出价"])
+                if "最大转化量出价" in mapping else ""
+            )
+            placement_col = "投放版位" if "投放版位" in mapping else "版位"
+            placement_text = (
+                _cell(row, mapping[placement_col])
+                if placement_col in mapping else ""
+            )
+            age_text = _cell(row, mapping["年龄"]) if "年龄" in mapping else ""
+            geo_col = "地域" if "地域" in mapping else "投放地域"
+            geo_text = _cell(row, mapping[geo_col]) if geo_col in mapping else ""
             bid_min, bid_max, bid_amount = parse_bid(bid_text)
+            bid_scene = parse_bid_scene(bid_scene_text)
+            custom_cost_cap_fen = None
+            if bid_scene == BID_MODE_MAX_CONVERSION:
+                if not max_conversion_bid_text:
+                    raise ValueError("最大转化量出价不能为空")
+                max_bid_min, max_bid_max, max_bid_amount = parse_bid(max_conversion_bid_text)
+                if max_bid_min != max_bid_max or max_bid_amount is None:
+                    raise ValueError("最大转化量出价必须是固定值")
+                custom_cost_cap_fen = max_bid_amount
+            placement = parse_placement_config(placement_text)
+            age_min, age_max = _parse_age_range(age_text)
+            geo_location = parse_geo_location_config(geo_text)
             enabled_records.append(AccountConfigInput(
                 account_id=account_id,
                 audience_name=audience_name,
@@ -159,6 +270,21 @@ def sync_from_feishu(
                 bid_amount_fen=bid_amount,
                 daily_budget_fen=parse_budget_fen(budget_text),
                 enabled=True,
+                material_source=normalize_material_source(material_source_text),
+                ai_fallback_to_history=parse_bool_flag(fallback_text),
+                bid_scene=bid_scene,
+                custom_cost_cap_fen=custom_cost_cap_fen,
+                automatic_site_enabled=(
+                    placement.automatic_site_enabled if placement is not None else None
+                ),
+                site_set=(placement.site_set if placement is not None else None),
+                age_min=age_min,
+                age_max=age_max,
+                location_types=(
+                    geo_location.location_types if geo_location is not None else None
+                ),
+                region_ids=(geo_location.region_ids if geo_location is not None else None),
+                config_date=row_config_date,
             ))
             stats["enabled"] += 1
         except Exception as e:
@@ -170,7 +296,12 @@ def sync_from_feishu(
         raise RuntimeError(f"飞书配置表存在 {stats['errors']} 行错误,停止本轮自动化")
 
     for account_id in disabled_accounts:
-        set_account_automation_enabled(account_id, False, dry_run=dry_run)
+        set_account_automation_enabled(
+            account_id,
+            False,
+            config_date=target_config_date,
+            dry_run=dry_run,
+        )
     for record in enabled_records:
         upsert_account_config(record, dry_run=dry_run)
 
@@ -184,6 +315,7 @@ def main() -> int:
     parser.add_argument("--spreadsheet-token", default=DEFAULT_SPREADSHEET_TOKEN)
     parser.add_argument("--sheet-id", default=DEFAULT_SHEET_ID)
     parser.add_argument("--range", default=DEFAULT_RANGE)
+    parser.add_argument("--config-date", help="只同步该配置日期,格式 YYYYMMDD 或 YYYY-MM-DD")
     parser.add_argument("--dry-run", action="store_true")
     parser.add_argument("--allow-row-errors", action="store_true")
     args = parser.parse_args()
@@ -192,6 +324,7 @@ def main() -> int:
         spreadsheet_token=args.spreadsheet_token,
         sheet_id=args.sheet_id,
         cell_range=args.range,
+        config_date=args.config_date,
         dry_run=args.dry_run,
         strict=not args.allow_row_errors,
     )

+ 55 - 0
examples/auto_put_ad_mini/test_ad_creation_status.py

@@ -11,6 +11,12 @@ if str(PROJECT_ROOT) not in sys.path:
     sys.path.insert(0, str(PROJECT_ROOT))
 
 from tools.ad_creation import AdCandidate, build_ad_request_body
+from tools.delivery_config import (
+    BID_MODE_AVERAGE_COST,
+    BID_MODE_MAX_CONVERSION,
+    parse_bid_scene,
+    parse_placement_config,
+)
 
 
 def _candidate() -> AdCandidate:
@@ -39,6 +45,55 @@ class AdCreationStatusTest(unittest.TestCase):
 
         self.assertEqual(body["configured_status"], "AD_STATUS_NORMAL")
 
+    def test_max_conversion_uses_systematic_bid_with_custom_cost_cap(self):
+        candidate = _candidate()
+        candidate.bid_scene = BID_MODE_MAX_CONVERSION
+        candidate.custom_cost_cap_fen = 48
+        with patch("tools.ad_creation.get_account_feedback_id", return_value=6700001):
+            body = build_ad_request_body(candidate, begin_date="2026-07-01")
+
+        self.assertEqual("SMART_BID_TYPE_SYSTEMATIC", body["smart_bid_type"])
+        self.assertEqual(0, body["bid_amount"])
+        self.assertEqual("COST_CONSTRAINT_SCENE_OPEN", body["cost_constraint_scene"])
+        self.assertEqual(48, body["custom_cost_cap"])
+        self.assertNotIn("bid_scene", body)
+
+    def test_aim_placement_uses_automatic_site_without_site_set(self):
+        candidate = _candidate()
+        candidate.automatic_site_enabled = True
+        candidate.site_set = []
+        with patch("tools.ad_creation.get_account_feedback_id", return_value=6700001):
+            body = build_ad_request_body(candidate, begin_date="2026-07-01")
+
+        self.assertTrue(body["automatic_site_enabled"])
+        self.assertNotIn("site_set", body)
+
+    def test_parse_feishu_bid_scene_and_placement(self):
+        self.assertEqual(BID_MODE_AVERAGE_COST, parse_bid_scene(""))
+        self.assertEqual(BID_MODE_MAX_CONVERSION, parse_bid_scene("最大转化量"))
+
+        placement = parse_placement_config(
+            "微信朋友圈,微信公众号与小程序,腾讯平台与内容媒体,腾讯营销联盟"
+        )
+        self.assertFalse(placement.automatic_site_enabled)
+        self.assertEqual(
+            [
+                "SITE_SET_MOMENTS",
+                "SITE_SET_WECHAT",
+                "SITE_SET_WECHAT_PLUGIN",
+                "SITE_SET_TENCENT_NEWS",
+                "SITE_SET_TENCENT_VIDEO",
+                "SITE_SET_KANDIAN",
+                "SITE_SET_QQ_MUSIC_GAME",
+                "SITE_SET_MOBILE_UNION",
+            ],
+            placement.site_set,
+        )
+
+        aim = parse_placement_config("AIM+")
+        self.assertTrue(aim.automatic_site_enabled)
+        self.assertEqual([], aim.site_set)
+
 
 if __name__ == "__main__":
     unittest.main()

+ 68 - 0
examples/auto_put_ad_mini/test_ai_generated_materials.py

@@ -0,0 +1,68 @@
+import unittest
+import os
+import sys
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+_HERE = Path(__file__).parent
+sys.path.insert(0, str(_HERE))
+sys.path.insert(0, str(_HERE.parent.parent))
+
+from tools.ai_generated_material import (
+    _public_oss_url,
+    build_generation_prompts,
+)
+from tools.account_material_strategy import normalize_material_source, parse_bool_flag
+from tools.video_feature_query import read_cached_video_element_features
+
+
+class AiGeneratedMaterialsTest(unittest.TestCase):
+    def test_normalize_material_source_defaults_to_history(self):
+        self.assertEqual(normalize_material_source(""), "history")
+        self.assertEqual(normalize_material_source("历史素材"), "history")
+        self.assertEqual(normalize_material_source("AI生成素材"), "ai_generated")
+
+    def test_parse_bool_flag_accepts_feishu_yes_no(self):
+        self.assertTrue(parse_bool_flag("是"))
+        self.assertFalse(parse_bool_flag("否"))
+        self.assertFalse(parse_bool_flag(""))
+
+    def test_build_generation_prompts_uses_cached_video_topic(self):
+        load_dotenv(_HERE / ".env")
+        video_id = int(os.getenv("AI_MATERIAL_TEST_VIDEO_ID", "69131739"))
+        try:
+            features = read_cached_video_element_features([video_id]).get(video_id) or []
+        except Exception as e:
+            self.skipTest(f"数据库不可用,跳过真实视频特征测试:{e}")
+        topic = next((f for f in features if f.element_dimension == "解构选题"), None)
+        if topic is None:
+            self.skipTest(f"video_id={video_id} 在本地缓存表中没有解构选题")
+
+        prompts = build_generation_prompts(
+            video_id=video_id,
+            title="不应作为兜底标题",
+            category="不应作为兜底品类",
+            features=features,
+        )
+
+        self.assertEqual([p.prompt_type for p in prompts], ["topic"])
+        self.assertIn(topic.standard_element, prompts[0].prompt_text)
+        self.assertEqual(prompts[0].feature_hits[0]["original_standard_element"], topic.standard_element)
+        self.assertEqual(prompts[0].feature_hits[0]["video_description"], topic.standard_element)
+        self.assertIn("腾讯广告信息流 / 公众号投放", prompts[0].prompt_text)
+        self.assertNotIn("不应作为兜底标题", prompts[0].prompt_text)
+        self.assertNotIn("不应作为兜底品类", prompts[0].prompt_text)
+        self.assertIn("16:9", prompts[0].prompt_text)
+
+    def test_public_oss_url_keeps_directory_slashes(self):
+        self.assertEqual(
+            _public_oss_url(
+                "https://rescdn.yishihui.com/",
+                "auto_put_tencent/image/a b/test.jpg",
+            ),
+            "https://rescdn.yishihui.com/auto_put_tencent/image/a%20b/test.jpg",
+        )
+
+if __name__ == "__main__":
+    unittest.main()

+ 75 - 0
examples/auto_put_ad_mini/test_im_approval_creation.py

@@ -0,0 +1,75 @@
+import importlib
+import os
+import sys
+import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import patch
+
+from openpyxl import load_workbook
+
+
+ROOT = Path(__file__).resolve().parent
+sys.path.insert(0, str(ROOT))
+
+from tools.im_approval_creation import (  # noqa: E402
+    DECISION_COL_LETTER,
+    generate_approval_xlsx,
+)
+import tools.im_approval_creation as approval_module  # noqa: E402
+
+
+def _sample_record() -> dict:
+    return {
+        "approval_date": "2026-07-08",
+        "account_id": 86197363,
+        "audience_tier": "回流330以上人群",
+        "adgroup_id": 115273005955,
+        "adgroup_name": "回流330以上人群-20260708-关键页面-targeted",
+        "bid_amount_yuan": "0.40",
+        "site_set": "微信公众号,朋友圈",
+        "age_range": "45-66",
+        "landing_video_id": 63747053,
+        "landing_video_url": "https://example.com/video.mp4",
+        "landing_title": "测试落地视频",
+        "landing_risk_level": 1,
+        "landing_risk_tag_ids": "85856",
+        "landing_risk_reason": "risk level 1 <= allowed 5",
+        "material_source": "ai_generated",
+        "material_cover_url": "https://rescdn.yishihui.com/auto_put_tencent/image/test.jpg",
+        "creative_name": "touliu_tencent_20260708_63747053_test",
+        "_description_contents": ["打开看看"],
+    }
+
+
+class CreativeApprovalSheetTest(unittest.TestCase):
+    def test_embeds_material_preview_images_by_default(self):
+        with patch.dict(os.environ, {}, clear=False):
+            os.environ.pop("CREATION_APPROVAL_EMBED_IMAGES", None)
+            reloaded = importlib.reload(approval_module)
+
+        self.assertTrue(reloaded.EMBED_MATERIAL_PREVIEW_IMAGES)
+
+    def test_keeps_dedicated_material_link_column(self):
+        with TemporaryDirectory() as tmp:
+            output = Path(tmp) / "approval.xlsx"
+            generate_approval_xlsx([_sample_record()], output)
+
+            wb = load_workbook(output, data_only=False)
+            ws = wb.active
+            headers = [ws.cell(1, col).value for col in range(1, ws.max_column + 1)]
+
+            self.assertIn("素材预览", headers)
+            self.assertIn("素材链接", headers)
+            self.assertEqual("决策", headers[-1])
+            self.assertEqual("AB", DECISION_COL_LETTER)
+
+            material_link_col = headers.index("素材链接") + 1
+            self.assertEqual(
+                '=HYPERLINK("https://rescdn.yishihui.com/auto_put_tencent/image/test.jpg","打开素材")',
+                ws.cell(2, material_link_col).value,
+            )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 177 - 0
examples/auto_put_ad_mini/test_landing_video_dedupe.py

@@ -0,0 +1,177 @@
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+
+_HERE = Path(__file__).parent
+sys.path.insert(0, str(_HERE))
+
+import execute_creation_once  # noqa: E402
+
+
+class _Strategy:
+    def __init__(self, material_source):
+        self.material_source = material_source
+        self.use_ai_generated = material_source == "ai_generated"
+        self.ai_fallback_to_history = False
+
+
+class LandingVideoDedupeTest(unittest.TestCase):
+    def test_same_crowd_package_excludes_landing_video_across_accounts_in_one_run(self):
+        calls = []
+
+        def fake_prepare(account_id, adgroup_id, *, excluded_material_ids, excluded_landing_ids, **kwargs):
+            calls.append((account_id, adgroup_id, set(excluded_landing_ids)))
+            return {
+                "account_id": account_id,
+                "adgroup_id": adgroup_id,
+                "audience_tier": "wx*商业",
+                "landing_video_id": 1000 + account_id,
+                "_material_id": f"m-{account_id}",
+                "material_source": "history",
+            }
+
+        with patch.object(execute_creation_once, "get_creation_account_ids", return_value=[1, 2]), \
+            patch.object(execute_creation_once, "load_excluded_ad_ids_from_adjustment", return_value=set()), \
+            patch.object(execute_creation_once, "get_account_crowd_package", return_value="wx*商业"), \
+            patch.object(execute_creation_once, "load_recent_landing_usage_counts", return_value={"history": {}, "ai_generated": {}}), \
+            patch.object(execute_creation_once, "load_account_material_strategy", return_value=_Strategy("history")), \
+            patch.object(execute_creation_once, "find_ads_needing_creatives", side_effect=[
+                [{"adgroup_id": 101, "creative_count": 3}],
+                [{"adgroup_id": 201, "creative_count": 3}],
+            ]), \
+            patch.object(execute_creation_once, "build_landing_candidate_pool", return_value=object()), \
+            patch.object(execute_creation_once, "prepare_one_creative_for_ad", side_effect=fake_prepare), \
+            patch.object(execute_creation_once, "record_prepared_material_usage"):
+            records = execute_creation_once.phase1_prepare(target_creatives=4)
+
+        self.assertEqual(2, len(records))
+        self.assertEqual(set(), calls[0][2])
+        self.assertEqual({1001}, calls[1][2])
+
+    def test_ai_landing_dedupe_is_independent_from_history_and_limits_once_per_ai_pool(self):
+        calls = []
+
+        def fake_prepare(account_id, adgroup_id, *, excluded_material_ids, excluded_landing_ids, **kwargs):
+            calls.append(set(excluded_landing_ids))
+            return {
+                "account_id": account_id,
+                "adgroup_id": adgroup_id,
+                "audience_tier": "wx*商业",
+                "landing_video_id": 777,
+                "_material_id": f"ai:{account_id}-{adgroup_id}-{len(calls)}",
+                "material_source": "ai_generated",
+            }
+
+        with patch.object(execute_creation_once, "get_creation_account_ids", return_value=[1, 2]), \
+            patch.object(execute_creation_once, "load_excluded_ad_ids_from_adjustment", return_value=set()), \
+            patch.object(execute_creation_once, "get_account_crowd_package", return_value="wx*商业"), \
+            patch.object(
+                execute_creation_once,
+                "load_recent_landing_usage_counts",
+                return_value={"history": {777: 1}, "ai_generated": {}},
+            ), \
+            patch.object(execute_creation_once, "load_account_material_strategy", return_value=_Strategy("ai_generated")), \
+            patch.object(execute_creation_once, "find_ads_needing_creatives", side_effect=[
+                [{"adgroup_id": 101, "creative_count": 11}],
+                [{"adgroup_id": 201, "creative_count": 11}],
+            ]), \
+            patch.object(execute_creation_once, "build_landing_candidate_pool", return_value=object()), \
+            patch.object(execute_creation_once, "prepare_one_creative_for_ad", side_effect=fake_prepare), \
+            patch.object(execute_creation_once, "record_prepared_material_usage"):
+            records = execute_creation_once.phase1_prepare(target_creatives=12)
+
+        self.assertEqual(1, len(records))
+        self.assertEqual(set(), calls[0])
+        self.assertEqual({777}, calls[1])
+
+    def test_phase1_reuses_landing_candidate_pool_for_multiple_creative_attempts(self):
+        pool = object()
+        build_calls = []
+        prepare_calls = []
+
+        def fake_build_pool(account_id):
+            build_calls.append(account_id)
+            return pool
+
+        def fake_prepare(
+            account_id,
+            adgroup_id,
+            *,
+            excluded_material_ids,
+            excluded_landing_ids,
+            landing_candidates,
+            failed_landing_ids,
+        ):
+            prepare_calls.append((landing_candidates, failed_landing_ids))
+            return {
+                "account_id": account_id,
+                "adgroup_id": adgroup_id,
+                "audience_tier": "R330",
+                "landing_video_id": 1000 + len(prepare_calls),
+                "_material_id": f"ai:{len(prepare_calls)}",
+                "material_source": "ai_generated",
+            }
+
+        with patch.object(execute_creation_once, "get_creation_account_ids", return_value=[1]), \
+            patch.object(execute_creation_once, "load_excluded_ad_ids_from_adjustment", return_value=set()), \
+            patch.object(execute_creation_once, "get_account_crowd_package", return_value="R330"), \
+            patch.object(execute_creation_once, "load_recent_landing_usage_counts", return_value={"history": {}, "ai_generated": {}}), \
+            patch.object(execute_creation_once, "load_account_material_strategy", return_value=_Strategy("ai_generated")), \
+            patch.object(execute_creation_once, "find_ads_needing_creatives", return_value=[
+                {"adgroup_id": 101, "creative_count": 0},
+            ]), \
+            patch.object(execute_creation_once, "build_landing_candidate_pool", side_effect=fake_build_pool), \
+            patch.object(execute_creation_once, "prepare_one_creative_for_ad", side_effect=fake_prepare), \
+            patch.object(execute_creation_once, "record_prepared_material_usage"):
+            records = execute_creation_once.phase1_prepare(target_creatives=2)
+
+        self.assertEqual(2, len(records))
+        self.assertEqual([1], build_calls)
+        self.assertEqual([(pool, set()), (pool, set())], prepare_calls)
+
+    def test_phase1_carries_failed_landing_ids_between_attempts(self):
+        calls = []
+
+        def fake_prepare(
+            account_id,
+            adgroup_id,
+            *,
+            excluded_material_ids,
+            excluded_landing_ids,
+            landing_candidates,
+            failed_landing_ids,
+        ):
+            calls.append(set(failed_landing_ids))
+            if len(calls) == 1:
+                failed_landing_ids.add(777)
+                return None
+            return {
+                "account_id": account_id,
+                "adgroup_id": adgroup_id,
+                "audience_tier": "R330",
+                "landing_video_id": 888,
+                "_material_id": "ai:ok",
+                "material_source": "ai_generated",
+            }
+
+        with patch.object(execute_creation_once, "get_creation_account_ids", return_value=[1]), \
+            patch.object(execute_creation_once, "load_excluded_ad_ids_from_adjustment", return_value=set()), \
+            patch.object(execute_creation_once, "get_account_crowd_package", return_value="R330"), \
+            patch.object(execute_creation_once, "load_recent_landing_usage_counts", return_value={"history": {}, "ai_generated": {}}), \
+            patch.object(execute_creation_once, "load_account_material_strategy", return_value=_Strategy("ai_generated")), \
+            patch.object(execute_creation_once, "find_ads_needing_creatives", return_value=[
+                {"adgroup_id": 101, "creative_count": 0},
+            ]), \
+            patch.object(execute_creation_once, "build_landing_candidate_pool", return_value=object()), \
+            patch.object(execute_creation_once, "prepare_one_creative_for_ad", side_effect=fake_prepare), \
+            patch.object(execute_creation_once, "record_prepared_material_usage"):
+            records = execute_creation_once.phase1_prepare(target_creatives=2)
+
+        self.assertEqual(1, len(records))
+        self.assertEqual([set(), {777}], calls)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 71 - 0
examples/auto_put_ad_mini/test_video_recall_pagination.py

@@ -0,0 +1,71 @@
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+
+_HERE = Path(__file__).parent
+sys.path.insert(0, str(_HERE))
+
+from tools.video_recall import LandingVideo, fetch_landing_videos_for_account  # noqa: E402
+
+
+def _video(video_id: int) -> LandingVideo:
+    return LandingVideo(
+        video_id=video_id,
+        title=f"video-{video_id}",
+        cover_url="",
+        video_url="",
+        score=0.0,
+        rov=0.0,
+        sim=0.0,
+        visit_uv=0,
+        category="",
+        standard_element="",
+        category_name="",
+        demand_content_title="",
+        demand_content_topic="",
+        demand_content_id="",
+        demand_type="",
+        point_type="",
+        dimension="",
+        experiment_id=f"exp-{video_id}",
+    )
+
+
+class VideoRecallPaginationTest(unittest.TestCase):
+    def test_fetch_for_account_reads_up_to_three_pages_and_dedupes(self):
+        calls = []
+
+        def fake_fetch(*, crowd_package, page_size, page_num=1, source, **kwargs):
+            calls.append((crowd_package, page_size, page_num, source))
+            pages = {
+                1: [_video(1), _video(2)],
+                2: [_video(2), _video(3)],
+                3: [_video(4)],
+            }
+            return pages.get(page_num, [])
+
+        with patch("tools.video_recall.get_account_crowd_package", return_value="回流330以上人群"), \
+            patch("tools.video_recall.fetch_landing_videos", side_effect=fake_fetch):
+            videos = fetch_landing_videos_for_account(
+                86197363,
+                page_size=2,
+                max_pages=3,
+                source="prior",
+                enable_hot_fallback=False,
+            )
+
+        self.assertEqual([1, 2, 3, 4], [v.video_id for v in videos])
+        self.assertEqual(
+            [
+                ("R_330+", 2, 1, "prior"),
+                ("R_330+", 2, 2, "prior"),
+                ("R_330+", 2, 3, "prior"),
+            ],
+            calls,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 141 - 0
examples/auto_put_ad_mini/tools/account_material_strategy.py

@@ -0,0 +1,141 @@
+"""Account-level creative material source strategy.
+
+The default remains historical material recall. Accounts explicitly configured
+as AI generated use generated image assets in module B.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+
+logger = logging.getLogger(__name__)
+
+MATERIAL_SOURCE_HISTORY = "history"
+MATERIAL_SOURCE_AI_GENERATED = "ai_generated"
+
+_AI_SOURCE_VALUES = {
+    "ai",
+    "ai_generated",
+    "ai生成",
+    "ai生成素材",
+    "生成素材",
+    "新生成素材",
+}
+_HISTORY_SOURCE_VALUES = {
+    "",
+    "history",
+    "历史",
+    "历史素材",
+    "历史投放素材",
+    "历史已投素材",
+}
+_TRUE_VALUES = {"1", "true", "yes", "y", "是", "允许", "回退"}
+
+
+@dataclass(frozen=True)
+class AccountMaterialStrategy:
+    account_id: int
+    material_source: str = MATERIAL_SOURCE_HISTORY
+    ai_fallback_to_history: bool = False
+
+    @property
+    def use_ai_generated(self) -> bool:
+        return self.material_source == MATERIAL_SOURCE_AI_GENERATED
+
+
+def normalize_material_source(raw: object) -> str:
+    text = str(raw or "").strip().lower()
+    if text in _AI_SOURCE_VALUES:
+        return MATERIAL_SOURCE_AI_GENERATED
+    if text in _HISTORY_SOURCE_VALUES:
+        return MATERIAL_SOURCE_HISTORY
+    raise ValueError(f"未知素材来源:{raw}")
+
+
+def parse_bool_flag(raw: object) -> bool:
+    return str(raw or "").strip().lower() in _TRUE_VALUES
+
+
+def ensure_account_material_strategy_columns() -> None:
+    """Add strategy columns when deploying to an older DB schema."""
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT COLUMN_NAME
+                FROM information_schema.COLUMNS
+                WHERE TABLE_SCHEMA = DATABASE()
+                  AND TABLE_NAME = 'ad_creation_account_config'
+                  AND COLUMN_NAME IN ('material_source', 'ai_fallback_to_history')
+                """
+            )
+            existing = {row["COLUMN_NAME"] for row in (cur.fetchall() or [])}
+            if "material_source" not in existing:
+                cur.execute(
+                    """
+                    ALTER TABLE ad_creation_account_config
+                    ADD COLUMN material_source VARCHAR(50) NOT NULL DEFAULT 'history'
+                    COMMENT '创意素材来源:history/ai_generated'
+                    AFTER feedback_key
+                    """
+                )
+            if "ai_fallback_to_history" not in existing:
+                cur.execute(
+                    """
+                    ALTER TABLE ad_creation_account_config
+                    ADD COLUMN ai_fallback_to_history BOOLEAN NOT NULL DEFAULT FALSE
+                    COMMENT 'AI生成失败时是否回退历史素材'
+                    AFTER material_source
+                    """
+                )
+        conn.commit()
+    finally:
+        conn.close()
+
+
+def load_account_material_strategy(account_id: int) -> AccountMaterialStrategy:
+    """Load configured material strategy. Missing config defaults to history."""
+    from db.connection import get_connection
+
+    try:
+        ensure_account_material_strategy_columns()
+    except Exception as e:
+        logger.warning(
+            "[material_strategy] ensure columns failed account=%d, fallback history: %s",
+            account_id, e,
+        )
+        return AccountMaterialStrategy(account_id=account_id)
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT material_source, ai_fallback_to_history
+                FROM ad_creation_account_config
+                WHERE account_id=%s
+                LIMIT 1
+                """,
+                (account_id,),
+            )
+            row = cur.fetchone() or {}
+    finally:
+        conn.close()
+
+    try:
+        source = normalize_material_source(row.get("material_source"))
+    except ValueError:
+        logger.warning(
+            "[material_strategy] account=%d material_source=%r 非法,按 history 处理",
+            account_id, row.get("material_source"),
+        )
+        source = MATERIAL_SOURCE_HISTORY
+    return AccountMaterialStrategy(
+        account_id=account_id,
+        material_source=source,
+        ai_fallback_to_history=bool(row.get("ai_fallback_to_history")),
+    )

+ 30 - 2
examples/auto_put_ad_mini/tools/ad_api.py

@@ -42,9 +42,14 @@ TOKEN_API_URL = os.getenv(
     "TENCENT_AD_TOKEN_API",
     "https://api.piaoquantv.com/ad/put/tencent/getAccessToken",
 )
+USER_TOKEN_API_URL = os.getenv(
+    "TENCENT_AD_USER_TOKEN_API",
+    "https://api.piaoquantv.com/ad/put/tencent/getUserToken",
+)
 
 # Token 缓存:避免每次 API 调用都重新获取
 _token_cache: Dict[int, Dict[str, Any]] = {}
+_user_token_cache: Dict[int, Dict[str, Any]] = {}
 _TOKEN_CACHE_TTL = 1800  # 缓存有效期 30 分钟
 
 
@@ -123,11 +128,34 @@ def _get(path: str, params: Dict[str, Any]) -> Dict[str, Any]:
 
 
 def _get_user_token_for_account(account_id: int) -> str:
-    """获取 user_token。优先 DB(account_whitelist.user_token),fallback 到环境变量。
+    """动态获取 user_token,接口不可用时 fallback 到 DB/环境变量。
 
     DB 设计原因(2026-06-05):每个账户可能有不同的经办人授权 token,
     全局环境变量无法覆盖多账户场景。
     """
+    cached = _user_token_cache.get(account_id)
+    if cached and time.time() - cached["ts"] < _TOKEN_CACHE_TTL:
+        return cached["token"]
+
+    try:
+        resp = httpx.get(
+            USER_TOKEN_API_URL,
+            params={"accountId": account_id},
+            timeout=10,
+        )
+        resp.raise_for_status()
+        token = resp.text.strip()
+        if token and len(token) > 10:
+            _user_token_cache[account_id] = {"token": token, "ts": time.time()}
+            logger.info(
+                "[UserTokenAPI] 已获取 account=%s 的 user_token (缓存30分钟)",
+                account_id,
+            )
+            return token
+        logger.warning("[UserTokenAPI] 返回内容异常,降级使用 DB/env")
+    except Exception as e:
+        logger.warning("[UserTokenAPI] 请求失败,降级使用 DB/env: %s", e)
+
     if account_id:
         try:
             from db.connection import get_connection
@@ -157,7 +185,7 @@ def _post(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
     公共参数在 URL query,业务参数在 JSON body。
 
     ⚠️ 重要:腾讯广告写操作需要 user_token(实名认证令牌)
-    优先级:DB account_whitelist.user_token > 环境变量 TENCENT_AD_USER_TOKEN
+    优先级:动态 user token API > DB account_whitelist.user_token > 环境变量
     """
     account_id = body.get("account_id", 0)
     params = _common_params(account_id)

+ 42 - 13
examples/auto_put_ad_mini/tools/ad_creation.py

@@ -51,6 +51,7 @@ from config import (
     # 监测链接 / 反馈 ID
     get_account_feedback_id,
 )
+from tools.delivery_config import BID_MODE_MAX_CONVERSION
 
 logger = logging.getLogger(__name__)
 
@@ -80,6 +81,9 @@ class AdCandidate:
     # 版位定投场景 wechat_position(2026-06-09 1 账户 N 广告差异化机制)
     # None = 无定投(走 site_set 默认全场景)/ list[int] = 勾选具体场景 ID
     wechat_position: Optional[list] = None
+    bid_scene: str = "average_cost"
+    custom_cost_cap_fen: Optional[int] = None
+    automatic_site_enabled: bool = False
 
 
 # ═══════════════════════════════════════════
@@ -94,6 +98,8 @@ def compute_fingerprint(
     age: list,
     geo_regions: list,
     wechat_position: Optional[list] = None,
+    automatic_site_enabled: bool = False,
+    bid_scene: str = "average_cost",
 ) -> str:
     """计算"营销内容指纹"用于本地唯一性预校验。
 
@@ -108,6 +114,8 @@ def compute_fingerprint(
         "age": age,
         "geo_regions": sorted(geo_regions),
         "wechat_position": sorted(wechat_position) if wechat_position else None,
+        "automatic_site_enabled": automatic_site_enabled,
+        "bid_scene": bid_scene,
     }
     return hashlib.md5(
         json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
@@ -194,12 +202,14 @@ def enumerate_new_ad_candidates(
     tier_label = cfg["audience_tier_label"]
     custom_audience = [pack_id] if pack_id else None
     bid_amount_fen = cfg["bid_amount_fen"]
+    automatic_site_enabled = bool(cfg.get("automatic_site_enabled"))
+    bid_scene = cfg.get("bid_scene") or "average_cost"
 
     # 差异化策略(2026-06-09 用户确认 + 2026-06-11 缩减):
     #   广告 #1 wechat_position=None   (无定投,走 site_set 默认全场景)
-    #   广告 #2 wechat_position=WECHAT_POSITION_TARGETED_PRESET(本期 4 项小程序版位)
+    #   广告 #2 wechat_position=WECHAT_POSITION_TARGETED_PRESET(公众号内容 + 小程序位置)
     # 同 site_set 同 targeting 但 wechat_position 不同 → 腾讯唯一性绕过(参考 77868332 实测)
-    wechat_position_variants = [None, WECHAT_POSITION_TARGETED_PRESET]
+    wechat_position_variants = [None] if automatic_site_enabled else [None, WECHAT_POSITION_TARGETED_PRESET]
 
     candidates: list[AdCandidate] = []
     site_set = cfg["site_set"]
@@ -214,6 +224,8 @@ def enumerate_new_ad_candidates(
             age=cfg["age"],
             geo_regions=cfg["region_ids"],
             wechat_position=wp,
+            automatic_site_enabled=automatic_site_enabled,
+            bid_scene=bid_scene,
         )
         if fingerprint in existing_fingerprints:
             logger.info(
@@ -233,6 +245,9 @@ def enumerate_new_ad_candidates(
                 site_set=site_set,
                 custom_audience=custom_audience,
                 bid_amount_fen=bid_amount_fen,
+                bid_scene=bid_scene,
+                custom_cost_cap_fen=cfg.get("custom_cost_cap_fen"),
+                automatic_site_enabled=automatic_site_enabled,
                 audience_tier_label=tier_label,
                 age=cfg["age"],
                 location_types=cfg["location_types"],
@@ -274,14 +289,13 @@ def build_ad_request_body(
     """
     begin_date = begin_date or datetime.now().strftime("%Y-%m-%d")
 
-    # 定向 — 固定地域 + 固定年龄 + 可选人群包
-    targeting: dict = {
-        "geo_location": {
+    # 定向 — 账户级年龄/地域 + 可选人群包。
+    targeting: dict = {"age": candidate.age}
+    if candidate.location_types or candidate.region_ids:
+        targeting["geo_location"] = {
             "location_types": candidate.location_types,
             "regions": candidate.region_ids,
-        },
-        "age": candidate.age,
-    }
+        }
     if candidate.custom_audience:
         targeting["custom_audience"] = candidate.custom_audience
 
@@ -312,9 +326,16 @@ def build_ad_request_body(
         "conversion_id": DEFAULT_CONVERSION_ID,
         # === 出价 / 计费(SOP 稳定拿量)===
         "bid_mode": BID_MODE,
-        "smart_bid_type": SMART_BID_TYPE,
         "bid_strategy": BID_STRATEGY,
-        "bid_amount": candidate.bid_amount_fen,
+        "smart_bid_type": (
+            "SMART_BID_TYPE_SYSTEMATIC"
+            if candidate.bid_scene == BID_MODE_MAX_CONVERSION
+            else SMART_BID_TYPE
+        ),
+        "bid_amount": (
+            0 if candidate.bid_scene == BID_MODE_MAX_CONVERSION
+            else candidate.bid_amount_fen
+        ),
         "daily_budget": candidate.daily_budget_fen,
         "auto_acquisition_enabled": AUTO_ACQUISITION_ENABLED,
         "auto_derived_creative_enabled": AUTO_DERIVED_CREATIVE_ENABLED,
@@ -324,8 +345,7 @@ def build_ad_request_body(
         "end_date": _add_years(begin_date, 1),
         "time_series": candidate.time_series,
         # === 版位 ===
-        "automatic_site_enabled": False,
-        "site_set": candidate.site_set,
+        "automatic_site_enabled": candidate.automatic_site_enabled,
         # 搜索场景扩量 · 定向拓展(用户 2026-06-05 确认:关)
         "search_expand_targeting_switch": SEARCH_EXPAND_TARGETING_SWITCH,
         # === 定向 ===
@@ -335,10 +355,19 @@ def build_ad_request_body(
         "smart_targeting_mode": SMART_TARGETING_MODE,
         "targeting": targeting,
     }
+    if not candidate.automatic_site_enabled:
+        body["site_set"] = candidate.site_set
+    if candidate.bid_scene == BID_MODE_MAX_CONVERSION:
+        if candidate.custom_cost_cap_fen is None:
+            raise ValueError(
+                f"account_id {candidate.account_id} 最大转化量缺少 custom_cost_cap_fen"
+            )
+        body["cost_constraint_scene"] = "COST_CONSTRAINT_SCENE_OPEN"
+        body["custom_cost_cap"] = candidate.custom_cost_cap_fen
     # 版位定投场景 wechat_position(2026-06-10 实测修正:POST 时 scene_spec 是顶层字段)
     # 之前误把 scene_spec 放 targeting 内 → 腾讯 reject code 12813 "包含不识别的参数 scene_spec"
     # WebFetch /adgroups/add 文档确认:scene_spec 是顶层 struct,wechat_position 是它的子字段
-    if candidate.wechat_position:
+    if candidate.wechat_position and not candidate.automatic_site_enabled:
         body["scene_spec"] = {"wechat_position": list(candidate.wechat_position)}
 
     if AUTO_ACQUISITION_ENABLED and AUTO_ACQUISITION_BUDGET_FEN:

+ 1196 - 0
examples/auto_put_ad_mini/tools/ai_generated_material.py

@@ -0,0 +1,1196 @@
+"""AI generated creative image assets for module B.
+
+This module is intentionally isolated from the historical material recall path.
+External side effects happen only when an account explicitly uses
+material_source=ai_generated.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import json
+import logging
+import mimetypes
+import os
+import re
+import uuid
+from dataclasses import dataclass
+from email.utils import formatdate
+from io import BytesIO
+from pathlib import Path
+from typing import Any, Iterable, Optional, Sequence
+from urllib.parse import quote, urlparse
+
+import httpx
+from PIL import Image
+
+from tools.material_recall import Material
+from tools.video_feature_query import VideoElementFeature, read_cached_video_element_features
+from tools.video_recall import LandingVideo
+
+logger = logging.getLogger(__name__)
+
+OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
+    "OPENROUTER_CHAT_COMPLETIONS_URL",
+    "https://openrouter.ai/api/v1/chat/completions",
+)
+OPENROUTER_IMAGES_URL = os.getenv(
+    "OPENROUTER_IMAGES_URL",
+    "https://openrouter.ai/api/v1/images",
+)
+OPENROUTER_IMAGE_MODEL = os.getenv(
+    "OPENROUTER_IMAGE_MODEL",
+    "google/gemini-3.1-flash-image",
+)
+OPENROUTER_TEXT_MODEL = os.getenv(
+    "OPENROUTER_TEXT_MODEL",
+    "google/gemini-3-flash-preview",
+)
+AI_MATERIAL_REVIEW_MODEL = os.getenv("AI_MATERIAL_REVIEW_MODEL", "google/gemini-3-flash-preview")
+AI_IMAGE_USE_PATTERN_SELECTOR = os.getenv("AI_IMAGE_USE_PATTERN_SELECTOR", "1").strip().lower() not in (
+    "0", "false", "no", "否",
+)
+AI_IMAGE_PATTERN_TOP_K = int(os.getenv("AI_IMAGE_PATTERN_TOP_K", "1"))
+AI_IMAGE_PATTERN_PLACEMENT = os.getenv("AI_IMAGE_PATTERN_PLACEMENT", "WECHAT_OFFICIAL_ACCOUNTS")
+AI_COVER_COPY_REQUIRED = os.getenv("AI_COVER_COPY_REQUIRED", "1").strip().lower() not in (
+    "0", "false", "no", "否",
+)
+AI_COVER_COPY_MAX_TOKENS = int(os.getenv("AI_COVER_COPY_MAX_TOKENS", "900"))
+AI_COVER_COPY_TITLE_MAX_LEN = int(os.getenv("AI_COVER_COPY_TITLE_MAX_LEN", "22"))
+AI_IMAGE_OSS_PREFIX = os.getenv("AI_IMAGE_OSS_PREFIX", "auto_put_tencent/image").strip("/")
+AI_IMAGE_ASPECT_RATIO = os.getenv("AI_IMAGE_ASPECT_RATIO", "16:9")
+AI_IMAGE_RESOLUTION = os.getenv("AI_IMAGE_RESOLUTION", "1K")
+AI_IMAGE_OUTPUT_FORMAT = os.getenv("AI_IMAGE_OUTPUT_FORMAT", "jpeg")
+AI_IMAGE_TARGET_WIDTH = int(os.getenv("AI_IMAGE_TARGET_WIDTH", "1280"))
+AI_IMAGE_TARGET_HEIGHT = int(os.getenv("AI_IMAGE_TARGET_HEIGHT", "720"))
+DEFAULT_AI_IMAGE_PROMPT_TEMPLATE_PATH = (
+    Path(__file__).resolve().parents[1] / "prompts" / "ai_generated_material.md"
+)
+DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH = (
+    Path(__file__).resolve().parents[1] / "prompts" / "ai_sanitize_video_description.md"
+)
+DEFAULT_AI_COVER_COPY_PROMPT_TEMPLATE_PATH = (
+    Path(__file__).resolve().parents[1] / "prompts" / "ai_cover_copy.md"
+)
+DEFAULT_AI_IMAGE_OSS_BUCKET = "art-pubbucket"
+DEFAULT_AI_IMAGE_PUBLIC_BASE_URL = "https://rescdn.yishihui.com"
+
+
+CREATE_AI_MATERIAL_TABLE_SQL = """
+CREATE TABLE IF NOT EXISTS ai_generated_material (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
+    adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
+    crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
+    landing_video_id BIGINT NOT NULL COMMENT '承接视频ID',
+    landing_title VARCHAR(500) DEFAULT NULL COMMENT '承接视频标题',
+    landing_category VARCHAR(200) DEFAULT NULL COMMENT '承接视频品类',
+    prompt_type VARCHAR(50) NOT NULL COMMENT '生成角度:topic',
+    prompt_text MEDIUMTEXT NOT NULL COMMENT '最终生成prompt',
+    prompt_feature_json MEDIUMTEXT DEFAULT NULL COMMENT 'prompt使用的视频特征JSON',
+    model VARCHAR(200) NOT NULL COMMENT 'OpenRouter模型',
+    oss_object_key VARCHAR(500) DEFAULT NULL COMMENT 'OSS对象key',
+    oss_url VARCHAR(1000) DEFAULT NULL COMMENT 'OSS公网URL',
+    status VARCHAR(50) NOT NULL DEFAULT 'generated' COMMENT 'generated/prepared/approved/rejected/hold/skip/posted_ok/post_failed/error',
+    approval_status VARCHAR(50) DEFAULT NULL COMMENT '人工审批状态',
+    tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
+    dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
+    ai_review_status VARCHAR(50) DEFAULT NULL COMMENT 'AI审核状态:pass/reject/hold/error',
+    ai_review_score INT DEFAULT NULL COMMENT 'AI审核评分0-100',
+    ai_review_model VARCHAR(200) DEFAULT NULL COMMENT 'AI审核模型',
+    ai_review_reason TEXT DEFAULT NULL COMMENT 'AI审核原因摘要',
+    ai_review_json MEDIUMTEXT DEFAULT NULL COMMENT 'AI审核原始JSON',
+    ai_reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT 'AI审核时间',
+    error TEXT DEFAULT NULL COMMENT '生成/上传/创建错误',
+    raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    KEY idx_account_ad_landing_status (account_id, adgroup_id, landing_video_id, status),
+    KEY idx_crowd_created (crowd_package, created_at),
+    KEY idx_status_created (status, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成创意图片素材'
+"""
+
+AI_MATERIAL_REVIEW_COLUMNS_SQL = {
+    "ai_review_status": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_status VARCHAR(50) DEFAULT NULL COMMENT 'AI审核状态:pass/reject/hold/error' AFTER dynamic_creative_id",
+    "ai_review_score": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_score INT DEFAULT NULL COMMENT 'AI审核评分0-100' AFTER ai_review_status",
+    "ai_review_model": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_model VARCHAR(200) DEFAULT NULL COMMENT 'AI审核模型' AFTER ai_review_score",
+    "ai_review_reason": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_reason TEXT DEFAULT NULL COMMENT 'AI审核原因摘要' AFTER ai_review_model",
+    "ai_review_json": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_json MEDIUMTEXT DEFAULT NULL COMMENT 'AI审核原始JSON' AFTER ai_review_reason",
+    "ai_reviewed_at": "ALTER TABLE ai_generated_material ADD COLUMN ai_reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT 'AI审核时间' AFTER ai_review_json",
+}
+
+
+@dataclass(frozen=True)
+class GenerationPrompt:
+    prompt_type: str
+    prompt_text: str
+    feature_hits: list[dict]
+
+
+@dataclass(frozen=True)
+class CoverCopy:
+    main_title: str
+    hook_angle: str = ""
+    hook_point: str = ""
+    highlight_terms: list[str] | None = None
+    reason: str = ""
+    candidates: list[dict[str, Any]] | None = None
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "main_title": self.main_title,
+            "hook_angle": self.hook_angle,
+            "hook_point": self.hook_point,
+            "highlight_terms": self.highlight_terms or [],
+            "reason": self.reason,
+            "candidates": self.candidates or [],
+        }
+
+
+@dataclass(frozen=True)
+class GeneratedMaterialAsset:
+    id: int
+    account_id: int
+    adgroup_id: Optional[int]
+    crowd_package: str
+    landing_video_id: int
+    prompt_type: str
+    prompt_text: str
+    model: str
+    oss_url: str
+    oss_object_key: str
+    feature_hits: list[dict]
+
+    @property
+    def material_id(self) -> str:
+        return f"ai:{self.id}"
+
+    def to_material(self) -> Material:
+        return Material(
+            material_id=self.material_id,
+            score=1.0,
+            title=f"AI生成素材-{self.prompt_type}",
+            cover=self.oss_url,
+            cost=None,
+            ctr=None,
+            cvr=None,
+            roi=None,
+            impressions=None,
+            quality_score=None,
+            recall_strategy=f"AI生成-{self.prompt_type}",
+            recall_query_text=self.prompt_text[:500],
+            recall_config_code="AI_GENERATED_IMAGE",
+            recall_element_dimension="AI生成素材",
+            recall_point_type=self.prompt_type,
+            recall_standard_element="",
+            recall_hit_queries=self.feature_hits,
+            raw={
+                "ai_generated_material_id": self.id,
+                "oss_url": self.oss_url,
+                "prompt_type": self.prompt_type,
+                "prompt_text": self.prompt_text,
+                "model": self.model,
+            },
+        )
+
+
+def _feature_attr(feature, name: str, default=""):
+    if isinstance(feature, dict):
+        return feature.get(name, default)
+    return getattr(feature, name, default)
+
+
+def _feature_to_hit(feature) -> dict:
+    return {
+        "element_dimension": str(_feature_attr(feature, "element_dimension") or ""),
+        "point_type": str(_feature_attr(feature, "point_type") or ""),
+        "standard_element": str(_feature_attr(feature, "standard_element") or ""),
+        "contribution_score": float(_feature_attr(feature, "contribution_score", 0) or 0),
+        "dt": str(_feature_attr(feature, "dt") or ""),
+    }
+
+
+def _top_feature(features: Iterable, *, dimension: str, point_type: str = "") -> Optional[dict]:
+    candidates = []
+    for feature in features:
+        hit = _feature_to_hit(feature)
+        if hit["element_dimension"] != dimension:
+            continue
+        if point_type and hit["point_type"] != point_type:
+            continue
+        if not hit["standard_element"] or hit["standard_element"] == "-":
+            continue
+        candidates.append(hit)
+    if not candidates:
+        return None
+    return sorted(candidates, key=lambda x: x["contribution_score"], reverse=True)[0]
+
+
+def _load_prompt_template() -> str:
+    raw_path = os.getenv("AI_IMAGE_PROMPT_TEMPLATE_PATH", "").strip()
+    path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_IMAGE_PROMPT_TEMPLATE_PATH
+    return path.read_text(encoding="utf-8")
+
+
+def _load_sanitize_prompt_messages(raw_description: str) -> list[dict]:
+    raw_path = os.getenv("AI_SANITIZE_PROMPT_TEMPLATE_PATH", "").strip()
+    path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH
+    template = path.read_text(encoding="utf-8")
+    if "【system】" not in template or "【user】" not in template:
+        raise RuntimeError(f"清洗prompt模板缺少【system】/【user】标记:{path}")
+    system_part, user_part = template.split("【user】", 1)
+    system_text = system_part.replace("【system】", "", 1).strip()
+    user_text = user_part.replace("{{raw_description}}", raw_description).strip()
+    if not system_text or not user_text:
+        raise RuntimeError(f"清洗prompt模板为空:{path}")
+    return [
+        {"role": "system", "content": system_text},
+        {"role": "user", "content": user_text},
+    ]
+
+
+def _load_cover_copy_prompt_messages(video_description: str, selection) -> list[dict]:
+    raw_path = os.getenv("AI_COVER_COPY_PROMPT_TEMPLATE_PATH", "").strip()
+    path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_COVER_COPY_PROMPT_TEMPLATE_PATH
+    template = path.read_text(encoding="utf-8")
+    if "【system】" not in template or "【user】" not in template:
+        raise RuntimeError(f"标题prompt模板缺少【system】/【user】标记:{path}")
+    pattern = selection.pattern
+    pattern_json = {
+        "pattern_key": pattern.pattern_key,
+        "pattern_name": pattern.pattern_name,
+        "hook_category": pattern.hook_category,
+        "visual_template": pattern.visual_template,
+        "title_hook_rule": pattern.title_hook_rule,
+        "visual_rule": pattern.visual_rule,
+        "relevance_rule": pattern.relevance_rule,
+        "positive_examples": pattern.positive_examples or [],
+        "negative_examples": pattern.negative_examples or [],
+    }
+    system_part, user_part = template.split("【user】", 1)
+    system_text = system_part.replace("【system】", "", 1).strip()
+    user_text = (
+        user_part
+        .replace("{{video_description}}", video_description)
+        .replace("{{pattern_json}}", json.dumps(pattern_json, ensure_ascii=False, indent=2))
+        .strip()
+    )
+    if not system_text or not user_text:
+        raise RuntimeError(f"标题prompt模板为空:{path}")
+    return [
+        {"role": "system", "content": system_text},
+        {"role": "user", "content": user_text},
+    ]
+
+
+def _render_prompt(video_description: str) -> str:
+    return (
+        _load_prompt_template()
+        .replace("{{video_description}}", video_description)
+        .replace("{{aspect_ratio}}", AI_IMAGE_ASPECT_RATIO)
+    )
+
+
+def _extract_chat_completion_text(data: dict) -> str:
+    choices = data.get("choices") or []
+    if not choices:
+        raise RuntimeError("OpenRouter 清洗响应缺 choices")
+    message = choices[0].get("message") or {}
+    content = message.get("content")
+    if isinstance(content, str):
+        return content.strip()
+    if isinstance(content, list):
+        parts = []
+        for item in content:
+            if isinstance(item, dict) and isinstance(item.get("text"), str):
+                parts.append(item["text"])
+        return "\n".join(parts).strip()
+    return ""
+
+
+def _extract_json_object(text: str) -> dict[str, Any]:
+    raw = str(text or "").strip()
+    if raw.startswith("```"):
+        raw = re.sub(r"^```(?:json)?", "", raw).strip()
+        raw = re.sub(r"```$", "", raw).strip()
+    try:
+        parsed = json.loads(raw)
+    except json.JSONDecodeError:
+        start = raw.find("{")
+        if start >= 0:
+            try:
+                parsed, _ = json.JSONDecoder().raw_decode(raw[start:])
+            except json.JSONDecodeError:
+                parsed = None
+            if isinstance(parsed, dict):
+                return parsed
+        match = re.search(r"\{.*?\}", raw, flags=re.S)
+        if not match:
+            raise
+        parsed = json.loads(match.group(0))
+    if isinstance(parsed, list):
+        parsed = next((item for item in parsed if isinstance(item, dict)), None)
+    if isinstance(parsed, dict) and "selected" in parsed and isinstance(parsed["selected"], list):
+        selected = next((item for item in parsed["selected"] if isinstance(item, dict)), None)
+        if selected:
+            parsed = selected
+    if not isinstance(parsed, dict):
+        raise ValueError("模型返回不是JSON object")
+    return parsed
+
+
+def sanitize_video_description(raw_description: str, model: str = OPENROUTER_TEXT_MODEL) -> str:
+    """Rewrite ODPS topic into an ad-theme seed for image generation."""
+    raw = (raw_description or "").strip()
+    if not raw:
+        raise RuntimeError("缺少视频解构选题,无法清洗生成描述")
+
+    body = {
+        "model": model,
+        "temperature": 0.2,
+        "max_tokens": 180,
+        "messages": _load_sanitize_prompt_messages(raw),
+    }
+    headers = {
+        "authorization": f"Bearer {_openrouter_api_key()}",
+        "content-type": "application/json",
+        "accept": "application/json",
+    }
+    resp = httpx.post(OPENROUTER_CHAT_COMPLETIONS_URL, json=body, headers=headers, timeout=60)
+    resp.raise_for_status()
+    cleaned = _extract_chat_completion_text(resp.json())
+    cleaned = cleaned.strip().strip("`").strip().strip("“”\"'").replace("\n", "")
+    if not cleaned:
+        raise RuntimeError("OpenRouter 清洗后描述为空")
+    return cleaned
+
+
+
+def _clean_cover_copy_text(value: Any, *, max_len: int) -> str:
+    text = str(value or "").strip()
+    text = text.strip("`").strip().strip("“”\"'‘’")
+    text = re.sub(r"[\r\n\t]+", "", text)
+    text = re.sub(r"[,,。.!!??::;;、]+", "", text)
+    text = re.sub(r"\s+", "", text)
+    return text[:max_len]
+
+
+def _normalize_cover_copy_candidates(value: Any) -> list[dict[str, Any]]:
+    if not isinstance(value, list):
+        return []
+    candidates: list[dict[str, Any]] = []
+    for item in value:
+        if not isinstance(item, dict):
+            continue
+        title = _clean_cover_copy_text(item.get("title"), max_len=64)
+        if not title:
+            continue
+        if len(title) > AI_COVER_COPY_TITLE_MAX_LEN:
+            continue
+        normalized = dict(item)
+        normalized["title"] = title
+        for key in ("hook_score", "plain_score", "relevance_score"):
+            try:
+                normalized[key] = int(float(normalized.get(key) or 0))
+            except (TypeError, ValueError):
+                normalized[key] = 0
+        candidates.append(normalized)
+    return candidates
+
+
+def _best_cover_copy_candidate(candidates: list[dict[str, Any]]) -> dict[str, Any] | None:
+    if not candidates:
+        return None
+    return sorted(
+        candidates,
+        key=lambda item: (
+            int(item.get("hook_score") or 0),
+            int(item.get("plain_score") or 0),
+            int(item.get("relevance_score") or 0),
+        ),
+        reverse=True,
+    )[0]
+
+
+def _normalize_highlight_terms(value: Any, *, title: str) -> list[str]:
+    if not isinstance(value, list):
+        return []
+    out: list[str] = []
+    for item in value:
+        term = _clean_cover_copy_text(item, max_len=8)
+        if not term:
+            continue
+        if term not in title:
+            continue
+        if term in out:
+            continue
+        out.append(term)
+        if len(out) >= 2:
+            break
+    return out
+
+
+def generate_cover_copy(video_description: str, selection, model: str = OPENROUTER_TEXT_MODEL) -> CoverCopy:
+    """Generate the cover title before image generation.
+
+    The image model is weak at deciding ad copy and then rendering it. This node
+    makes the copy explicit while keeping final compliance to later review.
+    """
+    description = (video_description or "").strip()
+    if not description:
+        raise RuntimeError("缺少广告主题种子,无法生成封面标题")
+    body = {
+        "model": model,
+        "temperature": 0.5,
+        "max_tokens": AI_COVER_COPY_MAX_TOKENS,
+        "response_format": {"type": "json_object"},
+        "messages": _load_cover_copy_prompt_messages(description, selection),
+    }
+    headers = {
+        "authorization": f"Bearer {_openrouter_api_key()}",
+        "content-type": "application/json",
+        "accept": "application/json",
+    }
+    resp = httpx.post(OPENROUTER_CHAT_COMPLETIONS_URL, json=body, headers=headers, timeout=60)
+    resp.raise_for_status()
+    parsed = _extract_json_object(_extract_chat_completion_text(resp.json()))
+    candidates = _normalize_cover_copy_candidates(parsed.get("candidates"))
+    best_candidate = _best_cover_copy_candidate(candidates)
+    main_title = _clean_cover_copy_text(
+        parsed.get("selected_title") or parsed.get("main_title"),
+        max_len=64,
+    )
+    if len(main_title) > AI_COVER_COPY_TITLE_MAX_LEN:
+        main_title = ""
+    if not main_title and best_candidate:
+        main_title = str(best_candidate.get("title") or "")
+    if not main_title:
+        raise RuntimeError(
+            f"OpenRouter 标题节点未返回 {AI_COVER_COPY_TITLE_MAX_LEN} 字以内的 main_title"
+        )
+    highlight_terms = _normalize_highlight_terms(
+        parsed.get("highlight_terms"),
+        title=main_title,
+    )
+    return CoverCopy(
+        main_title=main_title,
+        hook_angle=str(parsed.get("hook_angle") or (best_candidate or {}).get("hook_angle") or "").strip()[:50],
+        hook_point=str(parsed.get("hook_point") or "").strip()[:160],
+        highlight_terms=highlight_terms,
+        reason=str(parsed.get("reason") or "").strip()[:200],
+        candidates=candidates,
+    )
+
+
+def _render_pattern_prompt(video_description: str, selection, cover_copy: CoverCopy | None = None) -> str:
+    pattern = selection.pattern
+    positive_examples = "、".join(pattern.positive_examples or []) or "无"
+    negative_examples = "、".join(pattern.negative_examples or []) or "无"
+    cover_copy_text = ""
+    if cover_copy:
+        cover_copy_text = f"""
+【封面标题】
+- 主标题:{cover_copy.main_title}
+- 标题钩子:{cover_copy.hook_angle or "未标注"}
+- 高亮词:{",".join(cover_copy.highlight_terms or []) or "无"}
+
+图片中必须使用上面的主标题。主标题不能添加任何括号、引号、书名号或额外符号。只允许高亮【高亮词】中列出的词;如果高亮词为“无”,不要强行彩色高亮。不要生成副标题,也不要自行新增其他标题、角标或引导文字。
+"""
+    enriched_description = f"""【广告主题种子】
+{video_description}
+{cover_copy_text}
+
+【选中的创意Pattern】
+- pattern_key:{pattern.pattern_key}
+- pattern_name:{pattern.pattern_name}
+- hook_category:{pattern.hook_category}
+- visual_template:{pattern.visual_template}
+- title_hook_rule:{pattern.title_hook_rule}
+- visual_direction:{pattern.visual_rule}
+- relevance_rule:{pattern.relevance_rule}
+- compliance_rule:{pattern.compliance_rule}
+- positive_examples:{positive_examples}
+- negative_examples:{negative_examples}
+
+visual_direction 只是可选视觉方向参考,不是硬约束;如果它和画面主体多样性、非默认人物、非默认老人正脸冲突,以全局视觉策略为准。
+请基于【广告主题种子】和【选中的创意Pattern】生成广告封面。视频内容只提供主题方向,画面可以做适合信息流点击的合理转译。"""
+    return _render_prompt(enriched_description)
+
+
+def build_generation_prompts(
+    *,
+    video_id: int,
+    title: str,
+    category: str,
+    features: Iterable[VideoElementFeature],
+    sanitized_description: str = "",
+) -> list[GenerationPrompt]:
+    """Build one generation prompt from the video's ODPS topic."""
+    feature_list = list(features or [])
+    topic = _top_feature(feature_list, dimension="解构选题")
+    topic_text = (topic or {}).get("standard_element")
+    if not topic_text:
+        return []
+    video_description = sanitized_description.strip() or topic_text
+    topic_hit = dict(topic)
+    topic_hit["original_standard_element"] = topic_text
+    topic_hit["video_description"] = video_description
+    return [
+        GenerationPrompt(
+            prompt_type="topic",
+            prompt_text=_render_prompt(video_description),
+            feature_hits=[topic_hit],
+        )
+    ]
+
+
+def build_pattern_generation_prompts(
+    *,
+    video_id: int,
+    title: str,
+    category: str,
+    features: Iterable[VideoElementFeature],
+    sanitized_description: str,
+    crowd_package: str = "",
+    placement: str = AI_IMAGE_PATTERN_PLACEMENT,
+    top_k: int = AI_IMAGE_PATTERN_TOP_K,
+    text_model: str = OPENROUTER_TEXT_MODEL,
+) -> list[GenerationPrompt]:
+    """Build generation prompts using model-selected creative patterns."""
+    from tools.material_strategy_learning import select_creative_patterns
+
+    feature_list = list(features or [])
+    topic = _top_feature(feature_list, dimension="解构选题")
+    topic_text = (topic or {}).get("standard_element")
+    if not topic_text:
+        return []
+    video_description = sanitized_description.strip() or topic_text
+    selections = select_creative_patterns(
+        video_features=feature_list,
+        crowd_package=crowd_package,
+        placement=placement,
+        include_draft=True,
+        top_k=top_k,
+        use_model=True,
+        model=text_model,
+    )
+    if not selections:
+        logger.info(
+            "[ai_generated_material] video=%d no matched pattern, fallback to topic prompt",
+            video_id,
+        )
+        return build_generation_prompts(
+            video_id=video_id,
+            title=title,
+            category=category,
+            features=feature_list,
+            sanitized_description=video_description,
+        )
+    prompts: list[GenerationPrompt] = []
+    for selection in selections[:max(1, int(top_k))]:
+        cover_copy = None
+        try:
+            cover_copy = generate_cover_copy(video_description, selection, model=text_model)
+        except Exception as e:
+            message = (
+                f"[ai_generated_material] video={video_id} pattern={selection.pattern.pattern_key} "
+                f"cover copy generation failed:{e}"
+            )
+            if AI_COVER_COPY_REQUIRED:
+                raise RuntimeError(message) from e
+            logger.warning("%s, fallback to image prompt", message)
+        topic_hit = dict(topic)
+        topic_hit["original_standard_element"] = topic_text
+        topic_hit["video_description"] = video_description
+        topic_hit["pattern_selection"] = {
+            "pattern_key": selection.pattern.pattern_key,
+            "pattern_name": selection.pattern.pattern_name,
+            "score": selection.score,
+            "reasons": selection.reasons,
+            "penalties": selection.penalties,
+            "matched_features": selection.matched_features,
+            "hook_category": selection.pattern.hook_category,
+            "visual_template": selection.pattern.visual_template,
+            "title_hook_rule": selection.pattern.title_hook_rule,
+            "visual_rule": selection.pattern.visual_rule,
+            "relevance_rule": selection.pattern.relevance_rule,
+            "compliance_rule": selection.pattern.compliance_rule,
+            "positive_examples": selection.pattern.positive_examples or [],
+            "negative_examples": selection.pattern.negative_examples or [],
+        }
+        if cover_copy:
+            topic_hit["cover_copy"] = cover_copy.to_dict()
+        prompts.append(
+            GenerationPrompt(
+                prompt_type=selection.pattern.pattern_key,
+                prompt_text=_render_pattern_prompt(video_description, selection, cover_copy),
+                feature_hits=[topic_hit],
+            )
+        )
+    return prompts
+
+
+def ensure_ai_material_table() -> None:
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(CREATE_AI_MATERIAL_TABLE_SQL)
+            for column_name, alter_sql in AI_MATERIAL_REVIEW_COLUMNS_SQL.items():
+                cur.execute("SHOW COLUMNS FROM ai_generated_material LIKE %s", (column_name,))
+                if not cur.fetchone():
+                    cur.execute(alter_sql)
+        conn.commit()
+    finally:
+        conn.close()
+
+
+def _openrouter_api_key() -> str:
+    key = os.getenv("OPEN_ROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY")
+    if not key:
+        raise RuntimeError("缺少 OPENROUTER_API_KEY/OPEN_ROUTER_API_KEY,无法生成AI素材")
+    return key
+
+
+def _to_jpeg_bytes(image_bytes: bytes) -> bytes:
+    """Normalize model output to RGB JPEG for Tencent material upload."""
+    with Image.open(BytesIO(image_bytes)) as img:
+        if img.mode not in ("RGB", "L"):
+            background = Image.new("RGB", img.size, (255, 255, 255))
+            if img.mode in ("RGBA", "LA"):
+                alpha = img.getchannel("A")
+                background.paste(img.convert("RGB"), mask=alpha)
+            else:
+                background.paste(img.convert("RGB"))
+            out_img = background
+        else:
+            out_img = img.convert("RGB")
+        target_ratio = AI_IMAGE_TARGET_WIDTH / AI_IMAGE_TARGET_HEIGHT
+        image_ratio = out_img.width / out_img.height
+        if image_ratio > target_ratio:
+            new_width = int(out_img.height * target_ratio)
+            left = (out_img.width - new_width) // 2
+            out_img = out_img.crop((left, 0, left + new_width, out_img.height))
+        elif image_ratio < target_ratio:
+            new_height = int(out_img.width / target_ratio)
+            top = (out_img.height - new_height) // 2
+            out_img = out_img.crop((0, top, out_img.width, top + new_height))
+        out_img = out_img.resize((AI_IMAGE_TARGET_WIDTH, AI_IMAGE_TARGET_HEIGHT), Image.Resampling.LANCZOS)
+        out = BytesIO()
+        out_img.save(out, format="JPEG", quality=92, optimize=True)
+        return out.getvalue()
+
+
+def _extract_image_ref_from_openrouter(data: dict) -> str:
+    choices = data.get("choices") or []
+    if not choices:
+        raise RuntimeError("OpenRouter 响应缺 choices")
+    msg = choices[0].get("message") or {}
+    images = msg.get("images") or []
+    for image in images:
+        image_url = image.get("image_url") if isinstance(image, dict) else None
+        if isinstance(image_url, dict) and image_url.get("url"):
+            return image_url["url"]
+        if isinstance(image_url, str):
+            return image_url
+
+    content = msg.get("content")
+    if isinstance(content, list):
+        for part in content:
+            if not isinstance(part, dict):
+                continue
+            image_url = part.get("image_url")
+            if isinstance(image_url, dict) and image_url.get("url"):
+                return image_url["url"]
+            if part.get("type") in ("image_url", "output_image") and part.get("url"):
+                return part["url"]
+    if isinstance(content, str):
+        match = re.search(r"!\[[^\]]*]\(([^)]+)\)", content)
+        if match:
+            return match.group(1)
+        data_match = re.search(r"(data:image/[^;\s]+;base64,[A-Za-z0-9+/=]+)", content)
+        if data_match:
+            return data_match.group(1)
+        url_match = re.search(r"https?://\S+", content)
+        if url_match:
+            return url_match.group(0).rstrip(").,,。")
+    raise RuntimeError("OpenRouter 响应未包含可识别图片URL/base64")
+
+
+def generate_image_bytes(prompt: str, model: str = OPENROUTER_IMAGE_MODEL) -> tuple[bytes, str, dict]:
+    """Generate one image through OpenRouter Images API.
+
+    The Images API lets us pass aspect_ratio directly. We still normalize the
+    returned raster to JPEG in code because not every model supports
+    output_format.
+    """
+    body = {
+        "model": model,
+        "prompt": prompt,
+        "aspect_ratio": AI_IMAGE_ASPECT_RATIO,
+        "resolution": AI_IMAGE_RESOLUTION,
+        "n": 1,
+    }
+    headers = {
+        "authorization": f"Bearer {_openrouter_api_key()}",
+        "content-type": "application/json",
+        "accept": "application/json",
+    }
+    resp = httpx.post(OPENROUTER_IMAGES_URL, json=body, headers=headers, timeout=120)
+    resp.raise_for_status()
+    data = resp.json()
+    images = data.get("data") or []
+    if not images or not images[0].get("b64_json"):
+        raise RuntimeError("OpenRouter Images API 响应缺少 data[0].b64_json")
+    image_bytes = base64.b64decode(images[0]["b64_json"])
+    if AI_IMAGE_OUTPUT_FORMAT.lower() in ("jpg", "jpeg"):
+        return _to_jpeg_bytes(image_bytes), "image/jpeg", data
+    media_type = images[0].get("media_type") or "image/png"
+    return image_bytes, media_type, data
+
+
+def _public_oss_url(public_base_url: str, object_key: str) -> str:
+    return f"{public_base_url.rstrip('/')}/{quote(object_key, safe='/')}"
+
+
+def _oss_env() -> tuple[str, str, str, str, str, str]:
+    endpoint = os.getenv("ALIYUN_OSS_ENDPOINT", "").strip()
+    bucket = (
+        os.getenv("ALIYUN_OSS_BUCKET", "").strip()
+        or os.getenv("AI_IMAGE_OSS_BUCKET", "").strip()
+        or DEFAULT_AI_IMAGE_OSS_BUCKET
+    )
+    access_key_id = os.getenv("ALIYUN_OSS_ACCESS_KEY_ID", "").strip()
+    access_key_secret = os.getenv("ALIYUN_OSS_ACCESS_KEY_SECRET", "").strip()
+    public_base_url = (
+        os.getenv("AI_IMAGE_PUBLIC_BASE_URL", "").strip()
+        or DEFAULT_AI_IMAGE_PUBLIC_BASE_URL
+    )
+    object_prefix = os.getenv("AI_IMAGE_OSS_PREFIX", AI_IMAGE_OSS_PREFIX).strip("/")
+    missing = [
+        name for name, value in [
+            ("ALIYUN_OSS_ENDPOINT", endpoint),
+            ("ALIYUN_OSS_BUCKET", bucket),
+            ("ALIYUN_OSS_ACCESS_KEY_ID", access_key_id),
+            ("ALIYUN_OSS_ACCESS_KEY_SECRET", access_key_secret),
+        ]
+        if not value
+    ]
+    if missing:
+        raise RuntimeError(f"缺少 OSS 配置:{','.join(missing)}")
+    return endpoint, bucket, access_key_id, access_key_secret, public_base_url.rstrip("/"), object_prefix
+
+
+def _normalize_oss_endpoint(endpoint: str, bucket: str) -> str:
+    endpoint = endpoint.rstrip("/")
+    if not endpoint.startswith(("http://", "https://")):
+        endpoint = "https://" + endpoint
+    parsed = urlparse(endpoint)
+    host = parsed.netloc
+    if not host.startswith(f"{bucket}."):
+        host = f"{bucket}.{host}"
+    return f"{parsed.scheme}://{host}"
+
+
+def upload_image_to_oss(image_bytes: bytes, content_type: str, object_key: str) -> str:
+    endpoint, bucket, access_key_id, access_key_secret, public_base_url, _ = _oss_env()
+    content_md5 = base64.b64encode(hashlib.md5(image_bytes).digest()).decode("ascii")
+    date = formatdate(usegmt=True)
+    canonical_resource = f"/{bucket}/{object_key}"
+    string_to_sign = f"PUT\n{content_md5}\n{content_type}\n{date}\n{canonical_resource}"
+    signature = base64.b64encode(
+        hmac.new(
+            access_key_secret.encode("utf-8"),
+            string_to_sign.encode("utf-8"),
+            hashlib.sha1,
+        ).digest()
+    ).decode("ascii")
+    upload_base = _normalize_oss_endpoint(endpoint, bucket)
+    url = f"{upload_base}/{quote(object_key, safe='/')}"
+    resp = httpx.put(
+        url,
+        content=image_bytes,
+        headers={
+            "Date": date,
+            "Content-Type": content_type,
+            "Content-MD5": content_md5,
+            "Authorization": f"OSS {access_key_id}:{signature}",
+        },
+        timeout=60,
+    )
+    resp.raise_for_status()
+    return _public_oss_url(public_base_url, object_key)
+
+
+def build_ai_image_object_key(
+    *,
+    account_id: int | str,
+    landing_video_id: int | str,
+    prompt_type: str,
+    extension: str,
+    debug: bool = False,
+) -> str:
+    object_prefix = os.getenv("AI_IMAGE_OSS_PREFIX", AI_IMAGE_OSS_PREFIX).strip("/")
+    ext = extension if extension.startswith(".") else f".{extension}"
+    if debug:
+        scope = "debug"
+    else:
+        scope = f"account_{account_id}/video_{landing_video_id}"
+    return f"{object_prefix}/{scope}/{prompt_type}_{uuid.uuid4().hex}{ext}"
+
+
+def _insert_generated_material(
+    *,
+    account_id: int,
+    adgroup_id: int,
+    crowd_package: str,
+    landing: LandingVideo,
+    prompt: GenerationPrompt,
+    model: str,
+    object_key: str,
+    oss_url: str,
+    raw_response: dict,
+) -> GeneratedMaterialAsset:
+    ensure_ai_material_table()
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                INSERT INTO ai_generated_material
+                    (account_id, adgroup_id, crowd_package, landing_video_id,
+                     landing_title, landing_category, prompt_type, prompt_text,
+                     prompt_feature_json, model, oss_object_key, oss_url, raw_response)
+                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                """,
+                (
+                    account_id,
+                    adgroup_id,
+                    crowd_package,
+                    landing.video_id,
+                    landing.title,
+                    landing.category,
+                    prompt.prompt_type,
+                    prompt.prompt_text,
+                    json.dumps(prompt.feature_hits, ensure_ascii=False),
+                    model,
+                    object_key,
+                    oss_url,
+                    json.dumps(raw_response, ensure_ascii=False, default=str)[:16000000],
+                ),
+            )
+            asset_id = int(cur.lastrowid)
+        conn.commit()
+    finally:
+        conn.close()
+    return GeneratedMaterialAsset(
+        id=asset_id,
+        account_id=account_id,
+        adgroup_id=adgroup_id,
+        crowd_package=crowd_package,
+        landing_video_id=landing.video_id,
+        prompt_type=prompt.prompt_type,
+        prompt_text=prompt.prompt_text,
+        model=model,
+        oss_url=oss_url,
+        oss_object_key=object_key,
+        feature_hits=prompt.feature_hits,
+    )
+
+
+def insert_and_review_generated_material(
+    *,
+    account_id: int,
+    adgroup_id: int,
+    crowd_package: str,
+    landing: LandingVideo,
+    prompt: GenerationPrompt,
+    model: str,
+    object_key: str,
+    oss_url: str,
+    raw_response: dict,
+) -> tuple[GeneratedMaterialAsset, Any]:
+    """Persist one generated asset and run the same AI review used by production."""
+    asset = _insert_generated_material(
+        account_id=account_id,
+        adgroup_id=adgroup_id,
+        crowd_package=crowd_package,
+        landing=landing,
+        prompt=prompt,
+        model=model,
+        object_key=object_key,
+        oss_url=oss_url,
+        raw_response=raw_response,
+    )
+    from tools.ai_material_review import (
+        MaterialReviewResult,
+        review_generated_material,
+        update_material_review_result,
+    )
+
+    try:
+        review = review_generated_material(
+            image_url=oss_url,
+            prompt_type=prompt.prompt_type,
+            prompt_text=prompt.prompt_text,
+            feature_hits=prompt.feature_hits,
+        )
+    except Exception as e:
+        logger.exception(
+            "[ai_generated_material] AI审核异常 account=%d adgroup=%d landing=%d asset=%d: %s",
+            account_id, adgroup_id, landing.video_id, asset.id, e,
+        )
+        review = MaterialReviewResult(
+            status="error",
+            score=0,
+            reason=f"AI审核异常:{e}",
+            risk_tags=["review_error"],
+            ocr_text="",
+            raw={"error": str(e)},
+        )
+    update_material_review_result(asset.id, review)
+    return asset, review
+
+
+def _rows_to_assets(rows: list[dict]) -> list[GeneratedMaterialAsset]:
+    out = []
+    for row in rows:
+        try:
+            feature_hits = json.loads(row.get("prompt_feature_json") or "[]")
+        except Exception:
+            feature_hits = []
+        out.append(GeneratedMaterialAsset(
+            id=int(row["id"]),
+            account_id=int(row["account_id"]),
+            adgroup_id=int(row["adgroup_id"]) if row.get("adgroup_id") else None,
+            crowd_package=str(row.get("crowd_package") or ""),
+            landing_video_id=int(row["landing_video_id"]),
+            prompt_type=str(row.get("prompt_type") or ""),
+            prompt_text=str(row.get("prompt_text") or ""),
+            model=str(row.get("model") or ""),
+            oss_url=str(row.get("oss_url") or ""),
+            oss_object_key=str(row.get("oss_object_key") or ""),
+            feature_hits=feature_hits,
+        ))
+    return out
+
+
+def load_available_generated_assets(
+    account_id: int,
+    adgroup_id: int,
+    landing_video_id: int,
+) -> list[GeneratedMaterialAsset]:
+    ensure_ai_material_table()
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT *
+                FROM ai_generated_material
+                WHERE account_id=%s
+                  AND adgroup_id=%s
+                  AND landing_video_id=%s
+                  AND status IN ('generated', 'prepared')
+                  AND ai_review_status='pass'
+                  AND oss_url IS NOT NULL
+                  AND oss_url <> ''
+                ORDER BY id ASC
+                """,
+                (account_id, adgroup_id, landing_video_id),
+            )
+            rows = cur.fetchall() or []
+    finally:
+        conn.close()
+    return _rows_to_assets(rows)
+
+
+def generate_assets_for_landing(
+    *,
+    account_id: int,
+    adgroup_id: int,
+    crowd_package: str,
+    landing: LandingVideo,
+    model: str = OPENROUTER_IMAGE_MODEL,
+    use_pattern_selector: bool = AI_IMAGE_USE_PATTERN_SELECTOR,
+    placement: str = AI_IMAGE_PATTERN_PLACEMENT,
+    pattern_top_k: int = AI_IMAGE_PATTERN_TOP_K,
+    text_model: str = OPENROUTER_TEXT_MODEL,
+    skip_prompt_types: set[str] | None = None,
+    max_new_assets: int | None = None,
+) -> list[GeneratedMaterialAsset]:
+    db_features = read_cached_video_element_features([landing.video_id]).get(landing.video_id) or []
+    if not db_features:
+        logger.info(
+            "[ai_generated_material] landing=%d no cached DB features for generation",
+            landing.video_id,
+        )
+        return []
+    topic = _top_feature(db_features, dimension="解构选题")
+    topic_text = (topic or {}).get("standard_element")
+    if not topic_text:
+        logger.info(
+            "[ai_generated_material] landing=%d no topic feature for generation",
+            landing.video_id,
+        )
+        return []
+    sanitized_description = sanitize_video_description(topic_text)
+    logger.info(
+        "[ai_generated_material] landing=%d sanitized description=%r",
+        landing.video_id, sanitized_description,
+    )
+    if use_pattern_selector:
+        prompts = build_pattern_generation_prompts(
+            video_id=landing.video_id,
+            title=landing.title,
+            category=landing.category,
+            features=db_features,
+            sanitized_description=sanitized_description,
+            crowd_package=crowd_package,
+            placement=placement,
+            top_k=pattern_top_k,
+            text_model=text_model,
+        )
+        logger.info(
+            "[ai_generated_material] landing=%d pattern prompts=%s",
+            landing.video_id, [p.prompt_type for p in prompts],
+        )
+    else:
+        prompts = build_generation_prompts(
+            video_id=landing.video_id,
+            title=landing.title,
+            category=landing.category,
+            features=db_features,
+            sanitized_description=sanitized_description,
+        )
+    if skip_prompt_types:
+        prompts = [p for p in prompts if p.prompt_type not in skip_prompt_types]
+    if max_new_assets is not None:
+        prompts = prompts[:max(0, int(max_new_assets))]
+    assets: list[GeneratedMaterialAsset] = []
+    for prompt in prompts:
+        image_bytes, content_type, raw_response = generate_image_bytes(prompt.prompt_text, model)
+        ext = mimetypes.guess_extension(content_type) or ".jpg"
+        if ext == ".jpe":
+            ext = ".jpg"
+        object_key = build_ai_image_object_key(
+            account_id=account_id,
+            landing_video_id=landing.video_id,
+            prompt_type=prompt.prompt_type,
+            extension=ext,
+        )
+        oss_url = upload_image_to_oss(image_bytes, content_type, object_key)
+        asset, review = insert_and_review_generated_material(
+            account_id=account_id,
+            adgroup_id=adgroup_id,
+            crowd_package=crowd_package,
+            landing=landing,
+            prompt=prompt,
+            model=model,
+            object_key=object_key,
+            oss_url=oss_url,
+            raw_response=raw_response,
+        )
+        if not review.passed:
+            logger.warning(
+                "[ai_generated_material] AI审核未通过 account=%d adgroup=%d landing=%d asset=%d status=%s score=%d reason=%s",
+                account_id, adgroup_id, landing.video_id, asset.id, review.status, review.score, review.reason,
+            )
+            continue
+        assets.append(asset)
+        logger.info(
+            "[ai_generated_material] generated reviewed account=%d adgroup=%d landing=%d asset=%d type=%s score=%d url=%s",
+            account_id, adgroup_id, landing.video_id, asset.id, prompt.prompt_type, review.score, oss_url,
+        )
+    return assets
+
+
+def get_or_generate_assets_for_landing(
+    *,
+    account_id: int,
+    adgroup_id: int,
+    crowd_package: str,
+    landing: LandingVideo,
+    use_pattern_selector: bool = AI_IMAGE_USE_PATTERN_SELECTOR,
+    placement: str = AI_IMAGE_PATTERN_PLACEMENT,
+    pattern_top_k: int = AI_IMAGE_PATTERN_TOP_K,
+    text_model: str = OPENROUTER_TEXT_MODEL,
+) -> list[GeneratedMaterialAsset]:
+    existing = load_available_generated_assets(account_id, adgroup_id, landing.video_id)
+    target_count = max(1, int(pattern_top_k) if use_pattern_selector else 1)
+    if len(existing) >= target_count:
+        return existing[:target_count]
+    skip_prompt_types = {asset.prompt_type for asset in existing}
+    generated = generate_assets_for_landing(
+        account_id=account_id,
+        adgroup_id=adgroup_id,
+        crowd_package=crowd_package,
+        landing=landing,
+        use_pattern_selector=use_pattern_selector,
+        placement=placement,
+        pattern_top_k=pattern_top_k,
+        text_model=text_model,
+        skip_prompt_types=skip_prompt_types,
+        max_new_assets=target_count - len(existing),
+    )
+    return [*existing, *generated]
+
+
+def update_generated_material_status(
+    record: dict,
+    status: str,
+    *,
+    dynamic_creative_id: int | str | None = None,
+    tencent_image_id: str = "",
+    error: str = "",
+) -> None:
+    asset_id = record.get("_ai_generated_material_id")
+    if not asset_id:
+        return
+    ensure_ai_material_table()
+    from db.connection import get_connection
+
+    normalized_status = {
+        "approve": "approved",
+        "reject": "rejected",
+    }.get(status, status)
+    approval_status = {
+        "approve": "approved",
+        "reject": "rejected",
+        "hold": "hold",
+        "skip": "skip",
+    }.get(status, normalized_status)
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                UPDATE ai_generated_material
+                SET status=%s,
+                    approval_status=%s,
+                    dynamic_creative_id=COALESCE(%s, dynamic_creative_id),
+                    tencent_image_id=COALESCE(NULLIF(%s, ''), tencent_image_id),
+                    error=COALESCE(NULLIF(%s, ''), error),
+                    updated_at=CURRENT_TIMESTAMP
+                WHERE id=%s
+                """,
+                (
+                    normalized_status,
+                    approval_status,
+                    int(dynamic_creative_id) if dynamic_creative_id else None,
+                    tencent_image_id,
+                    error[:2000] if error else "",
+                    int(asset_id),
+                ),
+            )
+        conn.commit()
+    finally:
+        conn.close()

+ 228 - 0
examples/auto_put_ad_mini/tools/ai_material_review.py

@@ -0,0 +1,228 @@
+"""AI review for generated image materials.
+
+This module is intentionally separate from image generation. It can be used
+both inline after generation and later for rescanning historical assets.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+from dataclasses import dataclass
+from typing import Any
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
+    "OPENROUTER_CHAT_COMPLETIONS_URL",
+    "https://openrouter.ai/api/v1/chat/completions",
+)
+AI_MATERIAL_REVIEW_MODEL = os.getenv("AI_MATERIAL_REVIEW_MODEL", "google/gemini-3-flash-preview")
+
+
+@dataclass(frozen=True)
+class MaterialReviewResult:
+    status: str
+    score: int
+    reason: str
+    risk_tags: list[str]
+    ocr_text: str
+    raw: dict[str, Any]
+
+    @property
+    def passed(self) -> bool:
+        return self.status == "pass"
+
+
+def _openrouter_api_key() -> str:
+    key = os.getenv("OPEN_ROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY")
+    if not key:
+        raise RuntimeError("缺少 OPENROUTER_API_KEY/OPEN_ROUTER_API_KEY,无法进行AI素材审核")
+    return key
+
+
+def _extract_chat_completion_text(data: dict) -> str:
+    choices = data.get("choices") or []
+    if not choices:
+        raise RuntimeError("OpenRouter AI审核响应缺 choices")
+    message = choices[0].get("message") or {}
+    content = message.get("content")
+    if isinstance(content, str):
+        return content.strip()
+    if isinstance(content, list):
+        parts = []
+        for item in content:
+            if isinstance(item, dict) and isinstance(item.get("text"), str):
+                parts.append(item["text"])
+        return "\n".join(parts).strip()
+    return ""
+
+
+def _extract_json_object(text: str) -> dict[str, Any]:
+    raw = str(text or "").strip()
+    if raw.startswith("```"):
+        raw = re.sub(r"^```(?:json)?", "", raw).strip()
+        raw = re.sub(r"```$", "", raw).strip()
+    try:
+        parsed = json.loads(raw)
+    except json.JSONDecodeError:
+        match = re.search(r"\{.*\}", raw, flags=re.S)
+        if not match:
+            raise
+        parsed = json.loads(match.group(0))
+    if not isinstance(parsed, dict):
+        raise ValueError("AI审核模型返回不是JSON object")
+    return parsed
+
+
+def _normalize_status(value: Any) -> str:
+    status = str(value or "").strip().lower()
+    if status in {"pass", "approve", "approved", "通过"}:
+        return "pass"
+    if status in {"hold", "review", "manual_review", "人工复核"}:
+        return "hold"
+    return "reject"
+
+
+def review_generated_material(
+    *,
+    image_url: str,
+    prompt_type: str,
+    prompt_text: str,
+    feature_hits: list[dict],
+    model: str = AI_MATERIAL_REVIEW_MODEL,
+) -> MaterialReviewResult:
+    """Review one generated material image with multimodal model."""
+    payload = {
+        "task": "审核一张AI生成的腾讯广告信息流中文封面图是否可进入人工投放审批候选",
+        "review_goals": [
+            "判断图片标题是否有乱码、错字、悬挂标点、分裂文案、不可读文字",
+            "判断是否包含强领取承诺、专家背书、假官方、假按钮、二维码、下载/播放按钮伪装",
+            "判断是否涉及医疗疗效、健康恐吓、违法血腥、迷信、低俗、名人肖像、外国人物",
+            "判断图片是否和视频主题及pattern相关",
+            "判断是否适合60-75岁中老年用户的信息流广告封面",
+        ],
+        "hard_reject_rules": [
+            "出现乱码、明显错字、不可读标题、标题被截断或标点悬挂",
+            "出现国家发钱、官方发放、已到账、马上到账、保证到账、立即领取、直接领取、点这里领取等明确承诺或行动诱导",
+            "出现假微信/假聊天/假按钮/二维码/下载按钮/播放按钮伪装",
+            "出现医疗疗效承诺、健康恐吓、医院药品专家治疗背书",
+            "出现真实名人肖像感、外国人物、logo、水印、畸形人物",
+            "图片与视频主题或pattern明显不相关",
+        ],
+        "soft_review_rules": [
+            "政策、退休补贴、养老金、清单讲解类素材中,领取、领全、能领、补贴等词不单独构成拒绝理由",
+            "如果标题只是提出疑问或提示核对,例如“这几项你知道吗”“你问清了吗”“你领全了吗”,可判为hold或pass,不要仅因词语本身reject",
+            "专家详细解读、老师讲解、讲清楚、一图看懂不单独构成拒绝理由;只有和医疗疗效、投资收益、官方承诺、领取承诺强绑定时才reject",
+            "如果存在轻度政策诱导但画面质量好、主题相关,优先hold;只有明确承诺结果或伪官方才reject",
+        ],
+        "prompt_type": prompt_type,
+        "feature_hits": feature_hits[:3],
+        "output_schema": {
+            "decision": "pass/reject/hold",
+            "score": "0-100整数",
+            "ocr_text": "识别到的主标题文字",
+            "risk_tags": ["命中的风险标签"],
+            "reason": "一句话说明审核结论",
+            "theme_relevance": "high/medium/low",
+            "title_quality": "good/medium/bad",
+            "visual_quality": "good/medium/bad",
+        },
+    }
+    messages = [
+        {
+            "role": "system",
+            "content": (
+                "你是腾讯广告中文信息流素材的AI预审员。"
+                "只输出JSON object,不要解释正文。"
+                "审核要区分硬性违规和轻度风险:标题不可读、明确承诺、伪官方或主题不相关才reject;"
+                "政策信息讲解中的领取相关疑问或专家解读表述,不应仅凭单个词直接reject,可按风险程度给hold或pass。"
+            ),
+        },
+        {
+            "role": "user",
+            "content": [
+                {"type": "text", "text": json.dumps(payload, ensure_ascii=False)},
+                {"type": "image_url", "image_url": {"url": image_url}},
+            ],
+        },
+    ]
+    resp = httpx.post(
+        OPENROUTER_CHAT_COMPLETIONS_URL,
+        headers={
+            "Authorization": f"Bearer {_openrouter_api_key()}",
+            "Content-Type": "application/json",
+            "Accept": "application/json",
+        },
+        json={
+            "model": model,
+            "messages": messages,
+            "temperature": 0.1,
+            "max_tokens": 900,
+        },
+        timeout=90,
+    )
+    resp.raise_for_status()
+    parsed = _extract_json_object(_extract_chat_completion_text(resp.json()))
+    try:
+        score = int(float(parsed.get("score", 0)))
+    except (TypeError, ValueError):
+        score = 0
+    status = _normalize_status(parsed.get("decision"))
+    if score < 60 and status == "pass":
+        status = "hold"
+    return MaterialReviewResult(
+        status=status,
+        score=max(0, min(100, score)),
+        reason=str(parsed.get("reason") or "").strip(),
+        risk_tags=[str(v) for v in parsed.get("risk_tags") or [] if str(v).strip()],
+        ocr_text=str(parsed.get("ocr_text") or "").strip(),
+        raw=parsed,
+    )
+
+
+def update_material_review_result(
+    material_id: int,
+    result: MaterialReviewResult,
+    *,
+    model: str = AI_MATERIAL_REVIEW_MODEL,
+) -> None:
+    from db.connection import get_connection
+
+    status = "generated" if result.passed else result.status
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                UPDATE ai_generated_material
+                SET status=%s,
+                    ai_review_status=%s,
+                    ai_review_score=%s,
+                    ai_review_model=%s,
+                    ai_review_reason=%s,
+                    ai_review_json=%s,
+                    ai_reviewed_at=CURRENT_TIMESTAMP,
+                    error=CASE WHEN %s='pass' THEN error ELSE %s END,
+                    updated_at=CURRENT_TIMESTAMP
+                WHERE id=%s
+                """,
+                (
+                    status,
+                    result.status,
+                    result.score,
+                    model,
+                    result.reason[:2000],
+                    json.dumps(result.raw, ensure_ascii=False, default=str)[:16000000],
+                    result.status,
+                    result.reason[:2000],
+                    int(material_id),
+                ),
+            )
+        conn.commit()
+    finally:
+        conn.close()

+ 313 - 93
examples/auto_put_ad_mini/tools/creative_creation.py

@@ -23,8 +23,8 @@
 
 import logging
 import random
-from dataclasses import asdict
-from typing import Optional
+from dataclasses import asdict, dataclass
+from typing import Iterator, Optional
 
 from config import (
     CREATIVE_DESCRIPTION_COUNT_PER_AD,
@@ -36,6 +36,11 @@ from config import (
     TARGET_CREATIVES_PER_AD,
 )
 from tools.ad_api import _check, _get, _post, images_add
+from tools.account_material_strategy import load_account_material_strategy
+from tools.ai_generated_material import (
+    get_or_generate_assets_for_landing,
+    update_generated_material_status,
+)
 from tools.landing_plan import LandingPlanResult, create_landing_plan
 from tools.material_recall import Material, recall_materials_for_video
 from tools.creative_material_usage import (
@@ -50,7 +55,7 @@ from tools.video_recall import (
     get_account_crowd_package,
     map_crowd_package_for_video_recall,
 )
-from tools.video_feature_query import fetch_video_element_features
+from tools.video_feature_query import VideoElementFeature, fetch_video_element_features
 from tools.video_risk import VideoRiskResult, check_video_risk
 
 logger = logging.getLogger(__name__)
@@ -86,6 +91,121 @@ def _pick_image_url(material: Material) -> str:
     return images[0] if images else ""
 
 
+@dataclass
+class _LandingSourceState:
+    videos: list[LandingVideo]
+    features_by_vid: dict[int, list[VideoElementFeature]]
+    stats: dict
+
+
+class LandingCandidatePool:
+    """一次运行内复用的承接视频候选池。
+
+    只缓存无外部副作用的步骤:视频拉取、品类过滤、ODPS 特征读取。
+    风险审核按实际遍历到的 landing 懒执行并缓存,避免一次性审核用不到的视频。
+    图片上传、AI 生图、xcx/save 仍由 prepare_one_creative_for_ad 串行执行。
+    """
+
+    def __init__(self, account_id: int, max_landings: int = MAX_LANDING_ATTEMPTS_PER_AD):
+        self.account_id = account_id
+        self.max_landings = max_landings
+        self.crowd_package = get_account_crowd_package(account_id)
+        self.video_crowd_package = map_crowd_package_for_video_recall(self.crowd_package)
+        self._source_state_by_label: dict[str, _LandingSourceState] = {}
+        self._risk_by_vid: dict[int, VideoRiskResult] = {}
+        self._no_features_logged: set[tuple[str, int]] = set()
+
+    @property
+    def source_plan(self) -> list[tuple[str, str]]:
+        plan = [("primary", PIAOQUANTV_VIDEO_SOURCE)]
+        if PIAOQUANTV_HOT_FALLBACK_SOURCE != PIAOQUANTV_VIDEO_SOURCE:
+            plan.append(("hot", PIAOQUANTV_HOT_FALLBACK_SOURCE))
+        return plan
+
+    def iter_featured_landings(self) -> Iterator[tuple[str, LandingVideo, list[VideoElementFeature]]]:
+        for source_label, source in self.source_plan:
+            state = self._load_source(source_label, source)
+            for v in state.videos:
+                element_features = state.features_by_vid.get(v.video_id) or []
+                if not element_features:
+                    if (source_label, v.video_id) not in self._no_features_logged:
+                        state.stats["no_features"] += 1
+                        self._no_features_logged.add((source_label, v.video_id))
+                        logger.info(
+                            "[landing_candidate_pool]   landing=%d 无 ODPS 召回特征,跳过",
+                            v.video_id,
+                        )
+                    continue
+
+                state.stats["feature_candidates_yielded"] += 1
+                yield source_label, v, element_features
+
+    def check_risk(self, source_label: str, landing: LandingVideo) -> VideoRiskResult:
+        risk = self._risk_by_vid.get(landing.video_id)
+        if risk is not None:
+            return risk
+
+        state = self._source_state_by_label[source_label]
+        risk = check_video_risk(landing.video_id)
+        self._risk_by_vid[landing.video_id] = risk
+        state.stats["risk_checked"] += 1
+        if not risk.passed:
+            state.stats["risk_blocked"] += 1
+            state.stats["risk_blocked_by_vid"][landing.video_id] = risk.reason
+            logger.warning(
+                "[landing_candidate_pool]   landing=%d 风险拦截:%s",
+                landing.video_id, risk.reason,
+            )
+        return risk
+
+    def _load_source(self, source_label: str, source: str) -> _LandingSourceState:
+        if source_label in self._source_state_by_label:
+            return self._source_state_by_label[source_label]
+
+        videos = fetch_landing_videos_for_account(
+            self.account_id,
+            page_size=100,
+            source=source,
+            enable_hot_fallback=False,
+        )
+        valid = [v for v in videos if _is_landing_candidate(v)]
+        features_by_vid = fetch_video_element_features(v.video_id for v in valid)
+        stats = {
+            "fetched": len(videos),
+            "valid": len(valid),
+            "category_filtered": max(len(videos) - len(valid), 0),
+            "risk_checked": 0,
+            "risk_blocked": 0,
+            "risk_blocked_by_vid": {},
+            "no_features": 0,
+            "feature_candidates_yielded": 0,
+        }
+        logger.info(
+            "[landing_candidate_pool] account=%d source=%s valid=%d/%d feature_videos=%d feature_rows=%d",
+            self.account_id, source_label, len(valid),
+            len(videos), len(features_by_vid), sum(len(v) for v in features_by_vid.values()),
+        )
+
+        state = _LandingSourceState(
+            videos=valid,
+            features_by_vid=features_by_vid,
+            stats=stats,
+        )
+        self._source_state_by_label[source_label] = state
+        logger.info(
+            "[landing_candidate_pool] account=%d source=%s stats=%s",
+            self.account_id, source_label, stats,
+        )
+        return state
+
+
+def build_landing_candidate_pool(
+    account_id: int,
+    max_landings: int = MAX_LANDING_ATTEMPTS_PER_AD,
+) -> LandingCandidatePool:
+    return LandingCandidatePool(account_id, max_landings=max_landings)
+
+
 def find_ads_needing_creatives(
     account_id: int,
     min_creatives: int = TARGET_CREATIVES_PER_AD,
@@ -518,6 +638,8 @@ def prepare_one_creative_for_ad(
     adgroup_id: int,
     excluded_material_ids: Optional[set] = None,
     excluded_landing_ids: Optional[set] = None,
+    landing_candidates: Optional[LandingCandidatePool] = None,
+    failed_landing_ids: Optional[set[int]] = None,
     max_landings: int = MAX_LANDING_ATTEMPTS_PER_AD,
     max_materials_per_landing: int = MAX_MATERIAL_PER_LANDING,
 ) -> Optional[dict]:
@@ -533,7 +655,10 @@ def prepare_one_creative_for_ad(
     Args:
         excluded_material_ids: 同广告内已挂的 material_id 集合(2026-06-09 N=3 时去重必需)。
                                召回后过滤掉这些,避免同广告挂重复素材被腾讯模型降权曝光。
-        excluded_landing_ids: 兼容旧调用保留,当前不再使用。落地页视频允许重复。
+        excluded_landing_ids: 本轮/近期已使用的 landing_video_id 集合。
+                              用于同人群包跨广告、跨轮 landing 排重。
+        landing_candidates: 本轮复用的承接视频候选池。传入后不重复拉视频/查特征/风险。
+        failed_landing_ids: 本广告本轮已尝试失败的 landing_video_id,避免 AI 拒审/无素材后重复尝试。
 
     Returns:
         pending record dict(飞书表格字段 + Phase 3 POST 用的完整 body + 元数据);
@@ -543,23 +668,40 @@ def prepare_one_creative_for_ad(
 
     excluded_material_ids = excluded_material_ids or set()
     excluded_landing_ids = excluded_landing_ids or set()
+    failed_landing_ids = failed_landing_ids if failed_landing_ids is not None else set()
     crowd_package = get_account_crowd_package(account_id)
+    material_strategy = load_account_material_strategy(account_id)
     video_crowd_package = map_crowd_package_for_video_recall(crowd_package)
-    try:
-        recent_material_ids = load_recent_used_material_ids(crowd_package)
-    except Exception as e:
-        logger.warning(
-            "[prepare_one_creative] account=%d crowd=%r 读取素材使用历史失败,仅使用本轮排重:%s",
-            account_id, crowd_package, e,
-        )
+    landing_candidates = landing_candidates or build_landing_candidate_pool(
+        account_id,
+        max_landings=max_landings,
+    )
+    if material_strategy.use_ai_generated and not material_strategy.ai_fallback_to_history:
         recent_material_ids = set()
+        logger.info(
+            "[prepare_one_creative] account=%d adgroup=%d material_source=%s fallback_history=%s; "
+            "AI素材不回退历史,跳过历史素材排重库",
+            account_id, adgroup_id, material_strategy.material_source,
+            material_strategy.ai_fallback_to_history,
+        )
+    else:
+        try:
+            recent_material_ids = load_recent_used_material_ids(crowd_package)
+        except Exception as e:
+            logger.warning(
+                "[prepare_one_creative] account=%d crowd=%r 读取素材使用历史失败,仅使用本轮排重:%s",
+                account_id, crowd_package, e,
+            )
+            recent_material_ids = set()
     effective_excluded_material_ids = merge_used_material_ids(
         excluded_material_ids,
         recent_material_ids,
     )
     logger.info(
-        "[prepare_one_creative] account=%d adgroup=%d crowd=%r video_crowd=%r material_dedupe run=%d recent=%d effective=%d",
+        "[prepare_one_creative] account=%d adgroup=%d crowd=%r video_crowd=%r "
+        "material_source=%s fallback_history=%s material_dedupe run=%d recent=%d effective=%d",
         account_id, adgroup_id, crowd_package, video_crowd_package,
+        material_strategy.material_source, material_strategy.ai_fallback_to_history,
         len(excluded_material_ids), len(recent_material_ids),
         len(effective_excluded_material_ids),
     )
@@ -568,103 +710,157 @@ def prepare_one_creative_for_ad(
     chosen_material = None
     chosen_risk: Optional[VideoRiskResult] = None
     chosen_landing_source = ""
+    chosen_material_source = material_strategy.material_source
+    chosen_ai_generated_material_id = None
+
+    source_stats_by_label: dict[str, dict] = {}
+
+    for source_label, v, element_features in landing_candidates.iter_featured_landings():
+        source_stats = source_stats_by_label.setdefault(source_label, {
+            "pool_candidates": 0,
+            "landing_dedupe": 0,
+            "failed_landing_dedupe": 0,
+            "ai_attempts": 0,
+            "ai_failed": 0,
+            "ai_empty": 0,
+            "history_recall_empty": 0,
+            "all_excluded": 0,
+            "selected": 0,
+        })
+        source_stats["pool_candidates"] += 1
+
+        if v.video_id in excluded_landing_ids:
+            source_stats["landing_dedupe"] += 1
+            logger.info(
+                "[prepare_one_creative]   landing=%d landing 排重命中,跳过",
+                v.video_id,
+            )
+            continue
+        if v.video_id in failed_landing_ids:
+            source_stats["failed_landing_dedupe"] += 1
+            logger.info(
+                "[prepare_one_creative]   landing=%d 本广告本轮已失败,跳过",
+                v.video_id,
+            )
+            continue
 
-    # 每个来源最多拉 100 条视频。先主池,主池产不出可用创意时再用同人群包 hot 兜底。
-    source_plan = [("primary", PIAOQUANTV_VIDEO_SOURCE)]
-    if PIAOQUANTV_HOT_FALLBACK_SOURCE != PIAOQUANTV_VIDEO_SOURCE:
-        source_plan.append(("hot", PIAOQUANTV_HOT_FALLBACK_SOURCE))
-
-    for source_label, source in source_plan:
-        videos = fetch_landing_videos_for_account(
-            account_id,
-            page_size=100,
-            source=source,
-            enable_hot_fallback=False,
-        )
-        valid = [v for v in videos if _is_landing_candidate(v)]
-        features_by_vid = fetch_video_element_features(v.video_id for v in valid)
-        logger.info(
-            "[prepare_one_creative] account=%d adgroup=%d source=%s valid landing=%d/100 feature_videos=%d feature_rows=%d excl_mat=%d",
-            account_id, adgroup_id, source_label, len(valid),
-            len(features_by_vid), sum(len(v) for v in features_by_vid.values()),
-            len(effective_excluded_material_ids),
-        )
-
-        attempts = 0
-        for v in valid:
-            if v.video_id in excluded_landing_ids:
+        risk = landing_candidates.check_risk(source_label, v)
+        if not risk.passed:
+            continue
+        source_stats = source_stats_by_label[source_label]
+        materials = []
+        candidate_material_source = material_strategy.material_source
+        if material_strategy.use_ai_generated:
+            source_stats["ai_attempts"] += 1
+            try:
+                assets = get_or_generate_assets_for_landing(
+                    account_id=account_id,
+                    adgroup_id=adgroup_id,
+                    crowd_package=crowd_package,
+                    landing=v,
+                )
+                materials = [asset.to_material() for asset in assets]
                 logger.info(
-                    "[prepare_one_creative]   landing=%d 本轮同广告 landing 限频,跳过",
-                    v.video_id,
+                    "[prepare_one_creative]   landing=%d AI生成素材候选=%d fallback_history=%s",
+                    v.video_id, len(materials), material_strategy.ai_fallback_to_history,
                 )
-                continue
-            attempts += 1
-            if attempts > max_landings:
-                break
-
-            # 2026-06-29:承接视频风险审核。必须在素材召回 / xcx-save 前完成,
-            # 避免高风险 landing 继续产生 plan/rootSourceId 等外部副作用。
-            risk = check_video_risk(v.video_id)
-            if not risk.passed:
-                logger.warning(
-                    "[prepare_one_creative]   landing=%d 风险拦截:%s",
-                    v.video_id, risk.reason,
+            except Exception as e:
+                source_stats["ai_failed"] += 1
+                failed_landing_ids.add(v.video_id)
+                logger.exception(
+                    "[prepare_one_creative]   landing=%d AI生成素材失败:%s",
+                    v.video_id, e,
                 )
-                continue
+                if not material_strategy.ai_fallback_to_history:
+                    continue
+
+        if (
+            material_strategy.use_ai_generated
+            and not materials
+            and not material_strategy.ai_fallback_to_history
+        ):
+            source_stats["ai_empty"] += 1
+            failed_landing_ids.add(v.video_id)
+            logger.info(
+                "[prepare_one_creative]   landing=%d AI无可用素材且不允许回退历史素材,跳过",
+                v.video_id,
+            )
+            continue
 
-            element_features = features_by_vid.get(v.video_id) or []
-            if not element_features:
+        if (not materials) and (
+            not material_strategy.use_ai_generated
+            or material_strategy.ai_fallback_to_history
+        ):
+            if material_strategy.use_ai_generated:
                 logger.info(
-                    "[prepare_one_creative]   landing=%d 无 ODPS 召回特征,跳过",
+                    "[prepare_one_creative]   landing=%d AI无可用素材,按账户配置回退历史素材",
                     v.video_id,
                 )
-                continue
-
+                candidate_material_source = "history_fallback"
             materials = recall_materials_for_video(
                 v,
                 final_top_n=max_materials_per_landing,
                 element_features=element_features,
             )
-            # material_id 去重(2026-06-09):跳过已用素材(账户层 set,跨广告也共享)
-            fresh = [
-                m for m in materials
-                if m.material_id not in effective_excluded_material_ids
-            ]
-            if fresh:
-                chosen_landing = v
-                chosen_material = fresh[0]
-                chosen_risk = risk
-                chosen_landing_source = source_label
-                logger.info(
-                    "[prepare_one_creative]   选中 landing=%d source=%s category=%r material=%s recall=%s/%s/%s cost=%s roi=%s ctr=%s imp=%s score=%s policy=score>=0.8,cost_desc",
-                    v.video_id, source_label, v.category,
-                    chosen_material.material_id,
-                    chosen_material.recall_element_dimension,
-                    chosen_material.recall_point_type,
-                    chosen_material.recall_standard_element,
-                    chosen_material.cost,
-                    chosen_material.roi,
-                    chosen_material.ctr,
-                    chosen_material.impressions,
-                    chosen_material.score,
-                )
-                break
-            if materials:
-                logger.info(
-                    "[prepare_one_creative]   landing=%d 召回 %d 全在 excluded,试下一条",
-                    v.video_id, len(materials),
-                )
-        if chosen_landing and chosen_material:
+            if not materials:
+                source_stats["history_recall_empty"] += 1
+                failed_landing_ids.add(v.video_id)
+        # material_id 去重(2026-06-09):跳过已用素材(账户层 set,跨广告也共享)
+        fresh = [
+            m for m in materials
+            if m.material_id not in effective_excluded_material_ids
+        ]
+        if fresh:
+            chosen_landing = v
+            chosen_material = fresh[0]
+            chosen_risk = risk
+            chosen_landing_source = source_label
+            chosen_material_source = candidate_material_source
+            if chosen_material.material_id.startswith("ai:"):
+                chosen_ai_generated_material_id = chosen_material.raw.get("ai_generated_material_id")
+            source_stats["selected"] += 1
+            logger.info(
+                "[prepare_one_creative]   选中 landing=%d source=%s category=%r material_source=%s material=%s recall=%s/%s/%s cost=%s roi=%s ctr=%s imp=%s score=%s policy=%s",
+                v.video_id, source_label, v.category,
+                chosen_material_source,
+                chosen_material.material_id,
+                chosen_material.recall_element_dimension,
+                chosen_material.recall_point_type,
+                chosen_material.recall_standard_element,
+                chosen_material.cost,
+                chosen_material.roi,
+                chosen_material.ctr,
+                chosen_material.impressions,
+                chosen_material.score,
+                "ai_generated" if chosen_material.material_id.startswith("ai:") else "score>=0.8,cost_desc",
+            )
             break
+        if materials:
+            source_stats["all_excluded"] += 1
+            failed_landing_ids.add(v.video_id)
+            logger.info(
+                "[prepare_one_creative]   landing=%d 召回 %d 全在 excluded,试下一条",
+                v.video_id, len(materials),
+            )
+
+    if chosen_landing and chosen_material:
+        logger.info(
+            "[prepare_one_creative] source_summary account=%d adgroup=%d source=%s stats=%s",
+            account_id, adgroup_id, chosen_landing_source,
+            source_stats_by_label.get(chosen_landing_source, {}),
+        )
+    else:
         logger.info(
-            "[prepare_one_creative] account=%d adgroup=%d source=%s 未产出可用创意",
-            account_id, adgroup_id, source_label,
+            "[prepare_one_creative] account=%d adgroup=%d 未产出可用创意 stats=%s",
+            account_id, adgroup_id, source_stats_by_label,
         )
 
     if not chosen_landing or not chosen_material:
         logger.error(
-            "[prepare_one_creative] account=%d adgroup=%d 穷尽 landing 后无可用素材(excluded=%d)",
-            account_id, adgroup_id, len(effective_excluded_material_ids),
+            "[prepare_one_creative] account=%d adgroup=%d material_source=%s fallback_history=%s "
+            "穷尽 landing 后无可用素材(excluded=%d)",
+            account_id, adgroup_id, material_strategy.material_source,
+            material_strategy.ai_fallback_to_history, len(effective_excluded_material_ids),
         )
         return None
 
@@ -674,10 +870,16 @@ def prepare_one_creative_for_ad(
             "[prepare_one_creative] material=%s 既无 cover 也无 imageList,放弃",
             chosen_material.material_id,
         )
+        failed_landing_ids.add(chosen_landing.video_id)
         return None
 
-    # 上传素材图(MD5 幂等)
-    material_image_id = images_add(account_id, image_url)
+    is_ai_generated_material = chosen_material.material_id.startswith("ai:")
+    if is_ai_generated_material:
+        # AI 图先走飞书人工审批,审批通过后在 Phase 3 上传腾讯图片。
+        material_image_id = ""
+    else:
+        # 历史素材保持原有行为:Phase 1 上传腾讯图片(MD5 幂等)。
+        material_image_id = images_add(account_id, image_url)
 
     # 注册落地计划(xcx/save)
     plan = create_landing_plan(crowd_package, chosen_landing)
@@ -720,6 +922,7 @@ def prepare_one_creative_for_ad(
         "landing_title": chosen_landing.title,
         "landing_source": chosen_landing_source,
         "landing_category": chosen_landing.category,
+        "material_source": chosen_material_source,
         "material_cover_url": chosen_material.cover,
         # 素材质量字段(2026-06-10 batchByText 升级 — 给 Task 25 飞书表展示用)
         "material_ctr": chosen_material.ctr,
@@ -728,7 +931,10 @@ def prepare_one_creative_for_ad(
         "material_impressions": chosen_material.impressions,
         "material_quality_score": chosen_material.quality_score,
         "material_score": chosen_material.score,
-        "material_selection_policy": "score>=0.8,cost_desc",
+        "material_selection_policy": (
+            "ai_generated_manual_review"
+            if is_ai_generated_material else "score>=0.8,cost_desc"
+        ),
         "material_recall_strategy": chosen_material.recall_strategy,
         "material_recall_query_text": chosen_material.recall_query_text,
         "material_recall_config_code": chosen_material.recall_config_code,
@@ -746,6 +952,8 @@ def prepare_one_creative_for_ad(
         # === 追溯元数据(写 summary JSON)===
         "_material_id": chosen_material.material_id,
         "_material_image_id": material_image_id,
+        "_pending_image_url": image_url if is_ai_generated_material else "",
+        "_ai_generated_material_id": chosen_ai_generated_material_id,
         "_plan_id": plan.plan_id,
         "_experiment_id": chosen_landing.experiment_id,
         "_brand_image_id": brand["brand_image_id"],
@@ -754,6 +962,18 @@ def prepare_one_creative_for_ad(
     }
     if chosen_risk is not None:
         rec.update(chosen_risk.to_record_fields())
+    if chosen_ai_generated_material_id:
+        try:
+            update_generated_material_status(
+                rec,
+                "prepared",
+                tencent_image_id=material_image_id,
+            )
+        except Exception as e:
+            logger.warning(
+                "[prepare_one_creative] AI素材状态更新失败 id=%s:%s",
+                chosen_ai_generated_material_id, e,
+            )
     return rec
 
 

+ 217 - 1
examples/auto_put_ad_mini/tools/creative_material_usage.py

@@ -16,6 +16,12 @@ logger = logging.getLogger(__name__)
 CREATIVE_MATERIAL_DEDUPE_LOOKBACK_DAYS = int(
     os.getenv("CREATIVE_MATERIAL_DEDUPE_LOOKBACK_DAYS", "7")
 )
+CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS = int(
+    os.getenv("CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS", "7")
+)
+CREATION_RECOVERY_LOOKBACK_HOURS = int(
+    os.getenv("CREATION_RECOVERY_LOOKBACK_HOURS", "24")
+)
 
 CREATE_USAGE_TABLE_SQL = """
 CREATE TABLE IF NOT EXISTS creative_material_usage (
@@ -23,7 +29,7 @@ CREATE TABLE IF NOT EXISTS creative_material_usage (
     account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
     adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
     crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
-    landing_video_id BIGINT DEFAULT NULL COMMENT '承接视频ID,仅审计不默认排重',
+    landing_video_id BIGINT DEFAULT NULL COMMENT '承接视频ID,用于同人群包近期排重',
     material_id VARCHAR(100) NOT NULL COMMENT '内部素材ID',
     material_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
     dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
@@ -33,6 +39,7 @@ CREATE TABLE IF NOT EXISTS creative_material_usage (
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     KEY idx_crowd_material_created (crowd_package, material_id, created_at),
+    KEY idx_crowd_landing_created (crowd_package, landing_video_id, created_at),
     KEY idx_crowd_account_ad_material (crowd_package, account_id, adgroup_id, material_id),
     KEY idx_account_created (account_id, created_at),
     KEY idx_status_created (status, created_at)
@@ -48,6 +55,14 @@ def ensure_usage_table() -> None:
     try:
         with conn.cursor() as cur:
             cur.execute(CREATE_USAGE_TABLE_SQL)
+            cur.execute("SHOW INDEX FROM creative_material_usage WHERE Key_name = 'idx_crowd_landing_created'")
+            if not cur.fetchall():
+                cur.execute(
+                    """
+                    ALTER TABLE creative_material_usage
+                    ADD INDEX idx_crowd_landing_created (crowd_package, landing_video_id, created_at)
+                    """
+                )
         conn.commit()
     finally:
         conn.close()
@@ -97,6 +112,207 @@ def load_recent_used_material_ids(
     return {str(row["material_id"]) for row in rows if row.get("material_id")}
 
 
+def load_recent_used_landing_video_ids(
+    crowd_package: str,
+    lookback_days: int = CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS,
+) -> set[int]:
+    """Load recent landing-video reservations for the same crowd package."""
+    if not crowd_package:
+        return set()
+    ensure_usage_table()
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT DISTINCT landing_video_id
+                FROM (
+                    SELECT landing_video_id
+                    FROM creative_material_usage
+                    WHERE crowd_package=%s
+                      AND landing_video_id IS NOT NULL
+                      AND landing_video_id > 0
+                      AND status IN ('posted_ok', 'submitted')
+                      AND created_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
+
+                    UNION
+
+                    SELECT landing_video_id
+                    FROM creative_creation_task
+                    WHERE landing_video_id IS NOT NULL
+                      AND landing_video_id > 0
+                      AND submitted_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
+                      AND JSON_UNQUOTE(JSON_EXTRACT(raw_record, '$.audience_tier'))=%s
+                ) t
+                """,
+                (crowd_package, int(lookback_days), int(lookback_days), crowd_package),
+            )
+            rows = cur.fetchall() or []
+    finally:
+        conn.close()
+
+    out: set[int] = set()
+    for row in rows:
+        try:
+            out.add(int(row["landing_video_id"]))
+        except (TypeError, ValueError):
+            continue
+    return out
+
+
+def _material_source_from_record(material_id: object, raw_record: object) -> str:
+    material_id_text = str(material_id or "").strip()
+    if material_id_text.startswith("ai:"):
+        return "ai_generated"
+    if raw_record:
+        try:
+            data = json.loads(str(raw_record))
+            source = str(data.get("material_source") or "").strip()
+            raw_material_id = str(data.get("_material_id") or "").strip()
+            if source == "ai_generated" or raw_material_id.startswith("ai:"):
+                return "ai_generated"
+        except Exception:
+            pass
+    return "history"
+
+
+def load_recent_landing_usage_counts(
+    crowd_package: str,
+    lookback_days: int = CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS,
+) -> dict[str, dict[int, int]]:
+    """Load recent landing-video usage counts split by material source.
+
+    The `source` column in creative_material_usage means primary/hot video source,
+    so material source must be derived from material_id/raw_record.
+    """
+    counts: dict[str, dict[int, int]] = {
+        "history": {},
+        "ai_generated": {},
+    }
+    if not crowd_package:
+        return counts
+    ensure_usage_table()
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT account_id, adgroup_id, landing_video_id, material_id, raw_record
+                FROM creative_material_usage
+                WHERE crowd_package=%s
+                  AND landing_video_id IS NOT NULL
+                  AND landing_video_id > 0
+                  AND status IN ('posted_ok', 'submitted')
+                  AND created_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
+                """,
+                (crowd_package, int(lookback_days)),
+            )
+            usage_rows = cur.fetchall() or []
+
+            cur.execute(
+                """
+                SELECT account_id, adgroup_id, landing_video_id, material_id, raw_record
+                FROM creative_creation_task
+                WHERE landing_video_id IS NOT NULL
+                  AND landing_video_id > 0
+                  AND submitted_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
+                  AND JSON_UNQUOTE(JSON_EXTRACT(raw_record, '$.audience_tier'))=%s
+                """,
+                (int(lookback_days), crowd_package),
+            )
+            task_rows = cur.fetchall() or []
+    finally:
+        conn.close()
+
+    seen: set[tuple[str, int, int, int, str]] = set()
+    for row in list(usage_rows) + list(task_rows):
+        try:
+            landing_video_id = int(row["landing_video_id"])
+            account_id = int(row.get("account_id") or 0)
+            adgroup_id = int(row.get("adgroup_id") or 0)
+        except (TypeError, ValueError):
+            continue
+        material_id = str(row.get("material_id") or "")
+        source = _material_source_from_record(material_id, row.get("raw_record"))
+        key = (source, account_id, adgroup_id, landing_video_id, material_id)
+        if key in seen:
+            continue
+        seen.add(key)
+        counts.setdefault(source, {})
+        counts[source][landing_video_id] = counts[source].get(landing_video_id, 0) + 1
+    return counts
+
+
+def load_recoverable_prepared_records(
+    *,
+    account_id: int,
+    adgroup_id: int,
+    crowd_package: str,
+    material_source: str,
+    limit: int,
+    lookback_hours: int = CREATION_RECOVERY_LOOKBACK_HOURS,
+) -> list[dict]:
+    """Load unsubmitted prepared records after an interrupted Phase 1 run."""
+    if limit <= 0 or not crowd_package:
+        return []
+    ensure_usage_table()
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT raw_record
+                FROM creative_material_usage
+                WHERE account_id=%s
+                  AND adgroup_id <=> %s
+                  AND crowd_package=%s
+                  AND status='prepared'
+                  AND dynamic_creative_id IS NULL
+                  AND raw_record IS NOT NULL
+                  AND raw_record <> ''
+                  AND created_at >= DATE_SUB(NOW(), INTERVAL %s HOUR)
+                ORDER BY id ASC
+                LIMIT %s
+                """,
+                (
+                    int(account_id),
+                    int(adgroup_id) if adgroup_id else None,
+                    crowd_package,
+                    int(lookback_hours),
+                    max(int(limit) * 3, int(limit)),
+                ),
+            )
+            rows = cur.fetchall() or []
+    finally:
+        conn.close()
+
+    out: list[dict] = []
+    seen_materials: set[str] = set()
+    for row in rows:
+        try:
+            record = json.loads(str(row.get("raw_record") or ""))
+        except Exception:
+            continue
+        material_id = str(record.get("_material_id") or "").strip()
+        if not material_id or material_id in seen_materials:
+            continue
+        if _material_source_from_record(material_id, row.get("raw_record")) != material_source:
+            continue
+        if not record.get("_request_body"):
+            continue
+        seen_materials.add(material_id)
+        out.append(record)
+        if len(out) >= limit:
+            break
+    return out
+
+
 def record_prepared_material_usage(record: dict, status: str = "prepared") -> None:
     """Reserve a material once Phase 1 has produced a pending creative."""
     material_id = str(record.get("_material_id") or "").strip()

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

@@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS creative_creation_task (
     UNIQUE KEY uk_account_creative (account_id, dynamic_creative_id),
     KEY idx_review_status (review_status),
     KEY idx_submitted_at (submitted_at),
+    KEY idx_landing_submitted (landing_video_id, submitted_at),
     KEY idx_last_review_checked_at (last_review_checked_at),
     KEY idx_account_status (account_id, review_status)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意创建与审核扫描任务'

+ 126 - 0
examples/auto_put_ad_mini/tools/delivery_config.py

@@ -0,0 +1,126 @@
+"""Account-level bidding and placement config parsed from Feishu/DB."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import lru_cache
+import json
+from pathlib import Path
+
+BID_MODE_AVERAGE_COST = "average_cost"
+BID_MODE_MAX_CONVERSION = "max_conversion"
+
+
+@dataclass(frozen=True)
+class PlacementConfig:
+    automatic_site_enabled: bool
+    site_set: list[str]
+
+
+@dataclass(frozen=True)
+class GeoLocationConfig:
+    location_types: list[str]
+    region_ids: list[int]
+
+
+_BID_SCENE_VALUES = {
+    "": BID_MODE_AVERAGE_COST,
+    "平均成本": BID_MODE_AVERAGE_COST,
+    "稳定成本": BID_MODE_AVERAGE_COST,
+    "最大转化量": BID_MODE_MAX_CONVERSION,
+    "最大转化量出价": BID_MODE_MAX_CONVERSION,
+    "average_cost": BID_MODE_AVERAGE_COST,
+    "max_conversion": BID_MODE_MAX_CONVERSION,
+    # 兼容已经同步过的旧值。
+    "BID_SCENE_NORMAL_AVERAGE": BID_MODE_AVERAGE_COST,
+    "BID_SCENE_NORMAL_MAX": BID_MODE_MAX_CONVERSION,
+}
+
+_PLACEMENT_GROUPS = {
+    "微信朋友圈": ["SITE_SET_MOMENTS"],
+    "朋友圈": ["SITE_SET_MOMENTS"],
+    "微信公众号与小程序": ["SITE_SET_WECHAT", "SITE_SET_WECHAT_PLUGIN"],
+    "微信公众号": ["SITE_SET_WECHAT"],
+    "微信插件": ["SITE_SET_WECHAT_PLUGIN"],
+    "搜索场景": ["SITE_SET_SEARCH_SCENE"],
+    "腾讯平台与内容媒体": [
+        "SITE_SET_TENCENT_NEWS",
+        "SITE_SET_TENCENT_VIDEO",
+        "SITE_SET_KANDIAN",
+        "SITE_SET_QQ_MUSIC_GAME",
+    ],
+    "腾讯营销联盟": ["SITE_SET_MOBILE_UNION"],
+}
+
+
+def parse_bid_scene(raw: object) -> str:
+    text = str(raw or "").strip()
+    if text in _BID_SCENE_VALUES:
+        return _BID_SCENE_VALUES[text]
+    raise ValueError(f"未知出价方式:{raw}")
+
+
+def parse_placement_config(raw: object) -> PlacementConfig | None:
+    text = str(raw or "").strip()
+    if not text:
+        return None
+    if text.upper() == "AIM+":
+        return PlacementConfig(automatic_site_enabled=True, site_set=[])
+
+    site_set: list[str] = []
+    for part in text.replace(",", ",").split(","):
+        name = part.strip()
+        if not name:
+            continue
+        values = _PLACEMENT_GROUPS.get(name)
+        if values is None:
+            raise ValueError(f"未知投放版位:{name}")
+        for value in values:
+            if value not in site_set:
+                site_set.append(value)
+    if not site_set:
+        raise ValueError(f"投放版位为空:{raw}")
+    return PlacementConfig(automatic_site_enabled=False, site_set=site_set)
+
+
+@lru_cache(maxsize=1)
+def _region_name_mapping() -> dict[str, int]:
+    path = Path(__file__).resolve().parents[1] / "data" / "tencent_constants" / "regions_all.json"
+    regions = json.loads(path.read_text(encoding="utf-8"))
+    mapping: dict[str, int] = {}
+    for region in regions:
+        name = str(region.get("name") or "").strip()
+        if not name:
+            continue
+        region_id = int(region["id"])
+        mapping.setdefault(name, region_id)
+        for suffix in ("特别行政区", "壮族自治区", "回族自治区", "维吾尔自治区", "自治区", "省", "市"):
+            if name.endswith(suffix):
+                mapping.setdefault(name[:-len(suffix)], region_id)
+                break
+    return mapping
+
+
+def parse_geo_location_config(raw: object) -> GeoLocationConfig | None:
+    """Parse Feishu geography: blank keeps the delivery template; 不限 omits geo targeting."""
+    text = str(raw or "").strip()
+    if not text:
+        return None
+    if text.lower() in {"不限", "不限地域", "全国", "all"}:
+        return GeoLocationConfig(location_types=[], region_ids=[])
+
+    names = [part.strip() for part in text.replace(",", ",").split(",") if part.strip()]
+    if not names:
+        raise ValueError(f"地域为空:{raw}")
+    name_mapping = _region_name_mapping()
+    region_ids: list[int] = []
+    for name in names:
+        try:
+            region_id = int(name)
+        except ValueError:
+            region_id = name_mapping.get(name, 0)
+        if not region_id:
+            raise ValueError(f"未知地域:{name}")
+        if region_id not in region_ids:
+            region_ids.append(region_id)
+    return GeoLocationConfig(location_types=["LIVE_IN"], region_ids=region_ids)

+ 18 - 9
examples/auto_put_ad_mini/tools/im_approval_ad_creation.py

@@ -33,34 +33,35 @@ from tools.feishu_doc import (
     import_to_feishu,
 )
 from tools.scene_spec import describe_position_ids
+from tools.delivery_config import BID_MODE_MAX_CONVERSION
 
 logger = logging.getLogger(__name__)
 
 FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
 
-# 9 列(中文)
+# 11 列(中文)
 HEADERS = [
     # A 浅灰 4 列
     "日期", "账户ID", "人群包", "广告名称",
-    # B 浅紫 4 列(广告维度)
-    "投放版位", "版位定投场景", "出价(元)", "年龄定向",
+    # B 浅紫 6 列(广告维度)
+    "投放版位", "版位定投场景", "出价方式", "出价(元)", "智能版位", "年龄定向",
     # C 浅绿 1 列
     "决策",
 ]
 
 GROUP_COLORS = [
     ((1, 4), "FFD9D9D9"),    # A 浅灰
-    ((5, 8), "FFD9C8E8"),    # B 浅紫
-    ((9, 9), "FFC6E0B4"),    # C 浅绿
+    ((5, 10), "FFD9C8E8"),   # B 浅紫
+    ((11, 11), "FFC6E0B4"),  # C 浅绿
 ]
 
 COL_WIDTHS = {
     "A": 12, "B": 14, "C": 16, "D": 32,
-    "E": 36, "F": 50, "G": 10, "H": 12,
-    "I": 14,
+    "E": 36, "F": 50, "G": 18, "H": 10,
+    "I": 12, "J": 12, "K": 14,
 }
 
-DECISION_COL_LETTER = "I"  # 第 9
+DECISION_COL_LETTER = "K"  # 第 11
 VALID_ACTIONS = ("approve", "reject", "hold")
 
 
@@ -87,10 +88,16 @@ def _format_site_set_cn(site_set: list) -> str:
 
 def _format_record_to_row(rec: dict) -> list:
     """rec 是 ad_creation.AdCandidate 的 dict 形式 + 加 approval_date / audience_tier / age_range 等"""
-    site_set_cn = _format_site_set_cn(rec.get("site_set") or [])
+    automatic_site_enabled = bool(rec.get("automatic_site_enabled"))
+    site_set_cn = "AIM+" if automatic_site_enabled else _format_site_set_cn(rec.get("site_set") or [])
     wp_ids = rec.get("wechat_position") or []
     wp_cn = describe_position_ids(int(rec["account_id"]), wp_ids)
     bid_yuan = f"{rec['bid_amount_fen'] / 100:.2f}" if rec.get("bid_amount_fen") else ""
+    bid_scene_cn = (
+        "最大转化量"
+        if rec.get("bid_scene") == BID_MODE_MAX_CONVERSION
+        else "平均成本"
+    )
 
     return [
         rec["approval_date"],
@@ -99,7 +106,9 @@ def _format_record_to_row(rec: dict) -> list:
         rec.get("adgroup_name", ""),
         site_set_cn,
         wp_cn,
+        bid_scene_cn,
         bid_yuan,
+        "是" if automatic_site_enabled else "否",
         rec.get("age_range", ""),
         "",  # 决策列空,等运营填
     ]

+ 91 - 11
examples/auto_put_ad_mini/tools/im_approval_creation.py

@@ -19,14 +19,18 @@
 import asyncio
 import json
 import logging
+import os
+import tempfile
 import time
 from pathlib import Path
 from typing import Optional
 
 import httpx
 from openpyxl import Workbook
+from openpyxl.drawing.image import Image as OpenpyxlImage
 from openpyxl.styles import Alignment, Font, PatternFill
 from openpyxl.worksheet.datavalidation import DataValidation
+from PIL import Image as PilImage
 
 from config import (
     CREATION_APPROVAL_TIMEOUT_MINUTES,
@@ -38,15 +42,15 @@ logger = logging.getLogger(__name__)
 
 FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
 
-# 26 列(中文)— 素材排序改为 score 准入 + cost 倒序,报表同步展示召回依据。
+# 28 列(中文)— 素材排序改为 score 准入 + cost 倒序,报表同步展示召回依据。
 HEADERS = [
     # A 浅灰 5 列
     "日期", "账户ID", "人群包", "广告ID", "广告名称",
     # B 浅紫 3 列
     "出价(元)", "投放版位", "年龄定向",
-    # C 浅橙 11 列(落地视频 + 风险审核 + 素材质量)
+    # C 浅橙 19 列(落地视频 + 风险审核 + 素材来源 + 素材质量)
     "落地视频", "落地视频标题", "风险等级", "风险标签", "风险原因",
-    "素材预览", "创意文案",
+    "素材来源", "素材预览", "素材链接", "创意文案",
     "成本(元)", "ROI", "CTR", "曝光数", "相似度",
     "召回维度", "召回点类型", "召回元素", "命中维度明细",
     "创意名(归因)",
@@ -57,23 +61,28 @@ HEADERS = [
 GROUP_COLORS = [
     ((1, 5), "FFD9D9D9"),    # A 浅灰
     ((6, 8), "FFD9C8E8"),    # B 浅紫
-    ((9, 25), "FFFCD8B4"),   # C 浅橙
-    ((26, 26), "FFC6E0B4"),  # D 浅绿
+    ((9, 27), "FFFCD8B4"),   # C 浅橙
+    ((28, 28), "FFC6E0B4"),  # D 浅绿
 ]
 
 COL_WIDTHS = {
     "A": 12, "B": 14, "C": 16, "D": 16, "E": 28,
     "F": 10, "G": 28, "H": 12,
     "I": 14, "J": 26, "K": 10, "L": 24, "M": 32,
-    "N": 18, "O": 30,
-    "P": 12, "Q": 10, "R": 10, "S": 10, "T": 10,
-    "U": 14, "V": 14, "W": 18, "X": 42,
-    "Y": 38,                      # 创意名(归因)
-    "Z": 14,                      # 决策
+    "N": 14, "O": 18, "P": 24, "Q": 30,
+    "R": 12, "S": 10, "T": 10, "U": 10, "V": 10,
+    "W": 14, "X": 14, "Y": 18, "Z": 42,
+    "AA": 38,                     # 创意名(归因)
+    "AB": 14,                     # 决策
 }
 
-DECISION_COL_LETTER = "Z"
+DECISION_COL_LETTER = "AB"
 VALID_ACTIONS = ("approve", "reject", "hold")
+EMBED_MATERIAL_PREVIEW_IMAGES = os.getenv(
+    "CREATION_APPROVAL_EMBED_IMAGES", "1"
+).strip().lower() in {"1", "true", "yes", "y", "on"}
+MATERIAL_PREVIEW_COL_IDX = 15
+MATERIAL_PREVIEW_COL_LETTER = "O"
 
 
 def _color_for_col(col_idx: int) -> str:
@@ -121,8 +130,10 @@ def _format_record_to_row(rec: dict) -> list:
         rec.get("landing_risk_level", ""),
         rec.get("landing_risk_tag_ids", ""),
         rec.get("landing_risk_reason", ""),
+        rec.get("material_source", "history"),
         # 素材预览:HYPERLINK(cover_url, "查看素材")— 见模块顶部说明
         f'=HYPERLINK("{rec["material_cover_url"]}","查看素材")',
+        f'=HYPERLINK("{rec["material_cover_url"]}","打开素材")',
         # 创意文案(2026-06-09 加列):description 换行展示
         descriptions_str,
         f"{cost:.2f}" if cost is not None else "",
@@ -139,6 +150,73 @@ def _format_record_to_row(rec: dict) -> list:
     ]
 
 
+def _download_preview_image(url: str, output_path: Path) -> bool:
+    try:
+        resp = httpx.get(url, timeout=20, follow_redirects=True)
+        resp.raise_for_status()
+        output_path.write_bytes(resp.content)
+        return True
+    except Exception as e:
+        logger.warning("[im_approval_creation] 素材图下载失败 url=%s error=%s", url, e)
+        return False
+
+
+def _prepare_thumbnail(src_path: Path, dst_path: Path) -> bool:
+    try:
+        with PilImage.open(src_path) as img:
+            img = img.convert("RGB")
+            img.thumbnail((180, 100))
+            img.save(dst_path, format="JPEG", quality=85)
+        return True
+    except Exception as e:
+        logger.warning(
+            "[im_approval_creation] 素材图缩略图生成失败 path=%s error=%s",
+            src_path, e,
+        )
+        return False
+
+
+def _embed_material_preview_images(ws, records: list[dict]) -> None:
+    """Embed thumbnails into the material preview column, keeping hyperlinks as fallback."""
+    if not EMBED_MATERIAL_PREVIEW_IMAGES:
+        return
+
+    tmp_dir = tempfile.TemporaryDirectory(prefix="creative_preview_")
+    tmp_root = Path(tmp_dir.name)
+    # Keep the tempdir alive until workbook.save() finishes.
+    ws._creative_preview_tmp_dir = tmp_dir  # type: ignore[attr-defined]
+    embedded = 0
+    for row_idx, rec in enumerate(records, start=2):
+        url = str(rec.get("material_cover_url") or "").strip()
+        if not url:
+            continue
+        raw_path = tmp_root / f"raw_{row_idx}"
+        thumb_path = tmp_root / f"thumb_{row_idx}.jpg"
+        if not _download_preview_image(url, raw_path):
+            continue
+        if not _prepare_thumbnail(raw_path, thumb_path):
+            continue
+        try:
+            img = OpenpyxlImage(str(thumb_path))
+            img.width = 180
+            img.height = 100
+            ws.add_image(img, f"{MATERIAL_PREVIEW_COL_LETTER}{row_idx}")
+            ws.cell(row=row_idx, column=MATERIAL_PREVIEW_COL_IDX).value = "查看素材"
+            ws.cell(row=row_idx, column=MATERIAL_PREVIEW_COL_IDX).hyperlink = url
+            ws.row_dimensions[row_idx].height = 88
+            embedded += 1
+        except Exception as e:
+            logger.warning(
+                "[im_approval_creation] 素材图嵌入失败 row=%d url=%s error=%s",
+                row_idx, url, e,
+            )
+
+    logger.info(
+        "[im_approval_creation] 素材图嵌入完成 embedded=%d/%d",
+        embedded, len(records),
+    )
+
+
 def generate_approval_xlsx(records: list[dict], output_path: Path) -> Path:
     """生成待审批 xlsx,含 hyperlink/下拉/颜色/冻结。"""
     wb = Workbook()
@@ -189,6 +267,8 @@ def generate_approval_xlsx(records: list[dict], output_path: Path) -> Path:
     for r in range(2, 2 + len(records)):
         ws.row_dimensions[r].height = 80
 
+    _embed_material_preview_images(ws, records)
+
     output_path.parent.mkdir(parents=True, exist_ok=True)
     wb.save(output_path)
     logger.info(

+ 1078 - 0
examples/auto_put_ad_mini/tools/material_strategy_learning.py

@@ -0,0 +1,1078 @@
+"""Material strategy learning persistence.
+
+This module stores high-consumption material snapshots and visual annotations.
+It is intentionally separate from the ad/creative creation pipeline: importing
+learning data must not create or modify Tencent ads.
+"""
+
+from __future__ import annotations
+
+import csv
+import hashlib
+import json
+import logging
+import os
+import re
+from dataclasses import dataclass
+from datetime import date
+from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
+from pathlib import Path
+from typing import Any, Iterable, Sequence
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+
+CREATE_STRATEGY_LEARNING_TABLES_SQL = [
+    """
+CREATE TABLE IF NOT EXISTS material_performance_snapshot_run (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
+    window_start DATE NOT NULL COMMENT '统计窗口开始日期',
+    window_end DATE NOT NULL COMMENT '统计窗口结束日期',
+    top_n INT NOT NULL DEFAULT 5000 COMMENT '拉取TopN',
+    source VARCHAR(64) NOT NULL DEFAULT 'odps' COMMENT '数据来源',
+    sql_file VARCHAR(255) DEFAULT NULL COMMENT 'SQL文件路径',
+    row_count INT NOT NULL DEFAULT 0 COMMENT '明细行数',
+    total_cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内总消耗(分)',
+    status VARCHAR(32) NOT NULL DEFAULT 'SUCCESS' COMMENT '任务状态',
+    error_message MEDIUMTEXT DEFAULT NULL COMMENT '错误信息',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_run_id (run_id),
+    KEY idx_window (window_start, window_end)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高消耗素材表现快照任务'
+""",
+    """
+CREATE TABLE IF NOT EXISTS material_performance_snapshot_item (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
+    rank_no INT NOT NULL COMMENT '消耗排名',
+    account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
+    ad_id BIGINT NOT NULL COMMENT '广告ID',
+    creative_id BIGINT NOT NULL COMMENT '创意ID',
+    creative_name VARCHAR(255) DEFAULT NULL COMMENT '创意名称',
+    ad_name VARCHAR(255) DEFAULT NULL COMMENT '广告名称',
+    video_id BIGINT DEFAULT NULL COMMENT '承接视频ID',
+    title VARCHAR(512) DEFAULT NULL COMMENT '素材标题',
+    image_url VARCHAR(1024) DEFAULT NULL COMMENT '素材图片URL',
+    image_hash VARCHAR(64) DEFAULT NULL COMMENT '图片内容hash',
+    crowd_package VARCHAR(255) DEFAULT NULL COMMENT '人群包名称',
+    optimization_goal VARCHAR(128) DEFAULT NULL COMMENT '优化目标',
+    bid_amount_fen BIGINT DEFAULT NULL COMMENT '出价(分)',
+    day_amount_fen BIGINT DEFAULT NULL COMMENT '日预算(分)',
+    cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '消耗(分)',
+    impressions BIGINT NOT NULL DEFAULT 0 COMMENT '曝光',
+    clicks BIGINT NOT NULL DEFAULT 0 COMMENT '点击',
+    ctr DECIMAL(10, 6) DEFAULT NULL COMMENT '点击率',
+    key_page_view_count BIGINT NOT NULL DEFAULT 0 COMMENT '关键页访问次数',
+    key_page_rate DECIMAL(10, 6) DEFAULT NULL COMMENT '关键页访问/点击',
+    conversions_count BIGINT NOT NULL DEFAULT 0 COMMENT '转化数',
+    conversion_rate DECIMAL(10, 6) DEFAULT NULL COMMENT '转化率',
+    active_days INT NOT NULL DEFAULT 0 COMMENT '有消耗天数',
+    first_dt VARCHAR(16) DEFAULT NULL COMMENT '首次投放日期',
+    last_dt VARCHAR(16) DEFAULT NULL COMMENT '最后投放日期',
+    raw_json MEDIUMTEXT DEFAULT NULL COMMENT '原始行JSON',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_run_creative (run_id, creative_id),
+    KEY idx_creative (creative_id),
+    KEY idx_image_hash (image_hash),
+    KEY idx_video (video_id),
+    KEY idx_package_cost (crowd_package, cost_fen)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高消耗素材表现快照明细'
+""",
+    """
+CREATE TABLE IF NOT EXISTS material_visual_annotation (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    image_hash VARCHAR(64) NOT NULL COMMENT '图片内容hash',
+    image_url VARCHAR(1024) NOT NULL COMMENT '素材图片URL',
+    annotation_version VARCHAR(64) NOT NULL COMMENT '标注版本',
+    annotator VARCHAR(64) NOT NULL COMMENT '标注来源',
+    creative_id BIGINT DEFAULT NULL COMMENT '样本创意ID',
+    visual_template VARCHAR(128) DEFAULT NULL COMMENT '视觉模板',
+    hook_category VARCHAR(255) DEFAULT NULL COMMENT '标题钩子分类',
+    title_text VARCHAR(512) DEFAULT NULL COMMENT '图片/素材标题',
+    title_length INT DEFAULT NULL COMMENT '标题长度',
+    scene_type VARCHAR(128) DEFAULT NULL COMMENT '场景类型',
+    person_type VARCHAR(128) DEFAULT NULL COMMENT '人物/主体估计',
+    has_human TINYINT DEFAULT NULL COMMENT '是否有人物',
+    text_area_level VARCHAR(32) DEFAULT NULL COMMENT '文字面积估计等级',
+    color_style VARCHAR(128) DEFAULT NULL COMMENT '颜色/调性',
+    button_like_element TINYINT NOT NULL DEFAULT 0 COMMENT '是否疑似按钮诱导',
+    fake_ui_risk TINYINT NOT NULL DEFAULT 0 COMMENT '假界面风险',
+    official_policy_risk TINYINT NOT NULL DEFAULT 0 COMMENT '官方/政策承诺风险',
+    medical_health_risk TINYINT NOT NULL DEFAULT 0 COMMENT '医疗健康风险',
+    politics_sensitive_risk TINYINT NOT NULL DEFAULT 0 COMMENT '涉政/国家情绪风险',
+    celebrity_or_history_risk TINYINT NOT NULL DEFAULT 0 COMMENT '名人/历史人物风险',
+    strong_inducement_risk TINYINT NOT NULL DEFAULT 0 COMMENT '强诱导风险',
+    greeting_blessing_risk TINYINT NOT NULL DEFAULT 0 COMMENT '早晚安/祝福风险',
+    compliance_level VARCHAR(32) NOT NULL DEFAULT 'caution' COMMENT '生成可学习等级',
+    learnable_points MEDIUMTEXT DEFAULT NULL COMMENT '可学习点',
+    avoid_points MEDIUMTEXT DEFAULT NULL COMMENT '避让点',
+    raw_annotation MEDIUMTEXT DEFAULT NULL COMMENT '原始标注JSON',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_image_version (image_hash, annotation_version),
+    KEY idx_creative (creative_id),
+    KEY idx_template (visual_template),
+    KEY idx_compliance (compliance_level)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材图片结构化标注'
+""",
+    """
+CREATE TABLE IF NOT EXISTS material_creative_pattern (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    pattern_version VARCHAR(64) NOT NULL COMMENT '策略版本',
+    pattern_key VARCHAR(128) NOT NULL COMMENT '策略唯一键',
+    pattern_name VARCHAR(128) NOT NULL COMMENT '策略名称',
+    hook_category VARCHAR(128) NOT NULL COMMENT '标题钩子类型',
+    visual_template VARCHAR(128) NOT NULL COMMENT '视觉模板',
+    applicable_crowd_packages VARCHAR(1024) DEFAULT NULL COMMENT '适用人群包',
+    applicable_placements VARCHAR(1024) DEFAULT NULL COMMENT '适用版位',
+    target_age_min INT DEFAULT NULL COMMENT '适用年龄下限',
+    target_age_max INT DEFAULT NULL COMMENT '适用年龄上限',
+    title_hook_rule MEDIUMTEXT NOT NULL COMMENT '标题钩子规则',
+    visual_rule MEDIUMTEXT NOT NULL COMMENT '视觉规则',
+    relevance_rule MEDIUMTEXT NOT NULL COMMENT '视频相关性规则',
+    compliance_rule MEDIUMTEXT NOT NULL COMMENT '合规规则',
+    selector_config MEDIUMTEXT DEFAULT NULL COMMENT '选择器配置JSON:match/exclude/score',
+    positive_examples MEDIUMTEXT DEFAULT NULL COMMENT '正例JSON',
+    negative_examples MEDIUMTEXT DEFAULT NULL COMMENT '反例JSON',
+    source_run_id VARCHAR(64) DEFAULT NULL COMMENT '来源快照ID',
+    source_material_count INT NOT NULL DEFAULT 0 COMMENT '来源素材数',
+    source_total_cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '来源素材消耗(分)',
+    status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/APPROVED/REJECTED',
+    reviewed_by VARCHAR(64) DEFAULT NULL COMMENT '审核人',
+    reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT '审核时间',
+    enabled TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_version_key (pattern_version, pattern_key),
+    KEY idx_status_enabled (status, enabled),
+    KEY idx_hook_template (hook_category, visual_template)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成素材可学习创意模式'
+""",
+    """
+CREATE TABLE IF NOT EXISTS material_strategy_learning_report (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    report_id VARCHAR(64) NOT NULL COMMENT '报告ID',
+    run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
+    report_version VARCHAR(64) NOT NULL COMMENT '报告版本',
+    summary MEDIUMTEXT NOT NULL COMMENT '报告摘要',
+    top_patterns MEDIUMTEXT DEFAULT NULL COMMENT '核心模式JSON',
+    risk_summary MEDIUMTEXT DEFAULT NULL COMMENT '风险摘要JSON',
+    recommended_actions MEDIUMTEXT DEFAULT NULL COMMENT '建议动作JSON',
+    report_path VARCHAR(512) DEFAULT NULL COMMENT '本地报告路径',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_report_id (report_id),
+    KEY idx_run_id (run_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材策略学习周报'
+""",
+]
+
+STRATEGY_LEARNING_MIGRATIONS_SQL = [
+    """
+ALTER TABLE material_performance_snapshot_item
+MODIFY COLUMN bid_amount_fen BIGINT DEFAULT NULL COMMENT '出价(分)'
+""",
+    """
+ALTER TABLE material_performance_snapshot_item
+MODIFY COLUMN day_amount_fen BIGINT DEFAULT NULL COMMENT '日预算(分)'
+""",
+]
+
+
+@dataclass(frozen=True)
+class ImportResult:
+    run_id: str
+    snapshot_rows: int
+    annotation_rows: int
+    report_rows: int
+
+
+@dataclass(frozen=True)
+class CreativePattern:
+    pattern_version: str
+    pattern_key: str
+    pattern_name: str
+    hook_category: str
+    visual_template: str
+    title_hook_rule: str
+    visual_rule: str
+    relevance_rule: str
+    compliance_rule: str
+    selector_config: dict[str, Any] | None = None
+    applicable_crowd_packages: str = ""
+    applicable_placements: str = ""
+    target_age_min: int | None = 60
+    target_age_max: int | None = 75
+    positive_examples: list[str] | None = None
+    negative_examples: list[str] | None = None
+    source_run_id: str | None = None
+    source_material_count: int = 0
+    source_total_cost_fen: int = 0
+    status: str = "DRAFT"
+    enabled: int = 0
+
+
+@dataclass(frozen=True)
+class PatternSelection:
+    pattern: CreativePattern
+    score: float
+    reasons: list[str]
+    penalties: list[str]
+    matched_features: list[str]
+
+
+DEFAULT_PATTERN_VERSION = "seed_20260708_v1"
+DEFAULT_SOURCE_RUN_ID = "material_30d_20260607_20260706_top5000"
+DEFAULT_PATTERN_SEED_PATH = (
+    Path(__file__).resolve().parents[1] / "configs" / "material_creative_patterns_seed.json"
+)
+DEFAULT_PATTERN_SELECTOR_PROMPT_PATH = (
+    Path(__file__).resolve().parents[1] / "prompts" / "ai_pattern_selector.md"
+)
+OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
+    "OPENROUTER_CHAT_COMPLETIONS_URL",
+    "https://openrouter.ai/api/v1/chat/completions",
+)
+OPENROUTER_TEXT_MODEL = os.getenv("OPENROUTER_TEXT_MODEL", "google/gemini-3-flash-preview")
+PATTERN_SELECTOR_MIN_MODEL_SCORE = float(os.getenv("PATTERN_SELECTOR_MIN_MODEL_SCORE", "75"))
+
+def ensure_strategy_learning_tables() -> None:
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            for sql in CREATE_STRATEGY_LEARNING_TABLES_SQL:
+                cur.execute(sql)
+            for sql in STRATEGY_LEARNING_MIGRATIONS_SQL:
+                cur.execute(sql)
+            cur.execute("SHOW COLUMNS FROM material_creative_pattern LIKE 'selector_config'")
+            if not cur.fetchone():
+                cur.execute(
+                    """
+                    ALTER TABLE material_creative_pattern
+                    ADD COLUMN selector_config MEDIUMTEXT DEFAULT NULL
+                    COMMENT '选择器配置JSON:match/exclude/score'
+                    AFTER compliance_rule
+                    """
+                )
+        conn.commit()
+    finally:
+        conn.close()
+
+
+def _pattern_from_row(row: dict[str, Any]) -> CreativePattern:
+    def _loads_list(value: Any) -> list[str]:
+        if not value:
+            return []
+        try:
+            parsed = json.loads(str(value))
+        except json.JSONDecodeError:
+            return []
+        if isinstance(parsed, list):
+            return [str(item) for item in parsed]
+        return []
+
+    def _loads_dict(value: Any) -> dict[str, Any]:
+        if not value:
+            return {}
+        if isinstance(value, dict):
+            return value
+        try:
+            parsed = json.loads(str(value))
+        except json.JSONDecodeError:
+            return {}
+        return parsed if isinstance(parsed, dict) else {}
+
+    return CreativePattern(
+        pattern_version=str(row.get("pattern_version") or ""),
+        pattern_key=str(row.get("pattern_key") or ""),
+        pattern_name=str(row.get("pattern_name") or ""),
+        hook_category=str(row.get("hook_category") or ""),
+        visual_template=str(row.get("visual_template") or ""),
+        applicable_crowd_packages=str(row.get("applicable_crowd_packages") or ""),
+        applicable_placements=str(row.get("applicable_placements") or ""),
+        target_age_min=_as_int(row.get("target_age_min")),
+        target_age_max=_as_int(row.get("target_age_max")),
+        title_hook_rule=str(row.get("title_hook_rule") or ""),
+        visual_rule=str(row.get("visual_rule") or ""),
+        relevance_rule=str(row.get("relevance_rule") or ""),
+        compliance_rule=str(row.get("compliance_rule") or ""),
+        selector_config=_loads_dict(row.get("selector_config")),
+        positive_examples=_loads_list(row.get("positive_examples")),
+        negative_examples=_loads_list(row.get("negative_examples")),
+        source_run_id=str(row.get("source_run_id") or "") or None,
+        source_material_count=_as_required_int(row.get("source_material_count")),
+        source_total_cost_fen=_as_required_int(row.get("source_total_cost_fen")),
+        status=str(row.get("status") or "DRAFT"),
+        enabled=_as_required_int(row.get("enabled")),
+    )
+
+
+def _load_seed_patterns(path: Path = DEFAULT_PATTERN_SEED_PATH) -> list[CreativePattern]:
+    raw_patterns = json.loads(path.read_text(encoding="utf-8"))
+    patterns: list[CreativePattern] = []
+    for raw in raw_patterns:
+        patterns.append(CreativePattern(
+            pattern_version=str(raw.get("pattern_version") or DEFAULT_PATTERN_VERSION),
+            pattern_key=str(raw["pattern_key"]),
+            pattern_name=str(raw["pattern_name"]),
+            hook_category=str(raw.get("hook_category") or ""),
+            visual_template=str(raw.get("visual_template") or ""),
+            applicable_crowd_packages=str(raw.get("applicable_crowd_packages") or ""),
+            applicable_placements=str(raw.get("applicable_placements") or ""),
+            target_age_min=_as_int(raw.get("target_age_min")) or 60,
+            target_age_max=_as_int(raw.get("target_age_max")) or 75,
+            title_hook_rule=str(raw.get("title_hook_rule") or ""),
+            visual_rule=str(raw.get("visual_rule") or ""),
+            relevance_rule=str(raw.get("relevance_rule") or ""),
+            compliance_rule=str(raw.get("compliance_rule") or ""),
+            selector_config=raw.get("selector_config") or {},
+            positive_examples=[str(v) for v in raw.get("positive_examples") or []],
+            negative_examples=[str(v) for v in raw.get("negative_examples") or []],
+            source_run_id=str(raw.get("source_run_id") or "") or None,
+            source_material_count=_as_required_int(raw.get("source_material_count")),
+            source_total_cost_fen=_as_required_int(raw.get("source_total_cost_fen")),
+            status=str(raw.get("status") or "DRAFT"),
+            enabled=_as_required_int(raw.get("enabled")),
+        ))
+    return patterns
+
+
+def seed_default_draft_patterns(seed_path: Path = DEFAULT_PATTERN_SEED_PATH) -> int:
+    """Upsert initial DRAFT creative patterns learned from Top100 analysis."""
+    ensure_strategy_learning_tables()
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.executemany(
+                """
+                INSERT INTO material_creative_pattern (
+                    pattern_version, pattern_key, pattern_name, hook_category, visual_template,
+                    applicable_crowd_packages, applicable_placements, target_age_min, target_age_max,
+                    title_hook_rule, visual_rule, relevance_rule, compliance_rule, selector_config,
+                    positive_examples, negative_examples, source_run_id, source_material_count,
+                    source_total_cost_fen, status, enabled
+                ) VALUES (
+                    %s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s
+                )
+                ON DUPLICATE KEY UPDATE
+                    pattern_name=VALUES(pattern_name),
+                    hook_category=VALUES(hook_category),
+                    visual_template=VALUES(visual_template),
+                    applicable_crowd_packages=VALUES(applicable_crowd_packages),
+                    applicable_placements=VALUES(applicable_placements),
+                    target_age_min=VALUES(target_age_min),
+                    target_age_max=VALUES(target_age_max),
+                    title_hook_rule=VALUES(title_hook_rule),
+                    visual_rule=VALUES(visual_rule),
+                    relevance_rule=VALUES(relevance_rule),
+                    compliance_rule=VALUES(compliance_rule),
+                    selector_config=VALUES(selector_config),
+                    positive_examples=VALUES(positive_examples),
+                    negative_examples=VALUES(negative_examples),
+                    source_run_id=VALUES(source_run_id),
+                    source_material_count=VALUES(source_material_count),
+                    source_total_cost_fen=VALUES(source_total_cost_fen),
+                    status=VALUES(status),
+                    enabled=VALUES(enabled),
+                    updated_at=CURRENT_TIMESTAMP
+                """,
+                [
+                    (
+                        p.pattern_version,
+                        p.pattern_key,
+                        p.pattern_name,
+                        p.hook_category,
+                        p.visual_template,
+                        p.applicable_crowd_packages or None,
+                        p.applicable_placements or None,
+                        p.target_age_min,
+                        p.target_age_max,
+                        p.title_hook_rule,
+                        p.visual_rule,
+                        p.relevance_rule,
+                        p.compliance_rule,
+                        _json_dumps(p.selector_config or {}),
+                        _json_dumps(p.positive_examples or []),
+                        _json_dumps(p.negative_examples or []),
+                        p.source_run_id,
+                        p.source_material_count,
+                        p.source_total_cost_fen,
+                        p.status,
+                        p.enabled,
+                    )
+                    for p in _load_seed_patterns(seed_path)
+                ],
+            )
+        conn.commit()
+    finally:
+        conn.close()
+    return len(_load_seed_patterns(seed_path))
+
+
+def load_creative_patterns(include_draft: bool = False) -> list[CreativePattern]:
+    """Load selectable patterns. Production should keep include_draft=False."""
+    ensure_strategy_learning_tables()
+    from db.connection import get_connection
+
+    if include_draft:
+        where_sql = "status IN ('DRAFT', 'APPROVED')"
+    else:
+        where_sql = "status = 'APPROVED' AND enabled = 1"
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                f"""
+                SELECT *
+                FROM material_creative_pattern
+                WHERE {where_sql}
+                ORDER BY enabled DESC, source_total_cost_fen DESC, id ASC
+                """
+            )
+            rows = cur.fetchall() or []
+    finally:
+        conn.close()
+    return [_pattern_from_row(row) for row in rows]
+
+
+def _feature_texts(video_features: Sequence[Any]) -> list[str]:
+    texts: list[str] = []
+    for feature in video_features:
+        element_dimension = str(getattr(feature, "element_dimension", "") or "")
+        point_type = str(getattr(feature, "point_type", "") or "")
+        standard_element = str(getattr(feature, "standard_element", "") or "")
+        text = " ".join(part for part in [element_dimension, point_type, standard_element] if part)
+        if text:
+            texts.append(text)
+    return texts
+
+
+def _contains_any(text: str, keywords: Sequence[str]) -> bool:
+    return any(keyword and keyword in text for keyword in keywords)
+
+
+def _selection_context_bonus(
+    pattern: CreativePattern,
+    *,
+    placement: str,
+) -> tuple[float, list[str], list[str]]:
+    score = 0.0
+    reasons: list[str] = []
+    penalties: list[str] = []
+    if pattern.status == "APPROVED" and pattern.enabled:
+        score += 20
+        reasons.append("approved_enabled")
+    elif pattern.status == "DRAFT":
+        score += 5
+        reasons.append("draft_for_review")
+
+    if pattern.applicable_placements:
+        placements = [p.strip() for p in pattern.applicable_placements.split(",") if p.strip()]
+        if placement and placement in placements:
+            score += 6
+            reasons.append("placement_matched")
+        else:
+            score -= 6
+            penalties.append("placement_not_matched")
+    return score, reasons, penalties
+
+
+def _fallback_score_pattern(
+    pattern: CreativePattern,
+    *,
+    feature_texts: Sequence[str],
+    placement: str,
+) -> PatternSelection:
+    """Stable fallback score when the model selector is unavailable.
+
+    This intentionally does not use keyword relevance matching. Pattern
+    relevance is the model node's responsibility. Fallback only keeps the system
+    available and auditable.
+    """
+    joined_features = " ".join(feature_texts)
+    score = 10.0
+    context_score, reasons, penalties = _selection_context_bonus(
+        pattern,
+        placement=placement,
+    )
+    score += context_score
+    reasons = ["fallback_candidate", *reasons]
+    matched_features: list[str] = list(feature_texts[:3])
+
+    # Weak prior from historical material volume, not semantic relevance.
+    if pattern.source_total_cost_fen:
+        score += min(15.0, pattern.source_total_cost_fen / 10_000_000)
+        reasons.append("historical_cost_prior")
+    if pattern.source_material_count:
+        score += min(5.0, pattern.source_material_count / 2)
+        reasons.append("historical_sample_count_prior")
+
+    if not feature_texts:
+        score -= 15
+        penalties.append("no_video_features")
+
+    deduped_matches = []
+    seen_matches: set[str] = set()
+    for text in matched_features:
+        if text not in seen_matches:
+            seen_matches.add(text)
+            deduped_matches.append(text)
+
+    return PatternSelection(
+        pattern=pattern,
+        score=round(score, 2),
+        reasons=reasons,
+        penalties=penalties,
+        matched_features=deduped_matches[:5],
+    )
+
+
+def _openrouter_api_key() -> str:
+    # Keep the same precedence as tools/ai_generated_material.py.
+    key = os.getenv("OPEN_ROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY")
+    if not key:
+        raise RuntimeError("缺少 OPENROUTER_API_KEY/OPEN_ROUTER_API_KEY,无法用模型选择pattern")
+    return key
+
+
+def _extract_json_object(text: str) -> dict[str, Any]:
+    raw = str(text or "").strip()
+    if raw.startswith("```"):
+        raw = re.sub(r"^```(?:json)?", "", raw).strip()
+        raw = re.sub(r"```$", "", raw).strip()
+    try:
+        parsed = json.loads(raw)
+    except json.JSONDecodeError:
+        match = re.search(r"\{.*\}", raw, flags=re.S)
+        if not match:
+            raise
+        parsed = json.loads(match.group(0))
+    if not isinstance(parsed, dict):
+        raise ValueError("模型返回不是JSON object")
+    return parsed
+
+
+def _load_pattern_selector_policy(path: Path = DEFAULT_PATTERN_SELECTOR_PROMPT_PATH) -> str:
+    return path.read_text(encoding="utf-8").strip()
+
+
+def _model_rerank_pattern_selections(
+    *,
+    feature_texts: Sequence[str],
+    placement: str,
+    candidates: Sequence[PatternSelection],
+    top_k: int,
+    model: str | None = None,
+) -> list[PatternSelection]:
+    """Use an LLM node to choose from pre-existing candidate patterns.
+
+    The model can only rerank/select candidates produced from DB. It cannot
+    invent pattern keys or bypass compliance/risk context.
+    """
+    if not candidates:
+        return []
+    model_name = model or OPENROUTER_TEXT_MODEL
+    candidate_payload = [
+        {
+            "pattern_key": item.pattern.pattern_key,
+            "pattern_name": item.pattern.pattern_name,
+            "hook_category": item.pattern.hook_category,
+            "visual_template": item.pattern.visual_template,
+            "title_hook_rule": item.pattern.title_hook_rule,
+            "visual_rule": item.pattern.visual_rule,
+            "relevance_rule": item.pattern.relevance_rule,
+            "compliance_rule": item.pattern.compliance_rule,
+            "fallback_score": item.score,
+            "fallback_reasons": item.reasons,
+            "fallback_penalties": item.penalties,
+            "positive_examples": item.pattern.positive_examples or [],
+            "negative_examples": item.pattern.negative_examples or [],
+        }
+        for item in candidates
+    ]
+    prompt_payload = {
+        "selection_policy": _load_pattern_selector_policy(),
+        "video_features": list(feature_texts),
+        "placement": placement,
+        "top_k": top_k,
+        "candidate_patterns": candidate_payload,
+        "output_schema": {
+            "selected": [
+                {
+                    "pattern_key": "候选pattern_key",
+                    "score": "0-100整数,表示模型选择置信度",
+                    "reason": "为什么这个pattern最匹配视频",
+                    "matched_features": ["命中的视频特征文本"],
+                    "risk_notes": ["需要注意的合规风险,没有则空数组"],
+                }
+            ],
+            "no_match_example": {"selected": []},
+        },
+    }
+    resp = httpx.post(
+        OPENROUTER_CHAT_COMPLETIONS_URL,
+        headers={
+            "Authorization": f"Bearer {_openrouter_api_key()}",
+            "Content-Type": "application/json",
+        },
+        json={
+            "model": model_name,
+            "messages": [
+                {
+                    "role": "system",
+                    "content": "你是广告创意策略选择器,只做结构化JSON输出。",
+                },
+                {
+                    "role": "user",
+                    "content": json.dumps(prompt_payload, ensure_ascii=False),
+                },
+            ],
+            "temperature": 0.2,
+        },
+        timeout=45,
+    )
+    resp.raise_for_status()
+    data = resp.json()
+    content = (((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "")
+    parsed = _extract_json_object(content)
+    selected = parsed.get("selected") or []
+    by_key = {item.pattern.pattern_key: item for item in candidates}
+    out: list[PatternSelection] = []
+    seen: set[str] = set()
+    for raw in selected:
+        if not isinstance(raw, dict):
+            continue
+        key = str(raw.get("pattern_key") or "")
+        if not key or key in seen or key not in by_key:
+            continue
+        seen.add(key)
+        base = by_key[key]
+        try:
+            model_score = float(raw.get("score"))
+        except (TypeError, ValueError):
+            model_score = base.score
+        if model_score < PATTERN_SELECTOR_MIN_MODEL_SCORE:
+            continue
+        reason = str(raw.get("reason") or "").strip()
+        risk_notes = [str(v) for v in raw.get("risk_notes") or [] if str(v)]
+        matched = [str(v) for v in raw.get("matched_features") or [] if str(v)]
+        out.append(PatternSelection(
+            pattern=base.pattern,
+            score=round(model_score, 2),
+            reasons=[*base.reasons, "model_selected", *(["model_reason:" + reason] if reason else [])],
+            penalties=[*base.penalties, *["model_risk:" + note for note in risk_notes]],
+            matched_features=matched or base.matched_features,
+        ))
+        if len(out) >= top_k:
+            break
+    return out
+
+
+def select_creative_patterns(
+    *,
+    video_features: Sequence[Any],
+    crowd_package: str = "",
+    placement: str = "",
+    include_draft: bool = False,
+    top_k: int = 3,
+    use_model: bool = True,
+    model: str | None = None,
+) -> list[PatternSelection]:
+    """Select creative patterns for one video.
+
+    Production shape:
+    1. Load patterns from DB.
+    2. Send all available candidates to the model for semantic selection.
+    3. If the model fails, use a non-semantic stable fallback.
+
+    crowd_package is intentionally ignored: material style selection is shared
+    across audience packages.
+    """
+    patterns = load_creative_patterns(include_draft=include_draft)
+    feature_texts = _feature_texts(video_features)
+    placement = str(placement or "")
+    selections = [
+        _fallback_score_pattern(
+            pattern,
+            feature_texts=feature_texts,
+            placement=placement,
+        )
+        for pattern in patterns
+    ]
+    selections.sort(key=lambda item: item.score, reverse=True)
+    if use_model:
+        try:
+            return _model_rerank_pattern_selections(
+                feature_texts=feature_texts,
+                placement=placement,
+                candidates=selections,
+                top_k=max(1, int(top_k)),
+                model=model,
+            )
+        except Exception as e:
+            logger.warning("[pattern_selector] model selection failed, fallback to stable score: %s", e)
+    return selections[:max(1, int(top_k))]
+
+
+
+def _read_csv(path: Path) -> list[dict[str, str]]:
+    with path.open("r", encoding="utf-8-sig", newline="") as f:
+        return list(csv.DictReader(f))
+
+
+def _read_json(path: Path) -> Any:
+    return json.loads(path.read_text(encoding="utf-8"))
+
+
+def _json_dumps(data: Any) -> str:
+    return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
+
+
+def _as_int(value: Any) -> int | None:
+    text = str(value or "").strip()
+    if not text:
+        return None
+    try:
+        return int(Decimal(text))
+    except (InvalidOperation, ValueError):
+        return None
+
+
+def _as_required_int(value: Any, default: int = 0) -> int:
+    parsed = _as_int(value)
+    return default if parsed is None else parsed
+
+
+def _as_decimal(value: Any, places: str = "0.000001") -> Decimal | None:
+    text = str(value or "").strip()
+    if not text:
+        return None
+    try:
+        return Decimal(text).quantize(Decimal(places), rounding=ROUND_HALF_UP)
+    except (InvalidOperation, ValueError):
+        return None
+
+
+def _yuan_to_fen(value: Any) -> int:
+    text = str(value or "").strip()
+    if not text:
+        return 0
+    try:
+        yuan = Decimal(text)
+    except (InvalidOperation, ValueError):
+        return 0
+    return int((yuan * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
+
+
+def _bool_int(value: Any) -> int:
+    return 1 if str(value or "").strip().lower() in {"1", "true", "yes", "y", "是"} else 0
+
+
+def _risk_flags(row: dict[str, str]) -> set[str]:
+    raw = row.get("risk_flags") or ""
+    return {part.strip() for part in raw.split(",") if part.strip()}
+
+
+def _file_or_url_hash(image_path: str, image_url: str, base_dir: Path) -> str:
+    path = Path(image_path) if image_path else Path()
+    if image_path and not path.is_absolute():
+        path = base_dir / path
+    if image_path and path.exists():
+        return hashlib.sha256(path.read_bytes()).hexdigest()
+    return hashlib.sha256(str(image_url or "").encode("utf-8")).hexdigest()
+
+
+def _annotation_hash_map(annotation_rows: Iterable[dict[str, str]], base_dir: Path) -> dict[int, str]:
+    out: dict[int, str] = {}
+    for row in annotation_rows:
+        creative_id = _as_int(row.get("creative_id"))
+        if creative_id is None:
+            continue
+        out[creative_id] = _file_or_url_hash(
+            row.get("image_path") or "",
+            row.get("image_url") or "",
+            base_dir,
+        )
+    return out
+
+
+def import_current_material_analysis(
+    *,
+    run_id: str,
+    window_start: date,
+    window_end: date,
+    performance_csv: Path,
+    performance_summary_json: Path,
+    visual_annotations_csv: Path,
+    visual_summary_json: Path,
+    report_path: Path,
+    sql_file: str,
+    top_n: int = 5000,
+    annotation_version: str = "top100_rule_v1",
+    annotator: str = "rule_contact_sheet_review",
+    report_version: str = "top100_visual_v1",
+) -> ImportResult:
+    """Import the existing local high-consumption material analysis into DB."""
+    ensure_strategy_learning_tables()
+
+    performance_rows = _read_csv(performance_csv)
+    performance_summary = _read_json(performance_summary_json)
+    annotation_rows = _read_csv(visual_annotations_csv)
+    visual_summary = _read_json(visual_summary_json)
+    image_hash_by_creative = _annotation_hash_map(annotation_rows, Path.cwd())
+
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                INSERT INTO material_performance_snapshot_run (
+                    run_id, window_start, window_end, top_n, source, sql_file,
+                    row_count, total_cost_fen, status, error_message
+                ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
+                ON DUPLICATE KEY UPDATE
+                    window_start=VALUES(window_start),
+                    window_end=VALUES(window_end),
+                    top_n=VALUES(top_n),
+                    source=VALUES(source),
+                    sql_file=VALUES(sql_file),
+                    row_count=VALUES(row_count),
+                    total_cost_fen=VALUES(total_cost_fen),
+                    status=VALUES(status),
+                    error_message=VALUES(error_message),
+                    updated_at=CURRENT_TIMESTAMP
+                """,
+                (
+                    run_id,
+                    window_start,
+                    window_end,
+                    top_n,
+                    "odps",
+                    sql_file,
+                    int(performance_summary.get("rows") or len(performance_rows)),
+                    _yuan_to_fen(performance_summary.get("total_cost_yuan")),
+                    "SUCCESS",
+                    None,
+                ),
+            )
+
+            snapshot_values = []
+            for idx, row in enumerate(performance_rows, start=1):
+                creative_id = _as_required_int(row.get("creative_id"))
+                rank_no = _as_int(row.get("rank")) or idx
+                snapshot_values.append(
+                    (
+                        run_id,
+                        rank_no,
+                        _as_required_int(row.get("account_id")),
+                        _as_required_int(row.get("ad_id")),
+                        creative_id,
+                        row.get("creative_name") or None,
+                        row.get("ad_name") or None,
+                        _as_int(row.get("video_id")),
+                        row.get("title") or None,
+                        row.get("image_url") or None,
+                        image_hash_by_creative.get(creative_id),
+                        row.get("package_name") or None,
+                        row.get("optimization_goal") or None,
+                        _as_int(row.get("bid_amount")),
+                        _as_int(row.get("day_amount")),
+                        _yuan_to_fen(row.get("cost_yuan")),
+                        _as_required_int(row.get("view_count")),
+                        _as_required_int(row.get("valid_click_count")),
+                        _as_decimal(row.get("ctr")),
+                        _as_required_int(row.get("key_page_view_count")),
+                        _as_decimal(row.get("key_page_rate")),
+                        _as_required_int(row.get("conversions_count")),
+                        _as_decimal(row.get("conversion_rate")),
+                        _as_required_int(row.get("active_days")),
+                        row.get("first_dt") or None,
+                        row.get("last_dt") or None,
+                        _json_dumps(row),
+                    )
+                )
+            cur.executemany(
+                """
+                INSERT INTO material_performance_snapshot_item (
+                    run_id, rank_no, account_id, ad_id, creative_id, creative_name, ad_name,
+                    video_id, title, image_url, image_hash, crowd_package, optimization_goal,
+                    bid_amount_fen, day_amount_fen, cost_fen, impressions, clicks, ctr,
+                    key_page_view_count, key_page_rate, conversions_count, conversion_rate,
+                    active_days, first_dt, last_dt, raw_json
+                ) VALUES (
+                    %s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s
+                )
+                ON DUPLICATE KEY UPDATE
+                    rank_no=VALUES(rank_no),
+                    account_id=VALUES(account_id),
+                    ad_id=VALUES(ad_id),
+                    creative_name=VALUES(creative_name),
+                    ad_name=VALUES(ad_name),
+                    video_id=VALUES(video_id),
+                    title=VALUES(title),
+                    image_url=VALUES(image_url),
+                    image_hash=VALUES(image_hash),
+                    crowd_package=VALUES(crowd_package),
+                    optimization_goal=VALUES(optimization_goal),
+                    bid_amount_fen=VALUES(bid_amount_fen),
+                    day_amount_fen=VALUES(day_amount_fen),
+                    cost_fen=VALUES(cost_fen),
+                    impressions=VALUES(impressions),
+                    clicks=VALUES(clicks),
+                    ctr=VALUES(ctr),
+                    key_page_view_count=VALUES(key_page_view_count),
+                    key_page_rate=VALUES(key_page_rate),
+                    conversions_count=VALUES(conversions_count),
+                    conversion_rate=VALUES(conversion_rate),
+                    active_days=VALUES(active_days),
+                    first_dt=VALUES(first_dt),
+                    last_dt=VALUES(last_dt),
+                    raw_json=VALUES(raw_json),
+                    updated_at=CURRENT_TIMESTAMP
+                """,
+                snapshot_values,
+            )
+
+            annotation_values = []
+            for row in annotation_rows:
+                flags = _risk_flags(row)
+                image_hash = _file_or_url_hash(
+                    row.get("image_path") or "",
+                    row.get("image_url") or "",
+                    Path.cwd(),
+                )
+                annotation_values.append(
+                    (
+                        image_hash,
+                        row.get("image_url") or "",
+                        annotation_version,
+                        annotator,
+                        _as_int(row.get("creative_id")),
+                        row.get("visual_template") or None,
+                        row.get("hook_categories") or None,
+                        row.get("title") or None,
+                        len(row.get("title") or ""),
+                        row.get("scene_type") or None,
+                        row.get("main_subject_est") or None,
+                        _bool_int(row.get("has_human_est")),
+                        row.get("text_area_ratio_est") or None,
+                        row.get("dominant_tone") or None,
+                        _bool_int(row.get("button_like_element_est")),
+                        1 if "fake_ui" in flags or "fake_button" in flags else 0,
+                        1 if "policy_money_claim_risk" in flags else 0,
+                        1 if "medical_health_risk" in flags else 0,
+                        1 if "politics_country_sensitive" in flags else 0,
+                        1 if "celebrity_or_history_person" in flags else 0,
+                        1 if "strong_inducement" in flags or "button_like_inducement" in flags else 0,
+                        1 if "greeting_blessing_filtered" in flags else 0,
+                        row.get("risk_level_for_generation") or "caution",
+                        row.get("learnable_points") or None,
+                        row.get("avoid_points") or None,
+                        _json_dumps(row),
+                    )
+                )
+            cur.executemany(
+                """
+                INSERT INTO material_visual_annotation (
+                    image_hash, image_url, annotation_version, annotator, creative_id,
+                    visual_template, hook_category, title_text, title_length, scene_type,
+                    person_type, has_human, text_area_level, color_style,
+                    button_like_element, fake_ui_risk, official_policy_risk,
+                    medical_health_risk, politics_sensitive_risk, celebrity_or_history_risk,
+                    strong_inducement_risk, greeting_blessing_risk, compliance_level,
+                    learnable_points, avoid_points, raw_annotation
+                ) VALUES (
+                    %s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s
+                )
+                ON DUPLICATE KEY UPDATE
+                    image_url=VALUES(image_url),
+                    annotator=VALUES(annotator),
+                    creative_id=VALUES(creative_id),
+                    visual_template=VALUES(visual_template),
+                    hook_category=VALUES(hook_category),
+                    title_text=VALUES(title_text),
+                    title_length=VALUES(title_length),
+                    scene_type=VALUES(scene_type),
+                    person_type=VALUES(person_type),
+                    has_human=VALUES(has_human),
+                    text_area_level=VALUES(text_area_level),
+                    color_style=VALUES(color_style),
+                    button_like_element=VALUES(button_like_element),
+                    fake_ui_risk=VALUES(fake_ui_risk),
+                    official_policy_risk=VALUES(official_policy_risk),
+                    medical_health_risk=VALUES(medical_health_risk),
+                    politics_sensitive_risk=VALUES(politics_sensitive_risk),
+                    celebrity_or_history_risk=VALUES(celebrity_or_history_risk),
+                    strong_inducement_risk=VALUES(strong_inducement_risk),
+                    greeting_blessing_risk=VALUES(greeting_blessing_risk),
+                    compliance_level=VALUES(compliance_level),
+                    learnable_points=VALUES(learnable_points),
+                    avoid_points=VALUES(avoid_points),
+                    raw_annotation=VALUES(raw_annotation),
+                    updated_at=CURRENT_TIMESTAMP
+                """,
+                annotation_values,
+            )
+
+            report_id = f"{run_id}_{report_version}"
+            summary_text = report_path.read_text(encoding="utf-8") if report_path.exists() else ""
+            cur.execute(
+                """
+                INSERT INTO material_strategy_learning_report (
+                    report_id, run_id, report_version, summary, top_patterns,
+                    risk_summary, recommended_actions, report_path
+                ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
+                ON DUPLICATE KEY UPDATE
+                    run_id=VALUES(run_id),
+                    report_version=VALUES(report_version),
+                    summary=VALUES(summary),
+                    top_patterns=VALUES(top_patterns),
+                    risk_summary=VALUES(risk_summary),
+                    recommended_actions=VALUES(recommended_actions),
+                    report_path=VALUES(report_path),
+                    updated_at=CURRENT_TIMESTAMP
+                """,
+                (
+                    report_id,
+                    run_id,
+                    report_version,
+                    summary_text,
+                    _json_dumps(visual_summary.get("visual_template_counts") or {}),
+                    _json_dumps(
+                        {
+                            "risk_level_counts": visual_summary.get("risk_level_counts") or {},
+                            "risk_flag_counts": visual_summary.get("risk_flag_counts") or [],
+                        }
+                    ),
+                    _json_dumps(
+                        [
+                            "学习大字信息差结构,不要复制历史标题",
+                            "避免伪按钮、假界面、强诱导、涉政和医疗恐吓",
+                            "生成素材仍以承接视频ODPS内容特征为主",
+                        ]
+                    ),
+                    str(report_path),
+                ),
+            )
+        conn.commit()
+    finally:
+        conn.close()
+
+    return ImportResult(
+        run_id=run_id,
+        snapshot_rows=len(performance_rows),
+        annotation_rows=len(annotation_rows),
+        report_rows=1,
+    )

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

@@ -263,6 +263,67 @@ ON DUPLICATE KEY UPDATE
         conn.close()
 
 
+def read_cached_video_element_features(
+    video_ids: Iterable[int],
+) -> dict[int, list[VideoElementFeature]]:
+    """Read latest cached feature rows per video id from local DB only."""
+    ids: list[int] = []
+    seen: set[int] = set()
+    for raw in video_ids:
+        try:
+            vid = int(raw)
+        except (TypeError, ValueError):
+            continue
+        if vid in seen:
+            continue
+        seen.add(vid)
+        ids.append(vid)
+    if not ids:
+        return {}
+
+    _ensure_cache_table()
+    from db.connection import get_connection
+
+    out: dict[int, list[VideoElementFeature]] = {}
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            for batch in _chunks(ids, 500):
+                placeholders = ",".join(["%s"] * len(batch))
+                params = [*batch, *batch]
+                cur.execute(
+                    f"""
+SELECT c.video_id, c.dt, c.element_dimension, c.point_type,
+       c.standard_element, c.contribution_score, c.is_miss
+FROM video_element_feature_cache c
+JOIN (
+    SELECT video_id, MAX(dt) AS dt
+    FROM video_element_feature_cache
+    WHERE video_id IN ({placeholders})
+    GROUP BY video_id
+) latest ON latest.video_id = c.video_id AND latest.dt = c.dt
+WHERE c.video_id IN ({placeholders})
+ORDER BY c.video_id, c.contribution_score DESC
+""",
+                    params,
+                )
+                for row in cur.fetchall():
+                    if row.get("is_miss"):
+                        continue
+                    video_id = int(row["video_id"])
+                    out.setdefault(video_id, []).append(VideoElementFeature(
+                        video_id=video_id,
+                        element_dimension=str(row["element_dimension"] or ""),
+                        point_type=str(row["point_type"] or ""),
+                        standard_element=str(row["standard_element"] or ""),
+                        contribution_score=float(row["contribution_score"] or 0.0),
+                        dt=str(row["dt"] or ""),
+                    ))
+    finally:
+        conn.close()
+    return out
+
+
 def fetch_video_element_features(
     video_ids: Iterable[int],
     chunk_size: int = 100,

+ 85 - 5
examples/auto_put_ad_mini/tools/video_recall.py

@@ -47,6 +47,7 @@ PIAOQUANTV_HOT_FALLBACK_ENABLED = os.getenv(
     "PIAOQUANTV_HOT_FALLBACK_ENABLED", "true"
 ).strip().lower() not in {"0", "false", "no", "off"}
 PIAOQUANTV_HOT_FALLBACK_SOURCE = os.getenv("PIAOQUANTV_HOT_FALLBACK_SOURCE", "hot")
+PIAOQUANTV_VIDEO_MAX_PAGES = int(os.getenv("PIAOQUANTV_VIDEO_MAX_PAGES", "3"))
 
 
 def _load_video_crowd_package_map() -> dict[str, str]:
@@ -63,17 +64,54 @@ def _load_video_crowd_package_map() -> dict[str, str]:
                 }
         except json.JSONDecodeError:
             logger.warning("[video_recall] VIDEO_RECALL_CROWD_PACKAGE_MAP 不是合法 JSON,使用默认映射")
-    return {"cell*year*商业": "wx*商业"}
+    return {
+        "cell*year*商业": "wx*商业",
+        "回流330以上人群": "R_330+",
+    }
 
 
 VIDEO_RECALL_CROWD_PACKAGE_MAP = _load_video_crowd_package_map()
 
 
+def _load_video_source_map() -> dict[str, str]:
+    raw = os.getenv("VIDEO_RECALL_SOURCE_MAP", "").strip()
+    if raw:
+        try:
+            data = json.loads(raw)
+            if isinstance(data, dict):
+                return {
+                    str(k).strip(): str(v).strip()
+                    for k, v in data.items()
+                    if str(k).strip()
+                }
+        except json.JSONDecodeError:
+            logger.warning("[video_recall] VIDEO_RECALL_SOURCE_MAP 不是合法 JSON,使用默认映射")
+    return {}
+
+
+VIDEO_RECALL_SOURCE_MAP = _load_video_source_map()
+
+
 def map_crowd_package_for_video_recall(crowd_package: str) -> str:
     """只映射内容服务 videoContentList 的 crowdPackage,不影响腾讯投放人群包。"""
     return VIDEO_RECALL_CROWD_PACKAGE_MAP.get(crowd_package, crowd_package)
 
 
+def map_source_for_video_recall(
+    crowd_package: str,
+    video_crowd_package: str,
+    source: str,
+) -> str:
+    """只映射内容服务 videoContentList 的 source,不影响 hot 兜底语义。"""
+    if source == PIAOQUANTV_HOT_FALLBACK_SOURCE:
+        return source
+    if crowd_package in VIDEO_RECALL_SOURCE_MAP:
+        return VIDEO_RECALL_SOURCE_MAP[crowd_package]
+    if video_crowd_package in VIDEO_RECALL_SOURCE_MAP:
+        return VIDEO_RECALL_SOURCE_MAP[video_crowd_package]
+    return source
+
+
 def _normalize_category(raw) -> str:
     """把 category 字段规范化成字符串(list 用逗号拼接,None 返回空串)。"""
     if raw is None:
@@ -191,7 +229,8 @@ def fetch_landing_videos(
     }
 
     logger.info(
-        "[video_recall] fetch crowd=%r page=%d size=%d", crowd_package, page_num, page_size,
+        "[video_recall] fetch crowd=%r source=%r page=%d size=%d",
+        crowd_package, source, page_num, page_size,
     )
     resp = httpx.post(PIAOQUANTV_VIDEO_API, json=body, headers=headers, timeout=timeout)
     resp.raise_for_status()
@@ -243,6 +282,39 @@ def fetch_landing_videos(
     return videos
 
 
+def _fetch_landing_video_pages(
+    *,
+    crowd_package: str,
+    page_size: int,
+    source: str,
+    max_pages: int,
+) -> List[LandingVideo]:
+    max_pages = max(1, int(max_pages or 1))
+    merged: List[LandingVideo] = []
+    seen: set[int] = set()
+    for page_num in range(1, max_pages + 1):
+        page = fetch_landing_videos(
+            crowd_package=crowd_package,
+            page_size=page_size,
+            page_num=page_num,
+            source=source,
+        )
+        if not page:
+            break
+        for video in page:
+            if video.video_id in seen:
+                continue
+            merged.append(video)
+            seen.add(video.video_id)
+        if len(page) < page_size:
+            break
+    logger.info(
+        "[video_recall] 分页合并 crowd=%r source=%r pages<=%d merged=%d",
+        crowd_package, source, max_pages, len(merged),
+    )
+    return merged
+
+
 def get_account_crowd_package(account_id: int) -> str:
     """从 account_whitelist 读账户级 crowd_package。
 
@@ -275,6 +347,7 @@ def fetch_landing_videos_for_account(
     page_size: int = 10,
     source: Optional[str] = None,
     enable_hot_fallback: bool = True,
+    max_pages: int = PIAOQUANTV_VIDEO_MAX_PAGES,
 ) -> List[LandingVideo]:
     """根据账户的 crowd_package 字段拉视频。
 
@@ -282,11 +355,17 @@ def fetch_landing_videos_for_account(
     """
     crowd_package = get_account_crowd_package(account_id)
     video_crowd_package = map_crowd_package_for_video_recall(crowd_package)
-    selected_source = PIAOQUANTV_VIDEO_SOURCE if source is None else source
-    primary = fetch_landing_videos(
+    requested_source = PIAOQUANTV_VIDEO_SOURCE if source is None else source
+    selected_source = map_source_for_video_recall(
+        crowd_package,
+        video_crowd_package,
+        requested_source,
+    )
+    primary = _fetch_landing_video_pages(
         crowd_package=video_crowd_package,
         page_size=page_size,
         source=selected_source,
+        max_pages=max_pages,
     )
     if (
         source is not None
@@ -302,10 +381,11 @@ def fetch_landing_videos_for_account(
         "[video_recall] primary 不足,用 hot 兜底: account=%d crowd=%r video_crowd=%r primary=%d need=%d",
         account_id, crowd_package, video_crowd_package, len(primary), missing,
     )
-    fallback = fetch_landing_videos(
+    fallback = _fetch_landing_video_pages(
         crowd_package=video_crowd_package,
         page_size=missing,
         source=PIAOQUANTV_HOT_FALLBACK_SOURCE,
+        max_pages=max_pages,
     )
     seen = {v.video_id for v in primary}
     merged = list(primary)

+ 38 - 0
examples/tencent_realtime_control/.env.example

@@ -0,0 +1,38 @@
+RTC_START_HOUR=12
+RTC_STOP_HOUR=21
+RTC_POLL_SECONDS=600
+RTC_HIGH_CPM=250
+RTC_LOW_CPM=190
+RTC_BID_UP_RATIO=1.25
+RTC_INVENTORY_REFRESH_MINUTES=60
+RTC_MAX_PARTITION_LAG_HOURS=2
+RTC_DB_LOCK_NAME=tencent_realtime_control
+RTC_FEISHU_NOTIFY_ENABLED=1
+RTC_FEISHU_CHAT_ID=
+RTC_FEISHU_TIMEOUT_SECONDS=30
+RTC_VERIFY_ATTEMPTS=3
+RTC_VERIFY_DELAY_SECONDS=1
+RTC_APPLY_ENABLED=0
+RTC_COMMAND_ENABLED=1
+RTC_COMMAND_CHAT_ID=
+RTC_COMMAND_ALLOWED_OPEN_IDS=
+RTC_COMMAND_CONFIRM_TTL_MINUTES=10
+RTC_COMMAND_WORKERS=2
+
+ODPS_ACCESS_ID=
+ODPS_ACCESS_SECRET=
+ODPS_PROJECT=loghubods
+ODPS_ENDPOINT=http://service.odps.aliyun.com/api
+DB_HOST=
+DB_PORT=3306
+DB_USER=
+DB_PASSWORD=
+DB_NAME=
+
+TENCENT_AD_BASE_URL=https://api.e.qq.com/v3.0
+TENCENT_AD_TOKEN_API=https://api.piaoquantv.com/ad/put/tencent/getAccessToken
+TENCENT_AD_USER_TOKEN_API=https://api.piaoquantv.com/ad/put/tencent/getUserToken
+
+FEISHU_APP_ID=
+FEISHU_APP_SECRET=
+FEISHU_AD_PROJECT_CHAT_ID=

+ 4 - 0
examples/tencent_realtime_control/.gitignore

@@ -0,0 +1,4 @@
+__pycache__/
+outputs/*.csv
+outputs/*.json
+outputs/*.xlsx

+ 203 - 0
examples/tencent_realtime_control/README.md

@@ -0,0 +1,203 @@
+# 腾讯广告实时调控
+
+独立管理实时投放指标采集、状态判断和腾讯广告调控,不依赖广告创建或历史调价分析流程。
+
+## 数据和范围
+
+- 从 `loghubods.advertiser_data_da_hour` 读取当天整体小时数据。
+- CPM 直接使用 `真实cpm_总`。
+- 固定过滤名称、广告主、公司、行业、客户和落地页类型均为 `SUM`。
+- 实时管理账户取历史 `ad_creation_account_config` 与
+  `account_whitelist.enabled=1` 的交集;关闭创建配置只停止新建和补创意,
+  不会让既有广告退出实时管理。
+- 普通出价使用 `bid_amount`,最大转化量使用 `custom_cost_cap`。
+- 每条广告首次纳管时将腾讯当前实际出价持久化为基础出价。
+
+## 调控规则
+
+北京时间默认每天 `12:00-21:00` 每 10 分钟检查一次。广告自身投放时段
+仍由 `time_series` 管理,在实时调控开始前按基础出价正常投放:
+
+- `CPM > 250`:基础出价上调 25%。
+- `190 <= CPM <= 250`:恢复基础出价并恢复策略暂停的广告。
+- `CPM < 190`:恢复基础出价,将广告开始日期延后至次日;保持广告开启,次日
+  进入 `time_series` 投放时段后由腾讯自动投放。
+- 同一广告每天最多上调一次;恢复基础价后,当天也不会再次上调。
+- 到达 `RTC_STOP_HOUR`:恢复基础出价,不主动暂停广告。广告由腾讯的
+  `time_series` 在非投放时段自动停止配量,次日进入投放时段后自动投放。
+- 截止时会恢复由低 CPM 策略暂停的广告,但不打开人工暂停广告。
+
+每个真实发生调价、延后投放或状态修改的窗口会生成一张飞书在线表格
+并发送到调控群。
+群消息包含触发时间、数据分区、当前 CPM、决策、影响账户/广告数、动作分布和
+受影响广告当日累计消耗。表格包含日期、数据分区、当前 CPM、账户、广告 ID、
+广告名称、人群包、广告当日累计消耗/曝光/点击、基础出价、调整前出价、执行后
+当前出价和动作。
+同一窗口的所有成功动作合并在一张表中;无动作、dry-run 和已达到当日上调
+上限的广告不发送通知。飞书发送失败不会回滚已经成功的腾讯操作,本地 xlsx
+仍保存在 `outputs/`,错误会写入本轮 JSON 报告和进程日志。
+
+腾讯写接口返回 `code=0` 后不会立即判定成功。流程会按广告 ID 重新读取腾讯
+广告并核对本次修改的出价和状态字段,默认最多校验 3 次、间隔 1 秒。只有读回
+值与目标一致才更新成功状态并进入成功通知;持续不一致按失败写入动作审计,
+飞书卡片标红并在表格中展示错误。
+
+ODPS 小时分区可能延迟。当天尚未出现 `06` 点及之后的分区时只等待,不使用
+夜间低流量分区做关停判断。最新分区相对当前时间最多允许延迟 2 小时,超过后
+本轮不执行基于 CPM 的调价、恢复或关停;每天 06:00 的定时恢复不依赖 CPM。
+
+状态保存在 MySQL,使用数据库锁避免多 Pod 同时执行。相同分区和相同决策默认
+不重复扫描腾讯;每 60 分钟至少刷新一次广告清单以纳管新广告。
+
+## 初始化
+
+```bash
+.venv/bin/python examples/tencent_realtime_control/init_db.py
+```
+
+## 数据检查
+
+读取上海时区当天分时 CPM:
+
+```bash
+.venv/bin/python examples/tencent_realtime_control/fetch_daily_hourly_cpm.py
+```
+
+指定日期:
+
+```bash
+.venv/bin/python examples/tencent_realtime_control/fetch_daily_hourly_cpm.py \
+  --date 20260724
+```
+
+## 单次执行
+
+默认 dry-run,不修改腾讯:
+
+```bash
+.venv/bin/python examples/tencent_realtime_control/run_once.py
+```
+
+验证 CPM 分支:
+
+```bash
+.venv/bin/python examples/tencent_realtime_control/run_once.py \
+  --at 2026-07-24T15:00:00 \
+  --cpm-override 260
+```
+
+使用真实账户、广告、出价和当日指标模拟完整通知链路,不修改腾讯广告:
+
+```bash
+.venv/bin/python examples/tencent_realtime_control/run_once.py \
+  --at 2026-07-24T15:00:00 \
+  --cpm-override 260 \
+  --test-notification
+```
+
+模拟卡片、动作列和表格标题都会标记“模拟”。`--test-notification` 不能与
+`--apply` 同时使用。
+
+真实执行:
+
+```bash
+.venv/bin/python examples/tencent_realtime_control/run_once.py --apply
+```
+
+## 一次性延后生效日期
+
+需要让全部自动化广告在指定日期才开始投放时,先 dry-run:
+
+```bash
+.venv/bin/python \
+  examples/tencent_realtime_control/schedule_automated_ads_from_date.py \
+  --begin-date 2026-07-26
+```
+
+确认账户数、广告数、待更新数和待恢复数后真实执行:
+
+```bash
+.venv/bin/python \
+  examples/tencent_realtime_control/schedule_automated_ads_from_date.py \
+  --begin-date 2026-07-26 \
+  --apply
+```
+
+脚本只处理当前启用的自动化账户。它保留广告原有 `end_date` 和
+`time_series`,逐账户更新 `begin_date` 并回读校验;日期更新成功后只恢复
+由实时策略暂停的广告,不会打开人工暂停广告。默认 dry-run,必须显式传
+`--apply` 才会修改腾讯广告。
+
+## Docker 调度
+
+统一控制服务在投放时段按 10 分钟边界执行,20 点收尾后休眠到次日 6 点:
+
+```bash
+RTC_APPLY_ENABLED=0 \
+  .venv/bin/python \
+  examples/tencent_realtime_control/run_control_service.py
+```
+
+默认所有执行入口均为 dry-run,必须设置 `RTC_APPLY_ENABLED=1` 或显式传
+`--apply` 才会调用腾讯写接口。
+`--at` 和 `--cpm-override` 只允许 dry-run。
+
+生产使用统一完整镜像:
+
+```bash
+docker build \
+  -f Dockerfile.auto_put_ad_mini \
+  -t ad-put-agent:local .
+```
+
+Compose 从同一镜像启动 `ad-control-service` 和 `ad-daily-service`。完整部署和
+dry-run 切换步骤见
+`examples/auto_put_ad_mini/docs/unified_services_deployment.md`。
+生产 Compose 文件兼容当前 ECS 的 Docker Compose `2.27.0`。
+
+## ECS 首次迁移 ODPS 配置
+
+仅用于旧 `auto_put_ad_mini` 容器仍在运行、但 ECS 的
+`/home/server/.env` 缺少 ODPS 环境变量时。命令不会在终端打印密钥值。
+
+先从旧容器迁移配置:
+
+```bash
+cd /home/server
+
+sed -i \
+  '/^ODPS_ACCESS_ID=/d;/^ODPS_ACCESS_SECRET=/d;/^ODPS_PROJECT=/d' \
+  /home/server/.env
+
+docker exec auto_put_ad_mini python -c 'import ast,shlex; tree=ast.parse(open("/app/examples/auto_put_ad_mini/tools/odps_module.py",encoding="utf-8").read()); values={target.attr:node.value.value for node in ast.walk(tree) if isinstance(node,ast.Assign) and isinstance(node.value,ast.Constant) for target in node.targets if isinstance(target,ast.Attribute)}; print("ODPS_ACCESS_ID="+shlex.quote(values["accessId"])); print("ODPS_ACCESS_SECRET="+shlex.quote(values["accessSecret"])); print("ODPS_PROJECT=loghubods")' >> /home/server/.env
+
+chmod 600 /home/server/.env
+```
+
+确认变量名已经写入,不显示变量值:
+
+```bash
+grep -E '^ODPS_(ACCESS_ID|ACCESS_SECRET|PROJECT)=' /home/server/.env \
+  | cut -d= -f1
+```
+
+验证 ODPS 查询:
+
+```bash
+cd /home/server
+VERSION=latest docker compose --env-file /dev/null run --rm \
+  ad_control_service \
+  python /app/examples/tencent_realtime_control/fetch_daily_hourly_cpm.py \
+  --date 20260724
+```
+
+这只是旧配置迁移手段。迁移完成后应轮换已经硬编码在旧代码和 Git 历史中的
+AccessKey,并将新值只保存在受权限保护的生产环境配置中。
+
+## 环境变量
+
+参考 `.env.example`。部署必须提供 ODPS、MySQL、腾讯 token 服务和飞书应用
+配置,不能在代码中写入凭证。通知群优先读取 `RTC_FEISHU_CHAT_ID`,为空时
+复用广告调控流程的 `FEISHU_AD_PROJECT_CHAT_ID`;不会回退发送到个人或运营
+审批群。`RTC_FEISHU_NOTIFY_ENABLED=0` 可临时关闭
+通知。

+ 1 - 0
examples/tencent_realtime_control/__init__.py

@@ -0,0 +1 @@
+"""Tencent advertising real-time control module."""

+ 201 - 0
examples/tencent_realtime_control/feishu_command_service.py

@@ -0,0 +1,201 @@
+"""Feishu WebSocket entry for deterministic advertising control commands."""
+
+from __future__ import annotations
+
+import logging
+import os
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from agent.tools.builtin.feishu.feishu_client import (
+    ChatType,
+    FeishuClient,
+    FeishuMessageEvent,
+)
+
+from operator_commands import (
+    ACTION_CANCEL,
+    ACTION_CONFIRM,
+    ACTION_DAY_PAUSE,
+    ACTION_RESUME,
+    ACTION_STATUS,
+    ACTION_STOP,
+    parse_command,
+)
+from operator_control import (
+    cancel_command,
+    execute_confirmed_command,
+    pause_status_summary,
+    preview_write_command,
+)
+from realtime_config import RealtimeControlConfig
+
+
+SHANGHAI = ZoneInfo("Asia/Shanghai")
+logger = logging.getLogger("tencent_realtime_control.feishu_commands")
+
+_ACTION_LABELS = {
+    ACTION_DAY_PAUSE: "仅暂停今天",
+    ACTION_STOP: "持续停止",
+    ACTION_RESUME: "恢复投放",
+}
+
+
+def _split_ids(raw: str) -> set[str]:
+    normalized = str(raw or "").replace(",", ",").replace(" ", ",")
+    return {value.strip() for value in normalized.split(",") if value.strip()}
+
+
+class FeishuCommandService:
+    def __init__(self, *, apply: bool) -> None:
+        app_id = os.getenv("FEISHU_APP_ID", "").strip()
+        app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
+        if not app_id or not app_secret:
+            raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
+        self.allowed_chat_id = (
+            os.getenv("RTC_COMMAND_CHAT_ID", "").strip()
+            or os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "").strip()
+        )
+        self.allowed_open_ids = _split_ids(
+            os.getenv("RTC_COMMAND_ALLOWED_OPEN_IDS", "")
+            or os.getenv("FEISHU_OPERATOR_OPEN_ID", "")
+        )
+        if not self.allowed_chat_id:
+            raise RuntimeError(
+                "RTC_COMMAND_CHAT_ID/FEISHU_AD_PROJECT_CHAT_ID is required"
+            )
+        if not self.allowed_open_ids:
+            raise RuntimeError(
+                "RTC_COMMAND_ALLOWED_OPEN_IDS/FEISHU_OPERATOR_OPEN_ID is required"
+            )
+        self.apply = apply
+        self.config = RealtimeControlConfig.from_env()
+        self.confirmation_ttl_minutes = int(
+            os.getenv("RTC_COMMAND_CONFIRM_TTL_MINUTES", "10")
+        )
+        if self.confirmation_ttl_minutes < 1:
+            raise ValueError("RTC_COMMAND_CONFIRM_TTL_MINUTES must be positive")
+        self.client = FeishuClient(app_id=app_id, app_secret=app_secret)
+        self.executor = ThreadPoolExecutor(
+            max_workers=int(os.getenv("RTC_COMMAND_WORKERS", "2")),
+            thread_name_prefix="feishu-ad-command",
+        )
+
+    def start(self) -> Any:
+        return self.client.start_websocket(
+            on_message=self._enqueue_message,
+            blocking=False,
+        )
+
+    def _enqueue_message(self, event: FeishuMessageEvent) -> None:
+        self.executor.submit(self.handle_message, event)
+
+    def _reply(self, event: FeishuMessageEvent, text: str) -> None:
+        self.client.send_message(
+            to=event.chat_id,
+            text=text,
+            reply_to_message_id=event.message_id,
+        )
+
+    def _authorized(self, event: FeishuMessageEvent) -> bool:
+        if event.sender_open_id not in self.allowed_open_ids:
+            return False
+        if event.chat_type == ChatType.GROUP:
+            return (
+                event.chat_id == self.allowed_chat_id
+                and event.mentioned_bot
+            )
+        return event.chat_type == ChatType.P2P
+
+    def handle_message(self, event: FeishuMessageEvent) -> None:
+        if event.content_type not in {"text", "post"}:
+            return
+        if not self._authorized(event):
+            return
+        try:
+            parsed = parse_command(event.content)
+            if parsed is None:
+                return
+            now = datetime.now(SHANGHAI)
+            if parsed.action == ACTION_STATUS:
+                summary = pause_status_summary()
+                accounts = ",".join(str(value) for value in summary["accounts"]) or "无"
+                self._reply(
+                    event,
+                    "当前运营暂停状态\n"
+                    f"- 暂停广告:{summary['total']} 条\n"
+                    f"- 仅暂停今天:{summary['until_next_delivery']} 条\n"
+                    f"- 持续停止:{summary['until_manual']} 条\n"
+                    f"- 涉及账户:{accounts}",
+                )
+                return
+            if parsed.action == ACTION_CANCEL:
+                command = cancel_command(
+                    parsed.command_id or "",
+                    event.sender_open_id,
+                    now,
+                )
+                message = (
+                    f"命令 {command['command_id']} 已取消"
+                    if command["status"] == "CANCELLED"
+                    else (
+                        f"命令 {command['command_id']} 未取消,"
+                        f"当前状态:{command['status']}"
+                    )
+                )
+                self._reply(
+                    event,
+                    message,
+                )
+                return
+            if parsed.action == ACTION_CONFIRM:
+                if not self.apply:
+                    self._reply(event, "当前服务为 dry-run,禁止执行腾讯写操作。")
+                    return
+                command = execute_confirmed_command(
+                    parsed.command_id or "",
+                    sender_open_id=event.sender_open_id,
+                    now=now,
+                    start_hour=self.config.start_hour,
+                    lock_name=self.config.lock_name,
+                )
+                self._reply(
+                    event,
+                    f"命令 {command['command_id']} 执行完成\n"
+                    f"- 状态:{command['status']}\n"
+                    f"- 成功:{command.get('successes', 0)} 条\n"
+                    f"- 失败:{command.get('failures', 0)} 条",
+                )
+                return
+
+            command = preview_write_command(
+                parsed,
+                now=now,
+                source_message_id=event.message_id,
+                chat_id=event.chat_id,
+                sender_open_id=event.sender_open_id,
+                sender_name=event.sender_name,
+                confirmation_ttl_minutes=self.confirmation_ttl_minutes,
+                start_hour=self.config.start_hour,
+            )
+            resume_text = (
+                command["resume_at"].strftime("%Y-%m-%d %H:%M")
+                if command.get("resume_at")
+                else "仅人工明确恢复"
+            )
+            self._reply(
+                event,
+                f"操作预览:{_ACTION_LABELS[command['action']]}\n"
+                f"- 账户:{command['preview_account_count']} 个\n"
+                f"- 预计涉及广告:{command['preview_ad_count']} 条\n"
+                f"- 恢复时间:{resume_text}\n"
+                f"- 命令ID:{command['command_id']}\n"
+                f"请在 {self.confirmation_ttl_minutes} 分钟内回复:"
+                f"确认 {command['command_id']}\n"
+                f"取消命令请回复:取消 {command['command_id']}",
+            )
+        except Exception as exc:
+            logger.exception("Feishu command failed")
+            self._reply(event, f"命令处理失败:{exc}")

+ 607 - 0
examples/tencent_realtime_control/feishu_notifier.py

@@ -0,0 +1,607 @@
+"""Send successful real-time control actions as a Feishu spreadsheet."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import time
+from datetime import datetime
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+import requests
+from openpyxl import Workbook
+from openpyxl.styles import Alignment, Font, PatternFill
+
+
+ROOT = Path(__file__).resolve().parent
+FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
+HEADERS = [
+    "日期",
+    "数据分区",
+    "当前CPM",
+    "账户ID",
+    "广告ID",
+    "广告名称",
+    "人群包",
+    "今日消耗(元)",
+    "今日曝光",
+    "今日点击",
+    "基础出价(元)",
+    "调整前出价(元)",
+    "当前出价(元)",
+    "调整前开始日期",
+    "调整后开始日期",
+    "动作",
+    "执行结果",
+    "错误信息",
+]
+
+logger = logging.getLogger("tencent_realtime_control.feishu")
+
+
+def _yuan(fen: int) -> float:
+    return float((Decimal(fen) / Decimal("100")).quantize(Decimal("0.00")))
+
+
+def _action_text(result: dict[str, Any], bid_up_ratio: Decimal) -> str:
+    decision = result["decision"]
+    bid_changed = bool(result.get("bid_change_needed"))
+    status_changed = bool(result.get("status_change_needed"))
+
+    if decision == "BOOST":
+        percent = int((bid_up_ratio - Decimal("1")) * Decimal("100"))
+        return f"出价上调{percent}%"
+    if decision == "RESTORE":
+        if bid_changed and status_changed:
+            return "恢复基础出价并开启投放"
+        if bid_changed:
+            return "恢复基础出价"
+        return "恢复投放"
+    if decision == "PAUSE":
+        target_date = str(result.get("target_begin_date") or "次日")
+        return (
+            f"恢复基础出价并延后至{target_date}(CPM过低)"
+            if bid_changed
+            else f"延后至{target_date}(CPM过低)"
+        )
+    if decision == "CUTOFF":
+        return "恢复基础出价并关停(投放截止)" if bid_changed else "关停(投放截止)"
+    return decision
+
+
+def notification_rows(
+    *,
+    now: datetime,
+    results: list[dict[str, Any]],
+    bid_up_ratio: Decimal,
+    observed_partition: str | None,
+    observed_cpm: Decimal | None,
+    test_mode: bool = False,
+) -> list[list[Any]]:
+    rows: list[list[Any]] = []
+    for result in results:
+        expected_statuses = {"planned"} if test_mode else {"success", "failed"}
+        if result.get("status") not in expected_statuses:
+            continue
+
+        is_bid_change = bool(result.get("bid_change_needed"))
+        is_begin_date_change = bool(result.get("begin_date_change_needed"))
+        is_pause = (
+            result.get("decision") in {"PAUSE", "CUTOFF"}
+            and bool(result.get("status_change_needed"))
+        )
+        if not is_bid_change and not is_begin_date_change and not is_pause:
+            continue
+
+        rows.append(
+            [
+                now.strftime("%Y-%m-%d %H:%M:%S"),
+                observed_partition or "",
+                float(observed_cpm) if observed_cpm is not None else "",
+                str(result["account_id"]),
+                str(result["adgroup_id"]),
+                str(result.get("adgroup_name") or ""),
+                str(result.get("audience_name") or ""),
+                _yuan(int(result.get("today_cost_fen") or 0)),
+                int(result.get("today_impressions") or 0),
+                int(result.get("today_clicks") or 0),
+                _yuan(int(result["base_bid_fen"])),
+                _yuan(int(result["before_bid_fen"])),
+                (
+                    _yuan(int(result["target_bid_fen"]))
+                    if result.get("status") != "failed"
+                    else ""
+                ),
+                str(result.get("before_begin_date") or ""),
+                str(result.get("target_begin_date") or ""),
+                (
+                    f"【模拟】{_action_text(result, bid_up_ratio)}"
+                    if test_mode
+                    else _action_text(result, bid_up_ratio)
+                ),
+                (
+                    "模拟"
+                    if test_mode
+                    else "成功"
+                    if result.get("status") == "success"
+                    else "失败"
+                ),
+                str(result.get("error") or ""),
+            ]
+        )
+    return rows
+
+
+def create_notification_xlsx(
+    *,
+    run_id: str,
+    now: datetime,
+    rows: list[list[Any]],
+    test_mode: bool = False,
+) -> Path:
+    output_dir = ROOT / "outputs"
+    output_dir.mkdir(parents=True, exist_ok=True)
+    mode = "test" if test_mode else "apply"
+    path = output_dir / (
+        f"realtime_actions_{mode}_{now.strftime('%Y%m%d_%H%M%S')}_"
+        f"{run_id[:8]}.xlsx"
+    )
+
+    workbook = Workbook()
+    sheet = workbook.active
+    sheet.title = "实时调控动作_模拟" if test_mode else "实时调控动作"
+    sheet.append(HEADERS)
+    for row in rows:
+        sheet.append(row)
+
+    header_fill = PatternFill("solid", fgColor="1F4E78")
+    for cell in sheet[1]:
+        cell.fill = header_fill
+        cell.font = Font(color="FFFFFF", bold=True)
+        cell.alignment = Alignment(horizontal="center", vertical="center")
+
+    widths = [
+        21, 18, 12, 14, 18, 30, 22, 16, 14, 14, 16, 16, 16, 18, 18, 38, 12, 55
+    ]
+    for index, width in enumerate(widths, start=1):
+        sheet.column_dimensions[chr(64 + index)].width = width
+    for row in sheet.iter_rows(min_row=2):
+        for cell in row:
+            cell.alignment = Alignment(horizontal="center", vertical="center")
+    for column in ("C", "H", "K", "L", "M"):
+        for cell in sheet[column][1:]:
+            cell.number_format = "0.00"
+    sheet.freeze_panes = "A2"
+    sheet.auto_filter.ref = sheet.dimensions
+    workbook.save(path)
+    return path
+
+
+class FeishuNotifier:
+    def __init__(self) -> None:
+        self.app_id = os.getenv("FEISHU_APP_ID", "").strip()
+        self.app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
+        self.chat_id = (
+            os.getenv("RTC_FEISHU_CHAT_ID", "").strip()
+            or os.getenv("FEISHU_AD_PROJECT_CHAT_ID", "").strip()
+        )
+        self.timeout = int(os.getenv("RTC_FEISHU_TIMEOUT_SECONDS", "30"))
+        self.session = requests.Session()
+
+    def _require_config(self) -> None:
+        missing = [
+            name
+            for name, value in (
+                ("FEISHU_APP_ID", self.app_id),
+                ("FEISHU_APP_SECRET", self.app_secret),
+                ("RTC_FEISHU_CHAT_ID/FEISHU_AD_PROJECT_CHAT_ID", self.chat_id),
+            )
+            if not value
+        ]
+        if missing:
+            raise RuntimeError(
+                f"Missing Feishu notification configuration: {', '.join(missing)}"
+            )
+
+    def _tenant_token(self) -> str:
+        response = self.session.post(
+            f"{FEISHU_BASE_URL}/auth/v3/tenant_access_token/internal",
+            json={"app_id": self.app_id, "app_secret": self.app_secret},
+            timeout=self.timeout,
+        )
+        response.raise_for_status()
+        payload = response.json()
+        if payload.get("code") != 0:
+            raise RuntimeError(f"Feishu token failed: {payload}")
+        return str(payload["tenant_access_token"])
+
+    @staticmethod
+    def _headers(token: str) -> dict[str, str]:
+        return {"Authorization": f"Bearer {token}"}
+
+    def _upload(self, token: str, path: Path) -> str:
+        with path.open("rb") as file:
+            response = self.session.post(
+                f"{FEISHU_BASE_URL}/drive/v1/medias/upload_all",
+                headers=self._headers(token),
+                data={
+                    "file_name": path.name,
+                    "parent_type": "explorer",
+                    "parent_node": "",
+                    "size": str(path.stat().st_size),
+                },
+                files={
+                    "file": (
+                        path.name,
+                        file,
+                        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+                    )
+                },
+                timeout=max(self.timeout, 60),
+            )
+        response.raise_for_status()
+        payload = response.json()
+        if payload.get("code") != 0:
+            raise RuntimeError(f"Feishu upload failed: {payload}")
+        return str(payload["data"]["file_token"])
+
+    def _import_sheet(self, token: str, file_token: str, path: Path) -> dict[str, Any]:
+        response = self.session.post(
+            f"{FEISHU_BASE_URL}/drive/v1/import_tasks",
+            headers={**self._headers(token), "Content-Type": "application/json"},
+            json={
+                "file_extension": "xlsx",
+                "file_token": file_token,
+                "type": "sheet",
+                "file_name": path.stem,
+                "point": {"mount_type": 1, "mount_key": ""},
+            },
+            timeout=self.timeout,
+        )
+        response.raise_for_status()
+        payload = response.json()
+        if payload.get("code") != 0:
+            raise RuntimeError(f"Feishu import task failed: {payload}")
+        ticket = str(payload["data"]["ticket"])
+
+        deadline = time.monotonic() + 60
+        while time.monotonic() < deadline:
+            response = self.session.get(
+                f"{FEISHU_BASE_URL}/drive/v1/import_tasks/{ticket}",
+                headers=self._headers(token),
+                timeout=self.timeout,
+            )
+            response.raise_for_status()
+            payload = response.json()
+            if payload.get("code") != 0:
+                raise RuntimeError(f"Feishu import query failed: {payload}")
+            result = (payload.get("data") or {}).get("result") or {}
+            if result.get("job_status") == 0:
+                return result
+            if result.get("job_status") == 3:
+                raise RuntimeError(
+                    f"Feishu import failed: {result.get('job_error_msg')}"
+                )
+            time.sleep(2)
+        raise RuntimeError("Feishu spreadsheet import timed out after 60 seconds")
+
+    def _set_read_permission(
+        self, token: str, sheet_token: str, file_type: str
+    ) -> None:
+        response = self.session.patch(
+            f"{FEISHU_BASE_URL}/drive/v1/permissions/{sheet_token}/public",
+            headers={**self._headers(token), "Content-Type": "application/json"},
+            params={"type": file_type},
+            json={
+                "external_access_entity": "open",
+                "link_share_entity": "anyone_readable",
+            },
+            timeout=self.timeout,
+        )
+        response.raise_for_status()
+        payload = response.json()
+        if payload.get("code") != 0:
+            logger.warning("Feishu permission update failed: %s", payload)
+
+    def _send_card(
+        self,
+        token: str,
+        *,
+        url: str,
+        now: datetime,
+        summary: dict[str, Any],
+        test_mode: bool,
+    ) -> None:
+        action_counts = summary["action_counts"]
+        action_text = ",".join(
+            f"{name} {count} 条"
+            for name, count in action_counts.items()
+            if count
+        )
+        cpm_text = (
+            f"{summary['observed_cpm']:.2f}"
+            if summary.get("observed_cpm") is not None
+            else "无(定时收尾)"
+        )
+        summary_lines = [
+            f"**触发时间:** {now.strftime('%Y-%m-%d %H:%M:%S')}",
+        ]
+        if test_mode:
+            summary_lines.append("**执行模式:** 仅模拟,未修改腾讯广告")
+        summary_lines.extend(
+            [
+                f"**数据分区:** {summary.get('observed_partition') or '无'}",
+                f"**当前 CPM:** {cpm_text}",
+                f"**本轮决策:** {summary['decision']}",
+                (
+                    f"**影响范围:** {summary['account_count']} 个账户,"
+                    f"{summary['row_count']} 条广告"
+                ),
+                f"**动作概要:** {action_text}",
+                (
+                    "**受影响广告今日消耗:** "
+                    f"{summary['today_cost_yuan']:.2f} 元"
+                ),
+                f"**执行结果:** 成功 {summary['success_count']} 条,失败 "
+                f"{summary['failure_count']} 条",
+            ]
+        )
+        card = {
+            "config": {"wide_screen_mode": True},
+            "header": {
+                "title": {
+                    "tag": "plain_text",
+                    "content": (
+                        "腾讯广告实时调控通知(模拟)"
+                        if test_mode
+                        else "腾讯广告实时调控通知"
+                    ),
+                },
+                "template": (
+                    "orange"
+                    if test_mode
+                    else "red"
+                    if summary["failure_count"]
+                    else "blue"
+                ),
+            },
+            "elements": [
+                {
+                    "tag": "div",
+                    "text": {
+                        "tag": "lark_md",
+                        "content": "\n".join(summary_lines),
+                    },
+                },
+                {
+                    "tag": "action",
+                    "actions": [
+                        {
+                            "tag": "button",
+                            "text": {"tag": "plain_text", "content": "打开动作明细"},
+                            "type": "primary",
+                            "url": url,
+                        }
+                    ],
+                },
+            ],
+        }
+        response = self.session.post(
+            f"{FEISHU_BASE_URL}/im/v1/messages",
+            headers={**self._headers(token), "Content-Type": "application/json"},
+            params={"receive_id_type": "chat_id"},
+            json={
+                "receive_id": self.chat_id,
+                "msg_type": "interactive",
+                "content": json.dumps(card, ensure_ascii=False),
+            },
+            timeout=self.timeout,
+        )
+        response.raise_for_status()
+        payload = response.json()
+        if payload.get("code") != 0:
+            raise RuntimeError(f"Feishu message failed: {payload}")
+
+    def send_text(self, text: str) -> None:
+        self._require_config()
+        token = self._tenant_token()
+        response = self.session.post(
+            f"{FEISHU_BASE_URL}/im/v1/messages",
+            headers={**self._headers(token), "Content-Type": "application/json"},
+            params={"receive_id_type": "chat_id"},
+            json={
+                "receive_id": self.chat_id,
+                "msg_type": "text",
+                "content": json.dumps({"text": text}, ensure_ascii=False),
+            },
+            timeout=self.timeout,
+        )
+        response.raise_for_status()
+        payload = response.json()
+        if payload.get("code") != 0:
+            raise RuntimeError(f"Feishu text message failed: {payload}")
+
+    def send(
+        self,
+        *,
+        path: Path,
+        now: datetime,
+        summary: dict[str, Any],
+        test_mode: bool = False,
+    ) -> str:
+        self._require_config()
+        token = self._tenant_token()
+        file_token = self._upload(token, path)
+        result = self._import_sheet(token, file_token, path)
+        url = str(result.get("url") or "")
+        if not url:
+            raise RuntimeError("Feishu import succeeded without spreadsheet URL")
+        sheet_token = str(result.get("token") or "")
+        if sheet_token:
+            self._set_read_permission(
+                token,
+                sheet_token,
+                str(result.get("type") or "sheet"),
+            )
+        self._send_card(
+            token,
+            url=url,
+            now=now,
+            summary=summary,
+            test_mode=test_mode,
+        )
+        return url
+
+
+def build_notification_summary(
+    *,
+    rows: list[list[Any]],
+    results: list[dict[str, Any]],
+    decision: str,
+    observed_partition: str | None,
+    observed_cpm: Decimal | None,
+    test_mode: bool = False,
+) -> dict[str, Any]:
+    expected_statuses = {"planned"} if test_mode else {"success", "failed"}
+    notified = [
+        result
+        for result in results
+        if result.get("status") in expected_statuses
+        and (
+            result.get("bid_change_needed")
+            or result.get("begin_date_change_needed")
+            or (
+                result.get("decision") in {"PAUSE", "CUTOFF"}
+                and result.get("status_change_needed")
+            )
+        )
+    ]
+    return {
+        "decision": decision,
+        "observed_partition": observed_partition,
+        "observed_cpm": float(observed_cpm) if observed_cpm is not None else None,
+        "row_count": len(rows),
+        "account_count": len({int(row["account_id"]) for row in notified}),
+        "today_cost_yuan": sum(
+            int(row.get("today_cost_fen") or 0) for row in notified
+        )
+        / 100,
+        "success_count": sum(
+            row.get("status") in {"success", "planned"} for row in notified
+        ),
+        "failure_count": sum(row.get("status") == "failed" for row in notified),
+        "action_counts": {
+            "上调": sum(row.get("decision") == "BOOST" for row in notified),
+            "恢复基础价": sum(
+                row.get("decision") == "RESTORE"
+                and row.get("bid_change_needed")
+                for row in notified
+            ),
+            "关停": sum(
+                row.get("decision") == "CUTOFF"
+                and row.get("status_change_needed")
+                for row in notified
+            ),
+            "延后至次日": sum(
+                row.get("decision") == "PAUSE"
+                and row.get("begin_date_change_needed")
+                for row in notified
+            ),
+        },
+    }
+
+
+def send_action_notification(
+    *,
+    run_id: str,
+    now: datetime,
+    results: list[dict[str, Any]],
+    bid_up_ratio: Decimal,
+    decision: str,
+    observed_partition: str | None,
+    observed_cpm: Decimal | None,
+    test_mode: bool = False,
+) -> dict[str, Any]:
+    rows = notification_rows(
+        now=now,
+        results=results,
+        bid_up_ratio=bid_up_ratio,
+        observed_partition=observed_partition,
+        observed_cpm=observed_cpm,
+        test_mode=test_mode,
+    )
+    if not rows:
+        return {"status": "no_actions", "row_count": 0}
+
+    path = create_notification_xlsx(
+        run_id=run_id,
+        now=now,
+        rows=rows,
+        test_mode=test_mode,
+    )
+    summary = build_notification_summary(
+        rows=rows,
+        results=results,
+        decision=decision,
+        observed_partition=observed_partition,
+        observed_cpm=observed_cpm,
+        test_mode=test_mode,
+    )
+    enabled = os.getenv("RTC_FEISHU_NOTIFY_ENABLED", "1").strip().lower() in {
+        "1",
+        "true",
+        "yes",
+        "y",
+        "on",
+    }
+    if not enabled:
+        return {
+            "status": "disabled",
+            "row_count": len(rows),
+            "xlsx_path": str(path),
+        }
+
+    try:
+        url = FeishuNotifier().send(
+            path=path,
+            now=now,
+            summary=summary,
+            test_mode=test_mode,
+        )
+        return {
+            "status": "sent",
+            "row_count": len(rows),
+            "xlsx_path": str(path),
+            "url": url,
+        }
+    except Exception as exc:
+        logger.exception("Feishu action notification failed")
+        return {
+            "status": "failed",
+            "row_count": len(rows),
+            "xlsx_path": str(path),
+            "error": str(exc),
+        }
+
+
+def send_operator_reconciliation_notification(
+    results: list[dict[str, Any]],
+) -> None:
+    rows = [
+        result
+        for result in results
+        if result.get("decision") == "OPERATOR_MANUAL_OVERRIDE"
+    ]
+    if not rows:
+        return
+    details = "\n".join(
+        f"- 账户 {row['account_id']} / 广告 {row['adgroup_id']}"
+        for row in rows
+    )
+    FeishuNotifier().send_text(
+        "检测到腾讯后台人工开启广告,已清除系统运营停止状态并尊重人工操作:\n"
+        f"{details}"
+    )

+ 60 - 0
examples/tencent_realtime_control/fetch_daily_hourly_cpm.py

@@ -0,0 +1,60 @@
+#!/usr/bin/env python
+"""Fetch one day's overall hourly CPM for real-time ad control."""
+
+from __future__ import annotations
+
+import argparse
+import re
+from datetime import datetime
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+from dotenv import load_dotenv
+
+from odps_source import build_odps_client, fetch_hourly_cpm
+
+SHANGHAI = ZoneInfo("Asia/Shanghai")
+ROOT = Path(__file__).resolve().parent
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--date",
+        default=datetime.now(SHANGHAI).strftime("%Y%m%d"),
+        help="Data date in YYYYMMDD format; defaults to today in Asia/Shanghai.",
+    )
+    parser.add_argument(
+        "--output",
+        type=Path,
+        help="CSV output path; defaults to outputs/hourly_cpm_<date>.csv.",
+    )
+    return parser.parse_args()
+
+
+def main() -> None:
+    args = parse_args()
+    if not re.fullmatch(r"\d{8}", args.date):
+        raise ValueError("--date must use YYYYMMDD format")
+
+    load_dotenv(ROOT.parent / "auto_put_ad_mini" / ".env", override=False)
+    load_dotenv(Path.cwd() / ".env", override=False)
+    frame = fetch_hourly_cpm(build_odps_client(), args.date)
+    if frame.empty:
+        print(f"No hourly CPM data found for {args.date}.")
+        return
+
+    output_path = args.output or ROOT / "outputs" / f"hourly_cpm_{args.date}.csv"
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    frame.to_csv(output_path, index=False)
+
+    display = frame.copy()
+    display["hour"] = display["hour_of_day"].map(lambda value: f"{value:02d}:00")
+    display["cpm"] = display["cpm"].map(lambda value: f"{value:.2f}")
+    print(display[["hour", "impressions", "cpm"]].to_string(index=False))
+    print(f"hours={len(frame)} latest_partition={frame.iloc[-1]['dt']}")
+    print(f"output={output_path}")
+
+
+if __name__ == "__main__":
+    main()

+ 18 - 0
examples/tencent_realtime_control/init_db.py

@@ -0,0 +1,18 @@
+#!/usr/bin/env python
+"""Initialize real-time control state tables."""
+
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+from storage import initialize_schema
+
+
+ROOT = Path(__file__).resolve().parent
+
+
+if __name__ == "__main__":
+    load_dotenv(ROOT.parent / "auto_put_ad_mini" / ".env", override=False)
+    load_dotenv(Path.cwd() / ".env", override=False)
+    initialize_schema()
+    print("Real-time control schema initialized.")

+ 74 - 0
examples/tencent_realtime_control/odps_source.py

@@ -0,0 +1,74 @@
+"""ODPS source for the overall hourly CPM signal."""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from datetime import date
+
+import pandas as pd
+from odps import ODPS
+
+
+@dataclass(frozen=True)
+class HourlyCpm:
+    partition: str
+    hour_of_day: int
+    impressions: int
+    cpm: float
+
+
+def build_odps_client() -> ODPS:
+    missing = [
+        key
+        for key in ("ODPS_ACCESS_ID", "ODPS_ACCESS_SECRET")
+        if not os.getenv(key)
+    ]
+    if missing:
+        raise RuntimeError(f"Missing ODPS environment variables: {', '.join(missing)}")
+    return ODPS(
+        os.environ["ODPS_ACCESS_ID"],
+        os.environ["ODPS_ACCESS_SECRET"],
+        os.getenv("ODPS_PROJECT", "loghubods"),
+        endpoint=os.getenv("ODPS_ENDPOINT", "http://service.odps.aliyun.com/api"),
+    )
+
+
+def fetch_hourly_cpm(client: ODPS, data_date: str) -> pd.DataFrame:
+    sql = f"""
+SELECT dt,
+       CAST(SUBSTR(dt, 9, 2) AS BIGINT) AS hour_of_day,
+       `曝光次数_总` AS impressions,
+       `真实cpm_总` AS cpm
+FROM loghubods.advertiser_data_da_hour
+WHERE dt LIKE '{data_date}%'
+  AND `名称` = 'SUM'
+  AND advertisercode = 'SUM'
+  AND company = 'SUM'
+  AND `行业` = 'SUM'
+  AND `客户` = 'SUM'
+  AND `落地页类型` = 'SUM'
+ORDER BY dt
+"""
+    instance = client.execute_sql(sql, hints={"odps.sql.submit.mode": "script"})
+    with instance.open_reader(tunnel=True) as reader:
+        frame = reader.to_pandas()
+    if frame.empty:
+        return frame
+    frame["hour_of_day"] = pd.to_numeric(frame["hour_of_day"]).astype("int64")
+    frame["impressions"] = pd.to_numeric(frame["impressions"]).astype("int64")
+    frame["cpm"] = pd.to_numeric(frame["cpm"]).astype(float)
+    return frame
+
+
+def fetch_latest_cpm(client: ODPS, data_date: date) -> HourlyCpm | None:
+    frame = fetch_hourly_cpm(client, data_date.strftime("%Y%m%d"))
+    if frame.empty:
+        return None
+    row = frame.iloc[-1]
+    return HourlyCpm(
+        partition=str(row["dt"]),
+        hour_of_day=int(row["hour_of_day"]),
+        impressions=int(row["impressions"]),
+        cpm=float(row["cpm"]),
+    )

+ 94 - 0
examples/tencent_realtime_control/operator_commands.py

@@ -0,0 +1,94 @@
+"""Deterministic parsing for Feishu advertising control commands."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+
+ACTION_DAY_PAUSE = "DAY_PAUSE"
+ACTION_STOP = "STOP"
+ACTION_RESUME = "RESUME"
+ACTION_STATUS = "STATUS"
+ACTION_CONFIRM = "CONFIRM"
+ACTION_CANCEL = "CANCEL"
+
+WRITE_ACTIONS = {ACTION_DAY_PAUSE, ACTION_STOP, ACTION_RESUME}
+
+_COMMAND_ID_RE = re.compile(r"\bcmd_[0-9A-Za-z_-]+\b", re.IGNORECASE)
+_ACCOUNT_ID_RE = re.compile(r"(?<!\d)(\d{7,12})(?!\d)")
+
+
+@dataclass(frozen=True)
+class ParsedCommand:
+    action: str
+    scope_type: str = "ACCOUNTS"
+    account_ids: tuple[int, ...] = ()
+    command_id: str | None = None
+
+
+def normalize_text(raw: str) -> str:
+    text = str(raw or "").strip()
+    translations = str.maketrans(
+        {
+            ",": ",",
+            "、": ",",
+            ";": ",",
+            ";": ",",
+            ":": " ",
+            ":": " ",
+            "\t": " ",
+            "\r": " ",
+            "\n": " ",
+        }
+    )
+    return re.sub(r"\s+", " ", text.translate(translations)).strip()
+
+
+def parse_command(raw: str) -> ParsedCommand | None:
+    text = normalize_text(raw)
+    if not text:
+        return None
+
+    lowered = text.lower()
+    command_match = _COMMAND_ID_RE.search(text)
+    if lowered.startswith("确认"):
+        if not command_match:
+            raise ValueError("确认命令缺少 command_id")
+        return ParsedCommand(
+            action=ACTION_CONFIRM,
+            command_id=command_match.group(0).lower(),
+        )
+    if lowered.startswith("取消"):
+        if not command_match:
+            raise ValueError("取消命令缺少 command_id")
+        return ParsedCommand(
+            action=ACTION_CANCEL,
+            command_id=command_match.group(0).lower(),
+        )
+
+    if "查看暂停状态" in text or "查询暂停状态" in text:
+        return ParsedCommand(action=ACTION_STATUS, scope_type="ALL")
+
+    if text.startswith(("停止", "持续停止", "永久停止")):
+        action = ACTION_STOP
+    elif text.startswith(("暂停", "今天暂停", "暂停到明天", "今天不投")):
+        action = ACTION_DAY_PAUSE
+    elif text.startswith(("恢复", "开启", "继续投放")):
+        action = ACTION_RESUME
+    else:
+        return None
+
+    if "全部" in text or "所有" in text:
+        return ParsedCommand(action=action, scope_type="ALL")
+
+    account_ids = tuple(
+        dict.fromkeys(int(value) for value in _ACCOUNT_ID_RE.findall(text))
+    )
+    if not account_ids:
+        raise ValueError("命令中没有有效账户 ID,也没有指定“全部”")
+    return ParsedCommand(
+        action=action,
+        scope_type="ACCOUNTS",
+        account_ids=account_ids,
+    )

+ 531 - 0
examples/tencent_realtime_control/operator_control.py

@@ -0,0 +1,531 @@
+"""Preview and execute confirmed Feishu operator commands."""
+
+from __future__ import annotations
+
+import logging
+import uuid
+from datetime import datetime, timedelta
+from typing import Any
+
+from operator_commands import (
+    ACTION_DAY_PAUSE,
+    ACTION_RESUME,
+    ACTION_STOP,
+    ParsedCommand,
+)
+from storage import (
+    advisory_lock,
+    clear_operator_pause,
+    create_operator_command,
+    insert_operator_command_item,
+    load_ad_states,
+    load_managed_accounts,
+    load_operator_command,
+    load_operator_pauses,
+    set_operator_pause,
+    transition_operator_command,
+    update_operator_command,
+    upsert_ad_state,
+)
+from tencent_client import (
+    ACTIVE_STATUS,
+    SUSPEND_STATUS,
+    PostWriteVerificationError,
+    TencentWriteNotSentError,
+    TencentWriteOutcomeUnknownError,
+    TencentWriteRejectedError,
+    TencentClient,
+    current_bid_fen,
+    resolve_bid_field,
+)
+
+
+COMMAND_PENDING = "PENDING_CONFIRMATION"
+COMMAND_EXECUTING = "EXECUTING"
+COMMAND_SUCCEEDED = "SUCCEEDED"
+COMMAND_PARTIAL = "PARTIAL"
+COMMAND_FAILED = "FAILED"
+COMMAND_CANCELLED = "CANCELLED"
+COMMAND_EXPIRED = "EXPIRED"
+FINAL_COMMAND_STATUSES = {
+    COMMAND_SUCCEEDED,
+    COMMAND_PARTIAL,
+    COMMAND_FAILED,
+    COMMAND_CANCELLED,
+    COMMAND_EXPIRED,
+}
+
+PAUSE_UNTIL_NEXT_DELIVERY = "UNTIL_NEXT_DELIVERY"
+PAUSE_UNTIL_MANUAL = "UNTIL_MANUAL"
+logger = logging.getLogger("tencent_realtime_control.operator_control")
+
+
+def _managed_account_map() -> dict[int, dict[str, Any]]:
+    return {
+        int(row["account_id"]): row
+        for row in load_managed_accounts()
+    }
+
+
+def resolve_target_accounts(parsed: ParsedCommand) -> list[dict[str, Any]]:
+    managed = _managed_account_map()
+    if parsed.scope_type == "ALL":
+        accounts = list(managed.values())
+        if not accounts:
+            raise ValueError("当前没有启用白名单的自动化管理账户")
+        return accounts
+    missing = [account_id for account_id in parsed.account_ids if account_id not in managed]
+    if missing:
+        raise ValueError(f"账户不在自动化管理范围: {missing}")
+    return [managed[account_id] for account_id in parsed.account_ids]
+
+
+def _next_delivery_start(now: datetime, start_hour: int) -> datetime:
+    return datetime.combine(
+        now.date() + timedelta(days=1),
+        datetime.min.time().replace(hour=start_hour),
+        now.tzinfo,
+    )
+
+
+def preview_write_command(
+    parsed: ParsedCommand,
+    *,
+    now: datetime,
+    source_message_id: str,
+    chat_id: str,
+    sender_open_id: str,
+    sender_name: str | None,
+    confirmation_ttl_minutes: int,
+    start_hour: int,
+    tencent: TencentClient | None = None,
+) -> dict[str, Any]:
+    accounts = resolve_target_accounts(parsed)
+    client = tencent or TencentClient()
+    preview_ad_count = 0
+    if parsed.action in {ACTION_DAY_PAUSE, ACTION_STOP}:
+        for account in accounts:
+            ads = client.get_ads(int(account["account_id"]))
+            preview_ad_count += sum(
+                str(ad.get("configured_status") or "") == ACTIVE_STATUS
+                for ad in ads
+            )
+    elif parsed.action == ACTION_RESUME:
+        preview_ad_count = len(
+            load_operator_pauses([int(row["account_id"]) for row in accounts])
+        )
+    else:
+        raise ValueError(f"Unsupported write action: {parsed.action}")
+
+    command_id = f"cmd_{now.strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}"
+    expires_at = now + timedelta(minutes=confirmation_ttl_minutes)
+    command = create_operator_command(
+        {
+            "command_id": command_id,
+            "source_message_id": source_message_id,
+            "chat_id": chat_id,
+            "sender_open_id": sender_open_id,
+            "sender_name": sender_name,
+            "action": parsed.action,
+            "scope_type": parsed.scope_type,
+            "target_account_ids": [int(row["account_id"]) for row in accounts],
+            "status": COMMAND_PENDING,
+            "preview_account_count": len(accounts),
+            "preview_ad_count": preview_ad_count,
+            "expires_at": expires_at,
+        }
+    )
+    command["resume_at"] = (
+        _next_delivery_start(now, start_hour)
+        if parsed.action == ACTION_DAY_PAUSE
+        else None
+    )
+    return command
+
+
+def cancel_command(command_id: str, sender_open_id: str, now: datetime) -> dict[str, Any]:
+    command = load_operator_command(command_id)
+    if not command:
+        raise ValueError(f"命令不存在: {command_id}")
+    if command["sender_open_id"] != sender_open_id:
+        raise PermissionError("只能取消自己发起的命令")
+    if command["status"] != COMMAND_PENDING:
+        return command
+    transition_operator_command(
+        command_id,
+        expected_statuses={COMMAND_PENDING},
+        target_status=COMMAND_CANCELLED,
+        executed_at=now,
+    )
+    return load_operator_command(command_id) or command
+
+
+def _ensure_state(
+    *,
+    account: dict[str, Any],
+    ad: dict[str, Any],
+    states: dict[int, dict[str, Any]],
+    now: datetime,
+) -> dict[str, Any]:
+    adgroup_id = int(ad["adgroup_id"])
+    state = states.get(adgroup_id)
+    if state:
+        return state
+    bid_field = resolve_bid_field(ad, account.get("bid_scene"))
+    base_bid = current_bid_fen(ad, bid_field)
+    if base_bid is None or base_bid <= 0:
+        raise ValueError(
+            f"广告缺少有效基础出价: account={account['account_id']} ad={adgroup_id}"
+        )
+    upsert_ad_state(
+        account_id=int(account["account_id"]),
+        adgroup_id=adgroup_id,
+        adgroup_name=str(ad.get("adgroup_name") or ""),
+        bid_field=bid_field,
+        base_bid_fen=base_bid,
+        boosted_date=None,
+        paused_by_strategy=False,
+        pause_reason=None,
+        last_action="REGISTER_BASE",
+        action_at=now,
+    )
+    state = {
+        "account_id": int(account["account_id"]),
+        "adgroup_id": adgroup_id,
+        "bid_field": bid_field,
+        "base_bid_fen": base_bid,
+        "paused_by_strategy": False,
+    }
+    states[adgroup_id] = state
+    return state
+
+
+def _record_item(command: dict[str, Any], account: dict[str, Any], **values: Any) -> None:
+    insert_operator_command_item(
+        {
+            "command_id": command["command_id"],
+            "account_id": int(account["account_id"]),
+            "audience_name": account.get("audience_name"),
+            **values,
+        }
+    )
+
+
+def _execute_pause(
+    command: dict[str, Any],
+    account: dict[str, Any],
+    *,
+    now: datetime,
+    start_hour: int,
+    client: TencentClient,
+) -> tuple[int, int]:
+    account_id = int(account["account_id"])
+    ads = client.get_ads(account_id)
+    states = load_ad_states(account_id)
+    successes = failures = 0
+    mode = (
+        PAUSE_UNTIL_NEXT_DELIVERY
+        if command["action"] == ACTION_DAY_PAUSE
+        else PAUSE_UNTIL_MANUAL
+    )
+    resume_at = _next_delivery_start(now, start_hour) if mode == PAUSE_UNTIL_NEXT_DELIVERY else None
+    for ad in ads:
+        adgroup_id = int(ad.get("adgroup_id") or 0)
+        before_status = str(ad.get("configured_status") or "")
+        if before_status != ACTIVE_STATUS:
+            _record_item(
+                command,
+                account,
+                adgroup_id=adgroup_id,
+                adgroup_name=ad.get("adgroup_name"),
+                before_status=before_status,
+                target_status=SUSPEND_STATUS,
+                readback_status=before_status,
+                execution_status="SKIPPED_NOT_ACTIVE",
+            )
+            continue
+        tencent_updated = False
+        try:
+            _ensure_state(account=account, ad=ad, states=states, now=now)
+            set_operator_pause(
+                account_id=account_id,
+                adgroup_id=adgroup_id,
+                mode=mode,
+                resume_at=resume_at,
+                command_id=command["command_id"],
+                paused_from_status=before_status,
+                paused_at=now,
+            )
+            readback = client.update_ad(
+                account_id,
+                adgroup_id,
+                target_status=SUSPEND_STATUS,
+            )
+            tencent_updated = True
+            _record_item(
+                command,
+                account,
+                adgroup_id=adgroup_id,
+                adgroup_name=ad.get("adgroup_name"),
+                before_status=before_status,
+                target_status=SUSPEND_STATUS,
+                readback_status=readback.get("configured_status"),
+                execution_status="SUCCESS",
+            )
+            successes += 1
+        except Exception as exc:
+            if isinstance(
+                exc,
+                (PostWriteVerificationError, TencentWriteOutcomeUnknownError),
+            ):
+                tencent_updated = True
+            safe_to_clear = isinstance(
+                exc,
+                (TencentWriteNotSentError, TencentWriteRejectedError),
+            )
+            if not tencent_updated and safe_to_clear:
+                clear_operator_pause(
+                    account_id,
+                    adgroup_id,
+                    action="OPERATOR_PAUSE_FAILED",
+                    action_at=now,
+                )
+            try:
+                _record_item(
+                    command,
+                    account,
+                    adgroup_id=adgroup_id,
+                    adgroup_name=ad.get("adgroup_name"),
+                    before_status=before_status,
+                    target_status=SUSPEND_STATUS,
+                    readback_status=(
+                        exc.actual.get("configured_status")
+                        if isinstance(exc, PostWriteVerificationError)
+                        else SUSPEND_STATUS
+                        if tencent_updated
+                        else None
+                    ),
+                    execution_status=(
+                        "VERIFY_FAILED"
+                        if isinstance(exc, PostWriteVerificationError)
+                        else "OUTCOME_UNKNOWN"
+                        if isinstance(exc, TencentWriteOutcomeUnknownError)
+                        else "AUDIT_FAILED"
+                        if tencent_updated
+                        else "FAILED"
+                    ),
+                    error_message=str(exc),
+                )
+            except Exception:
+                logger.exception(
+                    "Failed to persist operator command failure item "
+                    "command=%s account=%s ad=%s",
+                    command["command_id"],
+                    account_id,
+                    adgroup_id,
+                )
+            failures += 1
+    return successes, failures
+
+
+def _execute_resume(
+    command: dict[str, Any],
+    account: dict[str, Any],
+    *,
+    now: datetime,
+    client: TencentClient,
+) -> tuple[int, int]:
+    account_id = int(account["account_id"])
+    pauses = load_operator_pauses([account_id])
+    if not pauses:
+        return 0, 0
+    ads = {
+        int(ad.get("adgroup_id") or 0): ad
+        for ad in client.get_ads(account_id)
+    }
+    successes = failures = 0
+    for state in pauses:
+        adgroup_id = int(state["adgroup_id"])
+        ad = ads.get(adgroup_id)
+        if not ad:
+            _record_item(
+                command,
+                account,
+                adgroup_id=adgroup_id,
+                adgroup_name=state.get("adgroup_name"),
+                execution_status="FAILED",
+                error_message="Tencent ad not found",
+            )
+            failures += 1
+            continue
+        before_status = str(ad.get("configured_status") or "")
+        try:
+            readback_status = before_status
+            target_status = None
+            if before_status == SUSPEND_STATUS and not bool(state.get("paused_by_strategy")):
+                target_status = ACTIVE_STATUS
+                readback = client.update_ad(
+                    account_id,
+                    adgroup_id,
+                    target_status=ACTIVE_STATUS,
+                )
+                readback_status = str(readback.get("configured_status") or "")
+            clear_operator_pause(
+                account_id,
+                adgroup_id,
+                action="OPERATOR_RESUME",
+                action_at=now,
+            )
+            _record_item(
+                command,
+                account,
+                adgroup_id=adgroup_id,
+                adgroup_name=ad.get("adgroup_name"),
+                before_status=before_status,
+                target_status=target_status,
+                readback_status=readback_status,
+                execution_status="SUCCESS",
+            )
+            successes += 1
+        except Exception as exc:
+            _record_item(
+                command,
+                account,
+                adgroup_id=adgroup_id,
+                adgroup_name=ad.get("adgroup_name"),
+                before_status=before_status,
+                target_status=ACTIVE_STATUS,
+                execution_status="FAILED",
+                error_message=str(exc),
+            )
+            failures += 1
+    return successes, failures
+
+
+def execute_confirmed_command(
+    command_id: str,
+    *,
+    sender_open_id: str,
+    now: datetime,
+    start_hour: int,
+    lock_name: str,
+    tencent: TencentClient | None = None,
+) -> dict[str, Any]:
+    command = load_operator_command(command_id)
+    if not command:
+        raise ValueError(f"命令不存在: {command_id}")
+    if command["sender_open_id"] != sender_open_id:
+        raise PermissionError("只能确认自己发起的命令")
+    if command["status"] in FINAL_COMMAND_STATUSES:
+        return command
+    if command["status"] not in {COMMAND_PENDING, COMMAND_EXECUTING}:
+        raise RuntimeError(f"命令当前不可确认: {command['status']}")
+    with advisory_lock(lock_name) as acquired:
+        if not acquired:
+            raise RuntimeError("实时控制正在执行,请稍后再次确认")
+        command = load_operator_command(command_id) or command
+        if command["status"] in FINAL_COMMAND_STATUSES:
+            return command
+        if command["status"] not in {COMMAND_PENDING, COMMAND_EXECUTING}:
+            raise RuntimeError(f"命令当前不可确认: {command['status']}")
+        if (
+            command["status"] == COMMAND_PENDING
+            and command.get("expires_at")
+            and now.replace(tzinfo=None) > command["expires_at"]
+        ):
+            transition_operator_command(
+                command_id,
+                expected_statuses={COMMAND_PENDING},
+                target_status=COMMAND_EXPIRED,
+                executed_at=now,
+            )
+            return load_operator_command(command_id) or command
+        if command["status"] == COMMAND_PENDING:
+            transitioned = transition_operator_command(
+                command_id,
+                expected_statuses={COMMAND_PENDING},
+                target_status=COMMAND_EXECUTING,
+                confirmed_at=now,
+            )
+            if not transitioned:
+                latest = load_operator_command(command_id) or command
+                if latest["status"] in FINAL_COMMAND_STATUSES:
+                    return latest
+                raise RuntimeError(
+                    f"命令状态并发变化: {latest['status']}"
+                )
+        try:
+            managed = _managed_account_map()
+            missing = [
+                account_id
+                for account_id in command["target_account_ids"]
+                if account_id not in managed
+            ]
+            if missing:
+                raise RuntimeError(
+                    f"确认时账户已不在自动化管理范围: {missing}"
+                )
+            accounts = [
+                managed[account_id]
+                for account_id in command["target_account_ids"]
+            ]
+            client = tencent or TencentClient()
+            successes = failures = 0
+            for account in accounts:
+                if command["action"] in {ACTION_DAY_PAUSE, ACTION_STOP}:
+                    ok, failed = _execute_pause(
+                        command,
+                        account,
+                        now=now,
+                        start_hour=start_hour,
+                        client=client,
+                    )
+                elif command["action"] == ACTION_RESUME:
+                    ok, failed = _execute_resume(
+                        command,
+                        account,
+                        now=now,
+                        client=client,
+                    )
+                else:
+                    raise ValueError(f"Unsupported command action: {command['action']}")
+                successes += ok
+                failures += failed
+        except Exception as exc:
+            update_operator_command(
+                command_id,
+                COMMAND_FAILED,
+                executed_at=now,
+                error_message=str(exc),
+            )
+            raise
+
+        status = (
+            COMMAND_FAILED
+            if failures and not successes
+            else COMMAND_PARTIAL
+            if failures
+            else COMMAND_SUCCEEDED
+        )
+        update_operator_command(command_id, status, executed_at=now)
+        result = load_operator_command(command_id) or command
+        result["successes"] = successes
+        result["failures"] = failures
+        return result
+
+
+def pause_status_summary() -> dict[str, Any]:
+    account_ids = [int(row["account_id"]) for row in load_managed_accounts()]
+    pauses = load_operator_pauses(account_ids)
+    return {
+        "total": len(pauses),
+        "until_next_delivery": sum(
+            row["operator_pause_mode"] == PAUSE_UNTIL_NEXT_DELIVERY
+            for row in pauses
+        ),
+        "until_manual": sum(
+            row["operator_pause_mode"] == PAUSE_UNTIL_MANUAL
+            for row in pauses
+        ),
+        "accounts": sorted({int(row["account_id"]) for row in pauses}),
+    }

+ 55 - 0
examples/tencent_realtime_control/realtime_config.py

@@ -0,0 +1,55 @@
+"""Runtime configuration for Tencent real-time control."""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from decimal import Decimal
+
+
+@dataclass(frozen=True)
+class RealtimeControlConfig:
+    start_hour: int = 12
+    stop_hour: int = 21
+    poll_seconds: int = 600
+    high_cpm: Decimal = Decimal("250")
+    low_cpm: Decimal = Decimal("190")
+    bid_up_ratio: Decimal = Decimal("1.25")
+    inventory_refresh_minutes: int = 60
+    max_partition_lag_hours: int = 2
+    lock_name: str = "tencent_realtime_control"
+
+    @classmethod
+    def from_env(cls) -> "RealtimeControlConfig":
+        config = cls(
+            start_hour=int(os.getenv("RTC_START_HOUR", "12")),
+            stop_hour=int(os.getenv("RTC_STOP_HOUR", "21")),
+            poll_seconds=int(os.getenv("RTC_POLL_SECONDS", "600")),
+            high_cpm=Decimal(os.getenv("RTC_HIGH_CPM", "250")),
+            low_cpm=Decimal(os.getenv("RTC_LOW_CPM", "190")),
+            bid_up_ratio=Decimal(os.getenv("RTC_BID_UP_RATIO", "1.25")),
+            inventory_refresh_minutes=int(
+                os.getenv("RTC_INVENTORY_REFRESH_MINUTES", "60")
+            ),
+            max_partition_lag_hours=int(
+                os.getenv("RTC_MAX_PARTITION_LAG_HOURS", "2")
+            ),
+            lock_name=os.getenv(
+                "RTC_DB_LOCK_NAME", "tencent_realtime_control"
+            ).strip(),
+        )
+        if not 0 <= config.start_hour < config.stop_hour <= 23:
+            raise ValueError("RTC hours must satisfy 0 <= start < stop <= 23")
+        if config.poll_seconds < 60:
+            raise ValueError("RTC_POLL_SECONDS must be at least 60")
+        if config.low_cpm >= config.high_cpm:
+            raise ValueError("RTC_LOW_CPM must be lower than RTC_HIGH_CPM")
+        if config.bid_up_ratio <= 1:
+            raise ValueError("RTC_BID_UP_RATIO must be greater than 1")
+        if config.inventory_refresh_minutes < 5:
+            raise ValueError("RTC_INVENTORY_REFRESH_MINUTES must be at least 5")
+        if config.max_partition_lag_hours < 1:
+            raise ValueError("RTC_MAX_PARTITION_LAG_HOURS must be at least 1")
+        if not config.lock_name:
+            raise ValueError("RTC_DB_LOCK_NAME must not be empty")
+        return config

+ 6 - 0
examples/tencent_realtime_control/requirements.txt

@@ -0,0 +1,6 @@
+pandas>=2.0.0
+openpyxl>=3.1.0
+pyodps>=0.11.0
+pymysql>=1.1.0
+python-dotenv>=1.0.0
+requests>=2.31.0

+ 66 - 0
examples/tencent_realtime_control/run_control_service.py

@@ -0,0 +1,66 @@
+#!/usr/bin/env python
+"""Run Feishu controls and the ten-minute real-time control loop."""
+
+from __future__ import annotations
+
+import argparse
+import logging
+import os
+import sys
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+if str(ROOT) not in sys.path:
+    sys.path.insert(0, str(ROOT))
+
+from run_once import load_environment  # noqa: E402
+from run_scheduler import run_forever  # noqa: E402
+from storage import initialize_schema  # noqa: E402
+
+
+logger = logging.getLogger("tencent_realtime_control.service")
+
+
+def _env_flag(name: str, default: bool = False) -> bool:
+    raw = os.getenv(name)
+    if raw is None:
+        return default
+    return raw.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--apply",
+        action="store_true",
+        help="Enable confirmed Feishu commands and real Tencent updates.",
+    )
+    return parser.parse_args()
+
+
+def main() -> None:
+    load_environment()
+    args = parse_args()
+    apply = args.apply or _env_flag("RTC_APPLY_ENABLED")
+    initialize_schema()
+    if os.getenv("RTC_COMMAND_ENABLED", "1").strip().lower() in {
+        "1",
+        "true",
+        "yes",
+    }:
+        from feishu_command_service import FeishuCommandService
+
+        FeishuCommandService(apply=apply).start()
+        logger.info("Feishu command WebSocket started")
+    else:
+        logger.warning("Feishu command WebSocket is disabled")
+    run_forever(apply=apply)
+
+
+if __name__ == "__main__":
+    logging.basicConfig(
+        level=os.getenv("LOG_LEVEL", "INFO"),
+        format="%(asctime)s %(levelname)s %(name)s %(message)s",
+    )
+    main()

+ 785 - 0
examples/tencent_realtime_control/run_once.py

@@ -0,0 +1,785 @@
+#!/usr/bin/env python
+"""Run one idempotent real-time CPM control cycle."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+import time
+import uuid
+from datetime import datetime, timedelta
+from decimal import Decimal, ROUND_HALF_UP
+from pathlib import Path
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from dotenv import load_dotenv
+
+
+ROOT = Path(__file__).resolve().parent
+SHANGHAI = ZoneInfo("Asia/Shanghai")
+ACTIVE_STATUS = "AD_STATUS_NORMAL"
+SUSPEND_STATUS = "AD_STATUS_SUSPEND"
+
+DECISION_BOOST = "BOOST"
+DECISION_RESTORE = "RESTORE"
+DECISION_PAUSE = "PAUSE"
+DECISION_CUTOFF = "CUTOFF"
+DECISION_OFF_HOURS = "OFF_HOURS"
+DECISION_WAIT_DATA = "WAIT_DATA"
+
+logger = logging.getLogger("tencent_realtime_control")
+
+
+def load_environment() -> None:
+    load_dotenv(ROOT.parent / "auto_put_ad_mini" / ".env", override=False)
+    load_dotenv(Path.cwd() / ".env", override=False)
+
+
+def decide(cpm: Decimal, low_cpm: Decimal, high_cpm: Decimal) -> str:
+    if cpm > high_cpm:
+        return DECISION_BOOST
+    if cpm < low_cpm:
+        return DECISION_PAUSE
+    return DECISION_RESTORE
+
+
+def boosted_bid(base_bid_fen: int, ratio: Decimal) -> int:
+    return int(
+        (Decimal(base_bid_fen) * ratio).quantize(
+            Decimal("1"), rounding=ROUND_HALF_UP
+        )
+    )
+
+
+def partition_lag_hours(partition: str, now: datetime) -> float:
+    try:
+        partition_time = datetime.strptime(partition[:10], "%Y%m%d%H").replace(
+            tzinfo=SHANGHAI
+        )
+    except (TypeError, ValueError) as exc:
+        raise ValueError(f"Invalid CPM partition: {partition!r}") from exc
+    return (now - partition_time).total_seconds() / 3600
+
+
+def should_refresh_inventory(
+    daily_state: dict[str, Any],
+    *,
+    observed_partition: str,
+    decision: str,
+    now: datetime,
+    refresh_minutes: int,
+) -> bool:
+    if (
+        daily_state.get("last_observed_partition") != observed_partition
+        or daily_state.get("last_decision") != decision
+    ):
+        return True
+    refreshed_at = daily_state.get("last_inventory_refresh_at")
+    if not refreshed_at:
+        return True
+    if refreshed_at.tzinfo is None:
+        refreshed_at = refreshed_at.replace(tzinfo=SHANGHAI)
+    return now - refreshed_at >= timedelta(minutes=refresh_minutes)
+
+
+def target_for_ad(
+    *,
+    decision: str,
+    control_date: Any,
+    current_status: str,
+    current_bid: int,
+    base_bid: int,
+    boosted_date: Any,
+    paused_by_strategy: bool,
+    operator_pause_expired: bool,
+    bid_up_ratio: Decimal,
+) -> dict[str, Any] | None:
+    managed_suspend = paused_by_strategy or operator_pause_expired
+    manual_suspend = current_status != ACTIVE_STATUS and not managed_suspend
+
+    target_bid = base_bid
+    target_status: str | None = None
+    next_boosted_date = boosted_date
+    next_paused = False
+    pause_reason = None
+    limit_reached = False
+    defer_to_next_day = False
+
+    if manual_suspend:
+        if decision not in (DECISION_PAUSE, DECISION_CUTOFF):
+            return None
+        return {
+            "target_bid_fen": base_bid,
+            "target_status": None,
+            "boosted_date": boosted_date,
+            "paused_by_strategy": False,
+            "pause_reason": None,
+            "boost_limit_reached": False,
+            "bid_change_needed": current_bid != base_bid,
+            "status_change_needed": False,
+            "defer_to_next_day": False,
+        }
+
+    if decision == DECISION_BOOST:
+        if boosted_date == control_date:
+            limit_reached = True
+            raised_bid = boosted_bid(base_bid, bid_up_ratio)
+            if current_bid == raised_bid:
+                target_bid = raised_bid
+        else:
+            target_bid = boosted_bid(base_bid, bid_up_ratio)
+            next_boosted_date = control_date
+        if managed_suspend:
+            target_status = ACTIVE_STATUS
+    elif decision == DECISION_RESTORE:
+        if managed_suspend:
+            target_status = ACTIVE_STATUS
+    elif decision == DECISION_PAUSE:
+        if managed_suspend:
+            target_status = ACTIVE_STATUS
+        defer_to_next_day = True
+    elif decision == DECISION_CUTOFF:
+        target_status = SUSPEND_STATUS if current_status != SUSPEND_STATUS else None
+        next_paused = True
+        pause_reason = "cutoff"
+    else:
+        raise ValueError(f"Unsupported decision: {decision}")
+
+    return {
+        "target_bid_fen": target_bid,
+        "target_status": target_status,
+        "boosted_date": next_boosted_date,
+        "paused_by_strategy": next_paused,
+        "pause_reason": pause_reason,
+        "boost_limit_reached": limit_reached,
+        "bid_change_needed": current_bid != target_bid,
+        "status_change_needed": target_status is not None
+        and target_status != current_status,
+        "defer_to_next_day": defer_to_next_day,
+    }
+
+
+def write_run_report(payload: dict[str, Any]) -> Path:
+    output_dir = ROOT / "outputs"
+    output_dir.mkdir(parents=True, exist_ok=True)
+    timestamp = datetime.now(SHANGHAI).strftime("%Y%m%d_%H%M%S")
+    mode = "apply" if payload["apply"] else "dry_run"
+    path = output_dir / f"control_{mode}_{timestamp}.json"
+    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+    return path
+
+
+def execute_inventory_action(
+    *,
+    run_id: str,
+    now: datetime,
+    decision: str,
+    observed_partition: str | None,
+    observed_cpm: Decimal | None,
+    apply: bool,
+    accounts: list[dict[str, Any]],
+    config: Any,
+    tencent: Any,
+    test_notification: bool = False,
+) -> dict[str, Any]:
+    from storage import (
+        clear_operator_pause,
+        insert_action_log,
+        load_ad_states,
+        upsert_ad_state,
+    )
+    from tencent_client import current_bid_fen, resolve_bid_field
+
+    results: list[dict[str, Any]] = []
+    failures = 0
+    api_updates = 0
+
+    for account in accounts:
+        account_id = int(account["account_id"])
+        states = load_ad_states(account_id)
+        try:
+            ads = tencent.get_ads(account_id)
+        except Exception as exc:
+            failures += 1
+            results.append(
+                {
+                    "account_id": account_id,
+                    "status": "account_fetch_failed",
+                    "error": str(exc),
+                }
+            )
+            continue
+
+        for ad in ads:
+            adgroup_id = int(ad.get("adgroup_id") or 0)
+            current_status = str(ad.get("configured_status") or "")
+            state = states.get(adgroup_id)
+            paused_by_strategy = bool(
+                (state or {}).get("paused_by_strategy")
+            )
+            operator_pause_mode = str(
+                (state or {}).get("operator_pause_mode") or ""
+            )
+            operator_resume_at = (state or {}).get("operator_resume_at")
+            if operator_resume_at and operator_resume_at.tzinfo is None:
+                operator_resume_at = operator_resume_at.replace(tzinfo=SHANGHAI)
+            operator_manually_opened = bool(
+                operator_pause_mode and current_status == ACTIVE_STATUS
+            )
+            operator_pause_expired = bool(
+                operator_pause_mode == "UNTIL_NEXT_DELIVERY"
+                and operator_resume_at
+                and operator_resume_at <= now
+            )
+            operator_pause_active = bool(
+                operator_pause_mode
+                and not operator_manually_opened
+                and not operator_pause_expired
+            )
+
+            if operator_manually_opened and apply:
+                clear_operator_pause(
+                    account_id,
+                    adgroup_id,
+                    action="OPERATOR_MANUAL_OVERRIDE",
+                    action_at=now,
+                )
+                results.append(
+                    {
+                        "account_id": account_id,
+                        "audience_name": account.get("audience_name"),
+                        "adgroup_id": adgroup_id,
+                        "adgroup_name": ad.get("adgroup_name"),
+                        "decision": "OPERATOR_MANUAL_OVERRIDE",
+                        "before_status": current_status,
+                        "target_status": current_status,
+                        "status": "success",
+                        "bid_change_needed": False,
+                        "status_change_needed": False,
+                    }
+                )
+                continue
+
+            if operator_pause_active:
+                continue
+
+            if (
+                current_status != ACTIVE_STATUS
+                and not paused_by_strategy
+                and not operator_pause_expired
+                and (
+                    state is None
+                    or decision not in (DECISION_PAUSE, DECISION_CUTOFF)
+                )
+            ):
+                continue
+
+            bid_field = (
+                str(state["bid_field"])
+                if state
+                else resolve_bid_field(ad, account.get("bid_scene"))
+            )
+            current_bid = current_bid_fen(ad, bid_field)
+            if current_bid is None or current_bid <= 0:
+                failures += 1
+                results.append(
+                    {
+                        "account_id": account_id,
+                        "adgroup_id": adgroup_id,
+                        "status": "invalid_current_bid",
+                        "bid_field": bid_field,
+                    }
+                )
+                continue
+
+            base_bid = int(state["base_bid_fen"]) if state else current_bid
+            plan = target_for_ad(
+                decision=decision,
+                control_date=now.date(),
+                current_status=current_status,
+                current_bid=current_bid,
+                base_bid=base_bid,
+                boosted_date=(state or {}).get("boosted_date"),
+                paused_by_strategy=paused_by_strategy,
+                operator_pause_expired=operator_pause_expired,
+                bid_up_ratio=config.bid_up_ratio,
+            )
+            if plan is None:
+                continue
+
+            before_begin_date = str(ad.get("begin_date") or "")
+            target_begin_date = None
+            begin_date_change_needed = False
+            if plan["defer_to_next_day"]:
+                next_day = (now.date() + timedelta(days=1)).isoformat()
+                target_begin_date = (
+                    before_begin_date
+                    if before_begin_date > next_day
+                    else next_day
+                )
+                begin_date_change_needed = before_begin_date != target_begin_date
+
+            needs_api = (
+                plan["bid_change_needed"]
+                or plan["status_change_needed"]
+                or begin_date_change_needed
+            )
+            execution_status = "planned" if needs_api else "noop"
+            error = None
+            if apply and state is None:
+                upsert_ad_state(
+                    account_id=account_id,
+                    adgroup_id=adgroup_id,
+                    adgroup_name=str(ad.get("adgroup_name") or ""),
+                    bid_field=bid_field,
+                    base_bid_fen=base_bid,
+                    boosted_date=None,
+                    paused_by_strategy=plan["paused_by_strategy"],
+                    pause_reason=plan["pause_reason"],
+                    last_action="REGISTER_BASE",
+                    action_at=now,
+                )
+            elif (
+                apply
+                and decision in (DECISION_PAUSE, DECISION_CUTOFF)
+                and plan["paused_by_strategy"]
+            ):
+                upsert_ad_state(
+                    account_id=account_id,
+                    adgroup_id=adgroup_id,
+                    adgroup_name=str(ad.get("adgroup_name") or ""),
+                    bid_field=bid_field,
+                    base_bid_fen=base_bid,
+                    boosted_date=(state or {}).get("boosted_date"),
+                    paused_by_strategy=True,
+                    pause_reason=plan["pause_reason"],
+                    last_action=f"{decision}_PENDING",
+                    action_at=now,
+                )
+            if apply and needs_api:
+                try:
+                    if begin_date_change_needed:
+                        tencent.update_ad_begin_dates(
+                            account_id,
+                            [adgroup_id],
+                            str(target_begin_date),
+                        )
+                    if plan["bid_change_needed"] or plan["status_change_needed"]:
+                        tencent.update_ad(
+                            account_id,
+                            adgroup_id,
+                            bid_field=bid_field
+                            if plan["bid_change_needed"]
+                            else None,
+                            target_bid_fen=plan["target_bid_fen"]
+                            if plan["bid_change_needed"]
+                            else None,
+                            target_status=plan["target_status"]
+                            if plan["status_change_needed"]
+                            else None,
+                        )
+                    execution_status = "success"
+                    api_updates += 1
+                    time.sleep(0.15)
+                except Exception as exc:
+                    execution_status = "failed"
+                    error = str(exc)
+                    failures += 1
+
+            result = {
+                "account_id": account_id,
+                "audience_name": account.get("audience_name"),
+                "adgroup_id": adgroup_id,
+                "adgroup_name": ad.get("adgroup_name"),
+                "decision": decision,
+                "bid_field": bid_field,
+                "base_bid_fen": base_bid,
+                "before_bid_fen": current_bid,
+                "target_bid_fen": plan["target_bid_fen"],
+                "before_status": current_status,
+                "target_status": plan["target_status"],
+                "boost_limit_reached": plan["boost_limit_reached"],
+                "bid_change_needed": plan["bid_change_needed"],
+                "status_change_needed": plan["status_change_needed"],
+                "before_begin_date": before_begin_date,
+                "target_begin_date": target_begin_date,
+                "begin_date_change_needed": begin_date_change_needed,
+                "status": execution_status,
+                "error": error,
+            }
+            results.append(result)
+
+            if apply and execution_status != "failed":
+                upsert_ad_state(
+                    account_id=account_id,
+                    adgroup_id=adgroup_id,
+                    adgroup_name=str(ad.get("adgroup_name") or ""),
+                    bid_field=bid_field,
+                    base_bid_fen=base_bid,
+                    boosted_date=plan["boosted_date"],
+                    paused_by_strategy=plan["paused_by_strategy"],
+                    pause_reason=plan["pause_reason"],
+                    last_action=decision,
+                    action_at=now,
+                )
+                if operator_pause_expired:
+                    clear_operator_pause(
+                        account_id,
+                        adgroup_id,
+                        action="OPERATOR_DAY_PAUSE_EXPIRED",
+                        action_at=now,
+                    )
+            if apply and (needs_api or state is None or execution_status == "failed"):
+                insert_action_log(
+                    {
+                        "run_id": run_id,
+                        "control_date": now.date(),
+                        "observed_partition": observed_partition,
+                        "observed_cpm": observed_cpm,
+                        "decision": decision,
+                        "account_id": account_id,
+                        "adgroup_id": adgroup_id,
+                        "adgroup_name": ad.get("adgroup_name"),
+                        "bid_field": bid_field,
+                        "base_bid_fen": base_bid,
+                        "before_bid_fen": current_bid,
+                        "target_bid_fen": plan["target_bid_fen"],
+                        "before_status": current_status,
+                        "target_status": plan["target_status"],
+                        "apply_mode": True,
+                        "execution_status": execution_status,
+                        "error_message": error,
+                    }
+                )
+
+    notification = {"status": "dry_run" if not apply else "no_actions"}
+    if apply or test_notification:
+        from feishu_notifier import send_action_notification
+
+        successful_actions: dict[int, list[dict[str, Any]]] = {}
+        expected_status = "success" if apply else "planned"
+        for result in results:
+            if result.get("status") != expected_status:
+                continue
+            if not (
+                result.get("bid_change_needed")
+                or result.get("begin_date_change_needed")
+                or (
+                    result.get("decision") in (DECISION_PAUSE, DECISION_CUTOFF)
+                    and result.get("status_change_needed")
+                )
+            ):
+                continue
+            successful_actions.setdefault(int(result["account_id"]), []).append(
+                result
+            )
+
+        for account_id, account_results in successful_actions.items():
+            try:
+                metrics = tencent.get_today_ad_metrics(
+                    account_id,
+                    [int(row["adgroup_id"]) for row in account_results],
+                    now.date(),
+                )
+                for result in account_results:
+                    ad_metrics = metrics.get(int(result["adgroup_id"]), {})
+                    result["today_cost_fen"] = int(
+                        ad_metrics.get("cost_fen") or 0
+                    )
+                    result["today_impressions"] = int(
+                        ad_metrics.get("impressions") or 0
+                    )
+                    result["today_clicks"] = int(
+                        ad_metrics.get("clicks") or 0
+                    )
+            except Exception as exc:
+                logger.warning(
+                    "Failed to fetch today's metrics account=%s: %s",
+                    account_id,
+                    exc,
+                )
+
+        notification = send_action_notification(
+            run_id=run_id,
+            now=now,
+            results=results,
+            bid_up_ratio=config.bid_up_ratio,
+            decision=decision,
+            observed_partition=observed_partition,
+            observed_cpm=observed_cpm,
+            test_mode=test_notification,
+        )
+        if apply:
+            try:
+                from feishu_notifier import (
+                    send_operator_reconciliation_notification,
+                )
+
+                send_operator_reconciliation_notification(results)
+            except Exception:
+                logger.exception(
+                    "Failed to notify operator manual override reconciliation"
+                )
+
+    return {
+        "failures": failures,
+        "api_updates": api_updates,
+        "results": results,
+        "notification": notification,
+    }
+
+
+def run_cycle(
+    *,
+    now: datetime,
+    apply: bool,
+    cpm_override: Decimal | None = None,
+    force_refresh: bool = False,
+    test_notification: bool = False,
+) -> dict[str, Any]:
+    from odps_source import HourlyCpm, build_odps_client, fetch_latest_cpm
+    from realtime_config import RealtimeControlConfig
+    from storage import (
+        advisory_lock,
+        load_daily_state,
+        load_realtime_accounts,
+        save_daily_state,
+    )
+    from tencent_client import TencentClient
+
+    config = RealtimeControlConfig.from_env()
+    run_id = str(uuid.uuid4())
+    payload: dict[str, Any] = {
+        "run_id": run_id,
+        "now": now.isoformat(),
+        "apply": apply,
+        "decision": None,
+        "observed_partition": None,
+        "observed_cpm": None,
+        "summary": {},
+    }
+
+    with advisory_lock(config.lock_name) as acquired:
+        if not acquired:
+            payload["decision"] = "LOCKED_BY_OTHER_WORKER"
+            return payload
+
+        daily_state = load_daily_state(now.date())
+        accounts = load_realtime_accounts()
+        if now.hour < config.start_hour:
+            payload["decision"] = DECISION_OFF_HOURS
+            return payload
+
+        if now.hour >= config.stop_hour:
+            payload["decision"] = DECISION_CUTOFF
+            if apply and bool(daily_state.get("cutoff_done")) and not force_refresh:
+                payload["summary"] = {"status": "cutoff_already_done"}
+                return payload
+            execution = execute_inventory_action(
+                run_id=run_id,
+                now=now,
+                decision=DECISION_RESTORE,
+                observed_partition=None,
+                observed_cpm=None,
+                apply=apply,
+                accounts=accounts,
+                config=config,
+                tencent=TencentClient(),
+                test_notification=test_notification,
+            )
+            payload["summary"] = execution
+            if apply and not execution["failures"]:
+                save_daily_state(
+                    now.date(),
+                    cutoff_done=True,
+                    last_decision=DECISION_CUTOFF,
+                    last_inventory_refresh_at=now,
+                    last_evaluated_at=now,
+                )
+            return payload
+
+        if apply and not bool(daily_state.get("morning_recovery_done")):
+            execution = execute_inventory_action(
+                run_id=run_id,
+                now=now,
+                decision=DECISION_RESTORE,
+                observed_partition=None,
+                observed_cpm=None,
+                apply=True,
+                accounts=accounts,
+                config=config,
+                tencent=TencentClient(),
+                test_notification=test_notification,
+            )
+            payload["decision"] = "MORNING_RECOVERY"
+            payload["summary"] = execution
+            if not execution["failures"]:
+                save_daily_state(
+                    now.date(),
+                    morning_recovery_done=True,
+                    last_inventory_refresh_at=now,
+                    last_evaluated_at=now,
+                )
+            else:
+                return payload
+
+        if cpm_override is not None:
+            signal = HourlyCpm(
+                partition=f"{now.strftime('%Y%m%d%H')}_override",
+                hour_of_day=now.hour,
+                impressions=0,
+                cpm=float(cpm_override),
+            )
+        else:
+            signal = fetch_latest_cpm(build_odps_client(), now.date())
+        earliest_signal_hour = max(0, config.start_hour - 1)
+        if signal is None or signal.hour_of_day < earliest_signal_hour:
+            payload["decision"] = DECISION_WAIT_DATA
+            payload["summary"] = {
+                "reason": (
+                    "No same-day CPM partition at or after "
+                    f"{earliest_signal_hour:02d}:00"
+                )
+            }
+            return payload
+
+        observed_cpm = Decimal(str(signal.cpm))
+        lag_hours = partition_lag_hours(signal.partition, now)
+        payload.update(
+            {
+                "observed_partition": signal.partition,
+                "observed_cpm": float(observed_cpm),
+                "impressions": signal.impressions,
+                "partition_lag_hours": round(lag_hours, 4),
+            }
+        )
+        if lag_hours < 0 or lag_hours > config.max_partition_lag_hours:
+            payload["decision"] = DECISION_WAIT_DATA
+            payload["summary"] = {
+                "reason": (
+                    f"CPM partition lag {lag_hours:.2f}h exceeds allowed "
+                    f"{config.max_partition_lag_hours}h"
+                )
+            }
+            return payload
+
+        decision = decide(observed_cpm, config.low_cpm, config.high_cpm)
+        payload.update(
+            {
+                "decision": decision,
+            }
+        )
+
+        refresh = force_refresh or should_refresh_inventory(
+            daily_state,
+            observed_partition=signal.partition,
+            decision=decision,
+            now=now,
+            refresh_minutes=config.inventory_refresh_minutes,
+        )
+        if apply and not refresh:
+            save_daily_state(now.date(), last_evaluated_at=now)
+            payload["summary"] = {"status": "same_signal_noop"}
+            return payload
+
+        execution = execute_inventory_action(
+            run_id=run_id,
+            now=now,
+            decision=decision,
+            observed_partition=signal.partition,
+            observed_cpm=observed_cpm,
+            apply=apply,
+            accounts=accounts,
+            config=config,
+            tencent=TencentClient(),
+            test_notification=test_notification,
+        )
+        payload["summary"] = execution
+        if apply and not execution["failures"]:
+            save_daily_state(
+                now.date(),
+                last_observed_partition=signal.partition,
+                last_observed_cpm=observed_cpm,
+                last_decision=decision,
+                last_inventory_refresh_at=now,
+                last_evaluated_at=now,
+            )
+        return payload
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--apply",
+        action="store_true",
+        help="Execute Tencent updates. Default is dry-run.",
+    )
+    parser.add_argument(
+        "--at",
+        help="Dry-run only: simulate Shanghai local time, ISO format.",
+    )
+    parser.add_argument(
+        "--cpm-override",
+        type=Decimal,
+        help="Dry-run only: override latest CPM to validate decision branches.",
+    )
+    parser.add_argument("--force-refresh", action="store_true")
+    parser.add_argument(
+        "--test-notification",
+        action="store_true",
+        help=(
+            "Dry-run only: use real inventory and metrics, then send a clearly "
+            "marked simulated Feishu action sheet."
+        ),
+    )
+    return parser.parse_args()
+
+
+def main() -> int:
+    load_environment()
+    args = parse_args()
+    if args.apply and (args.at or args.cpm_override is not None):
+        raise ValueError("--at and --cpm-override are forbidden with --apply")
+    if args.apply and args.test_notification:
+        raise ValueError("--test-notification is forbidden with --apply")
+    now = (
+        datetime.fromisoformat(args.at).replace(tzinfo=SHANGHAI)
+        if args.at
+        else datetime.now(SHANGHAI)
+    )
+    payload = run_cycle(
+        now=now,
+        apply=args.apply,
+        cpm_override=args.cpm_override,
+        force_refresh=args.force_refresh,
+        test_notification=args.test_notification,
+    )
+    report = write_run_report(payload)
+    compact = {
+        "decision": payload["decision"],
+        "observed_partition": payload.get("observed_partition"),
+        "observed_cpm": payload.get("observed_cpm"),
+        "apply": payload["apply"],
+        "api_updates": payload.get("summary", {}).get("api_updates", 0),
+        "failures": payload.get("summary", {}).get("failures", 0),
+        "notification": payload.get("summary", {}).get("notification", {}).get(
+            "status"
+        ),
+        "report": str(report),
+    }
+    print(json.dumps(compact, ensure_ascii=False))
+    return 1 if compact["failures"] else 0
+
+
+if __name__ == "__main__":
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s %(levelname)s %(name)s %(message)s",
+    )
+    try:
+        raise SystemExit(main())
+    except Exception as exc:
+        logger.exception("Real-time control failed: %s", exc)
+        raise SystemExit(1)

+ 112 - 0
examples/tencent_realtime_control/run_scheduler.py

@@ -0,0 +1,112 @@
+#!/usr/bin/env python
+"""Run real-time control every ten minutes during delivery hours."""
+
+from __future__ import annotations
+
+import argparse
+import logging
+import signal
+import time
+from datetime import datetime, timedelta
+from zoneinfo import ZoneInfo
+
+from realtime_config import RealtimeControlConfig
+from run_once import load_environment, run_cycle, write_run_report
+
+
+SHANGHAI = ZoneInfo("Asia/Shanghai")
+logger = logging.getLogger("tencent_realtime_control.scheduler")
+stopping = False
+
+
+def request_stop(signum: int, _frame: object) -> None:
+    global stopping
+    logger.info("Received signal %s; scheduler will stop.", signum)
+    stopping = True
+
+
+def next_wake(now: datetime, config: RealtimeControlConfig) -> datetime:
+    if now.hour >= config.stop_hour:
+        tomorrow = now.date() + timedelta(days=1)
+        return datetime.combine(
+            tomorrow,
+            datetime.min.time().replace(hour=config.start_hour),
+            SHANGHAI,
+        )
+    if now.hour < config.start_hour:
+        return datetime.combine(
+            now.date(),
+            datetime.min.time().replace(hour=config.start_hour),
+            SHANGHAI,
+        )
+    seconds = int(now.timestamp())
+    next_boundary = (
+        seconds // config.poll_seconds + 1
+    ) * config.poll_seconds
+    return datetime.fromtimestamp(next_boundary, SHANGHAI)
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--apply",
+        action="store_true",
+        help="Execute Tencent updates. Default scheduler is dry-run.",
+    )
+    return parser.parse_args()
+
+
+def run_forever(*, apply: bool, install_signal_handlers: bool = True) -> None:
+    config = RealtimeControlConfig.from_env()
+    if install_signal_handlers:
+        signal.signal(signal.SIGINT, request_stop)
+        signal.signal(signal.SIGTERM, request_stop)
+
+    logger.info(
+        "Scheduler started apply=%s hours=%02d:00-%02d:00 interval=%ss",
+        apply,
+        config.start_hour,
+        config.stop_hour,
+        config.poll_seconds,
+    )
+    while not stopping:
+        try:
+            payload = run_cycle(
+                now=datetime.now(SHANGHAI),
+                apply=apply,
+            )
+            report = write_run_report(payload)
+            logger.info(
+                "Cycle decision=%s cpm=%s updates=%s failures=%s notification=%s report=%s",
+                payload.get("decision"),
+                payload.get("observed_cpm"),
+                (payload.get("summary") or {}).get("api_updates", 0),
+                (payload.get("summary") or {}).get("failures", 0),
+                ((payload.get("summary") or {}).get("notification") or {}).get(
+                    "status"
+                ),
+                report,
+            )
+        except Exception:
+            logger.exception("Real-time control cycle failed")
+
+        wake_at = next_wake(datetime.now(SHANGHAI), config)
+        while not stopping:
+            remaining = (wake_at - datetime.now(SHANGHAI)).total_seconds()
+            if remaining <= 0:
+                break
+            time.sleep(min(remaining, 5))
+
+
+def main() -> None:
+    load_environment()
+    args = parse_args()
+    run_forever(apply=args.apply)
+
+
+if __name__ == "__main__":
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s %(levelname)s %(name)s %(message)s",
+    )
+    main()

+ 105 - 0
examples/tencent_realtime_control/schema.sql

@@ -0,0 +1,105 @@
+CREATE TABLE IF NOT EXISTS realtime_control_daily_state (
+    control_date DATE NOT NULL PRIMARY KEY,
+    morning_recovery_done BOOLEAN NOT NULL DEFAULT FALSE,
+    cutoff_done BOOLEAN NOT NULL DEFAULT FALSE,
+    last_observed_partition VARCHAR(20) DEFAULT NULL,
+    last_observed_cpm DECIMAL(12, 4) DEFAULT NULL,
+    last_decision VARCHAR(32) DEFAULT NULL,
+    last_inventory_refresh_at DATETIME DEFAULT NULL,
+    last_evaluated_at DATETIME DEFAULT NULL,
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实时CPM调控每日状态';
+
+CREATE TABLE IF NOT EXISTS realtime_control_ad_state (
+    account_id BIGINT NOT NULL,
+    adgroup_id BIGINT NOT NULL,
+    adgroup_name VARCHAR(255) DEFAULT NULL,
+    bid_field VARCHAR(32) NOT NULL,
+    base_bid_fen INT NOT NULL,
+    boosted_date DATE DEFAULT NULL,
+    paused_by_strategy BOOLEAN NOT NULL DEFAULT FALSE,
+    pause_reason VARCHAR(32) DEFAULT NULL,
+    operator_pause_mode VARCHAR(32) DEFAULT NULL,
+    operator_resume_at DATETIME DEFAULT NULL,
+    operator_command_id VARCHAR(64) DEFAULT NULL,
+    operator_paused_from_status VARCHAR(50) DEFAULT NULL,
+    operator_paused_at DATETIME DEFAULT NULL,
+    last_action VARCHAR(32) DEFAULT NULL,
+    last_action_at DATETIME DEFAULT NULL,
+    last_seen_at DATETIME DEFAULT NULL,
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (account_id, adgroup_id),
+    KEY idx_strategy_pause (paused_by_strategy),
+    KEY idx_operator_pause (operator_pause_mode, operator_resume_at),
+    KEY idx_boosted_date (boosted_date),
+    KEY idx_last_seen (last_seen_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实时CPM调控广告基准与状态';
+
+CREATE TABLE IF NOT EXISTS realtime_control_action_log (
+    id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+    run_id VARCHAR(36) NOT NULL,
+    control_date DATE NOT NULL,
+    observed_partition VARCHAR(20) DEFAULT NULL,
+    observed_cpm DECIMAL(12, 4) DEFAULT NULL,
+    decision VARCHAR(32) NOT NULL,
+    account_id BIGINT NOT NULL,
+    adgroup_id BIGINT NOT NULL,
+    adgroup_name VARCHAR(255) DEFAULT NULL,
+    bid_field VARCHAR(32) DEFAULT NULL,
+    base_bid_fen INT DEFAULT NULL,
+    before_bid_fen INT DEFAULT NULL,
+    target_bid_fen INT DEFAULT NULL,
+    before_status VARCHAR(50) DEFAULT NULL,
+    target_status VARCHAR(50) DEFAULT NULL,
+    apply_mode BOOLEAN NOT NULL DEFAULT FALSE,
+    execution_status VARCHAR(32) NOT NULL,
+    error_message TEXT DEFAULT NULL,
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    KEY idx_run_id (run_id),
+    KEY idx_control_date (control_date),
+    KEY idx_adgroup (account_id, adgroup_id),
+    KEY idx_execution_status (execution_status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实时CPM调控动作审计';
+
+CREATE TABLE IF NOT EXISTS operator_command (
+    command_id VARCHAR(64) NOT NULL PRIMARY KEY,
+    source_message_id VARCHAR(128) NOT NULL,
+    chat_id VARCHAR(128) NOT NULL,
+    sender_open_id VARCHAR(128) NOT NULL,
+    sender_name VARCHAR(255) DEFAULT NULL,
+    action VARCHAR(32) NOT NULL,
+    scope_type VARCHAR(32) NOT NULL,
+    target_account_ids TEXT NOT NULL,
+    status VARCHAR(32) NOT NULL,
+    preview_account_count INT NOT NULL DEFAULT 0,
+    preview_ad_count INT NOT NULL DEFAULT 0,
+    expires_at DATETIME DEFAULT NULL,
+    confirmed_at DATETIME DEFAULT NULL,
+    executed_at DATETIME DEFAULT NULL,
+    error_message TEXT DEFAULT NULL,
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_operator_source_message (source_message_id),
+    KEY idx_operator_status_expiry (status, expires_at),
+    KEY idx_operator_sender (sender_open_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='飞书运营控制命令';
+
+CREATE TABLE IF NOT EXISTS operator_command_item (
+    id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+    command_id VARCHAR(64) NOT NULL,
+    account_id BIGINT NOT NULL,
+    audience_name VARCHAR(200) DEFAULT NULL,
+    adgroup_id BIGINT DEFAULT NULL,
+    adgroup_name VARCHAR(255) DEFAULT NULL,
+    before_status VARCHAR(50) DEFAULT NULL,
+    target_status VARCHAR(50) DEFAULT NULL,
+    readback_status VARCHAR(50) DEFAULT NULL,
+    execution_status VARCHAR(32) NOT NULL,
+    error_message TEXT DEFAULT NULL,
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    KEY idx_operator_item_command (command_id),
+    KEY idx_operator_item_ad (account_id, adgroup_id),
+    KEY idx_operator_item_status (execution_status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='飞书运营控制命令广告明细';

+ 551 - 0
examples/tencent_realtime_control/storage.py

@@ -0,0 +1,551 @@
+"""MySQL state and audit storage for real-time control."""
+
+from __future__ import annotations
+
+import os
+import json
+from contextlib import contextmanager
+from datetime import date, datetime
+from pathlib import Path
+from typing import Any, Iterator
+
+import pymysql
+import pymysql.cursors
+
+
+ROOT = Path(__file__).resolve().parent
+
+
+def connect() -> pymysql.Connection:
+    required = ["DB_HOST", "DB_USER", "DB_NAME"]
+    missing = [key for key in required if not os.getenv(key)]
+    if missing:
+        raise RuntimeError(f"Missing database environment variables: {', '.join(missing)}")
+    return pymysql.connect(
+        host=os.environ["DB_HOST"],
+        port=int(os.getenv("DB_PORT", "3306")),
+        user=os.environ["DB_USER"],
+        password=os.getenv("DB_PASSWORD", ""),
+        database=os.environ["DB_NAME"],
+        charset="utf8mb4",
+        cursorclass=pymysql.cursors.DictCursor,
+        autocommit=True,
+        connect_timeout=int(os.getenv("DB_CONNECT_TIMEOUT", "10")),
+        read_timeout=int(os.getenv("DB_READ_TIMEOUT", "60")),
+        write_timeout=int(os.getenv("DB_WRITE_TIMEOUT", "60")),
+    )
+
+
+def initialize_schema() -> None:
+    statements = [
+        statement.strip()
+        for statement in (ROOT / "schema.sql").read_text(encoding="utf-8").split(";")
+        if statement.strip()
+    ]
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            for statement in statements:
+                cursor.execute(statement)
+            cursor.execute(
+                """
+                SELECT COLUMN_NAME
+                FROM information_schema.COLUMNS
+                WHERE TABLE_SCHEMA=%s
+                  AND TABLE_NAME='realtime_control_ad_state'
+                """,
+                (os.environ["DB_NAME"],),
+            )
+            existing_columns = {row["COLUMN_NAME"] for row in cursor.fetchall()}
+            migrations = {
+                "operator_pause_mode": "VARCHAR(32) DEFAULT NULL",
+                "operator_resume_at": "DATETIME DEFAULT NULL",
+                "operator_command_id": "VARCHAR(64) DEFAULT NULL",
+                "operator_paused_from_status": "VARCHAR(50) DEFAULT NULL",
+                "operator_paused_at": "DATETIME DEFAULT NULL",
+            }
+            for column, definition in migrations.items():
+                if column not in existing_columns:
+                    cursor.execute(
+                        f"ALTER TABLE realtime_control_ad_state "
+                        f"ADD COLUMN {column} {definition}"
+                    )
+            cursor.execute(
+                """
+                SELECT INDEX_NAME
+                FROM information_schema.STATISTICS
+                WHERE TABLE_SCHEMA=%s
+                  AND TABLE_NAME='realtime_control_ad_state'
+                  AND INDEX_NAME='idx_operator_pause'
+                """,
+                (os.environ["DB_NAME"],),
+            )
+            if not cursor.fetchone():
+                cursor.execute(
+                    """
+                    CREATE INDEX idx_operator_pause
+                    ON realtime_control_ad_state
+                        (operator_pause_mode, operator_resume_at)
+                    """
+                )
+    finally:
+        connection.close()
+
+
+@contextmanager
+def advisory_lock(lock_name: str) -> Iterator[bool]:
+    connection = connect()
+    acquired = False
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute("SELECT GET_LOCK(%s, 0) AS acquired", (lock_name,))
+            acquired = bool((cursor.fetchone() or {}).get("acquired"))
+        yield acquired
+    finally:
+        if acquired:
+            try:
+                with connection.cursor() as cursor:
+                    cursor.execute("SELECT RELEASE_LOCK(%s)", (lock_name,))
+            except Exception:
+                pass
+        connection.close()
+
+
+def load_enabled_accounts() -> list[dict[str, Any]]:
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                SELECT c.account_id, c.audience_name, c.bid_scene
+                FROM ad_creation_account_config c
+                JOIN account_whitelist w ON w.account_id = c.account_id
+                WHERE c.enabled = TRUE
+                  AND w.enabled = TRUE
+                ORDER BY c.account_id
+                """
+            )
+            return list(cursor.fetchall())
+    finally:
+        connection.close()
+
+
+def load_realtime_accounts() -> list[dict[str, Any]]:
+    """All historical automation accounts still enabled by the whitelist."""
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                SELECT c.account_id, c.audience_name, c.bid_scene
+                FROM ad_creation_account_config c
+                JOIN account_whitelist w ON w.account_id = c.account_id
+                WHERE w.enabled = TRUE
+                ORDER BY c.account_id
+                """
+            )
+            return list(cursor.fetchall())
+    finally:
+        connection.close()
+
+
+def load_managed_accounts() -> list[dict[str, Any]]:
+    """Historical automation accounts still allowed by the account whitelist."""
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                SELECT c.account_id, c.audience_name, c.bid_scene, c.enabled
+                FROM ad_creation_account_config c
+                JOIN account_whitelist w ON w.account_id = c.account_id
+                WHERE w.enabled = TRUE
+                ORDER BY c.account_id
+                """
+            )
+            return list(cursor.fetchall())
+    finally:
+        connection.close()
+
+
+def load_daily_state(control_date: date) -> dict[str, Any]:
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                "SELECT * FROM realtime_control_daily_state WHERE control_date=%s",
+                (control_date,),
+            )
+            return cursor.fetchone() or {}
+    finally:
+        connection.close()
+
+
+def save_daily_state(control_date: date, **values: Any) -> None:
+    allowed = {
+        "morning_recovery_done",
+        "cutoff_done",
+        "last_observed_partition",
+        "last_observed_cpm",
+        "last_decision",
+        "last_inventory_refresh_at",
+        "last_evaluated_at",
+    }
+    unknown = set(values) - allowed
+    if unknown:
+        raise ValueError(f"Unsupported daily-state fields: {sorted(unknown)}")
+    columns = ["control_date", *values]
+    params = [control_date, *values.values()]
+    updates = ", ".join(f"{column}=VALUES({column})" for column in values)
+    placeholders = ", ".join(["%s"] * len(columns))
+    sql = (
+        f"INSERT INTO realtime_control_daily_state ({', '.join(columns)}) "
+        f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}"
+    )
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(sql, params)
+    finally:
+        connection.close()
+
+
+def load_ad_states(account_id: int) -> dict[int, dict[str, Any]]:
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                "SELECT * FROM realtime_control_ad_state WHERE account_id=%s",
+                (account_id,),
+            )
+            return {int(row["adgroup_id"]): row for row in cursor.fetchall()}
+    finally:
+        connection.close()
+
+
+def upsert_ad_state(
+    *,
+    account_id: int,
+    adgroup_id: int,
+    adgroup_name: str,
+    bid_field: str,
+    base_bid_fen: int,
+    boosted_date: date | None,
+    paused_by_strategy: bool,
+    pause_reason: str | None,
+    last_action: str,
+    action_at: datetime,
+) -> None:
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                INSERT INTO realtime_control_ad_state
+                    (account_id, adgroup_id, adgroup_name, bid_field, base_bid_fen,
+                     boosted_date, paused_by_strategy, pause_reason, last_action,
+                     last_action_at, last_seen_at)
+                VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
+                ON DUPLICATE KEY UPDATE
+                    adgroup_name=VALUES(adgroup_name),
+                    bid_field=VALUES(bid_field),
+                    boosted_date=VALUES(boosted_date),
+                    paused_by_strategy=VALUES(paused_by_strategy),
+                    pause_reason=VALUES(pause_reason),
+                    last_action=VALUES(last_action),
+                    last_action_at=VALUES(last_action_at),
+                    last_seen_at=VALUES(last_seen_at)
+                """,
+                (
+                    account_id,
+                    adgroup_id,
+                    adgroup_name,
+                    bid_field,
+                    base_bid_fen,
+                    boosted_date,
+                    paused_by_strategy,
+                    pause_reason,
+                    last_action,
+                    action_at,
+                    action_at,
+                ),
+            )
+    finally:
+        connection.close()
+
+def insert_action_log(record: dict[str, Any]) -> None:
+    columns = [
+        "run_id",
+        "control_date",
+        "observed_partition",
+        "observed_cpm",
+        "decision",
+        "account_id",
+        "adgroup_id",
+        "adgroup_name",
+        "bid_field",
+        "base_bid_fen",
+        "before_bid_fen",
+        "target_bid_fen",
+        "before_status",
+        "target_status",
+        "apply_mode",
+        "execution_status",
+        "error_message",
+    ]
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                f"INSERT INTO realtime_control_action_log ({', '.join(columns)}) "
+                f"VALUES ({', '.join(['%s'] * len(columns))})",
+                [record.get(column) for column in columns],
+            )
+    finally:
+        connection.close()
+
+
+def create_operator_command(record: dict[str, Any]) -> dict[str, Any]:
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            try:
+                cursor.execute(
+                    """
+                    INSERT INTO operator_command
+                        (command_id, source_message_id, chat_id, sender_open_id,
+                         sender_name, action, scope_type, target_account_ids,
+                         status, preview_account_count, preview_ad_count, expires_at)
+                    VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
+                    """,
+                    (
+                        record["command_id"],
+                        record["source_message_id"],
+                        record["chat_id"],
+                        record["sender_open_id"],
+                        record.get("sender_name"),
+                        record["action"],
+                        record["scope_type"],
+                        json.dumps(record["target_account_ids"]),
+                        record["status"],
+                        record.get("preview_account_count", 0),
+                        record.get("preview_ad_count", 0),
+                        record.get("expires_at"),
+                    ),
+                )
+            except pymysql.err.IntegrityError:
+                cursor.execute(
+                    "SELECT * FROM operator_command WHERE source_message_id=%s",
+                    (record["source_message_id"],),
+                )
+                existing = cursor.fetchone()
+                if existing:
+                    existing["target_account_ids"] = json.loads(
+                        existing.get("target_account_ids") or "[]"
+                    )
+                    return existing
+                raise
+        return load_operator_command(record["command_id"]) or {}
+    finally:
+        connection.close()
+
+
+def load_operator_command(command_id: str) -> dict[str, Any] | None:
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                "SELECT * FROM operator_command WHERE command_id=%s",
+                (command_id,),
+            )
+            row = cursor.fetchone()
+            if row:
+                row["target_account_ids"] = json.loads(
+                    row.get("target_account_ids") or "[]"
+                )
+            return row
+    finally:
+        connection.close()
+
+
+def update_operator_command(command_id: str, status: str, **values: Any) -> None:
+    allowed = {"confirmed_at", "executed_at", "error_message"}
+    unknown = set(values) - allowed
+    if unknown:
+        raise ValueError(f"Unsupported operator-command fields: {sorted(unknown)}")
+    assignments = ["status=%s"]
+    params: list[Any] = [status]
+    for column, value in values.items():
+        assignments.append(f"{column}=%s")
+        params.append(value)
+    params.append(command_id)
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                f"UPDATE operator_command SET {', '.join(assignments)} "
+                "WHERE command_id=%s",
+                params,
+            )
+    finally:
+        connection.close()
+
+
+def transition_operator_command(
+    command_id: str,
+    *,
+    expected_statuses: set[str],
+    target_status: str,
+    **values: Any,
+) -> bool:
+    allowed = {"confirmed_at", "executed_at", "error_message"}
+    unknown = set(values) - allowed
+    if unknown:
+        raise ValueError(f"Unsupported operator-command fields: {sorted(unknown)}")
+    if not expected_statuses:
+        raise ValueError("expected_statuses must not be empty")
+    assignments = ["status=%s"]
+    params: list[Any] = [target_status]
+    for column, value in values.items():
+        assignments.append(f"{column}=%s")
+        params.append(value)
+    placeholders = ", ".join(["%s"] * len(expected_statuses))
+    params.extend([command_id, *sorted(expected_statuses)])
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            affected = cursor.execute(
+                f"""
+                UPDATE operator_command
+                SET {', '.join(assignments)}
+                WHERE command_id=%s
+                  AND status IN ({placeholders})
+                """,
+                params,
+            )
+            return affected == 1
+    finally:
+        connection.close()
+
+
+def insert_operator_command_item(record: dict[str, Any]) -> None:
+    columns = [
+        "command_id",
+        "account_id",
+        "audience_name",
+        "adgroup_id",
+        "adgroup_name",
+        "before_status",
+        "target_status",
+        "readback_status",
+        "execution_status",
+        "error_message",
+    ]
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                f"INSERT INTO operator_command_item ({', '.join(columns)}) "
+                f"VALUES ({', '.join(['%s'] * len(columns))})",
+                [record.get(column) for column in columns],
+            )
+    finally:
+        connection.close()
+
+
+def set_operator_pause(
+    *,
+    account_id: int,
+    adgroup_id: int,
+    mode: str,
+    resume_at: datetime | None,
+    command_id: str,
+    paused_from_status: str,
+    paused_at: datetime,
+) -> None:
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                UPDATE realtime_control_ad_state
+                SET operator_pause_mode=%s,
+                    operator_resume_at=%s,
+                    operator_command_id=%s,
+                    operator_paused_from_status=%s,
+                    operator_paused_at=%s,
+                    last_action='OPERATOR_PAUSE',
+                    last_action_at=%s,
+                    last_seen_at=%s
+                WHERE account_id=%s AND adgroup_id=%s
+                """,
+                (
+                    mode,
+                    resume_at,
+                    command_id,
+                    paused_from_status,
+                    paused_at,
+                    paused_at,
+                    paused_at,
+                    account_id,
+                    adgroup_id,
+                ),
+            )
+    finally:
+        connection.close()
+
+
+def clear_operator_pause(
+    account_id: int,
+    adgroup_id: int,
+    *,
+    action: str,
+    action_at: datetime,
+) -> None:
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                UPDATE realtime_control_ad_state
+                SET operator_pause_mode=NULL,
+                    operator_resume_at=NULL,
+                    operator_command_id=NULL,
+                    operator_paused_from_status=NULL,
+                    operator_paused_at=NULL,
+                    last_action=%s,
+                    last_action_at=%s,
+                    last_seen_at=%s
+                WHERE account_id=%s AND adgroup_id=%s
+                """,
+                (action, action_at, action_at, account_id, adgroup_id),
+            )
+    finally:
+        connection.close()
+
+
+def load_operator_pauses(
+    account_ids: list[int] | None = None,
+) -> list[dict[str, Any]]:
+    params: list[Any] = []
+    where = "WHERE operator_pause_mode IS NOT NULL"
+    if account_ids is not None:
+        if not account_ids:
+            return []
+        where += f" AND account_id IN ({', '.join(['%s'] * len(account_ids))})"
+        params.extend(account_ids)
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                f"""
+                SELECT *
+                FROM realtime_control_ad_state
+                {where}
+                ORDER BY account_id, adgroup_id
+                """,
+                params,
+            )
+            return list(cursor.fetchall())
+    finally:
+        connection.close()

+ 462 - 0
examples/tencent_realtime_control/tencent_client.py

@@ -0,0 +1,462 @@
+"""Minimal Tencent Ads read/write client for real-time control."""
+
+from __future__ import annotations
+
+import json
+import os
+import time
+import uuid
+from datetime import date
+from typing import Any
+
+import requests
+
+from storage import connect
+
+
+ACTIVE_STATUS = "AD_STATUS_NORMAL"
+SUSPEND_STATUS = "AD_STATUS_SUSPEND"
+AD_FIELDS = [
+    "adgroup_id",
+    "adgroup_name",
+    "configured_status",
+    "system_status",
+    "bid_amount",
+    "custom_cost_cap",
+    "smart_bid_type",
+    "cost_constraint_scene",
+    "begin_date",
+    "end_date",
+    "time_series",
+]
+
+
+class PostWriteVerificationError(RuntimeError):
+    """Tencent accepted a write, but the read-back did not converge in time."""
+
+    def __init__(
+        self,
+        *,
+        account_id: int,
+        adgroup_id: int,
+        expected: dict[str, Any],
+        actual: dict[str, Any],
+    ) -> None:
+        self.account_id = account_id
+        self.adgroup_id = adgroup_id
+        self.expected = expected
+        self.actual = actual
+        super().__init__(
+            "Post-write verification failed: "
+            f"account={account_id} adgroup={adgroup_id} "
+            f"expected={expected} actual={actual}"
+        )
+
+
+class TencentWriteNotSentError(RuntimeError):
+    """The request failed before Tencent received the write request."""
+
+
+class TencentWriteRejectedError(RuntimeError):
+    """Tencent explicitly rejected the write request."""
+
+
+class TencentWriteOutcomeUnknownError(RuntimeError):
+    """The request may have reached Tencent, but no definitive result exists."""
+
+
+class TencentClient:
+    def __init__(self) -> None:
+        self.base_url = os.getenv(
+            "TENCENT_AD_BASE_URL", "https://api.e.qq.com/v3.0"
+        ).rstrip("/")
+        self.access_token_api = os.getenv(
+            "TENCENT_AD_TOKEN_API",
+            "https://api.piaoquantv.com/ad/put/tencent/getAccessToken",
+        )
+        self.user_token_api = os.getenv(
+            "TENCENT_AD_USER_TOKEN_API",
+            "https://api.piaoquantv.com/ad/put/tencent/getUserToken",
+        )
+        self.timeout = int(os.getenv("TENCENT_AD_TIMEOUT_SECONDS", "30"))
+        self.verify_attempts = int(os.getenv("RTC_VERIFY_ATTEMPTS", "3"))
+        self.verify_delay_seconds = float(
+            os.getenv("RTC_VERIFY_DELAY_SECONDS", "1")
+        )
+        if self.verify_attempts < 1:
+            raise ValueError("RTC_VERIFY_ATTEMPTS must be at least 1")
+        if self.verify_delay_seconds < 0:
+            raise ValueError("RTC_VERIFY_DELAY_SECONDS must not be negative")
+        self._access_tokens: dict[int, str] = {}
+        self._user_tokens: dict[int, str] = {}
+        self.session = requests.Session()
+
+    def _access_token(self, account_id: int) -> str:
+        if account_id not in self._access_tokens:
+            response = self.session.get(
+                self.access_token_api,
+                params={"accountId": account_id},
+                timeout=15,
+            )
+            response.raise_for_status()
+            token = response.text.strip()
+            if len(token) <= 10:
+                raise RuntimeError(f"Invalid access token for account={account_id}")
+            self._access_tokens[account_id] = token
+        return self._access_tokens[account_id]
+
+    def _user_token(self, account_id: int) -> str:
+        if account_id in self._user_tokens:
+            return self._user_tokens[account_id]
+        token = ""
+        try:
+            response = self.session.get(
+                self.user_token_api,
+                params={"accountId": account_id},
+                timeout=15,
+            )
+            response.raise_for_status()
+            candidate = response.text.strip()
+            if len(candidate) > 10:
+                token = candidate
+        except Exception:
+            pass
+        if not token:
+            connection = connect()
+            try:
+                with connection.cursor() as cursor:
+                    cursor.execute(
+                        "SELECT user_token FROM account_whitelist WHERE account_id=%s",
+                        (account_id,),
+                    )
+                    row = cursor.fetchone()
+                    token = str((row or {}).get("user_token") or "")
+            finally:
+                connection.close()
+        if not token:
+            token = os.getenv("TENCENT_AD_USER_TOKEN", "").strip()
+        if not token:
+            raise RuntimeError(f"No user_token available for account={account_id}")
+        self._user_tokens[account_id] = token
+        return token
+
+    def _common_params(self, account_id: int) -> dict[str, Any]:
+        return {
+            "access_token": self._access_token(account_id),
+            "timestamp": int(time.time()),
+            "nonce": uuid.uuid4().hex,
+        }
+
+    @staticmethod
+    def _check(payload: dict[str, Any], operation: str) -> dict[str, Any]:
+        if payload.get("code") != 0:
+            message = payload.get("message_cn") or payload.get("message") or "unknown"
+            raise RuntimeError(
+                f"{operation} failed: code={payload.get('code')} message={message}"
+            )
+        return payload.get("data") or {}
+
+    def get_ads(self, account_id: int) -> list[dict[str, Any]]:
+        ads: list[dict[str, Any]] = []
+        page = 1
+        while True:
+            params = {
+                **self._common_params(account_id),
+                "account_id": account_id,
+                "fields": json.dumps(AD_FIELDS, ensure_ascii=False),
+                "page": page,
+                "page_size": 100,
+            }
+            response = self.session.get(
+                f"{self.base_url}/adgroups/get",
+                params=params,
+                timeout=self.timeout,
+            )
+            response.raise_for_status()
+            data = self._check(response.json(), "get_ads")
+            rows = data.get("list") or []
+            ads.extend(rows)
+            page_info = data.get("page_info") or {}
+            if page >= int(page_info.get("total_page") or 1):
+                break
+            page += 1
+        return ads
+
+    def get_ad(self, account_id: int, adgroup_id: int) -> dict[str, Any]:
+        params = {
+            **self._common_params(account_id),
+            "account_id": account_id,
+            "fields": json.dumps(AD_FIELDS, ensure_ascii=False),
+            "filtering": json.dumps(
+                [
+                    {
+                        "field": "adgroup_id",
+                        "operator": "IN",
+                        "values": [str(adgroup_id)],
+                    }
+                ]
+            ),
+            "page": 1,
+            "page_size": 10,
+        }
+        response = self.session.get(
+            f"{self.base_url}/adgroups/get",
+            params=params,
+            timeout=self.timeout,
+        )
+        response.raise_for_status()
+        data = self._check(response.json(), "get_ad")
+        rows = data.get("list") or []
+        for row in rows:
+            if int(row.get("adgroup_id") or 0) == adgroup_id:
+                return row
+        raise RuntimeError(
+            f"Ad not found after update: account={account_id} adgroup={adgroup_id}"
+        )
+
+    def update_ad(
+        self,
+        account_id: int,
+        adgroup_id: int,
+        *,
+        bid_field: str | None = None,
+        target_bid_fen: int | None = None,
+        target_status: str | None = None,
+    ) -> dict[str, Any]:
+        body: dict[str, Any] = {
+            "account_id": account_id,
+            "adgroup_id": adgroup_id,
+        }
+        if bid_field and target_bid_fen is not None:
+            body[bid_field] = target_bid_fen
+        if target_status:
+            body["configured_status"] = target_status
+        try:
+            params = {
+                **self._common_params(account_id),
+                "user_token": self._user_token(account_id),
+            }
+        except Exception as exc:
+            raise TencentWriteNotSentError(str(exc)) from exc
+        try:
+            response = self.session.post(
+                f"{self.base_url}/adgroups/update",
+                params=params,
+                json=body,
+                timeout=self.timeout,
+            )
+        except requests.RequestException as exc:
+            raise TencentWriteOutcomeUnknownError(str(exc)) from exc
+        if response.status_code == 408 or response.status_code >= 500:
+            raise TencentWriteOutcomeUnknownError(
+                f"Tencent HTTP {response.status_code}: {response.text[:500]}"
+            )
+        try:
+            response.raise_for_status()
+        except requests.HTTPError as exc:
+            raise TencentWriteRejectedError(str(exc)) from exc
+        try:
+            payload = response.json()
+        except Exception as exc:
+            raise TencentWriteOutcomeUnknownError(
+                f"Tencent returned non-JSON success response: {response.text[:500]}"
+            ) from exc
+        try:
+            self._check(payload, "update_ad")
+        except Exception as exc:
+            raise TencentWriteRejectedError(str(exc)) from exc
+
+        expected: dict[str, Any] = {}
+        if bid_field and target_bid_fen is not None:
+            expected[bid_field] = target_bid_fen
+        if target_status:
+            expected["configured_status"] = target_status
+
+        last_actual: dict[str, Any] = {}
+        for attempt in range(1, self.verify_attempts + 1):
+            try:
+                ad = self.get_ad(account_id, adgroup_id)
+                last_actual = {field: ad.get(field) for field in expected}
+                matches = True
+                for field, target in expected.items():
+                    actual = ad.get(field)
+                    if field in {"bid_amount", "custom_cost_cap"}:
+                        try:
+                            actual = int(actual)
+                        except (TypeError, ValueError):
+                            matches = False
+                            break
+                    if actual != target:
+                        matches = False
+                        break
+                if matches:
+                    return ad
+            except Exception as exc:
+                last_actual = {"verification_error": str(exc)}
+            if attempt < self.verify_attempts:
+                time.sleep(self.verify_delay_seconds)
+
+        raise PostWriteVerificationError(
+            account_id=account_id,
+            adgroup_id=adgroup_id,
+            expected=expected,
+            actual=last_actual,
+        )
+
+    def update_ad_begin_dates(
+        self,
+        account_id: int,
+        adgroup_ids: list[int],
+        begin_date: str,
+    ) -> list[dict[str, Any]]:
+        if not adgroup_ids:
+            return []
+        if len(adgroup_ids) > 100:
+            raise ValueError("Tencent update_datetime supports at most 100 ads")
+
+        specs = [
+            {"adgroup_id": adgroup_id, "begin_date": begin_date}
+            for adgroup_id in adgroup_ids
+        ]
+        params = {
+            **self._common_params(account_id),
+            "user_token": self._user_token(account_id),
+        }
+        response = self.session.post(
+            f"{self.base_url}/adgroups/update_datetime",
+            params=params,
+            json={
+                "account_id": account_id,
+                "update_datetime_spec": specs,
+            },
+            timeout=self.timeout,
+        )
+        response.raise_for_status()
+        data = self._check(response.json(), "update_ad_begin_dates")
+        item_failures = [
+            item
+            for item in data.get("list") or []
+            if int(item.get("code") or 0) != 0
+        ]
+        failed_ids = [int(value) for value in data.get("fail_id_list") or []]
+        if item_failures or failed_ids:
+            raise RuntimeError(
+                "update_ad_begin_dates partially failed: "
+                f"items={item_failures} fail_id_list={failed_ids}"
+            )
+
+        target_ids = set(adgroup_ids)
+        last_actual: dict[int, str] = {}
+        for attempt in range(1, self.verify_attempts + 1):
+            ads = {
+                int(ad.get("adgroup_id") or 0): ad
+                for ad in self.get_ads(account_id)
+                if int(ad.get("adgroup_id") or 0) in target_ids
+            }
+            last_actual = {
+                adgroup_id: str(
+                    (ads.get(adgroup_id) or {}).get("begin_date") or ""
+                )
+                for adgroup_id in adgroup_ids
+            }
+            if all(value == begin_date for value in last_actual.values()):
+                return [ads[adgroup_id] for adgroup_id in adgroup_ids]
+            if attempt < self.verify_attempts:
+                time.sleep(self.verify_delay_seconds)
+
+        raise RuntimeError(
+            "Tencent begin_date verification failed: "
+            f"account={account_id} expected={begin_date} actual={last_actual}"
+        )
+
+    def get_today_ad_metrics(
+        self,
+        account_id: int,
+        adgroup_ids: list[int],
+        data_date: date,
+    ) -> dict[int, dict[str, int]]:
+        if not adgroup_ids:
+            return {}
+
+        metrics: dict[int, dict[str, int]] = {}
+        page = 1
+        while True:
+            params = {
+                **self._common_params(account_id),
+                "account_id": account_id,
+                "level": "REPORT_LEVEL_ADGROUP",
+                "date_range": json.dumps(
+                    {
+                        "start_date": data_date.isoformat(),
+                        "end_date": data_date.isoformat(),
+                    }
+                ),
+                "group_by": json.dumps(["adgroup_id", "date"]),
+                "fields": json.dumps(
+                    [
+                        "account_id",
+                        "adgroup_id",
+                        "date",
+                        "view_count",
+                        "valid_click_count",
+                        "cost",
+                        "conversions_count",
+                    ]
+                ),
+                "filtering": json.dumps(
+                    [
+                        {
+                            "field": "adgroup_id",
+                            "operator": "IN",
+                            "values": [str(value) for value in adgroup_ids],
+                        }
+                    ]
+                ),
+                "page": page,
+                "page_size": 100,
+                "time_line": "REQUEST_TIME",
+            }
+            response = self.session.get(
+                f"{self.base_url}/daily_reports/get",
+                params=params,
+                timeout=self.timeout,
+            )
+            response.raise_for_status()
+            data = self._check(response.json(), "get_today_ad_metrics")
+            for row in data.get("list") or []:
+                adgroup_id = int(row.get("adgroup_id") or 0)
+                if adgroup_id <= 0:
+                    continue
+                metrics[adgroup_id] = {
+                    "cost_fen": int(row.get("cost") or 0),
+                    "impressions": int(row.get("view_count") or 0),
+                    "clicks": int(row.get("valid_click_count") or 0),
+                    "conversions": int(row.get("conversions_count") or 0),
+                }
+            page_info = data.get("page_info") or {}
+            if page >= int(page_info.get("total_page") or 1):
+                break
+            page += 1
+        return metrics
+
+
+def resolve_bid_field(ad: dict[str, Any], configured_bid_scene: str | None) -> str:
+    smart_bid_type = str(ad.get("smart_bid_type") or "").upper()
+    try:
+        has_custom_cost_cap = int(ad.get("custom_cost_cap") or 0) > 0
+    except (TypeError, ValueError):
+        has_custom_cost_cap = False
+    if (
+        has_custom_cost_cap
+        or smart_bid_type == "SMART_BID_TYPE_SYSTEMATIC"
+        or configured_bid_scene == "max_conversion"
+    ):
+        return "custom_cost_cap"
+    return "bid_amount"
+
+
+def current_bid_fen(ad: dict[str, Any], bid_field: str) -> int | None:
+    try:
+        return int(ad.get(bid_field))
+    except (TypeError, ValueError):
+        return None