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:
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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=True;DB 不可用时回退环境变量"""
|
||||
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):
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user