286 lines
11 KiB
Python
286 lines
11 KiB
Python
import os
|
||
import re
|
||
import time
|
||
from typing import Any
|
||
|
||
|
||
async def send_reply_flow(client, original_msg: dict, reply_content: str):
|
||
"""
|
||
发送回复消息(从 websocket_client.py 拆出)。
|
||
|
||
Args:
|
||
original_msg: 收到的原始消息字典
|
||
reply_content: 回复内容(文本或本地文件路径/http地址)
|
||
"""
|
||
trace_id = original_msg.get("_trace_id", "")
|
||
if not client.websocket:
|
||
client._activity_log(
|
||
"send_reply_skipped",
|
||
trace_id=trace_id,
|
||
reason="websocket_not_connected",
|
||
acc_id=original_msg.get("acc_id", ""),
|
||
customer_id=original_msg.get("from_id", ""),
|
||
)
|
||
return
|
||
|
||
reply_content = colloquialize_outbound_reply(reply_content)
|
||
reply_content = await ai_generate_outbound_reply(
|
||
client=client,
|
||
original_msg=original_msg,
|
||
reply_content=str(reply_content or ""),
|
||
)
|
||
|
||
# 同一客户外发限流:N 秒内最多 1 条
|
||
try:
|
||
from config.config import OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS
|
||
cooldown = max(0, int(OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS))
|
||
except Exception:
|
||
cooldown = 5
|
||
if cooldown > 0:
|
||
ckey = f"{original_msg.get('acc_id', '')}:{original_msg.get('from_id', '')}"
|
||
now_mono = time.monotonic()
|
||
last = client._last_reply_sent_at.get(ckey, 0.0)
|
||
if (now_mono - last) < cooldown:
|
||
client._activity_log(
|
||
"send_reply_throttled",
|
||
trace_id=trace_id,
|
||
key=ckey,
|
||
cooldown_s=cooldown,
|
||
msg=str(reply_content),
|
||
)
|
||
return
|
||
client._last_reply_sent_at[ckey] = now_mono
|
||
|
||
shop_id = original_msg.get("acc_id", "")
|
||
|
||
# 根据轻简API文档:
|
||
# from_id = 客户ID(收消息方)
|
||
# cy_id = 非群聊时与 from_id 相同
|
||
customer_id = original_msg.get("from_id", "")
|
||
customer_name = original_msg.get("from_name", "")
|
||
|
||
allow_send, checked_reply, guard_reason = await ai_guard_outbound_reply(
|
||
client=client,
|
||
original_msg=original_msg,
|
||
reply_content=str(reply_content),
|
||
)
|
||
client._activity_log(
|
||
"reply_guard_decision",
|
||
trace_id=trace_id,
|
||
acc_id=shop_id,
|
||
customer_id=customer_id,
|
||
result="ok" if allow_send else "blocked",
|
||
reason=guard_reason,
|
||
original_reply=str(reply_content),
|
||
final_reply=str(checked_reply or ""),
|
||
)
|
||
if not allow_send:
|
||
return
|
||
|
||
reply_content = checked_reply or str(reply_content)
|
||
pass_send, _ = client._outbound_arbiter(
|
||
original_msg=original_msg,
|
||
reply_content=reply_content,
|
||
trace_id=trace_id,
|
||
)
|
||
if not pass_send:
|
||
return
|
||
|
||
reply = {
|
||
"msg_id": "",
|
||
"acc_id": shop_id,
|
||
"msg": reply_content,
|
||
"from_id": customer_id,
|
||
"from_name": customer_name,
|
||
"cy_id": customer_id,
|
||
"acc_type": original_msg.get("acc_type", ""),
|
||
"msg_type": 0,
|
||
"cy_name": customer_name,
|
||
}
|
||
client._log_outbound_once(original_msg, str(reply_content))
|
||
client._activity_log(
|
||
"send_reply_attempt",
|
||
trace_id=trace_id,
|
||
acc_id=shop_id,
|
||
customer_id=customer_id,
|
||
msg=str(reply_content),
|
||
)
|
||
reply["_trace_id"] = trace_id
|
||
await client.send_message(reply)
|
||
|
||
|
||
async def ai_generate_outbound_reply(client, original_msg: dict, reply_content: str) -> str:
|
||
"""
|
||
强制全量 AI 出站生成层:
|
||
- 所有普通文本外发先由 AI 生成最终话术;
|
||
- 控制命令/纯链接/转接指令直接绕过。
|
||
"""
|
||
text = (reply_content or "").strip()
|
||
if not text:
|
||
return text
|
||
if text.startswith("话术|") or "[转移会话]" in text or "TRANSFER_REQUESTED" in text:
|
||
return text
|
||
if re.fullmatch(r"https?://\S+", text):
|
||
return text
|
||
if not client._force_ai_generate_reply or not client.enable_agent or not client.agent or not client.AgentDeps:
|
||
return text
|
||
try:
|
||
deps = client.AgentDeps(
|
||
msg_id=str(original_msg.get("msg_id", "") or "outbound_generate"),
|
||
acc_id=str(original_msg.get("acc_id", "") or ""),
|
||
from_id=str(original_msg.get("from_id", "") or ""),
|
||
platform=str(original_msg.get("acc_type", "") or ""),
|
||
)
|
||
customer_msg = client.to_chinese(str(original_msg.get("msg", "") or ""))
|
||
prompt = (
|
||
"你是淘宝客服外发文案生成器。请根据“回复意图草稿”生成最终发给客户的话。\n"
|
||
"要求:\n"
|
||
"1) 保留原意,不新增价格/承诺/流程;\n"
|
||
"2) 自然像真人聊天,不用固定模板句;\n"
|
||
"3) 1-2句;\n"
|
||
"4) 只输出最终回复文本。\n\n"
|
||
f"客户原话: {customer_msg}\n"
|
||
f"回复意图草稿: {text}\n"
|
||
)
|
||
result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
|
||
out = str(getattr(result, "output", "") or "").strip()
|
||
if not out:
|
||
return text
|
||
if out.startswith("话术|") or "[转移会话]" in out:
|
||
return text
|
||
client._activity_log(
|
||
"ai_generate_reply",
|
||
acc_id=str(original_msg.get("acc_id", "") or ""),
|
||
customer_id=str(original_msg.get("from_id", "") or ""),
|
||
draft=text[:160],
|
||
generated=out[:160],
|
||
)
|
||
return out
|
||
except Exception as e:
|
||
client._activity_log(
|
||
"ai_generate_reply_error",
|
||
acc_id=str(original_msg.get("acc_id", "") or ""),
|
||
customer_id=str(original_msg.get("from_id", "") or ""),
|
||
error=str(e),
|
||
)
|
||
return text
|
||
|
||
|
||
def colloquialize_outbound_reply(text: Any) -> Any:
|
||
"""统一外发口语化处理,避免机械话术。"""
|
||
if not isinstance(text, str):
|
||
return text
|
||
raw = text.strip()
|
||
if not raw:
|
||
return text
|
||
# 控制指令/转接命令不得改写
|
||
if raw.startswith("话术|") or "[转移会话]" in raw:
|
||
return text
|
||
# 纯链接不改
|
||
if re.fullmatch(r"https?://\S+", raw):
|
||
return text
|
||
|
||
out = raw
|
||
replacements = {
|
||
"我这边": "我这边",
|
||
"请您": "你",
|
||
"您好": "你好",
|
||
"稍后": "一会儿",
|
||
"可以的话": "可以的话",
|
||
"请稍等": "稍等哈",
|
||
"先不乱报价": "先不急着给你乱报",
|
||
"建议转人工评估更稳": "建议转人工看会更稳",
|
||
"统一报价": "一起报价",
|
||
"马上安排": "马上给你安排",
|
||
"确认我就安排": "你点头我就开做",
|
||
"收到,我看看哈": "收到,我先看下",
|
||
"收到,我找找刚才那几张": "收到,我把刚才那几张一起看下",
|
||
"这组图我这边暂时识别不稳定": "这组图我这边识别得不太稳",
|
||
"这组图我这边暂时识别异常": "这组图我这边刚才识别有点异常",
|
||
"你可以换一张更清晰的,我再给你准报价。": "你换张更清晰的发我,我再给你报准点。",
|
||
"你可以换清晰图再发我。": "你换张清晰点的再发我哈。",
|
||
"你可以稍后再发我。": "你晚点再发我也行。",
|
||
"收到付款,我马上安排处理,有需要第一时间联系您": "收到付款啦,我马上安排处理,有进展第一时间告诉你",
|
||
"亲,正在为您转接人工客服,请稍等~": "我这就给你转人工,稍等哈~",
|
||
}
|
||
for k, v in replacements.items():
|
||
out = out.replace(k, v)
|
||
return out
|
||
|
||
|
||
async def ai_guard_outbound_reply(client, original_msg: dict, reply_content: str) -> tuple[bool, str, str]:
|
||
"""
|
||
专用AI质检:发送前判断“这句是否该发”,可拦截或改写。
|
||
读取当前客户在当前店铺的完整对话上下文。
|
||
"""
|
||
text = (reply_content or "").strip()
|
||
if not text:
|
||
return False, "", "empty_reply"
|
||
if text.startswith("话术|") or "[转移会话]" in text:
|
||
return True, text, "command_bypass"
|
||
if not client._reply_guard_enabled or not client.enable_agent or not client.agent or not client.AgentDeps:
|
||
return True, text, "guard_disabled"
|
||
try:
|
||
from db.chat_log_db import get_conversation
|
||
import json as _json
|
||
import re as _re
|
||
|
||
acc_id = str(original_msg.get("acc_id", "") or "")
|
||
customer_id = str(original_msg.get("from_id", "") or "")
|
||
if not customer_id:
|
||
return True, text, "no_customer_id"
|
||
|
||
# 默认读取较大窗口,尽量覆盖完整上下文;可用环境变量继续放大。
|
||
try:
|
||
max_rows = max(50, int(os.getenv("AI_REPLY_GUARD_CONTEXT_ROWS", "500")))
|
||
except Exception:
|
||
max_rows = 500
|
||
rows = get_conversation(customer_id=customer_id, limit=max_rows) or []
|
||
shop_rows = [r for r in rows if str(r.get("acc_id", "") or "") == acc_id] if acc_id else rows
|
||
|
||
context_lines = []
|
||
for r in shop_rows:
|
||
role = "客" if (r.get("direction") == "in") else "服"
|
||
msg = client.to_chinese((r.get("message") or "").strip())
|
||
if msg:
|
||
context_lines.append(f"{role}:{msg}")
|
||
context_text = "\n".join(context_lines) if context_lines else "无历史"
|
||
|
||
deps = client.AgentDeps(
|
||
msg_id=str(original_msg.get("msg_id", "") or "reply_guard"),
|
||
acc_id=acc_id,
|
||
from_id=customer_id,
|
||
platform=str(original_msg.get("acc_type", "") or ""),
|
||
)
|
||
prompt = (
|
||
"你是淘宝客服回复质检器。目标:判断候选回复是否和上下文一致,是否会造成重复触发式答复。\n"
|
||
"必须检查:\n"
|
||
"1) 是否答非所问;\n"
|
||
"2) 是否重复说“马上报价/继续发图”但当前上下文不需要;\n"
|
||
"3) 是否与历史状态冲突;\n"
|
||
"4) 语气是否自然可直接发给客户。\n"
|
||
"若不合适,给可直接发送的一句改写。\n"
|
||
"只输出 JSON:{\"allow\":true/false,\"rewrite\":\"...\",\"reason\":\"...\"}\n\n"
|
||
f"完整上下文(当前店铺):\n{context_text}\n\n"
|
||
f"客户当前消息:{client.to_chinese(original_msg.get('msg', '') or '')}\n"
|
||
f"候选回复:{text}\n"
|
||
)
|
||
result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
|
||
raw = str(getattr(result, "output", "") or "").strip()
|
||
if not raw:
|
||
return True, text, "guard_empty_output"
|
||
m = _re.search(r"\{[\s\S]*\}", raw)
|
||
if not m:
|
||
return True, text, "guard_non_json"
|
||
obj = _json.loads(m.group(0))
|
||
allow = bool(obj.get("allow", True))
|
||
rewrite = str(obj.get("rewrite", "") or "").strip()
|
||
reason = str(obj.get("reason", "") or "").strip() or "guard_decision"
|
||
if allow:
|
||
return True, (rewrite or text), reason
|
||
if rewrite:
|
||
return True, rewrite, reason
|
||
return False, "", reason
|
||
except Exception as e:
|
||
return True, text, f"guard_error:{e}"
|