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

feat(auto-put): improve AI creative generation flow

刘立冬 3 недель назад
Родитель
Сommit
b1d99400df
28 измененных файлов с 4197 добавлено и 199 удалено
  1. 10 4
      AGENTS.md
  2. 60 13
      examples/auto_put_ad_mini/PRODUCTION_AUTOMATION.md
  3. 8 4
      examples/auto_put_ad_mini/config.py
  4. 381 0
      examples/auto_put_ad_mini/configs/material_creative_patterns_seed.json
  5. 3 1
      examples/auto_put_ad_mini/configure_creation_accounts.py
  6. 166 0
      examples/auto_put_ad_mini/db/schema.sql
  7. 83 11
      examples/auto_put_ad_mini/debug_generate_ai_material.py
  8. 123 0
      examples/auto_put_ad_mini/debug_select_creative_patterns.py
  9. 211 0
      examples/auto_put_ad_mini/docs/ai_material_strategy_plan_2026-07-07.md
  10. 282 0
      examples/auto_put_ad_mini/docs/material_strategy_learning_db_design_2026-07-08.md
  11. 151 18
      examples/auto_put_ad_mini/execute_creation_once.py
  12. 118 0
      examples/auto_put_ad_mini/import_material_strategy_learning.py
  13. 75 0
      examples/auto_put_ad_mini/prompts/ai_cover_copy.md
  14. 54 84
      examples/auto_put_ad_mini/prompts/ai_generated_material.md
  15. 17 15
      examples/auto_put_ad_mini/prompts/ai_sanitize_video_description.md
  16. 27 0
      examples/auto_put_ad_mini/seed_material_creative_patterns.py
  17. 177 0
      examples/auto_put_ad_mini/sql/high_consumption_materials_30d.sql
  18. 1 1
      examples/auto_put_ad_mini/sync_feishu_account_config.py
  19. 64 0
      examples/auto_put_ad_mini/test_im_approval_creation.py
  20. 45 1
      examples/auto_put_ad_mini/test_landing_video_dedupe.py
  21. 71 0
      examples/auto_put_ad_mini/test_video_recall_pagination.py
  22. 425 17
      examples/auto_put_ad_mini/tools/ai_generated_material.py
  23. 228 0
      examples/auto_put_ad_mini/tools/ai_material_review.py
  24. 70 14
      examples/auto_put_ad_mini/tools/creative_creation.py
  25. 84 0
      examples/auto_put_ad_mini/tools/creative_material_usage.py
  26. 90 11
      examples/auto_put_ad_mini/tools/im_approval_creation.py
  27. 1088 0
      examples/auto_put_ad_mini/tools/material_strategy_learning.py
  28. 85 5
      examples/auto_put_ad_mini/tools/video_recall.py

+ 10 - 4
AGENTS.md

@@ -15,7 +15,7 @@
 ## 模块 B 创意创建规则
 ## 模块 B 创意创建规则
 
 
 - 目标是单广告最终合格创意数,不是单次生成的 pending 行数。
 - 目标是单广告最终合格创意数,不是单次生成的 pending 行数。
-- 当前目标:每条广告最终有效创意不少于 4 个。
+- 当前目标:每条广告最终有效创意不少于 12 个。
 - `DENIED` 创意不计入有效创意。
 - `DENIED` 创意不计入有效创意。
 - 每次创意准备中,每个视频来源最多获取并尝试 100 条视频。
 - 每次创意准备中,每个视频来源最多获取并尝试 100 条视频。
 - 先尝试 primary 视频来源。
 - 先尝试 primary 视频来源。
@@ -47,18 +47,24 @@
 - 当前素材硬筛只看相似度:`score >= 0.8`。
 - 当前素材硬筛只看相似度:`score >= 0.8`。
 - 曝光、CTR、ROI 只作为审批展示和兜底排序参考,不作为硬筛。
 - 曝光、CTR、ROI 只作为审批展示和兜底排序参考,不作为硬筛。
 - 素材通过相似度筛选后,默认按历史消耗 `cost` 倒序选择。
 - 素材通过相似度筛选后,默认按历史消耗 `cost` 倒序选择。
-- 落地页视频需要做同人群包去重:当前运行内同一 `crowd_package + landing_video_id` 只能进入本轮审批候选 1 次。
-- 落地页视频还需要做跨轮近期排重:默认按同人群包下最近 7 天进入 pending 或已提交腾讯的 `landing_video_id` 排除。
+- 落地页视频去重按素材来源拆池。历史素材链路同一 `crowd_package + landing_video_id` 默认最多 1 次。
+- AI 生成素材链路和历史素材链路互不占用 landing 去重名额;AI 链路同一 `crowd_package + landing_video_id` 默认最多 1 次。
+- 落地页视频还需要做跨轮近期排重:默认按同人群包下最近 7 天进入 pending 或已提交腾讯的 `landing_video_id` 统计使用次数。
 - 同一广告内同一 `landing_video_id` 仍保留限频保护,默认最多 1 次。
 - 同一广告内同一 `landing_video_id` 仍保留限频保护,默认最多 1 次。
 - 素材需要跨账户/跨天排重,默认按同人群包下近期使用过的 `material_id` 排除。
 - 素材需要跨账户/跨天排重,默认按同人群包下近期使用过的 `material_id` 排除。
 - 当前运行内的审批候选需要做轻量展示去重:同一 `crowd_package + material_id` 只进入本轮审批表一次;该去重只存在内存,不写入历史排重库。
 - 当前运行内的审批候选需要做轻量展示去重:同一 `crowd_package + material_id` 只进入本轮审批表一次;该去重只存在内存,不写入历史排重库。
+- `videoContentList` 每个 source 默认最多读取 3 页,每页 100 条,按 `video_id` 去重合并。
 
 
 ## 视频召回人群包映射
 ## 视频召回人群包映射
 
 
 - 腾讯投放、人群包授权、落地计划仍使用账户配置的人群包。
 - 腾讯投放、人群包授权、落地计划仍使用账户配置的人群包。
 - 内容服务 `videoContentList` 的 `crowdPackage` 可以有独立映射。
 - 内容服务 `videoContentList` 的 `crowdPackage` 可以有独立映射。
-- 当前默认映射:`cell*year*商业` 获取视频时映射为 `wx*商业`。
+- 当前默认映射:
+  - `cell*year*商业` 获取视频时映射为 `wx*商业`
+  - `回流330以上人群` 获取视频时映射为 `R_330+`
 - `source=hot` 只能改变内容服务的视频来源,不能改变映射后的召回人群包。
 - `source=hot` 只能改变内容服务的视频来源,不能改变映射后的召回人群包。
+- 内容服务 `source` 也可以按人群包做独立映射。
+- 当前默认没有 source 覆盖;`回流330以上人群` 获取视频时使用 `.env` 中的 `prior`。
 
 
 ## 腾讯人群包和资产规则
 ## 腾讯人群包和资产规则
 
 

+ 60 - 13
examples/auto_put_ad_mini/PRODUCTION_AUTOMATION.md

@@ -31,17 +31,23 @@
 
 
 当 `是否自动化执行=是` 时,每日主流程会扫描并处理该账户。为否时,系统不再为该账户新建广告或补创意,但不会暂停或删除既有广告。
 当 `是否自动化执行=是` 时,每日主流程会扫描并处理该账户。为否时,系统不再为该账户新建广告或补创意,但不会暂停或删除既有广告。
 
 
+单广告预算语义:
+
+- 空白: 使用投放模板默认预算。
+- `不限制` / `不限` / `-`: 按腾讯不限预算传 `daily_budget=0`。
+- 数字: 按元读取,转换为分后传腾讯。
+
 ## 生产目标
 ## 生产目标
 
 
 当前单广告最终有效创意目标:
 当前单广告最终有效创意目标:
 
 
 ```text
 ```text
-TARGET_CREATIVES_PER_AD = 4
+TARGET_CREATIVES_PER_AD = 12
 ```
 ```
 
 
 含义:
 含义:
 
 
-- 每条广告最终有效创意数不少于 4
+- 每条广告最终有效创意数不少于 12
 - 已有有效创意会计入目标
 - 已有有效创意会计入目标
 - `DENIED` 创意不计入有效创意
 - `DENIED` 创意不计入有效创意
 - 差几个补几个
 - 差几个补几个
@@ -52,27 +58,36 @@ TARGET_CREATIVES_PER_AD = 4
 每次为一条广告准备一条创意时,视频最多扫描:
 每次为一条广告准备一条创意时,视频最多扫描:
 
 
 ```text
 ```text
-primary source: 最多 100 条
-hot source: 最多 100 条
+primary source: 默认最多 3 页,每页 100 条
+hot source: 默认最多 3 页,每页 100 条
 ```
 ```
 
 
 执行顺序:
 执行顺序:
 
 
 1. 先用账户配置的人群包拉主池视频
 1. 先用账户配置的人群包拉主池视频
-2. 主池 100 条经过过滤、风险审核、素材召回后仍无法产出可用创意时,再用同一个人群包拉 `source=hot`
-3. hot 池同样最多 100 条
+2. 主池最多 3 页经过过滤、风险审核、素材召回后仍无法产出可用创意时,再用同一个人群包拉 `source=hot`
+3. hot 池同样最多 3 页
 4. hot 池同样走风险审核、品类过滤、素材质量过滤
 4. hot 池同样走风险审核、品类过滤、素材质量过滤
 
 
-注意: `source=hot` 只改变内容服务的视频来源。默认情况下 `crowdPackage` 使用当前账户配置的人群包;如果配置了视频召回映射,则 primary/hot 都使用映射后的召回人群包。
+注意: `source=hot` 只改变内容服务的视频来源。默认情况下 `crowdPackage` 使用当前账户配置的人群包;如果配置了视频召回映射,则 primary/hot 都使用映射后的召回人群包。分页结果按 `video_id` 去重合并。
 
 
 当前默认视频召回映射:
 当前默认视频召回映射:
 
 
 ```text
 ```text
 cell*year*商业 -> wx*商业
 cell*year*商业 -> wx*商业
+回流330以上人群 -> R_330+
 ```
 ```
 
 
 该映射只影响 `videoContentList` 获取视频,不影响腾讯投放定向、人群包授权和 `xcx/save` 落地计划。
 该映射只影响 `videoContentList` 获取视频,不影响腾讯投放定向、人群包授权和 `xcx/save` 落地计划。
 
 
+内容服务 `source` 也可独立映射。当前默认没有 source 覆盖,会使用 `.env` 的 `PIAOQUANTV_VIDEO_SOURCE=prior`。
+
+```text
+回流330以上人群 -> crowdPackage=R_330+, source=prior
+```
+
+原因:内容服务中 330 人群包的实际参数是 `R_330+`。
+
 ## 视频过滤规则
 ## 视频过滤规则
 
 
 内容服务 `videoContentList` 返回的 `category` 字段会用于内容品类过滤。
 内容服务 `videoContentList` 返回的 `category` 字段会用于内容品类过滤。
@@ -194,10 +209,12 @@ AI 图默认上传到:
 - 历史排重只读取已有明确结果的素材使用记录,按同人群包下的 `material_id` 排除。
 - 历史排重只读取已有明确结果的素材使用记录,按同人群包下的 `material_id` 排除。
 - 本轮审批候选做内存展示去重,同一 `crowd_package + material_id` 只进入当前审批表一次。该规则不写入历史排重库,进程结束即失效。
 - 本轮审批候选做内存展示去重,同一 `crowd_package + material_id` 只进入当前审批表一次。该规则不写入历史排重库,进程结束即失效。
 
 
