xhs_factory/llm_service.py
zhoujie 1ea8bfb554 feat(analytics): 增强 MCP 数据解析兼容性
- 优化用户资料和笔记详情的数据提取逻辑,优先从 `raw["raw"]["content"]` 获取内容,并回退到 `raw["content"]`
- 在笔记详情解析中,增加从 `result["text"]` 提取文本的备用路径
- 在用户动态流解析中,优先从 `f["id"]` 获取笔记 ID,并增加无 ID 条目的日志警告

 feat(persona): 扩展人设池并集成视觉风格配置

- 新增“赛博AI虚拟博主”和“性感福利主播”人设及其对应的主题与关键词
- 在 `sd_service.py` 中新增 `PERSONA_SD_PROFILES` 字典,为每个人设定义视觉增强词、风格后缀和 LLM 绘图指导
- 新增 `get_persona_sd_profile` 函数,根据人设文本匹配对应的视觉配置

♻️ refactor(llm): 重构 SD 绘图提示词生成以支持人设

- 修改 `LLMService.get_sd_prompt_guide` 函数签名,新增 `persona` 参数
- 在生成的绘图指南中,根据匹配到的人设追加特定的视觉风格指导文本
- 针对“赛博AI虚拟博主”人设,调整反 AI 检测提示,允许使用高质量词汇和专业光效
- 更新所有调用 `get_sd_prompt_guide` 的地方(如文案生成函数),传入 `persona` 参数

♻️ refactor(sd): 重构文生图服务以支持人设视觉增强

- 修改 `SDService.txt2img` 函数签名,新增 `persona` 参数
- 在生成最终提示词时,注入人设特定的增强词(`prompt_boost`)和风格词(`prompt_style`)
- 在生成最终负面提示词时,追加人设特定的额外负面词(`negative_extra`)
- 增加人设视觉增强已注入的日志信息

🔧 chore(config): 更新默认人设配置

- 将 `config_manager.py` 中的默认 `persona` 从“身材管理健身美女”更新为“性感福利主播”

🔧 chore(main): 更新 UI 函数签名以传递人设参数

- 更新 `generate_images` 函数签名,新增 `persona_text` 参数,并在内部解析为人设对象
- 更新 `auto_publish_once` 和 `generate_to_queue` 函数中调用 `sd_svc.txt2img` 的地方,传入 `persona` 参数
- 更新 Gradio 界面中 `btn_gen_img` 的点击事件,将 `persona` 输入传递给 `generate_images` 函数
2026-02-10 22:29:55 +08:00

