修复旧消息时间戳检查漏洞 + mark_message_replied AttributeError

1. 修复 mark_message_replied AttributeError:
   - 在 MessageMonitor 类中添加委托方法,转发到 MessageHistory.mark_message_replied()
   - 解决 gui.py 调用 self.monitor.mark_message_replied() 时找不到方法的问题

2. 陈旧时间戳强制丢弃 (Task #1684):
   - 将 freshness_score < 0 分支从 score += freshness_score(扣10分)改为 score = -100.0(强制丢弃)
   - 根因:短消息(+5)+疑问词(+10)可得15分,仅扣10分仍为5分>0,导致数小时前的旧消息通过阈值
   - 例:10:55发送的'什么是推广渠道违规?'在16:24被OCR读取,freshness_score=-10,基础分15-10=5>0 → 误回复
   - 修复:与 -999.0 统一,所有超过1小时的旧消息均强制丢弃,绝不触发回复

3. 保留时间戳信息:
   - 将 expanded 列表从存储字符串改为存储 (text, timestamp) 元组
   - 保留 OCR 阶段的时间戳信息,供后续两遍扫描使用
   - 优化时间戳继承逻辑,优先使用继承的时间戳计算新鲜度
This commit is contained in:
youpaishidifu
2026-09-02 16:51:10 +08:00
parent b4f8298d32
commit aad8594b06
+93 -31
View File
@@ -1166,6 +1166,11 @@ class MessageMonitor:
except Exception as e:
logger.error(f"[msg-history] 加载历史回复失败: {e}")
def mark_message_replied(self, platform: str, content: str):
"""标记消息已回复 — 委托给 MessageHistory.mark_message_replied"""
if self._msg_history:
self._msg_history.mark_message_replied(platform, content)
def set_poll_interval(self, seconds: float):
self._poll_interval = max(0.5, seconds)
@@ -4154,7 +4159,7 @@ class MessageMonitor:
logger.info(f"[ocr] [{engine_label}/后处理] line[{i}]: {rl[:120]!r}")
# —— 时间戳分割(跨行继承) ——
expanded = []
expanded = [] # 存储 (text, timestamp) 元组,保留时间戳信息
_inherited_ts = None # 跨行继承:上一行的时间戳,供当前行无时间戳时使用
self._inherited_ts_from_past = False # 跨行继承:上一行时间戳是否来自过去日期前缀
# ⭐ QQ系统消息模式:跳过这些消息的时间戳继承,防止旧时间戳覆盖真实用户消息
@@ -4283,14 +4288,14 @@ class MessageMonitor:
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 合并消息中的残片: {_mm[:60]!r}")
else:
logger.info(f"[ocr] stage[{i}] [filter/放行] 合并消息片段通过: {_mm[:60]!r}")
expanded.append(_mm)
expanded.append((_mm, last_ts)) # 保留时间戳信息
elif not msg:
logger.debug(f"[ocr] stage[{i}] 剥离后为空,丢弃防空消息: {part[:40]!r}")
elif self._is_reply_fragment(msg):
logger.info(f"[ocr] stage[{i}] [filter4/回复残片] DROP 回复残片跳过: {msg[:60]!r}")
else:
logger.info(f"[ocr] stage[{i}] [filter/放行] 通过所有过滤器,添加到expanded: {msg[:60]!r}")
expanded.append(msg)
expanded.append((msg, last_ts)) # 保留时间戳信息
buf = ''
last_ts = part
_inherited_past = False # 新时间戳重置过去日期标记
@@ -4380,7 +4385,7 @@ class MessageMonitor:
logger.info(f"[ocr] stage[{i}] [filter/放行] 合并尾部消息片段通过: {_mm[:60]!r}")
split_msgs = self._split_merged_ocr_text(_mm)
for sm in split_msgs:
expanded.append(sm)
expanded.append((sm, last_ts)) # 保留时间戳信息
elif not msg:
logger.debug(f"[ocr] stage[{i}] 尾部剥离后为空,丢弃防空消息: {part[:40]!r}")
elif self._is_reply_fragment(msg):
@@ -4392,7 +4397,7 @@ class MessageMonitor:
# 这种合并文本。尝试在中文句尾标点处拆分为多条。
split_msgs = self._split_merged_ocr_text(msg)
for sm in split_msgs:
expanded.append(sm)
expanded.append((sm, last_ts)) # 保留时间戳信息
# ⭐ QQ系统消息不更新继承时间戳,防止旧时间戳污染后续真实消息
if not _is_qq_system_msg:
# 更新跨行继承时间戳
@@ -4411,9 +4416,15 @@ class MessageMonitor:
return []
ocr_lines = expanded
texts = []
for line in ocr_lines:
if not line:
for item in ocr_lines:
if not item:
continue
# 解包元组 (text, timestamp)
if isinstance(item, tuple) and len(item) == 2:
line, inherited_ts = item
else:
line = item
inherited_ts = None
if line in ("查看更多", "收起", "选择会话开始聊天"):
continue
if re.match(r'^[✿♡♥❤★☆✦✧❀❁❂❃❄❅❆❇❈❉❊❋➠➡➢➣➤.·•●○◦◎◉◍◐◑◒◓◔◕◖◗◢◣◤◥☺☻☼♠♣♦♤♧♡♢♔♕♖♗♘♙🕐-🕧\s]{1,3}$', line):
@@ -5068,10 +5079,10 @@ class MessageMonitor:
logger.debug(f"[{platform}] OCR 过滤: 病句(问句+乱码拼接) line={line!r}")
continue
texts.append(line)
texts.append((line, inherited_ts)) # 保留时间戳信息
# ⭐ 2026-08-29 修复:在单条消息通过所有过滤器后入库,替代 _ocr_text_has_fresh_timestamp 中整屏入库
if self._msg_history:
self._msg_history.record_user_message(platform, line)
self._msg_history.record_user_message(platform, line) # 只记录文本,不记录时间戳
logger.debug(f"[{platform}] OCR -> {len(ocr_lines)} raw, {len(texts)} filtered, "
f"last=\"{texts[-1][:50] if texts else ''}\"")
@@ -6357,15 +6368,27 @@ class MessageMonitor:
result = []
SIM_THRESHOLD = 0.75 # Jaccard相似度阈值:>=0.75视为语义重复(提高防误杀)
for text in texts:
for item in texts:
# 解包元组 (text, timestamp)
if isinstance(item, tuple) and len(item) == 2:
text, inherited_ts = item
else:
text = item
inherited_ts = None
is_dup = False
# ⭐ 问句豁免:含问号/疑问词的消息不视为语义重复(用户可能重复提问)
# 例:"登录插件在哪儿下载?" vs baseline轮次的同问句 → 应放行而非静默丢弃
if re.search(r'[??]', text) or any(qw in text for qw in ('吗', '呢', '多少', '怎么', '如何', '哪', '什么')):
result.append(text)
result.append(item) # 保留原始元组
self._semantic_fingerprint_cache.append((text, now))
continue
for kept_text in result:
for kept_item in result:
# 解包保留项的元组
if isinstance(kept_item, tuple) and len(kept_item) == 2:
kept_text, _ = kept_item
else:
kept_text = kept_item
sim = self._semantic_similarity(text, kept_text)
if sim >= SIM_THRESHOLD:
logger.debug(
@@ -6375,7 +6398,7 @@ class MessageMonitor:
is_dup = True
break
if not is_dup:
result.append(text)
result.append(item) # 保留原始元组
self._semantic_fingerprint_cache.append((text, now))
return result
@@ -9964,9 +9987,10 @@ class MessageMonitor:
# 有结果了,继续处理
logger.debug(f"[{platform}] 获取到后台OCR结果: {len(texts)} 条消息")
# ⭐ Task #1680: 调试日志 - 查看texts内容
for i, t in enumerate(texts):
if t.strip():
_t = t.strip()[:60]
for i, item in enumerate(texts):
_t_text = item[0] if isinstance(item, tuple) else item
if _t_text.strip():
_t = _t_text.strip()[:60]
logger.debug(f"[{platform}] texts[{i}]: {_t!r}")
except Exception as e:
@@ -10032,10 +10056,10 @@ class MessageMonitor:
stale = 0
# 4a. 全文本签名快速跳过 — OCR 输出与上轮完全相同 → 无变化,跳过
sig = "||".join([t.strip() for t in texts if t.strip()])
sig = "||".join([t[0].strip() if isinstance(t, tuple) else t.strip() for t in texts if (t[0].strip() if isinstance(t, tuple) else t.strip())])
if sig == self._last_sig.get(platform, ""):
# 检查是否有未回复的消息(防止未回复消息被永久跳过)
unreplied_texts = [t for t in texts if t.strip() and t.strip() not in self._replied_texts]
unreplied_texts = [t for t in texts if (t[0].strip() if isinstance(t, tuple) else t.strip()) and (t[0].strip() if isinstance(t, tuple) else t.strip()) not in self._replied_texts]
if not unreplied_texts:
# 所有消息均已回复,正常跳过
stale = self._stale_count.get(platform, 0) + 1
@@ -10069,27 +10093,59 @@ class MessageMonitor:
# 原因:QQ时间戳出现在消息之间(如 msg1, ts1, msg2, ts2, ...)
# 第一遍时msg1没有前驱时间戳可继承,需在第二遍用后继时间戳补救
_ts_positions = [] # [(index, freshness_score)] 时间戳在texts中的位置和分数
for idx, t in enumerate(texts):
for idx, item in enumerate(texts):
# 解包元组 (text, timestamp)
if isinstance(item, tuple) and len(item) == 2:
t, inherited_ts = item
else:
t = item
inherited_ts = None
ts = t.strip()
if not ts:
continue
# 优先使用继承的时间戳分数,如果没有则计算
if inherited_ts:
# 使用继承的时间戳计算新鲜度分数
freshness_score = self._timestamp_freshness_score(inherited_ts)
logger.debug(f"[{platform}] freshness_scores构建(继承): {ts[:20]!r} <- {inherited_ts!r} -> {freshness_score:.1f}")
else:
# 计算时间戳新鲜度分数
freshness_score = self._timestamp_freshness_score(ts)
if len(ts) < 30:
logger.debug(f"[{platform}] freshness_scores构建(自身): {ts!r} -> {freshness_score:.1f}")
freshness_scores[ts] = freshness_score
if freshness_score != -999.0:
_ts_positions.append((idx, freshness_score))
# ⭐ Task #1680: 调试日志
if len(ts) < 30:
logger.debug(f"[{platform}] freshness_scores构建: {ts!r} -> {freshness_score:.1f}")
# 第二遍:为无时间戳的消息分配最近时间戳的新鲜度
for idx, t in enumerate(texts):
for idx, item in enumerate(texts):
# 解包元组 (text, timestamp)
if isinstance(item, tuple) and len(item) == 2:
t, inherited_ts = item
else:
t = item
inherited_ts = None
ts = t.strip()
if not ts:
continue
if freshness_scores.get(ts) != -999.0:
continue # 已有合法时间戳,跳过
# 搜索最近的时间戳(前后双向)
# 仅当消息尚无合法时间戳时,尝试继承;否则跳过继承步骤
if freshness_scores.get(ts) == -999.0:
# 如果有继承的时间戳,使用它
if inherited_ts:
inherited_score = self._timestamp_freshness_score(inherited_ts)
if inherited_score != -999.0:
logger.debug(f"[{platform}] 无时间戳消息继承时间戳新鲜度 "
f"(pos {idx} <- inherited ts {inherited_ts!r}): "
f"{inherited_score:.1f}: {ts[:20]!r}")
freshness_scores[ts] = inherited_score
# 若继承后仍无合法时间戳,搜索最近的时间戳(前后双向)
if freshness_scores.get(ts) == -999.0:
best_freshness = None
best_dist = float('inf')
for ts_idx, ts_fresh in _ts_positions:
@@ -10103,6 +10159,8 @@ class MessageMonitor:
f"{best_freshness:.1f}: {ts[:20]!r}")
freshness_scores[ts] = best_freshness
# else: 整批无时间戳,保持-999.0
# === 所有消息都必须经过过滤器(不再跳过) ===
# === URL-only 兜底:无中文且含 URL → 碎片 ===
if not re.search(r'[\u4e00-\u9fff]', ts) and re.search(r'https?\S', ts, re.IGNORECASE):
logger.debug(f"[{platform}] Poll过滤: URL-only碎片 ts={ts!r}")
@@ -10801,14 +10859,18 @@ class MessageMonitor:
elif freshness_score == -999.0:
# ⭐ Task #1661+#1665: 无有效时间戳(整批OCR丢失时间戳)
# 完全无法判断消息年龄,直接强制丢弃
# 注意:短消息(+5)+疑问词(+10)可得15分,仅扣10分仍为5分>0
# 会导致无时间戳的旧消息被误处理,所以改为强制设为极低分
# ⭐ 与 freshness_score < 0 统一:所有无法确认新鲜度的消息均强制丢弃
score = -100.0
logger.info(f"[{platform}] 无有效时间戳(OCR丢失?)强制丢弃: {item[:50]}")
elif freshness_score < 0:
# 负分(超过1小时):扣10分
score += freshness_score # 加上负分 = 扣分
logger.info(f"[{platform}] 陈旧时间戳扣分{freshness_score:.0f}: {item[:50]}")
# ⭐ Task #1684: 负分(超过1小时)→ 强制丢弃,不再仅扣10分
# 根因:短消息(+5)+疑问词(+10)可得15分,仅扣10分仍为5分>0,
# 导致数小时前的旧消息通过阈值被错误处理。
# 例:10:55发送的"什么是推广渠道违规?"在16:24被OCR读取,
# freshness_score=-10,基础分15-10=5>0 → 误回复。
# 修复:与-999.0一样强制丢弃,确保超过1小时的旧消息绝不触发回复。
score = -100.0
logger.info(f"[{platform}] 陈旧时间戳(>{abs(freshness_score)/10:.0f}h)强制丢弃: {item[:50]}")
else:
# 0-4分(10分钟-1小时):查DB决定是否加分
if self._msg_history and self._msg_history.has_ocr_message_record(platform, cleaned_item):