-落地页视频排重分两层:
+落地页视频排重按素材来源拆池:
 
 
-- 当前运行内同一 `crowd_package + landing_video_id` 只允许进入 pending creative 一次。
-- 跨轮近期排重默认读取 `creative_material_usage` 和 `creative_creation_task`,排除同人群包最近 7 天进入 pending 或已提交腾讯的 `landing_video_id`。
+- 历史素材召回链路:同一 `crowd_package + landing_video_id` 默认最多使用 1 次。
+- AI 生成素材链路:同一 `crowd_package + landing_video_id` 默认最多使用 1 次。
+- 两条链路互不占用名额。历史素材已用过的 landing,不阻止 AI 生成素材链路继续使用;AI 使用记录也不阻止历史素材链路按自己的规则判断。
+- 跨轮近期排重默认读取 `creative_material_usage` 和 `creative_creation_task`,按同人群包最近 7 天进入 pending 或已提交腾讯的 `landing_video_id` 统计使用次数。
 - 同一广告内同一 `landing_video_id` 仍保留限频保护,默认最多 1 次。
 - 同一广告内同一 `landing_video_id` 仍保留限频保护,默认最多 1 次。
 
 
 可通过环境变量调整同广告落地页视频本轮限频:
 可通过环境变量调整同广告落地页视频本轮限频:
@@ -205,6 +222,7 @@ AI 图默认上传到:
 ```bash
 ```bash
 MAX_SAME_LANDING_PER_AD_IN_RUN=1
 MAX_SAME_LANDING_PER_AD_IN_RUN=1
 CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS=7
 CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS=7
+PIAOQUANTV_VIDEO_MAX_PAGES=3
 ```
 ```
 
 
 ## 热门兜底配置
 ## 热门兜底配置
@@ -237,14 +255,16 @@ PIAOQUANTV_HOT_FALLBACK_ENABLED=true
 PIAOQUANTV_HOT_FALLBACK_SOURCE=hot
 PIAOQUANTV_HOT_FALLBACK_SOURCE=hot
 LANDING_EXCLUDED_CATEGORIES=早中晚好,祝福音乐,历史名人
 LANDING_EXCLUDED_CATEGORIES=早中晚好,祝福音乐,历史名人
 VIDEO_RISK_MAX_ALLOWED_LEVEL=5
 VIDEO_RISK_MAX_ALLOWED_LEVEL=5
-VIDEO_RECALL_CROWD_PACKAGE_MAP={"cell*year*商业":"wx*商业"}
+VIDEO_RECALL_CROWD_PACKAGE_MAP={"cell*year*商业":"wx*商业","回流330以上人群":"R_330+"}
 MAX_SAME_LANDING_PER_AD_IN_RUN=1
 MAX_SAME_LANDING_PER_AD_IN_RUN=1
+PIAOQUANTV_VIDEO_MAX_PAGES=3
 CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS=7
 CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS=7
 TENCENT_AUDIENCE_SOURCE_ACCOUNT_ID=55615440
 TENCENT_AUDIENCE_SOURCE_ACCOUNT_ID=55615440
 TENCENT_AUDIENCE_GRANT_BUSINESS_ID=12312
 TENCENT_AUDIENCE_GRANT_BUSINESS_ID=12312
 OPENROUTER_API_KEY=...
 OPENROUTER_API_KEY=...
-OPENROUTER_TEXT_MODEL=google/gemini-2.5-flash
-OPENROUTER_IMAGE_MODEL=google/gemini-3-pro-image
+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_ENDPOINT=oss-xxx.aliyuncs.com
 ALIYUN_OSS_BUCKET=art-pubbucket
 ALIYUN_OSS_BUCKET=art-pubbucket
 ALIYUN_OSS_ACCESS_KEY_ID=...
 ALIYUN_OSS_ACCESS_KEY_ID=...
@@ -257,6 +277,33 @@ AI_IMAGE_TARGET_HEIGHT=720
 
 
 OSS key 已迁移到本工程环境变量。生产 Docker 也应显式注入这些变量,不依赖外层参考工程。
 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=1
