Initial commit: yu-zhi-ran platform with automation integration

This commit is contained in:
lt
2026-04-19 14:05:09 +08:00
commit 3cb2df51c8
209 changed files with 80379 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# FastAPI 应用初始化
+1
View File
@@ -0,0 +1 @@
# API routes
+54
View File
@@ -0,0 +1,54 @@
from fastapi import APIRouter, HTTPException, Query
from pathlib import Path
import os
from datetime import datetime, date
router = APIRouter(prefix="/api/articles", tags=["articles"])
PROJECT_ROOT = Path('/root/.openclaw/workspaces/yzr-yxl/projects/yu-zhi-ran')
@router.get("/drafts")
def list_drafts(publish_date: str = None):
"""列出指定日期的草稿文件(三平台)"""
if not publish_date:
publish_date = date.today().isoformat()
base_dir = PROJECT_ROOT / "automation" / "data" / "releases" / publish_date
if not base_dir.exists():
raise HTTPException(status_code=404, detail="No releases for this date")
platforms = ["zhihu", "wechat", "xiaohongshu"]
result = {}
for p in platforms:
path = base_dir / p
if path.exists():
files = sorted([f.name for f in path.glob("*.html") if f.is_file()])
result[p] = files
else:
result[p] = []
return {"date": publish_date, "files": result}
@router.get("/{topic_id}/preview")
def preview_article(topic_id: str, platform: str = "zhihu", publish_date: str = None):
"""预览某选题的HTML内容"""
if not publish_date:
publish_date = date.today().isoformat()
filename = f"{platform}_{topic_id}_{platform}.html"
file_path = PROJECT_ROOT / "automation" / "data" / "releases" / publish_date / platform / filename
# DEBUG
print(f"[DEBUG] file_path={file_path}, exists={file_path.exists()}")
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"Article not found: {file_path}")
content = file_path.read_text(encoding='utf-8')
return {"topic_id": topic_id, "platform": platform, "html": content}
@router.get("/optimization-report")
def get_optimization_report(publish_date: str = None):
"""获取合规优化报告"""
if not publish_date:
publish_date = date.today().isoformat()
report_path = PROJECT_ROOT / "automation" / "data" / "drafts" / publish_date / "optimization_report.json"
if not report_path.exists():
raise HTTPException(status_code=404, detail="No optimization report for this date")
report = report_path.read_text(encoding='utf-8')
import json
return json.loads(report)
+156
View File
@@ -0,0 +1,156 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List, Optional
from pathlib import Path
import subprocess
import json
from datetime import datetime
from ..database import get_db
from ..models import Topic
router = APIRouter(prefix="/api/publisher", tags=["publisher"])
# 项目根目录(从 api/publisher.py 上升到 yu-zhi-ran 根目录)
import os
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if os.getenv('PROJECT_ROOT'):
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
SCRIPTS_DIR = PROJECT_ROOT / "scripts"
@router.get("/ready")
def get_ready_topics(
platform: Optional[str] = None,
db: Session = Depends(get_db)
):
"""获取待发布的选题(状态为 ready"""
query = db.query(Topic).filter(Topic.status == "ready")
if platform:
# 筛选未在该平台发布的选题
# platform_urls 是 JSON 字段,需要特殊处理
pass # 简化:暂不筛选
topics = query.order_by(Topic.ready_at.desc()).all()
return topics
@router.post("/generate/{topic_id}")
def generate_publish_package(
topic_id: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db)
):
"""为指定选题生成发布包(所有平台HTML)"""
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
# 调用 publisher.py 脚本
script_path = SCRIPTS_DIR / "publisher.py"
if not script_path.exists():
raise HTTPException(status_code=500, detail="Publisher script not found")
try:
result = subprocess.run(
["python3", str(script_path), "--topic-id", topic_id],
capture_output=True,
text=True,
timeout=300,
cwd=str(PROJECT_ROOT)
)
if result.returncode != 0:
raise HTTPException(status_code=500, detail=f"Publisher failed: {result.stderr}")
return {
"message": "Publish package generated",
"topic_id": topic_id,
"output": result.stdout
}
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="Publisher timeout")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/packages/{topic_id}")
def list_platform_packages(topic_id: str):
"""列出某个选题的所有平台发布包"""
release_dir = PROJECT_ROOT / "automation" / "data" / "releases"
today = datetime.now().strftime("%Y-%m-%d")
packages = []
for platform in ["zhihu", "wechat", "xiaohongshu", "bilibili", "toutiao"]:
html_file = release_dir / today / platform / f"{platform}_{topic_id}_{platform}.html"
if html_file.exists():
packages.append({
"platform": platform,
"file": str(html_file.relative_to(PROJECT_ROOT)),
"size": html_file.stat().st_size
})
published_dir = PROJECT_ROOT / "content" / "published" / topic_id / "手动发布"
if published_dir.exists():
for platform_dir in published_dir.iterdir():
if platform_dir.is_dir():
html_file = platform_dir / "文章.html"
if html_file.exists():
packages.append({
"platform": platform_dir.name,
"file": str(html_file.relative_to(PROJECT_ROOT)),
"size": html_file.stat().st_size,
"manual": True
})
return {"topic_id": topic_id, "packages": packages}
@router.get("/package/{topic_id}/{platform}")
def get_package_html(topic_id: str, platform: str):
"""获取指定平台发布包的HTML内容"""
# 优先查找 published 目录(手动发布包)
published_html = PROJECT_ROOT / "content" / "published" / topic_id / "手动发布" / platform / "文章.html"
if published_html.exists():
return {"html": published_html.read_text(encoding='utf-8')}
# 其次查找 releases 目录(自动生成)
today = datetime.now().strftime("%Y-%m-%d")
release_html = PROJECT_ROOT / "automation" / "data" / "releases" / today / platform / f"{platform}_{topic_id}_{platform}.html"
if release_html.exists():
return {"html": release_html.read_text(encoding='utf-8')}
raise HTTPException(status_code=404, detail="Package not found")
@router.post("/mark/{topic_id}/published")
def mark_as_published(
topic_id: str,
platform_urls: dict,
db: Session = Depends(get_db)
):
"""手动标记选题为已发布,记录平台链接"""
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
topic.status = "published"
topic.published_at = datetime.now().date()
topic.platform_urls = platform_urls
db.commit()
return {"message": "Topic marked as published", "topic_id": topic_id}
@router.get("/status")
def get_publisher_status():
"""获取发布统计"""
# 统计今日已发布数量等
today = datetime.now().strftime("%Y-%m-%d")
release_dir = PROJECT_ROOT / "automation" / "data" / "releases" / today
stats = {
"today_releases": 0,
"platforms": {}
}
if release_dir.exists():
for platform_dir in release_dir.iterdir():
if platform_dir.is_dir():
count = len(list(platform_dir.glob("*.html")))
stats["platforms"][platform_dir.name] = count
stats["today_releases"] += count
return stats
+197
View File
@@ -0,0 +1,197 @@
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime, date, timedelta
from typing import Dict, Any, List, Optional
from pathlib import Path
import os
import json
from ..database import get_db
from ..models import Topic, Article
from ..schemas import SystemStatus
from ..core.generator import run_creator
from ..core.optimizer import run_optimizer
from ..core.sync import sync_topic_to_db, sync_all_topics
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if os.getenv('PROJECT_ROOT'):
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
DATA_DIR = PROJECT_ROOT / "automation" / "data"
router = APIRouter(prefix="/api/system", tags=["system"])
@router.get("/status", response_model=SystemStatus)
def get_status(db: Session = Depends(get_db)):
"""系统状态概览"""
total = db.query(Topic).count()
by_status_result = db.query(Topic.status, func.count()).group_by(Topic.status).all()
by_status = {status: count for status, count in by_status_result}
# 确保返回所有状态,避免前端 undefined
for key in ('pending', 'ready', 'published'):
by_status.setdefault(key, 0)
ready = db.query(Topic).filter(Topic.status == "ready").all()
today_str = date.today().isoformat()
# 计算今日文章数:查找 releases/2026-04-16 目录下的 html 文件
# 这里简单统计数据库中 created_at 为今天的文章(不完全准确)
today_articles = db.query(Article).filter(
func.date(Article.created_at) == date.today()
).count()
# 合规率:假设所有 ready 的都是合规的(实际从report读取)
# 可以后续优化
# 获取最后一次优化时间
last_opt = db.query(Article).filter(
Article.status == "optimized"
).order_by(Article.created_at.desc()).first()
return SystemStatus(
total_topics=total,
topics_by_status=by_status,
ready_topics=ready,
today_articles=today_articles,
compliance_rate=100.0, # placeholder
last_optimization=last_opt.created_at if last_opt else None
)
@router.post("/generate/run")
def trigger_generation(topic_id: str = None, db: Session = Depends(get_db)):
"""手动触发内容创作任务
Args:
topic_id: 可选,指定要创作的选题ID。不指定则创作优先级最高的待处理选题。
"""
try:
result = run_creator(topic_id)
if not result["ok"]:
raise HTTPException(status_code=500, detail=result["error"])
from ..core.sync import sync_all_topics
sync_all_topics()
return {"message": "Generation triggered", "result": result}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/optimize/run")
def trigger_optimization(topic_ids: List[str] = None, db: Session = Depends(get_db)):
"""手动触发合规优化任务
Args:
topic_ids: 可选,指定要优化的选题ID列表。不指定则优化所有 draft 状态文章。
"""
try:
result = run_optimizer(topic_ids)
if not result["ok"]:
raise HTTPException(status_code=500, detail=result["error"])
report = result.get("report")
if report:
from ..core.sync import sync_all_topics
sync_all_topics()
return {
"message": "Optimization completed",
"summary": report["summary"]
}
else:
return {"message": "Optimization completed but no report found", "stdout": result.get("stdout", "")}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/logs/{log_date}")
def get_logs(log_date: str, log_type: str = "creator"):
"""读取日志文件内容,log_type: creator, optimizer, collector"""
log_file = LOGS_DIR / f"{log_type}_{log_date}.log"
if not log_file.exists():
raise HTTPException(status_code=404, detail=f"Log file not found: {log_file}")
content = log_file.read_text(encoding='utf-8')
lines = content.splitlines()[-100:] if log_type != "collector" else content.splitlines()[-200:]
return {"log_date": log_date, "log_type": log_type, "content": lines}
@router.get("/pipeline/status")
def get_pipeline_status():
"""获取流水线各模块状态(最后运行时间和结果)"""
try:
# 读取选题文件
topics_file = DATA_DIR / "sustainability_topics.json"
topics = []
if topics_file.exists():
topics = json.loads(topics_file.read_text(encoding='utf-8'))
# 统计状态分布
status_counts = {}
for t in topics:
s = t.get('status', 'unknown')
status_counts[s] = status_counts.get(s, 0) + 1
# 检查各日志文件的最新修改时间
log_files = {
"collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log",
"creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log",
"optimizer": LOGS_DIR / f"optimizer_{date.today().isoformat()}.log",
"publisher": LOGS_DIR / f"publisher_{date.today()}.log"
}
pipeline_status = {}
for name, log_file in log_files.items():
if log_file.exists():
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
pipeline_status[name] = {
"last_run": mtime.isoformat(),
"exists": True,
"size_bytes": log_file.stat().st_size
}
# 简单推断成功/失败(TODO: 解析日志加强)
last_lines = log_file.read_text(encoding='utf-8').splitlines()[-10:]
has_error = any("error" in line.lower() or "失败" in line or "failed" in line.lower() for line in last_lines)
pipeline_status[name]["has_error"] = has_error
else:
pipeline_status[name] = {"exists": False, "last_run": None}
return {
"topics_count": len(topics),
"status_distribution": status_counts,
"pipeline_modules": pipeline_status,
"data_dir": str(DATA_DIR),
"logs_dir": str(LOGS_DIR)
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/sync/run")
def run_sync():
"""手动触发数据同步(流水线JSON → 平台数据库)"""
try:
sync_all_topics()
return {"message": "Sync completed"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/automation/topics")
def list_automation_topics():
"""直接读取自动化流水线的选题JSON(供调试)"""
try:
topics_file = DATA_DIR / "sustainability_topics.json"
if not topics_file.exists():
raise HTTPException(status_code=404, detail="Topics JSON not found")
topics = json.loads(topics_file.read_text(encoding='utf-8'))
return {
"count": len(topics),
"topics": topics[-50:] # 只返回最近50个,避免过大
}
except json.JSONDecodeError as e:
raise HTTPException(status_code=500, detail=f"JSON parse error: {e}")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/refresh")
def refresh_all():
"""刷新所有数据:同步JSON + 更新状态"""
try:
sync_all_topics()
return {"message": "Refresh completed"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+43
View File
@@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime
from ..database import get_db
from ..models import Topic
from ..schemas import TopicResponse, PublishRequest
router = APIRouter(prefix="/api/topics", tags=["topics"])
@router.get("", response_model=List[TopicResponse])
def list_topics(
status: str = None,
db: Session = Depends(get_db)
):
query = db.query(Topic)
if status:
query = query.filter(Topic.status == status)
topics = query.order_by(Topic.priority_score.desc(), Topic.created_at.desc()).all()
return topics
@router.get("/{topic_id}", response_model=TopicResponse)
def get_topic(topic_id: str, db: Session = Depends(get_db)):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
return topic
@router.post("/{topic_id}/publish")
def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db)):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
if topic.status != "ready":
raise HTTPException(status_code=400, detail="Topic not in ready status")
topic.status = "published"
topic.published_at = datetime.now().date()
topic.platform_urls = req.platform_urls
db.commit()
return {"message": "Topic marked as published", "topic_id": topic_id}
+1
View File
@@ -0,0 +1 @@
# core package
+53
View File
@@ -0,0 +1,53 @@
import subprocess
from pathlib import Path
import logging
import os
logger = logging.getLogger(__name__)
# 计算项目根目录(从本文件位置上升4层)
PROJECT_ROOT = Path(__file__).resolve().parents[4]
# 允许环境变量覆盖(适合容器部署)
if os.getenv('PROJECT_ROOT'):
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
def run_creator(topic_id: str = None):
"""运行内容创作脚本,返回简略结果
Args:
topic_id: 可选,指定要创作的选题ID。不指定则创作优先级最高的选题。
"""
script_path = PROJECT_ROOT / "scripts" / "creator.py"
cmd = ["python3", str(script_path)]
if topic_id:
cmd.extend(["--topic-id", topic_id])
result = subprocess.run(
cmd,
cwd=str(PROJECT_ROOT),
capture_output=True,
text=True,
timeout=300 # 5分钟超时
)
if result.returncode != 0:
logger.error(f"Creator failed: {result.stderr}")
return {"ok": False, "error": result.stderr}
# 解析日志,找出选择了哪个选题
topic_id = None
for line in result.stdout.splitlines():
if "选择了选题:" in line:
# 格式: 2026-04-16 ... INFO - 选择了选题: 标题 (优先级: X)
# 标题可能在行内,但ID不一定有。我们稍后用文件同步。
logger.info(line.strip())
if "选题" in line and "已标记为「待发布」" in line:
# 如: 2026-04-16 ... INFO - 选题 A01 已标记为「待发布」
import re
m = re.search(r'选题\s+([A-Za-z0-9]+)', line)
if m:
topic_id = m.group(1)
return {
"ok": True,
"topic_id": topic_id,
"stdout": result.stdout[-1000:] if len(result.stdout) > 1000 else result.stdout
}
+118
View File
@@ -0,0 +1,118 @@
"""
NVIDIA 专用 LLM 客户端(fixed configuration
使用 OpenAI 兼容接口调用 stepfun-ai/step-3.5-flash
"""
import requests
import json
from typing import Optional
class LLMError(Exception):
pass
# 固定配置(你的可用 key
CONFIG = {
"base_url": "https://integrate.api.nvidia.com/v1",
"api_key": "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",
"model": "stepfun-ai/step-3.5-flash",
}
def call_llm(
prompt: str,
system_prompt: str = "你是一个专业的内容创作助手。",
temperature: float = 0.7,
max_tokens: int = 2000,
stream: bool = False,
) -> str:
"""
调用 NVIDIA LLM 生成文本
"""
endpoint = f"{CONFIG['base_url'].rstrip('/')}/chat/completions"
headers = {
"Authorization": f"Bearer {CONFIG['api_key']}",
"Content-Type": "application/json"
}
payload = {
"model": CONFIG["model"],
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
"temperature": temperature,
"max_tokens": max_tokens,
"stream": stream,
}
try:
resp = requests.post(endpoint, json=payload, headers=headers, timeout=120, stream=stream)
if resp.status_code != 200:
raise LLMError(f"HTTP {resp.status_code}: {resp.text[:200]}")
if stream:
full = []
for line in resp.iter_lines():
if not line:
continue
if line.startswith(b'data: '):
data = line[6:]
if data == b'[DONE]':
break
try:
chunk = json.loads(data)
delta = chunk['choices'][0]['delta']
# 支持 reasoning_content 或 reasoning 字段
if 'reasoning_content' in delta and delta['reasoning_content']:
full.append(delta['reasoning_content'])
if 'content' in delta and delta['content']:
full.append(delta['content'])
except Exception:
continue
return "".join(full)
else:
data = resp.json()
msg = data["choices"][0]["message"]
content = msg.get('content') or msg.get('reasoning') or msg.get('reasoning_content')
return content.strip() if content else ''
except requests.RequestException as e:
raise LLMError(f"Request failed: {e}")
def expand_content_with_llm(topic: dict, section_title: str, section_content: str, context: str = "") -> str:
"""扩写大纲章节,返回包含 ## 标题的完整 Markdown"""
prompt = f"""你是一个专业的内容创作者。请将以下大纲扩展为完整的文章章节。
# 选题信息
- 标题:{topic.get('title')}
- 领域:{topic.get('field')}
- 核心观点:{topic.get('core_concept', '')}
- 受众痛点:{topic.get('audience_pain', '')}
- 独特视角:{topic.get('unique_angle', '')}
# 当前章节
## {section_title}
{section_content}
# 要求
- 以 `## {section_title}` 作为章节标题开头
- 字数:300-500字
- 风格:客观、专业、易懂
- 使用 Markdown 格式
- 包含具体数据或案例(如果有)
- 保持与整体文章调性一致
直接输出完整的 Markdown 章节(包括 ## 标题和正文段落)。"""
if context:
prompt = f"# 参考资料\n{context}\n\n{prompt}"
try:
result = call_llm(prompt, temperature=0.8, max_tokens=2000)
return result.strip()
except Exception as e:
return f"## {section_title}\n\nLLM 调用失败:{e},请手动补充)"
# 测试
if __name__ == "__main__":
try:
print(f"[nvidia_client] 使用模型: {CONFIG['model']}")
resp = call_llm("你好,请用一句话介绍你自己。", max_tokens=50)
print(f"[nvidia_client] 响应: {resp}")
except Exception as e:
print(f"[nvidia_client] 错误: {e}")
+47
View File
@@ -0,0 +1,47 @@
import subprocess
from pathlib import Path
import logging
import os
import json
from datetime import datetime
from typing import List
logger = logging.getLogger(__name__)
# 计算项目根目录(从本文件位置上升4层)
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if os.getenv('PROJECT_ROOT'):
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
def run_optimizer(topic_ids: List[str] = None):
"""运行合规优化脚本,返回报告摘要
Args:
topic_ids: 可选,指定要优化的选题ID列表。不指定则优化所有 draft 文章。
"""
script_path = PROJECT_ROOT / "scripts" / "compliance_optimizer.py"
cmd = ["python3", str(script_path)]
if topic_ids:
cmd.extend(["--topic-ids", ','.join(topic_ids)])
logger.info(f"[DEBUG] Running optimizer with topic_ids={topic_ids}, cmd={' '.join(cmd)}")
result = subprocess.run(
cmd,
cwd=str(PROJECT_ROOT),
capture_output=True,
text=True,
timeout=600 # 10分钟
)
if result.returncode != 0:
logger.error(f"Optimizer failed: {result.stderr}")
return {"ok": False, "error": result.stderr}
# 读取优化报告(优化脚本会在 today 的 drafts 目录生成报告)
report_date = datetime.now().strftime("%Y-%m-%d")
report_path = PROJECT_ROOT / "automation" / "data" / "drafts" / report_date / "optimization_report.json"
if report_path.exists():
report = json.loads(report_path.read_text(encoding='utf-8'))
return {"ok": True, "report": report}
else:
logger.warning(f"Report not found: {report_path}")
return {"ok": True, "report": None, "stdout": result.stdout}
+113
View File
@@ -0,0 +1,113 @@
"""
qnaigc 专用 LLM 客户端
模型:arcee-ai/trinity-large-preview
"""
import requests
import json
from typing import Optional
class LLMError(Exception):
pass
CONFIG = {
"base_url": "https://api.qnaigc.com/v1",
"api_key": "sk-2cb9561a18351015d3120ffac4abae0480fa17e0d28469bdce5fc905d1a42e0d",
"model": "arcee-ai/trinity-large-preview",
}
def call_llm(
prompt: str,
system_prompt: str = "你是一个专业的内容创作助手。",
temperature: float = 0.7,
max_tokens: int = 2000,
stream: bool = False,
) -> str:
endpoint = f"{CONFIG['base_url'].rstrip('/')}/chat/completions"
headers = {
"Authorization": f"Bearer {CONFIG['api_key']}",
"Content-Type": "application/json"
}
payload = {
"model": CONFIG["model"],
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
"temperature": temperature,
"max_tokens": max_tokens,
"stream": stream,
}
try:
resp = requests.post(endpoint, json=payload, headers=headers, timeout=120, stream=stream)
if resp.status_code != 200:
raise LLMError(f"HTTP {resp.status_code}: {resp.text[:200]}")
if stream:
full = []
for line in resp.iter_lines():
if not line:
continue
if line.startswith(b'data: '):
data = line[6:]
if data == b'[DONE]':
break
try:
chunk = json.loads(data)
delta = chunk['choices'][0]['delta']
if 'reasoning_content' in delta and delta['reasoning_content']:
full.append(delta['reasoning_content'])
if 'content' in delta and delta['content']:
full.append(delta['content'])
except Exception:
continue
return "".join(full)
else:
data = resp.json()
msg = data["choices"][0]["message"]
content = msg.get('content') or msg.get('reasoning') or msg.get('reasoning_content')
return content.strip() if content else ''
except requests.RequestException as e:
raise LLMError(f"Request failed: {e}")
def expand_content_with_llm(topic: dict, section_title: str, section_content: str, context: str = "") -> str:
"""扩写大纲章节,返回包含 ## 标题的完整 Markdown"""
prompt = f"""你是一个专业的内容创作者。请将以下大纲扩展为完整的文章章节。
# 选题信息
- 标题:{topic.get('title')}
- 领域:{topic.get('field')}
- 核心观点:{topic.get('core_concept', '')}
- 受众痛点:{topic.get('audience_pain', '')}
- 独特视角:{topic.get('unique_angle', '')}
# 当前章节
## {section_title}
{section_content}
# 要求
- 以 `## {section_title}` 作为章节标题开头
- 字数:300-500字
- 风格:客观、专业、易懂
- 使用 Markdown 格式
- 包含具体数据或案例(如果有)
- 保持与整体文章调性一致
- 所有数据和时间必须基于2025年及以后,避免引用2024年以前的具体事件或统计数据。如果信息不足,请使用'近期''最新'等模糊表述,不要编造旧数据。
直接输出完整的 Markdown 章节(包括 ## 标题和正文段落)。"""
if context:
prompt = f"# 参考资料\n{context}\n\n{prompt}"
try:
result = call_llm(prompt, temperature=0.8, max_tokens=2000)
return result.strip()
except Exception as e:
return f"## {section_title}\n\nLLM 调用失败:{e},请手动补充)"
# 测试
if __name__ == "__main__":
try:
print(f"[qnaigc_client] 使用模型: {CONFIG['model']}")
resp = call_llm("你好,请用一句话介绍你自己。", max_tokens=50)
print(f"[qnaigc_client] 响应: {resp}")
except Exception as e:
print(f"[qnaigc_client] 错误: {e}")
+65
View File
@@ -0,0 +1,65 @@
import json
from datetime import datetime, date
from pathlib import Path
from sqlalchemy.orm import Session
from ..database import SessionLocal
from ..models import Topic
import os
# 计算项目根目录(从本文件位置上升4层)
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if os.getenv('PROJECT_ROOT'):
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
def sync_topic_to_db(topic_id: str, db: Session = None) -> Topic:
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
topic_data = next((t for t in topics if t['id'] == topic_id), None)
if not topic_data:
raise ValueError(f"Topic {topic_id} not found in file")
close_db = False
if db is None:
db = SessionLocal()
close_db = True
try:
db_topic = db.query(Topic).filter(Topic.id == topic_id).first()
if db_topic is None:
db_topic = Topic(
id=topic_data['id'],
title=topic_data['title'],
field=topic_data['field'],
format=topic_data.get('format'),
core_concept=topic_data.get('core_concept'),
audience_pain=topic_data.get('audience_pain'),
unique_angle=topic_data.get('unique_angle'),
priority=topic_data.get('priority'),
priority_score=topic_data.get('priority_score', 0),
total_score=topic_data.get('total_score')
)
db.add(db_topic)
db_topic.status = topic_data.get('status', db_topic.status)
db_topic.ready_at = datetime.strptime(topic_data['ready_at'], '%Y-%m-%d').date() if topic_data.get('ready_at') else None
db_topic.published_at = datetime.strptime(topic_data['published_at'], '%Y-%m-%d').date() if topic_data.get('published_at') else None
db_topic.compliance_score = topic_data.get('compliance_score', db_topic.compliance_score)
db_topic.platform_urls = topic_data.get('platform_urls', {})
db_topic.updated_at = datetime.now()
db.commit()
db.refresh(db_topic)
return db_topic
finally:
if close_db:
db.close()
def sync_all_topics():
db = SessionLocal()
try:
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
for t in topics:
sync_topic_to_db(t['id'], db)
print(f"✅ 同步 {len(topics)} 个选题到数据库")
finally:
db.close()
if __name__ == "__main__":
sync_all_topics()
+28
View File
@@ -0,0 +1,28 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
from pathlib import Path
# 计算项目根目录(backend/app/database.py -> yu-zhi-ran
# __file__: platform/backend/app/database.py
# parents[0]=app, [1]=backend, [2]=platform, [3]=yu-zhi-ran
PROJECT_ROOT = Path(__file__).resolve().parents[3]
DATA_DIR = os.getenv('DATA_DIR', str(PROJECT_ROOT / 'data'))
os.makedirs(DATA_DIR, exist_ok=True)
DB_PATH = os.path.join(DATA_DIR, 'yzr.db')
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_PATH}"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def init_db():
Base.metadata.create_all(bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+55
View File
@@ -0,0 +1,55 @@
import json
import os
from datetime import datetime
from pathlib import Path
from .database import SessionLocal, init_db
from .models import Topic
# 计算项目根目录(backend/app/initial_data.py -> 上升3层到 yu-zhi-ran
PROJECT_ROOT = Path(__file__).resolve().parents[3]
if os.getenv('PROJECT_ROOT'):
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
def import_topics_from_json():
db = SessionLocal()
try:
if db.query(Topic).count() > 0:
print("数据库已有数据,跳过导入")
return
if not __import__('os').path.exists(TOPICS_FILE):
print(f"选题文件不存在: {TOPICS_FILE}")
return
topics = json.loads(open(TOPICS_FILE, encoding='utf-8').read())
for t in topics:
topic = Topic(
id=t['id'],
title=t['title'],
field=t['field'],
format=t.get('format'),
core_concept=t.get('core_concept'),
audience_pain=t.get('audience_pain'),
unique_angle=t.get('unique_angle'),
priority=t.get('priority'),
priority_score=t.get('priority_score', 0),
total_score=t.get('total_score'),
status=t.get('status', 'pending'),
cases=t.get('cases', []),
source_file=t.get('source_file'),
ready_at=datetime.strptime(t['ready_at'], '%Y-%m-%d').date() if t.get('ready_at') else None,
published_at=datetime.strptime(t['published_at'], '%Y-%m-%d').date() if t.get('published_at') else None,
compliance_score=t.get('compliance_score'),
platform_urls=t.get('platform_urls', {})
)
db.add(topic)
db.commit()
print(f"✅ 导入 {len(topics)} 个选题到数据库")
except Exception as e:
print(f"导入失败: {e}")
db.rollback()
finally:
db.close()
if __name__ == "__main__":
init_db()
import_topics_from_json()
+65
View File
@@ -0,0 +1,65 @@
import logging
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from datetime import datetime
from pathlib import Path
import os
from .database import engine, get_db, init_db
from .models import Base
from .api import topics, system, articles, publisher
from .initial_data import import_topics_from_json
app = FastAPI(title="宇之然内容创作平台", version="0.1.0")
logger = logging.getLogger(__name__)
# CORS - 生产环境应限制 origins
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # TODO: 生产环境改为具体域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 初始化数据库
Base.metadata.create_all(bind=engine)
init_db()
import_topics_from_json() # 首次自动导入
# 注册路由
app.include_router(topics.router)
app.include_router(system.router)
app.include_router(articles.router)
app.include_router(publisher.router)
# 挂载前端静态文件
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
STATIC_DIR = FRONTEND_DIR / "static"
# 检查前端文件是否存在,若不存在下载Element Plus等依赖
if not FRONTEND_DIR.exists():
FRONTEND_DIR.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger(__name__)
logger.warning(f"Frontend dir not found: {FRONTEND_DIR}, will serve API only")
# 默认静态文件服务(若前端存在)
if FRONTEND_DIR.exists() and (FRONTEND_DIR / "index.html").exists():
app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
logging.getLogger(__name__).info(f"Frontend mounted at / from {FRONTEND_DIR}")
else:
@app.get("/")
def root():
return {
"service": "宇之然内容创作平台 API",
"version": "0.1.0",
"docs": "/docs",
"frontend_missing": str(FRONTEND_DIR)
}
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
+39
View File
@@ -0,0 +1,39 @@
from sqlalchemy import Column, String, Integer, Float, Date, DateTime, Text, Boolean, JSON
from sqlalchemy.sql import func
from .database import Base
from datetime import datetime
class Topic(Base):
__tablename__ = "topics"
id = Column(String, primary_key=True, index=True)
title = Column(String, nullable=False)
field = Column(String, nullable=False)
format = Column(String)
core_concept = Column(Text)
audience_pain = Column(Text)
unique_angle = Column(Text)
priority = Column(String) # 高/中
priority_score = Column(Integer, default=0)
total_score = Column(Float)
status = Column(String, default="pending") # pending/draft/ready/published
cases = Column(JSON, default=list)
source_file = Column(String)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
ready_at = Column(Date)
published_at = Column(Date)
compliance_score = Column(Integer)
platform_urls = Column(JSON, default=dict) # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."}
class Article(Base):
__tablename__ = "articles"
id = Column(String, primary_key=True) # e.g., A01_zhihu
topic_id = Column(String, nullable=False)
platform = Column(String, nullable=False)
file_path = Column(String, nullable=False)
status = Column(String, default="draft") # draft/optimized/published
created_at = Column(DateTime(timezone=True), server_default=func.now())
compliance_score = Column(Integer)
html_content = Column(Text) # 可缓存HTML内容以便预览
+53
View File
@@ -0,0 +1,53 @@
from pydantic import BaseModel
from datetime import datetime, date
from typing import Optional, List, Dict, Any
class TopicBase(BaseModel):
id: str
title: str
field: str
priority_score: int = 0
status: str = "pending"
compliance_score: Optional[int] = None
ready_at: Optional[date] = None
published_at: Optional[date] = None
platform_urls: Optional[Dict[str, str]] = None
class TopicResponse(TopicBase):
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class ArticleBase(BaseModel):
id: str
topic_id: str
platform: str
file_path: str
status: str = "draft"
compliance_score: Optional[int] = None
created_at: Optional[datetime] = None
class ArticleResponse(ArticleBase):
class Config:
from_attributes = True
class SystemStatus(BaseModel):
total_topics: int
topics_by_status: Dict[str, int]
ready_topics: List[TopicResponse]
today_articles: int
compliance_rate: float
last_optimization: Optional[datetime] = None
execution_time: Optional[float] = None # 任务执行耗时(秒)
class OptimizationRequest(BaseModel):
topic_ids: Optional[List[str]] = None # None表示全部
class PublishRequest(BaseModel):
topic_id: str
platform_urls: Dict[str, str] # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."}
class BatchPublishRequest(BaseModel):
date: str # YYYY-MM-DD