Files
wecom_it_smart_desk/backend/app/services/closing_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

923 lines
34 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智能服务台 — 关闭机制服务
# =============================================================================
# 说明:管理会话的完整关闭生命周期,包括五种关闭场景:
# 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
# ==========================================================================
# 场景524h内重开
# ==========================================================================
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