修复时间戳继承导致回复旧消息的问题 (Task #1710)

QQ客户端的时间戳渲染在消息下方(作为分隔符),但代码假设时间戳在消息上方,导致新消息时间戳被错误地'继承'给旧消息,机器人回复旧消息而非新消息。

三处关键修复:
1. stage splitting阶段:使用当前时间戳(part)处理文本,而非last_ts
2. 添加后处理步骤:重新分配时间戳,使每条消息使用其下方的时间戳
3. freshness_scores计算:优先使用前方(下一个)时间戳,后方时间戳惩罚2倍
This commit is contained in:
youpaishidifu
2026-09-14 19:16:03 +08:00
parent 16b5a388e9
commit 811c26db9d
+90 -56
View File
@@ -4275,6 +4275,10 @@ class MessageMonitor:
last_ts = None if _is_qq_system_msg else _inherited_ts last_ts = None if _is_qq_system_msg else _inherited_ts
# ⭐ 继承的过去日期标记:若前一阶段有"昨天"等过去前缀,继承的时间戳也跳过未来检查 # ⭐ 继承的过去日期标记:若前一阶段有"昨天"等过去前缀,继承的时间戳也跳过未来检查
_inherited_past = _had_past_date_prefix or getattr(self, '_inherited_ts_from_past', False) _inherited_past = _had_past_date_prefix or getattr(self, '_inherited_ts_from_past', False)
# ⭐ QQ时间戳在消息下方:待处理消息缓冲区
# 消息应该关联到其下方的时间戳,而不是上方的时间戳
# 当遇到时间戳时,处理之前的待处理消息
_pending_msgs = [] # [(msg_text, ...), ...] 待处理的消息列表
# ⭐ Task #1558: 完全用关键词打分控制消息留舍,OCR时间戳仅作为参考 # ⭐ Task #1558: 完全用关键词打分控制消息留舍,OCR时间戳仅作为参考
# - 不依赖时间戳丢弃消息(时间戳可能被OCR继承错误) # - 不依赖时间戳丢弃消息(时间戳可能被OCR继承错误)
# - 消息抓来后直接打分:历史消息≥2分则丢弃,新消息≤1分继续处理 # - 消息抓来后直接打分:历史消息≥2分则丢弃,新消息≤1分继续处理
@@ -4283,15 +4287,16 @@ class MessageMonitor:
for part in parts: for part in parts:
if re.match(r'^\d{1,2}:\d{1,2}', part): if re.match(r'^\d{1,2}:\d{1,2}', part):
if buf.strip(): if buf.strip():
if not last_ts: # ⭐ QQ时间戳在消息下方:时间戳属于其上方的消息
# ⭐ 无前置时间戳:位于首个时间戳之前的消息。 # 使用当前时间戳(part)处理之前的buf,而不是last_ts
# ⚠ 不再整段丢弃:可能是被裁切/漏识别时间戳的新消息, _effective_ts = part
# 交由下游DB查重(避免连续聊天时时间戳被覆盖误杀用户消息)。 if not last_ts and not _inherited_ts:
logger.info(f"[ocr] stage[{i}] [filter/放行] 无前置时间戳,交由DB查重: {buf.strip()[:60]!r}") # ⭐ 无前置时间戳也无继承时间戳:交由DB查重
logger.info(f"[ocr] stage[{i}] [filter/放行] 无时间戳,交由DB查重: {buf.strip()[:60]!r}")
else: else:
# ⭐ Task #1558: 不再因时间戳陈旧而丢弃,统一交由过滤器层+关键词打分 # ⭐ Task #1558: 不再因时间戳陈旧而丢弃,统一交由过滤器层+关键词打分
# 时间戳仅记录,不参与丢弃判断 # 使用当前时间戳(part)处理之前的buf
logger.debug(f"[ocr] stage[{i}] 时间戳{last_ts}更新,消息交由过滤器层: {buf.strip()[:60]!r}") logger.debug(f"[ocr] stage[{i}] 时间戳{_effective_ts}更新,消息交由过滤器层: {buf.strip()[:60]!r}")
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)—— # —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
# ⭐ 2026-08-12 调整顺序:必须先剥离 bot 旧回复片段,再剥 md 尾巴。 # ⭐ 2026-08-12 调整顺序:必须先剥离 bot 旧回复片段,再剥 md 尾巴。
# 原因:OCR 常把「用户新消息 + bot 旧回复」拼成一行,bot 回复若含 # 原因:OCR 常把「用户新消息 + bot 旧回复」拼成一行,bot 回复若含
@@ -4352,25 +4357,28 @@ class MessageMonitor:
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 合并消息中的残片: {_mm[:60]!r}") logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 合并消息中的残片: {_mm[:60]!r}")
else: else:
logger.info(f"[ocr] stage[{i}] [filter/放行] 合并消息片段通过: {_mm[:60]!r}") logger.info(f"[ocr] stage[{i}] [filter/放行] 合并消息片段通过: {_mm[:60]!r}")
expanded.append((_mm, last_ts)) # 保留时间戳信息 expanded.append((_mm, _effective_ts)) # 保留时间戳信息
elif not msg: elif not msg:
logger.debug(f"[ocr] stage[{i}] 剥离后为空,丢弃防空消息: {part[:40]!r}") logger.debug(f"[ocr] stage[{i}] 剥离后为空,丢弃防空消息: {part[:40]!r}")
elif self._is_reply_fragment(msg): elif self._is_reply_fragment(msg):
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 回复残片跳过: {msg[:60]!r}") logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 回复残片跳过: {msg[:60]!r}")
else: else:
logger.info(f"[ocr] stage[{i}] [filter/放行] 通过所有过滤器,添加到expanded: {msg[:60]!r}") logger.info(f"[ocr] stage[{i}] [filter/放行] 通过所有过滤器,添加到expanded: {msg[:60]!r}")
expanded.append((msg, last_ts)) # 保留时间戳信息 expanded.append((msg, _effective_ts)) # 保留时间戳信息
buf = '' buf = ''
last_ts = part last_ts = part
_inherited_past = False # 新时间戳重置过去日期标记 _inherited_past = False # 新时间戳重置过去日期标记
# ⭐ QQ时间戳在消息下方:时间戳属于其上方的消息
# 当前时间戳已经用于处理之前的buf,不再设置_inherited_ts供后续消息使用
# 后续消息需要等待下一个时间戳
# 注意:_inherited_ts 保持不变(可能是 None 或之前的值),让后续消息等待下一个时间戳
else: else:
buf += part buf += part
if buf.strip(): if buf.strip():
# 尾部检查:尾部消息位于最后一个可见时间戳之后,无独立时间戳。 # 尾部检查:尾部消息位于最后一个可见时间戳之后,无独立时间戳。
# 关键修复:过滤器层(md/噪声/回复残片)必须【始终运行】,无论继承时间戳新旧, # ⭐ QQ时间戳在消息下方:尾部消息没有自己的时间戳,使用继承的时间戳
# 之前错误地嵌在"时间戳较老"分支内,导致新鲜尾部消息(如用户刚发的提问) # _inherited_ts 可能是 None(如果本阶段没有遇到时间戳)或之前的值
# 被静默吞噬、永不进入 expanded。 # 交由下游DB查重+关键词打分
# ⭐ Task #1558: 不再因时间戳陈旧而丢弃,统一交由过滤器层+关键词打分
logger.info(f"[ocr] stage[{i}] [filter/放行] 尾部消息交由DB查重+关键词打分: {buf.strip()[:60]!r}") logger.info(f"[ocr] stage[{i}] [filter/放行] 尾部消息交由DB查重+关键词打分: {buf.strip()[:60]!r}")
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)—— # —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
if buf.strip(): if buf.strip():
@@ -4449,7 +4457,7 @@ class MessageMonitor:
logger.info(f"[ocr] stage[{i}] [filter/放行] 合并尾部消息片段通过: {_mm[:60]!r}") logger.info(f"[ocr] stage[{i}] [filter/放行] 合并尾部消息片段通过: {_mm[:60]!r}")
split_msgs = self._split_merged_ocr_text(_mm) split_msgs = self._split_merged_ocr_text(_mm)
for sm in split_msgs: for sm in split_msgs:
expanded.append((sm, last_ts)) # 保留时间戳信息 expanded.append((sm, _inherited_ts)) # 保留时间戳信息
elif not msg: elif not msg:
logger.debug(f"[ocr] stage[{i}] 尾部剥离后为空,丢弃防空消息: {part[:40]!r}") logger.debug(f"[ocr] stage[{i}] 尾部剥离后为空,丢弃防空消息: {part[:40]!r}")
elif self._is_reply_fragment(msg): elif self._is_reply_fragment(msg):
@@ -4461,7 +4469,7 @@ class MessageMonitor:
# 这种合并文本。尝试在中文句尾标点处拆分为多条。 # 这种合并文本。尝试在中文句尾标点处拆分为多条。
split_msgs = self._split_merged_ocr_text(msg) split_msgs = self._split_merged_ocr_text(msg)
for sm in split_msgs: for sm in split_msgs:
expanded.append((sm, last_ts)) # 保留时间戳信息 expanded.append((sm, _inherited_ts)) # 保留时间戳信息
# ⭐ QQ系统消息不更新继承时间戳,防止旧时间戳污染后续真实消息 # ⭐ QQ系统消息不更新继承时间戳,防止旧时间戳污染后续真实消息
if not _is_qq_system_msg: if not _is_qq_system_msg:
# 更新跨行继承时间戳 # 更新跨行继承时间戳
@@ -4474,6 +4482,46 @@ class MessageMonitor:
else: else:
logger.debug(f"[ocr] stage[{i}] QQ系统消息不更新继承时间戳: {rl[:60]!r}") logger.debug(f"[ocr] stage[{i}] QQ系统消息不更新继承时间戳: {rl[:60]!r}")
# ⭐ QQ时间戳在消息下方:重新分配时间戳
# 每条消息应关联到其下方的时间戳(即下一条消息的继承时间戳)
# 例如:消息A → 17:25 → 消息B → 17:54 → 消息C
# 消息A应使用17:25,消息B应使用17:54,消息C使用当前时间
if expanded:
# 构建时间戳位置映射:记录每个时间戳在expanded中的位置
_ts_in_expanded = {} # {ts_value: [position_indices]}
for _eidx, _eitem in enumerate(expanded):
if isinstance(_eitem, tuple) and len(_eitem) == 2:
_etxt, _ets = _eitem
if _ets:
if _ets not in _ts_in_expanded:
_ts_in_expanded[_ets] = []
_ts_in_expanded[_ets].append(_eidx)
# 为每条消息找到下一个时间戳
for _eidx in range(len(expanded)):
_eitem = expanded[_eidx]
if not isinstance(_eitem, tuple) or len(_eitem) != 2:
continue
_etxt, _ets = _eitem
if not _etxt:
continue
# 搜索下一个时间戳(在expanded中位置在当前消息之后的)
_next_ts = None
for _nidx in range(_eidx + 1, len(expanded)):
_nitem = expanded[_nidx]
if isinstance(_nitem, tuple) and len(_nitem) == 2:
_ntxt, _nts = _nitem
if _nts and _nts != _ets: # 找到不同的时间戳
_next_ts = _nts
break
if _next_ts:
# ⭐ 找到下一个时间戳,使用它(QQ时间戳在消息下方)
expanded[_eidx] = (_etxt, _next_ts)
logger.debug(f"[ocr] QQ时间戳重分配: pos {_eidx} <- 下一个时间戳 {_next_ts!r}: {_etxt[:30]!r}")
# else: 没有下一个时间戳,保持原样(使用继承的时间戳或None)
# —— 逐行过滤 —— # —— 逐行过滤 ——
# ⚠️ 急停检查:stage splitting 完成后,逐行过滤前再查一次 # ⚠️ 急停检查:stage splitting 完成后,逐行过滤前再查一次
if not self._running: if not self._running:
@@ -8834,30 +8882,6 @@ class MessageMonitor:
logger.debug(f"[ocr] 过滤器2结果: 检测到闲聊消息(无具体主题),返回True: {text!r}") logger.debug(f"[ocr] 过滤器2结果: 检测到闲聊消息(无具体主题),返回True: {text!r}")
return True return True
# ⭐ 闲聊消息检测(2026-08-22 新增)
# 特征:短文本 + 疑问词 + 无具体查询主题
# 例:"有时间吗"、"在不在"、"好的"、"嗯嗯" → 缺乏可查库的具体主题
# 检测逻辑:
# 1. 长度 ≤ 8 字符(太短无法构成完整查询)
# 2. 包含疑问词 "吗" 或 "呢"(疑问句式)
# 3. 无核心关键词(无插件名、功能词、名词等)
_chat_patterns = [
r'^有[\s\S]*?吗$', # "有时间吗"、"有东西吗"
r'^在不在$', # "在不在"
r'^你好吗?$', # "你好"、"你好吗"
r'^好的?$', # "好"、"好的"
r'^嗯(?:嗯)?$', # "嗯"、"嗯嗯"
r'^哦?$', # "哦"
r'^嗯哼$', # "嗯哼"
r'^收到$', # "收到"
r'^明白$', # "明白"
r'^知道了$', # "知道了"
]
if len(text) <= 8 and ('吗' in text or '呢' in text):
for pat in _chat_patterns:
if re.match(pat, text.strip()):
logger.debug(f"[ocr] 过滤器2结果: 检测到闲聊消息(无具体主题),返回True: {text!r}")
return True
# HTML 标签:<br>、<br/>、<p>、</p>、<div>等 # HTML 标签:<br>、<br/>、<p>、</p>、<div>等
if re.search(r'<\s*(?:br|p|div|span|a|img)\s*/?\s*>', text, re.IGNORECASE): if re.search(r'<\s*(?:br|p|div|span|a|img)\s*/?\s*>', text, re.IGNORECASE):
logger.debug(f"[ocr] 过滤器2结果: 检测到HTML标签,返回True") logger.debug(f"[ocr] 过滤器2结果: 检测到HTML标签,返回True")
@@ -10291,29 +10315,39 @@ class MessageMonitor:
if not ts: if not ts:
continue continue
# 仅当消息尚无合法时间戳时,尝试继承;否则跳过继承步骤 # ⭐ QQ时间戳在消息下方:所有非自身时间戳的消息都需要重新分配
if freshness_scores.get(ts) == -999.0: # 包括:无时间戳(-999.0)和有继承时间戳的消息
# 如果有继承的时间戳,使用它 # 因为继承的时间戳是"上方"的时间戳,QQ中应使用"下方"的时间戳
if inherited_ts: _needs_reassign = (freshness_scores.get(ts) == -999.0) or inherited_ts
inherited_score = self._timestamp_freshness_score(inherited_ts) if _needs_reassign:
if inherited_score != -999.0: # ⭐ QQ时间戳在消息下方:搜索最近的时间戳,优先使用前方(下一个)时间戳
logger.debug(f"[{platform}] 无时间戳消息继承时间戳新鲜度 " # QQ客户端的时间戳显示在消息下方,消息应关联到其下方的时间戳
f"(pos {idx} <- inherited ts {inherited_ts!r}): " # 即:消息位于时间戳T1和T2之间时,应使用T2(更晚的时间戳)
f"{inherited_score:.1f}: {ts[:20]!r}")
freshness_scores[ts] = inherited_score
# 若继承后仍无合法时间戳,搜索最近的时间戳(前后双向)
if freshness_scores.get(ts) == -999.0:
best_freshness = None best_freshness = None
best_dist = float('inf') best_dist = float('inf')
best_is_forward = False
for ts_idx, ts_fresh in _ts_positions: for ts_idx, ts_fresh in _ts_positions:
dist = abs(idx - ts_idx) # ⭐ 优先使用前方(下一个)时间戳
if dist < best_dist: # forward_dist > 0 表示时间戳在消息之后(QQ正确语义)
# forward_dist <= 0 表示时间戳在消息之前
forward_dist = ts_idx - idx
if forward_dist > 0:
# 前方时间戳:直接使用距离(不惩罚)
dist = forward_dist
is_forward = True
else:
# 后方时间戳:惩罚2倍(QQ中后方时间戳语义不正确)
dist = (-forward_dist) * 2
is_forward = False
if dist < best_dist or (dist == best_dist and is_forward and not best_is_forward):
best_dist = dist best_dist = dist
best_freshness = ts_fresh best_freshness = ts_fresh
best_is_forward = is_forward
if best_freshness is not None: if best_freshness is not None:
logger.debug(f"[{platform}] 无时间戳消息继承最近时间戳新鲜度 " _fwd_hint = "前方" if best_is_forward else "后方"
f"(pos {idx} <- ts pos {idx-best_dist if best_dist <= idx else idx+best_dist}): " _actual_pos = idx + best_dist // (1 if best_is_forward else 2)
logger.debug(f"[{platform}] 无时间戳消息继承{_fwd_hint}时间戳新鲜度 "
f"(pos {idx} <- ts pos {_actual_pos}): "
f"{best_freshness:.1f}: {ts[:20]!r}") f"{best_freshness:.1f}: {ts[:20]!r}")
freshness_scores[ts] = best_freshness freshness_scores[ts] = best_freshness
# else: 整批无时间戳,保持-999.0 # else: 整批无时间戳,保持-999.0