修复OCR回复牛头不对马嘴+残留消息+EasyOCR崩溃捕获+360误报

1. knowledge_base.py: 检索排序惩罚标题-only/短正文段,降低heading bonus,奖励正文命中,避免纯标题/免责声明段排第一
2. auto_reply.py: 动作类查询要求正文命中才放行;拒绝纯标题/免责声明答案
3. monitor.py: 调试截图改存%LOCALAPPDATA%/windowmsgbot/ocr_debug避开源码目录(360); 收紧残留消息fresh gate(600s硬门); 启动即崩溃/请求崩溃时读取easyocr_crash.log写入主日志
4. easyocr_worker.py: 加faulthandler+sys.excepthook全局捕获,崩溃traceback落盘%LOCALAPPDATA%/windowmsgbot/easyocr_crash.log
This commit is contained in:
youpaishidifu
2026-08-01 21:21:05 +08:00
parent 9ff019df00
commit 0405e42a90
4 changed files with 225 additions and 46 deletions
+115 -34
View File
@@ -1108,13 +1108,17 @@ class MessageMonitor:
self._easyocr_retry_until = time.time() + 30
return False
if not line:
# stdout 关闭 = worker 崩溃,读 stderr 看原因
# stdout 关闭 = worker 崩溃,读 stderr + 崩溃日志看原因
err = ""
try:
err = self._easyocr_proc.stderr.read()[:500]
except Exception:
pass
logger.error(f"[ocr] EasyOCR worker 启动即崩溃: {err}")
crash = _read_easyocr_crash_tail()
detail = err.strip()
if crash:
detail = (detail + "\n[ocr] worker 崩溃日志(easyocr_crash.log):\n" + crash).strip()
logger.error(f"[ocr] EasyOCR worker 启动即崩溃: {detail}")
self._easyocr_proc = None
self._easyocr_retry_until = time.time() + 30
return False
@@ -1201,7 +1205,11 @@ class MessageMonitor:
else:
line = self._easyocr_proc.stdout.readline()
if not line:
raise ConnectionError("EasyOCR 子进程 stdout 关闭")
crash = _read_easyocr_crash_tail()
msg = "EasyOCR 子进程 stdout 关闭"
if crash:
msg += f"(崩溃日志: {crash})"
raise ConnectionError(msg)
result = json.loads(line)
if result.get("ok"):
# 成功 → 重置连续失败计数
@@ -1496,12 +1504,13 @@ class MessageMonitor:
return []
# 保存调试截图(在颜色/哈希检查之前,即使跳过也有图可查)
_dbg_path = ""
try:
script_dir = os.path.dirname(os.path.abspath(__file__))
debug_dir = _get_ocr_debug_dir()
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_dbg_path = os.path.join(script_dir, f"ocr_debug_qq_{ts}.png")
_dbg_path = os.path.join(debug_dir, f"ocr_debug_qq_{ts}.png")
im.save(_dbg_path, format="PNG")
_clean_old_ocr_debug(script_dir, keep=6, prefix="ocr_debug_qq_")
_clean_old_ocr_debug(debug_dir, keep=6, prefix="ocr_debug_qq_")
if os.path.exists(_dbg_path):
logger.info(f"[qq] 调试截图已保存: {_dbg_path}")
else:
@@ -1540,16 +1549,16 @@ class MessageMonitor:
logger.debug(f"[ocr] 预处理: resize 2x + sharpen(1.2) + contrast(1.2)")
# 保存OCR处理后的图片(JPG格式)用于肉眼对比
try:
script_dir = os.path.dirname(os.path.abspath(__file__))
debug_dir = _get_ocr_debug_dir()
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_processed_path = os.path.join(script_dir, f"ocr_processed_qq_{ts}.jpg")
_processed_path = os.path.join(debug_dir, f"ocr_processed_qq_{ts}.jpg")
# 转换为RGB模式保存JPG(不支持RGBA)
if im.mode == 'RGBA':
im_rgb = im.convert('RGB')
else:
im_rgb = im
im_rgb.save(_processed_path, format="JPEG", quality=95)
_clean_old_ocr_debug(script_dir, keep=6, prefix="ocr_processed_qq_")
_clean_old_ocr_debug(debug_dir, keep=6, prefix="ocr_processed_qq_")
if os.path.exists(_processed_path):
logger.debug(f"[qq] OCR处理后图片已保存: {_processed_path}")
except Exception as e:
@@ -1628,12 +1637,13 @@ class MessageMonitor:
return []
# 保存调试截图(在颜色/哈希检查之前,即使跳过也有图可查)
_dbg_path = ""
try:
script_dir = os.path.dirname(os.path.abspath(__file__))
debug_dir = _get_ocr_debug_dir()
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_dbg_path = os.path.join(script_dir, f"ocr_debug_wx_{ts}.png")
_dbg_path = os.path.join(debug_dir, f"ocr_debug_wx_{ts}.png")
im.save(_dbg_path, format="PNG")
_clean_old_ocr_debug(script_dir, keep=6, prefix="ocr_debug_wx_")
_clean_old_ocr_debug(debug_dir, keep=6, prefix="ocr_debug_wx_")
if os.path.exists(_dbg_path):
logger.info(f"[wechat] 调试截图已保存: {_dbg_path}")
else:
@@ -1670,16 +1680,16 @@ class MessageMonitor:
im = ImageEnhance.Contrast(im).enhance(1.2) # 降低对比度,保持笔画连贯
# 保存OCR处理后的图片(JPG格式)用于肉眼对比
try:
script_dir = os.path.dirname(os.path.abspath(__file__))
debug_dir = _get_ocr_debug_dir()
ts = datetime.now().strftime('%Y%m%d_%H%M%S.%f')[:19]
_processed_path = os.path.join(script_dir, f"ocr_processed_wx_{ts}.jpg")
_processed_path = os.path.join(debug_dir, f"ocr_processed_wx_{ts}.jpg")
# 转换为RGB模式保存JPG(不支持RGBA)
if im.mode == 'RGBA':
im_rgb = im.convert('RGB')
else:
im_rgb = im
im_rgb.save(_processed_path, format="JPEG", quality=95)
_clean_old_ocr_debug(script_dir, keep=6, prefix="ocr_processed_wx_")
_clean_old_ocr_debug(debug_dir, keep=6, prefix="ocr_processed_wx_")
if os.path.exists(_processed_path):
logger.debug(f"[wechat] OCR处理后图片已保存: {_processed_path}")
except Exception as e:
@@ -1796,8 +1806,9 @@ class MessageMonitor:
if buf.strip():
_fresh = self._looks_fresh_content(buf.strip())
if last_ts and self._is_timestamp_old(last_ts, 300):
if _fresh:
logger.debug(f"[ocr] stage[{i}] 时间戳{last_ts}过旧({300}s)但含新鲜特征,放行: {buf.strip()[:60]!r}")
# 仅当时间戳不是太久(<600s)且内容明确新鲜时才放行
if _fresh and not self._is_timestamp_old(last_ts, 600):
logger.debug(f"[ocr] stage[{i}] 时间戳{last_ts}过旧({300}s)但较新且含新鲜特征,放行: {buf.strip()[:60]!r}")
else:
logger.debug(f"[ocr] stage[{i}] 时间戳{last_ts}过旧({300}s),跳过: {buf.strip()[:60]!r}")
buf = ''
@@ -1821,11 +1832,11 @@ class MessageMonitor:
if buf.strip():
# 尾部检查:如果最后一个可见时间戳已过期(≥300s),尾部内容也是历史消息
# 注释:21:56:17清晰可见但已过期 → 尾部内容不应被当成新消息
# ⚠ 例外:尾部含新鲜特征(疑问词/问句/标点结尾)→ 可能是无独立时间戳的新消息,放行
# ⚠ 例外:尾部含明确新鲜特征且时间戳<600s → 可能是无独立时间戳的新消息,放行
_fresh_tail = self._looks_fresh_content(buf.strip())
if last_ts and self._is_timestamp_old(last_ts, 300):
if _fresh_tail:
logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}已过期但含新鲜特征,放行: {buf.strip()[:60]!r}")
if _fresh_tail and not self._is_timestamp_old(last_ts, 600):
logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}过旧但较新且含新鲜特征,放行: {buf.strip()[:60]!r}")
else:
logger.debug(f"[ocr] stage[{i}] 尾部时间戳{last_ts}已过期(≥{300}s),跳过尾部: {buf.strip()[:60]!r}")
buf = ''
@@ -2863,20 +2874,19 @@ class MessageMonitor:
def _looks_fresh_content(text: str) -> bool:
"""判断一段 OCR 文本是否含"新鲜消息"特征。
OCR 常把多条消息挤在同一行/同一时间戳块(微信时间戳不是每条消息都有),
新消息(≤300s)和旧消息共享一个前置旧时间戳。仅凭时间戳年龄会误杀整块。
若文本含疑问词 / 问句 / 句尾标点 / 冒号 → 视为可能是新鲜用户消息,放行。
只放行明显是用户新发的查询/聊天消息,避免旧历史记录因为含问句被误放行。
规则严格:短消息 或 明确疑问/请求句(含疑问词+问号/怎么/如何等)。
"""
if not text or not text.strip():
return False
t = text.strip()
if len(t) <= 8 and re.search(r'[\u4e00-\u9fff]', t):
return True # 短中文消息("好的"、"嗯")直接放行
# 疑问词/请求词
if re.search(r'怎么办|为什么|能不能|怎么|如何|什么|可以|帮忙|谢谢|好的|嗯|收到|OK|ok|吗|呢|吧', t):
# 超短中文消息(如"好的"、"嗯")可能无时间戳
if len(t) <= 4 and re.search(r'[\u4e00-\u9fff]', t):
return True
# 句尾标点(。!?…)或冒号(发送者:内容)
if re.search(r'[。!?!?…]$', t) or re.search(r'[::]', t):
# 明确疑问/请求:含疑问词+问号,或"怎么/如何/怎么办"等
if re.search(r'(?:怎么办|为什么|能不能|怎么|如何|什么|哪些|哪个|吗|呢)\s*[??]', t):
return True
if re.search(r'^(?:怎么|如何|请问|请教|求助)', t):
return True
return False
@@ -2885,8 +2895,8 @@ class MessageMonitor:
有时间戳且至少一个 ≤300s → True(确定新消息)
无时间戳 → 保守放行(可能是短句无时间戳的新消息)
有时间戳但全部过期 → 检查文本是否含新鲜内容特征(疑问词/问句等),
若含则放行(新消息可能没有独立时间戳),否则丢弃
有时间戳但全部过期 → 仅当最新时间戳不太老(≤600s)且文本含明确新鲜特征时放行,
否则视为历史消息丢弃
"""
if not text:
return False
@@ -2895,16 +2905,28 @@ class MessageMonitor:
timestamps = re.findall(r'\d{1,2}:\d{1,2}(?::\d{1,2})?', normalized)
if not timestamps:
return True
# 有新鲜时间戳 → 确定有新消息
for ts in timestamps:
try:
if not self._is_timestamp_old(ts, 300):
return True
except Exception:
pass
# 所有时间戳都过期,但文本含新鲜内容特征 → 可能是旧时间戳后紧跟新消息
# 让 stage splitting 的逐条放行逻辑做精细过滤
# 全部过期:若最新时间戳已 ≥600s,整屏视为历史消息,直接丢弃
newest_fresh_enough = False
for ts in timestamps:
try:
if not self._is_timestamp_old(ts, 600):
newest_fresh_enough = True
break
except Exception:
pass
if not newest_fresh_enough:
logger.debug(f"[ocr] 最新可见时间戳已过期≥600s,丢弃: {text[:80]!r}")
return False
# 最新时间戳在 300-600s 之间,且文本含明确新鲜特征 → 放行给 stage splitting
if self._looks_fresh_content(text):
logger.debug(f"[ocr] 时间戳全过期但含新鲜特征,放行: {text[:80]!r}")
logger.debug(f"[ocr] 时间戳全过期但最新≤600s且含新鲜特征,放行: {text[:80]!r}")
return True
return False
@@ -4224,6 +4246,65 @@ class MessageMonitor:
self._cached_hwnd.pop(platform, None)
self._cached_pid.pop(platform, None)
def _get_ocr_debug_dir() -> str:
"""返回 OCR 调试截图保存目录。
放在用户本地数据目录(%LOCALAPPDATA%/windowmsgbot/ocr_debug),
避免在源码目录频繁创建/删除图片被安全软件误报为勒索行为。
"""
try:
local_appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
if local_appdata:
debug_dir = os.path.join(local_appdata, "windowmsgbot", "ocr_debug")
else:
debug_dir = os.path.join(os.path.expanduser("~"), "windowmsgbot", "ocr_debug")
except Exception:
debug_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ocr_debug")
try:
os.makedirs(debug_dir, exist_ok=True)
except Exception:
pass
return debug_dir
def _get_easyocr_crash_log() -> str:
"""返回 easyocr_worker 崩溃日志路径,必须与 easyocr_worker.py 中 CRASH_LOG 保持一致。
放在 %LOCALAPPDATA%/windowmsgbot/easyocr_crash.log,避免在源码目录写文件(防 360)。
"""
try:
local_appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
if local_appdata:
base = os.path.join(local_appdata, "windowmsgbot")
else:
base = os.path.join(os.path.expanduser("~"), "windowmsgbot")
except Exception:
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, "easyocr_crash.log")
def _read_easyocr_crash_tail(max_bytes: int = 2000) -> str:
"""读取 easyocr_worker 崩溃日志尾部,供父进程在检测到 worker 崩溃时把原因写进主日志。
只在文件存在且较新时读取,避免每次都读陈旧历史。
"""
try:
path = _get_easyocr_crash_log()
if not os.path.exists(path):
return ""
# 只看最近 60s 内的崩溃(文件可能被很久以前的残留占据)
if time.time() - os.path.getmtime(path) > 60:
return ""
with open(path, "r", encoding="utf-8", errors="ignore") as f:
data = f.read()
# 取最后一段(最近一次崩溃通常在末尾)
if len(data) > max_bytes:
data = "...(截断)...\n" + data[-max_bytes:]
return data.strip()
except Exception:
return ""
def _clean_old_ocr_debug(dir_path: str, keep: int = 20, prefix: str = "ocr_debug_"):
"""清理 ocr_debug_*.png 和 ocr_processed_*.jpg 调试截图,按 prefix 分别保留最近 keep 张。