fix: 修复OCR自言自语循环 - Bot回复归一化去重拦截

根因:OCR读取bot自己回复时,文本格式差异(时间戳前缀、标点全/半角、空格)
导致精确/模糊匹配全部失效,bot反复回复自己的旧回复。

修复(Task #1574):
1. 去重管道最前端新增「Bot自言自语拦截」:
   - 归一化OCR文本(去时间戳、去标点空格、只保留CJK)
   - CJK骨架完全相同 → 直接拦截
   - 归一化Jaccard相似度>=0.60 → 拦截
2. _last_bot_reply_content检查:
   - 相似度阈值从0.9降至0.65
   - CJK骨架长度要求从6降至4,覆盖短回复场景
This commit is contained in:
youpaishidifu
2026-08-23 17:59:59 +08:00
parent 32f36dc5fd
commit 308bae1fa5
+48 -3
View File
@@ -8902,6 +8902,49 @@ class MessageMonitor:
if self._fuzzy_duplicate(ts, replied, threshold=0.75):
logger.debug(f"[{platform}] 回复模糊跳过: {ts[:50]}")
continue
# ⭐ Task #1574: Bot自言自语拦截(归一化比对)
# 根因:OCR读取bot自己的回复时,文本格式与存储的回复有差异
# (时间戳前缀、标点全/半角、空格数量),导致上述精确/模糊匹配全部失效。
# 修复:对OCR文本做归一化(去时间戳、去标点、去空格、CJK骨架提取),
# 与最近bot回复做归一化比对。阈值宽松(0.60)确保拦截,同时用CJK骨架
# 做二重确认防误杀(用户真实新消息的CJK骨架不可能与bot回复完全相同)。
if len(ts) >= 6 and self._msg_history and recent_replies:
try:
# 归一化函数:去时间戳、去标点空格、去英文
def _norm_self_check(s):
s = re.sub(r'\d{1,2}:\d{2}(?::\d{2})?', '', s) # 去时间戳
s = re.sub(r'[^\u4e00-\u9fff]', '', s) # 只保留CJK
return s
ts_norm = _norm_self_check(ts)
if len(ts_norm) >= 4:
# CJK骨架提取:连续中文字符序列
sk_ts = ''.join(c for c in ts if '\u4e00' <= c <= '\u9fff')
_self_skip = False
# 与最近5分钟的bot回复做归一化比对
for br in recent_replies:
br_norm = _norm_self_check(br)
if not br_norm or len(br_norm) < 4:
continue
# 策略1:CJK骨架完全相同(OCR把同一段中文读了两遍)
sk_br = ''.join(c for c in br if '\u4e00' <= c <= '\u9fff')
if len(sk_ts) >= 4 and sk_ts == sk_br:
logger.info(f"[{platform}] Bot自言自语拦截(骨架相同): {ts[:50]}")
_self_skip = True
break
# 策略2:归一化后Jaccard相似度>=0.6(吸收OCR标点/空格差异)
set_ts = set(ts_norm)
set_br = set(br_norm)
inter = len(set_ts & set_br)
union = len(set_ts | set_br)
if union > 0 and inter / union >= 0.60:
logger.info(f"[{platform}] Bot自言自语拦截(归一化相似): "
f"jaccard={inter/union:.2f} ts={ts[:40]}")
_self_skip = True
break
if _self_skip:
continue
except Exception:
pass
# _replied_texts 子串包含:OCR 提取的 md 表格细胞值(如"依赖插件可选/必选")
# 是 bot 回复表的一部分,不是用户提问。长度>5才检查避免误杀短消息。
# OCR 常把 ,转 , 等标点差异,做双向子串检查(原始+归一化标点)
@@ -9143,19 +9186,21 @@ class MessageMonitor:
# ⭐ 清理OCR噪声前缀(如"必选,")后再搜索知识库
cleaned_item = self._clean_ocr_prefix(item)
# ⭐ 检查是否与最近bot回复内容相同(OCR噪声干扰,把bot回复当作用户消息)
# ⭐ Task #1574: 降低阈值 0.9→0.65,骨架长度 6→4,防止自言自语漏过
last_reply = self._last_bot_reply_content.get(platform, "")
if last_reply and cleaned_item and len(cleaned_item) >= 4:
similarity = SequenceMatcher(None, cleaned_item, last_reply).ratio()
if similarity >= 0.9:
if similarity >= 0.65:
logger.info(f"[{platform}] OCR噪声检测: 与最近bot回复相似度{similarity:.2%},跳过: {cleaned_item[:60]}")
seen.add(cleaned_item)
self._last_new_msg_time[platform] = time.time()
continue # 跳过本条消息,处理下一条
# ⭐ 2026-08-12: 中文骨架匹配(吸收 OCR 拉丁混淆 php↔4p)
# 字符级相似度跌破 90% 时(php→4p 整句 ~83%),用中文骨架兜底拦截
# 字符级相似度跌破 65% 时,用中文骨架兜底拦截
# ⭐ Task #1574: 放宽骨架长度 6→4,覆盖短回复场景
sk_cur = _cn_skeleton(cleaned_item)
sk_rep = _cn_skeleton(last_reply)
if len(sk_cur) >= 6 and sk_rep and sk_cur == sk_rep:
if len(sk_cur) >= 4 and sk_rep and sk_cur == sk_rep:
logger.info(f"[{platform}] OCR噪声检测(骨架匹配): 与最近bot回复中文骨架相同,跳过: {cleaned_item[:60]}")
seen.add(cleaned_item)
self._last_new_msg_time[platform] = time.time()