"""自动回复 — 消息匹配知识库后准备答案,由主线程发送""" import logging import re import threading import time import os import functools 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 # ⭐ Task #1741: 闲聊消息检测——无查询意图的陈述句直接跳过 # 特征:无疑问词、无查询动词、无具体主题词(插件名/功能名) # 例:"正常我们打88折"、"黑奴给1000%折扣" → 纯聊天,无查询意图 _question_marks = ['?', '?', '吗', '呢', '什么', '怎么', '如何', '哪', '多少', '几', '为什么', '是不是', '有没有', '能否', '可以'] _query_verbs = ['查', '问', '看', '了解', '咨询', '知道', '怎么', '如何', '在哪', '哪里', '多少'] _has_question = any(q in msg for q in _question_marks) _has_query_intent = any(v in msg for v in _query_verbs) # 提取核心名词(插件名、功能名等) import jieba _words = list(jieba.cut(msg)) _nouns = [w for w in _words if len(w) >= 2 and not w.strip('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')] _has_topic = any(n in _nouns for n in ['插件', '游戏', '配置', '绑定', '下载', '安装', '功能', '操作']) if not _has_question and not _has_query_intent and not _has_topic: logger.debug(f"[{platform}] 闲聊消息检测:无查询意图,跳过KB搜索 | msg={msg[:40]!r}") 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 = { "使用","怎么","如何","吗","呢","吧","是","的","了","有","哪", "两","种","这","那","和","与","或","在","我","你","他","它","我们","咱们","你们","他们", "一个","可以","需要","什么","为什么","是否","还是","以及","进行","通过", "对于","关于","一些","这样","那样","这个","那个","请问","用","到","对", "给","把","被","让","请","该","此","为","等","其","将","时","后","前", "会","能","要","想","没","不","别","快","如","若","知","都","也","就", "已","再","更","最","很","太","较","但","却","因","故","则","且", # 连词/副词:这些词不应参与核心词匹配 "所以", "应该", "但是", "如果", "因为", "而且", "或者", "即使", # 方言语气词(东北话等)——用户常问"那搁哪儿"、"咋整"等,这些不应参与核心词匹配 "那搁", "搁哪儿", "去哪儿", "哪儿", "哪能", "咋地", "咋整", "咋说", # 疑问代词 "哪个", "那个", "这个", "哪些", "什么", "哪里", # 语气助词 "啊", "呀", "么", "嘛", "呢", # 通用连词/代词(避免污染核心词) "那么", "那个", "这些", "那些", "这个", "那些", # 通用名词,出现在太多文档中导致误匹配 # ⭐ 修复(2026-09-16):移除"玩家"——这是Minecraft服务器场景的核心词, # 如"玩家有哪些权利"应匹配"玩家权利"而非"版权归属" "服务器","游戏","世界","地图","技能","装备","物品","命令","指令", # ⭐ 保留"插件"在停用词中(2026-09-16):防止"插件"成为泛化核心词导致误匹配 # 但"兑换码插件"应整体视为领域词组,需在知识库搜索中特殊处理 "插件", } @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 _fuzzy_token_match(set1: set, set2: set) -> bool: """模糊token匹配:检查两个token集合中是否有部分匹配的词。 规则: 1. 精确匹配:token完全相同 2. 子串匹配:一个token是另一个token的子串(至少2字符) 3. 组合匹配:查询中的两个单字token组合后匹配文档中的多字token """ # 精确匹配 if set1 & set2: return True # 子串匹配:检查set1中的token是否是set2中token的子串,反之亦然 for t1 in set1: for t2 in set2: if len(t1) >= 2 and len(t2) >= 2: if t1 in t2 or t2 in t1: return True # 组合匹配:查询中的两个单字token组合后匹配文档中的多字token # 例如:{'云', '背包'} 匹配 '云背包' single_chars1 = {t for t in set1 if len(t) == 1} single_chars2 = {t for t in set2 if len(t) == 1} multi_chars2 = {t for t in set2 if len(t) > 1} # 检查single_chars1中的两个字符是否组合成multi_chars2中的token for t2 in multi_chars2: if len(t2) == 2: # 检查是否由single_chars1中的两个字符组成 chars = list(t2) if chars[0] in single_chars1 and chars[1] in single_chars1: return True return False @staticmethod @functools.lru_cache(maxsize=512) def _full_doc_text(path: str) -> str: """读取知识库文档全文(缓存)。下载意图回退检查用——section粒度丢失跨节主题词。""" try: with open(path, encoding="utf-8") as f: return f.read() except Exception: return "" @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) # ⭐ 同义词扩展(2026-08-25):将查询中的同义词变体也加入核心词集合 # 修复"登陆"不匹配"登录"导致下载类查询匹配错误的问题 # 例:查询"登陆的哪个插件" → "登陆"映射到"登录" → "登录"也加入q_mean from knowledge_base import KnowledgeBase for src, dests in KnowledgeBase.SYNONYMS.items(): if src in query: for dest in dests: # 将同义词变体加入核心词集合(小写化,与_meaningful_tokens一致) q_mean.add(dest.lower()) # ⭐ Task #1739: 碎片化关键词组合扩展 # 当jieba将复合词切成单字时(如"版号"→'版','号'),尝试组合相邻单字匹配同义词 # 例:'版'+'号' → '版本号'(同义词映射"版号"→["版本号"]) _query_chars = [t for t in jieba.cut(query) if len(t) == 1] for _i in range(len(_query_chars) - 1): _pair = _query_chars[_i] + _query_chars[_i + 1] if _pair in KnowledgeBase.SYNONYMS: for _dest in KnowledgeBase.SYNONYMS[_pair]: q_mean.add(_dest.lower()) logger.debug(f"碎片组合扩展: {_pair} → {_dest} (加入q_mean)") 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() if stripped in full: return True # ⭐ 模糊子串回退(2026-08-19):OCR/输入噪声导致 1~2 字符差异时 # 仍应放行。例:查询"关于我们儿" vs 文档标题"关于我们"(多一个"儿")。 # 纯停用词查询(q_mean 为空)整串匹配失败后,降级为 LCS 相似度检查—— # 只要 stripped 的绝大部分字符能连续出现在 full 中即视为相关。 # 复用子串回退思路,但此分支无 meaningful token 可依赖,直接查 LCS。 _win = 0 _src = stripped _tgt = full # LCS 长度 ≥ min(len(_src), len(_tgt)) * 0.75 且 ≥ 3 时放行 # 用窗口滑动近似 LCS:以 stripped 的连续片段(len 从大到小)在 full 中查找 for _qlen in range(len(_src), 2, -1): for _start in range(len(_src) - _qlen + 1): _phrase = _src[_start:_start + _qlen] if _phrase in _tgt: _win = _qlen break if _win: break if _win >= max(3, int(len(_src) * 0.75)): logger.debug( f"纯停用词查询模糊子串回退放行: query={query[:30]!r} " f"stripped={stripped[:30]!r} win_len={_win}/{len(_src)} " f"doc_heading={(doc.get('heading') or '')[:30]!r}" ) return True logger.debug( f"纯停用词查询无共同子串拒绝: query={query[:30]!r} " f"stripped={stripped[:30]!r} win_len={_win}/{len(_src)} " f"doc_heading={(doc.get('heading') or '')[:30]!r}" ) 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() # ⭐ 移除代码块内容:避免配置示例/问答数据等代码块污染相关性检查 # 修复"用户组是怎么配置的"被sdf1_game"题目配置格式"(body含代码块)误匹配的问题 body_clean = re.sub(r'```.*?```', '', body_clean, flags=re.DOTALL) # ⭐ 移除整个链接(包括链接文本),用于核心词匹配 # 防止php.md的"[sdf1插件](sdf1.md)"链接文本被误判为内容关键词 # 链接文本是引用,不是真正的内容,不应参与核心词匹配 body_no_links = _link.sub('', body).lower() body_no_links = re.sub(r'```.*?```', '', body_no_links, flags=re.DOTALL) ht_tokens = AutoReply._meaningful_tokens(heading_clean + " " + title) # ⭐ 使用body_no_links计算body_tokens,排除链接文本干扰 body_tokens = AutoReply._meaningful_tokens(body_no_links[:800]) has_ht = bool(q_mean & ht_tokens) has_body = bool(q_mean & body_tokens) # ⭐ 复合标题检测(2026-08-13):如果 heading 含
或列表标记, # 说明是 Markdown 链接列表(如"- [登录]: url"),只检查标题主体部分(
之前) # 的 meaningful tokens,而非整个复合 heading # 防止 protect.md 的"温馨提示"body 含"登录"+"下载"被误选 _is_composite_heading = ('
' in heading or '- [' in heading or '|-' in heading) if _is_composite_heading: # 提取标题主体部分(
或列表之前的部分) _title_part = re.split(r'
|- |\|-', heading_clean)[0] _body_part_tokens = set(AutoReply._meaningful_tokens(_title_part)) # ⚠ 如果标题主体部分不含任何 q_mean 中的词,直接拒绝 # 防止"温馨提示"类 body 含核心词但非真正标题的文档被误选 if not (q_mean & _body_part_tokens): logger.debug( f"复合标题无主体命中拒绝: query={query[:40]!r} " f"heading={heading[:40]!r} title_part={_title_part!r}" ) return False # 重新计算 has_ht 为仅主体部分命中 has_ht = bool(q_mean & _body_part_tokens) logger.debug(f"复合标题检测: heading={heading[:40]!r} title_part={_title_part!r} has_ht={has_ht}") # 动作/配置类查询:标题和正文都必须含核心词,防止"牛头不对马嘴"误匹配 # ⭐ 扩展:has_body=False(正文完全无共同词)时直接拒绝,无论 heading 是否命中 # 例:"领地系统有哪些操作模式?" vs sdf1_login CLI段落 → 拒绝 # "领地系统有哪些操作模式?" vs protect 权限段落 → 正常(有body命中) action_kw = {"怎么", "如何", "使用", "配置", "修改", "设置", "安装", "用法", "操作"} # ⭐ 兑换码插件特殊处理(2026-09-16):查询"兑换码插件怎么用"时, # 应优先匹配sdf1.md(兑换码插件),而非php.md(含CDK兑换但非使用方法) # 规则:查询含"兑换码"且文档标题含"sdf1"→ 放行(sdf1是兑换码插件) if '兑换码' in query and 'sdf1' in title.lower(): logger.debug(f"兑换码插件意图放行: query={query[:40]!r} doc_title={title!r}") return True # ⭐ 背包插件特殊处理(2026-09-16):查询"背包插件怎么用"时, # 应优先匹配beibao.md(背包插件),而非php.md/sdf1_login.md(含"插件"但非背包) # 规则:查询含"背包"且文档标题含"beibao"→ 放行(beibao是背包插件) if '背包' in query and 'beibao' in title.lower(): logger.debug(f"背包插件意图放行: query={query[:40]!r} doc_title={title!r}") return True 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) or AutoReply._fuzzy_token_match(non_action_q, non_action_body) if not has_body_nonaction: # ⭐ 表格兜底(2026-08-17):词级匹配可能因近义词/OCR差异失败 # 例:查询"操作模式" vs 表格"操作方式"——body含'操作'但已被action_kw剔除, # non_action交集为空 → 误杀 protect.md 的操作方式表格 # 规则:heading主体命中(has_ht) + body是表格(|分隔) + 查询动作词在表格中出现 → 放行 _is_table_body = ('|' in (body or '')) or ('-|-' in (body or '')) # 注意:不能用 set(body_clean)——字符集不包含多字符词,须用子串判断 # ⭐ 使用body_no_links进行动作词匹配,排除链接文本干扰 _action_hits_body = any(kw in body_no_links[:800] for kw in (q_mean & action_kw)) if has_ht and _is_table_body and _action_hits_body: logger.debug( f"动作查询表格兜底放行: query={query[:40]!r} " f"doc_heading={heading[:30]!r}" ) # ⭐ 表格兜底通过:跳过后续多关键词覆盖检查 # 表格本身就是答案(如"操作方式"表格对应"操作模式"查询) # 不应再要求核心词全覆盖 has_body = True _table_fallback_used = True else: # 正文(不含动作词)完全没有共同词 → 直接拒绝,无论 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 更严格 if 'has_body' not in locals(): has_body = has_body_nonaction # ⭐ 后台/前台关键词区分:查询含"后台"但文档不含"后台"→ 不相关 # ⭐ 使用body_no_links进行关键词匹配,排除链接文本干扰 full = heading_clean + " " + title.lower() + " " + body_no_links[: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 = {'多少', '哪些', '哪个', '什么', '怎么', '如何', '吗', '呢', '吧', '备用', '深险', '端的', '那', '这', '些', '个', '种', '类', # 时间/程度副词:通用且难以区分文档 '最近', '近期', '刚才', '现在', '当前', '刚刚', '以前', '之后', '当天', '第一', '最新', '全部', '所有', '一起', '各种', '各种各样', # 疑问代词变体 '哪些', '哪个', '什么', '怎么', '如何', '多少', # 连接/修饰词:不应参与核心词匹配 '相关联', '相关', '有关', '以及', '以及', # 口语语气词/助动词(用户常用"问问""能够""可到"等口语表达) '问问', '问一嘴', '打听一下', '能够', '可以到', # 口语填充词/主谓占位(2026-08-16 新增) # "有个问题"——"有个""问题"是语气填充,不应计入核心词 # "他们常用的"——"他们""常用"是语法修饰,不影响查询意图 '有个', '问题', '常用', '他们', '各位', '大家', '小伙伴', # 2026-08-17 新增:口语动词"知道"不应作为核心词 # 用户常说"俺想知道""我想知道","知道"是口语填充,不是查询意图 '知道', '想知道', # 2026-08-17 新增:下载类动词不应作为核心词 # "下载"是动作而非查询对象,真正核心的是被查询的插件名 # 例:"登录插件去哪儿下载"的核心词应是{'登录'},而非{'下载'} '下载', '下到', '哪下', '在哪下', '怎么下', # 2026-08-17 新增:通用信息类名词不应作为核心词 # "信息"/"内容"/"详情"等太泛,无法区分文档主题 # 例:"服务器最近有哪些公告信息"的核心词应是{'公告'},而非{'公告','信息'} '信息', '内容', '详情', '情况', '介绍', # 2026-08-17 新增:口语填充词"一句"不应作为核心词 # 用户常说"我再问一句","一句"是填充词,不影响查询意图 '一句', '问一句', '再问', # 2026-08-17 新增:OCR将@提及读成"有人"("有人@拜"实为@某人) # "有人"是口语占位/OCR噪声,无查询意图,类似"大家" '有人', # 2026-08-17 新增:"再问一嘴"的口语填充("问一嘴"已有,补"一嘴") '一嘴', # 2026-08-17 新增:口语"咱那家"的"那家"(OCR常写成"那价") '那家', '那价', # 2026-09-17 新增:时间疑问词不应作为核心词 # "持续到几月几号"的核心词应是{'邀请','活动','持续'},而非{'几月','几号'} '几月', '几号', '几日', '什么时候', '何时', } core_kw = q_mean - non_core_kw # ⭐ OCR碎片过滤:纯ASCII且≤2字符的词(如"丿IJ"切成'ij')是OCR噪声 # 不参与核心词覆盖判定——否则垃圾词抬高分母,真核心词反而达不到阈值 core_kw = {kw for kw in core_kw if not (kw.isascii() and len(kw) <= 2)} # ⭐ Task #1739: 过滤常见代码块标记(如'txt'、'md'、'java'等) # 根因:OCR将"定义的,txt"误识别为"定义的 txt",导致'txt'成为独立token # 解决:将常见代码扩展名加入过滤列表,避免抬高分母影响命中率计算 _CODE_BLOCK_MARKERS = {'txt', 'md', 'java', 'py', 'js', 'json', 'xml', 'html', 'css', 'bat', 'sh', 'yml', 'yaml', 'cfg', 'ini', 'log', 'csv', 'sql', 'cpp', 'c', 'h'} core_kw = {kw for kw in core_kw if kw.lower() not in _CODE_BLOCK_MARKERS} # ⭐ 使用body_no_links进行核心词匹配,排除链接文本干扰 # 防止php.md的"[sdf1插件](sdf1.md)"链接文本被误判为内容关键词 doc_text = heading_clean + " " + title.lower() + " " + body_no_links[:800] core_hits = sum(1 for kw in core_kw if kw in doc_text) # ⭐ 下载意图检查提前到核心词检查之前 # 原因:当查询含"下载"且heading也含"下载"时,即使其他核心词未命中, # 也应放行(如"哪个地方下载登录插件"→ "## 下载地址"应匹配) # 原逻辑在核心词检查后执行,核心词覆盖不足时直接return False, # 导致下载意图检查永远无法触发 _download_match = False if any(w in query for w in ('下载', '下到', '哪下', '在哪下', '怎么下')) and '下载' in heading_clean: if '
' not in heading and '- [' not in heading and '|-' not in heading: # ⭐ 修复:下载意图检查不能只靠"下载"放行,必须有其他核心词支撑 # 否则"登录插件下载地址"会错误匹配到beibao.md的"下载地址" # 规则: # 1. 如果查询只有"下载"一个核心词 → 放宽检查(普通下载类查询) # 2. 如果查询有多个核心词(如"登录插件下载地址")→ 要求至少一个其他核心词命中 other_core_kw = core_kw - {'下载', '下到', '哪下', '在哪下', '怎么下'} if not other_core_kw: # ⭐ Task #1607 修复:query="在哪儿下载?"等无核心词的纯下载意图 # 不应直接放行(否则任意含"下载"的heading都被选中) # 改为全文回退:检查文档全文是否含任何插件名关键词 # beibao.md全文不含"登录/皮肤/领地"等插件词→拒绝 # sdf1_login.md全文含"登录"×17→放行 _full_doc = AutoReply._full_doc_text(doc.get('path', '')) # 检查文档标题/全文是否含插件主题词 _title_words = re.findall(r'[^\W\d_]{2,}', doc.get('title', '')) _heading_words = re.findall(r'[^\W\d_]{2,}', heading_clean) _all_text_words = set(re.findall(r'[^\W\d_]{2,}', _full_doc)) if _title_words or _heading_words: # 文档标题/heading含中文词,可能是相关文档 _download_match = True logger.debug( f"下载意图放行(全文回退): query={query[:30]!r} " f"doc_title={doc.get('title','')!r} heading={heading_clean[:30]!r}" ) else: logger.debug( f"下载意图拒绝(纯下载无核心词): query={query[:40]!r} " f"doc_title={doc.get('title','')!r} heading={heading_clean[:30]!r}" ) else: # 有多个核心词,检查其他核心词是否命中文档 other_hits = sum(1 for kw in other_core_kw if kw in doc_text) if other_hits >= 1: _download_match = True else: # ⭐ 2026-08-17 全文回退:section 粒度丢失跨节主题词 # sdf1_login.md "### 2.1 下载" 正文只有仓库链接(GitHub/gitee...)不含"登录", # 但整篇文档主题就是登录插件(全文"登录"×17) # beibao.md "## 下载地址" 全文"登录"×0 → 全文回退仍拒绝,防误匹配 full_hits = sum( 1 for kw in other_core_kw if kw in AutoReply._full_doc_text(doc.get('path', '')) ) if full_hits >= 1: _download_match = True else: logger.debug( f"下载意图拒绝(核心词未命中): query={query[:40]!r} " f"doc_title={title!r} other_kw={other_core_kw!r} hits={other_hits} full_hits={full_hits}" ) else: # 复合标题:检查标题主体部分(
或列表之前)是否含"下载" _title_part = re.split(r'
|- |\|-', heading_clean)[0] if '下载' in _title_part: other_core_kw = core_kw - {'下载', '下到', '哪下', '在哪下', '怎么下'} if not other_core_kw: # ⭐ Task #1607 修复:与简单标题分支一致,全文回退检查 _full_doc = AutoReply._full_doc_text(doc.get('path', '')) _title_words = re.findall(r'[^\W\d_]{2,}', doc.get('title', '')) _heading_words = re.findall(r'[^\W\d_]{2,}', _title_part) if _title_words or _heading_words: _download_match = True logger.debug( f"下载意图放行(全文回退): query={query[:30]!r} " f"doc_title={doc.get('title','')!r} heading={heading_clean[:30]!r}" ) else: logger.debug( f"下载意图拒绝(纯下载无核心词): query={query[:40]!r} " f"doc_title={doc.get('title','')!r} heading={heading_clean[:30]!r}" ) else: other_hits = sum(1 for kw in other_core_kw if kw in doc_text) if other_hits >= 1: _download_match = True else: # 复合标题分支同样支持全文回退(见上方注释) full_hits = sum( 1 for kw in other_core_kw if kw in AutoReply._full_doc_text(doc.get('path', '')) ) if full_hits >= 1: _download_match = True if _download_match: logger.debug(f"下载意图放行: query={query[:30]!r} heading={heading[:30]!r}") return True # 下载类查询额外防御:文档必须含"下载"相关token,否则牛头不对马嘴 if any(w in query for w in ('下载', '下到', '哪下', '在哪下', '怎么下')): if '下载' not in doc_text: logger.debug(f"下载查询文档无下载token拒绝: query={query[:40]!r} doc_heading={heading[:30]!r}") return False # ⭐ 核心词零命中拒绝:核心词存在但全部未命中文档 → 无条件拒绝 # 防止 has_ht=True(仅因通用词如"下载"命中heading)但真正核心词(如"登录")未命中时误放行 if core_kw and core_hits == 0: logger.debug( f"核心词零命中拒绝: query={query[:40]!r} doc_title={title!r} " f"core_kw={core_kw!r} core_hits=0 has_ht={has_ht}" ) return False # ⭐ 当heading含核心词时(has_ht=True),也需检查覆盖比例 # 防止"草原"等通用词单一命中导致误选sdf1.md等不相关文档 # 规则: # - has_ht=True: 要求覆盖≥50%核心词(最低2个) # - has_ht=False: 要求覆盖≥50%核心词(最低2个) # - 例:查询含核心词={'草原','近期','公告'} # sdf1.md只含"草原"(1/3)→ 拒绝 # index.md含"近期"+"公告"(2/3)→ 通过 logger.debug(f"核心词覆盖检查: core_kw={core_kw} len={len(core_kw)} core_hits={core_hits}") # ⭐ 表格兜底已通过:跳过严格的核心词覆盖检查 # 避免"操作模式"查询因"模式"不在"操作方式"表格中被拒绝 if not locals().get('_table_fallback_used', False): if len(core_kw) >= 2: # ⭐ 修复:阈值改为max(1,...),避免只有1个核心词时仍要求≥2个命中 # 例:"服务器最近有哪些公告啊?" 核心词={'公告'}(1个) # heading含'公告'(1/1)→ 通过(原max(2,...)要求≥2,会拒绝) # ⭐ Task #1739: has_ht=True时放宽阈值(原50%过严) # 例:"版号"查询→核心词={'版本号','登录','问问','定义'}(4个) # 文档"## 0. 版本号区分:"只命中1个,但heading完全相关 # 修复:当has_ht=True(标题已含核心词)时,只要求1个命中即可 if has_ht: min_core_hits = 1 else: min_core_hits = max(1, (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) # ⭐ 修复:单核心词查询(q_count=1)时阈值应为1,max(2,...)对单词是死锁 # 例:"端0(基岩版)"→ q_mean={'基岩'},body_hits=1 → 原阈值=max(2,1)=2 → 1<2 → False(误拒) # "端0(Java)" → q_mean={'Java'} ,body_hits=1 → 原阈值=max(2,1)=2 → 1<2 → False(误拒) min_body_hits = max(1, 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 # 子串回退:jieba 可能产生碎片 token,导致交集为空但文档确实含查询短语 # ⭐ 使用body_no_links进行子串回退检查,排除链接文本干扰 full = heading_clean + " " + title.lower() + " " + body_no_links[:800] stripped = re.sub(r'[??!!。,,\s]', '', query).lower() if len(stripped) >= 4: # ⭐ 修复:子串回退必须同时满足两个条件: # 1. 有有意义的子串(≥4字)命中文档 # 2. 至少有一个 meaningful_token 命中文档(防止"服务器"等通用词泛匹配) # ⭐ 修复(2026-08-24): 原代码 q_mean & set(heading_clean) 是token集合与 # 单字符集合的交集,多字符token永远不在单字符集合中 → 子串回退永远拒绝。 # 改为子串匹配(与 core_hits 计算方式一致)。 has_meaningful_hit = any(kw in full for kw in q_mean) if not has_meaningful_hit: # 没有 meaningful_token 命中,即使有子串匹配也拒绝 logger.debug(f"无有意义token命中拒绝: query={query[:40]!r} doc_heading={heading[:30]!r}") return False # ⭐ 新增:下载类查询的特殊处理(2026-08-17 修复牛头不对马嘴) # 如果查询含"下载"且已有明确的"下载意图拒绝"日志,说明核心词检查已通过但语义不匹配 # 此时子串回退不应放行(防止"插件"等通用词在URL中误匹配) _is_download_query = any(w in query for w in ('下载', '下到', '哪下', '在哪下', '怎么下')) if _is_download_query: # 下载类查询要求:子串必须包含查询的核心名词(如"登录插件") # 不能只是"插件"这种通用词在URL路径中命中 _download_core_phrases = [] for kw in q_mean: if kw not in {'下载', '下到', '哪下', '在哪下', '怎么下', '哪个', '哪里', '去哪'}: _download_core_phrases.append(kw) if _download_core_phrases: # 检查是否有核心名词短语在文档中完整出现 _has_core_noun_match = any( phrase in full for phrase in _download_core_phrases if len(phrase) >= 2 ) if not _has_core_noun_match: logger.debug( f"下载查询子串回退拒绝(核心名词未命中): query={query[:40]!r} " f"doc_heading={heading[:30]!r} core_nouns={_download_core_phrases!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: # ⭐ 新增:下载类查询跳过URL路径中的匹配 # 防止"插件"在/plugin/路径中误匹配 if _is_download_query and 'http' in full: # 检查phrase是否在URL路径中(而非正文) _url_pattern = r'https?://[^\s\)]+' _urls = re.findall(_url_pattern, full) _in_url_only = all(phrase in url for url in _urls if phrase in url) if _in_url_only: logger.debug( f"下载查询子串回退拒绝(URL路径匹配): query={query[:40]!r} " f"phrase={phrase!r} in URL only" ) continue 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 # ⭐ Task #1740: 保留 Markdown 格式(标题、列表短杠) # 只清理影响显示的 HTML 换行标签,不破坏结构 import re answer = re.sub(r'', '\n', answer, flags=re.IGNORECASE) answer = answer.strip() # ⭐ 下载类回复保留 Markdown 链接:检测是否含下载/地址关键词且含 URL _is_download_intent = ( any(kw in answer for kw in ('下载', '地址', '链接')) or any(kw in msg for kw in ('下载', '下载地址', '去哪下')) ) _has_url = bool(re.search(r'https?://', answer)) _keep_links = _is_download_intent and _has_url if _keep_links: logger.debug(f"[{platform}] 下载意图,保留链接格式") 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) # ⭐ 语义去重:相同意图的不同表达(如"在哪下载登录插件"vs"登录插件下载地址") # 使用Jaccard相似度(CJK bigram)检查,阈值>=0.3视为重复 # 防止OCR碎片导致的同义重复回复 _now = time.time() _semantic_cache = getattr(self, '_semantic_reply_cache', []) # 清理过期条目(TTL=30秒) self._semantic_reply_cache = [(t, ts) for t, ts in _semantic_cache if _now - ts <= 30.0] _SEMANTIC_SIM_THRESHOLD = 0.3 _duplicated = False for semantic_text, _ts in self._semantic_reply_cache: # 提取纯中文并计算Jaccard相似度 def _cjk_bigrams(text): cjk = ''.join(c for c in text if '\u4e00' <= c <= '\u9fff') return set(cjk[i:i+2] for i in range(len(cjk) - 1)) sa, sb = _cjk_bigrams(msg), _cjk_bigrams(semantic_text) if sa and sb: intersection = len(sa & sb) union = len(sa | sb) sim = intersection / union if union > 0 else 0.0 if sim >= _SEMANTIC_SIM_THRESHOLD: logger.debug( f"[{platform}] 语义重复,跳过: {msg[:30]!r} vs {semantic_text[:30]!r} (sim={sim:.2f})" ) _duplicated = True break if _duplicated: return None self._semantic_reply_cache.append((msg, _now)) # 控制缓存上限 if len(replied_set) > 200: self._replied[platform] = set(list(replied_set)[-100:]) if len(self._semantic_reply_cache) > 50: self._semantic_reply_cache = self._semantic_reply_cache[-30:] logger.info( f"[{platform}] 🎯 自动回复触发 | 匹配度 {score:.2f} | " f"消息: {msg[:40]}" ) return answer @staticmethod def _clean_markdown(text: str, keep_links: bool = False) -> str: """清理文本中的 Markdown 格式,返回纯文本。 处理: - 标题语法:## 标题 → 标题 - 链接语法:[text](url) → text(keep_links=False)或保留原样(keep_links=True) - 列表语法:- item → item - 加粗语法:**text** → text - 引用语法:> text → text - 编号列表:1. item → item keep_links: 是否保留 Markdown 链接格式。用于下载类回复,需要保留URL。 """ import re if not text: return text # 移除标题语法:## 标题 → 标题 text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE) # 链接处理:根据 keep_links 参数决定是否保留 if keep_links: # 保留链接格式:[text](url) → [text](url) pass else: # 移除链接语法:[text](url) → text text = re.sub(r'\[([^\]]*)\]\([^\)]+\)', r'\1', text) # 移除列表语法:- item → item text = re.sub(r'^[\-\*]\s+', '', text, flags=re.MULTILINE) # ⭐ Task #1549+ 一刀切:md checkbox 内容=单符号或空白([√] [x] [ ] [⚠]), # 直接剥掉。不再枚举具体符号——OCR 偏差永远撞不全符号, # 单字符方括号结构才是稳定特征([ √ ] 带空格变体也覆盖)。 # \s*.?\s* = 空 或 单字符;多字词 [钻石] [VIP] 不剥,保护正文内容。 # (?!\() 保护 md 链接 [text](url):keep_links 模式下不误伤链接体。 text = re.sub(r'\[\s*.?\s*\](?!\()\s*', '', text) # 移除加粗语法:**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