"""自动回复 — 消息匹配知识库后准备答案,由主线程发送""" 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] # 注意:"管理后台"是整体概念,拆分后"后台"可能不在heading中,但"管理员"+"后端"已表达管理意图 # 所以当heading含"管理员"或"后端"时,不应因缺"后台"而拒绝 if "后台" in query and "后台" not in full: # 检查是否已通过其他语义规则确认是管理端查询 is_admin_intent = ('管理员' in query) or ('管理' in query and '密码' in query) if not is_admin_intent and ('管理员' not in heading_clean and '后端' not in heading_clean): 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 # ⭐ 语义意图验证:OCR查询可能含错字(如"录"→"登"、"php"→"pHp"), # 需要结合heading确认查询意图,防止"云背包管理员密码"压过"php管理员后端" # 查询含"php"时,heading必须含"php"或满足以下例外规则: if 'php' in query.lower(): if 'php' not in heading_clean: # 例外1:查询含"管理"+"密码"类词,heading含"管理"即可(指向phpadmin的管理功能) # 例:"php用户管理可以看到哪些信息" → 允许返回"### 用户管理" has_management_intent = ('管理' in query and '管理' in heading_clean) # 例外2:查询含"后端"概念,heading含"后端"即可 # 例:"那pHP后端的那管理员默认密码是多少" → 允许返回"## php管理员后端" has_backend_intent = ('后端' in query and '后端' in heading_clean) if not has_management_intent and not has_backend_intent: logger.debug(f"php查询意图不匹配: query={query[:40]!r} heading={heading[:30]!r}") return False # ⭐ 增强:区分php.md(玩家端)和phpadmin.md(管理端) # 当查询含"密码"且含"php"时,heading必须含"管理员"或"后端"(防止php.md玩家端被误选) if '密码' in query and 'php' in query.lower(): if '管理员' not in heading_clean and '后端' not in heading_clean: logger.debug( f"php密码查询意图不匹配(疑似玩家端): query={query[:40]!r} " f"heading={heading[:30]!r}" ) return False # ⭐ 新增:当query含"后端"或"管理员端"但heading不含"后端"也不含"php"时,可能是云背包等无关文档 # 例:"那pHP后端的那管理员默认密码是多少" vs beibao.md的"## 使用方法-管理员" # beibao不含"php"也不含"后端"→ 拒绝 # 例:"那pHP的那个管理员端的密码" vs beibao.md的"## 使用方法-管理员" # 查询含"管理员端",heading只含"管理员"→ 拒绝(必须含"php"或"后端") if ('后端' in query or '管理员端' in query) and '后端' not in heading_clean and 'php' not in heading_clean: logger.debug( f"php后端/管理员端查询意图不匹配: query={query[:40]!r} " f"heading={heading[:30]!r} (heading不含'后端'或'php')" ) return False if has_ht or has_body: # ⭐ 多关键词覆盖防御(2026-08-13): # 区分核心词与非核心词,防止通用短语因单一关键词误选不相关文档。 # 核心词:实词(名词/动词/形容词),排除疑问词、量词、方位词等 # 规则: # 1. 如果heading含核心词命中(has_ht=True)→ 放宽检查,不强制覆盖全部 # 2. 如果heading无命中(has_ht=False)且正文有多关键词 → 要求覆盖≥50% # 例:"草原深险服务器备用号服务器近期的一些公告有哪些" # 核心词={'草原','近期','公告'}(排除'哪些','备用','深险'等OCR碎片) # sdf1.md"注意事项"只含"草原"(1/3)→ 若has_ht=False则拒绝 # index.md"近期公告"含"近期"+"公告"(2/3)→ 通过 non_core_kw = {'多少', '哪些', '哪个', '什么', '怎么', '如何', '吗', '呢', '吧', '备用', '深险', '端的', '那', '这', '些', '个', '种', '类', # 时间/程度副词:通用且难以区分文档 '最近', '近期', '刚才', '现在', '当前', '刚刚', '以前', '之后', '当天', '第一', '最新', '全部', '所有', '一起', '各种', '各种各样', # 疑问代词变体 '哪些', '哪个', '什么', '怎么', '如何', '多少', } core_kw = q_mean - non_core_kw doc_text = heading_clean + " " + title.lower() + " " + body_clean[:800] core_hits = sum(1 for kw in core_kw if kw in doc_text) # ⭐ 当heading含核心词时(has_ht=True),也需检查覆盖比例 # 防止"草原"等通用词单一命中导致误选sdf1.md等不相关文档 # 规则: # - has_ht=True: 要求覆盖≥50%核心词(最低2个) # - has_ht=False: 要求覆盖≥50%核心词(最低2个) # - 例:查询含核心词={'草原','近期','公告'} # sdf1.md只含"草原"(1/3)→ 拒绝 # index.md含"近期"+"公告"(2/3)→ 通过 if len(core_kw) >= 2: min_core_hits = max(2, (len(core_kw) + 1) // 2) if core_hits < min_core_hits: logger.debug( f"多关键词覆盖不足跳过(防牛头不对马嘴): query={query[:40]!r} " f"doc_heading={heading[:30]!r} core_hits={core_hits}/{len(core_kw)} " f"(阈值≥{min_core_hits}), missing={ [kw for kw in core_kw if kw not in doc_text]!r}, has_ht={has_ht}" ) return False # 原有正文覆盖不足防御(保留作为兜底) if not has_ht and has_body: q_count = len(q_mean) body_hits = len(q_mean & body_tokens) min_body_hits = min(q_count, (q_count * 3 + 4) // 5) 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: # ⭐ 修复:子串回退必须同时满足两个条件: # 1. 有有意义的子串(≥4字)命中文档 # 2. 至少有一个 meaningful_token 命中文档(防止"服务器"等通用词泛匹配) has_meaningful_hit = bool(q_mean & (set(heading_clean) | set(title.lower()) | set(body_clean[:800]))) if not has_meaningful_hit: # 没有 meaningful_token 命中,即使有子串匹配也拒绝 logger.debug(f"无有意义token命中拒绝: query={query[:40]!r} doc_heading={heading[:30]!r}") return False 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 换行标签(纯文本消息里
会原形显示) import re answer = re.sub(r'', '\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 个 `|` 就算表格行,不要求行尾必须有 `|` (避免 OCR 识别不完整或 Markdown 表格未闭合时遗漏数据行) - 上文:表格前最近 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() # ⭐ 2026-08-14 修复:放宽表格行检测,不要求行尾必须有 | # 原因:Markdown 表格可能未闭合(如 `| 2026年6月7日 | 🎮 升级服务器到26.1.2`) # 或 OCR 识别不完整,导致数据行被误判为非表格行 if '|' in s 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