feat: generate spam rules from text with ai

This commit is contained in:
ngfchl
2026-07-07 19:45:25 +08:00
parent 3e5936653b
commit 39d39b07f9
7 changed files with 522 additions and 358 deletions
+149 -25
View File
@@ -10,6 +10,7 @@ import hashlib
import ipaddress
import socket
import time
import httpx
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
@@ -626,6 +627,107 @@ def sanitize_manual_rule(rule_type: str, pattern: str) -> tuple[str, str | None]
return cleaned, (original if changed else None)
def parse_json_object(text: str) -> dict[str, Any] | None:
body = (text or "").strip()
if not body:
return None
try:
data = json.loads(body)
return data if isinstance(data, dict) else None
except Exception:
pass
m = re.search(r"\{[\s\S]*\}", body)
if not m:
return None
try:
data = json.loads(m.group(0))
return data if isinstance(data, dict) else None
except Exception:
return None
def fallback_rule_from_text(text: str) -> dict[str, Any]:
norm = spam_detector.normalize(text)
domains = sorted(spam_detector.extract_domains_from_text(norm))
if domains:
return {"type": "keyword", "pattern": domains[0], "score": 80, "reason": "AI不可用,按域名生成关键词规则"}
compacted = spam_detector.compact(norm)
compacted = re.sub(r"[,。!?、,.!?;;::\s]+", "", compacted)
if len(compacted) > 80:
compacted = compacted[:80]
return {"type": "keyword", "pattern": compacted, "score": 90, "reason": "AI不可用,按紧凑文本生成关键词规则"}
async def generate_rule_from_text(text: str, use_ai: bool = True) -> dict[str, Any]:
import config
if not use_ai:
return fallback_rule_from_text(text)
prompt = f"""你是中文群聊垃圾广告规则提取器。请从下面的聊天内容中提取一条最适合写入拦截库的规则。
要求:
1. 只输出 JSON,不要解释,不要 Markdown。
2. JSON 格式:{{"type":"keyword|regex","pattern":"规则内容","score":1-200,"reason":"一句话说明"}}
3. 优先生成能覆盖变体但不容易误杀的规则。
4. 如果内容包含明确广告域名/链接,优先提取域名作为 keyword。
5. 如果有零宽字符、断词、空格插入、黑U/USDT/跑分/贷款/博彩等变体,优先生成 regex。
6. keyword 不要太泛,禁止只输出:联系、项目、赚钱、福利、微信、QQ、TG、http、https。
7. regex 必须是 Python re 可编译的正则,不要带 /.../ 包裹。
8. 分数建议:明确灰产/博彩/色情/诈骗 90-120;普通营销 60-80;域名 80。
聊天内容:
{text[:2000]}
"""
try:
async with httpx.AsyncClient(timeout=60, proxy=None) as client:
resp = await client.post(
f"{config.OLLAMA_URL}/api/generate",
json={"model": config.OLLAMA_MODEL, "prompt": prompt, "stream": False, "think": False, "options": {"temperature": 0.1, "num_predict": 220}},
)
resp.raise_for_status()
reply = resp.json().get("response", "")
data = parse_json_object(reply)
if not data:
raise ValueError("AI 未返回合法 JSON")
rule_type = str(data.get("type") or "keyword").strip().lower()
pattern = str(data.get("pattern") or "").strip()
score = int(data.get("score") or 90)
reason = str(data.get("reason") or "AI 生成规则").strip()
if rule_type not in {"keyword", "regex"}:
rule_type = "keyword"
if not pattern:
raise ValueError("AI 生成规则为空")
return {"type": rule_type, "pattern": pattern, "score": max(1, min(score, 200)), "reason": reason, "ai_used": True}
except Exception as e:
logging.getLogger("spam_guard").warning(f"⚠️ AI 生成规则失败,使用兜底规则: {e}")
data = fallback_rule_from_text(text)
data["ai_used"] = False
data["reason"] = f"{data.get('reason', '兜底生成')}AI异常: {type(e).__name__}"
return data
async def append_spam_rule(rule_type: str, pattern: str, score: int, source_note: str = "Web 添加规则") -> dict[str, Any]:
rule_type = rule_type.strip().lower()
if rule_type not in {"keyword", "regex"}:
raise ValueError("规则类型只能是 keyword 或 regex")
score = max(1, min(int(score), 200))
stored_pattern, original_pattern = sanitize_manual_rule(rule_type, pattern)
path = spam_detector.RULES_DIR / ("spam_keywords.txt" if rule_type == "keyword" else "spam_regex.txt")
line = f"{stored_pattern}|{score}"
async with _rule_write_lock:
existing = path.read_text(encoding="utf-8") if path.exists() else ""
existing_patterns = {raw.lstrip("\ufeff").rsplit("|", 1)[0].strip() for raw in existing.splitlines() if raw.strip() and not raw.strip().lstrip("\ufeff").startswith("#")}
if stored_pattern in existing_patterns:
raise FileExistsError("规则已存在")
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
if existing and not existing.endswith("\n"):
f.write("\n")
note = f" 原始: {original_pattern}" if original_pattern else ""
f.write(f"\n# {source_note} {format_dt(now_tz())}{note}\n{line}\n")
counts = spam_detector.reload_external_rules()
return {"type": rule_type, "pattern": stored_pattern, "original_pattern": original_pattern, "score": score, "rule_counts": counts}
@app.get("/api/spam-rules")
async def api_list_spam_rules(limit: int = Query(default=500, ge=1, le=2000)):
def read_rules(path: Path, rule_type: str):
@@ -676,7 +778,7 @@ async def api_spam_test(data: SpamTestPayload):
"local_is_spam": bool(decision) if decision is not None else None,
"reason": reason,
"score": score,
"rule_counts": {"keywords": len(spam_detector.EXTERNAL_KEYWORD_RULES), "regex": len(spam_detector.EXTERNAL_REGEX_RULES)},
"rule_counts": {"keywords": len(spam_detector.EXTERNAL_KEYWORD_RULES), "regex": len(spam_detector.EXTERNAL_REGEX_RULES), "adblock_domains": len(spam_detector.ADBLOCK_DOMAINS)},
}
if use_ai:
ai_is_spam, ai_reason = await spam_detector.detect(text)
@@ -686,38 +788,60 @@ async def api_spam_test(data: SpamTestPayload):
return JSONResponse(result)
@app.post("/api/spam-rules/from-text")
async def api_add_spam_rule_from_text(request: Request):
try:
raw = await request.json()
if not isinstance(raw, dict):
raw = {}
except Exception:
return JSONResponse({"ok": False, "error": "请求体必须是合法 JSON"}, status_code=400)
text = str(raw.get("text") or "").strip()
use_ai = bool(raw.get("use_ai", True))
if not text:
return JSONResponse({"ok": False, "error": "请输入要入库的聊天内容"}, status_code=400)
if len(text) > 4000:
return JSONResponse({"ok": False, "error": "聊天内容不能超过 4000 字"}, status_code=400)
generated = None
try:
generated = await generate_rule_from_text(text, use_ai)
saved = await append_spam_rule(generated["type"], generated["pattern"], generated["score"], "Web AI生成规则")
except FileExistsError as e:
return JSONResponse({"ok": False, "error": str(e), "generated_rule": generated}, status_code=409)
except re.error as e:
return JSONResponse({"ok": False, "error": f"AI 生成的正则无效:{e}", "generated_rule": generated}, status_code=400)
except ValueError as e:
return JSONResponse({"ok": False, "error": str(e), "generated_rule": generated}, status_code=400)
await Action.create(action="SPAM_RULE_AI_ADD", details={"text": text[:500], "generated": generated, "saved": saved})
return JSONResponse({"ok": True, **saved, "generated_rule": generated})
@app.post("/api/spam-rules")
async def api_add_spam_rule(data: SpamRulePayload):
rule_type = data.type.strip().lower()
pattern = data.pattern.strip()
score = data.score
if rule_type not in {"keyword", "regex"}:
return JSONResponse({"ok": False, "error": "规则类型只能是 keyword 或 regex"}, status_code=400)
async def api_add_spam_rule(request: Request):
try:
raw = await request.json()
if not isinstance(raw, dict):
raw = {}
except Exception:
return JSONResponse({"ok": False, "error": "请求体必须是合法 JSON"}, status_code=400)
rule_type = str(raw.get("type") or "keyword")
pattern = str(raw.get("pattern") or "").strip()
try:
score = int(raw.get("score") or 75)
except Exception:
score = 75
if not pattern or len(pattern) > 500:
return JSONResponse({"ok": False, "error": "规则内容不能为空,且不能超过 500 字"}, status_code=400)
score = max(1, min(score, 200))
try:
stored_pattern, original_pattern = sanitize_manual_rule(rule_type, pattern)
saved = await append_spam_rule(rule_type, pattern, score, "Web 手动添加规则")
except FileExistsError as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=409)
except re.error as e:
return JSONResponse({"ok": False, "error": f"正则无效:{e}"}, status_code=400)
except ValueError as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=400)
path = spam_detector.RULES_DIR / ("spam_keywords.txt" if rule_type == "keyword" else "spam_regex.txt")
line = f"{stored_pattern}|{score}"
async with _rule_write_lock:
existing = path.read_text(encoding="utf-8") if path.exists() else ""
existing_patterns = {raw.rsplit("|", 1)[0].strip() for raw in existing.splitlines() if raw.strip() and not raw.strip().startswith("#")}
if stored_pattern in existing_patterns:
return JSONResponse({"ok": False, "error": "规则已存在"}, status_code=409)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
if existing and not existing.endswith("\n"):
f.write("\n")
note = f" 原始: {original_pattern}" if original_pattern else ""
f.write(f"\n# Web 手动添加规则 {format_dt(now_tz())}{note}\n{line}\n")
counts = spam_detector.reload_external_rules()
await Action.create(action="SPAM_RULE_ADD", details={"type": rule_type, "pattern": stored_pattern, "score": score, "original": original_pattern})
return JSONResponse({"ok": True, "type": rule_type, "pattern": stored_pattern, "original_pattern": original_pattern, "score": score, "rule_counts": counts})
await Action.create(action="SPAM_RULE_ADD", details={"type": saved["type"], "pattern": saved["pattern"], "score": saved["score"], "original": saved.get("original_pattern")})
return JSONResponse({"ok": True, **saved})
@app.post("/api/restart")