Files
yu-zhi-ran/platform/backend/app/api/config_items.py
T
Yuzhiran Dev 23ff63baa9 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 字段 + 供应商列表排序
2026-06-08 13:51:35 +08:00

424 lines
22 KiB
Python

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel, ConfigDict
from datetime import datetime
import json
from ..database import get_db
from ..models import KeywordDomainMap, SensitiveWord, ContentCleanRule, TrendFieldMapping, CollectorCategory
from .auth import get_current_admin
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
DEFAULT_KEYWORD_DOMAIN_MAP = [
{"pattern": r"AI|人工智能|大模型|GPT|机器学习|深度学习|聊天机器人|LLM", "domain": "AI工具", "sort_order": 1},
{"pattern": r"远程|居家办公|自由职业|数字游民|远程协作", "domain": "远程工作", "sort_order": 2},
{"pattern": r"可持续|环保|低碳|绿色|碳中和|循环|零浪费|垃圾分类|节能", "domain": "可持续生活", "sort_order": 3},
{"pattern": r"知识管理|笔记|Obsidian|Notion|第二大脑|读书|阅读", "domain": "知识管理", "sort_order": 4},
{"pattern": r"数字生活|数码|手机|电脑|智能|APP|应用、软件", "domain": "数字生活", "sort_order": 5},
{"pattern": r"科技|人文|教育|心理|哲学|社会学", "domain": "科技人文", "sort_order": 6},
]
DEFAULT_TREND_FIELD_MAP = [
{"trend_keyword": "远程工作", "field_name": "未来工作方式", "sort_order": 1},
{"trend_keyword": "AI工具", "field_name": "AI与效率", "sort_order": 2},
{"trend_keyword": "可持续生活", "field_name": "可持续生活系统", "sort_order": 3},
{"trend_keyword": "知识管理", "field_name": "个人知识工厂", "sort_order": 4},
{"trend_keyword": "数字生活", "field_name": "科技人文交叉", "sort_order": 5},
{"trend_keyword": "科技人文", "field_name": "科技人文交叉", "sort_order": 6},
{"trend_keyword": "个人成长", "field_name": "个人成长", "sort_order": 7},
{"trend_keyword": "副业", "field_name": "个人成长", "sort_order": 8},
{"trend_keyword": "AI创作", "field_name": "AI与效率", "sort_order": 9},
{"trend_keyword": "未来工作", "field_name": "未来工作方式", "sort_order": 10},
{"trend_keyword": "效率工具", "field_name": "AI与效率", "sort_order": 11},
{"trend_keyword": "家庭教育", "field_name": "科技人文交叉", "sort_order": 12},
]
DEFAULT_CHINA_PAIN_TEMPLATES = {
"循环消费": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"低碳出行": "新能源车充电设施不足、城市规划不支持骑行、通勤距离长",
"干净饮食": "有机食品价格高、真伪难辨、外卖为主的生活方式难以改变",
"零浪费生活": "环保产品溢价高、可持续选择不便、漂绿营销难以分辨",
"绿色家电与节能": "绿色家电初期投入高、节能效果难量化、老旧小区改造难",
"碳普惠": "碳账户普及率低、减排量兑换吸引力不足、公众认知有限",
"环保科技产品": "绿色产品溢价68%难以承受、缺乏统一认证标准、担心漂绿",
"AI与效率": "AI工具选择困难、数据隐私担忧、学习成本高、实际效果难验证",
}
DEFAULT_GLOBAL_RSS_KEYWORDS = ['sustainable', 'green', 'eco', 'circular', 'climate', 'carbon', 'zero waste', 'renewable', 'recycle', '环保', '可持续', '碳中和', '循环经济', '零浪费', '低碳', '生态']
DEFAULT_PRIORITY_WEIGHTS = {"audience_match": 0.3, "data_availability": 0.25, "uniqueness": 0.2, "executability": 0.15, "brand_fit": 0.1}
DEFAULT_DOMAINS = ["远程工作", "AI工具", "可持续生活", "知识管理", "数字生活", "科技人文"]
DEFAULT_SENSITIVE_WORDS = [
{"word": "国家主席", "category": "political"},
{"word": "政治局", "category": "political"},
{"word": "常委", "category": "political"},
{"word": "军委", "category": "political"},
{"word": "统战部", "category": "political"},
{"word": "颠覆国家", "category": "political"},
{"word": "分裂主义", "category": "political"},
{"word": "台独", "category": "political"},
{"word": "疆独", "category": "political"},
{"word": "藏独", "category": "political"},
{"word": "赌博", "category": "prohibited"},
{"word": "毒品", "category": "prohibited"},
{"word": "迷药", "category": "prohibited"},
{"word": "枪支", "category": "prohibited"},
{"word": "炸药", "category": "prohibited"},
{"word": "色情", "category": "prohibited"},
{"word": "低俗", "category": "prohibited"},
{"word": "反动", "category": "prohibited"},
{"word": "邪教", "category": "prohibited"},
{"word": "保证赚钱", "category": "misleading"},
{"word": "一夜暴富", "category": "misleading"},
{"word": "100%有效", "category": "misleading"},
{"word": "包治百病", "category": "misleading"},
{"word": "绝对正确", "category": "misleading"},
{"word": "国家机密", "category": "legal"},
{"word": "军事秘密", "category": "legal"},
{"word": "绝密", "category": "legal"},
{"word": "迷信", "category": "legal"},
]
DEFAULT_CONTENT_CLEAN_RULES = [
{"rule_type": "thinking", "pattern": r"^(好的|好的,|好[的,]|我来|让我|我将|我这就).*?(?=\n|$)", "description": "AI思考模式1", "sort_order": 1},
{"rule_type": "thinking", "pattern": r"^(以下|下面是|这是|为您|根据).*?(?=\n|$)", "description": "AI思考模式2", "sort_order": 2},
{"rule_type": "preface", "pattern": r"^(基于|\u3010.*?\u3011|这里.*)", "description": "AI前缀模式", "sort_order": 3},
{"rule_type": "verbosity", "pattern": r"^首先|^其次|^最后", "description": "AI废话-首先其次", "sort_order": 4},
{"rule_type": "verbosity", "pattern": r"^总的来说$", "description": "AI废话-总的来说", "sort_order": 5},
{"rule_type": "verbosity", "pattern": r"^值得注意的是$", "description": "AI废话-值得注意的是", "sort_order": 6},
{"rule_type": "verbosity", "pattern": r"^换句话说$", "description": "AI废话-换句话说", "sort_order": 7},
{"rule_type": "verbosity", "pattern": r"^总而言之$", "description": "AI废话-总而言之", "sort_order": 8},
{"rule_type": "verbosity", "pattern": r"^简而言之$", "description": "AI废话-简而言之", "sort_order": 9},
{"rule_type": "verbosity", "pattern": r"^一言以蔽之$", "description": "AI废话-一言以蔽之", "sort_order": 10},
{"rule_type": "verbosity", "pattern": r"^可以说$", "description": "AI废话-可以说", "sort_order": 11},
{"rule_type": "verbosity", "pattern": r"^不难发现$", "description": "AI废话-不难发现", "sort_order": 12},
{"rule_type": "verbosity", "pattern": r"^由此可见$", "description": "AI废话-由此可见", "sort_order": 13},
{"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):
id: int
pattern: str
domain: str
sort_order: int
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class KeywordDomainMapCreate(BaseModel):
pattern: str
domain: str
sort_order: int = 0
class KeywordDomainMapUpdate(BaseModel):
pattern: Optional[str] = None
domain: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class SensitiveWordResponse(BaseModel):
id: int
word: str
category: str
is_active: bool
added_by: Optional[str] = None
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class SensitiveWordCreate(BaseModel):
word: str
category: str = "general"
class ContentCleanRuleResponse(BaseModel):
id: int
rule_type: str
pattern: str
description: Optional[str] = None
is_active: bool
sort_order: int
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class ContentCleanRuleCreate(BaseModel):
rule_type: str
pattern: str
description: Optional[str] = None
sort_order: int = 0
class ContentCleanRuleUpdate(BaseModel):
pattern: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
sort_order: Optional[int] = None
class TrendFieldMappingResponse(BaseModel):
id: int
trend_keyword: str
field_name: str
sort_order: int
is_active: bool
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class TrendFieldMappingCreate(BaseModel):
trend_keyword: str
field_name: str
sort_order: int = 0
class TrendFieldMappingUpdate(BaseModel):
trend_keyword: Optional[str] = None
field_name: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class SystemConfigValueResponse(BaseModel):
key: str
value: Optional[str] = None
description: Optional[str] = None
def _ensure_defaults(db: Session):
if db.query(KeywordDomainMap).count() == 0:
for item in DEFAULT_KEYWORD_DOMAIN_MAP:
db.add(KeywordDomainMap(**item))
if db.query(SensitiveWord).count() == 0:
for item in DEFAULT_SENSITIVE_WORDS:
db.add(SensitiveWord(**item))
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))
for name, pain in DEFAULT_CHINA_PAIN_TEMPLATES.items():
cat = db.query(CollectorCategory).filter(CollectorCategory.name == name).first()
if cat and not cat.pain_template:
cat.pain_template = pain
db.commit()
@router.get("/keyword-domain-map", response_model=List[KeywordDomainMapResponse])
def list_keyword_domain_map(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
_ensure_defaults(db)
return db.query(KeywordDomainMap).order_by(KeywordDomainMap.sort_order).all()
@router.post("/keyword-domain-map", response_model=KeywordDomainMapResponse)
def create_keyword_domain_map(data: KeywordDomainMapCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = KeywordDomainMap(**data.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return item
@router.put("/keyword-domain-map/{item_id}", response_model=KeywordDomainMapResponse)
def update_keyword_domain_map(item_id: int, data: KeywordDomainMapUpdate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(KeywordDomainMap).filter(KeywordDomainMap.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
for k, v in data.model_dump(exclude_unset=True).items():
if v is not None:
setattr(item, k, v)
db.commit()
db.refresh(item)
return item
@router.delete("/keyword-domain-map/{item_id}")
def delete_keyword_domain_map(item_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(KeywordDomainMap).filter(KeywordDomainMap.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
db.delete(item)
db.commit()
return {"message": "删除成功"}
@router.get("/sensitive-words", response_model=List[SensitiveWordResponse])
def list_sensitive_words(category: Optional[str] = None, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
_ensure_defaults(db)
q = db.query(SensitiveWord)
if category:
q = q.filter(SensitiveWord.category == category)
return q.order_by(SensitiveWord.id).all()
@router.post("/sensitive-words", response_model=SensitiveWordResponse)
def create_sensitive_word(data: SensitiveWordCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = SensitiveWord(**data.model_dump(), added_by=admin_user.username)
db.add(item)
db.commit()
db.refresh(item)
return item
@router.put("/sensitive-words/{item_id}", response_model=SensitiveWordResponse)
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="未找到")
item.word = body.word
item.category = body.category
db.commit()
db.refresh(item)
return item
@router.delete("/sensitive-words/{item_id}")
def delete_sensitive_word(item_id: int, 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="未找到")
db.delete(item)
db.commit()
return {"message": "删除成功"}
@router.get("/content-clean-rules", response_model=List[ContentCleanRuleResponse])
def list_content_clean_rules(rule_type: Optional[str] = None, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
_ensure_defaults(db)
q = db.query(ContentCleanRule)
if rule_type:
q = q.filter(ContentCleanRule.rule_type == rule_type)
return q.order_by(ContentCleanRule.sort_order).all()
@router.post("/content-clean-rules", response_model=ContentCleanRuleResponse)
def create_content_clean_rule(data: ContentCleanRuleCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = ContentCleanRule(**data.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return item
@router.put("/content-clean-rules/{item_id}", response_model=ContentCleanRuleResponse)
def update_content_clean_rule(item_id: int, data: ContentCleanRuleUpdate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(ContentCleanRule).filter(ContentCleanRule.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
for k, v in data.model_dump(exclude_unset=True).items():
if v is not None:
setattr(item, k, v)
db.commit()
db.refresh(item)
return item
@router.delete("/content-clean-rules/{item_id}")
def delete_content_clean_rule(item_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(ContentCleanRule).filter(ContentCleanRule.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
db.delete(item)
db.commit()
return {"message": "删除成功"}
@router.get("/trend-field-mapping", response_model=List[TrendFieldMappingResponse])
def list_trend_field_mapping(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
_ensure_defaults(db)
return db.query(TrendFieldMapping).filter(TrendFieldMapping.is_active == True).order_by(TrendFieldMapping.sort_order).all()
@router.post("/trend-field-mapping", response_model=TrendFieldMappingResponse)
def create_trend_field_mapping(data: TrendFieldMappingCreate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = TrendFieldMapping(**data.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return item
@router.put("/trend-field-mapping/{item_id}", response_model=TrendFieldMappingResponse)
def update_trend_field_mapping(item_id: int, data: TrendFieldMappingUpdate, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(TrendFieldMapping).filter(TrendFieldMapping.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
for k, v in data.model_dump(exclude_unset=True).items():
if v is not None:
setattr(item, k, v)
db.commit()
db.refresh(item)
return item
@router.delete("/trend-field-mapping/{item_id}")
def delete_trend_field_mapping(item_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
item = db.query(TrendFieldMapping).filter(TrendFieldMapping.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="未找到")
db.delete(item)
db.commit()
return {"message": "删除成功"}
@router.get("/system-config/{key}", response_model=SystemConfigValueResponse)
def get_system_config(key: str, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
from ..models import SystemConfig
cfg = db.query(SystemConfig).filter(SystemConfig.key == key).first()
if not cfg:
default_map = {
"trend_domains": json.dumps(DEFAULT_DOMAINS),
"rss_default_keywords": json.dumps(DEFAULT_GLOBAL_RSS_KEYWORDS),
"priority_weights": json.dumps(DEFAULT_PRIORITY_WEIGHTS),
}
if key in default_map:
return SystemConfigValueResponse(key=key, value=default_map[key], description=f"系统默认配置 - {key}")
raise HTTPException(status_code=404, detail="未找到")
return SystemConfigValueResponse(key=cfg.key, value=cfg.value, description=cfg.description)
@router.put("/system-config/{key}")
def update_system_config(key: str, value: str, description: Optional[str] = None, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
from ..models import SystemConfig
cfg = db.query(SystemConfig).filter(SystemConfig.key == key).first()
if cfg:
cfg.value = value
if description is not None:
cfg.description = description
else:
cfg = SystemConfig(key=key, value=value, description=description or key)
db.add(cfg)
db.commit()
return {"message": "保存成功", "key": key, "value": value}
@router.get("/system-configs")
def list_system_configs(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
from ..models import SystemConfig
configs = db.query(SystemConfig).order_by(SystemConfig.key).all()
defaults = {
"trend_domains": json.dumps(DEFAULT_DOMAINS),
"rss_default_keywords": json.dumps(DEFAULT_GLOBAL_RSS_KEYWORDS),
"priority_weights": json.dumps(DEFAULT_PRIORITY_WEIGHTS),
}
result = []
for c in configs:
result.append({"key": c.key, "value": c.value, "description": c.description, "is_default": False})
for k, v in defaults.items():
if not any(x["key"] == k for x in result):
result.append({"key": k, "value": v, "description": f"系统默认 - {k}", "is_default": True})
return result