moderation.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import os
  2. import tempfile
  3. from urllib.parse import urlparse
  4. import requests
  5. from fastapi import APIRouter, HTTPException
  6. from ..core.ark_client import get_client
  7. from ..config.config import Config
  8. from ..schemas.chat import ModerationRequest, ModerationResponse
  9. from ..db.mongo import save_chat_log
  10. router = APIRouter()
  11. # 待审核文本最大长度限制(超出则拒绝,避免超长文本拖慢模型响应)
  12. MAX_CONTENT_LENGTH = 20000
  13. # 远程媒体下载大小限制(火山方舟视频上限 50MB)
  14. MAX_VIDEO_SIZE = 50 * 1024 * 1024
  15. MAX_IMAGE_SIZE = 20 * 1024 * 1024
  16. # 下载超时(连接超时, 读取超时)
  17. _DOWNLOAD_TIMEOUT = (10, 60)
  18. # 内容审核系统提示词:约束模型仅输出 true / false,降低解析歧义并提升响应速度
  19. _MODERATION_PROMPT = """你是一个严格、专业的内容安全审核员。请判断用户提供的内容(文本、图片或视频画面)是否包含违规信息。
  20. 违规内容包括以下任一类别:
  21. 1. 涉黄内容:色情、低俗、性暗示、裸露等;
  22. 2. 暴力恐怖内容:血腥、暴力、恐怖主义、极端主义等;
  23. 3. 涉政敏感内容:政治敏感、反动言论、危害国家安全等;
  24. 4. 其他不良信息:违法犯罪、赌博、诈骗、毒品、辱骂攻击、封建迷信等;
  25. 5. 广告推广内容:商业营销、引流推广、联系方式诱导、二维码等。
  26. 判断规则:
  27. - 只要内容命中上述任意一类,即判定为违规;
  28. - 对图片/视频,需结合画面主体、文字、场景等综合判断;
  29. - 内容健康、正常、合规的判定为合规;
  30. - 仅依据所提供的内容判断,不做额外联想或补全。
  31. 输出要求(务必严格遵守):
  32. - 只输出一个单词:true 或 false;
  33. - true 表示检测到违规内容,false 表示合规内容;
  34. - 禁止输出任何解释、标点、空格或其他多余字符。"""
  35. def _extract_text(response) -> str:
  36. """从 responses API 返回结果中提取文本内容"""
  37. result = ""
  38. for item in response.output:
  39. if hasattr(item, 'type') and item.type == 'message' and hasattr(item, 'content'):
  40. if isinstance(item.content, list):
  41. for content_item in item.content:
  42. if hasattr(content_item, 'text'):
  43. result += content_item.text
  44. else:
  45. result += str(item.content)
  46. return result
  47. def _parse_moderation_result(text: str) -> bool:
  48. """将模型输出解析为布尔值:True=违规,False=合规"""
  49. cleaned = text.strip().lower()
  50. if cleaned.startswith("true"):
  51. return True
  52. if cleaned.startswith("false"):
  53. return False
  54. # 容错:模型可能附带少量多余字符,取首次出现的关键词
  55. has_true = "true" in cleaned
  56. has_false = "false" in cleaned
  57. if has_true and not has_false:
  58. return True
  59. if has_false and not has_true:
  60. return False
  61. # 无法明确解析时抛错,交由调用方重试,避免误判放行违规内容
  62. raise ValueError(f"无法解析审核结果: {text[:50]}")
  63. def _resolve_ext(url: str, content: bytes, default: str) -> str:
  64. """推断文件扩展名:优先取 URL 路径后缀,其次按文件魔数嗅探,最后用默认值。
  65. Files API 依据扩展名识别媒体类型,因此需给出正确后缀。"""
  66. ext = os.path.splitext(urlparse(url).path)[1].lower()
  67. known = (".mp4", ".mov", ".webm", ".mkv", ".avi", ".flv",
  68. ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp")
  69. if ext in known:
  70. return ext
  71. # 文件魔数嗅探(URL 无有效后缀时,如 COS 的 ?imageMogr2 处理链接)
  72. if content[:3] == b"\xff\xd8\xff":
  73. return ".jpg"
  74. if content[:8] == b"\x89PNG\r\n\x1a\n":
  75. return ".png"
  76. if content[:4] == b"RIFF" and content[8:12] == b"WEBP":
  77. return ".webp"
  78. if content[:6] in (b"GIF87a", b"GIF89a"):
  79. return ".gif"
  80. if content[4:8] == b"ftyp":
  81. return ".mp4"
  82. return default
  83. def _upload_remote_to_ark(client, url: str, kind: str) -> str:
  84. """下载远程 URL 并上传到火山方舟 Files API,返回 file_id。
  85. 直接把 COS 等第三方 URL 传给模型时,若源站返回的 Content-Type 为
  86. application/octet-stream,方舟会因无法识别媒体类型而报 400。通过
  87. 下载 + Files API 上传(按扩展名识别类型)可彻底绕开该限制。
  88. kind: 'video' | 'image',用于大小限制与默认扩展名。
  89. """
  90. max_size = MAX_VIDEO_SIZE if kind == "video" else MAX_IMAGE_SIZE
  91. try:
  92. resp = requests.get(url, timeout=_DOWNLOAD_TIMEOUT, stream=True)
  93. resp.raise_for_status()
  94. content = b""
  95. for chunk in resp.iter_content(chunk_size=1 << 20):
  96. if not chunk:
  97. continue
  98. content += chunk
  99. if len(content) > max_size:
  100. raise HTTPException(
  101. status_code=400,
  102. detail=f"{kind}文件超过大小限制({max_size // 1024 // 1024}MB)",
  103. )
  104. except HTTPException:
  105. raise
  106. except Exception as e:
  107. raise HTTPException(status_code=400, detail=f"无法下载{kind}资源: {str(e)}")
  108. if not content:
  109. raise HTTPException(status_code=400, detail=f"{kind}资源内容为空")
  110. ext = _resolve_ext(url, content, ".mp4" if kind == "video" else ".jpg")
  111. filename = f"moderation_{kind}{ext}"
  112. tmp_path = None
  113. try:
  114. with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
  115. tmp.write(content)
  116. tmp_path = tmp.name
  117. with open(tmp_path, "rb") as f:
  118. uploaded = client.files.create(file=(filename, f), purpose="user_data")
  119. return uploaded.id
  120. except Exception as e:
  121. raise HTTPException(status_code=502, detail=f"上传{kind}到审核服务失败: {str(e)}")
  122. finally:
  123. if tmp_path and os.path.exists(tmp_path):
  124. os.unlink(tmp_path)
  125. def _resolve_type(request: ModerationRequest) -> str:
  126. """确定审核类型:优先使用显式 type,否则根据已传参数自动推断"""
  127. if request.type:
  128. t = request.type.strip().lower()
  129. if t not in ("text", "image", "video"):
  130. raise HTTPException(status_code=400, detail=f"不支持的审核类型: {request.type}")
  131. return t
  132. if request.video_url:
  133. return "video"
  134. if request.image_url:
  135. return "image"
  136. if request.content:
  137. return "text"
  138. raise HTTPException(status_code=400, detail="请提供待审核的内容(content / image_url / video_url)")
  139. def _build_input_content(mod_type: str, request: ModerationRequest, client) -> list:
  140. """根据审核类型构造 Responses API 多模态 input content,并做参数校验。
  141. 图片/视频统一走 Files API(file_id),避免第三方 URL 的 mimetype 校验问题。"""
  142. if mod_type == "text":
  143. content = (request.content or "").strip()
  144. if not content:
  145. raise HTTPException(status_code=400, detail="待审核文本不能为空")
  146. if len(content) > MAX_CONTENT_LENGTH:
  147. raise HTTPException(
  148. status_code=400,
  149. detail=f"待审核文本超过长度限制({MAX_CONTENT_LENGTH} 字符),请分段提交",
  150. )
  151. return [{"type": "input_text", "text": content}]
  152. if mod_type == "image":
  153. image_url = (request.image_url or "").strip()
  154. if not image_url:
  155. raise HTTPException(status_code=400, detail="图片审核需提供 image_url")
  156. file_id = _upload_remote_to_ark(client, image_url, "image")
  157. return [
  158. {"type": "input_image", "file_id": file_id},
  159. {"type": "input_text", "text": "请审核这张图片的画面内容是否违规。"},
  160. ]
  161. # video:对视频画面 + 封面图一起审核
  162. video_url = (request.video_url or "").strip()
  163. thumb_url = (request.thumb_url or "").strip()
  164. if not video_url:
  165. raise HTTPException(status_code=400, detail="视频审核需提供 video_url")
  166. if not thumb_url:
  167. raise HTTPException(status_code=400, detail="视频审核需提供封面图 thumb_url")
  168. video_file_id = _upload_remote_to_ark(client, video_url, "video")
  169. thumb_file_id = _upload_remote_to_ark(client, thumb_url, "image")
  170. return [
  171. {"type": "input_video", "file_id": video_file_id},
  172. {"type": "input_image", "file_id": thumb_file_id},
  173. {"type": "input_text", "text": "请综合审核这段视频的画面及封面图内容是否违规。"},
  174. ]
  175. # 火山方舟输入/输出安全护栏拦截时返回的错误码统一含该关键字
  176. _SENSITIVE_ERROR_KEYWORD = "SensitiveContentDetected"
  177. def _is_sensitive_block(error: Exception) -> bool:
  178. """判断异常是否为方舟安全护栏的敏感内容拦截。
  179. 当输入(文本/图片/视频)命中敏感信息时,方舟会在模型处理前直接返回
  180. 形如 InputTextSensitiveContentDetected 的 400 错误。对审核接口而言,
  181. 这本身即表明内容违规,应判定为 sensitive=true,而非当作服务异常报错。
  182. """
  183. code = getattr(error, "code", None)
  184. if code and _SENSITIVE_ERROR_KEYWORD in str(code):
  185. return True
  186. return _SENSITIVE_ERROR_KEYWORD in str(error)
  187. def _log_target(mod_type: str, request: ModerationRequest) -> str:
  188. """生成审核日志的目标标识(文本截断 / 媒体 URL)"""
  189. if mod_type == "text":
  190. return (request.content or "")[:500]
  191. if mod_type == "image":
  192. return f"[image] {request.image_url}"
  193. return f"[video] video={request.video_url} thumb={request.thumb_url}"
  194. @router.post("/check", response_model=ModerationResponse)
  195. async def moderate_content(request: ModerationRequest):
  196. """
  197. 公共内容审核接口(无需登录认证),供外部系统调用。
  198. 支持三种审核类型,利用 AI 模型检测是否包含涉黄、暴恐、涉政、其他不良、广告推广等违规内容:
  199. - text:审核文本内容(content)
  200. - image:审核图片(image_url)
  201. - video:审核视频画面 + 封面图(video_url + thumb_url)
  202. 图片/视频通过 Files API 上传换取 file_id 后送审,规避第三方 URL 的 mimetype 校验问题。
  203. 未显式传 type 时,根据已传参数自动推断(video_url > image_url > content)。
  204. 返回:sensitive=true 表示检测到违规内容,false 表示合规内容。
  205. """
  206. mod_type = _resolve_type(request)
  207. log_target = _log_target(mod_type, request)
  208. try:
  209. client = get_client()
  210. except Exception as e:
  211. raise HTTPException(status_code=503, detail=f"审核服务初始化失败: {str(e)}")
  212. # 图片/视频会在此处下载远程资源并上传 Files API(可能抛 400/502)
  213. input_content = _build_input_content(mod_type, request, client)
  214. system_msg = {"role": "system", "content": [{"type": "input_text", "text": _MODERATION_PROMPT}]}
  215. user_msg = {"role": "user", "content": input_content}
  216. try:
  217. response = client.responses.create(
  218. model=Config.MODEL_NAME,
  219. input=[system_msg, user_msg],
  220. stream=False,
  221. store=False, # 无状态审核,不保存上下文,提升性能
  222. text={"format": {"type": "text"}},
  223. )
  224. except Exception as e:
  225. # 护栏拦截即视为命中违规内容,直接返回 sensitive=true
  226. if _is_sensitive_block(e):
  227. save_chat_log(
  228. user_id="moderation",
  229. question=log_target,
  230. stream_mode=False,
  231. status="blocked",
  232. error=str(e),
  233. )
  234. return ModerationResponse(sensitive=True)
  235. save_chat_log(
  236. user_id="moderation",
  237. question=log_target,
  238. stream_mode=False,
  239. status="error",
  240. error=str(e),
  241. )
  242. raise HTTPException(status_code=500, detail=f"内容审核服务异常: {str(e)}")
  243. raw_text = _extract_text(response)
  244. try:
  245. is_sensitive = _parse_moderation_result(raw_text)
  246. except ValueError as e:
  247. save_chat_log(
  248. user_id="moderation",
  249. question=log_target,
  250. stream_mode=False,
  251. raw_response=repr(response),
  252. status="error",
  253. error=str(e),
  254. )
  255. raise HTTPException(status_code=500, detail="内容审核结果解析失败,请重试")
  256. return ModerationResponse(sensitive=is_sensitive)
  257. router.tags = ["内容审核"]