OCR过滤:加多/cmd指令列表碎片检测 + 内容相关性检查防偶然匹配

This commit is contained in:
youpaishidifu
2026-07-29 15:29:42 +08:00
parent 47bd990f76
commit 659f667d74
2 changed files with 136 additions and 97 deletions
+23 -1
View File
@@ -2,6 +2,7 @@
import logging
import threading
import jieba
logger = logging.getLogger("msgbot.autoreply")
@@ -23,7 +24,7 @@ class AutoReply:
logger.info(f"自动回复 {'已开启' if enabled else '已关闭'}")
def set_min_score(self, score: float):
self._min_score = max(0.0, min(1.0, score))
self._min_score = max(0.0, min(10.0, score))
@property
def enabled(self) -> bool:
@@ -44,6 +45,13 @@ class AutoReply:
f"[{platform}] 匹配度 {score:.2f} < 阈值 {self._min_score},跳过回复"
)
return None
# 内容相关性检查:查询词与匹配文档标题至少有一个共同2字+词,防偶然匹配
if not self._has_content_relevance(msg, top_doc):
heading = top_doc.get("heading", "")[:40]
logger.debug(
f"[{platform}] 内容相关性检查失败,跳过: msg={msg[:40]!r} doc_heading={heading!r}"
)
return None
with self._lock:
return self._get_answer(platform, msg, top_doc, score)
@@ -64,6 +72,20 @@ class AutoReply:
return {"platform": platform, "answer": answer, "sender": sender}
@staticmethod
def _has_content_relevance(query: str, doc: dict) -> bool:
"""查询词与匹配文档标题至少有一个共同2字+词,防偶然匹配。
短查询(≤2个2字+词)直接放行,不做严格检查。
"""
q_tokens = set(t for t in jieba.cut(query) if len(t) >= 2)
if len(q_tokens) <= 2:
return True # 短查询不拦截
doc_text = doc.get("heading", "") + " " + doc.get("title", "")
d_tokens = set(t for t in jieba.cut(doc_text) if len(t) >= 2)
if not (q_tokens & d_tokens):
return False
return True
def _get_answer(self, platform: str, msg: str, doc: dict, score: float) -> str or None:
"""提取答案 + 防重复检查。
优先返回 doc['answer'](Q&A 段),兜底使用 doc['content']。
+113 -96
View File
@@ -325,79 +325,49 @@ class MessageMonitor:
# ── 双击 ESC 急停 ──────────────────────────────────────
def _esc_check_loop(self):
"""独立 daemon 线程:底层键盘钩子检测双击 ESC → 强制急停。
"""独立 daemon 线程:RegisterHotKey 全局热键检测双击 ESC → 强制急停。
使用 WH_KEYBOARD_LL 系统级全局钩子,不依赖窗口焦点,
任何进程按 ESC 都能捕获(包括游戏全屏、浏览器、桌面等)。
钩子回调必须运行在安装钩子的线程消息泵里(GetMessage)。
使用 RegisterHotKey 注册全局热键 + GetMessage 消息泵,
不依赖窗口焦点,任何进程按 ESC 都能捕获。
比 WH_KEYBOARD_LL 更可靠(避免 ctypes 64位调用约定问题)。
"""
from ctypes import wintypes
# ── Win32 常量 & 结构体 ──
_WH_KEYBOARD_LL = 13
_WM_KEYDOWN = 0x0100
_VK_ESCAPE = 0x1B
_WM_HOTKEY = 0x0312
_HOTKEY_ID_ESC = 0x4553 # 'ES'
class _KBDLLHOOKSTRUCT(ctypes.Structure):
_fields_ = [
("vkCode", wintypes.DWORD),
("scanCode", wintypes.DWORD),
("flags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),
]
_HOOKPROC = ctypes.CFUNCTYPE(
ctypes.c_long, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM
)
def _ll_hook(nCode, wParam, lParam):
"""底层键盘钩子回调 — 在 _esc_check_loop 线程执行。"""
if nCode >= 0 and wParam == _WM_KEYDOWN:
kb = ctypes.cast(lParam, ctypes.POINTER(_KBDLLHOOKSTRUCT)).contents
if kb.vkCode == _VK_ESCAPE:
now = time.time()
if _ll_hook.last_press > 0 and (now - _ll_hook.last_press) < 0.5:
_ll_hook.last_press = 0.0
logger.warning("⚠️ 双击ESC急停触发!强制终止所有操作...")
self._emergency_stop()
else:
_ll_hook.last_press = now
return ctypes.windll.user32.CallNextHookEx(None, nCode, wParam, lParam)
_ll_hook.last_press = 0.0
# 引用保持(防止 GC 回收回调 → 系统崩溃)
self._esc_hook_proc = _HOOKPROC(_ll_hook)
# ⭐ 步骤1: 先创建线程消息队列(PeekMessage 强制创建)
# WH_KEYBOARD_LL 要求安装线程有消息队列,否则钩子回调无法投递
# ⭐ 步骤1: 创建消息队列
_msg = wintypes.MSG()
ctypes.windll.user32.PeekMessageW(ctypes.byref(_msg), None, 0, 0, 0) # PM_NOREMOVE
ctypes.windll.user32.PeekMessageW(ctypes.byref(_msg), None, 0, 0, 0)
# ⭐ 步骤2: 安装钩子 — hmod 必须为 NULL (0)
# GetModuleHandleW(None) 返回 Python.exe 的模块句柄 ≠ NULL → 钩子静默失败!
hook_handle = ctypes.windll.user32.SetWindowsHookExW(
_WH_KEYBOARD_LL, self._esc_hook_proc, None, 0 # hmod = NULL
)
if not hook_handle:
# ⭐ 步骤2: 注册全局热键(hwnd=NULL → 投递到线程消息队列)
# MOD_NOREPEAT = 0x4000:长按不重复触发,只有按下时触发一次
ok = ctypes.windll.user32.RegisterHotKey(None, _HOTKEY_ID_ESC, 0x4000, _VK_ESCAPE)
if not ok:
err = ctypes.windll.kernel32.GetLastError()
logger.error(
"[急停] 底层键盘钩子安装失败 (err=%d) — ESC 急停不可用", err
)
# err=1409 = ERROR_HOTKEY_ALREADY_REGISTERED,其他热键已占 ESC
logger.error("[急停] RegisterHotKey 失败 (err=%d) — ESC 急停不可用", err)
return
logger.info("[急停] 底层键盘钩子安装成功,ESC 急停已激活")
logger.info("[急停] 全局热键注册成功,双击 ESC 急停已激活")
# ⭐ 步骤3: 消息泵 — WH_KEYBOARD_LL 通过 GetMessage 分发钩子回调
# ⭐ 步骤3: 消息泵 — 检测双击 ESC
last_esc_time = 0.0
while self._running:
ret = ctypes.windll.user32.GetMessageW(ctypes.byref(_msg), None, 0, 0)
if ret <= 0:
break # WM_QUIT 或出错
ctypes.windll.user32.TranslateMessage(ctypes.byref(_msg))
ctypes.windll.user32.DispatchMessageW(ctypes.byref(_msg))
break
if _msg.message == _WM_HOTKEY and _msg.wParam == _HOTKEY_ID_ESC:
now = time.time()
if last_esc_time > 0 and (now - last_esc_time) < 0.5:
last_esc_time = 0.0
logger.warning("⚠️ 双击ESC急停触发!强制终止所有操作...")
self._emergency_stop()
else:
last_esc_time = now
# 线程退出 → 卸载钩子
ctypes.windll.user32.UnhookWindowsHookEx(hook_handle)
# 线程退出 → 注销热键
ctypes.windll.user32.UnregisterHotKey(None, _HOTKEY_ID_ESC)
def _emergency_stop(self):
"""强制停止监控+杀死 EasyOCR+清空发送锁。"""
@@ -1417,55 +1387,63 @@ class MessageMonitor:
logger.debug(f"[ocr] stage[{i}] split -> {len(parts)} parts: "
f"{[p[:60] for p in parts if p.strip()]}")
buf = ''
last_ts = None # 跟踪上一时间戳,用于判断消息块年龄
for part in parts:
if re.match(r'^\d{1,2}:\d{1,2}', part):
if buf.strip():
if self._is_markdown_document(buf.strip()):
extracted = self._extract_user_text_from_md_fragment(buf.strip())
for e in extracted:
if e:
# 再检:提取后的文本不应是纯 md 语法
if self._is_markdown_document(e):
logger.debug(f"[ocr] stage[{i}] md-only after extraction: {e[:60]!r}")
continue
expanded.append(e)
if not extracted:
logger.debug(f"[ocr] stage[{i}] markdown fragment skipped: {buf.strip()[:80]!r}")
if last_ts and self._is_timestamp_old(last_ts, 300):
logger.debug(f"[ocr] stage[{i}] 时间戳{last_ts}过旧({300}s),跳过: {buf.strip()[:60]!r}")
else:
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', buf.strip())
if msg:
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
if self._is_markdown_document(buf.strip()):
extracted = self._extract_user_text_from_md_fragment(buf.strip())
for e in extracted:
if e:
# 再检:提取后的文本不应是纯 md 语法
if self._is_markdown_document(e):
logger.debug(f"[ocr] stage[{i}] md-only after extraction: {e[:60]!r}")
continue
expanded.append(e)
if not extracted:
logger.debug(f"[ocr] stage[{i}] markdown fragment skipped: {buf.strip()[:80]!r}")
else:
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', buf.strip())
if msg:
if self._is_reply_fragment(msg):
logger.debug(f"[ocr] stage[{i}] 回复残片跳过: {msg[:60]!r}")
else:
expanded.append(msg)
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
if msg:
if self._is_reply_fragment(msg):
logger.debug(f"[ocr] stage[{i}] 回复残片跳过: {msg[:60]!r}")
else:
expanded.append(msg)
buf = ''
last_ts = part
else:
buf += part
if buf.strip():
if self._is_markdown_document(buf.strip()):
extracted = self._extract_user_text_from_md_fragment(buf.strip())
for e in extracted:
if e:
if self._is_markdown_document(e):
logger.debug(f"[ocr] stage[{i}] md-only after extraction: {e[:60]!r}")
continue
if self._is_reply_fragment(e):
logger.debug(f"[ocr] stage[{i}] md提取回复残片跳过: {e[:60]!r}")
continue
expanded.append(e)
if not extracted:
logger.debug(f"[ocr] stage[{i}] markdown fragment skipped: {buf.strip()[:80]!r}")
if last_ts and self._is_timestamp_old(last_ts, 300):
logger.debug(f"[ocr] stage[{i}] 时间戳{last_ts}过旧({300}s),跳过尾部: {buf.strip()[:60]!r}")
else:
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', buf.strip())
if msg:
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
if self._is_markdown_document(buf.strip()):
extracted = self._extract_user_text_from_md_fragment(buf.strip())
for e in extracted:
if e:
if self._is_markdown_document(e):
logger.debug(f"[ocr] stage[{i}] md-only after extraction: {e[:60]!r}")
continue
if self._is_reply_fragment(e):
logger.debug(f"[ocr] stage[{i}] md提取回复残片跳过: {e[:60]!r}")
continue
expanded.append(e)
if not extracted:
logger.debug(f"[ocr] stage[{i}] markdown fragment skipped: {buf.strip()[:80]!r}")
else:
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', buf.strip())
if msg:
if self._is_reply_fragment(msg):
logger.debug(f"[ocr] stage[{i}] 回复残片跳过: {msg[:60]!r}")
else:
expanded.append(msg)
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
if msg:
if self._is_reply_fragment(msg):
logger.debug(f"[ocr] stage[{i}] 回复残片跳过: {msg[:60]!r}")
else:
expanded.append(msg)
# —— 逐行过滤 ——
ocr_lines = expanded
@@ -1620,6 +1598,14 @@ class MessageMonitor:
logger.debug(f"[{platform}] OCR 过滤: 管道符碎片(绿色气泡) line={line!r}")
continue
# —— 游戏指令列表碎片:重复 /cmd 模式 ——
# OCR 截到命令帮助文本如 "/sdfl主指令 /sdfl reload /sdfl update"
# 正常用户消息极少含 ≥2 个独立 /command
slash_cmds = re.findall(r'(?:^|\s)/(\w{2,})', line)
if len(slash_cmds) >= 2:
logger.debug(f"[{platform}] OCR 过滤: 指令列表碎片(多/cmd={slash_cmds}) line={line!r}")
continue
# —— JSON/YAML 碎片兜底:OCR 截屏混入配置面板 ——
# 行首是 "key": value 或 key: value(YAML)→ 配置面板碎片
if re.match(r'^["\']?[a-zA-Z_][\w]*["\']?\s*:\s*["\d\[\{tfn]', line):
@@ -2170,6 +2156,37 @@ class MessageMonitor:
return True
return False
@staticmethod
def _is_timestamp_old(ts_str: str, max_age: int = 300) -> bool:
"""检查 HH:MM 或 HH:MM:SS 时间戳是否超过 max_age 秒。
处理跨午夜边界:如果 diff > 12h 说明是昨天的时间戳。
返回 True = 时间戳太老,应跳过。
"""
ts = ts_str.strip()
if not ts:
return False
m = re.match(r'^(\d{1,2}):(\d{2})(?::(\d{2}))?$', ts)
if not m:
return False
h, mi, s = int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)
if h > 23 or mi > 59 or s > 59:
return False
now = datetime.now()
try:
parsed = now.replace(hour=h, minute=mi, second=s, microsecond=0)
except ValueError:
return False
diff = (now - parsed).total_seconds()
# 跨午夜: diff > 12h 说明时间戳来自昨天
if diff > 43200:
parsed = parsed.replace(day=parsed.day - 1)
diff = (now - parsed).total_seconds()
# 未来时间戳: diff < -12h 说明是明天(当前 23:55,时间戳 00:05)
if diff < -43200:
parsed = parsed.replace(day=parsed.day + 1)
diff = (now - parsed).total_seconds()
return diff > max_age
@staticmethod
def _is_system_message(text: str) -> bool:
"""过滤机器人/系统消息,不作为用户消息处理。"""