909 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
LLM 服务模块
封装对 OpenAI 兼容 API 的调用,包含文案生成、热点分析、评论回复等 Prompt
"""
import requests
import json
import re
import random
import logging
logger = logging.getLogger(__name__)
# ================= Prompt 模板 =================
PROMPT_COPYWRITING = """
你是一个真实的小红书博主,正在用手机编辑一篇笔记。你不是内容专家,你只是一个想认真分享的普通人。
【你的写作状态】:
想象你刚体验完某件事(试了一个产品/去了一个地方/学到一个技巧),打开小红书想跟朋友们聊聊。你不会字字斟酌,就是把感受写出来。
【标题规则】(严格执行)
1. 长度限制:必须控制在 18 字以内含Emoji绝对不能超过 20 字!
2. 像你发朋友圈的语气,口语化、有情绪感。可以用疑问句、感叹句、省略句
3. 可以加1-2个emoji但不要堆砌
4. 禁止广告法违禁词("第一" "" "顶级"等)
5. 好的标题示例:"后悔没早买!这个真的绝了" "姐妹们被我找到了" "求求你们别再踩这个坑了"
6. 避免AI感标题不要用"震惊!" "必看!" "干货"这种过于营销的开头
【正文规则——像说话一样写】:
1. 想象你在跟闺蜜/朋友面对面聊天,把她说的话打下来就对了
2. 正文控制在 400-600 字
3. 不要像写作文一样"首先、其次、最后",用碎碎念的方式自然展开
4. 可以有小情绪:吐槽、感叹、自嘲、开心炸裂都行
5. emoji不要每句话都有穿插在情绪高点就好一段文字2-4个emoji足够
6. 真人笔记特征:
- 会有"话说" "对了" "哦对" 这种口语转折
- 会有"不是我说" "真的会谢" "笑不活了"这种网络表达
- 会有不完整的句子、省略号、波浪号
- 段落长短不一,有的段就一句话,有的段会稍长
7. 绝对禁止:
"值得一提的是" "需要注意的是" "总的来说" "综上所述"
"作为一个xxx" "在这里给大家分享"
❌ 排比句、对仗工整的总结
❌ 每段都很整齐的1234结构
"小伙伴们" "宝子们" 等过度热情的称呼(偶尔一次可以)
8. 结尾加 5-8 个相关话题标签(#)
【!!极其重要 — 反AI检测规则!!】:
你的文案会被AI检测工具审查必须通过以下防检测措施
1. 句子长度必须参差不齐有的句子就3-5个字有的20多字绝对不能每句都差不多长
2. 不要所有段落逻辑都很通顺——真人写东西会突然跑题、突然想起另一件事
3. 偶尔犯一点小"不规范":比如某句话没写完就换话题了、用了个不太准确的词
4. 标点符号不要太规范:真人用手机打字经常漏掉逗号、句号不加,偶尔连续用两个问号??
5. 用词不要太"正确":真人会说"巨好用" "绝绝子" "无敌了" 而不是"非常好用" "十分出色"
6. 避免完美的逻辑链条:不要每段都工工整整地推进论点,真人笔记是跳跃式的
7. 偶尔口语化到"学渣"程度:"就 很那个 你懂的" "属于是" "多少有点" "怎么说呢"
8. 绝对不要用"然而" "此外" "因此" "尽管" "虽然...但是..."这些书面连接词
【绘图 Prompt】
{sd_prompt_guide}
返回 JSON 格式:
{{"title": "...", "content": "...", "sd_prompt": "...", "tags": ["标签1", "标签2", ...]}}
"""
PROMPT_PERFORMANCE_ANALYSIS = """
你是一个有实战经验的小红书运营数据分析师。下面是一个博主已发布的笔记数据,按互动量从高到低排列:
{note_data}
【权重学习分析任务】:
请深度分析这些笔记的互动数据,找出「什么样的内容最受欢迎」的规律。
请分析以下维度:
1. **高表现内容特征**:表现好的笔记有什么共同特征?主题、标题套路、风格、标签……越具体越好
2. **低表现内容反思**:表现差的笔记问题出在哪?是选题不行、标题没吸引力、还是其他原因?
3. **用户偏好画像**:从数据反推,关注这个账号的用户最喜欢什么样的内容?
4. **内容优化建议**:给出 5 个具体的下一步内容方向,每个都要说清楚为什么推荐
5. **标题优化建议**:总结 3 个高互动标题的写法模板,直接给出可套用的句式
6. **最佳实践标签**:推荐 10 个最有流量潜力的标签组合
注意:
- 用数据说话,不要空谈
- 建议要具体到可以直接执行的程度
- 不要说废话和套话
返回 JSON 格式:
{{"high_perform_features": "...", "low_perform_issues": "...", "user_preference": "...", "content_suggestions": [{{"topic": "...", "reason": "...", "priority": 1-5}}], "title_templates": ["模板1", "模板2", "模板3"], "recommended_tags": ["标签1", "标签2", ...]}}
"""
PROMPT_WEIGHTED_COPYWRITING = """
你是一个真实的小红书博主,正在用手机编辑一篇笔记。
【智能学习洞察——基于你过去笔记的数据分析】:
{weight_insights}
【创作要求】:
基于以上数据洞察,请创作一篇更容易获得高互动的笔记。要把数据分析的结论融入创作中,但写出来的内容要自然,不能看出是"为了数据而写"
【标题规则】(严格执行)
1. 长度限制:必须控制在 18 字以内含Emoji绝对不能超过 20 字!
2. 参考高互动标题的模式:{title_advice}
3. 口语化,有情绪感,像发朋友圈
4. 禁止广告法违禁词
【正文规则——像说话一样写】:
1. 想象你在跟闺蜜/朋友面对面聊天
2. 正文控制在 400-600 字
3. 自然展开,不要分点罗列
4. 可以有小情绪:吐槽、感叹、自嘲、开心炸裂
5. emoji穿插在情绪高点不要每句都有
6. 绝对禁止 AI 痕迹书面用语
【推荐标签】:优先使用这些高权重标签 → {hot_tags}
【绘图 Prompt】
{sd_prompt_guide}
返回 JSON 格式:
{{"title": "...", "content": "...", "sd_prompt": "...", "tags": ["标签1", "标签2", ...]}}
"""
PROMPT_HOTSPOT_ANALYSIS = """
你是一个有实战经验的小红书运营人。下面是搜索到的热门笔记信息:
{feed_data}
你要像一个真正做过内容运营的人来分析这些数据,说话方式接地气一点,不要写得像论文。
请分析以下方面:
1. **热门选题方向**:提炼 3-5 个最火的细分选题。不要只写大方向如"美妆""穿搭",要具体到用户真正感兴趣的切入角度
2. **标题拆解**:这些高赞标题的套路是什么?比如用了什么句式、什么情绪钩子、什么悬念。直接举例说明,不要写得很理论
3. **内容结构**:爆款笔记是怎么组织内容的?是先说痛点再给方案?还是直接晒结果?用大白话说清楚
4. **推荐模仿方案**:给出 3 个具体的选题建议。要具体到"如果是我来写,我会这样切入"的程度,不要泛泛而谈
注意:
- 你的分析是给一个普通创作者看的,不是给甲方看报告
- 说人话,不要堆砌专业术语
- "内容矩阵""赛道""心智占领""用户画像"这种运营黑话能不用就不用
- 建议要可执行,不要写"结合自身特色打造差异化内容"这种废话
返回 JSON 格式:
{{"hot_topics": ["...", "..."], "title_patterns": ["...", "..."], "content_structure": "...", "suggestions": [{{"topic": "...", "reason": "..."}}]}}
"""
PROMPT_COMMENT_REPLY = """
你现在是一个真实的小红书博主,你的人设是:{persona}
有粉丝在你的笔记下留言了,你需要像真人一样自然回复。
【核心模拟原则——极其重要】:
你必须模拟一个真实的、活生生的小红书用户在手机上随手打字的状态:
- 打字随意,不追求语法完美,偶尔可以有口语化的省略
- 真人不会每句话都加 emoji有时一个都不加也很正常
- 绝对不要用"哈哈哈"开头(这是 AI 最常见的暴露特征)
- 不要用书面化的转折词("不过""然而""但是""确实"),用口语("""""""是吧""对对对""哇塞"
- 回复可以很短,甚至只有几个字("谢谢宝!""对的呀""试试看~"
- 绝对不要用逗号和句号写出完整的完美句子,真人打字经常没有标点
- 不要用感叹号过多真人回复感叹号一般不超过1个
- 不要用""""""等语气词堆砌在一起
【回复策略】:
- 如果是夸奖/支持 → 简短感谢,可以适当撒娇或害羞("嘿嘿被你发现了""爱你""抱住"
- 如果是提问 → 直接回答,不要先说"这个问题很好"之类的废话
- 如果是质疑/负面 → 轻松带过或幽默化解,不要长篇大论解释
- 如果是分享经历 → 表示共鸣,简短回应
- 如果评论很简短(比如"好看""不错" → 你的回复也应该很简短
【禁止出现的 AI 特征】:
"谢谢你的xxx" "感谢你的分享/支持/喜欢"
"作为一个xxx" "我觉得xxx"
❌ 完整的主谓宾结构句子
❌ 连续使用3个以上emoji
"哈哈哈"开头
❌ 每句话都有标点符号
❌ 排比句、对仗句
"宝子""姐妹""家人们"等称呼(除非人设就是这种风格)
笔记标题:{post_title}
粉丝评论:{comment}
直接输出回复内容,不要有任何解释。回复长度请匹配对方评论的长度——对方说的短你也短,对方说的长你可以稍长一点。
"""
PROMPT_PROACTIVE_COMMENT = """
你必须完美伪装成一个真实的小红书用户。你的人设是:{persona}
你正在刷小红书,看到了一篇笔记,你想留一条看起来像真人写的评论。
【核心!!——你是一个在手机上随手打字的普通人】:
把自己想象成一个躺在沙发上刷手机的人,看到一篇笔记后随手评论。
你打字很随意,不会字字斟酌,想到什么说什么。
【真人评论特征——必须遵循】:
1. 长度自然变化:有时候一句话("救命也太好看了吧"有时候两三句极少超过50字
2. 真人打字习惯:
- 经常省略主语("看完立马下单了" 而不是 "我看完后立马下单了"
- 会用缩写和网络用语("绝绝子""yyds""蹲一个""dd""awsl"
- 感叹用语如 "天哪""救命""啊啊啊""绝了" 而不是文绉绉的"真的很棒"
- 偶尔打错字也ok但不要刻意
3. emoji 使用规则:
- 50%的概率不加任何emoji
- 加的话最多1-2个而且偏好 😭🫠❤️🥺😍 这类情绪化的
- 不要用 ✨💫🌟 这种博主式的装饰emoji
4. 绝对不要分点列举真人评论从不分1234条说
5. 不要用完整标点,真人评论经常没逗号句号
【评论类型——随机选择一种自然风格】:
- 分享真实感受("这个颜色实物真的绝了 上次路过柜台试了一下就走不动了"
- 提一个具体问题("这个是什么色号呀""博主身高多少 我怕买了不合适"
- 表达种草("看完直接去搜了""钱包在哭泣"
- 补充相关经验("我之前买过xxx 感觉跟这个搭也蛮好看的"
- 简短共鸣("真的!""笑死""太真实了""懂了"
【绝对禁止——这些是AI评论的特征】
"写得真好" "内容很有价值" "干货满满" "收藏了"
"博主太厉害了" "学到了" "受益匪浅" "非常实用"
"我也觉得xxx" "我认为xxx" 这种过于理性客观的表达
"首先...其次...最后..." 任何分点罗列
❌ 以"哈哈"开头
❌ 超过3个emoji
❌ 完整规范的标点使用
❌ 每句话都很完整很正式
❌ 同时出现""和emoji选一个就够了
❌ 把笔记标题的关键词重复一遍(比如笔记标题说"穿搭"你就评论"穿搭真好看"
【笔记信息】:
标题:{post_title}
正文摘要:{post_content}
【已有评论参考(避免重复)】:
{existing_comments}
请直接输出一条评论不要有任何解释或前缀。记住你是一个真人不是AI。
"""
PROMPT_COPY_WITH_REFERENCE = """
你是一个真实的小红书博主,正在参考一些热门笔记来写一篇自己的原创内容。
你不是在写营销文案,你只是觉得这些笔记写得不错,想借鉴思路写一篇自己的体验分享。
【参考笔记】:
{reference_notes}
【创作主题】:{topic}
【风格要求】:{style}
【标题规则】:
1. 长度限制:必须控制在 18 字以内含Emoji绝对不能超过 20 字!
2. 学习参考笔记标题的情绪感和口语感,但内容完全原创
3. 写得像你发给朋友看的那种,不要像广告
【正文规则——写得像真人】:
1. 想象你是刚体验完然后打开小红书写笔记,把你的真实感受和过程写出来
2. 正文控制在 400-600 字
3. 真人写法:
- 开头可以直接说事,不需要"嗨大家好"之类的开场白
- 中间夹杂一些个人感受和小吐槽("一开始还在犹豫 结果用了之后真香"
- 不要面面俱到什么优点都说一遍挑2-3个最有感触的重点说
- 可以适当说一两个小缺点,让内容更真实("唯一的缺点就是xxx 但瑕不掩瑜"
- 段落自然分割,有的段一两句,有的段稍长
4. emoji 穿插在情绪高点,不要每句都有,整篇 6-10 个足够
5. 绝对禁止:
❌ 排比句、对仗句("不仅...而且..." "既...又..."
"值得一提" "需要注意" "总结一下" 等总结性书面用语
❌ 每个段落都很工整的1234结构
❌ 面面俱到地罗列所有优点
6. 结尾加 5-8 个话题标签(#)
【绘图 Prompt】
{sd_prompt_guide}
返回 JSON 格式:
{{"title": "...", "content": "...", "sd_prompt": "...", "tags": ["标签1", "标签2", ...]}}
"""
class LLMService:
"""LLM API 服务封装"""
# 当主模型返回空内容时,依次尝试的备选模型列表
FALLBACK_MODELS = ["deepseek-v3", "gemini-2.5-flash", "deepseek-v3.1"]
def __init__(self, api_key: str, base_url: str, model: str = "gpt-3.5-turbo"):
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.model = model
@staticmethod
def get_sd_prompt_guide(sd_model_name: str = None, persona: str = None) -> str:
"""根据当前 SD 模型 + 人设 生成 LLM 使用的绘图 Prompt 指南(含反 AI 检测指导 + 人设视觉风格)"""
from sd_service import SD_MODEL_PROFILES, detect_model_profile, get_persona_sd_profile
key = detect_model_profile(sd_model_name) if sd_model_name else "juggernautXL"
profile = SD_MODEL_PROFILES.get(key, SD_MODEL_PROFILES["juggernautXL"])
arch = profile.get("arch", "sdxl")
display = profile.get("display_name", key)
desc = profile.get("description", "")
# 通用反 AI 检测 prompt 技巧
anti_detect_tips = (
"\n\n【重要 - 反AI检测】生成的图片需要尽量像真实手机拍摄\n"
"- 在 prompt 中加入真实感关键词shot on iPhone, casual snapshot, real life, candid photo\n"
"- 加入微小不完美slight motion blur, natural background, everyday environment\n"
"- 避免过度完美的构图词:不要用 perfect composition, symmetrical, flawless 等\n"
"- 光线自然化:用 natural daylight, indoor ambient light, window light 而非 studio lighting\n"
"- 模拟手机拍照特征phone camera, slightly overexposed, casual angle, not centered\n"
)
# 人设视觉风格指导 (追加于末尾)
persona_guide = ""
persona_sd = get_persona_sd_profile(persona) if persona else None
if persona_sd and persona_sd.get("llm_guide"):
persona_guide = persona_sd["llm_guide"]
# 赛博AI虚拟博主不需要反AI检测大方承认是AI
if "赛博AI虚拟博主" in (persona or ""):
anti_detect_tips = (
"\n\n【注意 - 本人设无需反AI检测】该博主大方承认是AI生成不需要伪装真实感。\n"
"- 尽情使用 perfect, flawless, ultra detailed 等高质量词\n"
"- 追求视觉震撼和极致精致,不需要模拟手机拍照\n"
"- 可以使用 studio lighting, dramatic lighting, cinematic 等专业光效词\n"
)
if key == "majicmixRealistic":
base = (
f"生成 Stable Diffusion 英文提示词,当前使用模型: {display} ({desc})\n"
"该模型擅长东亚网红/朋友圈自拍风格,请按以下规则生成 sd_prompt\n"
"- 人物要求(最重要!):必须是东亚面孔中国人\n"
"- 推荐使用 (权重:数值) 语法加强关键词,例如 (asian girl:1.3), (best quality:1.4)\n"
"- 风格关键词RAW photo, realistic, photorealistic, natural makeup, instagram aesthetic\n"
"- 氛围词soft lighting, warm tone, natural skin texture, phone camera feel\n"
"- 非常适合:自拍、穿搭展示、美妆效果、生活日常、闺蜜合照风格\n"
"- 画面要有「朋友圈精选照片」的感觉,自然不做作\n"
"- 用英文逗号分隔"
)
elif key == "realisticVision":
base = (
f"生成 Stable Diffusion 英文提示词,当前使用模型: {display} ({desc})\n"
"该模型擅长写实纪实摄影风格,请按以下规则生成 sd_prompt\n"
"- 人物要求(最重要!):必须是东亚面孔中国人\n"
"- 推荐使用 (权重:数值) 语法,例如 (realistic:1.4), (photorealistic:1.4)\n"
"- 风格关键词RAW photo, DSLR, documentary style, street photography, film color grading\n"
"- 质感词skin pores, detailed skin texture, natural imperfections, real lighting\n"
"- 镜头感shot on Canon/Sony, 85mm lens, f/1.8, depth of field\n"
"- 非常适合:街拍、纪实风、旅行照、真实场景、有故事感的画面\n"
"- 画面要有「专业摄影师抓拍」的质感,保留真实皮肤纹理\n"
"- 用英文逗号分隔"
)
else: # juggernautXL (SDXL)
base = (
f"生成 Stable Diffusion 英文提示词,当前使用模型: {display} ({desc})\n"
"该模型为 SDXL 架构,擅长电影级大片质感,请按以下规则生成 sd_prompt\n"
"- 人物要求(最重要!):必须是东亚面孔中国人,绝对禁止西方人特征\n"
"- 不要使用 (权重:数值) 括号语法SDXL 模型直接用逗号分隔即可\n"
"- 质量词masterpiece, best quality, ultra detailed, 8k uhd, high resolution\n"
"- 风格photorealistic, cinematic lighting, cinematic composition, commercial photography\n"
"- 光影volumetric lighting, ray tracing, golden hour, studio lighting\n"
"- 非常适合:商业摄影、时尚大片、复杂光影场景、杂志封面风格\n"
"- 画面要有「电影画面/杂志大片」的高级感\n"
"- 用英文逗号分隔"
)
return base + anti_detect_tips + persona_guide
def _chat(self, system_prompt: str, user_message: str,
json_mode: bool = True, temperature: float = 0.8) -> str:
"""底层聊天接口含空返回检测、json_mode 回退、模型降级)"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
if json_mode:
user_message = user_message + "\n请以json格式返回。"
# 构建要尝试的模型列表:主模型 + 备选模型(去重)
models_to_try = [self.model] + [m for m in self.FALLBACK_MODELS if m != self.model]
last_error = None
for model_idx, current_model in enumerate(models_to_try):
payload = {
"model": current_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
"temperature": temperature,
}
if json_mode:
payload["response_format"] = {"type": "json_object"}
try:
resp = requests.post(
f"{self.base_url}/chat/completions",
headers=headers, json=payload, timeout=90
)
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
# 检测空返回 — 如果启用了 json_mode 且返回为空,回退去掉 response_format 重试
if not content or not content.strip():
if json_mode:
logger.warning("[%s] LLM 返回空内容 (json_mode=True),关闭 json_mode 回退重试...", current_model)
payload.pop("response_format", None)
resp2 = requests.post(
f"{self.base_url}/chat/completions",
headers=headers, json=payload, timeout=90
)
resp2.raise_for_status()
content = resp2.json()["choices"][0]["message"]["content"]
if not content or not content.strip():
# 当前模型完全无法返回内容,尝试下一个模型
if model_idx < len(models_to_try) - 1:
next_model = models_to_try[model_idx + 1]
logger.warning("[%s] 返回空内容,自动降级到模型: %s", current_model, next_model)
continue
raise RuntimeError(f"所有模型均返回空内容(已尝试: {', '.join(models_to_try[:model_idx+1])}")
if model_idx > 0:
logger.info("模型降级成功: %s%s", self.model, current_model)
return content
except requests.exceptions.HTTPError as e:
status = getattr(resp, 'status_code', 0)
body = getattr(resp, 'text', '')[:300]
# 某些模型/提供商不支持 response_format自动回退重试
if json_mode and status in (400, 422, 500):
logger.warning("[%s] json_mode 请求失败 (HTTP %s),关闭 response_format 回退重试...", current_model, status)
payload.pop("response_format", None)
try:
resp2 = requests.post(
f"{self.base_url}/chat/completions",
headers=headers, json=payload, timeout=90
)
resp2.raise_for_status()
content = resp2.json()["choices"][0]["message"]["content"]
if content and content.strip():
if model_idx > 0:
logger.info("模型降级成功: %s%s", self.model, current_model)
return content
except Exception:
pass
# 当前模型失败,尝试下一个
last_error = ConnectionError(f"LLM API 错误 ({status}): {body}")
if model_idx < len(models_to_try) - 1:
logger.warning("[%s] HTTP %s 失败,降级到: %s", current_model, status, models_to_try[model_idx + 1])
continue
raise last_error
except requests.exceptions.Timeout:
last_error = TimeoutError(f"[{current_model}] LLM 请求超时")
if model_idx < len(models_to_try) - 1:
logger.warning("[%s] 请求超时,降级到: %s", current_model, models_to_try[model_idx + 1])
continue
raise TimeoutError("LLM 请求超时,所有模型均超时,请检查网络")
except (ConnectionError, RuntimeError):
raise
except Exception as e:
last_error = RuntimeError(f"LLM 调用异常: {e}")
if model_idx < len(models_to_try) - 1:
logger.warning("[%s] 调用异常 (%s),降级到: %s", current_model, e, models_to_try[model_idx + 1])
continue
raise last_error
raise last_error or RuntimeError("LLM 调用失败: 未知错误")
def _parse_json(self, text: str) -> dict:
"""从 LLM 返回文本中解析 JSON多重容错"""
if not text or not text.strip():
raise ValueError("LLM 返回内容为空,无法解析 JSON")
raw = text.strip()
# 策略1: 去除 markdown 代码块
cleaned = re.sub(r"```(?:json)?\s*", "", raw)
cleaned = re.sub(r"```", "", cleaned).strip()
# 策略2: 直接解析
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
# 策略3: 提取最外层的 { ... } 块
match = re.search(r'(\{[\s\S]*\})', cleaned)
if match:
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
pass
# 策略4: 逐行查找 JSON 开始位置
for i, ch in enumerate(cleaned):
if ch == '{':
try:
return json.loads(cleaned[i:])
except json.JSONDecodeError:
pass
break
# 策略5: 尝试修复常见问题(尾部多余逗号、缺少闭合括号)
try:
# 去除尾部多余逗号
fixed = re.sub(r',\s*([}\]])', r'\1', cleaned)
return json.loads(fixed)
except json.JSONDecodeError:
pass
# 全部失败,打日志并抛出有用的错误信息
preview = raw[:500] if len(raw) > 500 else raw
logger.error("JSON 解析全部失败LLM 原始返回: %s", preview)
raise ValueError(
f"LLM 返回内容无法解析为 JSON。\n"
f"返回内容前200字: {raw[:200]}\n\n"
f"💡 可能原因: 模型不支持 JSON 输出格式,建议更换模型重试"
)
# ---------- 业务方法 ----------
def get_models(self) -> list[str]:
"""获取可用模型列表"""
url = f"{self.base_url}/models"
headers = {"Authorization": f"Bearer {self.api_key}"}
try:
resp = requests.get(url, headers=headers, timeout=10)
resp.raise_for_status()
text = resp.text.strip()
if not text:
logger.warning("GET %s 返回空响应", url)
return []
data = resp.json()
return [item["id"] for item in data.get("data", [])]
except Exception as e:
logger.warning("获取模型列表失败 (%s): %s", url, e)
return []
def generate_copy(self, topic: str, style: str, sd_model_name: str = None, persona: str = None) -> dict:
"""生成小红书文案含重试逻辑自动适配SD模型支持人设"""
sd_guide = self.get_sd_prompt_guide(sd_model_name, persona=persona)
system_prompt = PROMPT_COPYWRITING.format(sd_prompt_guide=sd_guide)
user_msg = f"主题:{topic}\n风格:{style}"
if persona:
user_msg = f"【博主人设】:{persona}\n请以此人设的视角和风格创作。\n\n{user_msg}"
last_error = None
for attempt in range(2):
try:
# 第二次尝试不使用 json_mode兼容不支持的模型
use_json_mode = (attempt == 0)
content = self._chat(
system_prompt,
user_msg,
json_mode=use_json_mode,
temperature=0.92,
)
data = self._parse_json(content)
# 强制标题长度限制
title = data.get("title", "")
if len(title) > 20:
title = title[:20]
data["title"] = title
# 去 AI 化后处理
if "content" in data:
data["content"] = self._humanize_content(data["content"])
return data
except (json.JSONDecodeError, ValueError) as e:
last_error = e
if attempt == 0:
logger.warning("文案生成 JSON 解析失败 (尝试 %d/2): %s,将关闭 json_mode 重试", attempt + 1, e)
continue
else:
logger.error("文案生成 JSON 解析失败 (尝试 %d/2): %s", attempt + 1, e)
raise RuntimeError(f"文案生成失败: LLM 返回无法解析为 JSON已重试 2 次。\n最后错误: {last_error}")
def generate_copy_with_reference(self, topic: str, style: str,
reference_notes: str, sd_model_name: str = None, persona: str = None) -> dict:
"""参考热门笔记生成文案含重试逻辑自动适配SD模型支持人设"""
sd_guide = self.get_sd_prompt_guide(sd_model_name, persona=persona)
prompt = PROMPT_COPY_WITH_REFERENCE.format(
reference_notes=reference_notes, topic=topic, style=style,
sd_prompt_guide=sd_guide,
)
user_msg = f"请创作关于「{topic}」的小红书笔记"
if persona:
user_msg = f"【博主人设】:{persona}\n请以此人设的视角和风格创作。\n\n{user_msg}"
last_error = None
for attempt in range(2):
try:
use_json_mode = (attempt == 0)
content = self._chat(
prompt, user_msg,
json_mode=use_json_mode, temperature=0.92,
)
data = self._parse_json(content)
title = data.get("title", "")
if len(title) > 20:
data["title"] = title[:20]
if "content" in data:
data["content"] = self._humanize_content(data["content"])
return data
except (json.JSONDecodeError, ValueError) as e:
last_error = e
if attempt == 0:
logger.warning("参考文案生成 JSON 解析失败 (尝试 %d/2): %s,将关闭 json_mode 重试", attempt + 1, e)
continue
else:
logger.error("参考文案生成 JSON 解析失败 (尝试 %d/2): %s", attempt + 1, e)
raise RuntimeError(f"参考文案生成失败: LLM 返回无法解析为 JSON已重试 2 次。\n最后错误: {last_error}")
def analyze_hotspots(self, feed_data: str) -> dict:
"""分析热门内容趋势(含重试逻辑)"""
prompt = PROMPT_HOTSPOT_ANALYSIS.format(feed_data=feed_data)
last_error = None
for attempt in range(2):
try:
use_json_mode = (attempt == 0)
content = self._chat(prompt, "请分析以上热门笔记数据",
json_mode=use_json_mode)
return self._parse_json(content)
except (json.JSONDecodeError, ValueError) as e:
last_error = e
if attempt == 0:
logger.warning("热点分析 JSON 解析失败 (尝试 %d/2): %s,将关闭 json_mode 重试", attempt + 1, e)
continue
else:
logger.error("热点分析 JSON 解析失败 (尝试 %d/2): %s", attempt + 1, e)
raise RuntimeError(f"热点分析失败: LLM 返回无法解析为 JSON已重试 2 次。\n最后错误: {last_error}")
@staticmethod
def _humanize_content(text: str) -> str:
"""后处理: 深度去除 AI 书面痕迹,模拟真人手机打字风格"""
t = text
# ========== 第一层: 替换过于书面化/AI化的表达 ==========
ai_phrases = {
"值得一提的是": "对了",
"需要注意的是": "不过要注意",
"总的来说": "反正",
"综上所述": "总之",
"总而言之": "总之",
"不仅如此": "而且",
"与此同时": "然后",
"除此之外": "还有",
"众所周知": "",
"毋庸置疑": "",
"不言而喻": "",
"在这里给大家分享": "来分享",
"在此分享给大家": "分享一下",
"接下来让我们": "",
"话不多说": "",
"废话不多说": "",
"下面我来": "",
"让我来": "",
"首先我要说": "先说",
"我认为": "我觉得",
"我相信": "我觉得",
"事实上": "其实",
"实际上": "其实",
"毫无疑问": "",
"不可否认": "",
"客观来说": "",
"坦白说": "",
"具体而言": "就是",
"简而言之": "就是说",
"换句话说": "就是",
"归根结底": "说白了",
"由此可见": "",
"正如我所说": "",
"正如前文所述": "",
"在我看来": "我觉得",
"从某种程度上说": "",
"在一定程度上": "",
"非常值得推荐": "真的可以试试",
"强烈推荐": "真心推荐",
"性价比极高": "性价比很高",
"给大家安利": "安利",
"为大家推荐": "推荐",
"希望对大家有所帮助": "",
"希望能帮到大家": "",
"以上就是": "",
"感谢阅读": "",
"感谢大家的阅读": "",
}
for old, new in ai_phrases.items():
t = t.replace(old, new)
# ========== 第二层: 去掉分点罗列感 ==========
t = re.sub(r'(?m)^首先[,:\s]*', '', t)
t = re.sub(r'(?m)^其次[,:\s]*', '', t)
t = re.sub(r'(?m)^最后[,:\s]*', '', t)
t = re.sub(r'(?m)^再者[,:\s]*', '', t)
t = re.sub(r'(?m)^另外[,:\s]*', '', t)
# 去序号: "1. " "2、" "①" 等
t = re.sub(r'(?m)^[①②③④⑤⑥⑦⑧⑨⑩]\s*', '', t)
t = re.sub(r'(?m)^[1-9][.、))]\s*', '', t)
# ========== 第三层: 去掉AI常见的空洞开头 ==========
for prefix in ["嗨大家好!", "嗨,大家好!", "大家好,", "大家好!",
"哈喽大家好!", "Hello大家好", "嗨~", "hey~",
"各位姐妹大家好!", "各位宝子们好!"]:
if t.startswith(prefix):
t = t[len(prefix):].strip()
# ========== 第四层: 标点符号真人化 ==========
# AI 特征: 每句话都有完整标点 → 真人经常不加标点或只用逗号
sentences = t.split('\n')
humanized_lines = []
for line in sentences:
if not line.strip():
humanized_lines.append(line)
continue
# 随机去掉句末句号 (真人经常不打句号)
if line.rstrip().endswith('') and random.random() < 0.35:
line = line.rstrip()[:-1]
# 随机把部分逗号替换成空格或什么都不加 (模拟打字不加标点)
if random.random() < 0.15:
# 只替换一个逗号
comma_positions = [m.start() for m in re.finditer(r'[,]', line)]
if comma_positions:
pos = random.choice(comma_positions)
line = line[:pos] + ' ' + line[pos+1:]
humanized_lines.append(line)
t = '\n'.join(humanized_lines)
# ========== 第五层: 随机添加真人口语化元素 ==========
# 在段落开头随机插入口语衔接词
oral_connectors = [
"对了 ", "哦对 ", "话说 ", "然后 ", "就是说 ", "emmm ", "",
"说真的 ", "不是 ", "离谱的是 ", "我发现 ",
]
paragraphs = t.split('\n\n')
if len(paragraphs) > 2:
# 在中间段落随机加1-2个口语衔接词
inject_count = random.randint(1, min(2, len(paragraphs) - 2))
inject_indices = random.sample(range(1, len(paragraphs)), inject_count)
for idx in inject_indices:
if paragraphs[idx].strip() and not any(paragraphs[idx].strip().startswith(c.strip()) for c in oral_connectors):
connector = random.choice(oral_connectors)
paragraphs[idx] = connector + paragraphs[idx].lstrip()
t = '\n\n'.join(paragraphs)
# ========== 第六层: 句子长度打散 ==========
# AI 特征: 句子长度高度均匀 → 真人笔记长短参差不齐
# 随机把一些长句用换行打散
lines = t.split('\n')
final_lines = []
for line in lines:
# 超过60字的行, 随机在一个位置断句
if len(line) > 60 and random.random() < 0.3:
# 找到中间附近的标点位置断句
mid = len(line) // 2
best_pos = -1
for offset in range(0, mid):
for check_pos in [mid + offset, mid - offset]:
if 0 < check_pos < len(line) and line[check_pos] in ',。!?、,':
best_pos = check_pos
break
if best_pos > 0:
break
if best_pos > 0:
final_lines.append(line[:best_pos + 1])
final_lines.append(line[best_pos + 1:].lstrip())
continue
final_lines.append(line)
t = '\n'.join(final_lines)
# ========== 第七层: 随机注入微小不完美 ==========
# 真人打字偶尔有重复字、多余空格等
if random.random() < 0.2:
# 随机在某处加一个波浪号或省略号
insert_chars = ['~', '...', '', '..']
lines = t.split('\n')
if lines:
target = random.randint(0, len(lines) - 1)
if lines[target].rstrip() and not lines[target].rstrip()[-1] in '~.。!?!?':
lines[target] = lines[target].rstrip() + random.choice(insert_chars)
t = '\n'.join(lines)
# ========== 第八层: 清理 ==========
# 去掉连续3个以上的 emoji
t = re.sub(r'([\U0001F600-\U0001F9FF\u2600-\u27BF])\1{2,}', r'\1\1', t)
# 清理多余空行
t = re.sub(r'\n{3,}', '\n\n', t)
# 清理行首多余空格 (手机打字不会缩进)
t = re.sub(r'(?m)^[ \t]+', '', t)
return t.strip()
@staticmethod
def _humanize(text: str) -> str:
"""后处理: 深度去除 AI 评论/回复中的非人类痕迹"""
t = text.strip()
# 去掉前后引号包裹
if (t.startswith('"') and t.endswith('"')) or (t.startswith("'") and t.endswith("'")):
t = t[1:-1].strip()
# 去掉 AI 常见的前缀
for prefix in ["回复:", "回复:", "评论:", "评论:", "以下是", "好的,",
"当然,", "当然!", "谢谢你的", "感谢你的", "好的!",
"嗯,"]:
if t.startswith(prefix):
t = t[len(prefix):].strip()
# 去掉末尾多余的句号(真人评论很少用句号结尾)
if t.endswith(""):
t = t[:-1]
# 去掉末尾的"哦" "呢" 堆叠 (AI 常见)
t = re.sub(r'[哦呢呀哈]{2,}$', lambda m: m.group()[0], t)
# 替换过于完整规范的标点为口语化
if random.random() < 0.25 and '' in t:
# 随机去掉一个逗号
comma_pos = [m.start() for m in re.finditer('', t)]
if comma_pos:
pos = random.choice(comma_pos)
t = t[:pos] + ' ' + t[pos+1:]
# 随机去掉末尾感叹号(真人不是每句都加!)
if t.endswith('') and random.random() < 0.3:
t = t[:-1]
# 限制连续 emoji最多2个
t = re.sub(r'([\U0001F600-\U0001F9FF\u2600-\u27BF])\1{2,}', r'\1\1', t)
return t
def generate_reply(self, persona: str, post_title: str, comment: str) -> str:
"""AI 生成评论回复"""
prompt = PROMPT_COMMENT_REPLY.format(
persona=persona, post_title=post_title, comment=comment
)
raw = self._chat(prompt, "请生成回复", json_mode=False, temperature=0.95)
return self._humanize(raw)
def generate_proactive_comment(self, persona: str, post_title: str,
post_content: str, existing_comments: str = "") -> str:
"""AI 生成主动评论"""
prompt = PROMPT_PROACTIVE_COMMENT.format(
persona=persona, post_title=post_title,
post_content=post_content,
existing_comments=existing_comments or "暂无评论",
)
raw = self._chat(prompt, "请生成评论", json_mode=False, temperature=0.95)
return self._humanize(raw)
def analyze_note_performance(self, note_data: str) -> dict:
"""AI 深度分析笔记表现,生成内容策略建议"""
prompt = PROMPT_PERFORMANCE_ANALYSIS.format(note_data=note_data)
last_error = None
for attempt in range(2):
try:
use_json_mode = (attempt == 0)
content = self._chat(prompt, "请深度分析以上笔记数据,找出规律并给出优化建议",
json_mode=use_json_mode, temperature=0.7)
return self._parse_json(content)
except (json.JSONDecodeError, ValueError) as e:
last_error = e
if attempt == 0:
logger.warning("表现分析 JSON 解析失败 (尝试 %d/2): %s", attempt + 1, e)
continue
raise RuntimeError(f"笔记表现分析失败: {last_error}")
def generate_weighted_copy(self, topic: str, style: str,
weight_insights: str, title_advice: str,
hot_tags: str, sd_model_name: str = None, persona: str = None) -> dict:
"""基于权重学习生成高互动潜力的文案自动适配SD模型支持人设"""
sd_guide = self.get_sd_prompt_guide(sd_model_name, persona=persona)
prompt = PROMPT_WEIGHTED_COPYWRITING.format(
weight_insights=weight_insights,
title_advice=title_advice,
hot_tags=hot_tags,
sd_prompt_guide=sd_guide,
)
user_msg = f"主题:{topic}\n风格:{style}\n请创作一篇基于数据洞察的高质量小红书笔记"
if persona:
user_msg = f"【博主人设】:{persona}\n请以此人设的视角和风格创作。\n\n{user_msg}"
last_error = None
for attempt in range(2):
try:
use_json_mode = (attempt == 0)
content = self._chat(
prompt,
user_msg,
json_mode=use_json_mode,
temperature=0.92,
)
data = self._parse_json(content)
title = data.get("title", "")
if len(title) > 20:
data["title"] = title[:20]
if "content" in data:
data["content"] = self._humanize_content(data["content"])
return data
except (json.JSONDecodeError, ValueError) as e:
last_error = e
if attempt == 0:
logger.warning("加权文案生成失败 (尝试 %d/2): %s", attempt + 1, e)
continue
raise RuntimeError(f"加权文案生成失败: {last_error}")