Browse Source

feat: 添加内容审核接口/main/moderation/check

zhangwl 1 month ago
parent
commit
36749a1144
4 changed files with 298 additions and 1 deletions
  1. 1 0
      README.md
  2. 274 0
      app/routers/moderation.py
  3. 21 0
      app/schemas/chat.py
  4. 2 1
      main.py

+ 1 - 0
README.md

@@ -30,6 +30,7 @@ create_time=创建时间
 
 1 线上服务器 172.17.240.75 和 172.17.240.80
 2 地址: /data01/chat-ai-api
+3 升级为root:  su
 3 git pull //代码更新到最新,在新的服务器上,就需要git clone下拉代码
 4 docker stop chat-ai-api //先暂停原来的docker
 5 docker rm chat-ai-api   //再删除原来的镜像

+ 274 - 0
app/routers/moderation.py

@@ -0,0 +1,274 @@
+import os
+import tempfile
+from urllib.parse import urlparse
+
+import requests
+from fastapi import APIRouter, HTTPException
+
+from ..core.ark_client import get_client
+from ..config.config import Config
+from ..schemas.chat import ModerationRequest, ModerationResponse
+from ..db.mongo import save_chat_log
+
+router = APIRouter()
+
+# 待审核文本最大长度限制(超出则拒绝,避免超长文本拖慢模型响应)
+MAX_CONTENT_LENGTH = 20000
+# 远程媒体下载大小限制(火山方舟视频上限 50MB)
+MAX_VIDEO_SIZE = 50 * 1024 * 1024
+MAX_IMAGE_SIZE = 20 * 1024 * 1024
+# 下载超时(连接超时, 读取超时)
+_DOWNLOAD_TIMEOUT = (10, 60)
+
+# 内容审核系统提示词:约束模型仅输出 true / false,降低解析歧义并提升响应速度
+_MODERATION_PROMPT = """你是一个严格、专业的内容安全审核员。请判断用户提供的内容(文本、图片或视频画面)是否包含违规信息。
+
+违规内容包括以下任一类别:
+1. 涉黄内容:色情、低俗、性暗示、裸露等;
+2. 暴力恐怖内容:血腥、暴力、恐怖主义、极端主义等;
+3. 涉政敏感内容:政治敏感、反动言论、危害国家安全等;
+4. 其他不良信息:违法犯罪、赌博、诈骗、毒品、辱骂攻击、封建迷信等;
+5. 广告推广内容:商业营销、引流推广、联系方式诱导、二维码等。
+
+判断规则:
+- 只要内容命中上述任意一类,即判定为违规;
+- 对图片/视频,需结合画面主体、文字、场景等综合判断;
+- 内容健康、正常、合规的判定为合规;
+- 仅依据所提供的内容判断,不做额外联想或补全。
+
+输出要求(务必严格遵守):
+- 只输出一个单词:true 或 false;
+- true 表示检测到违规内容,false 表示合规内容;
+- 禁止输出任何解释、标点、空格或其他多余字符。"""
+
+
+def _extract_text(response) -> str:
+    """从 responses API 返回结果中提取文本内容"""
+    result = ""
+    for item in response.output:
+        if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
+            if isinstance(item.content, list):
+                for content_item in item.content:
+                    if hasattr(content_item, 'text'):
+                        result += content_item.text
+            else:
+                result += str(item.content)
+    return result
+
+
+def _parse_moderation_result(text: str) -> bool:
+    """将模型输出解析为布尔值:True=违规,False=合规"""
+    cleaned = text.strip().lower()
+    if cleaned.startswith("true"):
+        return True
+    if cleaned.startswith("false"):
+        return False
+    # 容错:模型可能附带少量多余字符,取首次出现的关键词
+    has_true = "true" in cleaned
+    has_false = "false" in cleaned
+    if has_true and not has_false:
+        return True
+    if has_false and not has_true:
+        return False
+    # 无法明确解析时抛错,交由调用方重试,避免误判放行违规内容
+    raise ValueError(f"无法解析审核结果: {text[:50]}")
+
+
+def _resolve_ext(url: str, content: bytes, default: str) -> str:
+    """推断文件扩展名:优先取 URL 路径后缀,其次按文件魔数嗅探,最后用默认值。
+    Files API 依据扩展名识别媒体类型,因此需给出正确后缀。"""
+    ext = os.path.splitext(urlparse(url).path)[1].lower()
+    known = (".mp4", ".mov", ".webm", ".mkv", ".avi", ".flv",
+             ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp")
+    if ext in known:
+        return ext
+    # 文件魔数嗅探(URL 无有效后缀时,如 COS 的 ?imageMogr2 处理链接)
+    if content[:3] == b"\xff\xd8\xff":
+        return ".jpg"
+    if content[:8] == b"\x89PNG\r\n\x1a\n":
+        return ".png"
+    if content[:4] == b"RIFF" and content[8:12] == b"WEBP":
+        return ".webp"
+    if content[:6] in (b"GIF87a", b"GIF89a"):
+        return ".gif"
+    if content[4:8] == b"ftyp":
+        return ".mp4"
+    return default
+
+
+def _upload_remote_to_ark(client, url: str, kind: str) -> str:
+    """下载远程 URL 并上传到火山方舟 Files API,返回 file_id。
+
+    直接把 COS 等第三方 URL 传给模型时,若源站返回的 Content-Type 为
+    application/octet-stream,方舟会因无法识别媒体类型而报 400。通过
+    下载 + Files API 上传(按扩展名识别类型)可彻底绕开该限制。
+
+    kind: 'video' | 'image',用于大小限制与默认扩展名。
+    """
+    max_size = MAX_VIDEO_SIZE if kind == "video" else MAX_IMAGE_SIZE
+    try:
+        resp = requests.get(url, timeout=_DOWNLOAD_TIMEOUT, stream=True)
+        resp.raise_for_status()
+        content = b""
+        for chunk in resp.iter_content(chunk_size=1 << 20):
+            if not chunk:
+                continue
+            content += chunk
+            if len(content) > max_size:
+                raise HTTPException(
+                    status_code=400,
+                    detail=f"{kind}文件超过大小限制({max_size // 1024 // 1024}MB)",
+                )
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(status_code=400, detail=f"无法下载{kind}资源: {str(e)}")
+
+    if not content:
+        raise HTTPException(status_code=400, detail=f"{kind}资源内容为空")
+
+    ext = _resolve_ext(url, content, ".mp4" if kind == "video" else ".jpg")
+    filename = f"moderation_{kind}{ext}"
+    tmp_path = None
+    try:
+        with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
+            tmp.write(content)
+            tmp_path = tmp.name
+        with open(tmp_path, "rb") as f:
+            uploaded = client.files.create(file=(filename, f), purpose="user_data")
+        return uploaded.id
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"上传{kind}到审核服务失败: {str(e)}")
+    finally:
+        if tmp_path and os.path.exists(tmp_path):
+            os.unlink(tmp_path)
+
+
+def _resolve_type(request: ModerationRequest) -> str:
+    """确定审核类型:优先使用显式 type,否则根据已传参数自动推断"""
+    if request.type:
+        t = request.type.strip().lower()
+        if t not in ("text", "image", "video"):
+            raise HTTPException(status_code=400, detail=f"不支持的审核类型: {request.type}")
+        return t
+    if request.video_url:
+        return "video"
+    if request.image_url:
+        return "image"
+    if request.content:
+        return "text"
+    raise HTTPException(status_code=400, detail="请提供待审核的内容(content / image_url / video_url)")
+
+
+def _build_input_content(mod_type: str, request: ModerationRequest, client) -> list:
+    """根据审核类型构造 Responses API 多模态 input content,并做参数校验。
+    图片/视频统一走 Files API(file_id),避免第三方 URL 的 mimetype 校验问题。"""
+    if mod_type == "text":
+        content = (request.content or "").strip()
+        if not content:
+            raise HTTPException(status_code=400, detail="待审核文本不能为空")
+        if len(content) > MAX_CONTENT_LENGTH:
+            raise HTTPException(
+                status_code=400,
+                detail=f"待审核文本超过长度限制({MAX_CONTENT_LENGTH} 字符),请分段提交",
+            )
+        return [{"type": "input_text", "text": content}]
+
+    if mod_type == "image":
+        image_url = (request.image_url or "").strip()
+        if not image_url:
+            raise HTTPException(status_code=400, detail="图片审核需提供 image_url")
+        file_id = _upload_remote_to_ark(client, image_url, "image")
+        return [
+            {"type": "input_image", "file_id": file_id},
+            {"type": "input_text", "text": "请审核这张图片的画面内容是否违规。"},
+        ]
+
+    # video:对视频画面 + 封面图一起审核
+    video_url = (request.video_url or "").strip()
+    thumb_url = (request.thumb_url or "").strip()
+    if not video_url:
+        raise HTTPException(status_code=400, detail="视频审核需提供 video_url")
+    if not thumb_url:
+        raise HTTPException(status_code=400, detail="视频审核需提供封面图 thumb_url")
+    video_file_id = _upload_remote_to_ark(client, video_url, "video")
+    thumb_file_id = _upload_remote_to_ark(client, thumb_url, "image")
+    return [
+        {"type": "input_video", "file_id": video_file_id},
+        {"type": "input_image", "file_id": thumb_file_id},
+        {"type": "input_text", "text": "请综合审核这段视频的画面及封面图内容是否违规。"},
+    ]
+
+
+def _log_target(mod_type: str, request: ModerationRequest) -> str:
+    """生成审核日志的目标标识(文本截断 / 媒体 URL)"""
+    if mod_type == "text":
+        return (request.content or "")[:500]
+    if mod_type == "image":
+        return f"[image] {request.image_url}"
+    return f"[video] video={request.video_url} thumb={request.thumb_url}"
+
+
+@router.post("/check", response_model=ModerationResponse)
+async def moderate_content(request: ModerationRequest):
+    """
+    公共内容审核接口(无需登录认证),供外部系统调用。
+    支持三种审核类型,利用 AI 模型检测是否包含涉黄、暴恐、涉政、其他不良、广告推广等违规内容:
+    - text:审核文本内容(content)
+    - image:审核图片(image_url)
+    - video:审核视频画面 + 封面图(video_url + thumb_url)
+
+    图片/视频通过 Files API 上传换取 file_id 后送审,规避第三方 URL 的 mimetype 校验问题。
+    未显式传 type 时,根据已传参数自动推断(video_url > image_url > content)。
+    返回:sensitive=true 表示检测到违规内容,false 表示合规内容。
+    """
+    mod_type = _resolve_type(request)
+    log_target = _log_target(mod_type, request)
+
+    try:
+        client = get_client()
+    except Exception as e:
+        raise HTTPException(status_code=503, detail=f"审核服务初始化失败: {str(e)}")
+
+    # 图片/视频会在此处下载远程资源并上传 Files API(可能抛 400/502)
+    input_content = _build_input_content(mod_type, request, client)
+
+    system_msg = {"role": "system", "content": [{"type": "input_text", "text": _MODERATION_PROMPT}]}
+    user_msg = {"role": "user", "content": input_content}
+
+    try:
+        response = client.responses.create(
+            model=Config.MODEL_NAME,
+            input=[system_msg, user_msg],
+            stream=False,
+            store=False,  # 无状态审核,不保存上下文,提升性能
+            text={"format": {"type": "text"}},
+        )
+    except Exception as e:
+        save_chat_log(
+            user_id="moderation",
+            question=log_target,
+            stream_mode=False,
+            status="error",
+            error=str(e),
+        )
+        raise HTTPException(status_code=500, detail=f"内容审核服务异常: {str(e)}")
+
+    raw_text = _extract_text(response)
+    try:
+        is_sensitive = _parse_moderation_result(raw_text)
+    except ValueError as e:
+        save_chat_log(
+            user_id="moderation",
+            question=log_target,
+            stream_mode=False,
+            raw_response=repr(response),
+            status="error",
+            error=str(e),
+        )
+        raise HTTPException(status_code=500, detail="内容审核结果解析失败,请重试")
+
+    return ModerationResponse(sensitive=is_sensitive)
+
+
+router.tags = ["内容审核"]

