# ============================================================================= # 企微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)。 Args: message: 员工发送的消息内容 conversation_id: Dify 会话ID user_id: 员工企微 UserID Yields: Dict: { "delta": str, # 增量内容 "finished": bool, # 是否结束 "conversation_id": str, "hit": bool, # 最终判断是否命中 } 做什么:SSE 流式读取 Dify 返回,逐块 yield 给调用方 为什么: - 流式返回能提升用户体验(不用等 AI 全部生成完才显示) - 通过 WebSocket 推送增量内容到 H5 前端 - 目前第一步先实现非流式,流式作为后续优化 """ # TODO: 第一步简化,先 yield 完整内容(非真正流式) # 后续优化:解析 SSE 事件流,逐块 yield result = await self.get_reply(message, conversation_id, user_id) yield { "delta": result["content"], "finished": True, "conversation_id": result["conversation_id"], "hit": result["hit"], } # -------------------------------------------------------------------------- # 判断是否命中知识库 # -------------------------------------------------------------------------- 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