修复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
+30 -4
View File
@@ -80,8 +80,10 @@ class AutoReply:
"""查询词与匹配文档至少有一个共同2字+词,防偶然匹配。
短查询(≤2个2字+词)直接放行,不做严格检查。
检查顺序:heading → title → content/body。
动作/配置类查询额外要求正文含核心词,不能只命中标题。
高匹配度(≥10.0):如果heading+title+body都无共同词,直接拒绝。
"""
import re
q_tokens = set(t for t in jieba.cut(query) if len(t) >= 2)
if len(q_tokens) <= 2:
return True # 短查询不拦截
@@ -89,20 +91,35 @@ class AutoReply:
# 1. 检查 heading + title
doc_text = doc.get("heading", "") + " " + doc.get("title", "")
d_tokens = set(t for t in jieba.cut(doc_text) if len(t) >= 2)
if q_tokens & d_tokens:
return True
has_heading_hit = bool(q_tokens & d_tokens)
# 2. 检查 content/body(TF-IDF匹配的段落正文)
body = doc.get("content", "") or doc.get("body", "")
b_tokens = set()
has_body_hit = False
if body:
# 截断前500字防超长文档
b_tokens = set(t for t in jieba.cut(body[:500]) if len(t) >= 2)
if q_tokens & b_tokens:
has_body_hit = bool(q_tokens & b_tokens)
# 动作/配置类查询:不能只命中标题,正文必须也含核心词
# 否则"# Sdf1系列登录插件使用说明"会因标题含"登录/插件/使用"被误放
action_keywords = {"怎么", "如何", "使用", "配置", "修改", "设置", "安装", "用法"}
if action_keywords & q_tokens:
core_q_tokens = q_tokens - {"插件", "使用", "怎么", "如何", "吗", "呢", "吧"}
if core_q_tokens and not (core_q_tokens & b_tokens):
logger.debug(
f"动作查询正文相关性不足: query={query[:40]!r} "
f"heading_hit={has_heading_hit} body_hit={has_body_hit}"
)
return False
if has_heading_hit or has_body_hit:
return True
# 3. 高匹配度但无共同词:可能是TF-IDF误匹配,拒绝
# 用户问"PVP击杀播报",KB返回"json格式说明"(匹配度9.53)→ 应拒绝
if score >= 10.0 and not (q_tokens & d_tokens) and not (q_tokens & (b_tokens if body else set())):
if score >= 10.0 and not has_heading_hit and not has_body_hit:
return False
return False
@@ -128,6 +145,15 @@ class AutoReply:
if not answer:
return None
# 拒绝把纯标题/免责声明当作答案发送
import re
if answer.startswith("# ") and len(answer) < 60:
logger.debug(f"[{platform}] 答案为纯标题,跳过: {answer[:60]!r}")
return None
if re.search(r'特别说明|免责声明|本插件.*自行修改|仅供参考|版权所有', answer):
logger.debug(f"[{platform}] 答案为免责声明/通用说明,跳过: {answer[:60]!r}")
return None
# 防重复指纹
fp = hash(f"{platform}:{msg}:{answer[:80]}")
replied_set = self._replied.setdefault(platform, set())
+49 -2
View File
@@ -16,13 +16,58 @@ import base64
import io
import os
import traceback
from datetime import datetime
# ── 顶层异常捕获:确保崩溃原因写入 stderr(否则父进程看到空 stderr) ──
# ── 崩溃日志文件:写入 %LOCALAPPDATA%/windowmsgbot/,避开源码目录(防 360 误报 + 父进程方便取) ──
def _get_crash_log_path() -> str:
try:
local = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
base = os.path.join(local, "windowmsgbot") if local \
else os.path.join(os.path.expanduser("~"), "windowmsgbot")
except Exception:
base = os.path.dirname(os.path.abspath(__file__))
try:
os.makedirs(base, exist_ok=True)
except Exception:
pass
return os.path.join(base, "easyocr_crash.log")
CRASH_LOG = _get_crash_log_path()
def _write_crash_log(msg: str):
try:
with open(CRASH_LOG, "a", encoding="utf-8") as f:
f.write(msg + "\n")
f.flush()
except Exception:
pass
# ── 顶层异常捕获:确保崩溃原因写入 stderr + 落盘(否则父进程看到空 stderr) ──
def _flush_exit(msg, code=1):
sys.stderr.write(f"[easyocr_worker] FATAL: {msg}\n")
full = f"[easyocr_worker] FATAL: {msg}"
sys.stderr.write(full + "\n")
sys.stderr.flush()
_write_crash_log(f"{datetime.now()} {full}")
sys.exit(code)
# ── 全局未捕获异常钩子:任何漏网的 Python 异常都落盘 ──
def _global_excepthook(exc_type, exc_val, exc_tb):
msg = "".join(traceback.format_exception(exc_type, exc_val, exc_tb))
sys.stderr.write(f"[easyocr_worker] 未捕获异常:\n{msg}")
sys.stderr.flush()
_write_crash_log(f"{datetime.now()} [easyocr_worker] 未捕获异常:\n{msg}")
sys.excepthook = _global_excepthook
# ── faulthandler:捕获原生层崩溃(segfault / CUDA 初始化竞态),Python try/except 抓不到 ──
# 必须在重型 import 之前启用,连 import 阶段的原生崩溃也能 dump 出来。
try:
import faulthandler
_fh = open(CRASH_LOG, "a", encoding="utf-8")
faulthandler.enable(file=_fh, all_threads=True)
except Exception:
pass
try:
# ── 线程/进程限制:避免 MKL/OpenMP 在子进程多线程冲突 ──
os.environ.setdefault("OMP_NUM_THREADS", "1")
@@ -74,12 +119,14 @@ def _process_image(img_b64: str, reader) -> str:
def _main():
_write_crash_log(f"{datetime.now()} [easyocr_worker] 启动 (pid={os.getpid()})")
reader = None
try:
reader = _init_reader()
except Exception as e:
sys.stderr.write(f"[easyocr_worker] Reader 初始化失败: {e}\n")
sys.stderr.flush()
_write_crash_log(f"{datetime.now()} [easyocr_worker] Reader 初始化失败: {e}")
# ⭐ 就绪握手:即使 Reader 失败也要发 ready,否则父进程永远等握手
sys.stdout.write(json.dumps({"ready": True}, ensure_ascii=False) + "\n")
sys.stdout.flush()
+30 -5
View File
@@ -300,6 +300,24 @@ class KnowledgeBase:
combined_scores = 0.7 * tfidf_scores + 0.3 * keyword_scores
# 内容质量调整:惩罚标题-only/正文过短的 section,奖励正文含查询词的 section
# 避免"# Sdf1系列登录插件使用说明"这种纯标题段因为标题词匹配就排第一
for i, doc in enumerate(self.docs):
body = doc.get("body", "")
body_len = len(body.strip())
heading = doc.get("heading", "")
# 单层 # 标题且无实质正文 → 大幅降权
if heading.startswith("# ") and body_len < 50:
combined_scores[i] *= 0.2
elif body_len < 30:
combined_scores[i] *= 0.5
# 正文含查询词 → 奖励(说明 section 真的在讲用户问的内容)
body_hits = sum(1 for t in query_tokens_set if t in self._body_token_sets[i])
if body_hits >= 2:
combined_scores[i] *= 1.3
elif body_hits >= 1:
combined_scores[i] *= 1.1
# 短查询惩罚:分词数 ≤2 的短查询(如图标残片/短词)得分大幅降权
# 防止 OCR 碎片中的高 IDF 词(如"下载")获得超过真实用户消息的分数
n_tokens = len(tokens)
@@ -413,19 +431,26 @@ class KnowledgeBase:
heading = doc.get("heading", "")
body = doc.get("body", doc.get("content", ""))
h_tokens = set(t for t in jieba.cut(heading) if len(t) >= 2)
# heading 包含查询关键词 → bonus(5.0 每命中词,足够翻转 TF-IDF 差距)
# heading 包含查询关键词 → 适度 bonus(2.0 每命中词)
heading_hits = len(query_tokens & h_tokens)
bonus = heading_hits * 5.0
# heading 包含查询连续短语 → 额外 bonus(如查询含"奖励配置"且 heading 含"奖励配置")
bonus = heading_hits * 2.0
# heading 包含查询连续短语 → 额外 bonus
for qlen in range(min(len(query_stripped), 8), 3, -1):
for start in range(len(query_stripped) - qlen + 1):
phrase = query_stripped[start:start+qlen]
if len(phrase) >= 4 and phrase in heading:
bonus += 8.0
bonus += 6.0
break
# body 包含查询连续短语 → 强 bonus(说明正文真的在讲用户问的内容)
for qlen in range(min(len(query_stripped), 8), 3, -1):
for start in range(len(query_stripped) - qlen + 1):
phrase = query_stripped[start:start+qlen]
if len(phrase) >= 4 and phrase in body:
bonus += 10.0
break
# 文档头部惩罚:heading 是单层 # + 文档名(如 "# php管理员后端"),且无 body
if heading.startswith("# ") and (not body or len(body) < 50):
bonus -= 5.0
bonus -= 15.0
reranked.append((idx, doc, score + bonus))
reranked.sort(key=lambda x: x[2], reverse=True)
doc_sections = reranked
+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 张。