修复bot自言自语大小写不敏感问题 + 退出时后台内存回收不阻塞

- monitor.py: 去重管道所有比较改为大小写不敏感(recent_replies时间窗口、_replied_texts、_fuzzy_duplicate、子串包含、长公共子串、_last_bot_reply_content)
- main.py: CTRL+C信号处理将内存回收放到后台线程,不阻塞退出
- gui.py: 无实质修改(仅格式)
This commit is contained in:
youpaishidifu
2026-08-23 21:30:05 +08:00
parent 25b844098f
commit 2a3ecdba14
3 changed files with 142 additions and 13 deletions
+103 -2
View File
@@ -9,6 +9,8 @@ import threading
import os
import re
import time
import gc
import ctypes
import customtkinter as ctk
@@ -20,6 +22,83 @@ from auto_reply import AutoReply
logger = logging.getLogger("msgbot.gui")
def _force_system_memory_cleanup():
"""强制回收Python进程占用的内存归还系统调度。
Windows API:
- EmptyWorkingSet: 清空工作集,强制归还物理内存
- SetProcessWorkingSetSize: 设置工作集大小为最小值,触发系统回收
"""
try:
# 获取当前进程句柄
kernel32 = ctypes.windll.kernel32
psapi = ctypes.windll.psapi
# 获取当前进程句柄
h_process = kernel32.GetCurrentProcess()
# 方法1: EmptyWorkingSet - 清空工作集
try:
psapi.EmptyWorkingSet(h_process)
logger.debug("系统内存清理: EmptyWorkingSet 完成")
except Exception as e:
logger.debug(f"EmptyWorkingSet 失败: {e}")
# 方法2: SetProcessWorkingSetSize - 设置最小工作集
try:
# dwMinimumWorkingSetSize=1, dwMaximumWorkingSetSize=-1 (无限)
# 设置最小值为1字节,最大值为无限,让系统自由回收
kernel32.SetProcessWorkingSetSize(h_process, 1, -1)
logger.debug("系统内存清理: SetProcessWorkingSetSize 完成")
except Exception as e:
logger.debug(f"SetProcessWorkingSetSize 失败: {e}")
# Python GC + torch CUDA 清理
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.synchronize()
logger.debug("系统内存清理: torch.cuda.empty_cache 完成")
except ImportError:
pass # torch 未安装
except Exception as e:
logger.debug(f"torch.cuda.empty_cache 失败: {e}")
# Python GC 强制回收
collected = gc.collect()
logger.debug(f"系统内存清理: gc.collect 回收 {collected} 个对象")
except Exception as e:
logger.debug(f"系统内存清理异常: {e}")
def _kill_easyocr_subprocesses():
"""杀死所有EasyOCR子进程,彻底释放GPU/CPU内存。"""
try:
import psutil
current_pid = os.getpid()
killed = 0
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
# EasyOCR子进程通常以python启动,cmdline包含easyocr
if proc.pid == current_pid:
continue
cmdline = ' '.join(proc.cmdline() or [])
if 'easyocr' in cmdline.lower() or 'ocr_worker' in cmdline.lower():
proc.kill()
killed += 1
logger.debug(f"杀死EasyOCR子进程: PID={proc.pid}")
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
if killed:
logger.debug(f"共杀死 {killed} 个EasyOCR子进程")
except ImportError:
pass
except Exception as e:
logger.debug(f"杀死EasyOCR子进程异常: {e}")
class OCREngineManager:
"""双引擎OCR管理器:EasyOCR + Windows OCR,得分最高者胜出。
@@ -191,18 +270,29 @@ class OCREngineManager:
self._timer_thread.start()
def _release(self):
"""释放OCR引擎资源"""
"""释放OCR引擎资源 + 杀EasyOCR子进程 + 系统内存回收"""
with self._lock:
self._easyocr_reader = None
self._winocr_engine = None
self._initialized = False
self._log("🗑 OCR引擎已释放")
# 杀EasyOCR子进程(彻底释放GPU内存)
_kill_easyocr_subprocesses()
# 系统级内存回收
_force_system_memory_cleanup()
self._log("🧹 系统内存已回收")
def set_monitor_active(self, active: bool):
"""设置消息监控激活状态"""
self._monitor_active = active
if active and self._initialized:
self._last_use_time = time.time() # 激活时重置计时
elif not active and self._initialized:
# 监控停止 → 立即释放OCR(杀子进程+系统内存回收)
self._log("⏸ 监控停止,立即释放OCR内存...")
self._release()
def recognize(self, image) -> list:
"""双引擎并发识别,返回得分最高的结果
@@ -463,6 +553,10 @@ class MsgBotApp(ctk.CTk):
# 自动回复执行队列(转发到主线程,避免 daemon 线程 SetForegroundWindow 失败)
self._reply_queue: queue.Queue[dict] = queue.Queue()
# ⭐ 启动时系统内存清理(强制回收Python进程内存)
self._log("🧹 启动内存清理...")
threading.Thread(target=_force_system_memory_cleanup, daemon=True).start()
# ⭐ OCR引擎管理器(双引擎:EasyOCR + Windows OCR)
self._ocr_manager = OCREngineManager(log_callback=self._log)
# 预初始化OCR引擎(后台线程,不阻塞UI)
@@ -1325,11 +1419,18 @@ class MsgBotApp(ctk.CTk):
# ── 窗口关闭 ──
def _on_close(self):
# ⭐ 关闭窗口同样把 stop 放到后台线程,完成后再关闭,避免主线程冻结。
# ⭐ 关闭窗口:释放OCR + 系统内存清理 → stop → destroy
def _stop_then_close():
try:
# 释放OCR引擎(杀子进程+系统内存回收)
self._ocr_manager._release()
except Exception:
pass
try:
self.monitor.stop()
except Exception:
pass
# 最终系统内存清理
_force_system_memory_cleanup()
self.after(0, self.destroy)
threading.Thread(target=_stop_then_close, daemon=True, name="gui-stop-close").start()
+18 -1
View File
@@ -4,6 +4,7 @@ import logging
import os
import sys
import signal
import threading
# ── Worker 模式检测:PyInstaller 打包后,exe 被调用来运行 worker 脚本时,
# 通过环境变量区分,避免 bootloader 执行 main() 导致闪退 ──
@@ -53,10 +54,26 @@ def main():
app = MsgBotApp()
# Ctrl+C 信号处理:Windows Tkinter mainloop 不响应 KeyboardInterrupt
# 需 signal handler 强制停止监控 + 退出
# 需 signal handler 强制停止监控 + 释放内存 + 退出
def _sigint_handler(sig, frame):
logger.info("收到 Ctrl+C,正在关闭...")
# 后台线程执行内存回收,不阻塞退出
def _cleanup_in_background():
try:
# 释放OCR引擎(杀子进程+系统内存回收)
app._ocr_manager._release()
except Exception:
pass
try:
app.monitor.stop()
except Exception:
pass
# 系统内存清理
from gui import _force_system_memory_cleanup
_force_system_memory_cleanup()
logger.info("后台内存回收完成")
threading.Thread(target=_cleanup_in_background, daemon=True, name="sigint-cleanup").start()
# 立即退出,不等待内存回收完成
app.quit()
sys.exit(0)
+20 -9
View File
@@ -2048,14 +2048,17 @@ class MessageMonitor:
OCR 输出有噪声(每次勖彐碎片、行首数字、尾部截断不同),精确匹配无法去重。
用 difflib.SequenceMatcher 的 quick_ratio() 做快速上界剪枝。
⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写。
"""
if not new_text or len(new_text) < 4:
return False
new_text_lower = new_text.lower()
for existing in seen_set:
if abs(len(existing) - len(new_text)) > max(len(existing), len(new_text)) * 0.4:
# 长度差距超 40%,不可能相似(quick_ratio 上界 = 2*min/max < 阈值)
continue
ratio = difflib.SequenceMatcher(None, new_text, existing).quick_ratio()
# 使用小写文本进行比较
ratio = difflib.SequenceMatcher(None, new_text_lower, existing.lower()).quick_ratio()
if ratio >= threshold:
return True
return False
@@ -8893,15 +8896,17 @@ class MessageMonitor:
# 剥离后继续走后续过滤(不 continue),让干净的用户提问正常进入 new_items
# ⭐ 近5分钟时间窗口查重:如果消息在近5分钟的bot回复中,跳过
# ⭐ v2:拆行匹配——recent_replies存完整多行回复,OCR只读首行
# ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写
if recent_replies:
_in_recent = False
ts_lower = ts.lower()
for _rr in recent_replies:
if ts in _rr:
if ts_lower in _rr.lower():
_in_recent = True
break
# 拆行:OCR读到的可能是bot回复的某一行
for _rr_line in _rr.split('\n'):
if ts.strip() == _rr_line.strip():
if ts_lower == _rr_line.strip().lower():
_in_recent = True
break
if _in_recent:
@@ -8910,7 +8915,8 @@ class MessageMonitor:
logger.debug(f"[{platform}] 时间窗口查重跳过(近5分钟): {ts[:50]}")
continue
# _replied_texts 优先检查(永不裁切,100% 拦住自己的回复)
if ts in replied:
# ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写
if any(ts.lower() == r.lower() for r in replied):
continue
# _replied_texts 模糊匹配:OCR 噪声导致精确匹配失效(长文本尤甚)
if self._fuzzy_duplicate(ts, replied, threshold=0.75):
@@ -8982,13 +8988,15 @@ class MessageMonitor:
# _replied_texts 子串包含:OCR 提取的 md 表格细胞值(如"依赖插件可选/必选")
# 是 bot 回复表的一部分,不是用户提问。长度>5才检查避免误杀短消息。
# OCR 常把 ,转 , 等标点差异,做双向子串检查(原始+归一化标点)
# ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写
if len(ts) > 5:
if any(ts in r for r in replied):
ts_lower = ts.lower()
if any(ts_lower in r.lower() for r in replied):
logger.debug(f"[{platform}] 回复子串包含跳过({len(ts)}c): {ts[:50]}")
continue
# 归一化标点后重试(OCR 噪声:,→, 。→. !→! ?→?)
norm_ts = ts.replace(',', ',').replace('.', '。').replace('!', '!').replace('?', '?')
if norm_ts != ts and any(norm_ts in r for r in replied):
if norm_ts != ts and any(norm_ts.lower() in r.lower() for r in replied):
logger.debug(f"[{platform}] 回复子串包含跳过(归一化,{len(ts)}c): {ts[:50]}")
continue
# _replied_texts 长公共子串检测:OCR 截取的碎片开头/结尾有噪声,精确/子串匹配失效
@@ -8996,16 +9004,18 @@ class MessageMonitor:
# ⭐ Task #1534: 问句豁免——用户提问即使与旧回复有部分匹配也放行
# 例:"PHP默认的那管理后台管理密码是多少,php有2种登陆模式..."
# 该问句与旧回复共享"php有2种登陆模式"子串,但不应被跳过
# ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写
is_question = bool(re.search(r'[??]', ts)) or any(qw in ts for qw in ('吗', '呢', '多少', '怎么', '如何', '哪', '什么'))
if len(ts) > 8 and not is_question:
import difflib
matched_r = False
ts_lower = ts.lower()
# 第一轮:原始文本匹配(阈值 6,OCR 噪声导致连续匹配可能中断)
for r in replied:
if len(r) < 5:
continue
sm = difflib.SequenceMatcher(None, ts, r)
if sm.find_longest_match(0, len(ts), 0, len(r)).size >= 6:
sm = difflib.SequenceMatcher(None, ts_lower, r.lower())
if sm.find_longest_match(0, len(ts_lower), 0, len(r.lower())).size >= 6:
matched_r = True
break
# 第二轮:CJK-only 降阈值到 5(去除标点空格干扰后更稳定)
@@ -9230,7 +9240,8 @@ class MessageMonitor:
_last_reply_lines = [last_reply]
_blocked_by_reply = False
for _lr in _last_reply_lines:
similarity = SequenceMatcher(None, cleaned_item, _lr).ratio()
# ⭐ 大小写不敏感:OCR可能把PHP读成php,比较时忽略大小写
similarity = SequenceMatcher(None, cleaned_item.lower(), _lr.lower()).ratio()
if similarity >= 0.65:
logger.info(f"[{platform}] OCR噪声检测: 与最近bot回复相似度{similarity:.2%},跳过: {cleaned_item[:60]}")
_blocked_by_reply = True