feat: generate spam rules from text with ai
This commit is contained in:
+40
-2
@@ -29,6 +29,7 @@ const spamTesting = ref(false);
|
||||
const spamTestResult = ref<Record<string, any> | null>(null);
|
||||
const ruleForm = reactive({ type: 'keyword', pattern: '', score: 75 });
|
||||
const ruleSaving = ref(false);
|
||||
const generatedRuleHint = ref('');
|
||||
const spamRules = reactive({ keywords: [] as Record<string, any>[], regex: [] as Record<string, any>[], adblock_domains: [] as Record<string, any>[], counts: { keywords: 0, regex: 0, adblock_domains: 0 } });
|
||||
const rulesLoading = ref(false);
|
||||
const ruleKeyword = ref('');
|
||||
@@ -224,7 +225,11 @@ async function logout() {
|
||||
async function fetchJson(url: string, options?: RequestInit) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) throw new Error(data.error || data.message || `HTTP ${res.status}`);
|
||||
if (!res.ok || data.ok === false) {
|
||||
const err: any = new Error(data.error || data.message || `HTTP ${res.status}`);
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
async function loadChatHistory() {
|
||||
@@ -441,6 +446,7 @@ async function saveSpamRule() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ruleForm)
|
||||
});
|
||||
generatedRuleHint.value = '';
|
||||
showToast(`规则已写入,关键词 ${result.rule_counts?.keywords || '-'} / 正则 ${result.rule_counts?.regex || '-'}`);
|
||||
ruleForm.pattern = '';
|
||||
await loadSpamRules();
|
||||
@@ -449,6 +455,37 @@ async function saveSpamRule() {
|
||||
finally { ruleSaving.value = false; }
|
||||
}
|
||||
|
||||
async function addSpamTextToRules() {
|
||||
if (!spamTestText.value.trim()) return showToast('请输入要入库的聊天内容');
|
||||
if (ruleSaving.value) return;
|
||||
ruleSaving.value = true;
|
||||
try {
|
||||
const result = await fetchJson('/api/spam-rules/from-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: spamTestText.value, use_ai: spamUseAi.value })
|
||||
});
|
||||
const generated = result.generated_rule || {};
|
||||
ruleForm.type = result.type || generated.type || 'keyword';
|
||||
ruleForm.pattern = result.pattern || generated.pattern || '';
|
||||
ruleForm.score = Number(result.score || generated.score || 90);
|
||||
generatedRuleHint.value = `${generated.ai_used === false ? '兜底生成' : 'AI生成'}:${ruleForm.type === 'regex' ? '正则' : '关键词'} / ${ruleForm.score} / ${ruleForm.pattern}${generated.reason ? `(${generated.reason})` : ''}`;
|
||||
showToast(`已${generated.ai_used === false ? '兜底' : 'AI'}生成并写入规则:${ruleForm.pattern}`);
|
||||
await loadSpamRules();
|
||||
await testSpamText();
|
||||
} catch (e: any) {
|
||||
const generated = e?.data?.generated_rule;
|
||||
if (generated?.pattern) {
|
||||
ruleForm.type = generated.type || 'keyword';
|
||||
ruleForm.pattern = generated.pattern;
|
||||
ruleForm.score = Number(generated.score || 90);
|
||||
generatedRuleHint.value = `已生成但未写入:${ruleForm.pattern}${generated.reason ? `(${generated.reason})` : ''}`;
|
||||
}
|
||||
showToast(`内容入库失败:${e.message}`);
|
||||
}
|
||||
finally { ruleSaving.value = false; }
|
||||
}
|
||||
|
||||
function reloadByGroup() { loadChatHistory(); loadBans(); loadStats(); loadShopItems(); loadShopTransactions(); loadLotteries(); }
|
||||
|
||||
function connectSSE() {
|
||||
@@ -878,8 +915,9 @@ onBeforeUnmount(() => {
|
||||
<div class="rule-action-bar">
|
||||
<label class="switch-line compact"><input v-model="spamUseAi" type="checkbox" /> AI 兜底</label>
|
||||
<SButtonLoading color="primary" :loading="spamTesting" :disabled="spamTesting" @click="testSpamText">开始检测</SButtonLoading>
|
||||
<SButtonLoading variant="outline" color="warning" :loading="ruleSaving" :disabled="ruleSaving || !spamTestText.trim()" @click="ruleForm.pattern = spamTestText; ruleForm.type = 'keyword'; ruleForm.score = 90; saveSpamRule()">内容入库</SButtonLoading>
|
||||
<SButtonLoading variant="outline" color="warning" :loading="ruleSaving" :disabled="ruleSaving || !spamTestText.trim()" @click="addSpamTextToRules">AI生成规则并入库</SButtonLoading>
|
||||
</div>
|
||||
<div v-if="generatedRuleHint" class="generated-rule-hint">{{ generatedRuleHint }}</div>
|
||||
<div class="rule-write-grid inline-rule-writer compact-rule-writer">
|
||||
<label>类型<select v-model="ruleForm.type" class="form-select"><option value="keyword">关键词</option><option value="regex">正则</option></select></label>
|
||||
<label>分数<SInput v-model="ruleForm.score" type="number" placeholder="75" /></label>
|
||||
|
||||
@@ -549,3 +549,5 @@ body { background: #f3f6fb; }
|
||||
.overview-ban-list .ban-meta { width: 100%; display: flex !important; align-items: center; flex-wrap: nowrap !important; gap: 5px !important; font-size: 10px !important; line-height: 1.2; white-space: nowrap !important; overflow: hidden; }
|
||||
.overview-ban-list .ban-meta > span { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.overview-ban-list .ban-meta .s-button { height: 22px; min-height: 22px; padding: 0 8px; font-size: 10px; }
|
||||
|
||||
.generated-rule-hint { margin-top: 6px; padding: 6px 9px; border-radius: 10px; background: #f0fdf4; color: #166534; border: 1px solid rgba(34,197,94,.24); font-size: 12px; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
+149
-25
@@ -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")
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icon.svg" />
|
||||
<title>TG Spam Guard</title>
|
||||
<script type="module" crossorigin src="/static/assets/index-CzBH2oNp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-2RjTuR2b.css">
|
||||
<script type="module" crossorigin src="/static/assets/index-D8A3wB6d.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-BTCfe6yH.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user