Files
msgbot/gui.py
T
youpaishidifu 9fb2634609 fix: 修复'邀请活动持续到几月几号'查询返回错误位置
- non_core_kw添加'几月','几号','几日','什么时候','何时'等时间疑问词过滤
- SYNONYMS添加'持续到'→'持续时间','到几月'→'持续时间'等同义词映射
- knowledge_base.py添加持续时间意图加权:heading含'持续时间'的section×4.0
- 根因:'几月'/'几号'被当作核心关键词参与匹配,root heading含'邀请'+'活动'
  获得heading_hits加分但body为空,压过了真正含答案的'### 持续时间'section
- 验证:修复后'### 持续时间'section得分189.82(第1),root heading仅27.13(第2)
2026-09-20 10:30:40 +08:00

1548 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""GUI — customtkinter 界面,开关 + 知识库路径 + 日志 """
import tkinter as tk
from tkinter import filedialog, messagebox
from typing import Optional
import logging
import queue
import threading
import os
import re
import time
import gc
import ctypes
import customtkinter as ctk
from config import load_config, save_config
from monitor import MessageMonitor
from knowledge_base import KnowledgeBase
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 清理
# ⭐ Task #1630: 修复内存687M——不主动 import torch(EasyOCR 已子进程化)
# import torch 会加载 ~300MB DLL;主进程无 CUDA 状态,仅在 torch 已加载时才清理
try:
import sys as _sys
if 'torch' in _sys.modules:
_torch = _sys.modules['torch']
if _torch.cuda.is_available():
_torch.cuda.empty_cache()
_torch.cuda.synchronize()
logger.debug("系统内存清理: torch.cuda.empty_cache 完成")
# else: 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,得分最高者胜出。
功能:
1. 预初始化两套引擎
2. 双引擎并发识别,取得分最高者的结果
3. 1分钟超时释放机制:无操作则杀进程释放内存
4. 支持消息监控调度时直接使用预热的引擎
"""
def __init__(self, log_callback=None):
self._log = log_callback or (lambda msg: None)
self._easyocr_reader = None
self._winocr_engine = None
self._initialized = False
self._lock = threading.Lock()
self._last_use_time = 0
self._timeout_seconds = 60 # 1分钟超时
self._consecutive_success = 0
self._consecutive_fail = 0
self._monitor_active = False # 消息监控是否激活
self._timer_thread = None
self._timer_stop = threading.Event()
@staticmethod
def _probe_cuda_safety(timeout: int = 30) -> bool:
"""子进程 CUDA 安全探针:在子进程里做 CUDA 显存分配。
返回 True = CUDA 可用且安全,False = 不可用或崩溃。
崩溃只杀子进程,不影响主进程。"""
import subprocess, sys
_probe_code = (
"import torch; "
"assert torch.cuda.is_available(), 'CUDA not available'; "
"t = torch.zeros(1, device='cuda'); "
"del t; "
"torch.cuda.empty_cache(); "
"print('CUDA_OK')"
)
try:
_py = sys.executable
_r = subprocess.run(
[_py, "-c", _probe_code],
capture_output=True, text=True, timeout=timeout,
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)
)
ok = _r.returncode == 0 and "CUDA_OK" in _r.stdout
if not ok:
# 子进程崩溃或超时
_stderr_snip = (_r.stderr or "")[:200]
logging.getLogger(__name__).warning(
f"[gui] CUDA 子进程探针失败(rc={_r.returncode}): {_stderr_snip}"
)
return ok
except subprocess.TimeoutExpired:
logging.getLogger(__name__).warning("[gui] CUDA 子进程探针超时")
return False
except Exception as e:
logging.getLogger(__name__).warning(f"[gui] CUDA 子进程探针异常: {e}")
return False
def _check_memory_for_gpu(self) -> bool:
"""检查系统内存 + CUDA 安全性,决定是否可用GPU模式。"""
try:
import torch
if not torch.cuda.is_available():
self._log(" ℹ CUDA不可用,将使用CPU模式")
return False
import psutil
mem = psutil.virtual_memory()
available_gb = mem.available / (1024**3)
total_gb = mem.total / (1024**3)
# 页面文件信息
try:
swap = psutil.swap_memory()
swap_total_gb = swap.total / (1024**3)
swap_avail_gb = swap.free / (1024**3)
swap_pct = swap.percent
except Exception:
swap_total_gb = swap_avail_gb = swap_pct = 0
self._log(f" 📊 内存: {available_gb:.1f}GB可用/总{total_gb:.1f}GB({mem.percent}%), "
f"页面文件: {swap_avail_gb:.1f}GB可用({swap_pct}%)")
# 可用内存 < 2GB → 降级CPU
if available_gb < 2.0:
self._log(f" ⚠ 可用内存不足({available_gb:.1f}GB < 2GB),降级CPU模式")
return False
# 页面文件 > 95% → 冷却
if swap_pct > 95.0:
self._log(f" ⚠ 页面文件不足({swap_pct}% > 95%),降级CPU模式")
return False
# 系统提交量 > 95% → 降级
phys_used_gb = total_gb - available_gb
page_used_gb = swap_total_gb - swap_avail_gb
commit_total = total_gb + swap_total_gb
commit_pct = ((phys_used_gb + page_used_gb) / commit_total * 100) if commit_total > 0 else 0
if commit_pct > 95.0:
self._log(f" ⚠ 系统提交量过高({commit_pct:.0f}% > 95%),降级CPU模式")
return False
# ⭐ Task #1601: CUDA 子进程安全探针
# torch.cuda.is_available() 通过不代表 CUDA 安全——
# 显存分配/access violation 在 native 层崩溃,Python try/except 捕获不到。
# 子进程探针做真实 CUDA tensor 分配,崩溃只杀子进程。
self._log(" 🔍 运行 CUDA 安全探针...")
if not self._probe_cuda_safety(timeout=30):
self._log(" ⚠ CUDA 探针失败(驱动/显存异常),降级CPU模式")
return False
self._log(" ✅ CUDA 探针通过")
self._log(f" ✅ 内存充足,启用GPU模式")
return True
except Exception as e:
self._log(f" ⚠ 内存检查异常: {e},降级CPU模式")
return False
def initialize(self):
"""预初始化OCR引擎(仅 Windows OCR)。
⭐ Task #1630: 修复内存687M——不在主进程中 import easyocr/torch!
EasyOCR 已完全子进程化(easyocr_worker.py),主进程 import easyocr 会连带
加载 torch DLL (~300MB),且 Python 无法卸载已加载的 DLL,导致内存永远不释放。
主进程仅使用 Windows OCR;EasyOCR 通过 monitor.py 的子进程隔离方案使用。
"""
if self._initialized:
return True
with self._lock:
if self._initialized:
return True
self._log("🔧 初始化OCR引擎...")
# ⭐ EasyOCR 不再在主进程中初始化——子进程隔离方案已接管
self._easyocr_reader = None
# 初始化Windows OCR
try:
from winrt.windows.media.ocr import OcrEngine
from winrt.windows.globalization import Language
self._winocr_engine = OcrEngine.try_create_from_user_profile_languages()
if self._winocr_engine:
self._log(" ✅ Windows OCR就绪")
else:
self._log(" ⚠ Windows OCR不可用")
except Exception as e:
self._log(f" ⚠ Windows OCR初始化失败: {e}")
self._winocr_engine = None
if not self._winocr_engine:
self._log("❌ 无可用OCR引擎")
return False
self._initialized = True
self._use_gpu = False
self._last_use_time = time.time()
self._start_timeout_timer()
self._log("✅ OCR引擎初始化完成")
return True
def _start_timeout_timer(self):
"""启动超时计时器线程"""
if self._timer_thread and self._timer_thread.is_alive():
return
self._timer_stop.clear()
def _timer_loop():
while not self._timer_stop.is_set():
self._timer_stop.wait(10) # 每10秒检查一次
if self._timer_stop.is_set():
break
# 消息监控激活中,不释放
if self._monitor_active:
self._last_use_time = time.time() # 重置计时
continue
# 有新截图动作,不释放
if time.time() - self._last_use_time < self._timeout_seconds:
continue
# 超时,释放资源
self._log("⏰ OCR引擎1分钟无操作,释放内存...")
self._release()
break
self._timer_thread = threading.Thread(target=_timer_loop, daemon=True)
self._timer_thread.start()
def _release(self):
"""释放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:
"""双引擎并发识别,返回得分最高的结果
Args:
image: PIL Image对象
Returns:
识别到的文本列表
"""
if not self._initialized:
self.initialize()
if not self._initialized:
return []
self._last_use_time = time.time()
results_easyocr = []
results_winocr = []
errors = []
# 并发识别
def _run_easyocr():
nonlocal results_easyocr
try:
if self._easyocr_reader:
results_easyocr = self._easyocr_reader.readtext(image, detail=1)
except Exception as e:
errors.append(f"EasyOCR: {e}")
def _run_winocr():
nonlocal results_winocr
try:
if self._winocr_engine:
results_winocr = self._recognize_winocr(image)
except Exception as e:
errors.append(f"WinOCR: {e}")
threads = []
if self._easyocr_reader:
threads.append(threading.Thread(target=_run_easyocr, daemon=True))
if self._winocr_engine:
threads.append(threading.Thread(target=_run_winocr, daemon=True))
for t in threads:
t.start()
for t in threads:
t.join(timeout=30) # 30秒超时
# 选择得分最高的结果
best_texts = self._select_best_result(results_easyocr, results_winocr)
if best_texts:
self._consecutive_success += 1
self._consecutive_fail = 0
else:
self._consecutive_success = 0
self._consecutive_fail += 1
if errors:
self._log(f"⚠ OCR识别失败: {'; '.join(errors)}")
return best_texts
def _recognize_winocr(self, image) -> list:
"""Windows OCR识别,返回带得分的结果"""
import asyncio
import io
import tempfile
try:
# 保存为临时文件
tmp_path = os.path.join(tempfile.gettempdir(), '_ocr_nick_temp.png')
image.save(tmp_path)
async def _do_ocr():
from winrt.windows.media.ocr import OcrEngine
from winrt.windows.graphics.imaging import BitmapDecoder
from winrt.windows.storage import StorageFile
file = await StorageFile.get_file_from_path_async(tmp_path)
stream = await file.open_read_async()
decoder = await BitmapDecoder.create_async(stream)
bitmap = await decoder.get_software_bitmap_async()
result = await self._winocr_engine.recognize_async(bitmap)
return result.text
text = asyncio.run(_do_ocr())
# 按行分割,每行视为一个结果(得分0.8作为默认)
lines = [line.strip() for line in text.split('\n') if line.strip()]
return [(line, 0.8) for line in lines]
except Exception as e:
logger.error(f"Windows OCR失败: {e}")
return []
def _select_best_result(self, easyocr_results, winocr_results) -> list:
"""选择得分最高的结果
EasyOCR返回: [(bbox, text, confidence), ...]
WinOCR返回: [(text, confidence), ...]
"""
# 统一格式为 [(text, confidence), ...]
eo = [(r[1], r[2]) for r in easyocr_results if len(r) >= 3]
wo = winocr_results # 已经是 [(text, confidence)]
all_results = eo + wo
if not all_results:
return []
# 按置信度排序,取最高的
all_results.sort(key=lambda x: x[1], reverse=True)
# 返回所有结果的文本(去重)
seen = set()
texts = []
for text, conf in all_results:
text = text.strip()
if text and text not in seen:
seen.add(text)
texts.append(text)
return texts
def get_status(self) -> dict:
"""获取引擎状态"""
return {
'initialized': self._initialized,
'easyocr': self._easyocr_reader is not None,
'winocr': self._winocr_engine is not None,
'gpu_mode': getattr(self, '_use_gpu', False),
'consecutive_success': self._consecutive_success,
'consecutive_fail': self._consecutive_fail,
'monitor_active': self._monitor_active,
}
def _clean_query_for_kb(msg: str) -> str:
"""清理查询文本中的用户名/头衔/时间戳等噪音,提升KB搜索精度。"""
if not msg:
return msg
t = msg.strip()
# 1. 移除QQ群头衔格式: [头衔]用户名 / 【头衔】用户名
t = re.sub(r'[\[【][^】\]]+[】\]]\s*(?:[A-Za-z0-9_\u4e00-\u9fff]{2,30})\s*[,,]?\s*', '', t)
# 2. 移除@提及: @ASCII昵称 (仅后接空白/串尾时移除)
# ⚠ 注意:不能匹配 @中文 格式(如@问问...),否则会把整个问句误删
# 修复:步骤2只清理ASCII昵称,中文昵称由步骤4a统一处理
t = re.sub(r'@[A-Za-z0-9_]{1,20}(?=\s|$)', '', t)
# 3. 移除时间戳: HH:MM:SS / HH:MM / 日期格式
t = re.sub(r'\d{1,2}[::]\d{2}(?:[::]\d{2})?\s*', '', t)
t = re.sub(r'\d{4}[//\-]\d{1,2}[//\-]\d{1,2}\s*', '', t)
# 4. 移除已知昵称列表
try:
cfg = load_config()
nicknames = cfg.get('qq_nicknames', [])
for nick in nicknames:
if nick in t:
t = t.replace(nick, '')
except Exception:
pass
# 4a. 清理孤立@前缀:移除OCR残留的@标记(无论后面跟中文还是ASCII)
# 例:@问问... → 问问... / @用户ABC... → 用户ABC...
# 但如果是真实@提及(如@小明 你好),步骤2已处理,这里不再重复
t = re.sub(r'^@\s*', '', t)
# 5. 清理残留标点/空格
t = re.sub(r'[,,、\s]+', ' ', t).strip(',,、 \t')
# 6. ⭐ 清理OCR污染:同屏捕获时,用户问句后可能拼接其他消息的碎片
# 如 "个登录插件是在哪个地方下载? 4001234S67 ]插件下载:见github..."
# 检测模式:6位以上大写数字/字母ID + ] + 插件下载/下载地址 等bot回复关键词
# 如果检测到,截断到第一个完整问句结束(?或?)
_ocr_pollution = re.compile(
r'[??]\s*' # 问句结束符后可能有空格
r'[\s]*' # 更多空格
r'[A-Z0-9]{6,}' # 订单号/ID(6位以上大写数字字母)
r'\s*]' # 跟随的]符号
r'\s*插件下载' # bot回复内容关键词
r'.*' # 其余内容
)
if _ocr_pollution.search(t):
# 找到第一个完整问句并截断
q_end = max(
t.find('?') if '?' in t else -1,
t.find('?') if '?' in t else -1
)
if q_end >= 0:
t = t[:q_end + 1]
logger.debug(f"[kb] OCR污染清理: {msg[:50]!r} -> {t!r}")
# 7. ⭐ 清理bot回复碎片残留(2026-08-17 修复)
# 用户发送新消息时,OCR可能同时捕获到bot之前的回复
# 如 "那个登录插件去哪下载[官方下载" 或 "插件下载:见github..."
# 检测模式:问句结束后紧跟[官方下载]/[bbsmc分流]/插件下载等bot回复特征
# 如果检测到,截断到问句结束
_bot_reply_fragment = re.compile(
r'[??]\s*' # 问句结束符
r'[\[((]?\s*' # 可选的方括号/圆括号
r'(官方下载|bbsmc分流|插件下载|下载地址|下载地址)' # bot回复关键词
r'.*' # 其余内容
)
if _bot_reply_fragment.search(t):
# 找到第一个问句结束符并截断
q_end = max(
t.find('?') if '?' in t else -1,
t.find('?') if '?' in t else -1
)
if q_end >= 0:
t = t[:q_end + 1]
logger.debug(f"[kb] Bot回复碎片清理: {msg[:50]!r} -> {t!r}")
elif '[官方下载' in t or '[bbsmc分流' in t:
# 没有问号,但包含bot回复的Markdown链接格式
# 如 "那个登录插件去哪下载[官方下载"
# 尝试在"["之前截断
cut_pos = min(
t.find('[官方下载') if '[官方下载' in t else len(t),
t.find('[bbsmc分流') if '[bbsmc分流' in t else len(t),
t.find('插件下载:') if '插件下载:' in t else len(t),
)
if cut_pos < len(t) and cut_pos > 5: # 确保不是太短
t = t[:cut_pos].rstrip(',,、 ')
logger.debug(f"[kb] Bot回复Markdown清理: {msg[:50]!r} -> {t!r}")
return t
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("green")
class MsgBotApp(ctk.CTk):
"""主窗口"""
def __init__(self):
super().__init__()
self.title("消息问答助手 v1.0")
self.geometry("720x560")
self.minsize(640, 480)
# 配置
self.cfg = load_config()
# 知识库
self.kb = KnowledgeBase(self.cfg.get("knowledge_base_path", ""))
# 监控器
self.monitor = MessageMonitor(on_message=self._on_new_message)
self.monitor.set_poll_interval(self.cfg.get("poll_interval_seconds", 2.0))
self.monitor.set_enabled_platforms(
wechat=self.cfg.get("wechat_enabled", True),
qq=self.cfg.get("qq_enabled", False),
)
self.monitor.set_emergency_callback(self._on_emergency_stop)
# 自动回复
self.ar = AutoReply(self.monitor)
# 日志队列(线程安全)— 必须在OCR管理器之前创建
self._log_queue: queue.Queue[str] = queue.Queue()
# 自动回复执行队列(转发到主线程,避免 daemon 线程 SetForegroundWindow 失败)
self._reply_queue: queue.Queue[dict] = queue.Queue()
# ⭐ monitor→GUI 日志回调:EasyOCR 子进程初始化等关键状态转发到 GUI 日志
self.monitor.set_log_callback(self._log)
# ⭐ 启动时系统内存清理(强制回收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)
threading.Thread(target=self._ocr_manager.initialize, daemon=True).start()
# ⭐ 跨平台消息处理锁:防止同一消息被QQ和微信同时处理
self._processing_lock = threading.Lock()
self._processing_messages = set() # 正在处理的消息hash集合
self.ar.set_enabled(self.cfg.get("auto_reply_enabled", False))
self.ar.set_min_score(self.cfg.get("auto_reply_min_score", 0.35))
# ⭐ 防止 _update_stop_ui() 中 deselect() 触发 _toggle_monitor() 回调的标志
self._suppress_toggle_callback = False
# ⭐ 防止停止/启动死循环:stop过程中禁止toggle重新触发start
self._stop_in_progress = False
self._build_ui()
# 定时从队列取日志刷新
self.after(200, self._flush_log)
# 定时处理自动回复队列(主线程才能可靠 SetForegroundWindow)
self.after(200, self._flush_reply_queue)
# 窗口关闭
self.protocol("WM_DELETE_WINDOW", self._on_close)
# ── UI 构建 ──────────────────────────────
def _build_ui(self):
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(2, weight=1)
# ── 顶部:开关 + 状态 ──
header = ctk.CTkFrame(self)
header.grid(row=0, column=0, padx=12, pady=(12, 4), sticky="ew")
header.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(header, text="🤖 消息问答助手", font=("Microsoft YaHei", 18, "bold")).grid(
row=0, column=0, padx=(8, 16), pady=8, sticky="w"
)
self._status_label = ctk.CTkLabel(header, text="⏸ 已暂停", font=("Microsoft YaHei", 13))
self._status_label.grid(row=0, column=1, padx=4, pady=8, sticky="w")
self._toggle_btn = ctk.CTkSwitch(
header,
text="",
command=self._toggle_monitor,
onvalue=True,
offvalue=False,
)
self._toggle_btn.grid(row=0, column=2, padx=(4, 8), pady=8, sticky="e")
ctk.CTkLabel(header, text="启用监控", font=("Microsoft YaHei", 12)).grid(
row=0, column=3, padx=(0, 12), pady=8, sticky="w"
)
# ── 设置区域 ──
settings = ctk.CTkFrame(self)
settings.grid(row=1, column=0, padx=12, pady=4, sticky="ew")
settings.grid_columnconfigure(1, weight=1)
row = 0
# 知识库路径
ctk.CTkLabel(settings, text="📂 知识库路径:", font=("Microsoft YaHei", 12)).grid(
row=row, column=0, padx=(8, 4), pady=6, sticky="w"
)
self._kb_path_var = ctk.StringVar(value=self.cfg.get("knowledge_base_path", ""))
self._kb_entry = ctk.CTkEntry(
settings, textvariable=self._kb_path_var, placeholder_text="选择知识库文件夹(.md / .txt)"
)
self._kb_entry.grid(row=row, column=1, padx=4, pady=6, sticky="ew")
self._kb_browse_btn = ctk.CTkButton(
settings, text="浏览", width=64, command=self._browse_kb
)
self._kb_browse_btn.grid(row=row, column=2, padx=(4, 8), pady=6, sticky="e")
self._kb_status = ctk.CTkLabel(settings, text="未加载", font=("Microsoft YaHei", 11))
self._kb_status.grid(row=row, column=3, padx=4, pady=6, sticky="w")
row += 1
# 平台选择
ctk.CTkLabel(settings, text="📱 监控平台:", font=("Microsoft YaHei", 12)).grid(
row=row, column=0, padx=(8, 4), pady=6, sticky="w"
)
self._wx_var = ctk.BooleanVar(value=self.cfg.get("wechat_enabled", True))
self._wx_cb = ctk.CTkCheckBox(settings, text="微信", variable=self._wx_var,
command=self._save_plat_settings)
self._wx_cb.grid(row=row, column=1, padx=4, pady=6, sticky="w")
self._qq_var = ctk.BooleanVar(value=self.cfg.get("qq_enabled", False))
self._qq_cb = ctk.CTkCheckBox(settings, text="QQ", variable=self._qq_var,
command=self._save_plat_settings)
self._qq_cb.grid(row=row, column=1, padx=(90, 4), pady=6, sticky="w")
row += 1
# 轮询间隔
ctk.CTkLabel(settings, text="⏱ 轮询间隔(秒):", font=("Microsoft YaHei", 12)).grid(
row=row, column=0, padx=(8, 4), pady=6, sticky="w"
)
self._interval_var = ctk.DoubleVar(value=self.cfg.get("poll_interval_seconds", 2.0))
self._interval_slider = ctk.CTkSlider(
settings, from_=0.5, to=10.0, number_of_steps=19,
variable=self._interval_var, command=self._save_interval
)
self._interval_slider.grid(row=row, column=1, padx=4, pady=6, sticky="ew")
self._interval_label = ctk.CTkLabel(
settings, text=f"{self._interval_var.get():.1f}s", width=40
)
self._interval_label.grid(row=row, column=2, padx=(4, 8), pady=6, sticky="w")
# 应用按钮
self._apply_btn = ctk.CTkButton(
settings, text="应用设置", width=80, command=self._apply_settings
)
self._apply_btn.grid(row=row, column=3, padx=4, pady=6, sticky="e")
row += 1
# ── 自动回复 ──
ctk.CTkLabel(settings, text="🤖 自动回复:", font=("Microsoft YaHei", 12)).grid(
row=row, column=0, padx=(8, 4), pady=6, sticky="w"
)
ar_frame = ctk.CTkFrame(settings, fg_color="transparent")
ar_frame.grid(row=row, column=1, columnspan=2, padx=4, pady=6, sticky="ew")
ar_frame.grid_columnconfigure(2, weight=1)
self._ar_var = ctk.BooleanVar(value=self.cfg.get("auto_reply_enabled", False))
self._ar_toggle = ctk.CTkSwitch(
ar_frame, text="", variable=self._ar_var,
command=self._save_ar_settings,
onvalue=True, offvalue=False,
)
self._ar_toggle.grid(row=0, column=0, padx=(0, 6), pady=0, sticky="w")
ctk.CTkLabel(ar_frame, text="阈值:", font=("Microsoft YaHei", 12)).grid(
row=0, column=1, padx=(0, 4), pady=0, sticky="w"
)
self._ar_threshold_var = ctk.DoubleVar(
value=self.cfg.get("auto_reply_min_score", 0.35)
)
self._ar_threshold_slider = ctk.CTkSlider(
ar_frame, from_=0.10, to=0.90, number_of_steps=16,
variable=self._ar_threshold_var, command=self._save_ar_threshold,
)
self._ar_threshold_slider.grid(row=0, column=2, padx=2, pady=0, sticky="ew")
self._ar_threshold_label = ctk.CTkLabel(
ar_frame, text=f"{self._ar_threshold_var.get():.2f}", width=36
)
self._ar_threshold_label.grid(row=0, column=3, padx=(6, 0), pady=0, sticky="w")
row += 1
# ── QQ昵称管理 ──
nick_label = ctk.CTkLabel(settings, text="👥 QQ昵称:", font=("Microsoft YaHei", 12))
nick_label.grid(row=row, column=0, padx=(8, 4), pady=6, sticky="nw")
nick_frame = ctk.CTkFrame(settings, fg_color="transparent")
nick_frame.grid(row=row, column=1, columnspan=2, padx=4, pady=6, sticky="ew")
nick_frame.grid_columnconfigure(0, weight=1)
# 昵称列表显示
self._nick_list_var = ctk.StringVar(value="")
self._nick_entry = ctk.CTkEntry(
nick_frame, placeholder_text="输入新昵称后回车添加"
)
self._nick_entry.grid(row=0, column=0, padx=(0, 4), pady=2, sticky="ew")
self._nick_entry.bind("<Return>", self._add_nickname)
self._add_nick_btn = ctk.CTkButton(
nick_frame, text="添加", width=56, command=self._add_nickname
)
self._add_nick_btn.grid(row=0, column=1, padx=(0, 4), pady=2, sticky="e")
# 昵称列表
self._nick_listbox = ctk.CTkTextbox(nick_frame, height=60, font=("Microsoft YaHei", 11))
self._nick_listbox.grid(row=1, column=0, columnspan=2, padx=0, pady=4, sticky="ew")
# 操作按钮行
nick_btn_frame = ctk.CTkFrame(nick_frame, fg_color="transparent")
nick_btn_frame.grid(row=2, column=0, columnspan=2, pady=2, sticky="ew")
self._del_nick_btn = ctk.CTkButton(
nick_btn_frame, text="删除选中", width=72,
command=self._delete_selected_nickname
)
self._del_nick_btn.pack(side="left", padx=(0, 4))
self._refresh_nick_btn = ctk.CTkButton(
nick_btn_frame, text="刷新", width=56,
command=self._refresh_nick_list
)
self._refresh_nick_btn.pack(side="left", padx=(0, 4))
self._ocr_nick_btn = ctk.CTkButton(
nick_btn_frame, text="📸 从截图识别", width=100,
command=self._ocr_nicknames_from_screenshot
)
self._ocr_nick_btn.pack(side="left")
# 初始加载昵称列表
self._refresh_nick_list()
# ── 日志区域 ──
log_frame = ctk.CTkFrame(self)
log_frame.grid(row=2, column=0, padx=12, pady=(4, 12), sticky="nsew")
log_frame.grid_rowconfigure(0, weight=1)
log_frame.grid_columnconfigure(0, weight=1)
self._log_text = ctk.CTkTextbox(log_frame, font=("Consolas", 11), wrap="word")
self._log_text.grid(row=0, column=0, padx=4, pady=4, sticky="nsew")
self._log_text.configure(state="disabled")
btn_frame = ctk.CTkFrame(log_frame, fg_color="transparent")
btn_frame.grid(row=1, column=0, pady=4, sticky="ew")
ctk.CTkButton(btn_frame, text="清空日志", width=80,
command=self._clear_log).pack(side="right", padx=4)
# 初始更新知识库状态
self._update_kb_status()
# ── 回调 ──────────────────────────────
def _toggle_monitor(self):
# ⭐ 防止 _update_stop_ui() 中 deselect() 触发的回调
if self._suppress_toggle_callback:
return
# ⭐ 防止 stop 过程中被 re-toggle 导致死循环
if self._stop_in_progress:
# ⭐ 安全检查:如果 monitor 已经停止(_running=False),说明 stop() 可能挂起,
# 强制重置 _stop_in_progress,允许用户重新启动。
if not self.monitor.is_running:
logger.warning("[gui] _stop_in_progress=True 但 monitor 已停止,强制重置")
self._stop_in_progress = False
else:
return
enabled = self._toggle_btn.get()
if enabled:
# 检查知识库是否已加载
if not self.kb.is_ready:
self._log("⚠ 知识库未加载,请先设置知识库路径")
# 设置标志防止 deselect() 触发回调
self._suppress_toggle_callback = True
try:
self._toggle_btn.deselect()
except Exception:
try:
self._toggle_btn._variable.set(False)
except Exception:
pass
finally:
self.after(100, lambda: setattr(self, '_suppress_toggle_callback', False))
return
# 用户手动点击启用 → 彻底重启(重新检测内存,尝试恢复GPU)
self.monitor.start(full_restart=True)
self._status_label.configure(text="▶ 运行中", text_color="#2ecc71")
self._log("✅ 消息监控已启动")
# 通知OCR管理器:监控已激活,不释放引擎
self._ocr_manager.set_monitor_active(True)
else:
# ⭐ UI 立即更新;stop() 在后台线程执行,主线程保持响应,不再触发冻结弹窗
self._stop_in_progress = True # ⭐ 防止 stop 过程中被 re-toggle 导致死循环
self._status_label.configure(text="⏸ 已暂停")
self._log("⏸ 消息监控已暂停")
# 通知OCR管理器:监控已停止,开始计时
self._ocr_manager.set_monitor_active(False)
threading.Thread(target=self._stop_monitor_async, daemon=True, name="gui-toggle-stop").start()
def _browse_kb(self):
path = filedialog.askdirectory(title="选择知识库文件夹")
if path:
self._kb_path_var.set(path)
self._apply_settings()
def _apply_settings(self):
# 保存知识库路径
path = self._kb_path_var.get().strip()
self.cfg["knowledge_base_path"] = path
self.kb.set_path(path)
self._update_kb_status()
# 平台
self.cfg["wechat_enabled"] = self._wx_var.get()
self.cfg["qq_enabled"] = self._qq_var.get()
# 自动回复
self.cfg["auto_reply_enabled"] = self._ar_var.get()
self.cfg["auto_reply_min_score"] = round(self._ar_threshold_var.get(), 2)
self.ar.set_enabled(self.cfg["auto_reply_enabled"])
self.ar.set_min_score(self.cfg["auto_reply_min_score"])
# 间隔
self.cfg["poll_interval_seconds"] = round(self._interval_var.get(), 1)
save_config(self.cfg)
# 更新监控器
self.monitor.set_poll_interval(self.cfg["poll_interval_seconds"])
self.monitor.set_enabled_platforms(
wechat=self.cfg["wechat_enabled"],
qq=self.cfg["qq_enabled"],
)
self._log(f"📝 设置已保存 | 知识库: {self.kb.get_doc_count()} 个文档 | "
f"轮询间隔: {self.cfg['poll_interval_seconds']}s")
def _save_plat_settings(self):
# 只保存平台选择,不触发完整应用
self.cfg["wechat_enabled"] = self._wx_var.get()
self.cfg["qq_enabled"] = self._qq_var.get()
save_config(self.cfg)
self.monitor.set_enabled_platforms(
wechat=self.cfg["wechat_enabled"],
qq=self.cfg["qq_enabled"],
)
def _save_interval(self, _=None):
self._interval_label.configure(text=f"{self._interval_var.get():.1f}s")
def _save_ar_settings(self):
"""自动回复开关即时保存"""
self.cfg["auto_reply_enabled"] = self._ar_var.get()
save_config(self.cfg)
self.ar.set_enabled(self.cfg["auto_reply_enabled"])
status = "开" if self._ar_var.get() else "关"
self._log(f"🤖 自动回复已切换为{status}")
def _save_ar_threshold(self, _=None):
val = round(self._ar_threshold_var.get(), 2)
self._ar_threshold_label.configure(text=f"{val:.2f}")
def _update_kb_status(self):
if self.kb.is_ready:
self._kb_status.configure(
text=f"✅ {self.kb.get_doc_count()} 个文档", text_color="#2ecc71"
)
else:
self._kb_status.configure(text="❌ 未加载", text_color="#e74c3c")
# ── 昵称管理 ──
def _refresh_nick_list(self):
"""刷新昵称列表显示"""
self.cfg = load_config()
nicks = self.cfg.get("qq_nicknames", [])
self._nick_listbox.configure(state="normal")
self._nick_listbox.delete("1.0", "end")
if nicks:
for n in nicks:
self._nick_listbox.insert("end", n + "\n")
else:
self._nick_listbox.insert("end", "(空 — 通过@提及或手动添加)")
self._nick_listbox.configure(state="disabled")
def _add_nickname(self, event=None):
"""添加新昵称到列表"""
nick = self._nick_entry.get().strip()
if not nick:
return
self.cfg = load_config()
nicks = self.cfg.setdefault("qq_nicknames", [])
if nick in nicks:
self._log(f"⚠ 昵称 '{nick}' 已存在")
return
nicks.append(nick)
save_config(self.cfg)
self._nick_entry.delete(0, "end")
self._refresh_nick_list()
self._log(f"✅ 已添加昵称: {nick}")
def _delete_selected_nickname(self):
"""删除光标所在行的昵称"""
try:
cursor_pos = self._nick_listbox.index("insert")
line_text = self._nick_listbox.get(cursor_pos + " linestart",
cursor_pos + " lineend").strip()
if not line_text or line_text.startswith("("):
return
self.cfg = load_config()
nicks = self.cfg.get("qq_nicknames", [])
if line_text in nicks:
nicks.remove(line_text)
save_config(self.cfg)
self._refresh_nick_list()
self._log(f"🗑 已删除昵称: {line_text}")
except Exception as e:
self._log(f"⚠ 删除失败: {e}")
def _ocr_nicknames_from_screenshot(self):
"""截图OCR识别昵称:截屏→双引擎OCR→提取昵称→入库"""
self._log("📸 正在截屏,请确保QQ聊天窗口可见...")
self._ocr_nick_btn.configure(state="disabled", text="截屏中...")
def _do_ocr():
try:
import time
time.sleep(1.5) # 等待用户切换到QQ窗口
# 截取全屏
screenshot = self._capture_screen()
if screenshot is None:
self._log_queue.put("⚠ 截屏失败")
return
self._log_queue.put("🔍 OCR识别中(双引擎并发)...")
# 使用OCR管理器识别(双引擎并发,得分最高者胜出)
results = self._ocr_manager.recognize(screenshot)
if not results:
self._log_queue.put("⚠ OCR未识别到任何文字")
return
self._log_queue.put(f"📝 OCR识别到 {len(results)} 段文字")
# 从OCR结果中提取昵称
nicks_found = self._extract_nicknames_from_ocr(results)
if not nicks_found:
self._log_queue.put("⚠ 未检测到QQ昵称模式([头衔]用户名 或 @用户名)")
return
# 添加到配置
self.cfg = load_config()
existing = self.cfg.setdefault("qq_nicknames", [])
added = []
for n in nicks_found:
if n not in existing:
existing.append(n)
added.append(n)
if added:
save_config(self.cfg)
self._refresh_nick_list()
self._log_queue.put(f"✅ 新增 {len(added)} 个昵称: {', '.join(added)}")
else:
self._log_queue.put(f"ℹ 识别到的昵称已全部存在: {', '.join(nicks_found)}")
except Exception as e:
self._log_queue.put(f"⚠ 截图OCR异常: {e}")
finally:
# 恢复按钮
self.after(0, lambda: self._ocr_nick_btn.configure(
state="normal", text="📸 从截图识别"))
threading.Thread(target=_do_ocr, daemon=True).start()
def _capture_screen(self):
"""使用Windows API截取全屏,返回PIL Image"""
try:
import win32gui
import win32ui
import win32con
import ctypes
from PIL import Image
# 获取屏幕尺寸(ctypes方式,兼容性更好)
user32 = ctypes.windll.user32
screen_w = user32.GetSystemMetrics(0) # SM_CXSCREEN
screen_h = user32.GetSystemMetrics(1) # SM_CYSCREEN
# 创建设备上下文
hdesktop = win32gui.GetDesktopWindow()
hdc = win32gui.GetDC(hdesktop)
mfcDC = win32ui.CreateDCFromHandle(hdc)
saveDC = mfcDC.CreateCompatibleDC()
# 创建位图
saveBitMap = win32ui.CreateBitmap()
saveBitMap.CreateCompatibleBitmap(mfcDC, screen_w, screen_h)
saveDC.SelectObject(saveBitMap)
# 复制屏幕
saveDC.BitBlt((0, 0), (screen_w, screen_h), mfcDC, (0, 0), win32con.SRCCOPY)
# 转为PIL Image
bmpinfo = saveBitMap.GetInfo()
bmpstr = saveBitMap.GetBitmapBits(True)
img = Image.frombuffer('RGB', (bmpinfo['bmWidth'], bmpinfo['bmHeight']),
bmpstr, 'raw', 'BGRX', 0, 1)
# 清理
win32gui.DeleteObject(saveBitMap.GetHandle())
saveDC.DeleteDC()
mfcDC.DeleteDC()
win32gui.ReleaseDC(hdesktop, hdc)
return img
except Exception as e:
logger.error(f"截屏失败: {e}")
return None
def _extract_nicknames_from_ocr(self, ocr_results):
"""从OCR识别结果中提取QQ昵称模式"""
nicks = set()
# 合并所有识别文本
full_text = '\n'.join(ocr_results) if isinstance(ocr_results, list) else str(ocr_results)
# 模式1: [头衔]用户名 或 【头衔】用户名
for m in re.finditer(r'[\[【]([^\]】]+)[】\]]\s*([A-Za-z0-9_\u4e00-\u9fff]{2,30})', full_text):
nick = m.group(2).strip()
if len(nick) >= 2:
nicks.add(nick)
# 模式2: @用户名
for m in re.finditer(r'@([A-Za-z0-9_\u4e00-\u9fff]{2,30})', full_text):
nick = m.group(1).strip()
if len(nick) >= 2:
nicks.add(nick)
# 模式3: 行首的纯中文/英文用户名(2-15字符),后跟消息内容
# 这个模式比较宽松,需要人工确认
for line in ocr_results if isinstance(ocr_results, list) else full_text.split('\n'):
line = line.strip()
if not line:
continue
# 行首用户名:2-15字符的中英文,后跟逗号/冒号/空格+内容
m = re.match(r'^([A-Za-z0-9_\u4e00-\u9fff]{2,15})\s*[,,::]\s*.+', line)
if m:
nick = m.group(1).strip()
# 过滤常见非昵称词
skip_words = {'你好', '请问', '怎么', '什么', '为什么', '多少', '在哪', '下载',
'安装', '配置', '设置', '使用', '更新', '版本', '错误', '问题'}
if nick not in skip_words:
nicks.add(nick)
return list(nicks)
# ── 消息处理 ──
def _on_new_message(self, platform: str, msg: str):
"""收到新消息 -> 搜索知识库 -> 日志输出"""
# ⭐ 纯时间戳/纯日期过滤:不含中文字符的消息不送查库
# OCR 常把日期"2026/8 / 6"作为独立 part 传入,匹配度虚高但答非所问
if not re.search(r'[\u4e00-\u9fff]', msg):
self._log_queue.put(f"[{platform.upper()}] ⚠ 消息无中文字符,跳过查库: {msg[:40]}")
return
if not self.kb.is_ready:
self._log_queue.put(f"[{platform.upper()}] {msg}")
self._log_queue.put(" ⚠ 知识库未加载,无法搜索答案")
return
# ⭐ 计算消息hash,用于锁内去重(防竞态条件)
import hashlib
msg_hash = hashlib.md5(msg.strip().encode('utf-8')).hexdigest()
# ⭐ 在锁内完成所有检查,防止竞态条件:
# 原来DB查重在无锁情况下运行,两个线程可同时通过DB查重,
# 然后都加入_processing_messages,导致同一条消息被处理两次。
with self._processing_lock:
# 检查是否正在处理(同一消息的第二次调用)
if msg_hash in self._processing_messages:
self._log_queue.put(f"[{platform.upper()}] ⚠ 消息正在处理中(锁去重),跳过: {msg[:40]}")
return
# DB查重:检查用户消息是否已被回复过(防重复回复)
if self.monitor._msg_history:
if self.monitor._msg_history.is_user_message_replied(platform, msg, time.time()):
self._log_queue.put(f"[{platform.upper()}] ⚠ 消息已被回复过(DB查重),跳过: {msg[:40]}")
return
# 加入正在处理集合
self._processing_messages.add(msg_hash)
# 在后台线程搜索
threading.Thread(target=self._search_and_log,
args=(platform, msg, msg_hash), daemon=True).start()
def _search_and_log(self, platform: str, msg: str, msg_hash: str):
"""搜索知识库并输出结果,匹配成功时队列自动回复到主线程"""
try:
# 详细调试日志:记录发送给KB搜索的完整消息
logger.debug(f"[{platform}] KB搜索输入: msg={msg!r} (长度={len(msg)})")
# 清理查询文本:移除用户名/头衔/时间戳等噪音,提升搜索精度
cleaned_msg = _clean_query_for_kb(msg)
if cleaned_msg != msg:
logger.debug(f"[{platform}] KB清理后: cleaned={cleaned_msg!r}")
results = self.kb.search(cleaned_msg, top_k=10)
lines = [f"[{platform.upper()}] {msg}"]
if results:
lines.append(f" 🔍 找到 {len(results)} 条相关结果:")
for i, (doc, score) in enumerate(results):
title = doc["title"]
heading = doc.get("heading", "")
body = doc.get("body", "")
content = doc.get("content", "")
# 详细调试日志:记录每个搜索结果的详细信息
logger.debug(f"[{platform}] KB结果[{i}]: 标题={title!r}, 匹配度={score:.2f}")
logger.debug(f"[{platform}] KB结果[{i}] heading={heading[:80]!r}")
logger.debug(f"[{platform}] KB结果[{i}] body={body[:120]!r}")
logger.debug(f"[{platform}] KB结果[{i}] content={content[:150]!r}")
# 显示更详细的信息
lines.append(f" 📄 [{title}] (匹配度 {score:.2f})")
lines.append(f" 标题: {heading[:60]}")
lines.append(f" 正文片段: {body[:100].replace(chr(10), ' ')}...")
if i == 0:
lines.append(f" 完整内容片段: {content[:200].replace(chr(10), ' ')}...")
# 从 top_k 结果中选取【第一个通过内容相关性校验】的文档发送,
# 避免永远只取 results[0] 导致"牛头不对马嘴"的错误文档被发送。
# 一条消息只回复一段(选中最相关且语义匹配的那一条),不触发 N 段回复。
# ⭐ 2026-08-17 修复:收集所有相关候选,按质量分排序选最优
# 原来只选第一个通过的,导致 protect"温馨提示"(score高但内容浅)压过 sdf1_login"2.1下载"(score略低但内容深)
chosen = None
if self.ar.enabled:
# 提取query的核心词(复用auto_reply的逻辑)
q_tokens = AutoReply._meaningful_tokens(cleaned_msg)
# 非核心词(复制自auto_reply.py)
non_core_kw = {
'多少', '哪些', '哪个', '什么', '怎么', '如何', '吗', '呢', '吧',
'备用', '深险', '端的', '那', '这', '些', '个', '种', '类',
'最近', '近期', '刚才', '现在', '当前', '刚刚', '以前', '之后', '当天',
'第一', '最新', '全部', '所有', '一起', '各种', '各种各样',
'哪些', '哪个', '什么', '怎么', '如何', '多少',
'相关联', '相关', '有关', '以及', '以及',
'问问', '问一嘴', '打听一下', '能够', '可以到',
'有个', '问题', '常用', '他们', '各位', '大家', '小伙伴',
'知道', '想知道',
'下载', '下到', '哪下', '在哪下', '怎么下',
'信息', '内容', '详情', '情况', '介绍',
'一句', '问一句', '再问',
'有人',
'一嘴',
'那家', '那价',
# 2026-09-17 新增:时间疑问词不应作为核心词
'几月', '几号', '几日', '什么时候', '何时',
}
core_kw = q_tokens - non_core_kw
# OCR碎片过滤
core_kw = {kw for kw in core_kw if not (kw.isascii() and len(kw) <= 2)}
# 收集所有通过相关性检查的候选
relevant_candidates = []
for idx, (doc, sc) in enumerate(results):
if sc < self.ar.min_score:
continue
rel = self.ar._has_content_relevance(cleaned_msg, doc, sc)
logger.debug(
f"[{platform}] 相关性候选[{idx}]: title={doc.get('title', '')!r} "
f"score={sc:.2f} relevance={rel}"
)
if rel:
# 计算质量分:body越长、有表格/链接列表,质量越高
body = doc.get('body', '') or doc.get('content', '')
body_full_len = len(body.strip())
# ⭐ 代码块长度惩罚:body含```代码块时,代码块内容(如配置格式示例、问答数据)
# 不应贡献body_len加分。截断到代码块起始位置,只计算"说明文字"的长度。
# 修复"用户组是怎么配置的"被sdf1_game"题目配置格式"(body含完整代码块)压过的问题
_code_block_idx = body.find('```')
if _code_block_idx >= 0:
body = body[:_code_block_idx]
body_len = len(body.strip())
# 下载类查询优先选择详细表格,而非简略提示
is_download_query = any(w in cleaned_msg for w in ('下载', '下到', '哪下', '怎么下'))
is_table_body = '|' in body or '- [' in body
# ⭐ heading 可能把正文一起吞进来(如'## 温馨提示...<br>如果您:<br> - [ √ ] 插件下载...')
# 质量分只看 heading 第一段(<br>/换行/| 之前),避免'温馨提示'借正文'下载'蹭加分
_heading_head = re.split(r'<br>|\n|\||- ', (doc.get('heading') or ''))[0].lower()
if is_download_query:
if '下载' in _heading_head:
# ⭐ heading 明确是"下载"section(如'### 2.1 下载'、'## 4. 下载地址')
# → 这是真正的下载答案,强加分
quality_score = sc + 20.0
elif ('见' in body and body_len < 80) or '依托' in _heading_head:
# ⭐ 指针式提示(section内无实际内容,只写"见xxx"或"本系统依托[xx]")
# 优先于表格分支判定——body即使带'- ['链接列表也降权,
# 避免'温馨提示'这类简略提示压过真正下载表格
quality_score = sc - 10.0
elif is_table_body:
quality_score = sc + 10.0 # 下载查询中,表格/链接列表body额外+10分
else:
quality_score = sc + body_len / 100.0
else:
# 计算heading核心词命中奖励
heading_text = (doc.get('heading') or '').lower()
heading_hits = sum(1 for kw in core_kw if kw in heading_text)
heading_bonus = heading_hits * 10.0 # 每个核心词命中heading加10分
# 表格body加分(非下载查询也适用)
table_bonus = 5.0 if is_table_body else 0.0
quality_score = sc + body_len / 100.0 + heading_bonus + table_bonus
relevant_candidates.append((quality_score, sc, doc))
if relevant_candidates:
# 按质量分降序排列,选最高的
relevant_candidates.sort(key=lambda x: x[0], reverse=True)
_, orig_score, chosen_doc = relevant_candidates[0]
chosen = (chosen_doc, orig_score)
logger.debug(
f"[{platform}] 选优: score={orig_score:.2f} "
f"heading={(chosen_doc.get('heading') or '')[:30]!r}"
)
if not self.ar.enabled:
lines.append(" ⚠ 自动回复已关闭,不触发回复")
elif chosen is None:
lines.append(" ⚠ 所有结果均未通过内容相关性/阈值检查,跳过回复")
else:
lines.append(" ✅ 匹配通过,准备发送自动回复")
# 自动回复:队列到主线程执行(daemon 线程不能可靠 SetForegroundWindow)
if chosen is not None:
reply_data = self.ar.get_reply_data(
platform, msg, chosen[0], chosen[1], cleaned_msg=cleaned_msg
)
if reply_data:
self._reply_queue.put(reply_data)
else:
lines.append(" ⚠ get_reply_data 返回 None(重复命中或内部检查失败)")
else:
reply_data = None
else:
lines.append(" ❌ 知识库中未找到相关答案")
for line in lines:
self._log_queue.put(line)
finally:
# ⭐ 处理完成后从集合中移除消息hash
with self._processing_lock:
self._processing_messages.discard(msg_hash)
def _log(self, text: str):
"""直接写日志(主线程)"""
self._log_queue.put(text)
def _flush_log(self):
"""从队列消费日志到文本框"""
try:
while True:
line = self._log_queue.get_nowait()
self._log_text.configure(state="normal")
self._log_text.insert("end", line + "\n")
self._log_text.see("end")
self._log_text.configure(state="disabled")
except queue.Empty:
pass
self.after(200, self._flush_log)
def _flush_reply_queue(self):
"""从自动回复队列消费——主线程执行,SetForegroundWindow 可靠。"""
try:
while True:
data = self._reply_queue.get_nowait()
try:
self._execute_reply(data)
except Exception as e:
logger.error(f"自动回复执行异常: {e}", exc_info=True)
self.monitor._sending_event.clear()
except queue.Empty:
pass
self.after(200, self._flush_reply_queue)
def _execute_reply(self, data: dict):
"""主线程执行自动回复发送。加上发送锁防止监控轮询干扰。
自动重试:失败后延缓5秒重试,连续3次失败视为用户接管,退避本轮。
⭐ 2026-08-20 修复:记录目标重试时间,防止Tkinter after()延迟导致计时不准。
"""
import time as _time
platform = data["platform"]
answer = data["answer"]
sender = data.get("sender")
msg = data.get("msg", "")
max_retries = 3
retry_delay = 5 # 秒
attempt = data.get("_attempt", 0)
# ⭐ 验证重试时机:若目标时间未到,重新调度(防止after()延迟)
if attempt > 0:
_retry_at = data.get("_retry_at", 0)
_now = _time.time()
if _now < _retry_at:
_remaining = int((_retry_at - _now) * 1000)
logger.debug(f"[{platform}] 重试时机未到({_remaining}ms),重新调度")
data["_retry_at"] = _retry_at
self.after(_remaining, lambda d=data: self._execute_reply(d))
return
# ⭐ 发送前强制检查时间戳:≤300秒则发送,≥300秒则丢弃
msg_age = self._check_message_freshness(msg)
if msg_age is not None and msg_age > 300:
self._log(f"[{platform}] ⚠ 消息时间戳过期({msg_age:.0f}秒>300秒),丢弃: {msg[:40]}")
return
# 设发送锁,阻止监控轮询切走焦点
self.monitor._sending_event.set()
# ⭐ 设置回复进行中标志:防止打断后下次轮询重复触发
self.monitor._reply_in_progress[platform] = True
try:
if sender:
# 搜索联系人切换会话
found = self.monitor._search_contact(platform, sender)
if not found:
self._log(f"[{platform}] ⚠ 找不到联系人 {sender},当前会话回复")
# 首次正常发送,重试时 force_retry 跳过60秒冷却
ok = self.monitor._paste_and_send(platform, answer, force_retry=(attempt > 0))
if ok:
self._log(f"[{platform}] 🤖 已自动回复: {answer[:60]}...")
# ⭐ Task #1673: 发送成功,标记消息已回复
if msg:
self.monitor.mark_message_replied(platform, msg)
return # 成功直接退出
else:
if attempt < max_retries - 1:
# ⭐ 记录目标重试时间,确保重试间隔准确
_retry_at = _time.time() + retry_delay
data["_attempt"] = attempt + 1
data["_retry_at"] = _retry_at
self._log(f"[{platform}] ⚠ 自动回复发送失败,{retry_delay}秒后重试({attempt+1}/{max_retries})")
# ⭐ 2026-08-17 修复GUI冻结:原实现主线程 time.sleep(5) ×3 = 界面冻结15秒+
# 窗口不前置时 _paste_and_send 必失败,用户跟踪日志时 GUI 无响应。
# 改为 after() 异步重试:主线程立即返回,5秒后再执行,界面保持流畅。
self.after(int(retry_delay * 1000), lambda d=data: self._execute_reply(d))
else:
self._log(f"[{platform}] ⚠ 自动回复发送失败(连续{max_retries}次),用户可能在接管,本轮跳过")
# ⭐ Task #1673: 连续3次发送失败,标记消息发送失败
if msg:
self.monitor.mark_message_send_failed(platform, msg)
finally:
self.monitor._sending_event.clear()
# ⭐ 回复完成或失败后清除进行中标志
self.monitor._reply_in_progress.pop(platform, None)
def _check_message_freshness(self, msg: str) -> float or None:
"""检查消息时间戳是否新鲜(≤300秒)。返回消息年龄(秒),无法解析返回None。"""
import re
from datetime import datetime, timedelta
# 尝试从消息中提取时间戳
# 常见格式:HH:MM, YYYY-MM-DD HH:MM, MM-DD HH:MM
patterns = [
(r'(\d{1,2}):(\d{2})', 'time_only'), # HH:MM
(r'(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})', 'full'), # YYYY-MM-DD HH:MM
(r'(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})', 'date_time'), # MM-DD HH:MM
]
for pattern, fmt in patterns:
match = re.search(pattern, msg)
if match:
now = datetime.now()
try:
if fmt == 'time_only':
# 只有时间,假设是今天
hour, minute = int(match.group(1)), int(match.group(2))
msg_time = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
# 如果时间在未来,可能是昨天的消息
if msg_time > now:
msg_time -= timedelta(days=1)
elif fmt == 'full':
year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3))
hour, minute = int(match.group(4)), int(match.group(5))
msg_time = datetime(year, month, day, hour, minute)
elif fmt == 'date_time':
month, day = int(match.group(1)), int(match.group(2))
hour, minute = int(match.group(3)), int(match.group(4))
msg_time = now.replace(month=month, day=day, hour=hour, minute=minute, second=0, microsecond=0)
# 如果时间在未来,可能是去年的消息
if msg_time > now:
msg_time = msg_time.replace(year=now.year - 1)
age = (now - msg_time).total_seconds()
if age >= 0:
return age
except (ValueError, OverflowError):
continue
# 无法解析时间戳,返回None(允许发送)
return None
def _clear_log(self):
self._log_text.configure(state="normal")
self._log_text.delete("1.0", "end")
self._log_text.configure(state="disabled")
# ── 急停回调 ──
def _on_emergency_stop(self):
"""双击 ESC 急停的回调(从 daemon 线程调用,日志队列安全)。"""
self._log_queue.put("⚠️⚠️⚠️ 双击ESC急停!消息监控已强制终止")
# ⭐ 通知 OCR 管理器:监控已停止,开始计时释放引擎
# 这步必须在 daemon 线程里同步做(self.after 可能延迟),
# 否则 OCR 引擎不会被正确释放,重启后 EasyOCR 无法初始化。
try:
self._ocr_manager.set_monitor_active(False)
except Exception as e:
logger.warning(f"[gui] 急停通知 OCR 管理器失败: {e}")
# 安全更新 toggle 状态(主线程 after 调度)
self.after(0, self._update_stop_ui)
def _stop_monitor_async(self):
"""后台线程执行 monitor.stop(),避免阻塞 GUI 主线程触发 Windows 冻结弹窗。
stop() 内含多个线程 join + _kill_easyocr_proc(terminate+wait 最多 6s),
同步在 GUI 主线程执行会冻结 6~8s,触发系统"软件无响应"弹窗。
"""
try:
self.monitor.stop()
except Exception as e:
logger.warning(f"[gui] 后台 stop 异常: {e}")
finally:
# ⭐ stop 完成后在主线程重置标志,允许下一次 toggle
self.after(0, lambda: setattr(self, '_stop_in_progress', False))
def _update_stop_ui(self):
"""主线程更新急停后的 UI(stop 放到后台线程,主线程不冻结)。"""
# ⭐ 设置标志防止 deselect() 触发 _toggle_monitor() 回调
self._suppress_toggle_callback = True
self._stop_in_progress = True # ⭐ 防止死循环
try:
# ⭐ 用 deselect() 替代 _variable.set(False)——customtkinter 的
# CTkSwitch.deselect() 内部同时更新视觉状态和变量,更可靠。
self._toggle_btn.deselect()
except Exception:
# 降级:直接设置变量值
try:
self._toggle_btn._variable.set(False)
except Exception:
pass
finally:
# 延迟重置标志,确保回调已被跳过
self.after(100, lambda: setattr(self, '_suppress_toggle_callback', False))
self._status_label.configure(text="⏸ 急停", text_color="#e74c3c")
self._log_queue.put("🔄 可点击「启用监控」重新启动")
# ⭐ 安全超时:如果 _stop_in_progress 在 20s 内没被重置(stop() 挂起),
# 强制重置,确保用户永远能重新启动。
def _safety_reset_stop_flag():
if self._stop_in_progress:
logger.warning("[gui] _stop_in_progress 安全超时重置(stop() 可能挂起)")
self._stop_in_progress = False
self.after(20000, _safety_reset_stop_flag)
# ⭐ UI 立即更新;stop() 在后台线程执行,主线程保持响应,不再触发冻结弹窗。
threading.Thread(target=self._stop_monitor_async, daemon=True, name="gui-stop").start()
# ── 窗口关闭 ──
def _on_close(self):
# ⭐ 关闭窗口:释放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()