266 lines
8.7 KiB
Python
266 lines
8.7 KiB
Python
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
|