From 7886f2f523856cf64d5a2746c1f008ae1fa0d649 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 18 Jul 2026 11:24:10 +0800 Subject: [PATCH] =?UTF-8?q?kfid=E8=B7=AF=E7=94=B1=E7=AA=97=E5=8F=A3:=205?= =?UTF-8?q?=E7=B1=BB=E5=88=AB=E6=98=A0=E5=B0=84=E4=BC=81=E5=BE=AE=E5=AE=A2?= =?UTF-8?q?=E6=9C=8D=E9=93=BE=E6=8E=A5=EF=BC=88ROUTING=5FTARGETS=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8C=96=EF=BC=8C=E5=BC=83=E7=94=A8business=5Fcontact?= =?UTF-8?q?s=E4=B8=AA=E4=BA=BA=E5=90=8D=E7=89=87=EF=BC=89+=20send=5Fservic?= =?UTF-8?q?e=5Fcard=E6=96=B0=E5=87=BD=E6=95=B0=20+=20ContactCard=E6=94=AF?= =?UTF-8?q?=E6=8C=81service=5Furl=E7=82=B9=E5=87=BB=E6=89=93=E5=BC=80?= =?UTF-8?q?=E5=AE=A2=E6=9C=8D=E4=BC=9A=E8=AF=9D=EF=BC=88=E8=A1=8C=E6=94=BF?= =?UTF-8?q?=E2=86=92=E6=9C=BA=E7=A5=A8=E9=85=92=E5=BA=97=E5=89=8D=E5=8F=B0?= =?UTF-8?q?/=E4=BA=BA=E5=8A=9B=E2=86=92=E5=85=B1=E4=BA=AB=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1/=E8=B4=A2=E5=8A=A1=E2=86=92=E6=8A=A5=E9=94=80?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=8F=B0/=E6=B3=95=E5=8A=A1=E2=86=92?= =?UTF-8?q?=E8=A1=8C=E6=94=BF=E6=B3=95=E5=8A=A1/=E7=89=A9=E4=B8=9A?= =?UTF-8?q?=E2=86=92=E7=89=A9=E4=B8=9A=E6=9C=8D=E5=8A=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/routing_service.py | 243 ++++++++++++++++++ backend/app/tasks/h5_ai_task.py | 40 +-- .../src/components/chat/ContactCard.vue | 21 +- 3 files changed, 279 insertions(+), 25 deletions(-) diff --git a/backend/app/services/routing_service.py b/backend/app/services/routing_service.py index 8778a10..456aa7e 100644 --- a/backend/app/services/routing_service.py +++ b/backend/app/services/routing_service.py @@ -35,6 +35,61 @@ 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:修复每次调用新建连接的泄漏问题) # ============================================================================= @@ -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( db, conversation: Conversation, diff --git a/backend/app/tasks/h5_ai_task.py b/backend/app/tasks/h5_ai_task.py index 6d1f494..348f718 100644 --- a/backend/app/tasks/h5_ai_task.py +++ b/backend/app/tasks/h5_ai_task.py @@ -823,25 +823,26 @@ async def _handle_routing( routing_confidence = 0.75 # 降级兜底给一个略高于阈值的置信度 logger.info(f"路由意图检测(兜底): business_category={business_category}") - # 4. 查询联系人 - contact = await get_contact_by_category(db, business_category) - if not contact: - logger.warning(f"未找到 {business_category} 类别的联系人,跳过路由推荐") + # 4. 查询路由目标窗口(v4.0 kfid 配置化) + from app.services.routing_service import get_routing_target, send_service_card + target = get_routing_target(business_category) + if not target: + logger.warning(f"未配置 {business_category} 类别的路由窗口,跳过路由推荐") return False # 5. 构建路由说明文本 category_display = business_category.replace("行政-物业", "物业") reason = ( f"您的问题属于{category_display}业务范畴,不在IT服务台服务范围内 😊\n\n" - f"为您推荐{category_display}服务相关联系人,您可以直接点击名片联系TA:" + f"为您推荐「{target['service_name']}」,点击下方卡片即可咨询:" ) - # 6. 发送名片三段式消息 - await send_contact_card( + # 6. 发送服务窗口卡片消息(kfid 模式) + await send_service_card( db=db, conversation=conversation, employee_id=employee_id, - contact=contact, + target=target, reason=reason, business_category=business_category, routing_confidence=routing_confidence, @@ -855,7 +856,7 @@ async def _handle_routing( message_content=content, business_category=business_category, routing_confidence=routing_confidence, - contact=contact, + contact=None, ) 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 为空,跳过") return False - # 查询联系人 - contact = await get_contact_by_category(db, business_category) - if not contact: - logger.warning(f"未找到 {business_category} 类别的联系人,跳过路由推荐") + # 查询路由目标窗口(v4.0 kfid 配置化,替代 business_contacts 表查询) + from app.services.routing_service import get_routing_target, send_service_card + target = get_routing_target(business_category) + if not target: + logger.warning(f"未配置 {business_category} 类别的路由窗口,跳过路由推荐") return False # 构建路由说明文本 category_display = business_category.replace("行政-物业", "物业") reason = ( f"您的问题属于{category_display}业务范畴,不在IT服务台服务范围内 😊\n\n" - f"为您推荐{category_display}服务相关联系人,您可以直接点击名片联系TA:" + f"为您推荐「{target['service_name']}」,点击下方卡片即可咨询:" ) - # 发送名片三段式消息 - await send_contact_card( + # 发送服务窗口卡片消息(kfid 模式) + await send_service_card( db=db, conversation=conversation, employee_id=employee_id, - contact=contact, + target=target, reason=reason, business_category=business_category, routing_confidence=routing_confidence, ) - # 记录路由事件 + # 记录路由事件(kfid 模式无 contact 对象,contact_name 存窗口名) await record_routing_event( db=db, conversation_id=str(conversation.id), @@ -1105,7 +1107,7 @@ async def _step_routing_from_result(db, conversation, employee_id, content, resu message_content=content, business_category=business_category, routing_confidence=routing_confidence, - contact=contact, + contact=None, ) return True diff --git a/frontend-h5/src/components/chat/ContactCard.vue b/frontend-h5/src/components/chat/ContactCard.vue index b6129e4..27b1ba4 100644 --- a/frontend-h5/src/components/chat/ContactCard.vue +++ b/frontend-h5/src/components/chat/ContactCard.vue @@ -27,19 +27,19 @@
{{ contact.name }} - {{ genderIcon }} + {{ genderIcon }}
{{ contact.department }} · {{ contact.position }}
- - 员工名片 + + {{ contact.service_url ? '服务窗口' : '员工名片' }} - +
- 负责业务 + {{ contact.service_url ? '服务内容' : '负责业务' }} {{ contact.responsibility }} @@ -58,7 +58,7 @@
@@ -88,6 +88,8 @@ export interface ContactInfo { wecom_userid: string avatar_url: string business_category: string + /** v4.0 kfid 服务模式:企微客服窗口链接(存在时点击打开链接而非 openEnterpriseChat) */ + service_url?: string } const props = defineProps<{ @@ -142,6 +144,13 @@ const serviceAreaLabel = computed(() => { * - openEnterpriseChat 调用失败 → Toast 提示 */ 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 // 1. 检查 userid 是否有效