feat: 同轮择优机制防bot抢跑

当一轮poll检测到多条候选消息时,只选最可能是真实用户消息的那条触发回复。

新增 _score_message_quality() 方法,评估维度:
- 长度惩罚(越长越可疑)
- 疑问词奖励(用户提问特征)
- 噪声前缀惩罚(UI标签提示污染)
- 有效关键词奖励(插件/下载等)
- 英文比例惩罚(乱码提示)
- 标点混乱惩罚(OCR噪声)

修复场景:
污染历史消息(长+无前缀+无疑问词) vs 真实用户消息(短+含疑问词)
→ 择优后只回复真实用户消息,避免bot抢跑

相关issue: #1542
This commit is contained in:
youpaishidifu
2026-08-16 21:37:46 +08:00
parent 7d78fe4918
commit 9a7580acd0
+89 -8
View File
@@ -5513,6 +5513,62 @@ class MessageMonitor:
return False
return bool(re.search(r'[\u4e00-\u9fff]', stripped))
@staticmethod
def _score_message_quality(text: str) -> float:
"""评估消息质量分,用于同轮择优(防bot抢跑)。
真实用户消息通常:
- 较短(污染历史消息常较长)
- 含疑问词或问号(用户提问)
- 无前缀噪声(如"必选,"、"运营方"等UI标签)
- 含有效关键词(插件、下载、地址等)
污染历史消息通常:
- 较长(混合了多条消息或UI元素)
- 无前缀噪声
- 不含疑问词
返回质量分(越高越可能是真实用户消息)。
"""
if not text or len(text.strip()) < 2:
return -100.0 # 太短,可疑
score = 0.0
length = len(text)
# 长度惩罚:越长越可疑(污染消息通常较长)
if length > 50:
score -= (length - 50) * 0.5 # 每超1字扣0.5分
elif length <= 20:
score += 5.0 # 短消息加分
# 疑问词奖励:用户提问通常含疑问词
_QUESTION_WORDS = ('?', '?', '吗', '呢', '哪些', '什么', '怎么', '如何', '为什么', '能', '可以', '哪', '哪儿')
if any(q in text for q in _QUESTION_WORDS):
score += 10.0
# 噪声前缀惩罚:UI标签(如"必选,"、"运营方")提示历史污染
_NOISE_PREFIXES = ('必选', '运营方', '玩家', '服务器', '公告', '规则', '下载', '注意', '重要')
if text.startswith(_NOISE_PREFIXES) or any(text.startswith(p + ',') or text.startswith(p + ',') for p in _NOISE_PREFIXES):
score -= 15.0
# 有效关键词奖励:插件、下载、地址等提示真实用户意图
_GOOD_KEYWORDS = ('插件', '下载', '地址', '安装', '配置', '教程', '问题', '错误')
if any(kw in text for kw in _GOOD_KEYWORDS):
score += 3.0
# 英文/数字比例惩罚:污染消息常含乱码英文
cjk_count = len(re.findall(r'[\u4e00-\u9fff]', text))
ascii_count = len(re.findall(r'[a-zA-Z0-9]', text))
if ascii_count > cjk_count * 2 and cjk_count < 5:
score -= 10.0 # 纯英文/数字消息,可疑
# 标点混乱惩罚:OCR噪声常导致标点异常
if text.count(',') > 3 or text.count(',') > 3:
score -= 5.0
return score
@staticmethod
def _normalize_timestamps(text: str) -> str:
"""规整 OCR 逐字输出的时间戳,修复「孤儿空格」导致的漏消息。
@@ -7862,13 +7918,17 @@ class MessageMonitor:
if now - v < 60.0
}
# ⭐ 同轮择优:当一轮检测到多条候选消息时,只选最可能是真实用户消息的那条
# 防bot抢跑:污染历史消息(长、含噪声前缀)vs 真实用户消息(短、含疑问词)
_candidates = [] # list of (score, item, cleaned_item)
# ⭐ 首次成功轮询(上次无消息→本次有消息):拦截历史基线
is_baseline = self._last_new_msg_time.get(platform, 0) == 0
baseline_handled = False # 基线轮只处理一次(标记第一条消息)
# ⭐ 遍历处理所有新消息(不再只取最后一条)
# ⭐ 第一轮:评估所有候选消息质量
for item in new_items:
# ⭐ 基线轮:只标记第一条消息,不触发回复
# 基线轮:只标记第一条消息,不触发回复
if is_baseline and not baseline_handled:
# 基线轮:日志标记但不触发回复。
# ⚠ 四项关键处置,防止"基线吞消息后永不回复":
@@ -7912,7 +7972,6 @@ class MessageMonitor:
# 例:第一次OCR"所以那登录插件应该去哪里下载呢?" vs 第二次"所以那登录插件应该去哪里下载呢?"
_normalized_item = cleaned_item.replace('?', '?').replace('。', '.').replace(',', ',')
# ⭐ 5分钟内OCR重复识别去重:同一消息被OCR识别两次(乱码+正确文本),相似度>=90%则跳过
now = time.time()
# 清理过期条目
self._recent_processed_messages = [
(txt, ts) for txt, ts in self._recent_processed_messages
@@ -7946,17 +8005,39 @@ class MessageMonitor:
if is_duplicate:
self._last_new_msg_time[platform] = time.time()
continue
# 添加到最近处理列表
self._recent_processed_messages.append((cleaned_item, now))
# 添加到候选列表(含质量分)
score = self._score_message_quality(cleaned_item)
_candidates.append((score, item, cleaned_item))
# ⭐ 同轮择优:只选质量分最高的那条消息触发回复
if _candidates:
# 按质量分降序排序,选最高分
_candidates.sort(key=lambda x: x[0], reverse=True)
_best_score, _best_raw, _best_cleaned = _candidates[0]
# 如果有多条同分,优先选较短的(真实用户消息通常更短)
_top_candidates = [c for c in _candidates if c[0] == _best_score]
_top_candidates.sort(key=lambda x: len(x[2]))
_best_score, _best_raw, _best_cleaned = _top_candidates[0]
logger.info(
f"[{platform}] 同轮择优: 选中({_best_score:.1f}分) {_best_cleaned[:50]!r}, "
f"候选{len(_candidates)}条"
)
if len(_candidates) > 1:
for _sc, _raw, _cl in _candidates[1:]:
logger.debug(f"[{platform}] 同轮择优: 丢弃({_sc:.1f}分) {_cl[:50]!r}")
# ⭐ 添加到最近处理列表
self._recent_processed_messages.append((_best_cleaned, now))
self._last_new_msg_time[platform] = time.time()
self._last_msg[platform] = cleaned_item
self._last_msg[platform] = _best_cleaned
engine_tag = self._last_chosen_engine.get(platform, "")
logger.info(f"[{platform}] 新消息[{engine_tag}]: {item[:60]}")
logger.info(f"[{platform}] 新消息[{engine_tag}]: {_best_raw[:60]}")
# ⚠️ 急停检查:回复前最后检查,防止急停后仍然发送
if not self._running:
return
if self._on_message:
self._on_message(platform, cleaned_item)
self._on_message(platform, _best_cleaned)
if platform == "qq":
if stale == 3:
logger.debug(f"[QQ] 冻结 #{stale},强制刷新")