修复微信端知识库答非所问 + QQ OCR 漏消息(双 bug 修复)
knowledge_base.py(问题2:微信端答非所问): - 文档级标题语义匹配改为 token 级(原仅字面子串匹配),OCR 丢失"php"只剩"管理员端默认"也能正确命中 phpadmin - 新增"管理员+默认"组合强偏好:无"领地"时 phpadmin ×3.0、protect ×0.4,避免退化查询误选 protect/全局配置 - section 级重排新增 bonus +30:查询含"管理员+默认"时,偏好正文真正含"密码/登录/ypshidifu"的 section,压过"## 目录"目录项 monitor.py(问题1 + 问题2 上游): - 新增 _split_and_denoise_segments:按消息边界预分割 + 分段噪声隔离,修复 Windows OCR 大杂烩把用户消息当噪声整段丢弃(QQ 漏消息) - 候选行剥离 bot 自身旧回复文本:OCR 把用户新提问与屏上 bot 旧回复拼成一行时,先剥离旧回复再走过滤,消除答非所问的上游拼接根因 测试:新增 debug_kb.py 复现退化查询、test_kb.py 回归;跨主题回归全部正确,py_compile 通过
This commit is contained in:
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""调试知识库搜索 — 打印每个 section 的分数,定位为什么返回错误结果"""
|
||||||
|
import sys, os, re
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
import jieba
|
||||||
|
from knowledge_base import KnowledgeBase
|
||||||
|
|
||||||
|
def dump(query):
|
||||||
|
kb = KnowledgeBase("D:/桌面/源码/文档/docs")
|
||||||
|
kb.reload()
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"查询: {query!r}")
|
||||||
|
print(f"jieba tokens: {list(jieba.cut(query))}")
|
||||||
|
query_nouns = set(jieba.cut(query)) & {'密码','用户管理','领地','商品','CDK','工单','债券','在线'}
|
||||||
|
print(f"query core_nouns: {query_nouns}")
|
||||||
|
|
||||||
|
results = kb.search(query, top_k=3)
|
||||||
|
print("\n--- 最终返回结果 (top_k=3) ---")
|
||||||
|
if results:
|
||||||
|
for i,(doc,score) in enumerate(results):
|
||||||
|
print(f" [{i+1}] {doc['title']} | {score:.2f} | heading={doc['heading'][:50]!r}")
|
||||||
|
print(f" body={doc['body'][:80]!r}")
|
||||||
|
else:
|
||||||
|
print(" 无结果")
|
||||||
|
|
||||||
|
# 手动遍历所有 section,打印 combined_scores 近似(不精确,但看排序)
|
||||||
|
# 直接复用内部逻辑太复杂,这里只打印每个 doc 的 heading+body 片段供人工判断
|
||||||
|
print("\n--- 知识库所有 section(含'密码'/'管理员'关键词的优先)---")
|
||||||
|
matches = []
|
||||||
|
for d in kb.docs:
|
||||||
|
txt = d.get('heading','') + ' ' + d.get('body','')
|
||||||
|
if '密码' in txt or '管理员' in txt:
|
||||||
|
matches.append(d)
|
||||||
|
for d in matches:
|
||||||
|
print(f" [{d['title']}] heading={d['heading'][:40]!r}")
|
||||||
|
print(f" body={d['body'][:90]!r}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for q in [
|
||||||
|
# 干净查询(对照组,应返回 phpadmin)
|
||||||
|
"PHP管理员端的默认密码是多少?",
|
||||||
|
# 假设 OCR 丢失"密码"二字
|
||||||
|
"PHP管理员端的默认是多少?",
|
||||||
|
"php管理员端默认",
|
||||||
|
"管理员端默认",
|
||||||
|
"php管理员端默认是多少",
|
||||||
|
# 假设 OCR 把"密码"拆成"密 码"或加了空格
|
||||||
|
"php 管理员 端 默认 密码 是 多少",
|
||||||
|
# 假设查询被拼接了 bot 可见的旧回复(全局配置)
|
||||||
|
"PHP管理员端的默认密码是多少?全局配置:管理员可通过该配置,配置默认用户拥有多少格领地,创建领地价格是多少",
|
||||||
|
# 假设 OCR 把 PHP 误读(大小写/空格)
|
||||||
|
"Php管理员端的默认密码是多少?",
|
||||||
|
"php 管理员端的默认密码是多少?",
|
||||||
|
]:
|
||||||
|
dump(q)
|
||||||
+31
-20
@@ -466,30 +466,35 @@ class KnowledgeBase:
|
|||||||
# 弱匹配(score < min_score*2)不计入 doc_count,防低分堆叠
|
# 弱匹配(score < min_score*2)不计入 doc_count,防低分堆叠
|
||||||
# ⭐ 文档标题语义匹配奖励:查询词与文档标题语义相关 → 巨幅奖励
|
# ⭐ 文档标题语义匹配奖励:查询词与文档标题语义相关 → 巨幅奖励
|
||||||
# 如"php管理员"应优先返回phpadmin文档,"领地"应优先返回protect文档
|
# 如"php管理员"应优先返回phpadmin文档,"领地"应优先返回protect文档
|
||||||
title_query_map = {
|
# 改为 token 级匹配(而非字面子串),OCR 丢失"php"只剩"管理员端默认"也能命中 phpadmin
|
||||||
'phpadmin': ['php管理员', 'phpadmin', 'php端管理员'],
|
DOC_TOPIC_TOKENS = {
|
||||||
'protect': ['领地', 'protect', '保护'],
|
'phpadmin': {'php', 'php管理员', '后端'},
|
||||||
'login': ['登录', 'login', '账号'],
|
'protect': {'领地', 'protect', '保护'},
|
||||||
'beibao': ['背包', 'beibao', '仓库'],
|
'login': {'登录', 'login', '账号', 'sdf1'},
|
||||||
|
'beibao': {'背包', 'beibao', '仓库', '云背包'},
|
||||||
}
|
}
|
||||||
|
query_lc = query_stripped.lower()
|
||||||
|
# 查询 token 统一小写,与 DOC_TOPIC_TOKENS 对齐(OCR 常把 PHP→php/Php)
|
||||||
|
query_token_set2 = set(t.lower() for t in jieba.cut(query_stripped) if len(t) >= 2)
|
||||||
|
q_has_admin = '管理员' in query_token_set2
|
||||||
|
q_has_default = '默认' in query_token_set2
|
||||||
for doc_title in doc_best:
|
for doc_title in doc_best:
|
||||||
# 精确匹配(文档标题在查询中)
|
# 精确匹配(文档标题在查询中)
|
||||||
if doc_title.lower() in query_stripped.lower():
|
if doc_title.lower() in query_lc:
|
||||||
doc_best[doc_title] *= 5.0
|
doc_best[doc_title] *= 5.0
|
||||||
else:
|
continue
|
||||||
# 语义匹配:查询词包含文档标题的同义词
|
# token 级语义匹配:文档主题 token 命中查询 → 加权(命中越多权重越高)
|
||||||
title_keywords = title_query_map.get(doc_title, [doc_title])
|
topic_tokens = DOC_TOPIC_TOKENS.get(doc_title, set())
|
||||||
# 精确短语匹配(如"php管理员"在查询中)
|
hits = topic_tokens & query_token_set2
|
||||||
exact_phrase_match = any(kw in query_stripped.lower() for kw in title_keywords)
|
if hits:
|
||||||
if exact_phrase_match:
|
doc_best[doc_title] *= (2.5 + 1.0 * len(hits))
|
||||||
# 精确短语匹配 → 高权重
|
# ⭐ 管理员 + 默认 组合 → 强偏好 phpadmin 文档
|
||||||
doc_best[doc_title] *= 4.0
|
# 此时 protect/全局配置 虽含"管理员+默认",但主题是领地;查询无"领地"时应让位
|
||||||
else:
|
if q_has_admin and q_has_default:
|
||||||
# 部分匹配(如查询包含"php"但不包含"php管理员")
|
if 'phpadmin' in doc_best and '领地' not in query_token_set2:
|
||||||
# 这种情况下,更具体的文档(如phpadmin)应该比泛泛的文档(如php)权重更高
|
doc_best['phpadmin'] *= 3.0
|
||||||
partial_match = any(kw.split('_')[0] in query_stripped.lower() for kw in [doc_title] if '_' in kw)
|
if 'protect' in doc_best and '领地' not in query_token_set2:
|
||||||
if partial_match:
|
doc_best['protect'] *= 0.4
|
||||||
doc_best[doc_title] *= 1.5
|
|
||||||
sorted_docs = sorted(
|
sorted_docs = sorted(
|
||||||
doc_best.items(),
|
doc_best.items(),
|
||||||
key=lambda x: (x[1], doc_count.get(x[0], 0)),
|
key=lambda x: (x[1], doc_count.get(x[0], 0)),
|
||||||
@@ -597,6 +602,12 @@ class KnowledgeBase:
|
|||||||
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)
|
||||||
if body_noun_hits >= 1:
|
if body_noun_hits >= 1:
|
||||||
bonus += 15.0 # body 包含核心名词 → 优先返回
|
bonus += 15.0 # body 包含核心名词 → 优先返回
|
||||||
|
# ⭐ 管理员+默认 组合(OCR 常丢"php/密码")→ 偏好真正含"密码/登录"的 section
|
||||||
|
# 防止"## 目录 - 管理员端(当前)"这种目录项压过"## php管理员后端"(默认登录密码)
|
||||||
|
# 权重给足,必须压过目录项因 heading/phrase 命中的基础分差
|
||||||
|
if '管理员' in query_tokens and '默认' in query_tokens:
|
||||||
|
if any(k in body for k in ('密码', '登录', 'ypshidifu')):
|
||||||
|
bonus += 30.0
|
||||||
# 文档头部惩罚:heading 是单层 # + 文档名(如 "# php管理员后端"),且无 body
|
# 文档头部惩罚:heading 是单层 # + 文档名(如 "# php管理员后端"),且无 body
|
||||||
# ⭐ 例外:body 包含查询核心词时不惩罚(可能是精确答案)
|
# ⭐ 例外:body 包含查询核心词时不惩罚(可能是精确答案)
|
||||||
body_has_query = any(t in body for t in query_tokens if len(t) >= 2)
|
body_has_query = any(t in body for t in query_tokens if len(t) >= 2)
|
||||||
|
|||||||
+108
-1
@@ -2359,10 +2359,17 @@ class MessageMonitor:
|
|||||||
# 在 split 前先剥离这些行,避免 JSON 碎片污染用户消息
|
# 在 split 前先剥离这些行,避免 JSON 碎片污染用户消息
|
||||||
raw_text = self._strip_json_yaml_blocks(raw_text, platform)
|
raw_text = self._strip_json_yaml_blocks(raw_text, platform)
|
||||||
|
|
||||||
|
# —— ⭐ 混合文本预分割:分段噪声隔离 ——
|
||||||
|
# Windows OCR 返回整屏文字的大杂烩(bot回复含<br>/图片URL + 用户消息)。
|
||||||
|
# 若整体 _is_noise_text() 会因 HTML 标签把整段丢弃,用户消息跟着被杀。
|
||||||
|
# 修复:先按消息边界分割成候选段,对各段单独做噪声检测,
|
||||||
|
# 只丢弃噪声段(bot回复),保留干净段(用户消息)。
|
||||||
|
raw_text = self._split_and_denoise_segments(raw_text, platform)
|
||||||
|
|
||||||
# —— 可见时间戳新鲜性检查 ——
|
# —— 可见时间戳新鲜性检查 ——
|
||||||
# 若文本中有可见时间戳(HH:MM),但全部过期(>300s),视为历史消息直接丢弃
|
# 若文本中有可见时间戳(HH:MM),但全部过期(>300s),视为历史消息直接丢弃
|
||||||
# 防止 OCR 混入旧消息内容误触发回复
|
# 防止 OCR 混入旧消息内容误触发回复
|
||||||
if not self._ocr_text_has_fresh_timestamp(raw_text, platform):
|
if not raw_text or not self._ocr_text_has_fresh_timestamp(raw_text, platform):
|
||||||
logger.debug(f"[{platform}] OCR 历史消息(无可识别新鲜时间戳),丢弃: {raw_text[:80]!r}")
|
logger.debug(f"[{platform}] OCR 历史消息(无可识别新鲜时间戳),丢弃: {raw_text[:80]!r}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -3929,6 +3936,91 @@ class MessageMonitor:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _split_and_denoise_segments(self, raw_text: str, platform: str) -> str:
|
||||||
|
"""混合文本预分割 + 分段噪声隔离。
|
||||||
|
|
||||||
|
Windows OCR 返回整屏文字的大杂烩(bot 回复含 <br>/图片URL + 用户消息)。
|
||||||
|
若整体调用 _is_noise_text() 会因 HTML 标签把整段丢弃,用户消息跟着被杀。
|
||||||
|
|
||||||
|
策略:
|
||||||
|
1. 按消息边界将文本分割成候选段
|
||||||
|
2. 对每个段单独做 _is_noise_text() 检查
|
||||||
|
3. 只保留干净的用户消息段,丢弃含噪声的 bot 回复段
|
||||||
|
4. 返回清理后的文本(空字符串 = 全部是噪声)
|
||||||
|
|
||||||
|
分割规则(按优先级):
|
||||||
|
- <br> 标签:bot markdown 回复的典型特征
|
||||||
|
- 时间戳 HH:MM / HH:MM:SS:消息间天然边界
|
||||||
|
- 图片 URL (https://img.ypshidifu.cn/...):bot 回复中的图片引用
|
||||||
|
- 长空白/间隔:消息间的自然分隔
|
||||||
|
"""
|
||||||
|
if not raw_text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 快速路径:文本本身不含噪声特征 → 直接原样返回,不做分割开销
|
||||||
|
if not self._is_noise_text(raw_text) and '<br' not in raw_text and 'https://img' not in raw_text:
|
||||||
|
return raw_text
|
||||||
|
|
||||||
|
# ---- 分割成候选段 ----
|
||||||
|
import copy
|
||||||
|
|
||||||
|
segments = []
|
||||||
|
|
||||||
|
# 按多级分隔符分割
|
||||||
|
# 1) <br> 或 <br/> 是 bot 回复中最强的噪声标记,优先在此处切分
|
||||||
|
parts = re.split(r'(<\s*br\s*/?\s*>|\s*<br>\s*)', raw_text)
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
if not part or not part.strip():
|
||||||
|
continue
|
||||||
|
part = part.strip()
|
||||||
|
|
||||||
|
# 如果段落仍然很长且包含多种内容,尝试进一步切分
|
||||||
|
if len(part) > 100 and ('https://' in part or re.search(r'\d{1,2}:\d{1,2}', part)):
|
||||||
|
# 按 URL 前后的问号句或时间戳进一步切分
|
||||||
|
sub_parts = re.split(
|
||||||
|
r'(?<=。|?|!|\.)\s+(?=[^<])'
|
||||||
|
r'|(?<=[^:])\s+(?=\d{1,2}:\d{1,2})'
|
||||||
|
r'|(?<=\S)\s{2,}(?=\S)',
|
||||||
|
part
|
||||||
|
)
|
||||||
|
for sp in sub_parts:
|
||||||
|
sp = sp.strip()
|
||||||
|
if sp:
|
||||||
|
segments.append(sp)
|
||||||
|
else:
|
||||||
|
segments.append(part)
|
||||||
|
|
||||||
|
if not segments:
|
||||||
|
return raw_text # 分割失败,回退到原始文本
|
||||||
|
|
||||||
|
# ---- 对各段单独做噪声检测 ----
|
||||||
|
clean_segments = []
|
||||||
|
noise_count = 0
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
is_noise = self._is_noise_text(seg)
|
||||||
|
if is_noise:
|
||||||
|
noise_count += 1
|
||||||
|
logger.debug(f"[{platform}] 预分割: 段[{i}] 含噪声特征,丢弃: {seg[:60]!r}")
|
||||||
|
else:
|
||||||
|
clean_segments.append(seg)
|
||||||
|
logger.debug(f"[{platform}] 预分割: 段[{i}] 干净,保留: {seg[:60]!r}")
|
||||||
|
|
||||||
|
# ---- 结果汇总 ----
|
||||||
|
if not clean_segments:
|
||||||
|
# 全部都是噪声 → 返回原始文本让后续流程正常丢弃
|
||||||
|
logger.debug(f"[{platform}] 预分割: 全部{len(segments)}段均为噪声,回退原始文本")
|
||||||
|
return raw_text
|
||||||
|
|
||||||
|
if noise_count == 0:
|
||||||
|
# 没有任何噪声段 → 原始文本本身可能就是干净的
|
||||||
|
return raw_text
|
||||||
|
|
||||||
|
# 有部分噪声被剥离,有部分干净段保留
|
||||||
|
result = '\n'.join(clean_segments)
|
||||||
|
logger.info(f"[{platform}] 预分割: {len(segments)}段中剥离{noise_count}段噪声,保留{len(clean_segments)}段干净文本")
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_noise_text(text: str) -> bool:
|
def _is_noise_text(text: str) -> bool:
|
||||||
"""检测是否为OCR噪声文本(HTML标签、图片路径、OCR乱码等)。
|
"""检测是否为OCR噪声文本(HTML标签、图片路径、OCR乱码等)。
|
||||||
@@ -4893,6 +4985,21 @@ class MessageMonitor:
|
|||||||
seen.add(ts)
|
seen.add(ts)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# ⭐ 候选行包含 bot 自己的旧回复(OCR 把用户新消息 + bot 旧回复拼成一行)
|
||||||
|
# 例:用户问"PHP管理员端的默认密码"时屏上仍显示 bot 先前关于"全局配置"的回复,
|
||||||
|
# 两者被 OCR 拼成一条 → 直接拿去搜 KB 会命中 bot 旧回复(答非所问)。
|
||||||
|
# 剥离 bot 旧回复文本,保留用户真正的提问再去走后续过滤与 KB 搜索。
|
||||||
|
if len(ts) > 5:
|
||||||
|
stripped_bot = False
|
||||||
|
for r in replied:
|
||||||
|
if len(r) >= 6 and r in ts:
|
||||||
|
ts = re.sub(re.escape(r), ' ', ts)
|
||||||
|
stripped_bot = True
|
||||||
|
if stripped_bot:
|
||||||
|
ts = re.sub(r'\s+', ' ', ts).strip()
|
||||||
|
if not ts:
|
||||||
|
continue
|
||||||
|
# 剥离后继续走后续过滤(不 continue),让干净的用户提问正常进入 new_items
|
||||||
# _replied_texts 优先检查(永不裁切,100% 拦住自己的回复)
|
# _replied_texts 优先检查(永不裁切,100% 拦住自己的回复)
|
||||||
if ts in replied:
|
if ts in replied:
|
||||||
continue
|
continue
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""测试知识库搜索"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from knowledge_base import KnowledgeBase
|
||||||
|
|
||||||
|
def test_search():
|
||||||
|
kb = KnowledgeBase("D:/桌面/源码/文档/docs")
|
||||||
|
kb.reload()
|
||||||
|
|
||||||
|
if not kb.is_ready:
|
||||||
|
print("知识库未加载")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"知识库加载了 {len(kb.docs)} 个文档")
|
||||||
|
|
||||||
|
# 测试查询
|
||||||
|
queries = [
|
||||||
|
"用户管理的部分可以查到哪些信息",
|
||||||
|
"用户管理",
|
||||||
|
"查询用户信息",
|
||||||
|
"php管理员后端",
|
||||||
|
]
|
||||||
|
|
||||||
|
for query in queries:
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"查询: {query}")
|
||||||
|
results = kb.search(query, top_k=3)
|
||||||
|
if results:
|
||||||
|
for i, (doc, score) in enumerate(results):
|
||||||
|
print(f" {i+1}. 标题: {doc['title']}, 匹配度: {score:.2f}")
|
||||||
|
print(f" heading: {doc['heading'][:60]}")
|
||||||
|
print(f" body片段: {doc['body'][:100].replace(chr(10), ' ')}...")
|
||||||
|
else:
|
||||||
|
print(" 无结果")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_search()
|
||||||
Reference in New Issue
Block a user