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

998 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微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