2026-07-11 23:13:10 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 企微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__)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 11:24:10 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 路由目标配置(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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 01:05:20 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 共享 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 23:13:10 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 路由关键词预过滤列表
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 说明:覆盖 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)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 构建请求 URL:base_url + /v1/chat-messages(Dify 原生 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",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 01:05:20 +08:00
|
|
|
|
client = await _get_routing_client(timeout)
|
|
|
|
|
|
response = await client.post(url, json=body, headers=headers)
|
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
data = response.json()
|
2026-07-11 23:13:10 +08:00
|
|
|
|
|
2026-07-18 01:05:20 +08:00
|
|
|
|
# 解析 Dify 原生响应:answer 字段包含 AI 返回的 JSON 字符串
|
|
|
|
|
|
answer = data.get("answer", "")
|
|
|
|
|
|
parsed = json.loads(answer)
|
2026-07-11 23:13:10 +08:00
|
|
|
|
|
2026-07-18 01:05:20 +08:00
|
|
|
|
# 解析统一意图识别的 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)),
|
|
|
|
|
|
}
|
2026-07-11 23:13:10 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 联系人查询
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 名片三段式发送
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
2026-07-18 11:24:10 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 23:13:10 +08:00
|
|
|
|
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}")
|