feat(guard): 添加内容审查模块并集成至聊天服务(默认关闭)

- 新增内容审查关键词库 src/static/bad_keywords.txt,支持自定义敏感词
- 实现内容审查插件 src/plugins/guard.py,提供敏感词检测功能
- 配置新增 enable_content_guard 选项,支持开关内容审查功能
- 聊天路由 chat_router.py 集成输入和输出内容审查逻辑,拦截敏感内容
- 前端设置页增加内容审查开关,方便管理员启用或禁用该功能
- 错误时前端显示详细错误信息,并停止消息流,提升用户体验
- README 添加服务安全说明文档,介绍内容审查机制与配置方法
This commit is contained in:
Wenjie Zhang 2025-09-18 20:33:38 +08:00
parent aebe63ce1c
commit 342c541645
8 changed files with 225 additions and 9 deletions

View File

@ -314,6 +314,14 @@ MCP_SERVERS = {
}
```
### 服务安全
为了保障服务内容的合规性,系统内置了一套内容审查机制。对用户的输入和模型生成的输出进行关键词过滤,防止不当内容的传播。
管理员可以在 `设置` -> `基本设置` 页面一键启用或禁用内容审查功能。**敏感词词库**位于 `src/static/bad_keywords.txt` 文件,可以根据需要自行修改,每行一个关键词。
### 服务端口说明
| 端口 | 服务 | 说明 |

View File

@ -20,6 +20,7 @@ from src import config as conf
from src.agents import agent_manager
from src.agents.common.tools import gen_tool_info, get_buildin_tools
from src.models import select_model
from src.plugins.guard import content_guard
from src.utils.logging_config import logger
chat = APIRouter(prefix="/chat", tags=["chat"])
@ -142,6 +143,11 @@ async def chat_agent(
# 代表服务端已经收到了请求
yield make_chunk(status="init", meta=meta, msg=HumanMessage(content=query).model_dump())
# Input guard
if conf.enable_content_guard and content_guard.check(query):
yield make_chunk(status="error", message="输入内容包含敏感词", meta=meta)
return
try:
agent = agent_manager.get_agent(agent_id)
except Exception as e:
@ -158,9 +164,16 @@ async def chat_agent(
input_context = {"user_id": user_id, "thread_id": thread_id}
try:
# Output guard for streaming
accumulated_content = ""
async for msg, metadata in agent.stream_messages(messages, input_context=input_context):
# logger.debug(f"msg: {msg.model_dump()}, metadata: {metadata}")
if isinstance(msg, AIMessageChunk):
accumulated_content += msg.content
if conf.enable_content_guard and content_guard.check(accumulated_content):
logger.warning(f"Sensitive content detected in stream: {accumulated_content}")
yield make_chunk(message="检测到敏感内容,已中断输出", status="error")
return
yield make_chunk(content=msg.content, msg=msg.model_dump(), metadata=metadata, status="loading")
else:
yield make_chunk(msg=msg.model_dump(), metadata=metadata, status="loading")

View File

@ -49,6 +49,7 @@ class Config(SimpleConfig):
### >>> 默认配置
# 功能选项
self.add_item("enable_reranker", default=False, des="是否开启重排序")
self.add_item("enable_content_guard", default=False, des="是否启用内容审查")
self.add_item(
"enable_web_search",
default=False,

34
src/plugins/guard.py Normal file
View File

@ -0,0 +1,34 @@
import os
from typing import List
def load_keywords(file_path: str) -> List[str]:
"""Loads keywords from a file, one per line."""
if not os.path.exists(file_path):
keywords = []
with open(file_path, "r", encoding="utf-8") as f:
keywords = [line.strip() for line in f if line.strip() and not line.startswith("#")]
return keywords
class ContentGuard:
def __init__(self, keywords_file: str = "src/static/bad_keywords.txt"):
self.keywords = load_keywords(keywords_file)
if not self.keywords:
# Default keywords if the file is empty or not found
self.keywords = ["贩毒"]
def check(self, text: str) -> bool:
"""
Checks if the text contains any sensitive keywords.
Returns True if sensitive content is found, False otherwise.
"""
if not text:
return False
text_lower = text.lower()
for keyword in self.keywords:
if keyword in text_lower:
return True
return False
# Global instance
content_guard = ContentGuard()

129
src/static/bad_keywords.txt Normal file
View File

@ -0,0 +1,129 @@
# ==========================================================
# 有害内容过滤关键词列表
# 说明:
# 1. 本列表仅为基础示例,请根据您的具体应用场景进行修改和扩展。
# 2. 很多词语具有多义性,请务必结合语境进行判断,以防误伤。
# 3. 网络用语和暗语变化迅速,建议定期更新此列表。
# ==========================================================
# ----------------------------------------
# 1. 仇恨言论 (Hate Speech)
# ----------------------------------------
# ** 种族与民族歧视 **
尼哥
黑鬼
白皮猪
棒子
阿三
# ** 宗教歧视 **
异教徒
邪教
# ** 性别与性取向歧视 **
死娘炮
男人婆
同性恋不得好死
恐同
# ----------------------------------------
# 2. 暴力与极端内容 (Violence & Extremism)
# ----------------------------------------
# ** 煽动暴力 **
杀全家
弄死他
打断腿
血洗
人肉他
# ** 恐怖主义与极端思想 **
圣战
ISIS
东突
# ** 血腥与残忍 **
虐杀
活剐
分尸
电锯惊魂
# ** 武器与爆炸物 **
土制炸弹
买枪
军火
C4炸药
炸药爆
制作炸药
制作武器
炸弹
# ----------------------------------------
# 3. 非法活动与违禁品 (Illegal Activities)
# ----------------------------------------
# ** 毒品 **
冰毒
K粉
海洛因
吸毒
贩毒
摇头丸
毒品走私
# ** 赌博 **
线上赌场
澳门首家
性感荷官
百家乐
六合彩
# ** 诈骗 **
杀猪盘
刷单兼职
网贷陷阱
冒充公检法
# ** 违禁品交易 **
出售个人信息
办假证
针孔摄像头
窃听器
# ----------------------------------------
# 4. 色情与成人内容 (Pornography)
# ----------------------------------------
AV女优
草榴
91大神
黄片
色情网站
约炮
裸聊
福利姬
# ----------------------------------------
# 5. 自残与危险行为 (Self-harm)
# ----------------------------------------
自杀教程
割腕
烧炭
无痛自杀
一起死
# ----------------------------------------
# 6. 网络欺凌与骚扰 (Cyberbullying)
# ----------------------------------------
biss
nmsl
开盒
挂人
孤儿
# ==========================================================
# 列表结束
# ==========================================================

View File

@ -34,7 +34,6 @@ body {
.layout-container {
width: 100%;
padding: 0px 30px;
background-color: #FAFCFD;
h2 {
margin: 20px 0 10px 0;

View File

@ -381,7 +381,31 @@ const _processStreamChunk = (chunk, threadId) => {
break;
case 'error':
handleChatError({ message }, 'stream');
resetOnGoingConv(threadId);
// Stop the loading indicator
if (threadState) {
threadState.isStreaming = false;
// Create a new AI message chunk for the error
const errorMsgChunk = {
id: 'ai-error-' + Date.now(),
type: 'ai',
role: 'assistant',
content: chunk.message || 'An error occurred',
isError: true // Custom flag for styling
};
// Add this to the chunks of the ongoing conversation
if (threadState.onGoingConv && threadState.onGoingConv.msgChunks) {
threadState.onGoingConv.msgChunks[errorMsgChunk.id] = [errorMsgChunk];
}
// Abort the stream controller to stop processing further events
if (threadState.streamAbortController) {
threadState.streamAbortController.abort();
threadState.streamAbortController = null;
}
}
// We no longer call resetOnGoingConv to keep the context.
break;
case 'finished':
fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId });

View File

@ -1,5 +1,5 @@
<template>
<div class="">
<div class="setting-view">
<HeaderComponent title="设置" class="setting-header">
<template #actions>
@ -49,6 +49,13 @@
</a-select-option>
</a-select>
</div>
<div class="card">
<span class="label">{{ items?.enable_content_guard.des }}</span>
<a-switch
:checked="configStore.config?.enable_content_guard"
@change="handleChange('enable_content_guard', $event)"
/>
</div>
</div>
<!-- 服务链接部分 -->
@ -225,8 +232,10 @@ const openLink = (url) => {
</script>
<style lang="less" scoped>
.setting-container {
--setting-header-height: 65px;
--setting-header-height: 55px;
max-width: 870px;
}
.setting-header {
@ -246,7 +255,7 @@ const openLink = (url) => {
}
.sider {
width: 200px;
width: 180px;
height: 100%;
padding: 0 20px;
position: sticky;
@ -254,7 +263,6 @@ const openLink = (url) => {
display: flex;
flex-direction: column;
align-items: center;
border-right: 1px solid var(--main-20);
gap: 8px;
padding-top: 20px;
@ -271,12 +279,12 @@ const openLink = (url) => {
color: var(--gray-700);
&:hover {
background: var(--gray-100);
background: var(--gray-50);
}
&.activesec {
background: var(--gray-200);
color: var(--gray-900);
background: var(--gray-100);
color: var(--main-700);
}
}
}