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分块上传
923 lines
34 KiB
Python
923 lines
34 KiB
Python
# =============================================================================
|
||
# 企微IT智能服务台 — 关闭机制服务
|
||
# =============================================================================
|
||
# 说明:管理会话的完整关闭生命周期,包括五种关闭场景:
|
||
# 1. AI自助解决(employee_self_resolve)— 员工确认AI已解决
|
||
# 2. 坐席结单确认(agent_initiate_resolve → employee_confirm_resolve)— 坐席发起→员工确认
|
||
# 3. 员工主动关闭(employee_initiative_close)— 员工自行关闭
|
||
# 4. 超时自动关闭(auto_timeout_close)— 系统定时任务触发
|
||
# 5. 不满意重新接入(reopen_conversation)— 24h内重开创建新会话
|
||
#
|
||
# 状态流转:
|
||
# ai_handling →(AI解决+员工确认)→ resolved
|
||
# ai_handling →(30min超时→提醒→10min)→ resolved
|
||
# serving →(坐席结单+员工确认)→ resolved
|
||
# serving →(坐席结单+员工拒绝)→ serving(继续服务)
|
||
# serving →(10min无响应)→ pending_close →(5min)→ resolved
|
||
# resolved →(24h内重开)→ 新会话(关联原会话ID)
|
||
#
|
||
# 知识沉淀:resolved后检查是否有诊断报告+修复记录→生成知识条目草稿→管理后台审核
|
||
# =============================================================================
|
||
|
||
import logging
|
||
from datetime import datetime, timedelta
|
||
from typing import Any, Dict, Optional
|
||
from uuid import UUID
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.models.agent import Agent
|
||
from app.models.conversation import Conversation
|
||
from app.models.message import Message
|
||
from app.services.ws_manager import manager as ws_manager
|
||
from app.utils.response import AppException
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# =============================================================================
|
||
# 超时配置(分钟)
|
||
# =============================================================================
|
||
# AI处理阶段超时:30分钟无互动 → 发送提醒 → 再过10分钟 → 自动关闭
|
||
AI_HANDLING_TIMEOUT_MINUTES = 30
|
||
AI_HANDLING_REMINDER_TO_CLOSE_MINUTES = 10
|
||
|
||
# 坐席服务阶段:已在 reminder_task.py 中定义
|
||
# REMINDER_TIMEOUT_MINUTES = 3 → 发送提醒
|
||
# CLOSE_TIMEOUT_MINUTES = 10 → 标记 pending_close
|
||
# 本服务新增:pending_close → resolved 的超时
|
||
PENDING_CLOSE_AUTO_RESOLVE_MINUTES = 5
|
||
|
||
# 重开时限(小时)
|
||
REOPEN_WINDOW_HOURS = 24
|
||
|
||
|
||
class ClosingService:
|
||
"""关闭机制服务 — 管理会话的完整关闭生命周期。
|
||
|
||
核心职责:
|
||
- 处理五种关闭场景的状态转换
|
||
- 推送 WS 事件通知前端
|
||
- 触发知识沉淀流程
|
||
- 处理24h内重开
|
||
|
||
设计决策:
|
||
- 坐席结单需员工确认(G1),员工有权否决
|
||
- AI解决支持卡片确认和关键词识别两种方式(G2)
|
||
- 超时自动关闭作为兜底,防止会话挂起
|
||
- 知识沉淀为异步流程,不阻塞关闭主流程
|
||
"""
|
||
|
||
def __init__(self, db: AsyncSession):
|
||
"""初始化关闭机制服务。
|
||
|
||
Args:
|
||
db: 数据库异步会话
|
||
"""
|
||
self.db = db
|
||
|
||
# ==========================================================================
|
||
# 场景1:AI自助解决 — 员工确认已解决
|
||
# ==========================================================================
|
||
|
||
async def employee_self_resolve(
|
||
self,
|
||
employee_id: str,
|
||
resolve_summary: Optional[str] = None,
|
||
) -> Conversation:
|
||
"""员工确认AI已解决问题(AI自助场景)。
|
||
|
||
触发场景:
|
||
- 对话流中的"已解决"确认卡片按钮
|
||
- 员工发送包含关闭关键词的消息
|
||
|
||
状态转换:ai_handling → resolved
|
||
关闭方:employee
|
||
关闭方式:ai_self
|
||
|
||
Args:
|
||
employee_id: 员工企微UserID
|
||
resolve_summary: 员工可选填写的解决摘要
|
||
|
||
Returns:
|
||
Conversation: 更新后的会话对象
|
||
|
||
Raises:
|
||
AppException: 会话不存在或状态不允许
|
||
"""
|
||
conversation = await self._get_active_conversation(employee_id)
|
||
|
||
# 状态校验:只有 ai_handling 状态可以走AI自助解决
|
||
if conversation.status not in ("ai_handling", "queued"):
|
||
raise AppException(
|
||
1004,
|
||
f"当前会话状态为 {conversation.status},无法通过AI自助关闭。"
|
||
"如需关闭请联系坐席。",
|
||
)
|
||
|
||
# 更新会话状态
|
||
conversation.status = "resolved"
|
||
conversation.resolved_by = "employee"
|
||
conversation.resolved_method = "ai_self"
|
||
conversation.resolve_summary = resolve_summary or "员工确认AI已解决"
|
||
conversation.updated_at = datetime.now()
|
||
self.db.add(conversation)
|
||
await self.db.flush()
|
||
|
||
logger.info(
|
||
f"AI自助解决关闭: conv_id={conversation.id}, employee={employee_id}"
|
||
)
|
||
|
||
# 推送 WS 事件:会话已关闭
|
||
await self._push_conversation_resolved(conversation, "employee", "ai_self")
|
||
|
||
# 触发知识沉淀(异步,不阻塞)
|
||
await self._trigger_knowledge_sedimentation(conversation)
|
||
|
||
return conversation
|
||
|
||
# ==========================================================================
|
||
# 场景2:坐席结单 → 员工确认
|
||
# ==========================================================================
|
||
|
||
async def agent_initiate_resolve(
|
||
self,
|
||
conversation_id: str,
|
||
agent_id: str,
|
||
resolve_summary: str,
|
||
) -> Conversation:
|
||
"""坐席发起结单,触发员工确认流程。
|
||
|
||
状态转换:serving → pending_close
|
||
后续:员工确认 → resolved / 员工拒绝 → serving / 超时 → resolved
|
||
|
||
WS事件:推送 resolve_confirm 给员工,前端弹出确认卡片
|
||
|
||
Args:
|
||
conversation_id: 会话ID
|
||
agent_id: 坐席ID(必须是主责坐席)
|
||
resolve_summary: 结单摘要(问题类型+根因+解决方式)
|
||
|
||
Returns:
|
||
Conversation: 更新后的会话对象
|
||
|
||
Raises:
|
||
AppException: 会话不存在、状态不允许、非主责坐席
|
||
"""
|
||
conversation = await self._get_conversation_by_id(conversation_id)
|
||
|
||
# 状态校验
|
||
if conversation.status == "resolved":
|
||
raise AppException(3002, "会话已结单")
|
||
if conversation.status != "serving":
|
||
raise AppException(
|
||
1004,
|
||
f"当前会话状态为 {conversation.status},只有服务中的会话可以结单。",
|
||
)
|
||
|
||
# 权限校验:只有主责坐席才能结单
|
||
if conversation.assigned_agent_id != agent_id:
|
||
raise AppException(3027, "只有主责坐席才能结单")
|
||
|
||
# 更新会话状态为待关闭
|
||
conversation.status = "pending_close"
|
||
conversation.resolve_summary = resolve_summary
|
||
conversation.pending_close_at = datetime.now()
|
||
conversation.updated_at = datetime.now()
|
||
self.db.add(conversation)
|
||
await self.db.flush()
|
||
|
||
logger.info(
|
||
f"坐席发起结单: conv_id={conversation_id}, agent={agent_id}, "
|
||
f"summary={resolve_summary[:50]}..."
|
||
)
|
||
|
||
# 推送 WS 事件:结单确认请求 → 员工端弹出确认卡片
|
||
await self._push_resolve_confirm(conversation, agent_id, resolve_summary)
|
||
|
||
return conversation
|
||
|
||
async def employee_confirm_resolve(
|
||
self,
|
||
employee_id: str,
|
||
) -> Conversation:
|
||
"""员工确认坐席的结单请求。
|
||
|
||
状态转换:pending_close → resolved
|
||
关闭方:agent
|
||
关闭方式:agent_confirm
|
||
|
||
Args:
|
||
employee_id: 员工企微UserID
|
||
|
||
Returns:
|
||
Conversation: 更新后的会话对象
|
||
|
||
Raises:
|
||
AppException: 无 pending_close 状态的会话
|
||
"""
|
||
# 查找该员工处于 pending_close 状态的会话
|
||
stmt = select(Conversation).where(
|
||
Conversation.employee_id == employee_id,
|
||
Conversation.status == "pending_close",
|
||
).order_by(Conversation.updated_at.desc())
|
||
result = await self.db.execute(stmt)
|
||
conversation = result.scalars().first()
|
||
|
||
if not conversation:
|
||
raise AppException(1005, "没有待确认的结单请求")
|
||
|
||
# 更新会话状态
|
||
conversation.status = "resolved"
|
||
conversation.resolved_by = "agent"
|
||
conversation.resolved_method = "agent_confirm"
|
||
conversation.updated_at = datetime.now()
|
||
self.db.add(conversation)
|
||
|
||
# 更新坐席服务数 -1
|
||
await self._decrement_agent_load(conversation.assigned_agent_id)
|
||
|
||
await self.db.flush()
|
||
|
||
logger.info(
|
||
f"员工确认结单: conv_id={conversation.id}, employee={employee_id}"
|
||
)
|
||
|
||
# 推送 WS 事件
|
||
await self._push_conversation_resolved(conversation, "agent", "agent_confirm")
|
||
|
||
# 触发知识沉淀
|
||
await self._trigger_knowledge_sedimentation(conversation)
|
||
|
||
return conversation
|
||
|
||
async def employee_reject_resolve(
|
||
self,
|
||
employee_id: str,
|
||
reason: Optional[str] = None,
|
||
) -> Conversation:
|
||
"""员工拒绝坐席的结单请求,会话回到服务中。
|
||
|
||
状态转换:pending_close → serving
|
||
重置超时提醒相关字段,让坐席继续服务
|
||
|
||
Args:
|
||
employee_id: 员工企微UserID
|
||
reason: 拒绝原因(可选)
|
||
|
||
Returns:
|
||
Conversation: 更新后的会话对象
|
||
"""
|
||
stmt = select(Conversation).where(
|
||
Conversation.employee_id == employee_id,
|
||
Conversation.status == "pending_close",
|
||
).order_by(Conversation.updated_at.desc())
|
||
result = await self.db.execute(stmt)
|
||
conversation = result.scalars().first()
|
||
|
||
if not conversation:
|
||
raise AppException(1005, "没有待确认的结单请求")
|
||
|
||
# 恢复会话状态
|
||
conversation.status = "serving"
|
||
conversation.pending_close_at = None
|
||
conversation.reminder_sent = False
|
||
conversation.reminder_sent_at = None
|
||
conversation.updated_at = datetime.now()
|
||
self.db.add(conversation)
|
||
await self.db.flush()
|
||
|
||
logger.info(
|
||
f"员工拒绝结单,恢复服务: conv_id={conversation.id}, "
|
||
f"employee={employee_id}, reason={reason or '未提供'}"
|
||
)
|
||
|
||
# 推送 WS 事件给坐席:员工拒绝了结单
|
||
if conversation.assigned_agent_id:
|
||
await ws_manager.send_to_agent(
|
||
conversation.assigned_agent_id,
|
||
{
|
||
"type": "resolve_rejected",
|
||
"data": {
|
||
"conversation_id": str(conversation.id),
|
||
"employee_id": employee_id,
|
||
"reason": reason or "员工未提供原因",
|
||
"timestamp": datetime.now().isoformat(),
|
||
},
|
||
},
|
||
)
|
||
|
||
# 推送给员工:已恢复服务
|
||
await ws_manager.send_to_employee(
|
||
employee_id,
|
||
{
|
||
"type": "resolve_rejected",
|
||
"data": {
|
||
"conversation_id": str(conversation.id),
|
||
"message": "已为您恢复服务,坐席将继续处理您的问题。",
|
||
"timestamp": datetime.now().isoformat(),
|
||
},
|
||
},
|
||
)
|
||
|
||
return conversation
|
||
|
||
# ==========================================================================
|
||
# 场景3:员工主动关闭
|
||
# ==========================================================================
|
||
|
||
async def employee_initiative_close(
|
||
self,
|
||
employee_id: str,
|
||
close_reason: Optional[str] = None,
|
||
) -> Conversation:
|
||
"""员工主动关闭会话(非AI解决场景)。
|
||
|
||
状态转换:ai_handling/queued/serving → resolved
|
||
关闭方:employee
|
||
关闭方式:employee_initiative
|
||
|
||
适用场景:
|
||
- 员工问题自行解决,不需要AI或坐席帮助
|
||
- 员工不想继续等待
|
||
- 员工问题已通过其他渠道解决
|
||
|
||
Args:
|
||
employee_id: 员工企微UserID
|
||
close_reason: 关闭原因(可选)
|
||
|
||
Returns:
|
||
Conversation: 更新后的会话对象
|
||
"""
|
||
conversation = await self._get_active_conversation(employee_id)
|
||
|
||
# 更新会话状态
|
||
conversation.status = "resolved"
|
||
conversation.resolved_by = "employee"
|
||
conversation.resolved_method = "employee_initiative"
|
||
conversation.resolve_summary = close_reason or "员工主动关闭"
|
||
conversation.updated_at = datetime.now()
|
||
self.db.add(conversation)
|
||
|
||
# 如果有分配坐席,更新坐席服务数
|
||
if conversation.assigned_agent_id:
|
||
await self._decrement_agent_load(conversation.assigned_agent_id)
|
||
|
||
await self.db.flush()
|
||
|
||
logger.info(
|
||
f"员工主动关闭: conv_id={conversation.id}, employee={employee_id}, "
|
||
f"reason={close_reason or '未提供'}"
|
||
)
|
||
|
||
# 推送 WS 事件
|
||
await self._push_conversation_resolved(conversation, "employee", "employee_initiative")
|
||
|
||
return conversation
|
||
|
||
# ==========================================================================
|
||
# 场景4:超时自动关闭(由 reminder_task.py 调用)
|
||
# ==========================================================================
|
||
|
||
async def auto_timeout_close(
|
||
self,
|
||
conversation_id: str,
|
||
timeout_type: str = "pending_close",
|
||
) -> Conversation:
|
||
"""超时自动关闭会话。
|
||
|
||
两种超时场景:
|
||
1. pending_close 超时(坐席发起结单后5分钟员工未响应)
|
||
2. ai_handling 超时(AI处理阶段30+10分钟无互动)
|
||
|
||
状态转换:pending_close/ai_handling → resolved
|
||
关闭方:system_timeout
|
||
关闭方式:auto_timeout
|
||
|
||
Args:
|
||
conversation_id: 会话ID
|
||
timeout_type: 超时类型(pending_close / ai_handling)
|
||
|
||
Returns:
|
||
Conversation: 更新后的会话对象
|
||
"""
|
||
conversation = await self._get_conversation_by_id(conversation_id)
|
||
|
||
# 更新会话状态
|
||
conversation.status = "resolved"
|
||
conversation.resolved_by = "system_timeout"
|
||
conversation.resolved_method = "auto_timeout"
|
||
conversation.resolve_summary = f"系统超时自动关闭({timeout_type})"
|
||
conversation.updated_at = datetime.now()
|
||
self.db.add(conversation)
|
||
|
||
# 如果有分配坐席,更新坐席服务数
|
||
if conversation.assigned_agent_id:
|
||
await self._decrement_agent_load(conversation.assigned_agent_id)
|
||
|
||
await self.db.flush()
|
||
|
||
logger.info(
|
||
f"超时自动关闭: conv_id={conversation_id}, type={timeout_type}"
|
||
)
|
||
|
||
# 推送 WS 事件
|
||
await self._push_conversation_resolved(conversation, "system_timeout", "auto_timeout")
|
||
|
||
# 触发知识沉淀
|
||
await self._trigger_knowledge_sedimentation(conversation)
|
||
|
||
return conversation
|
||
|
||
# ==========================================================================
|
||
# 场景5:24h内重开
|
||
# ==========================================================================
|
||
|
||
async def reopen_conversation(
|
||
self,
|
||
employee_id: str,
|
||
original_conversation_id: str,
|
||
) -> Conversation:
|
||
"""24小时内重开已关闭的会话。
|
||
|
||
创建新会话并关联原会话ID,用于上下文继承。
|
||
新会话状态为 ai_handling,复用原会话的员工信息。
|
||
|
||
Args:
|
||
employee_id: 员工企微UserID
|
||
original_conversation_id: 原会话ID
|
||
|
||
Returns:
|
||
Conversation: 新创建的会话对象
|
||
|
||
Raises:
|
||
AppException: 原会话不存在、未关闭、超过24h窗口
|
||
"""
|
||
# 查找原会话
|
||
original = await self._get_conversation_by_id(original_conversation_id)
|
||
|
||
# 校验:原会话必须已关闭
|
||
if original.status != "resolved":
|
||
raise AppException(1006, "只有已关闭的会话可以重开")
|
||
|
||
# 校验:24小时窗口
|
||
# 使用 updated_at 作为关闭时间近似(resolved后没有专门的 resolved_at 字段)
|
||
close_time = original.updated_at
|
||
if close_time:
|
||
elapsed = datetime.now() - close_time
|
||
if elapsed > timedelta(hours=REOPEN_WINDOW_HOURS):
|
||
raise AppException(
|
||
1007,
|
||
f"已超过 {REOPEN_WINDOW_HOURS} 小时重开窗口,请发起新会话。",
|
||
)
|
||
|
||
# 创建新会话,关联原会话
|
||
new_conversation = Conversation(
|
||
corp_id=original.corp_id,
|
||
employee_id=original.employee_id,
|
||
employee_name=original.employee_name,
|
||
department=original.department,
|
||
position=original.position,
|
||
level=original.level,
|
||
status="ai_handling",
|
||
is_vip=original.is_vip,
|
||
urgency_score=max(original.urgency_score, 2), # 重开提升紧急度
|
||
info_locked=original.info_locked, # 继承信息锁定状态
|
||
queue_priority=0,
|
||
reference_conversation_id=str(original.id), # 关联原会话
|
||
tags={"reopened": True, "original_conv_id": str(original.id)},
|
||
last_message_summary="问题复发,重新接入",
|
||
)
|
||
self.db.add(new_conversation)
|
||
await self.db.flush()
|
||
|
||
logger.info(
|
||
f"重开会话: new_conv={new_conversation.id}, "
|
||
f"original={original_conversation_id}, employee={employee_id}"
|
||
)
|
||
|
||
# 推送 WS 事件给坐席端:有新会话进入
|
||
await ws_manager.broadcast({
|
||
"type": "conversation_created",
|
||
"data": {
|
||
"conversation_id": str(new_conversation.id),
|
||
"employee_id": employee_id,
|
||
"employee_name": new_conversation.employee_name,
|
||
"is_reopen": True,
|
||
"reference_conversation_id": str(original.id),
|
||
"urgency_score": new_conversation.urgency_score,
|
||
},
|
||
})
|
||
|
||
return new_conversation
|
||
|
||
# ==========================================================================
|
||
# 关键词识别(决策 G2)
|
||
# ==========================================================================
|
||
|
||
@staticmethod
|
||
def check_resolve_keywords(message_content: str) -> bool:
|
||
"""检查消息内容是否包含关闭关键词。
|
||
|
||
用于 AI 对话中识别员工表达"已解决"意图。
|
||
当 AI 检测到关键词时,推送确认卡片让员工二次确认。
|
||
|
||
Args:
|
||
message_content: 员工发送的消息内容
|
||
|
||
Returns:
|
||
bool: 是否包含关闭关键词
|
||
"""
|
||
# 延迟导入避免循环依赖
|
||
from app.services.triage_service import RESOLVE_KEYWORDS
|
||
|
||
content_lower = message_content.lower().strip()
|
||
for keyword in RESOLVE_KEYWORDS:
|
||
if keyword in content_lower:
|
||
return True
|
||
return False
|
||
|
||
# ==========================================================================
|
||
# Phase 6A: 诊断闭环协调 — 基于 diagnosis_stage 判断
|
||
# ==========================================================================
|
||
|
||
@staticmethod
|
||
def get_diagnosis_stage(conversation: Conversation) -> Optional[str]:
|
||
"""从会话 tags 中获取当前诊断阶段。
|
||
|
||
做什么:读取 conversation.tags["diagnosis_stage"] 字段,
|
||
该字段由 _persist_and_push_structured() 在每次 AI 回复时更新。
|
||
为什么:closing_service 需要知道 AI 的诊断进度,
|
||
以决定是否建议关闭会话或触发结单流程。
|
||
|
||
Args:
|
||
conversation: 会话对象
|
||
|
||
Returns:
|
||
Optional[str]: 诊断阶段值(initial/gathering_info/diagnosing/
|
||
recommending/resolved/escalating),无则 None
|
||
"""
|
||
if not conversation.tags:
|
||
return None
|
||
return conversation.tags.get("diagnosis_stage")
|
||
|
||
@staticmethod
|
||
def should_suggest_resolve(conversation: Conversation) -> bool:
|
||
"""判断是否应建议员工确认解决(基于 diagnosis_stage)。
|
||
|
||
做什么:当 AI 返回 diagnosis_stage == "resolved" 时,
|
||
表示 AI 认为问题已解决,系统可推送确认卡片。
|
||
为什么:相比纯关键词匹配,diagnosis_stage 是 AI 主动判断的结果,
|
||
更准确地反映问题解决状态。
|
||
|
||
Args:
|
||
conversation: 会话对象
|
||
|
||
Returns:
|
||
bool: True 表示应推送解决确认卡片
|
||
"""
|
||
stage = ClosingService.get_diagnosis_stage(conversation)
|
||
return stage == "resolved"
|
||
|
||
@staticmethod
|
||
def should_escalate_to_human(conversation: Conversation) -> bool:
|
||
"""判断是否应建议转人工(基于 diagnosis_stage)。
|
||
|
||
做什么:当 AI 返回 diagnosis_stage == "escalating" 时,
|
||
表示 AI 无法解决问题,应转人工坐席。
|
||
为什么:AI 主动判断无法解决比超时兜底更及时,
|
||
能更快地将员工转给人工坐席。
|
||
|
||
Args:
|
||
conversation: 会话对象
|
||
|
||
Returns:
|
||
bool: True 表示应转人工
|
||
"""
|
||
stage = ClosingService.get_diagnosis_stage(conversation)
|
||
return stage == "escalating"
|
||
|
||
async def _notify_queue_position_update(self) -> None:
|
||
"""通知所有排队员工其队列位置已更新(WS事件 queue_position_update)。
|
||
|
||
当有会话被关闭/分配/取消时,排在后面的员工位置前移。
|
||
此方法查询所有排队中的会话,计算每个员工的新位置并推送。
|
||
|
||
为了避免大量推送,仅在有人排队时执行。
|
||
"""
|
||
from app.services.queue_service import get_queue_service
|
||
|
||
try:
|
||
queue_service = get_queue_service() # 无参单例
|
||
|
||
# 查询所有排队中的会话
|
||
stmt = select(Conversation).where(
|
||
Conversation.status == "queued"
|
||
).order_by(Conversation.created_at.asc())
|
||
result = await self.db.execute(stmt)
|
||
queued_conversations = result.scalars().all()
|
||
|
||
if not queued_conversations:
|
||
return
|
||
|
||
# 为每个排队员工计算新位置并推送
|
||
for conv in queued_conversations:
|
||
try:
|
||
status = await queue_service.get_comprehensive_status(self.db, conv)
|
||
queue_info = status.get("queue", {})
|
||
await ws_manager.send_to_employee(
|
||
conv.employee_id,
|
||
{
|
||
"type": "queue_position_update",
|
||
"data": {
|
||
"conversation_id": str(conv.id),
|
||
"position": queue_info.get("position", 0),
|
||
"segment": queue_info.get("segment", ""),
|
||
"ahead_count": queue_info.get("ahead_count", 0),
|
||
"queue_priority": conv.queue_priority,
|
||
"timestamp": datetime.now().isoformat(),
|
||
},
|
||
}
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"推送队列位置更新失败(单个): conv_id={conv.id}, {e}")
|
||
continue
|
||
|
||
except Exception as e:
|
||
logger.warning(f"队列位置更新推送异常: {e}")
|
||
|
||
# ==========================================================================
|
||
# 超时检查辅助方法(供 reminder_task.py 调用)
|
||
# ==========================================================================
|
||
|
||
async def get_pending_close_timeout_sessions(self) -> list[Conversation]:
|
||
"""获取 pending_close 超时需要自动关闭的会话列表。
|
||
|
||
条件:status=pending_close 且 pending_close_at 超过5分钟
|
||
"""
|
||
threshold = datetime.now() - timedelta(minutes=PENDING_CLOSE_AUTO_RESOLVE_MINUTES)
|
||
stmt = select(Conversation).where(
|
||
Conversation.status == "pending_close",
|
||
Conversation.pending_close_at.isnot(None),
|
||
Conversation.pending_close_at < threshold,
|
||
)
|
||
result = await self.db.execute(stmt)
|
||
return list(result.scalars().all())
|
||
|
||
async def get_ai_handling_timeout_sessions(self) -> list[Conversation]:
|
||
"""获取 ai_handling 超时需要自动关闭的会话列表。
|
||
|
||
条件:status=ai_handling 且 last_message_at 超过 (30+10)=40 分钟
|
||
"""
|
||
total_timeout = AI_HANDLING_TIMEOUT_MINUTES + AI_HANDLING_REMINDER_TO_CLOSE_MINUTES
|
||
threshold = datetime.now() - timedelta(minutes=total_timeout)
|
||
stmt = select(Conversation).where(
|
||
Conversation.status == "ai_handling",
|
||
Conversation.last_message_at.isnot(None),
|
||
Conversation.last_message_at < threshold,
|
||
)
|
||
result = await self.db.execute(stmt)
|
||
return list(result.scalars().all())
|
||
|
||
async def get_ai_handling_reminder_sessions(self) -> list[Conversation]:
|
||
"""获取 ai_handling 需要发送超时提醒的会话列表。
|
||
|
||
条件:status=ai_handling 且 last_message_at 超过30分钟 且未发过提醒
|
||
"""
|
||
threshold = datetime.now() - timedelta(minutes=AI_HANDLING_TIMEOUT_MINUTES)
|
||
stmt = select(Conversation).where(
|
||
Conversation.status == "ai_handling",
|
||
Conversation.last_message_at.isnot(None),
|
||
Conversation.last_message_at < threshold,
|
||
Conversation.reminder_sent == False,
|
||
)
|
||
result = await self.db.execute(stmt)
|
||
return list(result.scalars().all())
|
||
|
||
# ==========================================================================
|
||
# 内部辅助方法
|
||
# ==========================================================================
|
||
|
||
async def _get_active_conversation(self, employee_id: str) -> Conversation:
|
||
"""获取员工的活跃会话(非 resolved 状态)。
|
||
|
||
Args:
|
||
employee_id: 员工企微UserID
|
||
|
||
Returns:
|
||
Conversation: 活跃会话对象
|
||
|
||
Raises:
|
||
AppException: 无活跃会话
|
||
"""
|
||
stmt = select(Conversation).where(
|
||
Conversation.employee_id == employee_id,
|
||
Conversation.status.in_(["ai_handling", "queued", "serving", "pending_close"]),
|
||
).order_by(Conversation.created_at.desc())
|
||
result = await self.db.execute(stmt)
|
||
conversation = result.scalars().first()
|
||
|
||
if not conversation:
|
||
raise AppException(1001, "当前没有活跃会话")
|
||
|
||
return conversation
|
||
|
||
async def _get_conversation_by_id(self, conversation_id: str) -> Conversation:
|
||
"""根据ID获取会话。
|
||
|
||
Args:
|
||
conversation_id: 会话ID
|
||
|
||
Returns:
|
||
Conversation: 会话对象
|
||
|
||
Raises:
|
||
AppException: 会话不存在
|
||
"""
|
||
stmt = select(Conversation).where(Conversation.id == conversation_id)
|
||
result = await self.db.execute(stmt)
|
||
conversation = result.scalars().first()
|
||
|
||
if not conversation:
|
||
raise AppException(3001, "会话不存在")
|
||
|
||
return conversation
|
||
|
||
async def _decrement_agent_load(self, agent_id: Optional[str]) -> None:
|
||
"""减少坐席当前服务数。
|
||
|
||
Args:
|
||
agent_id: 坐席ID
|
||
"""
|
||
if not agent_id:
|
||
return
|
||
|
||
stmt = select(Agent).where(Agent.user_id == agent_id)
|
||
result = await self.db.execute(stmt)
|
||
agent = result.scalars().first()
|
||
|
||
if agent and agent.current_load > 0:
|
||
agent.current_load -= 1
|
||
self.db.add(agent)
|
||
|
||
async def _push_resolve_confirm(
|
||
self,
|
||
conversation: Conversation,
|
||
agent_id: str,
|
||
resolve_summary: str,
|
||
) -> None:
|
||
"""推送结单确认请求给员工(WS事件 resolve_confirm)。
|
||
|
||
前端收到此事件后,在对话流中弹出确认卡片:
|
||
- 坐席摘要展示
|
||
- "已解决"按钮 → 调用 employee_confirm_resolve
|
||
- "未解决"按钮 → 调用 employee_reject_resolve
|
||
- 提示:5分钟内不响应将自动关闭
|
||
"""
|
||
payload = {
|
||
"type": "resolve_confirm",
|
||
"data": {
|
||
"conversation_id": str(conversation.id),
|
||
"agent_id": agent_id,
|
||
"resolve_summary": resolve_summary,
|
||
"auto_close_minutes": PENDING_CLOSE_AUTO_RESOLVE_MINUTES,
|
||
"timestamp": datetime.now().isoformat(),
|
||
},
|
||
}
|
||
|
||
try:
|
||
await ws_manager.send_to_employee(conversation.employee_id, payload)
|
||
except Exception as e:
|
||
logger.warning(f"推送 resolve_confirm 失败: {e}")
|
||
|
||
async def _push_conversation_resolved(
|
||
self,
|
||
conversation: Conversation,
|
||
resolved_by: str,
|
||
resolved_method: str,
|
||
) -> None:
|
||
"""推送会话已关闭事件(WS事件 conversation_resolved)。
|
||
|
||
通知坐席端和员工端会话已关闭。
|
||
同时触发:
|
||
1. 队列位置更新通知(queue_position_update)— 通知所有排队员工位置变化
|
||
2. 自动分配下一个排队会话(三段排序)
|
||
"""
|
||
payload = {
|
||
"type": "conversation_resolved",
|
||
"data": {
|
||
"conversation_id": str(conversation.id),
|
||
"status": "resolved",
|
||
"resolved_by": resolved_by,
|
||
"resolved_method": resolved_method,
|
||
"resolve_summary": conversation.resolve_summary or "",
|
||
"timestamp": datetime.now().isoformat(),
|
||
},
|
||
}
|
||
|
||
# 推送给坐席端(广播,因为可能多个坐席需要看到状态变更)
|
||
try:
|
||
await ws_manager.broadcast(payload)
|
||
except Exception as e:
|
||
logger.warning(f"推送 conversation_resolved 给坐席失败: {e}")
|
||
|
||
# 推送给员工端
|
||
try:
|
||
await ws_manager.send_to_employee(conversation.employee_id, payload)
|
||
except Exception as e:
|
||
logger.warning(f"推送 conversation_resolved 给员工失败: {e}")
|
||
|
||
# ------------------------------------------------------------------
|
||
# 触发1:通知所有排队员工队列位置已更新(queue_position_update)
|
||
# ------------------------------------------------------------------
|
||
# 会话关闭后,排在后面的员工位置前移1位
|
||
try:
|
||
await self._notify_queue_position_update()
|
||
except Exception as e:
|
||
logger.warning(f"推送 queue_position_update 失败: {e}")
|
||
|
||
# ------------------------------------------------------------------
|
||
# 触发2:自动分配队列中的下一个会话(三段排序)
|
||
# ------------------------------------------------------------------
|
||
# 坐席空闲后,从队列中按 VIP → 已梳理 → 待梳理 顺序分配
|
||
try:
|
||
from app.services.session_service import SessionService
|
||
session_service = SessionService(self.db)
|
||
assigned = await session_service.auto_assign_from_queue()
|
||
if assigned:
|
||
logger.info(f"关闭后自动分配下一个会话: conv_id={assigned.id}")
|
||
except Exception as e:
|
||
logger.warning(f"关闭后自动分配失败(不阻塞): {e}")
|
||
|
||
async def _push_auto_close_warning(
|
||
self,
|
||
conversation: Conversation,
|
||
minutes_remaining: int,
|
||
) -> None:
|
||
"""推送超时关闭警告(WS事件 auto_close_warning)。
|
||
|
||
在 pending_close 后4分钟(1分钟前剩)时推送,提醒员工即将自动关闭。
|
||
"""
|
||
payload = {
|
||
"type": "auto_close_warning",
|
||
"data": {
|
||
"conversation_id": str(conversation.id),
|
||
"minutes_remaining": minutes_remaining,
|
||
"message": f"您的会话将在 {minutes_remaining} 分钟后自动关闭,"
|
||
f"如需继续服务请点击「未解决」。",
|
||
"timestamp": datetime.now().isoformat(),
|
||
},
|
||
}
|
||
|
||
try:
|
||
await ws_manager.send_to_employee(conversation.employee_id, payload)
|
||
except Exception as e:
|
||
logger.warning(f"推送 auto_close_warning 失败: {e}")
|
||
|
||
async def _trigger_knowledge_sedimentation(
|
||
self,
|
||
conversation: Conversation,
|
||
) -> None:
|
||
"""触发知识沉淀流程(异步,不阻塞关闭主流程)。
|
||
|
||
决策 G5:resolved后判断是否有诊断报告+修复记录
|
||
→ 生成知识条目草稿 → 管理后台审核入库
|
||
|
||
当前实现:仅记录日志,后续接入诊断服务后完善。
|
||
知识沉淀为 P2 功能,此处预留接口。
|
||
"""
|
||
# TODO: P2 阶段接入诊断服务后完善
|
||
# 1. 检查是否有关联的诊断报告(DiagnosticReport)
|
||
# 2. 检查是否有修复记录(DiagnosticDispatch.fix_dispatched)
|
||
# 3. 如果有,调用 Dify 总结会话+诊断报告 → 生成知识条目草稿
|
||
# 4. 草稿存入知识库待审核表
|
||
logger.info(
|
||
f"知识沉淀触发(P2预留): conv_id={conversation.id}, "
|
||
f"method={conversation.resolved_method}, "
|
||
f"summary={conversation.resolve_summary[:50] if conversation.resolve_summary else 'N/A'}"
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# 模块级单例工厂
|
||
# =============================================================================
|
||
# 与 queue_service / quiz_service 一致的模式:
|
||
# 每次调用时传入 db session,服务本身无状态
|
||
|
||
_closing_service_instance: Optional[ClosingService] = None
|
||
|
||
|
||
def get_closing_service(db: AsyncSession) -> ClosingService:
|
||
"""获取关闭机制服务实例。
|
||
|
||
Args:
|
||
db: 数据库异步会话
|
||
|
||
Returns:
|
||
ClosingService: 关闭机制服务实例
|
||
"""
|
||
global _closing_service_instance
|
||
if _closing_service_instance is None or _closing_service_instance.db is not db:
|
||
_closing_service_instance = ClosingService(db)
|
||
return _closing_service_instance
|