998 lines
37 KiB
Python
998 lines
37 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\"}"
|
||
)
|
||
|
||
# --------------------------------------------------------------------------
|
||
# AI 辅助消息框 Prompt(4 个功能,与 PRD v1.0 对齐)
|
||
# --------------------------------------------------------------------------
|
||
|
||
_AUTOCOMPLETE_SYSTEM_PROMPT: str = (
|
||
"你是一个IT服务坐席输入助手。根据坐席当前正在输入的内容和对话上下文,补齐下一句话。\n"
|
||
"要求:\n"
|
||
"1. 补齐内容自然衔接当前文字,不要重复已有内容\n"
|
||
"2. 长度控制在1-2个短句,不超过80字\n"
|
||
"3. 语气专业、简洁,符合IT服务规范\n"
|
||
"4. 只返回补齐的文字,不要加引号或其他标记"
|
||
)
|
||
|
||
_TONE_ADJUST_SYSTEM_PROMPT: str = (
|
||
"你是一个IT服务话术改写助手。将坐席选中的文字改写为指定风格。\n\n"
|
||
"语气定义:\n"
|
||
"- 专业(professional):使用准确的技术术语,结构化表达,去除口语化内容\n"
|
||
"- 友好(friendly):适当增加问候和关心语,语气更亲和\n"
|
||
"- 简洁(concise):去除冗余,直奔主题,控制字数\n\n"
|
||
"要求:\n"
|
||
"1. 保持原意不变,只调整语气和表达方式\n"
|
||
"2. 改写后的文字长度与原文相近(±30%)\n"
|
||
"3. 符合IT服务坐席的专业规范\n"
|
||
"4. 以JSON格式输出,包含 rewritten_text、tone、changes_summary 三个字段\n"
|
||
"5. changes_summary 简述所做的修改"
|
||
)
|
||
|
||
_POLISH_SYSTEM_PROMPT: str = (
|
||
"你是一个IT服务文字精修助手。对坐席输入的文字进行指定操作的处理。\n\n"
|
||
"操作定义:\n"
|
||
"- 扩写(expand):在原文基础上增加操作步骤、注意事项、解释说明,使回复更完整\n"
|
||
"- 压缩(compress):精简表达,去除重复和冗余,保留核心信息,控制字数\n"
|
||
"- 纠错(correct):检查并修正错别字、语法错误、标点符号、格式问题\n\n"
|
||
"要求:\n"
|
||
"1. 保持原意不变\n"
|
||
"2. 以JSON格式输出,包含 polished_text、action、changes_summary 三个字段\n"
|
||
"3. changes_summary 简述所做的修改"
|
||
)
|
||
|
||
_REWRITE_SYSTEM_PROMPT: str = (
|
||
"你是一个IT服务智能回复助手。基于对话上下文和知识库,为坐席生成3个不同风格的备选回复。\n\n"
|
||
"版本要求:\n"
|
||
"- 版本1:简洁直接,一句话说明问题和解决方案\n"
|
||
"- 版本2:详细带步骤,包含操作步骤和注意事项\n"
|
||
"- 版本3:带知识库引用,引用相关文档并给出权威解决方案\n\n"
|
||
"要求:\n"
|
||
"1. 3个版本内容不重复,各有侧重\n"
|
||
"2. 每个版本独立成段,用 --- 分隔(三个减号,独占一行)\n"
|
||
"3. 只返回回复内容,不要加额外解释\n\n"
|
||
"输出格式:\n"
|
||
"版本1简洁内容\n---\n版本2详细内容\n---\n版本3知识库引用内容"
|
||
)
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 知识建议生成专用 Prompt(Tier0 / T03 — 通道 A/B 复用)
|
||
# --------------------------------------------------------------------------
|
||
_KNOWLEDGE_SUGGESTION_PROMPT: str = (
|
||
"你是一个IT知识库优化助手,基于对话上下文分析知识库的不足,"
|
||
"生成结构化的知识库优化建议。\n\n"
|
||
"分析以下对话,判断是否需要新增FAQ或更新已有知识条目。\n"
|
||
"如果AI回复被标记为无用,生成更新建议;如果AI无法解决需转人工,生成新增FAQ建议。\n\n"
|
||
"必须以JSON格式输出,包含以下字段:\n"
|
||
"- suggestion_type: 建议类型,\"new_faq\" 或 \"update\"\n"
|
||
"- title: 问题标题(简洁明了)\n"
|
||
"- content: 答案内容(分步骤、可操作)\n"
|
||
"- category: 分类,只能是 硬件/软件/网络/安全/账号/其他 之一\n"
|
||
"- tags: 标签列表,如 [\"VPN\", \"连接\"]\n"
|
||
"- confidence: 你对这个建议的信心度,0.0-1.0之间\n"
|
||
"- issue: 对应的Neo4j图问题节点名称,如\"VPN问题\"\n"
|
||
"- action: 对应的Neo4j图动作节点名称,如\"VPN连接修复\"\n"
|
||
"- relation_type: 图关系类型,\"LEADS_TO\" 或 \"RELATES_TO\"\n"
|
||
"- parent_issue: 父问题名称(如果没有则为空字符串)\n\n"
|
||
"输出格式示例:\n"
|
||
"{\"suggestion_type\": \"new_faq\", \"title\": \"VPN连不上怎么办\", "
|
||
"\"content\": \"1.检查网络连接 2.重启VPN客户端 3.联系IT支持\", "
|
||
"\"category\": \"网络\", \"tags\": [\"VPN\", \"连接\"], "
|
||
"\"confidence\": 0.86, \"issue\": \"VPN问题\", "
|
||
"\"action\": \"VPN连接修复\", \"relation_type\": \"LEADS_TO\", "
|
||
"\"parent_issue\": \"网络问题\"}"
|
||
)
|
||
|
||
def __init__(self, redis_client=None):
|
||
"""初始化 Wingman 服务。
|
||
|
||
从配置读取 Wingman Agent 的 API 地址和认证信息。
|
||
独立于 AIService,使用自己的 httpx 客户端。
|
||
|
||
Args:
|
||
redis_client: Redis 异步客户端(可选,用于补齐结果缓存)
|
||
"""
|
||
# 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
|
||
|
||
# Redis 客户端(可选,用于补齐结果缓存)
|
||
self._redis = redis_client
|
||
|
||
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
|
||
|
||
# --------------------------------------------------------------------------
|
||
# AI 辅助消息框:4 个核心方法
|
||
# --------------------------------------------------------------------------
|
||
|
||
async def generate_completion(
|
||
self,
|
||
conversation_id: str,
|
||
current_text: str,
|
||
messages: List[Dict[str, Any]],
|
||
max_length: int = 80,
|
||
) -> Dict[str, Any]:
|
||
"""自动补齐。
|
||
|
||
根据坐席当前输入和对话上下文,生成下一句的补齐建议。
|
||
使用 Redis 缓存(TTL=30s)减少重复请求。
|
||
|
||
Args:
|
||
conversation_id: 会话ID
|
||
current_text: 坐席当前输入的文本
|
||
messages: 会话消息历史列表
|
||
max_length: 补齐建议最大长度(默认 80 字符)
|
||
|
||
Returns:
|
||
Dict: {"completion": str, "confidence": float}
|
||
"""
|
||
# 1. 检查 Redis 缓存
|
||
cache_key = self._make_cache_key(current_text, conversation_id)
|
||
cached = await self._get_cache(cache_key)
|
||
if cached:
|
||
logger.info(f"自动补齐缓存命中: key={cache_key}")
|
||
return cached
|
||
|
||
# 2. 构建上下文消息
|
||
context = self._build_context_messages(
|
||
messages, self._AUTOCOMPLETE_SYSTEM_PROMPT
|
||
)
|
||
context.append({
|
||
"role": "user",
|
||
"content": (
|
||
f"坐席正在输入:{current_text}\n"
|
||
f"请补齐下一句话(不超过{max_length}字)"
|
||
),
|
||
})
|
||
|
||
# 3. 调用 Dify(降低 temperature 提高准确性)
|
||
try:
|
||
result = await self._call_wingman_api(context, temperature=0.2)
|
||
if result is None:
|
||
return {"completion": "", "confidence": 0.0}
|
||
|
||
completion = result.strip()
|
||
confidence = self._estimate_confidence(completion)
|
||
|
||
response = {"completion": completion, "confidence": confidence}
|
||
|
||
# 4. 写入 Redis 缓存(TTL=30s)
|
||
if self._redis:
|
||
await self._set_cache(cache_key, response, ttl=30)
|
||
|
||
return response
|
||
|
||
except Exception as e:
|
||
logger.error(f"自动补齐失败: {e}")
|
||
return {"completion": "", "confidence": 0.0}
|
||
|
||
async def adjust_tone(
|
||
self,
|
||
conversation_id: str,
|
||
selected_text: str,
|
||
full_text: str,
|
||
tone: str,
|
||
messages: List[Dict[str, Any]],
|
||
) -> Dict[str, Any]:
|
||
"""语气调整。
|
||
|
||
将坐席选中的文字改写为指定语气风格(专业/友好/简洁)。
|
||
|
||
Args:
|
||
conversation_id: 会话ID
|
||
selected_text: 选中的文字
|
||
full_text: 输入框完整内容(供 AI 理解上下文)
|
||
tone: 目标语气:professional/friendly/concise
|
||
messages: 会话消息历史列表
|
||
|
||
Returns:
|
||
Dict: {"rewritten_text": str, "tone": str, "changes_summary": str}
|
||
"""
|
||
tone_label = {"professional": "专业", "friendly": "友好", "concise": "简洁"}
|
||
|
||
context = self._build_context_messages(
|
||
messages, self._TONE_ADJUST_SYSTEM_PROMPT
|
||
)
|
||
context.append({
|
||
"role": "user",
|
||
"content": (
|
||
f"目标语气:{tone_label.get(tone, tone)}\n"
|
||
f"原文:{selected_text}\n"
|
||
f"完整输入框内容(供上下文参考):{full_text}\n"
|
||
f"请将原文改写为{tone_label.get(tone, tone)}风格。"
|
||
),
|
||
})
|
||
|
||
try:
|
||
result = await self._call_wingman_api(context, temperature=0.3)
|
||
if result is None:
|
||
return {
|
||
"rewritten_text": "",
|
||
"tone": tone,
|
||
"changes_summary": "AI 服务暂不可用",
|
||
}
|
||
|
||
parsed = self._parse_json_response(result, {})
|
||
return {
|
||
"rewritten_text": parsed.get("rewritten_text", result.strip()),
|
||
"tone": tone,
|
||
"changes_summary": parsed.get("changes_summary", "已完成语气调整"),
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"语气调整失败: {e}")
|
||
return {
|
||
"rewritten_text": "",
|
||
"tone": tone,
|
||
"changes_summary": "AI 服务暂不可用",
|
||
}
|
||
|
||
async def polish_text(
|
||
self,
|
||
conversation_id: str,
|
||
text: str,
|
||
action: str,
|
||
messages: List[Dict[str, Any]],
|
||
) -> Dict[str, Any]:
|
||
"""文字润色。
|
||
|
||
对坐席输入的文字进行扩写/压缩/纠错处理。
|
||
|
||
Args:
|
||
conversation_id: 会话ID
|
||
text: 待润色文字
|
||
action: 润色操作:expand(扩写)/compress(压缩)/correct(纠错)
|
||
messages: 会话消息历史列表
|
||
|
||
Returns:
|
||
Dict: {"polished_text": str, "action": str, "changes_summary": str}
|
||
"""
|
||
action_label = {"expand": "扩写", "compress": "压缩", "correct": "纠错"}
|
||
|
||
context = self._build_context_messages(
|
||
messages, self._POLISH_SYSTEM_PROMPT
|
||
)
|
||
context.append({
|
||
"role": "user",
|
||
"content": (
|
||
f"操作类型:{action_label.get(action, action)}\n"
|
||
f"原文:{text}\n"
|
||
f"请对原文进行{action_label.get(action, action)}处理。"
|
||
),
|
||
})
|
||
|
||
try:
|
||
result = await self._call_wingman_api(context, temperature=0.3)
|
||
if result is None:
|
||
return {
|
||
"polished_text": "",
|
||
"action": action,
|
||
"changes_summary": "AI 服务暂不可用",
|
||
}
|
||
|
||
parsed = self._parse_json_response(result, {})
|
||
return {
|
||
"polished_text": parsed.get("polished_text", result.strip()),
|
||
"action": action,
|
||
"changes_summary": parsed.get("changes_summary", "已完成润色"),
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"文字润色失败: {e}")
|
||
return {
|
||
"polished_text": "",
|
||
"action": action,
|
||
"changes_summary": "AI 服务暂不可用",
|
||
}
|
||
|
||
async def rewrite_versions(
|
||
self,
|
||
conversation_id: str,
|
||
current_text: str,
|
||
messages: List[Dict[str, Any]],
|
||
generate_count: int = 3,
|
||
include_knowledge: bool = True,
|
||
) -> Dict[str, Any]:
|
||
"""智能改写。
|
||
|
||
基于对话上下文和知识库,生成多个不同风格的备选回复。
|
||
版本1: 简洁直接 / 版本2: 详细带步骤 / 版本3: 带知识库引用。
|
||
|
||
Args:
|
||
conversation_id: 会话ID
|
||
current_text: 当前输入文本(可为空)
|
||
messages: 会话消息历史列表
|
||
generate_count: 生成版本数(默认 3)
|
||
include_knowledge: 是否包含知识库引用版本(默认 True)
|
||
|
||
Returns:
|
||
Dict: {"versions": [{"text": str, "style": str, "source": str}, ...]}
|
||
"""
|
||
context = self._build_context_messages(
|
||
messages, self._REWRITE_SYSTEM_PROMPT
|
||
)
|
||
|
||
# 知识库检索(版本3)
|
||
knowledge_snippets = ""
|
||
if include_knowledge:
|
||
query = current_text or " ".join(
|
||
[m.get("content", "") for m in messages[-3:]
|
||
])
|
||
kb_result = await self._search_knowledge(query)
|
||
if kb_result:
|
||
knowledge_snippets = kb_result
|
||
logger.info(f"改写知识库检索成功: {len(kb_result)} 字符")
|
||
else:
|
||
logger.info("改写知识库检索为空,降级为仅对话上下文")
|
||
|
||
user_content = (
|
||
f"当前输入:{current_text or '无(请基于对话上下文生成)'}\n"
|
||
)
|
||
if knowledge_snippets:
|
||
user_content += f"\n知识库参考资料:\n{knowledge_snippets}\n"
|
||
user_content += "\n请生成3个不同风格的备选回复。"
|
||
|
||
context.append({"role": "user", "content": user_content})
|
||
|
||
try:
|
||
result = await self._call_wingman_api(context, temperature=0.6)
|
||
if result is None:
|
||
return {"versions": []}
|
||
|
||
# 按 --- 分割版本
|
||
parts = [p.strip() for p in result.split("\n---\n") if p.strip()]
|
||
|
||
style_map = [
|
||
("简洁直接", "对话上下文"),
|
||
("详细带步骤", "对话上下文"),
|
||
(
|
||
"带知识库引用",
|
||
"RAGFlow知识库" if knowledge_snippets else "对话上下文",
|
||
),
|
||
]
|
||
|
||
versions = []
|
||
for i, text in enumerate(parts[:generate_count]):
|
||
if i < len(style_map):
|
||
style, source = style_map[i]
|
||
else:
|
||
style, source = f"版本{i+1}", "对话上下文"
|
||
versions.append({"text": text, "style": style, "source": source})
|
||
|
||
return {"versions": versions}
|
||
|
||
except Exception as e:
|
||
logger.error(f"智能改写失败: {e}")
|
||
return {"versions": []}
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 核心方法 4:生成知识库优化建议(Tier0 / T03 — 复用现有范式)
|
||
# --------------------------------------------------------------------------
|
||
async def generate_knowledge_suggestion(
|
||
self,
|
||
context_messages: List[Dict[str, Any]],
|
||
) -> Dict[str, Any]:
|
||
"""生成知识库优化建议(用于知识库自动迭代)。
|
||
|
||
传入对话上下文,让 Wingman Agent 分析并生成结构化的知识库优化建议。
|
||
复用 _build_context_messages + _call_wingman_api + _parse_json_response 范式。
|
||
|
||
Args:
|
||
context_messages: 对话消息历史列表
|
||
|
||
Returns:
|
||
Dict: {
|
||
"suggestion_type": str, # "new_faq" / "update"
|
||
"title": str, # 问题标题
|
||
"content": str, # 答案内容
|
||
"category": str, # 分类
|
||
"tags": list[str], # 标签列表
|
||
"confidence": float, # 置信度(0.0-1.0)
|
||
"issue": str, # 图节点:问题名称
|
||
"action": str, # 图节点:动作名称
|
||
"relation_type": str, # 图关系类型
|
||
"parent_issue": str, # 父 Issue 名称
|
||
}
|
||
"""
|
||
# 构建对话上下文消息列表(使用知识建议专用 Prompt)
|
||
context = self._build_context_messages(
|
||
context_messages, self._KNOWLEDGE_SUGGESTION_PROMPT
|
||
)
|
||
|
||
# 默认建议(降级时使用)
|
||
default_suggestion: Dict[str, Any] = {
|
||
"suggestion_type": "new_faq",
|
||
"title": "",
|
||
"content": "",
|
||
"category": "其他",
|
||
"tags": [],
|
||
"confidence": 0.0,
|
||
"issue": "",
|
||
"action": "",
|
||
"relation_type": "LEADS_TO",
|
||
"parent_issue": "",
|
||
}
|
||
|
||
try:
|
||
result = await self._call_wingman_api(context)
|
||
|
||
if result is None:
|
||
logger.warning("Wingman 知识建议生成失败(API 返回 None)")
|
||
return default_suggestion
|
||
|
||
# 尝试解析 JSON 格式的建议
|
||
parsed = self._parse_json_response(result, default_suggestion)
|
||
|
||
# 规范化字段
|
||
suggestion: Dict[str, Any] = {
|
||
"suggestion_type": parsed.get(
|
||
"suggestion_type", default_suggestion["suggestion_type"]
|
||
),
|
||
"title": parsed.get("title", default_suggestion["title"]),
|
||
"content": parsed.get("content", default_suggestion["content"]),
|
||
"category": parsed.get("category", default_suggestion["category"]),
|
||
"tags": parsed.get("tags", default_suggestion["tags"]),
|
||
"confidence": float(parsed.get("confidence", 0.0)),
|
||
"issue": parsed.get("issue", default_suggestion["issue"]),
|
||
"action": parsed.get("action", default_suggestion["action"]),
|
||
"relation_type": parsed.get(
|
||
"relation_type", default_suggestion["relation_type"]
|
||
),
|
||
"parent_issue": parsed.get(
|
||
"parent_issue", default_suggestion["parent_issue"]
|
||
),
|
||
}
|
||
|
||
# 如果 Dify 未返回 confidence,使用启发式估算
|
||
if suggestion["confidence"] == 0.0:
|
||
suggestion["confidence"] = self._estimate_confidence(
|
||
suggestion["content"]
|
||
)
|
||
|
||
logger.info(
|
||
f"知识建议生成完成: type={suggestion['suggestion_type']}, "
|
||
f"title={suggestion['title'][:50]}, "
|
||
f"confidence={suggestion['confidence']}"
|
||
)
|
||
return suggestion
|
||
|
||
except Exception as e:
|
||
logger.error(f"Wingman 知识建议生成异常: {e}")
|
||
return default_suggestion
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 内部方法
|
||
# --------------------------------------------------------------------------
|
||
|
||
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]],
|
||
temperature: float = 0.3,
|
||
) -> Optional[str]:
|
||
"""调用 Wingman Agent API(非流式)。
|
||
|
||
Args:
|
||
context_messages: OpenAI 格式的消息列表
|
||
temperature: 温度参数(0-1),默认 0.3。不同功能使用不同值:
|
||
补齐 0.2 / 语气调整 0.3 / 润色 0.3 / 改写 0.6
|
||
|
||
Returns:
|
||
Optional[str]: AI 回复内容,失败时返回 None
|
||
"""
|
||
payload = {
|
||
"model": "Chat",
|
||
"messages": context_messages,
|
||
"stream": False,
|
||
"temperature": 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))
|
||
|
||
# --------------------------------------------------------------------------
|
||
# AI 辅助消息框:私有辅助方法
|
||
# --------------------------------------------------------------------------
|
||
|
||
def _make_cache_key(self, text: str, conversation_id: str) -> str:
|
||
"""生成 Redis 缓存 key。
|
||
|
||
格式: wingman:autocomplete:{md5(text + conversation_id)}
|
||
对输入文本 + 会话ID 做 MD5 哈希,避免 key 过长。
|
||
|
||
Args:
|
||
text: 输入文本
|
||
conversation_id: 会话ID
|
||
|
||
Returns:
|
||
str: Redis 缓存 key
|
||
"""
|
||
import hashlib
|
||
raw = f"{text}:{conversation_id}"
|
||
digest = hashlib.md5(raw.encode()).hexdigest()
|
||
return f"wingman:autocomplete:{digest}"
|
||
|
||
async def _get_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
||
"""读取 Redis 缓存。
|
||
|
||
Redis 不可用时返回 None(降级),不阻塞主流程。
|
||
|
||
Args:
|
||
key: 缓存 key
|
||
|
||
Returns:
|
||
Optional[Dict]: 缓存的补齐结果,不存在或失败时返回 None
|
||
"""
|
||
if self._redis is None:
|
||
return None
|
||
try:
|
||
data = await self._redis.get(key)
|
||
if data:
|
||
return json.loads(data)
|
||
except Exception as e:
|
||
logger.warning(f"Redis 缓存读取失败: {e}")
|
||
return None
|
||
|
||
async def _set_cache(
|
||
self, key: str, value: Dict[str, Any], ttl: int = 30
|
||
) -> None:
|
||
"""写入 Redis 缓存。
|
||
|
||
Redis 不可用时静默跳过(降级),不阻塞主流程。
|
||
|
||
Args:
|
||
key: 缓存 key
|
||
value: 缓存值(会序列化为 JSON)
|
||
ttl: 过期时间(秒),默认 30 秒
|
||
"""
|
||
if self._redis is None:
|
||
return
|
||
try:
|
||
await self._redis.setex(
|
||
key, ttl, json.dumps(value, ensure_ascii=False)
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"Redis 缓存写入失败: {e}")
|
||
|
||
async def _search_knowledge(self, query: str) -> Optional[str]:
|
||
"""调用 RAGFlow 检索知识库。
|
||
|
||
用于改写版本3,从知识库获取参考资料。
|
||
检索失败时返回 None(降级),不影响其他版本生成。
|
||
|
||
Args:
|
||
query: 检索查询(基于对话上下文)
|
||
|
||
Returns:
|
||
Optional[str]: 合并后的知识片段文本,失败或无结果时返回 None
|
||
"""
|
||
try:
|
||
from app.integrations.factory import build_ragflow_client
|
||
|
||
client = await build_ragflow_client()
|
||
if client is None:
|
||
logger.warning("RAGFlow 客户端不可用")
|
||
return None
|
||
|
||
# 使用默认知识库检索(不指定 dataset_ids 则使用全局)
|
||
result = await client.retrieval(
|
||
question=query,
|
||
similarity_threshold=0.2,
|
||
top_k=3,
|
||
)
|
||
|
||
if result and result.chunks:
|
||
snippets = "\n\n".join([
|
||
f"[{c.document_keyword or '未知文档'}] {c.content[:500]}"
|
||
for c in result.chunks[:3]
|
||
])
|
||
return snippets
|
||
|
||
except Exception as e:
|
||
logger.error(f"RAGFlow 检索失败: {e}")
|
||
|
||
return None
|