7c9a8b88b4
- writer.py: inject_geo_metadata now detects content type (article/listicle/howto/faq/review) and injects appropriate JSON-LD (FAQPage, HowTo, ItemList) - New helpers: _detect_content_type, _extract_faq_pairs, _extract_howto_steps - outline.py: _extract_seo_keywords regex support for CJK - prompt_loader.py: Minor update Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
147 lines
5.3 KiB
Python
147 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
大纲阶段:基于选题和研究笔记,用 LLM 动态生成结构化大纲
|
||
"""
|
||
|
||
import json, datetime, logging, sys, re
|
||
from pathlib import Path
|
||
from typing import Dict
|
||
|
||
PROJECT_ROOT = Path(__file__).parent.parent
|
||
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 prompt_loader import get_prompt, get_prompt_params
|
||
try:
|
||
from app.core.nvidia_client import call_llm
|
||
HAVE_LLM = True
|
||
except ImportError:
|
||
HAVE_LLM = False
|
||
|
||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||
RESEARCH_DIR = DATA_DIR / "research"
|
||
OUTPUT_DIR = DATA_DIR / "outlines"
|
||
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
|
||
|
||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s',
|
||
handlers=[logging.FileHandler(LOGS_DIR / f"outline_{TODAY}.log"), logging.StreamHandler()])
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class Outliner:
|
||
def __init__(self, topic_id: str):
|
||
self.topic_id = topic_id
|
||
self.topic = self._load_topic()
|
||
research_file = RESEARCH_DIR / TODAY / f"{topic_id}_research.md"
|
||
self.research_notes = research_file.read_text(encoding='utf-8') if research_file.exists() else ""
|
||
self.output_dir = OUTPUT_DIR / TODAY
|
||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
def _load_topic(self) -> Dict:
|
||
topic = get_topic_by_id(self.topic_id)
|
||
if not topic: raise ValueError(f"Topic {self.topic_id} not found")
|
||
return topic
|
||
|
||
@staticmethod
|
||
def _extract_seo_keywords(research_notes: str) -> str:
|
||
"""从研究笔记中提取 SEO 关键词"""
|
||
if not research_notes:
|
||
return "暂无"
|
||
# 尝试匹配 "SEO关键词建议" 块
|
||
m = re.search(r'(?:SEO关键词建议|SEO关键词|搜索词)[::]\s*(.*?)(?:\n\n|\Z)', research_notes, re.DOTALL)
|
||
if m:
|
||
return m.group(1).strip()[:300]
|
||
# 回退:取所有 # 标签或关键词模式
|
||
keywords = re.findall(r'[##]([\u4e00-\u9fff\w]{2,6})', research_notes)
|
||
if keywords:
|
||
return "、".join(keywords[:5])
|
||
return "暂无"
|
||
|
||
def generate_outline(self) -> str:
|
||
title = self.topic['title']
|
||
field = self.topic.get('field', '')
|
||
core = self.topic.get('core_concept', '')
|
||
pain = self.topic.get('audience_pain', '')
|
||
angle = self.topic.get('unique_angle', '')
|
||
cases_summary = self.research_notes[:2000] if self.research_notes else "暂无研究笔记"
|
||
seo_keywords = self._extract_seo_keywords(self.research_notes)
|
||
|
||
if HAVE_LLM:
|
||
_now = datetime.datetime.now()
|
||
prompt = get_prompt("outline_generation",
|
||
date=_now.strftime('%Y年%m月%d日'),
|
||
year=_now.year,
|
||
title=title,
|
||
field=field,
|
||
core=core,
|
||
pain=pain,
|
||
angle=angle,
|
||
cases_summary=cases_summary,
|
||
seo_keywords=seo_keywords,
|
||
)
|
||
try:
|
||
params = get_prompt_params("outline_generation")
|
||
outline = call_llm(prompt, temperature=params.get("temperature", 0.7), max_tokens=params.get("max_tokens", 4000), system_prompt="你是一个有经验的内容编辑,擅长为不同选题设计差异化的文章结构。")
|
||
logger.info(f"LLM 大纲生成成功,长度:{len(outline)}")
|
||
return f"# 文章大纲:{title}\n\n{outline}\n\n---\n*大纲生成时间:{TODAY}*"
|
||
except Exception as e:
|
||
logger.warning(f"LLM 大纲生成失败: {e},使用模板")
|
||
|
||
return self._template_outline(title, field, core, pain, angle)
|
||
|
||
def _template_outline(self, title, field, core, pain, angle) -> str:
|
||
case_count = self.research_notes.count('### 案例') if self.research_notes else 0
|
||
case_section = f"""## 四、全球/行业趋势与案例
|
||
- 引用研究笔记中的 {case_count} 个案例,精选 2-3 个详述
|
||
- 数据支撑:提取研究笔记中的关键数据
|
||
- 趋势分析""" if case_count > 0 else ""
|
||
return f"""# 文章大纲:{title}
|
||
|
||
## 一、引言
|
||
- 场景切入:{title}
|
||
- 点明文章价值
|
||
|
||
## 二、核心观点
|
||
{core}
|
||
|
||
## 三、受众痛点分析
|
||
{pain}
|
||
{case_section}
|
||
|
||
## {"五" if case_section else "四"}、本土落地建议
|
||
- 结合{field}领域特点
|
||
- 提供可执行的步骤
|
||
- 注意事项
|
||
|
||
## {"六" if case_section else "五"}、独特视角:{angle}
|
||
|
||
## {"七" if case_section else "六"}、行动指南
|
||
1. 了解现状 2. 制定方案 3. 小范围验证 4. 持续优化
|
||
|
||
## {"八" if case_section else "七"}、总结与鼓励
|
||
|
||
---
|
||
|
||
*大纲生成时间:{TODAY}*"""
|
||
|
||
def save(self):
|
||
outline_text = self.generate_outline()
|
||
out_path = self.output_dir / f"{self.topic_id}_outline.md"
|
||
out_path.write_text(outline_text, encoding='utf-8')
|
||
logger.info(f"大纲已保存: {out_path}")
|
||
return out_path
|
||
|
||
def main():
|
||
import argparse
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument('--topic-id', required=True)
|
||
args = parser.parse_args()
|
||
o = Outliner(args.topic_id)
|
||
o.save()
|
||
print(f"SUCCESS: Outline created for {args.topic_id}")
|
||
sys.exit(0)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|