diff --git a/core/websocket_agent_reply_flow.py b/core/websocket_agent_reply_flow.py new file mode 100644 index 0000000..ffff588 --- /dev/null +++ b/core/websocket_agent_reply_flow.py @@ -0,0 +1,53 @@ +import logging + +from utils.observability import build_trace_id +from core.websocket_brain_flow import decide_brain_action, execute_brain_action + +logger = logging.getLogger("cs_agent") + + +async def handle_agent_reply_flow(client, data: dict, *, workflow, shop_type_resolver): + """处理单条消息:统一走 Brain 决策 + 执行。""" + try: + msg_text = client.to_chinese(data.get("msg", "")) + customer_id = data.get("from_id", "") + trace_id = build_trace_id(data.get("acc_id", ""), customer_id, data.get("msg_id", ""), msg_text[:64]) + data["_trace_id"] = trace_id + shop_type = shop_type_resolver(data.get("acc_id", ""), client.to_chinese(data.get("goods_name", "") or "")) + + customer_msg = client._build_customer_message(data) + decision = await decide_brain_action( + client, + data, + customer_msg, + trace_id=trace_id, + msg_text=msg_text, + shop_type=shop_type, + ) + client._activity_log( + "brain_decision", + trace_id=trace_id, + acc_id=data.get("acc_id", ""), + customer_id=data.get("from_id", ""), + action=decision.action, + source=decision.source, + should_reply=bool(decision.should_reply), + need_transfer=bool(decision.need_transfer), + ) + await execute_brain_action( + client, + data, + decision=decision, + trace_id=trace_id, + msg_text=msg_text, + ) + + except Exception as e: + logger.error("Agent 处理失败: %s", e) + client._activity_log( + "agent_process_error", + trace_id=data.get("_trace_id", ""), + acc_id=data.get("acc_id", ""), + customer_id=data.get("from_id", ""), + error=str(e), + ) diff --git a/core/websocket_auto_quote_flow.py b/core/websocket_auto_quote_flow.py new file mode 100644 index 0000000..a15cd7d --- /dev/null +++ b/core/websocket_auto_quote_flow.py @@ -0,0 +1,100 @@ +import asyncio +import os +from typing import Any + + +def cancel_auto_quote_task(client, key: str, reason: str = ""): + task = client._auto_quote_tasks.get(key) + if task and not task.done(): + task.cancel() + client._activity_log("auto_quote_cancel", key=key, reason=reason or "unknown") + + +def build_auto_quote_signature(state: Any) -> str: + """为待报价内容生成稳定签名,用于避免同一批内容反复自动触发。""" + urls = list(getattr(state, "pending_image_urls", []) or []) + reqs = list(getattr(state, "pending_requirements", []) or []) + req_tail = reqs[-6:] if len(reqs) > 6 else reqs + return "||".join(urls) + "##" + "||".join(req_tail) + + +async def schedule_auto_quote(client, data: dict, *, shop_type_resolver): + """ + 智能兜底:客户发图后若长时间不再补充消息,自动触发一次报价,避免会话卡住。 + """ + if not client.enable_agent or not client.agent: + return + try: + shop_type = shop_type_resolver(data.get('acc_id', ''), client.to_chinese(data.get('goods_name', '') or '')) + if shop_type != "find_image": + return + cid = data.get('from_id', '') + key = client._customer_key(data) + state = client.agent._get_conversation_state(cid) + if not state or not getattr(state, "pending_image_urls", None): + cancel_auto_quote_task(client, key, reason="no_pending_images") + client._auto_quote_done_sig.pop(key, None) + return + if state.quote_phase not in {"collecting", "waiting_result"}: + return + current_sig = build_auto_quote_signature(state) + if current_sig and client._auto_quote_done_sig.get(key) == current_sig: + client._activity_log( + "auto_quote_skip_duplicate", + key=key, + pending_count=len(state.pending_image_urls), + ) + return + try: + idle_seconds = max(8, int(os.getenv("AUTO_QUOTE_IDLE_SECONDS", "18"))) + except Exception: + idle_seconds = 18 + + cancel_auto_quote_task(client, key, reason="reschedule") + + async def _delayed_auto_quote(capture_key: str, capture_data: dict, wait_s: int, capture_sig: str): + await asyncio.sleep(wait_s) + async with client._get_customer_lock(capture_key): + capture_cid = capture_data.get('from_id', '') + st = client.agent._get_conversation_state(capture_cid) + if not st or not st.pending_image_urls: + client._auto_quote_done_sig.pop(capture_key, None) + return + # 内容变化时,放弃旧触发(会在新一轮消息后重新调度)。 + if build_auto_quote_signature(st) != capture_sig: + return + # 标记本批次已自动触发,避免同内容循环“马上报价”。 + client._auto_quote_done_sig[capture_key] = capture_sig + # 直接置为可报价,走内部自动报价入口(不伪造客户语句)。 + client.agent._mark_quote_ready(st) + client.agent._sync_pending_quote_state(capture_cid, st) + client._activity_log( + "auto_quote_trigger", + key=capture_key, + pending_count=len(st.pending_image_urls), + wait_s=wait_s, + ) + notify_data = dict(capture_data) + notify_data["msg_id"] = "auto_quote_idle_trigger" + notify_data["msg"] = "__AUTO_QUOTE_INTERNAL_TRIGGER__" + notify_msg = client._build_customer_message(notify_data) + response = await client.agent.build_auto_quote_reply(st, notify_msg) + if response.should_reply and response.reply and not response.need_transfer: + await client.send_reply(capture_data, response.reply) + client._activity_log( + "auto_quote_sent", + key=capture_key, + reply=response.reply, + ) + + task = asyncio.create_task(_delayed_auto_quote(key, dict(data), idle_seconds, current_sig)) + client._auto_quote_tasks[key] = task + client._activity_log( + "auto_quote_scheduled", + key=key, + pending_count=len(state.pending_image_urls), + phase=state.quote_phase, + wait_s=idle_seconds, + ) + except Exception as e: + client._activity_log("auto_quote_schedule_error", error=str(e), key=client._customer_key(data)) diff --git a/core/websocket_brain_flow.py b/core/websocket_brain_flow.py new file mode 100644 index 0000000..b61b599 --- /dev/null +++ b/core/websocket_brain_flow.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import re +from dataclasses import dataclass +from typing import Any + +logger = logging.getLogger("cs_agent") + + +@dataclass +class BrainDecision: + action: str # reply | quote | transfer | noop + source: str + reply: str = "" + transfer_msg: str = "" + should_reply: bool = False + need_transfer: bool = False + payload: dict[str, Any] | None = None + + +def _extract_json_obj(text: str) -> dict[str, Any] | None: + if not text: + return None + m = re.search(r"\{[\s\S]*\}", text) + if not m: + return None + try: + return json.loads(m.group(0)) + except Exception: + return None + + +async def _ai_policy_brain_decide(client, data: dict, *, msg_text: str, shop_type: str) -> BrainDecision | None: + if not client.enable_agent or not client.agent or not client.AgentDeps: + return None + + acc_id = str(data.get("acc_id", "") or "") + customer_id = str(data.get("from_id", "") or "") + current_urls = client._extract_image_urls(msg_text) + recent_urls = client._collect_recent_image_urls(customer_id, acc_id, max_count=6) + key = client._customer_key(data) + pending_urls = client._pending_images.get(key) or [] + + try: + order_status = client._detect_order_status(msg_text) + has_image_url = client._msg_has_image_url(msg_text) + refers_images = client._msg_refers_images(msg_text) + is_price = client._msg_is_price_inquiry(msg_text) + is_req = client._msg_is_requirement(msg_text) + ext_contact = client._msg_requests_external_contact(msg_text) + except Exception: + order_status, has_image_url, refers_images, is_price, is_req, ext_contact = "", False, False, False, False, False + + deps = client.AgentDeps( + msg_id=str(data.get("msg_id", "") or "brain_policy"), + acc_id=acc_id, + from_id=customer_id, + platform=str(data.get("acc_type", "") or "AliWorkbench"), + ) + + prompt = ( + "你是淘宝客服系统的主决策Brain,只做决策,不要解释。\n" + "你必须根据历史规则和当前上下文,输出唯一动作。\n" + "可选动作 action: reply / quote / transfer / noop。\n" + "历史规则(完整继承):\n" + "1) 客户发图/补图:先自然承接,再根据上下文决定继续收集或报价;\n" + "2) 客户询价且有可用图片(当前或最近)时,优先 action=quote;\n" + "3) 若有 pending 图片且客户催报价/补充需求,优先 quote_mode=flush_pending;\n" + "4) 仅打招呼/短无意义文本:可 action=reply 简短承接,不要机械模板;\n" + "5) 索要外部联系方式(微信/QQ/手机号)时,不外呼,站内引导;\n" + "6) 订单已付款:可回执安排处理;未付款/待付款:提醒完成付款;\n" + "7) 地图/政治/高风险内容:谨慎,必要时 transfer 或拒绝性 reply;\n" + "8) 尺寸超限/不可做场景:给明确边界,不要胡乱承诺;\n" + "9) 客户没发图却问价:先承接,再引导发图;\n" + "10) 避免重复外发,避免同一句话反复说。\n" + "\n" + "quote_mode 可选: flush_pending / analyze_current_or_recent / collect_only\n" + "只输出 JSON:\n" + '{"action":"reply|quote|transfer|noop","reply":"","transfer_msg":"","quote_mode":"","reason":""}\n\n' + f"店铺类型: {shop_type}\n" + f"legacy_fast_quote_enabled: {str(bool(client._legacy_fast_quote_enabled)).lower()}\n" + f"客户原话: {msg_text}\n" + f"has_image_url: {has_image_url}\n" + f"current_image_urls_count: {len(current_urls)}\n" + f"recent_image_urls_count: {len(recent_urls)}\n" + f"pending_image_urls_count: {len(pending_urls)}\n" + f"refers_images: {refers_images}\n" + f"is_price_inquiry: {is_price}\n" + f"is_requirement: {is_req}\n" + f"requests_external_contact: {ext_contact}\n" + f"order_status: {order_status or 'none'}\n" + ) + + try: + result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[]) + raw = str(getattr(result, "output", "") or "").strip() + obj = _extract_json_obj(raw) + if not obj: + client._activity_log( + "brain_policy_parse_error", + acc_id=acc_id, + customer_id=customer_id, + raw=raw[:300], + ) + return None + + action = str(obj.get("action", "") or "").strip().lower() + reply = str(obj.get("reply", "") or "").strip() + transfer_msg = str(obj.get("transfer_msg", "") or "").strip() + quote_mode = str(obj.get("quote_mode", "") or "").strip().lower() + reason = str(obj.get("reason", "") or "").strip() + + payload: dict[str, Any] | None = None + if action == "quote": + mode = quote_mode or "analyze_current_or_recent" + if mode == "flush_pending": + payload = {"mode": "flush_pending", "key": key, "pre_reply": reply} + elif mode == "collect_only": + payload = {"mode": "collect_only", "pre_reply": reply} + else: + urls = current_urls or recent_urls + payload = {"mode": "analyze_urls", "urls": urls, "pre_reply": reply} + + decision = BrainDecision( + action=action if action in {"reply", "quote", "transfer", "noop"} else "noop", + source="brain_ai_policy", + reply=reply, + transfer_msg=transfer_msg, + should_reply=bool(reply), + need_transfer=(action == "transfer"), + payload=payload, + ) + client._activity_log( + "brain_policy_raw", + acc_id=acc_id, + customer_id=customer_id, + action=decision.action, + quote_mode=quote_mode, + reason=reason, + ) + return decision + except Exception as e: + client._activity_log( + "brain_policy_error", + acc_id=acc_id, + customer_id=customer_id, + error=str(e), + ) + return None + + +async def decide_brain_action(client, data: dict, customer_msg, *, trace_id: str, msg_text: str, shop_type: str) -> BrainDecision: + """统一主决策层:优先由 Brain AI 决策;失败时回退 Agent 默认决策。""" + ai_decision = await _ai_policy_brain_decide(client, data, msg_text=msg_text, shop_type=shop_type) + if ai_decision is not None: + return ai_decision + + # 回退:保持可用性 + logger.info("Agent 正在处理消息...") + client._activity_log( + "agent_process_start", + trace_id=trace_id, + acc_id=data.get("acc_id", ""), + customer_id=data.get("from_id", ""), + msg=msg_text, + ) + response = await client.agent.process_message(customer_msg) + client._activity_log( + "agent_process_done", + trace_id=trace_id, + acc_id=data.get("acc_id", ""), + customer_id=data.get("from_id", ""), + result="ok", + should_reply=bool(response.should_reply), + need_transfer=bool(response.need_transfer), + ) + if response.need_transfer: + return BrainDecision( + action="transfer", + source="fallback_agent", + reply=response.reply or "", + transfer_msg=response.transfer_msg or "", + should_reply=bool(response.should_reply), + need_transfer=True, + ) + if response.should_reply and response.reply: + return BrainDecision( + action="reply", + source="fallback_agent", + reply=response.reply, + should_reply=True, + need_transfer=False, + ) + return BrainDecision(action="noop", source="fallback_agent", should_reply=False, need_transfer=False) + + +async def execute_brain_action(client, data: dict, *, decision: BrainDecision, trace_id: str, msg_text: str): + """统一执行层:只执行标准动作。""" + customer_id = data.get("from_id", "") + + if customer_id: + client._touch_customer_last_contact(customer_id) + + if decision.action == "transfer": + logger.info("Agent 决定转接人工") + client._activity_log( + "agent_transfer", + trace_id=trace_id, + acc_id=data.get("acc_id", ""), + customer_id=data.get("from_id", ""), + transfer_msg=decision.transfer_msg, + ) + client._fire_and_forget( + client._post_tianwang_callback( + "message_processed", + data, + extra={ + "should_reply": bool(decision.should_reply), + "need_transfer": True, + "agent_reply": decision.reply or "", + "transfer_msg": decision.transfer_msg or "", + }, + ) + ) + await client.transfer_to_human(data, decision.transfer_msg) + client._push_chat_to_wechat_safe( + data=data, + customer_msg=msg_text, + reply_msg=decision.transfer_msg or "转接", + tag="转人工", + ) + return + + if decision.action == "reply": + text = (decision.reply or "").strip() + if not text: + return + await asyncio.sleep(0.6) + client._activity_log( + "agent_reply", + trace_id=trace_id, + acc_id=data.get("acc_id", ""), + customer_id=data.get("from_id", ""), + reply=text, + ) + await client.send_reply(data, text) + await client._maybe_schedule_auto_quote(data) + client._fire_and_forget( + client._post_tianwang_callback( + "message_processed", + data, + extra={ + "should_reply": True, + "need_transfer": False, + "agent_reply": text, + }, + ) + ) + client._push_chat_to_wechat_safe( + data=data, + customer_msg=msg_text, + reply_msg=text, + tag="正常AI回复", + ) + return + + if decision.action == "quote": + payload = decision.payload or {} + pre_reply = str(payload.get("pre_reply", "") or "").strip() + if pre_reply: + await client.send_reply(data, pre_reply) + mode = str(payload.get("mode", "") or "") + if mode == "flush_pending": + key = str(payload.get("key", "") or "") + if key: + await client._flush_pending_images(key, data) + elif mode == "analyze_urls": + urls = payload.get("urls") or [] + if isinstance(urls, list) and urls: + if len(urls) == 1: + asyncio.create_task(client._analyze_single_and_reply(data, urls[0])) + else: + asyncio.create_task(client._analyze_multi_and_reply(data, urls)) + else: + await client.send_reply(data, "你把要处理的图再发我一下,我马上给你看。") + else: + if not pre_reply: + await client.send_reply(data, "收到,我先看一下哈,稍等哈。") + return + + # noop + client._activity_log( + "agent_no_reply", + trace_id=trace_id, + acc_id=data.get("acc_id", ""), + customer_id=data.get("from_id", ""), + ) + client._fire_and_forget( + client._post_tianwang_callback( + "message_processed", + data, + extra={ + "should_reply": False, + "need_transfer": False, + "agent_reply": "", + }, + ) + ) diff --git a/core/websocket_callback_flow.py b/core/websocket_callback_flow.py new file mode 100644 index 0000000..e27e17c --- /dev/null +++ b/core/websocket_callback_flow.py @@ -0,0 +1,48 @@ +import os +from datetime import datetime +from typing import Any, Dict, Optional + + +async def post_tianwang_callback_flow(client, event: str, data: dict, extra: Optional[Dict[str, Any]] = None): + """将消息处理事件回调给天网。""" + if not client._tianwang_callback_url: + return + try: + import httpx + + trust_env = os.getenv("TIANWANG_CALLBACK_TRUST_ENV", "false").lower() in ("1", "true", "yes") + payload = { + "event": event, + "timestamp": datetime.now().isoformat(), + "agent_name": client._tianwang_agent_name, + "acc_id": str(data.get("acc_id", "") or ""), + "customer_id": str(data.get("from_id", "") or ""), + "customer_name": client.to_chinese(data.get("from_name", "") or data.get("cy_name", "")), + "msg_id": str(data.get("msg_id", "") or ""), + "msg_type": int(data.get("msg_type", 0) or 0), + "msg": client.to_chinese(data.get("msg", "") or ""), + "goods_name": client.to_chinese(data.get("goods_name", "") or ""), + "goods_order": client.to_chinese(data.get("goods_order", "") or ""), + } + if extra: + payload.update(extra) + async with httpx.AsyncClient(timeout=6, trust_env=trust_env) as http_client: + resp = await http_client.post(client._tianwang_callback_url, json=payload) + ok = 200 <= resp.status_code < 300 + client._activity_log( + "tianwang_callback", + result="ok" if ok else "http_error", + event_name=event, + status_code=resp.status_code, + acc_id=payload["acc_id"], + customer_id=payload["customer_id"], + ) + except Exception as e: + client._activity_log( + "tianwang_callback", + result="error", + event_name=event, + acc_id=str(data.get("acc_id", "") or ""), + customer_id=str(data.get("from_id", "") or ""), + error=str(e), + ) diff --git a/core/websocket_client.py b/core/websocket_client.py index b00867a..4ef551c 100755 --- a/core/websocket_client.py +++ b/core/websocket_client.py @@ -1,17 +1,89 @@ import asyncio -import websockets import json -import re -import logging -import random -import secrets -import time import hashlib from collections import deque -from datetime import datetime, timedelta -from pathlib import Path +from datetime import datetime from typing import Optional, Dict, Any, List -from utils.observability import emit_activity, build_trace_id +from utils.observability import emit_activity +from core.websocket_agent_reply_flow import handle_agent_reply_flow +from core.websocket_quote_flow import handle_single_image_quote, handle_multi_image_quote +from core.websocket_debounce_flow import ( + debounce_agent_reply, + pick_debounce_seconds, + guess_intent_for_debounce, + looks_like_requirement_text, + rand_between, + msg_has_image_url, + msg_refers_images, + extract_image_urls, + collect_recent_image_urls, +) +from core.websocket_auto_quote_flow import ( + cancel_auto_quote_task, + build_auto_quote_signature, + schedule_auto_quote, +) +from core.websocket_system_inquiry_flow import ( + load_system_inquiry_rules, + normalize_kw_list, + resolve_system_inquiry_policy, + match_system_inquiry, + handle_system_inquiry, +) +from core.websocket_transfer_flow import transfer_to_human_flow +from core.websocket_outbound_arbiter_flow import ( + normalize_reply_semantic_key, + classify_outbound_reply, + template_family, + outbound_arbiter, +) +from core.websocket_followup_flow import ( + unreplied_followup_loop, + scan_and_send_unreplied_followups, + compose_ai_scene_reply, +) +from core.websocket_outbound_flow import ( + send_reply_flow, + ai_generate_outbound_reply, + ai_guard_outbound_reply, + colloquialize_outbound_reply, +) +from core.websocket_runtime_flow import command_handler_flow, run_client_flow +from core.websocket_workflow_flow import workflow_agent_notify_flow, workflow_send_flow +from core.websocket_connection_flow import connect_flow, receive_messages_flow, handle_message_flow +from core.websocket_send_flow import send_text_flow, send_image_flow, send_message_flow +from core.websocket_callback_flow import post_tianwang_callback_flow +from core.websocket_customer_profile_flow import extract_and_save_customer_info_flow +from core.websocket_message_utils_flow import ( + is_transfer_msg, + pick_transfer_greeting, + is_shop_card, + extract_customer_text_from_shop_card_msg, + has_chat_history, + should_ignore, + get_msg_type_name, + to_chinese_text, +) +from core.websocket_dispatch_flow import dispatch_assign_once_flow +from core.websocket_image_entry_flow import handle_image_message_flow +from core.websocket_misc_rules_flow import ( + msg_is_price_inquiry, + detect_order_status, + msg_requests_external_contact, + extract_size_pairs_m, + oversize_reply_if_needed, +) +from core.websocket_summary_flow import save_conversation_summary_flow +from core.websocket_helpers_flow import ( + fire_and_forget, + prune_seen, + log_inbound_once, + log_outbound_once, + build_customer_message, + touch_customer_last_contact, + push_chat_to_wechat_safe, +) +from core.websocket_logger_setup import setup_logger # ========== 转接分组映射 ========== def _get_transfer_group(acc_id: str) -> str: @@ -25,91 +97,9 @@ def _get_transfer_group(acc_id: str) -> str: cfg = json.load(f) return cfg.get(acc_id, cfg.get("default", default_group)) except Exception: - pass + logger.debug("读取转接分组配置失败,使用默认分组", exc_info=True) return default_group -# ========== 日志配置(轮转:按大小 10MB,保留 7 份)========== -class _AnsiColorFormatter(logging.Formatter): - RESET = "\033[0m" - MESSAGE_TEXT_REPLACEMENTS = ( - ("[PROMPT->AI 前置提示词]", "[AI提示词]"), - ("[PROMPT->AI", "[AI提示词"), - ("[THINK/TOOL_CALL]", "[AI思考-工具调用]"), - ("[THINK/TOOL_RETURN]", "[AI思考-工具返回]"), - ("[THINK/RAW_OUTPUT]", "[AI思考-原始输出]"), - ("[REPLY->CUSTOMER]", "[AI回复客户]"), - ("[ACTIVITY]", "[活动日志]"), - ("[AI质检]", "[AI质检]"), - ) - # 业务消息类型颜色(优先于 level) - MESSAGE_COLOR_RULES = ( - ("[PROMPT->AI", "\033[94m"), # bright blue - ("[THINK/", "\033[96m"), # bright cyan - ("[REPLY->CUSTOMER]", "\033[92m"), # bright green - ("Agent 回复", "\033[92m"), # bright green - ("[ACTIVITY]", "\033[95m"), # bright magenta - ("[AI质检]", "\033[97m"), # bright white - ("收到新消息", "\033[36m"), # cyan - ("发送成功", "\033[32m"), # green - ("防抖等待", "\033[93m"), # bright yellow - ) - COLORS = { - logging.DEBUG: "\033[36m", # cyan - logging.INFO: "\033[32m", # green - logging.WARNING: "\033[33m", # yellow - logging.ERROR: "\033[31m", # red - logging.CRITICAL: "\033[35m", # magenta - } - - def __init__(self, fmt: str, datefmt: str | None = None, use_color: bool = True): - super().__init__(fmt=fmt, datefmt=datefmt) - self.use_color = use_color - - def format(self, record: logging.LogRecord) -> str: - msg = super().format(record) - if not self.use_color: - for old, new in self.MESSAGE_TEXT_REPLACEMENTS: - msg = msg.replace(old, new) - return msg - raw_msg = record.getMessage() - for old, new in self.MESSAGE_TEXT_REPLACEMENTS: - msg = msg.replace(old, new) - for key, color in self.MESSAGE_COLOR_RULES: - if key in raw_msg: - return f"{color}{msg}{self.RESET}" - color = self.COLORS.get(record.levelno, "") - if not color: - return msg - return f"{color}{msg}{self.RESET}" - - -def setup_logger(): - from logging.handlers import RotatingFileHandler - from config.config import LOG_DIR, LOG_MAX_BYTES, LOG_BACKUP_COUNT - logger = logging.getLogger("cs_agent") - if getattr(logger, "_cs_logger_configured", False): - return logger - logger.setLevel(logging.INFO) - # 避免同一日志既被 cs_agent 打印,又向 root logger 传播再打一遍。 - logger.propagate = False - fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S") - use_color = (os.getenv("LOG_COLOR", "1").lower() in ("1", "true", "yes")) and not bool(os.getenv("NO_COLOR")) - ch = logging.StreamHandler() - ch.setFormatter(_AnsiColorFormatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S", use_color=use_color)) - logger.addHandler(ch) - LOG_DIR.mkdir(exist_ok=True) - today = datetime.now().strftime("%Y-%m-%d") - fh = RotatingFileHandler( - LOG_DIR / f"chat_{today}.log", - maxBytes=LOG_MAX_BYTES, - backupCount=LOG_BACKUP_COUNT, - encoding="utf-8", - ) - fh.setFormatter(fmt) - logger.addHandler(fh) - logger._cs_logger_configured = True - return logger - import os logger = setup_logger() @@ -146,6 +136,8 @@ class QingjianAPIClient: self.reply_id = "tb001" # 回复时使用的from_id self.last_msg = None # 保存最后一条消息 self.enable_agent = enable_agent and AGENT_AVAILABLE + self.logger = logger + self.AgentDeps = AgentDeps self.agent = None self._replied_msg_ids: deque = deque(maxlen=200) # 已回复消息ID,FIFO去重 @@ -219,84 +211,11 @@ class QingjianAPIClient: ) async def _post_tianwang_callback(self, event: str, data: dict, extra: Optional[Dict[str, Any]] = None): - """将消息处理事件回调给天网。""" - if not self._tianwang_callback_url: - return - try: - import httpx - - trust_env = os.getenv("TIANWANG_CALLBACK_TRUST_ENV", "false").lower() in ("1", "true", "yes") - payload = { - "event": event, - "timestamp": datetime.now().isoformat(), - "agent_name": self._tianwang_agent_name, - "acc_id": str(data.get("acc_id", "") or ""), - "customer_id": str(data.get("from_id", "") or ""), - "customer_name": self.to_chinese(data.get("from_name", "") or data.get("cy_name", "")), - "msg_id": str(data.get("msg_id", "") or ""), - "msg_type": int(data.get("msg_type", 0) or 0), - "msg": self.to_chinese(data.get("msg", "") or ""), - "goods_name": self.to_chinese(data.get("goods_name", "") or ""), - "goods_order": self.to_chinese(data.get("goods_order", "") or ""), - } - if extra: - payload.update(extra) - async with httpx.AsyncClient(timeout=6, trust_env=trust_env) as client: - resp = await client.post(self._tianwang_callback_url, json=payload) - ok = 200 <= resp.status_code < 300 - self._activity_log( - "tianwang_callback", - result="ok" if ok else "http_error", - event_name=event, - status_code=resp.status_code, - acc_id=payload["acc_id"], - customer_id=payload["customer_id"], - ) - except Exception as e: - self._activity_log( - "tianwang_callback", - result="error", - event_name=event, - acc_id=str(data.get("acc_id", "") or ""), - customer_id=str(data.get("from_id", "") or ""), - error=str(e), - ) + await post_tianwang_callback_flow(self, event, data, extra=extra) async def connect(self): - """连接WebSocket服务器""" - while self.running: - try: - logger.info(f"[{self.get_time()}] 正在连接轻简API {self.uri}...") - async with websockets.connect(self.uri) as websocket: - self.websocket = websocket - from utils.health_check import set_qingjian_connected - set_qingjian_connected(True) - logger.info(f"[{self.get_time()}] 连接成功!") - if self.enable_agent: - logger.info(f"[{self.get_time()}] AI Agent 已启用,将自动处理消息") - logger.info(f"[{self.get_time()}] 等待接收消息...") - - # 持续接收消息 - await self.receive_messages() - - except ConnectionRefusedError: - from utils.health_check import set_qingjian_connected - set_qingjian_connected(False) - logger.info(f"[{self.get_time()}] 连接被拒绝,请检查轻简软件是否已启动") - except websockets.exceptions.InvalidURI: - from utils.health_check import set_qingjian_connected - set_qingjian_connected(False) - logger.info(f"[{self.get_time()}] URI格式错误") - except Exception as e: - from utils.health_check import set_qingjian_connected - set_qingjian_connected(False) - logger.info(f"[{self.get_time()}] 连接错误: {e}") - - # 等待5秒后重连 - if self.running: - logger.info(f"[{self.get_time()}] 5秒后尝试重连...") - await asyncio.sleep(5) + await connect_flow(self) def _customer_key(self, data: dict) -> str: """同一店铺+客户 = 同一会话""" @@ -331,334 +250,62 @@ class QingjianAPIClient: await self.agent_reply(data) def _fire_and_forget(self, coro): - """后台执行协程,不阻塞接收循环;异常会记录到日志""" - task = asyncio.create_task(coro) - - def _done(t): - if t.cancelled(): - return - exc = t.exception() - if exc: - logger.exception(f"后台任务异常: {exc}") - - task.add_done_callback(_done) + fire_and_forget(self, coro) @staticmethod def _prune_seen(seen: dict, now_mono: float, ttl_sec: float = 8.0): - if len(seen) <= 2000: - return - stale = [k for k, t in seen.items() if (now_mono - t) > ttl_sec] - for k in stale: - seen.pop(k, None) + prune_seen(seen, now_mono, ttl_sec=ttl_sec) def _log_inbound_once(self, data: dict): - """统一记录入站消息,短窗口去重,避免多分支重复写库。""" - try: - cid = data.get("from_id", "") - if not cid: - return - msg = self.to_chinese(data.get("msg", "") or "") - acc_id = data.get("acc_id", "") - mtype = int(data.get("msg_type", 0) or 0) - now_mono = time.monotonic() - sig = f"{acc_id}|{cid}|{mtype}|{msg}" - last = self._inbound_log_seen.get(sig, 0.0) - if (now_mono - last) < 2.0: - return - self._inbound_log_seen[sig] = now_mono - self._prune_seen(self._inbound_log_seen, now_mono, ttl_sec=8.0) - _chat_log( - cid, - msg, - "in", - customer_name=self.to_chinese(data.get("from_name", "") or data.get("cy_name", "")), - acc_id=acc_id, - platform=data.get("acc_type", ""), - msg_type=mtype, - ) - except Exception: - pass + log_inbound_once(self, data, _chat_log) def _log_outbound_once(self, original_msg: dict, reply_content: str): - """统一记录出站消息,短窗口去重,避免重复写库。""" - try: - cid = original_msg.get("from_id", "") - if not cid or not reply_content: - return - acc_id = original_msg.get("acc_id", "") - now_mono = time.monotonic() - sig = f"{acc_id}|{cid}|{reply_content}" - last = self._outbound_log_seen.get(sig, 0.0) - if (now_mono - last) < 2.0: - return - self._outbound_log_seen[sig] = now_mono - self._prune_seen(self._outbound_log_seen, now_mono, ttl_sec=8.0) - _chat_log( - cid, - reply_content, - "out", - customer_name=self.to_chinese(original_msg.get("from_name", "") or original_msg.get("cy_name", "")), - acc_id=acc_id, - platform=original_msg.get("acc_type", ""), - ) - except Exception: - pass + log_outbound_once(self, original_msg, reply_content, _chat_log) + + def _build_customer_message(self, data: dict) -> CustomerMessage: + return build_customer_message(self, data, CustomerMessage) + + def _touch_customer_last_contact(self, customer_id: str): + touch_customer_last_contact(self, customer_id, db) + + def _push_chat_to_wechat_safe( + self, + *, + data: dict, + customer_msg: str, + reply_msg: str, + tag: str, + goods_name: str = "", + ) -> None: + push_chat_to_wechat_safe( + self, + data=data, + customer_msg=customer_msg, + reply_msg=reply_msg, + tag=tag, + goods_name=goods_name, + ) @staticmethod def _normalize_reply_semantic_key(text: str) -> str: - """把回复归一化为语义键,用于去重。""" - s = (text or "").strip().lower() - if not s: - return "" - for w in ("哈", "呀", "哦", "呢", "啦", "咯", "亲"): - s = s.replace(w, "") - s = re.sub(r"[,。!?、,.!?::;\s~\-—_]+", "", s) - return s[:200] + return normalize_reply_semantic_key(text) @staticmethod def _classify_outbound_reply(text: str) -> str: - s = (text or "").strip() - if not s: - return "empty" - if any(k in s for k in ("报价", "总价", "多少钱", "多少", "马上给你报价", "先给你报")): - return "quote" - if any(k in s for k in ("继续发图", "发完", "发图", "把图发", "先看图")): - return "collect" - if any(k in s for k in ("在吗", "你好", "在的", "在呢")): - return "greeting" - if any(k in s for k in ("转人工", "转接", "转给")): - return "transfer" - if any(k in s for k in ("稍等", "我先看", "看一下", "看下")): - return "ack" - return "general" + return classify_outbound_reply(text) @staticmethod def _template_family(reply: str) -> str: - """识别高频模板家族,做疲劳抑制。""" - s = (reply or "").strip() - if not s: - return "" - if "需求我记上了" in s and "继续发图" in s: - return "collect_remind" - if ("这批图过一遍" in s or "收齐了" in s or "收好了" in s) and ("总价" in s or "报价" in s): - return "quote_defer" - if "图片收到了" in s and "继续发" in s: - return "collect_ack" - if "好嘞,你稍等下,我这边看一下" in s: - return "fallback_ack" - return "" + return template_family(reply) def _outbound_arbiter(self, original_msg: dict, reply_content: str, trace_id: str) -> tuple[bool, str]: - """ - 统一出站裁决层: - 1) 语义去重(相同语义短窗口不重复); - 2) 同类回复节流(同类话术短窗口不重复)。 - """ - key = f"{original_msg.get('acc_id', '')}:{original_msg.get('from_id', '')}" - now_mono = time.monotonic() - sem_key = self._normalize_reply_semantic_key(reply_content) - reply_class = self._classify_outbound_reply(reply_content) - try: - sem_window = max(30, int(os.getenv("AI_OUTBOUND_SEMANTIC_DEDUPE_SECONDS", "180"))) - except Exception: - sem_window = 180 - try: - class_window = max(20, int(os.getenv("AI_OUTBOUND_CLASS_DEDUPE_SECONDS", "90"))) - except Exception: - class_window = 90 - try: - template_window = max(120, int(os.getenv("AI_OUTBOUND_TEMPLATE_FATIGUE_SECONDS", "600"))) - except Exception: - template_window = 600 - - sem_bucket = self._outbound_semantic_seen.setdefault(key, {}) - cls_bucket = self._outbound_class_seen.setdefault(key, {}) - tpl_bucket = self._outbound_template_seen.setdefault(key, {}) - self._prune_seen(sem_bucket, now_mono, ttl_sec=max(sem_window * 2, 240)) - self._prune_seen(cls_bucket, now_mono, ttl_sec=max(class_window * 2, 180)) - self._prune_seen(tpl_bucket, now_mono, ttl_sec=max(template_window * 2, 1200)) - - if sem_key and (now_mono - sem_bucket.get(sem_key, 0.0)) < sem_window: - self._activity_log( - "outbound_arbiter_block", - trace_id=trace_id, - acc_id=original_msg.get("acc_id", ""), - customer_id=original_msg.get("from_id", ""), - reason="semantic_duplicate", - semantic_key=sem_key[:80], - reply_class=reply_class, - msg=reply_content, - ) - return False, "semantic_duplicate" - - template_family = self._template_family(reply_content) - if template_family and (now_mono - tpl_bucket.get(template_family, 0.0)) < template_window: - self._activity_log( - "outbound_arbiter_block", - trace_id=trace_id, - acc_id=original_msg.get("acc_id", ""), - customer_id=original_msg.get("from_id", ""), - reason="template_fatigue", - template_family=template_family, - msg=reply_content, - ) - return False, "template_fatigue" - - if reply_class in {"quote", "collect", "ack"} and (now_mono - cls_bucket.get(reply_class, 0.0)) < class_window: - self._activity_log( - "outbound_arbiter_block", - trace_id=trace_id, - acc_id=original_msg.get("acc_id", ""), - customer_id=original_msg.get("from_id", ""), - reason="class_duplicate", - reply_class=reply_class, - msg=reply_content, - ) - return False, "class_duplicate" - - if sem_key: - sem_bucket[sem_key] = now_mono - cls_bucket[reply_class] = now_mono - if template_family: - tpl_bucket[template_family] = now_mono - self._activity_log( - "outbound_arbiter_pass", - trace_id=trace_id, - acc_id=original_msg.get("acc_id", ""), - customer_id=original_msg.get("from_id", ""), - reply_class=reply_class, - template_family=template_family, - semantic_key=sem_key[:80] if sem_key else "", - ) - return True, "pass" + return outbound_arbiter(self, original_msg, reply_content, trace_id) async def _unreplied_followup_loop(self): - """ - 定时补偿:对“最后一条是客户消息且长时间未回复”的会话,补发一次自然跟进。 - """ - if not self.enable_agent or not self.agent: - return - while self.running: - try: - await asyncio.sleep(max(30, int(os.getenv("UNREPLIED_FOLLOWUP_SCAN_SECONDS", "90")))) - await self._scan_and_send_unreplied_followups() - except asyncio.CancelledError: - break - except Exception as e: - self._activity_log("unreplied_followup_loop_error", error=str(e)) + await unreplied_followup_loop(self) async def _scan_and_send_unreplied_followups(self): - from db import chat_log_db as cdb - try: - idle_minutes = max(5, int(os.getenv("UNREPLIED_FOLLOWUP_IDLE_MINUTES", "12"))) - max_age_minutes = max(idle_minutes, int(os.getenv("UNREPLIED_FOLLOWUP_MAX_AGE_MINUTES", "180"))) - followup_cd = max(300, int(os.getenv("UNREPLIED_FOLLOWUP_COOLDOWN_SECONDS", "3600"))) - limit = max(10, int(os.getenv("UNREPLIED_FOLLOWUP_LIMIT", "40"))) - except Exception: - idle_minutes, max_age_minutes, followup_cd, limit = 12, 180, 3600, 40 - - now = datetime.now() - window_start = (now - timedelta(minutes=max_age_minutes)).strftime("%Y-%m-%d %H:%M:%S") - conn = None - try: - conn = cdb._get_conn() - rows = conn.execute( - cdb._sql( - """ - SELECT acc_id, customer_id, MAX(id) AS last_id - FROM chat_logs - WHERE timestamp >= ? - GROUP BY acc_id, customer_id - ORDER BY MAX(id) DESC - LIMIT ? - """ - ), - (window_start, limit * 6), - ).fetchall() - sessions = [dict(r) for r in rows] - sent = 0 - for s in sessions: - if sent >= limit: - break - acc_id = str(s.get("acc_id", "") or "") - cid = str(s.get("customer_id", "") or "") - if not acc_id or not cid: - continue - ckey = f"{acc_id}:{cid}" - if not self._is_owned_by_this_worker(ckey): - continue - last = conn.execute( - cdb._sql( - """ - SELECT id, direction, message, timestamp, customer_name, acc_id, platform - FROM chat_logs - WHERE acc_id = ? AND customer_id = ? - ORDER BY id DESC - LIMIT 1 - """ - ), - (acc_id, cid), - ).fetchone() - if not last: - continue - last = dict(last) - if str(last.get("direction", "")) != "in": - continue - last_ts = last.get("timestamp") - if isinstance(last_ts, datetime): - last_dt = last_ts - else: - last_dt = datetime.strptime(str(last_ts)[:19], "%Y-%m-%d %H:%M:%S") - idle_s = (now - last_dt).total_seconds() - if idle_s < idle_minutes * 60 or idle_s > max_age_minutes * 60: - continue - now_mono = time.monotonic() - if (now_mono - self._unreplied_followup_sent.get(ckey, 0.0)) < followup_cd: - continue - # 避免对明显结束语/确认语再次打扰 - last_msg = str(last.get("message", "") or "").strip().lower() - if last_msg in {"好的", "好", "ok", "收到", "嗯", "哦"}: - continue - - followup = await self._compose_ai_scene_reply( - original_msg={ - "acc_id": acc_id, - "from_id": cid, - "from_name": self.to_chinese(last.get("customer_name", "") or cid), - "acc_type": str(last.get("platform", "") or "AliWorkbench"), - "msg": str(last.get("message", "") or ""), - }, - scene="unreplied_followup", - intent_hint="客户上一条消息还没接上,先自然承接并请对方补一句当前要处理的图或要求。", - fallback="刚看到你消息了,我在的。你把要处理的图或要求再发我一下,我马上接着看。", - ) - fake = { - "acc_id": acc_id, - "from_id": cid, - "from_name": self.to_chinese(last.get("customer_name", "") or cid), - "cy_id": cid, - "cy_name": self.to_chinese(last.get("customer_name", "") or cid), - "acc_type": str(last.get("platform", "") or "AliWorkbench"), - "msg": str(last.get("message", "") or ""), - "msg_type": 0, - } - await self.send_reply(fake, followup) - self._unreplied_followup_sent[ckey] = now_mono - sent += 1 - self._activity_log( - "unreplied_followup_sent", - acc_id=acc_id, - customer_id=cid, - idle_seconds=int(idle_s), - last_msg=str(last.get("message", "") or "")[:120], - reply=followup, - ) - finally: - try: - if conn: - conn.close() - except Exception: - pass + await scan_and_send_unreplied_followups(self) async def _compose_ai_scene_reply( self, @@ -668,415 +315,48 @@ class QingjianAPIClient: intent_hint: str, fallback: str, ) -> str: - """ - 场景化 AI 直接生成回复(不依赖固定模板)。 - """ - if not self.enable_agent or not self.agent or not AgentDeps: - return fallback - try: - deps = AgentDeps( - msg_id=str(original_msg.get("msg_id", "") or f"{scene}_gen"), - 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 = self.to_chinese(str(original_msg.get("msg", "") or "")) - prompt = ( - "你是淘宝客服,直接生成一条发给客户的话。\n" - f"场景: {scene}\n" - f"意图: {intent_hint}\n" - f"客户原话: {customer_msg}\n" - "要求: 1-2句,自然口语,不要模板腔,不要新增价格/承诺;只输出最终回复。\n" - ) - result = await self.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[]) - out = str(getattr(result, "output", "") or "").strip() - if not out: - return fallback - if out.startswith("话术|") or "[转移会话]" in out or "TRANSFER_REQUESTED" in out: - return fallback - self._activity_log( - "ai_scene_reply_generated", - acc_id=str(original_msg.get("acc_id", "") or ""), - customer_id=str(original_msg.get("from_id", "") or ""), - scene=scene, - generated=out[:160], - ) - return out - except Exception as e: - self._activity_log( - "ai_scene_reply_error", - acc_id=str(original_msg.get("acc_id", "") or ""), - customer_id=str(original_msg.get("from_id", "") or ""), - scene=scene, - error=str(e), - ) - return fallback - - async def receive_messages(self): - """持续接收消息""" - try: - async for message in self.websocket: - await self.handle_message(message) - - except websockets.exceptions.ConnectionClosed: - from utils.health_check import set_qingjian_connected - set_qingjian_connected(False) - logger.info(f"[{self.get_time()}] 连接已关闭") - except Exception as e: - from utils.health_check import set_qingjian_connected - set_qingjian_connected(False) - logger.info(f"[{self.get_time()}] 接收消息错误: {e}") - - async def handle_message(self, message): - """处理接收到的消息""" - try: - data = json.loads(message) - - # 多进程分片检查:确保同一客户只由一个 worker 处理 - customer_key = self._customer_key(data) - if not self._is_owned_by_this_worker(customer_key): - return - - timestamp = self.get_time() - - # 保存最后一条消息用于回复 - self.last_msg = data - - # 打印格式化的消息 - logger.info(f"\n{'='*50}") - logger.info(f"[{timestamp}] 收到新消息:") - logger.info(f"{'='*50}") - logger.info(f" 消息ID: {data.get('msg_id', 'N/A')}") - logger.info(f" 账号ID: {self.to_chinese(data.get('acc_id', 'N/A'))}") - logger.info(f" 发送者ID: {self.to_chinese(data.get('from_id', 'N/A'))}") - logger.info(f" 发送者名称: {self.to_chinese(data.get('from_name', 'N/A'))}") - logger.info(f" 会话ID: {self.to_chinese(data.get('cy_id', 'N/A'))}") - logger.info(f" 平台类型: {data.get('acc_type', 'N/A')}") - logger.info(f" 消息类型: {self.get_msg_type_name(data.get('msg_type', 0))}") - logger.info(f" 消息内容: {self.to_chinese(data.get('msg', 'N/A'))}") - - # 显示商品信息(如果有) - if data.get('goods_name'): - logger.info(f" 商品名称: {self.to_chinese(data.get('goods_name', ''))}") - if data.get('goods_order'): - logger.info(f" 订单信息: {self.to_chinese(data.get('goods_order', ''))}") - - logger.info(f"{'='*50}\n") - - # 消息去重:同一条消息不重复处理 - msg_id = data.get('msg_id', '') - if msg_id and msg_id in self._replied_msg_ids: - logger.info(f"重复消息,跳过: {msg_id}") - return - if msg_id: - self._replied_msg_ids.append(msg_id) # deque 自动淘汰最旧的 - - # 空消息/无效消息过滤(N/A 或关键字段全为空) - from_id = data.get('from_id', '') - acc_id = data.get('acc_id', '') - msg_body = data.get('msg', '') - if not from_id or from_id == 'N/A' or not acc_id or acc_id == 'N/A': - logger.info(f"[{self.get_time()}] 空消息跳过(from_id={from_id!r} acc_id={acc_id!r})") - return - self._log_inbound_once(data) - self._fire_and_forget(self._post_tianwang_callback("message_received", data)) - - # Gemini 店铺:不回复,直接跳过 - goods_name = self.to_chinese(data.get('goods_name', '') or '') - if _get_shop_type(acc_id, goods_name) == "gemini_api": - logger.info(f"[{self.get_time()}] Gemini 店铺消息,跳过") - try: - from utils.wechat_chat_log import push_chat_to_wechat - asyncio.create_task(push_chat_to_wechat( - customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), - customer_id=data.get('from_id', ''), - acc_id=data.get('acc_id', ''), - customer_msg=self.to_chinese(data.get('msg', '')), - reply_msg="", - goods_name=goods_name, - )) - except Exception: - pass - return - - # 使用 Agent 自动回复(仅处理文本消息) - if self.enable_agent: - msg_type = data.get('msg_type', 0) - if msg_type == 0: - if self._is_transfer_msg(data): - # 会话转交 → 主动打招呼 - logger.info(f"[{self.get_time()}] 收到转交消息,发送问候") - greeting = self._pick_transfer_greeting() - await self.send_reply(data, greeting) - try: - from utils.wechat_chat_log import push_chat_to_wechat - asyncio.create_task(push_chat_to_wechat( - customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), - customer_id=data.get('from_id', ''), - acc_id=data.get('acc_id', ''), - customer_msg=self.to_chinese(data.get('msg', '')), - reply_msg=greeting, - goods_name=self.to_chinese(data.get('goods_name', '') or ''), - )) - except Exception: - pass - elif self._is_shop_card(data): - # 进店卡片:有历史对话就不回复,没有才打招呼(Gemini 已在上面统一跳过) - cid = data.get('from_id', '') - acc_id = data.get('acc_id', '') - residual_text = self._extract_customer_text_from_shop_card_msg(data.get('msg', '')) - if residual_text: - logger.info(f"[{self.get_time()}] 进店卡片携带客户文本,转普通消息处理: {residual_text}") - patched = dict(data) - patched['msg'] = residual_text - await self._debounce_agent_reply(patched) - elif self._has_chat_history(cid, acc_id=acc_id): - logger.info(f"[{self.get_time()}] 进店卡片(已有记录),跳过") - else: - logger.info(f"[{self.get_time()}] 进店卡片(新客户),发送问候") - greeting = "在呢,发图来我看看" - await self.send_reply(data, greeting) - try: - from utils.wechat_chat_log import push_chat_to_wechat - asyncio.create_task(push_chat_to_wechat( - customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), - customer_id=data.get('from_id', ''), - acc_id=data.get('acc_id', ''), - customer_msg=self.to_chinese(data.get('msg', '')), - reply_msg=greeting, - goods_name=goods_name, - )) - except Exception: - pass - elif await self._handle_system_inquiry(data): - logger.info(f"[{self.get_time()}] 系统客服询单消息,已按规则处理") - elif self._should_ignore(data): - logger.info(f"[{self.get_time()}] 系统通知,跳过回复") - else: - await self._debounce_agent_reply(data) - elif msg_type == 1: - # 图片消息直接处理,不走防抖(图片不会连续多发) - await self.handle_image_message(data) - - except json.JSONDecodeError: - logger.info(f"[{timestamp}] 收到非JSON消息: {message}") - - async def _debounce_agent_reply(self, data: dict): - """ - 消息防抖:同一客户在 _DEBOUNCE_SECONDS 内的连续消息合并后再处理。 - 订单通知、付款相关消息不走防抖,立即处理。 - """ - msg_body = data.get('msg', '') - key = f"{data.get('acc_id','')}:{data.get('from_id','')}" - self._cancel_auto_quote_task(key, reason="new_inbound") - # 以下情况跳过防抖,立即处理(后台执行,不阻塞接收循环) - immediate_keywords = ["买家已付款", "已付款", "[系统订单信息]"] - if any(kw in msg_body for kw in immediate_keywords): - self._activity_log( - "debounce_bypass_immediate", - acc_id=data.get("acc_id", ""), - customer_id=data.get("from_id", ""), - reason="payment_or_order", - msg=msg_body, - ) - self._fire_and_forget(self._agent_reply_serialized(data)) - return - - # 积攒消息 - if key not in self._pending_msgs: - self._pending_msgs[key] = [] - self._pending_msgs[key].append(msg_body) - self._activity_log( - "debounce_enqueue", - key=key, - queue_size=len(self._pending_msgs[key]), - msg=msg_body, + return await compose_ai_scene_reply( + self, + original_msg=original_msg, + scene=scene, + intent_hint=intent_hint, + fallback=fallback, ) - # 取消上一个等待任务(如果有) - old_task = self._debounce_tasks.get(key) - if old_task and not old_task.done(): - old_task.cancel() + async def receive_messages(self): + await receive_messages_flow(self) - debounce_seconds = self._pick_debounce_seconds(data, msg_body) + async def handle_message(self, message): + await handle_message_flow(self, message, shop_type_resolver=_get_shop_type) - # 创建新的延迟处理任务 - async def _delayed(capture_key, capture_data, wait_s: float): - await asyncio.sleep(wait_s) - msgs = self._pending_msgs.pop(capture_key, []) - if not msgs: - return - if len(msgs) == 1: - merged_msg = msgs[0] - else: - merged_msg = "、".join(m for m in msgs if m.strip()) - logger.info(f"[{self.get_time()}] 防抖合并 {len(msgs)} 条消息: {merged_msg[:60]}") - self._activity_log( - "debounce_flush", - key=capture_key, - merged_count=len(msgs), - merged_msg=merged_msg, - ) - merged_data = dict(capture_data) - merged_data['msg'] = merged_msg - await self._agent_reply_serialized(merged_data) - - task = asyncio.create_task(_delayed(key, data, debounce_seconds)) - self._debounce_tasks[key] = task + async def _debounce_agent_reply(self, data: dict): + await debounce_agent_reply(self, data) @staticmethod def _rand_between(low: float, high: float) -> float: - if high <= low: - return float(low) - # 使用 secrets 增强随机性,避免固定周期导致机械感 - span = high - low - return round(low + span * (secrets.randbelow(1000) / 1000.0), 2) + return rand_between(low, high) def _guess_intent_for_debounce(self, msg: str) -> str: - text = (msg or "").strip() - if not text: - return "unknown" - if self._msg_has_image_url(text): - return "image" - try: - from utils.intent_analyzer import detect_intent - decision = detect_intent(text) - intent = decision.intent - if intent: - self._activity_log( - "debounce_intent_detected", - intent=intent, - source=decision.source, - score=round(float(decision.score or 0.0), 4), - msg=text[:120], - ) - except Exception: - intent = "" - if intent: - return intent - lower = text.lower() - if any(k in lower for k in ["报价", "多少钱", "价格", "贵", "优惠", "收费", "怎么收费", "咋收费"]): - return "询价" - if any(k in lower for k in ["做一下", "改一下", "需求", "门头", "上面的字", "处理"]): - return "修改" - if any(k in lower for k in ["在吗", "你好", "有人"]): - return "打招呼" - return "unknown" + return guess_intent_for_debounce(self, msg) @staticmethod def _looks_like_requirement_text(msg: str) -> bool: - text = (msg or "").strip().lower() - if not text: - return False - req_kw = ( - "做一下", "改一下", "处理一下", "这个字", "上面的字", "门头", "去背景", "抠图", - "换色", "调色", "清晰", "高清", "尺寸", "比例", "横版", "竖版", "排版", "改字", - "按这个做", "照这个做", "就这张", "看看做", "弄一下", - ) - return any(k in text for k in req_kw) + return looks_like_requirement_text(msg) def _pick_debounce_seconds(self, data: dict, msg: str) -> float: - """意图驱动防抖:不同意图不同等待区间,并引入轻微随机。""" - base = max(1.0, float(self._DEBOUNCE_SECONDS)) - if not self._adaptive_debounce_enabled: - return base - - intent = self._guess_intent_for_debounce(msg) - is_req = self._looks_like_requirement_text(msg) - has_img = self._msg_has_image_url(msg) - # 区间策略:越明确、越短消息,等待越短;需求描述类稍长 - if intent == "打招呼": - low, high = 1.0, min(3.0, base) - elif intent in ("询价", "砍价"): - # 询价先略等一会,给客户补发图片/需求的窗口,减少机械两连回 - low, high = 4.0, min(7.0, max(base, 7.0)) - elif intent == "image": - # 文本里直接贴图链接:短等合并上下文,避免和上一条询价并发 - low, high = 2.2, 4.2 - elif intent in ("修改", "批量"): - low, high = max(3.0, base * 0.65), min(18.0, base + 2.0) - elif intent == "转接": - low, high = 1.0, 2.5 - else: - low, high = max(2.0, base * 0.5), base - - # 发图后的需求描述,优先“多等一点”收集完整需求,减少半句回复 - # 约束到 12-14s,避免等待过长。 - if is_req and not has_img: - low = max(low, 12.0) - high = min(14.0, max(high, 12.6)) - - # 短句更快,长句稍慢,避免把连续半句拆开 - text_len = len((msg or "").strip()) - if text_len <= 4: - high = min(high, max(low + 0.2, 2.5)) - elif text_len >= 18: - low = min(high, low + 0.6) - - wait_s = self._rand_between(low, high) - logger.info(f"防抖等待 {wait_s}s | intent={intent} | len={text_len}") - return wait_s + return pick_debounce_seconds(self, data, msg) def _msg_has_image_url(self, msg: str) -> bool: - """判断文本消息里是否包含图片URL(客户粘贴了图片链接,可能带前缀文字如 有吗#*#https://...)""" - if not msg: - return False - lower = msg.lower() - image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp") - image_hosts = ("alicdn.com", "imgextra", "taobao.com", "jd.com", "pinduoduo.com") - if "http://" in lower or "https://" in lower: - if any(ext in lower for ext in image_exts) or any(h in lower for h in image_hosts): - return True - return False + return msg_has_image_url(msg) def _msg_refers_images(self, msg: str) -> bool: - """判断文本是否指代之前的图片(图一/图二/这张/那张/上面那张等)""" - if not msg: - return False - refs = ( - "图一", "图二", "第一张", "第二张", - "这张", "那张", "这图", "那个图", - "这个", "这个呢", - "上面那张", "下面那张", "刚才那张", "上一张", "下一张", - ) - return any(r in msg for r in refs) + return msg_refers_images(msg) def _extract_image_urls(self, msg: str) -> list: - if not msg: - return [] - parts = [p.strip() for p in msg.split("#*#") if p.strip()] - urls = [] - for p in parts: - if p.startswith("http://") or p.startswith("https://"): - urls.append(p) - if not urls and ("http://" in msg or "https://" in msg): - tokens = re.findall(r'(https?://\S+)', msg) - for t in tokens: - if any(ext in t.lower() for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]): - urls.append(t) - return urls[:8] + return extract_image_urls(msg) def _collect_recent_image_urls(self, customer_id: str, acc_id: str, max_count: int = 6) -> list: - """从最近对话中回溯收集图片URL(优先买家消息),用于慢发或引用图片的场景""" - urls, seen = [], set() - try: - from db.chat_log_db import get_recent_conversation - recent = get_recent_conversation(customer_id=customer_id, acc_id=acc_id, limit=20) - # 从最近到更早遍历,收集买家(in)消息中的图片链接 - for m in reversed(recent): - if m.get("direction") != "in": - continue - ms = m.get("message") or "" - us = self._extract_image_urls(ms) - for u in us: - if u not in seen: - seen.add(u) - urls.append(u) - if len(urls) >= max_count: - return urls - except Exception: - pass - return urls + return collect_recent_image_urls(self, customer_id, acc_id, max_count=max_count) def _msg_is_requirement(self, msg: str) -> bool: if not msg: @@ -1109,1010 +389,103 @@ class QingjianAPIClient: await self._analyze_multi_and_reply(data, urls) def _msg_is_price_inquiry(self, msg: str) -> bool: - """判断是否是价格询问""" - if not msg: - return False - patterns = ("多少钱", "多少一张", "一张多少钱", "画图多少", "报价", "给个价", "几块", "多少钱") - return any(p in msg for p in patterns) + return msg_is_price_inquiry(msg) def _detect_order_status(self, msg: str) -> str: - if not msg: - return "" - s = msg - if "买家已付款" in s or "已付款" in s: - return "paid" - if "[系统订单信息]" in s: - if "等待买家付款" in s or "未付款" in s: - return "waiting" - return "order" - return "" + return detect_order_status(msg) async def _analyze_single_and_reply(self, data: dict, url: str): - try: - from image.image_analyzer import image_analyzer - r = await image_analyzer.analyze(url) - if isinstance(r, dict) and r.get("success", False): - if r.get("feasibility") == "no" or r.get("risk") == "high": - note = str(r.get("note", "") or "") - if "文字内容过于密集" in note or "密集文字" in note: - reply = "这类文字太密的图我们这边不接单,抱歉哈。你要是简化后再发我可以继续看。" - else: - reply = "这张处理风险比较高,我这边先不直接接,建议转人工评估更稳。" - await self.send_reply(data, reply) - return - from config.config import MIN_PRICE_FLOOR - p = r.get("price_suggest", 20) - floor_dyn = r.get("price_min", MIN_PRICE_FLOOR) - floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR) - p = max(floor, round(p / 5) * 5) - try: - from db.customer_db import db as _db - _db.update_last_min_price(data.get('from_id',''), floor) - except Exception: - pass - reply = f"这张按{p}元,满意再拍" - else: - # 识别失败时不做兜底报价,避免把未识别图片误判为可做 - reply = "这张我这边暂时识别不稳定,先不乱报价。你可以换一张更清晰的,我再给你准报价。" - await self.send_reply(data, reply) - except Exception: - pass + await handle_single_image_quote(self, data, url) async def agent_reply(self, data: dict): """使用 Agent 处理消息并回复""" - try: - msg_text = self.to_chinese(data.get('msg', '')) - _cid = data.get('from_id', '') - trace_id = build_trace_id(data.get("acc_id", ""), _cid, data.get("msg_id", ""), msg_text[:64]) - data["_trace_id"] = trace_id - _name = self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')) - _plat = data.get('acc_type', '') - _shop_type = _get_shop_type(data.get('acc_id', ''), self.to_chinese(data.get('goods_name', '') or '')) - - # 超大尺寸(米制)直接拒单,避免进入报价/处理流程 - oversize_reply = self._oversize_reply_if_needed(msg_text) - if oversize_reply: - await self.send_reply(data, oversize_reply) - return - - # 找图/修图店铺:统一走 Agent 的“收集需求后统一报价”流程,避免按单图快速报价 - if self._legacy_fast_quote_enabled and _shop_type != "find_image": - # 消息含图片URL:累积到待处理列表,先询问要求 - if self._msg_has_image_url(msg_text): - urls = self._extract_image_urls(msg_text) - key = self._customer_key(data) - self._add_pending_images(key, urls) - await self.send_reply(data, "收到,我看看哈") - old = self._pending_image_tasks.get(key) - if old and not old.done(): - old.cancel() - async def _delay_flush(capture_key, capture_data): - await asyncio.sleep(self._DEBOUNCE_SECONDS + 4) - # 与同客户 agent_reply 串行,避免“延迟报价”和“当前追问”并发打架 - async with self._get_customer_lock(capture_key): - await self._flush_pending_images(capture_key, capture_data) - task = asyncio.create_task(_delay_flush(key, data)) - self._pending_image_tasks[key] = task - return - elif self._msg_refers_images(msg_text): - urls = self._collect_recent_image_urls(_cid, data.get('acc_id', ''), max_count=6) - if urls: - key = self._customer_key(data) - self._add_pending_images(key, urls) - await self.send_reply(data, "稍等,我找找刚才那几张") - await self._flush_pending_images(key, data) - return - else: - status = self._detect_order_status(msg_text) - if status == "paid": - ack = "收到付款,我马上安排处理,有需要第一时间联系您" - await self.send_reply(data, ack) - return - elif status in ("waiting", "order"): - ack = "订单我看到了哈,方便的话请完成付款,我好安排处理" - await self.send_reply(data, ack) - return - else: - urls = self._extract_image_urls(msg_text) - if len(urls) == 1: - key = self._customer_key(data) - self._add_pending_images(key, urls) - await self.send_reply(data, "收到,我看看哈") - return - else: - if self._msg_requests_external_contact(msg_text): - reply = "这里沟通就可以哦,其他联系方式不方便" - await self.send_reply(data, reply) - try: - from utils.wechat_chat_log import push_chat_to_wechat - asyncio.create_task(push_chat_to_wechat( - customer_name=_name, - customer_id=_cid, - acc_id=data.get('acc_id', ''), - customer_msg=msg_text, - reply_msg=reply, - goods_name=self.to_chinese(data.get('goods_name', '') or ''), - )) - except Exception: - pass - return - if self._msg_is_requirement(msg_text) or self._msg_is_price_inquiry(msg_text): - key = self._customer_key(data) - if self._pending_images.get(key): - old = self._pending_image_tasks.get(key) - if old and not old.done(): - old.cancel() - await self.send_reply(data, "稍等,我把刚才那几张一起看下") - await self._flush_pending_images(key, data) - return - if self._msg_is_price_inquiry(msg_text): - recent_urls = self._collect_recent_image_urls(_cid, data.get('acc_id', ''), max_count=6) - if recent_urls: - await self.send_reply(data, "稍等,我刚才那几张一起看下") - if len(recent_urls) == 1: - asyncio.create_task(self._analyze_single_and_reply(data, recent_urls[0])) - else: - asyncio.create_task(self._analyze_multi_and_reply(data, recent_urls)) - return - status = self._detect_order_status(msg_text) - if status == "paid": - ack = "收到付款,我马上安排处理,有需要第一时间联系您" - await self.send_reply(data, ack) - return - elif status in ("waiting", "order"): - ack = "订单我看到了哈,方便的话请完成付款,我好安排处理" - await self.send_reply(data, ack) - return - - # 构建 CustomerMessage - customer_msg = CustomerMessage( - msg_id=data.get('msg_id', ''), - acc_id=data.get('acc_id', ''), - msg=self.to_chinese(data.get('msg', '')), - from_id=data.get('from_id', ''), - from_name=self.to_chinese(data.get('from_name', '')), - cy_id=data.get('cy_id', ''), - acc_type=data.get('acc_type', ''), - msg_type=data.get('msg_type', 0), - cy_name=self.to_chinese(data.get('cy_name', '')), - goods_name=self.to_chinese(data.get('goods_name', '')) if data.get('goods_name') else None, - goods_order=self.to_chinese(data.get('goods_order', '')) if data.get('goods_order') else None - ) - - # 先检查是否是 workflow 等待确认中的回复(如邮箱、确认/不满意) - if workflow: - workflow_reply = await workflow.handle_customer_reply( - customer_id=data.get('from_id', ''), - message=self.to_chinese(data.get('msg', '')), - acc_id=data.get('acc_id', ''), - acc_type=data.get('acc_type', 'AliWorkbench') - ) - if workflow_reply: - logger.info(f"Workflow 回复: {workflow_reply}") - await self.send_reply(data, workflow_reply) - # 推送到企微:客户消息+回复成对 - try: - from utils.wechat_chat_log import push_chat_to_wechat - asyncio.create_task(push_chat_to_wechat( - customer_name=_name, - customer_id=_cid, - acc_id=data.get('acc_id', ''), - customer_msg=msg_text, - reply_msg=workflow_reply, - goods_name=self.to_chinese(data.get('goods_name', '') or ''), - )) - except Exception: - pass - return - - logger.info("Agent 正在处理消息...") - self._activity_log( - "agent_process_start", - trace_id=trace_id, - acc_id=data.get("acc_id", ""), - customer_id=data.get("from_id", ""), - msg=msg_text, - ) - - # 调用 Agent - _t0 = time.monotonic() - response = await self.agent.process_message(customer_msg) - self._activity_log( - "agent_process_done", - trace_id=trace_id, - acc_id=data.get("acc_id", ""), - customer_id=data.get("from_id", ""), - result="ok", - latency_ms=int((time.monotonic() - _t0) * 1000), - should_reply=bool(response.should_reply), - need_transfer=bool(response.need_transfer), - ) - - # 检查是否需要转接人工 - if response.need_transfer: - logger.info("Agent 决定转接人工") - self._activity_log( - "agent_transfer", - trace_id=trace_id, - acc_id=data.get("acc_id", ""), - customer_id=data.get("from_id", ""), - transfer_msg=response.transfer_msg, - ) - self._fire_and_forget(self._post_tianwang_callback( - "message_processed", - data, - extra={ - "should_reply": bool(response.should_reply), - "need_transfer": True, - "agent_reply": response.reply or "", - "transfer_msg": response.transfer_msg or "", - }, - )) - await self.transfer_to_human(data, response.transfer_msg) - # 推送到企微:客户消息+转接回复成对 - try: - from utils.wechat_chat_log import push_chat_to_wechat - asyncio.create_task(push_chat_to_wechat( - customer_name=_name, - customer_id=_cid, - acc_id=data.get('acc_id', ''), - customer_msg=msg_text, - reply_msg=response.transfer_msg or "转接", - goods_name=self.to_chinese(data.get('goods_name', '') or ''), - )) - except Exception: - pass - - # 联系方式提取已由 Agent 的 update_contact_info 工具负责 - # 此处仅做兜底:更新最后联系时间 - customer_id = data.get('from_id', '') - if customer_id: - try: - profile = db.get_customer(customer_id) - profile.last_contact = datetime.now().isoformat() - db.save_customer(profile) - except Exception: - pass - - # 保存对话摘要(异步,不阻塞回复) - if response.should_reply and response.reply and customer_id: - asyncio.create_task(self._save_conversation_summary( - customer_id=customer_id, - buyer_msg=self.to_chinese(data.get('msg', '')), - agent_reply=response.reply, - )) - - # 正常回复 - if response.should_reply and response.reply: - # 过滤 AI 误输出的"无需回复"类废话,避免发给客户 - nonsense_patterns = [ - "无需", "流程已完成", "不需要回复", "无需额外", "已完成", - "无需回复", "不需要额外", "已经完成", "无需再", "操作已完成", - "任务完成", "流程完成", "记录完成", "报价已", - ] - matched = [p for p in nonsense_patterns if p in response.reply] - if matched: - logger.warning(f"Agent 回复含无效内容,已拦截: {response.reply} ← 命中pattern: {matched}") - else: - # 模拟真人打字延迟,避免瞬间回复太机械 - await asyncio.sleep(0.8) - logger.info(f"Agent 回复: {response.reply}") - self._activity_log( - "agent_reply", - trace_id=trace_id, - acc_id=data.get("acc_id", ""), - customer_id=data.get("from_id", ""), - reply=response.reply, - ) - await self.send_reply(data, response.reply) - await self._maybe_schedule_auto_quote(data) - self._fire_and_forget(self._post_tianwang_callback( - "message_processed", - data, - extra={ - "should_reply": True, - "need_transfer": bool(response.need_transfer), - "agent_reply": response.reply, - }, - )) - # 推送到企微:客户消息+AI回复成对 - try: - from utils.wechat_chat_log import push_chat_to_wechat - asyncio.create_task(push_chat_to_wechat( - customer_name=_name, - customer_id=_cid, - acc_id=data.get('acc_id', ''), - customer_msg=msg_text, - reply_msg=response.reply, - goods_name=self.to_chinese(data.get('goods_name', '') or ''), - )) - except Exception: - pass - elif not response.need_transfer: - logger.info("Agent 决定不回复此消息") - self._activity_log( - "agent_no_reply", - trace_id=trace_id, - acc_id=data.get("acc_id", ""), - customer_id=data.get("from_id", ""), - ) - self._fire_and_forget(self._post_tianwang_callback( - "message_processed", - data, - extra={ - "should_reply": False, - "need_transfer": False, - "agent_reply": "", - }, - )) - - except Exception as e: - logger.error(f"Agent 处理失败: {e}") - self._activity_log( - "agent_process_error", - trace_id=data.get("_trace_id", ""), - acc_id=data.get("acc_id", ""), - customer_id=data.get("from_id", ""), - error=str(e), - ) + await handle_agent_reply_flow( + self, + data, + workflow=workflow, + shop_type_resolver=_get_shop_type, + ) def _cancel_auto_quote_task(self, key: str, reason: str = ""): - task = self._auto_quote_tasks.get(key) - if task and not task.done(): - task.cancel() - self._activity_log("auto_quote_cancel", key=key, reason=reason or "unknown") + cancel_auto_quote_task(self, key, reason=reason) @staticmethod def _build_auto_quote_signature(state: Any) -> str: - """为待报价内容生成稳定签名,用于避免同一批内容反复自动触发。""" - urls = list(getattr(state, "pending_image_urls", []) or []) - reqs = list(getattr(state, "pending_requirements", []) or []) - req_tail = reqs[-6:] if len(reqs) > 6 else reqs - return "||".join(urls) + "##" + "||".join(req_tail) + return build_auto_quote_signature(state) async def _maybe_schedule_auto_quote(self, data: dict): - """ - 智能兜底:客户发图后若长时间不再补充消息,自动触发一次报价,避免会话卡住。 - """ - if not self.enable_agent or not self.agent: - return - try: - shop_type = _get_shop_type(data.get('acc_id', ''), self.to_chinese(data.get('goods_name', '') or '')) - if shop_type != "find_image": - return - cid = data.get('from_id', '') - key = self._customer_key(data) - state = self.agent._get_conversation_state(cid) - if not state or not getattr(state, "pending_image_urls", None): - self._cancel_auto_quote_task(key, reason="no_pending_images") - self._auto_quote_done_sig.pop(key, None) - return - if state.quote_phase not in {"collecting", "waiting_result"}: - return - current_sig = self._build_auto_quote_signature(state) - if current_sig and self._auto_quote_done_sig.get(key) == current_sig: - self._activity_log( - "auto_quote_skip_duplicate", - key=key, - pending_count=len(state.pending_image_urls), - ) - return - try: - idle_seconds = max(8, int(os.getenv("AUTO_QUOTE_IDLE_SECONDS", "18"))) - except Exception: - idle_seconds = 18 - - self._cancel_auto_quote_task(key, reason="reschedule") - - async def _delayed_auto_quote(capture_key: str, capture_data: dict, wait_s: int, capture_sig: str): - await asyncio.sleep(wait_s) - async with self._get_customer_lock(capture_key): - capture_cid = capture_data.get('from_id', '') - st = self.agent._get_conversation_state(capture_cid) - if not st or not st.pending_image_urls: - self._auto_quote_done_sig.pop(capture_key, None) - return - # 内容变化时,放弃旧触发(会在新一轮消息后重新调度)。 - if self._build_auto_quote_signature(st) != capture_sig: - return - # 标记本批次已自动触发,避免同内容循环“马上报价”。 - self._auto_quote_done_sig[capture_key] = capture_sig - # 直接置为可报价,走内部自动报价入口(不伪造客户语句)。 - self.agent._mark_quote_ready(st) - self.agent._sync_pending_quote_state(capture_cid, st) - self._activity_log( - "auto_quote_trigger", - key=capture_key, - pending_count=len(st.pending_image_urls), - wait_s=wait_s, - ) - notify_msg = CustomerMessage( - msg_id="auto_quote_idle_trigger", - acc_id=capture_data.get('acc_id', ''), - msg="__AUTO_QUOTE_INTERNAL_TRIGGER__", - from_id=capture_cid, - from_name=self.to_chinese(capture_data.get('from_name', '') or capture_data.get('cy_name', '')), - cy_id=capture_data.get('cy_id', ''), - acc_type=capture_data.get('acc_type', ''), - msg_type=0, - cy_name=self.to_chinese(capture_data.get('cy_name', '') or capture_data.get('from_name', '')), - goods_name=self.to_chinese(capture_data.get('goods_name', '')) if capture_data.get('goods_name') else None, - goods_order=self.to_chinese(capture_data.get('goods_order', '')) if capture_data.get('goods_order') else None, - ) - response = await self.agent.build_auto_quote_reply(st, notify_msg) - if response.should_reply and response.reply and not response.need_transfer: - await self.send_reply(capture_data, response.reply) - self._activity_log( - "auto_quote_sent", - key=capture_key, - reply=response.reply, - ) - - task = asyncio.create_task(_delayed_auto_quote(key, dict(data), idle_seconds, current_sig)) - self._auto_quote_tasks[key] = task - self._activity_log( - "auto_quote_scheduled", - key=key, - pending_count=len(state.pending_image_urls), - phase=state.quote_phase, - wait_s=idle_seconds, - ) - except Exception as e: - self._activity_log("auto_quote_schedule_error", error=str(e), key=self._customer_key(data)) + await schedule_auto_quote(self, data, shop_type_resolver=_get_shop_type) async def _analyze_multi_and_reply(self, data: dict, urls: list): - try: - from image.image_analyzer import image_analyzer - def _detect_composite_request() -> bool: - try: - from db.chat_log_db import get_recent_conversation - recent = get_recent_conversation( - customer_id=data.get('from_id', ''), - acc_id=data.get('acc_id', ''), - limit=8 - ) - kw = ("抓到", "放到", "合成", "融合", "嵌到", "换到", "替换", "P到", "抠出来放到") - for m in recent: - msg = (m.get("message") or "") - if any(k in msg for k in kw): - return True - except Exception: - pass - return False - - tasks = [image_analyzer.analyze(u) for u in urls] - results = await asyncio.gather(*tasks, return_exceptions=True) - # 先做风险分流:多图中只要出现不可做/高风险,不进入报价 - unsafe = [] - dense_text_reject = [] - for i, r in enumerate(results, 1): - if isinstance(r, dict) and r.get("success", False): - if r.get("feasibility") == "no" or r.get("risk") == "high": - unsafe.append(f"图{i}") - note = str(r.get("note", "") or "") - if "文字内容过于密集" in note or "密集文字" in note: - dense_text_reject.append(f"图{i}") - - if unsafe: - if dense_text_reject and len(dense_text_reject) == len(unsafe): - reply = "这类文字太密的图我们这边不接单,抱歉哈。你要是简化后再发我可以继续看。" - else: - reply = f"这批里{'、'.join(unsafe)}处理风险较高,我这边先不直接接,建议转人工评估更稳。" - await self.send_reply(data, reply) - return - - pairs = [] - for u, r in zip(urls, results): - if isinstance(r, dict) and r.get("success", False): - from config.config import MIN_PRICE_FLOOR - floor_dyn = r.get("price_min", MIN_PRICE_FLOOR) - floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR) - ps = max(floor, round(r.get("price_suggest", 20) / 5) * 5) - pairs.append((u, ps, r.get("category", ""), r.get("megapixels", 0.0))) - try: - if pairs: - floors = [] - for u, r in zip(urls, results): - if isinstance(r, dict) and r.get("success", False): - floor_dyn = r.get("price_min", MIN_PRICE_FLOOR) - floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR) - floors.append(floor) - if floors: - from db.customer_db import db as _db - _db.update_last_min_price(data.get('from_id',''), min(floors)) - except Exception: - pass - if not pairs: - await self.send_reply(data, "这组图我这边暂时识别不稳定,先不乱报价。你可以换清晰图再发我。") - return - composite = _detect_composite_request() - composite_fee = 5 if composite else 0 - avg_raw = sum(p for _, p, _, _ in pairs) / len(pairs) - from config.config import MIN_PRICE_FLOOR - avg_price = max(MIN_PRICE_FLOOR, round((avg_raw + composite_fee) / 5) * 5) - top_price = max(MIN_PRICE_FLOOR, max(pairs, key=lambda x: x[1])[1] + composite_fee) - count = len(pairs) - if composite: - reply = f"这组{count}张我看了,按{avg_price}元一张;合成那张{top_price}元,满意再拍" - else: - reply = f"这组{count}张我看了,按{avg_price}元一张;复杂那张{top_price}元,满意再拍" - await self.send_reply(data, reply) - except Exception as e: - logger.error(f"多图分析失败: {e}") - try: - await self.send_reply(data, "这组图我这边暂时识别异常,先不乱报价。你可以稍后再发我。") - except Exception: - pass + await handle_multi_image_quote(self, data, urls) def _msg_requests_external_contact(self, msg: str) -> bool: - if not msg: - return False - lower = msg.lower() - kws = ("加qq", "qq号", "vx", "微信", "加v", "联系方式", "私聊", "加一下", "加个", "手机号", "电话", "加群", "q q", "v 信") - return any(k in lower for k in kws) + return msg_requests_external_contact(msg) @staticmethod def _extract_size_pairs_m(msg: str) -> list[tuple[float, float]]: - """提取消息中的米制尺寸对,如 15*6.4米 / 15米*6.4 / 15x6.4m。""" - if not msg: - return [] - s = (msg or "").lower().replace("×", "*").replace("x", "*") - pairs = [] - patterns = [ - r'(\d+(?:\.\d+)?)\s*\*\s*(\d+(?:\.\d+)?)\s*(?:米|m)\b', - r'(\d+(?:\.\d+)?)\s*(?:米|m)\s*\*\s*(\d+(?:\.\d+)?)\b', - ] - for p in patterns: - for m in re.findall(p, s): - try: - a = float(m[0]) - b = float(m[1]) - if a > 0 and b > 0: - pairs.append((a, b)) - except Exception: - continue - return pairs + return extract_size_pairs_m(msg) def _oversize_reply_if_needed(self, msg: str) -> str: - """ - 检测超大尺寸需求并返回拒绝话术;未命中返回空字符串。 - 规则:最长边 > 阈值 或 面积 > 阈值。 - """ - try: - from config.config import MAX_SERVICE_SIZE_LONGEST_METERS, MAX_SERVICE_SIZE_AREA_SQM - longest_limit = float(MAX_SERVICE_SIZE_LONGEST_METERS) - area_limit = float(MAX_SERVICE_SIZE_AREA_SQM) - except Exception: - longest_limit = 10.0 - area_limit = 20.0 - - pairs = self._extract_size_pairs_m(msg) - for w, h in pairs: - longest = max(w, h) - area = w * h - if longest > longest_limit or area > area_limit: - return ( - f"{w:g}米*{h:g}米这个尺寸太大了,我们这边做不了。" - "如果要做可以拆成几段小尺寸,我再给你按段评估。" - ) - return "" + return oversize_reply_if_needed(msg) def _is_transfer_msg(self, data: dict) -> bool: - """判断是否是会话转交消息(需要主动打招呼)""" - msg = self.to_chinese(data.get('msg', '')) - return '转交给' in msg or '转接给' in msg + return is_transfer_msg(self, data) def _pick_transfer_greeting(self) -> str: - """转接后问候话术:简短自然,随机避免机械感。""" - choices = [ - "在的亲,发图我看下", - "在呢亲,有需求直接说", - "我在的,您把要求发我", - "在的哈,你说我这边看着处理", - "在呢,图和需求发来我看看", - ] - return random.choice(choices) + return pick_transfer_greeting() def _is_shop_card(self, data: dict) -> bool: - """判断是否是进店卡片消息""" - msg = self.to_chinese(data.get('msg', '')) - return msg.startswith('[进店卡片]') or '我想咨询你们店的这个商品' in msg + return is_shop_card(self, data) def _extract_customer_text_from_shop_card_msg(self, msg: str) -> str: - """从“进店卡片+文本”混合消息里提取客户真实文本。""" - text = self.to_chinese(msg or "").strip() - if not text: - return "" - parts = [p.strip() for p in text.split("#*#") if p and p.strip()] - kept = [] - for part in parts: - if part.startswith("[进店卡片]") or "我想咨询你们店的这个商品" in part: - continue - kept.append(part) - if kept: - return " ".join(kept).strip() - stripped = re.sub(r"\[进店卡片\][^\n\r]*", "", text).strip() - stripped = stripped.replace("我想咨询你们店的这个商品", "").strip(",。,#* ") - return stripped + return extract_customer_text_from_shop_card_msg(self, msg) def _has_chat_history(self, customer_id: str, acc_id: str = "") -> bool: - """判断该客户在当前店铺是否已有聊天记录。""" - if not customer_id: - return False - # 按店铺+客户查数据库,避免跨店串历史导致错误跳过。 - try: - from db.chat_log_db import get_recent_conversation - msgs = get_recent_conversation(customer_id, acc_id=acc_id, limit=1) - return len(msgs) > 0 - except Exception: - return False + return has_chat_history(customer_id, acc_id=acc_id) def _load_system_inquiry_rules(self) -> Dict[str, Any]: - """加载系统客服询单规则(全局 + 店铺覆盖)。""" - from config.config import ( - SYSTEM_INQUIRY_ENABLED, - SYSTEM_INQUIRY_DEFAULT_ACTION, - SYSTEM_INQUIRY_DEFAULT_REPLY, - SYSTEM_INQUIRY_RULES_FILE, - ) - enabled_env = os.getenv("SYSTEM_INQUIRY_ENABLED") - enabled = ( - enabled_env.lower() in ("1", "true", "yes") - if isinstance(enabled_env, str) - else bool(SYSTEM_INQUIRY_ENABLED) - ) - action = (os.getenv("SYSTEM_INQUIRY_DEFAULT_ACTION") or SYSTEM_INQUIRY_DEFAULT_ACTION or "silent").strip().lower() - reply = os.getenv("SYSTEM_INQUIRY_DEFAULT_REPLY") or SYSTEM_INQUIRY_DEFAULT_REPLY or "" - rules_file = os.getenv("SYSTEM_INQUIRY_RULES_FILE") or str(SYSTEM_INQUIRY_RULES_FILE) - defaults: Dict[str, Any] = { - "enabled": bool(enabled), - "default_action": action, - "default_reply": reply, - "sender_keywords": ["系统客服", "官方客服", "平台客服", "机器人客服", "商家客服系统"], - "message_keywords": ["系统询单", "代客咨询", "平台代问", "系统代发", "客服询单"], - "shops": {}, - } - try: - p = Path(rules_file) - if p.exists(): - with p.open("r", encoding="utf-8") as f: - loaded = json.load(f) - if isinstance(loaded, dict): - defaults.update(loaded) - except Exception as e: - logger.warning(f"系统询单规则加载失败,使用默认规则: {e}") - return defaults + return load_system_inquiry_rules() @staticmethod def _normalize_kw_list(v: Any) -> List[str]: - if not isinstance(v, list): - return [] - return [str(x).strip().lower() for x in v if str(x).strip()] + return normalize_kw_list(v) def _resolve_system_inquiry_policy(self, acc_id: str) -> Dict[str, Any]: - """根据店铺合并系统询单策略。""" - from config.config import SYSTEM_INQUIRY_SHOPS - - rules = self._system_inquiry_rules or {} - if not bool(rules.get("enabled", True)): - return {"enabled": False} - - shops_env = os.getenv("SYSTEM_INQUIRY_SHOPS", SYSTEM_INQUIRY_SHOPS or "") - shop_whitelist = [s.strip() for s in shops_env.split(",") if s.strip()] - if shop_whitelist and (acc_id or "") not in shop_whitelist: - return {"enabled": False} - - policy: Dict[str, Any] = { - "enabled": True, - "action": str(rules.get("default_action", "silent")).strip().lower(), - "reply": str(rules.get("default_reply", "")).strip(), - "sender_keywords": self._normalize_kw_list(rules.get("sender_keywords")), - "message_keywords": self._normalize_kw_list(rules.get("message_keywords")), - } - shop_cfg = (rules.get("shops") or {}).get(acc_id or "", {}) - if isinstance(shop_cfg, dict): - if "enabled" in shop_cfg and not bool(shop_cfg.get("enabled", True)): - return {"enabled": False} - if shop_cfg.get("action"): - policy["action"] = str(shop_cfg.get("action")).strip().lower() - if shop_cfg.get("reply"): - policy["reply"] = str(shop_cfg.get("reply")).strip() - if isinstance(shop_cfg.get("sender_keywords"), list): - policy["sender_keywords"] = self._normalize_kw_list(shop_cfg.get("sender_keywords")) - if isinstance(shop_cfg.get("message_keywords"), list): - policy["message_keywords"] = self._normalize_kw_list(shop_cfg.get("message_keywords")) - if policy["action"] not in ("silent", "reply", "transfer"): - policy["action"] = "silent" - return policy + return resolve_system_inquiry_policy(self, acc_id) def _match_system_inquiry(self, data: dict, policy: Dict[str, Any]) -> bool: - """识别是否为系统客服询单消息。""" - if not policy.get("enabled", False): - return False - - from_name = self.to_chinese(data.get("from_name", "") or "").lower() - from_id = str(data.get("from_id", "") or "").lower() - msg = self.to_chinese(data.get("msg", "") or "").lower() - - sender_hits = 0 - for kw in policy.get("sender_keywords", []): - if kw and (kw in from_name or kw in from_id): - sender_hits += 1 - message_hits = 0 - for kw in policy.get("message_keywords", []): - if kw and kw in msg: - message_hits += 1 - - # 优先看发送者特征;纯文本命中时至少要求两个关键词,降低误判风险 - return sender_hits > 0 or message_hits >= 2 + return match_system_inquiry(self, data, policy) async def _handle_system_inquiry(self, data: dict) -> bool: - """命中系统询单后按策略处理。""" - acc_id = data.get("acc_id", "") - policy = self._resolve_system_inquiry_policy(acc_id) - if not self._match_system_inquiry(data, policy): - return False - - customer_id = data.get("from_id", "") - metrics_emit("system_inquiry_detected", customer_id=customer_id, acc_id=acc_id) - action = policy.get("action", "silent") - logger.info(f"系统询单命中 | 店铺:{acc_id} | 客户:{customer_id} | action:{action}") - - if action == "reply": - reply = await self._compose_ai_scene_reply( - original_msg=data, - scene="system_inquiry_reply", - intent_hint="这是系统客服询单消息,简短确认已收到并说明会跟进即可。", - fallback=(policy.get("reply") or "您好,这边已收到询单消息,稍后由人工客服跟进处理。"), - ) - await self.send_reply(data, reply) - metrics_emit("system_inquiry_auto_reply", customer_id=customer_id, acc_id=acc_id) - return True - if action == "transfer": - await self.transfer_to_human(data, "系统询单转人工") - metrics_emit("system_inquiry_transfer", customer_id=customer_id, acc_id=acc_id) - return True - - metrics_emit("system_inquiry_ignored", customer_id=customer_id, acc_id=acc_id) - return True + return await handle_system_inquiry(self, data) def _should_ignore(self, data: dict) -> bool: - """判断是否应该忽略该消息(不回复)""" - msg = self.to_chinese(data.get('msg', '')) - - # 会话转交由 _is_transfer_msg 单独处理,这里不再忽略 - ignore_patterns = [ - '已转接', - '接入会话', - '结束会话', - '会话已', - '[系统消息]', - '[系统通知]', - ] - for pattern in ignore_patterns: - if pattern in msg: - return True - - # 发送者是自己(店铺账号),避免回复自己发的消息 - acc_id = data.get('acc_id', '') - from_id = data.get('from_id', '') - if acc_id and from_id and acc_id == from_id: - return True - - return False + return should_ignore(self, data) def get_msg_type_name(self, msg_type): - """获取消息类型名称""" - types = { - 0: "文本", - 1: "图片", - 2: "视频", - 3: "文件" - } - return types.get(msg_type, f"未知({msg_type})") + return get_msg_type_name(msg_type) def _extract_and_save_customer_info(self, message: str, customer_id: str): - """从消息中提取客户信息并保存""" - if not message or not customer_id: - return - - # 提取邮箱 - email_pattern = r'[\w\.-]+@[\w\.-]+\.\w+' - email_match = re.search(email_pattern, message) - if email_match: - db.update_email(customer_id, email_match.group()) - - # 提取手机号 - phone_pattern = r'1[3-9]\d{9}' - phone_match = re.search(phone_pattern, message) - if phone_match: - db.update_phone(customer_id, phone_match.group()) - - # 提取微信号 - wechat_pattern = r'[Vv微信]+号[::]?\s*([\w-]+)' - wechat_match = re.search(wechat_pattern, message) - if wechat_match: - db.update_wechat(customer_id, wechat_match.group(1)) - - # 提取预算关键词 - budget_keywords = ['预算', '不超过', '最多', '便宜点', '便宜'] - for keyword in budget_keywords: - if keyword in message: - db.add_personality_tag(customer_id, "关注价格") - break - - # 提取性格关键词 - personality_keywords = { - '爽快': '爽快', - '干脆': '爽快', - '纠结': '纠结', - '墨迹': '纠结', - '砍价': '砍价', - '贵': '砍价' - } - for keyword, tag in personality_keywords.items(): - if keyword in message: - db.add_personality_tag(customer_id, tag) - - # 更新最后联系时间 - profile = db.get_customer(customer_id) - profile.last_contact = datetime.now().isoformat() - db.save_customer(profile) + extract_and_save_customer_info_flow(self, message, customer_id, db) def to_chinese(self, text): - """处理文本,安全地转换 unicode 转义""" - if not isinstance(text, str): - return text - if '\\u' not in text: - return text - try: - return json.loads(f'"{text}"') - except Exception: - return text + return to_chinese_text(text) async def handle_image_message(self, data: dict): - """ - 处理图片消息。 - 先回复"我找找",然后把图片URL作为消息内容交给 Agent(后台执行)。 - Agent 会自主调用 analyze_image() 工具分析复杂度,再报价。 - 整个过程由 Agent 自主协调,无需外部干预。 - 不阻塞接收循环,可同时接收其他客户消息。 - """ - # 立刻回复,让客户感觉真人在操作 - await self.send_reply(data, "我找找") - - # 把图片URL当作消息内容,交给 Agent 后台处理(图片分析约 12 秒,不阻塞新消息接收) - image_data = dict(data) - image_data['msg'] = f"[客户发来图片] {data.get('msg', '')}" - image_data['msg_type'] = 0 # 转为文本消息,让 agent_reply 处理 - self._fire_and_forget(self._agent_reply_serialized(image_data)) + await handle_image_message_flow(self, data) async def _dispatch_assign_once(self) -> Dict[str, Any]: - """ - 调用新的一键派单接口: - GET {DISPATCH_BASE_URL}/assign - Header: X-API-Key - """ - base_url = os.getenv("DISPATCH_BASE_URL", "http://1.12.50.92:8006").strip().rstrip("/") - api_key = os.getenv("DISPATCH_API_KEY", "tuhui_dispatch_key_2026").strip() - timeout_s = float(os.getenv("DISPATCH_TIMEOUT_SECONDS", "5")) - if not base_url or not api_key: - return {"success": False, "reason": "dispatch config missing"} - try: - import httpx - async with httpx.AsyncClient(timeout=timeout_s) as client: - resp = await client.get( - f"{base_url}/assign", - headers={"X-API-Key": api_key}, - ) - if resp.status_code != 200: - return {"success": False, "reason": f"http {resp.status_code}"} - data = resp.json() if resp.content else {} - ok = bool((data or {}).get("success", False)) - return { - "success": ok, - "task_id": str((data or {}).get("task_id", "") or ""), - "assigned_to": str((data or {}).get("assigned_to", "") or ""), - "online_count": int((data or {}).get("online_count", 0) or 0), - "notification_sent": bool((data or {}).get("notification_sent", False)), - "raw": data, - } - except Exception as e: - return {"success": False, "reason": str(e)} + return await dispatch_assign_once_flow(self) async def transfer_to_human(self, data: dict, transfer_msg: str = ""): - """ - 转接人工客服。 - 1. 优先调用 dispatch 服务 GET /assign 一键派单 - 2. 派单失败时,回退旧版 designer_roster 派单 - 3. 无人在线或未配置时,回退到 config/transfer_groups.json - 设计师在线状态:仅在转人工时按需查询,不轮询。 - """ - if not self.websocket: - logger.info(f"[{self.get_time()}] 错误: 未连接到服务器") - return - - acc_id = data.get("acc_id", "") - group_id = None - assigned_to = "" - dispatch_res = await self._dispatch_assign_once() - if dispatch_res.get("success"): - assigned_to = str(dispatch_res.get("assigned_to", "") or "").strip() - logger.info( - f"一键派单成功 | task_id={dispatch_res.get('task_id','')} | assigned_to={assigned_to or '未知'} | online_count={dispatch_res.get('online_count',0)}" - ) - metrics_emit( - "dispatch_assign_success", - acc_id=acc_id, - assigned_to=assigned_to, - online_count=dispatch_res.get("online_count", 0), - ) - else: - logger.warning(f"一键派单失败,回退旧派单逻辑: {dispatch_res.get('reason', 'unknown')}") - metrics_emit("dispatch_assign_failed", acc_id=acc_id) - - # 2. 派单失败时,回退旧版 designer_roster - if not dispatch_res.get("success"): - try: - from utils.designer_roster import poll_and_update_roster - from db.designer_roster_db import get_transfer_group_for_shop - await poll_and_update_roster() - group_id = get_transfer_group_for_shop(acc_id) - except Exception as e: - logger.debug(f"设计师派单未启用或异常: {e}") - - # 3. 无人在线时企微提醒(新旧两套都没拿到在线结果时) - online_count = int(dispatch_res.get("online_count", 0) or 0) - if online_count <= 0 and not group_id: - try: - from config.config import WECHAT_WEBHOOK - if WECHAT_WEBHOOK: - import httpx - async with httpx.AsyncClient(timeout=5) as client: - resp = await client.post(WECHAT_WEBHOOK, json={ - "msgtype": "text", - "text": {"content": "谁在线啊"} - }) - if resp.status_code != 200: - logger.warning(f"企微提醒发送失败: {resp.status_code} {resp.text}") - else: - logger.debug("未配置 WECHAT_WEBHOOK,跳过企微提醒") - except Exception as e: - logger.warning(f"企微提醒发送异常: {e}") - - # 4. 构造转接命令:有 assigned_to 用人名,否则回退分组 - if assigned_to: - cmd = f"正在为你转接人工|[转移会话],{assigned_to},无原因" - await self.send_reply(data, cmd) - logger.info(f"[{self.get_time()}] 已发送转接请求 (店铺:{acc_id or '未知'} -> 设计师:{assigned_to})") - return - - if not group_id: - group_id = _get_transfer_group(acc_id) - cmd = f"话术|[转移会话],分组{group_id},无原因" - await self.send_reply(data, cmd) - logger.info(f"[{self.get_time()}] 已发送转接请求 (店铺:{acc_id or '未知'} -> 分组:{group_id})") + await transfer_to_human_flow( + self, + data, + transfer_msg=transfer_msg, + transfer_group_resolver=_get_transfer_group, + ) async def _save_conversation_summary(self, customer_id: str, buyer_msg: str, agent_reply: str): - """用 AI 生成一句话对话摘要并持久化""" - try: - from db.customer_db import db - from openai import AsyncOpenAI - client = AsyncOpenAI( - api_key=self.agent.api_key if self.agent else None, - base_url=self.agent.base_url if self.agent else None, - ) - resp = await client.chat.completions.create( - model=self.agent.model_name if self.agent else "gpt-4o-mini", - messages=[ - {"role": "system", "content": "用一句话(15字以内)总结这段对话的核心内容,只输出摘要文字。"}, - {"role": "user", "content": f"买家:{buyer_msg}\n客服:{agent_reply}"}, - ], - max_tokens=30, - temperature=0.3, - ) - summary = resp.choices[0].message.content.strip() - db.save_conversation_summary(customer_id, summary) - except Exception: - pass # 摘要失败不影响主流程 + await save_conversation_summary_flow(self, customer_id, buyer_msg, agent_reply) async def _workflow_agent_notify( self, @@ -2121,43 +494,7 @@ class QingjianAPIClient: acc_type: str, system_hint: str, ): - """图片处理完成后,让客服 AI 生成自然话术发给客户""" - if not self.enable_agent or not self.agent: - return - try: - from core.pydantic_ai_agent import CustomerMessage - notify_msg = CustomerMessage( - msg_id="workflow_notify", - acc_id=acc_id, - msg=system_hint, - from_id=customer_id, - from_name="", - cy_id=customer_id, - acc_type=acc_type, - msg_type=0, - cy_name="", - ) - response = await self.agent.process_message(notify_msg) - if response.should_reply and response.reply: - nonsense_patterns = [ - "无需", "流程已完成", "不需要回复", "无需额外", "已完成", - "无需回复", "不需要额外", "已经完成", "无需再", "操作已完成", - "任务完成", "流程完成", "记录完成", "报价已", - ] - if not any(p in response.reply for p in nonsense_patterns): - # 构造一个虚拟原始消息用于 send_reply - fake_data = { - "acc_id": acc_id, - "from_id": customer_id, - "from_name": "", - "cy_id": customer_id, - "acc_type": acc_type, - } - await asyncio.sleep(0.5) - await self.send_reply(fake_data, response.reply) - logger.info(f"[Workflow] AI 通知已发送: {response.reply}") - except Exception as e: - logger.error(f"[Workflow] AI 通知生成失败: {e}") + await workflow_agent_notify_flow(self, customer_id, acc_id, acc_type, system_hint) async def _workflow_send( self, @@ -2167,524 +504,42 @@ class QingjianAPIClient: content: str, msg_type: int = 0 ): - """workflow 回调:图片AI完成后用此方法推送消息给客户""" - msg = { - "msg_id": "", - "acc_id": acc_id, - "msg": content, - "from_id": customer_id, - "from_name": customer_id, - "cy_id": customer_id, - "acc_type": acc_type, - "msg_type": msg_type, - "cy_name": customer_id - } - await self.send_message(msg) + await workflow_send_flow(self, customer_id, acc_id, acc_type, content, msg_type=msg_type) async def send_reply(self, original_msg, reply_content): - """ - 发送回复消息 - - Args: - original_msg: 收到的原始消息字典 - reply_content: 回复内容(文本或本地文件路径/http地址) - """ - trace_id = original_msg.get("_trace_id", "") - if not self.websocket: - logger.info(f"[{self.get_time()}] 错误: 未连接到服务器") - self._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 = self._colloquialize_outbound_reply(reply_content) - reply_content = await self._ai_generate_outbound_reply( - 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 = self._last_reply_sent_at.get(ckey, 0.0) - if (now_mono - last) < cooldown: - logger.info( - f"外发限流命中,跳过发送 | 客户:{ckey} | cooldown:{cooldown}s | msg:{str(reply_content)[:40]}" - ) - self._activity_log( - "send_reply_throttled", - trace_id=trace_id, - key=ckey, - cooldown_s=cooldown, - msg=str(reply_content), - ) - return - self._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 self._ai_guard_outbound_reply( - original_msg=original_msg, - reply_content=str(reply_content), - ) - self._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: - logger.info(f"回复被AI质检拦截: {guard_reason}") - return - reply_content = checked_reply or str(reply_content) - pass_send, arbiter_reason = self._outbound_arbiter( - original_msg=original_msg, - reply_content=reply_content, - trace_id=trace_id, - ) - if not pass_send: - logger.info(f"回复被统一裁决层拦截: {arbiter_reason}") - 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 - } - self._log_outbound_once(original_msg, str(reply_content)) - self._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 self.send_message(reply) + await send_reply_flow(self, original_msg, reply_content) async def _ai_generate_outbound_reply(self, 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 self._force_ai_generate_reply or not self.enable_agent or not self.agent or not AgentDeps: - return text - try: - deps = 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 = self.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 self.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 - self._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: - self._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 + return await ai_generate_outbound_reply(self, original_msg, reply_content) def _colloquialize_outbound_reply(self, 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) - - # 收尾语气柔化 - out = out.replace("。", "。") - return out + return colloquialize_outbound_reply(text) async def _ai_guard_outbound_reply(self, 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 self._reply_guard_enabled or not self.enable_agent or not self.agent or not AgentDeps: - return True, text, "guard_disabled" - try: - from db.chat_log_db import get_conversation - - 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 = self.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 "无历史" - if self._reply_guard_verbose: - logger.info( - "[AI质检] 启动 | customer=%s | acc=%s | context_rows=%s | candidate=%s", - customer_id, - acc_id, - len(shop_rows), - text[:120], - ) - - deps = 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"客户当前消息:{self.to_chinese(original_msg.get('msg', '') or '')}\n" - f"候选回复:{text}\n" - ) - result = await self.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" - import json as _json - import re as _re - - 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 self._reply_guard_verbose: - logger.info( - "[AI质检] 结果 | allow=%s | reason=%s | rewrite=%s", - allow, - reason, - (rewrite or "")[:160], - ) - if allow: - return True, (rewrite or text), reason - if rewrite: - return True, rewrite, reason - return False, "", reason - except Exception as e: - logger.warning("[AI质检] 异常,降级放行: %s", e) - return True, text, f"guard_error:{e}" + return await ai_guard_outbound_reply(self, original_msg, reply_content) async def send_text(self, cy_id, acc_type, content): - """ - 主动发送文本消息 - - Args: - cy_id: 会话ID(对方ID) - acc_type: 平台类型 - content: 消息内容 - """ - message = { - "msg_id": "", - "acc_id": "", - "msg": content, - "from_id": self.reply_id, - "from_name": self.reply_id, - "cy_id": cy_id, - "acc_type": acc_type, - "msg_type": 0, - "cy_name": "" - } - await self.send_message(message) + await send_text_flow(self, cy_id, acc_type, content) async def send_image(self, cy_id, acc_type, image_path): - """ - 主动发送图片消息 - - Args: - cy_id: 会话ID(对方ID) - acc_type: 平台类型 - image_path: 图片本地路径或http地址 - """ - message = { - "msg_id": "", - "acc_id": "", - "msg": image_path, - "from_id": self.reply_id, - "from_name": self.reply_id, - "cy_id": cy_id, - "acc_type": acc_type, - "msg_type": 1, - "cy_name": "" - } - await self.send_message(message) + await send_image_flow(self, cy_id, acc_type, image_path) async def send_message(self, message): - """发送消息到服务器""" - if self.websocket and self.websocket.state == websockets.protocol.State.OPEN: - try: - msg_json = json.dumps(message, ensure_ascii=False) - await self.websocket.send(msg_json) - pretty = json.dumps(message, ensure_ascii=False, indent=2) - logger.info(f"[{self.get_time()}] 发送成功:\n{pretty}") - self._activity_log( - "send_message_success", - trace_id=message.get("_trace_id", ""), - acc_id=message.get("acc_id", ""), - customer_id=message.get("from_id", ""), - msg_type=message.get("msg_type", 0), - msg=message.get("msg", ""), - ) - except Exception as e: - logger.info(f"[{self.get_time()}] 发送失败: {e}") - self._activity_log( - "send_message_error", - trace_id=message.get("_trace_id", ""), - acc_id=message.get("acc_id", ""), - customer_id=message.get("from_id", ""), - error=str(e), - ) - else: - logger.info(f"[{self.get_time()}] 错误: 连接未打开") - self._activity_log( - "send_message_skipped", - trace_id=message.get("_trace_id", ""), - reason="socket_not_open", - acc_id=message.get("acc_id", ""), - customer_id=message.get("from_id", ""), - ) + await send_message_flow(self, message) async def auto_reply(self, data): """自动回复示例(已弃用,使用 agent_reply 替代)""" pass async def command_handler(self): - """命令行交互""" - logger.info("\n命令帮助:") - logger.info(" reply <内容> - 回复最后一条消息") - logger.info(" text <平台> <内容> - 发送文本消息") - logger.info(" img <平台> <路径> - 发送图片") - logger.info(" setid - 设置回复ID") - logger.info(" agent on/off - 开启/关闭 Agent") - logger.info(" exit/quit - 退出\n") - - while self.running: - try: - loop = asyncio.get_running_loop() - user_input = await loop.run_in_executor(None, input, "") - - parts = user_input.strip().split(maxsplit=1) - if not parts: - continue - - cmd = parts[0].lower() - - if cmd in ["exit", "quit", "q"]: - logger.info(f"[{self.get_time()}] 正在关闭...") - self.running = False - if self.websocket: - await self.websocket.close() - break - - elif cmd == "setid" and len(parts) > 1: - self.reply_id = parts[1] - logger.info(f"[{self.get_time()}] 回复ID已设置为: {self.reply_id}") - - elif cmd == "agent" and len(parts) > 1: - if parts[1].lower() == "on": - self.enable_agent = True - logger.info(f"[{self.get_time()}] Agent 已开启") - elif parts[1].lower() == "off": - self.enable_agent = False - logger.info(f"[{self.get_time()}] Agent 已关闭") - - elif cmd == "reply" and len(parts) > 1: - if self.last_msg: - await self.send_reply(self.last_msg, parts[1]) - else: - logger.info(f"[{self.get_time()}] 错误: 还没有收到任何消息") - - elif cmd == "text" and len(parts) > 1: - # text cy_id acc_type content - args = parts[1].split(maxsplit=2) - if len(args) >= 3: - await self.send_text(args[0], args[1], args[2]) - else: - logger.info(f"[{self.get_time()}] 格式: text <内容>") - - elif cmd == "img" and len(parts) > 1: - # img cy_id acc_type image_path - args = parts[1].split(maxsplit=2) - if len(args) >= 3: - await self.send_image(args[0], args[1], args[2]) - else: - logger.info(f"[{self.get_time()}] 格式: img <图片路径>") - - else: - logger.info(f"[{self.get_time()}] 未知命令: {cmd}") - - except Exception as e: - logger.info(f"[{self.get_time()}] 命令错误: {e}") + await command_handler_flow(self) def get_time(self): """获取当前时间字符串""" return datetime.now().strftime("%H:%M:%S") async def run(self): - """运行客户端""" - tasks = [self.connect(), self.command_handler()] - - # 启动邮件接收后台任务 - try: - from mail.email_receiver import email_receiver - if email_receiver.username: - logger.info(f"[{self.get_time()}] 邮件接收已启动,监控: {email_receiver.username}") - tasks.append(email_receiver.start()) - else: - logger.info(f"[{self.get_time()}] 未配置邮件账号,跳过邮件接收") - except Exception as e: - logger.info(f"[{self.get_time()}] 邮件接收模块加载失败: {e}") - - # 启动每日汇总定时任务 - try: - from utils.daily_summary import scheduler as daily_scheduler - tasks.append(daily_scheduler()) - logger.info(f"[{self.get_time()}] 每日日报定时任务已启动") - except Exception as e: - logger.info(f"[{self.get_time()}] 日报模块加载失败: {e}") - - # 设计师在线状态:转人工时按需查询,不再轮询 - - # 启动健康检查(轻简/企微断线告警) - try: - from utils.health_check import health_check_loop - def _qingjian_ok(): - return self.websocket is not None and not getattr(self.websocket, "closed", True) - tasks.append(health_check_loop(_qingjian_ok)) - logger.info(f"[{self.get_time()}] 健康检查已启动") - except Exception as e: - logger.info(f"[{self.get_time()}] 健康检查模块加载失败: {e}") - - # 每天早上8点发送启动消息到企微群 - try: - from utils.wechat_chat_log import morning_startup_scheduler - tasks.append(morning_startup_scheduler()) - logger.info(f"[{self.get_time()}] 早8点企微启动消息已启动") - except Exception as e: - logger.info(f"[{self.get_time()}] 企微启动消息模块加载失败: {e}") - - # 未回复会话补偿(可关闭) - if os.getenv("UNREPLIED_FOLLOWUP_ENABLED", "true").lower() in ("1", "true", "yes"): - tasks.append(self._unreplied_followup_loop()) - logger.info(f"[{self.get_time()}] 未回复会话补偿任务已启动") - - await asyncio.gather(*tasks) + await run_client_flow(self) if __name__ == "__main__": @@ -2699,94 +554,3 @@ if __name__ == "__main__": except KeyboardInterrupt: logger.info("\n已停止") - - async def _load_task_modules(self): - """延迟加载任务模块,避免循环导入""" - from core.task_scheduler import get_task_scheduler - from core.task_trigger import get_trigger_engine - from db.task_db.task_model import get_task_manager - self.trigger_engine = get_trigger_engine() - -async def check_and_trigger_tasks(self, data: dict): - """检查并触发匹配的任务""" - try: - customer_key = self._customer_key(data) - customer_id = data.get('from_id') - message = data.get('content', '') - - # 获取该客户的待触发任务 - pending_tasks = self.task_manager.get_pending_tasks(customer_id) - - for task in pending_tasks: - trigger = { - 'type': task['trigger_type'], - 'keyword': task['trigger_keyword'], - 'keywords': task['trigger_keywords'] - } - - # 检查是否匹配触发条件 - if self.task_scheduler.check_trigger_match(message, trigger): - logger.info(f"任务触发条件匹配:{task['task_id']}") - - # 异步执行任务 - asyncio.create_task(self.task_scheduler.execute_task(task)) - - except Exception as e: - logger.error(f"检查任务触发失败:{e}") - - - async def _load_task_modules(self): - """延迟加载任务模块,避免循环导入""" - from core.task_scheduler import get_task_scheduler - from core.task_trigger import get_trigger_engine - from db.task_db.task_model import get_task_manager - self.trigger_engine = get_trigger_engine() - -async def _load_task_modules(self): - """延迟加载任务模块""" - if self.task_scheduler is None: - from core.task_scheduler import get_task_scheduler - from core.task_trigger import get_trigger_engine - from db.task_db.task_model import get_task_manager - self.trigger_engine = get_trigger_engine() - -async def check_and_trigger_tasks_v2(self, data: dict): - """增强版:检查并触发匹配的任务(支持指定客户)""" - # 确保任务模块已加载 - await self._load_task_modules() - try: - customer_key = self._customer_key(data) - customer_id = data.get('from_id') - customer_name = data.get('from_name') - message = data.get('content', '') - - # 准备上下文 - context = { - 'customer_id': customer_id, - 'customer_name': customer_name, - 'acc_id': data.get('acc_id') - } - - # 获取该客户的待触发任务 - pending_tasks = self.task_manager.get_pending_tasks(customer_id) - - for task in pending_tasks: - trigger = { - 'type': task['trigger_type'], - 'keyword': task['trigger_keyword'], - 'keywords': task['trigger_keywords'], - # 指定客户相关字段 - 'customer_id': task.get('specified_customer_id'), - 'customer_name': task.get('specified_customer_name') - } - - # 使用触发引擎检查是否匹配 - if self.trigger_engine.check_trigger(message, trigger, context): - logger.info(f"任务触发条件匹配:{task['task_id']} (客户:{customer_name}/{customer_id})") - - # 异步执行任务 - asyncio.create_task(self.task_scheduler.execute_task(task)) - - except Exception as e: - logger.error(f"检查任务触发失败:{e}") - diff --git a/core/websocket_connection_flow.py b/core/websocket_connection_flow.py new file mode 100644 index 0000000..e4de8b0 --- /dev/null +++ b/core/websocket_connection_flow.py @@ -0,0 +1,57 @@ +import asyncio +import websockets + + +async def connect_flow(client): + """连接 WebSocket 服务器并自动重连。""" + while client.running: + try: + client.logger.info(f"[{client.get_time()}] 正在连接轻简API {client.uri}...") + async with websockets.connect(client.uri) as websocket: + client.websocket = websocket + from utils.health_check import set_qingjian_connected + set_qingjian_connected(True) + client.logger.info(f"[{client.get_time()}] 连接成功!") + if client.enable_agent: + client.logger.info(f"[{client.get_time()}] AI Agent 已启用,将自动处理消息") + client.logger.info(f"[{client.get_time()}] 等待接收消息...") + + await client.receive_messages() + + except ConnectionRefusedError: + from utils.health_check import set_qingjian_connected + set_qingjian_connected(False) + client.logger.info(f"[{client.get_time()}] 连接被拒绝,请检查轻简软件是否已启动") + except websockets.exceptions.InvalidURI: + from utils.health_check import set_qingjian_connected + set_qingjian_connected(False) + client.logger.info(f"[{client.get_time()}] URI格式错误") + except Exception as e: + from utils.health_check import set_qingjian_connected + set_qingjian_connected(False) + client.logger.info(f"[{client.get_time()}] 连接错误: {e}") + + if client.running: + client.logger.info(f"[{client.get_time()}] 5秒后尝试重连...") + await asyncio.sleep(5) + + +async def receive_messages_flow(client): + """持续接收消息。""" + try: + async for message in client.websocket: + await client.handle_message(message) + except websockets.exceptions.ConnectionClosed: + from utils.health_check import set_qingjian_connected + set_qingjian_connected(False) + client.logger.info(f"[{client.get_time()}] 连接已关闭") + except Exception as e: + from utils.health_check import set_qingjian_connected + set_qingjian_connected(False) + client.logger.info(f"[{client.get_time()}] 接收消息错误: {e}") + + +async def handle_message_flow(client, message, *, shop_type_resolver): + from core.websocket_inbound_flow import handle_incoming_message + + await handle_incoming_message(client, message, shop_type_resolver=shop_type_resolver) diff --git a/core/websocket_customer_profile_flow.py b/core/websocket_customer_profile_flow.py new file mode 100644 index 0000000..e06422b --- /dev/null +++ b/core/websocket_customer_profile_flow.py @@ -0,0 +1,45 @@ +import re +from datetime import datetime + + +def extract_and_save_customer_info_flow(client, message: str, customer_id: str, db): + """从消息中提取客户信息并保存。""" + if not message or not customer_id: + return + + email_pattern = r"[\w\.-]+@[\w\.-]+\.\w+" + email_match = re.search(email_pattern, message) + if email_match: + db.update_email(customer_id, email_match.group()) + + phone_pattern = r"1[3-9]\d{9}" + phone_match = re.search(phone_pattern, message) + if phone_match: + db.update_phone(customer_id, phone_match.group()) + + wechat_pattern = r"[Vv微信]+号[::]?\s*([\w-]+)" + wechat_match = re.search(wechat_pattern, message) + if wechat_match: + db.update_wechat(customer_id, wechat_match.group(1)) + + budget_keywords = ["预算", "不超过", "最多", "便宜点", "便宜"] + for keyword in budget_keywords: + if keyword in message: + db.add_personality_tag(customer_id, "关注价格") + break + + personality_keywords = { + "爽快": "爽快", + "干脆": "爽快", + "纠结": "纠结", + "墨迹": "纠结", + "砍价": "砍价", + "贵": "砍价", + } + for keyword, tag in personality_keywords.items(): + if keyword in message: + db.add_personality_tag(customer_id, tag) + + profile = db.get_customer(customer_id) + profile.last_contact = datetime.now().isoformat() + db.save_customer(profile) diff --git a/core/websocket_debounce_flow.py b/core/websocket_debounce_flow.py new file mode 100644 index 0000000..fe54a66 --- /dev/null +++ b/core/websocket_debounce_flow.py @@ -0,0 +1,265 @@ +import asyncio +import logging +import re +import secrets + +logger = logging.getLogger("cs_agent") + + +async def debounce_agent_reply(client, data: dict): + """ + 消息防抖:同一客户在 _DEBOUNCE_SECONDS 内的连续消息合并后再处理。 + 订单通知、付款相关消息不走防抖,立即处理。 + """ + msg_body = data.get("msg", "") + key = f"{data.get('acc_id','')}:{data.get('from_id','')}" + client._cancel_auto_quote_task(key, reason="new_inbound") + + # 以下情况跳过防抖,立即处理(后台执行,不阻塞接收循环) + immediate_keywords = ["买家已付款", "已付款", "[系统订单信息]"] + if any(kw in msg_body for kw in immediate_keywords): + client._activity_log( + "debounce_bypass_immediate", + acc_id=data.get("acc_id", ""), + customer_id=data.get("from_id", ""), + reason="payment_or_order", + msg=msg_body, + ) + client._fire_and_forget(client._agent_reply_serialized(data)) + return + + # 积攒消息 + if key not in client._pending_msgs: + client._pending_msgs[key] = [] + client._pending_msgs[key].append(msg_body) + client._activity_log( + "debounce_enqueue", + key=key, + queue_size=len(client._pending_msgs[key]), + msg=msg_body, + ) + + # 取消上一个等待任务(如果有) + old_task = client._debounce_tasks.get(key) + if old_task and not old_task.done(): + old_task.cancel() + + debounce_seconds = pick_debounce_seconds(client, data, msg_body) + + # 创建新的延迟处理任务 + async def _delayed(capture_key, capture_data, wait_s: float): + await asyncio.sleep(wait_s) + msgs = client._pending_msgs.pop(capture_key, []) + if not msgs: + return + if len(msgs) == 1: + merged_msg = msgs[0] + else: + merged_msg = "、".join(m for m in msgs if m.strip()) + logger.info(f"[{client.get_time()}] 防抖合并 {len(msgs)} 条消息: {merged_msg[:60]}") + client._activity_log( + "debounce_flush", + key=capture_key, + merged_count=len(msgs), + merged_msg=merged_msg, + ) + merged_data = dict(capture_data) + merged_data["msg"] = merged_msg + await client._agent_reply_serialized(merged_data) + + task = asyncio.create_task(_delayed(key, data, debounce_seconds)) + client._debounce_tasks[key] = task + + +def rand_between(low: float, high: float) -> float: + if high <= low: + return float(low) + # 使用 secrets 增强随机性,避免固定周期导致机械感 + span = high - low + return round(low + span * (secrets.randbelow(1000) / 1000.0), 2) + + +def guess_intent_for_debounce(client, msg: str) -> str: + text = (msg or "").strip() + if not text: + return "unknown" + if msg_has_image_url(text): + return "image" + try: + from utils.intent_analyzer import detect_intent + + decision = detect_intent(text) + intent = decision.intent + if intent: + client._activity_log( + "debounce_intent_detected", + intent=intent, + source=decision.source, + score=round(float(decision.score or 0.0), 4), + msg=text[:120], + ) + except Exception: + intent = "" + if intent: + return intent + lower = text.lower() + if any(k in lower for k in ["报价", "多少钱", "价格", "贵", "优惠", "收费", "怎么收费", "咋收费"]): + return "询价" + if any(k in lower for k in ["做一下", "改一下", "需求", "门头", "上面的字", "处理"]): + return "修改" + if any(k in lower for k in ["在吗", "你好", "有人"]): + return "打招呼" + return "unknown" + + +def looks_like_requirement_text(msg: str) -> bool: + text = (msg or "").strip().lower() + if not text: + return False + req_kw = ( + "做一下", + "改一下", + "处理一下", + "这个字", + "上面的字", + "门头", + "去背景", + "抠图", + "换色", + "调色", + "清晰", + "高清", + "尺寸", + "比例", + "横版", + "竖版", + "排版", + "改字", + "按这个做", + "照这个做", + "就这张", + "看看做", + "弄一下", + ) + return any(k in text for k in req_kw) + + +def pick_debounce_seconds(client, data: dict, msg: str) -> float: + """意图驱动防抖:不同意图不同等待区间,并引入轻微随机。""" + base = max(1.0, float(client._DEBOUNCE_SECONDS)) + if not client._adaptive_debounce_enabled: + return base + + intent = guess_intent_for_debounce(client, msg) + is_req = looks_like_requirement_text(msg) + has_img = msg_has_image_url(msg) + + # 区间策略:越明确、越短消息,等待越短;需求描述类稍长 + if intent == "打招呼": + low, high = 1.0, min(3.0, base) + elif intent in ("询价", "砍价"): + # 询价先略等一会,给客户补发图片/需求的窗口,减少机械两连回 + low, high = 4.0, min(7.0, max(base, 7.0)) + elif intent == "image": + # 文本里直接贴图链接:短等合并上下文,避免和上一条询价并发 + low, high = 2.2, 4.2 + elif intent in ("修改", "批量"): + low, high = max(3.0, base * 0.65), min(18.0, base + 2.0) + elif intent == "转接": + low, high = 1.0, 2.5 + else: + low, high = max(2.0, base * 0.5), base + + # 发图后的需求描述,优先“多等一点”收集完整需求,减少半句回复 + # 约束到 12-14s,避免等待过长。 + if is_req and not has_img: + low = max(low, 12.0) + high = min(14.0, max(high, 12.6)) + + # 短句更快,长句稍慢,避免把连续半句拆开 + text_len = len((msg or "").strip()) + if text_len <= 4: + high = min(high, max(low + 0.2, 2.5)) + elif text_len >= 18: + low = min(high, low + 0.6) + + wait_s = rand_between(low, high) + logger.info(f"防抖等待 {wait_s}s | intent={intent} | len={text_len}") + return wait_s + + +def msg_has_image_url(msg: str) -> bool: + """判断文本消息里是否包含图片URL(客户粘贴了图片链接,可能带前缀文字如 有吗#*#https://...)""" + if not msg: + return False + lower = msg.lower() + image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp") + image_hosts = ("alicdn.com", "imgextra", "taobao.com", "jd.com", "pinduoduo.com") + if "http://" in lower or "https://" in lower: + if any(ext in lower for ext in image_exts) or any(h in lower for h in image_hosts): + return True + return False + + +def msg_refers_images(msg: str) -> bool: + """判断文本是否指代之前的图片(图一/图二/这张/那张/上面那张等)""" + if not msg: + return False + refs = ( + "图一", + "图二", + "第一张", + "第二张", + "这张", + "那张", + "这图", + "那个图", + "这个", + "这个呢", + "上面那张", + "下面那张", + "刚才那张", + "上一张", + "下一张", + ) + return any(r in msg for r in refs) + + +def extract_image_urls(msg: str) -> list: + if not msg: + return [] + parts = [p.strip() for p in msg.split("#*#") if p.strip()] + urls = [] + for p in parts: + if p.startswith("http://") or p.startswith("https://"): + urls.append(p) + if not urls and ("http://" in msg or "https://" in msg): + tokens = re.findall(r"(https?://\S+)", msg) + for t in tokens: + if any(ext in t.lower() for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]): + urls.append(t) + return urls[:8] + + +def collect_recent_image_urls(client, customer_id: str, acc_id: str, max_count: int = 6) -> list: + """从最近对话中回溯收集图片URL(优先买家消息),用于慢发或引用图片的场景""" + urls, seen = [], set() + try: + from db.chat_log_db import get_recent_conversation + + recent = get_recent_conversation(customer_id=customer_id, acc_id=acc_id, limit=20) + # 从最近到更早遍历,收集买家(in)消息中的图片链接 + for item in reversed(recent): + if item.get("direction") != "in": + continue + message = item.get("message") or "" + found = extract_image_urls(message) + for u in found: + if u not in seen: + seen.add(u) + urls.append(u) + if len(urls) >= max_count: + return urls + except Exception: + logger.debug("收集近期图片URL失败", exc_info=True) + return urls diff --git a/core/websocket_dispatch_flow.py b/core/websocket_dispatch_flow.py new file mode 100644 index 0000000..e77769b --- /dev/null +++ b/core/websocket_dispatch_flow.py @@ -0,0 +1,36 @@ +import os + + +async def dispatch_assign_once_flow(client): + """ + 调用新的一键派单接口: + GET {DISPATCH_BASE_URL}/assign + Header: X-API-Key + """ + base_url = os.getenv("DISPATCH_BASE_URL", "http://1.12.50.92:8006").strip().rstrip("/") + api_key = os.getenv("DISPATCH_API_KEY", "tuhui_dispatch_key_2026").strip() + timeout_s = float(os.getenv("DISPATCH_TIMEOUT_SECONDS", "5")) + if not base_url or not api_key: + return {"success": False, "reason": "dispatch config missing"} + try: + import httpx + + async with httpx.AsyncClient(timeout=timeout_s) as http_client: + resp = await http_client.get( + f"{base_url}/assign", + headers={"X-API-Key": api_key}, + ) + if resp.status_code != 200: + return {"success": False, "reason": f"http {resp.status_code}"} + data = resp.json() if resp.content else {} + ok = bool((data or {}).get("success", False)) + return { + "success": ok, + "task_id": str((data or {}).get("task_id", "") or ""), + "assigned_to": str((data or {}).get("assigned_to", "") or ""), + "online_count": int((data or {}).get("online_count", 0) or 0), + "notification_sent": bool((data or {}).get("notification_sent", False)), + "raw": data, + } + except Exception as e: + return {"success": False, "reason": str(e)} diff --git a/core/websocket_followup_flow.py b/core/websocket_followup_flow.py new file mode 100644 index 0000000..54644c4 --- /dev/null +++ b/core/websocket_followup_flow.py @@ -0,0 +1,181 @@ +import asyncio +import os +import time +from datetime import datetime, timedelta +import logging + +logger = logging.getLogger("cs_agent") + + +async def unreplied_followup_loop(client): + """定时补偿:对“最后一条是客户消息且长时间未回复”的会话,补发一次自然跟进。""" + if not client.enable_agent or not client.agent: + return + while client.running: + try: + await asyncio.sleep(max(30, int(os.getenv("UNREPLIED_FOLLOWUP_SCAN_SECONDS", "90")))) + await scan_and_send_unreplied_followups(client) + except asyncio.CancelledError: + break + except Exception as e: + client._activity_log("unreplied_followup_loop_error", error=str(e)) + + +async def scan_and_send_unreplied_followups(client): + from db import chat_log_db as cdb + + try: + idle_minutes = max(5, int(os.getenv("UNREPLIED_FOLLOWUP_IDLE_MINUTES", "12"))) + max_age_minutes = max(idle_minutes, int(os.getenv("UNREPLIED_FOLLOWUP_MAX_AGE_MINUTES", "180"))) + followup_cd = max(300, int(os.getenv("UNREPLIED_FOLLOWUP_COOLDOWN_SECONDS", "3600"))) + limit = max(10, int(os.getenv("UNREPLIED_FOLLOWUP_LIMIT", "40"))) + except Exception: + idle_minutes, max_age_minutes, followup_cd, limit = 12, 180, 3600, 40 + + now = datetime.now() + window_start = (now - timedelta(minutes=max_age_minutes)).strftime("%Y-%m-%d %H:%M:%S") + conn = None + try: + conn = cdb._get_conn() + rows = conn.execute( + cdb._sql( + """ + SELECT acc_id, customer_id, MAX(id) AS last_id + FROM chat_logs + WHERE timestamp >= ? + GROUP BY acc_id, customer_id + ORDER BY MAX(id) DESC + LIMIT ? + """ + ), + (window_start, limit * 6), + ).fetchall() + sessions = [dict(r) for r in rows] + sent = 0 + for s in sessions: + if sent >= limit: + break + acc_id = str(s.get("acc_id", "") or "") + cid = str(s.get("customer_id", "") or "") + if not acc_id or not cid: + continue + ckey = f"{acc_id}:{cid}" + if not client._is_owned_by_this_worker(ckey): + continue + last = conn.execute( + cdb._sql( + """ + SELECT id, direction, message, timestamp, customer_name, acc_id, platform + FROM chat_logs + WHERE acc_id = ? AND customer_id = ? + ORDER BY id DESC + LIMIT 1 + """ + ), + (acc_id, cid), + ).fetchone() + if not last: + continue + last = dict(last) + if str(last.get("direction", "")) != "in": + continue + last_ts = last.get("timestamp") + if isinstance(last_ts, datetime): + last_dt = last_ts + else: + last_dt = datetime.strptime(str(last_ts)[:19], "%Y-%m-%d %H:%M:%S") + idle_s = (now - last_dt).total_seconds() + if idle_s < idle_minutes * 60 or idle_s > max_age_minutes * 60: + continue + now_mono = time.monotonic() + if (now_mono - client._unreplied_followup_sent.get(ckey, 0.0)) < followup_cd: + continue + + last_msg = str(last.get("message", "") or "").strip().lower() + if last_msg in {"好的", "好", "ok", "收到", "嗯", "哦"}: + continue + + followup = await compose_ai_scene_reply( + client, + original_msg={ + "acc_id": acc_id, + "from_id": cid, + "from_name": client.to_chinese(last.get("customer_name", "") or cid), + "acc_type": str(last.get("platform", "") or "AliWorkbench"), + "msg": str(last.get("message", "") or ""), + }, + scene="unreplied_followup", + intent_hint="客户上一条消息还没接上,先自然承接并请对方补一句当前要处理的图或要求。", + fallback="刚看到你消息了,我在的。你把要处理的图或要求再发我一下,我马上接着看。", + ) + fake = { + "acc_id": acc_id, + "from_id": cid, + "from_name": client.to_chinese(last.get("customer_name", "") or cid), + "cy_id": cid, + "cy_name": client.to_chinese(last.get("customer_name", "") or cid), + "acc_type": str(last.get("platform", "") or "AliWorkbench"), + "msg": str(last.get("message", "") or ""), + "msg_type": 0, + } + await client.send_reply(fake, followup) + client._unreplied_followup_sent[ckey] = now_mono + sent += 1 + client._activity_log( + "unreplied_followup_sent", + acc_id=acc_id, + customer_id=cid, + idle_seconds=int(idle_s), + last_msg=str(last.get("message", "") or "")[:120], + reply=followup, + ) + finally: + try: + if conn: + conn.close() + except Exception: + logger.debug("关闭数据库连接失败", exc_info=True) + + +async def compose_ai_scene_reply(client, *, original_msg: dict, scene: str, intent_hint: str, fallback: str) -> str: + """场景化 AI 直接生成回复(不依赖固定模板)。""" + if not client.enable_agent or not client.agent or not client.AgentDeps: + return fallback + try: + deps = client.AgentDeps( + msg_id=str(original_msg.get("msg_id", "") or f"{scene}_gen"), + 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" + f"场景: {scene}\n" + f"意图: {intent_hint}\n" + f"客户原话: {customer_msg}\n" + "要求: 1-2句,自然口语,不要模板腔,不要新增价格/承诺;只输出最终回复。\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 fallback + if out.startswith("话术|") or "[转移会话]" in out or "TRANSFER_REQUESTED" in out: + return fallback + client._activity_log( + "ai_scene_reply_generated", + acc_id=str(original_msg.get("acc_id", "") or ""), + customer_id=str(original_msg.get("from_id", "") or ""), + scene=scene, + generated=out[:160], + ) + return out + except Exception as e: + client._activity_log( + "ai_scene_reply_error", + acc_id=str(original_msg.get("acc_id", "") or ""), + customer_id=str(original_msg.get("from_id", "") or ""), + scene=scene, + error=str(e), + ) + return fallback diff --git a/core/websocket_helpers_flow.py b/core/websocket_helpers_flow.py new file mode 100644 index 0000000..a33d542 --- /dev/null +++ b/core/websocket_helpers_flow.py @@ -0,0 +1,128 @@ +import asyncio +import time +from datetime import datetime + + +def fire_and_forget(client, coro): + """后台执行协程,不阻塞接收循环;异常会记录到日志。""" + task = asyncio.create_task(coro) + + def _done(t): + if t.cancelled(): + return + exc = t.exception() + if exc: + client.logger.exception(f"后台任务异常: {exc}") + + task.add_done_callback(_done) + + +def prune_seen(seen: dict, now_mono: float, ttl_sec: float = 8.0): + if len(seen) <= 2000: + return + stale = [k for k, t in seen.items() if (now_mono - t) > ttl_sec] + for k in stale: + seen.pop(k, None) + + +def log_inbound_once(client, data: dict, chat_log_fn): + """统一记录入站消息,短窗口去重,避免多分支重复写库。""" + try: + cid = data.get("from_id", "") + if not cid: + return + msg = client.to_chinese(data.get("msg", "") or "") + acc_id = data.get("acc_id", "") + mtype = int(data.get("msg_type", 0) or 0) + now_mono = time.monotonic() + sig = f"{acc_id}|{cid}|{mtype}|{msg}" + last = client._inbound_log_seen.get(sig, 0.0) + if (now_mono - last) < 2.0: + return + client._inbound_log_seen[sig] = now_mono + prune_seen(client._inbound_log_seen, now_mono, ttl_sec=8.0) + chat_log_fn( + cid, + msg, + "in", + customer_name=client.to_chinese(data.get("from_name", "") or data.get("cy_name", "")), + acc_id=acc_id, + platform=data.get("acc_type", ""), + msg_type=mtype, + ) + except Exception: + client.logger.debug("入站消息写库失败", exc_info=True) + + +def log_outbound_once(client, original_msg: dict, reply_content: str, chat_log_fn): + """统一记录出站消息,短窗口去重,避免重复写库。""" + try: + cid = original_msg.get("from_id", "") + if not cid: + return + msg = reply_content or "" + acc_id = original_msg.get("acc_id", "") + now_mono = time.monotonic() + sig = f"{acc_id}|{cid}|0|{msg}" + last = client._outbound_log_seen.get(sig, 0.0) + if (now_mono - last) < 2.0: + return + client._outbound_log_seen[sig] = now_mono + prune_seen(client._outbound_log_seen, now_mono, ttl_sec=8.0) + chat_log_fn( + cid, + msg, + "out", + customer_name=client.to_chinese(original_msg.get("from_name", "") or original_msg.get("cy_name", "")), + acc_id=acc_id, + platform=original_msg.get("acc_type", ""), + msg_type=0, + ) + except Exception: + client.logger.debug("出站消息写库失败", exc_info=True) + + +def build_customer_message(client, data: dict, customer_message_cls): + """把原始消息字典转换为 Agent 输入模型。""" + return customer_message_cls( + msg_id=data.get("msg_id", ""), + acc_id=data.get("acc_id", ""), + msg=client.to_chinese(data.get("msg", "")), + from_id=data.get("from_id", ""), + from_name=client.to_chinese(data.get("from_name", "")), + cy_id=data.get("cy_id", ""), + acc_type=data.get("acc_type", ""), + msg_type=data.get("msg_type", 0), + cy_name=client.to_chinese(data.get("cy_name", "")), + goods_name=client.to_chinese(data.get("goods_name", "")) if data.get("goods_name") else None, + goods_order=client.to_chinese(data.get("goods_order", "")) if data.get("goods_order") else None, + ) + + +def touch_customer_last_contact(client, customer_id: str, db): + """兜底更新客户最后联系时间。""" + if not customer_id: + return + try: + profile = db.get_customer(customer_id) + profile.last_contact = datetime.now().isoformat() + db.save_customer(profile) + except Exception: + client.logger.debug("更新客户最后联系时间失败: customer_id=%s", customer_id, exc_info=True) + + +def push_chat_to_wechat_safe(client, *, data: dict, customer_msg: str, reply_msg: str, tag: str, goods_name: str = ""): + """异步推送企微聊天日志,失败不影响主流程。""" + try: + from utils.wechat_chat_log import push_chat_to_wechat + + asyncio.create_task(push_chat_to_wechat( + customer_name=client.to_chinese(data.get("from_name", "") or data.get("cy_name", "")), + customer_id=data.get("from_id", ""), + acc_id=data.get("acc_id", ""), + customer_msg=client.to_chinese(customer_msg or ""), + reply_msg=reply_msg or "", + goods_name=goods_name or client.to_chinese(data.get("goods_name", "") or ""), + )) + except Exception: + client.logger.debug("推送企微聊天日志失败(%s)", tag, exc_info=True) diff --git a/core/websocket_image_entry_flow.py b/core/websocket_image_entry_flow.py new file mode 100644 index 0000000..b0ed30b --- /dev/null +++ b/core/websocket_image_entry_flow.py @@ -0,0 +1,11 @@ +async def handle_image_message_flow(client, data: dict): + """ + 处理图片消息。 + 先回复"我找找",然后把图片URL作为消息内容交给 Agent(后台执行)。 + """ + await client.send_reply(data, "我找找") + + image_data = dict(data) + image_data["msg"] = f"[客户发来图片] {data.get('msg', '')}" + image_data["msg_type"] = 0 + client._fire_and_forget(client._agent_reply_serialized(image_data)) diff --git a/core/websocket_logger_setup.py b/core/websocket_logger_setup.py new file mode 100644 index 0000000..9ff2aee --- /dev/null +++ b/core/websocket_logger_setup.py @@ -0,0 +1,86 @@ +import logging +import os +from datetime import datetime + + +class _AnsiColorFormatter(logging.Formatter): + RESET = "\033[0m" + MESSAGE_TEXT_REPLACEMENTS = ( + ("[PROMPT->AI 前置提示词]", "[AI提示词]"), + ("[PROMPT->AI", "[AI提示词"), + ("[THINK/TOOL_CALL]", "[AI思考-工具调用]"), + ("[THINK/TOOL_RETURN]", "[AI思考-工具返回]"), + ("[THINK/RAW_OUTPUT]", "[AI思考-原始输出]"), + ("[REPLY->CUSTOMER]", "[AI回复客户]"), + ("[ACTIVITY]", "[活动日志]"), + ("[AI质检]", "[AI质检]"), + ) + MESSAGE_COLOR_RULES = ( + ("[PROMPT->AI", "\033[94m"), + ("[THINK/", "\033[96m"), + ("[REPLY->CUSTOMER]", "\033[92m"), + ("Agent 回复", "\033[92m"), + ("[ACTIVITY]", "\033[95m"), + ("[AI质检]", "\033[97m"), + ("收到新消息", "\033[36m"), + ("发送成功", "\033[32m"), + ("防抖等待", "\033[93m"), + ) + COLORS = { + logging.DEBUG: "\033[36m", + logging.INFO: "\033[32m", + logging.WARNING: "\033[33m", + logging.ERROR: "\033[31m", + logging.CRITICAL: "\033[35m", + } + + def __init__(self, fmt: str, datefmt: str | None = None, use_color: bool = True): + super().__init__(fmt=fmt, datefmt=datefmt) + self.use_color = use_color + + def format(self, record: logging.LogRecord) -> str: + msg = super().format(record) + if not self.use_color: + for old, new in self.MESSAGE_TEXT_REPLACEMENTS: + msg = msg.replace(old, new) + return msg + raw_msg = record.getMessage() + for old, new in self.MESSAGE_TEXT_REPLACEMENTS: + msg = msg.replace(old, new) + for key, color in self.MESSAGE_COLOR_RULES: + if key in raw_msg: + return f"{color}{msg}{self.RESET}" + color = self.COLORS.get(record.levelno, "") + if not color: + return msg + return f"{color}{msg}{self.RESET}" + + +def setup_logger(): + from logging.handlers import RotatingFileHandler + from config.config import LOG_DIR, LOG_MAX_BYTES, LOG_BACKUP_COUNT + + logger = logging.getLogger("cs_agent") + if getattr(logger, "_cs_logger_configured", False): + return logger + logger.setLevel(logging.INFO) + logger.propagate = False + fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S") + use_color = (os.getenv("LOG_COLOR", "1").lower() in ("1", "true", "yes")) and not bool(os.getenv("NO_COLOR")) + + ch = logging.StreamHandler() + ch.setFormatter(_AnsiColorFormatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S", use_color=use_color)) + logger.addHandler(ch) + + LOG_DIR.mkdir(exist_ok=True) + today = datetime.now().strftime("%Y-%m-%d") + fh = RotatingFileHandler( + LOG_DIR / f"chat_{today}.log", + maxBytes=LOG_MAX_BYTES, + backupCount=LOG_BACKUP_COUNT, + encoding="utf-8", + ) + fh.setFormatter(fmt) + logger.addHandler(fh) + logger._cs_logger_configured = True + return logger diff --git a/core/websocket_message_utils_flow.py b/core/websocket_message_utils_flow.py new file mode 100644 index 0000000..9db0c6a --- /dev/null +++ b/core/websocket_message_utils_flow.py @@ -0,0 +1,98 @@ +import json +import random +import re + + +def to_chinese_text(text): + """处理文本,安全地转换 unicode 转义。""" + if not isinstance(text, str): + return text + if "\\u" not in text: + return text + try: + return json.loads(f'"{text}"') + except Exception: + return text + + +def is_transfer_msg(client, data: dict) -> bool: + msg = to_chinese_text(data.get("msg", "")) + return "转交给" in msg or "转接给" in msg + + +def pick_transfer_greeting() -> str: + choices = [ + "在的亲,发图我看下", + "在呢亲,有需求直接说", + "我在的,您把要求发我", + "在的哈,你说我这边看着处理", + "在呢,图和需求发来我看看", + ] + return random.choice(choices) + + +def is_shop_card(client, data: dict) -> bool: + msg = to_chinese_text(data.get("msg", "")) + return msg.startswith("[进店卡片]") or "我想咨询你们店的这个商品" in msg + + +def extract_customer_text_from_shop_card_msg(client, msg: str) -> str: + text = to_chinese_text(msg or "").strip() + if not text: + return "" + parts = [p.strip() for p in text.split("#*#") if p and p.strip()] + kept = [] + for part in parts: + if part.startswith("[进店卡片]") or "我想咨询你们店的这个商品" in part: + continue + kept.append(part) + if kept: + return " ".join(kept).strip() + stripped = re.sub(r"\[进店卡片\][^\n\r]*", "", text).strip() + stripped = stripped.replace("我想咨询你们店的这个商品", "").strip(",。,#* ") + return stripped + + +def has_chat_history(customer_id: str, acc_id: str = "") -> bool: + if not customer_id: + return False + try: + from db.chat_log_db import get_recent_conversation + + msgs = get_recent_conversation(customer_id, acc_id=acc_id, limit=1) + return len(msgs) > 0 + except Exception: + return False + + +def should_ignore(client, data: dict) -> bool: + msg = to_chinese_text(data.get("msg", "")) + + ignore_patterns = [ + "已转接", + "接入会话", + "结束会话", + "会话已", + "[系统消息]", + "[系统通知]", + ] + for pattern in ignore_patterns: + if pattern in msg: + return True + + acc_id = data.get("acc_id", "") + from_id = data.get("from_id", "") + if acc_id and from_id and acc_id == from_id: + return True + + return False + + +def get_msg_type_name(msg_type): + types = { + 0: "文本", + 1: "图片", + 2: "视频", + 3: "文件", + } + return types.get(msg_type, f"未知({msg_type})") diff --git a/core/websocket_misc_rules_flow.py b/core/websocket_misc_rules_flow.py new file mode 100644 index 0000000..27bf161 --- /dev/null +++ b/core/websocket_misc_rules_flow.py @@ -0,0 +1,84 @@ +import re +from typing import Any + + +def msg_is_price_inquiry(msg: str) -> bool: + if not msg: + return False + patterns = ("多少钱", "多少一张", "一张多少钱", "画图多少", "报价", "给个价", "几块", "多少钱") + return any(p in msg for p in patterns) + + +def detect_order_status(msg: str) -> str: + if not msg: + return "" + s = msg + if "买家已付款" in s or "已付款" in s: + return "paid" + if "[系统订单信息]" in s: + if "等待买家付款" in s or "未付款" in s: + return "waiting" + return "order" + return "" + + +def msg_requests_external_contact(msg: str) -> bool: + if not msg: + return False + lower = msg.lower() + kws = ("加qq", "qq号", "vx", "微信", "加v", "联系方式", "私聊", "加一下", "加个", "手机号", "电话", "加群", "q q", "v 信") + return any(k in lower for k in kws) + + +def extract_size_pairs_m(msg: str) -> list[tuple[float, float]]: + """提取消息中的米制尺寸对,如 15*6.4米 / 15米*6.4 / 15x6.4m。""" + if not msg: + return [] + s = (msg or "").lower().replace("×", "*").replace("x", "*") + pairs = [] + patterns = [ + r"(\d+(?:\.\d+)?)\s*\*\s*(\d+(?:\.\d+)?)\s*(?:米|m)\b", + r"(\d+(?:\.\d+)?)\s*(?:米|m)\s*\*\s*(\d+(?:\.\d+)?)\b", + ] + for p in patterns: + for m in re.findall(p, s): + try: + a = float(m[0]) + b = float(m[1]) + if a > 0 and b > 0: + pairs.append((a, b)) + except Exception: + continue + return pairs + + +def oversize_reply_if_needed(msg: str) -> str: + """ + 检测超大尺寸需求并返回拒绝话术;未命中返回空字符串。 + 规则:最长边 > 阈值 或 面积 > 阈值。 + """ + try: + from config.config import MAX_SERVICE_SIZE_LONGEST_METERS, MAX_SERVICE_SIZE_AREA_SQM + + longest_limit = float(MAX_SERVICE_SIZE_LONGEST_METERS) + area_limit = float(MAX_SERVICE_SIZE_AREA_SQM) + except Exception: + longest_limit = 10.0 + area_limit = 20.0 + + pairs = extract_size_pairs_m(msg) + for w, h in pairs: + longest = max(w, h) + area = w * h + if longest > longest_limit or area > area_limit: + return ( + f"{w:g}米*{h:g}米这个尺寸太大了,我们这边做不了。" + "如果要做可以拆成几段小尺寸,我再给你按段评估。" + ) + return "" + + +def build_auto_quote_signature(state: Any) -> str: + from core.websocket_auto_quote_flow import build_auto_quote_signature as _build + + return _build(state) diff --git a/core/websocket_outbound_arbiter_flow.py b/core/websocket_outbound_arbiter_flow.py new file mode 100644 index 0000000..90e6168 --- /dev/null +++ b/core/websocket_outbound_arbiter_flow.py @@ -0,0 +1,130 @@ +import os +import re +import time + + +def normalize_reply_semantic_key(text: str) -> str: + s = (text or "").strip().lower() + if not s: + return "" + for w in ("哈", "呀", "哦", "呢", "啦", "咯", "亲"): + s = s.replace(w, "") + s = re.sub(r"[,。!?、,.!?::;\s~\-—_]+", "", s) + return s[:200] + + +def classify_outbound_reply(text: str) -> str: + s = (text or "").strip() + if not s: + return "empty" + if any(k in s for k in ("报价", "总价", "多少钱", "多少", "马上给你报价", "先给你报")): + return "quote" + if any(k in s for k in ("继续发图", "发完", "发图", "把图发", "先看图")): + return "collect" + if any(k in s for k in ("在吗", "你好", "在的", "在呢")): + return "greeting" + if any(k in s for k in ("转人工", "转接", "转给")): + return "transfer" + if any(k in s for k in ("稍等", "我先看", "看一下", "看下")): + return "ack" + return "general" + + +def template_family(reply: str) -> str: + s = (reply or "").strip() + if not s: + return "" + if "需求我记上了" in s and "继续发图" in s: + return "collect_remind" + if ("这批图过一遍" in s or "收齐了" in s or "收好了" in s) and ("总价" in s or "报价" in s): + return "quote_defer" + if "图片收到了" in s and "继续发" in s: + return "collect_ack" + if "好嘞,你稍等下,我这边看一下" in s: + return "fallback_ack" + return "" + + +def outbound_arbiter(client, original_msg: dict, reply_content: str, trace_id: str) -> tuple[bool, str]: + """ + 统一出站裁决层: + 1) 语义去重(相同语义短窗口不重复); + 2) 同类回复节流(同类话术短窗口不重复)。 + """ + key = f"{original_msg.get('acc_id', '')}:{original_msg.get('from_id', '')}" + now_mono = time.monotonic() + sem_key = normalize_reply_semantic_key(reply_content) + reply_class = classify_outbound_reply(reply_content) + try: + sem_window = max(30, int(os.getenv("AI_OUTBOUND_SEMANTIC_DEDUPE_SECONDS", "180"))) + except Exception: + sem_window = 180 + try: + class_window = max(20, int(os.getenv("AI_OUTBOUND_CLASS_DEDUPE_SECONDS", "90"))) + except Exception: + class_window = 90 + try: + template_window = max(120, int(os.getenv("AI_OUTBOUND_TEMPLATE_FATIGUE_SECONDS", "600"))) + except Exception: + template_window = 600 + + sem_bucket = client._outbound_semantic_seen.setdefault(key, {}) + cls_bucket = client._outbound_class_seen.setdefault(key, {}) + tpl_bucket = client._outbound_template_seen.setdefault(key, {}) + client._prune_seen(sem_bucket, now_mono, ttl_sec=max(sem_window * 2, 240)) + client._prune_seen(cls_bucket, now_mono, ttl_sec=max(class_window * 2, 180)) + client._prune_seen(tpl_bucket, now_mono, ttl_sec=max(template_window * 2, 1200)) + + if sem_key and (now_mono - sem_bucket.get(sem_key, 0.0)) < sem_window: + client._activity_log( + "outbound_arbiter_block", + trace_id=trace_id, + acc_id=original_msg.get("acc_id", ""), + customer_id=original_msg.get("from_id", ""), + reason="semantic_duplicate", + semantic_key=sem_key[:80], + reply_class=reply_class, + msg=reply_content, + ) + return False, "semantic_duplicate" + + family = template_family(reply_content) + if family and (now_mono - tpl_bucket.get(family, 0.0)) < template_window: + client._activity_log( + "outbound_arbiter_block", + trace_id=trace_id, + acc_id=original_msg.get("acc_id", ""), + customer_id=original_msg.get("from_id", ""), + reason="template_fatigue", + template_family=family, + msg=reply_content, + ) + return False, "template_fatigue" + + if reply_class in {"quote", "collect", "ack"} and (now_mono - cls_bucket.get(reply_class, 0.0)) < class_window: + client._activity_log( + "outbound_arbiter_block", + trace_id=trace_id, + acc_id=original_msg.get("acc_id", ""), + customer_id=original_msg.get("from_id", ""), + reason="class_duplicate", + reply_class=reply_class, + msg=reply_content, + ) + return False, "class_duplicate" + + if sem_key: + sem_bucket[sem_key] = now_mono + cls_bucket[reply_class] = now_mono + if family: + tpl_bucket[family] = now_mono + client._activity_log( + "outbound_arbiter_pass", + trace_id=trace_id, + acc_id=original_msg.get("acc_id", ""), + customer_id=original_msg.get("from_id", ""), + reply_class=reply_class, + template_family=family, + semantic_key=sem_key[:80] if sem_key else "", + ) + return True, "pass" diff --git a/core/websocket_runtime_flow.py b/core/websocket_runtime_flow.py new file mode 100644 index 0000000..dc72b0d --- /dev/null +++ b/core/websocket_runtime_flow.py @@ -0,0 +1,119 @@ +import asyncio +import os + + +async def command_handler_flow(client): + """命令行交互。""" + client.logger.info("\n命令帮助:") + client.logger.info(" reply <内容> - 回复最后一条消息") + client.logger.info(" text <平台> <内容> - 发送文本消息") + client.logger.info(" img <平台> <路径> - 发送图片") + client.logger.info(" setid - 设置回复ID") + client.logger.info(" agent on/off - 开启/关闭 Agent") + client.logger.info(" exit/quit - 退出\n") + + while client.running: + try: + loop = asyncio.get_running_loop() + user_input = await loop.run_in_executor(None, input, "") + + parts = user_input.strip().split(maxsplit=1) + if not parts: + continue + + cmd = parts[0].lower() + + if cmd in ["exit", "quit", "q"]: + client.logger.info(f"[{client.get_time()}] 正在关闭...") + client.running = False + if client.websocket: + await client.websocket.close() + break + + if cmd == "setid" and len(parts) > 1: + client.reply_id = parts[1] + client.logger.info(f"[{client.get_time()}] 回复ID已设置为: {client.reply_id}") + continue + + if cmd == "agent" and len(parts) > 1: + if parts[1].lower() == "on": + client.enable_agent = True + client.logger.info(f"[{client.get_time()}] Agent 已开启") + elif parts[1].lower() == "off": + client.enable_agent = False + client.logger.info(f"[{client.get_time()}] Agent 已关闭") + continue + + if cmd == "reply" and len(parts) > 1: + if client.last_msg: + await client.send_reply(client.last_msg, parts[1]) + else: + client.logger.info(f"[{client.get_time()}] 错误: 还没有收到任何消息") + continue + + if cmd == "text" and len(parts) > 1: + args = parts[1].split(maxsplit=2) + if len(args) >= 3: + await client.send_text(args[0], args[1], args[2]) + else: + client.logger.info(f"[{client.get_time()}] 格式: text <内容>") + continue + + if cmd == "img" and len(parts) > 1: + args = parts[1].split(maxsplit=2) + if len(args) >= 3: + await client.send_image(args[0], args[1], args[2]) + else: + client.logger.info(f"[{client.get_time()}] 格式: img <图片路径>") + continue + + client.logger.info(f"[{client.get_time()}] 未知命令: {cmd}") + + except Exception as e: + client.logger.info(f"[{client.get_time()}] 命令错误: {e}") + + +async def run_client_flow(client): + """运行客户端。""" + tasks = [client.connect(), client.command_handler()] + + try: + from mail.email_receiver import email_receiver + if email_receiver.username: + client.logger.info(f"[{client.get_time()}] 邮件接收已启动,监控: {email_receiver.username}") + tasks.append(email_receiver.start()) + else: + client.logger.info(f"[{client.get_time()}] 未配置邮件账号,跳过邮件接收") + except Exception as e: + client.logger.info(f"[{client.get_time()}] 邮件接收模块加载失败: {e}") + + try: + from utils.daily_summary import scheduler as daily_scheduler + tasks.append(daily_scheduler()) + client.logger.info(f"[{client.get_time()}] 每日日报定时任务已启动") + except Exception as e: + client.logger.info(f"[{client.get_time()}] 日报模块加载失败: {e}") + + try: + from utils.health_check import health_check_loop + + def _qingjian_ok(): + return client.websocket is not None and not getattr(client.websocket, "closed", True) + + tasks.append(health_check_loop(_qingjian_ok)) + client.logger.info(f"[{client.get_time()}] 健康检查已启动") + except Exception as e: + client.logger.info(f"[{client.get_time()}] 健康检查模块加载失败: {e}") + + try: + from utils.wechat_chat_log import morning_startup_scheduler + tasks.append(morning_startup_scheduler()) + client.logger.info(f"[{client.get_time()}] 早8点企微启动消息已启动") + except Exception as e: + client.logger.info(f"[{client.get_time()}] 企微启动消息模块加载失败: {e}") + + if os.getenv("UNREPLIED_FOLLOWUP_ENABLED", "true").lower() in ("1", "true", "yes"): + tasks.append(client._unreplied_followup_loop()) + client.logger.info(f"[{client.get_time()}] 未回复会话补偿任务已启动") + + await asyncio.gather(*tasks) diff --git a/core/websocket_send_flow.py b/core/websocket_send_flow.py new file mode 100644 index 0000000..02ea704 --- /dev/null +++ b/core/websocket_send_flow.py @@ -0,0 +1,70 @@ +import json +import websockets + + +async def send_text_flow(client, cy_id, acc_type, content): + """主动发送文本消息。""" + message = { + "msg_id": "", + "acc_id": "", + "msg": content, + "from_id": client.reply_id, + "from_name": client.reply_id, + "cy_id": cy_id, + "acc_type": acc_type, + "msg_type": 0, + "cy_name": "", + } + await client.send_message(message) + + +async def send_image_flow(client, cy_id, acc_type, image_path): + """主动发送图片消息。""" + message = { + "msg_id": "", + "acc_id": "", + "msg": image_path, + "from_id": client.reply_id, + "from_name": client.reply_id, + "cy_id": cy_id, + "acc_type": acc_type, + "msg_type": 1, + "cy_name": "", + } + await client.send_message(message) + + +async def send_message_flow(client, message): + """发送消息到服务器。""" + if client.websocket and client.websocket.state == websockets.protocol.State.OPEN: + try: + msg_json = json.dumps(message, ensure_ascii=False) + await client.websocket.send(msg_json) + pretty = json.dumps(message, ensure_ascii=False, indent=2) + client.logger.info(f"[{client.get_time()}] 发送成功:\n{pretty}") + client._activity_log( + "send_message_success", + trace_id=message.get("_trace_id", ""), + acc_id=message.get("acc_id", ""), + customer_id=message.get("from_id", ""), + msg_type=message.get("msg_type", 0), + msg=message.get("msg", ""), + ) + except Exception as e: + client.logger.info(f"[{client.get_time()}] 发送失败: {e}") + client._activity_log( + "send_message_error", + trace_id=message.get("_trace_id", ""), + acc_id=message.get("acc_id", ""), + customer_id=message.get("from_id", ""), + error=str(e), + ) + else: + client.logger.info(f"[{client.get_time()}] 错误: 连接未打开") + client._activity_log( + "send_message_skipped", + trace_id=message.get("_trace_id", ""), + reason="socket_not_open", + acc_id=message.get("acc_id", ""), + customer_id=message.get("from_id", ""), + ) diff --git a/core/websocket_summary_flow.py b/core/websocket_summary_flow.py new file mode 100644 index 0000000..9742694 --- /dev/null +++ b/core/websocket_summary_flow.py @@ -0,0 +1,23 @@ +async def save_conversation_summary_flow(client, customer_id: str, buyer_msg: str, agent_reply: str): + """用 AI 生成一句话对话摘要并持久化。""" + try: + from db.customer_db import db + from openai import AsyncOpenAI + + api_client = AsyncOpenAI( + api_key=client.agent.api_key if client.agent else None, + base_url=client.agent.base_url if client.agent else None, + ) + resp = await api_client.chat.completions.create( + model=client.agent.model_name if client.agent else "gpt-4o-mini", + messages=[ + {"role": "system", "content": "用一句话(15字以内)总结这段对话的核心内容,只输出摘要文字。"}, + {"role": "user", "content": f"买家:{buyer_msg}\n客服:{agent_reply}"}, + ], + max_tokens=30, + temperature=0.3, + ) + summary = resp.choices[0].message.content.strip() + db.save_conversation_summary(customer_id, summary) + except Exception: + client.logger.debug("保存对话摘要失败(不影响主流程)", exc_info=True) diff --git a/core/websocket_system_inquiry_flow.py b/core/websocket_system_inquiry_flow.py new file mode 100644 index 0000000..6689bf3 --- /dev/null +++ b/core/websocket_system_inquiry_flow.py @@ -0,0 +1,143 @@ +import json +import logging +import os +from pathlib import Path +from typing import Any, Dict, List + +from utils.metrics_tracker import emit as metrics_emit + +logger = logging.getLogger("cs_agent") + + +def load_system_inquiry_rules() -> Dict[str, Any]: + """加载系统客服询单规则(全局 + 店铺覆盖)。""" + from config.config import ( + SYSTEM_INQUIRY_ENABLED, + SYSTEM_INQUIRY_DEFAULT_ACTION, + SYSTEM_INQUIRY_DEFAULT_REPLY, + SYSTEM_INQUIRY_RULES_FILE, + ) + + enabled_env = os.getenv("SYSTEM_INQUIRY_ENABLED") + enabled = ( + enabled_env.lower() in ("1", "true", "yes") + if isinstance(enabled_env, str) + else bool(SYSTEM_INQUIRY_ENABLED) + ) + action = (os.getenv("SYSTEM_INQUIRY_DEFAULT_ACTION") or SYSTEM_INQUIRY_DEFAULT_ACTION or "silent").strip().lower() + reply = os.getenv("SYSTEM_INQUIRY_DEFAULT_REPLY") or SYSTEM_INQUIRY_DEFAULT_REPLY or "" + rules_file = os.getenv("SYSTEM_INQUIRY_RULES_FILE") or str(SYSTEM_INQUIRY_RULES_FILE) + defaults: Dict[str, Any] = { + "enabled": bool(enabled), + "default_action": action, + "default_reply": reply, + "sender_keywords": ["系统客服", "官方客服", "平台客服", "机器人客服", "商家客服系统"], + "message_keywords": ["系统询单", "代客咨询", "平台代问", "系统代发", "客服询单"], + "shops": {}, + } + try: + p = Path(rules_file) + if p.exists(): + with p.open("r", encoding="utf-8") as f: + loaded = json.load(f) + if isinstance(loaded, dict): + defaults.update(loaded) + except Exception as e: + logger.warning("系统询单规则加载失败,使用默认规则: %s", e) + return defaults + + +def normalize_kw_list(v: Any) -> List[str]: + if not isinstance(v, list): + return [] + return [str(x).strip().lower() for x in v if str(x).strip()] + + +def resolve_system_inquiry_policy(client, acc_id: str) -> Dict[str, Any]: + """根据店铺合并系统询单策略。""" + from config.config import SYSTEM_INQUIRY_SHOPS + + rules = client._system_inquiry_rules or {} + if not bool(rules.get("enabled", True)): + return {"enabled": False} + + shops_env = os.getenv("SYSTEM_INQUIRY_SHOPS", SYSTEM_INQUIRY_SHOPS or "") + shop_whitelist = [s.strip() for s in shops_env.split(",") if s.strip()] + if shop_whitelist and (acc_id or "") not in shop_whitelist: + return {"enabled": False} + + policy: Dict[str, Any] = { + "enabled": True, + "action": str(rules.get("default_action", "silent")).strip().lower(), + "reply": str(rules.get("default_reply", "")).strip(), + "sender_keywords": normalize_kw_list(rules.get("sender_keywords")), + "message_keywords": normalize_kw_list(rules.get("message_keywords")), + } + shop_cfg = (rules.get("shops") or {}).get(acc_id or "", {}) + if isinstance(shop_cfg, dict): + if "enabled" in shop_cfg and not bool(shop_cfg.get("enabled", True)): + return {"enabled": False} + if shop_cfg.get("action"): + policy["action"] = str(shop_cfg.get("action")).strip().lower() + if shop_cfg.get("reply"): + policy["reply"] = str(shop_cfg.get("reply")).strip() + if isinstance(shop_cfg.get("sender_keywords"), list): + policy["sender_keywords"] = normalize_kw_list(shop_cfg.get("sender_keywords")) + if isinstance(shop_cfg.get("message_keywords"), list): + policy["message_keywords"] = normalize_kw_list(shop_cfg.get("message_keywords")) + if policy["action"] not in ("silent", "reply", "transfer"): + policy["action"] = "silent" + return policy + + +def match_system_inquiry(client, data: dict, policy: Dict[str, Any]) -> bool: + """识别是否为系统客服询单消息。""" + if not policy.get("enabled", False): + return False + + from_name = client.to_chinese(data.get("from_name", "") or "").lower() + from_id = str(data.get("from_id", "") or "").lower() + msg = client.to_chinese(data.get("msg", "") or "").lower() + + sender_hits = 0 + for kw in policy.get("sender_keywords", []): + if kw and (kw in from_name or kw in from_id): + sender_hits += 1 + message_hits = 0 + for kw in policy.get("message_keywords", []): + if kw and kw in msg: + message_hits += 1 + + # 优先看发送者特征;纯文本命中时至少要求两个关键词,降低误判风险 + return sender_hits > 0 or message_hits >= 2 + + +async def handle_system_inquiry(client, data: dict) -> bool: + """命中系统询单后按策略处理。""" + acc_id = data.get("acc_id", "") + policy = resolve_system_inquiry_policy(client, acc_id) + if not match_system_inquiry(client, data, policy): + return False + + customer_id = data.get("from_id", "") + metrics_emit("system_inquiry_detected", customer_id=customer_id, acc_id=acc_id) + action = policy.get("action", "silent") + logger.info("系统询单命中 | 店铺:%s | 客户:%s | action:%s", acc_id, customer_id, action) + + if action == "reply": + reply = await client._compose_ai_scene_reply( + original_msg=data, + scene="system_inquiry_reply", + intent_hint="这是系统客服询单消息,简短确认已收到并说明会跟进即可。", + fallback=(policy.get("reply") or "您好,这边已收到询单消息,稍后由人工客服跟进处理。"), + ) + await client.send_reply(data, reply) + metrics_emit("system_inquiry_auto_reply", customer_id=customer_id, acc_id=acc_id) + return True + if action == "transfer": + await client.transfer_to_human(data, "系统询单转人工") + metrics_emit("system_inquiry_transfer", customer_id=customer_id, acc_id=acc_id) + return True + + metrics_emit("system_inquiry_ignored", customer_id=customer_id, acc_id=acc_id) + return True diff --git a/core/websocket_transfer_flow.py b/core/websocket_transfer_flow.py new file mode 100644 index 0000000..0d6ef4a --- /dev/null +++ b/core/websocket_transfer_flow.py @@ -0,0 +1,83 @@ +import logging + +from utils.metrics_tracker import emit as metrics_emit + +logger = logging.getLogger("cs_agent") + + +async def transfer_to_human_flow(client, data: dict, transfer_msg: str = "", *, transfer_group_resolver=None): + """ + 转接人工客服。 + 1. 优先调用 dispatch 服务 GET /assign 一键派单 + 2. 派单失败时,回退旧版 designer_roster 派单 + 3. 无人在线或未配置时,回退到 config/transfer_groups.json + 设计师在线状态:仅在转人工时按需查询,不轮询。 + """ + if not client.websocket: + logger.info("[%s] 错误: 未连接到服务器", client.get_time()) + return + + acc_id = data.get("acc_id", "") + group_id = None + assigned_to = "" + dispatch_res = await client._dispatch_assign_once() + if dispatch_res.get("success"): + assigned_to = str(dispatch_res.get("assigned_to", "") or "").strip() + logger.info( + "一键派单成功 | task_id=%s | assigned_to=%s | online_count=%s", + dispatch_res.get("task_id", ""), + assigned_to or "未知", + dispatch_res.get("online_count", 0), + ) + metrics_emit( + "dispatch_assign_success", + acc_id=acc_id, + assigned_to=assigned_to, + online_count=dispatch_res.get("online_count", 0), + ) + else: + logger.warning("一键派单失败,回退旧派单逻辑: %s", dispatch_res.get("reason", "unknown")) + metrics_emit("dispatch_assign_failed", acc_id=acc_id) + + # 2. 派单失败时,回退旧版 designer_roster + if not dispatch_res.get("success"): + try: + from utils.designer_roster import poll_and_update_roster + from db.designer_roster_db import get_transfer_group_for_shop + await poll_and_update_roster() + group_id = get_transfer_group_for_shop(acc_id) + except Exception as e: + logger.debug("设计师派单未启用或异常: %s", e) + + # 3. 无人在线时企微提醒(新旧两套都没拿到在线结果时) + online_count = int(dispatch_res.get("online_count", 0) or 0) + if online_count <= 0 and not group_id: + try: + from config.config import WECHAT_WEBHOOK + if WECHAT_WEBHOOK: + import httpx + + async with httpx.AsyncClient(timeout=5) as c: + resp = await c.post(WECHAT_WEBHOOK, json={ + "msgtype": "text", + "text": {"content": "谁在线啊"}, + }) + if resp.status_code != 200: + logger.warning("企微提醒发送失败: %s %s", resp.status_code, resp.text) + else: + logger.debug("未配置 WECHAT_WEBHOOK,跳过企微提醒") + except Exception as e: + logger.warning("企微提醒发送异常: %s", e) + + # 4. 构造转接命令:有 assigned_to 用人名,否则回退分组 + if assigned_to: + cmd = f"正在为你转接人工|[转移会话],{assigned_to},无原因" + await client.send_reply(data, cmd) + logger.info("[%s] 已发送转接请求 (店铺:%s -> 设计师:%s)", client.get_time(), acc_id or "未知", assigned_to) + return + + if not group_id: + group_id = transfer_group_resolver(acc_id) if transfer_group_resolver else "20252916034" + cmd = f"话术|[转移会话],分组{group_id},无原因" + await client.send_reply(data, cmd) + logger.info("[%s] 已发送转接请求 (店铺:%s -> 分组:%s)", client.get_time(), acc_id or "未知", group_id) diff --git a/core/websocket_workflow_flow.py b/core/websocket_workflow_flow.py new file mode 100644 index 0000000..cb47f88 --- /dev/null +++ b/core/websocket_workflow_flow.py @@ -0,0 +1,64 @@ +import asyncio + + +async def workflow_agent_notify_flow(client, customer_id: str, acc_id: str, acc_type: str, system_hint: str): + """图片处理完成后,让客服 AI 生成自然话术发给客户。""" + if not client.enable_agent or not client.agent: + return + try: + from core.pydantic_ai_agent import CustomerMessage + + notify_msg = CustomerMessage( + msg_id="workflow_notify", + acc_id=acc_id, + msg=system_hint, + from_id=customer_id, + from_name="", + cy_id=customer_id, + acc_type=acc_type, + msg_type=0, + cy_name="", + ) + response = await client.agent.process_message(notify_msg) + if response.should_reply and response.reply: + nonsense_patterns = [ + "无需", "流程已完成", "不需要回复", "无需额外", "已完成", + "无需回复", "不需要额外", "已经完成", "无需再", "操作已完成", + "任务完成", "流程完成", "记录完成", "报价已", + ] + if not any(p in response.reply for p in nonsense_patterns): + fake_data = { + "acc_id": acc_id, + "from_id": customer_id, + "from_name": "", + "cy_id": customer_id, + "acc_type": acc_type, + } + await asyncio.sleep(0.5) + await client.send_reply(fake_data, response.reply) + client.logger.info(f"[Workflow] AI 通知已发送: {response.reply}") + except Exception as e: + client.logger.error(f"[Workflow] AI 通知生成失败: {e}") + + +async def workflow_send_flow( + client, + customer_id: str, + acc_id: str, + acc_type: str, + content: str, + msg_type: int = 0, +): + """workflow 回调:图片AI完成后用此方法推送消息给客户。""" + msg = { + "msg_id": "", + "acc_id": acc_id, + "msg": content, + "from_id": customer_id, + "from_name": customer_id, + "cy_id": customer_id, + "acc_type": acc_type, + "msg_type": msg_type, + "cy_name": customer_id, + } + await client.send_message(msg)