# ============================================================================= # 企微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 推送给前端) # -------------------------------------------------------------------------- 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, } # -------------------------------------------------------------------------- # 结构化调用: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 原生 API(v2.1 新增,绕过 dify2openai 代理)。 做什么: 1. POST {base_url}/v1/chat-messages(blocking 模式) 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 模式调用 Dify(stream=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