Files
wecom_it_smart_desk/backend/app/services/queue_service.py
T
Simon 449c6d4875 feat: 2026-07-12~13 全量更新 - AI对话链路改造+H5 v4/v5+坐席端v5+上下文感知诊断+知识库迭代3
## H5 员工端 v4 (2026-07-13 00:48 已部署)
- 人工按钮三态文案统一为"人工坐席"
- 按钮位置移至发送键和语音按钮上方(垂直堆叠)
- 点按钮直接调 store.shakeAgent(),删除 CallAgentModal 弹窗动画
- 截图快捷键提示改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V"
- 移动端隐藏截图提示(CSS 媒体查询)
- AI转人工提示改为"已为您呼叫人工坐席,请稍等!"
- 坐席接入提示改为"坐席正在查看您的信息,请等待处理回复!"
- 删除"摇铃呼叫坐席"入口和文案
- 删除孤儿组件 MessageList.vue + shake 动画 CSS

## H5 员工端 v5 (2026-07-13 02:08 已部署)
- RightPanel v2.1:删除"软件安装"和"资源权限"标签页
- 移除标签栏,智能推荐(DynamicRecommend)直接展示
- 删除 SoftwareDownloads/ApprovalLinks 引用和相关 CSS

## AI 对话链路全栈改造 Phase 1-6 (已部署)
- Phase 1: Dify JSON输出 + 后端blocking解析 + 双WS推送 + 错误降级
- Phase 2: 关键词收窄(~25强意图词) + 两级分类Prompt + 删除前端checkApprovalIntent
- Phase 3: WS扩展(ai_thinking+dynamic_recommend) + ai_structured气泡 + RightPanel v2 + 选项回传
- Phase 4: VisionService接入 + 图片消息融合(5秒窗口) + 降级策略
- Phase 5: 坐席端ai_thinking指示器 + ai_structured/byod_card渲染 + handleNewMessage修复
- Phase 6: diagnosis_stage(6值) + response_time_ms计时 + 慢响应告警(>10s)

## 坐席端 v5 (2026-07-13 01:38 已部署)
- ai_structured/byod_card 只读渲染
- AI思考指示器 UI
- handleNewMessage 透传 msg_type/extra_data 修复
- 布局优化v2.0: QuickReplyBar L1+L2悬浮 + ReplyBox左右分区 + 右栏260/560px切换
- 键盘快捷键v2.3: 纯数字路由 + ESC分层撤销 + Shift+Space用event.code

## 上下文感知智能诊断闭环 (2026-07-12 已部署)
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked)
- 答题插队 + 五场景关闭
- 迁移052(6表+6列) + queue_service + quiz_service + closing_service
- H5前端: QueueWaiting + RightPanel双Tab + InputBar三态 + ResolveConfirmCard
- 坐席前端: pending_close结单流程 + 信息锁定(Dify步骤完成+有效回答率≥70%)

## 知识库迭代3 (2026-07-12 已部署)
- 分诊交互(H5+坐席+Dify独立应用)
- 拓扑预览(ECharts只读)
- 代答排除(4种匹配器: keyword/regex/intent/category)
- 迁移051 + 44文件43测试通过

## 后端变更
- 6个Python文件改造(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等)
- funny_phrase_service.py: shake/connected/keyword 默认文案更新
- session_service.py: 企微消息文案同步
- 新增: queue.py/quiz.py/triage.py/exclusion_rules.py 等API端点
- 新增: diagnostic.py/quiz.py/triage_session.py 等模型
- 新增: closing_service/queue_service/quiz_service/triage_service 等服务

## 文档更新
- CHANGELOG.md: 新增 [未发布] 区全部变更记录
- 项目管理主文档 v2.5: 新增v0.7.3版本 + 已完成看板 + 最近搞定
- 版本记录: 新增v0.7.3条目
- AI对话链路实施计划: Phase 1-6 全部标记已实施
- 新增架构图/时序图/类图(mermaid)

