修复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:
+124
-80
@@ -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
|
||||||
|
|||||||
@@ -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(" ❌ 知识库中未找到相关答案")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user