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