修复bot多段回复与表格全文回调:强化相关性+表格只取上下文

- Fix A: auto_reply._get_answer 含表格正文不再返回整段section,改为
  提取表格+紧邻上文(最多2行),丢弃表格后无关图文,截断到800字符
- Fix A: 清理答案中的HTML <br> 标签,避免原形显示
- Fix B: 强化 _has_content_relevance,要求查询与文档共享有区分度核心词
  (去停用词),动作类查询额外要求正文命中,防牛头不对马嘴
- Fix B: gui._search_and_log 从 top_k 结果中选取首个通过相关性校验的文档
  发送,不再永远取 results[0],保证一条消息只回复一段精准内容
This commit is contained in:
youpaishidifu
2026-08-10 17:59:18 +08:00
parent 18347a9f7d
commit 9ab2da404f
2 changed files with 153 additions and 102 deletions
+124 -80
View File
@@ -1,6 +1,7 @@
"""自动回复 — 消息匹配知识库后准备答案,由主线程发送""" """自动回复 — 消息匹配知识库后准备答案,由主线程发送"""
import logging import logging
import re
import threading import threading
import jieba import jieba
@@ -58,9 +59,13 @@ class AutoReply:
with self._lock: with self._lock:
return self._get_answer(platform, msg, top_doc, score) 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: def get_reply_data(self, platform: str, msg: str, top_doc: dict, score: float, cleaned_msg: str = None) -> dict or None:
"""返回 {platform, answer, sender} 供主线程发送。""" """返回 {platform, answer, sender, msg} 供主线程发送。
answer = self.on_kb_match(platform, msg, top_doc, score) cleaned_msg 为去噪后的查询(用于相关性校验与去重指纹),
msg 保留原始文本(含时间戳)供主线程做消息新鲜度检查。
"""
query = cleaned_msg if cleaned_msg else msg
answer = self.on_kb_match(platform, query, top_doc, score)
if not answer: if not answer:
return None return None
@@ -75,87 +80,86 @@ class AutoReply:
return {"platform": platform, "answer": answer, "sender": sender, "msg": msg} 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 @staticmethod
def _has_content_relevance(query: str, doc: dict, score: float = 0.0) -> bool: def _has_content_relevance(query: str, doc: dict, score: float = 0.0) -> bool:
"""查询词与匹配文档至少有一个共同2字+词,防偶然匹配。 """查询词与匹配文档必须共享至少一个有区分度的核心词,防偶然/牛头不对马嘴匹配。
短查询(≤2个2字+词)直接放行,不做严格检查。
检查顺序:heading → title → content/body。 判定顺序:
动作/配置类查询额外要求正文含核心词,不能只命中标题。 1. 提取 query 的有区分度关键词(_meaningful_tokens,去停用词、小写)。
高匹配度(≥10.0):如果heading+title+body都无共同词,直接拒绝。 2. 检查这些词是否出现在 doc 的 heading+title 或 body(前 800 字)中。
3. 动作/配置类查询(怎么/如何/使用/配置…)额外要求正文也含核心词,
不能只命中标题(否则 "# Sdf1系列登录插件使用说明" 会因标题含词被误放)。
4. 子串回退:连续 4~6 字子串命中文档(应对 jieba 切碎的短语,如"资源包")。
5. 高匹配度(≥10)但无任何共同词:TF-IDF 误匹配,拒绝。
""" """
import re import re
q_tokens = set(t for t in jieba.cut(query) if len(t) >= 2) q_mean = AutoReply._meaningful_tokens(query)
# ⭐ 修复:短查询不再无条件放行,改为必须有子串命中才放行 if not q_mean:
# 原逻辑导致"下载"(1 token)→"前置依赖"误匹配(无任何共同token却放行) # 极短/纯停用词查询:要求整个 query 子串命中文档
_is_short_query = len(q_tokens) <= 2 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", "") heading = doc.get("heading", "")
title = doc.get("title", "") title = doc.get("title", "")
# 清理 heading 中的 Markdown 链接:[text](url) → text # 清理 Markdown 链接文本,避免 "[sdf1_login登录插件](sdf1_login.md)" 误匹配
_link_pattern = re.compile(r'\[([^\]]*)\]\([^\)]+\)') heading_clean = _link.sub(r'\1', heading).lower()
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(排除链接文本)
body = doc.get("content", "") or doc.get("body", "") body = doc.get("content", "") or doc.get("body", "")
b_tokens = set() body_clean = _link.sub(r'\1', body).lower()
has_body_hit = False
if body: ht_tokens = AutoReply._meaningful_tokens(heading_clean + " " + title)
# 清理 body 中的链接文本,防止 "[sdf1_login登录插件](sdf1_login.md)" 误匹配 body_tokens = AutoReply._meaningful_tokens(body_clean[:800])
body_clean = _link_pattern.sub(r'\1', body) has_ht = bool(q_mean & ht_tokens)
body_no_link = _link_pattern.sub('', body) has_body = bool(q_mean & body_tokens)
# 截断前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)
# 动作/配置类查询:不能只命中标题,正文必须也含核心词 # 动作/配置类查询:不能只命中标题,正文必须也含核心词
# 否则"# Sdf1系列登录插件使用说明"会因标题含"登录/插件/使用"被误放 action_kw = {"怎么", "如何", "使用", "配置", "修改", "设置", "安装", "用法", "操作"}
action_keywords = {"怎么", "如何", "使用", "配置", "修改", "设置", "安装", "用法"} if (q_mean & action_kw) and not has_body:
if action_keywords & q_tokens: logger.debug(
core_q_tokens = q_tokens - {"插件", "使用", "怎么", "如何", "吗", "呢", "吧"} f"动作查询正文相关性不足: query={query[:40]!r} "
if core_q_tokens and not (core_q_tokens & b_tokens): f"heading_hit={has_ht} body_hit={has_body}"
logger.debug( )
f"动作查询正文相关性不足: query={query[:40]!r} " return False
f"heading_hit={has_heading_hit} body_hit={has_body_hit}"
)
return False
if has_heading_hit or has_body_hit: if has_ht or has_body:
return True return True
# 3. 子串回退:jieba分词可能产生"包是""还是"等碎片token, # 子串回退:jieba 可能产生碎片 token,导致交集为空但文档确实含查询短语
# 导致token交集为空但文档确实包含查询核心词(如"资源包")。 full = heading_clean + " " + title.lower() + " " + body_clean[:800]
# 检查查询中任意2~6字子串是否出现在文档文本中。 stripped = re.sub(r'[??!!。,,\s]', '', query).lower()
# ⭐ 短查询(≤2 token):检查整个query是否出现在文档中("下载"必须在文档中出现) if len(stripped) >= 4:
# ⭐ 使用清理后的文本(排除链接文本) for qlen in range(min(len(stripped), 6), 3, -1):
full_text = doc_text + " " + body_no_link[:500] if body else doc_text for start in range(len(stripped) - qlen + 1):
stripped_query = re.sub(r'[??!!。,,\s]', '', query) phrase = stripped[start:start + qlen]
# 短查询特殊处理:整个query必须作为子串出现在文档中 if phrase in full:
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:
logger.debug( 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 return True
# 4. 高匹配度但无共同词:可能是TF-IDF误匹配,拒绝 # 高匹配度但无共同词:TF-IDF 误匹配,拒绝
# 用户问"PVP击杀播报",KB返回"json格式说明"(匹配度9.53)→ 应拒绝 if score >= 10.0 and not (has_ht or has_body):
if score >= 10.0 and not has_heading_hit and not has_body_hit:
return False return False
return False return False
@@ -173,26 +177,20 @@ class AutoReply:
# 优先用 body(纯正文),兜底用 content(heading+正文) # 优先用 body(纯正文),兜底用 content(heading+正文)
body = doc.get("body", "").strip() body = doc.get("body", "").strip()
if body and len(body) > 20: if body and len(body) > 20:
# 表格内容动态计算长度,多退少补;普通文本保持300字符 answer = self._extract_table_answer(body)
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
else: else:
content = doc.get("content", "").strip() content = doc.get("content", "").strip()
if '|' in content: answer = self._extract_table_answer(content) if content else ""
max_len = min(len(content), 4000)
else:
max_len = 300
answer = content[:max_len-3] + "..." if len(content) > max_len else content
if not answer: if not answer:
return None return None
# 拒绝把纯标题/免责声明当作答案发送 # 清理 HTML 换行标签(纯文本消息里 <br> 会原形显示)
import re import re
answer = re.sub(r'</?br\s*/?>', '\n', answer, flags=re.IGNORECASE)
answer = answer.strip()
# 拒绝把纯标题/免责声明当作答案发送
if answer.startswith("# ") and len(answer) < 60: if answer.startswith("# ") and len(answer) < 60:
logger.debug(f"[{platform}] 答案为纯标题,跳过: {answer[:60]!r}") logger.debug(f"[{platform}] 答案为纯标题,跳过: {answer[:60]!r}")
return None return None
@@ -216,3 +214,49 @@ class AutoReply:
f"消息: {msg[:40]}" f"消息: {msg[:40]}"
) )
return answer 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
+29 -22
View File
@@ -940,8 +940,6 @@ class MsgBotApp(ctk.CTk):
lines = [f"[{platform.upper()}] {msg}"] lines = [f"[{platform.upper()}] {msg}"]
if results: if results:
answer_title = results[0][0]["title"]
answer_score = results[0][1]
lines.append(f" 🔍 找到 {len(results)} 条相关结果:") lines.append(f" 🔍 找到 {len(results)} 条相关结果:")
for i, (doc, score) in enumerate(results): for i, (doc, score) in enumerate(results):
@@ -963,32 +961,41 @@ class MsgBotApp(ctk.CTk):
if i == 0: if i == 0:
lines.append(f" 完整内容片段: {content[:200].replace(chr(10), ' ')}...") 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: if not self.ar.enabled:
lines.append(" ⚠ 自动回复已关闭,不触发回复") lines.append(" ⚠ 自动回复已关闭,不触发回复")
elif answer_score < self.ar.min_score: elif chosen is None:
lines.append(f" ⚠ 匹配度 {answer_score:.2f} < 阈值 {self.ar.min_score},跳过") lines.append(" ⚠ 所有结果均未通过内容相关性/阈值检查,跳过回复")
else: else:
# 检查内容相关性 lines.append(" ✅ 匹配通过,准备发送自动回复")
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(" ✅ 匹配通过,准备发送自动回复")
# 自动回复:队列到主线程执行(daemon 线程不能可靠 SetForegroundWindow) # 自动回复:队列到主线程执行(daemon 线程不能可靠 SetForegroundWindow)
reply_data = self.ar.get_reply_data(platform, msg, results[0][0], results[0][1]) if chosen is not None:
if reply_data: reply_data = self.ar.get_reply_data(
self._reply_queue.put(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: else:
lines.append(" ⚠ get_reply_data 返回 None(重复命中或内部检查失败)") reply_data = None
else: else:
lines.append(" ❌ 知识库中未找到相关答案") lines.append(" ❌ 知识库中未找到相关答案")