# -*- coding: utf-8 -*- """视觉模型提供方:豆包(火山方舟)与 DeepSeek,均为 OpenAI 兼容 chat/completions。 统一的返回结构: {"file", "provider", "status"(ok/parse_fail/error), "category", "attr", "logic", "raw", "usage": {"prompt", "completion", "cached"}, "finish_reason"} """ import asyncio import base64 import json import logging import os import re import aiohttp from .config import AppConfig logger = logging.getLogger("violation_detector.providers") MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".bmp": "image/bmp", ".tiff": "image/tiff"} IMAGE_EXTS = set(MIME) # 17 类标准名称:按分类号归一化模型输出的写法差异 CANON = { 1: "1. 烟草、毒品、赌博、犹太", 2: "2. 酒精", 3: "3. 色情", 4: "4. LGBT", 5: "5. 负向敏感类信息(血腥暴力)、战争(含动漫里的战争)、武器类、纳粹", 6: "6. 政治相关(包含军队组织等相关内容)", 7: "7. 种族歧视和文化差异", 8: "8. 旗帜和徽章", 9: "9. 宗教 - 其他教", 10: "10. 宗教 - 伊斯兰教", 11: "11. 宗教 - 基督教", 12: "12. 侵权 - 除人物外的其他侵权", 13: "13. 侵权 - 人物相关(如肖像权、姓名权等)", 14: "14. 脏话、侮辱性、谩骂攻击类文字图案(包含冒犯和争议性内容)", 15: "15. 图案消极低俗或者暴力(包含冒犯和争议性内容)", 16: "16. 欧区国家禁售", 17: "17. 冒犯和争议性内容", } def encode_image(path: str) -> str: ext = os.path.splitext(path)[1].lower() if ext not in MIME: raise ValueError(f"不支持的图片格式: {path}") with open(path, "rb") as f: b64 = base64.b64encode(f.read()).decode("utf-8") return f"data:{MIME[ext]};base64,{b64}" def norm_category(cat: str) -> str: c = str(cat).strip() m = re.match(r"^(\d+)", c) return CANON.get(int(m.group(1)), c) if m else c def parse_judgment(raw: str): """解析模型返回的三字段 JSON,返回 (attr, logic, category) 或 None。""" if not raw: return None text = raw.strip() m = re.search(r"```(?:json)?\s*(.*?)```", text, re.S) if m: text = m.group(1).strip() s, e = text.find("{"), text.rfind("}") if s == -1 or e <= s: return None try: obj = json.loads(text[s:e + 1]) except json.JSONDecodeError: return None keys = ["文字/图形属性", "侵权/违规逻辑", "违规分类"] if not all(k in obj for k in keys): return None return (str(obj["文字/图形属性"]).strip(), str(obj["侵权/违规逻辑"]).strip(), str(obj["违规分类"]).strip()) def _norm_usage(u: dict) -> dict: u = u or {} return {"prompt": u.get("prompt_tokens"), "completion": u.get("completion_tokens"), "cached": (u.get("prompt_tokens_details") or {}).get("cached_tokens", 0)} def _result(fname, provider, content, data, path_for_err=""): ch = (data.get("choices") or [{}])[0] parsed = parse_judgment(content) base = {"file": fname, "provider": provider, "finish_reason": ch.get("finish_reason"), "usage": _norm_usage(data.get("usage"))} if parsed: attr, logic, cat = parsed base.update({"status": "ok", "attr": attr, "logic": logic, "category": norm_category(cat), "raw": content}) else: base.update({"status": "parse_fail", "attr": "", "logic": "", "category": "违规不明", "raw": content or ""}) return base async def detect_ark(session, sem, cfg: AppConfig, prompt: str, path: str, stop=None): """豆包(火山方舟)视觉检测:图片在前、文本在后,强制 JSON 输出。""" fname = os.path.basename(path) url = cfg.ark_base_url.rstrip("/") + "/chat/completions" headers = {"Authorization": f"Bearer {cfg.ark_api_key}", "Content-Type": "application/json", "x-is-encrypted": "true"} payload = { "model": cfg.ark_model, "messages": [{ "role": "user", "content": [ {"type": "image_url", "image_url": {"url": encode_image(path)}}, {"type": "text", "text": prompt}, ], }], "response_format": {"type": "json_object"}, } last_err = "" for attempt in range(1, cfg.retries + 1): if stop and stop(): logger.info("豆包检测 %s:用户停止", fname) return {"file": fname, "provider": "doubao", "status": "error", "attr": "", "logic": "", "category": "检测异常", "raw": "用户停止", "usage": {}} try: async with sem: async with session.post(url, json=payload, headers=headers) as r: if r.status == 200: data = await r.json() content = data["choices"][0]["message"]["content"] return _result(fname, "doubao", content, data) body = (await r.text())[:2000] last_err = f"HTTP {r.status}: {body[:300]}" logger.warning("豆包检测 %s 第%d/%d次尝试失败:HTTP %s\n响应体:%s", fname, attempt, cfg.retries, r.status, body) except Exception: # noqa: BLE001 网络层异常:记录完整堆栈后重试 last_err = "网络/协议异常(详见日志)" logger.exception("豆包检测 %s 第%d/%d次尝试抛出异常", fname, attempt, cfg.retries) if attempt < cfg.retries: logger.info("豆包检测 %s:%.0f 秒后重试", fname, 3 * attempt) await asyncio.sleep(3 * attempt) logger.error("豆包检测 %s 最终失败:%s", fname, last_err) return {"file": fname, "provider": "doubao", "status": "error", "attr": "", "logic": "", "category": "检测异常", "raw": last_err, "usage": {}} async def detect_deepseek(session, sem, cfg: AppConfig, prompt: str, path: str, stop=None): """DeepSeek 视觉检测:temperature=0 + JSON 模式。 注意该模型为推理模型,reasoning 消耗 completion 预算,max_tokens 须留足。""" fname = os.path.basename(path) url = cfg.deepseek_base_url.rstrip("/") + "/chat/completions" headers = {"Authorization": f"Bearer {cfg.deepseek_api_key}", "Content-Type": "application/json"} payload = { "model": cfg.deepseek_model, "messages": [{ "role": "user", "content": [ {"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": encode_image(path)}}, ], }], "temperature": 0, "response_format": {"type": "json_object"}, "max_tokens": cfg.max_tokens, } last_err = "" for attempt in range(1, cfg.retries + 1): if stop and stop(): logger.info("DeepSeek 检测 %s:用户停止", fname) return {"file": fname, "provider": "deepseek", "status": "error", "attr": "", "logic": "", "category": "检测异常", "raw": "用户停止", "usage": {}} try: async with sem: async with session.post(url, json=payload, headers=headers) as r: if r.status == 200: data = await r.json() content = data["choices"][0]["message"]["content"] if content and content.strip(): return _result(fname, "deepseek", content, data) # 推理烧尽预算会得到空正文,按可重试错误处理 fr = data["choices"][0].get("finish_reason") last_err = f"empty content (finish_reason={fr}, max_tokens={cfg.max_tokens})" logger.warning("DeepSeek 检测 %s 第%d/%d次尝试返回空正文" "(finish_reason=%s,推理可能耗尽 max_tokens=%s)", fname, attempt, cfg.retries, fr, cfg.max_tokens) else: body = (await r.text())[:2000] last_err = f"HTTP {r.status}: {body[:300]}" logger.warning("DeepSeek 检测 %s 第%d/%d次尝试失败:HTTP %s\n响应体:%s", fname, attempt, cfg.retries, r.status, body) except Exception: # noqa: BLE001 网络层异常:记录完整堆栈后重试 last_err = "网络/协议异常(详见日志)" logger.exception("DeepSeek 检测 %s 第%d/%d次尝试抛出异常", fname, attempt, cfg.retries) if attempt < cfg.retries: logger.info("DeepSeek 检测 %s:%.0f 秒后重试", fname, 3 * attempt) await asyncio.sleep(3 * attempt) logger.error("DeepSeek 检测 %s 最终失败:%s", fname, last_err) return {"file": fname, "provider": "deepseek", "status": "error", "attr": "", "logic": "", "category": "检测异常", "raw": last_err, "usage": {}}