上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事
- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段); 修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品 - dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开; settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch - 新增用户管理(users API/admin service/改密页) + alembic 022/023/024 - 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example - A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
_score_prompt.py — AI 评委 prompt(让模型真读文案,不机械找词)
|
||||
评判标准忠于《富贵情绪营销理论》原文(口播一手来源),标实战补充出处。
|
||||
6维满分分布(倩倩姐2026-06-15拍板,与 llm_scorer._DIM_MAX / constants.AI_DIM_WEIGHTS 三处同步):
|
||||
痛点人群精准18 / 情绪张力18 / 买点转化18 / 开头钩子15 / 标题点击力13 / 真实感13 = 95
|
||||
7维满分分布(倩倩姐2026-06-22拍板补北哥要害⑤标签精准·与 llm_scorer._DIM_MAX / constants.AI_DIM_WEIGHTS 三处同步):
|
||||
痛点人群精准16 / 情绪张力16 / 买点转化16 / 开头钩子13 / 标题点击力13 / 真实感11 / 标签精准度10 = 95
|
||||
+ 合规5(机械硬拦,不进AI评委)= 100
|
||||
合格线80不动;新增"标签精准度"是补考核维度,分从原6维各匀出,总分仍95,不是降门槛。
|
||||
"真实感"替换旧"产品聚焦一件事(16)":富贵"很少提产品/前70%干货后30%植入"独立升维。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -18,13 +19,13 @@ SCORER_PERSONA = """你是一位资深小红书内容操盘手,深谙富贵情
|
||||
# ── 6 维评判标准(按权重降序;合规第7维由代码机械硬拦,不在此)────
|
||||
# 标准依据:括号内标注[富贵原文]或[实战补充],前者来自口播一手来源,权威最高。
|
||||
SCORING_DIMENSIONS = """
|
||||
【维度1·痛点人群精准(满分18)】[富贵原文]
|
||||
【维度1·痛点人群精准(满分16)】[富贵原文]
|
||||
判断:"说的就是我"——文案描述的处境/困扰,目标用户读了会不会对号入座。
|
||||
好:具体到某类人的某个真实生活瞬间,让人觉得被看穿,落在"我的大问题/我的处境/我的渴望"上。
|
||||
差:泛泛而谈谁都能套(如"适合所有想变美的女生");或PUA用户、拿别人的惨状吓唬人。
|
||||
依据:富贵"我的大问题→处境→渴望"内容骨架;人群越具体穿透力越强;"用户被宠成爹,你PUA他不好使"。
|
||||
|
||||
【维度2·情绪张力(满分18)】[富贵原文·第一性原理]
|
||||
【维度2·情绪张力(满分16)】[富贵原文·第一性原理]
|
||||
判断:有没有"成果/后果"双向情绪,而不是平铺直叙报卖点。
|
||||
· 后果=过去没用它,产生了什么糟糕处境(勾起恐惧/懊悔)
|
||||
· 成果=用了它之后,会变成什么样(给出期待/乐观)
|
||||
@@ -32,32 +33,43 @@ SCORING_DIMENSIONS = """
|
||||
差:全程客观介绍产品、无情绪、像说明书;或只单向吓唬、或只空喊美好。
|
||||
依据:富贵"营销第一性原理就是情绪,没有情绪什么内容都不转化";"成果是未来、后果是过去,要做这两种情绪"。
|
||||
|
||||
【维度3·买点转化(满分18)】[富贵原文]
|
||||
【维度3·买点转化(满分16)】[富贵原文]
|
||||
判断:产品卖点有没有翻译成用户能感知的场景化利益(人话),而不是品牌视角的功效/参数。
|
||||
好:用户能想象到的使用场景和结果(如"出门前最后一步、同事问我今天气色真好")。
|
||||
好:用户能想象到的使用场景和结果(如"出门前最后一步搞定、被身边人夸最近状态真好")。
|
||||
差:品牌语言/参数罗列(如"采用XX技术""含XX成分"),用户无感。
|
||||
依据:富贵"卖点是品牌视角、买点是用户视角";"用户买的不是产品,是使用场景背后被解决的问题"。
|
||||
|
||||
【维度4·开头钩子(满分15)】[富贵原文]
|
||||
【维度4·开头钩子(满分13)】[富贵原文]
|
||||
判断:开头能不能让人停下来、想继续读。
|
||||
好:开头第一句就直击用户的大问题/痛处/具体场景代入,每句都打要害。
|
||||
差:开头是套话或铺垫半天不进正题,没有任何抓人的点。
|
||||
依据:富贵"内容就是锋利的刀,一定要插用户的心窝子"。
|
||||
|
||||
【维度5·标题点击力(满分13)】[富贵原文+实战补充]
|
||||
判断:标题有没有让目标用户想点的诱因。
|
||||
好:标题带具体人群/场景/痛点/情绪钩子,一眼觉得"和我有关、我想看",最好来自用户真实说法。
|
||||
差:只有产品名、平淡无钩子、或太像广告。
|
||||
依据:富贵"热评就是标题,从真实评论里抓用户的话"[原文];标题善用痛点/人群/效果/情绪词[实战补充]。
|
||||
【维度5·标题点击力(满分13)】[富贵原文+实战补充+北哥要害①]
|
||||
判断:标题有没有让目标用户想点的诱因,且覆盖面够不够广。
|
||||
好:标题带悬念/反差/疑问钩子让人想点,同时"广覆盖"罩住尽量多的人(不一上来就锁死单一窄人群),精准筛人交给标签层。
|
||||
差:只有产品名、平淡无钩子、太像广告;或标题一上来就锁死单一窄人群(如只喊某一类人把其他人挡门外),覆盖面太窄。
|
||||
依据:富贵"热评就是标题,从真实评论里抓用户的话"[原文];北哥实战"标题广覆盖+悬念钩子,精准靠标签不靠标题筛人"[要害①]。
|
||||
|
||||
【维度6·真实感(满分13)】[富贵原文]
|
||||
【维度6·真实感(满分11)】[富贵原文]
|
||||
判断:整条文案是不是像真人分享,而不是品牌广告或功效说明书。
|
||||
好:价值/场景/感受为主,产品自然带出;前段是干货或真实经历,后段才软性推荐;不开头就报规格价格。
|
||||
差:通篇硬广、产品功效罗列当主体、开头就卖、语气像文案模板而非真实人设。
|
||||
依据:富贵"我们很少提产品";"前70%是价值/场景,后30%才是产品";用户能感受到"这不像广告"。
|
||||
好:价值/场景/感受为主,产品自然带出;前段是干货或真实经历,后段才软性推荐;不开头就报规格价格;种草借第三方视角(闺蜜/同事/被人问)而非自夸回购。
|
||||
差:通篇硬广、产品功效罗列当主体、开头就卖、语气像文案模板;或全程自卖自夸"我回购了N次"广告味重。
|
||||
依据:富贵"我们很少提产品";"前70%是价值/场景,后30%才是产品";北哥要害③"第三方闺蜜真实背书胜过自夸"。
|
||||
|
||||
【维度7·标签精准度(满分10)】[北哥要害⑤·出单命门]
|
||||
判断:tags 是不是"有明确购买意向的人才会搜"的精准埋词,而不是凑流量的泛词。
|
||||
好:目标人群+品类+核心卖点/场景组合成的精准长尾标签(搜它的人就是想买这类的人),至少2-3个精准埋词。
|
||||
差:清一色"#好物分享 #真实测评 #种草"这类谁都能用、购买意向弱的泛流量大词,搜来的人不精准。
|
||||
扣分细则(必须逐个标签判定,不许整体放水):把每个 tag 归为"精准埋词"或"泛词"。
|
||||
泛词=品类大词/平台流量词/无人群无卖点的口号词,如 #好物分享 #平价好物 #种草 #真实测评 #必入清单。
|
||||
精准埋词=含明确人群或场景或具体痛点卖点,搜它的人就是目标买家,如 #久坐党护腰好物 #通勤提神咖啡。
|
||||
评分上限:泛词占比≥50% 最高只能给6分;占比1/3左右给7-8分;几乎全是精准埋词才给9-10分。
|
||||
reason 里必须点名哪几个是泛词、哪几个是精准埋词,不能只给结论。
|
||||
依据:北哥实战"精准购买意向标签埋词是出单命门,泛词价值低、精准长尾才带来真买家"[要害⑤]。
|
||||
""".strip()
|
||||
|
||||
# ── 真实感已升为维度6(满分13),此处保留空字符串避免调用方引用报错 ──
|
||||
# ── 真实感已升为维度6(满分11),此处保留空字符串避免调用方引用报错 ──
|
||||
REALNESS_NOTE = "" # 升为 SCORING_DIMENSIONS 维度6,不再重复附加
|
||||
|
||||
# ── 输出格式约束 ──────────────────────────────────────────
|
||||
@@ -65,17 +77,20 @@ SCORER_OUTPUT_FORMAT = """
|
||||
读完这条文案后,严格返回纯JSON对象(不要markdown代码块、不要多余文字),格式:
|
||||
{
|
||||
"dims": [
|
||||
{"item":"痛点人群精准","score":<0-18整数>,"reason":"<针对本条的具体理由,30字内>"},
|
||||
{"item":"情绪张力","score":<0-18>,"reason":"..."},
|
||||
{"item":"买点转化","score":<0-18>,"reason":"..."},
|
||||
{"item":"开头钩子","score":<0-15>,"reason":"..."},
|
||||
{"item":"痛点人群精准","score":<0-16整数>,"reason":"<针对本条的具体理由,30字内>"},
|
||||
{"item":"情绪张力","score":<0-16>,"reason":"..."},
|
||||
{"item":"买点转化","score":<0-16>,"reason":"..."},
|
||||
{"item":"开头钩子","score":<0-13>,"reason":"..."},
|
||||
{"item":"标题点击力","score":<0-13>,"reason":"..."},
|
||||
{"item":"真实感","score":<0-13>,"reason":"..."}
|
||||
{"item":"真实感","score":<0-11>,"reason":"..."},
|
||||
{"item":"标签精准度","score":<0-10>,"reason":"..."}
|
||||
],
|
||||
"verdict":"<优秀|合格|不合格>",
|
||||
"summary":"<一句话总评,说清这条最大的优点和最该改的点>"
|
||||
}
|
||||
打分要敢拉开差距:平庸文案该给中低分,不要清一色高分;优秀的地方也别吝啬给高分。
|
||||
⚠️只评上面7个维度。合规/违禁词由系统机械检测,禁止你输出"合规性/违禁词/合规"等任何合规相关维度,
|
||||
也不要因为你主观觉得某词敏感而压分——"不假白/没那么黄/提气色"这类是合规的感受描述,不是功效违禁词。
|
||||
""".strip()
|
||||
|
||||
# 合规维度满分(机械硬拦,不进 AI 评委)
|
||||
@@ -86,9 +101,11 @@ AI_DIMS_MAX = 95
|
||||
|
||||
|
||||
def build_score_prompt(copy: dict, product: dict | None = None) -> str:
|
||||
"""组装单条文案的评委 prompt。copy={title,content,...},product 提供品牌/品类语境。"""
|
||||
"""组装单条文案的评委 prompt。copy={title,content,tags,...},product 提供品牌/品类语境。"""
|
||||
title = str(copy.get("title", "")).strip()
|
||||
content = str(copy.get("content", "")).strip()
|
||||
tags_raw = copy.get("tags") or []
|
||||
tags = " ".join(str(t).strip() for t in tags_raw if str(t).strip()) or "(本条未给标签)"
|
||||
ctx = ""
|
||||
if product:
|
||||
name = product.get("name") or product.get("title") or ""
|
||||
@@ -99,6 +116,6 @@ def build_score_prompt(copy: dict, product: dict | None = None) -> str:
|
||||
ctx = "【产品语境】\n" + "\n".join(bits) + "\n\n"
|
||||
return (
|
||||
f"{SCORING_DIMENSIONS}\n\n"
|
||||
f"{ctx}【待评文案】\n标题:{title}\n正文:{content}\n\n"
|
||||
f"{ctx}【待评文案】\n标题:{title}\n正文:{content}\n标签:{tags}\n\n"
|
||||
f"{SCORER_OUTPUT_FORMAT}"
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ def _cat_words(category: str) -> list[str]:
|
||||
def score_title(title: str, cat_words: list[str], w: dict) -> dict:
|
||||
ts = 0
|
||||
if 6 <= len(title) <= 34: ts += 8
|
||||
if re.search(r"[0-9一二三四五六七八九十几]|最近|这支|这个|每天|早八|学生党|新手|宝妈|懒人|伪素颜", title): ts += 5
|
||||
if re.search(r"[0-9一二三四五六七八九十几]|最近|这款|这个|每天|早八|学生党|新手|宝妈|懒人", title): ts += 5
|
||||
if _has_any(title, _SCENE_WORDS): ts += 6
|
||||
if _has_any(title, cat_words): ts += 5
|
||||
if re.search(r"[!!??]|救星|不踩雷|闭眼入|会回购|被问|惊喜|加分|常备|省心|实用|别乱选|放心|有气色", title): ts += 6
|
||||
|
||||
@@ -15,35 +15,38 @@ _PERSONA = """你是一个日常生活分享博主,不是品牌推广号。
|
||||
比例:70% 写生活场景和使用感受,30% 提产品,绝不颠倒。
|
||||
收尾铁律:每条结尾方式必须不同,禁止使用"东西放这了/买不买跟我没关系"这类被用滥的固定句式。"""
|
||||
|
||||
# ── Q1 随机变量池 ABC(反同质化核心,每次随机抽组合,N条不撞)──────────────
|
||||
# 方法层:框架固定,内容可扩展,绝不写死
|
||||
_POOL_A_IDENTITY: list[str] = [
|
||||
"上班族早八妆前随手抹",
|
||||
"宿舍懒人护肤三分钟搞定",
|
||||
"敏感肌妈妈哄完娃才有五分钟",
|
||||
"学生党第一次用高价护肤品",
|
||||
"素颜出门前最后一步",
|
||||
# ── Q1 叙事切入配方(反同质化核心·北哥四象限人群法,全品类通用不写死)────────
|
||||
# 方法层:只给"切入维度类型",具体人群/场景由模型按本产品卖点+目标人群填充
|
||||
# 北哥四象限:场景/习惯/适配/时机——主人群维度按条轮转,保证N条开篇不撞
|
||||
_AUDIENCE_DIMENSIONS: list[str] = [
|
||||
"使用场景人群(按该产品最高频的使用场景圈定一类人)",
|
||||
"使用习惯人群(懒人省心/进阶讲究等按习惯分的一类人)",
|
||||
"适配特质人群(该产品特别适配的某类特质或需求人群)",
|
||||
"使用时机人群(按使用时间/阶段/契机圈定的一类人)",
|
||||
"性价比人群(看重划算、对价格敏感的一类人)",
|
||||
]
|
||||
|
||||
_POOL_B_EMOTION: list[str] = [
|
||||
"看到镜子里发现气色暗了一周",
|
||||
"闺蜜问你最近皮肤怎么这么好",
|
||||
"出门被催快点根本来不及叠瓶",
|
||||
# 情绪触发类型(通用,模型按产品填具体内容)
|
||||
_EMOTION_TRIGGERS: list[str] = [
|
||||
"被身边人提醒或夸到才注意到的瞬间",
|
||||
"自己用的时候突然意识到的变化",
|
||||
"对比之前踩过的雷或走过的弯路",
|
||||
]
|
||||
|
||||
_POOL_C_FLAW: list[str] = [
|
||||
"第一次用量太少了没啥感觉",
|
||||
"包装简单到以为是山寨",
|
||||
"价格摆在那以为会很油很厚",
|
||||
# 真实小波折类型(增可信,通用不写死品类)
|
||||
_REAL_FLAWS: list[str] = [
|
||||
"一开始用法没掌握或用量不对",
|
||||
"第一眼对包装或价格有点误解",
|
||||
"起效没想象中快、用了阵子才感觉到",
|
||||
]
|
||||
|
||||
|
||||
def _pick_combo() -> dict[str, str]:
|
||||
"""随机抽一组 ABC 变量(每次生成调用一次,N条各不相同)"""
|
||||
def _pick_combo(index: int = 0) -> dict[str, str]:
|
||||
"""抽一组通用叙事切入配方(维度类型非写死内容);主人群维度按index轮转,N条不撞"""
|
||||
return {
|
||||
"identity": random.choice(_POOL_A_IDENTITY),
|
||||
"emotion": random.choice(_POOL_B_EMOTION),
|
||||
"flaw": random.choice(_POOL_C_FLAW),
|
||||
"identity": _AUDIENCE_DIMENSIONS[index % len(_AUDIENCE_DIMENSIONS)],
|
||||
"emotion": random.choice(_EMOTION_TRIGGERS),
|
||||
"flaw": random.choice(_REAL_FLAWS),
|
||||
}
|
||||
|
||||
|
||||
@@ -61,30 +64,34 @@ _EMOJI_RULES = """
|
||||
- 正文段落可点缀少量 emoji 烘托情绪(如 🥹 😭 🤍 💛),每段最多1-2个,不堆砌
|
||||
- 结尾话题标签前后带表情,如 "#好物分享 🛒"
|
||||
- emoji 服务情绪和分点,不要每句都加;整条正文 emoji 总量控制在 6-12 个
|
||||
- 常用小红书 emoji 池:✅✨🌿💧🪞🧴📦🔍💛🤍🥹😭🛒(按语义选,不乱用)
|
||||
- 常用小红书 emoji 池:✅✨🌿💧📦🔍💛🤍🥹😭🛒💯(按语义选,不乱用)
|
||||
""".strip()
|
||||
|
||||
# ── Q2 5步框架 + Q3 四段结构 ─────────────────────────────────────────────
|
||||
_STRUCTURE_RULES = """
|
||||
【5步框架(必须严格遵循)】
|
||||
① Hook暴击低价/痛点:第一句戳中场景或价格锚点,吊足读者好奇
|
||||
② 痛点共鸣:2-3句描写使用前的真实困境(用上面抽到的起因情绪A·B)
|
||||
② 痛点共鸣:2-3句描写使用前的真实困境(结合本条的情绪触发,并按北哥要害②召集多类人)
|
||||
③ 救星登场:自然带出产品,口吻是"碰巧发现/朋友安利/囤货时顺手",不是"推荐给你"
|
||||
④ 卖点罗列(每条加✅小标题):3条以内,卖点翻译成使用感受,不是功效列表
|
||||
⑤ 收尾(每条必须从下方策略池随机选一种,同批次不得重复同一种,禁止"东西放这了/买不买跟我没关系"此类固定句式):
|
||||
【收尾策略池·每条选不同策略】
|
||||
A·留白式感受:只说自己现在的状态,不提买不买,如"反正现在素颜出门我不慌了"
|
||||
B·反问读者:把感受抛回给读者,如"你们护肤有没有那种一用就回不去的东西?"
|
||||
C·场景延续:把故事延伸到未来某个细节,如"下次同事再问我皮肤的事我就知道说啥了"
|
||||
D·克制回购暗示:轻描淡写说自己行为,如"第一罐用完了,已经在备第二罐"
|
||||
A·留白式感受:只说自己现在的状态,不提买不买,如"反正现在出门我都带着它,心里踏实多了"
|
||||
B·反问读者:把感受抛回给读者,如"你们有没有那种一用就回不去的小东西?"
|
||||
C·场景延续:把故事延伸到未来某个细节,如"下次再有人问起,我就知道该推荐啥了"
|
||||
D·克制回购暗示:轻描淡写说自己行为,如"第一份用完了,已经在备第二份"
|
||||
E·纯记录收笔:像日记最后一句,不引导不评价,如"大概就这样,记录一下"
|
||||
F·引导搜索(仅在有品牌词时使用):自然提一句,如"感兴趣可以搜搜『{品牌词}』",不催单
|
||||
|
||||
【正文四段结构(必须)】
|
||||
段1·痛点引入:描写使用前的困境/触发场景(身份场景A·起因情绪B)
|
||||
段2·实测记录:真实使用过程,带上小缺点C(真实感来源)
|
||||
段3·种草核心:产品带来的变化,用感受描述而非功效声称
|
||||
段4·引导收尾:从收尾策略池随机选一种,佛系口吻,不强推,末尾带1-2个相关话题标签
|
||||
【正文叙事骨架(开篇方式由本条叙事主线决定,禁止三套同一开头)】
|
||||
段1·开篇分叉(按本条叙事主线三选一,这是三套拉开差异的关键):
|
||||
- 痛点先行→开篇即戳使用前的困境/情绪(结合主切入人群维度,按要害②召集多类人)
|
||||
- 场景先行→开篇先白描一个真实高频使用场景,痛点在场景里自然带出,不喊口号
|
||||
- 成分背书先行→开篇用成分原理/测评对比视角切入("为什么有用/亲测对比"),痛点后置
|
||||
段2·实测记录:真实使用过程,带上本条的真实小波折(真实感来源),用第三方视角背书(北哥要害③)
|
||||
段3·种草核心:产品带来的变化,用感受描述而非功效声称,核心成分逐条对应痛点(北哥要害④)
|
||||
段4·引导收尾:从收尾策略池随机选一种,佛系口吻,不强推,末尾带精准购买意向标签(北哥要害⑤)
|
||||
※ 卖点罗列的措辞/顺序/emoji 三套之间禁止雷同;身边人种草/被夸被问这类桥段三套不得共用同一套。
|
||||
|
||||
【字数】正文350-400字(不含标题tags),3-5处段落空行增强可读性
|
||||
""".strip()
|
||||
@@ -92,23 +99,46 @@ _STRUCTURE_RULES = """
|
||||
# ── Q8 标题公式(5类结构,每批次覆盖不同类型,不重复)──────────────────────
|
||||
_TITLE_FORMULA = """
|
||||
【标题公式(5类,每条用不同类型,禁止同批重复)】
|
||||
肤质型:「{肤质}+用了{产品}+{感受}」例→"油皮用了素颜霜整个夏天不脱妆"
|
||||
价格型:「{价格锚点}+{产品}+{功效感受}」例→"三位数买到大牌平替,用完第一罐回购第二罐"
|
||||
功效型:「{使用场景}+{产品}+{可感知变化}」例→"早起素颜出门靠这罐,同事问我最近皮肤怎么了"
|
||||
夸张型:「疑问/感叹+{夸张感受}+{产品}」例→"这什么神奇产品,涂上去感觉素颜也能出门了"
|
||||
标题党型:「{反常识/意外信息}+真相是{翻转}」例→"被人说皮肤变好了,没说的是我用了它一个月"
|
||||
特征型:「{适配特征}+用了{产品}+{感受}」例→"久坐党用了这把椅子,腰一整天不酸了"
|
||||
价格型:「{价格锚点}+{产品}+{效果感受}」例→"三位数买到大牌平替,用完第一份就回购"
|
||||
场景型:「{使用场景}+{产品}+{可感知变化}」例→"通勤路上靠这杯,到工位整个人都醒了"
|
||||
夸张型:「疑问/感叹+{夸张感受}+{产品}」例→"这什么神仙东西,用一次就回不去了"
|
||||
标题党型:「{反常识/意外信息}+真相是{翻转}」例→"被夸最近状态好,没说的是我换了它一个月"
|
||||
硬性约束:标题≤20字;禁止出现"绝绝子/YYDS/杀疯了";不直接写功效词(美白/祛斑等)。
|
||||
硬性约束·标题必须有具体锚点:禁止"有点反差/真的绝/太惊艳/有点东西"这类空泛无信息量的标题,
|
||||
必须落到一个具体的人群/场景/数字/反差细节上(读者扫一眼就知道讲什么、关我什么事)。
|
||||
""".strip()
|
||||
_ANTI_AI = f"""
|
||||
【反AI味必须遵守】
|
||||
- 禁止固定开篇套话:不许"姐妹们/宝子们/今天给大家分享"开头
|
||||
- 禁止以下AI味词出现在正文:{_NEGATIVE_WORDS}
|
||||
- 禁止人群重叠:{'{count}'}条文案中身份场景不能重复(靠变量池ABC保证)
|
||||
- 禁止场景重复:同批次文案不能都是"早上上班"或都是"学生宿舍"
|
||||
- 禁止人群重叠:{'{count}'}条文案主切入人群维度不能重复(靠Q1切入配方按条轮转保证)
|
||||
- 禁止场景重复:同批次文案开篇场景不能雷同(不能N条都挤在同一个使用场景)
|
||||
- 避免生硬推销词:按头安利/绝绝子/闭眼冲 不能出现
|
||||
""".strip()
|
||||
|
||||
# ── 系统 prompt(合并Q2/Q3/Q4/Q7/Q8,数据层走build_prompt动态注入)──────────
|
||||
# ── 北哥出单强化层·五大要害(实测出单命门,与富贵框架叠加,冲突以本层为准)──
|
||||
_BEIGE_FIVE = """
|
||||
【北哥出单强化层·五大要害(最高优先级,与上面框架叠加,冲突时以本层为准)】
|
||||
要害①·标题广覆盖+悬念钩子:标题要罩住尽量多的人,不要一上来就锁死单一窄人群
|
||||
(只喊一类窄人群会把其他潜在人群挡在外面);用悬念/反差/疑问做钩子留个扣子让人想点进来;
|
||||
精准筛人交给标签层,不靠标题筛人。
|
||||
要害②·开篇召集痛点:正文要把这个产品能解决的痛点尽量多列几个、召集多类人
|
||||
("不管你是…还是…"的覆盖感),让更多人对号入座,不要只写一类人的一个痛点。
|
||||
注意:召集痛点不必都堆在第一句——开篇方式由本条的叙事主线决定(见下方叙事主线),
|
||||
痛点先行的可开篇即戳痛点,场景先行/成分背书先行的则在叙事展开中自然召集多类人,
|
||||
绝不能因为这条要害就把三套都写成同一个"赶时间/痛点"开头。
|
||||
要害③·第三方闺蜜真实背书:种草借第三方视角(闺蜜/同事/家人说、被人问、别人推荐),
|
||||
是"别人替你证言"的真实感,不是自己反复夸自己回购多少次;自夸味=广告味=掉信任。
|
||||
要害④·成分逐条对应痛点:每个核心成分都明确对上它解决的那个痛点(成分1→痛点1,成分2→痛点2),
|
||||
逐条映射讲清"为什么有用",翻译成用户能感知的结果,不堆术语、不把成分名堆在一起不解释。
|
||||
要害⑤·精准购买意向标签埋词:tags 不要凑流量泛词(#好物分享#真实测评 这类谁都能用的大词价值低),
|
||||
要埋"有明确购买意向的人才会搜"的精准词——目标人群+品类+核心卖点/场景组合成精准长尾标签,
|
||||
至少2-3个精准埋词。
|
||||
这5条是北哥实测出单的命门,务必逐条命中。
|
||||
""".strip()
|
||||
|
||||
# ── 系统 prompt(合并Q2/Q3/Q4/Q7/Q8 + 北哥五段,数据层走build_prompt动态注入)──
|
||||
COPY_SYSTEM = f"""{_PERSONA}
|
||||
|
||||
合规红线(全品类通用):
|
||||
@@ -118,6 +148,8 @@ COPY_SYSTEM = f"""{_PERSONA}
|
||||
|
||||
{_ANTI_AI.format(count="N")}
|
||||
|
||||
{_BEIGE_FIVE}
|
||||
|
||||
{_TITLE_FORMULA}
|
||||
|
||||
{_EMOJI_RULES}
|
||||
@@ -178,30 +210,42 @@ def build_prompt(product: dict, count: int, extra_rules: str = "",
|
||||
custom = (product.get("custom_prompt") or "").strip()
|
||||
brand_kw = (product.get("brand_keyword") or "").strip()
|
||||
|
||||
# Q1:每条抽一组随机变量,传给模型作角色约束
|
||||
combos = [_pick_combo() for _ in range(count)]
|
||||
# Q1:每条按index轮转主人群维度+随机情绪/波折,传给模型作切入约束
|
||||
combos = [_pick_combo(i) for i in range(count)]
|
||||
combos_text = "\n".join(
|
||||
f" 第{i+1}条:身份场景=「{c['identity']}」·起因情绪=「{c['emotion']}」·小缺点=「{c['flaw']}」"
|
||||
f" 第{i+1}条:主切入人群维度=「{c['identity']}」·情绪触发=「{c['emotion']}」·真实小波折=「{c['flaw']}」"
|
||||
for i, c in enumerate(combos)
|
||||
)
|
||||
|
||||
angle_hint = f"文案角度要覆盖:{'、'.join(angles)}(每条用不同角度)。" if angles else ""
|
||||
brand_rule = f"每条正文和标题中植入品牌词「{brand_kw}」一次(自然融入,不生硬)。" if brand_kw else ""
|
||||
brand_rule = (
|
||||
f"每条正文中自然植入品牌词「{brand_kw}」至少一次(融入语境,不生硬)。"
|
||||
f"标题以悬念/钩子优先(北哥要害①),品牌词不必硬塞进标题;"
|
||||
f"若要进标题须自然不抢戏,禁止把品牌词堆在标题开头当详情页商品名(如'{brand_kw}{name}'式)。"
|
||||
) if brand_kw else ""
|
||||
benchmark_block = _build_benchmark_block(product.get("benchmark_refs") or [])
|
||||
|
||||
# strategy_narrative 是三套拉开差异的核心,加强标记顶到最高权重
|
||||
narrative_block = (
|
||||
f"\n========== 本套叙事主线(最高优先级·决定开篇与全文走向,禁止偏离)==========\n"
|
||||
f"{strategy_narrative}\n"
|
||||
f"==============================================================\n"
|
||||
if strategy_narrative else ""
|
||||
)
|
||||
|
||||
lines = [
|
||||
f"产品:{name}",
|
||||
f"核心卖点(必须翻译成用户能感知的生活化利益,禁止直接列功效词;翻译范例:'烟酰胺'→'熬夜后第二天脸不那么黄了','高保湿'→'涂上去一整天都没搓泥拔干'):{selling}",
|
||||
f"核心卖点(必须翻译成用户能感知的生活化利益,禁止直接列功效/参数词;翻译范例:技术参数'XX配方/XX材质'→'用起来的某个具体好处','大容量'→'出门一整天都不用补'):{selling}",
|
||||
f"风格调性:{style}",
|
||||
strategy_narrative,
|
||||
narrative_block,
|
||||
angle_hint,
|
||||
brand_rule,
|
||||
custom,
|
||||
benchmark_block,
|
||||
f"\n【Q1随机变量池·每条身份/起因/小缺点各不相同,严格按下方分配使用】",
|
||||
f"\n【Q1切入配方·每条主切入人群维度不同,模型按本产品把维度填成具体人群】",
|
||||
combos_text,
|
||||
extra_rules,
|
||||
f"\n请严格按5步框架+四段结构生成 {count} 条,每条350-400字,返回纯JSON数组。",
|
||||
f"\n请严格按5步框架+叙事骨架生成 {count} 条,开篇方式必须服从上方叙事主线,每条350-400字,返回纯JSON数组。",
|
||||
]
|
||||
return "\n".join(l for l in lines if l)
|
||||
|
||||
@@ -228,15 +272,15 @@ def build_local_drafts(product: dict, count: int) -> list[dict]:
|
||||
_fallback_angles = ["生活场景型", "成分分析型", "使用感受型", "性价比型", "痛点切入型"]
|
||||
angles_pool = product.get("text_angles") or _fallback_angles
|
||||
for i in range(count):
|
||||
c = _pick_combo()
|
||||
c = _pick_combo(i)
|
||||
# 循环取不同角度(角度相同的两条会被 dedupe_copies 过滤掉,所以必须不重复)
|
||||
angle = angles_pool[i % len(angles_pool)]
|
||||
yield {
|
||||
"title": f"发现一个{name}的{angle}用法",
|
||||
"content": (
|
||||
f"{c['identity']},{c['emotion']}。\n\n"
|
||||
f"最近开始用{name},{c['emotion']}。\n\n"
|
||||
f"用了一段时间,{points[i % len(points)]}这点最让我意外。\n\n"
|
||||
f"说个小缺点:{c['flaw']},后来才摸到感觉。\n\n"
|
||||
f"说个小波折:{c['flaw']},后来才摸到感觉。\n\n"
|
||||
f"反正现在用顺手了。✅"
|
||||
),
|
||||
"tags": [f"#{name}", "#真实测评", "#好物分享"],
|
||||
|
||||
121
backend/app/services/ai_engine/beige_color_grade.py
Normal file
121
backend/app/services/ai_engine/beige_color_grade.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
北哥醒图调色层(去AI化·暖棕奶茶色调)
|
||||
来源:北哥 v2 教程 f_010 醒图参数(倩倩姐2026-06-15录入),Pillow 近似实现。
|
||||
参数:色温+28(暖棕灵魂)/高光-35(压瓶身反光)/阴影+22/对比+9/饱和+6/暗角-10/HSL橙肤色。
|
||||
开关:环境变量 BEIGE_COLOR_GRADE,默认开(=1);off 则原样返回不调色。
|
||||
诚实声明:醒图是分区调整,Pillow 只能做全局近似,方向一致、强度可参数化微调。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageEnhance
|
||||
_PILLOW_OK = True
|
||||
except ImportError:
|
||||
_PILLOW_OK = False
|
||||
|
||||
# 北哥醒图默认参数(归一化到 Pillow 可用范围;±5 浮动由调用方覆盖)
|
||||
# 数值含义见模块 docstring;强度系数经验值,落地后按实拍微调
|
||||
BEIGE_PRESET: dict[str, float] = {
|
||||
"warmth": 28.0, # 色温(暖棕灵魂,口诀+25~30) → R提/B压
|
||||
"highlight": -35.0, # 高光(压瓶身反光,固定压暗高光区)
|
||||
"shadow": 22.0, # 阴影(提暗部)
|
||||
"contrast": 9.0, # 对比度
|
||||
"saturation": 6.0, # 饱和(别高=廉价感)
|
||||
"vignette": 10.0, # 暗角(聚焦中间,越大四角越暗)
|
||||
}
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""北哥调色开关,默认关(倩倩姐2026-06-22拍板)。设 BEIGE_COLOR_GRADE=1 重开。
|
||||
|
||||
关闭理由:北哥醒图 warmth=28 是给"手机翻拍真实照"去AI化用的;
|
||||
gpt-image-2 成片本身已渲染好色调,再叠暖色温→脏黄发闷(实测R-B黄偏11→14.2)。
|
||||
去AI化靠 SynthID 破除+重编码即可,调色这层在AI成片上帮倒忙。
|
||||
本模块代码保留,需要时设 BEIGE_COLOR_GRADE=1 即可重开调参。
|
||||
"""
|
||||
return os.environ.get("BEIGE_COLOR_GRADE", "0") == "1"
|
||||
|
||||
|
||||
def _apply_warmth(img: "Image.Image", warmth: float) -> "Image.Image":
|
||||
"""色温:暖棕奶茶调灵魂。R通道提、B通道压,强度由 warmth 控制。"""
|
||||
if abs(warmth) < 1:
|
||||
return img
|
||||
k = warmth / 100.0 # 28 → 0.28
|
||||
r, g, b = img.split()
|
||||
r = r.point(lambda v: min(255, int(v * (1 + 0.28 * k))))
|
||||
b = b.point(lambda v: max(0, int(v * (1 - 0.30 * k))))
|
||||
g = g.point(lambda v: min(255, int(v * (1 + 0.05 * k)))) # 微提绿保肤色不偏品红
|
||||
return Image.merge("RGB", (r, g, b))
|
||||
|
||||
|
||||
def _apply_high_shadow(img: "Image.Image", highlight: float, shadow: float) -> "Image.Image":
|
||||
"""高光压暗(压反光) + 阴影提亮(提暗部),按像素亮度分区近似。"""
|
||||
if abs(highlight) < 1 and abs(shadow) < 1:
|
||||
return img
|
||||
hl = highlight / 100.0 # -35 → -0.35
|
||||
sh = shadow / 100.0 # 22 → 0.22
|
||||
|
||||
def _curve(v: int) -> int:
|
||||
t = v / 255.0
|
||||
# 高光区(t>0.6)按 hl 压暗;阴影区(t<0.4)按 sh 提亮;中间过渡
|
||||
if t > 0.6:
|
||||
v2 = t + hl * (t - 0.6) / 0.4 * t
|
||||
elif t < 0.4:
|
||||
v2 = t + sh * (0.4 - t) / 0.4 * (1 - t)
|
||||
else:
|
||||
v2 = t
|
||||
return max(0, min(255, int(v2 * 255)))
|
||||
|
||||
lut = [_curve(i) for i in range(256)]
|
||||
return img.point(lut * 3)
|
||||
|
||||
|
||||
def _apply_vignette(img: "Image.Image", strength: float) -> "Image.Image":
|
||||
"""暗角:四角压暗聚焦中间。strength 越大越暗。"""
|
||||
if strength < 1:
|
||||
return img
|
||||
w, h = img.size
|
||||
s = strength / 100.0 # 10 → 0.10
|
||||
mask = Image.new("L", (w, h), 0)
|
||||
px = mask.load()
|
||||
cx, cy = w / 2.0, h / 2.0
|
||||
maxd = (cx ** 2 + cy ** 2) ** 0.5
|
||||
# 降采样算遮罩省时:每4px算一次
|
||||
step = 4
|
||||
for y in range(0, h, step):
|
||||
for x in range(0, w, step):
|
||||
d = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5 / maxd
|
||||
darken = int(255 * s * (d ** 2))
|
||||
for dy in range(step):
|
||||
for dx in range(step):
|
||||
if x + dx < w and y + dy < h:
|
||||
px[x + dx, y + dy] = darken
|
||||
black = Image.new("RGB", (w, h), (0, 0, 0))
|
||||
return Image.composite(black, img, mask)
|
||||
|
||||
|
||||
def grade(image_bytes: bytes, preset: dict | None = None) -> bytes:
|
||||
"""对单张图施加北哥暖棕调色。开关关或Pillow缺失则原样返回,失败降级返原图。"""
|
||||
if not is_enabled() or not _PILLOW_OK:
|
||||
return image_bytes
|
||||
p = {**BEIGE_PRESET, **(preset or {})}
|
||||
try:
|
||||
img = Image.open(__import__("io").BytesIO(image_bytes)).convert("RGB")
|
||||
img = _apply_warmth(img, p["warmth"])
|
||||
img = _apply_high_shadow(img, p["highlight"], p["shadow"])
|
||||
if p["contrast"]:
|
||||
img = ImageEnhance.Contrast(img).enhance(1 + p["contrast"] / 100.0)
|
||||
if p["saturation"]:
|
||||
img = ImageEnhance.Color(img).enhance(1 + p["saturation"] / 100.0)
|
||||
img = _apply_vignette(img, p["vignette"])
|
||||
buf = __import__("io").BytesIO()
|
||||
img.save(buf, format="JPEG", quality=100, subsampling=0, optimize=True)
|
||||
return buf.getvalue()
|
||||
except Exception as exc:
|
||||
logger.warning("北哥调色失败,降级返回原图: %s", exc)
|
||||
return image_bytes
|
||||
@@ -28,16 +28,17 @@ SCORE_WEIGHTS = {
|
||||
"keyword": 20,
|
||||
"compliance": 5,
|
||||
}
|
||||
# ── AI 评委 7 维满分分布(倩倩姐2026-06-15拍板·与 llm_scorer._DIM_MAX/_score_prompt 三处同步)──
|
||||
# 6维AI读分(痛点18+情绪18+买点18+钩子15+标题13+真实感13=95) + 合规5 = 100
|
||||
# "真实感"=富贵"很少提产品/前70%干货后30%植入"原则,替换旧机械维度"产品聚焦一件事(16)"
|
||||
# ── AI 评委 7 维满分分布(倩倩姐2026-06-22拍板补标签精准·与 llm_scorer._DIM_MAX/_score_prompt 三处同步)──
|
||||
# 7维AI读分(痛点16+情绪16+买点16+钩子13+标题13+真实感11+标签精准10=95) + 合规5 = 100
|
||||
# 标签精准度=北哥要害⑤出单命门(精准购买意向埋词),分从原6维各匀出,总分仍95,合格线80不动非降门槛
|
||||
AI_DIM_WEIGHTS = {
|
||||
"痛点人群精准": 18,
|
||||
"情绪张力": 18,
|
||||
"买点转化": 18,
|
||||
"开头钩子": 15,
|
||||
"痛点人群精准": 16,
|
||||
"情绪张力": 16,
|
||||
"买点转化": 16,
|
||||
"开头钩子": 13,
|
||||
"标题点击力": 13,
|
||||
"真实感": 13,
|
||||
"真实感": 11,
|
||||
"标签精准度": 10,
|
||||
"compliance": 5, # 机械硬拦,不进 AI 评委
|
||||
}
|
||||
# 过线分。倩倩姐2026-06-15拍板:80是临时观察值(AI评委给分克制,84文案实为合格)。
|
||||
@@ -54,13 +55,13 @@ DEDUP_TITLE_CONTENT_BODY = 0.72 # 标题+正文联合判重时的正文阈值
|
||||
MAX_OPTIMIZE_ROUNDS = 2 # 最多重生成轮次
|
||||
|
||||
# ── storyboard 分镜角色(枚举不写死数量)────────────────
|
||||
# Q6: 北哥6张套路顺序 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单
|
||||
# Q6: 北哥6张套路顺序 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤效果证明(按品类) ⑥促单
|
||||
PAGE_ROLES = [
|
||||
{"role": "hook", "name": "封面痛点大字", "focus": "负责点击:强情绪大字标题压痛点,产品露出,真实生活场景,像用户主动分享,不像广告海报"},
|
||||
{"role": "product_closeup", "name": "单品特写", "focus": "负责种草锚点:单品高清特写+品牌词自然植入,第2/6张都带品牌词,强化记忆"},
|
||||
{"role": "ingredient", "name": "成分拆解", "focus": "负责信任:核心成分信息、作用说明,避免医疗化和绝对化表达,信息清晰可信"},
|
||||
{"role": "texture", "name": "质地展示", "focus": "负责种草:质地近景、涂抹过程、肤感说明,真实手部/桌面/日常光线"},
|
||||
{"role": "applied_proof", "name": "上脸对比", "focus": "负责证明:可感知上脸效果,展示涂抹前后质地变化(不做肤色变白/瑕疵消失等违规暗示),第5张"},
|
||||
{"role": "applied_proof", "name": "效果证明", "focus": "负责证明:可感知的真实使用效果,按品类展示使用过程/状态变化(护肤=上脸肤感/食品=入口口感/家居数码=使用顺手等,不做违规功效暗示),第5张"},
|
||||
{"role": "closer", "name": "促单收尾", "focus": "负责转化:转化句+品牌词,引导搜索品牌词成交,软性收尾不硬广,第6张再带一次品牌词"},
|
||||
# 扩展角色(8张链路用)
|
||||
{"role": "pain_scene", "name": "痛点共鸣", "focus": "负责共鸣:展示目标人群的真实困扰和使用前情境,但不做功效前后对比"},
|
||||
@@ -79,7 +80,7 @@ ROLE_SCENE_PREFERENCE = {
|
||||
"product_closeup": ["primary"], # 单品特写:白底主图
|
||||
"ingredient": ["ingredient", "primary"], # 成分拆解:成分/包装细节
|
||||
"texture": ["texture", "primary"], # 质地展示:质地特写
|
||||
"applied_proof": ["model", "scene", "primary"], # 上脸:上脸图/场景
|
||||
"applied_proof": ["model", "scene", "primary"], # 效果证明:使用场景/上身/上脸图
|
||||
"social_proof": ["scene", "primary"], # 社交背书:场景
|
||||
"closer": ["primary"], # 促单收尾:主图
|
||||
"scenario": ["scene", "primary"],
|
||||
@@ -91,8 +92,8 @@ ROLE_SCENE_PREFERENCE = {
|
||||
# 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行
|
||||
STYLE_PROMPTS = {
|
||||
"xiaohongshu_cover": "小红书种草风,独立3:4图文海报/素材图,1024×1536构图,明亮干净,真实实拍质感,醒目中文短标题,文字在安全区内",
|
||||
"comparison": "小红书说明对比风,独立3:4图文海报/素材图,1024×1536构图,质地/场景/肤感左右或上下对比,信息层级清晰",
|
||||
"ingredient": "小红书成分科普风,独立3:4图文海报/素材图,1024×1536构图,成分卡片布局,浅色商务美妆风,避免医疗化表达",
|
||||
"comparison": "小红书说明对比风,独立3:4图文海报/素材图,1024×1536构图,质地/场景/使用状态左右或上下对比,信息层级清晰",
|
||||
"ingredient": "小红书信息科普风,独立3:4图文海报/素材图,1024×1536构图,成分/参数卡片布局,浅色干净商务风,避免医疗化表达",
|
||||
}
|
||||
STYLE_DEFAULT = "xiaohongshu_cover"
|
||||
|
||||
@@ -100,8 +101,8 @@ STYLE_DEFAULT = "xiaohongshu_cover"
|
||||
# 按图数告诉模型整组图的种草节奏,让每张各司其职不雷同
|
||||
NARRATIVE_BY_COUNT = {
|
||||
3: "3张极速链路:第1张负责点击,第2张是按品类变化的核心证明页,第3张负责软性转化。",
|
||||
6: "6张标准种草链路:封面点击、单品特写带品牌词、成分信任、质地种草、上脸证明、促单转化,每张画面和文字各司其职不重复。",
|
||||
8: "8张沉浸测评链路:点击、痛点共鸣、单品特写、成分、质地、上脸证明、背书、软性转化。",
|
||||
6: "6张标准种草链路:封面点击、单品特写带品牌词、成分/参数信任、质地/细节种草、按品类的真实效果证明、促单转化,每张画面和文字各司其职不重复。",
|
||||
8: "8张沉浸测评链路:点击、痛点共鸣、单品特写、成分/参数、质地/细节、按品类的真实效果证明、背书、软性转化。",
|
||||
}
|
||||
|
||||
# ── 3套正交叙事策略(倩倩姐2026-06-15起草,北哥过目版)──────────────
|
||||
@@ -109,19 +110,19 @@ NARRATIVE_BY_COUNT = {
|
||||
# 每套叙事链路注入 base_prompt 叙事链路段,替换 NARRATIVE_BY_COUNT 默认值
|
||||
NARRATIVE_BY_STRATEGY = {
|
||||
"A": (
|
||||
'【套A·痛点先行】整组基调:紧迫感、强对比、情绪共鸣,文字短促带感叹号,直戳"脸黄显疲惫""素颜不敢出门"。'
|
||||
'6张链路:①痛点暴击封面(强情绪大字直击暗黄/素颜焦虑)→ ②暗黄脸实拍对比(感叹号+对比词制造紧迫感)'
|
||||
'→ ③单品特写+品牌词 → ④成分为什么能救暗黄(成分拆解+信任) → ⑤上脸提亮实证 → ⑥"别再顶着黄脸早八"软性促单。'
|
||||
'【套A·痛点先行】整组基调:紧迫感、强对比、情绪共鸣,文字短促带感叹号,直戳产品要解决的核心痛点(痛点取自上文产品卖点/人群,不要套用别的品类)。'
|
||||
'6张链路:①痛点暴击封面(强情绪大字直击该产品核心痛点)→ ②痛点实拍对比(感叹号+对比词制造紧迫感)'
|
||||
'→ ③单品特写+品牌词 → ④核心成分为什么能解决该痛点(成分拆解+信任) → ⑤使用效果实证 → ⑥用痛点收束的软性促单。'
|
||||
),
|
||||
"B": (
|
||||
'【套B·场景先行】整组基调:轻松、生活化、代入感,突出"快/省时/伪素颜自由",点到性价比不堆砌。'
|
||||
'6张链路:①"早八来不及"场景封面(生活场景钩子) → ②手忙脚乱通勤场景(代入早八焦虑)'
|
||||
'→ ③一抹搞定单品特写+品牌词 → ④养肤成分让你敢素颜 → ⑤30秒上脸效果 → ⑥"伪素颜自由+平价"软性促单。'
|
||||
'【套B·场景先行】整组基调:轻松、生活化、代入感,突出该产品的真实使用场景与"快/省时/省心"卖点,点到性价比不堆砌。'
|
||||
'6张链路:①真实使用场景封面(取该产品最高频的生活场景做钩子) → ②场景代入(还原用户在该场景里的困扰)'
|
||||
'→ ③单品特写+品牌词 → ④核心成分支撑该场景卖点 → ⑤使用效果展示 → ⑥用场景化卖点收束的软性促单。'
|
||||
),
|
||||
"C": (
|
||||
'【套C·成分背书先行】整组基调:专业、可信、真实测评感,强调成分逻辑+前后对比+像有用户实证背书。'
|
||||
'6张链路:①成分权威封面(核心成分信息锚定信任) → ②核心成分图解(作用说明+清晰可信)'
|
||||
'→ ③单品特写+品牌词 → ④使用前后时间线对比 → ⑤真实上脸细节 → ⑥"成分党闭眼入"软性促单。'
|
||||
'6张链路:①成分权威封面(该产品核心成分信息锚定信任) → ②核心成分图解(作用说明+清晰可信)'
|
||||
'→ ③单品特写+品牌词 → ④使用前后时间线对比 → ⑤真实使用细节 → ⑥用成分可信度收束的软性促单。'
|
||||
),
|
||||
}
|
||||
|
||||
@@ -130,23 +131,37 @@ NARRATIVE_BY_STRATEGY = {
|
||||
# 与 NARRATIVE_BY_STRATEGY(图片侧)同根:套A文案痛点先行↔套A图也痛点先行,同套内文图一致
|
||||
TEXT_NARRATIVE_BY_STRATEGY = {
|
||||
"A": (
|
||||
"【本条叙事主线·痛点先行】开篇直戳用户困扰(脸黄显疲惫/素颜不敢出门/早八顶着黄脸),"
|
||||
"情绪强、句子短促带感叹,痛点贯穿全文到种草,结尾用'别再顶着黄脸早八'这类痛点收束促单。"
|
||||
"基调:紧迫感、强对比、情绪共鸣。"
|
||||
"【本条叙事主线·痛点先行】开篇第一句必须直接说出'使用前的困扰/焦虑情绪'本身"
|
||||
"(取自本产品要解决的那个具体痛点,以及试过很多没用的挫败感),先有情绪再有场景,"
|
||||
"痛点取自上文产品卖点/目标人群、不要套用别品类;情绪强、句子短促带感叹。"
|
||||
"⚠️开篇禁止从'时间/场景'切入(如一天里的某个时刻、赶时间、通勤,那是套B的开法),必须先抛情绪/痛点。"
|
||||
"▶中段背书方式=自己反复挣扎后的转折(试错→死心→偶然回购/被一个生活细节打动),"
|
||||
"靠自己使用前后的真实变化来自证,不要用'闺蜜/同事/家人种草'来背书。"
|
||||
"▶卖点要带着'这个困扰被解决了'的情绪逐个讲,emoji用情绪向(😭🥹💛)。"
|
||||
"▶收尾用该痛点的释然收束('现在不慌了'类),禁止用'被身边人夸/被问起'桥段(那是套B的尾)。"
|
||||
),
|
||||
"B": (
|
||||
"【本条叙事主线·场景先行】用真实生活场景开篇(早八来不及/通勤手忙脚乱/赶时间出门),"
|
||||
"轻松代入感,突出'快/省时/伪素颜自由',点到平价性价比但不堆砌。"
|
||||
"基调:轻松、生活化、像朋友随手分享。"
|
||||
"【本条叙事主线·场景先行】开篇用一个具体的生活使用场景白描切入"
|
||||
"(最真实高频的那个使用时刻,画面感优先),痛点藏在场景里自然带出、不喊口号,"
|
||||
"突出'快/省时/省心'类卖点,点到平价性价比但不堆砌。"
|
||||
"▶中段背书方式=身边人(同事/家人)在场景里的一句随口反馈,轻松不刻意。"
|
||||
"▶成分融在使用场景里一两句带过即可,不要单独列大段成分清单,emoji用场景向(☕🚇✨)。"
|
||||
"▶收尾用场景延续('下次再有人问…我知道怎么答了'类)。"
|
||||
),
|
||||
"C": (
|
||||
"【本条叙事主线·成分背书先行】用成分原理或测评视角开篇(核心成分为什么有用/亲测对比),"
|
||||
"专业可信,带使用前后时间线对比,像真实用户实证背书,结尾用'成分党闭眼入'收束。"
|
||||
"基调:专业、可信、真实测评感。"
|
||||
"【本条叙事主线·专业背书先行】开篇必须从'成分/参数/原理/测评对比'等专业依据切入"
|
||||
"(按本产品品类对应:护肤食品讲成分配方、数码家居讲参数原理、服饰讲材质工艺,"
|
||||
"如翻配料/参数表、对比实验、为什么这个设计/成分有用),先讲可信度再讲使用。"
|
||||
"⚠️开篇禁止从'时间/场景'切入(如赶时间、通勤、被人问起,那是套A/B的开法),必须从专业依据入手。"
|
||||
"▶中段背书方式=理性自证(参数/成分对比/和踩过的雷横向比/前后时间线实测/参考测评博主的拆解分析),"
|
||||
"靠数据和亲测说话,不要用'闺蜜/同事/家人种草'来背书。"
|
||||
"▶专业依据要讲得比A/B更深(为什么这项关键/感知差异/和别家差在哪),是本套的差异优势,"
|
||||
"emoji用理性向(🔍💧🧪),收尾用专业可信度收束。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
IMAGE_RETRY_ATTEMPTS = 3 # 生图重试总次数(含首次)
|
||||
IMAGE_RETRY_BACKOFF_BASE = 2.0 # 指数退避底数(秒)
|
||||
IMAGE_SIZE_DEFAULT = "1024x1536"
|
||||
|
||||
@@ -185,6 +200,7 @@ FLYWHEEL_WEIGHTS = {
|
||||
"text_select": 3,
|
||||
"image_select": 3,
|
||||
"text_edit": 5, # 改稿=用户真动手改字=最强真实意图,与approve同级(倩倩姐2026-06-16拍板)
|
||||
"text_import": 8, # 导入=客户实跑验证过的范本,权重最重(倩倩姐2026-06-26拍板),与enums.SIGNAL_WEIGHTS对齐
|
||||
"approve": 5,
|
||||
"reject_with_reason": -3,
|
||||
"regenerate": -1,
|
||||
|
||||
@@ -137,7 +137,13 @@ def build_fission_prompt(
|
||||
source_note: dict, product: dict, reference_level: str,
|
||||
note_count: int, image_count: int, dimensions: list[str] | None = None,
|
||||
) -> str:
|
||||
"""组装裂变 user prompt(对齐上线版 handleContentSplit 的 prompt 变量拼装)。"""
|
||||
"""组装裂变 user prompt(对齐上线版 handleContentSplit 的 prompt 变量拼装)。
|
||||
|
||||
D3修复:给每套分配正交叙事主线(复用 TEXT_NARRATIVE_BY_STRATEGY A/B/C),
|
||||
套数超过3时按顺序循环(套4=A/套5=B),让各套开篇/背书/收尾正交拉开。
|
||||
"""
|
||||
from app.services.ai_engine.constants import TEXT_NARRATIVE_BY_STRATEGY
|
||||
|
||||
src = source_note or {}
|
||||
prod = product or {}
|
||||
dims = dimensions or DEFAULT_DIMENSIONS
|
||||
@@ -149,9 +155,24 @@ def build_fission_prompt(
|
||||
points = prod.get("selling_points", []) or []
|
||||
audience = prod.get("target_audience", "") or "未提供"
|
||||
keywords = prod.get("keywords", []) or []
|
||||
kw_line = "、".join(keywords) if keywords else "、".join(
|
||||
[x for x in [name, audience, "真实测评", "好物分享"] if x and x != "未提供"]
|
||||
)
|
||||
# D2修复:去掉"真实测评/好物分享"泛词兜底(评分prompt明确泛词占≥50%最高6分,自打自)
|
||||
# 改用产品真实信息提炼精准词;实在无keywords也不塞流量泛词
|
||||
if keywords:
|
||||
kw_line = "、".join(keywords)
|
||||
else:
|
||||
# 从产品卖点/人群/名称里取实质性词,不拼平台泛词
|
||||
sp_words = [str(s).strip() for s in (points[:2] if points else []) if str(s).strip()]
|
||||
kw_parts = [x for x in ([name] + sp_words + ([audience] if audience != "未提供" else [])) if x and x != "未提供"]
|
||||
kw_line = "、".join(kw_parts) if kw_parts else ""
|
||||
|
||||
# D3修复:按套序循环分配 A/B/C 叙事主线
|
||||
narrative_keys = list(TEXT_NARRATIVE_BY_STRATEGY.keys()) # ["A","B","C"]
|
||||
narrative_lines = []
|
||||
for i in range(note_count):
|
||||
key = narrative_keys[i % len(narrative_keys)]
|
||||
narrative_lines.append(f"第{i+1}套叙事主线:{TEXT_NARRATIVE_BY_STRATEGY[key]}")
|
||||
narrative_block = "\n".join(narrative_lines)
|
||||
|
||||
return f"""爆款参考:
|
||||
标题:{title}
|
||||
正文:{content}
|
||||
@@ -166,7 +187,11 @@ def build_fission_prompt(
|
||||
生成数量:{note_count}套
|
||||
每套图片数量:{image_count}张
|
||||
|
||||
【各套正交叙事主线——必须严格执行,这是本次裂变不同质的关键】
|
||||
{narrative_block}
|
||||
|
||||
请生成{note_count}套完整小红书图文笔记包。每套都必须含标题、正文、标签、点击钩子标题、{image_count}张图的imagePlan。
|
||||
每套必须严格按其指定叙事主线展开开篇/背书/收尾,不同套之间叙事结构必须正交、不重复。
|
||||
imagePlan必须按叙事链路逐张递进。3张版第2张必须是按当前品类变化的核心证明页,不能和第1张重复构图,不得让第2张重复第1张标题。
|
||||
正文必须像真实小红书种草笔记一样自然带2-5个emoji;不要把图片规划/配图建议/内部审核建议写进正文。"""
|
||||
|
||||
|
||||
@@ -43,6 +43,15 @@ class AIClients:
|
||||
_model_image: str = "gpt-image-2"
|
||||
_model_text: str = "claude-opus-4-8" # 最强档(倩倩姐红线):Claude系一律4.8,绝不降级
|
||||
|
||||
def has_alt_channel(self) -> bool:
|
||||
"""是否真正配置了 codeproxy 备用通道(有独立 key 才算)。
|
||||
|
||||
🔴 没配 codeproxy key 时(用户未录 + env 未设),编排层据此跳过 codeproxy,
|
||||
避免每张图都白白尝试 codeproxy → 撞"绝不静默降级"红线 → 空转重试 3 次。
|
||||
录了 key 就自动启用真双通道互备,逻辑不变。
|
||||
"""
|
||||
return bool(self._alt_token)
|
||||
|
||||
def _client(self) -> httpx.AsyncClient:
|
||||
"""主通道(apiports) client,按当前事件循环缓存"""
|
||||
return self._client_for(self._gpt_base, self._gpt_token)
|
||||
@@ -67,15 +76,33 @@ class AIClients:
|
||||
# ── ImageClient 协议实现(供 image_gen.py 使用)────────
|
||||
|
||||
def _gpt_target(self, provider: str | None) -> tuple[str, str | None, httpx.AsyncClient]:
|
||||
"""按 provider 选 (base, token, client);codeproxy 走备用站独立 base+key"""
|
||||
if provider == "codeproxy" and self._alt_token:
|
||||
"""按 provider 选 (base, token, client);codeproxy 走备用站独立 base+key。
|
||||
|
||||
🔴 绝不静默降级(红线):显式点名 codeproxy 却没有 codeproxy 独立 key 时,
|
||||
必须抛错让该通道明确失败(由上层 generate_one_image 记录并切下一通道),
|
||||
绝不静默用 apiports 主 key 冒充 codeproxy——那等于"假双通道"且无声退化,
|
||||
既骗了主备设计也违反红线。用户要真备份就得录入第二把 key。
|
||||
"""
|
||||
if provider == "codeproxy":
|
||||
if not self._alt_token:
|
||||
raise RuntimeError(
|
||||
"codeproxy 通道未配置独立 key,拒绝用 apiports 主 key 冒充"
|
||||
"(红线:绝不静默降级)。请为该用户录入 codeproxy key 以启用真双通道互备。"
|
||||
)
|
||||
base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
|
||||
return base, self._alt_token, self._client_for(base, self._alt_token)
|
||||
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
|
||||
return base, self._gpt_token, self._client_for(self._gpt_base, self._gpt_token)
|
||||
|
||||
async def gpt_edits(self, prompt: str, reference_images: list[bytes], size: str, provider: str | None = None) -> bytes:
|
||||
"""GPT edits endpoint(带产品参考图,禁纯文生图)"""
|
||||
"""GPT edits endpoint(带产品参考图,禁纯文生图)。
|
||||
|
||||
codeproxy 走 OpenAI Images `/images/edits`;apiports 实测图片编辑注册在
|
||||
`/chat/completions` 多模态通道,不能复用 `/images/edits`。
|
||||
"""
|
||||
if provider == "apiports":
|
||||
return await self._apiports_chat_image_edit(prompt, reference_images)
|
||||
|
||||
import io
|
||||
files: list[tuple] = [("prompt", (None, prompt))]
|
||||
for i, img in enumerate(reference_images):
|
||||
@@ -87,6 +114,29 @@ class AIClients:
|
||||
resp.raise_for_status()
|
||||
return _extract_image_bytes(resp.json())
|
||||
|
||||
async def _apiports_chat_image_edit(self, prompt: str, reference_images: list[bytes]) -> bytes:
|
||||
"""apiports 图片编辑:走 /chat/completions 多模态,参考图用 data URL。"""
|
||||
if not reference_images:
|
||||
raise ValueError("apiports 图生图缺少参考图,拒绝纯文生图")
|
||||
|
||||
import base64
|
||||
|
||||
base, _, client = self._gpt_target("apiports")
|
||||
content: list[dict] = [{"type": "text", "text": prompt}]
|
||||
for img in reference_images:
|
||||
b64 = base64.b64encode(img).decode()
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/png;base64,{b64}"},
|
||||
})
|
||||
payload = {
|
||||
"model": self._model_image,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
}
|
||||
resp = await client.post(f"{base}/chat/completions", json=payload, timeout=300.0)
|
||||
resp.raise_for_status()
|
||||
return _extract_chat_image_bytes(resp.json())
|
||||
|
||||
async def gpt_generate(self, prompt: str, size: str, provider: str | None = None) -> bytes:
|
||||
"""GPT 纯文生图(仅 ALLOW_TEXT_ONLY_IMAGE=true 时用)"""
|
||||
base, _, client = self._gpt_target(provider)
|
||||
@@ -100,14 +150,17 @@ class AIClients:
|
||||
if not self._gemini_key:
|
||||
raise RuntimeError("Gemini key 未初始化")
|
||||
gemini_base = os.environ.get("GEMINI_API_URL", "https://generativelanguage.googleapis.com/v1beta")
|
||||
url = f"{gemini_base}/models/{model}:generateContent?key={self._gemini_key}"
|
||||
# 安全红线:key 走 header(x-goog-api-key)不进 URL query,
|
||||
# 否则 httpx 异常的 __str__ 含完整 URL,会把明文 key 带进 WARNING 日志。
|
||||
url = f"{gemini_base}/models/{model}:generateContent"
|
||||
parts: list[dict] = [{"text": prompt}]
|
||||
for img in reference_images:
|
||||
import base64
|
||||
parts.append({"inline_data": {"mime_type": "image/png", "data": base64.b64encode(img).decode()}})
|
||||
payload = {"contents": [{"role": "user", "parts": parts}], "generationConfig": {"responseModalities": ["IMAGE", "TEXT"]}}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=payload, timeout=120.0)
|
||||
resp = await client.post(url, json=payload,
|
||||
headers={"x-goog-api-key": self._gemini_key}, timeout=120.0)
|
||||
resp.raise_for_status()
|
||||
return _extract_gemini_image(resp.json())
|
||||
|
||||
@@ -126,9 +179,10 @@ class AIClients:
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except (httpx.HTTPStatusError, httpx.TransportError, httpx.TimeoutException) as exc:
|
||||
# 仅 5xx(服务端过载)或网络层错误才回落;4xx(参数/鉴权)回落也没用,直接抛。
|
||||
# 回落判定:网络层错误、5xx(过载)、以及 402(欠费)/429(限流) 都该切通道——
|
||||
# 这几种都是"本通道暂时不行、换通道有救"。401/403(鉴权)/400(参数) 回落也没用,直接抛。
|
||||
status = getattr(getattr(exc, "response", None), "status_code", None)
|
||||
retryable = status is None or status >= 500
|
||||
retryable = status is None or status >= 500 or status in (402, 429)
|
||||
if not (retryable and self._alt_token):
|
||||
raise
|
||||
alt_base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
|
||||
@@ -196,21 +250,26 @@ class AIClients:
|
||||
self._gpt_client_loop_id = None
|
||||
|
||||
|
||||
def build_ai_clients(plain_key: str, gemini_key: str | None = None) -> AIClients:
|
||||
def build_ai_clients(plain_key: str, gemini_key: str | None = None, alt_key: str | None = None) -> AIClients:
|
||||
"""
|
||||
用解密后的明文 key 构建 AIClients。
|
||||
只在 Celery worker 函数体内调用,plain_key 是局部变量。
|
||||
httpx client 不在此预创建(避免绑死到调用方 loop),首次 await 时按 loop 懒建。
|
||||
调用完成后 caller 负责 await clients.aclose()。
|
||||
|
||||
alt_key:用户录入的 codeproxy 备用站 key(基石B:worker 内查库解密后传入)。
|
||||
倩倩姐红线「全做成自己的,不要埋进系统」——codeproxy key 优先用用户录入的,
|
||||
未录入才回落 env CODEPROXY_KEY(向后兼容,避免没录时备用通道直接失效)。
|
||||
base 地址(中转站 URL)非秘密,仍走 env 配置。
|
||||
"""
|
||||
gpt_base = (
|
||||
os.environ.get("IMAGE_API_BASE") # 旧变量名
|
||||
or os.environ.get("APIPORTS_BASE_URL") # .env 实际变量名
|
||||
or ""
|
||||
).rstrip("/")
|
||||
# 备用站 codeproxy:系统级 key(非用户录入),apiports 503 时切过去保生图成功
|
||||
# 备用站 codeproxy:优先用户录入 key,回落 env;apiports 503 时切过去保生图成功
|
||||
alt_base = (os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
|
||||
alt_token = os.environ.get("CODEPROXY_KEY") or None
|
||||
alt_token = alt_key or os.environ.get("CODEPROXY_KEY") or None
|
||||
return AIClients(
|
||||
_gpt_token=plain_key,
|
||||
_gpt_base=gpt_base or None,
|
||||
@@ -240,6 +299,95 @@ def _extract_image_bytes(resp_json: dict) -> bytes:
|
||||
raise ValueError(f"无法解析图片响应:{list(item.keys())}")
|
||||
|
||||
|
||||
def _decode_b64_image(value: str) -> bytes:
|
||||
"""解 data URL 或裸 base64 图片。"""
|
||||
import base64
|
||||
if value.startswith("data:"):
|
||||
value = value.split(",", 1)[1]
|
||||
return base64.b64decode(value)
|
||||
|
||||
|
||||
def _bytes_from_image_item(item: dict) -> bytes | None:
|
||||
"""兼容 chat 图片 item 里的常见 url/base64 字段。"""
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
for key in ("b64_json", "base64", "data"):
|
||||
val = item.get(key)
|
||||
if isinstance(val, str) and val:
|
||||
return _decode_b64_image(val)
|
||||
url_val = None
|
||||
if isinstance(item.get("image_url"), dict):
|
||||
url_val = item["image_url"].get("url")
|
||||
elif isinstance(item.get("image_url"), str):
|
||||
url_val = item.get("image_url")
|
||||
elif isinstance(item.get("url"), str):
|
||||
url_val = item.get("url")
|
||||
if isinstance(url_val, str) and url_val:
|
||||
if url_val.startswith("data:"):
|
||||
return _decode_b64_image(url_val)
|
||||
resp = httpx.get(url_val, timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
return None
|
||||
|
||||
|
||||
def _bytes_from_content_string(text: str) -> bytes | None:
|
||||
"""从 chat 返回的正文字符串里抠图:data URL / markdown 图链 / 裸图片URL。
|
||||
|
||||
很多中转站把 gpt-image 结果以  或 data URL 嵌在 content 文本里,
|
||||
不走 images[]/parts 结构化字段,这里兜住这几种常见形态(前面带说明文字也能抠出)。
|
||||
"""
|
||||
import re
|
||||
m = re.search(r"data:image/[\w.+-]+;base64,[A-Za-z0-9+/=]+", text)
|
||||
if m:
|
||||
return _decode_b64_image(m.group(0))
|
||||
m = re.search(r"!\[[^\]]*\]\((https?://[^\s)]+)\)", text) # markdown 图链
|
||||
if m:
|
||||
resp = httpx.get(m.group(1), timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
m = re.search( # 裸图片URL(限图片后缀,避免误抓非图链接)
|
||||
r"https?://[^\s)\]\"']+\.(?:png|jpe?g|webp|gif)(?:\?[^\s)\]\"']*)?",
|
||||
text, re.IGNORECASE,
|
||||
)
|
||||
if m:
|
||||
resp = httpx.get(m.group(0), timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
return None
|
||||
|
||||
|
||||
def _extract_chat_image_bytes(resp_json: dict) -> bytes:
|
||||
"""从 /chat/completions 图片响应中提取图片 bytes。"""
|
||||
choices = resp_json.get("choices") or []
|
||||
if not choices:
|
||||
raise ValueError("chat 图片响应缺少 choices")
|
||||
message = choices[0].get("message") or {}
|
||||
|
||||
images = message.get("images")
|
||||
if isinstance(images, list):
|
||||
for item in images:
|
||||
found = _bytes_from_image_item(item)
|
||||
if found:
|
||||
return found
|
||||
|
||||
content = message.get("content")
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
found = _bytes_from_image_item(part)
|
||||
if found:
|
||||
return found
|
||||
elif isinstance(content, str) and content:
|
||||
found = _bytes_from_content_string(content)
|
||||
if found:
|
||||
return found
|
||||
|
||||
raise ValueError(
|
||||
"无法解析 chat 图片响应:"
|
||||
f"top={list(resp_json.keys())}, message={list(message.keys())}"
|
||||
)
|
||||
|
||||
|
||||
def _extract_gemini_image(resp_json: dict) -> bytes:
|
||||
"""从 Gemini generateContent 响应提取图片 bytes"""
|
||||
import base64
|
||||
|
||||
@@ -26,21 +26,37 @@ def _pick_reference_for_role(
|
||||
images_by_scene: dict[str, list[bytes]] | None,
|
||||
fallback: list[bytes] | None,
|
||||
) -> tuple[list[bytes] | None, str]:
|
||||
"""R5多图:按分镜 role 选该场景的产品图。取不到回落主图。
|
||||
"""R5多图:把【全部】产品参考图都传给每张分镜,按 role 偏好排序。
|
||||
|
||||
返回 (参考图bytes列表, 命中scene标签用于日志)。
|
||||
🔴 产品内外一体,绝不按 role 只喂单张(倩倩姐2026-06-26拍板纠正):
|
||||
旧逻辑命中偏好场景就 return 单张 → 质地特写分镜只拿到膏体微距、没有主图,
|
||||
模型缺完整瓶身锚点 → 脑补瓶身走样+乱印字("3张好/4张每套坏一张"的根因)。
|
||||
现改为每张分镜都把所有场景图一起传,只把本 role 最相关的场景排最前
|
||||
(多模态对靠前的图权重更高),且主图(primary)始终在列表内做瓶身锚点。
|
||||
|
||||
返回 (排序后的全部参考图bytes列表, 排序说明用于日志)。
|
||||
"""
|
||||
if images_by_scene:
|
||||
for scene in ROLE_SCENE_PREFERENCE.get(role, ["primary"]):
|
||||
prefs = ROLE_SCENE_PREFERENCE.get(role, ["primary"])
|
||||
ordered: list[bytes] = []
|
||||
used_scenes: list[str] = []
|
||||
# 1) 先按 role 偏好顺序排(本 role 最相关的视角靠前)
|
||||
for scene in prefs:
|
||||
imgs = images_by_scene.get(scene)
|
||||
if imgs:
|
||||
return imgs, scene
|
||||
# 偏好全落空:用任意可用图兜底(仍优先 primary)
|
||||
if images_by_scene.get("primary"):
|
||||
return images_by_scene["primary"], "primary"
|
||||
ordered.extend(imgs)
|
||||
used_scenes.append(scene)
|
||||
# 2) 主图始终保底进列表(即使不在偏好里,也要补做瓶身锚点)
|
||||
if "primary" not in used_scenes and images_by_scene.get("primary"):
|
||||
ordered.extend(images_by_scene["primary"])
|
||||
used_scenes.append("primary")
|
||||
# 3) 其余场景图也补进来:同一产品的所有视角都给模型看,防瓶身走样
|
||||
for scene, imgs in images_by_scene.items():
|
||||
if imgs:
|
||||
return imgs, f"{scene}(兜底)"
|
||||
if scene not in used_scenes and imgs:
|
||||
ordered.extend(imgs)
|
||||
used_scenes.append(scene)
|
||||
if ordered:
|
||||
return ordered, "+".join(used_scenes)
|
||||
return fallback, "fallback"
|
||||
|
||||
|
||||
@@ -53,17 +69,42 @@ class ImageClient(Protocol):
|
||||
async def gemini_generate(
|
||||
self, prompt: str, reference_images: list[bytes], model: str
|
||||
) -> bytes: ...
|
||||
def has_alt_channel(self) -> bool: ...
|
||||
|
||||
|
||||
def _image_provider_order() -> list[str]:
|
||||
"""从环境变量读主备顺序(扒 imageProviderOrder)"""
|
||||
def _image_provider_order(has_alt: bool = True) -> list[str]:
|
||||
"""从环境变量读主备顺序(扒 imageProviderOrder)。
|
||||
|
||||
🔴 真双通道互备(倩倩姐2026-06拍板):codeproxy / apiports 是两个独立中转站,
|
||||
各用自己的 base+key, 任一站上游挂(502/503)由另一站顶上 —— 这才是真备份。
|
||||
若 .env 把主备都设成同一个站(如都 codeproxy), 去重后只剩一个 → 退化成"假备份"
|
||||
(一站挂全挂)。故只要序列用到 GPT 中转站家族, 就自动补齐缺失的那个,
|
||||
保证 codeproxy+apiports 双通道都在。"gpt" 是 apiports 的等价别名(同 base+主key),
|
||||
归一化掉避免重复尝试同一站。codeproxy 走 /images/edits,apiports 走
|
||||
/chat/completions 多模态编辑;两者都必须带产品参考图,不碰纯文生图。
|
||||
|
||||
🔴 has_alt=False(用户/env 都没配 codeproxy key)时,绝不把 codeproxy 补进序列——
|
||||
否则每张图都白白尝试 codeproxy → 撞"绝不静默降级"红线 → 空转重试 3 次拖慢整体
|
||||
(倩倩姐2026-06-30方案A)。录了 key(has_alt=True)才补齐,真双通道逻辑不变。
|
||||
"""
|
||||
primary = os.environ.get("IMAGE_PROVIDER_PRIMARY", "gpt").lower()
|
||||
fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "gemini").lower()
|
||||
seen: list[str] = []
|
||||
order: list[str] = []
|
||||
for p in [primary, fallback]:
|
||||
if p and p not in seen:
|
||||
seen.append(p)
|
||||
return seen
|
||||
p = "apiports" if p == "gpt" else p
|
||||
# 没配 codeproxy key 时,显式点名 codeproxy 也跳过(避免空转重试)
|
||||
if p == "codeproxy" and not has_alt:
|
||||
continue
|
||||
if p and p not in order:
|
||||
order.append(p)
|
||||
if any(p in ("codeproxy", "apiports") for p in order):
|
||||
for ch in ("codeproxy", "apiports"):
|
||||
# codeproxy 仅在真有备用 key 时补齐
|
||||
if ch == "codeproxy" and not has_alt:
|
||||
continue
|
||||
if ch not in order:
|
||||
order.append(ch)
|
||||
return order
|
||||
|
||||
|
||||
def _gemini_models() -> list[str]:
|
||||
@@ -90,12 +131,20 @@ async def _retry(coro_fn, attempts: int = IMAGE_RETRY_ATTEMPTS, backoff: float =
|
||||
async def _request_gpt(client: ImageClient, prompt: str, reference_images: list[bytes], provider: str | None = None) -> bytes:
|
||||
if reference_images:
|
||||
return await client.gpt_edits(prompt, reference_images, IMAGE_SIZE_DEFAULT, provider)
|
||||
# 无产品参考图时降级为纯文生图(需 ALLOW_TEXT_ONLY_IMAGE=true 或 M2阶段)
|
||||
allow_text_only = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "true").lower() == "true"
|
||||
if allow_text_only:
|
||||
logger.warning("无产品参考图,降级为纯文生图(可能产品跑偏,建议前端上传参考图)")
|
||||
return await client.gpt_generate(prompt, IMAGE_SIZE_DEFAULT, provider)
|
||||
raise ValueError("GPT 主通道缺产品图:禁止纯文生图以免产品跑偏(设 ALLOW_TEXT_ONLY_IMAGE=true 可解锁)")
|
||||
# 🔴 红线:瓶身忠于参考图原样,禁止纯文生图脑补产品(倩倩姐2026-06-15拍板)。
|
||||
# ALLOW_TEXT_ONLY_IMAGE 默认 false,即使显式设为 true 也拒绝——纯文生图会让产品包装
|
||||
# 完全由模型自由发挥,严重走样,不符合合规与真实双重要求。
|
||||
# 若要解锁请联系产品负责人,不在代码层开放。
|
||||
_env_val = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "false").lower()
|
||||
if _env_val == "true":
|
||||
logger.warning(
|
||||
"检测到 ALLOW_TEXT_ONLY_IMAGE=true,但该选项已被锁定(红线:禁纯文生图)。"
|
||||
"将强制抛出缺图异常,请为产品上传参考图。"
|
||||
)
|
||||
raise ValueError(
|
||||
"缺少产品参考图,无法生成忠于实物的产品图,请先上传该产品的主图。"
|
||||
"(红线:禁止纯文生图脑补产品包装,ALLOW_TEXT_ONLY_IMAGE 已锁定为无效)"
|
||||
)
|
||||
|
||||
|
||||
async def _request_gemini(client: ImageClient, prompt: str, reference_images: list[bytes]) -> bytes:
|
||||
@@ -118,7 +167,9 @@ async def generate_one_image(
|
||||
返回图片 bytes(PNG/JPEG)。
|
||||
"""
|
||||
refs = reference_images or []
|
||||
providers = _image_provider_order()
|
||||
# 没配 codeproxy 备用 key 时不把它补进尝试序列(避免每张图空转重试,方案A)
|
||||
has_alt = bool(getattr(client, "has_alt_channel", lambda: True)())
|
||||
providers = _image_provider_order(has_alt)
|
||||
errors: list[str] = []
|
||||
|
||||
for provider in providers:
|
||||
@@ -191,6 +242,9 @@ async def generate_storyboard_images(
|
||||
f"素材使用={item.get('asset_use','')}。"
|
||||
f"{brand_line}"
|
||||
f"禁止事项={item.get('forbidden','')}。"
|
||||
"参考图说明:随附的多张参考图都是【同一个产品】的不同视角(主图=产品/包装本体,"
|
||||
"其余=质地/细节/场景特写);产品外形、颜色、包装上原有文字必须100%忠于主图原样,"
|
||||
"禁止重绘产品本体、禁止在包装上增删改任何文字(品牌词只放图上标题/贴纸等排版层,绝不印产品本体)。"
|
||||
"排版要求:独立小红书3:4图文海报,画面完整;标题只出现一次,不与其他页重复;"
|
||||
"中文文字少而清晰,主标题+最多3个短点位;可自然用✅✨🌿💧🪞🧴📦🔍种草符号但不堆砌;"
|
||||
"不要生成App截图或笔记详情页界面。"
|
||||
|
||||
@@ -21,6 +21,9 @@ except ImportError:
|
||||
_PILLOW_OK = False
|
||||
logger.warning("Pillow 未安装,image_postprocessor 不可用")
|
||||
|
||||
# 北哥暖棕调色层(A4),自带开关与降级
|
||||
from .beige_color_grade import grade as _beige_grade, is_enabled as _beige_enabled
|
||||
|
||||
# 比例映射表,对齐大卫 RATIO_MAP。key 为字符串如 '3:4'
|
||||
RATIO_MAP: dict[str, tuple[int, int]] = {
|
||||
"1:1": (1024, 1024),
|
||||
@@ -74,12 +77,27 @@ def process_image(
|
||||
img = ImageOps.fit(img, (tw, th), method=Image.LANCZOS)
|
||||
logger.debug("resize %dx%d → %dx%d (ratio=%s)", actual_w, actual_h, tw, th, aspect_ratio)
|
||||
|
||||
# --- Step2: resample_strength 削像素水印(可选,默认轻采样)---
|
||||
img = _apply_resample(img, resample_strength)
|
||||
# --- Step2+3: 去AI化破水印(硬路默认开,倩倩姐2026-06-26拍板,对齐大卫xhs实测版)---
|
||||
# 硬路 = 缩2px裁1px边错位采样 + 亮度/饱和微调,专破 SynthID 像素水印。
|
||||
# 「像素不能有任何压缩」红线:大卫硬路只动边缘2px,没有全图来回缩放;
|
||||
# Clover 原 Step2(缩98%再放大)是额外的全图 LANCZOS 重采样、会软化全图像素,
|
||||
# 故硬路开启时直接跳过 Step2,只走硬路局部错位——既破水印又不压全图像素。
|
||||
hard_mode = os.environ.get("SYNTHID_HARD_MODE", "1") != "0"
|
||||
if hard_mode:
|
||||
# 硬路只动边缘(缩2px裁1px),用图片当前尺寸即可,不依赖 RATIO_MAP;
|
||||
# 故非标准比例(target=None)也走硬路,绝不降级到全图 LANCZOS 有损重采样。
|
||||
img = _apply_synthid_break(img)
|
||||
else:
|
||||
# 仅硬路显式关闭时,才用全图轻采样兜底破水印
|
||||
img = _apply_resample(img, resample_strength)
|
||||
|
||||
# --- Step3: SynthID 破除(SYNTHID_HARD_MODE=1 才开,默认关)---
|
||||
if os.environ.get("SYNTHID_HARD_MODE") == "1" and target:
|
||||
img = _apply_synthid_break(img, target)
|
||||
# --- Step3.5: 北哥暖棕调色(BEIGE_COLOR_GRADE 默认开,去AI化核心)---
|
||||
# 在重编码前以 bytes 往返:grade 内部自带开关/降级,关则原样透传
|
||||
if _beige_enabled():
|
||||
buf0 = io.BytesIO()
|
||||
img.save(buf0, format="JPEG", quality=100, subsampling=0)
|
||||
graded = _beige_grade(buf0.getvalue())
|
||||
img = Image.open(io.BytesIO(graded)).convert("RGB")
|
||||
|
||||
# --- Step4: 高保真 JPEG 重编码,去所有元数据 ---
|
||||
buf = io.BytesIO()
|
||||
@@ -118,19 +136,20 @@ def _apply_resample(img: "Image.Image", strength: int) -> "Image.Image":
|
||||
return img
|
||||
|
||||
|
||||
def _apply_synthid_break(img: "Image.Image", target: tuple[int, int]) -> "Image.Image":
|
||||
def _apply_synthid_break(img: "Image.Image") -> "Image.Image":
|
||||
"""
|
||||
SynthID 破除(SYNTHID_HARD_MODE=1 时调用):
|
||||
对齐大卫逻辑 — 缩到(w-2,h-2)再裁掉1px边 + 亮度*1.005/饱和*0.998。
|
||||
SynthID 破除(硬路,默认开):对齐大卫 xhs 实测版。
|
||||
顺序对齐大卫:先微调亮度/饱和(modulate) → 再缩2px裁1px边错位采样。
|
||||
只动边缘像素、按图片当前尺寸算,不依赖目标比例,不做全图重采样、不压全图像素。
|
||||
诚实声明:只能削弱 SynthID,不保证 100% 清除。
|
||||
"""
|
||||
tw, th = target
|
||||
img = ImageOps.fit(img, (tw - 2, th - 2), method=Image.LANCZOS)
|
||||
# 裁掉1px边,消除边缘水印残留
|
||||
img = img.crop((1, 1, tw - 3, th - 3))
|
||||
# 微调亮度/饱和(对齐大卫 modulate brightness/saturation)
|
||||
# 先 modulate(亮度*1.005/饱和*0.998),对齐大卫 modulate 在缩裁之前
|
||||
img = ImageEnhance.Brightness(img).enhance(1.005)
|
||||
img = ImageEnhance.Color(img).enhance(0.998)
|
||||
# 按当前尺寸缩2px再裁1px边,错位采样破边缘水印残留(w-4 × h-4)
|
||||
w, h = img.size
|
||||
img = ImageOps.fit(img, (w - 2, h - 2), method=Image.LANCZOS)
|
||||
img = img.crop((1, 1, w - 3, h - 3))
|
||||
return img
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
llm_scorer.py — AI 评委打分入口(让模型真读文案,替代机械找词)
|
||||
合规第7维仍走机械硬拦(score_compliance);AI 读前6维给分+理由。
|
||||
任何异常/解析失败 → 回退旧机械 score_copy,绝不卡链路。
|
||||
B1(倩倩姐2026-06-26):评分两通道(apiports+codeproxy强档兜底)都挂时,
|
||||
绝不静默回退机械分糊弄,抛 ScoringUnavailableError 由上层逐条接住、标"评分不可用"。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
@@ -12,17 +13,23 @@ from typing import Any
|
||||
|
||||
from .constants import BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS, QUALITY_PASS_SCORE
|
||||
from ._scoring_dims import score_compliance
|
||||
from .text_scoring import score_copy
|
||||
from ._score_prompt import SCORER_PERSONA, build_score_prompt, COMPLIANCE_MAX
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 6 个 AI 维度满分(倩倩姐2026-06-15拍板·与 constants.AI_DIM_WEIGHTS/_score_prompt 三处同步)
|
||||
# 痛点18+情绪18+买点18+钩子15+标题13+真实感13=95,+合规5=100
|
||||
# "真实感"替换旧"产品聚焦一件事",对齐富贵"很少提产品/前70%干货后30%植入"原则
|
||||
|
||||
class ScoringUnavailableError(RuntimeError):
|
||||
"""评分通道(apiports+codeproxy 强档兜底)均不可用。
|
||||
按倩倩姐2026-06-26 B1拍板:评分拿不到真AI分时绝不静默回退机械分糊弄
|
||||
(机械分会误杀好文案/漏放烂文案,双破"质量过关"红线),明确抛错由上层逐条接住。"""
|
||||
|
||||
# 7 个 AI 维度满分(倩倩姐2026-06-22拍板补标签精准·与 constants.AI_DIM_WEIGHTS/_score_prompt 三处同步)
|
||||
# 痛点16+情绪16+买点16+钩子13+标题13+真实感11+标签精准10=95,+合规5=100
|
||||
# "标签精准度"=北哥要害⑤(精准购买意向埋词),分从原6维各匀出,总分仍95,合格线80不动
|
||||
_DIM_MAX = {
|
||||
"痛点人群精准": 18, "情绪张力": 18, "买点转化": 18,
|
||||
"开头钩子": 15, "标题点击力": 13, "真实感": 13,
|
||||
"痛点人群精准": 16, "情绪张力": 16, "买点转化": 16,
|
||||
"开头钩子": 13, "标题点击力": 13, "真实感": 11,
|
||||
"标签精准度": 10,
|
||||
}
|
||||
# 评委合规相关默认权重(仅供 score_compliance 复用其内部硬拦逻辑)
|
||||
_COMPLIANCE_W = {"compliance": COMPLIANCE_MAX}
|
||||
@@ -65,22 +72,29 @@ async def llm_score_copy(
|
||||
)
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001 — 含 httpx.HTTPStatusError 503/429
|
||||
# chat_complete 底层已含 apiports→codeproxy gpt-5.5 强档回落(含402欠费/429限流)。
|
||||
# 走到这里说明两通道都失败。503/429 给瞬时过载留重试窗口,其余直接判通道不可用。
|
||||
status = getattr(getattr(exc, "response", None), "status_code", 0)
|
||||
if status in (503, 429) and attempt < 3:
|
||||
await asyncio.sleep(backoff[min(attempt, 2)])
|
||||
continue
|
||||
logger.warning("AI评委调用失败,回退机械打分: %s", exc)
|
||||
return score_copy(copy, source, banned_words, pass_score=pass_score)
|
||||
# B1红线:绝不静默回退机械分糊弄,明确抛错由调用方逐条接住、标记"评分不可用"
|
||||
logger.error("AI评委两通道均失败,判定评分不可用(不降级机械分): %s", exc)
|
||||
raise ScoringUnavailableError(f"评分通道不可用: {exc}") from exc
|
||||
|
||||
verdict = _parse_verdict(raw)
|
||||
if not verdict:
|
||||
logger.warning("AI评委输出解析失败,回退机械打分。raw[:120]=%s", raw[:120])
|
||||
return score_copy(copy, source, banned_words, pass_score=pass_score)
|
||||
# 拿到了回复但不是合法JSON:模型偶发不听话。同样不静默机械分,抛错让上层标记。
|
||||
logger.error("AI评委输出解析失败(不降级机械分)。raw[:120]=%s", raw[:120])
|
||||
raise ScoringUnavailableError("评分输出解析失败")
|
||||
|
||||
details: list[dict] = []
|
||||
_COMPLIANCE_ALIASES = ("合规", "违禁", "禁用") # LLM 偶尔不听话多吐合规维度,显式剔除防污染机械合规分
|
||||
for d in verdict["dims"]:
|
||||
item = str(d.get("item", "")).strip()
|
||||
if item not in _DIM_MAX: # 只收白名单6维,模型偶尔多吐"总分"等噪声项,丢弃
|
||||
if any(a in item for a in _COMPLIANCE_ALIASES):
|
||||
continue # 合规只认机械 dim_comp,丢弃 LLM 主观合规判定
|
||||
if item not in _DIM_MAX: # 只收白名单7维,模型偶尔多吐"总分"等噪声项,丢弃
|
||||
continue
|
||||
mx = _DIM_MAX[item]
|
||||
sc = max(0, min(mx, int(round(float(d.get("score", 0))))))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
package_exporter.py — 达人素材交付包生成
|
||||
架构方案§五 1A步骤5:按笔记分文件夹 + 图(01/02/03) + 文案.txt + 发布清单 + 合规说明
|
||||
路径规则:uploads/packages/{workspace_id}/{task_id}/note_{n}/
|
||||
路径规则:{UPLOAD_ABS_ROOT}/packages/{workspace_id}/{task_id}/note_{n}/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
|
||||
@@ -53,7 +53,8 @@ def aggregate_preference_context(
|
||||
|
||||
# text_edit(改稿)是最强真实信号,角度按权重计入(倩倩姐2026-06-16拍板)
|
||||
# image_select(选图)按套别叙事角度计入,让选图偏好真正闭环回生图(R7断点2)
|
||||
if sig_type in ("text_select", "approve", "text_edit", "image_select") and angle:
|
||||
# text_import(导入)=客户实跑验证过的范本,权重最高(8),角度计入让飞轮最重学习(倩倩姐2026-06-26)
|
||||
if sig_type in ("text_select", "approve", "text_edit", "image_select", "text_import") and angle:
|
||||
angle_counts[angle] += weight
|
||||
elif sig_type == "reject_with_reason":
|
||||
reason = str(e.get("reason") or "").strip()
|
||||
|
||||
@@ -67,7 +67,7 @@ def get_narrative_roles(image_count: int = 6) -> list[dict]:
|
||||
"""
|
||||
按图数返回分镜角色列表(扒 getNarrativeRoles,Q6对齐北哥6张套路)
|
||||
≤3 张:极速链路 hook / applied_proof / closer
|
||||
≤6 张:北哥标准链路 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单
|
||||
≤6 张:北哥标准链路 ①封面痛点大字 ②单品特写+品牌词 ③成分/参数 ④质地/细节 ⑤效果证明(按品类) ⑥促单
|
||||
>6 张:沉浸链路 + pain_scene / scenario / social_proof
|
||||
"""
|
||||
count = clamp_count(image_count)
|
||||
@@ -102,14 +102,14 @@ def build_visual_system(product: dict, analysis: dict | None = None) -> dict:
|
||||
"typography": identity.get("typographyStyle", "主标题清晰黑体或手写感标题,辅助文字便签/勾选标注,字重颜色保持同一体系"),
|
||||
"sticker": identity.get("stickerLanguage", "少量箭头、放大镜、勾选、小表情、便签,不使用促销按钮"),
|
||||
"layout": identity.get("layoutStyle", "同一组图片保持色调、光线、产品露出方式一致,每张图承担不同叙事角色"),
|
||||
"texture": identity.get("materialTexture", "产品包装、质地、手背/上脸肤感要真实自然"),
|
||||
"texture": identity.get("materialTexture", "产品包装、材质、质地/细节要真实自然,符合本品类的真实使用质感"),
|
||||
"package_details": identity.get("packageDetails", "如果提供产品图,必须还原包装颜色、瓶身形状、标签方向和主视觉"),
|
||||
"xhs_style_preset": identity.get("xhsStylePreset", "真实测评风/手写安利风/清单便签风"),
|
||||
"symbol_system": identity.get("symbolSystem", "中等密度小红书种草符号:✅ ✨ 🌿 💧 🪞 🧴 📦 🔍 💛,每张最多2-4个"),
|
||||
"quality_rules": [
|
||||
"同组图片字体体系相对一致,但不要像固定模板",
|
||||
"每张压图文字必须服务当前叙事角色,不能重复封面标题",
|
||||
"护肤品优先出现手背涂抹、质地微距、自然上脸局部或真实生活场景",
|
||||
"效果证明页按品类出真实使用证据:护肤=手背涂抹/质地微距/自然上脸局部,食品=冲泡入口,家居数码=使用过程,服饰=上身材质,均为真实生活场景",
|
||||
"人物真实自然,有轻微皮肤纹理和生活感,不要AI精修美女",
|
||||
"禁止乱码、错别字、App底栏、Like评论分享、硬广价格牌、虚假功效before/after",
|
||||
],
|
||||
|
||||
@@ -18,11 +18,11 @@ import re
|
||||
|
||||
# ── sanitize(扒 sanitizeImagePlanText,防违禁视觉词进 prompt)
|
||||
_SANITIZE_RULES: list[tuple[str, str]] = [
|
||||
(r"before\s*&\s*after", "质地与肤感说明"),
|
||||
(r"before\s*/?\s*after", "质地与肤感说明"),
|
||||
(r"\bbefore\b", "质地状态"),
|
||||
(r"\bafter\b", "上脸肤感"),
|
||||
(r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/肤感说明"),
|
||||
(r"before\s*&\s*after", "质地与使用状态说明"),
|
||||
(r"before\s*/?\s*after", "质地与使用状态说明"),
|
||||
(r"\bbefore\b", "使用前状态"),
|
||||
(r"\bafter\b", "使用后状态"),
|
||||
(r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/使用状态说明"),
|
||||
(r"功效对比|效果对比|改善对比", "质地/场景说明对比"),
|
||||
(r"肤色变白|皮肤变白|变白|美白", "自然光泽感"),
|
||||
(r"瑕疵消失|斑点消失|痘印消失|消除瑕疵|祛斑", "妆感更服帖"),
|
||||
@@ -42,33 +42,33 @@ ROLE_STORYBOARD_TPL: dict[str, dict] = {
|
||||
"hook": {
|
||||
"goal": "让{audience}因为{pain}停下划走,产生点开欲",
|
||||
"overlay": "{hook}",
|
||||
"visual": "自然光生活场景,手持产品或产品在桌面前景,真实肤感/手部细节,像iPhone随手实拍的封面,不是海报",
|
||||
"visual": "自然光生活场景,手持产品或产品在桌面前景,真实使用细节/手部细节,像iPhone随手实拍的封面,不是海报",
|
||||
"basis": "来自选中文案标题、人群{audience}、痛点{pain}",
|
||||
"forbidden": "不要价格、不要重复后续卖点、不要App界面、不要广告海报感",
|
||||
},
|
||||
"product_closeup": {
|
||||
"goal": "建立单品记忆锚点,让用户记住是哪个产品",
|
||||
"overlay": "{brand}",
|
||||
"visual": "单品高清特写居中,干净浅色台面,柔和顶光,瓶身/包装/标签清晰可读,品牌词自然出现在画面或瓶身",
|
||||
"visual": "单品高清特写居中,干净浅色台面,柔和顶光,瓶身/包装/标签清晰可读且忠于参考图原样,品牌词只出现在图上标题/贴纸等文字排版层,绝不印到瓶身上",
|
||||
"basis": "来自产品名和品牌词,第2张和第6张都要带品牌词强化记忆",
|
||||
"forbidden": "不要堆多个产品、不要花哨背景抢主体、不要改包装文字",
|
||||
},
|
||||
"ingredient": {
|
||||
"goal": "用成分/配方信息建立信任,但不医疗化",
|
||||
"goal": "用成分/配方/参数信息建立信任,但不夸大不医疗化",
|
||||
"overlay": "看清{point}",
|
||||
"visual": "成分卡片式布局,产品+成分图标/短说明,浅色商务美妆风,信息层级清楚",
|
||||
"basis": "来自卖点里的成分/功效点,理性表达不夸大",
|
||||
"visual": "信息卡片式布局,产品+成分/参数图标+短说明,浅色干净商务风,信息层级清楚(护肤食品讲成分配方,数码家居讲参数原理,服饰讲材质工艺)",
|
||||
"basis": "来自卖点里的成分/参数/工艺点,理性表达不夸大",
|
||||
"forbidden": "不要治疗/改善疾病承诺、不要医生背书、不要绝对化",
|
||||
},
|
||||
"texture": {
|
||||
"goal": "让用户看到{point}的真实质感证据",
|
||||
"overlay": "{point}看得见",
|
||||
"visual": "手背或指尖涂抹质地微距,产品放在旁边,自然光,保留真实皮肤纹理,能看清延展和肤感",
|
||||
"basis": "来自卖点里的质地/肤感描述",
|
||||
"visual": "质地/材质/细节微距,产品放在旁边,自然光,保留真实纹理(护肤=涂抹延展肤感,食品=色泽质地,服饰=面料织纹,数码家居=做工细节),能看清{point}",
|
||||
"basis": "来自卖点里的质地/材质/做工描述",
|
||||
"forbidden": "不要生成变白效果、不要医疗化对比、不要和封面同构图",
|
||||
},
|
||||
"applied_proof": {
|
||||
"goal": "用可感知的上脸/使用证据证明{point}",
|
||||
"goal": "用可感知的真实使用证据证明{point}",
|
||||
"overlay": "{proof_overlay}",
|
||||
"visual": "{proof_visual}",
|
||||
"basis": "来自核心卖点{point}和用户对效果的关注",
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import Any
|
||||
from .constants import MAX_OPTIMIZE_ROUNDS
|
||||
from ._text_prompt import COPY_SYSTEM, build_prompt, parse_json_array, build_local_drafts
|
||||
from .text_scoring import score_copy, dedupe_copies
|
||||
from .llm_scorer import llm_score_copy
|
||||
from .llm_scorer import llm_score_copy, ScoringUnavailableError
|
||||
from .banned_word_checker import check_and_fix, build_entries_from_db, CheckResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -69,6 +69,35 @@ async def _call_llm(client: Any, prompt: str, max_tokens: int = 8192) -> str:
|
||||
# 故分批:每批最多 4 条,串行调用合并。批大小可经 TEXT_BATCH_SIZE 调。
|
||||
TEXT_BATCH_SIZE = int(os.environ.get("TEXT_BATCH_SIZE", "4"))
|
||||
|
||||
# 评分有限并发:评分无状态、逐条独立可并发,但仍限并发数防 apiports 限流(生成层串行
|
||||
# 不动,task45 实测全并发雪崩)。默认3=保守值(评分 max_tokens仅1500,远轻于生成8192,
|
||||
# 且评分已带 503/429 重试兜底),可经 SCORE_CONCURRENCY 调。
|
||||
_SCORE_CONCURRENCY = int(os.environ.get("SCORE_CONCURRENCY", "3"))
|
||||
|
||||
|
||||
def _build_cross_set_avoidance(previous: list[dict]) -> str:
|
||||
"""从已生成的其他套文案提炼回避约束,喂进生成 prompt 防三套撞车。
|
||||
覆盖三个最易同质化的点:已用标签、背书人物桥段、收尾句式。"""
|
||||
if not previous:
|
||||
return ""
|
||||
used_tags: list[str] = []
|
||||
used_titles: list[str] = []
|
||||
for c in previous:
|
||||
used_titles.append(c.get("title", ""))
|
||||
for t in (c.get("tags") or []):
|
||||
tag = str(t).replace("🛒", "").strip()
|
||||
if tag and tag not in used_tags:
|
||||
used_tags.append(tag)
|
||||
lines = ["【跨套去重·硬约束(本条务必与已发布的其它套拉开,禁止换皮重复)】"]
|
||||
if used_titles:
|
||||
lines.append(f"- 已用标题(禁止同主题/同句式):{ ';'.join(t for t in used_titles if t) }")
|
||||
if used_tags:
|
||||
lines.append(f"- 已用标签(本条至少换掉一半,改用不同人群/场景/成分组合的精准长尾,扩大触达不抢同批流量):{ '、'.join(used_tags) }")
|
||||
lines.append("- 标签禁止与已用标签共用同一核心搜索词(别套已用的核心词,本条标签就别再都堆这个词,换成本套独有的人群/场景/卖点关键词分流)。")
|
||||
lines.append("- 背书角色/桥段禁止与上面套雷同:若别套用了'闺蜜种草',本条换成自证/家人/同事/自己反复试错等不同来源;")
|
||||
lines.append("- 卖点罗列的措辞顺序与 emoji 禁止与别套一致;同一个具体种草桥段全局只许出现一次。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _generate_one_batch(llm_client: Any, product: dict, batch_n: int, extra: str,
|
||||
strategy_narrative: str = "") -> list[dict]:
|
||||
@@ -119,25 +148,53 @@ async def generate_text_variants(
|
||||
贯穿首批生成与优化轮,确保同套内文案同一叙事不串味。"""
|
||||
banned_entries = build_entries_from_db(banned_word_rows or [])
|
||||
extra = flywheel_context
|
||||
# 跨套去重:把已生成套的标签/背书桥段提炼成回避约束喂进生成 prompt,
|
||||
# 防止三套撞车(仅事后 dedupe 删不掉"同用闺蜜背书/同套标签"的同质化)
|
||||
avoid = _build_cross_set_avoidance(previous_copies or [])
|
||||
if avoid:
|
||||
extra = f"{extra}\n{avoid}" if extra else avoid
|
||||
|
||||
copies: list[dict] = await _generate_in_batches(
|
||||
llm_client, product, count, extra, strategy_narrative)
|
||||
if not copies:
|
||||
copies = list(build_local_drafts(product, count)) # generator → list
|
||||
|
||||
candidates: list[dict] = []
|
||||
for c in copies:
|
||||
# 评分有限并发:逐条独立无状态可并发,sem 限并发数防 apiports 限流(生成层串行不动)。
|
||||
# 每条语义与原串行完全一致:违禁词机械检查(同步,放 sem 外不占并发额度)→AI评分→
|
||||
# 评分不可用则标记不计合格不炸整批;auto_fixed 回写修正文案。gather 保序=候选顺序不变。
|
||||
sem = asyncio.Semaphore(_SCORE_CONCURRENCY)
|
||||
|
||||
async def _score_one(c: dict) -> dict:
|
||||
ban: CheckResult = check_and_fix(
|
||||
f"{c.get('title','')} {c.get('content','')}",
|
||||
banned_entries or None,
|
||||
)
|
||||
scored = await llm_score_copy(llm_client, c, product, [e.word for e in banned_entries])
|
||||
try:
|
||||
async with sem:
|
||||
scored = await llm_score_copy(llm_client, c, product, [e.word for e in banned_entries])
|
||||
except ScoringUnavailableError as exc:
|
||||
# B1:评分两通道都挂→不打机械分糊弄,这条标"评分不可用·不计合格",不炸整批
|
||||
logger.warning("文案评分不可用,标记不计合格(非质量问题): %s", exc)
|
||||
c.update({"source": "ai", "score": 0,
|
||||
"score_detail": [{"item": "评分不可用", "score": 0, "max": 100,
|
||||
"note": "评分通道(apiports+codeproxy)暂不可用,非文案质量问题"}],
|
||||
"passed": False, "banned_word_status": ban.status,
|
||||
"scoring_unavailable": True, "verdict": "", "summary": "评分服务暂不可用"})
|
||||
return c
|
||||
c.update({"source": "ai", "score": scored["score"], "score_detail": scored["score_detail"],
|
||||
"passed": scored["passed"], "banned_word_status": ban.status,
|
||||
"verdict": scored.get("verdict", ""), "summary": scored.get("summary", "")})
|
||||
if ban.status == "auto_fixed" and ban.fixed_text:
|
||||
c["content"] = ban.fixed_text
|
||||
candidates.append(c)
|
||||
return c
|
||||
|
||||
candidates: list[dict] = list(await asyncio.gather(*(_score_one(c) for c in copies)))
|
||||
scoring_failures = sum(1 for c in candidates if c.get("scoring_unavailable"))
|
||||
|
||||
# 整批评分全因通道不可用而失败 → 抛错让上层透传"评分服务不可用",区别于"质量不合格"
|
||||
if copies and scoring_failures == len(copies):
|
||||
raise ScoringUnavailableError(
|
||||
f"本套 {len(copies)} 条文案评分全部失败:评分通道(apiports+codeproxy)均不可用")
|
||||
|
||||
failed = [c for c in candidates if not c["passed"] and c["banned_word_status"] != "hard_block"]
|
||||
# 优化轮默认关闭:apiports 60s 网关限制下优化轮的 _call_llm 常需白等 60s 才 503,
|
||||
@@ -164,7 +221,11 @@ async def generate_text_variants(
|
||||
logger.warning("文案优化轮 LLM 失败,沿用原始候选不再重试")
|
||||
break
|
||||
for nc in parse_json_array(raw2):
|
||||
sc2 = await llm_score_copy(llm_client, nc, product, [e.word for e in banned_entries])
|
||||
try:
|
||||
sc2 = await llm_score_copy(llm_client, nc, product, [e.word for e in banned_entries])
|
||||
except ScoringUnavailableError as exc:
|
||||
logger.warning("优化轮评分不可用,跳过该条不计入: %s", exc)
|
||||
continue
|
||||
nc.update({"source": "ai", "score": sc2["score"], "score_detail": sc2["score_detail"],
|
||||
"passed": sc2["passed"], "banned_word_status": "pass",
|
||||
"verdict": sc2.get("verdict", ""), "summary": sc2.get("summary", "")})
|
||||
|
||||
Reference in New Issue
Block a user