修复2个问题:微信OCR hash碰撞(aHash→dHash+5秒强制刷新) + KB搜索动词+对象精准匹配防误召回

This commit is contained in:
youpaishidifu
2026-07-28 22:28:29 +08:00
parent 1ccaa926fd
commit a093528888
2 changed files with 125 additions and 21 deletions
+86 -3
View File
@@ -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
View File
@@ -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 残片/跨行碎片)。"""