新增功能
This commit is contained in:
+256
-96
@@ -278,12 +278,16 @@ class WeChatBot:
|
||||
# 重启不丢失;靠它提供 AI 上下文,每次只需增量提取最新消息。
|
||||
self.store = ConversationStore(os.path.join(_SCRIPT_DIR, "conversations.json"))
|
||||
# 已知会话指纹集合(用于感知指纹的汉明距离归一化)。
|
||||
# 从档案键值预热,重启后同一客户仍映射到原档案。
|
||||
# 同时读取旧版 8 字节头像指纹和新版 16 字节复合指纹;归一化时严格
|
||||
# 按长度隔离,避免新指纹被错误吸附到历史头像档案。
|
||||
self._known_fps = set()
|
||||
try:
|
||||
for k in list(self.store._data.keys()):
|
||||
if len(k) == 16: # 8 字节感知指纹的 hex
|
||||
self._known_fps.add(bytes.fromhex(k))
|
||||
if isinstance(k, str) and len(k) in (16, 32):
|
||||
try:
|
||||
self._known_fps.add(bytes.fromhex(k))
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
# 置顶状态(自动重连后需要恢复)
|
||||
@@ -978,68 +982,189 @@ class WeChatBot:
|
||||
|
||||
def _session_fingerprint(self, img: np.ndarray, rel_y: int) -> bytes:
|
||||
"""
|
||||
用会话条目的「头像区域像素」生成指纹,唯一标识一个会话。
|
||||
用会话条目的「头像 + 昵称首行文字」生成 16 字节复合感知指纹。
|
||||
指纹跟着会话走,不随列表重排 / 行号变化而改变,因此可用于跨重排的去重
|
||||
和会话档案的隔离(不同客户的上下文绝不互串)。
|
||||
和会话档案的隔离。头像相同但昵称不同的客户也不会共用上下文。
|
||||
|
||||
★ 感知哈希而非原始像素哈希:
|
||||
★ 两段感知哈希而非原始像素哈希:
|
||||
1. 采样区收窄到头像正中心(x 12~42, y ±12),避开圆角处会渗入
|
||||
悬停/选中背景色的边缘像素;
|
||||
2. 下采样到 8×8 网格取均值,再把颜色量化到 16 级——
|
||||
悬停高亮、抗锯齿、字体渲染等微小差异不会改变指纹,
|
||||
同一个客户在任何渲染状态下都稳定映射到同一份档案。
|
||||
2. 昵称区只取灰度绝对梯度(文字边缘),天然忽略纯色背景以及
|
||||
选中态造成的明暗反转;
|
||||
3. 两段分别下采样为 64 位,并在归一化时分别限制汉明距离。
|
||||
(采样头像中心也天然避开右上角的未读红点,红点数字变化不影响指纹。)
|
||||
"""
|
||||
if not isinstance(img, np.ndarray) or img.ndim < 3 or img.shape[2] < 3:
|
||||
return self._fallback_session_fingerprint(img, rel_y, -1)
|
||||
|
||||
row_idx = rel_y // self.session_item_h
|
||||
raw = self._raw_session_fingerprint(img, row_idx)
|
||||
if raw is None:
|
||||
return self._fallback_session_fingerprint(img, rel_y, row_idx)
|
||||
|
||||
# 默认头像 + 同名联系人仍可能产生完全相同的复合指纹。只要当前可见列表
|
||||
# 出现重复,宁可为该行建立保守的新档案(重排后可能丢上下文),也绝不能
|
||||
# 让两位客户共享医疗对话。兜底摘要还包含该行可见像素,可区分消息预览。
|
||||
visible_rows = (img.shape[0] + self.session_item_h - 1) // self.session_item_h
|
||||
for other_row in range(visible_rows):
|
||||
if other_row == row_idx:
|
||||
continue
|
||||
if self._raw_session_fingerprint(img, other_row) == raw:
|
||||
return self._fallback_session_fingerprint(img, rel_y, row_idx)
|
||||
|
||||
return self._canonical_fp(raw)
|
||||
|
||||
def _raw_session_fingerprint(
|
||||
self,
|
||||
img: np.ndarray,
|
||||
row_idx: int,
|
||||
) -> bytes | None:
|
||||
"""Return the uncanonicalized avatar+label fingerprint for one row."""
|
||||
|
||||
y_c = row_idx * self.session_item_h + self.session_item_h // 2
|
||||
x1 = int(12 * self.scale)
|
||||
x2 = min(int(42 * self.scale), img.shape[1])
|
||||
half = int(12 * self.scale)
|
||||
y1 = max(0, y_c - half)
|
||||
y2 = min(img.shape[0], y_c + half)
|
||||
if y2 <= y1 or x2 <= x1:
|
||||
return f"row{row_idx}".encode() # 越界兜底
|
||||
avatar_x1 = int(12 * self.scale)
|
||||
avatar_x2 = min(int(42 * self.scale), img.shape[1])
|
||||
avatar_half = max(1, int(12 * self.scale))
|
||||
avatar_y1 = max(0, y_c - avatar_half)
|
||||
avatar_y2 = min(img.shape[0], y_c + avatar_half)
|
||||
|
||||
# 企业微信会话行第一行昵称通常从 x≈58 开始;只取左侧稳定区域,
|
||||
# 避开右侧时间和未读数字。纵向范围停在行中心上方,避开消息预览。
|
||||
label_x1 = int(58 * self.scale)
|
||||
label_x2 = min(
|
||||
int(220 * self.scale),
|
||||
img.shape[1] - max(1, int(60 * self.scale)),
|
||||
)
|
||||
label_y1 = max(0, y_c - max(2, int(23 * self.scale)))
|
||||
label_y2 = min(img.shape[0], y_c - max(1, int(2 * self.scale)))
|
||||
if (
|
||||
avatar_y2 <= avatar_y1 or avatar_x2 <= avatar_x1
|
||||
or label_y2 <= label_y1 or label_x2 <= label_x1
|
||||
):
|
||||
return None
|
||||
|
||||
# 灰度块均值 → 与中位数比较得到 64 位二值指纹(经典 pHash 思路)
|
||||
region = img[y1:y2, x1:x2, :3].astype(np.float32)
|
||||
gray = region.mean(axis=2)
|
||||
gh = gw = 8
|
||||
h, w = gray.shape
|
||||
avatar = img[avatar_y1:avatar_y2, avatar_x1:avatar_x2, :3].astype(np.float32)
|
||||
avatar_hash = self._grid_median_hash(avatar.mean(axis=2), quantize=4.0)
|
||||
|
||||
# 绝对梯度在背景/前景同时反相时保持不变;固定弱边缘阈值过滤截图噪声。
|
||||
label = img[label_y1:label_y2, label_x1:label_x2, :3].astype(np.float32)
|
||||
label_gray = label.mean(axis=2)
|
||||
gx = np.zeros_like(label_gray)
|
||||
gy = np.zeros_like(label_gray)
|
||||
gx[:, 1:] = np.abs(np.diff(label_gray, axis=1))
|
||||
gy[1:, :] = np.abs(np.diff(label_gray, axis=0))
|
||||
gx[gx < 8.0] = 0.0
|
||||
gy[gy < 8.0] = 0.0
|
||||
if not np.any(gx) and not np.any(gy):
|
||||
return None
|
||||
# 纵向边缘和横向边缘各占 32 位,避免不同字形仅因总体边缘量相近而碰撞。
|
||||
label_bits = np.concatenate((
|
||||
self._grid_median_bits(gx, 4, 8),
|
||||
self._grid_median_bits(gy, 4, 8),
|
||||
))
|
||||
label_hash = np.packbits(label_bits).tobytes()
|
||||
|
||||
return avatar_hash + label_hash
|
||||
|
||||
@staticmethod
|
||||
def _grid_median_hash(values: np.ndarray, quantize: float = 0.0) -> bytes:
|
||||
"""将二维强度图按 8×8 块均值压缩为 64 位中位数感知哈希。"""
|
||||
means = WeChatBot._grid_means(values, 8, 8)
|
||||
if quantize > 0:
|
||||
means = np.round(means / quantize)
|
||||
bits = (means > np.median(means)).flatten()
|
||||
return np.packbits(bits).tobytes()
|
||||
|
||||
@staticmethod
|
||||
def _grid_median_bits(values: np.ndarray, gh: int, gw: int) -> np.ndarray:
|
||||
means = WeChatBot._grid_means(values, gh, gw)
|
||||
return (means > np.median(means)).flatten()
|
||||
|
||||
@staticmethod
|
||||
def _grid_means(values: np.ndarray, gh: int, gw: int) -> np.ndarray:
|
||||
h, w = values.shape
|
||||
ys = np.linspace(0, h, gh + 1).astype(int)
|
||||
xs = np.linspace(0, w, gw + 1).astype(int)
|
||||
means = np.zeros((gh, gw), dtype=np.float32)
|
||||
for i in range(gh):
|
||||
for j in range(gw):
|
||||
block = gray[ys[i]:ys[i + 1], xs[j]:xs[j + 1]]
|
||||
block = values[ys[i]:ys[i + 1], xs[j]:xs[j + 1]]
|
||||
if block.size:
|
||||
means[i, j] = block.mean()
|
||||
bits = (means > np.median(means)).flatten()
|
||||
raw = np.packbits(bits).tobytes() # 8 字节
|
||||
return self._canonical_fp(raw)
|
||||
return means
|
||||
|
||||
# 感知指纹的汉明距离容差:≤ 此值视为同一头像(64 位中容 6 位差异)
|
||||
_FP_HAMMING_TOL = 6
|
||||
def _fallback_session_fingerprint(
|
||||
self, img: np.ndarray, rel_y: int, row_idx: int
|
||||
) -> bytes:
|
||||
"""
|
||||
裁剪越界或文字不可辨识时的隔离优先兜底。
|
||||
|
||||
摘要包含当前行位置、画面尺寸和可见行像素;它可能牺牲跨重排连续性,
|
||||
但不会像旧 ``rowN`` 短字符串那样把不同画面中的客户并入同一档案。
|
||||
兜底摘要不参与模糊归一,进一步避免意外吸附。
|
||||
"""
|
||||
digest = hashlib.blake2b(digest_size=16, person=b"wx-row-fallback")
|
||||
shape = getattr(img, "shape", ())
|
||||
digest.update(f"{row_idx}:{rel_y}:{shape}".encode("utf-8"))
|
||||
if isinstance(img, np.ndarray) and img.size:
|
||||
if img.ndim >= 2 and row_idx >= 0:
|
||||
y1 = max(0, row_idx * self.session_item_h)
|
||||
y2 = min(img.shape[0], y1 + self.session_item_h)
|
||||
visible = img[y1:y2] if y2 > y1 else img
|
||||
else:
|
||||
visible = img
|
||||
digest.update(np.ascontiguousarray(visible).tobytes())
|
||||
raw = digest.digest()
|
||||
self._known_fps.add(raw)
|
||||
return raw
|
||||
|
||||
# 复合指纹分别限制头像和昵称边缘变化,不能用一段的相似掩盖另一段的不同。
|
||||
_FP_AVATAR_HAMMING_TOL = 6
|
||||
_FP_LABEL_HAMMING_TOL = 10
|
||||
# 仅用于兼容直接传入的历史 8 字节指纹;新 16 字节永远不会与它归并。
|
||||
_FP_HAMMING_TOL = _FP_AVATAR_HAMMING_TOL
|
||||
|
||||
def _canonical_fp(self, raw: bytes) -> bytes:
|
||||
"""
|
||||
指纹归一化:感知哈希对渲染噪声只能做到「几乎不变」,个别位仍可能翻转。
|
||||
在已知指纹集合中找汉明距离 ≤ _FP_HAMMING_TOL 的最近邻:
|
||||
指纹归一化:在相同长度的已知指纹中查找最近邻。
|
||||
|
||||
新版 16 字节指纹分别校验头像(前 8 字节)和昵称(后 8 字节)的
|
||||
汉明距离;历史 8 字节指纹只和历史 8 字节指纹比较,绝不跨代归并。
|
||||
找到 → 归一化为已知指纹(同一客户永远映射到同一份档案);
|
||||
找不到 → 登记为新会话指纹。
|
||||
"""
|
||||
if len(raw) != 8:
|
||||
if len(raw) not in (8, 16):
|
||||
return raw
|
||||
raw_int = int.from_bytes(raw, 'big')
|
||||
best, best_d = None, 999
|
||||
|
||||
best, best_score = None, 999
|
||||
for known in self._known_fps:
|
||||
d = bin(int.from_bytes(known, 'big') ^ raw_int).count('1')
|
||||
if d < best_d:
|
||||
best, best_d = known, d
|
||||
if best is not None and best_d <= self._FP_HAMMING_TOL:
|
||||
if len(known) != len(raw):
|
||||
continue
|
||||
if len(raw) == 16:
|
||||
avatar_d = self._hamming_distance(raw[:8], known[:8])
|
||||
label_d = self._hamming_distance(raw[8:], known[8:])
|
||||
if (
|
||||
avatar_d > self._FP_AVATAR_HAMMING_TOL
|
||||
or label_d > self._FP_LABEL_HAMMING_TOL
|
||||
):
|
||||
continue
|
||||
score = avatar_d + label_d
|
||||
else:
|
||||
score = self._hamming_distance(raw, known)
|
||||
if score > self._FP_HAMMING_TOL:
|
||||
continue
|
||||
if score < best_score:
|
||||
best, best_score = known, score
|
||||
if best is not None:
|
||||
return best
|
||||
self._known_fps.add(raw)
|
||||
return raw
|
||||
|
||||
@staticmethod
|
||||
def _hamming_distance(left: bytes, right: bytes) -> int:
|
||||
return bin(int.from_bytes(left, "big") ^ int.from_bytes(right, "big")).count("1")
|
||||
|
||||
# ── 4. 交互动作层 ─────────────────────────────────────────────────────────
|
||||
def set_topmost(self, enable: bool):
|
||||
"""
|
||||
@@ -1251,7 +1376,7 @@ class WeChatBot:
|
||||
def send_reply(self, text: str = None):
|
||||
"""向当前打开的会话发送回复,发送完后取消选中状态。"""
|
||||
if not self.wait_for_mouse_idle():
|
||||
return
|
||||
return False
|
||||
reply_text = text or AUTO_REPLY_TEXT
|
||||
self._begin_bot_mouse()
|
||||
try:
|
||||
@@ -1265,6 +1390,30 @@ class WeChatBot:
|
||||
finally:
|
||||
self._end_bot_mouse()
|
||||
self._deselect_session()
|
||||
return True
|
||||
|
||||
def _commit_generated_exchange(
|
||||
self,
|
||||
fp: bytes,
|
||||
reply_text: str = None,
|
||||
*,
|
||||
sent: bool,
|
||||
):
|
||||
"""Only persist the exchange after WeCom's send action completed."""
|
||||
pending = getattr(self, "_pending_ai_exchange", None)
|
||||
self._pending_ai_exchange = None
|
||||
if not sent or not isinstance(pending, dict):
|
||||
return
|
||||
if pending.get("session_id") != fp.hex():
|
||||
return
|
||||
actual_reply = reply_text or AUTO_REPLY_TEXT
|
||||
if pending.get("reply") != actual_reply:
|
||||
return
|
||||
self.remember_exchange(
|
||||
fp,
|
||||
pending.get("user_text") or "(客户发来新消息,内容未能提取为文字)",
|
||||
actual_reply,
|
||||
)
|
||||
|
||||
def _find_tool_row(self, img: np.ndarray) -> int:
|
||||
"""
|
||||
@@ -1466,93 +1615,96 @@ class WeChatBot:
|
||||
reply_text = self._generate_ai_reply(fp, chat_text=chat_text)
|
||||
self._activate_wx()
|
||||
time.sleep(0.2)
|
||||
self.send_reply(reply_text) # send_reply 内部会取消选中
|
||||
sent = self.send_reply(reply_text) # send_reply 内部会取消选中
|
||||
self._commit_generated_exchange(fp, reply_text, sent=bool(sent))
|
||||
time.sleep(0.5)
|
||||
|
||||
def _generate_ai_reply(self, fp: bytes, chat_text: str = None) -> str:
|
||||
"""
|
||||
对【当前已打开】的会话执行 AI 回复流程:
|
||||
从会话档案取历史上下文 + 增量提取新消息 → 调用 AI → 回写档案。
|
||||
从会话档案取历史上下文 + 增量提取新消息 → 调用 Agent → 暂存结果。
|
||||
只有企业微信发送动作成功后,调用方才会把本轮交换写入会话档案。
|
||||
chat_text 可传入已提取好的一屏文本(避免重复框选),仍会走增量比对。
|
||||
返回回复文本;AI 未启用或失败时返回 None(调用方会用默认回复兜底)。
|
||||
"""
|
||||
reply_text = None
|
||||
memory_user_text = None
|
||||
context_enabled = False
|
||||
try:
|
||||
from ai_config import AI_ENABLED, AI_USE_VISION, AI_CONTEXT_ENABLED
|
||||
from ai_config import (
|
||||
AI_ENABLED,
|
||||
AI_USE_VISION,
|
||||
AI_CONTEXT_ENABLED,
|
||||
)
|
||||
if not AI_ENABLED:
|
||||
return None
|
||||
from ai_chat import get_ai_reply, call_ai_text
|
||||
from ai_chat import call_ai_text
|
||||
|
||||
ai_reply = None
|
||||
context_enabled = bool(AI_CONTEXT_ENABLED)
|
||||
# 该会话的历史上下文(来自持久化档案,按会话指纹隔离,重启不丢)
|
||||
history = self.get_session_history(fp) if AI_CONTEXT_ENABLED else None
|
||||
if history:
|
||||
print(f" [AI] 会话档案提供历史上下文 {len(history)} 条")
|
||||
|
||||
if AI_USE_VISION:
|
||||
# 视觉模式:截图聊天区域发给多模态 AI
|
||||
time.sleep(0.5)
|
||||
image_bytes = self.capture_chat_area()
|
||||
print(f" [AI] 视觉模式,已截取聊天区域 ({len(image_bytes)} bytes)")
|
||||
ai_reply = get_ai_reply(image_bytes=image_bytes, history=history)
|
||||
chat_text = chat_text or ''
|
||||
print(" [AI] Grok Agent 客服使用聊天文字,本轮不绕过 Agent 调图片接口")
|
||||
|
||||
# Grok Agent 的上下文工具读取本地会话档案;这里只把本轮新增消息
|
||||
# 和稳定会话指纹交给 Agent,避免直接调用任何客服网址。
|
||||
time.sleep(0.5)
|
||||
chat_text = self.extract_context_for(fp, pre_text=chat_text)
|
||||
if chat_text:
|
||||
print(f" [AI] 本次交给 Grok Agent 的新内容:\n{chat_text[:200]}")
|
||||
else:
|
||||
# 文本模式:增量提取(首次建档全量、之后只取新增消息)
|
||||
time.sleep(0.5)
|
||||
chat_text = self.extract_context_for(fp, pre_text=chat_text)
|
||||
if chat_text:
|
||||
print(f" [AI] 本次发给 AI 的新内容:\n{chat_text[:200]}")
|
||||
ai_reply = call_ai_text(chat_text, history=history)
|
||||
else:
|
||||
# 提取失败,用通用提示词(仍携带档案历史上下文)
|
||||
print(" [AI] 未提取到聊天内容,使用通用提示词")
|
||||
ai_reply = call_ai_text(
|
||||
"客户在企业微信发来了一条新消息。"
|
||||
"请以客服身份生成一条礼貌、简短的问候回复,"
|
||||
"询问对方有什么可以帮到他。",
|
||||
history=history,
|
||||
)
|
||||
print(" [AI] 未提取到聊天内容,使用通用提示词")
|
||||
chat_text = (
|
||||
"客户在企业微信发来了一条新消息,但本轮未能提取到文字。"
|
||||
"请礼貌询问对方有什么可以帮到他。"
|
||||
)
|
||||
memory_user_text = chat_text
|
||||
ai_reply = call_ai_text(
|
||||
chat_text,
|
||||
history=history,
|
||||
session_id=fp.hex(),
|
||||
)
|
||||
|
||||
if ai_reply:
|
||||
# 医院名强制甄养堂 + 挂号话术;有挂号需求则写入登记表
|
||||
try:
|
||||
from registration_store import process_registration_reply, RegistrationStore
|
||||
agent = ""
|
||||
try:
|
||||
from ai_config import AI_AGENT_NAME
|
||||
agent = AI_AGENT_NAME
|
||||
except Exception:
|
||||
pass
|
||||
ai_reply, lead = process_registration_reply(
|
||||
session_id=fp.hex(),
|
||||
user_text=chat_text or "",
|
||||
reply_text=ai_reply,
|
||||
store=RegistrationStore(),
|
||||
agent_name=agent,
|
||||
)
|
||||
if lead:
|
||||
print(
|
||||
f" [挂号] 已登记 → {lead.get('contact')}|"
|
||||
f"{lead.get('status')}|病症:{lead.get('symptom') or '待问清'}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" [挂号] ⚠ 登记处理失败: {e}")
|
||||
# 即使模型已经调用校验工具,宿主在发送前仍强制执行一次纯本地
|
||||
# 校验。失败时丢弃整个草稿,绝不发送部分或不合规内容。
|
||||
from customer_service_policy import validate_reply_text
|
||||
|
||||
reply_text = ai_reply
|
||||
print(f" [AI] 回复内容: {reply_text[:60]}{'...' if len(reply_text) > 60 else ''}")
|
||||
# 记入该会话的上下文记忆,供下一轮回答衔接
|
||||
if AI_CONTEXT_ENABLED:
|
||||
self.remember_exchange(
|
||||
fp,
|
||||
chat_text or "(客户发来新消息,内容未能提取为文字)",
|
||||
ai_reply,
|
||||
validation = validate_reply_text(
|
||||
customer_message=chat_text or "",
|
||||
reply=ai_reply,
|
||||
)
|
||||
if validation.get("blocked"):
|
||||
codes = ",".join(
|
||||
str(item.get("code") or "")
|
||||
for item in validation.get("violations", [])
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
print(
|
||||
" [AI] [WARN] 回复未通过本地最终校验: "
|
||||
f"{codes or 'policy'}"
|
||||
)
|
||||
ai_reply = None
|
||||
reply_text = ai_reply
|
||||
if reply_text:
|
||||
print(f" [AI] 回复内容: {reply_text[:60]}{'...' if len(reply_text) > 60 else ''}")
|
||||
else:
|
||||
print(" [AI] ⚠ AI 未返回有效回复,使用默认回复")
|
||||
print(" [AI] [WARN] AI 未返回有效回复,使用默认回复")
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f" [AI] ⚠ AI 调用异常: {e}")
|
||||
print(f" [AI] [WARN] AI 调用异常: {e}")
|
||||
finally:
|
||||
if context_enabled and memory_user_text:
|
||||
self._pending_ai_exchange = {
|
||||
"session_id": fp.hex(),
|
||||
"user_text": memory_user_text,
|
||||
"reply": reply_text or AUTO_REPLY_TEXT,
|
||||
}
|
||||
return reply_text
|
||||
|
||||
# ── 5. 主轮询循环 ─────────────────────────────────────────────────────────
|
||||
@@ -1670,7 +1822,12 @@ class WeChatBot:
|
||||
print(" [安全模式] 企业微信已失去前台焦点,本次回复未发送。")
|
||||
break
|
||||
time.sleep(0.2)
|
||||
self.send_reply(reply_text)
|
||||
sent = self.send_reply(reply_text)
|
||||
self._commit_generated_exchange(
|
||||
target_fp,
|
||||
reply_text,
|
||||
sent=bool(sent),
|
||||
)
|
||||
|
||||
|
||||
# 标记该会话本轮已处理(无论回复成功与否,避免红点延迟消失或列表重排导致重复处理)
|
||||
@@ -1686,7 +1843,10 @@ class WeChatBot:
|
||||
after_fps = set()
|
||||
|
||||
if target_fp in after_fps:
|
||||
print(f" [⚠] row{row_idx} 回复后红点未消失(本轮不再重复处理该会话)")
|
||||
print(
|
||||
f" [WARN] row{row_idx} 回复后红点未消失"
|
||||
"(本轮不再重复处理该会话)"
|
||||
)
|
||||
else:
|
||||
print(f" [✓] 回复完成,红点已消失 → row{row_idx}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user