Files

492 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""微信智能客服引擎 v3wechatauto-replica
在 v2 固定话术自动回复基础上升级为「大模型上下文对话 + 本机知识库」:
1. 每个客户(username)维护独立会话历史,持久化到磁盘,跨重启不丢,
按「最近 N 轮 / 最近 N 秒」窗口取上下文喂给大模型。
2. 收到消息时检索本机 Obsidian 知识库(字符 n-gram 相似度),把最相关的
笔记片段注入 system prompt,让回复「结合知识库」。
3. 通过 OpenAI 兼容接口调用大模型生成自然回复(默认 DeepSeek,可换
智谱/Kimi/通义等,只要填 base_url + model + api_key)。
4. 无 api_key 时自动降级:关键词规则 → 知识库片段拼接 → 兜底话术,
保证引擎不因缺 key 而停摆。
用法(项目目录下,独立 venv):
.venv\\Scripts\\python.exe wechat_ai_reply.py
前置:微信 4.x 已登录、桌面未锁屏、管理员权限(提密钥)。
配置:改 ai_config.json(重点是 llm.api_key)。
"""
import json
import os
import re
import sys
import time
import threading
import urllib.request
import urllib.error
import msvcrt
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto.db import WeChatDB, Listener
from wechatauto.guia import WeChatGUI
import voice2text
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, "ai_config.json")
WATERMARK_PATH = os.path.join(BASE_DIR, "data", "watermark.json")
def load_watermark() -> dict:
"""加载监听水位(username -> 已推送的最大 sort_seq),重启不重复推送、不重复回复。"""
try:
if os.path.exists(WATERMARK_PATH):
data = json.loads(open(WATERMARK_PATH, "r", encoding="utf-8").read())
if isinstance(data, dict):
return data
except (OSError, ValueError):
pass
return {}
def save_watermark(wm: dict) -> None:
"""把监听水位落盘。"""
try:
os.makedirs(os.path.dirname(WATERMARK_PATH), exist_ok=True)
open(WATERMARK_PATH, "w", encoding="utf-8").write(
json.dumps(wm, ensure_ascii=False)
)
except OSError:
pass
def load_config() -> dict:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
CFG = load_config()
# ==================== 知识库检索(本地 n-gram 相似度) ====================
class KnowledgeBase:
"""读本机 Obsidian vault 的 .md 笔记,按字符 n-gram 重叠度检索。
与 Unai 本机 vault 的 hashed n-gram 检索思路一致:靠字符重叠而非语义。
排除 .obsidian 等元数据目录,只读已发布的分类笔记。
"""
def __init__(self, vault_dir: str):
self.vault_dir = vault_dir
self._notes = [] # [(title, path, text)]
self._load()
def _load(self):
if not self.vault_dir or not os.path.isdir(self.vault_dir):
print(f"[KB] 知识库目录不存在: {self.vault_dir}", flush=True)
return
for root, dirs, files in os.walk(self.vault_dir):
dirs[:] = [d for d in dirs if d not in (".obsidian", ".git")]
for name in files:
if not name.endswith(".md"):
continue
path = os.path.join(root, name)
try:
text = open(path, "r", encoding="utf-8", errors="ignore").read()
except OSError:
continue
if not text.strip():
continue
self._notes.append((name[:-3], path, text))
print(f"[KB] 已载入 {len(self._notes)} 篇笔记", flush=True)
@staticmethod
def _ngrams(s: str, n: int = 3):
s = re.sub(r"\s+", "", s)
return {s[i:i + n] for i in range(len(s) - n + 1)} if len(s) >= n else {s}
def search(self, query: str, top_k: int = 3, max_chars: int = 1500) -> str:
"""返回拼接后的相关笔记片段;无结果返回空串。"""
if not self._notes or not query:
return ""
q_grams = self._ngrams(query)
scored = []
for title, path, text in self._notes:
t_grams = self._ngrams(text, 3)
inter = len(q_grams & t_grams)
if inter == 0:
continue
# 重叠数 + 少量标题命中加权
score = inter
if title and self._ngrams(title) & q_grams:
score += 10
scored.append((score, title, text))
if not scored:
return ""
scored.sort(reverse=True, key=lambda x: x[0])
parts = []
for score, title, text in scored[:top_k]:
excerpt = text.strip()
if len(excerpt) > max_chars:
excerpt = excerpt[:max_chars] + "…"
parts.append(f"【{title}\n{excerpt}")
return "\n\n".join(parts)
# ==================== 每客户会话记忆 ====================
class ConversationMemory:
"""username -> 独立对话历史,内存缓存 + 磁盘持久化。"""
def __init__(self, base_dir: str, max_turns: int):
self.base_dir = base_dir
self.max_turns = max_turns
self._cache = {}
os.makedirs(base_dir, exist_ok=True)
def _safe_name(self, username: str) -> str:
return re.sub(r"[^0-9A-Za-z_@-]", "_", username) + ".json"
def _path(self, username: str) -> str:
return os.path.join(self.base_dir, self._safe_name(username))
def load(self, username: str) -> list:
if username in self._cache:
return self._cache[username]
path = self._path(username)
hist = []
if os.path.exists(path):
try:
hist = json.loads(open(path, "r", encoding="utf-8").read())
except (OSError, ValueError):
hist = []
self._cache[username] = hist
return hist
def append(self, username: str, role: str, content: str):
hist = self.load(username)
hist.append({"role": role, "content": content, "time": int(time.time())})
# 只保留最近 max_turns 轮(一轮 = 用户一条 + 助手一条)
hist = hist[-(self.max_turns * 2):]
self._cache[username] = hist
try:
open(self._path(username), "w", encoding="utf-8").write(
json.dumps(hist, ensure_ascii=False, indent=1)
)
except OSError:
pass
# ==================== 大模型客户端(OpenAI 兼容,零第三方依赖) ====================
class LLMClient:
def __init__(self, cfg: dict):
self.api_key = (cfg.get("api_key") or "").strip()
self.base_url = (cfg.get("base_url") or "").rstrip("/")
self.model = cfg.get("model") or "deepseek-chat"
self.timeout = cfg.get("timeout_sec") or 60
self.max_retry = cfg.get("max_retry") or 2
@property
def available(self) -> bool:
return bool(self.api_key)
def chat(self, messages: list) -> str:
"""调用 chat/completions,返回助手文本;失败抛异常。"""
url = self.base_url + "/chat/completions"
payload = {
"model": self.model,
"messages": messages,
"temperature": 0.7,
"stream": False,
}
data = json.dumps(payload).encode("utf-8")
last_err = None
for attempt in range(self.max_retry + 1):
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + self.api_key)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = resp.read().decode("utf-8")
obj = json.loads(body)
return obj["choices"][0]["message"]["content"].strip()
except (urllib.error.URLError, urllib.error.HTTPError, KeyError,
IndexError, ValueError, OSError) as e:
last_err = e
if attempt < self.max_retry:
time.sleep(1 + attempt)
raise RuntimeError(f"LLM 调用失败: {last_err!r}")
# ==================== 引擎主逻辑 ====================
_gui = None
_gui_lock = threading.Lock()
_send_lock = threading.Lock()
_last_reply = {} # username -> 上次回复时间戳
_last_reply["_db"] = None
_self_wxid = None # 当前登录账号的 wxid,用于跳过自己发的消息
_handled_seq = {} # username -> 已处理的最大 sort_seq(消息级去重,双保险)
_sent_recent = {} # username -> [(content, ts)]:引擎刚发出的回复回声,跳过"自己的消息被读回"
_SELF_SENDER_IDS = {"1"} # real_sender_id 里"自己"的取值(微信 4.1.13 实测 self=1
_pending = {} # username -> {"texts": [..], "timer": Timer|None}:冷却期内累积的消息,到期合并回复
_pending_lock = threading.Lock()
def _recent_sent(username: str, now: float, window: float = 120.0):
"""返回该用户最近 window 秒内发出的回复内容列表,用于回声匹配。"""
items = [(c, t) for c, t in _sent_recent.get(username, []) if now - t <= window]
_sent_recent[username] = items
return [c for c, _ in items]
_kb = KnowledgeBase(CFG["knowledge"].get("vault_dir", ""))
_llm = LLMClient(CFG["llm"])
_mem = ConversationMemory(
CFG["storage"].get("conversation_dir", "data/conversations"),
CFG["reply"].get("history_turns", 10),
)
def get_gui():
global _gui
with _gui_lock:
if _gui is None:
_gui = WeChatGUI()
return _gui
_BLOCK_PREFIXES = ("gh_",) # 公众号,不回复
_SYSTEM_USERS = {
"filehelper", "brandsessionholder", "brandservicesessionholder",
"notifymessage", "fmessage", "floatbottle", "medianote",
}
def should_reply(username: str) -> bool:
r = CFG["reply"]
if not r.get("enabled", True):
return False
allowlist = r.get("allowlist") or []
if allowlist:
# 白名单模式:只回复名单内的好友(测试期锁定单一联系人用)
return username in allowlist
if username in r.get("blocklist", []):
return False
if username in _SYSTEM_USERS:
return False
if "@" in username: # 群聊 @chatroom / 服务号 @openim / @weclaw / @placeholder 等
return False
if username.startswith(_BLOCK_PREFIXES):
return False
return True
def _build_messages(username: str, incoming: str) -> tuple:
"""构造发给大模型的 messages 列表,返回 (messages, kb_hit)。"""
sys_prompt = CFG["persona"].get("system_prompt", "")
kb_hit = ""
kb = _kb.search(incoming, top_k=CFG["knowledge"].get("top_k", 3),
max_chars=CFG["knowledge"].get("max_chars_per_note", 1500))
if kb:
kb_hit = kb
sys_prompt += "\n\n【知识库资料】\n" + kb
messages = [{"role": "system", "content": sys_prompt}]
# 取该客户历史(按时间窗口过滤)
hist = _mem.load(username)
window = CFG["reply"].get("history_window_sec", 86400)
now = time.time()
recent = [h for h in hist if now - h.get("time", 0) <= window]
for h in recent[-(CFG["reply"].get("history_turns", 10) * 2):]:
if h.get("content"):
messages.append({"role": h["role"], "content": h["content"]})
messages.append({"role": "user", "content": incoming})
return messages, kb_hit
def _fallback_reply(content: str, kb_hit: str) -> str:
"""无 api_key 时的降级:关键词规则 → 知识库片段 → 兜底话术。"""
rules = CFG["reply"].get("keyword_rules") or {}
for kw, reply in rules.items():
if kw and kw in content:
return reply
if kb_hit:
# 知识库命中但无大模型,只回片段开头的摘录 + 引导
head = kb_hit.strip().split("\n", 1)[0].replace("【", "").replace("】", "")
return f"关于这个问题,您可以先参考这份资料({head}):需要我展开哪一点,我帮您细看。"
return CFG["reply"].get("fallback_reply",
"【自动回复】已收到您的消息,稍后回复您。")
def _do_reply(username: str, content: str, db):
"""生成并发送一次回复(大模型优先,无 key 降级)。"""
nick = db.get_nickname(username) if db else username
try:
if _llm.available:
messages, kb_hit = _build_messages(username, content)
reply = _llm.chat(messages)
mode = "LLM" + ("+KB" if kb_hit else "")
else:
kb_hit = _kb.search(content, top_k=CFG["knowledge"].get("top_k", 3),
max_chars=CFG["knowledge"].get("max_chars_per_note", 1500))
reply = _fallback_reply(content, kb_hit)
mode = "降级(无key)" + ("+KB" if kb_hit else "")
except Exception as e: # noqa: BLE001
reply = CFG["reply"].get("fallback_reply", "【自动回复】已收到您的消息,稍后回复您。")
mode = f"异常回退:{e!r}"
# 记录历史(用户 + 助手),再发送(加锁,避免 timer 线程与 worker 线程并发写)
with _send_lock:
_mem.append(username, "user", content)
_mem.append(username, "assistant", reply)
_sent_recent.setdefault(username, []).append((reply, time.time()))
try:
r = get_gui().send_msg(reply, who=username, verify=True)
print(f"[智能回复|{mode}] {username} ({nick}) <- {reply} | ok={r.is_success}",
flush=True)
except Exception as e: # noqa: BLE001
print(f"[发送失败] {username}: {e!r}", flush=True)
def _flush_pending(username: str):
"""冷却期结束后,把累积的多条消息合并成一次回复发出。"""
with _pending_lock:
p = _pending.get(username)
if not p or not p["texts"]:
return
texts = p["texts"]
p["texts"] = []
p["timer"] = None
content = " ".join(texts).strip()
if not content:
return
db = _last_reply["_db"]
_last_reply[username] = time.time()
_do_reply(username, content, db)
def on_message(msg: dict, lst: Listener):
username = msg.get("username", "")
sender_id = str(msg.get("sender_id", ""))
content = str(msg.get("content", "")).strip()
sort_seq = msg.get("sort_seq", 0)
mtype = msg.get("type", "")
local_id = msg.get("local_id")
# 跳过自己发的(避免回复自己的回复 → 死循环)。
now = time.time()
# ---- 跳过"自己"的消息(三层防护,杜绝自回复死循环)----
# 1) sender_id 是真实 wxid 字符串时
if _self_wxid and sender_id == str(_self_wxid):
return
# 2) real_sender_id 数值 == 自己(微信 4.1.13 实测 self=1
if sender_id in _SELF_SENDER_IDS:
return
# 3) 内容回声:刚发出去的回复又被监听读回,直接丢弃(不依赖 sender_id 语义,最稳一层)
if content and content in _recent_sent(username, now):
return
if not should_reply(username) or not content:
return
# 消息级去重:sort_seq 不前进则说明是同一条消息被重复回调,直接丢弃
last_seq = _handled_seq.get(username, -1)
if sort_seq and sort_seq <= last_seq:
return
_handled_seq[username] = sort_seq
db = _last_reply["_db"]
# 语音消息:先转文字再回复(语音 content 是 "[语音]" 占位,真实内容在音频里)
if mtype == "语音" and local_id:
text = voice2text.transcribe_voice(username, local_id, db)
if text:
content = text
print(f"[语音转文字] {username} -> {text}", flush=True)
else:
print(f"[语音转文字] {username} 转写失败,跳过", flush=True)
return
# 冷却合并:冷却期内的新消息不丢弃,累积后统一回复(修复"只回第一句、后面不回")。
cooldown = CFG["reply"].get("cooldown_sec", 5)
if now - _last_reply.get(username, 0) < cooldown:
with _pending_lock:
p = _pending.setdefault(username, {"texts": [], "timer": None})
p["texts"].append(content)
if p["timer"] is not None:
p["timer"].cancel()
p["timer"] = threading.Timer(cooldown, _flush_pending, args=(username,))
p["timer"].daemon = True
p["timer"].start()
return
_last_reply[username] = now
_do_reply(username, content, db)
def main():
global _self_wxid
# 单实例锁:防止多个引擎进程并发读写同一个解密缓存导致 database malformed
_lock_path = os.path.join(BASE_DIR, "engine.lock")
_lock_fh = open(_lock_path, "wb")
try:
msvcrt.locking(_lock_fh.fileno(), msvcrt.LK_NBLCK, 1)
except OSError:
print("已有引擎实例在运行,本进程退出(避免并发写坏数据库缓存)。", flush=True)
_lock_fh.close()
sys.exit(0)
db = WeChatDB()
info = db.get_self_info()
nick = info.get("nick_name") or info.get("username")
_last_reply["_db"] = db
_self_wxid = info.get("username") or getattr(db, "wxid", None)
print(f"已接管微信: {nick} ({info.get('username')})", flush=True)
print(f"自身 wxid: {_self_wxid}(跳过自己发的消息,防自回复循环)", flush=True)
print(f"大模型: {'已配置 ' + _llm.model if _llm.available else '未配置(降级模式)'} "
f"| base_url={_llm.base_url}", flush=True)
print(f"知识库: {CFG['knowledge'].get('vault_dir','')} (已载入 {len(_kb._notes)} 篇)",
flush=True)
_allow = CFG['reply'].get('allowlist') or []
_scope = f"白名单 {len(_allow)} 人" if _allow else "所有好友(排除公众号/群聊/服务号)"
print(f"自动回复: {'开' if CFG['reply'].get('enabled') else '关'} | "
f"回复范围: {_scope} | "
f"防抖 {CFG['reply'].get('cooldown_sec')}s", flush=True)
lst = Listener(db, interval=3.0, watermark=load_watermark())
# 监听所有会话(add_all + discover 自动发现新会话)。should_reply 里排除
# 公众号/群聊/服务号/系统账号;若配置 allowlist 则只回名单内好友。
lst.add_all(on_message, discover=True)
print(f"智能客服监听已启动({_scope}),Ctrl+C 退出...",
flush=True)
lst.start()
_save_every = 0
try:
while True:
time.sleep(5)
_save_every += 5
if _save_every >= 30:
_save_every = 0
save_watermark(lst.watermark)
except KeyboardInterrupt:
pass
finally:
save_watermark(lst.watermark)
lst.stop()
print("已停止监听。", flush=True)
if __name__ == "__main__":
main()