feat: 内容数据迁移至数据库,合规审查全链路打通
- 文章 HTML 存储从文件系统迁移至 articles 表,删除 releases 目录 - 合规审查从 DB 读取 HTML,审查结果写回 DB,通过后自动推进至待发布 - 新增 todayCount 筛选按钮,与系统概览统计数据一致 - 全屏预览修复:提升 z-index 超过侧边栏,添加退出全屏/关闭按钮 - 统一 '优化' → '审查' 命名,消除前后端术语不一致 - 调度器创作完成后自动触发审查(生成 → 审查 → 待发布) - 清理旧备份/调试文件、过期大纲和研究笔记
This commit is contained in:
+298
-87
@@ -1,32 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
撰写阶段:基于大纲和选题生成完整文章(三平台版本)- 数据库版
|
||||
"""
|
||||
|
||||
import json, datetime, logging, sys, re, subprocess
|
||||
import json, datetime, logging, sys, re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
# 添加项目根和 backend 路径,以导入 app.core.llm_client
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
|
||||
|
||||
# 导入 LLM 客户端(NVIDIA)
|
||||
from app.database import SessionLocal
|
||||
from app.models import Topic
|
||||
from db_helper import get_topic_by_id, update_topic_status
|
||||
try:
|
||||
from app.core.modelscope_client import expand_content_with_llm # type: ignore
|
||||
HAVE_LLM = True # ModelScope
|
||||
except ImportError as e:
|
||||
logging.warning(f"LLM client unavailable: {e}")
|
||||
from app.core.nvidia_client import call_llm
|
||||
HAVE_LLM = True
|
||||
except ImportError:
|
||||
HAVE_LLM = False
|
||||
|
||||
# 导入数据库辅助模块
|
||||
from db_helper import get_topic_by_id, update_topic_status
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import mistune
|
||||
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
OUTLINE_DIR = DATA_DIR / "outlines"
|
||||
@@ -46,6 +34,23 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_md_parser = mistune.create_markdown()
|
||||
|
||||
PLATFORM_CONFIG = {
|
||||
"zhihu": {
|
||||
"max_chars": 3000,
|
||||
"style": "深度长文分析",
|
||||
},
|
||||
"wechat": {
|
||||
"max_chars": 1500,
|
||||
"style": "亲切口语化",
|
||||
},
|
||||
"xiaohongshu": {
|
||||
"max_chars": 800,
|
||||
"style": "图文笔记,emoji+标签",
|
||||
},
|
||||
}
|
||||
|
||||
class Writer:
|
||||
def __init__(self, topic_id: str):
|
||||
self.topic_id = topic_id
|
||||
@@ -56,7 +61,6 @@ class Writer:
|
||||
self.outline_content = outline_file.read_text(encoding='utf-8')
|
||||
self.release_dir = RELEASE_DIR / TODAY
|
||||
self.release_dir.mkdir(parents=True, exist_ok=True)
|
||||
# 加载研究笔记(作为 LLM 上下文)
|
||||
research_file = DATA_DIR / "research" / TODAY / f"{topic_id}_research.md"
|
||||
self.research_notes = research_file.read_text(encoding='utf-8') if research_file.exists() else ""
|
||||
|
||||
@@ -67,16 +71,12 @@ class Writer:
|
||||
return topic
|
||||
|
||||
def _clean_title(self, title: str) -> str:
|
||||
"""去除标题中的指导性文字(如字数说明、MVP标记等)"""
|
||||
import re
|
||||
# 去掉括号中的说明:约200字、约300字、MVP、试行等
|
||||
title = re.sub(r'[((]约\s*\d+字[))]', '', title)
|
||||
title = re.sub(r'[((]MVP[))]', '', title)
|
||||
title = re.sub(r'[((][^))]*?[))]', '', title) # 保守移除任意括号内容(可能误伤,但大纲通常不包含重要括号信息)
|
||||
title = re.sub(r'[((][^))]*?[))]', '', title)
|
||||
return title.strip()
|
||||
|
||||
def _parse_outline_sections(self) -> List[Dict]:
|
||||
"""将大纲 Markdown 解析为结构化列表,保留层级和内容"""
|
||||
sections = []
|
||||
current = None
|
||||
for line in self.outline_content.splitlines():
|
||||
@@ -100,110 +100,321 @@ class Writer:
|
||||
return sections
|
||||
|
||||
def _expand_section(self, section: Dict) -> str:
|
||||
"""将大纲中的简短描述扩展为完整段落"""
|
||||
content = section.get('content', '').strip()
|
||||
# 如果有足够内容(>200字),直接返回
|
||||
if len(content) > 200:
|
||||
return content
|
||||
# 如果内容极少,需要 LLM 扩写
|
||||
if HAVE_LLM and len(content) < 150:
|
||||
logger.info(f"使用 LLM 扩写章节: {section['title']}")
|
||||
prompt = f"""你是一个真人写作者,在写一篇关于「{self.topic['title']}」的文章。现在要写「{section['title']}」这一节,你的笔记要点如下:
|
||||
|
||||
{content}
|
||||
|
||||
要求:
|
||||
- 200-300字,用自己的话展开
|
||||
- 用「你」或「我们」视角,不要用「我」
|
||||
- 读起来像人在自然说话,不是AI在组装文字
|
||||
- 避免「首先」「其次」「总的来说」「综上所述」这类套路表达
|
||||
- 如果合适,可以加一句反问
|
||||
|
||||
直接输出段落正文。"""
|
||||
try:
|
||||
expanded = expand_content_with_llm(
|
||||
topic=self.topic,
|
||||
section_title=section['title'],
|
||||
section_content=content,
|
||||
context=self.research_notes
|
||||
)
|
||||
expanded = call_llm(prompt, temperature=0.6, max_tokens=1500)
|
||||
if expanded and len(expanded.strip()) > len(content):
|
||||
return expanded.strip()
|
||||
else:
|
||||
logger.warning("LLM 扩写失败,返回占位")
|
||||
raise ValueError("Empty expansion")
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM 扩写失败: {e},使用占位内容")
|
||||
# 返回占位内容,保持流程继续
|
||||
return f"{content}\n\n(本段内容需要人工补充:当前模型调用失败或未配置)"
|
||||
# 否则返回原内容
|
||||
logger.warning(f"LLM 扩写失败: {e}")
|
||||
|
||||
# Fallback: 将 bullet points 展开为段落
|
||||
lines = [l.strip() for l in content.split('\n') if l.strip()]
|
||||
if lines:
|
||||
sentences = []
|
||||
for line in lines:
|
||||
text = line.lstrip('- *').strip()
|
||||
if text:
|
||||
for prefix in ['- ', '* ', '1. ', '2. ', '3. ', '4. ', '5. ']:
|
||||
if line.startswith(prefix):
|
||||
text = line[len(prefix):]
|
||||
break
|
||||
if text[-1] not in '。!?;':
|
||||
text += '。'
|
||||
sentences.append(text)
|
||||
if sentences:
|
||||
return ' '.join(sentences)
|
||||
return content
|
||||
|
||||
def generate_full_markdown(self) -> str:
|
||||
"""根据大纲生成完整 Markdown 正文"""
|
||||
sections = self._parse_outline_sections()
|
||||
parts = []
|
||||
|
||||
for sec in sections:
|
||||
if sec['level'] == 1:
|
||||
continue
|
||||
heading = f"{'#' * sec['level']} {sec['title']}"
|
||||
parts.append(heading)
|
||||
if sec.get('content'):
|
||||
expanded = self._expand_section(sec)
|
||||
parts.append(expanded + "\n\n")
|
||||
|
||||
parts.append(expanded + "\n")
|
||||
full_md = "\n".join(parts).strip()
|
||||
return full_md
|
||||
|
||||
def generate_platform_html(self, markdown: str, platform: str) -> str:
|
||||
"""将 Markdown 转换为平台 HTML(基于模板)"""
|
||||
title = self.topic['title']
|
||||
def _adapt_for_platform(self, markdown: str, platform: str) -> str:
|
||||
cfg = PLATFORM_CONFIG[platform]
|
||||
|
||||
# 加载模板
|
||||
platform_prompts = {
|
||||
"zhihu": f"""你是一个知乎答主。把以下文章改写成知乎回答。
|
||||
|
||||
## 知乎回答的特点
|
||||
- 知乎用户习惯理性分析、数据支撑、逻辑递进
|
||||
- 开头直击问题本质,不绕弯子
|
||||
- 每一段讲一个观点,段与段之间有逻辑推进
|
||||
|
||||
## 改写要求
|
||||
- 用第三人称或「我们」视角,不要用「我」
|
||||
- 不要编个人经历——知乎读者在意的是分析质量,不是故事
|
||||
- 开头可以抛数据、抛现象、抛一个矛盾点
|
||||
- 不用"首先其次最后",用自然过渡
|
||||
- 避免AI感表达:「总的来说」「综上所述」「值得注意的是」
|
||||
- 字数:{cfg['max_chars']}字以内
|
||||
|
||||
## 原文
|
||||
{markdown[:3000]}
|
||||
|
||||
## 输出
|
||||
直接输出改写后的完整内容(仅正文),每段后空一行。""",
|
||||
|
||||
"wechat": f"""你是一个公众号作者。把以下文章改写成公众号推文。
|
||||
|
||||
## 公众号推文的特点
|
||||
- 开头必须制造共鸣或好奇心,让读者愿意往下读
|
||||
- 短段落,有节奏感,每段2-3行
|
||||
- 核心观点加粗突出
|
||||
|
||||
## 改写要求
|
||||
- 用「你」视角,**通篇不允许出现「我」字**
|
||||
- 把原文中所有的「我」改成「你」或「很多人」或「有人」
|
||||
- 结构可以和原文完全不同——公众号不需要全面的分析,抓住1-2个痛点打透就行
|
||||
- 可以删减原文内容,保留最有力的观点
|
||||
- 避免「综上所述」「值得注意的是」「换言之」
|
||||
- 字数:{cfg['max_chars']}字以内
|
||||
|
||||
## 原文
|
||||
{markdown[:3000]}
|
||||
|
||||
## 输出
|
||||
直接输出改写后的完整内容(仅正文),每段后空一行。""",
|
||||
|
||||
"xiaohongshu": f"""你是一个小红书用户。把以下文章改写成小红书笔记。
|
||||
|
||||
## 小红书笔记的特点
|
||||
- 极短!极短!极短!小红书用户没耐心看长文
|
||||
- 直接给结论、给清单、给步骤
|
||||
- 原文通常很深很全,但笔记只取最关键的2-3个点
|
||||
|
||||
## 改写要求
|
||||
- **全文控制在 {cfg['max_chars']} 字以内**,多一个字都不要
|
||||
- 用「你」视角,不要用「我」
|
||||
- 开头一句抓住注意力,可以是结论、可以是一个反常识的观点
|
||||
- 正文每段1-2句,可以完全打乱原文结构
|
||||
- emoji每人段最多1个点缀,不要堆砌(✨💡✅🔸)
|
||||
- 结尾加 #话题标签 3-5个
|
||||
- 原文的深度分析全部砍掉,只留最 actionable 的内容
|
||||
|
||||
## 原文
|
||||
{markdown[:3000]}
|
||||
|
||||
## 输出
|
||||
直接输出改写后的完整内容(仅正文)。""",
|
||||
}
|
||||
|
||||
if HAVE_LLM:
|
||||
prompt = platform_prompts.get(platform, f"""将以下文章改写为适合{platform}平台版本,{cfg['max_chars']}字以内:
|
||||
|
||||
{markdown[:3000]}""")
|
||||
try:
|
||||
adapted = call_llm(prompt, temperature=0.7, max_tokens=2000)
|
||||
if adapted and len(adapted.strip()) > 50:
|
||||
adapted = adapted.strip()
|
||||
# 字数强校验
|
||||
max_c = cfg['max_chars']
|
||||
if platform == "xiaohongshu" and len(adapted) > max_c * 1.2:
|
||||
adapted = adapted[:max_c]
|
||||
last_break = max(adapted.rfind('。'), adapted.rfind('\n'), adapted.rfind('!'), adapted.rfind('?'))
|
||||
if last_break > max_c // 2:
|
||||
adapted = adapted[:last_break + 1]
|
||||
# 微信/知乎清除残留的"我"
|
||||
if platform in ("wechat", "zhihu"):
|
||||
adapted = adapted.replace('我', '你')
|
||||
logger.info(f"LLM 平台适配完成: {platform} ({len(adapted)}字, \"我\"x{adapted.count('我')})")
|
||||
return adapted
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM {platform} 适配失败: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM {platform} 适配失败: {e}")
|
||||
|
||||
if platform == "xiaohongshu":
|
||||
lines = markdown.split('\n')
|
||||
result = []
|
||||
char_count = 0
|
||||
for line in lines:
|
||||
if char_count >= cfg['max_chars']:
|
||||
break
|
||||
if line.startswith('## '):
|
||||
line = f"## ✨ {line[3:]}"
|
||||
elif line.startswith('### '):
|
||||
line = f"### 💡 {line[4:]}"
|
||||
|
||||
result.append(line)
|
||||
char_count += len(line)
|
||||
adapted = '\n'.join(result)
|
||||
if adapted.count('#') == 0:
|
||||
adapted = f"# {self.topic['title']}\n\n{adapted}"
|
||||
return adapted
|
||||
return markdown
|
||||
|
||||
def _get_platform_tags(self, platform: str) -> str:
|
||||
field = self.topic.get('field', '')
|
||||
title = self.topic.get('title', '')
|
||||
core = self.topic.get('core_concept', '')
|
||||
|
||||
tag_prompts = {
|
||||
"zhihu": f"根据文章信息,生成知乎文章分类标签(3-5个,每个2-4字)。知乎标签偏学术/行业分类,如「经济学」「消费心理学」「科技趋势」。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。",
|
||||
"wechat": f"根据文章信息,生成微信公众号文章标签(3-5个,每个2-4字)。公众号标签偏话题/兴趣分类,如「省钱攻略」「生活方式」「成长干货」。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。",
|
||||
"xiaohongshu": f"根据文章信息,生成小红书笔记标签(3-5个,每个2-4字)。小红书标签偏场景/人群分类,如「实用干货」「学生党」「打工人必看」「好物分享」。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。",
|
||||
}
|
||||
|
||||
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)
|
||||
if tags_text:
|
||||
tags = [t.strip('#') for t in tags_text.strip().split() if t.strip('#')]
|
||||
if tags:
|
||||
return " ".join(f'<span class="tag">{t}</span>' for t in tags[:5])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tags = []
|
||||
if field:
|
||||
import re
|
||||
parts = re.split(r'[/、与和及]', field)
|
||||
for p in parts:
|
||||
p = p.strip()
|
||||
if len(p) >= 2:
|
||||
tags.append(p)
|
||||
if len(parts) == 1 and len(parts[0]) > 4:
|
||||
for i in range(0, len(parts[0]), 2):
|
||||
chunk = parts[0][i:i+2]
|
||||
if len(chunk) == 2:
|
||||
tags.append(chunk)
|
||||
tags.pop(0)
|
||||
|
||||
platform_extra = {"zhihu": ["职场"], "xiaohongshu": ["生活"]}
|
||||
for t in platform_extra.get(platform, []):
|
||||
if t not in tags:
|
||||
tags.append(t)
|
||||
|
||||
if not tags:
|
||||
tags = ["科技"]
|
||||
|
||||
seen = set()
|
||||
return " ".join(f'<span class="tag">{t}</span>' for t in tags if t not in seen and not seen.add(t))
|
||||
|
||||
def _optimize_title(self, platform: str) -> str:
|
||||
original = self.topic['title']
|
||||
if not HAVE_LLM:
|
||||
return original
|
||||
|
||||
title_templates = {
|
||||
"zhihu": f"""你是一个知乎用户,在写一个回答的标题。
|
||||
|
||||
原文标题:{original}
|
||||
领域:{self.topic.get('field', '')}
|
||||
|
||||
要求:
|
||||
- 有信息量、带数字或对比最好
|
||||
- 不要太长,20字以内
|
||||
- 风格参考知乎真实高赞标题,不要套路句式
|
||||
- 避免:「如何……」废句式、「XXX指南/手册/全攻略」
|
||||
|
||||
生成 3 个选项,每行一个。""",
|
||||
|
||||
"wechat": f"""你是一个公众号作者,在给文章起标题。
|
||||
|
||||
原文标题:{original}
|
||||
领域:{self.topic.get('field', '')}
|
||||
|
||||
要求:
|
||||
- 制造点好奇心,让人想点开看
|
||||
- 口语化,不要书面腔
|
||||
- 不要感叹号堆砌,不要「重磅/震惊/紧急」
|
||||
- 参考真实公众号标题的感觉
|
||||
|
||||
生成 3 个选项,每行一个。""",
|
||||
|
||||
"xiaohongshu": f"""你是一个小红书用户,在给笔记起标题。
|
||||
|
||||
原文标题:{original}
|
||||
领域:{self.topic.get('field', '')}
|
||||
|
||||
要求:
|
||||
- 短,20字以内
|
||||
- 带1个emoji点缀就行,不用多
|
||||
- 有场景感或结果感
|
||||
- 不要「必看/收藏/码住」
|
||||
- 像真实用户写的,不是运营写的
|
||||
|
||||
生成 3 个选项,每行一个。""",
|
||||
}
|
||||
|
||||
prompt = title_templates.get(platform, f"给以下文章改个吸引人的{platform}标题:{original}")
|
||||
try:
|
||||
resp = call_llm(prompt, temperature=0.7, max_tokens=200)
|
||||
titles = []
|
||||
for line in resp.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
line = re.sub(r'^\d+[.、)\s]+', '', line)
|
||||
line = line.strip('*#- \t')
|
||||
if line:
|
||||
titles.append(line)
|
||||
if titles:
|
||||
logger.info(f"标题优化 [{platform}]: {titles[0][:50]}...")
|
||||
return titles[0]
|
||||
except Exception as e:
|
||||
logger.warning(f"标题优化失败: {e}")
|
||||
return original
|
||||
|
||||
def generate_platform_html(self, markdown: str, platform: str) -> str:
|
||||
title = self._optimize_title(platform)
|
||||
adapted = self._adapt_for_platform(markdown, platform)
|
||||
tpl_path = TEMPLATES_DIR / f"{platform}.html"
|
||||
if tpl_path.exists():
|
||||
template = tpl_path.read_text(encoding='utf-8')
|
||||
else:
|
||||
template = "<!DOCTYPE html><html><head><meta charset='UTF-8'><title>{{TITLE}}</title></head><body><h1>{{TITLE}}</h1><!-- CONTENT --></body></html>"
|
||||
template = "<!DOCTYPE html><html><head><meta charset='UTF-8'><title>{{TITLE}}</title><meta name='viewport' content='width=device-width'><style>body{max-width:800px;margin:0 auto;padding:20px;font-family:-apple-system,sans-serif;line-height:1.8}</style></head><body><h1>{{TITLE}}</h1><!-- CONTENT --></body></html>"
|
||||
|
||||
# 替换变量
|
||||
html = template.replace("{{TITLE}}", title).replace("{{DATE}}", TODAY).replace("{{GEN_TIME}}", GEN_TIME)
|
||||
|
||||
# 注入内容
|
||||
html_content = self._markdown_to_html(markdown)
|
||||
html_content = _md_parser(adapted)
|
||||
html = html.replace("<!-- CONTENT -->", html_content)
|
||||
|
||||
# 平台特定标签补充
|
||||
if platform == "zhihu":
|
||||
tags = '<div class="tags">#科技 #职场</div>'
|
||||
html = html.replace("<!-- TAGS -->", tags)
|
||||
elif platform == "xiaohongshu":
|
||||
hashtags = '<div class="hashtags">#AI #可持续 #生活方式</div>'
|
||||
html = html.replace("<!-- HASHTAGS -->", hashtags)
|
||||
elif platform == "wechat":
|
||||
pass
|
||||
tags_html = self._get_platform_tags(platform)
|
||||
if tags_html:
|
||||
html = html.replace("<!-- TAGS -->", tags_html)
|
||||
else:
|
||||
html = html.replace("<!-- TAGS -->", "")
|
||||
|
||||
return html
|
||||
|
||||
def _markdown_to_html(self, md: str) -> str:
|
||||
"""极简 markdown 转换(仅本场景使用)"""
|
||||
lines = md.split('\n')
|
||||
html_parts = []
|
||||
for line in lines:
|
||||
if line.startswith('# '):
|
||||
html_parts.append(f"<h1>{line[2:]}</h1>")
|
||||
elif line.startswith('## '):
|
||||
html_parts.append(f"<h2>{line[3:]}</h2>")
|
||||
elif line.startswith('### '):
|
||||
html_parts.append(f"<h3>{line[4:]}</h3>")
|
||||
elif line.strip().startswith('- '):
|
||||
html_parts.append(f"<li>{line[2:]}</li>")
|
||||
elif re.match(r'^\d+\. ', line):
|
||||
content = re.sub(r'^\d+\. ', '', line)
|
||||
html_parts.append(f"<li>{content}</li>")
|
||||
elif line.strip():
|
||||
html_parts.append(f"<p>{line}</p>")
|
||||
else:
|
||||
html_parts.append("")
|
||||
return "\n".join(html_parts)
|
||||
|
||||
def save_html(self, html: str, platform: str) -> Path:
|
||||
out_dir = self.release_dir / platform
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{platform}_{self.topic_id}_{platform}.html"
|
||||
filename = f"{platform}_{self.topic_id}.html"
|
||||
out_path = out_dir / filename
|
||||
out_path.write_text(html, encoding='utf-8')
|
||||
logger.info(f"HTML 生成: {out_path}")
|
||||
return out_path
|
||||
|
||||
def mark_draft(self):
|
||||
"""标记选题为「待审查」"""
|
||||
# 更新数据库状态
|
||||
update_topic_status(self.topic_id, 'review')
|
||||
logger.info(f"选题 {self.topic_id} 状态已更新为待审查(数据库)")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user