上线版: 产品表单统一+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:
yangqianqian
2026-06-30 18:08:13 +08:00
parent a77212781c
commit df1856d793
150 changed files with 8616 additions and 1765 deletions

View File

@@ -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}"
)

View File

@@ -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

View File

@@ -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字不含标题tags3-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}", "#真实测评", "#好物分享"],

View 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

View File

@@ -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,

View File

@@ -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不要把图片规划/配图建议/内部审核建议写进正文。"""

View File

@@ -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基石Bworker 内查库解密后传入)。
倩倩姐红线「全做成自己的不要埋进系统」——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回落 envapiports 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 结果以 ![](url) 或 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

View File

@@ -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/editsapiports 走
/chat/completions 多模态编辑;两者都必须带产品参考图,不碰纯文生图。
🔴 has_alt=False用户/env 都没配 codeproxy key绝不把 codeproxy 补进序列——
否则每张图都白白尝试 codeproxy → 撞"绝不静默降级"红线 → 空转重试 3 次拖慢整体
倩倩姐2026-06-30方案A。录了 keyhas_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(
返回图片 bytesPNG/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截图或笔记详情页界面。"

View File

@@ -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

View File

@@ -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))))))

View File

@@ -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

View File

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

View File

@@ -67,7 +67,7 @@ def get_narrative_roles(image_count: int = 6) -> list[dict]:
"""
按图数返回分镜角色列表(扒 getNarrativeRolesQ6对齐北哥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",
],

View File

@@ -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}和用户对效果的关注",

View File

@@ -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", "")})