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

716 lines
27 KiB
Python
Raw Normal View History

# =============================================================================
# 企微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}")