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:
+82
-34
@@ -8892,9 +8892,23 @@ class MessageMonitor:
|
||||
continue
|
||||
# 剥离后继续走后续过滤(不 continue),让干净的用户提问正常进入 new_items
|
||||
# ⭐ 近5分钟时间窗口查重:如果消息在近5分钟的bot回复中,跳过
|
||||
if recent_replies and ts in recent_replies:
|
||||
logger.debug(f"[{platform}] 时间窗口查重跳过(近5分钟): {ts[:50]}")
|
||||
continue
|
||||
# ⭐ 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% 拦住自己的回复)
|
||||
if ts in replied:
|
||||
continue
|
||||
@@ -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,24 +8939,41 @@ class MessageMonitor:
|
||||
_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
|
||||
# ⭐ 拆行:把多行回复拆成独立行,逐行比对
|
||||
# 防止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')
|
||||
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
|
||||
# 策略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
|
||||
@@ -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()
|
||||
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)
|
||||
# 字符级相似度跌破 65% 时,用中文骨架兜底拦截
|
||||
# ⭐ Task #1574: 放宽骨架长度 6→4,覆盖短回复场景
|
||||
sk_cur = _cn_skeleton(cleaned_item)
|
||||
sk_rep = _cn_skeleton(last_reply)
|
||||
if len(sk_cur) >= 4 and sk_rep and sk_cur == sk_rep:
|
||||
logger.info(f"[{platform}] OCR噪声检测(骨架匹配): 与最近bot回复中文骨架相同,跳过: {cleaned_item[:60]}")
|
||||
_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]}")
|
||||
_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(_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
|
||||
|
||||
Reference in New Issue
Block a user