chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
# =============================================================================
|
||||
# 企微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))
|
||||
Reference in New Issue
Block a user