kfid路由窗口: 5类别映射企微客服链接(ROUTING_TARGETS配置化,弃用business_contacts个人名片)+ send_service_card新函数 + ContactCard支持service_url点击打开客服会话(行政→机票酒店前台/人力→共享服务/财务→报销服务台/法务→行政法务/物业→物业服务)

This commit is contained in:
Simon
2026-07-18 11:24:10 +08:00
parent 28bc4bda3b
commit 7886f2f523
3 changed files with 279 additions and 25 deletions
+243
View File
@@ -35,6 +35,61 @@ from app.services.ws_manager import manager as ws_manager
logger = logging.getLogger(__name__) 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:修复每次调用新建连接的泄漏问题) # 共享 httpx 客户端(v4.0 P1-2:修复每次调用新建连接的泄漏问题)
# ============================================================================= # =============================================================================
@@ -240,6 +295,194 @@ async def get_contact_by_category(db, category: str) -> Optional[BusinessContact
# 名片三段式发送 # 名片三段式发送
# ============================================================================= # =============================================================================
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( async def send_contact_card(
db, db,
conversation: Conversation, conversation: Conversation,
+21 -19
View File
@@ -823,25 +823,26 @@ async def _handle_routing(
routing_confidence = 0.75 # 降级兜底给一个略高于阈值的置信度 routing_confidence = 0.75 # 降级兜底给一个略高于阈值的置信度
logger.info(f"路由意图检测(兜底): business_category={business_category}") logger.info(f"路由意图检测(兜底): business_category={business_category}")
# 4. 查询联系人 # 4. 查询路由目标窗口(v4.0 kfid 配置化)
contact = await get_contact_by_category(db, business_category) from app.services.routing_service import get_routing_target, send_service_card
if not contact: target = get_routing_target(business_category)
logger.warning(f"未找到 {business_category} 类别的联系人,跳过路由推荐") if not target:
logger.warning(f"未配置 {business_category} 类别的路由窗口,跳过路由推荐")
return False return False
# 5. 构建路由说明文本 # 5. 构建路由说明文本
category_display = business_category.replace("行政-物业", "物业") category_display = business_category.replace("行政-物业", "物业")
reason = ( reason = (
f"您的问题属于{category_display}业务范畴,不在IT服务台服务范围内 😊\n\n" f"您的问题属于{category_display}业务范畴,不在IT服务台服务范围内 😊\n\n"
f"为您推荐{category_display}服务相关联系人,您可以直接点击名片联系TA" f"为您推荐{target['service_name']}」,点击下方卡片即可咨询"
) )
# 6. 发送名片三段式消息 # 6. 发送服务窗口卡片消息(kfid 模式)
await send_contact_card( await send_service_card(
db=db, db=db,
conversation=conversation, conversation=conversation,
employee_id=employee_id, employee_id=employee_id,
contact=contact, target=target,
reason=reason, reason=reason,
business_category=business_category, business_category=business_category,
routing_confidence=routing_confidence, routing_confidence=routing_confidence,
@@ -855,7 +856,7 @@ async def _handle_routing(
message_content=content, message_content=content,
business_category=business_category, business_category=business_category,
routing_confidence=routing_confidence, routing_confidence=routing_confidence,
contact=contact, contact=None,
) )
return True return True
@@ -1073,31 +1074,32 @@ async def _step_routing_from_result(db, conversation, employee_id, content, resu
logger.warning("[D1] 路由意图为 non_it_routing 但 business_category 为空,跳过") logger.warning("[D1] 路由意图为 non_it_routing 但 business_category 为空,跳过")
return False return False
# 查询联系人 # 查询路由目标窗口(v4.0 kfid 配置化,替代 business_contacts 表查询)
contact = await get_contact_by_category(db, business_category) from app.services.routing_service import get_routing_target, send_service_card
if not contact: target = get_routing_target(business_category)
logger.warning(f"未找到 {business_category} 类别的联系人,跳过路由推荐") if not target:
logger.warning(f"未配置 {business_category} 类别的路由窗口,跳过路由推荐")
return False return False
# 构建路由说明文本 # 构建路由说明文本
category_display = business_category.replace("行政-物业", "物业") category_display = business_category.replace("行政-物业", "物业")
reason = ( reason = (
f"您的问题属于{category_display}业务范畴,不在IT服务台服务范围内 😊\n\n" f"您的问题属于{category_display}业务范畴,不在IT服务台服务范围内 😊\n\n"
f"为您推荐{category_display}服务相关联系人,您可以直接点击名片联系TA" f"为您推荐{target['service_name']}」,点击下方卡片即可咨询"
) )
# 发送名片三段式消息 # 发送服务窗口卡片消息(kfid 模式)
await send_contact_card( await send_service_card(
db=db, db=db,
conversation=conversation, conversation=conversation,
employee_id=employee_id, employee_id=employee_id,
contact=contact, target=target,
reason=reason, reason=reason,
business_category=business_category, business_category=business_category,
routing_confidence=routing_confidence, routing_confidence=routing_confidence,
) )
# 记录路由事件 # 记录路由事件kfid 模式无 contact 对象,contact_name 存窗口名)
await record_routing_event( await record_routing_event(
db=db, db=db,
conversation_id=str(conversation.id), conversation_id=str(conversation.id),
@@ -1105,7 +1107,7 @@ async def _step_routing_from_result(db, conversation, employee_id, content, resu
message_content=content, message_content=content,
business_category=business_category, business_category=business_category,
routing_confidence=routing_confidence, routing_confidence=routing_confidence,
contact=contact, contact=None,
) )
return True return True
@@ -27,19 +27,19 @@
<div class="contact-card__name-row"> <div class="contact-card__name-row">
<div class="contact-card__name"> <div class="contact-card__name">
{{ contact.name }} {{ contact.name }}
<span class="contact-card__gender" :class="genderClass">{{ genderIcon }}</span> <span v-if="contact.gender" class="contact-card__gender" :class="genderClass">{{ genderIcon }}</span>
</div> </div>
<div class="contact-card__dept">{{ contact.department }} · {{ contact.position }}</div> <div class="contact-card__dept">{{ contact.department }} · {{ contact.position }}</div>
</div> </div>
<!-- 名片标识 --> <!-- 名片标识kfid 服务模式显示服务窗口 -->
<span class="contact-card__badge">员工名片</span> <span class="contact-card__badge">{{ contact.service_url ? '服务窗口' : '员工名片' }}</span>
</div> </div>
<!-- 卡片信息区负责业务(绿色高亮) / 分机号 / 服务区域 --> <!-- 卡片信息区负责业务/服务描述(绿色高亮) / 分机号 / 服务区域 -->
<div class="contact-card__body"> <div class="contact-card__body">
<div class="contact-card__info-item"> <div class="contact-card__info-item">
<span class="contact-card__info-label">负责业务</span> <span class="contact-card__info-label">{{ contact.service_url ? '服务内容' : '负责业务' }}</span>
<span class="contact-card__info-value contact-card__info-value--highlight"> <span class="contact-card__info-value contact-card__info-value--highlight">
{{ contact.responsibility }} {{ contact.responsibility }}
</span> </span>
@@ -58,7 +58,7 @@
<div class="contact-card__footer"> <div class="contact-card__footer">
<button class="contact-card__action-btn" @click="handleContact"> <button class="contact-card__action-btn" @click="handleContact">
<span class="contact-card__action-icon">💬</span> <span class="contact-card__action-icon">💬</span>
<span>联系TA</span> <span>{{ contact.service_url ? '立即咨询' : '联系TA' }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -88,6 +88,8 @@ export interface ContactInfo {
wecom_userid: string wecom_userid: string
avatar_url: string avatar_url: string
business_category: string business_category: string
/** v4.0 kfid 服务模式:企微客服窗口链接(存在时点击打开链接而非 openEnterpriseChat */
service_url?: string
} }
const props = defineProps<{ const props = defineProps<{
@@ -142,6 +144,13 @@ const serviceAreaLabel = computed(() => {
* - openEnterpriseChat 调用失败 → Toast 提示 * - openEnterpriseChat 调用失败 → Toast 提示
*/ */
function handleContact(): void { function handleContact(): void {
// v4.0 kfid 服务模式:有 service_url 时直接打开企微客服会话链接
if (props.contact.service_url) {
// 企微 H5 环境:window.open 打开 kfid 链接会唤起客服会话窗口
window.open(props.contact.service_url, '_blank')
return
}
const userid = props.contact.wecom_userid const userid = props.contact.wecom_userid
// 1. 检查 userid 是否有效 // 1. 检查 userid 是否有效