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

Refine AI material pattern prompts

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

+ 120 - 4
examples/auto_put_ad_mini/configs/material_creative_patterns_seed.json

@@ -181,9 +181,9 @@
     "pattern_name": "老年生活建议清单",
     "pattern_name": "老年生活建议清单",
     "hook_category": "number_list,family_emotion",
     "hook_category": "number_list,family_emotion",
     "visual_template": "elder_family_emotion_big_title",
     "visual_template": "elder_family_emotion_big_title",
-    "title_hook_rule": "把老年生活经验、晚年建议、自我成长、情绪调节等内容转成“几条建议/几个习惯/几件事”的清单型标题。表达要温和、有用、有现实感。",
-    "visual_rule": "用生活选择、清单、旧物、路口、院子、桌面道具、背影或少量人物交流表达经验感。标题突出“建议/经验/几件事”,不默认老人正脸。",
-    "relevance_rule": "适合视频特征包含老人、晚年、生活建议、自我成长、情绪、习惯、人生经验等主题。",
+    "title_hook_rule": "仅当主题本身明确包含建议、习惯、几件事、几个原因、步骤、清单等结构时,才转成“几条建议/几个习惯/几件事”的清单型标题。表达要温和、有用、有现实感,不要把抽象心态硬写成清单。",
+    "visual_rule": "用生活选择、纸面清单、路口、院子、桌面道具、背影或少量人物交流表达经验感。标题突出“建议/经验/几件事”,不默认老人正脸,不要把心理层面的放下强行画成丢弃物品。",
+    "relevance_rule": "适合视频特征明确包含老人、晚年、生活建议、习惯、人生经验、几件事、几个原因、步骤、清单等主题。若只是放下执念、清净自在、知足常乐等抽象心态,不要优先使用。",
     "compliance_rule": "不得制造恐惧,不得把建议写成绝对真理,不得贬低老年人或家庭成员。",
     "compliance_rule": "不得制造恐惧,不得把建议写成绝对真理,不得贬低老年人或家庭成员。",
     "positive_examples": ["老年人一旦迈入八十岁高龄不妨听听这七条建议"],
     "positive_examples": ["老年人一旦迈入八十岁高龄不妨听听这七条建议"],
     "negative_examples": ["不这样做晚年一定凄惨", "唯一正确活法"],
     "negative_examples": ["不这样做晚年一定凄惨", "唯一正确活法"],
@@ -196,13 +196,129 @@
       "base_score": 8,
       "base_score": 8,
       "min_match_groups": 1,
       "min_match_groups": 1,
       "match_groups": [
       "match_groups": [
-        {"name": "elder_advice", "score": 30, "keywords": ["建议", "习惯", "经验", "人生", "情绪", "内耗", "成长", "心态", "活法", "晚年", "老人", "老年"]}
+        {"name": "elder_advice", "score": 30, "keywords": ["建议", "习惯", "经验", "几件", "几个", "原因", "步骤", "清单", "晚年", "老人", "老年"]}
       ],
       ],
       "exclude_groups": [
       "exclude_groups": [
         {"name": "absolute_fear", "score": -25, "keywords": ["一定凄惨", "唯一", "必须", "不做就"]}
         {"name": "absolute_fear", "score": -25, "keywords": ["一定凄惨", "唯一", "必须", "不做就"]}
       ]
       ]
     }
     }
   },
   },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "self_attitude_emotion_hook",
