Initial commit: yu-zhi-ran platform with automation integration
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# FastAPI 应用初始化
|
||||
@@ -0,0 +1 @@
|
||||
# API routes
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
# core package
|
||||
@@ -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
|
||||
}
|
||||
@@ -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\n(LLM 调用失败:{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}")
|
||||
@@ -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}
|
||||
@@ -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\n(LLM 调用失败:{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}")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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内容以便预览
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user