فهرست منبع

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

刘立冬 3 هفته پیش
والد
کامیت
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 创意创建规则
 
 - 目标是单广告最终合格创意数,不是单次生成的 pending 行数。
-- 当前目标:每条广告最终有效创意不少于 4 个。
+- 当前目标:每条广告最终有效创意不少于 12 个。
 - `DENIED` 创意不计入有效创意。
 - 每次创意准备中,每个视频来源最多获取并尝试 100 条视频。
 - 先尝试 primary 视频来源。
@@ -47,18 +47,24 @@
 - 当前素材硬筛只看相似度:`score >= 0.8`。
 - 曝光、CTR、ROI 只作为审批展示和兜底排序参考,不作为硬筛。
 - 素材通过相似度筛选后,默认按历史消耗 `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 次。
 - 素材需要跨账户/跨天排重,默认按同人群包下近期使用过的 `material_id` 排除。
 - 当前运行内的审批候选需要做轻量展示去重:同一 `crowd_package + material_id` 只进入本轮审批表一次;该去重只存在内存,不写入历史排重库。
+- `videoContentList` 每个 source 默认最多读取 3 页,每页 100 条,按 `video_id` 去重合并。
 
 ## 视频召回人群包映射
 
 - 腾讯投放、人群包授权、落地计划仍使用账户配置的人群包。
 - 内容服务 `videoContentList` 的 `crowdPackage` 可以有独立映射。
-- 当前默认映射:`cell*year*商业` 获取视频时映射为 `wx*商业`。
+- 当前默认映射:
+  - `cell*year*商业` 获取视频时映射为 `wx*商业`
+  - `回流330以上人群` 获取视频时映射为 `R_330+`
 - `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
-TARGET_CREATIVES_PER_AD = 4
+TARGET_CREATIVES_PER_AD = 12
 ```
 
 含义:
 
-- 每条广告最终有效创意数不少于 4
+- 每条广告最终有效创意数不少于 12
 - 已有有效创意会计入目标
 - `DENIED` 创意不计入有效创意
 - 差几个补几个
@@ -52,27 +58,36 @@ TARGET_CREATIVES_PER_AD = 4
 每次为一条广告准备一条创意时,视频最多扫描:
 
 ```text
-primary source: 最多 100 条
-hot source: 最多 100 条
+primary source: 默认最多 3 页,每页 100 条
+hot source: 默认最多 3 页,每页 100 条
 ```
 
 执行顺序:
 
 1. 先用账户配置的人群包拉主池视频
-2. 主池 100 条经过过滤、风险审核、素材召回后仍无法产出可用创意时,再用同一个人群包拉 `source=hot`
-3. hot 池同样最多 100 条
+2. 主池最多 3 页经过过滤、风险审核、素材召回后仍无法产出可用创意时,再用同一个人群包拉 `source=hot`
+3. hot 池同样最多 3 页
 4. hot 池同样走风险审核、品类过滤、素材质量过滤
 
-注意: `source=hot` 只改变内容服务的视频来源。默认情况下 `crowdPackage` 使用当前账户配置的人群包;如果配置了视频召回映射,则 primary/hot 都使用映射后的召回人群包。
+注意: `source=hot` 只改变内容服务的视频来源。默认情况下 `crowdPackage` 使用当前账户配置的人群包;如果配置了视频召回映射,则 primary/hot 都使用映射后的召回人群包。分页结果按 `video_id` 去重合并。
 
 当前默认视频召回映射:
 
 ```text
 cell*year*商业 -> wx*商业
+回流330以上人群 -> R_330+
 ```
 
 该映射只影响 `videoContentList` 获取视频,不影响腾讯投放定向、人群包授权和 `xcx/save` 落地计划。
 
+内容服务 `source` 也可独立映射。当前默认没有 source 覆盖,会使用 `.env` 的 `PIAOQUANTV_VIDEO_SOURCE=prior`。
+
+```text
+回流330以上人群 -> crowdPackage=R_330+, source=prior
+```
+
+原因:内容服务中 330 人群包的实际参数是 `R_330+`。
+
 ## 视频过滤规则
 
 内容服务 `videoContentList` 返回的 `category` 字段会用于内容品类过滤。
@@ -194,10 +209,12 @@ AI 图默认上传到:
 - 历史排重只读取已有明确结果的素材使用记录,按同人群包下的 `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 次。
 
 可通过环境变量调整同广告落地页视频本轮限频:
@@ -205,6 +222,7 @@ AI 图默认上传到:
 ```bash
 MAX_SAME_LANDING_PER_AD_IN_RUN=1
 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
 LANDING_EXCLUDED_CATEGORIES=早中晚好,祝福音乐,历史名人
 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
+PIAOQUANTV_VIDEO_MAX_PAGES=3
 CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS=7
 TENCENT_AUDIENCE_SOURCE_ACCOUNT_ID=55615440
 TENCENT_AUDIENCE_GRANT_BUSINESS_ID=12312
 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_BUCKET=art-pubbucket
 ALIYUN_OSS_ACCESS_KEY_ID=...
@@ -257,6 +277,33 @@ AI_IMAGE_TARGET_HEIGHT=720
 
 OSS key 已迁移到本工程环境变量。生产 Docker 也应显式注入这些变量,不依赖外层参考工程。
 
+## 临时收窄运行
+
+每日主流程默认会扫描全部启用账户。应急验证或定向补量时,可以用运行时环境变量临时收窄范围,不改飞书、不改数据库配置:
+
+```bash
+CREATION_SKIP_PHASE0=1
+CREATION_ONLY_CROWD_PACKAGES=回流330以上人群
+```
+
+含义:
+
+- `CREATION_SKIP_PHASE0=1`: 本次跳过广告创建,只做创意准备、审批和提交。
+- `CREATION_ONLY_CROWD_PACKAGES`: 本次只处理指定人群包的账户;多个值用英文逗号分隔。
+- `CREATION_ONLY_ACCOUNT_IDS`: 可选,本次只处理指定账户 ID;多个值用英文逗号分隔。
+
+这些开关只影响当次进程。生产定时任务不配置这些变量时,仍按默认全量启用账户运行。
+
+创意审批表包含独立的 `素材预览` 和 `素材链接` 两列:`素材预览` 用于图片/查看入口,`素材链接` 保留原始素材 URL 的 `HYPERLINK` 公式。当前 `决策` 列为 `AB`,审批轮询也读取 `AB` 列。
+
+若需要把素材封面作为缩略图嵌入 xlsx 后再上传飞书在线表格,可临时开启:
+
+```bash
+CREATION_APPROVAL_EMBED_IMAGES=1
+```
+
+飞书导入若不保留 xlsx 内嵌图片,审批表仍保留链接兜底;后续可改用飞书 Sheets 写图片 API。
+
 ## 当前已知限制
 
 - `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,
         "location_types": location_types,
         "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,
     }
 
