feat: 同轮择优机制防bot抢跑
当一轮poll检测到多条候选消息时,只选最可能是真实用户消息的那条触发回复。 新增 _score_message_quality() 方法,评估维度: - 长度惩罚(越长越可疑) - 疑问词奖励(用户提问特征) - 噪声前缀惩罚(UI标签提示污染) - 有效关键词奖励(插件/下载等) - 英文比例惩罚(乱码提示) - 标点混乱惩罚(OCR噪声) 修复场景: 污染历史消息(长+无前缀+无疑问词) vs 真实用户消息(短+含疑问词) → 择优后只回复真实用户消息,避免bot抢跑 相关issue: #1542
This commit is contained in:
+89
-8
@@ -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},强制刷新")
|
||||
|
||||
Reference in New Issue
Block a user