Files
kefu/wechat_rpa/ai_chat.py
T
2026-07-16 12:11:05 +08:00

397 lines
16 KiB
Python
Raw 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.
"""
AI 聊天模块
===========
负责与 AI API 通信,支持文本模式、视觉模式,以及 MCP 工具增强回复。
使用标准 requests 库,无需安装 openai SDK。
"""
import requests
import base64
import json
import re
from urllib.parse import urlparse
import ai_config
# ⚠ 本模块所有配置一律通过 ai_config.XXX 动态读取(而非 from-import 快照),
# 这样在 GUI「AI 高级配置」中修改保存后,下一次请求立即生效,无需重启。
def _completions_url() -> str:
"""
组装实际请求地址:
- AI_API_BASE 形如 .../v1/chat-messages/v1/ 后已有路径)→ 原样使用,不再拼 /chat/completions
- AI_API_BASE 形如 https://api.deepseek.com 或 .../v1 → 追加 /chat/completions
"""
base = (ai_config.AI_API_BASE or "").rstrip("/")
path = urlparse(base).path or ""
if re.match(r"^/v1/.+", path):
return base
return f"{base}/chat/completions"
def _is_dify_endpoint() -> bool:
"""AI_API_BASE 指向 Dify 的 chat-messages / completion-messages 时走 Dify 协议。"""
path = (urlparse((ai_config.AI_API_BASE or "").rstrip("/")).path or "").lower()
return "chat-messages" in path or "completion-messages" in path
def _system_prompt() -> str:
"""
动态构建系统提示词:基础人设(用当前昵称/医院名实时渲染)
+ 按开关追加的可选模块(对骂模式 / MCP 工具说明)。
"""
prompt = ai_config.build_system_prompt()
if getattr(ai_config, 'AI_COUNTER_INSULT_ENABLED', False):
prompt += "\n" + getattr(ai_config, 'AI_COUNTER_INSULT_PROMPT', '')
if getattr(ai_config, 'AI_MCP_ENABLED', False):
prompt += "\n" + getattr(ai_config, 'AI_MCP_PROMPT', '')
return prompt
def _history_messages(history: list) -> list:
"""
将会话历史裁剪为最近 N 轮,作为多轮上下文消息插入到请求中。
history 为会话档案中的消息列表(可能带 ts 等额外字段,发 API 前只保留 role/content)。
运行时动态读取 ai_config(GUI 可在启动时修改开关)。
"""
if not getattr(ai_config, 'AI_CONTEXT_ENABLED', False) or not history:
return []
max_rounds = getattr(ai_config, 'AI_CONTEXT_MAX_ROUNDS', 5)
return [
{"role": m["role"], "content": m["content"]}
for m in history[-max_rounds * 2:]
]
def _headers():
key = (ai_config.AI_API_KEY or "").strip()
return {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Connection": "keep-alive",
}
def _call_dify(query: str, user: str = "wechat-rpa", conversation_id: str = "") -> str:
"""
调用 Dify /v1/chat-messagesblocking)。
鉴权用应用 API Key(通常以 app- 开头),与 OpenAI 兼容接口不同。
"""
payload = {
"inputs": {},
"query": query,
"response_mode": "blocking",
"user": user or "wechat-rpa",
}
if conversation_id:
payload["conversation_id"] = conversation_id
url = _completions_url()
resp = requests.post(
url, headers=_headers(), json=payload, timeout=ai_config.AI_TIMEOUT
)
if resp.status_code == 401:
detail = ""
try:
detail = resp.json().get("message") or resp.text[:200]
except Exception:
detail = resp.text[:200]
raise RuntimeError(
f"Dify 鉴权失败(401: {detail}。"
f"请到 Dify 应用 →「访问 API」复制 API Key(一般以 app- 开头)填到 AI_API_KEY"
f"当前 Key 看起来不像有效应用密钥。"
)
if not resp.ok:
detail = (resp.text or "")[:400]
raise RuntimeError(f"Dify 请求失败 {resp.status_code}: {detail}")
data = resp.json()
answer = data.get("answer")
if answer is None:
# 少数部署可能包一层 data
answer = (data.get("data") or {}).get("answer")
if not answer:
raise RuntimeError(f"Dify 未返回 answer: {json.dumps(data, ensure_ascii=False)[:300]}")
return str(answer)
def _dify_query_from_chat(chat_text: str, history: list = None) -> str:
"""
把本地会话档案拼进 Dify query,并注入事实铁律 + 挂号医院铁律。
"""
try:
hosp = getattr(ai_config, "AI_HOSPITAL_NAME", None) or "甄养堂互联网医院"
except Exception:
hosp = "甄养堂互联网医院"
rules = (
"【事实铁律|必须遵守】\n"
"1. 严禁编造快递单号、物流状态、签收时间、订单号、库存等业务数据。\n"
"2. 查快递/订单时:没有真实系统查询结果,只能追问单号/姓名/手机,或说去后台核实后回;"
"不准说「查到了」「已签收」并随口编一个单号。\n"
"3. 历史里出现的可疑示例单号(如 SF1234567890)一律视为无效,不要继续沿用。\n"
f"4. 凡建议就医/挂号/面诊,或回复里提到医院,【只能】写「{hosp}」,"
"严禁「正规医院」「当地医院」「三甲医院」「内分泌科」等说法(急救拨120除外)。"
f"本院是糖尿病中医专科医院,没有内分泌科。\n"
"5. 客户只是咨询(如血糖不稳定怎么办)时:先给专业建议,"
"可轻提一句需要可来本院挂号;【禁止】直接说已帮您预约。\n"
f"6. 仅当客户明确说要挂号/预约/面诊/帮我约 时,才说「已帮您预约了,稍后预约上了再联系您」,"
f"医院是{hosp}。客户说不需要/挂啥号时绝不能预约。\n"
"7. 只回复客户最后一条需要处理的问题。语气像干了十几年的老客服:口语、沉稳、"
"不急不躁,一次只说一件事,最多顺带问一个问题,不要复述对方原话,不要一次抛一大段方案。\n"
)
hist = _history_messages(history)
if not hist:
body = f"以下是最近的聊天记录:\n\n{chat_text}\n\n请生成回复:"
else:
lines = []
for m in hist:
role = "客户" if m.get("role") == "user" else "客服"
lines.append(f"{role}: {m.get('content', '')}")
body = (
"【近期对话|仅供参考,其中客服所述业务数据可能不实】\n"
+ "\n".join(lines)
+ f"\n\n【最新消息】\n{chat_text}\n\n请生成回复:"
)
return rules + "\n" + body
def _strip_thinking(text: str) -> str:
"""
去除 Qwen3 / DeepSeek-R1 等模型输出的 <think>...</think> 推理块,
只保留最终回复内容。
"""
if not text:
return ""
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
return cleaned.strip()
# 上游模型(尤其是 Dify 应用侧自带的固定人设/挡话话术)有时不听系统提示词,
# 这里做最后一道兜底净化:去掉暴露机器身份的自称,以及被禁止的轻浮语气词。
_SELF_ID_RE = re.compile(
r"[,]?\s*(我是|作为)(您|你)的?(专属)?"
r"(健康(顾问|助理|管家|专家)|智能(客服|助手)|AI(客服|助手)?|人工智能|"
r"虚拟(客服|助手)|机器人客服|机器人|自动回复(助手)?|系统助手|助手)"
r"[,。!]?"
)
_LAUGH_RE = re.compile(r"哈{2,}[,]?|哈哈[,]?")
def _humanize(text: str) -> str:
"""去除自称机器人/助手的句子,以及禁止的"哈哈"语气词,收拾多余标点。"""
if not text:
return text
# 用逗号占位替换,避免前后半句直接粘连;随后统一收拢多余标点
cleaned = _SELF_ID_RE.sub("", text)
cleaned = _LAUGH_RE.sub("", cleaned)
cleaned = re.sub(r"[,]{2,}", "", cleaned)
cleaned = re.sub(r"[,]\s*([。!?])", r"\1", cleaned)
cleaned = re.sub(r"([。!?])\s*[,]+", r"\1", cleaned)
cleaned = re.sub(r"[。!?]{2,}", "。", cleaned)
cleaned = re.sub(r"^[,、\s]+", "", cleaned)
cleaned = re.sub(r"\s{2,}", " ", cleaned)
return cleaned.strip()
def _chat_completion(messages: list, tools: list = None) -> dict:
"""
调用 chat/completions,返回 message 对象(含 content / tool_calls)。
"""
if _is_dify_endpoint():
# 兜底:误入 OpenAI 路径时改走 Dify(避免 400 Arg user must be provided
last_user = ""
for m in reversed(messages):
if m.get("role") == "user":
c = m.get("content")
last_user = c if isinstance(c, str) else str(c)
break
answer = _call_dify(last_user or "请回复")
return {"role": "assistant", "content": answer}
payload = {
"model": ai_config.AI_MODEL,
"messages": messages,
"max_tokens": ai_config.AI_MAX_TOKENS,
"temperature": ai_config.AI_TEMPERATURE,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
url = _completions_url()
resp = requests.post(
url, headers=_headers(), json=payload, timeout=ai_config.AI_TIMEOUT
)
if not resp.ok:
detail = (resp.text or "")[:400]
raise RuntimeError(f"AI 请求失败 {resp.status_code}: {detail}")
return resp.json()["choices"][0]["message"]
def _user_turn(chat_text: str) -> dict:
return {
"role": "user",
"content": f"以下是最近的聊天记录:\n\n{chat_text}\n\n请生成回复:",
}
def call_ai_text(chat_text: str, history: list = None) -> str:
"""
文本模式:将聊天记录文字发给文本 AI,返回回复。
若开启 AI_MCP_ENABLED,会连接外部 MCP Server,让模型按需调用工具后再回复。
若 AI_API_BASE 为 Dify chat-messages,走 Dify 协议(不支持 OpenAI tools)。
"""
if _is_dify_endpoint():
print(" [AI] 检测到 Dify 接口,使用 chat-messages 协议")
return _humanize(_strip_thinking(_call_dify(_dify_query_from_chat(chat_text, history))))
if getattr(ai_config, "AI_MCP_ENABLED", False):
try:
from mcp_bridge import run_coro
return _humanize(run_coro(_call_ai_text_with_mcp(chat_text, history)))
except Exception as e:
print(f" [MCP] ⚠ 工具增强失败,回退普通回复: {e}")
messages = [{"role": "system", "content": _system_prompt()}]
messages += _history_messages(history)
messages.append(_user_turn(chat_text))
msg = _chat_completion(messages)
return _humanize(_strip_thinking(msg.get("content") or ""))
async def _call_ai_text_with_mcp(chat_text: str, history: list = None) -> str:
"""带 MCP 工具调用的多轮回复。"""
from mcp_bridge import McpHub
async with McpHub() as hub:
tools = hub.openai_tools()
if not tools:
print(" [MCP] 未加载到任何工具,使用普通文本回复")
messages = [{"role": "system", "content": _system_prompt()}]
messages += _history_messages(history)
messages.append(_user_turn(chat_text))
msg = _chat_completion(messages)
return _strip_thinking(msg.get("content") or "")
print(f" [MCP] 已加载 {hub.tool_count} 个工具"
f"(服务器: {', '.join(hub.server_names) or '-'}")
messages = [{"role": "system", "content": _system_prompt()}]
messages += _history_messages(history)
messages.append(_user_turn(chat_text))
max_rounds = int(getattr(ai_config, "AI_MCP_MAX_ROUNDS", 5) or 5)
for i in range(max_rounds):
msg = _chat_completion(messages, tools=tools)
tool_calls = msg.get("tool_calls") or []
content = msg.get("content")
if not tool_calls:
return _strip_thinking(content or "")
# 保留 assistant 消息(含 tool_calls
messages.append({
"role": "assistant",
"content": content,
"tool_calls": tool_calls,
})
for tc in tool_calls:
fn = tc.get("function") or {}
name = fn.get("name") or ""
raw_args = fn.get("arguments") or "{}"
try:
args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {})
except json.JSONDecodeError:
args = {}
print(f" [MCP] 调用工具 {name} args={json.dumps(args, ensure_ascii=False)[:120]}")
result = await hub.call_tool(name, args)
preview = (result or "").replace("\n", " ")[:120]
print(f" [MCP] 工具结果: {preview}{'…' if len(result or '') > 120 else ''}")
messages.append({
"role": "tool",
"tool_call_id": tc.get("id") or name,
"content": result or "",
})
# 超过轮数:强制再要一次纯文本回复
print(" [MCP] 已达最大工具轮数,请求最终回复")
msg = _chat_completion(messages)
return _strip_thinking(msg.get("content") or "")
def call_ai_vision(image_bytes: bytes, history: list = None) -> str:
"""
视觉模式:将聊天区域截图发给多模态 AI,让 AI 直接阅读并回复。
image_bytes 为 PNG 图片的 bytes。
history 为该会话的历史上下文(多轮记忆),可为 None。
"""
b64 = base64.b64encode(image_bytes).decode("utf-8")
simple_headers = {
"Authorization": f"Bearer {ai_config.AI_API_KEY}",
"Content-Type": "application/json",
}
messages = [{"role": "system", "content": _system_prompt()}]
messages += _history_messages(history)
payload = {
"model": ai_config.AI_MODEL,
"messages": messages + [
{"role": "user", "content": [
{
"type": "text",
"text": (
"这是一个聊天对话窗口的截图。"
"左边的灰色气泡是对方(客户)发的消息,右边的蓝色气泡是我方之前的回复。"
"请只关注对方(客户)发的最后一条消息,针对那条消息直接回复。"
"只输出回复内容,不要描述图片,不要解释,不要加引号。"
),
},
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
},
]},
],
"max_tokens": ai_config.AI_MAX_TOKENS,
"temperature": ai_config.AI_TEMPERATURE,
}
url = _completions_url()
resp = requests.post(url, headers=simple_headers, json=payload, timeout=ai_config.AI_TIMEOUT)
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
return _humanize(_strip_thinking(content))
def get_ai_reply(chat_text: str = None, image_bytes: bytes = None, history: list = None) -> str:
"""
统一入口:根据 AI_USE_VISION 配置自动选择模式。
history 为该会话的历史上下文(多轮记忆),可为 None。
返回 AI 生成的回复文本。
"""
try:
if ai_config.AI_USE_VISION and image_bytes:
print(" [AI] 使用视觉模式分析聊天截图...")
return call_ai_vision(image_bytes, history=history)
elif chat_text:
if getattr(ai_config, "AI_MCP_ENABLED", False):
print(" [AI] 文本模式 + MCP 工具增强...")
else:
print(" [AI] 使用文本模式分析聊天记录...")
return call_ai_text(chat_text, history=history)
else:
return ""
except requests.exceptions.Timeout:
print(" [AI] ⚠ API 请求超时")
return ""
except requests.exceptions.RequestException as e:
print(f" [AI] ⚠ API 请求失败: {e}")
return ""
except (KeyError, IndexError, json.JSONDecodeError) as e:
print(f" [AI] ⚠ 解析响应失败: {e}")
return ""