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

完善自动投放创意生成与版位策略

刘立冬 3 недель назад
Родитель
Сommit
18791fea41

+ 4 - 1
AGENTS.md

@@ -47,7 +47,9 @@
 - 当前素材硬筛只看相似度:`score >= 0.8`。
 - 曝光、CTR、ROI 只作为审批展示和兜底排序参考,不作为硬筛。
 - 素材通过相似度筛选后,默认按历史消耗 `cost` 倒序选择。
-- 落地页视频不进历史排重库;当前运行内同一广告的同一 `landing_video_id` 默认最多进入本轮审批候选 2 次。
+- 落地页视频需要做同人群包去重:当前运行内同一 `crowd_package + landing_video_id` 只能进入本轮审批候选 1 次。
+- 落地页视频还需要做跨轮近期排重:默认按同人群包下最近 7 天进入 pending 或已提交腾讯的 `landing_video_id` 排除。
+- 同一广告内同一 `landing_video_id` 仍保留限频保护,默认最多 1 次。
 - 素材需要跨账户/跨天排重,默认按同人群包下近期使用过的 `material_id` 排除。
 - 当前运行内的审批候选需要做轻量展示去重:同一 `crowd_package + material_id` 只进入本轮审批表一次;该去重只存在内存,不写入历史排重库。
 
@@ -77,6 +79,7 @@
 - 修改范围要收敛在当前生产链路相关模块内。
 - 运营可能需要调整的生产行为,应提供配置项。
 - 属于账户、人群包、预算、出价、品牌、监测链接的值,优先进入飞书或 DB,不要写死在代码里。
+- 运行 `examples/auto_put_ad_mini` 下的 Python 脚本时,默认使用仓库根目录 `.venv/bin/python`;不要直接使用系统 `python3`,避免缺少 FastAPI、PyODPS、OSS 等项目依赖导致误判。
 - 端到端执行前,必须意识到可能产生外部副作用:腾讯广告/创意创建、图片上传、DataNexus 监测链接创建、人群包授权、`xcx/save` 落地计划创建。
 - 端到端流程中断时,已经完成的外部副作用不会自动回滚。
 - 行为、环境变量或运营流程变化时,要同步更新面向生产的文档。

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

@@ -42,6 +42,8 @@ FEISHU_OPERATOR_CHAT_ID=oc_88e0a1970a7de02eb5ac225a8b0cedea
 # ========================================
 # QWEN_API_KEY=xxx
 # OPEN_ROUTER_API_KEY=xxx
+# AI 图片生成模型。当前默认使用 OpenRouter Images API 可用的 pro 图片模型。
+# OPENROUTER_IMAGE_MODEL=google/gemini-3-pro-image
 
 # ========================================
 # 数据库配置(MySQL)

+ 63 - 4
examples/auto_put_ad_mini/PRODUCTION_AUTOMATION.md

@@ -151,16 +151,60 @@ RECALL_DISPLAY_K=30
 
 通过筛选后按历史消耗 `cost` 倒序选择。ROI、CTR、曝光数和相似度会进入创意审批报表,但不再作为硬筛。
 
-排重分两层:
+## AI 生成图片素材
+
+账户可通过飞书/DB 指定素材来源:
+
+- 空值或 `历史素材`:继续使用历史已投素材召回。
+- `AI生成素材`:按承接视频的 ODPS 特征生成新图片素材。
+
+AI 生成素材链路只改变“素材来源”,不改变视频筛选、人群包、出价、预算、落地计划、审批和腾讯创意创建主流程。
+当前每个视频通过 `landing_video_id` 读取本地库 `video_element_feature_cache` 中该视频最新分区的 `解构选题`,
+并使用贡献分最高的 `standard_element` 生成 1 张 `topic` 图片。
+生成图片前会先用 OpenRouter 文本模型清洗原始 `解构选题`,
+去掉转发、关注、公众号回复、加群、领取、底部提示、推广话术等互动/营销引导,同时保留核心事实、对象、数字和主题。
+清洗失败或清洗后仍包含互动/营销词时,本轮跳过该视频,不使用原始 topic 兜底。
+最终图片 prompt 由工程内模板和清洗后的动态承接视频描述拼接:
+
+- 清洗模板文件:`examples/auto_put_ad_mini/prompts/ai_sanitize_video_description.md`
+- 模板文件:`examples/auto_put_ad_mini/prompts/ai_generated_material.md`
+- 动态描述:`解构选题.standard_element` 经文本模型清洗后的主题描述
+- 图片比例:`AI_IMAGE_ASPECT_RATIO`,默认 `16:9`
+- 最终输出尺寸:`AI_IMAGE_TARGET_WIDTH` x `AI_IMAGE_TARGET_HEIGHT`,默认 `1280x720`
+
+AI 图先上传到 OSS 并进入飞书人工审批。只有人工 `approve` 后,Phase 3 才会把图片上传到腾讯 `images/add` 并创建创意。
+如果账户配置 `生成失败是否回退历史素材=是`,AI 生成失败或无候选时才回退历史素材;否则本轮跳过。
+
+AI 图默认上传到:
+
+- OSS bucket:`art-pubbucket`
+- OSS 目录:`auto_put_tencent/image`
+- 对外 URL 域名:`https://rescdn.yishihui.com/`
+
+手动调试生成效果可运行:
+
+```bash
+.venv/bin/python examples/auto_put_ad_mini/debug_generate_ai_material.py --video-id 71187017 --title "视频标题"
+```
+
+调试脚本按 `video_id` 读取本地特征缓存表,只生成并上传 OSS,打印图片 URL,不会创建腾讯广告或创意。
+
+素材排重分两层:
 
 - 历史排重只读取已有明确结果的素材使用记录,按同人群包下的 `material_id` 排除。
 - 本轮审批候选做内存展示去重,同一 `crowd_package + material_id` 只进入当前审批表一次。该规则不写入历史排重库,进程结束即失效。
-- 落地页视频不进入历史排重库,但同一广告在本轮准备中同一个 `landing_video_id` 默认最多出现 2 次。
+
+落地页视频排重分两层:
+
+- 当前运行内同一 `crowd_package + landing_video_id` 只允许进入 pending creative 一次。
+- 跨轮近期排重默认读取 `creative_material_usage` 和 `creative_creation_task`,排除同人群包最近 7 天进入 pending 或已提交腾讯的 `landing_video_id`。
+- 同一广告内同一 `landing_video_id` 仍保留限频保护,默认最多 1 次。
 
 可通过环境变量调整同广告落地页视频本轮限频:
 
 ```bash
-MAX_SAME_LANDING_PER_AD_IN_RUN=2
+MAX_SAME_LANDING_PER_AD_IN_RUN=1
+CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS=7
 ```
 
 ## 热门兜底配置
@@ -194,16 +238,31 @@ PIAOQUANTV_HOT_FALLBACK_SOURCE=hot
 LANDING_EXCLUDED_CATEGORIES=早中晚好,祝福音乐,历史名人
 VIDEO_RISK_MAX_ALLOWED_LEVEL=5
 VIDEO_RECALL_CROWD_PACKAGE_MAP={"cell*year*商业":"wx*商业"}
