- 新增 --host/--port 覆盖 $AI_REVIEW_HOST/$AI_REVIEW_PORT(CLI > ENV > 默认值) - 新增 --log-level debug|info|warn|error(启动打印,ai_review 无 db 日志客户端故仅作标准接口) - 新增 --instance 实例名(注册中心用) - 配置加载从模块顶层移入 __main__,argparse 解析 - services.yaml: ai_review 声明 --instance 和 --log-level
204 lines
6.8 KiB
Python
204 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
||
|
||
"""
|
||
AI Review Service
|
||
|
||
基于 DeepSeek + RAG 的 AI 内容审核微服务。
|
||
接收纯文本字符串,返回结构化审核结果。
|
||
"""
|
||
|
||
from flask import Flask, request, jsonify
|
||
from flask_cors import CORS
|
||
from waitress import serve
|
||
from dotenv import load_dotenv
|
||
import hashlib
|
||
import os
|
||
import sys
|
||
import threading
|
||
import time
|
||
from collections import defaultdict
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||
|
||
from agent.review_agent import ReviewAgent
|
||
from security_middleware import apply_security_headers
|
||
|
||
app = Flask(__name__)
|
||
apply_security_headers(app)
|
||
CORS(app)
|
||
|
||
review_agent = ReviewAgent()
|
||
|
||
# ── 内容大小限制(10KB,审核文本不应超过此值)──
|
||
MAX_CONTENT_LENGTH = 10 * 1024
|
||
|
||
# ── 简易滑动窗口限流器 ──────────────────────────────────────────────
|
||
# /review: 30次/分钟/IP(正常审核请求)
|
||
# /rag/feedback: 5次/分钟/IP(人工纠偏低频操作)
|
||
|
||
_RATE_LIMIT_WINDOW = 60 # 窗口秒数
|
||
_RATE_LIMIT_REVIEW = 30 # /review 每分钟上限
|
||
_RATE_LIMIT_FEEDBACK = 5 # /rag/feedback 每分钟上限
|
||
_RATE_LIMIT_LOCK = threading.Lock()
|
||
_RATE_LIMIT_BUCKETS: "dict[str, list[float]]" = defaultdict(list)
|
||
|
||
|
||
def _cleanup_buckets():
|
||
"""惰性清理过期时间戳,在线程锁内完成。"""
|
||
now = time.monotonic()
|
||
cutoff = now - _RATE_LIMIT_WINDOW
|
||
stale_keys = []
|
||
for key, timestamps in _RATE_LIMIT_BUCKETS.items():
|
||
timestamps[:] = [t for t in timestamps if t > cutoff]
|
||
if not timestamps:
|
||
stale_keys.append(key)
|
||
for key in stale_keys:
|
||
del _RATE_LIMIT_BUCKETS[key]
|
||
|
||
|
||
def _check_rate_limit(key: str, limit: int) -> bool:
|
||
"""检查是否超出限流,返回 True 表示放行,False 表示拒绝。"""
|
||
now = time.monotonic()
|
||
cutoff = now - _RATE_LIMIT_WINDOW
|
||
with _RATE_LIMIT_LOCK:
|
||
bucket = _RATE_LIMIT_BUCKETS[key]
|
||
bucket[:] = [t for t in bucket if t > cutoff]
|
||
if len(bucket) >= limit:
|
||
return False
|
||
bucket.append(now)
|
||
return True
|
||
|
||
|
||
def _client_ip() -> str:
|
||
"""获取客户端真实 IP(优先 X-Forwarded-For)。"""
|
||
xff = request.headers.get("X-Forwarded-For", "")
|
||
if xff:
|
||
return xff.split(",")[0].strip()
|
||
return request.remote_addr or "127.0.0.1"
|
||
|
||
|
||
@app.route('/review', methods=['POST'])
|
||
def review():
|
||
"""
|
||
审核接口
|
||
Request: {"content": "待审核的字符串"}
|
||
Response: {"invalid": "true"} 或 {"invalid": "false"}
|
||
"""
|
||
# 频率限制
|
||
ip = _client_ip()
|
||
if not _check_rate_limit(f"review:{ip}", _RATE_LIMIT_REVIEW):
|
||
return jsonify({"error": "rate limit exceeded", "retry_after": _RATE_LIMIT_WINDOW}), 429
|
||
|
||
data = request.get_json()
|
||
|
||
if not data or 'content' not in data:
|
||
return jsonify({"error": "content is required"}), 400
|
||
|
||
content = data.get('content', '').strip()
|
||
|
||
if not content:
|
||
return jsonify({"error": "content cannot be empty"}), 400
|
||
|
||
# 内容大小限制
|
||
if len(content) > MAX_CONTENT_LENGTH:
|
||
return jsonify({"error": f"content too large, max {MAX_CONTENT_LENGTH} bytes"}), 413
|
||
|
||
result = review_agent.review(content)
|
||
return jsonify(result), 200
|
||
|
||
|
||
@app.route('/rag/feedback', methods=['POST'])
|
||
def rag_feedback():
|
||
"""
|
||
人工纠偏反馈接口 —— AI 审核结果与人工判断不一致时调用。
|
||
|
||
Request: {"content": "被误判的文本", "correct_type": "violation"}
|
||
correct_type: "violation" — AI 漏判了(AI 通过但实际违规)
|
||
"safe" — AI 误杀了(AI 拦截但实际安全)
|
||
|
||
Response: {"added": true, "id": "v_xxx", "md5": "xxx"}
|
||
{"added": false, "reason": "duplicate"}
|
||
"""
|
||
# 频率限制(人工纠偏低频操作,严格限制)
|
||
ip = _client_ip()
|
||
if not _check_rate_limit(f"feedback:{ip}", _RATE_LIMIT_FEEDBACK):
|
||
return jsonify({"error": "rate limit exceeded", "retry_after": _RATE_LIMIT_WINDOW}), 429
|
||
|
||
data = request.get_json()
|
||
|
||
if not data or 'content' not in data:
|
||
return jsonify({"error": "content is required"}), 400
|
||
|
||
content = data.get('content', '').strip()
|
||
correct_type = data.get('correct_type', 'violation').strip()
|
||
|
||
if not content:
|
||
return jsonify({"error": "content cannot be empty"}), 400
|
||
|
||
if correct_type not in ("violation", "safe"):
|
||
return jsonify({"error": "correct_type must be 'violation' or 'safe'"}), 400
|
||
|
||
md5_hash = hashlib.md5(content.encode("utf-8")).hexdigest()
|
||
entry_id = f"{correct_type[:1]}_{md5_hash[:12]}"
|
||
category = "human_feedback"
|
||
|
||
added = review_agent.rag.add_example(entry_id, content, correct_type, category)
|
||
|
||
return jsonify({
|
||
"added": added,
|
||
"id": entry_id if added else None,
|
||
"md5": md5_hash,
|
||
"reason": None if added else "duplicate",
|
||
}), 200
|
||
|
||
|
||
@app.route('/health', methods=['GET'])
|
||
def health():
|
||
return jsonify({"status": "ok"}), 200
|
||
|
||
|
||
@app.route('/rag/list', methods=['GET'])
|
||
def rag_list():
|
||
"""返回 RAG 库中所有条目。"""
|
||
entries = review_agent.rag.list_all()
|
||
return jsonify({"total": len(entries), "entries": entries}), 200
|
||
|
||
|
||
def _parse_args():
|
||
"""解析命令行参数(标准化:CLI > ENV > 默认值)。"""
|
||
import argparse
|
||
p = argparse.ArgumentParser(
|
||
prog="ai_review",
|
||
description="AI Review Service — 基于 DeepSeek + RAG 的内容审核微服务",
|
||
)
|
||
p.add_argument("--host", default=None,
|
||
help="绑定地址(默认读取 $AI_REVIEW_HOST,否则 127.0.0.1)")
|
||
p.add_argument("--port", type=int, default=None,
|
||
help="监听端口(默认读取 $AI_REVIEW_PORT,否则 8007)")
|
||
p.add_argument("--log-level", default="info",
|
||
choices=["debug", "info", "warn", "error"],
|
||
help="日志级别(默认 info)")
|
||
p.add_argument("--instance", default="ai_review",
|
||
help="实例名(注册中心用,默认 ai_review)")
|
||
return p.parse_args()
|
||
|
||
|
||
# ── 配置默认值(ENV 覆盖,CLI 最高优先级)───────────────────────────
|
||
ADDR = os.getenv("AI_REVIEW_HOST", "127.0.0.1")
|
||
PORT = int(os.getenv("AI_REVIEW_PORT", "8007"))
|
||
|
||
if __name__ == "__main__":
|
||
args = _parse_args()
|
||
|
||
# CLI 覆盖(最高优先级)
|
||
if args.host is not None:
|
||
ADDR = args.host
|
||
if args.port is not None:
|
||
PORT = args.port
|
||
|
||
print(f'AI Review Service listening at http://{ADDR}:{PORT}')
|
||
print(f'[ai_review] instance={args.instance} log-level={args.log_level}')
|
||
print(f'[ai_review] 配置来源: CLI > ENV > 默认值')
|
||
serve(app, host=ADDR, port=PORT)
|