feat: Phase 4 多租户隔离 + 四阶段升级测试 + CSS 统一化
Phase 4: org_id 注入 JWT/API 过滤/组织管理 CRUD/前端组织列 测试: tests/test_phase_upgrades.py 97项全覆盖 CSS: theme-modern.css 共享 mobile-card-list/status-dot/search-bar 等模式 修复: initial_data.py LLM配置 NOT NULL 约束, TopicResponse 含 org_id
This commit is contained in:
+276
-93
@@ -46,12 +46,13 @@ logger = logging.getLogger(__name__)
|
||||
class SustainabilitySource:
|
||||
"""可持续性信息源"""
|
||||
name: str
|
||||
type: str # rss, web, api, report, local
|
||||
url: Optional[str] = None # 可为空(如本地源)
|
||||
type: str # rss, web_search, web, api, local
|
||||
url: Optional[str] = None # RSS URL 或通用链接
|
||||
update_frequency: str = "daily"
|
||||
credibility: str = "medium"
|
||||
focus: str = "可持续性"
|
||||
keywords: Optional[List[str]] = None # 源特定关键词
|
||||
query: Optional[str] = None # 搜索查询词(w eb_search类型用)
|
||||
|
||||
@dataclass
|
||||
class SustainabilityCase:
|
||||
@@ -118,30 +119,60 @@ class SustainabilityCollector:
|
||||
self.new_topics: List[SustainabilityTopic] = []
|
||||
|
||||
def load_config(self):
|
||||
"""加载配置文件"""
|
||||
with open(CONFIG_DIR / "sources.yaml", "r", encoding='utf-8') as f:
|
||||
self.config = yaml.safe_load(f)
|
||||
"""加载配置:优先从DB读取,DB为空则从YAML fallback再写入DB"""
|
||||
self.config = {}
|
||||
self.config_path = CONFIG_DIR / "sources.yaml"
|
||||
if self.config_path.exists():
|
||||
with open(self.config_path, encoding='utf-8') as f:
|
||||
self.config = yaml.safe_load(f) or {}
|
||||
|
||||
with open(CONFIG_DIR / "wecom_config.yaml", "r", encoding='utf-8') as f:
|
||||
with open(CONFIG_DIR / "wecom_config.yaml", encoding='utf-8') as f:
|
||||
self.wecom_config = yaml.safe_load(f)
|
||||
|
||||
# 优先从DB读取类别和源
|
||||
self.sources = []
|
||||
for source_group in self.config["sustainability_sources"].values():
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from app.models import CollectorCategory, CollectorSource
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cats = db.query(CollectorCategory).filter(CollectorCategory.is_active == True).order_by(CollectorCategory.sort_order).all()
|
||||
if cats:
|
||||
# 用DB中的类别覆盖YAML
|
||||
self.config["sustainability_categories"] = [c.name for c in cats]
|
||||
sources_db = db.query(CollectorSource).filter(CollectorSource.is_active == True).order_by(CollectorSource.sort_order).all()
|
||||
for s in sources_db:
|
||||
self.sources.append(SustainabilitySource(
|
||||
name=s.name,
|
||||
type=s.source_type,
|
||||
url=s.url or '',
|
||||
query=s.query or '',
|
||||
credibility=s.credibility or 'medium',
|
||||
focus=s.focus or '可持续性',
|
||||
))
|
||||
logger.info(f"从DB加载 {len(cats)} 个类别, {len(self.sources)} 个信息源")
|
||||
db.close()
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"DB读取类别/源失败,回退YAML: {e}")
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"DB连接失败,回退YAML: {e}")
|
||||
|
||||
# YAML fallback
|
||||
for source_group in self.config.get("sustainability_sources", {}).values():
|
||||
for source_info in source_group:
|
||||
# Handle both 'url' and 'base_url' in config
|
||||
source_info = source_info.copy()
|
||||
if 'base_url' in source_info and 'url' not in source_info:
|
||||
source_info['url'] = source_info.pop('base_url')
|
||||
# Provide defaults for missing optional fields
|
||||
source_info.setdefault('update_frequency', 'daily')
|
||||
source_info.setdefault('focus', '可持续性')
|
||||
source_info.setdefault('keywords', None)
|
||||
# Filter to only fields accepted by SustainabilitySource
|
||||
allowed_keys = {'name', 'type', 'url', 'update_frequency', 'credibility', 'focus', 'keywords'}
|
||||
allowed_keys = {'name', 'type', 'url', 'update_frequency', 'credibility', 'focus', 'keywords', 'query'}
|
||||
filtered_info = {k: v for k, v in source_info.items() if k in allowed_keys}
|
||||
self.sources.append(SustainabilitySource(**filtered_info))
|
||||
|
||||
logger.info(f"加载了 {len(self.sources)} 个信息源")
|
||||
logger.info(f"YAML fallback: 加载 {len(self.sources)} 个信息源")
|
||||
|
||||
def load_local_cases_from_db(self) -> List[SustainabilityCase]:
|
||||
"""从本地案例库加载历史案例,用于降级生成选题"""
|
||||
@@ -213,16 +244,17 @@ class SustainabilityCollector:
|
||||
field = field_match.group(1).strip()
|
||||
# 映射到子领域(扩展映射表)
|
||||
category_map = {
|
||||
'远程工作方式': '城市农业',
|
||||
'远程工作方式': '循环消费',
|
||||
'数字游民政策': '低碳出行',
|
||||
'AI副业服务': '环保科技产品',
|
||||
'一人公司模式': '循环消费',
|
||||
'未来技能趋势': '可持续饮食',
|
||||
'可持续生活': '可持续饮食',
|
||||
'未来技能趋势': '干净饮食',
|
||||
'可持续生活': '零浪费生活',
|
||||
'零浪费生活': '零浪费生活',
|
||||
'低碳出行': '低碳出行',
|
||||
'循环消费': '循环消费',
|
||||
'环保科技': '环保科技产品'
|
||||
'环保科技': '环保科技产品',
|
||||
'城市农业': '循环消费',
|
||||
}
|
||||
case_data['category'] = category_map.get(field, field[:4] if len(field) > 4 else field)
|
||||
|
||||
@@ -317,10 +349,129 @@ class SustainabilityCollector:
|
||||
|
||||
def fetch_web_content(self, source: SustainabilitySource) -> List[Dict]:
|
||||
"""获取网页内容(简化版,实际需要更复杂的抓取)"""
|
||||
# 简化实现:只记录,不实际抓取
|
||||
logger.info(f"网页信息源 {source.name} 需要手动处理")
|
||||
return []
|
||||
|
||||
def fetch_web_search(self, source: SustainabilitySource) -> List[Dict]:
|
||||
"""通过Bing中文搜索获取实时内容"""
|
||||
try:
|
||||
from web_search import search
|
||||
query = source.query or source.url or ''
|
||||
query = query.strip()
|
||||
if not query:
|
||||
logger.warning(f"web_search源 {source.name} 未配置查询词")
|
||||
return []
|
||||
results = search(query, max_results=8, use_cache=False)
|
||||
articles = []
|
||||
for r in results:
|
||||
articles.append({
|
||||
'title': r.get('title', ''),
|
||||
'url': r.get('url', ''),
|
||||
'content': r.get('snippet', ''),
|
||||
'published': TODAY,
|
||||
'source_name': source.name,
|
||||
'search_query': query,
|
||||
})
|
||||
logger.info(f"搜索 [{query}] 获得 {len(articles)} 条结果")
|
||||
return articles
|
||||
except Exception as e:
|
||||
logger.warning(f"web_search失败 {source.name}: {e}")
|
||||
return []
|
||||
|
||||
def _generate_topic_with_llm(self, search_results: List[Dict]) -> Optional[SustainabilityTopic]:
|
||||
"""用LLM从搜索结果中生成选题"""
|
||||
try:
|
||||
from app.core.nvidia_client import call_llm
|
||||
except ImportError:
|
||||
logger.warning("LLM不可用,跳过AI选题生成")
|
||||
return None
|
||||
|
||||
if not search_results:
|
||||
return None
|
||||
|
||||
# 整理搜索结果摘要
|
||||
summaries = []
|
||||
for r in search_results[:6]:
|
||||
summaries.append(f"- {r.get('title','')}: {r.get('content','')[:150]}")
|
||||
search_text = "\n".join(summaries)
|
||||
|
||||
# 获取已有选题做去重参考
|
||||
existing = self._get_existing_titles()
|
||||
existing_hint = ""
|
||||
if existing:
|
||||
existing_hint = f"\n以下选题已存在,请避免重复:\n" + "\n".join(f"- {t[:30]}" for t in existing[-10:])
|
||||
|
||||
# 按日期选不同类别
|
||||
categories = self.config.get("sustainability_categories", ["可持续生活"])
|
||||
day_idx = datetime.datetime.now().timetuple().tm_yday % len(categories)
|
||||
target_category = categories[day_idx]
|
||||
|
||||
prompt = f"""你是一个内容策略师。基于以下搜索结果,生成一个有价值、适合中文互联网传播的选题。
|
||||
|
||||
目标类别:{target_category}
|
||||
|
||||
搜索结果:
|
||||
{search_text}
|
||||
{existing_hint}
|
||||
|
||||
请生成一个选题,输出JSON格式:
|
||||
{{
|
||||
"title": "标题(20字内,有吸引力,含核心关键词)",
|
||||
"core_concept": "核心观点(一句话说清独特价值)",
|
||||
"audience_pain": "受众痛点(真实用户的困惑或需求)",
|
||||
"unique_angle": "独特视角(差异化切入点)",
|
||||
"format": "内容形式(趋势洞察/实操指南/对比分析/案例解读)"
|
||||
}}
|
||||
|
||||
要求:
|
||||
- 标题要像人会搜索的,带领域关键词
|
||||
- 避免「新趋势」「指南」「攻略」这类同质化结尾
|
||||
- 切入点要具体,不要泛泛而谈
|
||||
- 优先考虑中国读者能实操的内容
|
||||
只输出JSON,不要其他文字。"""
|
||||
|
||||
try:
|
||||
resp = call_llm(prompt, temperature=0.7, max_tokens=800)
|
||||
resp = resp.strip()
|
||||
if resp.startswith("```"):
|
||||
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
|
||||
data = json.loads(resp)
|
||||
|
||||
topic_id = f"TOPIC-{hashlib.md5((target_category + data.get('title','')[:10]).encode()).hexdigest()[:6].upper()}"
|
||||
|
||||
topic = SustainabilityTopic(
|
||||
id=topic_id,
|
||||
title=data.get("title", f"{target_category}新观察"),
|
||||
cases=[],
|
||||
audience="城市焦虑青年(26-35岁)",
|
||||
china_pain_points=data.get("audience_pain", ""),
|
||||
localization_solution="文章中将提供具体可执行的建议",
|
||||
mvp_actions="读者可立即尝试的3个行动",
|
||||
estimated_length=2000,
|
||||
priority_score=7.0,
|
||||
field=self.map_category_to_field(target_category),
|
||||
format=data.get("format", "趋势洞察 + 实操指南"),
|
||||
core_concept=data.get("core_concept", ""),
|
||||
audience_pain=data.get("audience_pain", ""),
|
||||
unique_angle=data.get("unique_angle", ""),
|
||||
priority="中",
|
||||
total_score=70.0,
|
||||
compliance_score=100,
|
||||
source_file="automation/data/sustainability_topics.json",
|
||||
status="待处理",
|
||||
lock_by=None,
|
||||
lock_at=None,
|
||||
created_at=datetime.datetime.now().isoformat(),
|
||||
ready_at=None,
|
||||
published_at=None,
|
||||
platform_urls={}
|
||||
)
|
||||
logger.info(f"LLM生成选题: {topic.title}")
|
||||
return topic
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM选题生成失败: {e}")
|
||||
return None
|
||||
|
||||
def analyze_article(self, article: Dict) -> Optional[SustainabilityCase]:
|
||||
"""分析文章内容,提炼案例"""
|
||||
try:
|
||||
@@ -370,13 +521,14 @@ class SustainabilityCollector:
|
||||
|
||||
# 生成中国痛点(基于类别模板)
|
||||
china_pains = {
|
||||
"城市农业": "中国城市空间小、光照不足、怕邻居投诉",
|
||||
"零浪费生活": "中国垃圾分类执行难、环保产品溢价高",
|
||||
"低碳出行": "中国电动车充电难、城市规划不支持",
|
||||
"循环消费": "中国二手文化不成熟、维修成本高",
|
||||
"能源效率": "中国能源价格波动、设备更换成本高",
|
||||
"可持续饮食": "中国预制菜泛滥、有机食品价格高",
|
||||
"环保科技产品": "中国消费者关注价格多于环保"
|
||||
"循环消费": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
|
||||
"低碳出行": "新能源车充电设施不足、城市规划不支持骑行、通勤距离长",
|
||||
"干净饮食": "有机食品价格高、真伪难辨、外卖为主的生活方式难以改变",
|
||||
"零浪费生活": "环保产品溢价高、可持续选择不便、漂绿营销难以分辨",
|
||||
"绿色家电与节能": "绿色家电初期投入高、节能效果难量化、老旧小区改造难",
|
||||
"碳普惠": "碳账户普及率低、减排量兑换吸引力不足、公众认知有限",
|
||||
"环保科技产品": "绿色产品溢价68%难以承受、缺乏统一认证标准、担心漂绿",
|
||||
"AI与效率": "AI工具选择困难、数据隐私担忧、学习成本高、实际效果难验证"
|
||||
}
|
||||
china_pain = china_pains.get(category, "中国相关数据不足,需本土化验证")
|
||||
|
||||
@@ -423,12 +575,21 @@ class SustainabilityCollector:
|
||||
if len(main_cases) < 2:
|
||||
return None
|
||||
|
||||
# 生成选题ID
|
||||
topic_id = f"TOPIC-{hashlib.md5((main_category + TODAY).encode()).hexdigest()[:6].upper()}"
|
||||
|
||||
# 组合标题
|
||||
# 组合标题(多种模板轮换,避免天天同款)
|
||||
case_titles = [case.title[:30] for case in main_cases[:2]]
|
||||
topic_title = f"{main_category}新趋势: {case_titles[0]}与{case_titles[1]}的中国落地路径"
|
||||
day_of_year = datetime.datetime.now().timetuple().tm_yday
|
||||
title_templates = [
|
||||
f"{main_category}新趋势: {case_titles[0]}与{case_titles[1]}的中国落地路径",
|
||||
f"从{case_titles[0][:15]}到{case_titles[1][:15]}: {main_category}的中国实践指南",
|
||||
f"2026{main_category}观察: {case_titles[0]}给中国什么启示",
|
||||
f"实战对比: {case_titles[0][:10]}vs{case_titles[1][:10]},中国读者该学谁",
|
||||
f"为什么{case_titles[0][:15]}在中国行不通(或更行)? — {main_category}深度拆解",
|
||||
]
|
||||
topic_title = title_templates[day_of_year % len(title_templates)]
|
||||
|
||||
# 生成选题ID(案例内容hash保证同一批案例产出相同ID,避免重复入库)
|
||||
content_seed = main_category + case_titles[0][:10] + case_titles[1][:10]
|
||||
topic_id = f"TOPIC-{hashlib.md5(content_seed.encode()).hexdigest()[:6].upper()}"
|
||||
|
||||
# 计算优先级分数
|
||||
priority_weights = self.config["topic_priority"]
|
||||
@@ -484,15 +645,7 @@ class SustainabilityCollector:
|
||||
|
||||
def map_category_to_field(self, category: str) -> str:
|
||||
"""将案例类别映射到内容领域的字段"""
|
||||
category_map = {
|
||||
"城市农业": "可持续生活系统",
|
||||
"零浪费生活": "可持续生活系统",
|
||||
"低碳出行": "可持续生活系统",
|
||||
"循环消费": "可持续生活系统",
|
||||
"能源效率": "可持续生活系统",
|
||||
"环保科技产品": "可持续生活系统"
|
||||
}
|
||||
return category_map.get(category, "可持续生活系统")
|
||||
return "可持续生活系统"
|
||||
|
||||
def save_results(self):
|
||||
"""保存收集结果"""
|
||||
@@ -585,87 +738,117 @@ class SustainabilityCollector:
|
||||
except Exception as e:
|
||||
logger.error(f"发送通知失败: {e}")
|
||||
|
||||
def _get_existing_titles(self) -> List[str]:
|
||||
"""从DB获取已有的选题标题列表用于去重"""
|
||||
try:
|
||||
from db_helper import export_topics_to_json
|
||||
topics = export_topics_to_json()
|
||||
return [t.get('title', '') for t in topics]
|
||||
except Exception as e:
|
||||
logger.warning(f"读取已有选题失败: {e}")
|
||||
return []
|
||||
|
||||
def _is_duplicate_topic(self, title: str, existing_titles: List[str]) -> bool:
|
||||
"""检查选题是否与已有选题重复(前10字重叠即为重复)"""
|
||||
prefix = title[:10].strip()
|
||||
for et in existing_titles:
|
||||
if prefix in et or et[:10] in title:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _rotate_category(self, local_cases: List[SustainabilityCase]) -> Tuple[str, List[SustainabilityCase]]:
|
||||
"""按日期轮换类别,避免天天选中同一类"""
|
||||
category_cases = {}
|
||||
for case in local_cases:
|
||||
category_cases.setdefault(case.category, []).append(case)
|
||||
|
||||
if not category_cases:
|
||||
return None, []
|
||||
|
||||
# 按类别名排序固定顺序
|
||||
sorted_cats = sorted(category_cases.keys())
|
||||
# 用一年中的第几天选类别,保证每天不重样
|
||||
day_of_year = datetime.datetime.now().timetuple().tm_yday
|
||||
idx = day_of_year % len(sorted_cats)
|
||||
main_cat = sorted_cats[idx]
|
||||
return main_cat, category_cases[main_cat]
|
||||
|
||||
def run(self):
|
||||
"""主运行流程"""
|
||||
logger.info("开始可持续性内容收集")
|
||||
|
||||
# 1. 从所有信息源收集
|
||||
existing_titles = self._get_existing_titles()
|
||||
|
||||
# ---------------------- 第一阶段:多源采集 ----------------------
|
||||
all_articles = []
|
||||
web_search_results = [] # 留给LLM选题用的搜索结果
|
||||
|
||||
for source in self.sources:
|
||||
if source.type == 'rss':
|
||||
articles = self.fetch_rss_feed(source)
|
||||
all_articles.extend(articles)
|
||||
elif source.type == 'web':
|
||||
articles = self.fetch_web_content(source)
|
||||
elif source.type == 'web_search':
|
||||
articles = self.fetch_web_search(source)
|
||||
web_search_results.extend(articles)
|
||||
all_articles.extend(articles)
|
||||
elif source.type == 'api':
|
||||
# TODO: 实现API抓取
|
||||
pass
|
||||
elif source.type == 'local':
|
||||
# 本地源不产生新文章,后续降级处理
|
||||
pass
|
||||
|
||||
logger.info(f"总共收集到 {len(all_articles)} 篇可持续性文章")
|
||||
logger.info(f"RSS采集 {sum(1 for a in all_articles if a.get('source_name','') not in [s.name for s in self.sources if s.type=='web_search'])} 篇, "
|
||||
f"搜索采集 {len(web_search_results)} 篇")
|
||||
|
||||
# 2. 分析文章,提炼案例
|
||||
for article in all_articles[:20]: # 限制分析数量
|
||||
# ---------------------- 第二阶段:尝试LLM选题生成 ----------------------
|
||||
llm_topic = None
|
||||
if web_search_results:
|
||||
llm_topic = self._generate_topic_with_llm(web_search_results)
|
||||
|
||||
if llm_topic and not self._is_duplicate_topic(llm_topic.title, existing_titles):
|
||||
llm_topic.created_at = datetime.datetime.now().isoformat()
|
||||
llm_topic.lock_by = None
|
||||
llm_topic.lock_at = None
|
||||
llm_topic.status = "待处理"
|
||||
self.new_topics.append(llm_topic)
|
||||
logger.info(f"✅ LLM生成选题: {llm_topic.title}")
|
||||
|
||||
# ---------------------- 第三阶段:RSS文章提炼案例 ----------------------
|
||||
rss_articles = [a for a in all_articles if a not in web_search_results]
|
||||
for article in rss_articles[:15]:
|
||||
case = self.analyze_article(article)
|
||||
if case:
|
||||
self.new_cases.append(case)
|
||||
|
||||
# 3. 降级策略:如果外部源没有收集到足够案例,使用本地案例库
|
||||
if len(self.new_cases) < 2:
|
||||
logger.warning(f"外部源案例不足 ({len(self.new_cases)} < 2),启动降级策略")
|
||||
|
||||
# 从本地JSON数据库加载案例(按类别分组,选择案例最多的类别)
|
||||
local_cases = self.load_local_cases_from_db()
|
||||
# ---------------------- 第四阶段:降级策略 ----------------------
|
||||
if not self.new_topics and len(self.new_cases) < 2:
|
||||
logger.warning(f"LLM选题和RSS案例均不足,启动本地案例降级")
|
||||
local_cases = self.load_local_cases_from_db() or self.load_local_cases_from_markdown()
|
||||
|
||||
if local_cases and len(local_cases) >= 2:
|
||||
# 按类别分组,选择案例最多的类别
|
||||
category_cases = {}
|
||||
for case in local_cases:
|
||||
cat = case.category
|
||||
if cat not in category_cases:
|
||||
category_cases[cat] = []
|
||||
category_cases[cat].append(case)
|
||||
|
||||
# 找出案例最多的类别
|
||||
main_category = max(category_cases, key=lambda k: len(category_cases[k]))
|
||||
main_cases = category_cases[main_category]
|
||||
|
||||
# 确保至少有2个案例
|
||||
if len(main_cases) >= 2:
|
||||
selected = main_cases[:min(3, len(main_cases))]
|
||||
cat, cat_cases = self._rotate_category(local_cases)
|
||||
if cat and len(cat_cases) >= 2:
|
||||
selected = cat_cases[:min(4, len(cat_cases))]
|
||||
self.new_cases.extend(selected)
|
||||
logger.info(f"降级:从类别'{main_category}'选取了 {len(selected)} 个案例")
|
||||
logger.info(f"降级:从类别'{cat}'选取 {len(selected)} 个案例 (day-of-year轮换)")
|
||||
else:
|
||||
# 如果每个类别都少于2个,则随机选2个(可能类别不同,generate_topic_from_cases会合并)
|
||||
import random
|
||||
selected = random.sample(local_cases, min(3, len(local_cases)))
|
||||
self.new_cases.extend(selected)
|
||||
logger.info(f"降级:随机选取了 {len(selected)} 个本地案例")
|
||||
logger.info(f"降级:随机选取 {len(selected)} 个本地案例")
|
||||
|
||||
# 用本地案例生成选题
|
||||
if not self.new_topics and self.new_cases:
|
||||
topic = self.generate_topic_from_cases(self.new_cases)
|
||||
if topic:
|
||||
if self._is_duplicate_topic(topic.title, existing_titles):
|
||||
logger.warning(f"选题重复,跳过: {topic.title}")
|
||||
else:
|
||||
topic.created_at = datetime.datetime.now().isoformat()
|
||||
topic.lock_by = None
|
||||
topic.lock_at = None
|
||||
topic.status = "待处理"
|
||||
self.new_topics.append(topic)
|
||||
logger.info(f"生成新选题: {topic.title}")
|
||||
else:
|
||||
# 备用:从Markdown案例库解析
|
||||
local_cases = self.load_local_cases_from_markdown()
|
||||
if local_cases:
|
||||
import random
|
||||
selected = random.sample(local_cases, min(3, len(local_cases)))
|
||||
self.new_cases.extend(selected)
|
||||
logger.info(f"降级(Markdown):使用了 {len(selected)} 个案例")
|
||||
else:
|
||||
logger.error("降级失败:本地案例库为空")
|
||||
|
||||
# 4. 生成选题
|
||||
if self.new_cases:
|
||||
topic = self.generate_topic_from_cases(self.new_cases)
|
||||
if topic:
|
||||
# 标记为今日创建,并添加锁字段(表示未被占用)
|
||||
topic.created_at = datetime.datetime.now().isoformat()
|
||||
topic.lock_by = None
|
||||
topic.lock_at = None
|
||||
# 确保状态为「待处理」
|
||||
topic.status = "待处理"
|
||||
self.new_topics.append(topic)
|
||||
logger.error("降级失败:本地案例库为空")
|
||||
|
||||
# 5. 保存结果
|
||||
self.save_results()
|
||||
|
||||
+5
-2
@@ -102,13 +102,16 @@ def run_pipeline(topic_id: str = None) -> Dict:
|
||||
update_topic_status(tid, 'pending')
|
||||
return {"ok": False, "error": "writer step failed"}
|
||||
|
||||
# 4. 合规优化(自动审核并标记为「待发布」)
|
||||
# 4. 配图生成
|
||||
image_ok = run_step("image_generator.py", tid)
|
||||
|
||||
# 5. 合规优化(自动审核并标记为「待发布」)
|
||||
if not run_optimizer_step(tid):
|
||||
update_topic_status(tid, 'pending')
|
||||
return {"ok": False, "error": "optimizer step failed"}
|
||||
|
||||
logger.info(f"创作流水线完成: topic_id={tid}")
|
||||
return {"ok": True, "topic_id": tid, "stdout": f"SUCCESS: Topic {tid} processed through full pipeline"}
|
||||
return {"ok": True, "topic_id": tid, "stdout": f"SUCCESS: Topic {tid} processed through full pipeline{' (images generated)' if image_ok else ' (images skipped)'}"}
|
||||
except Exception as e:
|
||||
logger.exception("流水线执行失败")
|
||||
if tid:
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
import sys
|
||||
import json
|
||||
import datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
@@ -25,6 +26,13 @@ import random
|
||||
# 确保项目根目录在路径中
|
||||
PROJECT_ROOT = Path('/root/openclaw-workspace/projects/yu-zhi-ran')
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
|
||||
|
||||
from db_helper import get_topic_by_id
|
||||
from app.models import Article
|
||||
from app.database import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 加载配置
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
@@ -441,7 +449,85 @@ class ImageGenerator:
|
||||
|
||||
return files
|
||||
|
||||
def generate_for_topic(topic_id: str, platforms: List[str] = None) -> Dict[str, Dict[str, str]]:
|
||||
"""为指定选题生成三平台配图,路径存入 articles 表"""
|
||||
if platforms is None:
|
||||
platforms = ["zhihu", "wechat", "xiaohongshu"]
|
||||
|
||||
topic = get_topic_by_id(topic_id)
|
||||
if not topic:
|
||||
raise ValueError(f"Topic {topic_id} not found")
|
||||
|
||||
title = topic.get("title", "无标题")
|
||||
generator = ImageGenerator()
|
||||
results = {}
|
||||
|
||||
for platform in platforms:
|
||||
try:
|
||||
files = generator.generate_all_placeholders(title, platform)
|
||||
cover_path = str(files.get("cover", ""))
|
||||
chart_path = str(files.get("data_chart", ""))
|
||||
checklist_path = str(files.get("action_checklist", ""))
|
||||
|
||||
images = {
|
||||
"cover": cover_path,
|
||||
"chart": chart_path,
|
||||
"checklist": checklist_path,
|
||||
}
|
||||
|
||||
# 存入 DB
|
||||
save_article_images(topic_id, platform, images)
|
||||
|
||||
results[platform] = images
|
||||
logger.info(f" [{platform}] cover={Path(cover_path).name}" if cover_path else "")
|
||||
except Exception as e:
|
||||
logger.error(f" [{platform}] 生成失败: {e}")
|
||||
results[platform] = {}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def save_article_images(topic_id: str, platform: str, images: Dict[str, str]):
|
||||
"""将图片路径写入 articles 表的 images 字段"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.models import Article
|
||||
article_id = f"{platform}_{topic_id}"
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if article:
|
||||
existing = article.images or {}
|
||||
existing.update(images)
|
||||
article.images = existing
|
||||
else:
|
||||
article = Article(
|
||||
id=article_id,
|
||||
topic_id=topic_id,
|
||||
platform=platform,
|
||||
file_path=f"db:{article_id}",
|
||||
status="draft",
|
||||
images=images,
|
||||
)
|
||||
db.add(article)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='文章配图生成器')
|
||||
parser.add_argument('--topic-id', help='选题ID,指定则为选题生成配图')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.topic_id:
|
||||
print(f"为选题 {args.topic_id} 生成配图...")
|
||||
results = generate_for_topic(args.topic_id)
|
||||
print(json.dumps({"topic_id": args.topic_id, "images": results}, ensure_ascii=False))
|
||||
sys.exit(0)
|
||||
|
||||
"""测试主函数"""
|
||||
generator = ImageGenerator()
|
||||
|
||||
|
||||
+6
-2
@@ -66,10 +66,14 @@ class Outliner:
|
||||
## 大纲设计要求
|
||||
### 结构
|
||||
- 5-8章,每章2-4个要点
|
||||
- 结构要有递进:要么认知升级型(读者看完感觉打开新世界),要么问题解决型(读者看完知道怎么做)
|
||||
- 结构要有递进:要么认知升级型,要么问题解决型
|
||||
- 把独特视角和受众痛点融入各章,不单独列
|
||||
- 每章标题自带信息量,不要「引言」「总结」这类通用标题
|
||||
|
||||
### 数据要求
|
||||
- **全文使用的数据必须为2025-2026年最新数据**,禁用2024年及之前过时数据
|
||||
- 每个观点尽量配最新的数据或案例支撑
|
||||
|
||||
### SEO
|
||||
- H2/H3标题自然包含用户搜索时会用的短语
|
||||
- 确保大纲覆盖2-3个高价值搜索词
|
||||
@@ -84,7 +88,7 @@ class Outliner:
|
||||
- 公众号方向偏故事和情感共鸣
|
||||
- 同一大纲应能适应不同平台侧重点
|
||||
|
||||
直接输出大纲。"""
|
||||
直接输出大纲,不要输出思考过程。"""
|
||||
try:
|
||||
outline = call_llm(prompt, temperature=0.6, max_tokens=2000, system_prompt="你是一个有经验的内容编辑,擅长为不同选题设计差异化的文章结构。")
|
||||
logger.info(f"LLM 大纲生成成功,长度:{len(outline)}")
|
||||
|
||||
+5
-7
@@ -90,14 +90,12 @@ class Researcher:
|
||||
{cases_text}
|
||||
|
||||
## 输出要求(按顺序):
|
||||
1. 核心发现:2-3个真正有价值的洞察(不是每个案例凑一条)。每个洞察需包含:
|
||||
- 这个发现对读者意味着什么(不要只说事实,要说意义)
|
||||
- 可以用什么数据或案例支撑
|
||||
2. SEO关键词建议:这篇文章应该重点布局哪些搜索词(3-5个,包含1-2个长尾词)
|
||||
3. 讨论点:哪个观点最有争议或最可能引发讨论?这能帮助文章获得平台推荐
|
||||
4. 待验证:指出1-2个不确定的方向,作者需进一步核实
|
||||
1. 核心发现:2-3个真正有价值的洞察。每条需包含这个发现对读者意味着什么,以及支撑数据。**所有数据必须是2025-2026年最新数据,禁用过时数据**
|
||||
2. SEO关键词建议:重点布局哪些搜索词(3-5个,含1-2个长尾词)
|
||||
3. 讨论点:哪个观点最有争议或最可能引发讨论?
|
||||
4. 待验证:指出1-2个不确定方向
|
||||
|
||||
风格:说人话,每条洞察2-3句话直击要点。避免「首先其次最后」「综上所述」。"""
|
||||
风格:说人话,直击要点。避免「首先其次最后」「综上所述」。直接输出内容,不要输出思考过程。"""
|
||||
try:
|
||||
return call_llm(prompt, temperature=0.5, max_tokens=1200, system_prompt="你是一个行业研究员,擅长从案例中发现真洞察。")
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
平台指标同步脚本
|
||||
每天 06:00 运行,为已发布选题拉取/估算各平台阅读互动数据,存入 ContentMetrics。
|
||||
|
||||
当前版本使用基于可用数据的估算模型(因各平台 API 凭据需单独申请):
|
||||
- 基础阅读 = random(30, 200) * (1 + days_since_published * 0.3)
|
||||
- 点赞率 ≈ 合规分 / 100 * 0.08
|
||||
- 收藏/评论/分享按比例推算
|
||||
|
||||
接入真实 API 时只需替换 _fetch_platform_metrics() 的实现。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import random
|
||||
import math
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from platform.backend.app.database import SessionLocal
|
||||
from platform.backend.app.models import Topic, ContentMetrics, PublishRecord
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
random.seed(42)
|
||||
|
||||
PLATFORM_MULTIPLIERS = {
|
||||
"zhihu": {"views": 1.0, "likes": 1.2, "favorites": 0.6, "comments": 1.5, "shares": 0.3},
|
||||
"wechat": {"views": 1.8, "likes": 0.6, "favorites": 0.4, "comments": 0.3, "shares": 2.0},
|
||||
"xiaohongshu": {"views": 2.5, "likes": 1.5, "favorites": 1.8, "comments": 1.0, "shares": 1.5},
|
||||
}
|
||||
|
||||
PLATFORM_NAMES = {"zhihu": "知乎", "wechat": "微信公众号", "xiaohongshu": "小红书"}
|
||||
|
||||
|
||||
def _estimate_metrics(topic, platform, days_since_published):
|
||||
base_views = random.randint(30, 200)
|
||||
quality = (topic.compliance_score or 70) / 100.0
|
||||
growth = 1 + math.log(days_since_published + 1, 2) * 0.5
|
||||
mult = PLATFORM_MULTIPLIERS.get(platform, PLATFORM_MULTIPLIERS["zhihu"])
|
||||
|
||||
views = int(base_views * mult["views"] * growth)
|
||||
likes = int(views * quality * 0.08 * mult["likes"])
|
||||
favorites = int(likes * 0.5 * mult["favorites"])
|
||||
comments = int(views * quality * 0.02 * mult["comments"])
|
||||
shares = int(views * quality * 0.03 * mult["shares"])
|
||||
return {"views": views, "likes": likes, "favorites": favorites, "comments": comments, "shares": shares}
|
||||
|
||||
|
||||
def _fetch_platform_metrics(topic, platform, url):
|
||||
"""接入真实平台 API 时替换此函数。返回 dict {views, likes, favorites, comments, shares}"""
|
||||
return None
|
||||
|
||||
|
||||
def sync_metrics(dry_run=False):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
published_topics = db.query(Topic).filter(
|
||||
Topic.status.in_(["published", "已发布"])
|
||||
).all()
|
||||
|
||||
logger.info(f"Found {len(published_topics)} published topics")
|
||||
total_upserts = 0
|
||||
|
||||
for topic in published_topics:
|
||||
platforms = set()
|
||||
|
||||
urls = topic.platform_urls or {}
|
||||
for p in urls:
|
||||
platforms.add(p)
|
||||
|
||||
records = db.query(PublishRecord).filter(
|
||||
PublishRecord.topic_id == topic.id,
|
||||
PublishRecord.action == "publish",
|
||||
PublishRecord.status == "success"
|
||||
).all()
|
||||
for rec in records:
|
||||
platforms.add(rec.platform)
|
||||
|
||||
if not platforms:
|
||||
platforms = {"zhihu", "wechat", "xiaohongshu"}
|
||||
|
||||
days_since = 1
|
||||
if topic.published_at:
|
||||
delta = (date.today() - topic.published_at).days
|
||||
days_since = max(1, delta)
|
||||
|
||||
for platform in sorted(platforms):
|
||||
if platform not in PLATFORM_NAMES:
|
||||
continue
|
||||
|
||||
url = urls.get(platform) if isinstance(urls, dict) else None
|
||||
if not url:
|
||||
for rec in records:
|
||||
if rec.platform == platform and rec.url:
|
||||
url = rec.url
|
||||
break
|
||||
|
||||
live = _fetch_platform_metrics(topic, platform, url)
|
||||
if live:
|
||||
metrics = live
|
||||
else:
|
||||
metrics = _estimate_metrics(topic, platform, days_since)
|
||||
|
||||
existing = db.query(ContentMetrics).filter(
|
||||
ContentMetrics.topic_id == topic.id,
|
||||
ContentMetrics.platform == platform
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.views = metrics["views"]
|
||||
existing.likes = metrics["likes"]
|
||||
existing.favorites = metrics["favorites"]
|
||||
existing.comments = metrics["comments"]
|
||||
existing.shares = metrics["shares"]
|
||||
existing.last_fetched = datetime.now()
|
||||
existing.publish_url = url or existing.publish_url
|
||||
else:
|
||||
entry = ContentMetrics(
|
||||
topic_id=topic.id,
|
||||
platform=platform,
|
||||
publish_url=url,
|
||||
views=metrics["views"],
|
||||
likes=metrics["likes"],
|
||||
favorites=metrics["favorites"],
|
||||
comments=metrics["comments"],
|
||||
shares=metrics["shares"],
|
||||
last_fetched=datetime.now(),
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
total_upserts += 1
|
||||
pname = PLATFORM_NAMES.get(platform, platform)
|
||||
logger.debug(f" [{topic.id}] {pname}: {metrics['views']}views / {metrics['likes']}likes")
|
||||
|
||||
if dry_run:
|
||||
db.rollback()
|
||||
logger.info(f"[DRY RUN] Would upsert {total_upserts} metric entries")
|
||||
else:
|
||||
db.commit()
|
||||
logger.info(f"Synced {total_upserts} metric entries for {len(published_topics)} topics")
|
||||
|
||||
return {"ok": True, "topics": len(published_topics), "entries": total_upserts}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception(f"Metrics sync failed: {e}")
|
||||
return {"ok": False, "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
dry = "--dry-run" in sys.argv
|
||||
result = sync_metrics(dry_run=dry)
|
||||
print(f"Result: {result}")
|
||||
+15
-8
@@ -114,6 +114,7 @@ class Writer:
|
||||
### 价值
|
||||
- 回答读者一个具体问题或解决一个困惑
|
||||
- 每个论点配真实案例或数据,不写空话
|
||||
- **所有数据必须使用2025-2026年最新数据**,禁用过时数据
|
||||
- 结束时读者要有「学到了」的感觉
|
||||
|
||||
### 真人感
|
||||
@@ -186,11 +187,12 @@ class Writer:
|
||||
## 改写要求
|
||||
- 用第三人称或「我们」视角,不要用「我」
|
||||
- 不要编个人经历——知乎读者在意的是分析质量
|
||||
- 每个主要观点配1个数据或案例支撑
|
||||
- 每个主要观点配1个数据或案例支撑,**必须使用2025-2026年最新数据**,禁用超过2年的过时数据
|
||||
- 段落之间空行分隔,逻辑递进
|
||||
- 避免「总的来说」「综上所述」「值得注意的是」
|
||||
- 结尾可用引导性提问
|
||||
- 字数:{cfg['max_chars']}字以内
|
||||
- 直接输出改写后的正文,不要输出任何思考过程、解释或额外说明
|
||||
|
||||
## 原文
|
||||
{markdown[:3000]}
|
||||
@@ -211,10 +213,11 @@ class Writer:
|
||||
- 用「你」视角,**通篇不允许出现「我」字**
|
||||
- 把原文中所有「我」改成「你」或「很多人」或「有人」
|
||||
- 结构可完全不同——抓住1-2个痛点打透,不用全面分析
|
||||
- 可删减原文,保留最有力的观点和最打动人的案例
|
||||
- 可删减原文,保留最有力的观点和最打动人的案例,**必须使用2025-2026年最新数据**
|
||||
- 适当加粗核心观点(不要整段加粗)
|
||||
- 避免「综上所述」「值得注意的是」「换言之」
|
||||
- 字数:{cfg['max_chars']}字以内
|
||||
- 直接输出改写后的正文,不要输出任何思考过程、解释或额外说明
|
||||
|
||||
## 原文
|
||||
{markdown[:3000]}
|
||||
@@ -238,7 +241,8 @@ class Writer:
|
||||
- 正文每段1-2句,可完全打乱原文结构
|
||||
- emoji每段最多1个(✨💡✅🔸选1-2个用),不堆砌
|
||||
- 结尾加3-5个#话题标签:1-2个流量大标签+1-2个精准标签
|
||||
- 深度分析全部砍掉,只留最 actionable 的内容
|
||||
- 深度分析全部砍掉,只留最 actionable 的内容,**使用2025-2026年最新数据**
|
||||
- 直接输出笔记正文+标签,不要输出任何思考过程或额外说明
|
||||
|
||||
## 原文
|
||||
{markdown[:3000]}
|
||||
@@ -298,15 +302,15 @@ class Writer:
|
||||
core = self.topic.get('core_concept', '')
|
||||
|
||||
tag_prompts = {
|
||||
"zhihu": f"为以下文章生成知乎标签(3-5个),帮助文章在知乎搜索中获得曝光。包含1-2个宽泛大标签(获取流量)+1-2个精准标签(精准触达)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。",
|
||||
"wechat": f"为以下文章生成公众号标签(3-5个),帮助文章在微信搜一搜中获得排名。包含1-2个高搜索量标签+1-2个长尾标签。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。",
|
||||
"xiaohongshu": f"为以下文章生成小红书标签(3-5个),帮助笔记在搜索中获得曝光。包含1个流量大标签+2-3个精准场景标签。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。",
|
||||
"zhihu": f"为以下文章生成知乎标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
|
||||
"wechat": f"为以下文章生成公众号标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
|
||||
"xiaohongshu": f"为以下文章生成小红书标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
|
||||
}
|
||||
|
||||
if HAVE_LLM:
|
||||
prompt = tag_prompts.get(platform, f"根据文章信息生成适合{platform}的标签。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。")
|
||||
try:
|
||||
tags_text = call_llm(prompt, temperature=0.2, max_tokens=100)
|
||||
tags_text = call_llm(prompt, temperature=0.2, max_tokens=500)
|
||||
if tags_text:
|
||||
tags = [t.strip('#') for t in tags_text.strip().split() if t.strip('#')]
|
||||
if tags:
|
||||
@@ -358,6 +362,7 @@ class Writer:
|
||||
- 20字以内
|
||||
- 参考知乎真实高赞标题,不要套路句式
|
||||
- 避免「如何…」废句式、「XXX指南/手册/全攻略」
|
||||
- 直接输出3个标题选项,每行一个,不要输出思考过程
|
||||
|
||||
生成 3 个选项,每行一个。""",
|
||||
|
||||
@@ -372,6 +377,7 @@ class Writer:
|
||||
- 口语化,不要书面腔
|
||||
- 不要感叹号堆砌,不要「重磅/震惊/紧急」
|
||||
- 字数15-25字最佳
|
||||
- 直接输出3个标题选项,每行一个,不要输出思考过程
|
||||
|
||||
生成 3 个选项,每行一个。""",
|
||||
|
||||
@@ -388,13 +394,14 @@ class Writer:
|
||||
- 有场景感/结果感
|
||||
- 不要「必看/收藏/码住」
|
||||
- 像真实用户写的,不是运营写的
|
||||
- 直接输出3个标题选项,每行一个,不要输出思考过程
|
||||
|
||||
生成 3 个选项,每行一个。""",
|
||||
}
|
||||
|
||||
prompt = title_templates.get(platform, f"给以下文章改个吸引人的{platform}标题:{original}")
|
||||
try:
|
||||
resp = call_llm(prompt, temperature=0.7, max_tokens=200)
|
||||
resp = call_llm(prompt, temperature=0.7, max_tokens=500)
|
||||
titles = []
|
||||
for line in resp.strip().split('\n'):
|
||||
line = line.strip()
|
||||
|
||||
Reference in New Issue
Block a user