@@ -938,10 +942,10 @@ AUDIENCE_TIER_PATTERNS = [
 # 数据流: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 阈值 + 补量目标的**同一个语义变量**,不要拆
-TARGET_CREATIVES_PER_AD = 4
+TARGET_CREATIVES_PER_AD = int(os.getenv("TARGET_CREATIVES_PER_AD", "12"))
 
 # --- 主循环 try-fallback 限额(防无限召回)---
 # 单广告最多尝试 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:
     text = str(raw or "").strip()
-    if not text or text in {"不限制", "不限", "-"}:
+    if not text:
         return None
+    if text in {"不限制", "不限", "-"}:
+        return 0
     # 飞书里预算按元填,腾讯 API 用分。
     return _bid_to_fen(text)
 

+ 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 '人工审批状态',
     tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
     dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
+    ai_review_status VARCHAR(50) DEFAULT NULL COMMENT 'AI审核状态:pass/reject/hold/error',
+    ai_review_score INT DEFAULT NULL COMMENT 'AI审核评分0-100',
+    ai_review_model VARCHAR(200) DEFAULT NULL COMMENT 'AI审核模型',
+    ai_review_reason TEXT DEFAULT NULL COMMENT 'AI审核原因摘要',
+    ai_review_json MEDIUMTEXT DEFAULT NULL COMMENT 'AI审核原始JSON',
+    ai_reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT 'AI审核时间',
     error TEXT DEFAULT NULL COMMENT '生成/上传/创建错误',
     raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -325,6 +331,166 @@ CREATE TABLE IF NOT EXISTS ai_generated_material (
     KEY idx_status_created (status, created_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成创意图片素材';
 
+-- =====================================================
+-- 14. 高消耗素材表现快照任务
+-- =====================================================
+CREATE TABLE IF NOT EXISTS material_performance_snapshot_run (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
+    window_start DATE NOT NULL COMMENT '统计窗口开始日期',
+    window_end DATE NOT NULL COMMENT '统计窗口结束日期',
+    top_n INT NOT NULL DEFAULT 5000 COMMENT '拉取TopN',
+    source VARCHAR(64) NOT NULL DEFAULT 'odps' COMMENT '数据来源',
+    sql_file VARCHAR(255) DEFAULT NULL COMMENT 'SQL文件路径',
+    row_count INT NOT NULL DEFAULT 0 COMMENT '明细行数',
+    total_cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内总消耗(分)',
+    status VARCHAR(32) NOT NULL DEFAULT 'SUCCESS' COMMENT '任务状态',
+    error_message MEDIUMTEXT DEFAULT NULL COMMENT '错误信息',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+    UNIQUE KEY uk_run_id (run_id),
+    KEY idx_window (window_start, window_end)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高消耗素材表现快照任务';
+
+-- =====================================================
+-- 15. 高消耗素材表现快照明细
+-- =====================================================
+CREATE TABLE IF NOT EXISTS material_performance_snapshot_item (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
+    rank_no INT NOT NULL COMMENT '消耗排名',
+    account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
+    ad_id BIGINT NOT NULL COMMENT '广告ID',
+    creative_id BIGINT NOT NULL COMMENT '创意ID',
+    creative_name VARCHAR(255) DEFAULT NULL COMMENT '创意名称',
+    ad_name VARCHAR(255) DEFAULT NULL COMMENT '广告名称',
+    video_id BIGINT DEFAULT NULL COMMENT '承接视频ID',
+    title VARCHAR(512) DEFAULT NULL COMMENT '素材标题',
+    image_url VARCHAR(1024) DEFAULT NULL COMMENT '素材图片URL',
+    image_hash VARCHAR(64) DEFAULT NULL COMMENT '图片内容hash',
+    crowd_package VARCHAR(255) DEFAULT NULL COMMENT '人群包名称',
+    optimization_goal VARCHAR(128) DEFAULT NULL COMMENT '优化目标',
+    bid_amount_fen BIGINT DEFAULT NULL COMMENT '出价(分)',
+    day_amount_fen BIGINT DEFAULT NULL COMMENT '日预算(分)',
+    cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '消耗(分)',
+    impressions BIGINT NOT NULL DEFAULT 0 COMMENT '曝光',
+    clicks BIGINT NOT NULL DEFAULT 0 COMMENT '点击',
+    ctr DECIMAL(10, 6) DEFAULT NULL COMMENT '点击率',
+    key_page_view_count BIGINT NOT NULL DEFAULT 0 COMMENT '关键页访问次数',
+    key_page_rate DECIMAL(10, 6) DEFAULT NULL COMMENT '关键页访问/点击',
+    conversions_count BIGINT NOT NULL DEFAULT 0 COMMENT '转化数',
+    conversion_rate DECIMAL(10, 6) DEFAULT NULL COMMENT '转化率',
+    active_days INT NOT NULL DEFAULT 0 COMMENT '有消耗天数',
+    first_dt VARCHAR(16) DEFAULT NULL COMMENT '首次投放日期',
+    last_dt VARCHAR(16) DEFAULT NULL COMMENT '最后投放日期',
+    raw_json MEDIUMTEXT DEFAULT NULL COMMENT '原始行JSON',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+    UNIQUE KEY uk_run_creative (run_id, creative_id),
+    KEY idx_creative (creative_id),
+    KEY idx_image_hash (image_hash),
+    KEY idx_video (video_id),
+    KEY idx_package_cost (crowd_package, cost_fen)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高消耗素材表现快照明细';
+
+-- =====================================================
+-- 16. 素材图片结构化标注
+-- =====================================================
+CREATE TABLE IF NOT EXISTS material_visual_annotation (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    image_hash VARCHAR(64) NOT NULL COMMENT '图片内容hash',
+    image_url VARCHAR(1024) NOT NULL COMMENT '素材图片URL',
+    annotation_version VARCHAR(64) NOT NULL COMMENT '标注版本',
+    annotator VARCHAR(64) NOT NULL COMMENT '标注来源',
+    creative_id BIGINT DEFAULT NULL COMMENT '样本创意ID',
+    visual_template VARCHAR(128) DEFAULT NULL COMMENT '视觉模板',
+    hook_category VARCHAR(255) DEFAULT NULL COMMENT '标题钩子分类',
+    title_text VARCHAR(512) DEFAULT NULL COMMENT '图片/素材标题',
+    title_length INT DEFAULT NULL COMMENT '标题长度',
+    scene_type VARCHAR(128) DEFAULT NULL COMMENT '场景类型',
+    person_type VARCHAR(128) DEFAULT NULL COMMENT '人物/主体估计',
+    has_human TINYINT DEFAULT NULL COMMENT '是否有人物',
+    text_area_level VARCHAR(32) DEFAULT NULL COMMENT '文字面积估计等级',
+    color_style VARCHAR(128) DEFAULT NULL COMMENT '颜色/调性',
+    button_like_element TINYINT NOT NULL DEFAULT 0 COMMENT '是否疑似按钮诱导',
+    fake_ui_risk TINYINT NOT NULL DEFAULT 0 COMMENT '假界面风险',
+    official_policy_risk TINYINT NOT NULL DEFAULT 0 COMMENT '官方/政策承诺风险',
+    medical_health_risk TINYINT NOT NULL DEFAULT 0 COMMENT '医疗健康风险',
+    politics_sensitive_risk TINYINT NOT NULL DEFAULT 0 COMMENT '涉政/国家情绪风险',
+    celebrity_or_history_risk TINYINT NOT NULL DEFAULT 0 COMMENT '名人/历史人物风险',
+    strong_inducement_risk TINYINT NOT NULL DEFAULT 0 COMMENT '强诱导风险',
+    greeting_blessing_risk TINYINT NOT NULL DEFAULT 0 COMMENT '早晚安/祝福风险',
+    compliance_level VARCHAR(32) NOT NULL DEFAULT 'caution' COMMENT '生成可学习等级',
+    learnable_points MEDIUMTEXT DEFAULT NULL COMMENT '可学习点',
+    avoid_points MEDIUMTEXT DEFAULT NULL COMMENT '避让点',
+    raw_annotation MEDIUMTEXT DEFAULT NULL COMMENT '原始标注JSON',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+    UNIQUE KEY uk_image_version (image_hash, annotation_version),
+    KEY idx_creative (creative_id),
+    KEY idx_template (visual_template),
+    KEY idx_compliance (compliance_level)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材图片结构化标注';
+
+-- =====================================================
+-- 17. AI生成素材可学习创意模式
+-- =====================================================
+CREATE TABLE IF NOT EXISTS material_creative_pattern (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    pattern_version VARCHAR(64) NOT NULL COMMENT '策略版本',
+    pattern_key VARCHAR(128) NOT NULL COMMENT '策略唯一键',
+    pattern_name VARCHAR(128) NOT NULL COMMENT '策略名称',
+    hook_category VARCHAR(128) NOT NULL COMMENT '标题钩子类型',
+    visual_template VARCHAR(128) NOT NULL COMMENT '视觉模板',
+    applicable_crowd_packages VARCHAR(1024) DEFAULT NULL COMMENT '适用人群包',
+    applicable_placements VARCHAR(1024) DEFAULT NULL COMMENT '适用版位',
+    target_age_min INT DEFAULT NULL COMMENT '适用年龄下限',
+    target_age_max INT DEFAULT NULL COMMENT '适用年龄上限',
+    title_hook_rule MEDIUMTEXT NOT NULL COMMENT '标题钩子规则',
+    visual_rule MEDIUMTEXT NOT NULL COMMENT '视觉规则',
+    relevance_rule MEDIUMTEXT NOT NULL COMMENT '视频相关性规则',
+    compliance_rule MEDIUMTEXT NOT NULL COMMENT '合规规则',
+    selector_config MEDIUMTEXT DEFAULT NULL COMMENT '选择器配置JSON:match/exclude/score',
+    positive_examples MEDIUMTEXT DEFAULT NULL COMMENT '正例JSON',
+    negative_examples MEDIUMTEXT DEFAULT NULL COMMENT '反例JSON',
+    source_run_id VARCHAR(64) DEFAULT NULL COMMENT '来源快照ID',
+    source_material_count INT NOT NULL DEFAULT 0 COMMENT '来源素材数',
+    source_total_cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '来源素材消耗(分)',
+    status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/APPROVED/REJECTED',
+    reviewed_by VARCHAR(64) DEFAULT NULL COMMENT '审核人',
+    reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT '审核时间',
+    enabled TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+    UNIQUE KEY uk_version_key (pattern_version, pattern_key),
+    KEY idx_status_enabled (status, enabled),
+    KEY idx_hook_template (hook_category, visual_template)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成素材可学习创意模式';
+
+-- =====================================================
+-- 18. 素材策略学习周报
+-- =====================================================
+CREATE TABLE IF NOT EXISTS material_strategy_learning_report (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    report_id VARCHAR(64) NOT NULL COMMENT '报告ID',
+    run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
+    report_version VARCHAR(64) NOT NULL COMMENT '报告版本',
+    summary MEDIUMTEXT NOT NULL COMMENT '报告摘要',
+    top_patterns MEDIUMTEXT DEFAULT NULL COMMENT '核心模式JSON',
+    risk_summary MEDIUMTEXT DEFAULT NULL COMMENT '风险摘要JSON',
+    recommended_actions MEDIUMTEXT DEFAULT NULL COMMENT '建议动作JSON',
+    report_path VARCHAR(512) DEFAULT NULL COMMENT '本地报告路径',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+    UNIQUE KEY uk_report_id (report_id),
+    KEY idx_run_id (run_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材策略学习周报';
+
 -- =====================================================
 -- 初始化数据
 -- =====================================================

+ 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))
 
 from tools.ai_generated_material import (  # noqa: E402
+    AI_IMAGE_PATTERN_PLACEMENT,
+    AI_IMAGE_PATTERN_TOP_K,
+    OPENROUTER_TEXT_MODEL,
     OPENROUTER_IMAGE_MODEL,
     build_ai_image_object_key,
     build_generation_prompts,
+    build_pattern_generation_prompts,
     generate_image_bytes,
+    insert_and_review_generated_material,
     sanitize_video_description,
     upload_image_to_oss,
 )
+from tools.video_recall import LandingVideo  # noqa: E402
 from tools.video_feature_query import read_cached_video_element_features  # noqa: E402
 
 
@@ -44,7 +50,15 @@ def main() -> int:
     parser.add_argument("--title", default="")
     parser.add_argument("--category", default="")
     parser.add_argument("--model", default=OPENROUTER_IMAGE_MODEL)
+    parser.add_argument("--text-model", default=OPENROUTER_TEXT_MODEL)
     parser.add_argument("--limit", type=int, default=1)
+    parser.add_argument("--account-id", type=int, default=0)
+    parser.add_argument("--adgroup-id", type=int, default=0)
+    parser.add_argument("--crowd-package", default="")
+    parser.add_argument("--placement", default=AI_IMAGE_PATTERN_PLACEMENT)
+    parser.add_argument("--pattern-top-k", type=int, default=AI_IMAGE_PATTERN_TOP_K)
+    parser.add_argument("--use-pattern-selector", action="store_true")
+    parser.add_argument("--write-db", action="store_true")
     args = parser.parse_args()
 
     if not args.video_id:
@@ -53,36 +67,94 @@ def main() -> int:
     features = features_by_vid.get(args.video_id) or []
     topic = next((f.standard_element for f in features if f.element_dimension == "解构选题"), "")
     sanitized_description = sanitize_video_description(topic) if topic else ""
-    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,
             title=args.title,
             category=args.category,
             features=features,
             sanitized_description=sanitized_description,
+            crowd_package=args.crowd_package,
+            placement=args.placement,
+            top_k=args.pattern_top_k,
+            text_model=args.text_model,
         )
-    ][: 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 = []
-    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"
         object_key = build_ai_image_object_key(
-            account_id="debug",
+            account_id=args.account_id or "debug",
             landing_video_id=args.video_id,
-            prompt_type=prompt_type,
+            prompt_type=prompt.prompt_type,
             extension=ext,
-            debug=True,
+            debug=not args.write_db,
         )
         oss_url = upload_image_to_oss(image_bytes, content_type, object_key)
+        asset_id = None
+        review_output = None
+        if args.write_db:
+            asset, review = insert_and_review_generated_material(
+                account_id=args.account_id,
+                adgroup_id=args.adgroup_id,
+                crowd_package=args.crowd_package or "debug",
+                landing=landing,
+                prompt=prompt,
+                model=args.model,
+                object_key=object_key,
+                oss_url=oss_url,
+                raw_response=raw,
+            )
+            asset_id = asset.id
+            review_output = {
+                "status": review.status,
+                "score": review.score,
+                "reason": review.reason,
+                "risk_tags": review.risk_tags,
+                "ocr_text": review.ocr_text,
+            }
         outputs.append({
-            "prompt_type": prompt_type,
+            "asset_id": asset_id,
+            "ai_review": review_output,
+            "prompt_type": prompt.prompt_type,
             "model": args.model,
+            "text_model": args.text_model,
             "content_type": content_type,
             "oss_url": oss_url,
             "object_key": object_key,
-            "prompt_text": prompt_text,
+            "prompt_text": prompt.prompt_text,
+            "feature_hits": prompt.feature_hits,
             "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 logging
+import os
 import sys
 import time
 from pathlib import Path
@@ -64,9 +65,13 @@ from tools.creative_creation import (  # noqa: E402
     prepare_one_creative_for_ad,
 )
 from tools.creative_material_usage import (  # noqa: E402
-    load_recent_used_landing_video_ids,
+    load_recent_landing_usage_counts,
     record_prepared_material_usage,
 )
+from tools.account_material_strategy import (  # noqa: E402
+    MATERIAL_SOURCE_AI_GENERATED,
+    load_account_material_strategy,
+)
 from tools.video_recall import get_account_crowd_package  # noqa: E402
 
 from execute_creation_apply import (  # noqa: E402
@@ -77,6 +82,62 @@ from execute_creation_apply import (  # noqa: E402
 
 logger = logging.getLogger("execute_creation_once")
 
+MATERIAL_SOURCE_HISTORY = "history"
+
+
+def _env_flag(name: str, default: bool = False) -> bool:
+    raw = os.getenv(name)
+    if raw is None:
+        return default
+    return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
+
+
+def _env_csv_set(name: str) -> set[str]:
+    return {
+        item.strip()
+        for item in os.getenv(name, "").split(",")
+        if item.strip()
+    }
+
+
+def _filter_creation_accounts(accounts: list[int], phase: str) -> list[int]:
+    """Apply one-off run filters. Empty env means production default: no filter."""
+    account_filter = {
+        int(v)
+        for v in _env_csv_set("CREATION_ONLY_ACCOUNT_IDS")
+        if v.isdigit()
+    }
+    crowd_filter = _env_csv_set("CREATION_ONLY_CROWD_PACKAGES")
+    if not account_filter and not crowd_filter:
+        return accounts
+
+    selected: list[int] = []
+    for account_id in accounts:
+        if account_filter and account_id not in account_filter:
+            continue
+        if crowd_filter:
+            try:
+                crowd_package = get_account_crowd_package(account_id)
+            except Exception as e:
+                logger.warning(
+                    "[%s] account=%d 临时过滤读取 crowd_package 失败,跳过:%s",
+                    phase, account_id, e,
+                )
+                continue
+            if crowd_package not in crowd_filter:
+                continue
+        selected.append(account_id)
+
+    logger.info(
+        "[%s] 临时运行过滤 account_ids=%s crowd_packages=%s: %d -> %d",
+        phase,
+        sorted(account_filter) if account_filter else "ALL",
+        sorted(crowd_filter) if crowd_filter else "ALL",
+        len(accounts),
+        len(selected),
+    )
+    return selected
+
 
 def _setup_logging() -> None:
     logging.basicConfig(
@@ -182,6 +243,10 @@ def phase0_create_ads(target_ads: int = ADS_PER_ACCOUNT) -> list[dict]:
     if not creation_accounts:
         logger.error("[phase0] 待投放账户配置为空,退出")
         return []
+    creation_accounts = _filter_creation_accounts(creation_accounts, "phase0")
+    if not creation_accounts:
+        logger.info("[phase0] 临时过滤后无待处理账户")
+        return []
 
     # Task 26:NORMAL + SUSPEND 都算占用唯一性槽位(腾讯文档:删除前历史广告占槽位)
     OCCUPIED_STATUSES = {"AD_STATUS_NORMAL", "AD_STATUS_SUSPEND"}
@@ -378,6 +443,10 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
     if not creation_accounts:
         logger.error("[phase1] 待投放账户配置为空,Phase 1 退出")
         return []
+    creation_accounts = _filter_creation_accounts(creation_accounts, "phase1")
+    if not creation_accounts:
+        logger.info("[phase1] 临时过滤后无待处理账户")
+        return []
 
     excluded_ad_ids = load_excluded_ad_ids_from_adjustment()
     logger.info("[phase1] 关联点过滤集合 size=%d", len(excluded_ad_ids))
@@ -388,8 +457,8 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
     # 但同一轮审批候选需要做展示去重,避免表格里同 crowd_package 反复出现同一素材。
     # 这个 set 只存在内存里,不写历史排重库;进程结束即失效。
     display_used_material_ids_by_crowd: dict[str, set[str]] = {}
-    # landing_video 在同一 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:
         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(
             crowd_package, set(),
         )
-        if crowd_package not in excluded_landing_ids_by_crowd:
+        if crowd_package not in landing_usage_counts_by_crowd:
             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,
                 )
             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",
                     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(
-                "[phase1] crowd=%r 近期 landing 排重 size=%d",
+                "[phase1] crowd=%r 近期 landing 使用 history=%d ai_generated=%d",
                 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:
             adgroup_id = ad["adgroup_id"]
             already_have = ad["creative_count"]
             to_add = max(0, target_creatives - already_have)
+            prepared_for_ad = 0
+            failed_prepare_for_ad = 0
             landing_counts_for_ad: dict[int, int] = {}
             logger.info(
                 "[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()
                     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 = (
-                    set(crowd_excluded_landing_ids) | landing_excluded_for_ad
+                    landing_excluded_for_source | landing_excluded_for_ad
                 )
                 try:
                     rec = prepare_one_creative_for_ad(
@@ -474,6 +577,7 @@ def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict
                     rec = None
 
                 if rec:
+                    prepared_for_ad += 1
                     pending_records.append(rec)
                     material_id = rec.get("_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.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(
-                            "[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(
                             "[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,
                         )
                 else:
+                    failed_prepare_for_ad += 1
                     # 2026-06-10 用户要求:单条 prepare 失败 → continue 不 break
                     # 同广告剩余 to_add 创意还能继续试,不被一次失败拖累
                     logger.info(
                         "[phase1]   adgroup=%d 本条创意 prepare 失败,试下一条",
                         adgroup_id,
                     )
+            logger.info(
+                "[phase1]   adgroup=%d 补创意完成 target=%d have_before=%d "
+                "planned=%d prepared=%d failed_prepare=%d",
+                adgroup_id, target_creatives, already_have, to_add,
+                prepared_for_ad, failed_prepare_for_ad,
+            )
+            if prepared_for_ad < to_add:
+                logger.warning(
+                    "[phase1]   adgroup=%d 未补满:缺口=%d。详细原因见上方 "
+                    "prepare_one_creative source_summary/失败日志",
+                    adgroup_id, to_add - prepared_for_ad,
+                )
 
     logger.info("=" * 60)
     logger.info("[phase1] 准备完成,共 %d 条 pending records", len(pending_records))
@@ -578,10 +707,14 @@ def run_once() -> dict:
 
     # Phase 0:模块 A 建广告(满足每账户 ADS_PER_ACCOUNT 条)
     logger.info("=" * 60)
-    logger.info("[main] Phase 0 启动 — 模块 A 检查 + 建广告")
-    created_ads = phase0_create_ads()
-    logger.info("[main] Phase 0 完成:本轮新建广告 %d 条", len(created_ads))
-    _wait_created_ads_visible(created_ads)
+    if _env_flag("CREATION_SKIP_PHASE0"):
+        logger.info("[main] CREATION_SKIP_PHASE0=True → 跳过模块 A 建广告")
+        created_ads = []
+    else:
+        logger.info("[main] Phase 0 启动 — 模块 A 检查 + 建广告")
+        created_ads = phase0_create_ads()
+        logger.info("[main] Phase 0 完成:本轮新建广告 %d 条", len(created_ads))
+        _wait_created_ads_visible(created_ads)
 
     # Phase 1:模块 B 给所有广告(新+旧)补创意
     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}}
 
-【核心目标】
-- 这是一张广告封面图,目标是提升中老年用户停留和点击兴趣;高消耗素材和高CTR素材在这里统一理解为高点击潜力素材。
-- 必须先理解输入主题的核心信息维度:核心对象、关键事实/数字、问题场景、情绪冲突、核心主张。
-- 图片标题和画面必须表达这些核心信息维度,不能只生成泛生活场景。
-- 不需要完全复刻视频场景,但必须和输入主题保持明确的主题相关、问题相关或情绪相关。
-- 可以把主题转译为更容易点击的中国本土生活化场景,但不能丢失输入主题的核心主张,不能编造无关故事。
-- 画面必须真实摄影感,不要卡通、二次元、科技海报、欧美商业海报。
-
-【生成优先级】
+【执行优先级】
 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】
-你是广告素材生成前的视频内容清洗器。
-只输出一段清洗后的中文视频主题描述,不要解释。
+你是广告素材生成前的广告主题提炼器。
+你的任务是把视频解构选题提炼成适合生成广告封面图的“广告主题种子”。
+只输出一句中文广告主题种子,不要解释。
 输出中绝对不要出现:领取、能领、已办成、赶紧、通知、不错过、转发、关注、公众号、加群、优惠券、卡券、下单、购买。
 
 【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}}

+ 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,不删除历史配置。
   - 「初始出价」支持固定值 0.35 或范围 0.28-0.31。
-  - 「预算(单广告)」为空/不限制/不限 时使用模板默认预算;数字按元转换为分。
+  - 「预算(单广告)」为空时使用模板默认预算;不限制/不限/- 按腾讯不限预算 0 传递;数字按元转换为分。
   - 「素材来源」为空默认历史素材;填 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
 
 
+class _Strategy:
+    def __init__(self, material_source):
+        self.material_source = material_source
+        self.use_ai_generated = material_source == "ai_generated"
+        self.ai_fallback_to_history = False
+
+
 class LandingVideoDedupeTest(unittest.TestCase):
     def test_same_crowd_package_excludes_landing_video_across_accounts_in_one_run(self):
         calls = []
@@ -22,12 +29,14 @@ class LandingVideoDedupeTest(unittest.TestCase):
                 "audience_tier": "wx*商业",
                 "landing_video_id": 1000 + account_id,
                 "_material_id": f"m-{account_id}",
+                "material_source": "history",
             }
 
         with patch.object(execute_creation_once, "get_creation_account_ids", return_value=[1, 2]), \
             patch.object(execute_creation_once, "load_excluded_ad_ids_from_adjustment", return_value=set()), \
             patch.object(execute_creation_once, "get_account_crowd_package", return_value="wx*商业"), \
-            patch.object(execute_creation_once, "load_recent_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=[
                 [{"adgroup_id": 101, "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({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__":
     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 io import BytesIO
 from pathlib import Path
-from typing import Iterable, Optional
+from typing import Any, Iterable, Optional, Sequence
 from urllib.parse import quote, urlparse
 
 import httpx
@@ -42,12 +42,22 @@ OPENROUTER_IMAGES_URL = os.getenv(
 )
 OPENROUTER_IMAGE_MODEL = os.getenv(
     "OPENROUTER_IMAGE_MODEL",
-    "google/gemini-3-pro-image",
+    "google/gemini-3.1-flash-image",
 )
 OPENROUTER_TEXT_MODEL = os.getenv(
     "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_ASPECT_RATIO = os.getenv("AI_IMAGE_ASPECT_RATIO", "16:9")
 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 = (
     Path(__file__).resolve().parents[1] / "prompts" / "ai_sanitize_video_description.md"
 )
+DEFAULT_AI_COVER_COPY_PROMPT_TEMPLATE_PATH = (
+    Path(__file__).resolve().parents[1] / "prompts" / "ai_cover_copy.md"
+)
 DEFAULT_AI_IMAGE_OSS_BUCKET = "art-pubbucket"
 DEFAULT_AI_IMAGE_PUBLIC_BASE_URL = "https://rescdn.yishihui.com"
 FORBIDDEN_SANITIZED_DESCRIPTION_TERMS = (
@@ -93,6 +106,12 @@ CREATE TABLE IF NOT EXISTS ai_generated_material (
     approval_status VARCHAR(50) DEFAULT NULL COMMENT '人工审批状态',
     tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
     dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
+    ai_review_status VARCHAR(50) DEFAULT NULL COMMENT 'AI审核状态:pass/reject/hold/error',
+    ai_review_score INT DEFAULT NULL COMMENT 'AI审核评分0-100',
+    ai_review_model VARCHAR(200) DEFAULT NULL COMMENT 'AI审核模型',
+    ai_review_reason TEXT DEFAULT NULL COMMENT 'AI审核原因摘要',
+    ai_review_json MEDIUMTEXT DEFAULT NULL COMMENT 'AI审核原始JSON',
+    ai_reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT 'AI审核时间',
     error TEXT DEFAULT NULL COMMENT '生成/上传/创建错误',
     raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -103,6 +122,15 @@ CREATE TABLE IF NOT EXISTS ai_generated_material (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成创意图片素材'
 """
 
+AI_MATERIAL_REVIEW_COLUMNS_SQL = {
+    "ai_review_status": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_status VARCHAR(50) DEFAULT NULL COMMENT 'AI审核状态:pass/reject/hold/error' AFTER dynamic_creative_id",
+    "ai_review_score": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_score INT DEFAULT NULL COMMENT 'AI审核评分0-100' AFTER ai_review_status",
+    "ai_review_model": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_model VARCHAR(200) DEFAULT NULL COMMENT 'AI审核模型' AFTER ai_review_score",
+    "ai_review_reason": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_reason TEXT DEFAULT NULL COMMENT 'AI审核原因摘要' AFTER ai_review_model",
+    "ai_review_json": "ALTER TABLE ai_generated_material ADD COLUMN ai_review_json MEDIUMTEXT DEFAULT NULL COMMENT 'AI审核原始JSON' AFTER ai_review_reason",
+    "ai_reviewed_at": "ALTER TABLE ai_generated_material ADD COLUMN ai_reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT 'AI审核时间' AFTER ai_review_json",
+}
+
 
 @dataclass(frozen=True)
 class GenerationPrompt:
@@ -111,6 +139,24 @@ class GenerationPrompt:
     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)
 class GeneratedMaterialAsset:
     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:
     return (
         _load_prompt_template()
@@ -238,8 +318,39 @@ def _extract_chat_completion_text(data: dict) -> str:
     return ""
 
 
+def _extract_json_object(text: str) -> dict[str, Any]:
+    raw = str(text or "").strip()
+    if raw.startswith("```"):
+        raw = re.sub(r"^```(?:json)?", "", raw).strip()
+        raw = re.sub(r"```$", "", raw).strip()
+    try:
+        parsed = json.loads(raw)
+    except json.JSONDecodeError:
+        start = raw.find("{")
+        if start >= 0:
+            try:
+                parsed, _ = json.JSONDecoder().raw_decode(raw[start:])
+            except json.JSONDecodeError:
+                parsed = None
+            if isinstance(parsed, dict):
+                return parsed
+        match = re.search(r"\{.*?\}", raw, flags=re.S)
+        if not match:
+            raise
+        parsed = json.loads(match.group(0))
+    if isinstance(parsed, list):
+        parsed = next((item for item in parsed if isinstance(item, dict)), None)
+    if isinstance(parsed, dict) and "selected" in parsed and isinstance(parsed["selected"], list):
+        selected = next((item for item in parsed["selected"] if isinstance(item, dict)), None)
+        if selected:
+            parsed = selected
+    if not isinstance(parsed, dict):
+        raise ValueError("模型返回不是JSON object")
+    return parsed
+
+
 def sanitize_video_description(raw_description: str, model: str = OPENROUTER_TEXT_MODEL) -> str:
-    """Rewrite ODPS topic into a clean visual description for image generation."""
+    """Rewrite ODPS topic into an ad-theme seed for image generation."""
     raw = (raw_description or "").strip()
     if not raw:
         raise RuntimeError("缺少视频解构选题,无法清洗生成描述")
@@ -267,6 +378,124 @@ def sanitize_video_description(raw_description: str, model: str = OPENROUTER_TEX
     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(
     *,
     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:
     from db.connection import get_connection
 
@@ -301,6 +604,10 @@ def ensure_ai_material_table() -> None:
     try:
         with conn.cursor() as cur:
             cur.execute(CREATE_AI_MATERIAL_TABLE_SQL)
+            for column_name, alter_sql in AI_MATERIAL_REVIEW_COLUMNS_SQL.items():
+                cur.execute("SHOW COLUMNS FROM ai_generated_material LIKE %s", (column_name,))
+                if not cur.fetchone():
+                    cur.execute(alter_sql)
         conn.commit()
     finally:
         conn.close()
@@ -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]:
     out = []
     for row in rows:
@@ -603,6 +964,7 @@ def load_available_generated_assets(
                   AND adgroup_id=%s
                   AND landing_video_id=%s
                   AND status='generated'
+                  AND ai_review_status='pass'
                   AND oss_url IS NOT NULL
                   AND oss_url <> ''
                 ORDER BY id ASC
@@ -622,6 +984,12 @@ def generate_assets_for_landing(
     crowd_package: str,
     landing: LandingVideo,
     model: str = OPENROUTER_IMAGE_MODEL,
+    use_pattern_selector: bool = AI_IMAGE_USE_PATTERN_SELECTOR,
+    placement: str = AI_IMAGE_PATTERN_PLACEMENT,
+    pattern_top_k: int = AI_IMAGE_PATTERN_TOP_K,
+    text_model: str = OPENROUTER_TEXT_MODEL,
+    skip_prompt_types: set[str] | None = None,
+    max_new_assets: int | None = None,
 ) -> list[GeneratedMaterialAsset]:
     db_features = read_cached_video_element_features([landing.video_id]).get(landing.video_id) or []
     if not db_features:
@@ -643,13 +1011,34 @@ def generate_assets_for_landing(
         "[ai_generated_material] landing=%d sanitized description=%r",
         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] = []
     for prompt in prompts:
         image_bytes, content_type, raw_response = generate_image_bytes(prompt.prompt_text, model)
@@ -663,7 +1052,7 @@ def generate_assets_for_landing(
             extension=ext,
         )
         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,
             adgroup_id=adgroup_id,
             crowd_package=crowd_package,
@@ -674,10 +1063,16 @@ def generate_assets_for_landing(
             oss_url=oss_url,
             raw_response=raw_response,
         )
+        if not review.passed:
+            logger.warning(
+                "[ai_generated_material] AI审核未通过 account=%d adgroup=%d landing=%d asset=%d status=%s score=%d reason=%s",
+                account_id, adgroup_id, landing.video_id, asset.id, review.status, review.score, review.reason,
+            )
+            continue
         assets.append(asset)
         logger.info(
-            "[ai_generated_material] generated 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
 
@@ -688,16 +1083,29 @@ def get_or_generate_assets_for_landing(
     adgroup_id: int,
     crowd_package: str,
     landing: LandingVideo,
+    use_pattern_selector: bool = AI_IMAGE_USE_PATTERN_SELECTOR,
+    placement: str = AI_IMAGE_PATTERN_PLACEMENT,
+    pattern_top_k: int = AI_IMAGE_PATTERN_TOP_K,
+    text_model: str = OPENROUTER_TEXT_MODEL,
 ) -> list[GeneratedMaterialAsset]:
     existing = load_available_generated_assets(account_id, adgroup_id, landing.video_id)
-    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,
         adgroup_id=adgroup_id,
         crowd_package=crowd_package,
         landing=landing,
+        use_pattern_selector=use_pattern_selector,
+        placement=placement,
+        pattern_top_k=pattern_top_k,
+        text_model=text_model,
+        skip_prompt_types=skip_prompt_types,
+        max_new_assets=target_count - len(existing),
     )
+    return [*existing, *generated]
 
 
 def update_generated_material_status(

+ 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)
     material_strategy = load_account_material_strategy(account_id)
     video_crowd_package = map_crowd_package_for_video_recall(crowd_package)
-    try:
-        recent_material_ids = load_recent_used_material_ids(crowd_package)
-    except Exception as e:
-        logger.warning(
-            "[prepare_one_creative] account=%d crowd=%r 读取素材使用历史失败,仅使用本轮排重:%s",
-            account_id, crowd_package, e,
-        )
+    if material_strategy.use_ai_generated and not material_strategy.ai_fallback_to_history:
         recent_material_ids = set()
+        logger.info(
+            "[prepare_one_creative] account=%d adgroup=%d material_source=%s fallback_history=%s; "
+            "AI素材不回退历史,跳过历史素材排重库",
+            account_id, adgroup_id, material_strategy.material_source,
+            material_strategy.ai_fallback_to_history,
+        )
+    else:
+        try:
+            recent_material_ids = load_recent_used_material_ids(crowd_package)
+        except Exception as e:
+            logger.warning(
+                "[prepare_one_creative] account=%d crowd=%r 读取素材使用历史失败,仅使用本轮排重:%s",
+                account_id, crowd_package, e,
+            )
+            recent_material_ids = set()
     effective_excluded_material_ids = merge_used_material_ids(
         excluded_material_ids,
         recent_material_ids,
     )
     logger.info(
-        "[prepare_one_creative] account=%d adgroup=%d crowd=%r video_crowd=%r material_dedupe run=%d recent=%d effective=%d",
+        "[prepare_one_creative] account=%d adgroup=%d crowd=%r video_crowd=%r "
+        "material_source=%s fallback_history=%s material_dedupe run=%d recent=%d effective=%d",
         account_id, adgroup_id, crowd_package, video_crowd_package,
+        material_strategy.material_source, material_strategy.ai_fallback_to_history,
         len(excluded_material_ids), len(recent_material_ids),
         len(effective_excluded_material_ids),
     )
@@ -591,9 +602,26 @@ def prepare_one_creative_for_ad(
         )
         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)
+        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(
-            "[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(effective_excluded_material_ids),
         )
@@ -601,6 +629,7 @@ def prepare_one_creative_for_ad(
         attempts = 0
         for v in valid:
             if v.video_id in excluded_landing_ids:
+                source_stats["landing_dedupe"] += 1
                 logger.info(
                     "[prepare_one_creative]   landing=%d landing 排重命中,跳过",
                     v.video_id,
@@ -608,12 +637,14 @@ def prepare_one_creative_for_ad(
                 continue
             attempts += 1
             if attempts > max_landings:
+                source_stats["max_landing_limit"] += 1
                 break
 
             # 2026-06-29:承接视频风险审核。必须在素材召回 / xcx-save 前完成,
             # 避免高风险 landing 继续产生 plan/rootSourceId 等外部副作用。
             risk = check_video_risk(v.video_id)
             if not risk.passed:
+                source_stats["risk_blocked"] += 1
                 logger.warning(
                     "[prepare_one_creative]   landing=%d 风险拦截:%s",
                     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 []
             if not element_features:
+                source_stats["no_features"] += 1
                 logger.info(
                     "[prepare_one_creative]   landing=%d 无 ODPS 召回特征,跳过",
                     v.video_id,
@@ -631,6 +663,7 @@ def prepare_one_creative_for_ad(
             materials = []
             candidate_material_source = material_strategy.material_source
             if material_strategy.use_ai_generated:
+                source_stats["ai_attempts"] += 1
                 try:
                     assets = get_or_generate_assets_for_landing(
                         account_id=account_id,
@@ -644,6 +677,7 @@ def prepare_one_creative_for_ad(
                         v.video_id, len(materials), material_strategy.ai_fallback_to_history,
                     )
                 except Exception as e:
+                    source_stats["ai_failed"] += 1
                     logger.exception(
                         "[prepare_one_creative]   landing=%d AI生成素材失败:%s",
                         v.video_id, e,
@@ -651,6 +685,18 @@ def prepare_one_creative_for_ad(
                     if not material_strategy.ai_fallback_to_history:
                         continue
 
+            if (
+                material_strategy.use_ai_generated
+                and not materials
+                and not material_strategy.ai_fallback_to_history
+            ):
+                source_stats["ai_empty"] += 1
+                logger.info(
+                    "[prepare_one_creative]   landing=%d AI无可用素材且不允许回退历史素材,跳过",
+                    v.video_id,
+                )
+                continue
+
             if (not materials) and (
                 not material_strategy.use_ai_generated
                 or material_strategy.ai_fallback_to_history
@@ -666,6 +712,8 @@ def prepare_one_creative_for_ad(
                     final_top_n=max_materials_per_landing,
                     element_features=element_features,
                 )
+                if not materials:
+                    source_stats["history_recall_empty"] += 1
             # material_id 去重(2026-06-09):跳过已用素材(账户层 set,跨广告也共享)
             fresh = [
                 m for m in materials
@@ -679,6 +727,7 @@ def prepare_one_creative_for_ad(
                 chosen_material_source = candidate_material_source
                 if chosen_material.material_id.startswith("ai:"):
                     chosen_ai_generated_material_id = chosen_material.raw.get("ai_generated_material_id")
+                source_stats["selected"] += 1
                 logger.info(
                     "[prepare_one_creative]   选中 landing=%d source=%s category=%r material_source=%s material=%s recall=%s/%s/%s cost=%s roi=%s ctr=%s imp=%s score=%s policy=%s",
                     v.video_id, source_label, v.category,
@@ -696,21 +745,28 @@ def prepare_one_creative_for_ad(
                 )
                 break
             if materials:
+                source_stats["all_excluded"] += 1
                 logger.info(
                     "[prepare_one_creative]   landing=%d 召回 %d 全在 excluded,试下一条",
                     v.video_id, len(materials),
                 )
         if chosen_landing and chosen_material:
+            logger.info(
+                "[prepare_one_creative] source_summary account=%d adgroup=%d source=%s stats=%s",
+                account_id, adgroup_id, source_label, source_stats,
+            )
             break
         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:
         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
 

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

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