修复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:
+115
-34
@@ -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 张。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user