ead5f83bee
dependencies.py 拆分为 dependencies/ 包; 新增 vision/ragflow_ingestion/neo4j 客户端与 h5_ai_task; alembic 045 图置信度迁移; 响应契约统一收尾。
332 lines
13 KiB
Python
332 lines
13 KiB
Python
# =============================================================================
|
||
# 企微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
|
||
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. 非流式(简单场景):一次性获取完整回复
|
||
2. 流式(推荐):SSE 流式返回,前端可逐字显示
|
||
|
||
参考:现有系统交接文档
|
||
- API URL: http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions
|
||
- Key: http://yw-dify.dc.servyou-it.com/v1|app-UaTWYdBSwN6VktKQlbh5YN5H|Chat
|
||
"""
|
||
|
||
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
|
||
|
||
# httpx 异步客户端(复用连接池)
|
||
self._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(
|
||
timeout=httpx.Timeout(self.timeout),
|
||
headers={
|
||
"Authorization": f"Bearer {self.api_key}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
)
|
||
return self._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")
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 非流式调用:一次性获取 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 推送给前端)
|
||
# --------------------------------------------------------------------------
|
||
async def get_reply_stream(
|
||
self,
|
||
message: str,
|
||
conversation_id: Optional[str] = None,
|
||
user_id: Optional[str] = None,
|
||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||
"""调用 Dify API 获取流式 AI 回复(SSE),逐块 yield 给调用方。
|
||
|
||
Yields:
|
||
Dict: {"delta": str, "finished": bool, "conversation_id": str, "hit": bool|None}
|
||
- 流式中间块:{"delta": 增量, "finished": False, "hit": None}
|
||
- 终态块:{"delta": "", "finished": True, "hit": 命中判断}
|
||
|
||
实现:
|
||
- stream=True 走 SSE,解析 data: {...} 行,逐块 yield delta
|
||
- 流结束后用完整内容整体判断 hit(_check_knowledge_hit)
|
||
容错:若 Dify 不支持流式 / 超时 / 非 SSE 格式,catch 后 fallback 到
|
||
get_reply 非流式,yield 一次完整内容(前端退化为"整段到达",
|
||
功能不破,仅无逐字动画)。
|
||
"""
|
||
payload = {
|
||
"model": "Chat",
|
||
"messages": [{"role": "user", "content": message}],
|
||
"stream": True,
|
||
"temperature": 0.1,
|
||
}
|
||
if conversation_id:
|
||
payload["conversation_id"] = conversation_id
|
||
if user_id:
|
||
payload["user"] = user_id
|
||
|
||
try:
|
||
client = await self._get_client()
|
||
full_parts: list = []
|
||
dify_conv_id = conversation_id or ""
|
||
async with client.stream("POST", self.api_url, json=payload) as response:
|
||
response.raise_for_status()
|
||
async for line in response.aiter_lines():
|
||
if not line:
|
||
continue
|
||
line = line.strip()
|
||
if not line.startswith("data:"):
|
||
continue
|
||
data = line[5:].strip()
|
||
if data == "[DONE]":
|
||
break
|
||
try:
|
||
chunk = json.loads(data)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
# OpenAI / Dify SSE 格式:choices[0].delta.content
|
||
try:
|
||
delta = chunk["choices"][0]["delta"].get("content", "")
|
||
except (KeyError, IndexError, TypeError):
|
||
delta = ""
|
||
if delta:
|
||
full_parts.append(delta)
|
||
yield {
|
||
"delta": delta,
|
||
"finished": False,
|
||
"conversation_id": dify_conv_id,
|
||
"hit": None,
|
||
}
|
||
# Dify 可能在流式块里给出 conversation_id
|
||
cid = chunk.get("conversation_id")
|
||
if cid:
|
||
dify_conv_id = cid
|
||
|
||
# 流结束:用完整内容判断命中
|
||
full_content = "".join(full_parts)
|
||
hit = self._check_knowledge_hit(full_content) if full_content else False
|
||
yield {
|
||
"delta": "",
|
||
"finished": True,
|
||
"conversation_id": dify_conv_id,
|
||
"hit": hit,
|
||
}
|
||
except Exception as e:
|
||
# 流式不可用(dify2openai 不支持 / 超时 / 非 SSE),回退非流式
|
||
logger.warning(f"Dify 流式失败,回退非流式: {e}")
|
||
try:
|
||
result = await self.get_reply(message, conversation_id, user_id)
|
||
yield {
|
||
"delta": result["content"],
|
||
"finished": True,
|
||
"conversation_id": result["conversation_id"],
|
||
"hit": result["hit"],
|
||
}
|
||
except Exception as e2:
|
||
logger.error(f"Dify 流式与非流式均失败: {e2}")
|
||
yield {
|
||
"delta": "⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。",
|
||
"finished": True,
|
||
"conversation_id": conversation_id or "",
|
||
"hit": False,
|
||
}
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 判断是否命中知识库
|
||
# --------------------------------------------------------------------------
|
||
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
|