From aeb4e0cf39e2953f5530eebbccbc081eb323f792 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 18 Jul 2026 01:05:20 +0800 Subject: [PATCH] =?UTF-8?q?P1-2:=20=E7=BB=9F=E4=B8=80Dify=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E7=82=B9=20-=20=E5=88=A0=E9=99=A4detect-intent?= =?UTF-8?q?=E6=AD=BB=E9=93=BE=E8=B7=AF(approval/byod)=20+=20routing=5Fserv?= =?UTF-8?q?ice=E5=85=B1=E4=BA=ABclient=E4=BF=AE=E5=A4=8D=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E6=B3=84=E6=BC=8F=20+=20=E5=89=8D=E7=AB=AF=E6=AD=BB=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/approval.py | 205 ------------------------ backend/app/api/byod.py | 158 +----------------- backend/app/dependencies/__init__.py | 10 ++ backend/app/services/routing_service.py | 50 ++++-- frontend-h5/src/api/conversation.ts | 55 +------ 5 files changed, 54 insertions(+), 424 deletions(-) diff --git a/backend/app/api/approval.py b/backend/app/api/approval.py index cde1853..ccaa639 100644 --- a/backend/app/api/approval.py +++ b/backend/app/api/approval.py @@ -8,7 +8,6 @@ # ============================================================================= import asyncio -import json import logging import os from typing import Optional @@ -332,34 +331,6 @@ class ApprovalUrgeRequest(BaseModel): 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调用辅助函数 # ============================================================================= @@ -973,179 +944,3 @@ async def get_approval_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)") - - # 构建请求 URL:base_url + /v1/chat-messages(Dify 原生 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, - )) diff --git a/backend/app/api/byod.py b/backend/app/api/byod.py index 10c93a7..8e63ea3 100644 --- a/backend/app/api/byod.py +++ b/backend/app/api/byod.py @@ -1,17 +1,15 @@ # ============================================================================= # IT智能服务台 — 自备电脑补贴(BYOD)资格查询 API # ============================================================================= -# 说明:提供自备电脑补贴资格的意图检测和资格检查功能 -# - 意图检测:关键词预过滤 → Dify → 关键词兜底(复用审批系统三级模式) +# 说明:提供自备电脑补贴资格检查功能 # - 资格检查:通过企微通讯录API获取员工岗位 → 与资格清单匹配 → 返回结果 # - 岗位匹配支持精确匹配和模糊匹配(如"高级前端开发岗"匹配"前端开发岗") +# - BYOD 意图检测已并入主 Dify 对话链路(v4.0 P1-2 删除独立 detect-intent 端点) # ============================================================================= -import json import logging from typing import Optional -import httpx from fastapi import APIRouter, Depends from pydantic import BaseModel import redis.asyncio as aioredis @@ -144,17 +142,6 @@ BYOD_REGISTER_NOTES: list[str] = [ # Schema 定义 # ============================================================================= -class ByodEligibilityRequest(BaseModel): - """BYOD 意图检测请求 - - Attributes: - text: 用户消息文本 - employee_id: 员工ID(可选,传给 Dify 作为用户标识) - """ - text: str - employee_id: Optional[str] = None - - class ByodCheckEligibilityRequest(BaseModel): """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-messages,blocking 模式。 - - 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)" - ) - - # 构建请求 URL:base_url + /v1/chat-messages(Dify 原生 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: @@ -322,82 +242,10 @@ def _byod_keyword_prefilter(text: str) -> bool: 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 端点 # ============================================================================= -@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") async def check_byod_eligibility( request: ByodCheckEligibilityRequest, diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py index f6f53b8..c08868a 100644 --- a/backend/app/dependencies/__init__.py +++ b/backend/app/dependencies/__init__.py @@ -215,6 +215,16 @@ async def cleanup_shared_services(): logger.warning(f"AIService 连接池关闭异常: {e}") _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_BLACKLIST_PREFIX = "token:blacklist:" diff --git a/backend/app/services/routing_service.py b/backend/app/services/routing_service.py index 8012d5a..8778a10 100644 --- a/backend/app/services/routing_service.py +++ b/backend/app/services/routing_service.py @@ -35,6 +35,24 @@ from app.services.ws_manager import manager as ws_manager 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", } - 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() + client = await _get_routing_client(timeout) + 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) + # 解析 Dify 原生响应:answer 字段包含 AI 返回的 JSON 字符串 + answer = data.get("answer", "") + parsed = json.loads(answer) - # 解析统一意图识别的 6 个字段 - return { - "is_approval_request": bool(parsed.get("is_approval_request", False)), - "confidence": float(parsed.get("confidence", 0.0)), - "approval_type": parsed.get("approval_type"), - "intent_type": str(parsed.get("intent_type", "chitchat")), - "business_category": parsed.get("business_category"), - "routing_confidence": float(parsed.get("routing_confidence", 0.0)), - } + # 解析统一意图识别的 6 个字段 + return { + "is_approval_request": bool(parsed.get("is_approval_request", False)), + "confidence": float(parsed.get("confidence", 0.0)), + "approval_type": parsed.get("approval_type"), + "intent_type": str(parsed.get("intent_type", "chitchat")), + "business_category": parsed.get("business_category"), + "routing_confidence": float(parsed.get("routing_confidence", 0.0)), + } # ============================================================================= diff --git a/frontend-h5/src/api/conversation.ts b/frontend-h5/src/api/conversation.ts index 1672b06..749ca93 100644 --- a/frontend-h5/src/api/conversation.ts +++ b/frontend-h5/src/api/conversation.ts @@ -439,33 +439,12 @@ export async function getAllCategoriesCard(): Promise { } // ============================================================================= -// 审批意图检测 API(Dify 意图置信度 — 替代纯关键词匹配) +// 审批意图检测 API —— v4.0 P1-2 已删除 // ============================================================================= - -/** 审批意图检测响应 */ -export interface ApprovalDetectIntentResponse { - /** 是否为审批请求 */ - 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 { - const response: any = await apiClient.post('/approval/detect-intent', { text }) - return response -} +// detectApprovalIntent / detectByodIntent 已删除: +// - 前端自 v2.0 起不再独立调用 detect-intent(store 注释确认零调用) +// - 后端 /approval/detect-intent 与 /byod/detect-intent 端点已同步删除 +// - 审批意图识别统一由主 Dify 对话链路(action 字段 + ApprovalMatcher)处理 /** * 获取软件下载列表 @@ -482,9 +461,9 @@ export async function getSoftwareDownloads(): Promise { // ============================================================================= // 自备电脑补贴(BYOD)资格查询 API // ============================================================================= -// 说明:提供自备电脑补贴的意图检测和资格检查功能 -// - detectByodIntent: 检测用户消息是否为 BYOD 意图(关键词预过滤 → Dify → 兜底) +// 说明:提供自备电脑补贴的资格检查功能 // - checkByodEligibility: 检查员工是否有资格申请自备电脑补贴 +// - BYOD 意图检测已并入主 Dify 对话链路(v4.0 P1-2 删除 detectByodIntent) // ============================================================================= /** BYOD 资格查询响应 */ @@ -509,26 +488,6 @@ export interface ByodEligibilityResponse { 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 { - const response: any = await apiClient.post('/byod/detect-intent', { - text, - employee_id: employeeId, - }) - return response -} - /** * 检查自备电脑补贴资格 * 后端通过企微通讯录 API 获取员工岗位,与资格清单匹配后返回结果。