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
@@ -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"},