2026-06-14 16:49:18 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 企微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
|
2026-07-13 02:17:03 +08:00
|
|
|
|
import time
|
2026-06-14 16:49:18 +08:00
|
|
|
|
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 回复能力。
|
|
|
|
|
|
|
2026-07-13 02:17:03 +08:00
|
|
|
|
支持三种调用模式:
|
|
|
|
|
|
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
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
参考:现有系统交接文档
|
2026-07-13 02:17:03 +08:00
|
|
|
|
- 代理 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(老线上应用,禁止使用)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
"""初始化 AI 服务。
|
|
|
|
|
|
|
|
|
|
|
|
做什么:从配置读取 Dify API 地址和认证信息
|
|
|
|
|
|
为什么:集中管理 API 配置,便于切换测试/生产环境
|
|
|
|
|
|
"""
|
2026-07-13 02:17:03 +08:00
|
|
|
|
# Dify 兼容 OpenAI 格式的 API 端点(代理)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-13 02:17:03 +08:00
|
|
|
|
# ★ v2.1: Dify 原生 API 配置(绕过代理)
|
|
|
|
|
|
self.native_base_url = settings.dify_native_base_url
|
|
|
|
|
|
self.native_api_key = settings.dify_native_api_key
|
|
|
|
|
|
|
|
|
|
|
|
# httpx 异步客户端(复用连接池)— 代理用
|
2026-06-14 16:49:18 +08:00
|
|
|
|
self._client: Optional[httpx.AsyncClient] = None
|
2026-07-13 02:17:03 +08:00
|
|
|
|
# ★ v2.1: 原生 API 专用客户端(不同 auth header)
|
|
|
|
|
|
self._native_client: Optional[httpx.AsyncClient] = None
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
async def _get_client(self) -> httpx.AsyncClient:
|
2026-07-13 02:17:03 +08:00
|
|
|
|
"""获取或创建 httpx 异步客户端(代理用)。
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
做什么:懒加载 httpx.AsyncClient,复用连接池
|
|
|
|
|
|
为什么:避免每次请求都创建新连接,提升性能
|
|
|
|
|
|
"""
|
|
|
|
|
|
if self._client is None or self._client.is_closed:
|
|
|
|
|
|
self._client = httpx.AsyncClient(
|
2026-07-18 00:28:15 +08:00
|
|
|
|
# v4.0 P0-6: proxy 路径用独立超时(12s),避免与 native 串行叠加超 wait_for(30s)
|
|
|
|
|
|
timeout=httpx.Timeout(settings.dify_proxy_timeout),
|
2026-06-14 16:49:18 +08:00
|
|
|
|
headers={
|
|
|
|
|
|
"Authorization": f"Bearer {self.api_key}",
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return self._client
|
|
|
|
|
|
|
2026-07-13 02:17:03 +08:00
|
|
|
|
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(
|
2026-07-18 00:28:15 +08:00
|
|
|
|
# v4.0 P0-6: native 路径用独立超时(12s),预算 12+12+开销 < wait_for(30s)
|
|
|
|
|
|
timeout=httpx.Timeout(settings.dify_native_timeout),
|
2026-07-13 02:17:03 +08:00
|
|
|
|
headers={
|
|
|
|
|
|
"Authorization": f"Bearer {self.native_api_key}",
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return self._native_client
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
async def close(self):
|
|
|
|
|
|
"""关闭 httpx 客户端。
|
|
|
|
|
|
|
2026-07-13 02:17:03 +08:00
|
|
|
|
做什么:释放连接池资源(代理 + 原生)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
为什么:避免连接泄漏,尤其在长期运行的 FastAPI 应用中
|
|
|
|
|
|
"""
|
|
|
|
|
|
if self._client and not self._client.is_closed:
|
|
|
|
|
|
await self._client.aclose()
|
|
|
|
|
|
self._client = None
|
|
|
|
|
|
logger.debug("AIService httpx client closed")
|
2026-07-13 02:17:03 +08:00
|
|
|
|
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")
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 非流式调用:一次性获取 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 推送给前端)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
2026-07-18 10:22:07 +08:00
|
|
|
|
# v4.0 批次4:get_reply_stream 已删除(~96 行)
|
|
|
|
|
|
# v2.0 起 AI 回复改为 blocking + JSON 结构化(get_structured_reply),
|
|
|
|
|
|
# 流式 SSE 路径零调用,属死代码。
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
2026-07-13 02:17:03 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 结构化调用: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", "")
|
2026-07-17 23:08:59 +08:00
|
|
|
|
# ★ 防御性类型保护: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]}...")
|
2026-07-13 02:17:03 +08:00
|
|
|
|
action = parsed.get("action")
|
|
|
|
|
|
options = parsed.get("options")
|
|
|
|
|
|
diagnosis_stage = parsed.get("diagnosis_stage")
|
2026-07-17 23:08:59 +08:00
|
|
|
|
|
|
|
|
|
|
# ★ 调试日志:打印 action 对象的详细内容
|
|
|
|
|
|
logger.info(f"[DEBUG] Dify action 对象: {action}")
|
|
|
|
|
|
if action:
|
|
|
|
|
|
logger.info(f"[DEBUG] action.approval_type = {action.get('approval_type')}")
|
|
|
|
|
|
|
2026-07-13 02:17:03 +08:00
|
|
|
|
hit = self._check_knowledge_hit(text) if text else False
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"Dify 原生 structured 返回: hit={hit}, "
|
|
|
|
|
|
f"text_len={len(text)}, "
|
2026-07-17 23:08:59 +08:00
|
|
|
|
f"text_preview={text[:60]}, "
|
2026-07-13 02:17:03 +08:00
|
|
|
|
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),
|
2026-07-18 02:46:28 +08:00
|
|
|
|
# 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"),
|
2026-07-13 02:17:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
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", "")
|
2026-07-17 23:08:59 +08:00
|
|
|
|
# ★ 防御性类型保护(与原生路径一致)
|
|
|
|
|
|
if not isinstance(text, str):
|
|
|
|
|
|
text = json.dumps(text, ensure_ascii=False) if text else ""
|
|
|
|
|
|
logger.warning(f"Dify 代理返回 text 非 String 类型,已转换: {text[:80]}...")
|
2026-07-13 02:17:03 +08:00
|
|
|
|
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),
|
2026-07-18 02:46:28 +08:00
|
|
|
|
# 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"),
|
2026-07-13 02:17:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 判断是否命中知识库
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
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
|