feat: upgrade客服多店铺分流、批量报价与稳定性防护
This commit is contained in:
@@ -3,10 +3,12 @@ import websockets
|
||||
import json
|
||||
import re
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
# ========== 转接分组映射 ==========
|
||||
def _get_transfer_group(acc_id: str) -> str:
|
||||
@@ -49,6 +51,7 @@ import os
|
||||
logger = setup_logger()
|
||||
|
||||
from db.chat_log_db import log_message as _chat_log
|
||||
from utils.metrics_tracker import emit as metrics_emit
|
||||
|
||||
# 导入 Agent 模块
|
||||
try:
|
||||
@@ -94,6 +97,8 @@ class QingjianAPIClient:
|
||||
self._agent_semaphore = asyncio.Semaphore(8)
|
||||
self._pending_images: dict = {}
|
||||
self._pending_image_tasks: dict = {}
|
||||
self._system_inquiry_rules = self._load_system_inquiry_rules()
|
||||
self._last_reply_sent_at: dict = {} # customer_key -> monotonic ts
|
||||
|
||||
# 延迟加载任务模块(避免循环导入)
|
||||
self.task_scheduler = None
|
||||
@@ -289,7 +294,7 @@ class QingjianAPIClient:
|
||||
if self._is_transfer_msg(data):
|
||||
# 会话转交 → 主动打招呼
|
||||
print(f"[{self.get_time()}] 收到转交消息,发送问候")
|
||||
greeting = "在呢,发图来我看看"
|
||||
greeting = self._pick_transfer_greeting()
|
||||
await self.send_reply(data, greeting)
|
||||
try:
|
||||
from utils.wechat_chat_log import push_chat_to_wechat
|
||||
@@ -324,6 +329,8 @@ class QingjianAPIClient:
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
elif await self._handle_system_inquiry(data):
|
||||
print(f"[{self.get_time()}] 系统客服询单消息,已按规则处理")
|
||||
elif self._should_ignore(data):
|
||||
print(f"[{self.get_time()}] 系统通知,跳过回复")
|
||||
else:
|
||||
@@ -529,6 +536,12 @@ class QingjianAPIClient:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 超大尺寸(米制)直接拒单,避免进入报价/处理流程
|
||||
oversize_reply = self._oversize_reply_if_needed(msg_text)
|
||||
if oversize_reply:
|
||||
await self.send_reply(data, oversize_reply)
|
||||
return
|
||||
|
||||
# 消息含图片URL:累积到待处理列表,先询问要求
|
||||
if self._msg_has_image_url(msg_text):
|
||||
urls = self._extract_image_urls(msg_text)
|
||||
@@ -543,6 +556,7 @@ class QingjianAPIClient:
|
||||
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:
|
||||
@@ -550,20 +564,24 @@ class QingjianAPIClient:
|
||||
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 = "这里沟通就可以哦,其他联系方式不方便"
|
||||
@@ -603,9 +621,11 @@ class QingjianAPIClient:
|
||||
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(
|
||||
@@ -813,11 +833,68 @@ class QingjianAPIClient:
|
||||
lower = msg.lower()
|
||||
kws = ("加qq", "qq号", "vx", "微信", "加v", "联系方式", "私聊", "加一下", "加个", "手机号", "电话", "加群", "q q", "v 信")
|
||||
return any(k in lower for k in kws)
|
||||
|
||||
@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
|
||||
|
||||
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 ""
|
||||
def _is_transfer_msg(self, data: dict) -> bool:
|
||||
"""判断是否是会话转交消息(需要主动打招呼)"""
|
||||
msg = self.to_chinese(data.get('msg', ''))
|
||||
return '转交给' in msg or '转接给' in msg
|
||||
|
||||
def _pick_transfer_greeting(self) -> str:
|
||||
"""转接后问候话术:简短自然,随机避免机械感。"""
|
||||
choices = [
|
||||
"在的亲,发图我看下",
|
||||
"在呢亲,有需求直接说",
|
||||
"我在的,您把要求发我",
|
||||
"在的哈,你说我这边看着处理",
|
||||
"在呢,图和需求发来我看看",
|
||||
]
|
||||
return random.choice(choices)
|
||||
|
||||
def _is_shop_card(self, data: dict) -> bool:
|
||||
"""判断是否是进店卡片消息"""
|
||||
msg = self.to_chinese(data.get('msg', ''))
|
||||
@@ -838,6 +915,130 @@ class QingjianAPIClient:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
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
|
||||
|
||||
@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()]
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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 = 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
|
||||
|
||||
def _should_ignore(self, data: dict) -> bool:
|
||||
"""判断是否应该忽略该消息(不回复)"""
|
||||
msg = self.to_chinese(data.get('msg', ''))
|
||||
@@ -1107,6 +1308,23 @@ class QingjianAPIClient:
|
||||
if not self.websocket:
|
||||
print(f"[{self.get_time()}] 错误: 未连接到服务器")
|
||||
return
|
||||
|
||||
# 同一客户外发限流: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]}"
|
||||
)
|
||||
return
|
||||
self._last_reply_sent_at[ckey] = now_mono
|
||||
|
||||
shop_id = original_msg.get("acc_id", "")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user