修复bot自言自语大小写不敏感问题 + 退出时后台内存回收不阻塞
- monitor.py: 去重管道所有比较改为大小写不敏感(recent_replies时间窗口、_replied_texts、_fuzzy_duplicate、子串包含、长公共子串、_last_bot_reply_content) - main.py: CTRL+C信号处理将内存回收放到后台线程,不阻塞退出 - gui.py: 无实质修改(仅格式)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user