fix(#1574): Bot自言自语拦截v2——拆行比对修复多行回复漏过

根因:bot回复是多行(含表格),OCR只读首行(如'php有2种登陆模式:')。
原比对把整个多行回复当一个字符串比较,CJK骨架/Jaccard被表格行稀释→漏过。

修复三处拆行:
1. recent_replies精确匹配:逐行 strip== 比对
2. Bot自言自语拦截(8928):拆行做骨架/Jaccard/子串三策略
3. _last_bot_reply_content检查(9222):拆行做SequenceMatcher/骨架/子串

新增策略3:CJK子串包含——OCR文本的CJK骨架是bot行CJK骨架的子串即拦截
(覆盖OCR截断场景:'有2种登陆模式'是'有2种登陆模式模式输入密码直登'的子串)
This commit is contained in:
youpaishidifu
2026-08-23 19:11:25 +08:00
parent 308bae1fa5
commit 25b844098f
+56 -8
View File
@@ -8892,7 +8892,21 @@ class MessageMonitor:
continue
# 剥离后继续走后续过滤(不 continue),让干净的用户提问正常进入 new_items
# ⭐ 近5分钟时间窗口查重:如果消息在近5分钟的bot回复中,跳过
if recent_replies and ts in recent_replies:
# ⭐ v2:拆行匹配——recent_replies存完整多行回复,OCR只读首行
if recent_replies:
_in_recent = False
for _rr in recent_replies:
if ts in _rr:
_in_recent = True
break
# 拆行:OCR读到的可能是bot回复的某一行
for _rr_line in _rr.split('\n'):
if ts.strip() == _rr_line.strip():
_in_recent = True
break
if _in_recent:
break
if _in_recent:
logger.debug(f"[{platform}] 时间窗口查重跳过(近5分钟): {ts[:50]}")
continue
# _replied_texts 优先检查(永不裁切,100% 拦住自己的回复)
@@ -8908,6 +8922,9 @@ class MessageMonitor:
# 修复:对OCR文本做归一化(去时间戳、去标点、去空格、CJK骨架提取),
# 与最近bot回复做归一化比对。阈值宽松(0.60)确保拦截,同时用CJK骨架
# 做二重确认防误杀(用户真实新消息的CJK骨架不可能与bot回复完全相同)。
# ⭐ Task #1574 v2:拆行比对——OCR只读bot回复的首行(如"php有2种登陆模式:"),
# 但recent_replies存的是完整多行回复(含表格)。整块比对Jaccard必然低分漏过。
# 修复:把每条bot回复拆成行,逐行比对。策略3新增:子串包含检查。
if len(ts) >= 6 and self._msg_history and recent_replies:
try:
# 归一化函数:去时间戳、去标点空格、去英文
@@ -8922,11 +8939,17 @@ class MessageMonitor:
_self_skip = False
# 与最近5分钟的bot回复做归一化比对
for br in recent_replies:
br_norm = _norm_self_check(br)
# ⭐ 拆行:把多行回复拆成独立行,逐行比对
# 防止OCR只读首行时被完整回复的长文本"稀释"导致Jaccard不达标
br_lines = [ln.strip() for ln in br.split('\n') if ln.strip()]
# 也把完整br加入比对列表(保留向后兼容)
_br_candidates = br_lines if br_lines else [br]
for _br in _br_candidates:
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')
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
@@ -8941,6 +8964,17 @@ class MessageMonitor:
f"jaccard={inter/union:.2f} ts={ts[:40]}")
_self_skip = True
break
# 策略3(v2新增):子串包含——OCR文本是bot某行的子串
# 例:OCR "php有2种登陆模式:" vs bot行 "php有2种登陆模式:| 模式 |"
# 归一化后用CJK做子串检查
if len(ts_norm) >= 4 and len(br_norm) >= 6:
if ts_norm in br_norm or br_norm in ts_norm:
logger.info(f"[{platform}] Bot自言自语拦截(子串包含): "
f"ts_norm={ts_norm[:30]} br_norm={br_norm[:30]}")
_self_skip = True
break
if _self_skip:
break
if _self_skip:
continue
except Exception:
@@ -9187,21 +9221,35 @@ class MessageMonitor:
cleaned_item = self._clean_ocr_prefix(item)
# ⭐ 检查是否与最近bot回复内容相同(OCR噪声干扰,把bot回复当作用户消息)
# ⭐ Task #1574: 降低阈值 0.9→0.65,骨架长度 6→4,防止自言自语漏过
# ⭐ Task #1574 v2: 拆行比对——bot回复是多行(含表格),OCR只读首行
# 整块比对骨架/相似度都会被表格行"稀释"而漏过。改为逐行比对。
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()
_last_reply_lines = [ln.strip() for ln in last_reply.split('\n') if ln.strip()]
if not _last_reply_lines:
_last_reply_lines = [last_reply]
_blocked_by_reply = False
for _lr in _last_reply_lines:
similarity = SequenceMatcher(None, cleaned_item, _lr).ratio()
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 # 跳过本条消息,处理下一条
_blocked_by_reply = True
break
# ⭐ 2026-08-12: 中文骨架匹配(吸收 OCR 拉丁混淆 php↔4p)
# 字符级相似度跌破 65% 时,用中文骨架兜底拦截
# ⭐ Task #1574: 放宽骨架长度 6→4,覆盖短回复场景
sk_cur = _cn_skeleton(cleaned_item)
sk_rep = _cn_skeleton(last_reply)
sk_rep = _cn_skeleton(_lr)
if len(sk_cur) >= 4 and sk_rep and sk_cur == sk_rep:
logger.info(f"[{platform}] OCR噪声检测(骨架匹配): 与最近bot回复中文骨架相同,跳过: {cleaned_item[:60]}")
_blocked_by_reply = True
break
# ⭐ v2新增:子串包含——OCR文本是bot行的子串(如首行不含表格列)
if len(sk_cur) >= 4 and sk_rep and (sk_cur in sk_rep or sk_rep in sk_cur):
logger.info(f"[{platform}] OCR噪声检测(骨架子串): {sk_cur[:20]} in {sk_rep[:30]}, 跳过: {cleaned_item[:60]}")
_blocked_by_reply = True
break
if _blocked_by_reply:
seen.add(cleaned_item)
self._last_new_msg_time[platform] = time.time()
continue