修复知识库搜索垃圾回复:登录插件下载查询返回指令碎片
- 列表项切分保护从'全部短'放宽为'≥70%短',防止sdf1_login指令列表被切成单行碎片 - 新增match_heading字段:合并的短列表项文本参与heading匹配,恢复'注册账号'等查询的命中强度 - 下载意图查询(下载/下到/哪下/怎么下)跳过has_action重排与guide合并,修复'登录插件怎么下载'返回安装说明 - guide合并步骤统计只认###级子标题,防止##章节编号误判为教程步骤 - 下载加权/优先逻辑排除皮肤类heading,防止skin.md'下载皮肤文件'误放大 - gui.py新增DB查重,防止同一消息重复回复
This commit is contained in:
@@ -145,6 +145,14 @@ class AutoReply:
|
|||||||
if has_ht or has_body:
|
if has_ht or has_body:
|
||||||
return True
|
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,导致交集为空但文档确实含查询短语
|
# 子串回退:jieba 可能产生碎片 token,导致交集为空但文档确实含查询短语
|
||||||
full = heading_clean + " " + title.lower() + " " + body_clean[:800]
|
full = heading_clean + " " + title.lower() + " " + body_clean[:800]
|
||||||
stripped = re.sub(r'[??!!。,,\s]', '', query).lower()
|
stripped = re.sub(r'[??!!。,,\s]', '', query).lower()
|
||||||
|
|||||||
@@ -912,6 +912,12 @@ class MsgBotApp(ctk.CTk):
|
|||||||
self._log_queue.put(" ⚠ 知识库未加载,无法搜索答案")
|
self._log_queue.put(" ⚠ 知识库未加载,无法搜索答案")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# ⭐ DB查重:检查用户消息是否已被回复过(防重复回复)
|
||||||
|
if self.monitor._msg_history:
|
||||||
|
if self.monitor._msg_history.is_user_message_replied(platform, msg, time.time()):
|
||||||
|
self._log_queue.put(f"[{platform.upper()}] ⚠ 消息已被回复过(DB查重),跳过: {msg[:40]}")
|
||||||
|
return
|
||||||
|
|
||||||
# ⭐ 跨平台互斥:同一消息只允许一个平台处理
|
# ⭐ 跨平台互斥:同一消息只允许一个平台处理
|
||||||
import hashlib
|
import hashlib
|
||||||
msg_hash = hashlib.md5(msg.strip().encode('utf-8')).hexdigest()
|
msg_hash = hashlib.md5(msg.strip().encode('utf-8')).hexdigest()
|
||||||
|
|||||||
+74
-11
@@ -34,6 +34,9 @@ class KnowledgeBase:
|
|||||||
"登陆": ["登录"],
|
"登陆": ["登录"],
|
||||||
"版号": ["版本号"],
|
"版号": ["版本号"],
|
||||||
"权力": ["权利"],
|
"权力": ["权利"],
|
||||||
|
# ⭐ 口语"下到/能下到"= 下载(OCR常见口语),映射到文档侧"下载"
|
||||||
|
# 修复"在哪儿能下到那登录插件啊?"无法命中"## 4. 下载地址"section
|
||||||
|
"下到": ["下载"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -126,6 +129,8 @@ class KnowledgeBase:
|
|||||||
|
|
||||||
# 检测body中的列表项,进行二次切分
|
# 检测body中的列表项,进行二次切分
|
||||||
# 列表项以 "- " 开头,每个列表项作为独立子section
|
# 列表项以 "- " 开头,每个列表项作为独立子section
|
||||||
|
match_heading = None
|
||||||
|
list_sections = []
|
||||||
if body:
|
if body:
|
||||||
# 按列表项切分
|
# 按列表项切分
|
||||||
list_sections = KnowledgeBase._split_body_by_list_items(body, heading)
|
list_sections = KnowledgeBase._split_body_by_list_items(body, heading)
|
||||||
@@ -139,11 +144,26 @@ class KnowledgeBase:
|
|||||||
})
|
})
|
||||||
section_id += 1
|
section_id += 1
|
||||||
continue
|
continue
|
||||||
|
# ⭐ 列表项多为短内容被合并(_split_body_by_list_items返回[]):
|
||||||
|
# 把列表项文本并入match_heading仅用于搜索匹配(heading token集),
|
||||||
|
# 展示仍用原heading。恢复拆分时的heading匹配强度(如"- reg 注册账号"
|
||||||
|
# 让"注册账号"查询命中heading),但section保持合并——TF-IDF被长文本
|
||||||
|
# 稀释,不会像单行碎片那样劫持无关查询。
|
||||||
|
# 修复"注册账号"/"怎么登录账号"被invite.md"违规处罚"section
|
||||||
|
# (heading携带完整条目文本获得免费heading命中)压过的问题
|
||||||
|
item_texts = []
|
||||||
|
for _line in body.split('\n'):
|
||||||
|
_stripped = _line.strip()
|
||||||
|
if _stripped.startswith('- ') and len(_stripped) > 2:
|
||||||
|
item_texts.append(_stripped[2:].strip())
|
||||||
|
if item_texts:
|
||||||
|
match_heading = heading + ' ' + ' '.join(item_texts)
|
||||||
|
|
||||||
sections.append({
|
sections.append({
|
||||||
"heading": heading,
|
"heading": heading,
|
||||||
"content": part_stripped,
|
"content": part_stripped,
|
||||||
"body": body,
|
"body": body,
|
||||||
|
"match_heading": match_heading,
|
||||||
"section_id": section_id,
|
"section_id": section_id,
|
||||||
})
|
})
|
||||||
section_id += 1
|
section_id += 1
|
||||||
@@ -189,7 +209,14 @@ class KnowledgeBase:
|
|||||||
if table_start != -1:
|
if table_start != -1:
|
||||||
table_ranges.append((table_start, len(lines) - 1))
|
table_ranges.append((table_start, len(lines) - 1))
|
||||||
|
|
||||||
# ⭐ 检测列表项是否都是短内容(如下载链接),如果是则不切分
|
# ⭐ 检测列表项是否多为短内容(指令枚举/下载链接),如果是则不切分
|
||||||
|
# ⭐ 修复(2026-08-10):从"全部短"放宽为"≥70%短"
|
||||||
|
# 原因:sdf1_login.md"## 3. 指令参数及介绍"13项中仅2项长(
|
||||||
|
# "sdf1_login radio reload 重载广播..."、"printer 打印流水数据..."),
|
||||||
|
# 原"全部短才保护"逻辑失效,导致该section被切成13个单行section,
|
||||||
|
# 任何含"登录"的查询都会命中"- login 登录账号"这种孤立行并被回复。
|
||||||
|
# 放宽后(10/13≈77%≥70%),整块指令列表保留为一个section,
|
||||||
|
# 回答包含完整上下文,不再返回单行碎片。
|
||||||
list_item_count = 0
|
list_item_count = 0
|
||||||
short_items = 0
|
short_items = 0
|
||||||
for line in lines:
|
for line in lines:
|
||||||
@@ -201,8 +228,8 @@ class KnowledgeBase:
|
|||||||
# ⭐ 修复:提高阈值到30字符,防止下载链接等短内容被切分
|
# ⭐ 修复:提高阈值到30字符,防止下载链接等短内容被切分
|
||||||
if len(item_content) < 30:
|
if len(item_content) < 30:
|
||||||
short_items += 1
|
short_items += 1
|
||||||
# 如果列表项都是短内容(如下载链接),不切分
|
# 如果列表项大多是短内容(短项占比≥70%),不切分
|
||||||
if list_item_count >= 2 and short_items == list_item_count:
|
if list_item_count >= 2 and short_items >= list_item_count * 0.7:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def is_in_table(line_idx):
|
def is_in_table(line_idx):
|
||||||
@@ -342,6 +369,7 @@ class KnowledgeBase:
|
|||||||
"content": sec["content"],
|
"content": sec["content"],
|
||||||
"heading": sec["heading"],
|
"heading": sec["heading"],
|
||||||
"body": sec["body"],
|
"body": sec["body"],
|
||||||
|
"match_heading": sec.get("match_heading"),
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
@@ -369,7 +397,8 @@ class KnowledgeBase:
|
|||||||
self._heading_token_sets = []
|
self._heading_token_sets = []
|
||||||
self._body_token_sets = []
|
self._body_token_sets = []
|
||||||
for doc in self.docs:
|
for doc in self.docs:
|
||||||
heading = doc.get("heading", "")
|
# ⭐ match_heading(合并的短列表项文本)优先用于搜索匹配,展示仍用原heading
|
||||||
|
heading = doc.get("match_heading") or doc.get("heading", "")
|
||||||
body = doc.get("body", doc["content"])
|
body = doc.get("body", doc["content"])
|
||||||
# ⭐ 修复:排除heading中的Markdown链接文本(如 [sdf1_login登录插件](sdf1_login.md))
|
# ⭐ 修复:排除heading中的Markdown链接文本(如 [sdf1_login登录插件](sdf1_login.md))
|
||||||
# 链接文本是引用,不是真正的内容,不应参与搜索匹配
|
# 链接文本是引用,不是真正的内容,不应参与搜索匹配
|
||||||
@@ -546,6 +575,15 @@ class KnowledgeBase:
|
|||||||
combined_scores[i] *= 1.3
|
combined_scores[i] *= 1.3
|
||||||
elif body_hits >= 1:
|
elif body_hits >= 1:
|
||||||
combined_scores[i] *= 1.1
|
combined_scores[i] *= 1.1
|
||||||
|
# ⭐ 下载意图加权(combined_scores阶段):查询含"下载/下到"等口语,
|
||||||
|
# heading 含"下载"的 section 加权——必须在文档聚合(doc_best)之前执行,
|
||||||
|
# 否则下载section的文档分不够高,排不进 top_k 文档(rerank bonus救不了)。
|
||||||
|
# 修复"登录插件怎么下载"被skin.md"menu"(body含"下载皮肤文件")压过的问题
|
||||||
|
# ⚠ 排除heading含"皮肤"的section("### 1.下载皮肤文件"是皮肤教程,非插件下载),
|
||||||
|
# 否则皮肤文档会被放大到最高分,guide合并后压过真正的下载地址section
|
||||||
|
if (any(w in query_stripped for w in ('下载', '下到', '哪下', '怎么下'))
|
||||||
|
and '下载' in heading_clean and '皮肤' not in heading_clean):
|
||||||
|
combined_scores[i] *= 2.5
|
||||||
# ⭐ 精确短语匹配:文档包含完整查询短语 → 巨幅奖励
|
# ⭐ 精确短语匹配:文档包含完整查询短语 → 巨幅奖励
|
||||||
# 防止"php端的管理员密码"匹配到不含"php"的云背包文档
|
# 防止"php端的管理员密码"匹配到不含"php"的云背包文档
|
||||||
# ⭐ 使用full_text_phrase(排除链接文字),防止链接文字如"sdf1_login登录插件"误匹配
|
# ⭐ 使用full_text_phrase(排除链接文字),防止链接文字如"sdf1_login登录插件"误匹配
|
||||||
@@ -685,7 +723,6 @@ class KnowledgeBase:
|
|||||||
key=lambda x: (x[1], doc_count.get(x[0], 0)),
|
key=lambda x: (x[1], doc_count.get(x[0], 0)),
|
||||||
reverse=True
|
reverse=True
|
||||||
)
|
)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# 查询意图检测
|
# 查询意图检测
|
||||||
@@ -695,6 +732,11 @@ class KnowledgeBase:
|
|||||||
has_action = bool(action_words & query_lower)
|
has_action = bool(action_words & query_lower)
|
||||||
has_def = bool(def_words & query_lower)
|
has_def = bool(def_words & query_lower)
|
||||||
def_headings = {"版本号", "版本", "区分", "定义"}
|
def_headings = {"版本号", "版本", "区分", "定义"}
|
||||||
|
# ⭐ 下载意图:查询明确要下载/获取插件时,下载section就是答案。
|
||||||
|
# 后续的has_action优先级重排("安装说明"含"安装"被抬到最前)和guide合并
|
||||||
|
# (numbered heading "## 2."/"## 4." 误判为教程步骤)都会覆盖下载section,
|
||||||
|
# 必须整体跳过。修复"登录插件怎么下载"返回"## 2.安装说明"的问题
|
||||||
|
download_intent = any(w in query_stripped for w in ('下载', '下到', '哪下', '怎么下'))
|
||||||
|
|
||||||
# 动作查询:提取动词+对象,对不含两者的文档降权
|
# 动作查询:提取动词+对象,对不含两者的文档降权
|
||||||
# 防止"圈地工具"匹配到含"工具"但不含"圈地"的文档(如寻宝插件)
|
# 防止"圈地工具"匹配到含"工具"但不含"圈地"的文档(如寻宝插件)
|
||||||
@@ -821,6 +863,13 @@ class KnowledgeBase:
|
|||||||
if len(phrase) >= 4 and phrase in body:
|
if len(phrase) >= 4 and phrase in body:
|
||||||
bonus += 3.0
|
bonus += 3.0
|
||||||
break
|
break
|
||||||
|
# ⭐ 下载意图加权:查询含"下载/下到"等口语,section heading 含"下载" → 高bonus
|
||||||
|
# 修复"在哪儿能下到那登录插件啊?"被"插件设置"(body含"登录")压过,
|
||||||
|
# 而真正的"## 4. 下载地址"section(3.93)排不进top3的问题
|
||||||
|
# ⚠ 排除heading含"皮肤"的section(皮肤教程非插件下载)
|
||||||
|
if (any(w in query_stripped for w in ('下载', '下到', '哪下', '怎么下'))
|
||||||
|
and '下载' in heading_no_links and '皮肤' not in heading_no_links):
|
||||||
|
bonus += 8.0
|
||||||
# ⭐ body 包含查询核心名词(如"密码")→ 巨幅 bonus
|
# ⭐ body 包含查询核心名词(如"密码")→ 巨幅 bonus
|
||||||
core_nouns = {'密码', '用户管理', '领地', '商品', 'CDK', '工单', '债券', '在线'}
|
core_nouns = {'密码', '用户管理', '领地', '商品', 'CDK', '工单', '债券', '在线'}
|
||||||
body_noun_hits = sum(1 for n in core_nouns if n in body and n in query_stripped)
|
body_noun_hits = sum(1 for n in core_nouns if n in body and n in query_stripped)
|
||||||
@@ -940,13 +989,15 @@ class KnowledgeBase:
|
|||||||
break
|
break
|
||||||
# ⭐ 下载意图优先:查询包含"下载"时,优先返回heading含"下载"的section
|
# ⭐ 下载意图优先:查询包含"下载"时,优先返回heading含"下载"的section
|
||||||
# 防止"在哪儿能下载到登录插件?"返回login指令而非下载链接
|
# 防止"在哪儿能下载到登录插件?"返回login指令而非下载链接
|
||||||
|
# ⚠ 排除heading含"皮肤"的section("### 1.下载皮肤文件"是皮肤教程,
|
||||||
|
# 会被误移到首位并导致guide合并返回"## menu"压过插件下载地址)
|
||||||
if len(doc_sections) > 1 and '下载' in query_stripped:
|
if len(doc_sections) > 1 and '下载' in query_stripped:
|
||||||
for i in range(len(doc_sections)):
|
for i in range(len(doc_sections)):
|
||||||
idx = doc_sections[i][0]
|
idx = doc_sections[i][0]
|
||||||
heading = self.docs[idx].get("heading", "")
|
heading = self.docs[idx].get("heading", "")
|
||||||
_link_pattern = re.compile(r'\[([^\]]*)\]\([^\)]+\)')
|
_link_pattern = re.compile(r'\[([^\]]*)\]\([^\)]+\)')
|
||||||
heading_no_links = _link_pattern.sub('', heading)
|
heading_no_links = _link_pattern.sub('', heading)
|
||||||
if '下载' in heading_no_links:
|
if '下载' in heading_no_links and '皮肤' not in heading_no_links:
|
||||||
doc_sections.insert(0, doc_sections.pop(i))
|
doc_sections.insert(0, doc_sections.pop(i))
|
||||||
break
|
break
|
||||||
# ⭐ php登录模式意图优先:查询包含"php"+"登录模式"时,优先返回php文档的section
|
# ⭐ php登录模式意图优先:查询包含"php"+"登录模式"时,优先返回php文档的section
|
||||||
@@ -959,7 +1010,9 @@ class KnowledgeBase:
|
|||||||
doc_sections.insert(0, doc_sections.pop(i))
|
doc_sections.insert(0, doc_sections.pop(i))
|
||||||
break
|
break
|
||||||
|
|
||||||
if has_action and len(doc_sections) > 1:
|
# ⭐ 下载意图查询跳过has_action重排:下载section已被下载优先级移到最前,
|
||||||
|
# 重排会把"安装说明"(heading含"安装")重新抬到首位,覆盖下载答案
|
||||||
|
if has_action and not download_intent and len(doc_sections) > 1:
|
||||||
# 动作查询:提取动词+对象,精准匹配 section
|
# 动作查询:提取动词+对象,精准匹配 section
|
||||||
# 如"怎么打印流水" → verb="打印", obj="流水",优先选 heading 同时含两者的 section
|
# 如"怎么打印流水" → verb="打印", obj="流水",优先选 heading 同时含两者的 section
|
||||||
action_verbs = {"打印", "查看", "设置", "创建", "使用", "查询", "导出", "执行", "改", "修改", "更改", "更换", "重置"}
|
action_verbs = {"打印", "查看", "设置", "创建", "使用", "查询", "导出", "执行", "改", "修改", "更改", "更换", "重置"}
|
||||||
@@ -1017,11 +1070,19 @@ class KnowledgeBase:
|
|||||||
# ⭐ 修复:当查询是操作指南(如何/怎么/教程)时,合并同一文档同一 guide 的所有相关 section
|
# ⭐ 修复:当查询是操作指南(如何/怎么/教程)时,合并同一文档同一 guide 的所有相关 section
|
||||||
# 避免"如何通过官网更新皮肤"只返回最后一个步骤
|
# 避免"如何通过官网更新皮肤"只返回最后一个步骤
|
||||||
# ⭐ 避免混合不同 guide 的内容(如 url 方式和正版玩家方式混合)
|
# ⭐ 避免混合不同 guide 的内容(如 url 方式和正版玩家方式混合)
|
||||||
|
# ⭐ 下载意图查询跳过guide合并:numbered heading("## 1.xxx"/"## 4.下载地址")
|
||||||
|
# 会被step_headings误判为教程步骤,guide合并选中"安装说明"guide覆盖下载答案
|
||||||
is_guide_query = any(w in query_stripped for w in ['如何', '怎么', '教程', '步骤', '操作', '指南'])
|
is_guide_query = any(w in query_stripped for w in ['如何', '怎么', '教程', '步骤', '操作', '指南'])
|
||||||
if is_guide_query and len(doc_sections) > 1:
|
if is_guide_query and not download_intent and len(doc_sections) > 1:
|
||||||
# 检查是否为操作指南类文档(多个步骤 section)
|
# 检查是否为操作指南类文档(多个步骤 section)
|
||||||
step_headings = {'1.', '2.', '3.', '4.', '5.', '步骤', '第一', '第二', '第三', '第四', '第五'}
|
step_headings = {'1.', '2.', '3.', '4.', '5.', '步骤', '第一', '第二', '第三', '第四', '第五'}
|
||||||
step_count = sum(1 for _, doc, _ in doc_sections if any(s in doc.get('heading', '') for s in step_headings))
|
# ⭐ 只统计"### "级子标题作为教程步骤(如skin.md的"### 1.下载皮肤文件")。
|
||||||
|
# 不统计"## 2."/"## 4."章节编号——sdf1_login.md的"## 3. 指令参数及介绍"
|
||||||
|
# 等章节被误判为步骤,导致"怎么登录账号"触发guide合并,吞掉正确section
|
||||||
|
# 返回"## 2.安装说明"guide。教程步骤一定是3级标题下的子节。
|
||||||
|
step_count = sum(1 for _, doc, _ in doc_sections
|
||||||
|
if doc.get('heading', '').startswith('### ')
|
||||||
|
and any(s in doc.get('heading', '') for s in step_headings))
|
||||||
if step_count >= 3:
|
if step_count >= 3:
|
||||||
# ⭐ 获取最佳文档的路径,查找该文档的所有 sections
|
# ⭐ 获取最佳文档的路径,查找该文档的所有 sections
|
||||||
best_doc_path = doc_sections[0][1].get('path', '')
|
best_doc_path = doc_sections[0][1].get('path', '')
|
||||||
@@ -1088,8 +1149,10 @@ class KnowledgeBase:
|
|||||||
if kw in query_stripped and kw in guide_text:
|
if kw in query_stripped and kw in guide_text:
|
||||||
score += 10
|
score += 10
|
||||||
# 优先选择步骤最多的 guide(默认行为)
|
# 优先选择步骤最多的 guide(默认行为)
|
||||||
step_in_guide = sum(1 for _, doc in sections
|
# ⭐ 与step_count一致:只统计"### "级子标题步骤
|
||||||
if any(s in doc.get('heading', '') for s in step_headings))
|
step_in_guide = sum(1 for _, doc in sections
|
||||||
|
if doc.get('heading', '').startswith('### ')
|
||||||
|
and any(s in doc.get('heading', '') for s in step_headings))
|
||||||
score += step_in_guide * 2
|
score += step_in_guide * 2
|
||||||
|
|
||||||
if score > best_guide_score:
|
if score > best_guide_score:
|
||||||
|
|||||||
Reference in New Issue
Block a user