From 9ab2da404f92d53c0c0fbd76fe3660510c71fbca Mon Sep 17 00:00:00 2001 From: youpaishidifu Date: Mon, 10 Aug 2026 17:59:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dbot=E5=A4=9A=E6=AE=B5?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E4=B8=8E=E8=A1=A8=E6=A0=BC=E5=85=A8=E6=96=87?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=EF=BC=9A=E5=BC=BA=E5=8C=96=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=80=A7+=E8=A1=A8=E6=A0=BC=E5=8F=AA=E5=8F=96=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix A: auto_reply._get_answer 含表格正文不再返回整段section,改为 提取表格+紧邻上文(最多2行),丢弃表格后无关图文,截断到800字符 - Fix A: 清理答案中的HTML
标签,避免原形显示 - Fix B: 强化 _has_content_relevance,要求查询与文档共享有区分度核心词 (去停用词),动作类查询额外要求正文命中,防牛头不对马嘴 - Fix B: gui._search_and_log 从 top_k 结果中选取首个通过相关性校验的文档 发送,不再永远取 results[0],保证一条消息只回复一段精准内容 --- auto_reply.py | 204 ++++++++++++++++++++++++++++++-------------------- gui.py | 51 +++++++------ 2 files changed, 153 insertions(+), 102 deletions(-) diff --git a/auto_reply.py b/auto_reply.py index 76ca3f3..a2a40e7 100644 --- a/auto_reply.py +++ b/auto_reply.py @@ -1,6 +1,7 @@ """自动回复 — 消息匹配知识库后准备答案,由主线程发送""" import logging +import re import threading import jieba @@ -58,9 +59,13 @@ class AutoReply: 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) -> dict or None: - """返回 {platform, answer, sender} 供主线程发送。""" - answer = self.on_kb_match(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 @@ -75,87 +80,86 @@ class AutoReply: 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: - """查询词与匹配文档至少有一个共同2字+词,防偶然匹配。 - 短查询(≤2个2字+词)直接放行,不做严格检查。 - 检查顺序:heading → title → content/body。 - 动作/配置类查询额外要求正文含核心词,不能只命中标题。 - 高匹配度(≥10.0):如果heading+title+body都无共同词,直接拒绝。 + """查询词与匹配文档必须共享至少一个有区分度的核心词,防偶然/牛头不对马嘴匹配。 + + 判定顺序: + 1. 提取 query 的有区分度关键词(_meaningful_tokens,去停用词、小写)。 + 2. 检查这些词是否出现在 doc 的 heading+title 或 body(前 800 字)中。 + 3. 动作/配置类查询(怎么/如何/使用/配置…)额外要求正文也含核心词, + 不能只命中标题(否则 "# Sdf1系列登录插件使用说明" 会因标题含词被误放)。 + 4. 子串回退:连续 4~6 字子串命中文档(应对 jieba 切碎的短语,如"资源包")。 + 5. 高匹配度(≥10)但无任何共同词:TF-IDF 误匹配,拒绝。 """ import re - q_tokens = set(t for t in jieba.cut(query) if len(t) >= 2) - # ⭐ 修复:短查询不再无条件放行,改为必须有子串命中才放行 - # 原逻辑导致"下载"(1 token)→"前置依赖"误匹配(无任何共同token却放行) - _is_short_query = len(q_tokens) <= 2 + 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 - # 1. 检查 heading + title(排除链接文本) + _link = re.compile(r'\[([^\]]*)\]\([^\)]+\)') heading = doc.get("heading", "") title = doc.get("title", "") - # 清理 heading 中的 Markdown 链接:[text](url) → text - _link_pattern = re.compile(r'\[([^\]]*)\]\([^\)]+\)') - heading_clean = _link_pattern.sub(r'\1', heading) - # 进一步清理:去掉链接文本本身 - heading_no_link = _link_pattern.sub('', heading) - doc_text = heading_clean + " " + title - d_tokens = set(t for t in jieba.cut(doc_text) if len(t) >= 2) - has_heading_hit = bool(q_tokens & d_tokens) - - # 2. 检查 content/body(排除链接文本) + # 清理 Markdown 链接文本,避免 "[sdf1_login登录插件](sdf1_login.md)" 误匹配 + heading_clean = _link.sub(r'\1', heading).lower() body = doc.get("content", "") or doc.get("body", "") - b_tokens = set() - has_body_hit = False - if body: - # 清理 body 中的链接文本,防止 "[sdf1_login登录插件](sdf1_login.md)" 误匹配 - body_clean = _link_pattern.sub(r'\1', body) - body_no_link = _link_pattern.sub('', body) - # 截断前500字防超长文档 - b_tokens = set(t for t in jieba.cut(body_no_link[:500]) if len(t) >= 2) - has_body_hit = bool(q_tokens & b_tokens) + 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) # 动作/配置类查询:不能只命中标题,正文必须也含核心词 - # 否则"# Sdf1系列登录插件使用说明"会因标题含"登录/插件/使用"被误放 - action_keywords = {"怎么", "如何", "使用", "配置", "修改", "设置", "安装", "用法"} - if action_keywords & q_tokens: - core_q_tokens = q_tokens - {"插件", "使用", "怎么", "如何", "吗", "呢", "吧"} - if core_q_tokens and not (core_q_tokens & b_tokens): - logger.debug( - f"动作查询正文相关性不足: query={query[:40]!r} " - f"heading_hit={has_heading_hit} body_hit={has_body_hit}" - ) - return False + action_kw = {"怎么", "如何", "使用", "配置", "修改", "设置", "安装", "用法", "操作"} + if (q_mean & action_kw) and not has_body: + logger.debug( + f"动作查询正文相关性不足: query={query[:40]!r} " + f"heading_hit={has_ht} body_hit={has_body}" + ) + return False - if has_heading_hit or has_body_hit: + if has_ht or has_body: return True - # 3. 子串回退:jieba分词可能产生"包是""还是"等碎片token, - # 导致token交集为空但文档确实包含查询核心词(如"资源包")。 - # 检查查询中任意2~6字子串是否出现在文档文本中。 - # ⭐ 短查询(≤2 token):检查整个query是否出现在文档中("下载"必须在文档中出现) - # ⭐ 使用清理后的文本(排除链接文本) - full_text = doc_text + " " + body_no_link[:500] if body else doc_text - stripped_query = re.sub(r'[??!!。,,\s]', '', query) - # 短查询特殊处理:整个query必须作为子串出现在文档中 - if _is_short_query and len(stripped_query) >= 2: - if stripped_query in full_text: - logger.debug(f"短查询子串命中: query={stripped_query!r} in doc={doc.get('heading','')[:30]!r}") - return True - else: - logger.debug(f"短查询子串未命中: query={stripped_query!r} 不在文档中,拒绝") - return False - if len(stripped_query) >= 4: - for qlen in range(min(len(stripped_query), 6), 1, -1): - for start in range(len(stripped_query) - qlen + 1): - phrase = stripped_query[start:start + qlen] - if len(phrase) >= 2 and phrase in full_text: + # 子串回退: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}" + f"子串回退命中: phrase={phrase!r} in doc={doc.get('heading', '')[:30]!r}" ) return True - # 4. 高匹配度但无共同词:可能是TF-IDF误匹配,拒绝 - # 用户问"PVP击杀播报",KB返回"json格式说明"(匹配度9.53)→ 应拒绝 - if score >= 10.0 and not has_heading_hit and not has_body_hit: + # 高匹配度但无共同词:TF-IDF 误匹配,拒绝 + if score >= 10.0 and not (has_ht or has_body): return False return False @@ -173,26 +177,20 @@ class AutoReply: # 优先用 body(纯正文),兜底用 content(heading+正文) body = doc.get("body", "").strip() if body and len(body) > 20: - # 表格内容动态计算长度,多退少补;普通文本保持300字符 - if '|' in body: - # 表格内容:动态计算,上限4000字符(QQ单条约4500字符限制) - max_len = min(len(body), 4000) - else: - max_len = 300 - answer = body[:max_len-3] + "..." if len(body) > max_len else body + answer = self._extract_table_answer(body) else: content = doc.get("content", "").strip() - if '|' in content: - max_len = min(len(content), 4000) - else: - max_len = 300 - answer = content[:max_len-3] + "..." if len(content) > max_len else content + 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() + + # 拒绝把纯标题/免责声明当作答案发送 if answer.startswith("# ") and len(answer) < 60: logger.debug(f"[{platform}] 答案为纯标题,跳过: {answer[:60]!r}") return None @@ -216,3 +214,49 @@ class AutoReply: f"消息: {msg[:40]}" ) return answer + + @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 diff --git a/gui.py b/gui.py index b1888bc..cdff5a6 100644 --- a/gui.py +++ b/gui.py @@ -940,8 +940,6 @@ class MsgBotApp(ctk.CTk): lines = [f"[{platform.upper()}] {msg}"] if results: - answer_title = results[0][0]["title"] - answer_score = results[0][1] lines.append(f" 🔍 找到 {len(results)} 条相关结果:") for i, (doc, score) in enumerate(results): @@ -963,32 +961,41 @@ class MsgBotApp(ctk.CTk): if i == 0: lines.append(f" 完整内容片段: {content[:200].replace(chr(10), ' ')}...") - # 诊断日志:记录自动回复触发条件 + # 从 top_k 结果中选取【第一个通过内容相关性校验】的文档发送, + # 避免永远只取 results[0] 导致"牛头不对马嘴"的错误文档被发送。 + # 一条消息只回复一段(选中最相关且语义匹配的那一条),不触发 N 段回复。 + chosen = None + if self.ar.enabled: + for idx, (doc, sc) in enumerate(results): + if sc < self.ar.min_score: + continue + rel = self.ar._has_content_relevance(cleaned_msg, doc, sc) + logger.debug( + f"[{platform}] 相关性候选[{idx}]: title={doc.get('title', '')!r} " + f"score={sc:.2f} relevance={rel}" + ) + if rel: + chosen = (doc, sc) + break + if not self.ar.enabled: lines.append(" ⚠ 自动回复已关闭,不触发回复") - elif answer_score < self.ar.min_score: - lines.append(f" ⚠ 匹配度 {answer_score:.2f} < 阈值 {self.ar.min_score},跳过") + elif chosen is None: + lines.append(" ⚠ 所有结果均未通过内容相关性/阈值检查,跳过回复") else: - # 检查内容相关性 - has_relevance = self.ar._has_content_relevance(msg, results[0][0], answer_score) - - # 详细调试日志:记录内容相关性检查结果 - logger.debug(f"[{platform}] 内容相关性检查: msg={msg!r}, has_relevance={has_relevance}") - logger.debug(f"[{platform}] 内容相关性检查: doc标题={results[0][0].get('title', '')!r}") - logger.debug(f"[{platform}] 内容相关性检查: doc heading={results[0][0].get('heading', '')[:50]!r}") - - if not has_relevance: - heading = results[0][0].get("heading", "")[:40] - lines.append(f" ⚠ 内容相关性检查失败 (heading={heading!r}),跳过") - else: - lines.append(" ✅ 匹配通过,准备发送自动回复") + lines.append(" ✅ 匹配通过,准备发送自动回复") # 自动回复:队列到主线程执行(daemon 线程不能可靠 SetForegroundWindow) - reply_data = self.ar.get_reply_data(platform, msg, results[0][0], results[0][1]) - if reply_data: - self._reply_queue.put(reply_data) + if chosen is not None: + reply_data = self.ar.get_reply_data( + platform, msg, chosen[0], chosen[1], cleaned_msg=cleaned_msg + ) + if reply_data: + self._reply_queue.put(reply_data) + else: + lines.append(" ⚠ get_reply_data 返回 None(重复命中或内部检查失败)") else: - lines.append(" ⚠ get_reply_data 返回 None(重复命中或内部检查失败)") + reply_data = None else: lines.append(" ❌ 知识库中未找到相关答案")