P1-2: 统一Dify调用点 - 删除detect-intent死链路(approval/byod) + routing_service共享client修复连接泄漏 + 前端死代码清理

This commit is contained in:
Simon
2026-07-18 01:05:20 +08:00
parent af392f2bf0
commit aeb4e0cf39
5 changed files with 54 additions and 424 deletions
-205
View File
@@ -8,7 +8,6 @@
# ============================================================================= # =============================================================================
import asyncio import asyncio
import json
import logging import logging
import os import os
from typing import Optional from typing import Optional
@@ -332,34 +331,6 @@ class ApprovalUrgeRequest(BaseModel):
sp_no: str sp_no: str
class ApprovalDetectIntentRequest(BaseModel):
"""审批意图检测请求"""
text: str
employee_id: Optional[str] = None
class ApprovalDetectIntentResponse(BaseModel):
"""审批意图检测响应
Attributes:
is_approval_request: 是否为审批请求(原字段,语义不变)
confidence: 置信度(0.0~1.0)(原字段,语义不变)
approval_type: 审批类型(原字段,语义不变)
source: 结果来源 — dify(Dify识别) / keyword_prefilter(关键词预过滤未命中) / fallback(降级兜底)
intent_type: 意图大类(新增)— approval/it_consult/non_it_routing/chitchat
business_category: 非IT业务类别(新增)— 行政/人力资源/财务/法务/行政-物业,仅 non_it_routing 时有值
routing_confidence: 路由置信度(新增)— 0.0~1.0,≥0.7 触发名片推荐
"""
is_approval_request: bool
confidence: float
approval_type: Optional[str] = None
source: str # "dify" | "keyword_prefilter" | "fallback"
# 以下为 v3 统一意图识别新增字段(向后兼容:原审批逻辑只读取前4个字段)
intent_type: str = "chitchat"
business_category: Optional[str] = None
routing_confidence: float = 0.0
# ============================================================================= # =============================================================================
# 企微API调用辅助函数 # 企微API调用辅助函数
# ============================================================================= # =============================================================================
@@ -973,179 +944,3 @@ async def get_approval_keywords():
return success_response(data=keywords) return success_response(data=keywords)
# =============================================================================
# 审批意图识别(Dify + 关键词预过滤 + 降级兜底)
# =============================================================================
def _keyword_prefilter(text: str) -> bool:
"""关键词预过滤:检查文本是否包含审批相关关键词(v2.0 收窄版)。
v2.0 变更(2026-07-13):
- 不再合并 APPROVAL_TEMPLATES 的 keywords(包含"借用""升级""外联"等泛化词)
- 仅使用 APPROVAL_PREFILTER_KEYWORDS(强意图词 + 复合专有词)
- 模板 keywords 仍保留在 KEYWORD_TO_APPROVAL_TYPE 中,仅用于 Dify 不可用时的降级兜底
Args:
text: 用户消息文本
Returns:
bool: 是否包含审批关键词
"""
if not text:
return False
lower_text = text.lower()
# v2.0: 仅使用预过滤关键词列表,不合并模板 keywords
return any(kw.lower() in lower_text for kw in APPROVAL_PREFILTER_KEYWORDS)
def _fallback_detect(text: str) -> tuple[bool, float, Optional[str]]:
"""关键词兜底:Dify 不可用时通过关键词匹配判断审批意图。
遍历 KEYWORD_TO_APPROVAL_TYPE 映射,命中第一个关键词即返回对应审批类型。
置信度取 0.6(略低于阈值,但预过滤已通过说明有审批关键词)。
Args:
text: 用户消息文本
Returns:
tuple: (is_approval_request, confidence, approval_type)
"""
lower_text = (text or "").lower()
approval_type: Optional[str] = None
for kw, atype in KEYWORD_TO_APPROVAL_TYPE.items():
if kw.lower() in lower_text:
approval_type = atype
break
# 预过滤已通过(说明有审批关键词),兜底返回 is_approval_request=True
return True, 0.6, approval_type
async def _call_dify_approval_intent(text: str, employee_id: str = "") -> dict:
"""调用 Dify 审批意图识别应用(Dify 原生 API)。
直接调用 Dify 原生 /v1/chat-messages 接口,绕过 Dify2OpenAI 代理。
Dify2OpenAI 代理会将 JSON 响应序列化为 "[object Object]" 字符串,
导致后端无法解析。使用原生 API 可获得正确的 JSON 响应。
Dify 应用的 System Prompt 已在 Dify 后台配置好,后端只需把用户消息传过去。
返回 JSON: {"is_approval_request": bool, "confidence": float, "approval_type": str|null}
Args:
text: 用户消息文本
employee_id: 员工 ID(可选,传给 Dify 的 user 字段)
Returns:
dict: {"is_approval_request": bool, "confidence": float, "approval_type": str|None}
Raises:
Exception: Dify 调用失败或响应解析失败
"""
base_url = settings.approval_dify_base_url
api_key = settings.approval_dify_api_key
timeout = settings.approval_dify_timeout
if not base_url or not api_key:
raise ValueError("Dify 审批意图识别应用未配置(APPROVAL_DIFY_BASE_URL / APPROVAL_DIFY_API_KEY")
# 构建请求 URLbase_url + /v1/chat-messagesDify 原生 API
url = f"{base_url.rstrip('/')}/v1/chat-messages"
body = {
"inputs": {}, # Dify 应用的输入变量(无自定义变量时为空)
"query": text, # 用户消息文本
"response_mode": "blocking", # 阻塞模式,等待完整响应
"user": employee_id or "approval_detection", # 用户标识
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as client:
response = await client.post(url, json=body, headers=headers)
response.raise_for_status()
data = response.json()
# 解析 Dify 原生响应:answer 字段包含 AI 返回的文本(JSON 字符串)
answer = data.get("answer", "")
parsed = json.loads(answer)
return {
"is_approval_request": bool(parsed.get("is_approval_request", False)),
"confidence": float(parsed.get("confidence", 0.0)),
"approval_type": parsed.get("approval_type"),
# v3 统一意图识别新增字段(向后兼容:旧 Prompt 无这些字段时取默认值)
"intent_type": str(parsed.get("intent_type", "chitchat")),
"business_category": parsed.get("business_category"),
"routing_confidence": float(parsed.get("routing_confidence", 0.0)),
}
@router.post("/approval/detect-intent")
async def detect_approval_intent(request: ApprovalDetectIntentRequest):
"""审批意图检测端点。
流程:
1. 关键词预过滤 — 未命中直接返回 false(避免每条消息都调 Dify)
2. 命中关键词 → 调用 Dify 审批意图识别应用
3. Dify 调用失败 → 降级为关键词匹配(兜底)
Args:
request: 包含 text(用户消息)和可选的 employee_id
Returns:
ApprovalDetectIntentResponse: 检测结果
"""
text = request.text or ""
# 1. 关键词预过滤
if not _keyword_prefilter(text):
return success_response(data=ApprovalDetectIntentResponse(
is_approval_request=False,
confidence=0.0,
approval_type=None,
source="keyword_prefilter",
intent_type="chitchat",
business_category=None,
routing_confidence=0.0,
))
# 2. 调用 Dify 意图识别
try:
result = await _call_dify_approval_intent(text, request.employee_id or "")
# 检查置信度阈值
threshold = settings.approval_confidence_threshold
is_approval = result["is_approval_request"] and result["confidence"] >= threshold
logger.info(
f"审批意图检测(Dify): is_approval={is_approval}, "
f"confidence={result['confidence']}, type={result.get('approval_type')}, "
f"intent_type={result.get('intent_type')}, "
f"business_category={result.get('business_category')}, "
f"routing_confidence={result.get('routing_confidence')}"
)
return success_response(data=ApprovalDetectIntentResponse(
is_approval_request=is_approval,
confidence=result["confidence"],
approval_type=result.get("approval_type"),
source="dify",
intent_type=result.get("intent_type", "chitchat"),
business_category=result.get("business_category"),
routing_confidence=result.get("routing_confidence", 0.0),
))
except Exception as e:
logger.warning(f"Dify 审批意图识别失败,降级为关键词匹配: {e}")
# 3. 降级为关键词匹配
is_approval, confidence, approval_type = _fallback_detect(text)
logger.info(
f"审批意图检测(兜底): is_approval={is_approval}, "
f"confidence={confidence}, type={approval_type}"
)
return success_response(data=ApprovalDetectIntentResponse(
is_approval_request=is_approval,
confidence=confidence,
approval_type=approval_type,
source="fallback",
intent_type="approval" if is_approval else "chitchat",
business_category=None,
routing_confidence=0.0,
))
+3 -155
View File
@@ -1,17 +1,15 @@
# ============================================================================= # =============================================================================
# IT智能服务台 — 自备电脑补贴(BYOD)资格查询 API # IT智能服务台 — 自备电脑补贴(BYOD)资格查询 API
# ============================================================================= # =============================================================================
# 说明:提供自备电脑补贴资格的意图检测和资格检查功能 # 说明:提供自备电脑补贴资格检查功能
# - 意图检测:关键词预过滤 → Dify → 关键词兜底(复用审批系统三级模式)
# - 资格检查:通过企微通讯录API获取员工岗位 → 与资格清单匹配 → 返回结果 # - 资格检查:通过企微通讯录API获取员工岗位 → 与资格清单匹配 → 返回结果
# - 岗位匹配支持精确匹配和模糊匹配(如"高级前端开发岗"匹配"前端开发岗" # - 岗位匹配支持精确匹配和模糊匹配(如"高级前端开发岗"匹配"前端开发岗"
# - BYOD 意图检测已并入主 Dify 对话链路(v4.0 P1-2 删除独立 detect-intent 端点)
# ============================================================================= # =============================================================================
import json
import logging import logging
from typing import Optional from typing import Optional
import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
import redis.asyncio as aioredis import redis.asyncio as aioredis
@@ -144,17 +142,6 @@ BYOD_REGISTER_NOTES: list[str] = [
# Schema 定义 # Schema 定义
# ============================================================================= # =============================================================================
class ByodEligibilityRequest(BaseModel):
"""BYOD 意图检测请求
Attributes:
text: 用户消息文本
employee_id: 员工ID(可选,传给 Dify 作为用户标识)
"""
text: str
employee_id: Optional[str] = None
class ByodCheckEligibilityRequest(BaseModel): class ByodCheckEligibilityRequest(BaseModel):
"""BYOD 资格检查请求 """BYOD 资格检查请求
@@ -234,74 +221,7 @@ def _match_position(position: str) -> tuple[bool, str, str]:
# ============================================================================= # =============================================================================
# Dify 意图识别(复用审批意图识别 Dify 应用) # 关键词预过滤
# =============================================================================
# 说明:BYOD 意图识别复用审批系统的 Dify 应用(同一个 approval_dify_base_url /
# approval_dify_api_key),Dify 后台的 System Prompt 已更新为同时支持
# 审批意图和 BYOD 意图识别。Dify 返回的 JSON 中包含 is_byod_intent 字段。
async def _call_dify_byod_intent(text: str, employee_id: str = "") -> dict:
"""调用 Dify 意图识别应用,检测 BYOD 意图。
复用审批意图识别的 Dify 应用(approval_dify_base_url / approval_dify_api_key),
Dify 的 System Prompt 已更新为同时返回 is_byod_intent 字段。
Dify 原生 API 调用方式与 approval.py 中的 _call_dify_approval_intent 一致:
POST {base_url}/v1/chat-messagesblocking 模式。
Args:
text: 用户消息文本
employee_id: 员工 ID(可选,传给 Dify 的 user 字段)
Returns:
dict: {
"is_byod_intent": bool,
"confidence": float,
}
Raises:
Exception: Dify 调用失败或响应解析失败
"""
base_url = settings.approval_dify_base_url
api_key = settings.approval_dify_api_key
timeout = settings.approval_dify_timeout
if not base_url or not api_key:
raise ValueError(
"Dify 意图识别应用未配置(APPROVAL_DIFY_BASE_URL / APPROVAL_DIFY_API_KEY"
)
# 构建请求 URLbase_url + /v1/chat-messagesDify 原生 API
url = f"{base_url.rstrip('/')}/v1/chat-messages"
body = {
"inputs": {},
"query": text,
"response_mode": "blocking",
"user": employee_id or "byod_detection",
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as client:
response = await client.post(url, json=body, headers=headers)
response.raise_for_status()
data = response.json()
# 解析 Dify 原生响应:answer 字段包含 AI 返回的文本(JSON 字符串)
answer = data.get("answer", "")
parsed = json.loads(answer)
return {
"is_byod_intent": bool(parsed.get("is_byod_intent", False)),
"confidence": float(parsed.get("confidence", 0.0)),
}
# =============================================================================
# 关键词预过滤 & 降级兜底
# ============================================================================= # =============================================================================
def _byod_keyword_prefilter(text: str) -> bool: def _byod_keyword_prefilter(text: str) -> bool:
@@ -322,82 +242,10 @@ def _byod_keyword_prefilter(text: str) -> bool:
return any(kw.lower() in lower_text for kw in BYOD_PREFILTER_KEYWORDS) return any(kw.lower() in lower_text for kw in BYOD_PREFILTER_KEYWORDS)
def _byod_fallback_detect(text: str) -> tuple[bool, float]:
"""BYOD 关键词兜底:Dify 不可用时通过关键词匹配判断 BYOD 意图。
遍历 BYOD_PREFILTER_KEYWORDS,命中任意一个即认为有 BYOD 意图。
置信度取 0.6(预过滤已通过说明有 BYOD 相关关键词)。
Args:
text: 用户消息文本
Returns:
tuple: (is_byod_intent, confidence)
"""
lower_text = (text or "").lower()
for kw in BYOD_PREFILTER_KEYWORDS:
if kw.lower() in lower_text:
return True, 0.6
return False, 0.0
# ============================================================================= # =============================================================================
# API 端点 # API 端点
# ============================================================================= # =============================================================================
@router.post("/byod/detect-intent")
async def detect_byod_intent(request: ByodEligibilityRequest):
"""BYOD 意图检测端点。
流程(复用审批系统三级模式):
1. 关键词预过滤 — 未命中 BYOD 关键词直接返回 false(避免每条消息都调 Dify)
2. 命中关键词 → 调用 Dify 意图识别应用(复用审批 Dify 应用)
3. Dify 调用失败 → 降级为关键词匹配(兜底)
Args:
request: 包含 text(用户消息)和可选的 employee_id
Returns:
ByodEligibilityResponse: 检测结果(仅 is_byod_intent 和 source 字段有效)
"""
text = request.text or ""
# 1. 关键词预过滤
if not _byod_keyword_prefilter(text):
return success_response(data=ByodEligibilityResponse(
is_byod_intent=False,
eligible=False,
source="keyword_prefilter",
))
# 2. 调用 Dify 意图识别
try:
result = await _call_dify_byod_intent(text, request.employee_id or "")
threshold = settings.approval_confidence_threshold
is_byod = result["is_byod_intent"] and result["confidence"] >= threshold
logger.info(
f"BYOD意图检测(Dify): is_byod={is_byod}, "
f"confidence={result['confidence']}"
)
return success_response(data=ByodEligibilityResponse(
is_byod_intent=is_byod,
eligible=False,
source="dify",
))
except Exception as e:
logger.warning(f"Dify BYOD 意图识别失败,降级为关键词匹配: {e}")
# 3. 降级为关键词匹配
is_byod, confidence = _byod_fallback_detect(text)
logger.info(
f"BYOD意图检测(兜底): is_byod={is_byod}, confidence={confidence}"
)
return success_response(data=ByodEligibilityResponse(
is_byod_intent=is_byod,
eligible=False,
source="fallback",
))
@router.post("/byod/check-eligibility") @router.post("/byod/check-eligibility")
async def check_byod_eligibility( async def check_byod_eligibility(
request: ByodCheckEligibilityRequest, request: ByodCheckEligibilityRequest,
+10
View File
@@ -215,6 +215,16 @@ async def cleanup_shared_services():
logger.warning(f"AIService 连接池关闭异常: {e}") logger.warning(f"AIService 连接池关闭异常: {e}")
_shared_ai_handler = None _shared_ai_handler = None
# v4.0 P1-2: 关闭 routing_service 的共享 httpx 连接池
try:
from app.services import routing_service
if routing_service._routing_client and not routing_service._routing_client.is_closed:
await routing_service._routing_client.aclose()
routing_service._routing_client = None
logger.info("routing_service httpx 连接池已关闭")
except Exception as e:
logger.warning(f"routing_service 连接池关闭异常: {e}")
# Token 黑名单 Key 前缀(与 auth.py 中保持一致) # Token 黑名单 Key 前缀(与 auth.py 中保持一致)
TOKEN_BLACKLIST_PREFIX = "token:blacklist:" TOKEN_BLACKLIST_PREFIX = "token:blacklist:"
+34 -16
View File
@@ -35,6 +35,24 @@ from app.services.ws_manager import manager as ws_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# =============================================================================
# 共享 httpx 客户端(v4.0 P1-2:修复每次调用新建连接的泄漏问题)
# =============================================================================
_routing_client: Optional[httpx.AsyncClient] = None
async def _get_routing_client(timeout: float) -> httpx.AsyncClient:
"""获取共享的 httpx.AsyncClient(懒加载单例)。
为什么:之前每次 detect_routing_intent 调用都 `async with httpx.AsyncClient()`
新建连接池,高并发下产生大量 TIME_WAIT 连接(与 AIService 修复前同源问题)。
"""
global _routing_client
if _routing_client is None or _routing_client.is_closed:
_routing_client = httpx.AsyncClient(timeout=httpx.Timeout(timeout))
return _routing_client
# ============================================================================= # =============================================================================
# 路由关键词预过滤列表 # 路由关键词预过滤列表
# ============================================================================= # =============================================================================
@@ -169,24 +187,24 @@ async def detect_routing_intent(text: str, employee_id: str = "") -> dict:
"Content-Type": "application/json", "Content-Type": "application/json",
} }
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as client: client = await _get_routing_client(timeout)
response = await client.post(url, json=body, headers=headers) response = await client.post(url, json=body, headers=headers)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
# 解析 Dify 原生响应:answer 字段包含 AI 返回的 JSON 字符串 # 解析 Dify 原生响应:answer 字段包含 AI 返回的 JSON 字符串
answer = data.get("answer", "") answer = data.get("answer", "")
parsed = json.loads(answer) parsed = json.loads(answer)
# 解析统一意图识别的 6 个字段 # 解析统一意图识别的 6 个字段
return { return {
"is_approval_request": bool(parsed.get("is_approval_request", False)), "is_approval_request": bool(parsed.get("is_approval_request", False)),
"confidence": float(parsed.get("confidence", 0.0)), "confidence": float(parsed.get("confidence", 0.0)),
"approval_type": parsed.get("approval_type"), "approval_type": parsed.get("approval_type"),
"intent_type": str(parsed.get("intent_type", "chitchat")), "intent_type": str(parsed.get("intent_type", "chitchat")),
"business_category": parsed.get("business_category"), "business_category": parsed.get("business_category"),
"routing_confidence": float(parsed.get("routing_confidence", 0.0)), "routing_confidence": float(parsed.get("routing_confidence", 0.0)),
} }
# ============================================================================= # =============================================================================
+7 -48
View File
@@ -439,33 +439,12 @@ export async function getAllCategoriesCard(): Promise<ApprovalCardData> {
} }
// ============================================================================= // =============================================================================
// 审批意图检测 API(Dify 意图置信度 — 替代纯关键词匹配) // 审批意图检测 API —— v4.0 P1-2 已删除
// ============================================================================= // =============================================================================
// detectApprovalIntent / detectByodIntent 已删除:
/** 审批意图检测响应 */ // - 前端自 v2.0 起不再独立调用 detect-intentstore 注释确认零调用)
export interface ApprovalDetectIntentResponse { // - 后端 /approval/detect-intent 与 /byod/detect-intent 端点已同步删除
/** 是否为审批请求 */ // - 审批意图识别统一由主 Dify 对话链路(action 字段 + ApprovalMatcher)处理
is_approval_request: boolean
/** 置信度(0.0~1.0 */
confidence: number
/** 审批类型(设备申请/账号权限申请/软件服务申请/资产处置申请/办公用品申请) */
approval_type: string | null
/** 结果来源:dify(Dify识别) / keyword_prefilter(关键词预过滤未命中) / fallback(降级兜底) */
source: 'dify' | 'keyword_prefilter' | 'fallback'
}
/**
* 检测审批意图
* 员工发送消息后异步调用,后端先用关键词预过滤,命中后调 Dify 做意图识别。
* 返回 is_approval_request === true 时,前端在消息列表中插入审批卡片消息。
*
* @param text 用户消息文本
* @returns 审批意图检测结果
*/
export async function detectApprovalIntent(text: string): Promise<ApprovalDetectIntentResponse> {
const response: any = await apiClient.post('/approval/detect-intent', { text })
return response
}
/** /**
* 获取软件下载列表 * 获取软件下载列表
@@ -482,9 +461,9 @@ export async function getSoftwareDownloads(): Promise<SoftwareDownload[]> {
// ============================================================================= // =============================================================================
// 自备电脑补贴(BYOD)资格查询 API // 自备电脑补贴(BYOD)资格查询 API
// ============================================================================= // =============================================================================
// 说明:提供自备电脑补贴的意图检测和资格检查功能 // 说明:提供自备电脑补贴的资格检查功能
// - detectByodIntent: 检测用户消息是否为 BYOD 意图(关键词预过滤 → Dify → 兜底)
// - checkByodEligibility: 检查员工是否有资格申请自备电脑补贴 // - checkByodEligibility: 检查员工是否有资格申请自备电脑补贴
// - BYOD 意图检测已并入主 Dify 对话链路(v4.0 P1-2 删除 detectByodIntent
// ============================================================================= // =============================================================================
/** BYOD 资格查询响应 */ /** BYOD 资格查询响应 */
@@ -509,26 +488,6 @@ export interface ByodEligibilityResponse {
source: 'dify' | 'keyword_prefilter' | 'fallback' | 'wecom' source: 'dify' | 'keyword_prefilter' | 'fallback' | 'wecom'
} }
/**
* 检测自备电脑补贴意图
* 员工发送消息后调用,后端先用 BYOD 关键词预过滤,命中后调 Dify 做意图识别。
* 返回 is_byod_intent === true 时,前端应继续调用 checkByodEligibility 查询资格。
*
* @param text 用户消息文本
* @param employeeId 员工ID(可选,传给 Dify 作为用户标识)
* @returns BYOD 意图检测结果
*/
export async function detectByodIntent(
text: string,
employeeId?: string
): Promise<ByodEligibilityResponse> {
const response: any = await apiClient.post('/byod/detect-intent', {
text,
employee_id: employeeId,
})
return response
}
/** /**
* 检查自备电脑补贴资格 * 检查自备电脑补贴资格
* 后端通过企微通讯录 API 获取员工岗位,与资格清单匹配后返回结果。 * 后端通过企微通讯录 API 获取员工岗位,与资格清单匹配后返回结果。