fix: 三平台内容差异化 + admin敏感词管理表格化

- writer.py: _expand_section() 去除 <100字阈值,始终调用 LLM 平台专属扩写
- prompt_loader.py: 新增 section_expansion_zhihu/wechat/xiaohongshu 三个独立 prompt
- admin.html: 配置管理标签页 + 敏感词/清理规则子标签 + 敏感词表格化管理(编辑/删除)
- config_items.py: PUT /sensitive-words/{id} 支持更新 word/category
- compliance_checker.py: AI 套话从 DB 加载 + 人称规则修正
- initial_data.py: PlatformConfig 字数迁移 + 新种子
- 各前端页面: LLM 配置 rate_limit 字段 + 供应商列表排序
This commit is contained in:
Yuzhiran Dev
2026-06-08 13:51:35 +08:00
parent d0896ef10e
commit 23ff63baa9
26 changed files with 582 additions and 93 deletions
-1
View File
@@ -161,7 +161,6 @@ def update_article_content(
)
db.add(article)
article.html_content = html_content
article.updated_at = datetime.now()
db.commit()
return {"ok": True, "id": article_id}
+37 -3
View File
@@ -100,6 +100,32 @@ DEFAULT_CONTENT_CLEAN_RULES = [
{"rule_type": "verbosity", "pattern": r"^综上所述$", "description": "AI废话-综上所述", "sort_order": 14},
{"rule_type": "verbosity", "pattern": r"^通过以上", "description": "AI废话-通过以上", "sort_order": 15},
{"rule_type": "html_thinking", "pattern": r"<p[^>]*>(好的|好的,|好[的,]|我来|让我|我将|我这就)", "description": "AI思考-HTML模式", "sort_order": 16},
{"rule_type": "ai_telltale", "pattern": r"值得注意的是", "description": "AI套话—值得注意的是", "sort_order": 20},
{"rule_type": "ai_telltale", "pattern": r"首先.*其次.*最后", "description": "AI套话—首先其次最后", "sort_order": 21},
{"rule_type": "ai_telltale", "pattern": r"综上所述", "description": "AI套话—综上所述", "sort_order": 22},
{"rule_type": "ai_telltale", "pattern": r"总的来说", "description": "AI套话—总的来说", "sort_order": 23},
{"rule_type": "ai_telltale", "pattern": r"总而言之", "description": "AI套话—总而言之", "sort_order": 24},
{"rule_type": "ai_telltale", "pattern": r"无可否认", "description": "AI套话—无可否认", "sort_order": 25},
{"rule_type": "ai_telltale", "pattern": r"毋庸置疑", "description": "AI套话—毋庸置疑", "sort_order": 26},
{"rule_type": "ai_telltale", "pattern": r"众所周知", "description": "AI套话—众所周知", "sort_order": 27},
{"rule_type": "ai_telltale", "pattern": r"不可否认", "description": "AI套话—不可否认", "sort_order": 28},
{"rule_type": "ai_telltale", "pattern": r"从某种意义上", "description": "AI套话—从某种意义上", "sort_order": 29},
{"rule_type": "ai_telltale", "pattern": r"从某种程度上", "description": "AI套话—从某种程度上", "sort_order": 30},
{"rule_type": "ai_telltale", "pattern": r"在一定程度上", "description": "AI套话—在一定程度上", "sort_order": 31},
{"rule_type": "ai_telltale", "pattern": r"换而言之", "description": "AI套话—换而言之", "sort_order": 32},
{"rule_type": "ai_telltale", "pattern": r"换言之", "description": "AI套话—换言之", "sort_order": 33},
{"rule_type": "ai_telltale", "pattern": r"归根结底", "description": "AI套话—归根结底", "sort_order": 34},
{"rule_type": "ai_telltale", "pattern": r"说到底", "description": "AI套话—说到底", "sort_order": 35},
{"rule_type": "ai_telltale", "pattern": r"这为我们提供了", "description": "AI套话—这为我们提供了", "sort_order": 36},
{"rule_type": "ai_telltale", "pattern": r"引人深思", "description": "AI套话—引人深思", "sort_order": 37},
{"rule_type": "ai_telltale", "pattern": r"毫无悬念", "description": "AI套话—毫无悬念", "sort_order": 38},
{"rule_type": "ai_telltale", "pattern": r"不知大家", "description": "AI套话—不知大家", "sort_order": 39},
{"rule_type": "ai_telltale", "pattern": r"说回到", "description": "AI套话—说回到", "sort_order": 40},
{"rule_type": "ai_telltale", "pattern": r"如果你也", "description": "AI套话—如果你也", "sort_order": 41},
{"rule_type": "ai_telltale", "pattern": r"我们不难发现", "description": "AI套话—我们不难发现", "sort_order": 42},
{"rule_type": "ai_telltale", "pattern": r"我们可以看出", "description": "AI套话—我们可以看出", "sort_order": 43},
{"rule_type": "ai_telltale", "pattern": r"从以上分析可以看出", "description": "AI套话—从以上分析可以看出", "sort_order": 44},
{"rule_type": "ai_telltale", "pattern": r"来说说到底", "description": "AI套话—来说说到底", "sort_order": 45},
]
class KeywordDomainMapResponse(BaseModel):
@@ -193,6 +219,14 @@ def _ensure_defaults(db: Session):
if db.query(ContentCleanRule).count() == 0:
for item in DEFAULT_CONTENT_CLEAN_RULES:
db.add(ContentCleanRule(**item))
else:
# 补入缺失的 ai_telltale 规则(不覆盖已存在的内容)
existing_patterns = {r.pattern for r in db.query(ContentCleanRule).filter(ContentCleanRule.rule_type == 'ai_telltale').all()}
for item in DEFAULT_CONTENT_CLEAN_RULES:
if item['rule_type'] == 'ai_telltale' and item['pattern'] not in existing_patterns:
db.add(ContentCleanRule(**item))
if any(item['rule_type'] == 'ai_telltale' and item['pattern'] not in existing_patterns for item in DEFAULT_CONTENT_CLEAN_RULES):
db.flush()
if db.query(TrendFieldMapping).count() == 0:
for item in DEFAULT_TREND_FIELD_MAP:
db.add(TrendFieldMapping(**item))
@@ -253,12 +287,12 @@ def create_sensitive_word(data: SensitiveWordCreate, db: Session = Depends(get_d
return item
@router.put("/sensitive-words/{item_id}", response_model=SensitiveWordResponse)
def update_sensitive_word(item_id: int, enabled: bool = None, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
def update_sensitive_word(item_id: int, body: SensitiveWordCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(SensitiveWord).filter(SensitiveWord.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
if enabled is not None:
item.is_active = enabled
item.word = body.word
item.category = body.category
db.commit()
db.refresh(item)
return item
+11 -4
View File
@@ -304,10 +304,17 @@ def batch_update_status(
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
updated = q.update(
{Topic.status: status, Topic.updated_at: datetime.now()},
synchronize_session=False
)
now = datetime.now()
update_dict = {Topic.status: status, Topic.updated_at: now}
if status in ('review', '待审查'):
update_dict[Topic.generated_at] = now
update_dict[Topic.reviewed_at] = now
elif status in ('ready', '待发布'):
update_dict[Topic.ready_at] = now.date()
update_dict[Topic.reviewed_at] = now
elif status in ('published', '已发布'):
update_dict[Topic.published_at] = now.date()
updated = q.update(update_dict, synchronize_session=False)
db.commit()
return {"ok": True, "updated": updated}
+1
View File
@@ -65,6 +65,7 @@ def run_creator_blocking(topic_id: str = None, timeout: int = 1800):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if topic:
topic.generated_at = datetime.now(timezone.utc)
topic.reviewed_at = datetime.now(timezone.utc)
if topic.status in ('pending', '待处理'):
topic.status = 'review'
db.commit()
+42 -3
View File
@@ -1,9 +1,11 @@
"""
Unified LLM Client
支持 NVIDIA / opencode-go / 兼容 OpenAI 格式的 API,配置从环境变量读取
支持 NVIDIA / sensenova / 兼容 OpenAI 格式的 API,配置从环境变量读取
支持模型级 rate limit 和任务级模型选择(LLM_TASK_MODEL
"""
import os
import time
import requests
import json
import logging
@@ -30,10 +32,28 @@ _API_KEYS = {
# 代码级回退默认值(实际配置优先从 DB 读取)
_FALLBACK = {
"opencode-go": {"base_url": "https://opencode.ai/zen/go/v1", "model": "deepseek-v4-flash"},
"nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "model": "stepfun-ai/step-3.7-flash"},
"nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "model": "stepfun-ai/step-3.5-flash"},
"sensenova": {"base_url": "https://token.sensenova.cn/v1", "model": "deepseek-v4-flash"},
}
# 模型级 rate limiter(进程内,重启重置)
_RATE_LIMITER: Dict[str, List[float]] = {}
def _check_rate_limit(provider: str, model: str, limit: int, window_minutes: int) -> bool:
if limit <= 0:
return True
key = f"{provider}/{model}"
now = time.time()
window_sec = window_minutes * 60
timestamps = _RATE_LIMITER.get(key, [])
timestamps = [t for t in timestamps if now - t < window_sec]
_RATE_LIMITER[key] = timestamps
return len(timestamps) < limit
def _record_usage(provider: str, model: str):
key = f"{provider}/{model}"
_RATE_LIMITER.setdefault(key, []).append(time.time())
def _get_active_provider() -> str:
"""从 DB 读取活跃供应商,优先取 is_default=TrueDB 不可用时回退环境变量"""
try:
@@ -61,6 +81,8 @@ def _get_provider_config(provider: Optional[str] = None) -> dict:
db_model = None
db_base_url = None
db_api_key = None
db_rate_limit = 0
db_rate_window = 300
try:
from ..database import SessionLocal
from ..models import LLMConfig
@@ -70,6 +92,8 @@ def _get_provider_config(provider: Optional[str] = None) -> dict:
db_model = cfg.model
db_base_url = cfg.base_url
db_api_key = cfg.api_key
db_rate_limit = cfg.rate_limit or 0
db_rate_window = cfg.rate_limit_window_minutes or 300
db.close()
except Exception:
pass
@@ -82,6 +106,8 @@ def _get_provider_config(provider: Optional[str] = None) -> dict:
"api_key": api_key,
"model": db_model or fb.get("model", ""),
"base_url": db_base_url or fb.get("base_url", ""),
"rate_limit": db_rate_limit,
"rate_limit_window_minutes": db_rate_window,
}
def _get_db_defaults(provider: Optional[str] = None) -> dict:
@@ -139,14 +165,25 @@ def call_llm(
if not provider and os.getenv("LLM_TASK_PROVIDER"):
task_provider = os.getenv("LLM_TASK_PROVIDER")
providers_to_try = [task_provider] + [p for p in providers_to_try if p != task_provider]
# LLM_TASK_MODEL 环境变量可覆盖任务级别的模型选择
if not model and os.getenv("LLM_TASK_MODEL"):
model = os.getenv("LLM_TASK_MODEL")
last_error = None
for p in providers_to_try:
try:
cfg = _get_provider_config(p)
actual_model = model or cfg["model"]
# Rate limit check
rl = cfg.get("rate_limit", 0)
rw = cfg.get("rate_limit_window_minutes", 300)
if not _check_rate_limit(p, actual_model, rl, rw):
logger.warning(f"[LLM] {p}/{actual_model} rate limit reached ({rl}/{rw}min) → 尝试下一个")
last_error = LLMError(f"{p}/{actual_model} rate limit reached")
continue
endpoint = f"{cfg['base_url'].rstrip('/')}/chat/completions"
headers = {"Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json"}
payload = {
"model": model or cfg["model"],
"model": actual_model,
"messages": [{"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}],
"temperature": temperature, "max_tokens": max_tokens, "top_p": top_p,
"frequency_penalty": frequency_penalty, "presence_penalty": presence_penalty,
@@ -176,6 +213,7 @@ def call_llm(
if delta.get('content'): content_parts.append(delta['content'])
if delta.get('reasoning_content'): reasoning_parts.append(delta['reasoning_content'])
except Exception: continue
_record_usage(p, actual_model)
return "".join(content_parts) or "".join(reasoning_parts)
else:
data = resp.json()
@@ -186,6 +224,7 @@ def call_llm(
if rc:
parts = [p.strip() for p in rc.replace('\n', '').split('') if p.strip()]
content = parts[-1] if parts else rc
_record_usage(p, actual_model)
return content.strip() or ''
except LLMError as e:
if '429' in str(e):
+12 -2
View File
@@ -20,7 +20,7 @@ from .collector import run_collector_blocking
logger = logging.getLogger(__name__)
def _set_task_llm_provider(module_id: str):
"""从 TaskConfig 读取 llm_provider 并设为环境变量,供子进程和 call_llm 读取"""
"""从 TaskConfig 读取 llm_provider 和 llm_model 并设为环境变量,供子进程和 call_llm 读取"""
try:
from ..database import SessionLocal
from ..models import TaskConfig
@@ -32,10 +32,20 @@ def _set_task_llm_provider(module_id: str):
if provider:
os.environ["LLM_TASK_PROVIDER"] = provider
logger.debug("[%s] LLM provider set to %s", module_id, provider)
return
else:
os.environ.pop("LLM_TASK_PROVIDER", None)
model = cfg.params.get("llm_model")
if model:
os.environ["LLM_TASK_MODEL"] = model
logger.debug("[%s] LLM model set to %s", module_id, model)
else:
os.environ.pop("LLM_TASK_MODEL", None)
return
except Exception:
pass
os.environ.pop("LLM_TASK_PROVIDER", None)
os.environ.pop("LLM_TASK_MODEL", None)
MODULES = {
"scheduled_fetch_trends": {"name": "🔥 热点趋势", "cron": "01:10"},
+3
View File
@@ -48,12 +48,15 @@ def init_db():
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS base_url VARCHAR"))
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS api_key VARCHAR"))
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS is_default BOOLEAN DEFAULT FALSE"))
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS rate_limit INTEGER DEFAULT 0"))
conn.execute(text("ALTER TABLE llm_configs ADD COLUMN IF NOT EXISTS rate_limit_window_minutes INTEGER DEFAULT 300"))
try:
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images JSON DEFAULT '{}'::json"))
except Exception:
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT DEFAULT '{}'"))
for table, col, typ in [
("users", "org_id", "VARCHAR DEFAULT 'default'"),
("articles", "updated_at", "TIMESTAMP"),
("topics", "org_id", "VARCHAR DEFAULT 'default'"),
("topics", "reviewed_at", "TIMESTAMP"),
("platform_configs", "requires_image", "BOOLEAN DEFAULT FALSE"),
+38 -11
View File
@@ -35,14 +35,26 @@ def import_initial_data():
db.commit()
print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}")
# 补充或更新 LLM 供应商配置(nvidia 为主,opencode-go 为备
# 补充或更新 LLM 供应商配置(nvidia 为主,sensenova 为备,含多模型
expected = {
"opencode-go": dict(provider="opencode-go", model="deepseek-v4-flash",
base_url="https://opencode.ai/zen/go/v1", temperature=0.7, max_tokens=131072, is_active=True,
user_prompt_template="将以下内容扩展为完整文章:\n{topic_title}\n{core_concept}"),
"nvidia": dict(provider="nvidia", model="stepfun-ai/step-3.5-flash",
"nvidia": dict(provider="nvidia", model="qwen/qwen3.5-397b-a17b",
base_url="https://integrate.api.nvidia.com/v1", temperature=0.5, max_tokens=131072, is_active=False,
user_prompt_template="将以下内容扩展为完整章节:\n{section_content}"),
"sensenova-6.7-flash-lite": dict(provider="sensenova", model="sensenova-6.7-flash-lite",
base_url="https://token.sensenova.cn/v1", temperature=0.3, max_tokens=16384, is_active=True,
rate_limit=1500, rate_limit_window_minutes=300,
user_prompt_template="将以下内容扩展为完整章节:\n{section_content}"),
"sensenova-u1-fast": dict(provider="sensenova", model="sensenova-u1-fast",
base_url="https://token.sensenova.cn/v1", temperature=0.3, max_tokens=16384, is_active=True,
rate_limit=1500, rate_limit_window_minutes=300,
user_prompt_template="根据以下内容生成信息图:\n{section_content}"),
"sensenova-deepseek": dict(provider="sensenova", model="deepseek-v4-flash",
base_url="https://token.sensenova.cn/v1", temperature=0.3, max_tokens=16384, is_active=True,
rate_limit=500, rate_limit_window_minutes=300,
user_prompt_template="将以下内容扩展为完整章节:\n{section_content}"),
}
existing = {c.name: c for c in db.query(LLMConfig).all()}
for name, cfg in expected.items():
@@ -90,7 +102,7 @@ def import_initial_data():
"platform": "zhihu",
"name": "知乎",
"icon": "🔍",
"default_format": "长文深度分析1500-3000字,数据支撑",
"default_format": "深度分析 3000-8000字,数据驱动",
"compliance_rules": {
"max_title_len": 100,
"allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"],
@@ -102,14 +114,14 @@ def import_initial_data():
"image_count_max": 0,
"image_width": 0,
"image_height": 0,
"min_words": 1500,
"max_words": 3000
"min_words": 3000,
"max_words": 8000
},
{
"platform": "wechat",
"name": "微信公众号",
"icon": "💚",
"default_format": "公众号图文,800-1500字,亲切口语化",
"default_format": "个人叙事 2000-4000字,对话感",
"compliance_rules": {
"max_title_len": 32,
"allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"],
@@ -121,14 +133,14 @@ def import_initial_data():
"image_count_max": 3,
"image_width": 1080,
"image_height": 1080,
"min_words": 800,
"max_words": 1500
"min_words": 2000,
"max_words": 4000
},
{
"platform": "xiaohongshu",
"name": "小红书",
"icon": "📕",
"default_format": "图文笔记,300-800字,emoji+标签",
"default_format": "精炼干货 400-1000字,实用优先",
"compliance_rules": {
"max_title_len": 50,
"allowed_tags": ["生活方式", "可持续", "AI", "个人成长", "极简", "环保"],
@@ -140,14 +152,29 @@ def import_initial_data():
"image_count_max": 6,
"image_width": 1080,
"image_height": 1440,
"min_words": 300,
"max_words": 800
"min_words": 400,
"max_words": 1000
}
]
for p in platforms:
db.add(PlatformConfig(**p))
db.commit()
print("✅ 插入平台配置")
else:
# 更新已有平台配置的字数要求(迁移:2026-06 内容质量升级)
platform_updates = {
"zhihu": {"min_words": 3000, "max_words": 8000, "default_format": "深度分析 3000-8000字,数据驱动"},
"wechat": {"min_words": 2000, "max_words": 4000, "default_format": "个人叙事 2000-4000字,对话感"},
"xiaohongshu": {"min_words": 400, "max_words": 1000, "default_format": "精炼干货 400-1000字,实用优先"},
}
for p in db.query(PlatformConfig).all():
if p.platform in platform_updates:
up = platform_updates[p.platform]
p.min_words = up["min_words"]
p.max_words = up["max_words"]
p.default_format = up["default_format"]
db.commit()
print("✅ 平台配置已更新(字数/格式)")
if db.query(TopicField).count() == 0:
fields = [
+6 -1
View File
@@ -288,7 +288,8 @@ class Article(Base):
html_content = Column(Text)
word_count = Column(Integer, nullable=True)
outline = Column(Text, nullable=True)
images = Column(JSON, default=dict) # {"cover": "/path/to/cover.png", "chart": "/path/to/chart.png"}
images = Column(JSON, default=dict)
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # {"cover": "/path/to/cover.png", "chart": "/path/to/chart.png"}
class PublishRecord(Base):
@@ -628,6 +629,8 @@ class LLMConfig(Base):
api_key = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
is_default = Column(Boolean, default=False)
rate_limit = Column(Integer, default=0, comment="每个时间窗口的调用上限,0=不限")
rate_limit_window_minutes = Column(Integer, default=300, comment="时间窗口(分钟),默认5小时")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -645,6 +648,8 @@ class LLMConfig(Base):
"api_key": f"{self.api_key[:8]}..." if self.api_key else None,
"is_active": self.is_active,
"is_default": self.is_default,
"rate_limit": self.rate_limit,
"rate_limit_window_minutes": self.rate_limit_window_minutes,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
+2
View File
@@ -523,6 +523,8 @@ class LLMConfigBase(BaseModel):
api_key: Optional[str] = None
is_active: bool = True
is_default: bool = False
rate_limit: int = 0
rate_limit_window_minutes: int = 300
class LLMConfigResponse(LLMConfigBase):