修复旧消息时间戳检查漏洞 + 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:
+108
-46
@@ -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,40 +10093,74 @@ 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
|
||||
# 计算时间戳新鲜度分数
|
||||
freshness_score = self._timestamp_freshness_score(ts)
|
||||
|
||||
# 优先使用继承的时间戳分数,如果没有则计算
|
||||
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 # 已有合法时间戳,跳过
|
||||
# 搜索最近的时间戳(前后双向)
|
||||
best_freshness = None
|
||||
best_dist = float('inf')
|
||||
for ts_idx, ts_fresh in _ts_positions:
|
||||
dist = abs(idx - ts_idx)
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_freshness = ts_fresh
|
||||
if best_freshness is not None:
|
||||
logger.debug(f"[{platform}] 无时间戳消息继承最近时间戳新鲜度 "
|
||||
f"(pos {idx} <- ts pos {idx-best_dist if best_dist <= idx else idx+best_dist}): "
|
||||
f"{best_freshness:.1f}: {ts[:20]!r}")
|
||||
freshness_scores[ts] = best_freshness
|
||||
# else: 整批无时间戳,保持-999.0
|
||||
|
||||
# 仅当消息尚无合法时间戳时,尝试继承;否则跳过继承步骤
|
||||
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:
|
||||
dist = abs(idx - ts_idx)
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_freshness = ts_fresh
|
||||
if best_freshness is not None:
|
||||
logger.debug(f"[{platform}] 无时间戳消息继承最近时间戳新鲜度 "
|
||||
f"(pos {idx} <- ts pos {idx-best_dist if best_dist <= idx else idx+best_dist}): "
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user