446 lines
16 KiB
Python
446 lines
16 KiB
Python
# =============================================================================
|
||
# 企微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))
|