新增功能

This commit is contained in:
Your Name
2026-07-28 09:46:53 +08:00
parent 45b3bc0852
commit 980795b4da
57 changed files with 18421 additions and 1051 deletions
+256 -96
View File
@@ -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}")