fix(Task #1558): 重构消息去重:用关键词打分替代OCR时间戳判断
核心变化:
1. 完全移除OCR时间戳丢弃逻辑(_is_timestamp_old不再用于丢弃消息)
2. 改为关键词打分+时间感知去重:
- 得分≤1 → 直接放行(新消息)
- 得分≥1 → 检查最近一次打分时间:
* 最近300秒内有同类记录 → 丢弃(同一用户重复)
* 超过300秒或无记录 → 放行(不同用户的新提问)
3. 修复问题:
- 旧消息被陈旧时间戳继承导致误杀(如13:32:36继承给14:42:57的消息)
- 不同用户相同问题的误过滤(如"php管理员密码"被统一丢弃)
4. OCR时间戳仅作为参考,±10秒容差,超时用本地时间
修改文件:
- _keyword_scorer.py: 新增时间感知去重逻辑
- monitor.py: 移除时间戳丢弃分支
This commit is contained in:
+56
-8
@@ -188,12 +188,19 @@ class KeywordScorer:
|
||||
|
||||
def should_skip_message(self, text: str, platform: str,
|
||||
threshold: int = 2) -> Tuple[bool, int, List[str]]:
|
||||
"""判断是否应该跳过该消息(基于关键词得分)
|
||||
"""判断是否应该跳过该消息(基于关键词得分 + 时间感知)
|
||||
|
||||
⭐ Task #1558: 新逻辑
|
||||
- 得分≤1 → 直接放行(新消息)
|
||||
- 得分≥2 → 检查最近一次打分时间:
|
||||
* 最近打分在5分钟内 → 丢弃(同一用户重复)
|
||||
* 最近打分超过5分钟 → 放行(不同用户的新提问)
|
||||
这样避免一刀切≥2分就丢弃,因为不同用户可能提出相同问题。
|
||||
|
||||
Args:
|
||||
text: 消息文本
|
||||
platform: 平台标识
|
||||
threshold: 得分阈值,≥threshold 则跳过
|
||||
threshold: 得分阈值,≥threshold 则检查时间
|
||||
|
||||
Returns:
|
||||
(是否跳过, 得分, 匹配关键词)
|
||||
@@ -204,13 +211,54 @@ class KeywordScorer:
|
||||
|
||||
score, matched = self.score_against_history(keywords, platform)
|
||||
|
||||
# 记录本次得分
|
||||
is_new = score < threshold
|
||||
self.record_message_score(platform, text, keywords, score, matched, is_new)
|
||||
# 记录本次得分(先记录用于后续检查)
|
||||
self.record_message_score(platform, text, keywords, score, matched, is_new=True)
|
||||
|
||||
# 得分≤1:直接放行(新消息)
|
||||
if score <= 1:
|
||||
return False, score, matched
|
||||
|
||||
# 得分≥2:检查最近一次同类消息的打分时间
|
||||
should_skip = self._should_skip_by_recent_score(platform, score, matched)
|
||||
|
||||
should_skip = score >= threshold
|
||||
if should_skip:
|
||||
logger.info(f"[keyword-scorer] 跳过消息(得分{score}≥{threshold}): {text[:40]}... "
|
||||
f"匹配关键词: {matched}")
|
||||
logger.info(f"[keyword-scorer] 跳过消息(得分{score}≥{threshold},5分钟内): {text[:40]}... "
|
||||
f"匹配关键词: {matched[:5]}")
|
||||
else:
|
||||
logger.info(f"[keyword-scorer] 放行消息(得分{score}≥{threshold},超过5分钟或无记录): "
|
||||
f"{text[:40]}... 匹配关键词: {matched[:5]}")
|
||||
|
||||
return should_skip, score, matched
|
||||
|
||||
def _should_skip_by_recent_score(self, platform: str, score: int, matched: List[str]) -> bool:
|
||||
"""检查最近一次同类消息的打分时间,超过5分钟则放行"""
|
||||
conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
try:
|
||||
from datetime import timedelta
|
||||
cutoff_time = (datetime.now() - timedelta(seconds=300)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 查询最近300秒内得分≥1的消息(不限score阈值,只看时间)
|
||||
cursor = conn.execute("""
|
||||
SELECT local_time, score
|
||||
FROM keyword_scores
|
||||
WHERE platform = ?
|
||||
AND score >= 1
|
||||
AND matched_keywords != ''
|
||||
AND local_time > ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 3
|
||||
""", (platform, cutoff_time))
|
||||
|
||||
recent = cursor.fetchall()
|
||||
if not recent:
|
||||
# 无近期记录 → 放行(可能是新用户的相同提问)
|
||||
logger.debug(f"[keyword-scorer] 无近期记录,放行: score={score}, matched={matched[:3]}")
|
||||
return False
|
||||
|
||||
# 有近期记录 → 丢弃(同一用户重复提问)
|
||||
logger.debug(f"[keyword-scorer] 发现近期记录,跳过: count={len(recent)}, "
|
||||
f"最近时间={recent[0][0]}, score={recent[0][1]}")
|
||||
return True
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
+18
-44
@@ -3383,35 +3383,23 @@ class MessageMonitor:
|
||||
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)
|
||||
# ⭐ Task #1510: QQ 时间戳渲染在消息【下方】。消息行通常无自己的时间戳,
|
||||
# 时间戳出现在其后续行。若本行或下一行含新鲜时间戳(≤300s),则本行消息
|
||||
# 属于新鲜消息组 → 不应因继承上一行的陈旧时间戳而被误杀。
|
||||
# 典型场景:旧历史消息(12:12:40)+新消息(15:35:38)同屏被OCR整屏捕捉,
|
||||
# 新消息继承 12:12:40 被"尾部时间戳较老"整体丢弃。
|
||||
_stage_fresh_hint = (
|
||||
self._line_has_fresh_ts(rl, _inherited_past)
|
||||
or (i + 1 < len(ocr_lines) and self._line_has_fresh_ts(ocr_lines[i + 1], _inherited_past))
|
||||
)
|
||||
# ⭐ Task #1558: 完全用关键词打分控制消息留舍,OCR时间戳仅作为参考
|
||||
# - 不依赖时间戳丢弃消息(时间戳可能被OCR继承错误)
|
||||
# - 消息抓来后直接打分:历史消息≥2分则丢弃,新消息≤1分继续处理
|
||||
# - OCR时间戳±10秒容差,超过用本地时间
|
||||
# 典型场景:旧消息(13:32:36)+新消息(14:42:57)同屏,新消息不应被旧时间戳误杀
|
||||
for part in parts:
|
||||
if re.match(r'^\d{1,2}:\d{1,2}', part):
|
||||
if buf.strip():
|
||||
if not last_ts:
|
||||
# ⭐ 无前置时间戳:位于首个时间戳之前的消息。
|
||||
# ⚠ 不再整段丢弃:可能是被裁切/漏识别时间戳的新消息,
|
||||
# 交由下游 DB查重(避免连续聊天时时间戳被覆盖误杀用户消息)。
|
||||
# 交由下游DB查重(避免连续聊天时时间戳被覆盖误杀用户消息)。
|
||||
logger.info(f"[ocr] stage[{i}] [filter/放行] 无前置时间戳,交由DB查重: {buf.strip()[:60]!r}")
|
||||
elif self._is_timestamp_old(last_ts, 300, skip_future_check=_inherited_past):
|
||||
# ⭐ Task #1456: 继承时间戳>5min,直接丢弃,不再进行DB去重检查
|
||||
# 时间戳已经表明这些是旧消息,即使它们从未被回复过,也不应该被处理
|
||||
# 修复:移除DB去重检查,直接丢弃
|
||||
if buf.strip() and _stage_fresh_hint:
|
||||
# ⭐ Task #1510: 继承时间戳较老但本/下行含新鲜时间戳 → 新鲜组
|
||||
logger.info(f"[ocr] stage[{i}] [age] 继承时间戳{last_ts}较老但本/下行含新鲜时间戳,按新鲜处理: {buf.strip()[:60]!r}")
|
||||
elif buf.strip():
|
||||
logger.info(f"[ocr] stage[{i}] [age] DROP 继承时间戳{last_ts}较老(>300s),直接丢弃: {buf.strip()[:60]!r}")
|
||||
buf = ''
|
||||
last_ts = part
|
||||
continue
|
||||
else:
|
||||
# ⭐ Task #1558: 不再因时间戳陈旧而丢弃,统一交由过滤器层+关键词打分
|
||||
# 时间戳仅记录,不参与丢弃判断
|
||||
logger.debug(f"[ocr] stage[{i}] 时间戳{last_ts}更新,消息交由过滤器层: {buf.strip()[:60]!r}")
|
||||
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
|
||||
# ⭐ 2026-08-12 调整顺序:必须先剥离 bot 旧回复片段,再剥 md 尾巴。
|
||||
# 原因:OCR 常把「用户新消息 + bot 旧回复」拼成一行,bot 回复若含
|
||||
@@ -3474,25 +3462,8 @@ class MessageMonitor:
|
||||
# 关键修复:过滤器层(md/噪声/回复残片)必须【始终运行】,无论继承时间戳新旧,
|
||||
# 之前错误地嵌在"时间戳较老"分支内,导致新鲜尾部消息(如用户刚发的提问)
|
||||
# 被静默吞噬、永不进入 expanded。
|
||||
# 仅当继承时间戳较老(>5min)且无新鲜特征且非提问豁免时才丢弃(Task #1440 的5分钟窗口),
|
||||
# 其余一律保留交给过滤器层,绝不静默吞噬。
|
||||
if not last_ts:
|
||||
# 尾部无任何时间戳(整行无时间戳):交给下游DB查重,不整段丢弃
|
||||
# 避免连续聊天时时间戳被覆盖误杀用户消息
|
||||
logger.info(f"[ocr] stage[{i}] [filter/放行] 尾部无时间戳,交由DB查重: {buf.strip()[:60]!r}")
|
||||
else:
|
||||
# ⭐ Task #1456: 继承时间戳>5min,直接丢弃,不再进行DB去重检查
|
||||
# 时间戳已经表明这些是旧消息,即使它们从未被回复过,也不应该被处理
|
||||
# 修复:移除DB去重检查,直接丢弃
|
||||
if self._is_timestamp_old(last_ts, 300, skip_future_check=_inherited_past):
|
||||
if buf.strip() and _stage_fresh_hint:
|
||||
# ⭐ Task #1510: 尾部继承时间戳较老但本/下行含新鲜时间戳 → 新鲜组,
|
||||
# 不丢弃(QQ 时间戳在消息下方,见 stage 开头注释)
|
||||
logger.info(f"[ocr] stage[{i}] [age] 尾部时间戳{last_ts}较老但本/下行含新鲜时间戳,按新鲜处理: {buf.strip()[:60]!r}")
|
||||
elif buf.strip():
|
||||
logger.info(f"[ocr] stage[{i}] [age] DROP 尾部时间戳{last_ts}较老(>300s),直接丢弃: {buf.strip()[:60]!r}")
|
||||
buf = ''
|
||||
# else: 时间戳新鲜(<5min),直接保留
|
||||
# ⭐ Task #1558: 不再因时间戳陈旧而丢弃,统一交由过滤器层+关键词打分
|
||||
logger.info(f"[ocr] stage[{i}] [filter/放行] 尾部消息交由DB查重+关键词打分: {buf.strip()[:60]!r}")
|
||||
# —— 过滤器层(始终运行,绝不静默吞噬;每层放行/打回都记录)——
|
||||
if buf.strip():
|
||||
# ⭐ 2026-08-12 必须先剥离 bot 旧回复片段(同 for 循环块理由):
|
||||
@@ -8164,11 +8135,14 @@ class MessageMonitor:
|
||||
if len(ts) <= 3 and len(clean) == 0:
|
||||
continue
|
||||
|
||||
# ⭐ Task #1556: 关键词打分去重(替代OCR时间戳判断)
|
||||
# 核心思路:与历史回复匹配,得分≥2视为已知回复跳过,≤1视为新消息继续处理
|
||||
# ⭐ Task #1558: 关键词打分去重(替代OCR时间戳判断)
|
||||
# 核心思路:与历史回复匹配,得分≥1视为已知回复,
|
||||
# - 若5分钟内已有同类记录 → 跳过(同一用户重复)
|
||||
# - 若超过5分钟或无记录 → 放行(不同用户的新提问)
|
||||
# 得分≤1 直接放行(新消息)
|
||||
if self._keyword_scorer:
|
||||
should_skip, score, matched_kws = self._keyword_scorer.should_skip_message(
|
||||
ts, platform, threshold=2
|
||||
ts, platform, threshold=1
|
||||
)
|
||||
if should_skip:
|
||||
logger.debug(f"[{platform}] 关键词打分跳过(score={score}): {ts[:50]}... "
|
||||
|
||||
Reference in New Issue
Block a user