fix(ocr): 修复多时间戳场景下用户提问被误丢 (#1531 #1532)

1. stage splitting 时间戳规整:第二段分隔符由 \2(同分隔符) 放宽为 [.;:],
   使 15;27.05 这类 EasyOCR 混合分隔读法被正确识别为 15:27:05,
   修复 _inherited_ts 不更新导致下方消息继承陈旧时间戳被'尾部较老'误杀。
2. 过滤器层调整顺序:先剥 bot 旧回复片段,再剥 md 尾巴。OCR 把
   '用户新消息+bot旧回复'拼行时,md 尾巴剥离会在 bot 回复的 '::|::'
   残片处切断、误删用户提问,导致 filter4 整体丢弃。
3. _is_qq_ui_element 规则6:日志措辞修正(跳过->豁免/放行),并新增
   含@+疑问词直接放行的纵深防御,避免用户提问被 UI 规则误杀。
This commit is contained in:
youpaishidifu
2026-08-12 16:48:26 +08:00
parent 9d44b8b1d1
commit d043da57e3
+60 -34
View File
@@ -3233,8 +3233,12 @@ class MessageMonitor:
# 相邻的真实新消息(过滤1与stage继承双重误判)。
# 后顾 ?<![;.0-9A-Za-z%]:匹配起点前不能在URL字符后,防止 0.50;57 中
# 的子串 50;57 被单独转换(50:57 会被当合法时间戳)。
# ⭐ 2026-08-12: 第二段分隔符放宽为任意 [.;:](原 \2 要求前后分隔符相同,
# 但 EasyOCR 常把 15:27:05 读成 15;27.05,';' 与 '.' 不同 → 整串不被识别为
# 时间戳 → last_ts/_inherited_ts 不更新 → 其下方消息继承陈旧时间戳被"尾部较老"误杀。
# 与 _line_has_fresh_ts (5179) 的放宽逻辑保持一致。
rl = re.sub(
r'(?<![;.0-9A-Za-z%])(\d{1,2})([.;])(\d{2})(?:\2(\d{2}))?(?![;.0-9A-Za-z%])',
r'(?<![;.0-9A-Za-z%])(\d{1,2})([.;])(\d{2})(?:[.;:](\d{2}))?(?![;.0-9A-Za-z%])',
lambda m: f'{m.group(1)}:{m.group(3)}:{m.group(4)}' if m.group(4) else f'{m.group(1)}:{m.group(3)}',
rl)
# ⭐ 规整「逐字空格」时间戳:OCR 把 16:28:40 拆成 1 6 : 2 8,否则 re.split 找不到时间戳 → 漏消息
@@ -3277,26 +3281,31 @@ class MessageMonitor:
last_ts = part
continue
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
# ⭐ 2026-08-11 先剥 md 尾巴:md 在末尾→切尾巴留用户提问;
# md 大头/混中间→None 整段丢弃,防 md 尾巴入库撞关键词
md_stripped = self._strip_markdown_tail(buf.strip())
if md_stripped is None:
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md大头整段丢弃: {buf.strip()[:80]!r}")
elif md_stripped != buf.strip():
logger.info(f"[ocr] stage[{i}] [filter3/md] 剥离md尾巴: {buf.strip()[:60]!r} -> {md_stripped!r}")
if md_stripped and self._is_markdown_document(md_stripped):
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md文档跳过: {md_stripped[:80]!r}")
elif md_stripped and self._is_noise_text(md_stripped):
logger.info(f"[ocr] stage[{i}] [filter2/噪声] DROP 噪声文本跳过: {md_stripped[:80]!r}")
# ⭐ 2026-08-12 调整顺序:必须先剥离 bot 旧回复片段,再剥 md 尾巴。
# 原因:OCR 常把「用户新消息 + bot 旧回复」拼成一行,bot 回复若含
# '::|::' 等 UI 残片,_strip_markdown_tail 会在第一个 '|' 处切断,
# 误删其后真正的用户提问(如 "@游戏专用 PHP管理员端的那登录密码是多少?"),
# 导致用户消息被后续 filter4 整体丢弃。先剥离 bot 回复保留用户提问,
# 再走 md/噪声/残片过滤,用户提问才能存活。
_msg_bot = self._strip_bot_reply_fragments(buf.strip())
if not _msg_bot:
logger.debug(f"[ocr] stage[{i}] [filter/bot] bot旧回复整体剥离后为空,跳过: {buf.strip()[:60]!r}")
else:
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', md_stripped or '')
if msg:
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
# ⭐ 剥 md 尾巴:md 在末尾→切尾巴留用户提问;
# md 大头/混中间→None 整段丢弃,防 md 尾巴入库撞关键词
md_stripped = self._strip_markdown_tail(_msg_bot)
if md_stripped is None:
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md大头整段丢弃: {_msg_bot[:80]!r}")
elif md_stripped != _msg_bot:
logger.info(f"[ocr] stage[{i}] [filter3/md] 剥离md尾巴: {_msg_bot[:60]!r} -> {md_stripped!r}")
if md_stripped and self._is_markdown_document(md_stripped):
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md文档跳过: {md_stripped[:80]!r}")
elif md_stripped and self._is_noise_text(md_stripped):
logger.info(f"[ocr] stage[{i}] [filter2/噪声] DROP 噪声文本跳过: {md_stripped[:80]!r}")
else:
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', md_stripped or '')
if msg:
# ⭐ 先剥离 bot 旧回复片段:OCR 把「用户新消息 + bot 旧回复」
# 拼成一行时,filter4 会误判整段为残片丢弃,导致用户新消息
# 永久丢失(bot 回复一次后再也不回复)。剥离后保留用户提问。
msg = self._strip_bot_reply_fragments(msg)
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
if msg:
# ⭐ 剥离QQ用户昵称,防止昵称拼凑进消息文本导致查库关键词被压住
msg = self._strip_qq_nickname(msg)
@@ -3339,22 +3348,27 @@ class MessageMonitor:
# else: 时间戳新鲜(<5min),直接保留
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
if buf.strip():
md_stripped = self._strip_markdown_tail(buf.strip())
if md_stripped is None:
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md大头整段丢弃(尾部): {buf.strip()[:80]!r}")
elif md_stripped != buf.strip():
logger.info(f"[ocr] stage[{i}] [filter3/md] 剥离md尾巴(尾部): {buf.strip()[:60]!r} -> {md_stripped!r}")
if md_stripped and self._is_markdown_document(md_stripped):
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md文档跳过(尾部): {md_stripped[:80]!r}")
elif md_stripped and self._is_noise_text(md_stripped):
logger.info(f"[ocr] stage[{i}] [filter2/噪声] DROP 噪声文本跳过(尾部): {md_stripped[:80]!r}")
# ⭐ 2026-08-12 必须先剥离 bot 旧回复片段(同 for 循环块理由):
# OCR 把「用户新消息 + bot 旧回复」拼成一行时,先剥 bot 回复才能
# 保住用户提问,否则 _strip_markdown_tail 会在 bot 回复的 '::|::'
# 残片处切断、误删用户提问,导致 filter4 整体丢弃。
_msg_bot = self._strip_bot_reply_fragments(buf.strip())
if not _msg_bot:
logger.debug(f"[ocr] stage[{i}] [filter/bot] 尾部bot旧回复整体剥离后为空,跳过: {buf.strip()[:60]!r}")
else:
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', md_stripped or '')
if msg:
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
md_stripped = self._strip_markdown_tail(_msg_bot)
if md_stripped is None:
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md大头整段丢弃(尾部): {_msg_bot[:80]!r}")
elif md_stripped != _msg_bot:
logger.info(f"[ocr] stage[{i}] [filter3/md] 剥离md尾巴(尾部): {_msg_bot[:60]!r} -> {md_stripped!r}")
if md_stripped and self._is_markdown_document(md_stripped):
logger.info(f"[ocr] stage[{i}] [filter3/md] DROP md文档跳过(尾部): {md_stripped[:80]!r}")
elif md_stripped and self._is_noise_text(md_stripped):
logger.info(f"[ocr] stage[{i}] [filter2/噪声] DROP 噪声文本跳过(尾部): {md_stripped[:80]!r}")
else:
msg = re.sub(r'\s*(?:[A-Za-z0-9]~)+\s*$', '', md_stripped or '')
if msg:
# ⭐ 同上:尾部消息也要先剥离 bot 旧回复片段,避免整段误判为残片
msg = self._strip_bot_reply_fragments(msg)
msg = re.sub(r'\s+#[\u4e00-\u9fff][^\s#\n]{0,30}$', '', msg).strip()
if msg:
# ⭐ 剥离QQ用户昵称,防止昵称拼凑进消息文本导致查库关键词被压住
msg = self._strip_qq_nickname(msg)
@@ -5549,6 +5563,16 @@ class MessageMonitor:
if not line:
return False
s = line.strip()
# ⭐ 2026-08-12 纵深防御:含 @ 且含疑问词的行一律视为用户提问,
# 绝不被任何 UI 规则(规则1-6)判为界面元素而丢弃。即使后续规则误匹配,
# 此处直接放行,确保用户提问永远不会因 UI 过滤而丢失。
if '@' in s:
_qq_qwords = ('?', '?', '请问', '是不是', '有没有', '怎么', '如何', '什么',
'哪', '谁', '多少', '几', '为什么', '是否', '能否', '可以吗',
'好吗', '对吗', '是吗', '对不对', '好不好', '行不行', '可不可以')
if any(q in s for q in _qq_qwords):
logger.debug(f"[qq-ui] 含@+疑问词,直接放行(非UI元素): {s}")
return False
# 1) 图片文件引用: !0(img/xxx.png) 或 !0(img/xxx.jpg) 等
# QQ 发送图片后,OCR 识别出的文件引用格式
if re.match(r'^!0?\s*\(?\s*img/', s, re.IGNORECASE):
@@ -5596,7 +5620,9 @@ class MessageMonitor:
'哪', '谁', '多少', '几', '为什么', '是否', '能否', '可以吗',
'好吗', '对吗', '是吗', '对不对', '好不好', '行不行', '可不可以')
if any(q in s for q in _question_words):
logger.debug(f"[qq-ui] 规则6跳过(含疑问词): {s}")
# ⭐ 2026-08-12 日志措辞修正:此处 return False 表示「不是UI元素、
# 放行(当作用户消息处理)」,并非丢弃消息。"跳过"易误解为消息被跳过。
logger.debug(f"[qq-ui] 规则6豁免(含疑问词,放行): {s}")
return False
# 匹配模式:可选中文前缀(群名片) + @ + 任意字符(可能乱码) + 空格 + 用户名(可多词)
# 示例:"有人@拜 youpaishidifu"、"用户@张三 测试"、"@李四 123"