xhs_factory/llm_service.py
zhoujie 156a18ae0c feat(analytics): 新增智能学习引擎与笔记表现分析模块
- 新增 `analytics_service.py` 模块,实现笔记数据采集、权重计算与智能分析功能
- 支持定时采集已发布笔记的互动数据(点赞、评论、收藏),并计算主题、风格、标签等多维度权重
- 提供加权随机选题功能,根据历史表现优先生成高互动潜力内容
- 集成 LLM 深度分析,生成内容策略建议与优化报告
- 新增「智能学习」UI 标签页,支持数据采集、权重计算、AI 分析与定时自动学习

♻️ refactor(llm): 重构 LLM 服务以支持多模型智能适配与加权文案生成

- 扩展 `llm_service.py`,新增 `get_sd_prompt_guide()` 方法,根据当前 SD 模型动态生成绘图提示词指南
- 新增 `PROMPT_PERFORMANCE_ANALYSIS` 与 `PROMPT_WEIGHTED_COPYWRITING` 提示词模板,支持笔记表现分析与加权文案生成
- 重构 `generate_copy()`、`generate_copy_with_reference()` 方法,支持 `sd_model_name` 与 `persona` 参数,实现多模型适配与人设融合
- 新增 `analyze_note_performance()` 与 `generate_weighted_copy()` 方法,实现 AI 深度分析与智能加权创作

♻️ refactor(sd): 重构 SD 服务以支持多模型配置系统与智能参数适配

- 重构 `sd_service.py`,引入 `SD_MODEL_PROFILES` 配置体系,支持 `majicmixRealistic`、`Realistic Vision`、`Juggernaut XL` 三款模型
- 新增 `detect_model_profile()`、`get_model_profile()`、`get_model_profile_info()` 方法,实现模型自动识别与档案信息展示
- 重构 `txt2img()` 与 `img2img()` 方法,自动根据当前模型应用最优参数、提示词前缀/后缀与反向提示词
- 更新 `get_sd_preset()` 方法,支持模型专属预设参数加载

🎨 style(config): 更新默认配置与人设池

- 更新 `config.json` 与 `config_manager.py`,将默认模型改为 `gemini-3-flash-preview`,默认人设改为「身材管理健身美女」
- 新增 `use_smart_weights` 配置项,控制是否启用智能加权发布
- 扩展 `PERSONA_POOL_MAP`,新增「身材管理健身美女」人设及其对应主题与关键词库

🔧 chore(main): 集成智能学习引擎并扩展自动发布链路

- 在 `main.py` 中实例化 `AnalyticsService`,并集成至各功能模块
- 扩展 `generate_copy()`、`generate_from_hotspot()`、`auto_publish_once()` 等方法,支持 `sd_model_name`、`persona`、`quality_mode_val` 参数传递
- 实现智能加权发布逻辑:当启用权重且数据可用时,自动选择高权重主题、风格与标签,并使用加权文案模板
- 新增「智能学习」标签页相关 UI 组件与事件处理函数,包括数据采集、权重计算、AI 分析、定时学习与加权主题预览
- 更新 SD 模型选择事件,实时显示模型档案信息卡
- 扩展自动调度器,支持智能权重、人设与画质模式的参数传递

📝 docs(changelog): 更新版本日志记录新功能与改进

- 在 `CHANGELOG.md` 中新增 `[2.1.0]` 与 `[2.2.0]` 版本记录
- 详细描述「智能学习引擎」与「多 SD 模型智能适配」两大核心功能
- 列出相关代码重构、配置更新与文件新增情况
2026-02-10 21:29:57 +08:00

736 lines
35 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 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 个相关话题标签(#)
【绘图 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) -> str:
"""根据当前 SD 模型生成 LLM 使用的绘图 Prompt 指南"""
from sd_service import SD_MODEL_PROFILES, detect_model_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", "")
if key == "majicmixRealistic":
return (
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":
return (
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)
return (
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"
"- 用英文逗号分隔"
)
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)
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)
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_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)
# 去掉AI常见的空洞开头
for prefix in ["嗨大家好!", "嗨,大家好!", "大家好,", "大家好!", "哈喽大家好!"]:
if t.startswith(prefix):
t = t[len(prefix):].strip()
# 清理多余空行
t = re.sub(r'\n{3,}', '\n\n', 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]
# 限制连续 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)
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}")