import logging import asyncio import re import time import json from typing import Optional, List, Any, Dict from collections import deque from core.schema import StandardMessage, StandardResponse from core.adapters.qianniu_adapter import QianniuAdapter from core.pydantic_ai_agent_v2 import CustomerServiceBrain from core.events.event_bus import bus from core.repository import repo logger = logging.getLogger("cs_agent") # 配置常量 MSG_DEDUP_CAPACITY = 200 # 消息 ID 去重缓存容量 TRANSFER_COOLDOWN_SEC = 60 # 转接冷却时间(秒) DEBOUNCE_SECONDS = 2.0 # 消息防抖延迟(秒) class SystemOrchestrator: """ 全系统总编排:具备转接冷却、防抖合并、多消息去重、以及精准日志。 """ def __init__(self, ws_client=None): self.ws_client = ws_client self.qianniu_adapter = QianniuAdapter(ws_client) self.brain = CustomerServiceBrain() # 1. 消息 ID 去重 self._processed_msg_ids = deque(maxlen=MSG_DEDUP_CAPACITY) # 2. 转接冷却存储 (customer_id -> last_transfer_time) self._last_transfer_time: Dict[str, float] = {} # 3. 防抖配置 self._debounce_seconds = DEBOUNCE_SECONDS self._debounce_tasks: Dict[str, asyncio.Task] = {} self._pending_messages: Dict[str, List[StandardMessage]] = {} self._user_locks: Dict[str, asyncio.Lock] = {} bus.subscribe("MESSAGE_OUTBOUND", self.handle_outbound_event) @staticmethod def _has_transfer_intent(text: str) -> bool: if not text: return False t = str(text) keywords = ("转人工", "转接", "人工客服", "人工", "设计师", "叫人", "找人") return any(k in t for k in keywords) def _get_user_lock(self, user_id: str) -> asyncio.Lock: if user_id not in self._user_locks: self._user_locks[user_id] = asyncio.Lock() return self._user_locks[user_id] async def on_raw_message_received(self, platform: str, raw_data: dict): """链路入口""" try: if platform != "qianniu": return std_msg, direction = await self.qianniu_adapter.translate_inbound(raw_data) # 关键修复:确保 user_id 绝不为空 user_id = std_msg.user_id or str(raw_data.get("cy_id") or raw_data.get("from_id") or "unknown") std_msg.user_id = user_id # 店铺隔离:同一客户在不同店铺的对话独立处理 session_key = f"{user_id}@{std_msg.acc_id}" # 订单消息处理:静默入库并更新状态,但不触发 AI 回复 if "[系统订单信息]" in (std_msg.content or ""): await self._handle_order_packet(platform, std_msg) logger.info(f"[订单消息] user={user_id} acc={std_msg.acc_id} 已入库更新状态") await repo.save_chat(platform, user_id, std_msg.content, "in", acc_id=std_msg.acc_id) return preview = (std_msg.content or "").replace("\n", "\\n") if len(preview) > 120: preview = preview[:120] + "..." logger.info( f"[监听消息] dir={direction} user={user_id} acc={std_msg.acc_id} " f"type={std_msg.msg_type} images={len(std_msg.image_urls)} content={preview}" ) # 过滤心跳 if not std_msg.content.strip() and not std_msg.image_urls: return # 如果是商家人工回复,静默入库 if direction == "out": await repo.save_chat(platform, user_id, std_msg.content, "out", acc_id=std_msg.acc_id) return # ID 去重 if std_msg.msg_id: if std_msg.msg_id in self._processed_msg_ids: return self._processed_msg_ids.append(std_msg.msg_id) # 进入防抖(使用 session_key 隔离不同店铺) if session_key in self._debounce_tasks: self._debounce_tasks[session_key].cancel() if session_key not in self._pending_messages: self._pending_messages[session_key] = [] self._pending_messages[session_key].append(std_msg) self._debounce_tasks[session_key] = asyncio.create_task(self._debounced_process(session_key, user_id, platform)) except Exception as e: logger.error(f"[Orchestrator] 处理失败: {e}") async def _handle_order_packet(self, platform: str, msg: StandardMessage): try: price_match = re.search(r"订单金额:金额:\s*([\d\.]+)元", msg.content) if price_match: await repo.update_task_price(platform, msg.user_id, float(price_match.group(1))) # 判定成交结果(扩大范围:已付款 或 已发货 都视为成功,用于后期 AI 话术微调) if any(k in msg.content for k in ["买家已付款", "卖家已发货"]): await repo.update_task_outcome(platform, msg.user_id, "deal_success") elif any(k in msg.content for k in ["退款", "已关闭", "已取消"]): await repo.update_task_outcome(platform, msg.user_id, "refunded") except Exception as e: logger.warning(f"[Orchestrator] 订单消息处理异常: {e}") async def _analyze_images_background(self, session_key: str, image_urls: List[str]): """后台静默分析图片,存入用户数据库用于数据标定""" try: from services.service_image_analyzer import image_analyzer_service from db.customer_db import CustomerDatabase db = CustomerDatabase() profile = db.get_customer(session_key) for url in image_urls: try: result = await image_analyzer_service.analyze(url) result_json = json.dumps(result, ensure_ascii=False) # 更新最近一次分析 profile.last_image_analysis = result_json profile.last_image_url = url profile.last_gemini_prompt = result.get("gemini_prompt", "") profile.last_aspect_ratio = result.get("aspect_ratio", "1:1") profile.last_perspective = result.get("perspective", "no") # 追加到历史记录(保留最近20条) if profile.image_analysis_history is None: profile.image_analysis_history = [] profile.image_analysis_history.append(result_json) if len(profile.image_analysis_history) > 20: profile.image_analysis_history = profile.image_analysis_history[-20:] # 更新复杂度历史 complexity = result.get("complexity", "normal") if profile.complexity_history is None: profile.complexity_history = [] profile.complexity_history.append(complexity) if len(profile.complexity_history) > 10: profile.complexity_history = profile.complexity_history[-10:] # 更新图片类型历史 proc_type = result.get("proc_type", "") if proc_type and profile.image_type_history is not None: if proc_type not in profile.image_type_history: profile.image_type_history.append(proc_type) logger.debug(f"[ImageAnalysis] session={session_key} 分析完成: {result.get('subject', '?')} | {complexity}") except Exception as e: logger.warning(f"[ImageAnalysis] 单张图片分析失败: {e}") continue # 保存更新 db.save_customer(profile) logger.info(f"[ImageAnalysis] session={session_key} 分析结果已保存到数据库") except Exception as e: logger.warning(f"[ImageAnalysis] 后台分析失败: {e}") async def _debounced_process(self, session_key: str, user_id: str, platform: str): try: await asyncio.sleep(self._debounce_seconds) async with self._get_user_lock(session_key): messages = self._pending_messages.pop(session_key, []) if not messages: return # A. 合并与元数据修复 combined_content = "\n".join([m.content for m in messages if m.content.strip()]) all_image_urls = [] acc_id = messages[-1].acc_id acc_type = messages[-1].acc_type for m in messages: for url in m.image_urls: if url not in all_image_urls: all_image_urls.append(url) # 防抖合并后的消息仍需有 msg_id,避免触发 StandardMessage 校验失败 merged_msg_id = messages[-1].msg_id if messages[-1].msg_id else f"merged-{user_id}-{int(time.time() * 1000)}" final_msg = StandardMessage( platform=platform, msg_id=merged_msg_id, user_id=user_id, content=combined_content, msg_type=messages[-1].msg_type, image_urls=all_image_urls, acc_id=acc_id, acc_type=acc_type ) # B. 持久化 db_content = combined_content if all_image_urls: db_content = f"【系统:已收到{len(all_image_urls)}张图】\n{combined_content}" await repo.save_chat(platform, user_id, db_content, "in", acc_id=acc_id, image_urls=all_image_urls) # B2. 后台图片分析(不阻塞主流程,用于数据标定) if all_image_urls: asyncio.create_task(self._analyze_images_background(session_key, all_image_urls)) # C. 冷却检查:如果转接冷却期内发过转接,告诉大脑"已处于转接中" is_in_cooldown = (time.time() - self._last_transfer_time.get(session_key, 0)) < TRANSFER_COOLDOWN_SEC # D. 思考 history = await repo.get_chat_history(user_id, limit=10, acc_id=acc_id) if history and history[-1]['content'] == db_content: history = history[:-1] # 只在“明确又要转接”时注入冷却提示,普通问候/新需求不注入 transfer_intent = self._has_transfer_intent(combined_content) if is_in_cooldown and transfer_intent: final_msg.content = f"【系统:当前已向设计师发出转接请求,请勿再次调用转接工具】\n{final_msg.content}" std_res = await self.brain.think_and_reply(final_msg, history=history) # E. 发送并记录时间 if std_res.should_reply: # 关键修复:补全发送时的元数据,解决日志 customer_id 为空的问题 std_res.metadata = {"acc_id": acc_id, "acc_type": acc_type} await self.qianniu_adapter.translate_outbound(std_res, user_id) await repo.save_chat(platform, user_id, std_res.reply_content, "out", acc_id=acc_id) if "[转移会话]" in std_res.reply_content: self._last_transfer_time[session_key] = time.time() except asyncio.CancelledError: pass except Exception as e: logger.exception(f"[Orchestrator] 处理失败: {e}") async def handle_outbound_event(self, user_id: str, platform: str, response: StandardResponse): if platform == "qianniu": await self.qianniu_adapter.translate_outbound(response, user_id) # 全局单例 orchestrator: Optional[SystemOrchestrator] = None def init_orchestrator(ws_client): global orchestrator orchestrator = SystemOrchestrator(ws_client) return orchestrator