+ 21 - 0
app/schemas/chat.py

@@ -64,6 +64,27 @@ class StreamResponse(BaseModel):
     type: str = "answer"  # "thinking"=AI思考开过车delta | "searching"=搜索状态/关键词 | "answer" = 正式回答 delta(现有逻辑)
 
 
+# 内容审核请求
+class ModerationRequest(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    # 审核类型:text=文本 | image=图片 | video=视频;不传则根据已传参数自动推断
+    type: Optional[str] = None
+    # 文本审核:待审核的文本内容
+    content: Optional[str] = None
+    # 图片审核:图片 URL
+    image_url: Optional[str] = Field(default=None, alias="ImageUrl")
+    # 视频审核:视频 URL
+    video_url: Optional[str] = Field(default=None, alias="VideoUrl")
+    # 视频审核:视频封面图 URL
+    thumb_url: Optional[str] = Field(default=None, alias="ThumbUrl")
+
+
+# 内容审核返回
+class ModerationResponse(BaseModel):
+    sensitive: bool  # true=检测到敏感/违规内容,false=合规内容
+
+
 # 历史人物
 class HistoricalFigure(BaseModel):
     id: str = Field(alias="_id")  # MongoDB _id

+ 2 - 1
main.py

@@ -1,7 +1,7 @@
 from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 
-from app.routers import users, chat, chat_tools, ai_config
+from app.routers import users, chat, chat_tools, ai_config, moderation
 
 # 创建FastAPI应用实例
 app = FastAPI(title="聊天机器人", version="1.0.0", description="基于fastapi+VUE的聊天机器人")
@@ -22,6 +22,7 @@ app.include_router(users.router, prefix="/main/users", tags=["用户管理"])
 app.include_router(chat.router, prefix="/main/chat", tags=["聊天管理"])
 app.include_router(chat_tools.router, prefix="/main/chatTools", tags=["AI工具管理"])
 app.include_router(ai_config.router, prefix="/main/aiConfig", tags=["AI配置管理"])
+app.include_router(moderation.router, prefix="/main/moderation", tags=["内容审核"])