+```
+
+飞书导入若不保留 xlsx 内嵌图片,审批表仍保留链接兜底;后续可改用飞书 Sheets 写图片 API。
+
 ## 当前已知限制
 ## 当前已知限制
 
 
 - `R330` 账户如果源账户无法解析到 ONLINE/SUCCESS 人群包,Phase 0 会跳过该账户。
 - `R330` 账户如果源账户无法解析到 ONLINE/SUCCESS 人群包,Phase 0 会跳过该账户。

+ 8 - 4
examples/auto_put_ad_mini/config.py

@@ -422,7 +422,11 @@ def get_account_creation_config(account_id: int) -> dict:
         "automatic_site_enabled": bool(automatic_site_enabled) if automatic_site_enabled is not None else False,
         "automatic_site_enabled": bool(automatic_site_enabled) if automatic_site_enabled is not None else False,
         "location_types": location_types,
         "location_types": location_types,
         "region_ids": region_ids,
         "region_ids": region_ids,
-        "daily_budget_fen": int(row.get("account_daily_budget_fen") or row["daily_budget_fen"]),
+        "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,
         "time_series": time_series,
     }
     }
 
 
@@ -938,10 +942,10 @@ AUDIENCE_TIER_PATTERNS = [
 # 数据流:find_ads_needing_creatives → 关联点过滤 → 召回素材 → POST 创意。
 # 数据流:find_ads_needing_creatives → 关联点过滤 → 召回素材 → POST 创意。
 
 
 # --- 创意补量目标(单广告期望创意数)---
 # --- 创意补量目标(单广告期望创意数)---
-# 2026-07-01:用户确认最终合格创意不少于 4
-# 生产阶段长期应回到 15(腾讯经验下限,MIN_CREATIVES_PER_AD)
+# 2026-07-08:为提升冷启动素材供给,用户确认最终合格创意目标提升到 12
+# 生产阶段可继续测试 15-30(腾讯经验下限,MIN_CREATIVES_PER_AD)
 # 这是 find_ads_needing_creatives 阈值 + 补量目标的**同一个语义变量**,不要拆
 # 这是 find_ads_needing_creatives 阈值 + 补量目标的**同一个语义变量**,不要拆
-TARGET_CREATIVES_PER_AD = 4
+TARGET_CREATIVES_PER_AD = int(os.getenv("TARGET_CREATIVES_PER_AD", "12"))
 
 
 # --- 主循环 try-fallback 限额(防无限召回)---
 # --- 主循环 try-fallback 限额(防无限召回)---
 # 单广告最多尝试 N 条 landing,超过即放弃此条创意(不影响广告剩余 to_add)
 # 单广告最多尝试 N 条 landing,超过即放弃此条创意(不影响广告剩余 to_add)

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

@@ -0,0 +1,381 @@
+[
+  {
+    "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": "使用强封面感构图,超大中文标题优先,可有人物讲解、资料清单、家庭讨论等元素。画面要真实、清晰、适合45岁以上用户快速阅读。",
+    "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": "中国本土家庭/客厅/院子/饭桌/社区场景,1-3位中老年或家庭成员,大字标题突出生活提醒。",
+    "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": "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": "社区广场、楼道、家门口、院子、饭桌等本土生活场景,1-3人互动,大字标题突出发现感。",
+    "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": ["新闻播报", "紧急新闻", "官方通报"]}
+      ]
+    }
+  }
+]

+ 3 - 1
examples/auto_put_ad_mini/configure_creation_accounts.py

@@ -74,8 +74,10 @@ def parse_bid(raw: str) -> tuple[int, int, int | None]:
 
 
 def parse_budget_fen(raw: object) -> int | None:
 def parse_budget_fen(raw: object) -> int | None:
     text = str(raw or "").strip()
     text = str(raw or "").strip()
-    if not text or text in {"不限制", "不限", "-"}:
+    if not text:
         return None
         return None
+    if text in {"不限制", "不限", "-"}:
+        return 0
     # 飞书里预算按元填,腾讯 API 用分。
     # 飞书里预算按元填,腾讯 API 用分。
     return _bid_to_fen(text)
     return _bid_to_fen(text)
 
 

+ 166 - 0
examples/auto_put_ad_mini/db/schema.sql

@@ -315,6 +315,12 @@ CREATE TABLE IF NOT EXISTS ai_generated_material (
     approval_status VARCHAR(50) DEFAULT NULL COMMENT '人工审批状态',
     approval_status VARCHAR(50) DEFAULT NULL COMMENT '人工审批状态',
     tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
     tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
     dynamic_creative_id BIGINT 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 '生成/上传/创建错误',
     error TEXT DEFAULT NULL COMMENT '生成/上传/创建错误',
     raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
     raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -325,6 +331,166 @@ CREATE TABLE IF NOT EXISTS ai_generated_material (
     KEY idx_status_created (status, created_at)
     KEY idx_status_created (status, created_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成创意图片素材';
 ) 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='素材策略学习周报';
+
 -- =====================================================
 -- =====================================================
 -- 初始化数据
 -- 初始化数据
 -- =====================================================
 -- =====================================================

+ 83 - 11
examples/auto_put_ad_mini/debug_generate_ai_material.py

@@ -28,13 +28,19 @@ sys.path.insert(0, str(_HERE.parent.parent))
 sys.path.insert(0, str(_HERE))
 sys.path.insert(0, str(_HERE))
 
 
 from tools.ai_generated_material import (  # noqa: E402
 from tools.ai_generated_material import (  # noqa: E402
+    AI_IMAGE_PATTERN_PLACEMENT,
+    AI_IMAGE_PATTERN_TOP_K,
+    OPENROUTER_TEXT_MODEL,
     OPENROUTER_IMAGE_MODEL,
     OPENROUTER_IMAGE_MODEL,
     build_ai_image_object_key,
     build_ai_image_object_key,
     build_generation_prompts,
     build_generation_prompts,
+    build_pattern_generation_prompts,
     generate_image_bytes,
     generate_image_bytes,
+    insert_and_review_generated_material,
     sanitize_video_description,
     sanitize_video_description,
     upload_image_to_oss,
     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
 from tools.video_feature_query import read_cached_video_element_features  # noqa: E402
 
 
 
 
@@ -44,7 +50,15 @@ def main() -> int:
     parser.add_argument("--title", default="")
     parser.add_argument("--title", default="")
     parser.add_argument("--category", default="")
     parser.add_argument("--category", default="")
     parser.add_argument("--model", default=OPENROUTER_IMAGE_MODEL)
     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("--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()
     args = parser.parse_args()
 
 
     if not args.video_id:
     if not args.video_id:
@@ -53,36 +67,94 @@ def main() -> int:
     features = features_by_vid.get(args.video_id) or []
     features = features_by_vid.get(args.video_id) or []
     topic = next((f.standard_element for f in features if f.element_dimension == "解构选题"), "")
     topic = next((f.standard_element for f in features if f.element_dimension == "解构选题"), "")
     sanitized_description = sanitize_video_description(topic) if topic else ""
     sanitized_description = sanitize_video_description(topic) if topic else ""
-    prompts = [
-        (p.prompt_type, p.prompt_text)
-        for p in build_generation_prompts(
+    if args.use_pattern_selector:
+        built_prompts = build_pattern_generation_prompts(
             video_id=args.video_id,
             video_id=args.video_id,
             title=args.title,
             title=args.title,
             category=args.category,
             category=args.category,
             features=features,
             features=features,
             sanitized_description=sanitized_description,
             sanitized_description=sanitized_description,
+            crowd_package=args.crowd_package,
+            placement=args.placement,
+            top_k=args.pattern_top_k,
+            text_model=args.text_model,
         )
         )
-    ][: max(1, args.limit)]
+    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 = []
     outputs = []
-    for prompt_type, prompt_text in prompts:
-        image_bytes, content_type, raw = generate_image_bytes(prompt_text, model=args.model)
+    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"
         ext = ".png" if content_type == "image/png" else ".jpg"
         object_key = build_ai_image_object_key(
         object_key = build_ai_image_object_key(
-            account_id="debug",
+            account_id=args.account_id or "debug",
             landing_video_id=args.video_id,
             landing_video_id=args.video_id,
-            prompt_type=prompt_type,
+            prompt_type=prompt.prompt_type,
             extension=ext,
             extension=ext,
-            debug=True,
+            debug=not args.write_db,
         )
         )
         oss_url = upload_image_to_oss(image_bytes, content_type, object_key)
         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({
         outputs.append({
-            "prompt_type": prompt_type,
+            "asset_id": asset_id,
+            "ai_review": review_output,
+            "prompt_type": prompt.prompt_type,
             "model": args.model,
             "model": args.model,
+            "text_model": args.text_model,
             "content_type": content_type,
             "content_type": content_type,
             "oss_url": oss_url,
             "oss_url": oss_url,
             "object_key": object_key,
             "object_key": object_key,
-            "prompt_text": prompt_text,
+            "prompt_text": prompt.prompt_text,
+            "feature_hits": prompt.feature_hits,
             "raw_response_id": raw.get("id"),
             "raw_response_id": raw.get("id"),
         })
         })
 
 

+ 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())

+ 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 使用频控。

+ 151 - 18
examples/auto_put_ad_mini/execute_creation_once.py

@@ -23,6 +23,7 @@
 
 
 import json
 import json
 import logging
 import logging
+import os
 import sys
 import sys
 import time
 import time
 from pathlib import Path
 from pathlib import Path
@@ -64,9 +65,13 @@ from tools.creative_creation import (  # noqa: E402
     prepare_one_creative_for_ad,
     prepare_one_creative_for_ad,
 )
 )
 from tools.creative_material_usage import (  # noqa: E402
 from tools.creative_material_usage import (  # noqa: E402
-    load_recent_used_landing_video_ids,
+    load_recent_landing_usage_counts,
     record_prepared_material_usage,
     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 tools.video_recall import get_account_crowd_package  # noqa: E402
 
 
 from execute_creation_apply import (  # noqa: E402
 from execute_creation_apply import (  # noqa: E402
@@ -77,6 +82,62 @@ from execute_creation_apply import (  # noqa: E402
 
 
 logger = logging.getLogger("execute_creation_once")
 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:
 def _setup_logging() -> None:
     logging.basicConfig(
     logging.basicConfig(
@@ -182,6 +243,10 @@ def phase0_create_ads(target_ads: int = ADS_PER_ACCOUNT) -> list[dict]:
     if not creation_accounts:
     if not creation_accounts:
         logger.error("[phase0] 待投放账户配置为空,退出")
         logger.error("[phase0] 待投放账户配置为空,退出")
         return []
         return []
+    creation_accounts = _filter_creation_accounts(creation_accounts, "phase0")
+    if not creation_accounts:
+        logger.info("[phase0] 临时过滤后无待处理账户")
+        return []
 
 
     # Task 26:NORMAL + SUSPEND 都算占用唯一性槽位(腾讯文档:删除前历史广告占槽位)
     # Task 26:NORMAL + SUSPEND 都算占用唯一性槽位(腾讯文档:删除前历史广告占槽位)
     OCCUPIED_STATUSES = {"AD_STATUS_NORMAL", "AD_STATUS_SUSPEND"}
     OCCUPIED_STATUSES = {"AD_STATUS_NORMAL", "AD_STATUS_SUSPEND"}
@@ -378,6 +443,10 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
     if not creation_accounts:
     if not creation_accounts:
         logger.error("[phase1] 待投放账户配置为空,Phase 1 退出")
         logger.error("[phase1] 待投放账户配置为空,Phase 1 退出")
         return []
         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()
     excluded_ad_ids = load_excluded_ad_ids_from_adjustment()
     logger.info("[phase1] 关联点过滤集合 size=%d", len(excluded_ad_ids))
     logger.info("[phase1] 关联点过滤集合 size=%d", len(excluded_ad_ids))
@@ -388,8 +457,8 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
     # 但同一轮审批候选需要做展示去重,避免表格里同 crowd_package 反复出现同一素材。
     # 但同一轮审批候选需要做展示去重,避免表格里同 crowd_package 反复出现同一素材。
     # 这个 set 只存在内存里,不写历史排重库;进程结束即失效。
     # 这个 set 只存在内存里,不写历史排重库;进程结束即失效。
     display_used_material_ids_by_crowd: dict[str, set[str]] = {}
     display_used_material_ids_by_crowd: dict[str, set[str]] = {}
-    # landing_video 在同一 crowd_package 下做本轮 + 近期排重,降低 4+M 创意重复风险
-    excluded_landing_ids_by_crowd: dict[str, set[int]] = {}
+    # landing_video 按素材来源拆池。历史素材和 AI 生成素材互不占用,但池内都严格去重
+    landing_usage_counts_by_crowd: dict[str, dict[str, dict[int, int]]] = {}
 
 
     for account_id in creation_accounts:
     for account_id in creation_accounts:
         logger.info("=" * 60)
         logger.info("=" * 60)
@@ -424,9 +493,9 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
         display_excluded_material_ids = display_used_material_ids_by_crowd.setdefault(
         display_excluded_material_ids = display_used_material_ids_by_crowd.setdefault(
             crowd_package, set(),
             crowd_package, set(),
         )
         )
-        if crowd_package not in excluded_landing_ids_by_crowd:
+        if crowd_package not in landing_usage_counts_by_crowd:
             try:
             try:
-                excluded_landing_ids_by_crowd[crowd_package] = load_recent_used_landing_video_ids(
+                landing_usage_counts_by_crowd[crowd_package] = load_recent_landing_usage_counts(
                     crowd_package,
                     crowd_package,
                 )
                 )
             except Exception as e:
             except Exception as e:
@@ -434,18 +503,45 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
                     "[phase1] crowd=%r 读取 landing 使用历史失败,仅使用本轮排重:%s",
                     "[phase1] crowd=%r 读取 landing 使用历史失败,仅使用本轮排重:%s",
                     crowd_package, e,
                     crowd_package, e,
                 )
                 )
-                excluded_landing_ids_by_crowd[crowd_package] = set()
+                landing_usage_counts_by_crowd[crowd_package] = {
+                    MATERIAL_SOURCE_HISTORY: {},
+                    MATERIAL_SOURCE_AI_GENERATED: {},
+                }
             logger.info(
             logger.info(
-                "[phase1] crowd=%r 近期 landing 排重 size=%d",
+                "[phase1] crowd=%r 近期 landing 使用 history=%d ai_generated=%d",
                 crowd_package,
                 crowd_package,
-                len(excluded_landing_ids_by_crowd[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_excluded_landing_ids = excluded_landing_ids_by_crowd[crowd_package]
+        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
+        logger.info(
+            "[phase1] account=%d landing 排重池=%s max_uses=%d",
+            account_id, requested_material_source, landing_max_uses,
+        )
 
 
         for ad in ads_after_filter:
         for ad in ads_after_filter:
             adgroup_id = ad["adgroup_id"]
             adgroup_id = ad["adgroup_id"]
             already_have = ad["creative_count"]
             already_have = ad["creative_count"]
             to_add = max(0, target_creatives - already_have)
             to_add = max(0, target_creatives - already_have)
+            prepared_for_ad = 0
+            failed_prepare_for_ad = 0
             landing_counts_for_ad: dict[int, int] = {}
             landing_counts_for_ad: dict[int, int] = {}
             logger.info(
             logger.info(
                 "[phase1]   adgroup=%d(have=%d need=%d)",
                 "[phase1]   adgroup=%d(have=%d need=%d)",
@@ -458,8 +554,15 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
                     for vid, count in landing_counts_for_ad.items()
                     for vid, count in landing_counts_for_ad.items()
                     if count >= MAX_SAME_LANDING_PER_AD_IN_RUN
                     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
+                }
                 effective_excluded_landing_ids = (
                 effective_excluded_landing_ids = (
-                    set(crowd_excluded_landing_ids) | landing_excluded_for_ad
+                    landing_excluded_for_source | landing_excluded_for_ad
                 )
                 )
                 try:
                 try:
                     rec = prepare_one_creative_for_ad(
                     rec = prepare_one_creative_for_ad(
@@ -474,6 +577,7 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
                     rec = None
                     rec = None
 
 
                 if rec:
                 if rec:
+                    prepared_for_ad += 1
                     pending_records.append(rec)
                     pending_records.append(rec)
                     material_id = rec.get("_material_id")
                     material_id = rec.get("_material_id")
                     if material_id:
                     if material_id:
@@ -489,11 +593,23 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
                         landing_counts_for_ad[landing_video_id] = (
                         landing_counts_for_ad[landing_video_id] = (
                             landing_counts_for_ad.get(landing_video_id, 0) + 1
                             landing_counts_for_ad.get(landing_video_id, 0) + 1
                         )
                         )
-                        crowd_excluded_landing_ids.add(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
+                        )
+                        source_counts = crowd_landing_counts.setdefault(
+                            actual_material_source, {},
+                        )
+                        source_counts[landing_video_id] = (
+                            source_counts.get(landing_video_id, 0) + 1
+                        )
                         logger.info(
                         logger.info(
-                            "[phase1] 同人群包 landing 排重登记 crowd=%r landing=%d size=%d",
-                            crowd_package, landing_video_id,
-                            len(crowd_excluded_landing_ids),
+                            "[phase1] 同人群包 landing 使用登记 crowd=%r material_source=%s landing=%d count=%d max=%d",
+                            crowd_package, actual_material_source, landing_video_id,
+                            source_counts[landing_video_id],
+                            MAX_SAME_LANDING_PER_AD_IN_RUN,
                         )
                         )
                         logger.info(
                         logger.info(
                             "[phase1] 本轮同广告 landing 计数 adgroup=%d landing=%d count=%d limit=%d",
                             "[phase1] 本轮同广告 landing 计数 adgroup=%d landing=%d count=%d limit=%d",
@@ -509,12 +625,25 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
                             account_id, adgroup_id, rec.get("_material_id"), e,
                             account_id, adgroup_id, rec.get("_material_id"), e,
                         )
                         )
                 else:
                 else:
+                    failed_prepare_for_ad += 1
                     # 2026-06-10 用户要求:单条 prepare 失败 → continue 不 break
                     # 2026-06-10 用户要求:单条 prepare 失败 → continue 不 break
                     # 同广告剩余 to_add 创意还能继续试,不被一次失败拖累
                     # 同广告剩余 to_add 创意还能继续试,不被一次失败拖累
                     logger.info(
                     logger.info(
                         "[phase1]   adgroup=%d 本条创意 prepare 失败,试下一条",
                         "[phase1]   adgroup=%d 本条创意 prepare 失败,试下一条",
                         adgroup_id,
                         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("=" * 60)
     logger.info("[phase1] 准备完成,共 %d 条 pending records", len(pending_records))
     logger.info("[phase1] 准备完成,共 %d 条 pending records", len(pending_records))
@@ -578,10 +707,14 @@ def run_once() -> dict:
 
 
     # Phase 0:模块 A 建广告(满足每账户 ADS_PER_ACCOUNT 条)
     # Phase 0:模块 A 建广告(满足每账户 ADS_PER_ACCOUNT 条)
     logger.info("=" * 60)
     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()
+        logger.info("[main] Phase 0 完成:本轮新建广告 %d 条", len(created_ads))
+        _wait_created_ads_visible(created_ads)
 
 
     # Phase 1:模块 B 给所有广告(新+旧)补创意
     # Phase 1:模块 B 给所有广告(新+旧)补创意
     logger.info("=" * 60)
     logger.info("=" * 60)

+ 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())

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

@@ -0,0 +1,75 @@
+【system】
+你是中文信息流广告封面标题策划。
+你的任务是基于广告主题种子和创意 pattern,为 60-75 岁中老年用户生成更想看的封面主标题。
+目标不是一味悬疑,而是让目标用户觉得“和我有关、想继续看”。
+只输出 JSON,不要输出解释性正文。
+
+【user】
+请为下面的广告封面生成主标题。
+
+【广告主题种子】
+{{video_description}}
+
+【创意Pattern】
+{{pattern_json}}
+
+【核心目标】
+- 核心目标:让 60-75 岁中老年用户愿意点开/继续看。
+- 标题长度保持 12-22 个汉字。
+- 不要副标题。
+- 不要写成说明文、科普标题或平铺直叙的总结。
+
+【先做钩子判断】
+请先判断这个主题最适合哪一种 hook_angle,再写标题:
+- 反差型:表面和真相不一样,例如“晚年过得好不是靠钱多”
+- 未说破型:只说到关键处,留下后续原因,例如“退休后这件事很多人才看懂”
+- 过来人型:像同龄人经验,例如“人到六十才懂这几句话”
+- 清单型:具体几件事,例如“退休后这几件事要早点看透”
+- 情绪共鸣型:唱到心里、说到心坎,例如“这首歌越听越懂晚年的滋味”
+- 奇闻反差型:普通人做出不普通的事,例如“破棚里造出这辆怪车”
+- 实用提醒型:和日常生活直接相关,例如“路边太热心的人先别急着信”
+
+【候选标题要求】
+- 生成 5 个候选标题,每个候选必须是不同钩子角度或不同表达方式。
+- 每个标题都要通俗、直接、像老年人会点开的信息流大字。
+- 每个标题都要有反差、未说破、过来人经验、清单感、情绪共鸣或奇闻感中的至少一种。
+- 标题要与主题相关,但不要复刻视频脚本、镜头结构或高风险事件细节。
+- 尽量不用标点;不要使用逗号、句号、感叹号、问号、引号。
+- 不要错别字、乱码、异体字、不自然词组。
+
+【避免平淡表达】
+尽量不要使用这些容易变平的词:
+建议您看看、智慧锦囊、处世准则、幸福真相、详细拆解、综合指南、生活质量、深度解析、值得品读、开启优雅晚年。
+
+优先使用这些更通俗、有点击感的表达:
+早点看透、心里有数、别太较真、这几件事、原来是这样、很多人没想明白、看完心里踏实、过来人的话、老了才懂、越听越有味。
+
+【评分规则】
+请给每个候选打分:
+- hook_score: 老年人想看的钩子强度,0-100
+- plain_score: 通俗易懂程度,0-100
+- relevance_score: 与主题相关程度,0-100
+最终选择 selected_title 时优先看 hook_score,再看 plain_score,最后看 relevance_score。
+
+【底线限制】
+- 生成阶段优先保证点击吸引力和相关性,不要因为过度保守把标题写成平淡说明。
+- 不要写假官方通知、假系统提示、假按钮、二维码、医疗疗效、健康恐吓、血腥暴力、迷信预测、低俗擦边。
+- 避免明显夸张和强迫式表达,例如“100%”“唯一”“不看后悔”“最后一天”“紧急通知”。
+
+【输出JSON格式】
+{
+  "hook_angle": "最终选择的钩子角度",
+  "hook_point": "一句话说明这个视频最能让老年人想看的点",
+  "candidates": [
+    {
+      "title": "候选主标题",
+      "hook_angle": "反差型/未说破型/过来人型/清单型/情绪共鸣型/奇闻反差型/实用提醒型",
+      "hook_score": 90,
+      "plain_score": 90,
+      "relevance_score": 90,
+      "reason": "一句话说明"
+    }
+  ],
+  "selected_title": "最终主标题",
+  "reason": "一句话说明为什么选择它"
+}

+ 54 - 84
examples/auto_put_ad_mini/prompts/ai_generated_material.md

@@ -1,88 +1,58 @@
-请生成一张适合腾讯广告信息流 / 公众号投放的中文广告素材图,目标用户为60岁-75岁的中老年用户。
+【任务】
+生成一张腾讯广告信息流 / 公众号投放使用的中文广告封面图。
 
 
-【输入主题
+【输入】
 {{video_description}}
 {{video_description}}
 
 
-【核心目标】
-- 这是一张广告封面图,目标是提升中老年用户停留和点击兴趣;高消耗素材和高CTR素材在这里统一理解为高点击潜力素材。
-- 必须先理解输入主题的核心信息维度:核心对象、关键事实/数字、问题场景、情绪冲突、核心主张。
-- 图片标题和画面必须表达这些核心信息维度,不能只生成泛生活场景。
-- 不需要完全复刻视频场景,但必须和输入主题保持明确的主题相关、问题相关或情绪相关。
-- 可以把主题转译为更容易点击的中国本土生活化场景,但不能丢失输入主题的核心主张,不能编造无关故事。
-- 画面必须真实摄影感,不要卡通、二次元、科技海报、欧美商业海报。
-
-【生成优先级】
+【执行优先级】
 1. 合规安全。
 1. 合规安全。
-2. 和输入主题足够相关。
-3. 中老年用户看得懂、愿意停留。
-4. 一个视觉焦点,一个中文大标题区域。
-5. 风格美化不能覆盖前四项。
-
-【广告钩子提炼】
-先从输入主题提炼一个克制但有停留感的广告钩子,再生成图片。钩子必须来自输入主题,不能另起炉灶。
-- 如果主题是退休补贴、社保、养老金、清单信息:突出“有几项以前没问清楚/很多人没弄懂/一家人认真核对”的信息差,保留关键数字,不要承诺领取或到账。
-- 如果主题是拒绝内耗、活出自我、人际关系焦虑:突出“退休后才想明白/不再为闲话和攀比消耗自己/日子过给自己看”的情绪反转,不要变成翻相册等无关怀旧场景。
-- 如果主题是晚年健康与名利对比、歌声、回忆和牵挂:突出“听完有感触/晚年真正看重什么/老两口互相理解”的共鸣,不要写成苦情或过度煽动。
-- 如果主题是诈骗、防骗、手机辨别、亲友提醒:突出“家里人一起分辨/普通手机场景里的疑问/听完才知道要留心”的问题感,不要做假新闻或假手机界面。
-
-【标题钩子规则】
-按高点击/高消耗信息流标题的常见结构生成主标题,但标题必须来自输入主题的核心信息,不要为了点击改写成无关主题。标题要优先满足“我相关 + 有信息差 + 答案未完全说破”。
-- 对象明确:让用户一眼知道和谁有关,例如老人、退休人员、家里人、子女、老两口、邻里、唱歌的人。
-- 时间明确:可以使用今年、下个月、退休后、晚年、这几年、听完以后等时间锚点,但不能制造虚假紧迫。
-- 数字明确:如果输入主题有数字,优先保留,例如9项、几类、几个字、几件事、31省;没有数字时不要硬编。
-- 信息差明确:表达“很多人没弄懂、以前没问清楚、后来才明白、原来如此、这件事要留心”的感觉。
-- 未完成感克制:标题可以让人想继续看,但不能用恐吓、命令、强诱导或结果承诺。
-- 情绪共鸣:适合使用想通了、说到心里、听完沉默、活明白了等表达,但不要哭惨卖惨。
-
-可选标题结构,每张图只选一种,不要混用多种结构:
-- 对象 + 信息差:例如“退休后这几项很多人没弄懂”
-- 时间 + 对象 + 要留意的事:例如“退休后这件事家里人要问清”
-- 数字 + 主题对象 + 反差:例如“这9项清单很多人以前没细看”
-- 场景动作 + 才发现:例如“一家人核对后才发现不简单”
-- 情绪共鸣 + 核心主张:例如“活到这岁数才明白别内耗”
-
-【点击表现】
-先遵守抽象原则,例子只作参考,不要机械照抄。
-- 熟人关系:可信的人际关系或陪伴感,例如家人提醒、邻里聊天、老友讨论;不要编造冲突和秘密。
-- 生活困惑:日常判断、信息辨别、生活选择里的疑问,例如看资料、听讲解、互相提醒;手机只能作辅助道具,不要做假界面或假按钮。
-- 养老钱/退休生活:晚年安全感、退休安排、资料核对,例如纸质清单、账本、桌面资料;不要承诺领取或到账。
-- 反差发现:表现“原来如此”的认知转变,例如很多人没弄懂、这类情况要留心;不要恐吓。
-- 情绪共鸣:克制的感慨、释然或共鸣,例如想开了、听完有感触;不要过度煽动。
-- 表演内容:如果主题来自舞台、演唱、脱口秀,可保留现场氛围,也可突出观众反应和一句有共鸣的口语标题。
-
-【画面要求】
-- 画面由主体、场景、道具三部分组成:一个核心人物/事件动作,一个相关真实场景,一组帮助理解主题的道具。
-- 场景要服务广告钩子,不能为了生活化而弱化主题。人物动作应体现“核对、追问、想通、提醒、讨论、听完有感触”等与主题相关的状态。
-- 人物必须是中国本土语境,优先中老年人、家人、邻里、普通讲解者、观众等;不要外国人物。
-- 如果没有指定性别,不要强化男女特征,不要把美女/帅哥作为卖点。
-- 政策、补贴、社保、养老金类:使用中性日常场景,如家庭书桌、社区活动室、普通咨询桌、纸质资料核对;禁止机关单位、政务大厅、官方发布会、红头文件、公章、政府建筑。
-- 诈骗、防骗、社会风险类:使用中国本土日常生活或普通讲解场景,如家庭、社区活动室、普通讲解桌、亲友提醒;禁止新闻演播室、电视主播、蓝色科技新闻背景、世界地图、英文大屏。
-- 舞台、演唱、脱口秀类:可使用中国本土小剧场、社区剧场、电视演播厅、文艺汇演舞台、老年活动中心舞台;禁止欧美酒吧、英文招牌、stand-up comedy club、海外街景。
-- 画面真实、清晰、接地气,有代入感和停留感,不要过度精致商业化。
-
-【标题要求】
-- 画面中必须有一个超大中文主标题,标题是唯一文案区域。
-- 标题必须围绕输入主题的核心信息和广告钩子提炼,12-28个汉字。
-- 标题要有轻悬念或信息差,但要克制真实。优先使用“才明白、没弄懂、问清楚、想通了、原来如此、这几项、这件事”等弱诱导表达。
-- 标题优先使用“对象、时间、数字、信息差、情绪共鸣”中的1到2个钩子点,不要把所有钩子堆在一句话里。
-- 标题可分2到3行,但必须是同一个完整标题块,不要上下两段文字。
-- 标题用粗体大白字、黑色粗描边,高对比度,便于老年人阅读。
-- 标题口语化、通俗、有停留感,但不能催促点击、恐吓、夸大或承诺结果。
-- 不要副标题、小字解释、底部补充文案、口播字幕、互动提示。
-- 不要用逗号、顿号、句号拼接多个分句,不要写成分裂长句。
-
-【严格禁止】
-- 假官方通知、假新闻播报、假系统提示、假微信界面、假聊天记录、假按钮、假红包、假弹窗、二维码、下载按钮、播放按钮伪装。
-- 医疗治病、保健疗效、专家推荐药品、医院、健康恐吓。
-- 违法犯罪、血腥暴力、尸体、未成年人不当情节、迷信怪力乱神。
-- “100%”“唯一”“国家发钱”“不看后悔”“最后一天”“紧急通知”“赶紧看”等绝对化或强诱导表达。
-- 商品、优惠券、卡券、课程、价格、订单、购买、下单、领券、App下载、小程序推广等营销元素。
-- 低俗擦边、猎奇恶俗、哭惨卖惨、AI感强、畸形人物、logo、水印、乱码文字。
-- 领取、能领、已办成、马上、别错过、帮家人查查、去看看等承诺结果或催促行动的话。
-- 惊掉下巴、看哭无数人、亿万老人、身价大涨、慢性吃毒、再忙也要看、不看后悔、最后一天、国家发钱等夸张或高风险标题套路。
-
-【输出】
-输出一张完整广告图:中国本土生活化、主题相关、点击欲强、合规克制、16:9。
-
-【图片比例】
-{{aspect_ratio}}
+2. 服从输入中的“选中的创意Pattern”。
+3. 保持与视频主题相关。
+4. 参考高消耗/高点击率素材的表达方式,提升 60-75 岁中老年用户的停留和点击兴趣。
+5. 保证画面清晰、标题醒目、比例正确。
+
+【主题相关】
+- 输入内容是广告主题种子,不是视频脚本或画面脚本。
+- 必须保留主题背后的用户痛点、情绪冲突、信息差、生活提醒价值或核心主张。
+- 可以把具体事件转译成更普适、更安全、更有点击钩子的广告封面,但不能改成无关故事。
+- 相关性要求是“同主题/同情绪/同问题场景”,不是逐字逐事一致。
+- 不要求复刻视频原画面,优先生成更适合点击的同主题广告封面。
+
+【视觉策略】
+- 画面要像真实摄影广告封面,不要卡通、二次元、科技海报、欧美商业海报。
+- 视觉方向由 pattern 决定,基础 Prompt 不指定固定场景、人物关系、道具或色调。
+- 画面要服务点击钩子和主题相关性,不要套用固定视觉公式。
+- 人物必须是中国本土语境;不要外国人物;不要真实名人肖像或类似真实名人的脸。
+
+【标题策略】
+- 画面中必须有一个超大中文主标题。
+- 如果输入中提供了【封面标题】,必须使用其中的“主标题”,不要自行改写主标题。
+- 不要生成副标题、小字说明、角标或引导文案。
+- 主标题 12-22 个汉字,最多 2 行。
+- 主标题以大白字、黑色粗描边、高对比度为主。
+- 主标题中必须选择 1-2 个关键词使用黄色、橙色或红色做强调,但不要整句彩色化。
+- 主标题突出“我相关 + 信息差 + 未完全说破”,宁可短、有钩子,不要长句解释。
+- 主标题必须有广告素材钩子感,让用户产生“这和我有关、后面还有原因”的好奇。
+- 主标题不需要复述视频事实,应围绕同类用户会关心的问题、反差、提醒或悬念生成。
+- 主标题优先使用“提醒、反差、原因、清单、变化、很多人不知道、后来才明白”这类中老年高点击表达,但不要使用强恐吓和夸大承诺。
+- 主标题可使用对象、时间、数字、反差、情绪共鸣中的 1-2 个钩子点,不要堆叠。
+- 主标题必须是清晰可读的简体中文,不能有错别字、乱码、异体字或不自然词组。
+- 主标题不要使用逗号、冒号、顿号、句号、感叹号、问号;尤其不要使用感叹号或问号。
+- 除主标题外,不要口播字幕、互动提示、引导文案、伪按钮或界面文字。
+
+【多样性】
+- 如果同一视频生成多张图,每张图应在 scene / subject / prop / composition / color_mood 中至少 2 项明显不同。
+- 不同 pattern 必须产生不同视觉模板,不要只换标题不换画面。
+- 可以偏广告封面化、讲解感、事件感、资料特写、舞台反应或人物情绪反应,不要全部使用同一种平和室内场景。
+
+【合规禁止】
+- 禁止假官方通知、假新闻播报、假系统提示、假微信界面、假聊天记录、假按钮、假红包、假弹窗、二维码、下载按钮、播放按钮伪装。
+- 禁止医疗治病、保健疗效、专家推荐药品、医院、健康恐吓。
+- 禁止违法犯罪、血腥暴力、尸体、未成年人不当情节、迷信怪力乱神。
+- 禁止商品、优惠券、卡券、课程、价格、订单、购买、下单、领券、App下载、小程序推广。
+- 禁止“100%”“唯一”“国家发钱”“不看后悔”“最后一天”“紧急通知”“赶紧看”“惊掉下巴”“看哭无数人”“亿万老人”“身价大涨”等绝对化、强诱导或高风险表达。
+- 禁止 logo、水印、乱码文字、畸形人物、AI感强。
+
+【输出规格】
+- 输出一张完整广告图。
+- 图片比例:{{aspect_ratio}}。

+ 17 - 15
examples/auto_put_ad_mini/prompts/ai_sanitize_video_description.md

@@ -1,24 +1,26 @@
 【system】
 【system】
-你是广告素材生成前的视频内容清洗器。
-只输出一段清洗后的中文视频主题描述,不要解释。
+你是广告素材生成前的广告主题提炼器。
+你的任务是把视频解构选题提炼成适合生成广告封面图的“广告主题种子”。
+只输出一句中文广告主题种子,不要解释。
 输出中绝对不要出现:领取、能领、已办成、赶紧、通知、不错过、转发、关注、公众号、加群、优惠券、卡券、下单、购买。
 输出中绝对不要出现:领取、能领、已办成、赶紧、通知、不错过、转发、关注、公众号、加群、优惠券、卡券、下单、购买。
 
 
 【user】
 【user】
-请清洗下面的原始视频选题。
-清洗只负责去掉风险词、营销引导和不适合进入图片生成的表达,不要负责制造广告钩子,不要改写成新的生活故事。
+请把下面的原始视频选题提炼成广告封面图使用的主题种子。
+主题种子的目标用户是 60-75 岁中老年用户。
+主题种子要包含:中老年用户 + 核心问题/信息差 + 点击钩子方向。
+保留目标用户会关心的痛点、情绪冲突、信息差、生活提醒价值、可视化方向等关键维度,不局限于这些维度。
+不要复述视频镜头、口播包装、主持人、分屏结构、字幕设计或完整事件脚本。
 
 
 要求:
 要求:
-1. 必须保留原始选题里的核心事实、对象、数字和主题,例如人群对象、9笔/9项、退休补贴、AI诈骗、晚年生活等。
-2. 必须保留原始选题里的关键信息维度,例如核心主张、情绪冲突、问题场景、对象关系、内容类型;不能把它替换成泛化生活场景。
-3. 如果原始选题包含“拒绝内耗、活出自我、人际关系焦虑、退休补贴清单、9笔补贴、AI诈骗、晚年健康与名利对比”等信息,清洗结果必须保留对应含义。
-4. 去掉转发、关注、评论、公众号回复、私信、扫码、加群、领取、点击、下载、底部提示、醒目引导文案、标题设计、推广话术等互动/营销引导。
-5. 不要写能领取、已办成、赶紧去看、不错过福利等结果承诺或催促行动。
-6. 不要写假官方通知、不要写系统提示、不要写按钮或二维码,不要把画面设定为手机通知界面。
-7. 退休补贴类内容可以保留补贴数量、适用人群、专家讲解、清单图文展示,但不能写成已经办成或可以领取。
-8. 去掉商品售卖、优惠券、卡券、课程售卖、价格、订单、下单、购买、App下载、小程序推广等营销元素。
-9. 去掉低俗或不良联想词,例如寂寞、性感、风情、真人相册、私人相册、小视频、小短片等。
-10. 如果原始选题是新闻播报、官方通报、警示曝光、主持人播报、专家分析诈骗,只去掉不适合生成图片的播报包装,保留事件/风险/提醒的主题含义;不要改成无关的温馨日常。
-11. 输出 40-120 个中文字符,一句话。
+1. 保留原始选题背后的核心主题,例如退休补贴信息差、AI诈骗防范、拒绝内耗、晚年生活选择、家庭关系反差等。
+2. 可以把具体事件转译成更普适、更安全、更有点击钩子的广告主题,但不能改成无关故事。
+3. 高风险细节要转成中老年人相关的生活提醒、防范意识或信息差,不要复述拐骗骗局、犯罪过程、恐吓画面、儿童危险、诈骗操作步骤。
+4. 去掉转发、关注、评论、公众号回复、私信、扫码、加群、领取、点击、下载、底部提示、推广话术等互动/营销引导。
+5. 不要写能领取、已办成、保证可领、赶紧去看、不错过福利等结果承诺或催促行动。
+6. 不要写假官方通知、系统提示、按钮、二维码,不要把画面设定为手机通知界面。
+7. 输出 50-110 个中文字符,一句话,要有“为什么想点开”的广告主题感。
+8. 如果原始选题包含拐骗、诈骗、犯罪等高风险词,主题种子应优先写成“日常搭话、陌生求助、熟人提醒、热心帮忙、生活防范、容易忽略的小细节”等低风险表达。
+9. 不要在主题种子中出现“孩子、儿童、未成年人、拐骗、犯罪、修车求助、诈骗步骤、作案手段”等具体高风险对象或动作;如原始选题包含这些内容,请转译成中老年人更相关的生活防范提醒。
 
 
 原始视频选题:
 原始视频选题:
 {{raw_description}}
 {{raw_description}}

+ 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

+ 1 - 1
examples/auto_put_ad_mini/sync_feishu_account_config.py

@@ -11,7 +11,7 @@
   - 仅当「是否自动化执行」为「是」时启用/更新账户。
   - 仅当「是否自动化执行」为「是」时启用/更新账户。
   - 「否」会禁用已有的 ad_creation_account_config,不删除历史配置。
   - 「否」会禁用已有的 ad_creation_account_config,不删除历史配置。
   - 「初始出价」支持固定值 0.35 或范围 0.28-0.31。
   - 「初始出价」支持固定值 0.35 或范围 0.28-0.31。
-  - 「预算(单广告)」为空/不限制/不限 时使用模板默认预算;数字按元转换为分。
+  - 「预算(单广告)」为空时使用模板默认预算;不限制/不限/- 按腾讯不限预算 0 传递;数字按元转换为分。
   - 「素材来源」为空默认历史素材;填 AI生成素材 时走 AI 图片生成链路。
   - 「素材来源」为空默认历史素材;填 AI生成素材 时走 AI 图片生成链路。
   - 「生成失败是否回退历史素材」为空/否默认不回退。
   - 「生成失败是否回退历史素材」为空/否默认不回退。
   - 「出价方式」为空默认稳定成本;填「最大转化量」时使用「最大转化量出价」作为控制成本。
   - 「出价方式」为空默认稳定成本;填「最大转化量」时使用「最大转化量出价」作为控制成本。

+ 64 - 0
examples/auto_put_ad_mini/test_im_approval_creation.py

@@ -0,0 +1,64 @@
+import sys
+import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+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,
+)
+
+
+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_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()

+ 45 - 1
examples/auto_put_ad_mini/test_landing_video_dedupe.py

@@ -10,6 +10,13 @@ sys.path.insert(0, str(_HERE))
 import execute_creation_once  # noqa: E402
 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):
 class LandingVideoDedupeTest(unittest.TestCase):
     def test_same_crowd_package_excludes_landing_video_across_accounts_in_one_run(self):
     def test_same_crowd_package_excludes_landing_video_across_accounts_in_one_run(self):
         calls = []
         calls = []
@@ -22,12 +29,14 @@ class LandingVideoDedupeTest(unittest.TestCase):
                 "audience_tier": "wx*商业",
                 "audience_tier": "wx*商业",
                 "landing_video_id": 1000 + account_id,
                 "landing_video_id": 1000 + account_id,
                 "_material_id": f"m-{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]), \
         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, "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, "get_account_crowd_package", return_value="wx*商业"), \
-            patch.object(execute_creation_once, "load_recent_used_landing_video_ids", return_value=set()), \
+            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=[
             patch.object(execute_creation_once, "find_ads_needing_creatives", side_effect=[
                 [{"adgroup_id": 101, "creative_count": 3}],
                 [{"adgroup_id": 101, "creative_count": 3}],
                 [{"adgroup_id": 201, "creative_count": 3}],
                 [{"adgroup_id": 201, "creative_count": 3}],
@@ -40,6 +49,41 @@ class LandingVideoDedupeTest(unittest.TestCase):
         self.assertEqual(set(), calls[0][2])
         self.assertEqual(set(), calls[0][2])
         self.assertEqual({1001}, calls[1][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):
+            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, "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(2, len(records))
+        self.assertEqual(set(), calls[0])
+        self.assertEqual({777}, calls[1])
+
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
     unittest.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()

+ 425 - 17
examples/auto_put_ad_mini/tools/ai_generated_material.py

@@ -20,7 +20,7 @@ from dataclasses import dataclass
 from email.utils import formatdate
 from email.utils import formatdate
 from io import BytesIO
 from io import BytesIO
 from pathlib import Path
 from pathlib import Path
-from typing import Iterable, Optional
+from typing import Any, Iterable, Optional, Sequence
 from urllib.parse import quote, urlparse
 from urllib.parse import quote, urlparse
 
 
 import httpx
 import httpx
@@ -42,12 +42,22 @@ OPENROUTER_IMAGES_URL = os.getenv(
 )
 )
 OPENROUTER_IMAGE_MODEL = os.getenv(
 OPENROUTER_IMAGE_MODEL = os.getenv(
     "OPENROUTER_IMAGE_MODEL",
     "OPENROUTER_IMAGE_MODEL",
-    "google/gemini-3-pro-image",
+    "google/gemini-3.1-flash-image",
 )
 )
 OPENROUTER_TEXT_MODEL = os.getenv(
 OPENROUTER_TEXT_MODEL = os.getenv(
     "OPENROUTER_TEXT_MODEL",
     "OPENROUTER_TEXT_MODEL",
-    "google/gemini-2.5-flash",
+    "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_IMAGE_OSS_PREFIX = os.getenv("AI_IMAGE_OSS_PREFIX", "auto_put_tencent/image").strip("/")
 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_ASPECT_RATIO = os.getenv("AI_IMAGE_ASPECT_RATIO", "16:9")
 AI_IMAGE_RESOLUTION = os.getenv("AI_IMAGE_RESOLUTION", "1K")
 AI_IMAGE_RESOLUTION = os.getenv("AI_IMAGE_RESOLUTION", "1K")
@@ -60,6 +70,9 @@ DEFAULT_AI_IMAGE_PROMPT_TEMPLATE_PATH = (
 DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH = (
 DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH = (
     Path(__file__).resolve().parents[1] / "prompts" / "ai_sanitize_video_description.md"
     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_OSS_BUCKET = "art-pubbucket"
 DEFAULT_AI_IMAGE_PUBLIC_BASE_URL = "https://rescdn.yishihui.com"
 DEFAULT_AI_IMAGE_PUBLIC_BASE_URL = "https://rescdn.yishihui.com"
 FORBIDDEN_SANITIZED_DESCRIPTION_TERMS = (
 FORBIDDEN_SANITIZED_DESCRIPTION_TERMS = (
@@ -93,6 +106,12 @@ CREATE TABLE IF NOT EXISTS ai_generated_material (
     approval_status VARCHAR(50) DEFAULT NULL COMMENT '人工审批状态',
     approval_status VARCHAR(50) DEFAULT NULL COMMENT '人工审批状态',
     tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
     tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
     dynamic_creative_id BIGINT 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 '生成/上传/创建错误',
     error TEXT DEFAULT NULL COMMENT '生成/上传/创建错误',
     raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
     raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -103,6 +122,15 @@ CREATE TABLE IF NOT EXISTS ai_generated_material (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成创意图片素材'
 ) 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)
 @dataclass(frozen=True)
 class GenerationPrompt:
 class GenerationPrompt:
@@ -111,6 +139,24 @@ class GenerationPrompt:
     feature_hits: list[dict]
     feature_hits: list[dict]
 
 
 
 
+@dataclass(frozen=True)
+class CoverCopy:
+    main_title: str
+    hook_angle: str = ""
+    hook_point: str = ""
+    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,
+            "reason": self.reason,
+            "candidates": self.candidates or [],
+        }
+
+
 @dataclass(frozen=True)
 @dataclass(frozen=True)
 class GeneratedMaterialAsset:
 class GeneratedMaterialAsset:
     id: int
     id: int
@@ -213,6 +259,40 @@ def _load_sanitize_prompt_messages(raw_description: str) -> list[dict]:
     ]
     ]
 
 
 
 
+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:
 def _render_prompt(video_description: str) -> str:
     return (
     return (
         _load_prompt_template()
         _load_prompt_template()
@@ -238,8 +318,39 @@ def _extract_chat_completion_text(data: dict) -> str:
     return ""
     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:
 def sanitize_video_description(raw_description: str, model: str = OPENROUTER_TEXT_MODEL) -> str:
-    """Rewrite ODPS topic into a clean visual description for image generation."""
+    """Rewrite ODPS topic into an ad-theme seed for image generation."""
     raw = (raw_description or "").strip()
     raw = (raw_description or "").strip()
     if not raw:
     if not raw:
         raise RuntimeError("缺少视频解构选题,无法清洗生成描述")
         raise RuntimeError("缺少视频解构选题,无法清洗生成描述")
@@ -267,6 +378,124 @@ def sanitize_video_description(raw_description: str, model: str = OPENROUTER_TEX
     return cleaned
     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=28)
+        if not title:
+            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 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=28)
+    if not main_title and best_candidate:
+        main_title = str(best_candidate.get("title") or "")
+    if not main_title:
+        raise RuntimeError("OpenRouter 标题节点未返回 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],
+        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 "未标注"}
+
+图片中必须使用上面的主标题。不要生成副标题,也不要自行新增其他标题、角标或引导文字。
+"""
+    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_rule:{pattern.visual_rule}
+- relevance_rule:{pattern.relevance_rule}
+- compliance_rule:{pattern.compliance_rule}
+- positive_examples:{positive_examples}
+- negative_examples:{negative_examples}
+
+请基于【广告主题种子】和【选中的创意Pattern】生成广告封面。视频内容只提供主题方向,画面可以做适合信息流点击的合理转译。"""
+    return _render_prompt(enriched_description)
+
+
 def build_generation_prompts(
 def build_generation_prompts(
     *,
     *,
     video_id: int,
     video_id: int,
@@ -294,6 +523,80 @@ def build_generation_prompts(
     ]
     ]
 
 
 
 
+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,
+    )
+    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:
 def ensure_ai_material_table() -> None:
     from db.connection import get_connection
     from db.connection import get_connection
 
 
@@ -301,6 +604,10 @@ def ensure_ai_material_table() -> None:
     try:
     try:
         with conn.cursor() as cur:
         with conn.cursor() as cur:
             cur.execute(CREATE_AI_MATERIAL_TABLE_SQL)
             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()
         conn.commit()
     finally:
     finally:
         conn.close()
         conn.close()
@@ -561,6 +868,60 @@ def _insert_generated_material(
     )
     )
 
 
 
 
+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]:
 def _rows_to_assets(rows: list[dict]) -> list[GeneratedMaterialAsset]:
     out = []
     out = []
     for row in rows:
     for row in rows:
@@ -603,6 +964,7 @@ def load_available_generated_assets(
                   AND adgroup_id=%s
                   AND adgroup_id=%s
                   AND landing_video_id=%s
                   AND landing_video_id=%s
                   AND status='generated'
                   AND status='generated'
+                  AND ai_review_status='pass'
                   AND oss_url IS NOT NULL
                   AND oss_url IS NOT NULL
                   AND oss_url <> ''
                   AND oss_url <> ''
                 ORDER BY id ASC
                 ORDER BY id ASC
@@ -622,6 +984,12 @@ def generate_assets_for_landing(
     crowd_package: str,
     crowd_package: str,
     landing: LandingVideo,
     landing: LandingVideo,
     model: str = OPENROUTER_IMAGE_MODEL,
     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]:
 ) -> list[GeneratedMaterialAsset]:
     db_features = read_cached_video_element_features([landing.video_id]).get(landing.video_id) or []
     db_features = read_cached_video_element_features([landing.video_id]).get(landing.video_id) or []
     if not db_features:
     if not db_features:
@@ -643,13 +1011,34 @@ def generate_assets_for_landing(
         "[ai_generated_material] landing=%d sanitized description=%r",
         "[ai_generated_material] landing=%d sanitized description=%r",
         landing.video_id, sanitized_description,
         landing.video_id, sanitized_description,
     )
     )
-    prompts = build_generation_prompts(
-        video_id=landing.video_id,
-        title=landing.title,
-        category=landing.category,
-        features=db_features,
-        sanitized_description=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] = []
     assets: list[GeneratedMaterialAsset] = []
     for prompt in prompts:
     for prompt in prompts:
         image_bytes, content_type, raw_response = generate_image_bytes(prompt.prompt_text, model)
         image_bytes, content_type, raw_response = generate_image_bytes(prompt.prompt_text, model)
@@ -663,7 +1052,7 @@ def generate_assets_for_landing(
             extension=ext,
             extension=ext,
         )
         )
         oss_url = upload_image_to_oss(image_bytes, content_type, object_key)
         oss_url = upload_image_to_oss(image_bytes, content_type, object_key)
-        asset = _insert_generated_material(
+        asset, review = insert_and_review_generated_material(
             account_id=account_id,
             account_id=account_id,
             adgroup_id=adgroup_id,
             adgroup_id=adgroup_id,
             crowd_package=crowd_package,
             crowd_package=crowd_package,
@@ -674,10 +1063,16 @@ def generate_assets_for_landing(
             oss_url=oss_url,
             oss_url=oss_url,
             raw_response=raw_response,
             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)
         assets.append(asset)
         logger.info(
         logger.info(
-            "[ai_generated_material] generated account=%d adgroup=%d landing=%d asset=%d type=%s url=%s",
-            account_id, adgroup_id, landing.video_id, asset.id, prompt.prompt_type, oss_url,
+            "[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
     return assets
 
 
@@ -688,16 +1083,29 @@ def get_or_generate_assets_for_landing(
     adgroup_id: int,
     adgroup_id: int,
     crowd_package: str,
     crowd_package: str,
     landing: LandingVideo,
     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]:
 ) -> list[GeneratedMaterialAsset]:
     existing = load_available_generated_assets(account_id, adgroup_id, landing.video_id)
     existing = load_available_generated_assets(account_id, adgroup_id, landing.video_id)
-    if existing:
-        return existing
-    return generate_assets_for_landing(
+    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,
         account_id=account_id,
         adgroup_id=adgroup_id,
         adgroup_id=adgroup_id,
         crowd_package=crowd_package,
         crowd_package=crowd_package,
         landing=landing,
         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(
 def update_generated_material_status(

+ 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()

+ 70 - 14
examples/auto_put_ad_mini/tools/creative_creation.py

@@ -551,21 +551,32 @@ def prepare_one_creative_for_ad(
     crowd_package = get_account_crowd_package(account_id)
     crowd_package = get_account_crowd_package(account_id)
     material_strategy = load_account_material_strategy(account_id)
     material_strategy = load_account_material_strategy(account_id)
     video_crowd_package = map_crowd_package_for_video_recall(crowd_package)
     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,
-        )
+    if material_strategy.use_ai_generated and not material_strategy.ai_fallback_to_history:
         recent_material_ids = set()
         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(
     effective_excluded_material_ids = merge_used_material_ids(
         excluded_material_ids,
         excluded_material_ids,
         recent_material_ids,
         recent_material_ids,
     )
     )
     logger.info(
     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,
         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(excluded_material_ids), len(recent_material_ids),
         len(effective_excluded_material_ids),
         len(effective_excluded_material_ids),
     )
     )
@@ -591,9 +602,26 @@ def prepare_one_creative_for_ad(
         )
         )
         valid = [v for v in videos if _is_landing_candidate(v)]
         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)
         features_by_vid = fetch_video_element_features(v.video_id for v in valid)
+        source_stats = {
+            "fetched": len(videos),
+            "valid": len(valid),
+            "category_filtered": max(len(videos) - len(valid), 0),
+            "landing_dedupe": 0,
+            "risk_blocked": 0,
+            "no_features": 0,
+            "ai_attempts": 0,
+            "ai_failed": 0,
+            "ai_empty": 0,
+            "history_recall_empty": 0,
+            "all_excluded": 0,
+            "max_landing_limit": 0,
+            "selected": 0,
+        }
         logger.info(
         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),
+            "[prepare_one_creative] account=%d adgroup=%d source=%s material_source=%s "
+            "valid landing=%d/100 feature_videos=%d feature_rows=%d excl_mat=%d",
+            account_id, adgroup_id, source_label, material_strategy.material_source,
+            len(valid),
             len(features_by_vid), sum(len(v) for v in features_by_vid.values()),
             len(features_by_vid), sum(len(v) for v in features_by_vid.values()),
             len(effective_excluded_material_ids),
             len(effective_excluded_material_ids),
         )
         )
@@ -601,6 +629,7 @@ def prepare_one_creative_for_ad(
         attempts = 0
         attempts = 0
         for v in valid:
         for v in valid:
             if v.video_id in excluded_landing_ids:
             if v.video_id in excluded_landing_ids:
+                source_stats["landing_dedupe"] += 1
                 logger.info(
                 logger.info(
                     "[prepare_one_creative]   landing=%d landing 排重命中,跳过",
                     "[prepare_one_creative]   landing=%d landing 排重命中,跳过",
                     v.video_id,
                     v.video_id,
@@ -608,12 +637,14 @@ def prepare_one_creative_for_ad(
                 continue
                 continue
             attempts += 1
             attempts += 1
             if attempts > max_landings:
             if attempts > max_landings:
+                source_stats["max_landing_limit"] += 1
                 break
                 break
 
 
             # 2026-06-29:承接视频风险审核。必须在素材召回 / xcx-save 前完成,
             # 2026-06-29:承接视频风险审核。必须在素材召回 / xcx-save 前完成,
             # 避免高风险 landing 继续产生 plan/rootSourceId 等外部副作用。
             # 避免高风险 landing 继续产生 plan/rootSourceId 等外部副作用。
             risk = check_video_risk(v.video_id)
             risk = check_video_risk(v.video_id)
             if not risk.passed:
             if not risk.passed:
+                source_stats["risk_blocked"] += 1
                 logger.warning(
                 logger.warning(
                     "[prepare_one_creative]   landing=%d 风险拦截:%s",
                     "[prepare_one_creative]   landing=%d 风险拦截:%s",
                     v.video_id, risk.reason,
                     v.video_id, risk.reason,
@@ -622,6 +653,7 @@ def prepare_one_creative_for_ad(
 
 
             element_features = features_by_vid.get(v.video_id) or []
             element_features = features_by_vid.get(v.video_id) or []
             if not element_features:
             if not element_features:
+                source_stats["no_features"] += 1
                 logger.info(
                 logger.info(
                     "[prepare_one_creative]   landing=%d 无 ODPS 召回特征,跳过",
                     "[prepare_one_creative]   landing=%d 无 ODPS 召回特征,跳过",
                     v.video_id,
                     v.video_id,
@@ -631,6 +663,7 @@ def prepare_one_creative_for_ad(
             materials = []
             materials = []
             candidate_material_source = material_strategy.material_source
             candidate_material_source = material_strategy.material_source
             if material_strategy.use_ai_generated:
             if material_strategy.use_ai_generated:
+                source_stats["ai_attempts"] += 1
                 try:
                 try:
                     assets = get_or_generate_assets_for_landing(
                     assets = get_or_generate_assets_for_landing(
                         account_id=account_id,
                         account_id=account_id,
@@ -644,6 +677,7 @@ def prepare_one_creative_for_ad(
                         v.video_id, len(materials), material_strategy.ai_fallback_to_history,
                         v.video_id, len(materials), material_strategy.ai_fallback_to_history,
                     )
                     )
                 except Exception as e:
                 except Exception as e:
+                    source_stats["ai_failed"] += 1
                     logger.exception(
                     logger.exception(
                         "[prepare_one_creative]   landing=%d AI生成素材失败:%s",
                         "[prepare_one_creative]   landing=%d AI生成素材失败:%s",
                         v.video_id, e,
                         v.video_id, e,
@@ -651,6 +685,18 @@ def prepare_one_creative_for_ad(
                     if not material_strategy.ai_fallback_to_history:
                     if not material_strategy.ai_fallback_to_history:
                         continue
                         continue
 
 
+            if (
+                material_strategy.use_ai_generated
+                and not materials
+                and not material_strategy.ai_fallback_to_history
+            ):
+                source_stats["ai_empty"] += 1
+                logger.info(
+                    "[prepare_one_creative]   landing=%d AI无可用素材且不允许回退历史素材,跳过",
+                    v.video_id,
+                )
+                continue
+
             if (not materials) and (
             if (not materials) and (
                 not material_strategy.use_ai_generated
                 not material_strategy.use_ai_generated
                 or material_strategy.ai_fallback_to_history
                 or material_strategy.ai_fallback_to_history
@@ -666,6 +712,8 @@ def prepare_one_creative_for_ad(
                     final_top_n=max_materials_per_landing,
                     final_top_n=max_materials_per_landing,
                     element_features=element_features,
                     element_features=element_features,
                 )
                 )
+                if not materials:
+                    source_stats["history_recall_empty"] += 1
             # material_id 去重(2026-06-09):跳过已用素材(账户层 set,跨广告也共享)
             # material_id 去重(2026-06-09):跳过已用素材(账户层 set,跨广告也共享)
             fresh = [
             fresh = [
                 m for m in materials
                 m for m in materials
@@ -679,6 +727,7 @@ def prepare_one_creative_for_ad(
                 chosen_material_source = candidate_material_source
                 chosen_material_source = candidate_material_source
                 if chosen_material.material_id.startswith("ai:"):
                 if chosen_material.material_id.startswith("ai:"):
                     chosen_ai_generated_material_id = chosen_material.raw.get("ai_generated_material_id")
                     chosen_ai_generated_material_id = chosen_material.raw.get("ai_generated_material_id")
+                source_stats["selected"] += 1
                 logger.info(
                 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",
                     "[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,
                     v.video_id, source_label, v.category,
@@ -696,21 +745,28 @@ def prepare_one_creative_for_ad(
                 )
                 )
                 break
                 break
             if materials:
             if materials:
+                source_stats["all_excluded"] += 1
                 logger.info(
                 logger.info(
                     "[prepare_one_creative]   landing=%d 召回 %d 全在 excluded,试下一条",
                     "[prepare_one_creative]   landing=%d 召回 %d 全在 excluded,试下一条",
                     v.video_id, len(materials),
                     v.video_id, len(materials),
                 )
                 )
         if chosen_landing and chosen_material:
         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, source_label, source_stats,
+            )
             break
             break
         logger.info(
         logger.info(
-            "[prepare_one_creative] account=%d adgroup=%d source=%s 未产出可用创意",
-            account_id, adgroup_id, source_label,
+            "[prepare_one_creative] account=%d adgroup=%d source=%s 未产出可用创意 stats=%s",
+            account_id, adgroup_id, source_label, source_stats,
         )
         )
 
 
     if not chosen_landing or not chosen_material:
     if not chosen_landing or not chosen_material:
         logger.error(
         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
         return None
 
 

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

@@ -157,6 +157,90 @@ def load_recent_used_landing_video_ids(
     return out
     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 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 record_prepared_material_usage(record: dict, status: str = "prepared") -> None:
 def record_prepared_material_usage(record: dict, status: str = "prepared") -> None:
     """Reserve a material once Phase 1 has produced a pending creative."""
     """Reserve a material once Phase 1 has produced a pending creative."""
     material_id = str(record.get("_material_id") or "").strip()
     material_id = str(record.get("_material_id") or "").strip()

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

@@ -19,14 +19,18 @@
 import asyncio
 import asyncio
 import json
 import json
 import logging
 import logging
+import os
+import tempfile
 import time
 import time
 from pathlib import Path
 from pathlib import Path
 from typing import Optional
 from typing import Optional
 
 
 import httpx
 import httpx
 from openpyxl import Workbook
 from openpyxl import Workbook
+from openpyxl.drawing.image import Image as OpenpyxlImage
 from openpyxl.styles import Alignment, Font, PatternFill
 from openpyxl.styles import Alignment, Font, PatternFill
 from openpyxl.worksheet.datavalidation import DataValidation
 from openpyxl.worksheet.datavalidation import DataValidation
+from PIL import Image as PilImage
 
 
 from config import (
 from config import (
     CREATION_APPROVAL_TIMEOUT_MINUTES,
     CREATION_APPROVAL_TIMEOUT_MINUTES,
@@ -38,15 +42,15 @@ logger = logging.getLogger(__name__)
 
 
 FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
 FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
 
 
-# 27 列(中文)— 素材排序改为 score 准入 + cost 倒序,报表同步展示召回依据。
+# 28 列(中文)— 素材排序改为 score 准入 + cost 倒序,报表同步展示召回依据。
 HEADERS = [
 HEADERS = [
     # A 浅灰 5 列
     # A 浅灰 5 列
     "日期", "账户ID", "人群包", "广告ID", "广告名称",
     "日期", "账户ID", "人群包", "广告ID", "广告名称",
     # B 浅紫 3 列
     # B 浅紫 3 列
     "出价(元)", "投放版位", "年龄定向",
     "出价(元)", "投放版位", "年龄定向",
-    # C 浅橙 18 列(落地视频 + 风险审核 + 素材来源 + 素材质量)
+    # C 浅橙 19 列(落地视频 + 风险审核 + 素材来源 + 素材质量)
     "落地视频", "落地视频标题", "风险等级", "风险标签", "风险原因",
     "落地视频", "落地视频标题", "风险等级", "风险标签", "风险原因",
-    "素材来源", "素材预览", "创意文案",
+    "素材来源", "素材预览", "素材链接", "创意文案",
     "成本(元)", "ROI", "CTR", "曝光数", "相似度",
     "成本(元)", "ROI", "CTR", "曝光数", "相似度",
     "召回维度", "召回点类型", "召回元素", "命中维度明细",
     "召回维度", "召回点类型", "召回元素", "命中维度明细",
     "创意名(归因)",
     "创意名(归因)",
@@ -57,23 +61,28 @@ HEADERS = [
 GROUP_COLORS = [
 GROUP_COLORS = [
     ((1, 5), "FFD9D9D9"),    # A 浅灰
     ((1, 5), "FFD9D9D9"),    # A 浅灰
     ((6, 8), "FFD9C8E8"),    # B 浅紫
     ((6, 8), "FFD9C8E8"),    # B 浅紫
-    ((9, 26), "FFFCD8B4"),   # C 浅橙
-    ((27, 27), "FFC6E0B4"),  # D 浅绿
+    ((9, 27), "FFFCD8B4"),   # C 浅橙
+    ((28, 28), "FFC6E0B4"),  # D 浅绿
 ]
 ]
 
 
 COL_WIDTHS = {
 COL_WIDTHS = {
     "A": 12, "B": 14, "C": 16, "D": 16, "E": 28,
     "A": 12, "B": 14, "C": 16, "D": 16, "E": 28,
     "F": 10, "G": 28, "H": 12,
     "F": 10, "G": 28, "H": 12,
     "I": 14, "J": 26, "K": 10, "L": 24, "M": 32,
     "I": 14, "J": 26, "K": 10, "L": 24, "M": 32,
-    "N": 14, "O": 18, "P": 30,
-    "Q": 12, "R": 10, "S": 10, "T": 10, "U": 10,
-    "V": 14, "W": 14, "X": 18, "Y": 42,
-    "Z": 38,                      # 创意名(归因)
-    "AA": 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 = "AA"
+DECISION_COL_LETTER = "AB"
 VALID_ACTIONS = ("approve", "reject", "hold")
 VALID_ACTIONS = ("approve", "reject", "hold")
+EMBED_MATERIAL_PREVIEW_IMAGES = os.getenv(
+    "CREATION_APPROVAL_EMBED_IMAGES", ""
+).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:
 def _color_for_col(col_idx: int) -> str:
@@ -123,6 +132,7 @@ def _format_record_to_row(rec: dict) -> list:
         rec.get("material_source", "history"),
         rec.get("material_source", "history"),
         # 素材预览:HYPERLINK(cover_url, "查看素材")— 见模块顶部说明
         # 素材预览:HYPERLINK(cover_url, "查看素材")— 见模块顶部说明
         f'=HYPERLINK("{rec["material_cover_url"]}","查看素材")',
         f'=HYPERLINK("{rec["material_cover_url"]}","查看素材")',
+        f'=HYPERLINK("{rec["material_cover_url"]}","打开素材")',
         # 创意文案(2026-06-09 加列):description 换行展示
         # 创意文案(2026-06-09 加列):description 换行展示
         descriptions_str,
         descriptions_str,
         f"{cost:.2f}" if cost is not None else "",
         f"{cost:.2f}" if cost is not None else "",
@@ -139,6 +149,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:
 def generate_approval_xlsx(records: list[dict], output_path: Path) -> Path:
     """生成待审批 xlsx,含 hyperlink/下拉/颜色/冻结。"""
     """生成待审批 xlsx,含 hyperlink/下拉/颜色/冻结。"""
     wb = Workbook()
     wb = Workbook()
@@ -189,6 +266,8 @@ def generate_approval_xlsx(records: list[dict], output_path: Path) -> Path:
     for r in range(2, 2 + len(records)):
     for r in range(2, 2 + len(records)):
         ws.row_dimensions[r].height = 80
         ws.row_dimensions[r].height = 80
 
 
+    _embed_material_preview_images(ws, records)
+
     output_path.parent.mkdir(parents=True, exist_ok=True)
     output_path.parent.mkdir(parents=True, exist_ok=True)
     wb.save(output_path)
     wb.save(output_path)
     logger.info(
     logger.info(

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

@@ -0,0 +1,1088 @@
+"""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 = 45
+    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"
+)
+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")
+
+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 45,
+            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")
+
+    global_risk_groups = [
+        ("medical_health_topic", ["医疗", "医院", "看病", "治病", "疗效"], -30),
+        ("celebrity_or_history_topic", ["名人", "历史名人", "明星", "演员", "歌手"], -25),
+        ("crime_or_violence_topic", ["犯罪", "尸体", "暴力", "血腥", "凶杀"], -35),
+        ("superstition_prediction_topic", ["迷信", "预言", "算命", "降世"], -30),
+        ("fake_ui_or_clickbait", ["点击按钮", "扫码", "二维码", "立即领取", "最后一天"], -25),
+    ]
+    for name, keywords, penalty in global_risk_groups:
+        if _contains_any(joined_features, keywords):
+            score += penalty
+            penalties.append(name)
+
+    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 _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 = {
+        "task": "从候选广告创意pattern中选择最适合该视频的pattern",
+        "requirements": [
+            "只能从candidate_patterns里选择,不能创造新的pattern_key",
+            "优先选择和视频解构选题/关键点/目的点/灵感点语义最相关的pattern",
+            "目标用户是45岁以上中老年小程序视频产品用户,目标是提高点击和后续观看/裂变",
+            "保留轻悬念/轻猎奇/信息差,但必须规避医疗恐吓、涉政民族对立、名人历史、迷信、低俗两性、假按钮/假官方",
+            "输出必须是JSON,不要输出解释性正文",
+        ],
+        "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": ["需要注意的合规风险,没有则空数组"],
+                }
+            ]
+        },
+    }
+    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
+        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
+    if not out:
+        raise RuntimeError("模型未返回有效pattern_key")
+    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,
+    )

+ 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"
     "PIAOQUANTV_HOT_FALLBACK_ENABLED", "true"
 ).strip().lower() not in {"0", "false", "no", "off"}
 ).strip().lower() not in {"0", "false", "no", "off"}
 PIAOQUANTV_HOT_FALLBACK_SOURCE = os.getenv("PIAOQUANTV_HOT_FALLBACK_SOURCE", "hot")
 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]:
 def _load_video_crowd_package_map() -> dict[str, str]:
@@ -62,17 +63,54 @@ def _load_video_crowd_package_map() -> dict[str, str]:
                 }
                 }
         except json.JSONDecodeError:
         except json.JSONDecodeError:
             logger.warning("[video_recall] VIDEO_RECALL_CROWD_PACKAGE_MAP 不是合法 JSON,使用默认映射")
             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()
 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:
 def map_crowd_package_for_video_recall(crowd_package: str) -> str:
     """只映射内容服务 videoContentList 的 crowdPackage,不影响腾讯投放人群包。"""
     """只映射内容服务 videoContentList 的 crowdPackage,不影响腾讯投放人群包。"""
     return VIDEO_RECALL_CROWD_PACKAGE_MAP.get(crowd_package, crowd_package)
     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:
 def _normalize_category(raw) -> str:
     if raw is None:
     if raw is None:
         return ""
         return ""
@@ -189,7 +227,8 @@ def fetch_landing_videos(
     }
     }
 
 
     logger.info(
     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 = httpx.post(PIAOQUANTV_VIDEO_API, json=body, headers=headers, timeout=timeout)
     resp.raise_for_status()
     resp.raise_for_status()
@@ -241,6 +280,39 @@ def fetch_landing_videos(
     return 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:
 def get_account_crowd_package(account_id: int) -> str:
     """从 account_whitelist 读账户级 crowd_package。
     """从 account_whitelist 读账户级 crowd_package。
 
 
@@ -273,6 +345,7 @@ def fetch_landing_videos_for_account(
     page_size: int = 10,
     page_size: int = 10,
     source: Optional[str] = None,
     source: Optional[str] = None,
     enable_hot_fallback: bool = True,
     enable_hot_fallback: bool = True,
+    max_pages: int = PIAOQUANTV_VIDEO_MAX_PAGES,
 ) -> List[LandingVideo]:
 ) -> List[LandingVideo]:
     """根据账户的 crowd_package 字段拉视频。
     """根据账户的 crowd_package 字段拉视频。
 
 
@@ -280,11 +353,17 @@ def fetch_landing_videos_for_account(
     """
     """
     crowd_package = get_account_crowd_package(account_id)
     crowd_package = get_account_crowd_package(account_id)
     video_crowd_package = map_crowd_package_for_video_recall(crowd_package)
     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,
         crowd_package=video_crowd_package,
         page_size=page_size,
         page_size=page_size,
         source=selected_source,
         source=selected_source,
+        max_pages=max_pages,
     )
     )
     if (
     if (
         source is not None
         source is not None
@@ -300,10 +379,11 @@ def fetch_landing_videos_for_account(
         "[video_recall] primary 不足,用 hot 兜底: account=%d crowd=%r video_crowd=%r primary=%d need=%d",
         "[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,
         account_id, crowd_package, video_crowd_package, len(primary), missing,
     )
     )
-    fallback = fetch_landing_videos(
+    fallback = _fetch_landing_video_pages(
         crowd_package=video_crowd_package,
         crowd_package=video_crowd_package,
         page_size=missing,
         page_size=missing,
         source=PIAOQUANTV_HOT_FALLBACK_SOURCE,
         source=PIAOQUANTV_HOT_FALLBACK_SOURCE,
+        max_pages=max_pages,
     )
     )
     seen = {v.video_id for v in primary}
     seen = {v.video_id for v in primary}
     merged = list(primary)
     merged = list(primary)