Files
wecom_it_smart_desk/backend/app/services/wingman_service.py
T

998 lines
37 KiB
Python
Raw Normal View History

# =============================================================================
# 企微IT智能服务台 — AI Wingman 服务(坐席智能副驾驶)
# =============================================================================
# 说明:复用 Dify 基础设施,使用独立的 Wingman AgentAgent 2),
# 与员工端 AIAgent 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 AgentAgent 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 辅助消息框 Prompt4 个功能,与 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知识库引用内容"
)
# --------------------------------------------------------------------------
# 知识建议生成专用 PromptTier0 / 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