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}"