Spaces:
Running
Running
File size: 9,065 Bytes
c47ec10 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | from __future__ import annotations
import re
from curl_cffi import requests
from fastapi import HTTPException
from services.config import config
from services.proxy_service import proxy_settings
from utils.log import logger
DEFAULT_REVIEW_PROMPT = "判断用户请求是否允许。只回答 ALLOW 或 REJECT。"
# Strip base64 image data URIs before review: a text-only review model can't
# analyze image bytes, and a single inlined image easily blows past the token
# budget of the upstream review service.
_BASE64_DATA_URI = re.compile(r"data:[\w/.+;-]+;base64,[A-Za-z0-9+/=]+")
# Cap aligned to the upstream review service's max context. If text still
# exceeds the cap after base64 stripping, keep equal head/tail halves so both
# the system prompt and the most recent user message survive.
_MAX_REVIEW_TEXT_LEN = 100_000
_TRUNCATION_MARKER = "\n…[truncated]…\n"
def _text(value: object) -> str:
if isinstance(value, str):
return value
if isinstance(value, list):
return "\n".join(_text(item) for item in value)
if isinstance(value, dict):
return "\n".join(_text(value.get(key)) for key in ("text", "input_text", "content", "input", "instructions", "system", "prompt"))
return ""
def request_text(*values: object) -> str:
return "\n".join(part for value in values if (part := _text(value).strip()))
def request_shape(*values: object) -> dict[str, int]:
"""Return a safe structural summary without logging prompts or image bytes."""
stats = {
"response_message_items": 0,
"input_image_parts": 0,
"image_url_parts": 0,
"image_parts": 0,
"data_url_images": 0,
"remote_image_urls": 0,
"literal_image_placeholders": 0,
}
def walk(value: object, key: str = "") -> None:
if isinstance(value, str):
text = value.strip()
lower = text.lower()
if "<image>" in lower:
stats["literal_image_placeholders"] += 1
if lower.startswith("data:image/"):
stats["data_url_images"] += 1
elif key in {"image_url", "url"} and lower.startswith(("http://", "https://")):
stats["remote_image_urls"] += 1
return
if isinstance(value, list):
for item in value:
walk(item, key)
return
if not isinstance(value, dict):
return
item_type = str(value.get("type") or "").strip()
if item_type == "message":
stats["response_message_items"] += 1
elif item_type == "input_image":
stats["input_image_parts"] += 1
elif item_type == "image_url":
stats["image_url_parts"] += 1
elif item_type == "image":
stats["image_parts"] += 1
for child_key, child in value.items():
walk(child, str(child_key))
for value in values:
walk(value)
return {key: value for key, value in stats.items() if value}
def _sanitize_for_review(text: str) -> tuple[str, dict[str, int]]:
"""Strip base64 data URIs and truncate to the review-service context limit.
Returns (sanitized_text, stats) where stats carries base64_blocks_stripped
and truncated_chars so callers can emit structured logs.
"""
sanitized, base64_blocks_stripped = _BASE64_DATA_URI.subn("[image]", text)
truncated_chars = 0
if len(sanitized) > _MAX_REVIEW_TEXT_LEN:
# Reserve marker space so the result stays within the cap.
half = (_MAX_REVIEW_TEXT_LEN - len(_TRUNCATION_MARKER)) // 2
truncated_chars = len(sanitized) - 2 * half
sanitized = sanitized[:half] + _TRUNCATION_MARKER + sanitized[-half:]
stats = {
"base64_blocks_stripped": base64_blocks_stripped,
"truncated_chars": truncated_chars,
}
return sanitized, stats
def _extract_review_decision(data: object) -> str | None:
"""Defensively pull the decision text out of the review service response.
Returns None when the response shape doesn't match the OpenAI chat-completion
contract (e.g. {"error": ...} with no choices). The caller treats None as
"undecided" and applies the configured fail-open policy.
"""
if not isinstance(data, dict):
return None
choices = data.get("choices")
if not isinstance(choices, list) or not choices:
return None
first = choices[0]
if not isinstance(first, dict):
return None
message = first.get("message")
if not isinstance(message, dict):
return None
content = message.get("content")
if content is None:
return None
return str(content).strip().lower()
def _is_allow_decision(decision: str) -> bool:
return decision.startswith(("allow", "pass", "true", "yes", "通过", "允许", "安全"))
def _is_reject_decision(decision: str) -> bool:
return decision.startswith(("reject", "deny", "block", "false", "no", "拒绝", "不允许", "违规", "禁止"))
def _resolve_fail_open(review: dict) -> bool:
"""Resolve fail_open from review config. Defaults to True."""
value = review.get("fail_open")
if value is None:
return True
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def check_request(text: str) -> None:
text = str(text or "")
if not text.strip():
return
# Local sensitive-word match runs on the raw text (cheap, no network).
for word in config.sensitive_words:
if word in text:
raise HTTPException(status_code=400, detail={"error": "检测到敏感词,拒绝本次任务"})
review = config.ai_review
if not review.get("enabled"):
return
base_url = str(review.get("base_url") or "").strip().rstrip("/")
api_key = str(review.get("api_key") or "").strip()
model = str(review.get("model") or "").strip()
if not base_url or not api_key or not model:
raise HTTPException(status_code=400, detail={"error": "ai review config is incomplete"})
fail_open = _resolve_fail_open(review)
review_text, sanitize_stats = _sanitize_for_review(text)
if sanitize_stats["base64_blocks_stripped"] or sanitize_stats["truncated_chars"]:
logger.info({
"event": "ai_review_text_sanitized",
"original_text_len": len(text),
"review_text_len": len(review_text),
**sanitize_stats,
})
prompt = str(review.get("prompt") or DEFAULT_REVIEW_PROMPT).strip()
content = f"{prompt}\n\n用户请求:\n{review_text}\n\n只回答 ALLOW 或 REJECT。"
# fail_open=True (default): on upstream failure or ambiguous reply, let the
# request through. The review is a soft safety net; one missed review is
# preferable to a 5xx storm when the review service is flaky. Set
# config.ai_review.fail_open=false for strict-compliance deployments.
def _on_failure(event_payload: dict) -> None:
logger.warning(event_payload)
if not fail_open:
raise HTTPException(
status_code=503,
detail={"error": "AI 审核服务暂时不可用,请稍后重试"},
)
try:
response = requests.post(
f"{base_url}/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": model, "messages": [{"role": "user", "content": content}], "temperature": 0},
timeout=60,
**proxy_settings.build_session_kwargs(),
)
except Exception as exc:
_on_failure({
"event": "ai_review_request_failed",
"error": str(exc),
"error_type": exc.__class__.__name__,
"review_text_len": len(review_text),
"original_text_len": len(text),
})
return
try:
data = response.json()
except Exception as exc:
_on_failure({
"event": "ai_review_response_not_json",
"status_code": response.status_code,
"body_preview": str(response.text or "")[:200],
"error": str(exc),
})
return
decision = _extract_review_decision(data)
if decision is None:
_on_failure({
"event": "ai_review_malformed_response",
"status_code": response.status_code,
"body_preview": str(data)[:300],
"review_text_len": len(review_text),
"original_text_len": len(text),
})
return
if _is_allow_decision(decision):
return
if _is_reject_decision(decision):
raise HTTPException(status_code=400, detail={"error": "AI 审核未通过,拒绝本次任务"})
# Ambiguous decisions (e.g. "MAYBE", empty content) fall back to fail-open policy.
_on_failure({
"event": "ai_review_ambiguous_decision",
"decision": decision[:100],
"review_text_len": len(review_text),
})
return
|