fix: 修复bot回复一次后不再回复——filter4误杀混合消息

- 新增_strip_bot_reply_fragments:在_process_ocr_output的filter4(回复残片)判定前,
  先剥离OCR拼接到一起的bot旧回复片段,保留用户真实提问(OCR噪声鲁棒:归一化标点/空格)
- 修复根因:bot回复后,后续截图把'用户新消息+bot旧回复'拼成一行,filter4检测到bot旧
  回复文本即把整段(含用户新消息)误判为残片丢弃,导致bot永远不再回复
- _is_reply_fragment改分数阈值:原Latin>=6/CJK>=5过于敏感,用户昵称含'Minecraft'或
  问句含服务器名'草原探险服务器'即被误杀;改为需公共子串>=15或(>=8且占较短文本>=60%),
  仅当文本'大部分'是bot回复时才判为残片,保留正常用户问句
- 同步修复_group聊天慢于私聊:消息不再被误杀→不再陷入同帧反复OCR死循环,回复延迟回归15~20s
This commit is contained in:
youpaishidifu
2026-08-09 14:20:36 +08:00
parent 9c16d6da0d
commit 9682ba4112
+114 -30
View File
@@ -3140,11 +3140,16 @@ class MessageMonitor:
if msg:
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
if msg:
if self._is_reply_fragment(msg):
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 回复残片跳过: {msg[:60]!r}")
else:
logger.info(f"[ocr] stage[{i}] [filter/放行] 通过所有过滤器,添加到expanded: {msg[:60]!r}")
expanded.append(msg)
# ⭐ 先剥离 bot 旧回复片段:OCR 把「用户新消息 + bot 旧回复」
# 拼成一行时,filter4 会误判整段为残片丢弃,导致用户新消息
# 永久丢失(bot 回复一次后再也不回复)。剥离后保留用户提问。
msg = self._strip_bot_reply_fragments(msg)
if msg:
if self._is_reply_fragment(msg):
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 回复残片跳过: {msg[:60]!r}")
else:
logger.info(f"[ocr] stage[{i}] [filter/放行] 通过所有过滤器,添加到expanded: {msg[:60]!r}")
expanded.append(msg)
buf = ''
last_ts = part
_inherited_past = False # 新时间戳重置过去日期标记
@@ -3181,16 +3186,19 @@ class MessageMonitor:
if msg:
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
if msg:
if self._is_reply_fragment(msg):
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 回复残片跳过: {msg[:60]!r}")
else:
logger.info(f"[ocr] stage[{i}] [filter/放行] 尾部消息通过所有过滤器,添加到expanded: {msg[:60]!r}")
# EasyOCR 常把横向相邻的两条消息(用户消息+群昵称/UI文字)
# 检测为同一行,导致"兑换码导入错误时如何处置?游戏专用小筑憩-草一"
# 这种合并文本。尝试在中文句尾标点处拆分为多条。
split_msgs = self._split_merged_ocr_text(msg)
for sm in split_msgs:
expanded.append(sm)
# ⭐ 同上:尾部消息也要先剥离 bot 旧回复片段,避免整段误判为残片
msg = self._strip_bot_reply_fragments(msg)
if msg:
if self._is_reply_fragment(msg):
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 回复残片跳过(尾部): {msg[:60]!r}")
else:
logger.info(f"[ocr] stage[{i}] [filter/放行] 尾部消息通过所有过滤器,添加到expanded: {msg[:60]!r}")
# EasyOCR 常把横向相邻的两条消息(用户消息+群昵称/UI文字)
# 检测为同一行,导致"兑换码导入错误时如何处置?游戏专用小筑憩-草一"
# 这种合并文本。尝试在中文句尾标点处拆分为多条。
split_msgs = self._split_merged_ocr_text(msg)
for sm in split_msgs:
expanded.append(sm)
# 更新跨行继承时间戳
if last_ts:
_inherited_ts = last_ts
@@ -4222,8 +4230,82 @@ class MessageMonitor:
logger.debug(f"[ocr] 句尾标点拆分: {[r[:60] for r in result]}")
return result
def _norm_frag(self, s: str) -> str:
"""bot 回复片段比对用的归一化:去空格、去中英文标点,抵抗 OCR 噪声。"""
s = re.sub(r'\s+', '', s)
for a in (',', '。', '、', ';', ':', '!', '?', ',', '.', ';', ':', '!', '?',
' ', ' ', '~', '~'):
s = s.replace(a, '')
return s
def _strip_bot_reply_fragments(self, text: str) -> str:
"""从 OCR 识别出的单条消息中剥离 bot 自己的旧回复片段,保留用户真实提问。
与 _poll 中的「反向包含剥离」逻辑保持一致,但此处用于在 _process_ocr_output
的 filter4(回复残片)判定【之前】清理消息,避免「用户新消息 + bot 旧回复」
被 OCR 拼成一行后,filter4 因检测到 bot 旧回复文本而把整段(含用户新消息)
误判为残片整体丢弃。
两种策略:
1. 精确子串:某条 bot 旧回复 r 完整出现在 text 中 → 直接删除 r
2. 反向包含:OCR 把「用户新消息 + bot 旧回复」按逗号拼成一行,且 r 不是
text 的子串,但 text 中逗号分隔的片段可能是 r 的子串 → 逐片段判断,
剥离属于 bot 回复的片段,保留用户片段。
⭐ OCR 噪声鲁棒性:bot 旧回复被 OCR 重新识别时,标点(,。)、全/半角、空格
常与原文不一致(如原文"Minecraft 生存服务器,拥有完善的领地系统"被识别成
"Minecraft生存服务器。拥有完善的领地系统"),导致精确子串匹配失效。
比较前对双方做归一化(去空格、去标点),让被 OCR 扭曲的 bot 回复片段也能被识别并剥离。
"""
if not text or not text.strip():
return text
replied = self._replied_texts
if not replied:
return text
ts = text
if len(ts) > 5:
stripped_bot = False
# 策略1:精确子串(归一化后比对,抵抗 OCR 标点/空格噪声)
for r in replied:
if len(r) >= 6 and self._norm_frag(r) and self._norm_frag(r) in self._norm_frag(ts):
ts = re.sub(re.escape(r), ' ', ts)
stripped_bot = True
# 策略2:反向包含剥离——逗号分段后逐段判断是否属于 bot 回复
_has_comma = ',' in ts or (',' in ts and re.search(r'[\u4e00-\u9fff],[\u4e00-\u9fff]', ts))
if not stripped_bot and _has_comma:
parts = re.split(r'[,,]', ts)
parts = [p.strip() for p in parts if p.strip()]
if len(parts) >= 2:
user_parts = []
for part in parts:
if len(part) < 2:
continue
is_bot_frag = False
npart = self._norm_frag(part)
if npart:
for r in replied:
nr = self._norm_frag(r)
if len(nr) >= 2 and npart in nr:
is_bot_frag = True
break
if not is_bot_frag:
user_parts.append(part)
if len(user_parts) < len(parts):
ts = ','.join(user_parts).strip(',').strip()
stripped_bot = True
if stripped_bot:
ts = re.sub(r'\s+', ' ', ts).strip().strip(',').strip()
return ts
def _is_reply_fragment(self, text: str) -> bool:
"""检查文本是否为 bot 回复内容的碎片(OCR 截取的 md 残片/跨行碎片)。"""
"""检查文本是否为 bot 回复内容的碎片(OCR 截取的 md 残片/跨行碎片)。
⭐ 判定稳健性(防误杀用户新消息):仅当文本「大部分」是 bot 旧回复时才判为残片,
要求公共子串:长度≥15,或(长度≥8 且 占较短文本≥60%)。这样既能拦住
「bot 回复回声」(整段≈bot 回复,公共子串占文本大头),又不会因用户消息中恰好
含有 bot 回复里的词(如服务器名「草原探险服务器」7字、专有名词「Minecraft」)
而被整体误杀——用户问句只与 bot 回复共享一个短语,远不到 60%。
"""
logger.debug(f"[ocr] 过滤器4: _is_reply_fragment 检查: {text[:80]!r}")
if not self._replied_texts:
logger.debug(f"[ocr] 过滤器4结果: 无已回复文本,返回False")
@@ -4232,23 +4314,25 @@ class MessageMonitor:
logger.debug(f"[ocr] 过滤器4结果: 文本太短({len(text)}<4),返回False")
return False
import difflib
# 原始文本匹配(含字母数字)
t_norm = self._norm_frag(text)
t_cjk = ''.join(c for c in text if '\u4e00' <= c <= '\u9fff')
for r in self._replied_texts:
if len(r) < 5:
continue
sm = difflib.SequenceMatcher(None, text, r)
if sm.find_longest_match(0, len(text), 0, len(r)).size >= 6:
logger.debug(f"[ocr] 过滤器4结果: 原始文本匹配成功,返回True")
return True
# CJK-only 降阈值
cjk_t = ''.join(c for c in text if '\u4e00' <= c <= '\u9fff')
if len(cjk_t) >= 5:
for r in self._replied_texts:
r_cjk = ''.join(c for c in r if '\u4e00' <= c <= '\u9fff')
if len(r_cjk) < 5:
continue
sm = difflib.SequenceMatcher(None, cjk_t, r_cjk)
if sm.find_longest_match(0, len(cjk_t), 0, len(r_cjk)).size >= 5:
# Latin/归一化匹配(要求占比较短文本≥60%,或足够长)
r_norm = self._norm_frag(r)
if t_norm and r_norm:
sm = difflib.SequenceMatcher(None, t_norm, r_norm)
lm = sm.find_longest_match(0, len(t_norm), 0, len(r_norm)).size
if lm >= 15 or (lm >= 8 and lm >= 0.6 * min(len(t_norm), len(r_norm))):
logger.debug(f"[ocr] 过滤器4结果: 文本匹配成功,返回True")
return True
# CJK-only 匹配(同样要求占比较短文本≥60%,避免用户问句含服务器名被误杀)
r_cjk = ''.join(c for c in r if '\u4e00' <= c <= '\u9fff')
if len(t_cjk) >= 5 and len(r_cjk) >= 5:
sm = difflib.SequenceMatcher(None, t_cjk, r_cjk)
clm = sm.find_longest_match(0, len(t_cjk), 0, len(r_cjk)).size
if clm >= 15 or (clm >= 8 and clm >= 0.6 * min(len(t_cjk), len(r_cjk))):
logger.debug(f"[ocr] 过滤器4结果: CJK文本匹配成功,返回True")
return True
logger.debug(f"[ocr] 过滤器4结果: 未匹配到已回复文本,返回False")