kfid路由窗口: 5类别映射企微客服链接(ROUTING_TARGETS配置化,弃用business_contacts个人名片)+ send_service_card新函数 + ContactCard支持service_url点击打开客服会话(行政→机票酒店前台/人力→共享服务/财务→报销服务台/法务→行政法务/物业→物业服务)
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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 是否有效
|
||||||
|
|||||||
Reference in New Issue
Block a user