23ff63baa9
- writer.py: _expand_section() 去除 <100字阈值,始终调用 LLM 平台专属扩写
- prompt_loader.py: 新增 section_expansion_zhihu/wechat/xiaohongshu 三个独立 prompt
- admin.html: 配置管理标签页 + 敏感词/清理规则子标签 + 敏感词表格化管理(编辑/删除)
- config_items.py: PUT /sensitive-words/{id} 支持更新 word/category
- compliance_checker.py: AI 套话从 DB 加载 + 人称规则修正
- initial_data.py: PlatformConfig 字数迁移 + 新种子
- 各前端页面: LLM 配置 rate_limit 字段 + 供应商列表排序
256 lines
18 KiB
Python
256 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Prompt 配置加载器
|
||
|
||
架构:DB 为主,代码仅作初始种子。
|
||
- _PROMPT_DEFAULTS: 仅用于首次自动写入 DB(种子数据),不作为运行时 fallback
|
||
- prompt_configs 表: 运行时唯一来源,修改 prompt 直接改 DB
|
||
- DB 不可用时: 退化到代码默认值(仅用于紧急回退,不是日常模式)
|
||
|
||
新增 prompt 流程:
|
||
1. 在 _PROMPT_DEFAULTS 添加定义
|
||
2. 重启后自动补入 DB(仅当该 key 不在 DB 中时)
|
||
3. 后续修改直接在 DB 操作,不再改代码
|
||
|
||
修改 prompt 流程:
|
||
直接 UPDATE prompt_configs SET content = '...' WHERE key = '...';
|
||
"""
|
||
import os, sys
|
||
from pathlib import Path
|
||
from typing import Dict, Any, Optional
|
||
|
||
PROJECT_ROOT = Path(__file__).parent.parent
|
||
sys.path.insert(0, str(PROJECT_ROOT))
|
||
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
|
||
|
||
# ============================================================
|
||
# 种子数据(初始默认值)
|
||
# 修改 prompt 请直接在 DB 操作,不要改这里
|
||
# 仅在表里没有该 key 时用于首次填充
|
||
# ============================================================
|
||
_PROMPT_DEFAULTS = {
|
||
"topics_trends": {
|
||
"content": "你是中文互联网趋势分析师。今天是{date},请列出{domains}领域当前最值得创作的8个话题。\n\n输出要求:\n- 基于真实热点事件/数据/趋势,不要编造\n- 每个话题需包含:\n - \"domain\": 所属领域\n - \"topic\": 话题名称(15字内,有信息量)\n - \"reason\": 为什么现在讨论这个(1句话,有具体事件或数据支撑)\n - \"hot_keywords\": 3-5个搜索关键词(含1-2个长尾词)\n - \"platform\": 最适合平台(知乎/小红书/微信/多平台)\n - \"seo_angle\": 从什么角度切入能获得搜索流量(1句话)\n\n只输出 JSON 数组,不要其他文字。",
|
||
"temperature": 0.7, "max_tokens": 3000,
|
||
"variables": ["date", "domains"],
|
||
},
|
||
"topic_generate": {
|
||
"content": "你是一个内容策略师。基于以下信息,为「{target_category}」类别生成一个高质量选题。\n\n{data_section}\n{existing_hint}\n{trend_context}\n\n选题要求:\n- 从真实用户痛点出发,不写假大空的话题\n- 有独特观点,让人看完想转发\n- 包含SEO关键词潜力\n\n输出 JSON:\n{\n \"title\": \"标题(20字内,含核心关键词)\",\n \"core_concept\": \"核心观点(一句话说清独特价值)\",\n \"audience_pain\": \"受众痛点(真实用户的困惑/焦虑/需求)\",\n \"unique_angle\": \"差异化切入点\",\n \"format\": \"内容形式:趋势洞察/实操指南/对比分析/案例解读\"\n}\n只输出 JSON,不要其他文字。",
|
||
"temperature": 0.6, "max_tokens": 2000,
|
||
"variables": ["target_category", "data_section", "existing_hint", "trend_context"],
|
||
},
|
||
"topic_selector_gaps": {
|
||
"content": "你是一个敏锐的内容策略师,擅长将热点转化为有价值、有传播力的选题。以下热点当前未覆盖,请为每个热点生成选题建议。\n\n注意:选题要兼具SEO价值(能在搜索中被找到)和社交传播力(能在平台引发讨论)。\n\n热点列表:\n{gaps}\n\n每个选题需包含:\n- \"title\": 标题(20字内,含核心关键词)\n- \"field\": 所属领域\n- \"core_concept\": 核心观点(一句话说清独特价值)\n- \"audience_pain\": 受众痛点(真实用户的困惑/焦虑/需求)\n- \"unique_angle\": 独特视角(差异化切入点)\n- \"target_platform\": 最适合平台(知乎/小红书/微信/多平台)\n- \"estimated_search_volume\": 搜索热度(高/中/低)\n\n只输出 JSON 数组,不要其他文字。",
|
||
"temperature": 0.6, "max_tokens": 2000,
|
||
"variables": ["gaps"],
|
||
},
|
||
"section_expansion_zhihu": {
|
||
"content": "写「{topic_title}」文章中「{section_title}」这一节。\n\n今天:{date}。\n笔记要点:\n{content}\n\n写400-800字,3-6段。每段围绕一个论点展开:先摆事实/数据,再分析原因,最后说清这跟读者有什么关系。\n\n注意:\n- 每个论点必须配具体数据或案例(至少一个),不要空谈。引用研究、报告或行业事件\n- 用「你」称呼读者,像是在知乎回答里跟人讨论\n- 从具体问题切入,不要以「引言/总结/核心观点」这类标签开头\n- 回答「所以呢」——这条信息对读者有什么用\n- 避免「首先其次最后」「总的来说」「综上所述」「值得注意的是」这些表达\n- 来源放在文末括号内\n- 纯段落,不要列表/标题/序号标记",
|
||
"temperature": 0.78, "max_tokens": 4000,
|
||
"variables": ["topic_title", "section_title", "date", "content"],
|
||
},
|
||
"section_expansion_wechat": {
|
||
"content": "写「{topic_title}」文章中「{section_title}」这一节。\n\n今天:{date}。\n笔记要点:\n{content}\n\n写300-500字,用「我」的口吻叙述——像在跟朋友聊天,不是写文章。\n\n注意:\n- 从个人经历或感受切入,不要讲道理\n- 每个观点背后要有一个具体故事或观察\n- 段与段之间有情绪的起承转合,不是逻辑推理\n- 读起来像一个人在说话,而不是一篇文章在论述\n- 避免「首先其次最后」「总的来说」「综上所述」「值得注意的是」这些套路\n- 纯段落,不要标题/列表/序号标记",
|
||
"temperature": 0.8, "max_tokens": 3000,
|
||
"variables": ["topic_title", "section_title", "date", "content"],
|
||
},
|
||
"section_expansion_xiaohongshu": {
|
||
"content": "写「{topic_title}」笔记中「{section_title}」这一节。\n\n今天:{date}。\n笔记要点:\n{content}\n\n写100-200字,1-3小段。每段1-2句,直接给干货。\n\n注意:\n- 直接给结论或方法,不要铺垫\n- 可以用emoji(1个/段,不要堆砌)\n- 像博主在分享亲测有效的经验\n- 不要讲道理,只讲「是什么」和「怎么做」\n- 用「你」或直接省略主语\n- 避免「首先其次最后」「总的来说」「综上所述」「值得注意的是」这些套路\n- 纯段落,不要列表/标题/序号标记",
|
||
"temperature": 0.75, "max_tokens": 2000,
|
||
"variables": ["topic_title", "section_title", "date", "content"],
|
||
},
|
||
"title_optimize_zhihu": {
|
||
"content": "你是知乎老用户,经常上热榜。给这篇文章起3个标题候选:\n\n文章主题:{title}\n核心观点:{core}\n受众痛点:{pain}\n领域:{field}\n\n感觉像在跟人推荐一篇值得辩论的文章。信息要密、20字内、有明确立场、让人想点进来说两句。不要「如何」开头。\n\n每行一个,不要编号。",
|
||
"temperature": 0.8, "max_tokens": 1500,
|
||
"variables": ["title", "core", "pain", "field"],
|
||
},
|
||
"title_optimize_wechat": {
|
||
"content": "你是公众号编辑。这篇文章面向{field}领域的读者,帮起3个标题:\n\n文章主题:{title}\n核心观点:{core}\n\n要有身份代入感(打工人/30岁+/普通上班族)、有情绪钩子(焦虑/反常识/后悔)、含微信搜索关键词。15-25字,口语化,越具体越好。参考这种风格:「用了AI效率反而更低了?73%的人掉进了这个坑」\n\n每行一个,不要编号。",
|
||
"temperature": 0.8, "max_tokens": 1500,
|
||
"variables": ["title", "core", "field"],
|
||
},
|
||
"title_optimize_xhs": {
|
||
"content": "你是小红书博主。给这篇笔记起3个标题:\n\n笔记主题:{title}\n核心:{core}\n\n每条18字以内,有具体场景和结果感。可以加1个精确emoji(不要🔥💥❌❓)。参考:「用了两年AI工具,推荐这5个就够了」这种风格。\n\n每行一个,不要编号。",
|
||
"temperature": 0.8, "max_tokens": 1500,
|
||
"variables": ["title", "core"],
|
||
},
|
||
"research_summary": {
|
||
"content": "你是一个行业研究员+内容策略师。今天是{now},当前年份:{year}年。\n\n基于以下选题和相关案例,写出能支撑文章核心观点、对读者真正有用的研究发现。\n\n选题:\n标题:{title}\n领域:{field}\n核心观点:{core}\n受众痛点:{pain}\n独特视角:{angle}\n{search_section}\n\n相关案例({n}个):\n{cases_text}\n\n输出要求(按顺序):\n1. 国内外最新热点关联:与本选题相关的{year}年最新趋势,有具体事件或数据\n2. 3-5个有说服力的论据:每个论据含案例+数据+来源URL\n3. 受众痛点深挖:读者为什么会关心这个\n4. SEO关键词建议:3-5个关键词(含长尾词),搜索意图说明\n\n直接输出 Markdown 格式的研究笔记,不要输出其他说明。",
|
||
"temperature": 0.7, "max_tokens": 4000,
|
||
"variables": ["now", "year", "title", "field", "core", "pain", "angle", "search_section", "n", "cases_text"],
|
||
},
|
||
"outline_generation": {
|
||
"content": "你是一个资深内容编辑,擅长设计读者爱看+搜索引擎友好+平台愿意推荐的推文结构。\n\n今天是{date}。当前年份:{year}年。\n\n选题信息:\n标题:{title}\n领域:{field}\n核心观点:{core}\n受众痛点:{pain}\n独特视角:{angle}\n\n研究笔记:\n{cases_summary}\n\n大纲要求:\n- 5-8章,每章有完整段落要点(非单句)\n- 结构递进:认知升级型或问题解决型\n- 每章标题自带信息量+好奇心,不要「引言」「总结」这类通用标题\n- 每章的要点必须是2-4句有内容的段落,不是一行关键词\n- 开头从具体场景切入,不要空洞的开场白\n- 把「独特视角」融入各章,而不是单独列\n\n输出格式:每章以「## 标题」开头,下面跟2-4段要点文字。\n不要输出其他说明。",
|
||
"temperature": 0.7, "max_tokens": 4000,
|
||
"variables": ["date", "year", "title", "field", "core", "pain", "angle", "cases_summary"],
|
||
},
|
||
"compliance_fix": {
|
||
"content": "你是一个专业的内容合规与质量优化助手。以下文章存在需要修复的问题,请逐一修复并输出完整HTML。\n\n需修复的问题:\n{issues_desc}\n\n原文:\n{html}\n\n修复要求:\n- 只修复上述问题,不改变文章结构和核心内容\n- 保持<h2>, <h3>, <p>等标签结构不变\n- 替换敏感词时选择意思相近的替代词,不删节重要信息\n- AI套话:直接删除或改写「首先其次最后」「总的来说」「值得注意的是」「综上所述」等模式\n- 人称混用:统一为「你」\n- 缺少配图:在关键位置插入 <p></p> 空段落占位,配图由后续流程处理\n- 缺少互动/收藏引导:在文末自然加入(不要生硬)\n- 段落过长:将超过300字的段落拆分为2-3段\n\n输出完整的HTML,只输出HTML内容,不要其他文字说明。",
|
||
"temperature": 0.3, "max_tokens": 8000,
|
||
"variables": ["issues_desc", "html"],
|
||
},
|
||
"compliance_polish": {
|
||
"content": "你是一个专业的内容润色助手。请在保持原文事实和结构的前提下提升表达的自然感。\n\n原文:\n{html}\n\n要求:\n- 保持所有事实、数据、章节结构、HTML标签不变\n- 让语言更像是人写的,不是AI生成的\n- 删除或改写AI常见表达:「首先其次最后」「总的来说」「值得注意的是」「综上所述」「我们不难发现」「由此可见」\n- 短句化,读起来更流畅\n- 保持原文的段落划分和层次\n\n输出完整的HTML,不要其他文字说明。",
|
||
"temperature": 0.4, "max_tokens": 8000,
|
||
"variables": ["html"],
|
||
},
|
||
"sources_optimization": {
|
||
"content": "你是一个内容策略分析师。分析当前中文互联网可持续生活领域的真实热点,与以下配置进行对比。\n\n当前配置的类别({n}个):\n{cat_names}\n\n当前配置的信息源({n2}个):\n{src_summary}\n\n请完成:\n1. 评估每个类别是否仍符合{year}年中国市场真实热点(保留/淘汰/合并)\n2. 评估每个信息源是否可在国内正常访问\n3. 建议新增的类别(最多2条)\n4. 建议新增的信息源搜索词(最多3条,含具体搜索词)\n\n输出 JSON:\n{\n \"category_assessment\": [{\"name\": \"...\", \"status\": \"保留/淘汰/合并\", \"reason\": \"...\"}],\n \"source_assessment\": [{\"source\": \"...\", \"accessible\": true/false, \"note\": \"...\"}],\n \"new_categories\": [{\"name\": \"...\", \"reason\": \"...\"}],\n \"new_search_queries\": [{\"query\": \"...\", \"target\": \"...\"}]\n}\n只输出JSON。",
|
||
"temperature": 0.5, "max_tokens": 3000,
|
||
"variables": ["n", "cat_names", "n2", "src_summary", "year"],
|
||
},
|
||
"tags_generation": {
|
||
"content": "为以下文章生成{platform}标签(5-8个)。\n\n标题:{title}\n领域:{field}\n核心观点:{core}\n\n要求:\n- 每个标签2-5字\n- 包含1-2个搜索流量词(用户在{platform}会搜的词)\n- 包含1-2个热门话题词\n- 标签要有层次:大领域→小话题→具体场景\n- 不要重复意思相近的标签\n\n直接输出标签,空格分隔。不要输出思考过程和其他文字。",
|
||
"temperature": 0.3, "max_tokens": 500,
|
||
"variables": ["platform", "title", "field", "core"],
|
||
},
|
||
}
|
||
|
||
_DB_CACHE: Dict[str, Dict[str, Any]] = {}
|
||
_CACHE_LOADED = False
|
||
_DB_AVAILABLE = False # True if DB was successfully loaded at least once
|
||
|
||
|
||
def _seed_missing_prompts():
|
||
"""将代码默认值中不存在的 prompt 自动补入 DB"""
|
||
try:
|
||
if os.getenv('USE_POSTGRES', 'true') == 'true':
|
||
from app.database import SessionLocal
|
||
from app.models import PromptConfig
|
||
db = SessionLocal()
|
||
try:
|
||
existing_keys = {p.key for p in db.query(PromptConfig).all()}
|
||
for key, cfg in _PROMPT_DEFAULTS.items():
|
||
if key not in existing_keys:
|
||
pc = PromptConfig(
|
||
key=key,
|
||
content=cfg['content'],
|
||
temperature=cfg.get('temperature'),
|
||
max_tokens=cfg.get('max_tokens'),
|
||
enabled=True,
|
||
module_id='default',
|
||
variables=cfg.get('variables', []),
|
||
)
|
||
db.add(pc)
|
||
print(f"[prompt_loader] auto-seeded prompt: {key}")
|
||
if any(key not in existing_keys for key in _PROMPT_DEFAULTS):
|
||
db.commit()
|
||
finally:
|
||
db.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _ensure_db_loaded():
|
||
global _DB_CACHE, _CACHE_LOADED, _DB_AVAILABLE
|
||
if _CACHE_LOADED:
|
||
return
|
||
# 自动补缺:将代码默认值中不存在的 prompt 写入 DB
|
||
_seed_missing_prompts()
|
||
try:
|
||
if os.getenv('USE_POSTGRES', 'true') == 'true':
|
||
from app.database import SessionLocal
|
||
from app.models import PromptConfig
|
||
db = SessionLocal()
|
||
try:
|
||
for p in db.query(PromptConfig).filter(PromptConfig.enabled == True).all():
|
||
_DB_CACHE[p.key] = {
|
||
"content": p.content,
|
||
"temperature": p.temperature,
|
||
"max_tokens": p.max_tokens,
|
||
}
|
||
_DB_AVAILABLE = True
|
||
finally:
|
||
db.close()
|
||
except Exception:
|
||
pass
|
||
_CACHE_LOADED = True
|
||
|
||
|
||
def get_prompt(key: str, **kwargs) -> str:
|
||
"""获取 prompt 内容
|
||
|
||
优先级:DB → 代码默认值(仅 DB 不可用时)→ 空字符串
|
||
"""
|
||
_ensure_db_loaded()
|
||
cleaned = []
|
||
for k, v in kwargs.items():
|
||
cleaned.append((k, str(v)))
|
||
kwargs = dict(cleaned)
|
||
|
||
if key in _DB_CACHE:
|
||
content = _DB_CACHE[key]["content"]
|
||
for k, v in kwargs.items():
|
||
content = content.replace("{" + k + "}", str(v))
|
||
return content
|
||
|
||
# 仅 DB 不可用时回退代码默认值
|
||
if not _DB_AVAILABLE and key in _PROMPT_DEFAULTS:
|
||
content = _PROMPT_DEFAULTS[key]["content"]
|
||
for k, v in kwargs.items():
|
||
content = content.replace("{" + k + "}", str(v))
|
||
return content
|
||
|
||
return ""
|
||
|
||
|
||
def get_prompt_params(key: str) -> Dict[str, Any]:
|
||
_ensure_db_loaded()
|
||
if key in _DB_CACHE:
|
||
return {
|
||
"temperature": _DB_CACHE[key].get("temperature"),
|
||
"max_tokens": _DB_CACHE[key].get("max_tokens"),
|
||
}
|
||
if not _DB_AVAILABLE and key in _PROMPT_DEFAULTS:
|
||
return {
|
||
"temperature": _PROMPT_DEFAULTS[key].get("temperature"),
|
||
"max_tokens": _PROMPT_DEFAULTS[key].get("max_tokens"),
|
||
}
|
||
return {}
|
||
|
||
|
||
def seed_prompts(force: bool = False):
|
||
"""手动触发种子同步(用于 CLI 或 API)
|
||
|
||
force=True: 用代码默认值覆盖 DB
|
||
force=False: 仅补充 DB 中不存在的 key
|
||
"""
|
||
try:
|
||
from app.database import SessionLocal
|
||
from app.models import PromptConfig
|
||
db = SessionLocal()
|
||
try:
|
||
existing = {p.key: p for p in db.query(PromptConfig).all()}
|
||
for key, cfg in _PROMPT_DEFAULTS.items():
|
||
if force or key not in existing:
|
||
if key in existing:
|
||
p = existing[key]
|
||
p.content = cfg['content']
|
||
p.temperature = cfg.get('temperature')
|
||
p.max_tokens = cfg.get('max_tokens')
|
||
else:
|
||
p = PromptConfig(
|
||
key=key,
|
||
content=cfg['content'],
|
||
temperature=cfg.get('temperature'),
|
||
max_tokens=cfg.get('max_tokens'),
|
||
enabled=True,
|
||
module_id='default',
|
||
variables=cfg.get('variables', []),
|
||
)
|
||
db.add(p)
|
||
db.commit()
|
||
finally:
|
||
db.close()
|
||
return {"ok": True, "action": "force" if force else "seed"}
|
||
except Exception as e:
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
|
||
def reload_prompts():
|
||
global _CACHE_LOADED, _DB_CACHE, _DB_AVAILABLE
|
||
_CACHE_LOADED = False
|
||
_DB_CACHE = {}
|
||
_DB_AVAILABLE = False
|
||
_ensure_db_loaded()
|