Files
tw/core/websocket_outbound_flow.py

286 lines
11 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.
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}"