diff --git a/monitor.py b/monitor.py index 7165ae5..99fb33a 100644 --- a/monitor.py +++ b/monitor.py @@ -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")