## 部署路径修正
- 服务器项目根路径: /opt/wecom-it-desk/
- 所有前端dist均为ro bind mount,只能在宿主机源路径操作
- 服务器nginx /h5/ 是静态文件服务(非proxy_pass)
- elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传
2026-07-13 02:17:03 +08:00

483 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微IT智能服务台 — 分层排队服务
# =============================================================================
# 说明:实现三段排序的排队位置计算和平台统计
#
# 排队三段排序(决策 C1/C2):
# 段1VIP):is_vip = true,不受信息梳理影响
# 段2(已梳理):is_vip = false AND info_locked = true
# 段3(待梳理):is_vip = false AND info_locked = false
#
# 段内排序:queue_priority DESC → urgency_score DESC → created_at ASC
# 插队规则(决策 C4):queue_priority = min(答题数//3, 2),上限2
# =============================================================================
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_, select, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.conversation import Conversation
from app.models.quiz import EmployeePoints
logger = logging.getLogger(__name__)
# 预估每个排队者的服务时间(秒),用于计算预估等待时间
ESTIMATED_SERVICE_TIME_SEC = 300 # 5分钟/人
class QueueService:
"""分层排队服务。
提供排队位置计算、平台统计、坐席端看板数据等功能。
"""
# ======================================================================
# 段位判定
# ======================================================================
@staticmethod
def _determine_segment(conversation: Conversation) -> str:
"""判定会话属于哪个排队段位。
Args:
conversation: 会话对象
Returns:
str: "vip" / "completed" / "incomplete"
"""
if conversation.is_vip:
return "vip"
elif conversation.info_locked:
return "completed"
else:
return "incomplete"
# ======================================================================
# 排队位置计算
# ======================================================================
async def calculate_queue_position(
self, db: AsyncSession, conversation: Conversation
) -> Dict[str, Any]:
"""计算指定会话的排队位置(三段排序)。
排序逻辑:
1. VIP段排最前
2. 已梳理(info_locked=true)段排第二
3. 待梳理(info_locked=false)段排最后
4. 同段内:queue_priority DESC → urgency_score DESC → created_at ASC
Args:
db: 数据库会话
conversation: 要计算位置的会话
Returns:
Dict: {position, segment, ahead_count, estimated_wait_sec}
"""
segment = self._determine_segment(conversation)
ahead_count = 0
# ---- 计算更高段的人数 ----
if segment != "vip":
# 当前不是VIP段 → 所有VIP都排前面
vip_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == True, # noqa: E712
)
)
ahead_count += vip_count or 0
if segment == "incomplete":
# 当前是待梳理段 → 已梳理段也排前面
completed_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == False, # noqa: E712
Conversation.info_locked == True, # noqa: E712
)
)
ahead_count += completed_count or 0
# ---- 计算同段内排在前面的人数 ----
same_segment_ahead = await self._count_same_segment_ahead(db, conversation, segment)
ahead_count += same_segment_ahead
position = ahead_count + 1
estimated_wait = position * ESTIMATED_SERVICE_TIME_SEC
# 中文段位名称(前端展示用)
segment_labels = {
"vip": "VIP优先",
"completed": "已梳理",
"incomplete": "待梳理",
}
return {
"position": position,
"segment": segment,
"segment_label": segment_labels.get(segment, segment),
"ahead_count": ahead_count,
"estimated_wait_sec": estimated_wait,
"estimated_wait_text": self._format_wait_time(estimated_wait),
"queue_priority": conversation.queue_priority,
}
async def _count_same_segment_ahead(
self, db: AsyncSession, conversation: Conversation, segment: str
) -> int:
"""计算同段内排在当前会话前面的排队人数。
段内排序规则:queue_priority DESC → urgency_score DESC → created_at ASC
Args:
db: 数据库会话
conversation: 当前会话
segment: 当前段位
Returns:
int: 同段内排在前面的人数
"""
# 构建同段条件
conditions = [
Conversation.status == "queued",
Conversation.id != conversation.id, # 排除自己
]
if segment == "vip":
conditions.append(Conversation.is_vip == True) # noqa: E712
elif segment == "completed":
conditions.append(Conversation.is_vip == False) # noqa: E712
conditions.append(Conversation.info_locked == True) # noqa: E712
else: # incomplete
conditions.append(Conversation.is_vip == False) # noqa: E712
conditions.append(Conversation.info_locked == False) # noqa: E712
# 同段内排在前面的条件:
# 1. queue_priority 更高
# 2. 或 queue_priority 相同且 urgency_score 更高
# 3. 或 queue_priority 和 urgency_score 都相同且 created_at 更早
ahead_conditions = or_(
Conversation.queue_priority > conversation.queue_priority,
and_(
Conversation.queue_priority == conversation.queue_priority,
Conversation.urgency_score > conversation.urgency_score,
),
and_(
Conversation.queue_priority == conversation.queue_priority,
Conversation.urgency_score == conversation.urgency_score,
Conversation.created_at < conversation.created_at,
),
)
count = await db.scalar(
select(func.count(Conversation.id)).where(
*conditions, ahead_conditions
)
)
return count or 0
# ======================================================================
# 平台统计
# ======================================================================
async def get_platform_stats(self, db: AsyncSession) -> Dict[str, int]:
"""获取平台统计数据(决策 C5)。
total_active = ai_handling + queued + serving
queued = 排队中人数
serving = 服务中人数
Args:
db: 数据库会话
Returns:
Dict: {total_active, queued, serving, ai_handling}
"""
# 总活跃 = ai_handling + queued + serving
total_active = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status.in_(["ai_handling", "queued", "serving"])
)
)
queued = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued"
)
)
serving = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "serving"
)
)
ai_handling = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "ai_handling"
)
)
return {
"total_active": total_active or 0,
"queued": queued or 0,
"serving": serving or 0,
"ai_handling": ai_handling or 0,
}
async def get_queue_segment_stats(self, db: AsyncSession) -> Dict[str, int]:
"""获取排队分段统计(坐席端看板用)。
Returns:
Dict: {vip_count, completed_count, incomplete_count, total_queued}
"""
# VIP段
vip_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == True, # noqa: E712
)
)
# 已梳理段
completed_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == False, # noqa: E712
Conversation.info_locked == True, # noqa: E712
)
)
# 待梳理段
incomplete_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == False, # noqa: E712
Conversation.info_locked == False, # noqa: E712
)
)
total_queued = (vip_count or 0) + (completed_count or 0) + (incomplete_count or 0)
return {
"vip_count": vip_count or 0,
"completed_count": completed_count or 0,
"incomplete_count": incomplete_count or 0,
"total_queued": total_queued,
}
# ======================================================================
# 综合排队状态(H5端 queue/status API
# ======================================================================
async def get_comprehensive_status(
self, db: AsyncSession, conversation: Conversation
) -> Dict[str, Any]:
"""获取综合排队状态:排队位置+段位+平台统计+答题状态+积分。
供 GET /api/h5/queue/status API调用。
Args:
db: 数据库会话
conversation: 当前会话
Returns:
Dict: 综合状态数据
"""
# 1. 排队位置(仅排队中时计算)
if conversation.status == "queued":
queue_info = await self.calculate_queue_position(db, conversation)
else:
queue_info = {
"position": 0,
"segment": self._determine_segment(conversation),
"segment_label": "非排队中",
"ahead_count": 0,
"estimated_wait_sec": 0,
"estimated_wait_text": "",
"queue_priority": conversation.queue_priority,
}
# 2. 平台统计
platform_stats = await self.get_platform_stats(db)
# 3. 积分信息
points_info = await self._get_employee_points(db, conversation.employee_id)
# 4. 插队信息
quiz_answered = conversation.queue_priority * 3 if conversation.queue_priority > 0 else 0
max_quiz_for_jump = 6 # 2位×3题=6题
remaining_for_next_jump = 3 - (quiz_answered % 3) if quiz_answered < max_quiz_for_jump else 0
return {
"conversation_status": conversation.status,
"queue": queue_info,
"platform": platform_stats,
"points": points_info,
"quiz": {
"answered_in_session": quiz_answered,
"queue_priority": conversation.queue_priority,
"max_priority": 2,
"remaining_for_next_jump": remaining_for_next_jump,
"can_jump_more": conversation.queue_priority < 2,
},
"info_locked": conversation.info_locked,
}
# ======================================================================
# 坐席端排队看板
# ======================================================================
async def get_agent_dashboard(self, db: AsyncSession) -> Dict[str, Any]:
"""获取坐席端排队看板数据。
Returns:
Dict: {segment_stats, platform_stats, queue_list}
"""
segment_stats = await self.get_queue_segment_stats(db)
platform_stats = await self.get_platform_stats(db)
# 获取排队列表(按三段排序)
queue_list = await self._get_sorted_queue_list(db, limit=50)
return {
"segments": segment_stats,
"platform": platform_stats,
"queue_list": queue_list,
}
async def _get_sorted_queue_list(
self, db: AsyncSession, limit: int = 50
) -> List[Dict[str, Any]]:
"""获取按三段排序的排队列表。
Returns:
List[Dict]: 排队会话列表
"""
# 查询所有排队中的会话,按段位+段内排序
# 段位排序:VIP(0) > 已梳理(1) > 待梳理(2)
segment_order = case(
(Conversation.is_vip == True, 0), # noqa: E712
(Conversation.info_locked == True, 1), # noqa: E712
else_=2,
)
stmt = (
select(Conversation)
.where(Conversation.status == "queued")
.order_by(
segment_order,
Conversation.queue_priority.desc(),
Conversation.urgency_score.desc(),
Conversation.created_at.asc(),
)
.limit(limit)
)
result = await db.execute(stmt)
conversations = result.scalars().all()
# 转为前端需要的列表格式
queue_list = []
for conv in conversations:
segment = self._determine_segment(conv)
queue_list.append({
"conversation_id": conv.id,
"employee_name": conv.employee_name,
"department": conv.department,
"employee_id": conv.employee_id,
"segment": segment,
"segment_label": {
"vip": "VIP优先",
"completed": "已梳理",
"incomplete": "待梳理",
}.get(segment, segment),
"urgency_score": conv.urgency_score,
"queue_priority": conv.queue_priority,
"info_locked": conv.info_locked,
"is_vip": conv.is_vip,
"last_message_summary": conv.last_message_summary,
"created_at": conv.created_at.isoformat() if conv.created_at else None,
"waiting_seconds": int(
(datetime.now(timezone.utc) - conv.created_at).total_seconds()
) if conv.created_at else 0,
})
return queue_list
# ======================================================================
# 辅助方法
# ======================================================================
async def _get_employee_points(
self, db: AsyncSession, employee_id: str
) -> Dict[str, Any]:
"""获取员工积分信息。
Args:
db: 数据库会话
employee_id: 员工ID
Returns:
Dict: {total_points, level, answered_count, correct_count}
"""
result = await db.execute(
select(EmployeePoints).where(EmployeePoints.employee_id == employee_id)
)
points = result.scalar_one_or_none()
if points:
return {
"total_points": points.total_points,
"level": points.level,
"answered_count": points.answered_count,
"correct_count": points.correct_count,
}
else:
return {
"total_points": 0,
"level": "IT小白",
"answered_count": 0,
"correct_count": 0,
}
@staticmethod
def _format_wait_time(seconds: int) -> str:
"""将秒数格式化为人类可读的等待时间文本。
Args:
seconds: 秒数
Returns:
str: 如"约5分钟""约1小时30分钟"
"""
if seconds <= 0:
return "即将接通"
minutes = seconds // 60
if minutes < 1:
return f"{seconds}"
elif minutes < 60:
return f"{minutes}分钟"
else:
hours = minutes // 60
remaining_minutes = minutes % 60
if remaining_minutes == 0:
return f"{hours}小时"
return f"{hours}小时{remaining_minutes}分钟"
# 单例
_queue_service: Optional[QueueService] = None
def get_queue_service() -> QueueService:
"""获取 QueueService 单例。"""
global _queue_service
if _queue_service is None:
_queue_service = QueueService()
return _queue_service