-MAX_SAME_LANDING_PER_AD_IN_RUN=2
+MAX_SAME_LANDING_PER_AD_IN_RUN=1
+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
+ALIYUN_OSS_ENDPOINT=oss-xxx.aliyuncs.com
+ALIYUN_OSS_BUCKET=art-pubbucket
+ALIYUN_OSS_ACCESS_KEY_ID=...
+ALIYUN_OSS_ACCESS_KEY_SECRET=...
+AI_IMAGE_OSS_PREFIX=auto_put_tencent/image
+AI_IMAGE_PUBLIC_BASE_URL=https://rescdn.yishihui.com
+AI_IMAGE_TARGET_WIDTH=1280
+AI_IMAGE_TARGET_HEIGHT=720
 ```
 
+OSS key 已迁移到本工程环境变量。生产 Docker 也应显式注入这些变量,不依赖外层参考工程。
+
 ## 当前已知限制
 
 - `R330` 账户如果源账户无法解析到 ONLINE/SUCCESS 人群包,Phase 0 会跳过该账户。
 - `泛人群` 主池可能返回 0,需要依赖 `source=hot` 兜底。
 - 当前素材只按相似度准入并按消耗排序;低曝光/低 CTR 素材可能进入审批表,需要运营结合报表判断。
+- AI 生成素材 v1 不做机器预审,质量和合规依赖飞书人工审批 + 腾讯审核。
 - 每次 pending creative 准备会调用 `xcx/save` 生成落地计划;端到端测试中断不会自动清理已生成的落地计划。
 
 ## 上线检查

+ 18 - 6
examples/auto_put_ad_mini/config.py

@@ -792,15 +792,24 @@ SITE_SET_COMBINATIONS = [
 #   · SMART_TARGETING_NONE = 已关 · SMART_TARGETING_AUTO = 智能定向中
 SMART_TARGETING_MODE = "SMART_TARGETING_MANUAL"
 
-# --- WECHAT_POSITION 定投场景(2026-06-11 用户确认:生产 5 项小程序版位)---
-# 历史:9 项 preset(来自参考广告 77868332)→ 实际生产删除重建走 3 项 → 本期补 1024797。
+# --- WECHAT_POSITION 定投场景(2026-07-06 用户确认:扩展公众号 + 小程序位置)---
+# 历史:9 项 preset(来自参考广告 77868332)→ 实际生产删除重建走 3 项 → 补 1024797
+# → 2026-07-06 扩展公众号文章/订阅号相关位置,并补发现小程序。
 # 中文映射通过 tools.scene_spec.get_wechat_position_tags(account_id) 运行时查询(进程内缓存 1h)
 #
-# 业务生效场景(全部小程序流量位):
+# 业务生效场景(公众号内容场景):
+#   1024789 公众号文章底部
+#   1024790 公众号文章中部
+#   1024791 公众号文章视频贴片
+#   1024792 订阅号消息列表
+#   2100802 公众号文章评论区
+#
+# 业务生效场景(小程序流量位):
 #   1024795 小程序激励式广告
 #   1024796 小程序插屏广告
+#   1024797 小程序封面广告
+#   2100745 发现小程序
 #   2100748 小程序原生广告
-#   1024797 小程序封面广告(2026-06-11 新增)
 #
 # 1024794 小程序 banner 广告已移除:
 #    → /adgroups/update 接口已下线,报 code=1800945
@@ -809,7 +818,10 @@ SMART_TARGETING_MODE = "SMART_TARGETING_MANUAL"
 # ⚠️ 创建后锁死(2026-06-11 实测复现):wechat_position 一旦创建,update 报 code=36840
 #    → 要新增场景必须删除重建广告,代价是丢失已挂创意 + 学习数据
 WECHAT_POSITION_TARGETED_PRESET = [
-    1024795, 1024796, 2100748, 1024797,
+    # 公众号内容场景
+    1024789, 1024790, 1024791, 1024792, 2100802,
+    # 小程序场景
+    1024795, 1024796, 1024797, 2100745, 2100748,
 ]
 
 # 1 账户广告数(2026-06-09 用户确认:2 条,一条有 wechat_position 定投,一条无定投)
@@ -971,7 +983,7 @@ RECALL_QUERY_LIMIT_PER_VIDEO = int(os.getenv("RECALL_QUERY_LIMIT_PER_VIDEO", "12
 RECALL_MIN_IMPRESSIONS = 0                        # 保留兼容配置;当前不作为硬筛。
 RECALL_MIN_CTR = 0.0                              # 保留兼容配置;当前不作为硬筛。
 RECALL_SOURCE_LABELS = ["内部素材"]               # 只要内部
-MAX_SAME_LANDING_PER_AD_IN_RUN = int(os.getenv("MAX_SAME_LANDING_PER_AD_IN_RUN", "2"))
+MAX_SAME_LANDING_PER_AD_IN_RUN = int(os.getenv("MAX_SAME_LANDING_PER_AD_IN_RUN", "1"))
 RECALL_CONFIG_CODES_FULL = [
     "VIDEO_TOPIC", "VIDEO_INSPIRATION", "VIDEO_PURPOSE",
     "VIDEO_KEYPOINT", "VIDEO_TITLE",

+ 13 - 1
examples/auto_put_ad_mini/configure_creation_accounts.py

@@ -41,6 +41,8 @@ class AccountConfigInput:
     bid_amount_fen: int | None = None
     daily_budget_fen: int | None = None
     enabled: bool = True
+    material_source: str = "history"
+    ai_fallback_to_history: bool = False
 
 
 def _bid_to_fen(raw: str) -> int:
@@ -110,6 +112,7 @@ def _read_inputs(args: argparse.Namespace) -> list[AccountConfigInput]:
 
 def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> None:
     from db.connection import get_connection
+    from tools.account_material_strategy import ensure_account_material_strategy_columns
 
     source_account_id = None if row.audience_name == "泛人群" else DEFAULT_SOURCE_ACCOUNT_ID
     grant_status = "not_required_no_audience_pack" if row.audience_name == "泛人群" else "pending_resolve"
@@ -118,10 +121,13 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
     if dry_run:
         print(
             f"[dry-run] account={row.account_id} audience={row.audience_name} "
-            f"bid={row.bid_min_fen}-{row.bid_max_fen} source={source_account_id} status={grant_status}"
+            f"bid={row.bid_min_fen}-{row.bid_max_fen} source={source_account_id} "
+            f"status={grant_status} material_source={row.material_source} "
+            f"ai_fallback={row.ai_fallback_to_history}"
         )
         return
 
+    ensure_account_material_strategy_columns()
     conn = get_connection()
     try:
         with conn.cursor() as cur:
@@ -174,12 +180,14 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
                 """
                 INSERT INTO ad_creation_account_config
                   (account_id, enabled, delivery_version, brand_key, feedback_key,
+                   material_source, ai_fallback_to_history,
                    audience_name, audience_pack_id, audience_tier_label,
                    bid_min_fen, bid_max_fen, bid_amount_fen, daily_budget_fen,
                    age_min, age_max, audience_source_account_id, audience_grant_status,
                    remark, created_by, updated_by)
                 VALUES
                   (%s, TRUE, %s, %s, %s,
+                   %s, %s,
                    %s, %s, %s,
                    %s, %s, %s, %s,
                    %s, %s, %s, %s,
@@ -189,6 +197,8 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
                   delivery_version=VALUES(delivery_version),
                   brand_key=VALUES(brand_key),
                   feedback_key=VALUES(feedback_key),
+                  material_source=VALUES(material_source),
+                  ai_fallback_to_history=VALUES(ai_fallback_to_history),
                   audience_pack_id=IF(
                     ad_creation_account_config.audience_name = VALUES(audience_name),
                     ad_creation_account_config.audience_pack_id,
@@ -225,6 +235,8 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
                     DEFAULT_DELIVERY_VERSION,
                     DEFAULT_BRAND_KEY,
                     DEFAULT_FEEDBACK_KEY,
+                    row.material_source,
+                    1 if row.ai_fallback_to_history else 0,
                     row.audience_name,
                     audience_pack_id,
                     row.audience_name,

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

@@ -54,6 +54,8 @@ CREATE TABLE IF NOT EXISTS ad_creation_account_config (
     delivery_version VARCHAR(100) NOT NULL COMMENT '投放版本',
     brand_key VARCHAR(100) DEFAULT NULL COMMENT '品牌资产模板key',
     feedback_key VARCHAR(100) DEFAULT NULL COMMENT '监测链接模板key',
+    material_source VARCHAR(50) NOT NULL DEFAULT 'history' COMMENT '创意素材来源:history/ai_generated',
+    ai_fallback_to_history BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'AI生成失败时是否回退历史素材',
     audience_name VARCHAR(200) NOT NULL COMMENT '人工配置的人群包名称',
     audience_pack_id BIGINT DEFAULT NULL COMMENT '系统解析/验证后的人群包ID;NULL表示泛人群',
     audience_tier_label VARCHAR(100) DEFAULT NULL COMMENT '审批/命名展示用人群标签',
@@ -77,6 +79,7 @@ CREATE TABLE IF NOT EXISTS ad_creation_account_config (
     KEY idx_delivery_version (delivery_version),
     KEY idx_brand_key (brand_key),
     KEY idx_feedback_key (feedback_key),
+    KEY idx_material_source (material_source),
     KEY idx_audience_name (audience_name)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='待投放账户广告创建配置';
 
@@ -193,6 +196,7 @@ CREATE TABLE IF NOT EXISTS creative_creation_task (
     UNIQUE KEY uk_account_creative (account_id, dynamic_creative_id),
     KEY idx_review_status (review_status),
     KEY idx_submitted_at (submitted_at),
+    KEY idx_landing_submitted (landing_video_id, submitted_at),
     KEY idx_last_review_checked_at (last_review_checked_at),
     KEY idx_account_status (account_id, review_status)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意创建与审核扫描任务';
@@ -269,7 +273,7 @@ CREATE TABLE IF NOT EXISTS creative_material_usage (
     account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
     adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
     crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
-    landing_video_id BIGINT DEFAULT NULL COMMENT '承接视频ID,仅审计不默认排重',
+    landing_video_id BIGINT DEFAULT NULL COMMENT '承接视频ID,用于同人群包近期排重',
     material_id VARCHAR(100) NOT NULL COMMENT '内部素材ID',
     material_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
     dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
@@ -280,11 +284,43 @@ CREATE TABLE IF NOT EXISTS creative_material_usage (
     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
 
     KEY idx_crowd_material_created (crowd_package, material_id, created_at),
+    KEY idx_crowd_landing_created (crowd_package, landing_video_id, created_at),
     KEY idx_crowd_account_ad_material (crowd_package, account_id, adgroup_id, material_id),
     KEY idx_account_created (account_id, created_at),
     KEY idx_status_created (status, created_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='创意素材使用历史/占位';
 
+-- =====================================================
+-- 13. AI生成创意图片素材
+-- =====================================================
+CREATE TABLE IF NOT EXISTS ai_generated_material (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
+    adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
+    crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
+    landing_video_id BIGINT NOT NULL COMMENT '承接视频ID',
+    landing_title VARCHAR(500) DEFAULT NULL COMMENT '承接视频标题',
+    landing_category VARCHAR(200) DEFAULT NULL COMMENT '承接视频品类',
+    prompt_type VARCHAR(50) NOT NULL COMMENT '生成角度:topic',
+    prompt_text MEDIUMTEXT NOT NULL COMMENT '最终生成prompt',
+    prompt_feature_json MEDIUMTEXT DEFAULT NULL COMMENT 'prompt使用的视频特征JSON',
+    model VARCHAR(200) NOT NULL COMMENT 'OpenRouter模型',
+    oss_object_key VARCHAR(500) DEFAULT NULL COMMENT 'OSS对象key',
+    oss_url VARCHAR(1000) DEFAULT NULL COMMENT 'OSS公网URL',
+    status VARCHAR(50) NOT NULL DEFAULT 'generated' COMMENT 'generated/prepared/approved/rejected/hold/skip/posted_ok/post_failed/error',
+    approval_status VARCHAR(50) DEFAULT NULL COMMENT '人工审批状态',
+    tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
+    dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
+    error TEXT DEFAULT NULL COMMENT '生成/上传/创建错误',
+    raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+    KEY idx_account_ad_landing_status (account_id, adgroup_id, landing_video_id, status),
+    KEY idx_crowd_created (crowd_package, created_at),
+    KEY idx_status_created (status, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成创意图片素材';
+
 -- =====================================================
 -- 初始化数据
 -- =====================================================

+ 94 - 0
examples/auto_put_ad_mini/debug_generate_ai_material.py

@@ -0,0 +1,94 @@
+"""手动调试 AI 图片生成和 OSS 上传。
+
+默认只生成图片并上传 OSS,打印 JSON 输出;不会创建广告/创意。
+
+示例:
+  python debug_generate_ai_material.py --video-id 71187017
+需要环境变量:
+  OPENROUTER_API_KEY
+  ALIYUN_OSS_ENDPOINT
+  ALIYUN_OSS_BUCKET
+  ALIYUN_OSS_ACCESS_KEY_ID
+  ALIYUN_OSS_ACCESS_KEY_SECRET
+  AI_IMAGE_PUBLIC_BASE_URL
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+_HERE = Path(__file__).parent
+load_dotenv(_HERE / ".env")
+sys.path.insert(0, str(_HERE.parent.parent))
+sys.path.insert(0, str(_HERE))
+
+from tools.ai_generated_material import (  # noqa: E402
+    OPENROUTER_IMAGE_MODEL,
+    build_ai_image_object_key,
+    build_generation_prompts,
+    generate_image_bytes,
+    sanitize_video_description,
+    upload_image_to_oss,
+)
+from tools.video_feature_query import read_cached_video_element_features  # noqa: E402
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--video-id", type=int, default=0)
+    parser.add_argument("--title", default="")
+    parser.add_argument("--category", default="")
+    parser.add_argument("--model", default=OPENROUTER_IMAGE_MODEL)
+    parser.add_argument("--limit", type=int, default=1)
+    args = parser.parse_args()
+
+    if not args.video_id:
+        raise SystemExit("必须提供 --video-id")
+    features_by_vid = read_cached_video_element_features([args.video_id])
+    features = features_by_vid.get(args.video_id) or []
+    topic = next((f.standard_element for f in features if f.element_dimension == "解构选题"), "")
+    sanitized_description = sanitize_video_description(topic) if topic else ""
+    prompts = [
+        (p.prompt_type, p.prompt_text)
+        for p in build_generation_prompts(
+            video_id=args.video_id,
+            title=args.title,
+            category=args.category,
+            features=features,
+            sanitized_description=sanitized_description,
+        )
+    ][: 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)
+        ext = ".png" if content_type == "image/png" else ".jpg"
+        object_key = build_ai_image_object_key(
+            account_id="debug",
+            landing_video_id=args.video_id,
+            prompt_type=prompt_type,
+            extension=ext,
+            debug=True,
+        )
+        oss_url = upload_image_to_oss(image_bytes, content_type, object_key)
+        outputs.append({
+            "prompt_type": prompt_type,
+            "model": args.model,
+            "content_type": content_type,
+            "oss_url": oss_url,
+            "object_key": object_key,
+            "prompt_text": prompt_text,
+            "raw_response_id": raw.get("id"),
+        })
+
+    print(json.dumps(outputs, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 36 - 0
examples/auto_put_ad_mini/execute_creation_apply.py

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

+ 33 - 3
examples/auto_put_ad_mini/execute_creation_once.py

@@ -63,7 +63,10 @@ from tools.creative_creation import (  # noqa: E402
     load_excluded_ad_ids_from_adjustment,
     prepare_one_creative_for_ad,
 )
-from tools.creative_material_usage import record_prepared_material_usage  # noqa: E402
+from tools.creative_material_usage import (  # noqa: E402
+    load_recent_used_landing_video_ids,
+    record_prepared_material_usage,
+)
 from tools.video_recall import get_account_crowd_package  # noqa: E402
 
 from execute_creation_apply import (  # noqa: E402
@@ -382,7 +385,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 不进历史排重库,只在同一广告本轮候选内做限频。
+    # landing_video 在同一 crowd_package 下做本轮 + 近期排重,降低 4+M 创意重复风险。
+    excluded_landing_ids_by_crowd: dict[str, set[int]] = {}
 
     for account_id in creation_accounts:
         logger.info("=" * 60)
@@ -417,6 +421,23 @@ 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:
+            try:
+                excluded_landing_ids_by_crowd[crowd_package] = load_recent_used_landing_video_ids(
+                    crowd_package,
+                )
+            except Exception as e:
+                logger.warning(
+                    "[phase1] crowd=%r 读取 landing 使用历史失败,仅使用本轮排重:%s",
+                    crowd_package, e,
+                )
+                excluded_landing_ids_by_crowd[crowd_package] = set()
+            logger.info(
+                "[phase1] crowd=%r 近期 landing 排重 size=%d",
+                crowd_package,
+                len(excluded_landing_ids_by_crowd[crowd_package]),
+            )
+        crowd_excluded_landing_ids = excluded_landing_ids_by_crowd[crowd_package]
 
         for ad in ads_after_filter:
             adgroup_id = ad["adgroup_id"]
@@ -434,11 +455,14 @@ 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
                 }
+                effective_excluded_landing_ids = (
+                    set(crowd_excluded_landing_ids) | landing_excluded_for_ad
+                )
                 try:
                     rec = prepare_one_creative_for_ad(
                         account_id, adgroup_id,
                         excluded_material_ids=display_excluded_material_ids,
-                        excluded_landing_ids=landing_excluded_for_ad,
+                        excluded_landing_ids=effective_excluded_landing_ids,
                     )
                 except Exception as e:
                     logger.exception(
@@ -462,6 +486,12 @@ 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)
+                        logger.info(
+                            "[phase1] 同人群包 landing 排重登记 crowd=%r landing=%d size=%d",
+                            crowd_package, landing_video_id,
+                            len(crowd_excluded_landing_ids),
+                        )
                         logger.info(
                             "[phase1] 本轮同广告 landing 计数 adgroup=%d landing=%d count=%d limit=%d",
                             adgroup_id, landing_video_id,

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

@@ -0,0 +1,88 @@
+请生成一张适合腾讯广告信息流 / 公众号投放的中文广告素材图,目标用户为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}}

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

@@ -0,0 +1,24 @@
+【system】
+你是广告素材生成前的视频内容清洗器。
+只输出一段清洗后的中文视频主题描述,不要解释。
+输出中绝对不要出现:领取、能领、已办成、赶紧、通知、不错过、转发、关注、公众号、加群、优惠券、卡券、下单、购买。
+
+【user】
+请清洗下面的原始视频选题。
+清洗只负责去掉风险词、营销引导和不适合进入图片生成的表达,不要负责制造广告钩子,不要改写成新的生活故事。
+
+要求:
+1. 必须保留原始选题里的核心事实、对象、数字和主题,例如人群对象、9笔/9项、退休补贴、AI诈骗、晚年生活等。
+2. 必须保留原始选题里的关键信息维度,例如核心主张、情绪冲突、问题场景、对象关系、内容类型;不能把它替换成泛化生活场景。
+3. 如果原始选题包含“拒绝内耗、活出自我、人际关系焦虑、退休补贴清单、9笔补贴、AI诈骗、晚年健康与名利对比”等信息,清洗结果必须保留对应含义。
+4. 去掉转发、关注、评论、公众号回复、私信、扫码、加群、领取、点击、下载、底部提示、醒目引导文案、标题设计、推广话术等互动/营销引导。
+5. 不要写能领取、已办成、赶紧去看、不错过福利等结果承诺或催促行动。
+6. 不要写假官方通知、不要写系统提示、不要写按钮或二维码,不要把画面设定为手机通知界面。
+7. 退休补贴类内容可以保留补贴数量、适用人群、专家讲解、清单图文展示,但不能写成已经办成或可以领取。
+8. 去掉商品售卖、优惠券、卡券、课程售卖、价格、订单、下单、购买、App下载、小程序推广等营销元素。
+9. 去掉低俗或不良联想词,例如寂寞、性感、风情、真人相册、私人相册、小视频、小短片等。
+10. 如果原始选题是新闻播报、官方通报、警示曝光、主持人播报、专家分析诈骗,只去掉不适合生成图片的播报包装,保留事件/风险/提醒的主题含义;不要改成无关的温馨日常。
+11. 输出 40-120 个中文字符,一句话。
+
+原始视频选题:
+{{raw_description}}

+ 22 - 1
examples/auto_put_ad_mini/sync_feishu_account_config.py

@@ -12,6 +12,8 @@
   - 「否」会禁用已有的 ad_creation_account_config,不删除历史配置。
   - 「初始出价」支持固定值 0.35 或范围 0.28-0.31。
   - 「预算(单广告)」为空/不限制/不限 时使用模板默认预算;数字按元转换为分。
+  - 「素材来源」为空默认历史素材;填 AI生成素材 时走 AI 图片生成链路。
+  - 「生成失败是否回退历史素材」为空/否默认不回退。
 """
 
 from __future__ import annotations
@@ -37,6 +39,10 @@ from configure_creation_accounts import (  # noqa: E402
     set_account_automation_enabled,
     upsert_account_config,
 )
+from tools.account_material_strategy import (  # noqa: E402
+    normalize_material_source,
+    parse_bool_flag,
+)
 from tools.feishu_doc import (  # noqa: E402
     FEISHU_BASE_URL,
     _auth_headers,
@@ -46,7 +52,7 @@ from tools.feishu_doc import (  # noqa: E402
 
 DEFAULT_SPREADSHEET_TOKEN = "D8f4sKEb2hfBnNttX1ucZTklnHf"
 DEFAULT_SHEET_ID = "y3uCcz"
-DEFAULT_RANGE = "A1:G300"
+DEFAULT_RANGE = "A1:I300"
 
 HEADER_ROW_INDEX = 1
 DATA_START_INDEX = 2
@@ -59,6 +65,11 @@ REQUIRED_COLUMNS = {
     "是否自动化执行": "enabled",
 }
 
+OPTIONAL_COLUMNS = {
+    "素材来源": "material_source",
+    "生成失败是否回退历史素材": "ai_fallback_to_history",
+}
+
 
 def _plain_value(value: Any) -> str:
     if value is None:
@@ -144,6 +155,14 @@ def sync_from_feishu(
             audience_name = _cell(row, mapping["人群包"])
             bid_text = _cell(row, mapping["初始出价"])
             budget_text = _cell(row, mapping["预算(单广告)"])
+            material_source_text = (
+                _cell(row, mapping["素材来源"])
+                if "素材来源" in mapping else ""
+            )
+            fallback_text = (
+                _cell(row, mapping["生成失败是否回退历史素材"])
+                if "生成失败是否回退历史素材" in mapping else ""
+            )
             bid_min, bid_max, bid_amount = parse_bid(bid_text)
             enabled_records.append(AccountConfigInput(
                 account_id=account_id,
@@ -153,6 +172,8 @@ def sync_from_feishu(
                 bid_amount_fen=bid_amount,
                 daily_budget_fen=parse_budget_fen(budget_text),
                 enabled=True,
+                material_source=normalize_material_source(material_source_text),
+                ai_fallback_to_history=parse_bool_flag(fallback_text),
             ))
             stats["enabled"] += 1
         except Exception as e:

+ 68 - 0
examples/auto_put_ad_mini/test_ai_generated_materials.py

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

+ 45 - 0
examples/auto_put_ad_mini/test_landing_video_dedupe.py

@@ -0,0 +1,45 @@
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+
+_HERE = Path(__file__).parent
+sys.path.insert(0, str(_HERE))
+
+import execute_creation_once  # noqa: E402
+
+
+class LandingVideoDedupeTest(unittest.TestCase):
+    def test_same_crowd_package_excludes_landing_video_across_accounts_in_one_run(self):
+        calls = []
+
+        def fake_prepare(account_id, adgroup_id, *, excluded_material_ids, excluded_landing_ids):
+            calls.append((account_id, adgroup_id, set(excluded_landing_ids)))
+            return {
+                "account_id": account_id,
+                "adgroup_id": adgroup_id,
+                "audience_tier": "wx*商业",
+                "landing_video_id": 1000 + account_id,
+                "_material_id": f"m-{account_id}",
+            }
+
+        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, "find_ads_needing_creatives", side_effect=[
+                [{"adgroup_id": 101, "creative_count": 3}],
+                [{"adgroup_id": 201, "creative_count": 3}],
+            ]), \
+            patch.object(execute_creation_once, "prepare_one_creative_for_ad", side_effect=fake_prepare), \
+            patch.object(execute_creation_once, "record_prepared_material_usage"):
+            records = execute_creation_once.phase1_prepare(target_creatives=4)
+
+        self.assertEqual(2, len(records))
+        self.assertEqual(set(), calls[0][2])
+        self.assertEqual({1001}, calls[1][2])
+
+
+if __name__ == "__main__":
+    unittest.main()

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

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

+ 1 - 1
examples/auto_put_ad_mini/tools/ad_creation.py

@@ -197,7 +197,7 @@ def enumerate_new_ad_candidates(
 
     # 差异化策略(2026-06-09 用户确认 + 2026-06-11 缩减):
     #   广告 #1 wechat_position=None   (无定投,走 site_set 默认全场景)
-    #   广告 #2 wechat_position=WECHAT_POSITION_TARGETED_PRESET(本期 4 项小程序版位)
+    #   广告 #2 wechat_position=WECHAT_POSITION_TARGETED_PRESET(公众号内容 + 小程序位置)
     # 同 site_set 同 targeting 但 wechat_position 不同 → 腾讯唯一性绕过(参考 77868332 实测)
     wechat_position_variants = [None, WECHAT_POSITION_TARGETED_PRESET]
 

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

@@ -0,0 +1,752 @@
+"""AI generated creative image assets for module B.
+
+This module is intentionally isolated from the historical material recall path.
+External side effects happen only when an account explicitly uses
+material_source=ai_generated.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import json
+import logging
+import mimetypes
+import os
+import re
+import uuid
+from dataclasses import dataclass
+from email.utils import formatdate
+from io import BytesIO
+from pathlib import Path
+from typing import Iterable, Optional
+from urllib.parse import quote, urlparse
+
+import httpx
+from PIL import Image
+
+from tools.material_recall import Material
+from tools.video_feature_query import VideoElementFeature, read_cached_video_element_features
+from tools.video_recall import LandingVideo
+
+logger = logging.getLogger(__name__)
+
+OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
+    "OPENROUTER_CHAT_COMPLETIONS_URL",
+    "https://openrouter.ai/api/v1/chat/completions",
+)
+OPENROUTER_IMAGES_URL = os.getenv(
+    "OPENROUTER_IMAGES_URL",
+    "https://openrouter.ai/api/v1/images",
+)
+OPENROUTER_IMAGE_MODEL = os.getenv(
+    "OPENROUTER_IMAGE_MODEL",
+    "google/gemini-3-pro-image",
+)
+OPENROUTER_TEXT_MODEL = os.getenv(
+    "OPENROUTER_TEXT_MODEL",
+    "google/gemini-2.5-flash",
+)
+AI_IMAGE_OSS_PREFIX = os.getenv("AI_IMAGE_OSS_PREFIX", "auto_put_tencent/image").strip("/")
+AI_IMAGE_ASPECT_RATIO = os.getenv("AI_IMAGE_ASPECT_RATIO", "16:9")
+AI_IMAGE_RESOLUTION = os.getenv("AI_IMAGE_RESOLUTION", "1K")
+AI_IMAGE_OUTPUT_FORMAT = os.getenv("AI_IMAGE_OUTPUT_FORMAT", "jpeg")
+AI_IMAGE_TARGET_WIDTH = int(os.getenv("AI_IMAGE_TARGET_WIDTH", "1280"))
+AI_IMAGE_TARGET_HEIGHT = int(os.getenv("AI_IMAGE_TARGET_HEIGHT", "720"))
+DEFAULT_AI_IMAGE_PROMPT_TEMPLATE_PATH = (
+    Path(__file__).resolve().parents[1] / "prompts" / "ai_generated_material.md"
+)
+DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH = (
+    Path(__file__).resolve().parents[1] / "prompts" / "ai_sanitize_video_description.md"
+)
+DEFAULT_AI_IMAGE_OSS_BUCKET = "art-pubbucket"
+DEFAULT_AI_IMAGE_PUBLIC_BASE_URL = "https://rescdn.yishihui.com"
+FORBIDDEN_SANITIZED_DESCRIPTION_TERMS = (
+    "公众号", "私信", "评论区", "扫码", "二维码", "加群", "关注",
+    "回复", "关键词", "转发", "推广", "引导", "点击",
+    "下载", "按钮", "弹窗", "底部固定", "领取", "能领", "赶紧",
+    "通知", "已办成", "不错过", "社交分享", "分享给", "转发分享",
+    "优惠券", "卡券", "领券", "下单", "购买", "价格", "订单",
+    "商品售卖", "课程售卖", "App下载", "小程序推广",
+    "真人相册", "私人相册", "小视频", "小短片", "寂寞", "性感",
+    "风情", "舞女", "小姐", "软妹", "学妹", "女郎", "娇妇", "俏妹",
+)
+
+
+CREATE_AI_MATERIAL_TABLE_SQL = """
+CREATE TABLE IF NOT EXISTS ai_generated_material (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
+    account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
+    adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
+    crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
+    landing_video_id BIGINT NOT NULL COMMENT '承接视频ID',
+    landing_title VARCHAR(500) DEFAULT NULL COMMENT '承接视频标题',
+    landing_category VARCHAR(200) DEFAULT NULL COMMENT '承接视频品类',
+    prompt_type VARCHAR(50) NOT NULL COMMENT '生成角度:topic',
+    prompt_text MEDIUMTEXT NOT NULL COMMENT '最终生成prompt',
+    prompt_feature_json MEDIUMTEXT DEFAULT NULL COMMENT 'prompt使用的视频特征JSON',
+    model VARCHAR(200) NOT NULL COMMENT 'OpenRouter模型',
+    oss_object_key VARCHAR(500) DEFAULT NULL COMMENT 'OSS对象key',
+    oss_url VARCHAR(1000) DEFAULT NULL COMMENT 'OSS公网URL',
+    status VARCHAR(50) NOT NULL DEFAULT 'generated' COMMENT 'generated/prepared/approved/rejected/hold/skip/posted_ok/post_failed/error',
+    approval_status VARCHAR(50) DEFAULT NULL COMMENT '人工审批状态',
+    tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
+    dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
+    error TEXT DEFAULT NULL COMMENT '生成/上传/创建错误',
+    raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    KEY idx_account_ad_landing_status (account_id, adgroup_id, landing_video_id, status),
+    KEY idx_crowd_created (crowd_package, created_at),
+    KEY idx_status_created (status, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成创意图片素材'
+"""
+
+
+@dataclass(frozen=True)
+class GenerationPrompt:
+    prompt_type: str
+    prompt_text: str
+    feature_hits: list[dict]
+
+
+@dataclass(frozen=True)
+class GeneratedMaterialAsset:
+    id: int
+    account_id: int
+    adgroup_id: Optional[int]
+    crowd_package: str
+    landing_video_id: int
+    prompt_type: str
+    prompt_text: str
+    model: str
+    oss_url: str
+    oss_object_key: str
+    feature_hits: list[dict]
+
+    @property
+    def material_id(self) -> str:
+        return f"ai:{self.id}"
+
+    def to_material(self) -> Material:
+        return Material(
+            material_id=self.material_id,
+            score=1.0,
+            title=f"AI生成素材-{self.prompt_type}",
+            cover=self.oss_url,
+            cost=None,
+            ctr=None,
+            cvr=None,
+            roi=None,
+            impressions=None,
+            quality_score=None,
+            recall_strategy=f"AI生成-{self.prompt_type}",
+            recall_query_text=self.prompt_text[:500],
+            recall_config_code="AI_GENERATED_IMAGE",
+            recall_element_dimension="AI生成素材",
+            recall_point_type=self.prompt_type,
+            recall_standard_element="",
+            recall_hit_queries=self.feature_hits,
+            raw={
+                "ai_generated_material_id": self.id,
+                "oss_url": self.oss_url,
+                "prompt_type": self.prompt_type,
+                "prompt_text": self.prompt_text,
+                "model": self.model,
+            },
+        )
+
+
+def _feature_attr(feature, name: str, default=""):
+    if isinstance(feature, dict):
+        return feature.get(name, default)
+    return getattr(feature, name, default)
+
+
+def _feature_to_hit(feature) -> dict:
+    return {
+        "element_dimension": str(_feature_attr(feature, "element_dimension") or ""),
+        "point_type": str(_feature_attr(feature, "point_type") or ""),
+        "standard_element": str(_feature_attr(feature, "standard_element") or ""),
+        "contribution_score": float(_feature_attr(feature, "contribution_score", 0) or 0),
+        "dt": str(_feature_attr(feature, "dt") or ""),
+    }
+
+
+def _top_feature(features: Iterable, *, dimension: str, point_type: str = "") -> Optional[dict]:
+    candidates = []
+    for feature in features:
+        hit = _feature_to_hit(feature)
+        if hit["element_dimension"] != dimension:
+            continue
+        if point_type and hit["point_type"] != point_type:
+            continue
+        if not hit["standard_element"] or hit["standard_element"] == "-":
+            continue
+        candidates.append(hit)
+    if not candidates:
+        return None
+    return sorted(candidates, key=lambda x: x["contribution_score"], reverse=True)[0]
+
+
+def _load_prompt_template() -> str:
+    raw_path = os.getenv("AI_IMAGE_PROMPT_TEMPLATE_PATH", "").strip()
+    path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_IMAGE_PROMPT_TEMPLATE_PATH
+    return path.read_text(encoding="utf-8")
+
+
+def _load_sanitize_prompt_messages(raw_description: str) -> list[dict]:
+    raw_path = os.getenv("AI_SANITIZE_PROMPT_TEMPLATE_PATH", "").strip()
+    path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH
+    template = path.read_text(encoding="utf-8")
+    if "【system】" not in template or "【user】" not in template:
+        raise RuntimeError(f"清洗prompt模板缺少【system】/【user】标记:{path}")
+    system_part, user_part = template.split("【user】", 1)
+    system_text = system_part.replace("【system】", "", 1).strip()
+    user_text = user_part.replace("{{raw_description}}", raw_description).strip()
+    if not system_text or not user_text:
+        raise RuntimeError(f"清洗prompt模板为空:{path}")
+    return [
+        {"role": "system", "content": system_text},
+        {"role": "user", "content": user_text},
+    ]
+
+
+def _render_prompt(video_description: str) -> str:
+    return (
+        _load_prompt_template()
+        .replace("{{video_description}}", video_description)
+        .replace("{{aspect_ratio}}", AI_IMAGE_ASPECT_RATIO)
+    )
+
+
+def _extract_chat_completion_text(data: dict) -> str:
+    choices = data.get("choices") or []
+    if not choices:
+        raise RuntimeError("OpenRouter 清洗响应缺 choices")
+    message = choices[0].get("message") or {}
+    content = message.get("content")
+    if isinstance(content, str):
+        return content.strip()
+    if isinstance(content, list):
+        parts = []
+        for item in content:
+            if isinstance(item, dict) and isinstance(item.get("text"), str):
+                parts.append(item["text"])
+        return "\n".join(parts).strip()
+    return ""
+
+
+def sanitize_video_description(raw_description: str, model: str = OPENROUTER_TEXT_MODEL) -> str:
+    """Rewrite ODPS topic into a clean visual description for image generation."""
+    raw = (raw_description or "").strip()
+    if not raw:
+        raise RuntimeError("缺少视频解构选题,无法清洗生成描述")
+
+    body = {
+        "model": model,
+        "temperature": 0.2,
+        "max_tokens": 180,
+        "messages": _load_sanitize_prompt_messages(raw),
+    }
+    headers = {
+        "authorization": f"Bearer {_openrouter_api_key()}",
+        "content-type": "application/json",
+        "accept": "application/json",
+    }
+    resp = httpx.post(OPENROUTER_CHAT_COMPLETIONS_URL, json=body, headers=headers, timeout=60)
+    resp.raise_for_status()
+    cleaned = _extract_chat_completion_text(resp.json())
+    cleaned = cleaned.strip().strip("`").strip().strip("“”\"'").replace("\n", "")
+    if not cleaned:
+        raise RuntimeError("OpenRouter 清洗后描述为空")
+    hit_terms = [term for term in FORBIDDEN_SANITIZED_DESCRIPTION_TERMS if term in cleaned]
+    if hit_terms:
+        raise RuntimeError(f"OpenRouter 清洗后仍包含互动/营销词:{','.join(hit_terms)}")
+    return cleaned
+
+
+def build_generation_prompts(
+    *,
+    video_id: int,
+    title: str,
+    category: str,
+    features: Iterable[VideoElementFeature],
+    sanitized_description: str = "",
+) -> list[GenerationPrompt]:
+    """Build one generation prompt from the video's ODPS topic."""
+    feature_list = list(features or [])
+    topic = _top_feature(feature_list, dimension="解构选题")
+    topic_text = (topic or {}).get("standard_element")
+    if not topic_text:
+        return []
+    video_description = sanitized_description.strip() or topic_text
+    topic_hit = dict(topic)
+    topic_hit["original_standard_element"] = topic_text
+    topic_hit["video_description"] = video_description
+    return [
+        GenerationPrompt(
+            prompt_type="topic",
+            prompt_text=_render_prompt(video_description),
+            feature_hits=[topic_hit],
+        )
+    ]
+
+
+def ensure_ai_material_table() -> None:
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(CREATE_AI_MATERIAL_TABLE_SQL)
+        conn.commit()
+    finally:
+        conn.close()
+
+
+def _openrouter_api_key() -> str:
+    key = os.getenv("OPEN_ROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY")
+    if not key:
+        raise RuntimeError("缺少 OPENROUTER_API_KEY/OPEN_ROUTER_API_KEY,无法生成AI素材")
+    return key
+
+
+def _to_jpeg_bytes(image_bytes: bytes) -> bytes:
+    """Normalize model output to RGB JPEG for Tencent material upload."""
+    with Image.open(BytesIO(image_bytes)) as img:
+        if img.mode not in ("RGB", "L"):
+            background = Image.new("RGB", img.size, (255, 255, 255))
+            if img.mode in ("RGBA", "LA"):
+                alpha = img.getchannel("A")
+                background.paste(img.convert("RGB"), mask=alpha)
+            else:
+                background.paste(img.convert("RGB"))
+            out_img = background
+        else:
+            out_img = img.convert("RGB")
+        target_ratio = AI_IMAGE_TARGET_WIDTH / AI_IMAGE_TARGET_HEIGHT
+        image_ratio = out_img.width / out_img.height
+        if image_ratio > target_ratio:
+            new_width = int(out_img.height * target_ratio)
+            left = (out_img.width - new_width) // 2
+            out_img = out_img.crop((left, 0, left + new_width, out_img.height))
+        elif image_ratio < target_ratio:
+            new_height = int(out_img.width / target_ratio)
+            top = (out_img.height - new_height) // 2
+            out_img = out_img.crop((0, top, out_img.width, top + new_height))
+        out_img = out_img.resize((AI_IMAGE_TARGET_WIDTH, AI_IMAGE_TARGET_HEIGHT), Image.Resampling.LANCZOS)
+        out = BytesIO()
+        out_img.save(out, format="JPEG", quality=92, optimize=True)
+        return out.getvalue()
+
+
+def _extract_image_ref_from_openrouter(data: dict) -> str:
+    choices = data.get("choices") or []
+    if not choices:
+        raise RuntimeError("OpenRouter 响应缺 choices")
+    msg = choices[0].get("message") or {}
+    images = msg.get("images") or []
+    for image in images:
+        image_url = image.get("image_url") if isinstance(image, dict) else None
+        if isinstance(image_url, dict) and image_url.get("url"):
+            return image_url["url"]
+        if isinstance(image_url, str):
+            return image_url
+
+    content = msg.get("content")
+    if isinstance(content, list):
+        for part in content:
+            if not isinstance(part, dict):
+                continue
+            image_url = part.get("image_url")
+            if isinstance(image_url, dict) and image_url.get("url"):
+                return image_url["url"]
+            if part.get("type") in ("image_url", "output_image") and part.get("url"):
+                return part["url"]
+    if isinstance(content, str):
+        match = re.search(r"!\[[^\]]*]\(([^)]+)\)", content)
+        if match:
+            return match.group(1)
+        data_match = re.search(r"(data:image/[^;\s]+;base64,[A-Za-z0-9+/=]+)", content)
+        if data_match:
+            return data_match.group(1)
+        url_match = re.search(r"https?://\S+", content)
+        if url_match:
+            return url_match.group(0).rstrip(").,,。")
+    raise RuntimeError("OpenRouter 响应未包含可识别图片URL/base64")
+
+
+def generate_image_bytes(prompt: str, model: str = OPENROUTER_IMAGE_MODEL) -> tuple[bytes, str, dict]:
+    """Generate one image through OpenRouter Images API.
+
+    The Images API lets us pass aspect_ratio directly. We still normalize the
+    returned raster to JPEG in code because not every model supports
+    output_format.
+    """
+    body = {
+        "model": model,
+        "prompt": prompt,
+        "aspect_ratio": AI_IMAGE_ASPECT_RATIO,
+        "resolution": AI_IMAGE_RESOLUTION,
+        "n": 1,
+    }
+    headers = {
+        "authorization": f"Bearer {_openrouter_api_key()}",
+        "content-type": "application/json",
+        "accept": "application/json",
+    }
+    resp = httpx.post(OPENROUTER_IMAGES_URL, json=body, headers=headers, timeout=120)
+    resp.raise_for_status()
+    data = resp.json()
+    images = data.get("data") or []
+    if not images or not images[0].get("b64_json"):
+        raise RuntimeError("OpenRouter Images API 响应缺少 data[0].b64_json")
+    image_bytes = base64.b64decode(images[0]["b64_json"])
+    if AI_IMAGE_OUTPUT_FORMAT.lower() in ("jpg", "jpeg"):
+        return _to_jpeg_bytes(image_bytes), "image/jpeg", data
+    media_type = images[0].get("media_type") or "image/png"
+    return image_bytes, media_type, data
+
+
+def _public_oss_url(public_base_url: str, object_key: str) -> str:
+    return f"{public_base_url.rstrip('/')}/{quote(object_key, safe='/')}"
+
+
+def _oss_env() -> tuple[str, str, str, str, str, str]:
+    endpoint = os.getenv("ALIYUN_OSS_ENDPOINT", "").strip()
+    bucket = (
+        os.getenv("ALIYUN_OSS_BUCKET", "").strip()
+        or os.getenv("AI_IMAGE_OSS_BUCKET", "").strip()
+        or DEFAULT_AI_IMAGE_OSS_BUCKET
+    )
+    access_key_id = os.getenv("ALIYUN_OSS_ACCESS_KEY_ID", "").strip()
+    access_key_secret = os.getenv("ALIYUN_OSS_ACCESS_KEY_SECRET", "").strip()
+    public_base_url = (
+        os.getenv("AI_IMAGE_PUBLIC_BASE_URL", "").strip()
+        or DEFAULT_AI_IMAGE_PUBLIC_BASE_URL
+    )
+    object_prefix = os.getenv("AI_IMAGE_OSS_PREFIX", AI_IMAGE_OSS_PREFIX).strip("/")
+    missing = [
+        name for name, value in [
+            ("ALIYUN_OSS_ENDPOINT", endpoint),
+            ("ALIYUN_OSS_BUCKET", bucket),
+            ("ALIYUN_OSS_ACCESS_KEY_ID", access_key_id),
+            ("ALIYUN_OSS_ACCESS_KEY_SECRET", access_key_secret),
+        ]
+        if not value
+    ]
+    if missing:
+        raise RuntimeError(f"缺少 OSS 配置:{','.join(missing)}")
+    return endpoint, bucket, access_key_id, access_key_secret, public_base_url.rstrip("/"), object_prefix
+
+
+def _normalize_oss_endpoint(endpoint: str, bucket: str) -> str:
+    endpoint = endpoint.rstrip("/")
+    if not endpoint.startswith(("http://", "https://")):
+        endpoint = "https://" + endpoint
+    parsed = urlparse(endpoint)
+    host = parsed.netloc
+    if not host.startswith(f"{bucket}."):
+        host = f"{bucket}.{host}"
+    return f"{parsed.scheme}://{host}"
+
+
+def upload_image_to_oss(image_bytes: bytes, content_type: str, object_key: str) -> str:
+    endpoint, bucket, access_key_id, access_key_secret, public_base_url, _ = _oss_env()
+    content_md5 = base64.b64encode(hashlib.md5(image_bytes).digest()).decode("ascii")
+    date = formatdate(usegmt=True)
+    canonical_resource = f"/{bucket}/{object_key}"
+    string_to_sign = f"PUT\n{content_md5}\n{content_type}\n{date}\n{canonical_resource}"
+    signature = base64.b64encode(
+        hmac.new(
+            access_key_secret.encode("utf-8"),
+            string_to_sign.encode("utf-8"),
+            hashlib.sha1,
+        ).digest()
+    ).decode("ascii")
+    upload_base = _normalize_oss_endpoint(endpoint, bucket)
+    url = f"{upload_base}/{quote(object_key, safe='/')}"
+    resp = httpx.put(
+        url,
+        content=image_bytes,
+        headers={
+            "Date": date,
+            "Content-Type": content_type,
+            "Content-MD5": content_md5,
+            "Authorization": f"OSS {access_key_id}:{signature}",
+        },
+        timeout=60,
+    )
+    resp.raise_for_status()
+    return _public_oss_url(public_base_url, object_key)
+
+
+def build_ai_image_object_key(
+    *,
+    account_id: int | str,
+    landing_video_id: int | str,
+    prompt_type: str,
+    extension: str,
+    debug: bool = False,
+) -> str:
+    object_prefix = os.getenv("AI_IMAGE_OSS_PREFIX", AI_IMAGE_OSS_PREFIX).strip("/")
+    ext = extension if extension.startswith(".") else f".{extension}"
+    if debug:
+        scope = "debug"
+    else:
+        scope = f"account_{account_id}/video_{landing_video_id}"
+    return f"{object_prefix}/{scope}/{prompt_type}_{uuid.uuid4().hex}{ext}"
+
+
+def _insert_generated_material(
+    *,
+    account_id: int,
+    adgroup_id: int,
+    crowd_package: str,
+    landing: LandingVideo,
+    prompt: GenerationPrompt,
+    model: str,
+    object_key: str,
+    oss_url: str,
+    raw_response: dict,
+) -> GeneratedMaterialAsset:
+    ensure_ai_material_table()
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                INSERT INTO ai_generated_material
+                    (account_id, adgroup_id, crowd_package, landing_video_id,
+                     landing_title, landing_category, prompt_type, prompt_text,
+                     prompt_feature_json, model, oss_object_key, oss_url, raw_response)
+                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                """,
+                (
+                    account_id,
+                    adgroup_id,
+                    crowd_package,
+                    landing.video_id,
+                    landing.title,
+                    landing.category,
+                    prompt.prompt_type,
+                    prompt.prompt_text,
+                    json.dumps(prompt.feature_hits, ensure_ascii=False),
+                    model,
+                    object_key,
+                    oss_url,
+                    json.dumps(raw_response, ensure_ascii=False, default=str)[:16000000],
+                ),
+            )
+            asset_id = int(cur.lastrowid)
+        conn.commit()
+    finally:
+        conn.close()
+    return GeneratedMaterialAsset(
+        id=asset_id,
+        account_id=account_id,
+        adgroup_id=adgroup_id,
+        crowd_package=crowd_package,
+        landing_video_id=landing.video_id,
+        prompt_type=prompt.prompt_type,
+        prompt_text=prompt.prompt_text,
+        model=model,
+        oss_url=oss_url,
+        oss_object_key=object_key,
+        feature_hits=prompt.feature_hits,
+    )
+
+
+def _rows_to_assets(rows: list[dict]) -> list[GeneratedMaterialAsset]:
+    out = []
+    for row in rows:
+        try:
+            feature_hits = json.loads(row.get("prompt_feature_json") or "[]")
+        except Exception:
+            feature_hits = []
+        out.append(GeneratedMaterialAsset(
+            id=int(row["id"]),
+            account_id=int(row["account_id"]),
+            adgroup_id=int(row["adgroup_id"]) if row.get("adgroup_id") else None,
+            crowd_package=str(row.get("crowd_package") or ""),
+            landing_video_id=int(row["landing_video_id"]),
+            prompt_type=str(row.get("prompt_type") or ""),
+            prompt_text=str(row.get("prompt_text") or ""),
+            model=str(row.get("model") or ""),
+            oss_url=str(row.get("oss_url") or ""),
+            oss_object_key=str(row.get("oss_object_key") or ""),
+            feature_hits=feature_hits,
+        ))
+    return out
+
+
+def load_available_generated_assets(
+    account_id: int,
+    adgroup_id: int,
+    landing_video_id: int,
+) -> list[GeneratedMaterialAsset]:
+    ensure_ai_material_table()
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT *
+                FROM ai_generated_material
+                WHERE account_id=%s
+                  AND adgroup_id=%s
+                  AND landing_video_id=%s
+                  AND status='generated'
+                  AND oss_url IS NOT NULL
+                  AND oss_url <> ''
+                ORDER BY id ASC
+                """,
+                (account_id, adgroup_id, landing_video_id),
+            )
+            rows = cur.fetchall() or []
+    finally:
+        conn.close()
+    return _rows_to_assets(rows)
+
+
+def generate_assets_for_landing(
+    *,
+    account_id: int,
+    adgroup_id: int,
+    crowd_package: str,
+    landing: LandingVideo,
+    model: str = OPENROUTER_IMAGE_MODEL,
+) -> list[GeneratedMaterialAsset]:
+    db_features = read_cached_video_element_features([landing.video_id]).get(landing.video_id) or []
+    if not db_features:
+        logger.info(
+            "[ai_generated_material] landing=%d no cached DB features for generation",
+            landing.video_id,
+        )
+        return []
+    topic = _top_feature(db_features, dimension="解构选题")
+    topic_text = (topic or {}).get("standard_element")
+    if not topic_text:
+        logger.info(
+            "[ai_generated_material] landing=%d no topic feature for generation",
+            landing.video_id,
+        )
+        return []
+    sanitized_description = sanitize_video_description(topic_text)
+    logger.info(
+        "[ai_generated_material] landing=%d sanitized description=%r",
+        landing.video_id, sanitized_description,
+    )
+    prompts = build_generation_prompts(
+        video_id=landing.video_id,
+        title=landing.title,
+        category=landing.category,
+        features=db_features,
+        sanitized_description=sanitized_description,
+    )
+    assets: list[GeneratedMaterialAsset] = []
+    for prompt in prompts:
+        image_bytes, content_type, raw_response = generate_image_bytes(prompt.prompt_text, model)
+        ext = mimetypes.guess_extension(content_type) or ".jpg"
+        if ext == ".jpe":
+            ext = ".jpg"
+        object_key = build_ai_image_object_key(
+            account_id=account_id,
+            landing_video_id=landing.video_id,
+            prompt_type=prompt.prompt_type,
+            extension=ext,
+        )
+        oss_url = upload_image_to_oss(image_bytes, content_type, object_key)
+        asset = _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,
+        )
+        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,
+        )
+    return assets
+
+
+def get_or_generate_assets_for_landing(
+    *,
+    account_id: int,
+    adgroup_id: int,
+    crowd_package: str,
+    landing: LandingVideo,
+) -> list[GeneratedMaterialAsset]:
+    existing = load_available_generated_assets(account_id, adgroup_id, landing.video_id)
+    if existing:
+        return existing
+    return generate_assets_for_landing(
+        account_id=account_id,
+        adgroup_id=adgroup_id,
+        crowd_package=crowd_package,
+        landing=landing,
+    )
+
+
+def update_generated_material_status(
+    record: dict,
+    status: str,
+    *,
+    dynamic_creative_id: int | str | None = None,
+    tencent_image_id: str = "",
+    error: str = "",
+) -> None:
+    asset_id = record.get("_ai_generated_material_id")
+    if not asset_id:
+        return
+    ensure_ai_material_table()
+    from db.connection import get_connection
+
+    normalized_status = {
+        "approve": "approved",
+        "reject": "rejected",
+    }.get(status, status)
+    approval_status = {
+        "approve": "approved",
+        "reject": "rejected",
+        "hold": "hold",
+        "skip": "skip",
+    }.get(status, normalized_status)
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                UPDATE ai_generated_material
+                SET status=%s,
+                    approval_status=%s,
+                    dynamic_creative_id=COALESCE(%s, dynamic_creative_id),
+                    tencent_image_id=COALESCE(NULLIF(%s, ''), tencent_image_id),
+                    error=COALESCE(NULLIF(%s, ''), error),
+                    updated_at=CURRENT_TIMESTAMP
+                WHERE id=%s
+                """,
+                (
+                    normalized_status,
+                    approval_status,
+                    int(dynamic_creative_id) if dynamic_creative_id else None,
+                    tencent_image_id,
+                    error[:2000] if error else "",
+                    int(asset_id),
+                ),
+            )
+        conn.commit()
+    finally:
+        conn.close()

+ 81 - 11
examples/auto_put_ad_mini/tools/creative_creation.py

@@ -36,6 +36,11 @@ from config import (
     TARGET_CREATIVES_PER_AD,
 )
 from tools.ad_api import _check, _get, _post, images_add
+from tools.account_material_strategy import load_account_material_strategy
+from tools.ai_generated_material import (
+    get_or_generate_assets_for_landing,
+    update_generated_material_status,
+)
 from tools.landing_plan import LandingPlanResult, create_landing_plan
 from tools.material_recall import Material, recall_materials_for_video
 from tools.creative_material_usage import (
@@ -532,7 +537,8 @@ def prepare_one_creative_for_ad(
     Args:
         excluded_material_ids: 同广告内已挂的 material_id 集合(2026-06-09 N=3 时去重必需)。
                                召回后过滤掉这些,避免同广告挂重复素材被腾讯模型降权曝光。
-        excluded_landing_ids: 兼容旧调用保留,当前不再使用。落地页视频允许重复。
+        excluded_landing_ids: 本轮/近期已使用的 landing_video_id 集合。
+                              用于同人群包跨广告、跨轮 landing 排重。
 
     Returns:
         pending record dict(飞书表格字段 + Phase 3 POST 用的完整 body + 元数据);
@@ -543,6 +549,7 @@ def prepare_one_creative_for_ad(
     excluded_material_ids = excluded_material_ids or set()
     excluded_landing_ids = excluded_landing_ids or set()
     crowd_package = get_account_crowd_package(account_id)
+    material_strategy = load_account_material_strategy(account_id)
     video_crowd_package = map_crowd_package_for_video_recall(crowd_package)
     try:
         recent_material_ids = load_recent_used_material_ids(crowd_package)
@@ -567,6 +574,8 @@ def prepare_one_creative_for_ad(
     chosen_material = None
     chosen_risk: Optional[VideoRiskResult] = None
     chosen_landing_source = ""
+    chosen_material_source = material_strategy.material_source
+    chosen_ai_generated_material_id = None
 
     # 每个来源最多拉 100 条视频。先主池,主池产不出可用创意时再用同人群包 hot 兜底。
     source_plan = [("primary", PIAOQUANTV_VIDEO_SOURCE)]
@@ -593,7 +602,7 @@ def prepare_one_creative_for_ad(
         for v in valid:
             if v.video_id in excluded_landing_ids:
                 logger.info(
-                    "[prepare_one_creative]   landing=%d 本轮同广告 landing 限频,跳过",
+                    "[prepare_one_creative]   landing=%d landing 排重命中,跳过",
                     v.video_id,
                 )
                 continue
@@ -619,11 +628,44 @@ def prepare_one_creative_for_ad(
                 )
                 continue
 
-            materials = recall_materials_for_video(
-                v,
-                final_top_n=max_materials_per_landing,
-                element_features=element_features,
-            )
+            materials = []
+            candidate_material_source = material_strategy.material_source
+            if material_strategy.use_ai_generated:
+                try:
+                    assets = get_or_generate_assets_for_landing(
+                        account_id=account_id,
+                        adgroup_id=adgroup_id,
+                        crowd_package=crowd_package,
+                        landing=v,
+                    )
+                    materials = [asset.to_material() for asset in assets]
+                    logger.info(
+                        "[prepare_one_creative]   landing=%d AI生成素材候选=%d fallback_history=%s",
+                        v.video_id, len(materials), material_strategy.ai_fallback_to_history,
+                    )
+                except Exception as e:
+                    logger.exception(
+                        "[prepare_one_creative]   landing=%d AI生成素材失败:%s",
+                        v.video_id, e,
+                    )
+                    if not material_strategy.ai_fallback_to_history:
+                        continue
+
+            if (not materials) and (
+                not material_strategy.use_ai_generated
+                or material_strategy.ai_fallback_to_history
+            ):
+                if material_strategy.use_ai_generated:
+                    logger.info(
+                        "[prepare_one_creative]   landing=%d AI无可用素材,按账户配置回退历史素材",
+                        v.video_id,
+                    )
+                    candidate_material_source = "history_fallback"
+                materials = recall_materials_for_video(
+                    v,
+                    final_top_n=max_materials_per_landing,
+                    element_features=element_features,
+                )
             # material_id 去重(2026-06-09):跳过已用素材(账户层 set,跨广告也共享)
             fresh = [
                 m for m in materials
@@ -634,9 +676,13 @@ def prepare_one_creative_for_ad(
                 chosen_material = fresh[0]
                 chosen_risk = risk
                 chosen_landing_source = source_label
+                chosen_material_source = candidate_material_source
+                if chosen_material.material_id.startswith("ai:"):
+                    chosen_ai_generated_material_id = chosen_material.raw.get("ai_generated_material_id")
                 logger.info(
-                    "[prepare_one_creative]   选中 landing=%d source=%s category=%r material=%s recall=%s/%s/%s cost=%s roi=%s ctr=%s imp=%s score=%s policy=score>=0.8,cost_desc",
+                    "[prepare_one_creative]   选中 landing=%d source=%s category=%r material_source=%s material=%s recall=%s/%s/%s cost=%s roi=%s ctr=%s imp=%s score=%s policy=%s",
                     v.video_id, source_label, v.category,
+                    chosen_material_source,
                     chosen_material.material_id,
                     chosen_material.recall_element_dimension,
                     chosen_material.recall_point_type,
@@ -646,6 +692,7 @@ def prepare_one_creative_for_ad(
                     chosen_material.ctr,
                     chosen_material.impressions,
                     chosen_material.score,
+                    "ai_generated" if chosen_material.material_id.startswith("ai:") else "score>=0.8,cost_desc",
                 )
                 break
             if materials:
@@ -675,8 +722,13 @@ def prepare_one_creative_for_ad(
         )
         return None
 
-    # 上传素材图(MD5 幂等)
-    material_image_id = images_add(account_id, image_url)
+    is_ai_generated_material = chosen_material.material_id.startswith("ai:")
+    if is_ai_generated_material:
+        # AI 图先走飞书人工审批,审批通过后在 Phase 3 上传腾讯图片。
+        material_image_id = ""
+    else:
+        # 历史素材保持原有行为:Phase 1 上传腾讯图片(MD5 幂等)。
+        material_image_id = images_add(account_id, image_url)
 
     # 注册落地计划(xcx/save)
     plan = create_landing_plan(crowd_package, chosen_landing)
@@ -719,6 +771,7 @@ def prepare_one_creative_for_ad(
         "landing_title": chosen_landing.title,
         "landing_source": chosen_landing_source,
         "landing_category": chosen_landing.category,
+        "material_source": chosen_material_source,
         "material_cover_url": chosen_material.cover,
         # 素材质量字段(2026-06-10 batchByText 升级 — 给 Task 25 飞书表展示用)
         "material_ctr": chosen_material.ctr,
@@ -727,7 +780,10 @@ def prepare_one_creative_for_ad(
         "material_impressions": chosen_material.impressions,
         "material_quality_score": chosen_material.quality_score,
         "material_score": chosen_material.score,
-        "material_selection_policy": "score>=0.8,cost_desc",
+        "material_selection_policy": (
+            "ai_generated_manual_review"
+            if is_ai_generated_material else "score>=0.8,cost_desc"
+        ),
         "material_recall_strategy": chosen_material.recall_strategy,
         "material_recall_query_text": chosen_material.recall_query_text,
         "material_recall_config_code": chosen_material.recall_config_code,
@@ -745,6 +801,8 @@ def prepare_one_creative_for_ad(
         # === 追溯元数据(写 summary JSON)===
         "_material_id": chosen_material.material_id,
         "_material_image_id": material_image_id,
+        "_pending_image_url": image_url if is_ai_generated_material else "",
+        "_ai_generated_material_id": chosen_ai_generated_material_id,
         "_plan_id": plan.plan_id,
         "_experiment_id": chosen_landing.experiment_id,
         "_brand_image_id": brand["brand_image_id"],
@@ -753,6 +811,18 @@ def prepare_one_creative_for_ad(
     }
     if chosen_risk is not None:
         rec.update(chosen_risk.to_record_fields())
+    if chosen_ai_generated_material_id:
+        try:
+            update_generated_material_status(
+                rec,
+                "prepared",
+                tencent_image_id=material_image_id,
+            )
+        except Exception as e:
+            logger.warning(
+                "[prepare_one_creative] AI素材状态更新失败 id=%s:%s",
+                chosen_ai_generated_material_id, e,
+            )
     return rec
 
 

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

@@ -16,6 +16,9 @@ logger = logging.getLogger(__name__)
 CREATIVE_MATERIAL_DEDUPE_LOOKBACK_DAYS = int(
     os.getenv("CREATIVE_MATERIAL_DEDUPE_LOOKBACK_DAYS", "7")
 )
+CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS = int(
+    os.getenv("CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS", "7")
+)
 
 CREATE_USAGE_TABLE_SQL = """
 CREATE TABLE IF NOT EXISTS creative_material_usage (
@@ -23,7 +26,7 @@ CREATE TABLE IF NOT EXISTS creative_material_usage (
     account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
     adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
     crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
-    landing_video_id BIGINT DEFAULT NULL COMMENT '承接视频ID,仅审计不默认排重',
+    landing_video_id BIGINT DEFAULT NULL COMMENT '承接视频ID,用于同人群包近期排重',
     material_id VARCHAR(100) NOT NULL COMMENT '内部素材ID',
     material_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
     dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
@@ -33,6 +36,7 @@ CREATE TABLE IF NOT EXISTS creative_material_usage (
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     KEY idx_crowd_material_created (crowd_package, material_id, created_at),
+    KEY idx_crowd_landing_created (crowd_package, landing_video_id, created_at),
     KEY idx_crowd_account_ad_material (crowd_package, account_id, adgroup_id, material_id),
     KEY idx_account_created (account_id, created_at),
     KEY idx_status_created (status, created_at)
@@ -47,6 +51,14 @@ def ensure_usage_table() -> None:
     try:
         with conn.cursor() as cur:
             cur.execute(CREATE_USAGE_TABLE_SQL)
+            cur.execute("SHOW INDEX FROM creative_material_usage WHERE Key_name = 'idx_crowd_landing_created'")
+            if not cur.fetchall():
+                cur.execute(
+                    """
+                    ALTER TABLE creative_material_usage
+                    ADD INDEX idx_crowd_landing_created (crowd_package, landing_video_id, created_at)
+                    """
+                )
         conn.commit()
     finally:
         conn.close()
@@ -96,6 +108,55 @@ def load_recent_used_material_ids(
     return {str(row["material_id"]) for row in rows if row.get("material_id")}
 
 
+def load_recent_used_landing_video_ids(
+    crowd_package: str,
+    lookback_days: int = CREATIVE_LANDING_DEDUPE_LOOKBACK_DAYS,
+) -> set[int]:
+    """Load recent landing-video reservations for the same crowd package."""
+    if not crowd_package:
+        return set()
+    ensure_usage_table()
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT DISTINCT landing_video_id
+                FROM (
+                    SELECT landing_video_id
+                    FROM creative_material_usage
+                    WHERE crowd_package=%s
+                      AND landing_video_id IS NOT NULL
+                      AND landing_video_id > 0
+                      AND created_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
+
+                    UNION
+
+                    SELECT landing_video_id
+                    FROM creative_creation_task
+                    WHERE landing_video_id IS NOT NULL
+                      AND landing_video_id > 0
+                      AND submitted_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
+                      AND JSON_UNQUOTE(JSON_EXTRACT(raw_record, '$.audience_tier'))=%s
+                ) t
+                """,
+                (crowd_package, int(lookback_days), int(lookback_days), crowd_package),
+            )
+            rows = cur.fetchall() or []
+    finally:
+        conn.close()
+
+    out: set[int] = set()
+    for row in rows:
+        try:
+            out.add(int(row["landing_video_id"]))
+        except (TypeError, ValueError):
+            continue
+    return out
+
+
 def record_prepared_material_usage(record: dict, status: str = "prepared") -> None:
     """Reserve a material once Phase 1 has produced a pending creative."""
     material_id = str(record.get("_material_id") or "").strip()

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

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

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

@@ -38,15 +38,15 @@ logger = logging.getLogger(__name__)
 
 FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
 
-# 26 列(中文)— 素材排序改为 score 准入 + cost 倒序,报表同步展示召回依据。
+# 27 列(中文)— 素材排序改为 score 准入 + cost 倒序,报表同步展示召回依据。
 HEADERS = [
     # A 浅灰 5 列
     "日期", "账户ID", "人群包", "广告ID", "广告名称",
     # B 浅紫 3 列
     "出价(元)", "投放版位", "年龄定向",
-    # C 浅橙 11 列(落地视频 + 风险审核 + 素材质量)
+    # C 浅橙 18 列(落地视频 + 风险审核 + 素材来源 + 素材质量)
     "落地视频", "落地视频标题", "风险等级", "风险标签", "风险原因",
-    "素材预览", "创意文案",
+    "素材来源", "素材预览", "创意文案",
     "成本(元)", "ROI", "CTR", "曝光数", "相似度",
     "召回维度", "召回点类型", "召回元素", "命中维度明细",
     "创意名(归因)",
@@ -57,22 +57,22 @@ HEADERS = [
 GROUP_COLORS = [
     ((1, 5), "FFD9D9D9"),    # A 浅灰
     ((6, 8), "FFD9C8E8"),    # B 浅紫
-    ((9, 25), "FFFCD8B4"),   # C 浅橙
-    ((26, 26), "FFC6E0B4"),  # D 浅绿
+    ((9, 26), "FFFCD8B4"),   # C 浅橙
+    ((27, 27), "FFC6E0B4"),  # D 浅绿
 ]
 
 COL_WIDTHS = {
     "A": 12, "B": 14, "C": 16, "D": 16, "E": 28,
     "F": 10, "G": 28, "H": 12,
     "I": 14, "J": 26, "K": 10, "L": 24, "M": 32,
-    "N": 18, "O": 30,
-    "P": 12, "Q": 10, "R": 10, "S": 10, "T": 10,
-    "U": 14, "V": 14, "W": 18, "X": 42,
-    "Y": 38,                      # 创意名(归因)
-    "Z": 14,                      # 决策
+    "N": 14, "O": 18, "P": 30,
+    "Q": 12, "R": 10, "S": 10, "T": 10, "U": 10,
+    "V": 14, "W": 14, "X": 18, "Y": 42,
+    "Z": 38,                      # 创意名(归因)
+    "AA": 14,                     # 决策
 }
 
-DECISION_COL_LETTER = "Z"
+DECISION_COL_LETTER = "AA"
 VALID_ACTIONS = ("approve", "reject", "hold")
 
 
@@ -120,6 +120,7 @@ def _format_record_to_row(rec: dict) -> list:
         rec.get("landing_risk_level", ""),
         rec.get("landing_risk_tag_ids", ""),
         rec.get("landing_risk_reason", ""),
+        rec.get("material_source", "history"),
         # 素材预览:HYPERLINK(cover_url, "查看素材")— 见模块顶部说明
         f'=HYPERLINK("{rec["material_cover_url"]}","查看素材")',
         # 创意文案(2026-06-09 加列):description 换行展示

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

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