Files
msgbot/auto_reply.py
T
youpaishidifu 3e7ecf79a0 fix(kb): 将通用名词加入停用词列表,防止牛头不对马嘴误匹配
问题:查询'服务器的近期公告有哪些?'被匹配到player.md的'运营方权利'段落。
原因:'服务器'出现在太多文档中,导致TF-IDF分数虚高,且相关性检查认为有heading命中。

修复:将'服务器','玩家','游戏','世界','地图'等通用名词加入_STOPWORDS,
使相关性检查忽略这些词,只关注'公告','近期'等核心查询词。

结果:查询现在会正确匹配到index.md的'## 🔈近期公告'section。
2026-08-13 13:53:37 +08:00

366 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""自动回复 — 消息匹配知识库后准备答案,由主线程发送"""
import logging
import re
import threading
import jieba
logger = logging.getLogger("msgbot.autoreply")
class AutoReply:
"""自动回复控制器"""
def __init__(self, monitor):
self.monitor = monitor
self._enabled = False
self._min_score = 0.30
# 已回复的消息指纹(防重复)
self._replied: dict = {}
self._lock = threading.Lock()
def set_enabled(self, enabled: bool):
self._enabled = enabled
logger.info(f"自动回复 {'已开启' if enabled else '已关闭'}")
def set_min_score(self, score: float):
self._min_score = max(0.0, min(10.0, score))
@property
def enabled(self) -> bool:
return self._enabled
@property
def min_score(self) -> float:
return self._min_score
def on_kb_match(self, platform: str, msg: str, top_doc: dict, score: float) -> str or None:
"""知识库匹配命中时调用。线程安全。
返回要发送的回答文本,返回 None 表示不发送。
"""
if not self._enabled:
return None
if score < self._min_score:
logger.debug(
f"[{platform}] 匹配度 {score:.2f} < 阈值 {self._min_score},跳过回复"
)
return None
# 内容相关性检查:查询词与匹配文档至少有一个共同2字+词,防偶然匹配
if not self._has_content_relevance(msg, top_doc, score):
heading = top_doc.get("heading", "")[:40]
body_preview = (top_doc.get("content", "") or top_doc.get("body", ""))[:60]
logger.debug(
f"[{platform}] 内容相关性检查失败,跳过: "
f"msg={msg[:40]!r} score={score:.2f} doc_heading={heading!r} "
f"doc_body={body_preview!r}"
)
return None
with self._lock:
return self._get_answer(platform, msg, top_doc, score)
def get_reply_data(self, platform: str, msg: str, top_doc: dict, score: float, cleaned_msg: str = None) -> dict or None:
"""返回 {platform, answer, sender, msg} 供主线程发送。
cleaned_msg 为去噪后的查询(用于相关性校验与去重指纹),
msg 保留原始文本(含时间戳)供主线程做消息新鲜度检查。
"""
query = cleaned_msg if cleaned_msg else msg
answer = self.on_kb_match(platform, query, top_doc, score)
if not answer:
return None
# 获取发送者(在同一线程提取,不涉及窗口操作)
raw = self.monitor.get_raw_clipboard(platform)
sender = self.monitor._parse_last_sender(raw)
# 只有发送者是有效姓名且不在消息内容中时才切会话
if sender and sender not in msg and score >= self._min_score + 0.05:
pass
else:
sender = None
return {"platform": platform, "answer": answer, "sender": sender, "msg": msg}
# 相关性检查用的停用词(通用/无区分度的词),这些词命中不算"有内容相关性"
_STOPWORDS = {
"插件","使用","怎么","如何","吗","呢","吧","是","的","了","有","哪",
"两","种","这","那","和","与","或","在","我","你","他","它","我们","你们",
"一个","可以","需要","什么","为什么","是否","还是","以及","进行","通过",
"对于","关于","一些","这样","那样","这个","那个","请问","用","到","对",
"给","把","被","让","请","该","此","为","等","其","将","时","后","前",
"会","能","要","想","没","不","别","快","如","若","知","都","也","就",
"已","再","更","最","很","太","较","但","却","因","故","则","且",
# 通用名词,出现在太多文档中导致误匹配
"服务器","玩家","游戏","世界","地图","技能","装备","物品","命令","指令",
}
@staticmethod
def _meaningful_tokens(text: str) -> set:
"""提取有区分度的关键词(长度≥2 且非停用词),全部小写便于匹配。"""
toks = set(t.lower() for t in jieba.cut(text) if len(t) >= 2)
return toks - AutoReply._STOPWORDS
@staticmethod
def _has_content_relevance(query: str, doc: dict, score: float = 0.0) -> bool:
"""查询词与匹配文档必须共享至少一个有区分度的核心词,防偶然/牛头不对马嘴匹配。
判定顺序:
1. 提取 query 的有区分度关键词(_meaningful_tokens,去停用词、小写)。
2. 检查这些词是否出现在 doc 的 heading+title 或 body(前 800 字)中。
3. 动作/配置类查询(怎么/如何/使用/配置…)额外要求正文也含核心词,
不能只命中标题(否则 "# Sdf1系列登录插件使用说明" 会因标题含词被误放)。
4. 子串回退:连续 4~6 字子串命中文档(应对 jieba 切碎的短语,如"资源包")。
5. 高匹配度(≥10)但无任何共同词:TF-IDF 误匹配,拒绝。
"""
import re
q_mean = AutoReply._meaningful_tokens(query)
if not q_mean:
# 极短/纯停用词查询:要求整个 query 子串命中文档
stripped = re.sub(r'[??!!。,,\s]', '', query).lower()
if len(stripped) >= 2:
full = (doc.get("heading", "") + " " + doc.get("title", "")
+ " " + (doc.get("content", "") or doc.get("body", ""))).lower()
return stripped in full
return False
_link = re.compile(r'\[([^\]]*)\]\([^\)]+\)')
heading = doc.get("heading", "")
title = doc.get("title", "")
# 清理 Markdown 链接文本,避免 "[sdf1_login登录插件](sdf1_login.md)" 误匹配
heading_clean = _link.sub(r'\1', heading).lower()
body = doc.get("content", "") or doc.get("body", "")
body_clean = _link.sub(r'\1', body).lower()
ht_tokens = AutoReply._meaningful_tokens(heading_clean + " " + title)
body_tokens = AutoReply._meaningful_tokens(body_clean[:800])
has_ht = bool(q_mean & ht_tokens)
has_body = bool(q_mean & body_tokens)
# 动作/配置类查询:标题和正文都必须含核心词,防止"牛头不对马嘴"误匹配
# ⭐ 扩展:has_body=False(正文完全无共同词)时直接拒绝,无论 heading 是否命中
# 例:"领地系统有哪些操作模式?" vs sdf1_login CLI段落 → 拒绝
# "领地系统有哪些操作模式?" vs protect 权限段落 → 正常(有body命中)
action_kw = {"怎么", "如何", "使用", "配置", "修改", "设置", "安装", "用法", "操作"}
if q_mean & action_kw:
# ⭐ 排除 action_kw 词参与 body 覆盖统计,避免"使用"(verb)误命中
non_action_q = q_mean - action_kw
non_action_body = body_tokens - action_kw
has_body_nonaction = bool(non_action_q & non_action_body)
if not has_body_nonaction:
# 正文(不含动作词)完全没有共同词 → 直接拒绝,无论 heading 是否命中
logger.debug(
f"动作查询正文无共同词拒绝: query={query[:40]!r} "
f"doc_heading={heading[:30]!r} body_hits=0 ht_hit={has_ht}"
)
return False
if not has_ht:
# 正文有非动作词命中但 heading 无命中 → 检查 body 覆盖量(见下方正文覆盖不足防御)
pass
# has_ht and has_body_nonaction → 通过
# 注:原 has_body 已包含 action_kw 词,此处用 has_body_nonaction 更严格
has_body = has_body_nonaction
# ⭐ 后台/前台关键词区分:查询含"后台"但文档不含"后台"→ 不相关
full = heading_clean + " " + title.lower() + " " + body_clean[:800]
if "后台" in query and "后台" not in full:
logger.debug(f"后台关键词缺失: query={query[:40]!r} doc={doc.get('heading', '')[:30]!r}")
return False
if "前台" in query and "前台" not in full:
logger.debug(f"前台关键词缺失: query={query[:40]!r} doc={doc.get('heading', '')[:30]!r}")
return False
if has_ht or has_body:
# ⭐ 正文覆盖不足防御(牛头不对马嘴修复):
# 当 heading 无命中但有 body 命中时,检查 body 覆盖了多少查询词。
# 例:"领地系统有哪些权限级别?" vs sdf1_login 的 feature list
# | 领地系统 | 保护自己领地不受损失 | — body 只命中2/5个词(领地,系统)→ 误选
# 策略:
# - has_ht=True 时 heading 已提供语义锚定,只需任意 body_hit 即可(正确结果往往 score 低但方向对)
# - has_ht=False 且 has_body=True 时,要求 body 覆盖足够比例,防止单点散命中通过
# min_body_hits = min(ceil(q_count*40%), 4) 且至少为 2
# 5词查询需≥2,6词需≥3,7词需≥3,8词需≥4
if not has_ht and has_body:
q_count = len(q_mean)
body_hits = len(q_mean & body_tokens)
# 无 heading 命中的查询需更多 body 覆盖,防散点命中漏过:
# min_body_hits = min(q_count, ceil(q_count*60%)),最低 2
# 例:2token→ceil(1.2)=2, min(2,2)=2(全中);3token→ceil(1.8)=2,min(3,2)=2(缺1词过)
# 4token→ceil(2.4)=3,min(4,3)=3(缺1词不过);5token→ceil(3)=3
min_body_hits = min(q_count, (q_count * 3 + 4) // 5) # ceil(q_count*0.6)
min_body_hits = max(2, min_body_hits)
if body_hits < min_body_hits:
logger.debug(
f"正文覆盖不足跳过(防牛头不对马嘴): query={query[:40]!r} "
f"doc_heading={heading[:30]!r} body_hits={body_hits}/{q_count} "
f"(阈值≥{min_body_hits}), ht_hit={has_ht}"
)
return False
return True
# ⭐ 下载意图放行:查询含"下载/下到/哪下"等口语,且section heading含"下载"
# 修复"在哪儿能下到那登录插件啊?"——原文无"下载"二字(口语"下到"),
# q_mean 不含"下载","## 4. 下载地址"heading 虽有"下载"仍被相关性拒绝,
# 导致误回"插件设置"(body 恰含"登录超时秒数")
if any(w in query for w in ('下载', '下到', '哪下', '在哪下', '怎么下')) and '下载' in heading_clean:
logger.debug(f"下载意图放行: query={query[:30]!r} heading={heading[:30]!r}")
return True
# 子串回退:jieba 可能产生碎片 token,导致交集为空但文档确实含查询短语
full = heading_clean + " " + title.lower() + " " + body_clean[:800]
stripped = re.sub(r'[??!!。,,\s]', '', query).lower()
if len(stripped) >= 4:
for qlen in range(min(len(stripped), 6), 3, -1):
for start in range(len(stripped) - qlen + 1):
phrase = stripped[start:start + qlen]
if phrase in full:
logger.debug(
f"子串回退命中: phrase={phrase!r} in doc={doc.get('heading', '')[:30]!r}"
)
return True
# 高匹配度但无共同词:TF-IDF 误匹配,拒绝
if score >= 10.0 and not (has_ht or has_body):
return False
return False
def _get_answer(self, platform: str, msg: str, doc: dict, score: float) -> str or None:
"""提取答案 + 防重复检查。
优先返回 doc['answer'](Q&A 段),兜底使用 doc['body'](仅正文,不含 heading)。
KB 搜索已按 section 切分,doc['body'] 即最相关的片段正文。
只需截断到 300 字符避免消息过长。
"""
answer = doc.get("answer")
if answer:
answer = answer.strip()
else:
# 优先用 body(纯正文),兜底用 content(heading+正文)
body = doc.get("body", "").strip()
if body and len(body) > 20:
answer = self._extract_table_answer(body)
else:
content = doc.get("content", "").strip()
answer = self._extract_table_answer(content) if content else ""
if not answer:
return None
# 清理 HTML 换行标签(纯文本消息里 <br> 会原形显示)
import re
answer = re.sub(r'</?br\s*/?>', '\n', answer, flags=re.IGNORECASE)
answer = answer.strip()
# 清理 Markdown 格式,返回纯文本
answer = self._clean_markdown(answer)
if not answer:
return None
# 拒绝把纯标题/免责声明当作答案发送
if answer.startswith("# ") and len(answer) < 60:
logger.debug(f"[{platform}] 答案为纯标题,跳过: {answer[:60]!r}")
return None
if re.search(r'特别说明|免责声明|本插件.*自行修改|仅供参考|版权所有', answer):
logger.debug(f"[{platform}] 答案为免责声明/通用说明,跳过: {answer[:60]!r}")
return None
# 防重复指纹
fp = hash(f"{platform}:{msg}:{answer[:80]}")
replied_set = self._replied.setdefault(platform, set())
if fp in replied_set:
logger.debug(f"[{platform}] 重复命中,跳过: {msg[:40]}")
return None
replied_set.add(fp)
# 控制缓存上限
if len(replied_set) > 200:
self._replied[platform] = set(list(replied_set)[-100:])
logger.info(
f"[{platform}] 🎯 自动回复触发 | 匹配度 {score:.2f} | "
f"消息: {msg[:40]}"
)
return answer
@staticmethod
def _clean_markdown(text: str) -> str:
"""清理文本中的 Markdown 格式,返回纯文本。
处理:
- 标题语法:## 标题 → 标题
- 链接语法:[text](url) → text
- 列表语法:- item → item
- 加粗语法:**text** → text
- 引用语法:> text → text
- 编号列表:1. item → item
"""
import re
if not text:
return text
# 移除标题语法:## 标题 → 标题
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
# 移除链接语法:[text](url) → text
text = re.sub(r'\[([^\]]*)\]\([^\)]+\)', r'\1', text)
# 移除列表语法:- item → item
text = re.sub(r'^[\-\*]\s+', '', text, flags=re.MULTILINE)
# 移除加粗语法:**text** → text
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
# 移除引用语法:> text → text
text = re.sub(r'^>\s+', '', text, flags=re.MULTILINE)
# 移除编号列表:1. item → item
text = re.sub(r'^\d{1,3}[\.\)、]\s+', '', text, flags=re.MULTILINE)
# 移除多余空行
text = re.sub(r'\n\s*\n', '\n', text)
return text.strip()
@staticmethod
def _extract_table_answer(text: str, max_chars: int = 800) -> str:
"""从含表格的文本中提取表格 + 紧邻上下文,避免返回整段 section。
问题背景:知识库含表格的文档(如 php.md "## php后台玩家端")被匹配后,
原逻辑把整个 section(表格 + 其后大量无关图文)全部塞入回复。
实际答案只是表格附近的几句话。
提取规则:
- 表格块:连续以 `|` 开头且含 ≥2 个 `|` 的行(含表头/分隔/数据行)
- 上文:表格前最近 2 个非空行(通常是说明句),给答案提供语境
- 下文:不保留。表格之后的内容往往是下一个话题的开头(如"仪表盘:…"
"商城:…"),保留会重新引入整段无关图文,违背"只返回表格附近的几句话"
- 截断到 max_chars 字符
若文本中无完整表格,则退回前 max_chars 字符。
"""
if not text:
return ""
lines = text.split('\n')
table_start = None
table_end = None
for i, ln in enumerate(lines):
s = ln.strip()
if s.startswith('|') and s.endswith('|') and s.count('|') >= 2:
if table_start is None:
table_start = i
table_end = i
# 无表格或表格过短:退回前 max_chars 字符
if table_start is None:
return text[:max_chars]
# 上文:向前找最多 2 个非空行(说明句)
pre: list = []
j = table_start - 1
while j >= 0 and len(pre) < 2:
if lines[j].strip():
pre.insert(0, lines[j].strip())
j -= 1
block = pre + lines[table_start:table_end + 1]
out = '\n'.join(block)
if len(out) > max_chars:
out = out[:max_chars] + "..."
return out