+    "pattern_name": "自我态度情绪钩子",
+    "hook_category": "self_attitude,emotion_conflict,relationship_pressure",
+    "visual_template": "strong_subject_emotion_big_title",
+    "title_hook_rule": "围绕拒绝内耗、活出自我、看透攀比、人际压力、面子和自我和解生成标题。优先保留核心情绪和关系冲突,不要硬转成旧物、清单或退休建议。",
+    "visual_rule": "用能看出态度变化、关系压力、表达瞬间或情绪反差的画面承载主题。人物年龄、性别、身份和场景必须服务主题,不要固定主体模板。",
+    "relevance_rule": "适合视频特征包含拒绝内耗、活出自我、攀比、面子、人际关系、虚伪体面、心态、看透、焦虑、通透、人生选择等抽象情绪和自我表达主题。",
+    "compliance_rule": "不得制造极端恐吓,不得贬低特定群体,不得做低俗擦边,不得把人生建议写成唯一正确答案。",
+    "positive_examples": ["有些关系看清后人就轻松了", "别再为了面子把自己耗空"],
+    "negative_examples": ["扔掉三样旧物生活就顺了", "唯一正确活法"],
+    "source_run_id": "manual_pattern_20260709",
+    "source_material_count": 0,
+    "source_total_cost_fen": 0,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 12,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "self_attitude", "score": 38, "keywords": ["拒绝内耗", "内耗", "活出自我", "自我", "心态", "通透", "想开", "清醒", "自在", "体面"]},
+        {"name": "relationship_pressure", "score": 24, "keywords": ["人际关系", "关系", "攀比", "面子", "虚伪", "焦虑", "物质欲望", "压力", "看透"]}
+      ],
+      "exclude_groups": [
+        {"name": "concrete_policy_or_object", "score": -15, "keywords": ["养老金", "补贴", "工资", "旧鞋", "机器", "潮水", "燕子", "合体字"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "relationship_reality_gap",
+    "pattern_name": "人情关系现实反差",
+    "hook_category": "relationship_gap,family_emotion,social_observation",
+    "visual_template": "relationship_conflict_big_title",
+    "title_hook_rule": "围绕亲戚、熟人、邻里、朋友、家庭关系中的没说破、变淡、冷场、看清原因等现实反差生成标题。强调想看感,避免低俗伦理和隐私窥探。",
+    "visual_rule": "用能看出关系变化、未说破、冷场、对视、停顿或线索物件的画面承载主题。可以有人物强主体,但不要制造暴力冲突或固定家庭模板。",
+    "relevance_rule": "适合视频特征包含亲戚、熟人、邻里、朋友、人情、家庭关系、人际往来、看清关系、关系变淡、面子等主题。",
+    "compliance_rule": "不得隐私曝光,不得低俗两性,不得挑动家庭仇恨,不得使用血腥暴力或极端羞辱表达。",
+    "positive_examples": ["亲戚之间这件事很多人没说破", "有些关系变淡其实早有原因"],
+    "negative_examples": ["熟人丑事曝光", "偷听邻居秘密"],
+    "source_run_id": "manual_pattern_20260709",
+    "source_material_count": 0,
+    "source_total_cost_fen": 0,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 11,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "relationship", "score": 36, "keywords": ["亲戚", "熟人", "邻里", "邻居", "朋友", "人情", "关系", "家庭", "父母", "子女"]},
+        {"name": "reality_gap", "score": 18, "keywords": ["没说破", "看清", "变淡", "冷场", "原因", "面子", "现实", "后来才知道"]}
+      ],
+      "exclude_groups": [
+        {"name": "vulgar_privacy", "score": -35, "keywords": ["隐私", "丑事", "偷听", "出轨", "分居", "床"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "money_policy_info_gap",
+    "pattern_name": "钱款政策信息差",
+    "hook_category": "money_policy,information_gap,list_reminder",
+    "visual_template": "document_bill_big_title",
+    "title_hook_rule": "围绕养老金、退休工资、补贴、账单、清单、社保等钱款信息生成通俗提醒标题。要有信息差,但不能像官方通知或承诺到账。",
+    "visual_rule": "用钱款信息、资料线索、核对动作或讲解现场承载主题。背景避免强官方感,不要伪装政策通知。",
+    "relevance_rule": "适合视频特征包含养老金、退休工资、社保、补贴、账单、清单、时间变化、钱款核对、政策解读等主题。",
+    "compliance_rule": "不得承诺领取、到账、涨钱或收益;不得假官方通知;不得使用国家发钱、紧急、最后一天等高风险表达。",
+    "positive_examples": ["退休后这笔账很多人还没算明白", "这张清单别只看表面数字"],
+    "negative_examples": ["国家发钱马上到账", "最后一天赶紧领取"],
+    "source_run_id": "manual_pattern_20260709",
+    "source_material_count": 0,
+    "source_total_cost_fen": 0,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 12,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "money_policy", "score": 40, "keywords": ["养老金", "退休金", "退休工资", "社保", "补贴", "账单", "清单", "工资", "钱", "政策"]},
+        {"name": "info_gap", "score": 12, "keywords": ["很多人不知道", "算错", "看明白", "留意", "注意", "时间", "变化"]}
+      ],
+      "exclude_groups": [
+        {"name": "fake_policy_claim", "score": -40, "keywords": ["国家发钱", "马上到账", "领取", "紧急通知", "最后一天", "官方通知"]}
+      ]
+    }
+  },
+  {
+    "pattern_version": "seed_20260708_v1",
+    "pattern_key": "object_process_reverse_hook",
+    "pattern_name": "物品流程反差钩子",
+    "hook_category": "object_process,reverse_gap,light_curiosity",
+    "visual_template": "object_process_big_title",
+    "title_hook_rule": "围绕旧物去向、机器加工、日常物品背后流程、前后变化生成反差标题。让用户想知道普通东西后来变成了什么或背后怎么回事。",
+    "visual_rule": "用对象变化、流程现场、前后反差或过程线索承载主题。人物不是必需,重点突出变化本身。",
+    "relevance_rule": "适合视频特征包含旧鞋、回收、加工、机器、流程、去向、前后变化、普通物品背后原因、日常物件反差等主题。",
+    "compliance_rule": "不得涉及假货售卖、品牌侵权、违法生产、恐吓和假新闻播报。",
+    "positive_examples": ["旧鞋进厂后竟然变成了这个", "这个小东西背后还有一道工序"],
+    "negative_examples": ["名牌包都是这样造假", "违法工厂曝光"],
+    "source_run_id": "manual_pattern_20260709",
+    "source_material_count": 0,
+    "source_total_cost_fen": 0,
+    "status": "DRAFT",
+    "enabled": 0,
+    "selector_config": {
+      "base_score": 12,
+      "min_match_groups": 1,
+      "match_groups": [
+        {"name": "object_process", "score": 40, "keywords": ["旧鞋", "回收", "加工", "机器", "流程", "工厂", "变成", "去向", "制作", "物品"]},
+        {"name": "reverse_gap", "score": 12, "keywords": ["没想到", "竟然", "原来", "背后", "后来", "前后", "变化"]}
+      ],
+      "exclude_groups": [
+        {"name": "illegal_or_brand_risk", "score": -30, "keywords": ["假货", "仿冒", "违法", "曝光", "名牌"]}
+      ]
+    }
+  },
   {
   {
     "pattern_version": "seed_20260708_v1",
     "pattern_version": "seed_20260708_v1",
     "pattern_key": "local_life_social_observation",
     "pattern_key": "local_life_social_observation",

+ 10 - 5
examples/auto_put_ad_mini/prompts/ai_cover_copy.md

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

+ 11 - 6
examples/auto_put_ad_mini/prompts/ai_generated_material.md

@@ -13,22 +13,27 @@
 
 
 【主题与画面】
 【主题与画面】
 - 输入是广告主题种子,不是视频脚本或画面脚本。
 - 输入是广告主题种子,不是视频脚本或画面脚本。
-- 目标用户是 45-75 岁用户,但目标用户不等于画面主体。
-- 画面主体可以是人物、物品、资料清单、机器、自然景观、事件现场、舞台、桌面道具、建筑、老照片或场景对比。
+- 目标用户是 60 岁以上用户,但目标用户不等于画面主体。
 - 不强制出现人物;如果出现人物,也不强制是老人正脸。
 - 不强制出现人物;如果出现人物,也不强制是老人正脸。
 - 不复刻视频镜头,可以做同主题、同情绪、同问题场景的广告化转译。
 - 不复刻视频镜头,可以做同主题、同情绪、同问题场景的广告化转译。
-- 不要所有图都做成客厅、饭桌、会议室、病床、老人讨论的固定模板。
+- 不要把任何场景、人物或构图当成固定模板。
 
 
 【视觉风格】
 【视觉风格】
 - 像真实中文信息流广告封面,不要卡通、二次元、科技海报、欧美商业海报。
 - 像真实中文信息流广告封面,不要卡通、二次元、科技海报、欧美商业海报。
+- 画面必须有明确视觉 hook,第一眼就能看到一个值得停留的主体或反差,不要只是平静生活照。
+- 视觉 hook 必须由主题决定,可以是可见的反差、线索、情绪、过程、现场感、疑问感或表达瞬间。
+- 如果使用人物,人物的年龄、性别、身份和情绪都要服务主题,不要为了吸引点击而硬植入无关人物。
+- 如果不使用人物,也要让画面主体本身有足够信息量和停留感。
+- 人物不能低俗、擦边、暴露、挑逗。
 - 画面要有明确点击点:信息差、反差、清单感、奇闻感、情绪共鸣或生活提醒。
 - 画面要有明确点击点:信息差、反差、清单感、奇闻感、情绪共鸣或生活提醒。
-- 由 Pattern 决定主要视觉方向;不要因为目标用户是中老年就默认画老人。
+- 由 Pattern 决定主要视觉方向;不要因为目标用户是 60 岁以上就默认画老人。
 - 人物必须是中国本土语境;不要外国人物;不要真实名人肖像或类似真实名人的脸。
 - 人物必须是中国本土语境;不要外国人物;不要真实名人肖像或类似真实名人的脸。
 
 
 【标题排版】
 【标题排版】
 - 画面中必须有一个超大中文主标题。
 - 画面中必须有一个超大中文主标题。
 - 如果输入中提供了【封面标题】,必须逐字使用其中的“主标题”。
 - 如果输入中提供了【封面标题】,必须逐字使用其中的“主标题”。
 - 不要生成副标题、小字说明、角标、引导文案或额外字幕。
 - 不要生成副标题、小字说明、角标、引导文案或额外字幕。
+- 主标题不能出现方括号、圆括号、书名号、引号或额外符号,包括【】()《》“”。
 - 主标题最多 2 行,大白字、黑色粗描边、高对比度。
 - 主标题最多 2 行,大白字、黑色粗描边、高对比度。
 - 只能高亮输入中提供的【高亮词】;没有高亮词时不要强行彩色高亮。
 - 只能高亮输入中提供的【高亮词】;没有高亮词时不要强行彩色高亮。
 - 高亮色可用黄色、橙色或红色,但不要整句彩色化。
 - 高亮色可用黄色、橙色或红色,但不要整句彩色化。
@@ -37,8 +42,8 @@
 
 
 【多样性】
 【多样性】
 - 不同视频应有不同 scene / subject / prop / composition / color_mood。
 - 不同视频应有不同 scene / subject / prop / composition / color_mood。
-- 可以偏大字封面、资料特写、事件现场、物品近景、场景对比、舞台反应、自然奇观或人物情绪反应
-- 如果人物不是主题必要元素,优先考虑非人物主体或人物弱化构图
+- 不同主题要有不同的视觉组织方式,避免反复生成同类人物照、风景照或生活照
+- 如果人物不是主题必要元素,可以弱化人物或不用人物
 
 
 【合规禁止】
 【合规禁止】
 - 禁止假官方通知、假新闻播报、假系统提示、假微信界面、假聊天记录、假按钮、假红包、假弹窗、二维码、下载按钮、播放按钮伪装。
 - 禁止假官方通知、假新闻播报、假系统提示、假微信界面、假聊天记录、假按钮、假红包、假弹窗、二维码、下载按钮、播放按钮伪装。

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

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

+ 1 - 1
examples/auto_put_ad_mini/prompts/ai_sanitize_video_description.md

@@ -5,7 +5,7 @@
 【user】
 【user】
 请把下面的原始视频选题提炼成广告封面图使用的主题种子。
 请把下面的原始视频选题提炼成广告封面图使用的主题种子。
 
 
-目标用户:45-75 岁中文信息流用户。
+目标用户:60 岁以上中文信息流用户。
 
 
 提炼原则:
 提炼原则:
 1. 保留原始选题的核心问题、信息差、反差、情绪价值或生活提醒价值。
 1. 保留原始选题的核心问题、信息差、反差、情绪价值或生活提醒价值。

+ 14 - 20
examples/auto_put_ad_mini/tools/ai_generated_material.py

@@ -76,16 +76,6 @@ DEFAULT_AI_COVER_COPY_PROMPT_TEMPLATE_PATH = (
 )
 )
 DEFAULT_AI_IMAGE_OSS_BUCKET = "art-pubbucket"
 DEFAULT_AI_IMAGE_OSS_BUCKET = "art-pubbucket"
 DEFAULT_AI_IMAGE_PUBLIC_BASE_URL = "https://rescdn.yishihui.com"
 DEFAULT_AI_IMAGE_PUBLIC_BASE_URL = "https://rescdn.yishihui.com"
-FORBIDDEN_SANITIZED_DESCRIPTION_TERMS = (
-    "公众号", "私信", "评论区", "扫码", "二维码", "加群", "关注",
-    "回复", "关键词", "转发", "推广", "引导", "点击",
-    "下载", "按钮", "弹窗", "底部固定", "领取", "能领", "赶紧",
-    "通知", "已办成", "不错过", "社交分享", "分享给", "转发分享",
-    "优惠券", "卡券", "领券", "下单", "购买", "价格", "订单",
-    "商品售卖", "课程售卖", "App下载", "小程序推广",
-    "真人相册", "私人相册", "小视频", "小短片", "寂寞", "性感",
-    "风情", "舞女", "小姐", "软妹", "学妹", "女郎", "娇妇", "俏妹",
-)
 
 
 
 
 CREATE_AI_MATERIAL_TABLE_SQL = """
 CREATE_AI_MATERIAL_TABLE_SQL = """
@@ -375,9 +365,6 @@ def sanitize_video_description(raw_description: str, model: str = OPENROUTER_TEX
     cleaned = cleaned.strip().strip("`").strip().strip("“”\"'").replace("\n", "")
     cleaned = cleaned.strip().strip("`").strip().strip("“”\"'").replace("\n", "")
     if not cleaned:
     if not cleaned:
         raise RuntimeError("OpenRouter 清洗后描述为空")
         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
     return cleaned
 
 
 
 
@@ -431,15 +418,10 @@ def _best_cover_copy_candidate(candidates: list[dict[str, Any]]) -> dict[str, An
 def _normalize_highlight_terms(value: Any, *, title: str) -> list[str]:
 def _normalize_highlight_terms(value: Any, *, title: str) -> list[str]:
     if not isinstance(value, list):
     if not isinstance(value, list):
         return []
         return []
-    blocked = {
-        "视频里", "看完", "原来", "很多人", "这几件事", "才明白",
-        "国家", "补贴", "领取", "记性变差", "儿子", "女儿",
-        "病床", "诈骗", "犯罪",
-    }
     out: list[str] = []
     out: list[str] = []
     for item in value:
     for item in value:
         term = _clean_cover_copy_text(item, max_len=8)
         term = _clean_cover_copy_text(item, max_len=8)
-        if not term or term in blocked:
+        if not term:
             continue
             continue
         if term not in title:
         if term not in title:
             continue
             continue
@@ -515,7 +497,7 @@ def _render_pattern_prompt(video_description: str, selection, cover_copy: CoverC
 - 标题钩子:{cover_copy.hook_angle or "未标注"}
 - 标题钩子:{cover_copy.hook_angle or "未标注"}
 - 高亮词:{",".join(cover_copy.highlight_terms or []) or "无"}
 - 高亮词:{",".join(cover_copy.highlight_terms or []) or "无"}
 
 
-图片中必须使用上面的主标题。只允许高亮【高亮词】中列出的词;如果高亮词为“无”,不要强行彩色高亮。不要生成副标题,也不要自行新增其他标题、角标或引导文字。
+图片中必须使用上面的主标题。主标题不能添加任何括号、引号、书名号或额外符号。只允许高亮【高亮词】中列出的词;如果高亮词为“无”,不要强行彩色高亮。不要生成副标题,也不要自行新增其他标题、角标或引导文字。
 """
 """
     enriched_description = f"""【广告主题种子】
     enriched_description = f"""【广告主题种子】
 {video_description}
 {video_description}
@@ -595,6 +577,18 @@ def build_pattern_generation_prompts(
         use_model=True,
         use_model=True,
         model=text_model,
         model=text_model,
     )
     )
+    if not selections:
+        logger.info(
+            "[ai_generated_material] video=%d no matched pattern, fallback to topic prompt",
+            video_id,
+        )
+        return build_generation_prompts(
+            video_id=video_id,
+            title=title,
+            category=category,
+            features=feature_list,
+            sanitized_description=video_description,
+        )
     prompts: list[GenerationPrompt] = []
     prompts: list[GenerationPrompt] = []
     for selection in selections[:max(1, int(top_k))]:
     for selection in selections[:max(1, int(top_k))]:
         cover_copy = None
         cover_copy = None

+ 15 - 25
examples/auto_put_ad_mini/tools/material_strategy_learning.py

@@ -206,7 +206,7 @@ class CreativePattern:
     selector_config: dict[str, Any] | None = None
     selector_config: dict[str, Any] | None = None
     applicable_crowd_packages: str = ""
     applicable_crowd_packages: str = ""
     applicable_placements: str = ""
     applicable_placements: str = ""
-    target_age_min: int | None = 45
+    target_age_min: int | None = 60
     target_age_max: int | None = 75
     target_age_max: int | None = 75
     positive_examples: list[str] | None = None
     positive_examples: list[str] | None = None
     negative_examples: list[str] | None = None
     negative_examples: list[str] | None = None
@@ -231,11 +231,15 @@ DEFAULT_SOURCE_RUN_ID = "material_30d_20260607_20260706_top5000"
 DEFAULT_PATTERN_SEED_PATH = (
 DEFAULT_PATTERN_SEED_PATH = (
     Path(__file__).resolve().parents[1] / "configs" / "material_creative_patterns_seed.json"
     Path(__file__).resolve().parents[1] / "configs" / "material_creative_patterns_seed.json"
 )
 )
+DEFAULT_PATTERN_SELECTOR_PROMPT_PATH = (
+    Path(__file__).resolve().parents[1] / "prompts" / "ai_pattern_selector.md"
+)
 OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
 OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
     "OPENROUTER_CHAT_COMPLETIONS_URL",
     "OPENROUTER_CHAT_COMPLETIONS_URL",
     "https://openrouter.ai/api/v1/chat/completions",
     "https://openrouter.ai/api/v1/chat/completions",
 )
 )
 OPENROUTER_TEXT_MODEL = os.getenv("OPENROUTER_TEXT_MODEL", "google/gemini-3-flash-preview")
 OPENROUTER_TEXT_MODEL = os.getenv("OPENROUTER_TEXT_MODEL", "google/gemini-3-flash-preview")
+PATTERN_SELECTOR_MIN_MODEL_SCORE = float(os.getenv("PATTERN_SELECTOR_MIN_MODEL_SCORE", "75"))
 
 
 def ensure_strategy_learning_tables() -> None:
 def ensure_strategy_learning_tables() -> None:
     from db.connection import get_connection
     from db.connection import get_connection
@@ -322,7 +326,7 @@ def _load_seed_patterns(path: Path = DEFAULT_PATTERN_SEED_PATH) -> list[Creative
             visual_template=str(raw.get("visual_template") or ""),
             visual_template=str(raw.get("visual_template") or ""),
             applicable_crowd_packages=str(raw.get("applicable_crowd_packages") or ""),
             applicable_crowd_packages=str(raw.get("applicable_crowd_packages") or ""),
             applicable_placements=str(raw.get("applicable_placements") or ""),
             applicable_placements=str(raw.get("applicable_placements") or ""),
-            target_age_min=_as_int(raw.get("target_age_min")) or 45,
+            target_age_min=_as_int(raw.get("target_age_min")) or 60,
             target_age_max=_as_int(raw.get("target_age_max")) or 75,
             target_age_max=_as_int(raw.get("target_age_max")) or 75,
             title_hook_rule=str(raw.get("title_hook_rule") or ""),
             title_hook_rule=str(raw.get("title_hook_rule") or ""),
             visual_rule=str(raw.get("visual_rule") or ""),
             visual_rule=str(raw.get("visual_rule") or ""),
@@ -513,18 +517,6 @@ def _fallback_score_pattern(
         score += min(5.0, pattern.source_material_count / 2)
         score += min(5.0, pattern.source_material_count / 2)
         reasons.append("historical_sample_count_prior")
         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:
     if not feature_texts:
         score -= 15
         score -= 15
         penalties.append("no_video_features")
         penalties.append("no_video_features")
@@ -570,6 +562,10 @@ def _extract_json_object(text: str) -> dict[str, Any]:
     return parsed
     return parsed
 
 
 
 
+def _load_pattern_selector_policy(path: Path = DEFAULT_PATTERN_SELECTOR_PROMPT_PATH) -> str:
+    return path.read_text(encoding="utf-8").strip()
+
+
 def _model_rerank_pattern_selections(
 def _model_rerank_pattern_selections(
     *,
     *,
     feature_texts: Sequence[str],
     feature_texts: Sequence[str],
@@ -605,14 +601,7 @@ def _model_rerank_pattern_selections(
         for item in candidates
         for item in candidates
     ]
     ]
     prompt_payload = {
     prompt_payload = {
-        "task": "从候选广告创意pattern中选择最适合该视频的pattern",
-        "requirements": [
-            "只能从candidate_patterns里选择,不能创造新的pattern_key",
-            "优先选择和视频解构选题/关键点/目的点/灵感点语义最相关的pattern",
-            "目标用户是45岁以上中老年小程序视频产品用户,目标是提高点击和后续观看/裂变",
-            "保留轻悬念/轻猎奇/信息差,但必须规避医疗恐吓、涉政民族对立、名人历史、迷信、低俗两性、假按钮/假官方",
-            "输出必须是JSON,不要输出解释性正文",
-        ],
+        "selection_policy": _load_pattern_selector_policy(),
         "video_features": list(feature_texts),
         "video_features": list(feature_texts),
         "placement": placement,
         "placement": placement,
         "top_k": top_k,
         "top_k": top_k,
@@ -626,7 +615,8 @@ def _model_rerank_pattern_selections(
                     "matched_features": ["命中的视频特征文本"],
                     "matched_features": ["命中的视频特征文本"],
                     "risk_notes": ["需要注意的合规风险,没有则空数组"],
                     "risk_notes": ["需要注意的合规风险,没有则空数组"],
                 }
                 }
-            ]
+            ],
+            "no_match_example": {"selected": []},
         },
         },
     }
     }
     resp = httpx.post(
     resp = httpx.post(
@@ -671,6 +661,8 @@ def _model_rerank_pattern_selections(
             model_score = float(raw.get("score"))
             model_score = float(raw.get("score"))
         except (TypeError, ValueError):
         except (TypeError, ValueError):
             model_score = base.score
             model_score = base.score
+        if model_score < PATTERN_SELECTOR_MIN_MODEL_SCORE:
+            continue
         reason = str(raw.get("reason") or "").strip()
         reason = str(raw.get("reason") or "").strip()
         risk_notes = [str(v) for v in raw.get("risk_notes") or [] if str(v)]
         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)]
         matched = [str(v) for v in raw.get("matched_features") or [] if str(v)]
@@ -683,8 +675,6 @@ def _model_rerank_pattern_selections(
         ))
         ))
         if len(out) >= top_k:
         if len(out) >= top_k:
             break
             break
-    if not out:
-        raise RuntimeError("模型未返回有效pattern_key")
     return out
     return out