修复2个问题:微信OCR hash碰撞(aHash→dHash+5秒强制刷新) + KB搜索动词+对象精准匹配防误召回
This commit is contained in:
+86
-3
@@ -211,7 +211,38 @@ class KnowledgeBase:
|
||||
def is_ready(self) -> bool:
|
||||
return self._ready and len(self.docs) > 0
|
||||
|
||||
def search(self, query: str, top_k: int = 3, min_score: float = 0.05) -> List[Tuple[dict, float]]:
|
||||
@staticmethod
|
||||
def _extract_verb_object(query: str, action_verbs: set) -> List[Tuple[str, str]]:
|
||||
"""从查询中提取动词+对象对。
|
||||
|
||||
例:"怎么打印流水" → [("打印", "流水")]
|
||||
"圈地工具用什么" → [("圈地", "工具")]
|
||||
"""
|
||||
import re
|
||||
pairs = []
|
||||
# 模式1: 动词 + 对象(2+字动词 + 2+字名词)
|
||||
for verb in action_verbs:
|
||||
if verb in query:
|
||||
# 找动词后面的内容作为对象
|
||||
idx = query.index(verb) + len(verb)
|
||||
rest = query[idx:].strip()
|
||||
# 取动词后 2~6 字作为对象
|
||||
if len(rest) >= 2:
|
||||
obj = rest[:6].strip()
|
||||
# 去掉常见无意义尾词
|
||||
for stop in ["的", "地", "得", "了", "吗", "呢", "吧", "啊"]:
|
||||
obj = obj.rstrip(stop)
|
||||
if len(obj) >= 2:
|
||||
pairs.append((verb, obj))
|
||||
# 模式2: 2字动词 + 2字对象(从分词结果匹配)
|
||||
if not pairs:
|
||||
tokens = [t for t in jieba.cut(query) if len(t) >= 2]
|
||||
for i, t in enumerate(tokens):
|
||||
if t in action_verbs and i + 1 < len(tokens):
|
||||
pairs.append((t, tokens[i + 1]))
|
||||
return pairs
|
||||
|
||||
def search(self, query: str, top_k: int = 3, min_score: float = 0.1) -> List[Tuple[dict, float]]:
|
||||
"""搜索知识库,返回 (doc, score) 列表。
|
||||
|
||||
策略:
|
||||
@@ -310,6 +341,35 @@ class KnowledgeBase:
|
||||
has_def = bool(def_words & query_lower)
|
||||
def_headings = {"版本号", "版本", "区分", "定义"}
|
||||
|
||||
# 动作查询:提取动词+对象,对不含两者的文档降权
|
||||
# 防止"圈地工具"匹配到含"工具"但不含"圈地"的文档(如寻宝插件)
|
||||
action_verbs_all = {"打印", "查看", "设置", "创建", "使用", "查询", "导出", "执行", "怎么", "如何", "用法", "教程", "操作"}
|
||||
verb_obj_pairs = self._extract_verb_object(query_stripped, action_verbs_all)
|
||||
if verb_obj_pairs:
|
||||
adjusted = []
|
||||
for doc_title, doc_score in sorted_docs:
|
||||
# 检查文档内容是否包含动词+对象
|
||||
doc_text = ""
|
||||
for i, doc in enumerate(self.docs):
|
||||
if doc["title"] == doc_title:
|
||||
doc_text += doc.get("heading", "") + " " + doc.get("content", "")
|
||||
# 计算匹配度:动词+对象都包含 → 满分;只包含一个 → 降权;都不包含 → 大幅降权
|
||||
match_count = 0
|
||||
for verb, obj in verb_obj_pairs:
|
||||
v_in = verb in doc_text
|
||||
o_in = obj in doc_text
|
||||
if v_in and o_in:
|
||||
match_count += 2
|
||||
elif v_in or o_in:
|
||||
match_count += 1
|
||||
if match_count == 0:
|
||||
doc_score *= 0.2 # 完全不匹配,大幅降权
|
||||
elif match_count == 1:
|
||||
doc_score *= 0.5 # 部分匹配,降权
|
||||
adjusted.append((doc_title, doc_score))
|
||||
adjusted.sort(key=lambda x: x[1], reverse=True)
|
||||
sorted_docs = adjusted
|
||||
|
||||
# 定义类查询:检查每个文档是否有定义类 section,没有则降权
|
||||
if has_def:
|
||||
adjusted = []
|
||||
@@ -344,11 +404,34 @@ class KnowledgeBase:
|
||||
|
||||
# 同文档内,根据查询意图优先选合适的 section
|
||||
if has_action and len(doc_sections) > 1:
|
||||
# 动作查询:按优先级匹配 heading 词
|
||||
# 动作查询:提取动词+对象,精准匹配 section
|
||||
# 如"怎么打印流水" → verb="打印", obj="流水",优先选 heading 同时含两者的 section
|
||||
action_verbs = {"打印", "查看", "设置", "创建", "使用", "查询", "导出", "执行"}
|
||||
verb_obj_pairs = self._extract_verb_object(query_stripped, action_verbs)
|
||||
|
||||
if verb_obj_pairs:
|
||||
# 有动词+对象:找 heading 或 body 同时包含两者的 section
|
||||
best_idx = -1
|
||||
best_match = 0
|
||||
for i in range(len(doc_sections)):
|
||||
idx = doc_sections[i][0]
|
||||
heading = self.docs[idx].get("heading", "")
|
||||
content = self.docs[idx].get("content", "")
|
||||
for verb, obj in verb_obj_pairs:
|
||||
v_in_h = verb in heading or verb in content
|
||||
o_in_h = obj in heading or obj in content
|
||||
match_score = int(v_in_h) + int(o_in_h)
|
||||
if match_score > best_match:
|
||||
best_match = match_score
|
||||
best_idx = i
|
||||
if best_idx > 0:
|
||||
doc_sections.insert(0, doc_sections.pop(best_idx))
|
||||
else:
|
||||
# 无动词+对象:按优先级匹配 heading 词(旧逻辑)
|
||||
# "参数/指令/使用" > "方法" > "安装"
|
||||
priority_order = [
|
||||
({"参数", "指令", "使用"}), # 最高优先级
|
||||
({"方法"}), # 中等优先级(避免"安装方法"误匹配"参数"类)
|
||||
({"方法"}), # 中等优先级
|
||||
({"安装"}), # 最低优先级
|
||||
]
|
||||
for priority_level in priority_order:
|
||||
|
||||
+27
-6
@@ -199,6 +199,7 @@ class MessageMonitor:
|
||||
# 截图 hash(像素级,截图不变→OCR 必然无新消息)
|
||||
self._last_screenshot_hash: dict = {} # platform → str
|
||||
self._ocr_unchanged: dict = {} # platform → bool(本轮因截图不变跳过)
|
||||
self._last_ocr_time: dict = {} # platform → float(上次真正做OCR的时间戳)
|
||||
|
||||
def set_poll_interval(self, seconds: float):
|
||||
self._poll_interval = max(0.5, seconds)
|
||||
@@ -962,12 +963,17 @@ class MessageMonitor:
|
||||
if im.width < 50 or im.height < 50:
|
||||
return []
|
||||
# ⭐ 截图 hash:像素级指纹——内容没变就直接跳过整轮(easyocr 同一截图每次输出不同文本)
|
||||
# dHash + 5秒强制刷新:即使hash相同,每5秒也强制做一次OCR,防止永久卡死
|
||||
h = self._image_hash(im)
|
||||
if h == self._last_screenshot_hash.get("qq", ""):
|
||||
now = time.time()
|
||||
last_hash = self._last_screenshot_hash.get("qq", "")
|
||||
last_time = self._last_ocr_time.get("qq", 0)
|
||||
if h == last_hash and (now - last_time) < 5:
|
||||
self._ocr_unchanged["qq"] = True
|
||||
logger.debug(f"[qq] 截图 hash 相同,屏幕无变化,跳过本轮")
|
||||
return []
|
||||
self._last_screenshot_hash["qq"] = h
|
||||
self._last_ocr_time["qq"] = now
|
||||
self._ocr_unchanged.pop("qq", None)
|
||||
# 保存截屏到程序根目录用于调试(保留最近 20 张)
|
||||
try:
|
||||
@@ -1079,12 +1085,17 @@ class MessageMonitor:
|
||||
return []
|
||||
|
||||
# ⭐ 截图 hash:像素级指纹——内容没变就直接跳过整轮
|
||||
# dHash + 5秒强制刷新:即使hash相同,每5秒也强制做一次OCR,防止永久卡死
|
||||
h = self._image_hash(im)
|
||||
if h == self._last_screenshot_hash.get("wechat", ""):
|
||||
now = time.time()
|
||||
last_hash = self._last_screenshot_hash.get("wechat", "")
|
||||
last_time = self._last_ocr_time.get("wechat", 0)
|
||||
if h == last_hash and (now - last_time) < 5:
|
||||
self._ocr_unchanged["wechat"] = True
|
||||
logger.debug(f"[wechat] 截图 hash 相同,屏幕无变化,跳过本轮")
|
||||
return []
|
||||
self._last_screenshot_hash["wechat"] = h
|
||||
self._last_ocr_time["wechat"] = now
|
||||
self._ocr_unchanged.pop("wechat", None)
|
||||
|
||||
# 保存调试截图
|
||||
@@ -1788,11 +1799,21 @@ class MessageMonitor:
|
||||
|
||||
@staticmethod
|
||||
def _image_hash(im) -> str:
|
||||
"""感知哈希:缩至 8x8 灰阶 → 均值哈希(稳定,抗 1-2px 偏移)。"""
|
||||
small = im.convert('L').resize((8, 8), Image.LANCZOS)
|
||||
"""感知哈希:缩至 9x8 灰阶 → dHash(差值哈希,对局部变化敏感)。
|
||||
|
||||
dHash 比较每行相邻像素的梯度,比 aHash(均值哈希)更能捕捉文字变化。
|
||||
9 列 × 8 行 = 72 位 hash,比 8×8=64 位 aHash 信息量更大。
|
||||
"""
|
||||
small = im.convert('L').resize((9, 8), Image.LANCZOS)
|
||||
pixels = list(small.getdata())
|
||||
avg = sum(pixels) // len(pixels)
|
||||
return ''.join('1' if p > avg else '0' for p in pixels)
|
||||
# 每行 9 个像素,比较相邻像素:左 > 右 → 1,否则 → 0
|
||||
bits = []
|
||||
for row in range(8):
|
||||
for col in range(8):
|
||||
left = pixels[row * 9 + col]
|
||||
right = pixels[row * 9 + col + 1]
|
||||
bits.append('1' if left > right else '0')
|
||||
return ''.join(bits)
|
||||
|
||||
def _is_reply_fragment(self, text: str) -> bool:
|
||||
"""检查文本是否为 bot 回复内容的碎片(OCR 截取的 md 残片/跨行碎片)。"""
|
||||
|
||||
Reference in New Issue
Block a user