Files
wecom_it_smart_desk/backend/app/services/routing_service.py
T

716 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微IT智能服务台 — 业务路由推荐服务
# =============================================================================
# 说明:核心路由逻辑,在 H5 后台 AI 任务中拦截非IT业务消息,
# 调用 Dify 统一意图识别,判定业务类别后发送对应联系人名片卡片。
#
# 主要职责:
# 1. 关键词预过滤(ROUTING_PREFILTER_KEYWORDS)— 快速过滤非路由消息
# 2. Dify 统一意图识别 — 调用 /v1/chat-messages,解析 intent_type/business_category/routing_confidence
# 3. 联系人查询 — 按 business_category 查 business_contacts 表
# 4. 名片三段式发送 — 路由文本 → contact_card → 系统提示(WS双通道推送)
# 5. 路由事件记录(P1)— 记录路由命中统计
#
# 设计决策:
# - 路由检测放在后台任务而非前端调用,与 BYOD 卡片处理模式一致
# - 关键词预过滤与审批预过滤可能重叠,Dify Prompt 内部判断优先级确保正确分流
# - routing_confidence < 0.7 不触发名片推荐,走正常 AI 回复流程
# =============================================================================
import json
import logging
from datetime import datetime
from typing import Any, Optional
import httpx
from sqlalchemy import select
from app.config import settings
from app.models.business_contact import BusinessContact
from app.models.conversation import Conversation
from app.models.message import Message
from app.models.routing_event import RoutingEvent
from app.services.ws_manager import manager as ws_manager
logger = logging.getLogger(__name__)
# =============================================================================
# 路由目标配置(v4.0 2026-07-18:企微客服 kfid 窗口,替代个人名片)
# =============================================================================
# 说明:用户提供的「公共咨询统一联系窗口」清单(企微客服 kfid 链接)。
# 优势:人员变动不影响路由(客服窗口由团队维护),卡片点击直达客服会话。
# 原 business_contacts 个人名片方案弃用(表保留,路由逻辑不再读表)。
#
# 映射规则:Dify 输出的 business_category(5 类别) → 清单中最贴近的服务窗口。
# - 行政(打印机/复印机/保洁/名片印刷/行政杂事)→ 机票酒店前台(行政前台受理)
# - 人力资源(工牌/考勤/入离职/社保)→ 人力资源共享服务咨询
# - 财务(报销/发票/工资/付款)→ 总部报销服务台
# - 法务(合同/协议/盖章/律师)→ 行政法务团队
# - 行政-物业(空调/灯/门禁卡/车位/物业维修)→ 物业服务
# =============================================================================
ROUTING_TARGETS: dict[str, dict[str, str]] = {
"行政": {
"service_name": "机票酒店前台",
"description": "受理和咨询快递、访客、失物招领及行政办公相关事宜",
"url": "https://work.weixin.qq.com/nl/innerkfid/ikfCtcYBwAAV00zNJGfmuA0aG1c2qCDnQ",
},
"人力资源": {
"service_name": "人力资源共享服务咨询",
"description": "咨询和处理人力资源相关问题(工牌/考勤/入离职/社保公积金等)",
"url": "https://work.weixin.qq.com/nl/innerkfid/ikfCtcYBwAAvSRL5i5b_Xia8vCmFc2gRw",
},
"财务": {
"service_name": "总部报销服务台",
"description": "咨询和处理总部报销系统相关问题",
"url": "https://work.weixin.qq.com/nl/innerkfid/ikfCtcYBwAAG4HC1zWJtPuALPKl2X6jcw",
},
"法务": {
"service_name": "行政法务团队",
"description": "可咨询合同、合规、劳动争议、法律案件纠纷、外部检查、其他法律问题",
"url": "https://work.weixin.qq.com/nl/innerkfid/ikfCtcYBwAABF746pGAY5WZ0mOTP6kGKA",
},
"行政-物业": {
"service_name": "物业服务",
"description": "咨询和处理物业相关问题(空调/照明/门禁卡/车位/维修等)",
"url": "https://work.weixin.qq.com/nl/innerkfid/ikfCtcYBwAAUtkMyOToCZqe42ZBDupVEQ",
},
}
def get_routing_target(business_category: str) -> Optional[dict[str, str]]:
"""按业务类别获取路由目标窗口配置(kfid 客服链接)。
Args:
business_category: Dify 输出的业务类别(行政/人力资源/财务/法务/行政-物业)
Returns:
dict: {service_name, description, url};未配置返回 None
"""
return ROUTING_TARGETS.get(business_category)
# =============================================================================
# 共享 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
# =============================================================================
# 路由关键词预过滤列表
# =============================================================================
# 说明:覆盖 5 个业务类别的关键词,用于快速过滤非路由消息。
# 只要命中任意一个关键词才值得调用 Dify 做精确判断。
# 关键词可能与审批预过滤重叠(如"办公用品"),Dify Prompt 内部判断
# 优先级(先审批→再IT咨询→再非IT路由)确保正确分流。
ROUTING_PREFILTER_KEYWORDS: list[str] = [
# 行政
"打印机", "复印机", "扫描仪", "保洁", "名片印刷",
# 人力资源
"工牌", "考勤", "入职", "离职", "社保", "公积金",
# 财务
"报销", "发票", "借款", "工资条",
# 法务
"合同", "法务", "知识产权",
# 行政-物业
"空调", "电梯", "门禁", "停车",
]
# 关键词到业务类别的映射(Dify 不可用时降级兜底用)
ROUTING_KEYWORD_TO_CATEGORY: dict[str, str] = {
# 行政
"打印机": "行政", "复印机": "行政", "扫描仪": "行政",
"保洁": "行政", "名片印刷": "行政",
# 人力资源
"工牌": "人力资源", "考勤": "人力资源", "入职": "人力资源",
"离职": "人力资源", "社保": "人力资源", "公积金": "人力资源",
# 财务
"报销": "财务", "发票": "财务", "借款": "财务", "工资条": "财务",
# 法务
"合同": "法务", "法务": "法务", "知识产权": "法务",
# 行政-物业
"空调": "行政-物业", "电梯": "行政-物业", "门禁": "行政-物业", "停车": "行政-物业",
}
# =============================================================================
# 预过滤 & 降级兜底
# =============================================================================
def routing_keyword_prefilter(text: str) -> bool:
"""路由关键词预过滤:检查文本是否包含非IT业务关键词。
只要命中任意一个路由关键词即返回 True,未命中返回 False。
用于在调用 Dify 前快速过滤,减少不必要的 API 调用。
Args:
text: 用户消息文本
Returns:
bool: 是否包含路由关键词
"""
if not text:
return False
return any(kw in text for kw in ROUTING_PREFILTER_KEYWORDS)
def _keyword_fallback_category(text: str) -> Optional[str]:
"""关键词降级兜底:Dify 不可用时通过关键词匹配业务类别。
遍历 ROUTING_KEYWORD_TO_CATEGORY 映射,命中第一个关键词即返回对应业务类别。
Args:
text: 用户消息文本
Returns:
Optional[str]: 业务类别(行政/人力资源/财务/法务/行政-物业),未命中返回 None
"""
if not text:
return None
for kw, category in ROUTING_KEYWORD_TO_CATEGORY.items():
if kw in text:
return category
return None
# =============================================================================
# Dify 统一意图识别调用
# =============================================================================
async def detect_routing_intent(text: str, employee_id: str = "") -> dict:
"""调用 Dify 统一意图识别,解析路由相关字段。
复用 approval.py 的 _call_dify_approval_intent 调用模式(Dify 原生 API),
但本函数独立维护,解析路由关心的字段:
- intent_type: approval/it_consult/non_it_routing/chitchat
- business_category: 行政/人力资源/财务/法务/行政-物业(仅 non_it_routing 时有值)
- routing_confidence: 0.0~1.0,≥0.7 触发名片推荐
使用与审批意图识别相同的 Dify 应用(同一 API Key),只是解析各自关心的字段。
Args:
text: 用户消息文本
employee_id: 员工 ID(可选,传给 Dify 的 user 字段)
Returns:
dict: {
"intent_type": str,
"business_category": str | None,
"routing_confidence": float,
"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": {},
"query": text,
"response_mode": "blocking",
"user": employee_id or "routing_detection",
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/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)
# 解析统一意图识别的 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)),
}
# =============================================================================
# 联系人查询
# =============================================================================
async def get_contact_by_category(db, category: str) -> Optional[BusinessContact]:
"""按业务类别查询联系人。
按 business_category + is_active=True 查询,取第一条有效联系人。
P0 阶段单联系人推荐,P2 支持按服务区域匹配。
Args:
db: 异步 DB session
category: 业务类别(行政/人力资源/财务/法务/行政-物业)
Returns:
Optional[BusinessContact]: 联系人对象,未找到返回 None
"""
result = await db.execute(
select(BusinessContact)
.where(
BusinessContact.business_category == category,
BusinessContact.is_active == True, # noqa: E712
)
.order_by(BusinessContact.id)
.limit(1)
)
return result.scalar_one_or_none()
# =============================================================================
# 名片三段式发送
# =============================================================================
async def send_service_card(
db,
conversation: Conversation,
employee_id: str,
target: dict[str, str],
reason: str,
business_category: str,
routing_confidence: float,
) -> None:
"""发送服务窗口卡片消息(v4.0 kfid 模式:路由文本 → contact_card → 系统提示)。
与 send_contact_card 的差异:
- 数据来源:ROUTING_TARGETS 配置(kfid 客服窗口),非 business_contacts 表
- extra_data.contact 构造为窗口信息(name=服务名, responsibility=描述, service_url=kfid链接)
- 前端 ContactCard 检测 service_url 存在时,点击改为打开 kfid 客服会话
Args:
db: 异步 DB session
conversation: 当前会话对象
employee_id: 员工企微 UserID
target: get_routing_target 返回的窗口配置 {service_name, description, url}
reason: 路由说明文本
business_category: 业务类别
routing_confidence: 路由置信度
"""
# 构造窗口名片数据(字段对齐前端 ContactCard 的 ContactInfo 接口)
contact_data: dict[str, Any] = {
"id": 0,
"name": target["service_name"],
"gender": "",
"department": "企业微信-通讯录-员工服务",
"position": "公共咨询服务窗口",
"responsibility": target["description"],
"extension": "",
"service_area": "",
"wecom_userid": "", # kfid 模式无个人 userid
"avatar_url": "",
"business_category": business_category,
"service_url": target["url"], # 前端检测此字段 → 点击打开链接
}
extra_data: dict[str, Any] = {
"contact": contact_data,
"routing_reason": reason,
"business_category": business_category,
"routing_confidence": routing_confidence,
}
# === 1. 路由说明文本消息 ===
routing_text_msg = Message(
conversation_id=conversation.id,
sender_type="ai",
sender_id="ai_bot",
sender_name="Duckula(达寇拉)",
content=reason,
msg_type="text",
is_read=True,
)
db.add(routing_text_msg)
await db.flush()
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply",
"data": {
"message_id": str(routing_text_msg.id),
"conversation_id": str(conversation.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": reason,
"msg_type": "text",
"is_guidance": False,
"ai_reply_count": conversation.ai_substantive_reply_count,
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
"conversation_status": conversation.status,
},
})
try:
await ws_manager.broadcast({
"type": "new_message",
"data": {
"conversation_id": str(conversation.id),
"message_id": str(routing_text_msg.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": reason,
"msg_type": "text",
},
})
except Exception as ws_err:
logger.warning(f"路由文本 WS 广播给坐席失败: {ws_err}")
# === 2. contact_card 窗口卡片消息 ===
service_card_msg = Message(
conversation_id=conversation.id,
sender_type="ai",
sender_id="ai_bot",
sender_name="Duckula(达寇拉)",
content=f"为您推荐{business_category}服务窗口:{target['service_name']}",
msg_type="contact_card",
extra_data=extra_data,
is_read=True,
)
db.add(service_card_msg)
await db.flush()
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply",
"data": {
"message_id": str(service_card_msg.id),
"conversation_id": str(conversation.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": f"为您推荐{business_category}服务窗口:{target['service_name']}",
"msg_type": "contact_card",
"extra_data": extra_data,
"is_guidance": False,
"ai_reply_count": conversation.ai_substantive_reply_count,
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
"conversation_status": conversation.status,
},
})
try:
await ws_manager.broadcast({
"type": "new_message",
"data": {
"conversation_id": str(conversation.id),
"message_id": str(service_card_msg.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": f"为您推荐{business_category}服务窗口:{target['service_name']}",
"msg_type": "contact_card",
"extra_data": extra_data,
},
})
except Exception as ws_err:
logger.warning(f"窗口卡片 WS 广播给坐席失败: {ws_err}")
# === 3. 系统提示消息 ===
hint_msg = Message(
conversation_id=conversation.id,
sender_type="system",
sender_id="system",
sender_name="系统",
content="点击上方卡片可直接联系服务窗口(企微客服会话)",
msg_type="system",
is_read=True,
)
db.add(hint_msg)
await db.flush()
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply",
"data": {
"message_id": str(hint_msg.id),
"conversation_id": str(conversation.id),
"sender_type": "system",
"sender_id": "system",
"sender_name": "系统",
"content": "点击上方卡片可直接联系服务窗口(企微客服会话)",
"msg_type": "system",
"is_guidance": False,
"ai_reply_count": conversation.ai_substantive_reply_count,
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
"conversation_status": conversation.status,
},
})
try:
await ws_manager.broadcast({
"type": "new_message",
"data": {
"conversation_id": str(conversation.id),
"message_id": str(hint_msg.id),
"sender_type": "system",
"sender_id": "system",
"sender_name": "系统",
"content": "点击上方卡片可直接联系服务窗口(企微客服会话)",
"msg_type": "system",
},
})
except Exception as ws_err:
logger.warning(f"系统提示 WS 广播给坐席失败: {ws_err}")
await db.commit()
async def send_contact_card(
db,
conversation: Conversation,
employee_id: str,
contact: BusinessContact,
reason: str,
business_category: str,
routing_confidence: float,
) -> None:
"""发送名片三段式消息(路由文本 → contact_card → 系统提示)。
完全参考 _handle_byod_query 模式:
1. 创建路由说明文本消息(AI, text)→ 落库 + WS双通道推送
2. 创建 contact_card 名片消息(AI, contact_card)→ 落库 + WS双通道推送
3. 创建系统提示消息(system, system)→ 落库 + WS双通道推送
每条消息分别落库 + WS推送,与 PRD 4.3 交互流程一致。
Args:
db: 异步 DB session
conversation: 当前会话对象
employee_id: 员工企微 UserID
contact: 联系人对象
reason: 路由说明文本(如"打印机问题属于行政设备范畴..."
business_category: 业务类别
routing_confidence: 路由置信度
"""
contact_data = contact.to_dict()
extra_data: dict[str, Any] = {
"contact": contact_data,
"routing_reason": reason,
"business_category": business_category,
"routing_confidence": routing_confidence,
}
# === 1. 路由说明文本消息 ===
routing_text_msg = Message(
conversation_id=conversation.id,
sender_type="ai",
sender_id="ai_bot",
sender_name="Duckula(达寇拉)",
content=reason,
msg_type="text",
is_read=True,
)
db.add(routing_text_msg)
await db.flush()
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply",
"data": {
"message_id": str(routing_text_msg.id),
"conversation_id": str(conversation.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": reason,
"msg_type": "text",
"is_guidance": False,
"ai_reply_count": conversation.ai_substantive_reply_count,
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
"conversation_status": conversation.status,
},
})
try:
await ws_manager.broadcast({
"type": "new_message",
"data": {
"conversation_id": str(conversation.id),
"message_id": str(routing_text_msg.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": reason,
"msg_type": "text",
},
})
except Exception as ws_err:
logger.warning(f"路由文本 WS 广播给坐席失败: {ws_err}")
# === 2. contact_card 名片消息 ===
contact_card_msg = Message(
conversation_id=conversation.id,
sender_type="ai",
sender_id="ai_bot",
sender_name="Duckula(达寇拉)",
content=f"为您推荐{business_category}服务联系人:{contact.name}",
msg_type="contact_card",
extra_data=extra_data,
is_read=True,
)
db.add(contact_card_msg)
await db.flush()
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply",
"data": {
"message_id": str(contact_card_msg.id),
"conversation_id": str(conversation.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": f"为您推荐{business_category}服务联系人:{contact.name}",
"msg_type": "contact_card",
"extra_data": extra_data,
"is_guidance": False,
"ai_reply_count": conversation.ai_substantive_reply_count,
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
"conversation_status": conversation.status,
},
})
try:
await ws_manager.broadcast({
"type": "new_message",
"data": {
"conversation_id": str(conversation.id),
"message_id": str(contact_card_msg.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": f"为您推荐{business_category}服务联系人:{contact.name}",
"msg_type": "contact_card",
"extra_data": extra_data,
},
})
except Exception as ws_err:
logger.warning(f"名片卡片 WS 广播给坐席失败: {ws_err}")
# === 3. 系统提示消息 ===
system_text = "以上为AI自动推荐,点击名片可直接发起企微聊天"
system_msg = Message(
conversation_id=conversation.id,
sender_type="system",
sender_id="system",
sender_name="系统",
content=system_text,
msg_type="system",
is_read=True,
)
db.add(system_msg)
await db.flush()
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply",
"data": {
"message_id": str(system_msg.id),
"conversation_id": str(conversation.id),
"sender_type": "system",
"sender_id": "system",
"sender_name": "系统",
"content": system_text,
"msg_type": "system",
"is_guidance": False,
"ai_reply_count": conversation.ai_substantive_reply_count,
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
"conversation_status": conversation.status,
},
})
try:
await ws_manager.broadcast({
"type": "new_message",
"data": {
"conversation_id": str(conversation.id),
"message_id": str(system_msg.id),
"sender_type": "system",
"sender_id": "system",
"sender_name": "系统",
"content": system_text,
"msg_type": "system",
},
})
except Exception as ws_err:
logger.warning(f"系统提示 WS 广播给坐席失败: {ws_err}")
# 更新会话状态(路由推荐视为一次实质性 AI 回复)
conversation.ai_substantive_reply_count += 1
conversation.updated_at = datetime.now()
db.add(conversation)
await db.flush()
await db.commit()
logger.info(
f"路由名片发送完成: employee_id={employee_id}, category={business_category}, "
f"contact={contact.name}, confidence={routing_confidence}"
)
# =============================================================================
# 路由事件记录(P1
# =============================================================================
async def record_routing_event(
db,
conversation_id: str,
employee_id: str,
message_content: str,
business_category: str,
routing_confidence: float,
contact: Optional[BusinessContact],
) -> None:
"""记录路由命中事件(P1)。
为后续优化 Prompt 准确率、分析高频非IT业务提供数据支撑。
Args:
db: 异步 DB session
conversation_id: 会话ID
employee_id: 员工ID
message_content: 触发路由的员工消息(截断至500字)
business_category: 业务类别
routing_confidence: 路由置信度
contact: 推荐的联系人对象(可能为 None)
"""
try:
event = RoutingEvent(
conversation_id=conversation_id,
employee_id=employee_id,
message_content=message_content[:500],
business_category=business_category,
routing_confidence=routing_confidence,
contact_id=contact.id if contact else None,
contact_name=contact.name if contact else "",
is_clicked=False,
)
db.add(event)
await db.flush()
await db.commit()
except Exception as e:
# 路由事件记录失败不影响主流程,仅记录 warning
logger.warning(f"路由事件记录失败: {e}")