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

687 lines
29 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 服务(Dify 接入)
# =============================================================================
# 做什么:封装 Dify API 调用,实现 AI 自动回复
# 为什么:
# - ARCHITECTURE.md 设计了 ai_handling 状态,但当前未实现
# - 现有系统交接文档提供了 Dify API 地址和 Key
# - 这是实现「AI 自助解决」的核心模块
# 依赖:需要 Dify API 可达(生产环境 http://yw-dify.dc.servyou-it.com
# =============================================================================
import json
import logging
import asyncio
import time
from typing import Any, Dict, List, Optional, AsyncGenerator
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class AIService:
"""AI 服务:封装 Dify API,提供 AI 回复能力。
支持三种调用模式:
1. 非流式(简单场景):一次性获取完整回复(经 dify2openai 代理)
2. 流式(推荐):SSE 流式返回,前端可逐字显示(经 dify2openai 代理)
3. ★ 原生直连(v2.1 新增):绕过代理,直连 Dify /v1/chat-messages
v2.1 改造原因:
- dify2openai 代理存在 [object Object] 序列化 bug
- 直连 Dify 原生 API 响应格式更简单(answer 字段直接返回内容)
- 结构化回复(get_structured_reply)优先使用原生 API
参考:现有系统交接文档
- 代理 URL: http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions
- 原生 URL: http://yw-dify.dc.servyou-it.com/v1/chat-messages
- Key: app-7jkRkAzvX4QM9v9SM3P8mMEO(审批意图副本,推荐使用)
- ⚠️ 已弃用老Key app-UaTWYdBSwN6VktKQlbh5YN5H(老线上应用,禁止使用)
"""
def __init__(self):
"""初始化 AI 服务。
做什么:从配置读取 Dify API 地址和认证信息
为什么:集中管理 API 配置,便于切换测试/生产环境
"""
# Dify 兼容 OpenAI 格式的 API 端点(代理)
self.api_url = settings.dify_api_url
# Dify API Key(格式:base_url|app_id|app_name
self.api_key = settings.dify_api_key
# 请求超时(秒)
self.timeout = settings.dify_timeout
# ★ v2.1: Dify 原生 API 配置(绕过代理)
self.native_base_url = settings.dify_native_base_url
self.native_api_key = settings.dify_native_api_key
# httpx 异步客户端(复用连接池)— 代理用
self._client: Optional[httpx.AsyncClient] = None
# ★ v2.1: 原生 API 专用客户端(不同 auth header
self._native_client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
"""获取或创建 httpx 异步客户端(代理用)。
做什么:懒加载 httpx.AsyncClient,复用连接池
为什么:避免每次请求都创建新连接,提升性能
"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
# v4.0 P0-6: proxy 路径用独立超时(12s),避免与 native 串行叠加超 wait_for(30s)
timeout=httpx.Timeout(settings.dify_proxy_timeout),
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
)
return self._client
async def _get_native_client(self) -> httpx.AsyncClient:
"""获取或创建 Dify 原生 API 专用客户端(v2.1 新增)。
做什么:懒加载 httpx.AsyncClient,使用 Dify 原生 auth 格式
为什么:原生 API 使用 `Bearer app-xxx` 认证(非管道分隔格式),
需要独立客户端避免 auth header 冲突
"""
if self._native_client is None or self._native_client.is_closed:
self._native_client = httpx.AsyncClient(
# v4.0 P0-6: native 路径用独立超时(12s),预算 12+12+开销 < wait_for(30s)
timeout=httpx.Timeout(settings.dify_native_timeout),
headers={
"Authorization": f"Bearer {self.native_api_key}",
"Content-Type": "application/json",
}
)
return self._native_client
async def close(self):
"""关闭 httpx 客户端。
做什么:释放连接池资源(代理 + 原生)
为什么:避免连接泄漏,尤其在长期运行的 FastAPI 应用中
"""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
logger.debug("AIService httpx client closed")
if self._native_client and not self._native_client.is_closed:
await self._native_client.aclose()
self._native_client = None
logger.debug("AIService native client closed")
# --------------------------------------------------------------------------
# 非流式调用:一次性获取 AI 完整回复
# --------------------------------------------------------------------------
async def get_reply(
self,
message: str,
conversation_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> Dict[str, Any]:
"""调用 Dify API 获取 AI 回复(非流式)。
Args:
message: 员工发送的消息内容
conversation_id: 会话ID(用于 Dify 多轮对话上下文)
user_id: 员工企微 UserID(用于 Dify 用户标识)
Returns:
Dict: {
"content": str, # AI 回复内容
"hit": bool, # 是否命中知识库(可回复)
"conversation_id": str, # Dify 会话ID(用于后续多轮对话)
"usage": dict, # Token 用量(可选)
}
做什么:发送消息到 Dify,解析返回内容,判断是否能回复
为什么:
- 非流式适合简单场景,代码简单
- 返回结构兼容 OpenAI Chat Completions 格式
- 通过回复内容判断是否命中知识库(有实质内容 = 命中)
"""
payload = {
"model": "Chat", # Dify 应用名称(来自 API Key 格式)
"messages": [
{"role": "user", "content": message}
],
"stream": False, # 非流式
"temperature": 0.1, # 低温度,保证回答稳定性
}
# 传入 Dify 会话ID,保持多轮对话上下文
if conversation_id:
payload["conversation_id"] = conversation_id
# 传入用户标识(Dify 侧用于日志和追溯)
if user_id:
payload["user"] = user_id
try:
client = await self._get_client()
logger.info(f"调用 Dify API: message={message[:50]}...")
response = await client.post(self.api_url, json=payload)
response.raise_for_status()
data = response.json()
# 解析 OpenAI 兼容格式的返回
# 格式:{"choices": [{"message": {"content": "..."}}]}
choices = data.get("choices", [])
if not choices:
logger.warning("Dify API 返回空 choices")
return {
"content": "",
"hit": False,
"conversation_id": conversation_id or "",
"usage": {},
}
reply_content = choices[0]["message"]["content"]
# 判断是否命中知识库:
# 策略1:检查内容是否为空或过长(Dify 可能返回提示语)
# 策略2:检查是否包含「抱歉」「不知道」等无法回答的特征词
hit = self._check_knowledge_hit(reply_content)
# 提取 Dify 返回的 conversation_id(用于多轮对话)
dify_conv_id = data.get("conversation_id", conversation_id or "")
logger.info(
f"Dify API 返回: hit={hit}, "
f"content_length={len(reply_content)}, "
f"conv_id={dify_conv_id[:20] if dify_conv_id else '(new)'}"
)
return {
"content": reply_content,
"hit": hit,
"conversation_id": dify_conv_id,
"usage": data.get("usage", {}),
}
except httpx.TimeoutException:
logger.error("Dify API 超时")
return {
"content": "⏰ AI 服务响应超时,请稍后再试或输入「IT」转人工。",
"hit": False,
"conversation_id": conversation_id or "",
"usage": {},
}
except httpx.HTTPStatusError as e:
logger.error(f"Dify API HTTP 错误: status={e.response.status_code}")
return {
"content": "⚠️ AI 服务暂时不可用,请输入「IT」转人工。",
"hit": False,
"conversation_id": conversation_id or "",
"usage": {},
}
except Exception as e:
logger.error(f"Dify API 调用失败: {e}")
return {
"content": "⚠️ AI 服务异常,请输入「IT」转人工。",
"hit": False,
"conversation_id": conversation_id or "",
"usage": {},
}
# --------------------------------------------------------------------------
# 流式调用:SSE 流式返回(供 WebSocket 推送给前端)
# --------------------------------------------------------------------------
# v4.0 批次4get_reply_stream 已删除(~96 行)
# v2.0 起 AI 回复改为 blocking + JSON 结构化(get_structured_reply),
# 流式 SSE 路径零调用,属死代码。
# --------------------------------------------------------------------------
# 结构化调用:blocking 模式,返回解析后的 JSON {text, action, options}
# --------------------------------------------------------------------------
async def _call_dify_native(
self,
message: str,
conversation_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""直连 Dify 原生 APIv2.1 新增,绕过 dify2openai 代理)。
做什么:
1. POST {base_url}/v1/chat-messagesblocking 模式)
2. 解析返回的 answer 字段(直接包含 AI 回复内容)
3. 返回 raw_content 供上层 JSON 解析
为什么:
- dify2openai 代理存在 [object Object] 序列化 bug
- 原生 API 响应格式更简单:{"answer": "...", "conversation_id": "..."}
- 不经过 OpenAI 兼容层转换,避免格式损失
Args:
message: 员工发送的消息内容
conversation_id: Dify 会话ID(用于多轮对话上下文)
user_id: 员工企微 UserID
Returns:
Dict 或 None
- 成功:{"raw_content": str, "conversation_id": str, "response_time_ms": float}
- 失败:None(调用方应 fallback 到代理路径)
"""
if not self.native_base_url or not self.native_api_key:
return None # 未配置原生 API,调用方走代理路径
url = f"{self.native_base_url}/v1/chat-messages"
payload = {
"inputs": {},
"query": message,
"response_mode": "blocking", # 阻塞模式,等待完整回复
"user": user_id or "unknown",
}
# 传入 Dify 会话ID,保持多轮对话上下文
if conversation_id:
payload["conversation_id"] = conversation_id
try:
client = await self._get_native_client()
start_time = time.perf_counter()
logger.info(f"调用 Dify 原生 API: message={message[:50]}...")
response = await client.post(url, json=payload)
response.raise_for_status()
response_time_ms = (time.perf_counter() - start_time) * 1000
data = response.json()
# 原生 API 返回格式:{"answer": "...", "conversation_id": "..."}
raw_content = data.get("answer", "")
dify_conv_id = data.get("conversation_id", conversation_id or "")
if not raw_content:
logger.warning("Dify 原生 API 返回空 answer")
return None
logger.info(
f"Dify 原生 API 返回: content_len={len(raw_content)}, "
f"response_time={response_time_ms:.0f}ms, "
f"conv_id={dify_conv_id[:20] if dify_conv_id else '(new)'}"
)
return {
"raw_content": raw_content,
"conversation_id": dify_conv_id,
"response_time_ms": response_time_ms,
}
except httpx.TimeoutException:
logger.warning("Dify 原生 API 超时,将回退到代理路径")
return None
except httpx.HTTPStatusError as e:
logger.warning(f"Dify 原生 API HTTP 错误: status={e.response.status_code},将回退到代理路径")
return None
except Exception as e:
logger.warning(f"Dify 原生 API 调用失败: {e},将回退到代理路径")
return None
async def get_structured_reply(
self,
message: str,
conversation_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> Dict[str, Any]:
"""调用 Dify API 获取结构化 AI 回复(blocking 模式,JSON 输出)。
改造后的 Dify 主对话应用输出 JSON 格式:
{"text": "...", "action": {...}|null, "options": [...]|null}
本方法负责:
1. 以 blocking 模式调用 Difystream=False
2. 尝试解析返回内容为 JSON
3. 解析失败时降级为纯文本(向后兼容旧 Prompt)
4. 返回统一结构
Args:
message: 员工发送的消息内容(可能包含图片描述前缀)
conversation_id: Dify 会话ID(用于多轮对话上下文)
user_id: 员工企微 UserID
Returns:
Dict: {
"text": str, # 回复文字(始终有值)
"action": dict|None, # 操作卡片数据(审批/入口推荐)
"options": list|None, # 选项按钮列表
"hit": bool, # 是否命中知识库
"conversation_id": str, # Dify 会话ID
"raw_content": str, # 原始返回内容(调试用)
"is_structured": bool, # 是否成功解析为 JSON
}
"""
# ★ v2.1: 优先尝试 Dify 原生 API(绕过 dify2openai 代理)
# 为什么:代理存在 [object Object] 序列化 bug,原生 API 直接返回 answer 字段
# 降级:原生 API 未配置或调用失败时,自动回退到下方代理路径
native_result = await self._call_dify_native(message, conversation_id, user_id)
if native_result is not None:
raw_content = native_result["raw_content"]
dify_conv_id = native_result["conversation_id"]
response_time_ms = native_result["response_time_ms"]
# 尝试 JSON 解析(复用同一解析逻辑)
parsed = self._parse_structured_response(raw_content)
if parsed:
# JSON 解析成功
text = parsed.get("text", "")
# ★ 防御性类型保护:Dify 可能返回 text 为对象/列表而非字符串
# 当 text 为 dict/list 时,直接传给前端会显示 [object Object]
if not isinstance(text, str):
text = json.dumps(text, ensure_ascii=False) if text else ""
logger.warning(f"Dify 返回 text 非 String 类型,已转换为 JSON 字符串: {text[:80]}...")
action = parsed.get("action")
options = parsed.get("options")
diagnosis_stage = parsed.get("diagnosis_stage")
# ★ 调试日志:打印 action 对象的详细内容
logger.info(f"[DEBUG] Dify action 对象: {action}")
if action:
logger.info(f"[DEBUG] action.approval_type = {action.get('approval_type')}")
hit = self._check_knowledge_hit(text) if text else False
logger.info(
f"Dify 原生 structured 返回: hit={hit}, "
f"text_len={len(text)}, "
f"text_preview={text[:60]}, "
f"has_action={action is not None}, "
f"has_options={options is not None}, "
f"diagnosis_stage={diagnosis_stage}, "
f"response_time={response_time_ms:.0f}ms"
)
if response_time_ms > 10000:
logger.warning(f"Dify 原生慢响应告警: {response_time_ms:.0f}ms")
return {
"text": text,
"action": action,
"options": options,
"hit": hit,
"conversation_id": dify_conv_id,
"raw_content": raw_content,
"is_structured": True,
"diagnosis_stage": diagnosis_stage,
"response_time_ms": round(response_time_ms, 1),
# v4.0 D1 合并:透传路由意图字段(主 Dify 统一输出,消除 detect_routing_intent 串行调用)
"intent_type": parsed.get("intent_type"),
"business_category": parsed.get("business_category"),
"routing_confidence": parsed.get("routing_confidence"),
}
else:
# JSON 解析失败,降级为纯文本
logger.warning(
"Dify 原生返回非 JSON 格式,降级为纯文本。"
f"content={raw_content[:100]}..."
)
hit = self._check_knowledge_hit(raw_content) if raw_content else False
return {
"text": raw_content,
"action": None,
"options": None,
"hit": hit,
"conversation_id": dify_conv_id,
"raw_content": raw_content,
"is_structured": False,
"diagnosis_stage": None,
"response_time_ms": round(response_time_ms, 1),
}
# === 代理路径(fallback===
# 原生 API 不可用或调用失败时,走 dify2openai 代理(原有逻辑)
logger.info("使用 dify2openai 代理路径(原生 API 不可用或失败)")
payload = {
"model": "Chat",
"messages": [{"role": "user", "content": message}],
"stream": False, # blocking 模式
"temperature": 0.3, # 略高于流式,给 JSON 结构化留一点灵活性
}
if conversation_id:
payload["conversation_id"] = conversation_id
if user_id:
payload["user"] = user_id
try:
client = await self._get_client()
# Phase 6B: 记录 Dify 调用开始时间(性能监控)
start_time = time.perf_counter()
logger.info(f"调用 Dify API (structured): message={message[:50]}...")
response = await client.post(self.api_url, json=payload)
response.raise_for_status()
# Phase 6B: 计算响应耗时
response_time_ms = (time.perf_counter() - start_time) * 1000
data = response.json()
# 解析 OpenAI 兼容格式返回
choices = data.get("choices", [])
if not choices:
logger.warning("Dify API 返回空 choices (structured)")
return {
"text": "",
"action": None,
"options": None,
"hit": False,
"conversation_id": conversation_id or "",
"raw_content": "",
"is_structured": False,
"diagnosis_stage": None,
"response_time_ms": round(response_time_ms, 1),
}
raw_content = choices[0]["message"]["content"]
dify_conv_id = data.get("conversation_id", conversation_id or "")
# 尝试解析 JSON
parsed = self._parse_structured_response(raw_content)
if parsed:
# JSON 解析成功
text = parsed.get("text", "")
# ★ 防御性类型保护(与原生路径一致)
if not isinstance(text, str):
text = json.dumps(text, ensure_ascii=False) if text else ""
logger.warning(f"Dify 代理返回 text 非 String 类型,已转换: {text[:80]}...")
action = parsed.get("action")
options = parsed.get("options")
# Phase 6A: 提取诊断阶段(diagnosis_stage
diagnosis_stage = parsed.get("diagnosis_stage")
hit = self._check_knowledge_hit(text) if text else False
logger.info(
f"Dify structured 返回: hit={hit}, "
f"text_len={len(text)}, "
f"has_action={action is not None}, "
f"has_options={options is not None}, "
f"diagnosis_stage={diagnosis_stage}, "
f"response_time={response_time_ms:.0f}ms, "
f"conv_id={dify_conv_id[:20] if dify_conv_id else '(new)'}"
)
# Phase 6B: 慢响应告警(>10秒)
if response_time_ms > 10000:
logger.warning(
f"Dify 慢响应告警: {response_time_ms:.0f}ms "
f"(conv={dify_conv_id[:20] if dify_conv_id else 'new'})"
)
return {
"text": text,
"action": action,
"options": options,
"hit": hit,
"conversation_id": dify_conv_id,
"raw_content": raw_content,
"is_structured": True,
"diagnosis_stage": diagnosis_stage,
"response_time_ms": round(response_time_ms, 1),
# v4.0 D1 合并:透传路由意图字段(主 Dify 统一输出,消除 detect_routing_intent 串行调用)
"intent_type": parsed.get("intent_type"),
"business_category": parsed.get("business_category"),
"routing_confidence": parsed.get("routing_confidence"),
}
else:
# JSON 解析失败,降级为纯文本(向后兼容旧 Prompt)
logger.warning(
"Dify 返回非 JSON 格式,降级为纯文本。"
f"content={raw_content[:100]}..."
)
hit = self._check_knowledge_hit(raw_content) if raw_content else False
return {
"text": raw_content,
"action": None,
"options": None,
"hit": hit,
"conversation_id": dify_conv_id,
"raw_content": raw_content,
"is_structured": False,
"diagnosis_stage": None,
"response_time_ms": round(response_time_ms, 1),
}
except httpx.TimeoutException:
elapsed = (time.perf_counter() - start_time) * 1000
logger.error(f"Dify API 超时 (structured): {elapsed:.0f}ms")
return {
"text": "AI 服务响应超时,请稍后再试或转人工坐席。",
"action": None,
"options": None,
"hit": False,
"conversation_id": conversation_id or "",
"raw_content": "",
"is_structured": False,
"diagnosis_stage": None,
"response_time_ms": round(elapsed, 1),
}
except httpx.HTTPStatusError as e:
elapsed = (time.perf_counter() - start_time) * 1000
logger.error(f"Dify API HTTP 错误 (structured): status={e.response.status_code}, {elapsed:.0f}ms")
return {
"text": "AI 服务暂时不可用,请转人工坐席。",
"action": None,
"options": None,
"hit": False,
"conversation_id": conversation_id or "",
"raw_content": "",
"is_structured": False,
"diagnosis_stage": None,
"response_time_ms": round(elapsed, 1),
}
except Exception as e:
elapsed = (time.perf_counter() - start_time) * 1000
logger.error(f"Dify API 调用失败 (structured): {e}, {elapsed:.0f}ms")
return {
"text": "AI 服务异常,请转人工坐席或稍后重试。",
"action": None,
"options": None,
"hit": False,
"conversation_id": conversation_id or "",
"raw_content": "",
"is_structured": False,
"diagnosis_stage": None,
"response_time_ms": round(elapsed, 1),
}
def _parse_structured_response(self, content: str) -> Optional[Dict[str, Any]]:
"""尝试将 Dify 返回内容解析为结构化 JSON。
支持以下格式:
1. 纯 JSON: {"text": "...", "action": null, "options": null}
2. 带 markdown 代码块: ```json\n{...}\n```
3. 前后有多余文本的 JSON(提取第一个 { 到最后一个 }
Args:
content: Dify 返回的原始内容
Returns:
解析后的 dict,或 None(解析失败)
"""
if not content or not content.strip():
return None
text = content.strip()
# 尝试 1: 直接解析
try:
result = json.loads(text)
if isinstance(result, dict) and "text" in result:
return result
except json.JSONDecodeError:
pass
# 尝试 2: 去除 markdown 代码块
if text.startswith("```"):
# 去除 ```json 或 ``` 开头和结尾的 ```
lines = text.split("\n")
# 去掉第一行(```json 或 ```
if lines[0].strip().startswith("```"):
lines = lines[1:]
# 去掉最后一行(```
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
text = "\n".join(lines).strip()
try:
result = json.loads(text)
if isinstance(result, dict) and "text" in result:
return result
except json.JSONDecodeError:
pass
# 尝试 3: 提取第一个 { 到最后一个 }
first_brace = content.find("{")
last_brace = content.rfind("}")
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
json_str = content[first_brace:last_brace + 1]
try:
result = json.loads(json_str)
if isinstance(result, dict) and "text" in result:
return result
except json.JSONDecodeError:
pass
return None
# --------------------------------------------------------------------------
# 判断是否命中知识库
# --------------------------------------------------------------------------
def _check_knowledge_hit(self, content: str) -> bool:
"""判断 AI 回复是否命中知识库(可以回答用户问题)。
Args:
content: AI 回复内容
Returns:
bool: True=命中(可以回复),False=未命中(需转人工)
做什么:分析 AI 回复内容,判断是否能有效回答问题
为什么:
- Dify 在无法回答时通常会返回固定提示语
- 参考现有系统:「抱歉,您的问题可能不在服务业务范围内」
- 命中 = 有实质内容且不像是「无法回答」的提示
"""
if not content or len(content.strip()) < 5:
return False
# 未命中特征词(Dify 无法回答时的典型回复)
miss_keywords = [
"抱歉", "对不起", "不知道", "无法回答",
"不在服务范围内", "超出我的能力", "暂不支持",
"请转人工", "联系管理员",
]
content_lower = content.lower()
# 如果回复中包含多个未命中特征词 → 判断为未命中
miss_count = sum(1 for kw in miss_keywords if kw in content_lower)
if miss_count >= 2:
return False
# 如果回复长度过短(< 10 字符)且包含特征词 → 未命中
if len(content) < 10 and any(kw in content_lower for kw in miss_keywords):
return False
return True