449c6d4875
## 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分块上传
483 lines
17 KiB
Python
483 lines
17 KiB
Python
# =============================================================================
|
||
# 企微IT智能服务台 — 分层排队服务
|
||
# =============================================================================
|
||
# 说明:实现三段排序的排队位置计算和平台统计
|
||
#
|
||
# 排队三段排序(决策 C1/C2):
|
||
# 段1(VIP):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
|