# ============================================================================= # 企微IT智能服务台 — AI Wingman 服务(坐席智能副驾驶) # ============================================================================= # 说明:复用 Dify 基础设施,使用独立的 Wingman Agent(Agent 2), # 与员工端 AI(Agent 1)共用知识库但 system prompt 不同。 # # 核心能力: # 1. 生成 AI 草稿回复 — 基于对话上下文为坐席生成专业回复 # 2. 生成会话自动摘要 — 结单时自动提取问题/原因/解决方案 # 3. 生成自动标签建议 — 基于对话内容建议标签分类 # # 降级策略:Wingman Agent 不可用时返回友好错误信息,不抛异常 # ============================================================================= import json import logging from typing import Any, Dict, List, Optional import httpx from app.config import settings logger = logging.getLogger(__name__) class WingmanService: """AI Wingman 服务 — 坐席智能副驾驶。 复用 Dify 基础设施,使用独立的 Wingman Agent(Agent 2), 与员工端 AI(Agent 1)共用知识库但 system prompt 不同。 三个核心方法使用不同的 system prompt: - 草稿生成:生成坐席可采纳的专业回复 - 摘要生成:提取结构化的会话摘要 - 标签建议:建议标签分类和优先级 """ # -------------------------------------------------------------------------- # System Prompt 定义 # -------------------------------------------------------------------------- _DRAFT_SYSTEM_PROMPT: str = ( "你是一个IT服务坐席助手,基于对话上下文为坐席生成专业、准确的回复草稿。" "直接输出回复内容,不要解释。" ) _SUMMARY_SYSTEM_PROMPT: str = ( "你是一个IT服务分析助手,基于完整对话生成结构化摘要," "包含:问题、原因、解决方案。以JSON格式输出。" "输出格式:{\"problem\": \"问题描述\", \"cause\": \"原因\", \"solution\": \"解决方案\"}" ) _TAGS_SYSTEM_PROMPT: str = ( "你是一个IT服务分类助手,基于对话内容建议标签分类。以JSON格式输出。" "输出格式:{\"suggested_tags\": [\"标签1\", \"标签2\"], \"category\": \"分类\", \"priority\": \"low/medium/high\"}" ) def __init__(self): """初始化 Wingman 服务。 从配置读取 Wingman Agent 的 API 地址和认证信息。 独立于 AIService,使用自己的 httpx 客户端。 """ # Wingman Agent 专用 API 端点 self.api_url = settings.dify_wingman_api_url # Wingman Agent API Key self.api_key = settings.dify_wingman_api_key # 请求超时(秒) self.timeout = settings.dify_wingman_timeout # httpx 异步客户端(复用连接池) self._client: Optional[httpx.AsyncClient] = None async def _get_client(self) -> httpx.AsyncClient: """获取或创建 httpx 异步客户端(懒加载)。 复用连接池,避免每次请求都创建新连接。 Returns: httpx.AsyncClient: 异步 HTTP 客户端实例 """ if self._client is None or self._client.is_closed: self._client = httpx.AsyncClient( timeout=httpx.Timeout(self.timeout), headers={ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", } ) return self._client async def close(self): """关闭 httpx 客户端,释放连接池资源。""" if self._client and not self._client.is_closed: await self._client.aclose() self._client = None logger.debug("WingmanService httpx client closed") # -------------------------------------------------------------------------- # 核心方法 1:生成 AI 草稿回复 # -------------------------------------------------------------------------- async def generate_draft( self, conversation_id: str, messages: List[Dict[str, Any]], db: Any = None, ) -> Dict[str, Any]: """生成 AI 草稿回复。 传入当前会话的完整消息历史,让 Wingman Agent 基于上下文 生成坐席可以采纳的草稿回复。 Args: conversation_id: 会话ID messages: 会话消息历史列表,每条消息包含 sender_type/content 等 db: 数据库会话(可选,当前未使用) Returns: Dict: { "content": str, # 草稿内容 "confidence": float, # 置信度(0-1) "reasoning": str, # 生成推理说明 } """ # 构建对话上下文消息列表 context_messages = self._build_context_messages( messages, self._DRAFT_SYSTEM_PROMPT ) try: result = await self._call_wingman_api(context_messages) if result is None: return { "content": "", "confidence": 0.0, "reasoning": "Wingman 服务暂不可用", } reply_content = result # 基于回复长度和内容质量估算置信度 confidence = self._estimate_confidence(reply_content) return { "content": reply_content, "confidence": confidence, "reasoning": f"基于最近 {len(messages)} 条对话上下文生成", } except Exception as e: logger.error(f"Wingman 草稿生成失败: {e}") return { "content": "", "confidence": 0.0, "reasoning": f"AI 服务异常: {str(e)}", } # -------------------------------------------------------------------------- # 核心方法 2:生成会话自动摘要 # -------------------------------------------------------------------------- async def generate_summary( self, conversation_id: str, messages: List[Dict[str, Any]], ) -> Dict[str, Any]: """生成会话自动摘要。 基于完整对话生成结构化摘要,包含问题、原因、解决方案。 结单时自动调用。 Args: conversation_id: 会话ID messages: 会话消息历史列表 Returns: Dict: { "problem": str, # 问题描述 "cause": str, # 原因分析 "solution": str, # 解决方案 } """ context_messages = self._build_context_messages( messages, self._SUMMARY_SYSTEM_PROMPT ) # 默认摘要(降级时使用) default_summary = { "problem": "无法自动生成摘要", "cause": "", "solution": "", } try: result = await self._call_wingman_api(context_messages) if result is None: return default_summary # 尝试解析 JSON 格式的摘要 parsed = self._parse_json_response(result, default_summary) return { "problem": parsed.get("problem", default_summary["problem"]), "cause": parsed.get("cause", default_summary["cause"]), "solution": parsed.get("solution", default_summary["solution"]), } except Exception as e: logger.error(f"Wingman 摘要生成失败: {e}") return default_summary # -------------------------------------------------------------------------- # 核心方法 3:生成自动标签建议 # -------------------------------------------------------------------------- async def suggest_tags( self, conversation_id: str, messages: List[Dict[str, Any]], existing_tags: Dict[str, Any] = None, ) -> Dict[str, Any]: """生成自动标签建议。 基于对话内容建议标签分类,包含标签列表、分类和优先级。 Args: conversation_id: 会话ID messages: 会话消息历史列表 existing_tags: 已有标签(可选,用于避免重复建议) Returns: Dict: { "suggested_tags": list[str], # 建议标签列表 "category": str, # 分类 "priority": str, # 优先级: low/medium/high } """ context_messages = self._build_context_messages( messages, self._TAGS_SYSTEM_PROMPT ) # 默认标签建议(降级时使用) default_tags = { "suggested_tags": [], "category": "", "priority": "medium", } try: result = await self._call_wingman_api(context_messages) if result is None: return default_tags # 尝试解析 JSON 格式的标签建议 parsed = self._parse_json_response(result, default_tags) return { "suggested_tags": parsed.get("suggested_tags", []), "category": parsed.get("category", ""), "priority": parsed.get("priority", "medium"), } except Exception as e: logger.error(f"Wingman 标签建议失败: {e}") return default_tags # -------------------------------------------------------------------------- # 内部方法 # -------------------------------------------------------------------------- def _build_context_messages( self, messages: List[Dict[str, Any]], system_prompt: str, ) -> List[Dict[str, str]]: """构建发送给 Wingman Agent 的消息列表。 将数据库中的消息历史转换为 OpenAI Chat Completions 格式的 messages 列表,包含 system prompt 和对话上下文。 Args: messages: 数据库消息列表 system_prompt: 当前场景的 system prompt Returns: List[Dict]: OpenAI 格式的消息列表 """ # 构建上下文消息列表 context: List[Dict[str, str]] = [ {"role": "system", "content": system_prompt} ] # 角色映射:数据库 sender_type → OpenAI role role_map = { "employee": "user", # 员工消息 → user "agent": "assistant", # 坐席消息 → assistant "ai": "assistant", # AI消息 → assistant "system": "system", # 系统消息 → system } for msg in messages: role = role_map.get(msg.get("sender_type", ""), "user") content = msg.get("content", "") if content: # 跳过系统消息(已有 system prompt) if msg.get("sender_type") == "system": continue context.append({"role": role, "content": content}) return context async def _call_wingman_api( self, context_messages: List[Dict[str, str]], ) -> Optional[str]: """调用 Wingman Agent API(非流式)。 Args: context_messages: OpenAI 格式的消息列表 Returns: Optional[str]: AI 回复内容,失败时返回 None """ payload = { "model": "Chat", "messages": context_messages, "stream": False, "temperature": 0.3, # 适中的温度,保证准确性同时有一定灵活性 } try: client = await self._get_client() logger.info(f"调用 Wingman API: messages_count={len(context_messages)}") response = await client.post(self.api_url, json=payload) response.raise_for_status() data = response.json() # 解析 OpenAI 兼容格式的返回 choices = data.get("choices", []) if not choices: logger.warning("Wingman API 返回空 choices") return None reply_content = choices[0]["message"]["content"] logger.info(f"Wingman API 返回: content_length={len(reply_content)}") return reply_content except httpx.TimeoutException: logger.error("Wingman API 超时") return None except httpx.HTTPStatusError as e: logger.error(f"Wingman API HTTP 错误: status={e.response.status_code}") return None except Exception as e: logger.error(f"Wingman API 调用失败: {e}") return None def _parse_json_response( self, content: str, default: Dict[str, Any], ) -> Dict[str, Any]: """解析 AI 返回的 JSON 内容。 Wingman Agent 可能返回带 markdown 代码块的 JSON, 也可能返回纯 JSON。此方法尝试多种解析方式。 Args: content: AI 返回的原始文本 default: 解析失败时的默认值 Returns: Dict: 解析后的字典,失败时返回默认值 """ if not content: return default # 尝试 1:直接解析 try: return json.loads(content) except json.JSONDecodeError: pass # 尝试 2:提取 markdown 代码块中的 JSON # AI 可能返回 ```json ... ``` 格式 import re json_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', content, re.DOTALL) if json_match: try: return json.loads(json_match.group(1).strip()) except json.JSONDecodeError: pass # 尝试 3:查找第一个 { 到最后一个 } 之间的内容 start = content.find('{') end = content.rfind('}') if start != -1 and end != -1 and end > start: try: return json.loads(content[start:end + 1]) except json.JSONDecodeError: pass logger.warning(f"Wingman JSON 解析失败,使用默认值: {content[:200]}") return default def _estimate_confidence(self, content: str) -> float: """估算 AI 草稿回复的置信度。 基于回复长度和内容特征估算一个粗略的置信度值。 - 回复过短(< 10 字符):低置信度 - 回复包含不确定措辞:降低置信度 - 回复长度适中、内容具体:高置信度 Args: content: AI 回复内容 Returns: float: 置信度(0.0 - 1.0) """ if not content or len(content.strip()) < 5: return 0.2 confidence = 0.8 # 基础置信度 # 回复过短降低置信度 if len(content) < 10: confidence -= 0.3 elif len(content) < 30: confidence -= 0.1 # 包含不确定措辞降低置信度 uncertain_phrases = ["可能", "大概", "也许", "不确定", "建议您"] for phrase in uncertain_phrases: if phrase in content: confidence -= 0.05 # 包含具体步骤或链接提高置信度 confident_phrases = ["步骤", "请按以下", "点击", "打开", "http"] for phrase in confident_phrases: if phrase in content: confidence += 0.05 # 限制在 0.0 - 1.0 范围内 return max(0.0, min(1.0, confidence))