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分块上传
This commit is contained in:
@@ -23,6 +23,15 @@ from app.services.ai_service import AIService
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 代答排除命中后动作类型
|
||||
# --------------------------------------------------------------------------
|
||||
_EXCLUSION_ACTION_TRANSFER_HUMAN = "transfer_human"
|
||||
_EXCLUSION_ACTION_TRANSFER_WITH_CONTEXT = "transfer_human_with_context"
|
||||
_EXCLUSION_ACTION_PROMPT_TRANSFER = "prompt_transfer"
|
||||
_EXCLUSION_ACTION_SILENT_TRANSFER = "silent_transfer"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 打招呼关键词(匹配后 AI 引导用户描述问题,不计数)
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -114,10 +123,15 @@ class AIReplyResult:
|
||||
- "ai_hit": AI 命中知识库
|
||||
- "ai_miss": AI 未命中,需转人工
|
||||
- "ai_fallback": AI 调用异常,降级模板回复
|
||||
- "excluded": 代答排除命中(转人工/提示转人工/静默转人工)
|
||||
is_guidance: 是否为引导类消息(打招呼或呼叫人工),前端据此决定 UI 展示
|
||||
should_count: 是否应增加 ai_substantive_reply_count(仅 AI 命中时为 True)
|
||||
should_transfer: 是否应转人工(状态改为 queued)
|
||||
dify_conversation_id: Dify 会话ID(用于多轮对话上下文,AI 命中/未命中时更新)
|
||||
excluded_action: 代答排除命中动作类型(仅 reply_type="excluded" 时有值)
|
||||
action: 结构化操作卡片数据(审批/入口推荐),仅 Dify JSON 输出且 action 非空时有值
|
||||
options: 结构化选项按钮列表,仅 Dify JSON 输出且 options 非空时有值
|
||||
is_structured: 是否为 JSON 结构化回复(True=后端需推送 dynamic_recommend WS)
|
||||
"""
|
||||
content: str
|
||||
reply_type: str
|
||||
@@ -125,6 +139,11 @@ class AIReplyResult:
|
||||
should_count: bool = False
|
||||
should_transfer: bool = False
|
||||
dify_conversation_id: Optional[str] = None
|
||||
excluded_action: Optional[str] = None
|
||||
# v2.0 新增(2026-07-13):结构化消息字段
|
||||
action: Optional[dict] = None
|
||||
options: Optional[list] = None
|
||||
is_structured: bool = False
|
||||
|
||||
|
||||
class AIHandler:
|
||||
@@ -189,16 +208,20 @@ class AIHandler:
|
||||
content: str,
|
||||
dify_conversation_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
conversation_id: Optional[str] = None,
|
||||
db=None,
|
||||
) -> AIReplyResult:
|
||||
"""处理用户消息,返回统一的 AI 回复结果。
|
||||
|
||||
按照优先级依次检测:打招呼 → 呼叫人工 → AI 调用。
|
||||
按照优先级依次检测:打招呼 → 呼叫人工 → 代答排除 → AI 调用。
|
||||
每种路径返回不同的 reply_type,由调用方根据结果更新会话状态和计数。
|
||||
|
||||
Args:
|
||||
content: 用户消息内容
|
||||
dify_conversation_id: Dify 会话ID(用于多轮对话上下文)
|
||||
user_id: 用户标识(用于 Dify 日志追溯)
|
||||
conversation_id: 企微会话ID(用于代答排除检查,传入则启用排除检查)
|
||||
db: 数据库会话(用于代答排除检查,传入则启用排除检查)
|
||||
|
||||
Returns:
|
||||
AIReplyResult: 统一的 AI 回复结果
|
||||
@@ -232,7 +255,31 @@ class AIHandler:
|
||||
)
|
||||
|
||||
# ==================================================================
|
||||
# 3. 调用 Dify API 获取 AI 回复
|
||||
# 3. 代答排除检查(AI 回复前)
|
||||
# 仅在传入 conversation_id 和 db 时启用
|
||||
# ==================================================================
|
||||
if conversation_id and db and content:
|
||||
try:
|
||||
from app.services.exclusion_service import get_exclusion_service
|
||||
exclusion_service = get_exclusion_service()
|
||||
exclusion_result = await exclusion_service.check_exclusions(
|
||||
db=db,
|
||||
message=content,
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id or "",
|
||||
)
|
||||
|
||||
if exclusion_result.matched:
|
||||
# 命中排除规则,执行对应动作
|
||||
return self._handle_exclusion_hit(
|
||||
exclusion_result, dify_conversation_id,
|
||||
)
|
||||
except Exception as e:
|
||||
# 排除检查异常不阻断主流程,继续 AI 回复
|
||||
logger.error(f"代答排除检查异常(降级继续AI回复): {e}")
|
||||
|
||||
# ==================================================================
|
||||
# 4. 调用 Dify API 获取 AI 回复
|
||||
# ==================================================================
|
||||
try:
|
||||
ai_result = await self.ai_service.get_reply(
|
||||
@@ -272,7 +319,7 @@ class AIHandler:
|
||||
|
||||
except Exception as e:
|
||||
# ==============================================================
|
||||
# 4. AI 调用异常:降级模板回复
|
||||
# 5. AI 调用异常:降级模板回复
|
||||
# - 不计数(修复原 h5.py 降级误计数的 Bug)
|
||||
# - 不转人工(降级是临时故障,用户可继续尝试)
|
||||
# ==============================================================
|
||||
@@ -287,3 +334,94 @@ class AIHandler:
|
||||
should_transfer=False,
|
||||
dify_conversation_id=dify_conversation_id,
|
||||
)
|
||||
|
||||
def _handle_exclusion_hit(
|
||||
self,
|
||||
exclusion_result,
|
||||
dify_conversation_id: Optional[str],
|
||||
) -> AIReplyResult:
|
||||
"""处理代答排除命中,根据 action_type 执行对应动作。
|
||||
|
||||
4 种命中后动作(决策 #9):
|
||||
1. transfer_human: 转人工坐席,员工看到"已转接人工"提示
|
||||
2. transfer_human_with_context: 同上 + 附带 collected_context
|
||||
3. prompt_transfer: 返回 transfer_message,等用户确认
|
||||
4. silent_transfer: 静默转人工,员工无感知
|
||||
|
||||
Args:
|
||||
exclusion_result: ExclusionCheckResult 命中结果
|
||||
dify_conversation_id: Dify 会话ID
|
||||
|
||||
Returns:
|
||||
AIReplyResult: 统一的 AI 回复结果
|
||||
"""
|
||||
action = exclusion_result.action_type
|
||||
rule_name = exclusion_result.rule_name
|
||||
detail = exclusion_result.matched_detail
|
||||
transfer_msg = exclusion_result.transfer_message or "已为您转接人工坐席,请稍候..."
|
||||
|
||||
logger.info(
|
||||
f"代答排除命中: rule={rule_name}, action={action}, detail={detail}"
|
||||
)
|
||||
|
||||
if action == _EXCLUSION_ACTION_TRANSFER_HUMAN:
|
||||
# 转人工坐席
|
||||
return AIReplyResult(
|
||||
content=transfer_msg,
|
||||
reply_type="excluded",
|
||||
is_guidance=False,
|
||||
should_count=False,
|
||||
should_transfer=True,
|
||||
dify_conversation_id=dify_conversation_id,
|
||||
excluded_action=action,
|
||||
)
|
||||
|
||||
elif action == _EXCLUSION_ACTION_TRANSFER_WITH_CONTEXT:
|
||||
# 携带上下文转人工(与 transfer_human 相同的回复,上下文由调用方处理)
|
||||
return AIReplyResult(
|
||||
content=transfer_msg,
|
||||
reply_type="excluded",
|
||||
is_guidance=False,
|
||||
should_count=False,
|
||||
should_transfer=True,
|
||||
dify_conversation_id=dify_conversation_id,
|
||||
excluded_action=action,
|
||||
)
|
||||
|
||||
elif action == _EXCLUSION_ACTION_PROMPT_TRANSFER:
|
||||
# 仅提示转人工,等用户确认(不自动转)
|
||||
prompt_msg = transfer_msg or "此问题建议联系人工坐席处理,是否转接?"
|
||||
return AIReplyResult(
|
||||
content=prompt_msg,
|
||||
reply_type="excluded",
|
||||
is_guidance=False,
|
||||
should_count=False,
|
||||
should_transfer=False,
|
||||
dify_conversation_id=dify_conversation_id,
|
||||
excluded_action=action,
|
||||
)
|
||||
|
||||
elif action == _EXCLUSION_ACTION_SILENT_TRANSFER:
|
||||
# 静默转人工(不提示用户)
|
||||
return AIReplyResult(
|
||||
content="",
|
||||
reply_type="excluded",
|
||||
is_guidance=False,
|
||||
should_count=False,
|
||||
should_transfer=True,
|
||||
dify_conversation_id=dify_conversation_id,
|
||||
excluded_action=action,
|
||||
)
|
||||
|
||||
else:
|
||||
# 未知动作,默认转人工
|
||||
logger.warning(f"未知排除动作类型: {action},默认转人工")
|
||||
return AIReplyResult(
|
||||
content=transfer_msg,
|
||||
reply_type="excluded",
|
||||
is_guidance=False,
|
||||
should_count=False,
|
||||
should_transfer=True,
|
||||
dify_conversation_id=dify_conversation_id,
|
||||
excluded_action=action,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
@@ -24,13 +25,21 @@ logger = logging.getLogger(__name__)
|
||||
class AIService:
|
||||
"""AI 服务:封装 Dify API,提供 AI 回复能力。
|
||||
|
||||
支持两种调用模式:
|
||||
1. 非流式(简单场景):一次性获取完整回复
|
||||
2. 流式(推荐):SSE 流式返回,前端可逐字显示
|
||||
支持三种调用模式:
|
||||
1. 非流式(简单场景):一次性获取完整回复(经 dify2openai 代理)
|
||||
2. 流式(推荐):SSE 流式返回,前端可逐字显示(经 dify2openai 代理)
|
||||
3. ★ 原生直连(v2.1 新增):绕过代理,直连 Dify /v1/chat-messages
|
||||
|
||||
v2.1 改造原因:
|
||||
- dify2openai 代理存在 [object Object] 序列化 bug
|
||||
- 直连 Dify 原生 API 响应格式更简单(answer 字段直接返回内容)
|
||||
- 结构化回复(get_structured_reply)优先使用原生 API
|
||||
|
||||
参考:现有系统交接文档
|
||||
- API URL: http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions
|
||||
- Key: http://yw-dify.dc.servyou-it.com/v1|app-UaTWYdBSwN6VktKQlbh5YN5H|Chat
|
||||
- 代理 URL: http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions
|
||||
- 原生 URL: http://yw-dify.dc.servyou-it.com/v1/chat-messages
|
||||
- Key: app-7jkRkAzvX4QM9v9SM3P8mMEO(审批意图副本,推荐使用)
|
||||
- ⚠️ 已弃用老Key app-UaTWYdBSwN6VktKQlbh5YN5H(老线上应用,禁止使用)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -39,18 +48,24 @@ class AIService:
|
||||
做什么:从配置读取 Dify API 地址和认证信息
|
||||
为什么:集中管理 API 配置,便于切换测试/生产环境
|
||||
"""
|
||||
# Dify 兼容 OpenAI 格式的 API 端点
|
||||
# Dify 兼容 OpenAI 格式的 API 端点(代理)
|
||||
self.api_url = settings.dify_api_url
|
||||
# Dify API Key(格式:base_url|app_id|app_name)
|
||||
self.api_key = settings.dify_api_key
|
||||
# 请求超时(秒)
|
||||
self.timeout = settings.dify_timeout
|
||||
|
||||
# httpx 异步客户端(复用连接池)
|
||||
# ★ v2.1: Dify 原生 API 配置(绕过代理)
|
||||
self.native_base_url = settings.dify_native_base_url
|
||||
self.native_api_key = settings.dify_native_api_key
|
||||
|
||||
# httpx 异步客户端(复用连接池)— 代理用
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
# ★ v2.1: 原生 API 专用客户端(不同 auth header)
|
||||
self._native_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""获取或创建 httpx 异步客户端。
|
||||
"""获取或创建 httpx 异步客户端(代理用)。
|
||||
|
||||
做什么:懒加载 httpx.AsyncClient,复用连接池
|
||||
为什么:避免每次请求都创建新连接,提升性能
|
||||
@@ -65,16 +80,37 @@ class AIService:
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def _get_native_client(self) -> httpx.AsyncClient:
|
||||
"""获取或创建 Dify 原生 API 专用客户端(v2.1 新增)。
|
||||
|
||||
做什么:懒加载 httpx.AsyncClient,使用 Dify 原生 auth 格式
|
||||
为什么:原生 API 使用 `Bearer app-xxx` 认证(非管道分隔格式),
|
||||
需要独立客户端避免 auth header 冲突
|
||||
"""
|
||||
if self._native_client is None or self._native_client.is_closed:
|
||||
self._native_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.native_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
return self._native_client
|
||||
|
||||
async def close(self):
|
||||
"""关闭 httpx 客户端。
|
||||
|
||||
做什么:释放连接池资源
|
||||
做什么:释放连接池资源(代理 + 原生)
|
||||
为什么:避免连接泄漏,尤其在长期运行的 FastAPI 应用中
|
||||
"""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
logger.debug("AIService httpx client closed")
|
||||
if self._native_client and not self._native_client.is_closed:
|
||||
await self._native_client.aclose()
|
||||
self._native_client = None
|
||||
logger.debug("AIService native client closed")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 非流式调用:一次性获取 AI 完整回复
|
||||
@@ -290,6 +326,392 @@ class AIService:
|
||||
"hit": False,
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 结构化调用:blocking 模式,返回解析后的 JSON {text, action, options}
|
||||
# --------------------------------------------------------------------------
|
||||
async def _call_dify_native(
|
||||
self,
|
||||
message: str,
|
||||
conversation_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""直连 Dify 原生 API(v2.1 新增,绕过 dify2openai 代理)。
|
||||
|
||||
做什么:
|
||||
1. POST {base_url}/v1/chat-messages(blocking 模式)
|
||||
2. 解析返回的 answer 字段(直接包含 AI 回复内容)
|
||||
3. 返回 raw_content 供上层 JSON 解析
|
||||
|
||||
为什么:
|
||||
- dify2openai 代理存在 [object Object] 序列化 bug
|
||||
- 原生 API 响应格式更简单:{"answer": "...", "conversation_id": "..."}
|
||||
- 不经过 OpenAI 兼容层转换,避免格式损失
|
||||
|
||||
Args:
|
||||
message: 员工发送的消息内容
|
||||
conversation_id: Dify 会话ID(用于多轮对话上下文)
|
||||
user_id: 员工企微 UserID
|
||||
|
||||
Returns:
|
||||
Dict 或 None:
|
||||
- 成功:{"raw_content": str, "conversation_id": str, "response_time_ms": float}
|
||||
- 失败:None(调用方应 fallback 到代理路径)
|
||||
"""
|
||||
if not self.native_base_url or not self.native_api_key:
|
||||
return None # 未配置原生 API,调用方走代理路径
|
||||
|
||||
url = f"{self.native_base_url}/v1/chat-messages"
|
||||
payload = {
|
||||
"inputs": {},
|
||||
"query": message,
|
||||
"response_mode": "blocking", # 阻塞模式,等待完整回复
|
||||
"user": user_id or "unknown",
|
||||
}
|
||||
# 传入 Dify 会话ID,保持多轮对话上下文
|
||||
if conversation_id:
|
||||
payload["conversation_id"] = conversation_id
|
||||
|
||||
try:
|
||||
client = await self._get_native_client()
|
||||
start_time = time.perf_counter()
|
||||
logger.info(f"调用 Dify 原生 API: message={message[:50]}...")
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
response_time_ms = (time.perf_counter() - start_time) * 1000
|
||||
data = response.json()
|
||||
|
||||
# 原生 API 返回格式:{"answer": "...", "conversation_id": "..."}
|
||||
raw_content = data.get("answer", "")
|
||||
dify_conv_id = data.get("conversation_id", conversation_id or "")
|
||||
|
||||
if not raw_content:
|
||||
logger.warning("Dify 原生 API 返回空 answer")
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f"Dify 原生 API 返回: content_len={len(raw_content)}, "
|
||||
f"response_time={response_time_ms:.0f}ms, "
|
||||
f"conv_id={dify_conv_id[:20] if dify_conv_id else '(new)'}"
|
||||
)
|
||||
|
||||
return {
|
||||
"raw_content": raw_content,
|
||||
"conversation_id": dify_conv_id,
|
||||
"response_time_ms": response_time_ms,
|
||||
}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("Dify 原生 API 超时,将回退到代理路径")
|
||||
return None
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning(f"Dify 原生 API HTTP 错误: status={e.response.status_code},将回退到代理路径")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Dify 原生 API 调用失败: {e},将回退到代理路径")
|
||||
return None
|
||||
|
||||
async def get_structured_reply(
|
||||
self,
|
||||
message: str,
|
||||
conversation_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 Dify API 获取结构化 AI 回复(blocking 模式,JSON 输出)。
|
||||
|
||||
改造后的 Dify 主对话应用输出 JSON 格式:
|
||||
{"text": "...", "action": {...}|null, "options": [...]|null}
|
||||
|
||||
本方法负责:
|
||||
1. 以 blocking 模式调用 Dify(stream=False)
|
||||
2. 尝试解析返回内容为 JSON
|
||||
3. 解析失败时降级为纯文本(向后兼容旧 Prompt)
|
||||
4. 返回统一结构
|
||||
|
||||
Args:
|
||||
message: 员工发送的消息内容(可能包含图片描述前缀)
|
||||
conversation_id: Dify 会话ID(用于多轮对话上下文)
|
||||
user_id: 员工企微 UserID
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"text": str, # 回复文字(始终有值)
|
||||
"action": dict|None, # 操作卡片数据(审批/入口推荐)
|
||||
"options": list|None, # 选项按钮列表
|
||||
"hit": bool, # 是否命中知识库
|
||||
"conversation_id": str, # Dify 会话ID
|
||||
"raw_content": str, # 原始返回内容(调试用)
|
||||
"is_structured": bool, # 是否成功解析为 JSON
|
||||
}
|
||||
"""
|
||||
# ★ v2.1: 优先尝试 Dify 原生 API(绕过 dify2openai 代理)
|
||||
# 为什么:代理存在 [object Object] 序列化 bug,原生 API 直接返回 answer 字段
|
||||
# 降级:原生 API 未配置或调用失败时,自动回退到下方代理路径
|
||||
native_result = await self._call_dify_native(message, conversation_id, user_id)
|
||||
if native_result is not None:
|
||||
raw_content = native_result["raw_content"]
|
||||
dify_conv_id = native_result["conversation_id"]
|
||||
response_time_ms = native_result["response_time_ms"]
|
||||
|
||||
# 尝试 JSON 解析(复用同一解析逻辑)
|
||||
parsed = self._parse_structured_response(raw_content)
|
||||
|
||||
if parsed:
|
||||
# JSON 解析成功
|
||||
text = parsed.get("text", "")
|
||||
action = parsed.get("action")
|
||||
options = parsed.get("options")
|
||||
diagnosis_stage = parsed.get("diagnosis_stage")
|
||||
hit = self._check_knowledge_hit(text) if text else False
|
||||
|
||||
logger.info(
|
||||
f"Dify 原生 structured 返回: hit={hit}, "
|
||||
f"text_len={len(text)}, "
|
||||
f"has_action={action is not None}, "
|
||||
f"has_options={options is not None}, "
|
||||
f"diagnosis_stage={diagnosis_stage}, "
|
||||
f"response_time={response_time_ms:.0f}ms"
|
||||
)
|
||||
|
||||
if response_time_ms > 10000:
|
||||
logger.warning(f"Dify 原生慢响应告警: {response_time_ms:.0f}ms")
|
||||
|
||||
return {
|
||||
"text": text,
|
||||
"action": action,
|
||||
"options": options,
|
||||
"hit": hit,
|
||||
"conversation_id": dify_conv_id,
|
||||
"raw_content": raw_content,
|
||||
"is_structured": True,
|
||||
"diagnosis_stage": diagnosis_stage,
|
||||
"response_time_ms": round(response_time_ms, 1),
|
||||
}
|
||||
else:
|
||||
# JSON 解析失败,降级为纯文本
|
||||
logger.warning(
|
||||
"Dify 原生返回非 JSON 格式,降级为纯文本。"
|
||||
f"content={raw_content[:100]}..."
|
||||
)
|
||||
hit = self._check_knowledge_hit(raw_content) if raw_content else False
|
||||
|
||||
return {
|
||||
"text": raw_content,
|
||||
"action": None,
|
||||
"options": None,
|
||||
"hit": hit,
|
||||
"conversation_id": dify_conv_id,
|
||||
"raw_content": raw_content,
|
||||
"is_structured": False,
|
||||
"diagnosis_stage": None,
|
||||
"response_time_ms": round(response_time_ms, 1),
|
||||
}
|
||||
|
||||
# === 代理路径(fallback)===
|
||||
# 原生 API 不可用或调用失败时,走 dify2openai 代理(原有逻辑)
|
||||
logger.info("使用 dify2openai 代理路径(原生 API 不可用或失败)")
|
||||
payload = {
|
||||
"model": "Chat",
|
||||
"messages": [{"role": "user", "content": message}],
|
||||
"stream": False, # blocking 模式
|
||||
"temperature": 0.3, # 略高于流式,给 JSON 结构化留一点灵活性
|
||||
}
|
||||
if conversation_id:
|
||||
payload["conversation_id"] = conversation_id
|
||||
if user_id:
|
||||
payload["user"] = user_id
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
# Phase 6B: 记录 Dify 调用开始时间(性能监控)
|
||||
start_time = time.perf_counter()
|
||||
logger.info(f"调用 Dify API (structured): message={message[:50]}...")
|
||||
response = await client.post(self.api_url, json=payload)
|
||||
response.raise_for_status()
|
||||
# Phase 6B: 计算响应耗时
|
||||
response_time_ms = (time.perf_counter() - start_time) * 1000
|
||||
data = response.json()
|
||||
|
||||
# 解析 OpenAI 兼容格式返回
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
logger.warning("Dify API 返回空 choices (structured)")
|
||||
return {
|
||||
"text": "",
|
||||
"action": None,
|
||||
"options": None,
|
||||
"hit": False,
|
||||
"conversation_id": conversation_id or "",
|
||||
"raw_content": "",
|
||||
"is_structured": False,
|
||||
"diagnosis_stage": None,
|
||||
"response_time_ms": round(response_time_ms, 1),
|
||||
}
|
||||
|
||||
raw_content = choices[0]["message"]["content"]
|
||||
dify_conv_id = data.get("conversation_id", conversation_id or "")
|
||||
|
||||
# 尝试解析 JSON
|
||||
parsed = self._parse_structured_response(raw_content)
|
||||
|
||||
if parsed:
|
||||
# JSON 解析成功
|
||||
text = parsed.get("text", "")
|
||||
action = parsed.get("action")
|
||||
options = parsed.get("options")
|
||||
# Phase 6A: 提取诊断阶段(diagnosis_stage)
|
||||
diagnosis_stage = parsed.get("diagnosis_stage")
|
||||
hit = self._check_knowledge_hit(text) if text else False
|
||||
|
||||
logger.info(
|
||||
f"Dify structured 返回: hit={hit}, "
|
||||
f"text_len={len(text)}, "
|
||||
f"has_action={action is not None}, "
|
||||
f"has_options={options is not None}, "
|
||||
f"diagnosis_stage={diagnosis_stage}, "
|
||||
f"response_time={response_time_ms:.0f}ms, "
|
||||
f"conv_id={dify_conv_id[:20] if dify_conv_id else '(new)'}"
|
||||
)
|
||||
|
||||
# Phase 6B: 慢响应告警(>10秒)
|
||||
if response_time_ms > 10000:
|
||||
logger.warning(
|
||||
f"Dify 慢响应告警: {response_time_ms:.0f}ms "
|
||||
f"(conv={dify_conv_id[:20] if dify_conv_id else 'new'})"
|
||||
)
|
||||
|
||||
return {
|
||||
"text": text,
|
||||
"action": action,
|
||||
"options": options,
|
||||
"hit": hit,
|
||||
"conversation_id": dify_conv_id,
|
||||
"raw_content": raw_content,
|
||||
"is_structured": True,
|
||||
"diagnosis_stage": diagnosis_stage,
|
||||
"response_time_ms": round(response_time_ms, 1),
|
||||
}
|
||||
else:
|
||||
# JSON 解析失败,降级为纯文本(向后兼容旧 Prompt)
|
||||
logger.warning(
|
||||
"Dify 返回非 JSON 格式,降级为纯文本。"
|
||||
f"content={raw_content[:100]}..."
|
||||
)
|
||||
hit = self._check_knowledge_hit(raw_content) if raw_content else False
|
||||
|
||||
return {
|
||||
"text": raw_content,
|
||||
"action": None,
|
||||
"options": None,
|
||||
"hit": hit,
|
||||
"conversation_id": dify_conv_id,
|
||||
"raw_content": raw_content,
|
||||
"is_structured": False,
|
||||
"diagnosis_stage": None,
|
||||
"response_time_ms": round(response_time_ms, 1),
|
||||
}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
elapsed = (time.perf_counter() - start_time) * 1000
|
||||
logger.error(f"Dify API 超时 (structured): {elapsed:.0f}ms")
|
||||
return {
|
||||
"text": "AI 服务响应超时,请稍后再试或转人工坐席。",
|
||||
"action": None,
|
||||
"options": None,
|
||||
"hit": False,
|
||||
"conversation_id": conversation_id or "",
|
||||
"raw_content": "",
|
||||
"is_structured": False,
|
||||
"diagnosis_stage": None,
|
||||
"response_time_ms": round(elapsed, 1),
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
elapsed = (time.perf_counter() - start_time) * 1000
|
||||
logger.error(f"Dify API HTTP 错误 (structured): status={e.response.status_code}, {elapsed:.0f}ms")
|
||||
return {
|
||||
"text": "AI 服务暂时不可用,请转人工坐席。",
|
||||
"action": None,
|
||||
"options": None,
|
||||
"hit": False,
|
||||
"conversation_id": conversation_id or "",
|
||||
"raw_content": "",
|
||||
"is_structured": False,
|
||||
"diagnosis_stage": None,
|
||||
"response_time_ms": round(elapsed, 1),
|
||||
}
|
||||
except Exception as e:
|
||||
elapsed = (time.perf_counter() - start_time) * 1000
|
||||
logger.error(f"Dify API 调用失败 (structured): {e}, {elapsed:.0f}ms")
|
||||
return {
|
||||
"text": "AI 服务异常,请转人工坐席或稍后重试。",
|
||||
"action": None,
|
||||
"options": None,
|
||||
"hit": False,
|
||||
"conversation_id": conversation_id or "",
|
||||
"raw_content": "",
|
||||
"is_structured": False,
|
||||
"diagnosis_stage": None,
|
||||
"response_time_ms": round(elapsed, 1),
|
||||
}
|
||||
|
||||
def _parse_structured_response(self, content: str) -> Optional[Dict[str, Any]]:
|
||||
"""尝试将 Dify 返回内容解析为结构化 JSON。
|
||||
|
||||
支持以下格式:
|
||||
1. 纯 JSON: {"text": "...", "action": null, "options": null}
|
||||
2. 带 markdown 代码块: ```json\n{...}\n```
|
||||
3. 前后有多余文本的 JSON(提取第一个 { 到最后一个 })
|
||||
|
||||
Args:
|
||||
content: Dify 返回的原始内容
|
||||
|
||||
Returns:
|
||||
解析后的 dict,或 None(解析失败)
|
||||
"""
|
||||
if not content or not content.strip():
|
||||
return None
|
||||
|
||||
text = content.strip()
|
||||
|
||||
# 尝试 1: 直接解析
|
||||
try:
|
||||
result = json.loads(text)
|
||||
if isinstance(result, dict) and "text" in result:
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试 2: 去除 markdown 代码块
|
||||
if text.startswith("```"):
|
||||
# 去除 ```json 或 ``` 开头和结尾的 ```
|
||||
lines = text.split("\n")
|
||||
# 去掉第一行(```json 或 ```)
|
||||
if lines[0].strip().startswith("```"):
|
||||
lines = lines[1:]
|
||||
# 去掉最后一行(```)
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
text = "\n".join(lines).strip()
|
||||
try:
|
||||
result = json.loads(text)
|
||||
if isinstance(result, dict) and "text" in result:
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试 3: 提取第一个 { 到最后一个 }
|
||||
first_brace = content.find("{")
|
||||
last_brace = content.rfind("}")
|
||||
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
|
||||
json_str = content[first_brace:last_brace + 1]
|
||||
try:
|
||||
result = json.loads(json_str)
|
||||
if isinstance(result, dict) and "text" in result:
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 判断是否命中知识库
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,922 @@
|
||||
# =============================================================================
|
||||
# 企微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
|
||||
@@ -0,0 +1,284 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — Dify 分诊服务
|
||||
# =============================================================================
|
||||
# 说明:对接 Dify OpenAI 兼容接口,调用独立分诊应用进行问题分析。
|
||||
# 功能:
|
||||
# 1. analyze — 首次分诊分析,将问题拆分为分步选择题
|
||||
# 2. get_next_step — 根据已选选项动态调整后续步骤
|
||||
# 3. generate_reply — 根据收集的上下文生成最终回复
|
||||
# 降级处理:Dify 不可用时返回友好错误,不中断主流程。
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dify 分诊 System Prompt
|
||||
# =============================================================================
|
||||
TRIAGE_SYSTEM_PROMPT = """你是IT服务台智能分诊引擎,负责分析员工IT问题并拆分为分步选择题。
|
||||
|
||||
## 任务目标
|
||||
1. 识别问题类型(硬件/软件/网络/安全/账号/其他)和具体分类
|
||||
2. 评估置信度(0.0-1.0)和紧急度(high/medium/low)
|
||||
3. 将复杂问题拆分为分步选择题(简单问题1-2步,复杂问题3-5步)
|
||||
4. 每步最多4个选项,每个选项分配概率(0-1),所有选项概率之和为1
|
||||
5. 推荐路由渠道(ai_self/human/auto_approval)
|
||||
|
||||
## 紧急度规则
|
||||
- 消息含"紧急/马上/宕机/无法工作/崩溃/死机/蓝屏"→high
|
||||
- 消息含"报错/失败/连不上/打不开/不能用"→medium
|
||||
- 其余→low
|
||||
|
||||
## 排除选项
|
||||
excluded_options 中的选项不出现在后续步骤中。
|
||||
|
||||
## 输出约束
|
||||
必须输出合法JSON,不要输出解释性文字。JSON格式如下:
|
||||
{
|
||||
"triage_type": "confirm|transfer|approval",
|
||||
"confidence": 0.85,
|
||||
"urgency": "high|medium|low",
|
||||
"problem_type": "硬件|软件|网络|安全|账号|其他",
|
||||
"problem_category": "Outlook",
|
||||
"suggested_route": "ai_self|human|auto_approval",
|
||||
"matched_knowledge": "匹配到的知识条目描述",
|
||||
"match_score": 0.89,
|
||||
"context_tags": ["标签1", "标签2"],
|
||||
"triage_steps": [
|
||||
{
|
||||
"question": "步骤问题文本",
|
||||
"options": [
|
||||
{"label": "选项A", "probability": 0.68},
|
||||
{"label": "选项B", "probability": 0.22}
|
||||
]
|
||||
}
|
||||
],
|
||||
"total_steps": 3,
|
||||
"reply": "AI回复文本(当triage_type=confirm时,引导员工选择)"
|
||||
}
|
||||
|
||||
## 置信度评估
|
||||
基于知识库匹配度、问题清晰度、上下文完整度综合评估。"""
|
||||
|
||||
|
||||
class DifyTriageService:
|
||||
"""Dify 分诊应用对接服务。
|
||||
|
||||
通过 OpenAI 兼容接口调用 Dify 独立分诊应用,
|
||||
支持首次分析、动态步骤调整和最终回复生成。
|
||||
|
||||
Attributes:
|
||||
api_url: Dify OpenAI 兼容接口地址
|
||||
api_key: Dify API Key
|
||||
timeout: 请求超时时间(秒)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化 Dify 分诊服务。"""
|
||||
self.api_url = settings.dify_triage_api_url
|
||||
self.api_key = settings.dify_triage_api_key
|
||||
self.timeout = settings.dify_triage_timeout
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""检查 Dify 分诊服务是否可用。
|
||||
|
||||
Returns:
|
||||
bool: API URL 和 Key 均已配置时返回 True
|
||||
"""
|
||||
return bool(self.api_url and self.api_key)
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
question: str,
|
||||
context: Optional[List[str]] = None,
|
||||
excluded_options: Optional[List[str]] = None,
|
||||
step_index: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 Dify 分诊应用进行首次分析。
|
||||
|
||||
Args:
|
||||
question: 员工问题文本
|
||||
context: 已收集的上下文标签(分步选择中累积)
|
||||
excluded_options: 坐席已排除的选项标签
|
||||
step_index: 当前步骤序号(0=首次分诊)
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Dify 返回的分诊结果 JSON
|
||||
|
||||
Raises:
|
||||
RuntimeError: Dify 不可用或返回格式错误
|
||||
"""
|
||||
if not self.is_available():
|
||||
logger.warning("Dify 分诊服务未配置,降级处理")
|
||||
raise RuntimeError("Dify 分诊服务未配置")
|
||||
|
||||
# 构建用户消息内容(JSON 格式传入输入参数)
|
||||
user_content = json.dumps(
|
||||
{
|
||||
"question": question,
|
||||
"collected_context": context or [],
|
||||
"excluded_options": excluded_options or [],
|
||||
"step_index": step_index,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
resp = await client.post(
|
||||
f"{self.api_url}/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "triage-engine",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": TRIAGE_SYSTEM_PROMPT,
|
||||
},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 2000,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
# 解析 OpenAI 兼容响应格式
|
||||
resp_data = resp.json()
|
||||
content = resp_data["choices"][0]["message"]["content"]
|
||||
|
||||
# Dify 返回的是 JSON 字符串,需要解析
|
||||
# 兼容 markdown 代码块包裹的 JSON
|
||||
content = content.strip()
|
||||
if content.startswith("```json"):
|
||||
content = content[7:]
|
||||
if content.startswith("```"):
|
||||
content = content[3:]
|
||||
if content.endswith("```"):
|
||||
content = content[:-3]
|
||||
content = content.strip()
|
||||
|
||||
result = json.loads(content)
|
||||
logger.info(
|
||||
"Dify 分诊分析成功: problem_type=%s, confidence=%s, urgency=%s",
|
||||
result.get("problem_type"),
|
||||
result.get("confidence"),
|
||||
result.get("urgency"),
|
||||
)
|
||||
return result
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Dify 分诊请求超时(%s秒)", self.timeout)
|
||||
raise RuntimeError(f"Dify 分诊请求超时({self.timeout}秒)")
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error("Dify 分诊 HTTP 错误: %s, status=%s", e, e.response.status_code)
|
||||
raise RuntimeError(f"Dify 分诊服务返回错误: {e.response.status_code}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Dify 分诊返回 JSON 解析失败: %s", e)
|
||||
raise RuntimeError("Dify 分诊返回格式错误")
|
||||
except Exception as e:
|
||||
logger.error("Dify 分诊调用异常: %s", e, exc_info=True)
|
||||
raise RuntimeError(f"Dify 分诊调用异常: {e}")
|
||||
|
||||
async def generate_reply(
|
||||
self,
|
||||
question: str,
|
||||
collected_context: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""根据收集的上下文生成最终回复。
|
||||
|
||||
Args:
|
||||
question: 原始问题文本
|
||||
collected_context: 分诊过程中收集的所有上下文
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 包含 reply 和 confidence 的字典
|
||||
|
||||
Raises:
|
||||
RuntimeError: Dify 不可用或返回格式错误
|
||||
"""
|
||||
if not self.is_available():
|
||||
logger.warning("Dify 分诊服务未配置,降级处理(生成回复)")
|
||||
raise RuntimeError("Dify 分诊服务未配置")
|
||||
|
||||
user_content = json.dumps(
|
||||
{
|
||||
"question": question,
|
||||
"collected_context": collected_context,
|
||||
"excluded_options": [],
|
||||
"step_index": -1, # -1 表示最终回复生成
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
resp = await client.post(
|
||||
f"{self.api_url}/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "triage-engine",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": TRIAGE_SYSTEM_PROMPT,
|
||||
},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 2000,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
resp_data = resp.json()
|
||||
content = resp_data["choices"][0]["message"]["content"]
|
||||
|
||||
content = content.strip()
|
||||
if content.startswith("```json"):
|
||||
content = content[7:]
|
||||
if content.startswith("```"):
|
||||
content = content[3:]
|
||||
if content.endswith("```"):
|
||||
content = content[:-3]
|
||||
content = content.strip()
|
||||
|
||||
result = json.loads(content)
|
||||
return {
|
||||
"reply": result.get("reply", ""),
|
||||
"confidence": result.get("confidence", 0.0),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Dify 分诊生成回复异常: %s", e, exc_info=True)
|
||||
raise RuntimeError(f"Dify 分诊生成回复异常: {e}")
|
||||
|
||||
|
||||
# 单例
|
||||
_dify_triage_service: Optional[DifyTriageService] = None
|
||||
|
||||
|
||||
def get_dify_triage_service() -> DifyTriageService:
|
||||
"""获取 DifyTriageService 单例。
|
||||
|
||||
Returns:
|
||||
DifyTriageService: 单例实例
|
||||
"""
|
||||
global _dify_triage_service
|
||||
if _dify_triage_service is None:
|
||||
_dify_triage_service = DifyTriageService()
|
||||
return _dify_triage_service
|
||||
@@ -0,0 +1,344 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 代答排除匹配引擎
|
||||
# =============================================================================
|
||||
# 说明:责任链调度入口,按优先级排序规则,依次调用对应 Matcher,
|
||||
# 命中即停止并记录日志、更新 hit_count。
|
||||
#
|
||||
# 核心方法:
|
||||
# 1. check_exclusions — 检查消息是否命中排除规则
|
||||
# 2. test_match — 测试匹配(管理后台用,不记录日志)
|
||||
# 3. execute_action — 执行命中后动作(4种)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import func, select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.exclusion_log import ExclusionLog
|
||||
from app.models.exclusion_rule import ExclusionRule
|
||||
from app.services.matchers import MATCHER_REGISTRY, MatchResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 优先级排序权重(P0 最高)
|
||||
_PRIORITY_ORDER = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
||||
|
||||
|
||||
class ExclusionCheckResult:
|
||||
"""排除检查结果。
|
||||
|
||||
Attributes:
|
||||
matched: 是否命中
|
||||
rule_id: 命中的规则ID
|
||||
rule_name: 命中的规则名称
|
||||
match_type: 匹配方式
|
||||
matched_detail: 命中详情
|
||||
action_type: 命中后动作类型
|
||||
transfer_message: 转人工提示语
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
matched: bool = False,
|
||||
rule_id: str = "",
|
||||
rule_name: str = "",
|
||||
match_type: str = "",
|
||||
matched_detail: str = "",
|
||||
action_type: str = "",
|
||||
transfer_message: str = "",
|
||||
):
|
||||
self.matched = matched
|
||||
self.rule_id = rule_id
|
||||
self.rule_name = rule_name
|
||||
self.match_type = match_type
|
||||
self.matched_detail = matched_detail
|
||||
self.action_type = action_type
|
||||
self.transfer_message = transfer_message
|
||||
|
||||
|
||||
class ExclusionService:
|
||||
"""代答排除匹配引擎。
|
||||
|
||||
责任链调度:
|
||||
1. 查询所有启用的排除规则,按优先级排序(P0 > P1 > P2 > P3)
|
||||
2. 依次调用对应 Matcher 进行匹配
|
||||
3. 命中即停止,记录 exclusion_logs,更新 hit_count
|
||||
4. 返回命中结果(含 action_type)
|
||||
"""
|
||||
|
||||
async def check_exclusions(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
message: str,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
) -> ExclusionCheckResult:
|
||||
"""检查消息是否命中排除规则。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
message: 用户消息文本
|
||||
conversation_id: 会话ID
|
||||
user_id: 用户ID
|
||||
|
||||
Returns:
|
||||
ExclusionCheckResult: 检查结果
|
||||
"""
|
||||
# 查询所有启用的规则
|
||||
result = await db.execute(
|
||||
select(ExclusionRule)
|
||||
.where(ExclusionRule.status == "enabled")
|
||||
.order_by(ExclusionRule.priority, ExclusionRule.created_at)
|
||||
)
|
||||
rules = result.scalars().all()
|
||||
|
||||
if not rules:
|
||||
return ExclusionCheckResult(matched=False)
|
||||
|
||||
# 按优先级排序(P0 > P1 > P2 > P3)
|
||||
rules_sorted = sorted(
|
||||
rules,
|
||||
key=lambda r: _PRIORITY_ORDER.get(r.priority, 99),
|
||||
)
|
||||
|
||||
# 构建上下文
|
||||
context: Dict[str, Any] = {
|
||||
"conversation_id": conversation_id,
|
||||
"user_id": user_id,
|
||||
"db": db,
|
||||
}
|
||||
|
||||
# 责任链:依次调用对应 Matcher
|
||||
for rule in rules_sorted:
|
||||
matcher = MATCHER_REGISTRY.get(rule.match_type)
|
||||
if matcher is None:
|
||||
logger.warning("未知匹配类型: %s, rule_id=%s", rule.match_type, rule.id)
|
||||
continue
|
||||
|
||||
try:
|
||||
match_result: MatchResult = await matcher.match(
|
||||
message=message,
|
||||
condition=rule.match_condition,
|
||||
context=context,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"匹配器异常: rule=%s, type=%s, error=%s",
|
||||
rule.rule_name, rule.match_type, e,
|
||||
)
|
||||
continue
|
||||
|
||||
if match_result.matched:
|
||||
# 命中!记录日志、更新 hit_count
|
||||
await self._log_hit(
|
||||
db=db,
|
||||
rule=rule,
|
||||
message=message,
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
match_result=match_result,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"排除规则命中: rule=%s, type=%s, detail=%s, action=%s",
|
||||
rule.rule_name, rule.match_type,
|
||||
match_result.matched_detail, rule.action_type,
|
||||
)
|
||||
|
||||
return ExclusionCheckResult(
|
||||
matched=True,
|
||||
rule_id=rule.id,
|
||||
rule_name=rule.rule_name,
|
||||
match_type=rule.match_type,
|
||||
matched_detail=match_result.matched_detail,
|
||||
action_type=rule.action_type,
|
||||
transfer_message=rule.transfer_message or "",
|
||||
)
|
||||
|
||||
return ExclusionCheckResult(matched=False)
|
||||
|
||||
async def test_match(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
message: str,
|
||||
rule_id: Optional[str] = None,
|
||||
) -> ExclusionCheckResult:
|
||||
"""测试匹配(管理后台用,不记录日志、不更新 hit_count)。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
message: 测试消息文本
|
||||
rule_id: 指定规则ID(可选,不指定则测试所有启用规则)
|
||||
|
||||
Returns:
|
||||
ExclusionCheckResult: 测试结果
|
||||
"""
|
||||
if rule_id:
|
||||
# 测试指定规则
|
||||
result = await db.execute(
|
||||
select(ExclusionRule).where(ExclusionRule.id == rule_id)
|
||||
)
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
return ExclusionCheckResult(matched=False)
|
||||
rules_to_test = [rule]
|
||||
else:
|
||||
# 测试所有启用规则
|
||||
result = await db.execute(
|
||||
select(ExclusionRule)
|
||||
.where(ExclusionRule.status == "enabled")
|
||||
.order_by(ExclusionRule.priority)
|
||||
)
|
||||
rules_to_test = result.scalars().all()
|
||||
|
||||
rules_sorted = sorted(
|
||||
rules_to_test,
|
||||
key=lambda r: _PRIORITY_ORDER.get(r.priority, 99),
|
||||
)
|
||||
|
||||
context: Dict[str, Any] = {
|
||||
"conversation_id": "",
|
||||
"user_id": "",
|
||||
"db": db,
|
||||
}
|
||||
|
||||
for rule in rules_sorted:
|
||||
matcher = MATCHER_REGISTRY.get(rule.match_type)
|
||||
if matcher is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
match_result = await matcher.match(
|
||||
message=message,
|
||||
condition=rule.match_condition,
|
||||
context=context,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("测试匹配异常: rule=%s, error=%s", rule.rule_name, e)
|
||||
continue
|
||||
|
||||
if match_result.matched:
|
||||
return ExclusionCheckResult(
|
||||
matched=True,
|
||||
rule_id=rule.id,
|
||||
rule_name=rule.rule_name,
|
||||
match_type=rule.match_type,
|
||||
matched_detail=match_result.matched_detail,
|
||||
action_type=rule.action_type,
|
||||
transfer_message=rule.transfer_message or "",
|
||||
)
|
||||
|
||||
return ExclusionCheckResult(matched=False)
|
||||
|
||||
async def get_stats(self, db: AsyncSession) -> Dict[str, Any]:
|
||||
"""获取排除规则统计概要。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: {enabled_count, disabled_count, monthly_hits, monthly_transfers}
|
||||
"""
|
||||
# 启用规则数
|
||||
enabled_result = await db.execute(
|
||||
select(func.count()).select_from(ExclusionRule).where(
|
||||
ExclusionRule.status == "enabled"
|
||||
)
|
||||
)
|
||||
enabled_count = enabled_result.scalar() or 0
|
||||
|
||||
# 停用规则数
|
||||
disabled_result = await db.execute(
|
||||
select(func.count()).select_from(ExclusionRule).where(
|
||||
ExclusionRule.status == "disabled"
|
||||
)
|
||||
)
|
||||
disabled_count = disabled_result.scalar() or 0
|
||||
|
||||
# 本月命中次数
|
||||
now = datetime.now()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
hits_result = await db.execute(
|
||||
select(func.count()).select_from(ExclusionLog).where(
|
||||
ExclusionLog.created_at >= month_start
|
||||
)
|
||||
)
|
||||
monthly_hits = hits_result.scalar() or 0
|
||||
|
||||
# 本月转人工次数(action_type 含 transfer 的日志)
|
||||
transfer_result = await db.execute(
|
||||
select(func.count()).select_from(ExclusionLog).where(
|
||||
and_(
|
||||
ExclusionLog.created_at >= month_start,
|
||||
ExclusionLog.action_type.like("transfer%"),
|
||||
)
|
||||
)
|
||||
)
|
||||
monthly_transfers = transfer_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"enabled_count": enabled_count,
|
||||
"disabled_count": disabled_count,
|
||||
"monthly_hits": monthly_hits,
|
||||
"monthly_transfers": monthly_transfers,
|
||||
}
|
||||
|
||||
async def _log_hit(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
rule: ExclusionRule,
|
||||
message: str,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
match_result: MatchResult,
|
||||
) -> None:
|
||||
"""记录命中日志并更新 hit_count。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
rule: 命中的规则对象
|
||||
message: 用户消息文本
|
||||
conversation_id: 会话ID
|
||||
user_id: 用户ID
|
||||
match_result: 匹配结果
|
||||
"""
|
||||
# 创建命中日志
|
||||
log_entry = ExclusionLog(
|
||||
id=str(uuid.uuid4()),
|
||||
rule_id=rule.id,
|
||||
rule_name=rule.rule_name,
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
message_content=message[:2000] if message else "",
|
||||
match_type=rule.match_type,
|
||||
matched_detail=match_result.matched_detail,
|
||||
action_type=rule.action_type,
|
||||
action_result="success",
|
||||
)
|
||||
db.add(log_entry)
|
||||
|
||||
# 更新 hit_count
|
||||
rule.hit_count = (rule.hit_count or 0) + 1
|
||||
rule.updated_at = datetime.now()
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
# 单例
|
||||
_exclusion_service: Optional[ExclusionService] = None
|
||||
|
||||
|
||||
def get_exclusion_service() -> ExclusionService:
|
||||
"""获取 ExclusionService 单例。
|
||||
|
||||
Returns:
|
||||
ExclusionService: 单例实例
|
||||
"""
|
||||
global _exclusion_service
|
||||
if _exclusion_service is None:
|
||||
_exclusion_service = ExclusionService()
|
||||
return _exclusion_service
|
||||
@@ -29,10 +29,10 @@ class FunnyPhraseService:
|
||||
|
||||
# 默认话术(当数据库未配置时使用,和 PRD 一致)
|
||||
DEFAULT_PHRASES = {
|
||||
"shake": "少主,这就为您去摇人,稍等...",
|
||||
"keyword": "收到!这就帮您摇位大神来",
|
||||
"shake": "已为您呼叫人工坐席,请稍等!",
|
||||
"keyword": "已为您呼叫人工坐席,请稍等!",
|
||||
"waiting": "人还在路上,别急别急~",
|
||||
"connected": "人摇来了!IT坐席为您服务",
|
||||
"connected": "坐席正在查看您的信息,请等待处理回复!",
|
||||
"timeout": "坐席都在忙,不过AI还在呢,要不先聊聊?我再继续摇",
|
||||
"vip": "这就帮您安排专家,请稍候",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — IT 健康聚合服务
|
||||
# =============================================================================
|
||||
# 说明:整合联软(设备信息/CPU/内存/硬盘)、火绒(安全状态/病毒/漏洞)、
|
||||
# 资产服务(资产编号/启用时间)数据源,返回前端 BasicInfoCard.vue 期望的数据结构。
|
||||
#
|
||||
# 数据流:
|
||||
# 1. 用 employee_id (企微UserID) 作为联软 strusername 查询终端
|
||||
# 2. 联软 get_dev_all_info() 获取详细硬件/磁盘/网卡信息
|
||||
# 3. 火绒 list_terminals() 按计算机名匹配,获取安全状态
|
||||
# 4. 火绒 list_terminal_leaks() 检查漏洞,get_virus_events() 检查病毒
|
||||
# 5. 资产服务 find_asset() 查资产编号和启用时间
|
||||
#
|
||||
# 降级策略:
|
||||
# - 联软/火绒未配置 → 返回 Mock 数据(标记 data_source: "mock")
|
||||
# - 联软配置但火绒未配置 → 设备信息真实,安全状态为 pending
|
||||
# - 任一API调用失败 → 该部分数据返回 None,不影响其他部分
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ITHealthService:
|
||||
"""IT 健康聚合服务。
|
||||
|
||||
从联软、火绒、资产服务获取数据,聚合为前端期望的格式。
|
||||
所有外部 API 调用均做了异常隔离——任一数据源失败不影响整体。
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""初始化服务。
|
||||
|
||||
Args:
|
||||
db: 数据库会话(用于读取 system_configs 表中的集成配置)
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
async def get_it_health(self, employee_id: str) -> Dict[str, Any]:
|
||||
"""获取员工终端的 IT 健康信息。
|
||||
|
||||
这是主入口方法,聚合所有数据源,返回前端期望的 JSON 结构。
|
||||
|
||||
Args:
|
||||
employee_id: 员工企微 UserID(对应联软的 strusername)
|
||||
|
||||
Returns:
|
||||
Dict: 包含 current_device / other_devices / data_source / health_score
|
||||
"""
|
||||
# 尝试从联软获取真实设备信息
|
||||
device_info = await self._get_device_from_lianruan(employee_id)
|
||||
|
||||
if device_info is None:
|
||||
# 联软不可用 → 返回 Mock 数据
|
||||
return self._get_mock_data(employee_id)
|
||||
|
||||
# 联软数据可用,尝试从火绒获取安全状态
|
||||
security_info = await self._get_security_from_huorong(
|
||||
device_info.get("device_name", "")
|
||||
)
|
||||
|
||||
# 尝试从资产服务获取资产编号
|
||||
asset_info = await self._get_asset_info(device_info.get("device_name", ""))
|
||||
|
||||
# 聚合数据
|
||||
current_device = self._build_current_device(device_info, security_info, asset_info)
|
||||
|
||||
# 获取其他设备(联软中该用户的其他终端)
|
||||
other_devices = await self._get_other_devices(employee_id, device_info.get("device_name", ""))
|
||||
|
||||
return {
|
||||
"current_device": current_device,
|
||||
"other_devices": other_devices,
|
||||
"data_source": "real",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# 联软数据获取
|
||||
# ==========================================================================
|
||||
|
||||
async def _get_device_from_lianruan(self, employee_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""从联软查终端设备信息。
|
||||
|
||||
流程:
|
||||
1. 用 employee_id 作为 strusername 查终端列表
|
||||
2. 取第一个(最近活跃的)终端
|
||||
3. 调 get_dev_all_info() 获取详细硬件信息
|
||||
|
||||
Args:
|
||||
employee_id: 员工账号
|
||||
|
||||
Returns:
|
||||
Dict: 设备信息字典,联软不可用时返回 None
|
||||
"""
|
||||
try:
|
||||
from app.integrations.lianruan.config import get_lianruan_client
|
||||
|
||||
client = await get_lianruan_client(self.db)
|
||||
|
||||
# 按员工账号查终端列表
|
||||
result = await client.query_dev_by_params(strusername=employee_id, per_page=10)
|
||||
terminals = result.get("items", [])
|
||||
|
||||
if not terminals:
|
||||
logger.info(f"联软未找到 employee_id={employee_id} 的终端")
|
||||
return None
|
||||
|
||||
# 取第一个终端(联软默认按最近活跃排序)
|
||||
terminal = terminals[0]
|
||||
device_name = terminal.strdevname
|
||||
|
||||
if not device_name:
|
||||
logger.warning(f"联软返回的终端无计算机名: {terminal}")
|
||||
return None
|
||||
|
||||
# 获取详细信息
|
||||
detail = await client.get_dev_all_info(strdevname=device_name)
|
||||
|
||||
# 构建设备信息字典
|
||||
device = {
|
||||
"device_name": device_name,
|
||||
"is_online": terminal.istatus == "1",
|
||||
"ip_address": terminal.strdevip or detail.strip1,
|
||||
"mac": terminal.strmac or detail.strmac,
|
||||
"os": detail.stros or "",
|
||||
"location": terminal.strdeptname or "",
|
||||
"department": terminal.strdeptname or "",
|
||||
"switch_name": terminal.strswitchname or "",
|
||||
"uptime": self._format_uptime(detail.dtdevuptime),
|
||||
"last_online_time": detail.dtdevuptime or "",
|
||||
"last_offline_time": detail.dtdevdowntime or "",
|
||||
"device_type": detail.strdevtype or "台式机",
|
||||
"serial_number": detail.strserialnumber or "",
|
||||
"mainboard": detail.strmainboardtype or "",
|
||||
# 硬件详情
|
||||
"cpu_list": [
|
||||
{"name": c.name, "model": c.model, "vendor": c.vendor}
|
||||
for c in detail.cpu
|
||||
] if detail.cpu else [],
|
||||
"memory_list": [
|
||||
{"name": m.name, "capacity": m.capacity, "vendor": m.vendor}
|
||||
for m in detail.memory
|
||||
] if detail.memory else [],
|
||||
"logical_disks": [
|
||||
{
|
||||
"label": d.name,
|
||||
"total": d.total_size,
|
||||
"free": d.free_space,
|
||||
"usage_percent": d.usage_percent,
|
||||
}
|
||||
for d in detail.logical_disk
|
||||
] if detail.logical_disk else [],
|
||||
"network_cards": [
|
||||
{"name": n.name, "mac": n.mac, "is_wireless": n.is_wireless}
|
||||
for n in detail.network_card
|
||||
] if detail.network_card else [],
|
||||
}
|
||||
|
||||
logger.info(f"联软获取设备成功: {device_name} (employee={employee_id})")
|
||||
return device
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"联软获取设备信息失败: {e}")
|
||||
return None
|
||||
|
||||
async def _get_other_devices(
|
||||
self, employee_id: str, exclude_device: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取员工的其他设备(联软中该用户的其他终端)。
|
||||
|
||||
Args:
|
||||
employee_id: 员工账号
|
||||
exclude_device: 要排除的当前设备名
|
||||
|
||||
Returns:
|
||||
List: 其他设备列表
|
||||
"""
|
||||
try:
|
||||
from app.integrations.lianruan.config import get_lianruan_client
|
||||
|
||||
client = await get_lianruan_client(self.db)
|
||||
result = await client.query_dev_by_params(strusername=employee_id, per_page=10)
|
||||
terminals = result.get("items", [])
|
||||
|
||||
other = []
|
||||
for t in terminals:
|
||||
if t.strdevname and t.strdevname != exclude_device:
|
||||
other.append({
|
||||
"device_type": t.strdevtype or "设备",
|
||||
"device_name": t.strdevname,
|
||||
"last_login_time": t.istatus == "1" and "在线" or "离线",
|
||||
"last_login_location": t.strdeptname or "",
|
||||
})
|
||||
|
||||
return other
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"获取其他设备失败: {e}")
|
||||
return []
|
||||
|
||||
# ==========================================================================
|
||||
# 火绒安全数据获取
|
||||
# ==========================================================================
|
||||
|
||||
async def _get_security_from_huorong(
|
||||
self, computer_name: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""从火绒查终端安全状态。
|
||||
|
||||
流程:
|
||||
1. list_terminals() 全量分页搜索,按 computer_name 匹配
|
||||
2. 找到后 get_terminal_detail() 获取硬件/资产/网络配置
|
||||
3. list_terminal_leaks() 检查是否在漏洞清单中
|
||||
4. get_virus_events() 查病毒事件统计
|
||||
|
||||
Args:
|
||||
computer_name: 计算机名(联软的 strdevname)
|
||||
|
||||
Returns:
|
||||
Dict: 安全状态字典,火绒不可用时返回 None
|
||||
"""
|
||||
try:
|
||||
from app.integrations.huorong.config import get_huorong_client
|
||||
|
||||
client = await get_huorong_client(self.db)
|
||||
|
||||
# 分页搜索终端,按计算机名匹配
|
||||
target_client_id = None
|
||||
page = 1
|
||||
while page <= 10: # 最多查10页(2000台)
|
||||
result = await client.list_terminals(page=page, per_page=200)
|
||||
items = result.get("items", [])
|
||||
|
||||
for item in items:
|
||||
if item.computer_name and item.computer_name.upper() == computer_name.upper():
|
||||
target_client_id = item.client_id
|
||||
break
|
||||
|
||||
if target_client_id:
|
||||
break
|
||||
|
||||
if len(items) < 200:
|
||||
break # 没有更多数据
|
||||
page += 1
|
||||
|
||||
if not target_client_id:
|
||||
# 火绒中未找到该终端 → 可能未安装火绒
|
||||
return {
|
||||
"huorong_installed": False,
|
||||
"is_online": False,
|
||||
"version": "",
|
||||
"definitions": "",
|
||||
"high_risk_leaks": 0,
|
||||
"virus_count": 0,
|
||||
"virus_uncleaned": 0,
|
||||
}
|
||||
|
||||
# 获取终端详情
|
||||
detail = await client.get_terminal_detail(
|
||||
client_id=target_client_id,
|
||||
optional_fields=["hardware", "assets", "netconf"],
|
||||
)
|
||||
|
||||
# 检查漏洞清单
|
||||
leak_count = 0
|
||||
try:
|
||||
leaks_result = await client.list_terminal_leaks()
|
||||
for leak_item in leaks_result.get("items", []):
|
||||
if leak_item.hostname and leak_item.hostname.upper() == computer_name.upper():
|
||||
leak_count = 1 # 在漏洞清单中说明有高危漏洞
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"火绒漏洞查询失败: {e}")
|
||||
|
||||
# 查病毒事件
|
||||
virus_count = 0
|
||||
virus_uncleaned = 0
|
||||
try:
|
||||
virus_result = await client.get_virus_events(
|
||||
client_id=target_client_id, type=0
|
||||
)
|
||||
for stat in virus_result.get("items", []):
|
||||
virus_count += stat.count
|
||||
if stat.result:
|
||||
virus_uncleaned += stat.result.fail + stat.result.ignored
|
||||
except Exception as e:
|
||||
logger.warning(f"火绒病毒事件查询失败: {e}")
|
||||
|
||||
return {
|
||||
"huorong_installed": True,
|
||||
"is_online": True, # 从 list_terminals 已确认存在
|
||||
"version": detail.computer_name and "" or "", # 火绒版本从 list 获取
|
||||
"definitions": "",
|
||||
"high_risk_leaks": leak_count,
|
||||
"virus_count": virus_count,
|
||||
"virus_uncleaned": virus_uncleaned,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"火绒获取安全状态失败: {e}")
|
||||
return None
|
||||
|
||||
# ==========================================================================
|
||||
# 资产服务
|
||||
# ==========================================================================
|
||||
|
||||
async def _get_asset_info(self, device_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""从资产服务查设备资产编号和启用时间。
|
||||
|
||||
Args:
|
||||
device_name: 计算机名(用于日志,资产查询通过资产编号)
|
||||
|
||||
Returns:
|
||||
Dict: 资产信息(asset_tag / activate_date),不可用时返回 None
|
||||
"""
|
||||
try:
|
||||
from app.services.asset_service import AssetService
|
||||
|
||||
asset_svc = AssetService()
|
||||
# 资产服务目前通过资产编号查询,设备名无法直接查
|
||||
# 这里先返回 None,后续需要联软 devassetno → 资产编号 → 查询
|
||||
# 或者资产Excel按计算机名匹配
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"资产服务查询失败: {e}")
|
||||
return None
|
||||
|
||||
# ==========================================================================
|
||||
# 数据聚合
|
||||
# ==========================================================================
|
||||
|
||||
def _build_current_device(
|
||||
self,
|
||||
device_info: Dict[str, Any],
|
||||
security_info: Optional[Dict[str, Any]],
|
||||
asset_info: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""聚合联软+火绒+资产数据为前端期望的格式。
|
||||
|
||||
前端 BasicInfoCard.vue 期望的数据结构:
|
||||
- device_name / is_online / asset_tag / activate_date
|
||||
- ip_address / public_ip / location / os / mac / uptime
|
||||
- cpu / memory / disks (进度条)
|
||||
- security_checks / compliance_checks (状态数组)
|
||||
|
||||
Args:
|
||||
device_info: 联软设备信息
|
||||
security_info: 火绒安全状态(可能为 None)
|
||||
asset_info: 资产信息(可能为 None)
|
||||
|
||||
Returns:
|
||||
Dict: 前端期望的设备数据结构
|
||||
"""
|
||||
# CPU 使用率(联软不提供实时使用率,用硬件型号代替)
|
||||
cpu_model = ""
|
||||
cpu_usage = 0
|
||||
if device_info.get("cpu_list"):
|
||||
cpu = device_info["cpu_list"][0]
|
||||
cpu_model = f"{cpu.get('vendor', '')} {cpu.get('name', '')}".strip()
|
||||
cpu_usage = 0 # 联软不提供实时使用率
|
||||
|
||||
# 内存总量
|
||||
memory_total = ""
|
||||
memory_usage = 0
|
||||
if device_info.get("memory_list"):
|
||||
mem = device_info["memory_list"][0]
|
||||
memory_total = mem.get("capacity", "") or ""
|
||||
# 联软返回的是硬件容量,不是使用率
|
||||
|
||||
# 磁盘分区
|
||||
disks = []
|
||||
for disk in device_info.get("logical_disks", []):
|
||||
try:
|
||||
usage = int(float(disk.get("usage_percent", "0").replace("%", "").strip() or "0"))
|
||||
except (ValueError, TypeError):
|
||||
usage = 0
|
||||
|
||||
total = disk.get("total", "0")
|
||||
free = disk.get("free", "0")
|
||||
# 计算已用空间
|
||||
used = self._calc_used_space(total, free)
|
||||
|
||||
disks.append({
|
||||
"label": f"硬盘{disk.get('label', 'C盘')}",
|
||||
"usage": usage,
|
||||
"used": used,
|
||||
"total": total,
|
||||
})
|
||||
|
||||
# 安全检查状态数组(6项)
|
||||
security_checks = self._build_security_checks(security_info)
|
||||
|
||||
# 合规检查状态数组(2项)
|
||||
compliance_checks = self._build_compliance_checks(device_info, security_info)
|
||||
|
||||
# 健康评分
|
||||
health_score = self._calc_health_score(security_checks, compliance_checks, device_info.get("is_online", False))
|
||||
|
||||
return {
|
||||
"device_name": device_info.get("device_name", ""),
|
||||
"is_online": device_info.get("is_online", False),
|
||||
"asset_tag": asset_info.get("asset_tag", "") if asset_info else "",
|
||||
"activate_date": asset_info.get("activate_date", "") if asset_info else "",
|
||||
"ip_address": device_info.get("ip_address", ""),
|
||||
"public_ip": "", # 公网出口IP需要额外查询
|
||||
"location": device_info.get("location", ""),
|
||||
"os": device_info.get("os", ""),
|
||||
"mac": device_info.get("mac", ""),
|
||||
"uptime": device_info.get("uptime", ""),
|
||||
"cpu": {"usage": cpu_usage, "model": cpu_model},
|
||||
"memory": {"usage": memory_usage, "total": memory_total},
|
||||
"disks": disks,
|
||||
"security_checks": security_checks,
|
||||
"compliance_checks": compliance_checks,
|
||||
"health_score": health_score,
|
||||
}
|
||||
|
||||
def _build_security_checks(
|
||||
self, security_info: Optional[Dict[str, Any]]
|
||||
) -> List[Dict[str, str]]:
|
||||
"""构建安全检查状态数组(6项)。
|
||||
|
||||
前端期望6个检查项:
|
||||
0. 火绒安装状态
|
||||
1. 系统补丁(高危漏洞)
|
||||
2. 高危软件
|
||||
3. 病毒状态
|
||||
4. 内部攻击(接入中)
|
||||
5. 网络代理(接入中)
|
||||
|
||||
Args:
|
||||
security_info: 火绒安全状态(可能为 None)
|
||||
|
||||
Returns:
|
||||
List: 6个状态对象 [{status: "pass"|"warning"|"danger"|"pending"}]
|
||||
"""
|
||||
if security_info is None:
|
||||
# 火绒未配置 → 全部 pending
|
||||
return [{"status": "pending"}] * 6
|
||||
|
||||
checks = []
|
||||
|
||||
# 0. 火绒安装
|
||||
if security_info.get("huorong_installed"):
|
||||
checks.append({"status": "pass"})
|
||||
else:
|
||||
checks.append({"status": "danger"}) # 未安装火绒 = 危险
|
||||
|
||||
# 1. 系统补丁(高危漏洞)
|
||||
if security_info.get("high_risk_leaks", 0) > 0:
|
||||
checks.append({"status": "danger"})
|
||||
else:
|
||||
checks.append({"status": "pass"})
|
||||
|
||||
# 2. 高危软件(火绒不直接提供,暂返回 pass)
|
||||
checks.append({"status": "pass"})
|
||||
|
||||
# 3. 病毒状态
|
||||
uncleaned = security_info.get("virus_uncleaned", 0)
|
||||
if uncleaned > 0:
|
||||
checks.append({"status": "danger"})
|
||||
elif security_info.get("virus_count", 0) > 0:
|
||||
checks.append({"status": "warning"})
|
||||
else:
|
||||
checks.append({"status": "pass"})
|
||||
|
||||
# 4. 内部攻击(接入中 — 联软尚未对接此数据源)
|
||||
checks.append({"status": "pending"})
|
||||
|
||||
# 5. 网络代理(接入中 — 联软尚未对接此数据源)
|
||||
checks.append({"status": "pending"})
|
||||
|
||||
return checks
|
||||
|
||||
def _build_compliance_checks(
|
||||
self, device_info: Dict[str, Any], security_info: Optional[Dict[str, Any]]
|
||||
) -> List[Dict[str, str]]:
|
||||
"""构建合规检查状态数组(2项)。
|
||||
|
||||
前端期望2个检查项:
|
||||
0. 自备电脑检查
|
||||
1. 未审批商业软件检查
|
||||
|
||||
Args:
|
||||
device_info: 联软设备信息
|
||||
security_info: 火绒安全状态
|
||||
|
||||
Returns:
|
||||
List: 2个状态对象
|
||||
"""
|
||||
# 自备电脑:联软设备类型中如果有"自备"标记则 danger
|
||||
device_type = device_info.get("device_type", "")
|
||||
if "自备" in device_type:
|
||||
return [{"status": "danger"}, {"status": "pass"}]
|
||||
|
||||
# 默认通过
|
||||
return [{"status": "pass"}, {"status": "pass"}]
|
||||
|
||||
def _calc_health_score(
|
||||
self,
|
||||
security_checks: List[Dict[str, str]],
|
||||
compliance_checks: List[Dict[str, str]],
|
||||
is_online: bool,
|
||||
) -> int:
|
||||
"""计算 IT 健康评分(0-100)。
|
||||
|
||||
评分算法(与前端 BasicInfoCard.vue 一致):
|
||||
- 安全项 danger: -15 / warning: -8 / pending: 0
|
||||
- 合规项 danger: -10 / warning: -5
|
||||
- 离线设备权重 60%
|
||||
|
||||
Args:
|
||||
security_checks: 安全检查状态数组
|
||||
compliance_checks: 合规检查状态数组
|
||||
is_online: 设备是否在线
|
||||
|
||||
Returns:
|
||||
int: 健康评分 0-100
|
||||
"""
|
||||
score = 100
|
||||
|
||||
for check in security_checks:
|
||||
status = check.get("status", "pending")
|
||||
if status == "danger":
|
||||
score -= 15
|
||||
elif status == "warning":
|
||||
score -= 8
|
||||
|
||||
for check in compliance_checks:
|
||||
status = check.get("status", "pass")
|
||||
if status == "danger":
|
||||
score -= 10
|
||||
elif status == "warning":
|
||||
score -= 5
|
||||
|
||||
if not is_online:
|
||||
score = round(score * 0.6)
|
||||
|
||||
return max(0, score)
|
||||
|
||||
# ==========================================================================
|
||||
# 工具方法
|
||||
# ==========================================================================
|
||||
|
||||
def _format_uptime(self, last_online_time: str) -> str:
|
||||
"""格式化运行时长。
|
||||
|
||||
联软返回的是最近上线时间字符串,计算距现在的时长。
|
||||
如果无法解析则返回空字符串。
|
||||
|
||||
Args:
|
||||
last_online_time: 联软返回的上线时间字符串
|
||||
|
||||
Returns:
|
||||
str: 如 "12天3小时" 或空字符串
|
||||
"""
|
||||
if not last_online_time:
|
||||
return ""
|
||||
|
||||
try:
|
||||
# 联软时间格式可能是 "2026-07-12 08:30:00" 或类似
|
||||
dt = datetime.strptime(last_online_time.replace("T", " "), "%Y-%m-%d %H:%M:%S")
|
||||
now = datetime.now()
|
||||
delta = now - dt
|
||||
|
||||
days = delta.days
|
||||
hours = delta.seconds // 3600
|
||||
|
||||
if days > 0:
|
||||
return f"{days}天{hours}小时"
|
||||
else:
|
||||
minutes = delta.seconds // 60
|
||||
return f"{minutes}分钟"
|
||||
except (ValueError, TypeError):
|
||||
return ""
|
||||
|
||||
def _calc_used_space(self, total: str, free: str) -> str:
|
||||
"""计算已用空间。
|
||||
|
||||
Args:
|
||||
total: 总容量字符串(如 "256GB")
|
||||
free: 可用空间字符串(如 "86GB")
|
||||
|
||||
Returns:
|
||||
str: 已用空间(如 "170GB")
|
||||
"""
|
||||
try:
|
||||
# 尝试提取数字部分
|
||||
total_num = float("".join(c for c in total if c.isdigit() or c == "."))
|
||||
free_num = float("".join(c for c in free if c.isdigit() or c == "."))
|
||||
|
||||
used_num = total_num - free_num
|
||||
if used_num < 0:
|
||||
used_num = 0
|
||||
|
||||
# 保留单位
|
||||
unit = "".join(c for c in total if c.isalpha())
|
||||
if unit:
|
||||
return f"{int(used_num)}{unit}"
|
||||
return str(int(used_num))
|
||||
except (ValueError, TypeError):
|
||||
return ""
|
||||
|
||||
# ==========================================================================
|
||||
# Mock 数据(联软/火绒未配置时降级)
|
||||
# ==========================================================================
|
||||
|
||||
def _get_mock_data(self, employee_id: str) -> Dict[str, Any]:
|
||||
"""返回 Mock 数据(联软不可用时降级)。
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID(用于日志)
|
||||
|
||||
Returns:
|
||||
Dict: 与真实数据结构一致的 Mock 数据
|
||||
"""
|
||||
return {
|
||||
"current_device": {
|
||||
"device_name": "DESKTOP-MOCK",
|
||||
"is_online": True,
|
||||
"asset_tag": "",
|
||||
"activate_date": "",
|
||||
"ip_address": "10.90.5.x",
|
||||
"public_ip": "218.75.34.87",
|
||||
"location": "待获取",
|
||||
"os": "待获取",
|
||||
"mac": "",
|
||||
"uptime": "",
|
||||
"cpu": {"usage": 0, "model": ""},
|
||||
"memory": {"usage": 0, "total": ""},
|
||||
"disks": [],
|
||||
"security_checks": [{"status": "pending"}] * 6,
|
||||
"compliance_checks": [{"status": "pass"}, {"status": "pass"}],
|
||||
"health_score": 100,
|
||||
},
|
||||
"other_devices": [],
|
||||
"data_source": "mock",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 代答排除匹配器包
|
||||
# =============================================================================
|
||||
# 说明:策略模式实现 4 种匹配器,由 ExclusionService 责任链调度。
|
||||
# 1. KeywordMatcher — 关键词匹配(逗号分隔,包含任一即命中)
|
||||
# 2. RegexMatcher — 正则匹配(编译缓存 + ReDoS 超时保护)
|
||||
# 3. IntentMatcher — 意图匹配(复用审批意图识别 Dify 链路)
|
||||
# 4. CategoryMatcher — 分类匹配(查 triage_sessions 获取 problem_category)
|
||||
# =============================================================================
|
||||
|
||||
from app.services.matchers.base import BaseMatcher, MatchResult
|
||||
from app.services.matchers.keyword_matcher import KeywordMatcher
|
||||
from app.services.matchers.regex_matcher import RegexMatcher
|
||||
from app.services.matchers.intent_matcher import IntentMatcher
|
||||
from app.services.matchers.category_matcher import CategoryMatcher
|
||||
|
||||
# 匹配器注册表:match_type → Matcher 实例
|
||||
MATCHER_REGISTRY: dict[str, BaseMatcher] = {
|
||||
"keyword": KeywordMatcher(),
|
||||
"regex": RegexMatcher(),
|
||||
"intent": IntentMatcher(),
|
||||
"category": CategoryMatcher(),
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"BaseMatcher",
|
||||
"MatchResult",
|
||||
"KeywordMatcher",
|
||||
"RegexMatcher",
|
||||
"IntentMatcher",
|
||||
"CategoryMatcher",
|
||||
"MATCHER_REGISTRY",
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 匹配器基类 + 匹配结果
|
||||
# =============================================================================
|
||||
# 说明:策略模式接口定义,所有匹配器必须继承 BaseMatcher 并实现 match 方法。
|
||||
# =============================================================================
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchResult:
|
||||
"""匹配结果。
|
||||
|
||||
Attributes:
|
||||
matched: 是否命中
|
||||
matched_detail: 命中的关键词/正则/意图/分类(用于日志和测试展示)
|
||||
match_position: 匹配位置(用于测试展示,如 "位置 12-18")
|
||||
"""
|
||||
|
||||
matched: bool
|
||||
matched_detail: str = ""
|
||||
match_position: str = ""
|
||||
|
||||
|
||||
class BaseMatcher(ABC):
|
||||
"""匹配器基类 — 策略模式接口。
|
||||
|
||||
每种匹配器实现一种匹配逻辑(关键词/正则/意图/分类),
|
||||
由 ExclusionService 责任链按优先级依次调用。
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def match(
|
||||
self,
|
||||
message: str,
|
||||
condition: str,
|
||||
context: Optional[dict] = None,
|
||||
) -> MatchResult:
|
||||
"""检查消息是否匹配规则条件。
|
||||
|
||||
Args:
|
||||
message: 用户消息文本
|
||||
condition: 匹配条件
|
||||
- keyword: 逗号分隔关键词列表(如 "密码过期,账号锁定")
|
||||
- regex: 正则表达式(如 "密码.*过期")
|
||||
- intent: 逗号分隔意图ID列表(如 "password_reset,account_unlock")
|
||||
- category: 逗号分隔分类名称列表(如 "Outlook,VPN")
|
||||
context: 上下文字典,可含 conversation_id, user_id, db 等
|
||||
|
||||
Returns:
|
||||
MatchResult: 匹配结果
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,98 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 分类匹配器
|
||||
# =============================================================================
|
||||
# 说明:查询 triage_sessions 表获取分诊结果的 problem_category,
|
||||
# 检查是否在排除分类列表中。
|
||||
# 软依赖:无分诊结果时返回未命中,不影响其他匹配器执行。
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.triage_session import TriageSession
|
||||
from app.services.matchers.base import BaseMatcher, MatchResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CategoryMatcher(BaseMatcher):
|
||||
"""分类匹配器。
|
||||
|
||||
匹配逻辑:
|
||||
1. 从 context 中获取 conversation_id 和 db
|
||||
2. 查询 triage_sessions 获取最近一条分诊记录的 problem_category
|
||||
3. 检查 problem_category 是否在排除分类列表中
|
||||
|
||||
软依赖:
|
||||
- 无 conversation_id → 返回未命中
|
||||
- 无 db → 返回未命中
|
||||
- 无分诊记录 → 返回未命中
|
||||
- 分诊记录无 problem_category → 返回未命中
|
||||
|
||||
Example:
|
||||
condition = "Outlook,VPN,打印机"
|
||||
conversation_id = "conv-123"
|
||||
→ 查到最近分诊记录 problem_category = "Outlook"
|
||||
→ 命中,matched_detail="分类: Outlook"
|
||||
"""
|
||||
|
||||
async def match(
|
||||
self,
|
||||
message: str,
|
||||
condition: str,
|
||||
context: Optional[dict] = None,
|
||||
) -> MatchResult:
|
||||
"""检查分诊分类是否在排除列表中。
|
||||
|
||||
Args:
|
||||
message: 用户消息文本(本匹配器不直接使用,保留接口一致性)
|
||||
condition: 逗号分隔的分类名称列表
|
||||
context: 上下文,需含 conversation_id 和 db
|
||||
|
||||
Returns:
|
||||
MatchResult: 命中时 matched=True, matched_detail="分类: xxx"
|
||||
"""
|
||||
if not condition or not context:
|
||||
return MatchResult(matched=False)
|
||||
|
||||
conversation_id = context.get("conversation_id")
|
||||
db: Optional[AsyncSession] = context.get("db")
|
||||
|
||||
if not conversation_id or not db:
|
||||
return MatchResult(matched=False)
|
||||
|
||||
excluded_categories = [s.strip() for s in condition.split(",") if s.strip()]
|
||||
if not excluded_categories:
|
||||
return MatchResult(matched=False)
|
||||
|
||||
# 查询最近一条分诊记录
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(TriageSession)
|
||||
.where(TriageSession.conversation_id == conversation_id)
|
||||
.order_by(TriageSession.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
triage = result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error("分类匹配器查询分诊记录失败: %s", e)
|
||||
return MatchResult(matched=False)
|
||||
|
||||
if not triage or not triage.problem_category:
|
||||
# 无分诊结果,软依赖跳过
|
||||
return MatchResult(matched=False)
|
||||
|
||||
# 检查分类是否在排除列表中
|
||||
category = triage.problem_category
|
||||
for excluded in excluded_categories:
|
||||
if excluded.lower() == category.lower():
|
||||
return MatchResult(
|
||||
matched=True,
|
||||
matched_detail=f"分类: {category}",
|
||||
match_position=f"分诊分类匹配(triage_id={triage.id})",
|
||||
)
|
||||
|
||||
return MatchResult(matched=False)
|
||||
@@ -0,0 +1,142 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 意图匹配器
|
||||
# =============================================================================
|
||||
# 说明:复用审批意图识别 Dify 链路(approval_dify_base_url + approval_dify_api_key),
|
||||
# 调用 Dify 意图识别 API,检查返回意图是否在排除列表中。
|
||||
# 降级处理:Dify 不可用时返回未命中(不影响其他匹配器执行)。
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.services.matchers.base import BaseMatcher, MatchResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 意图识别 System Prompt
|
||||
_INTENT_SYSTEM_PROMPT = (
|
||||
"你是IT服务台意图识别引擎,负责分析用户消息的意图类别。\n"
|
||||
"输出约束:只输出意图ID(一个词),不要输出解释性文字。\n"
|
||||
"常见意图ID包括:password_reset, account_unlock, software_install, "
|
||||
"network_issue, hardware_repair, vpn_issue, email_issue, "
|
||||
"approval_request, information_inquiry, complaint, other."
|
||||
)
|
||||
|
||||
|
||||
class IntentMatcher(BaseMatcher):
|
||||
"""意图匹配器。
|
||||
|
||||
匹配逻辑:
|
||||
1. 调用 Dify 意图识别 API(复用审批意图链路)
|
||||
2. 获取用户消息的意图ID
|
||||
3. 检查意图ID是否在排除列表中
|
||||
|
||||
降级处理:
|
||||
- Dify 未配置或不可用 → 返回未命中
|
||||
- Dify 超时 → 返回未命中
|
||||
- 返回格式异常 → 返回未命中
|
||||
|
||||
Example:
|
||||
condition = "password_reset,account_unlock"
|
||||
message = "我的密码忘了,帮我重置一下"
|
||||
→ Dify 返回 "password_reset"
|
||||
→ 命中,matched_detail="意图: password_reset"
|
||||
"""
|
||||
|
||||
async def _recognize_intent(self, message: str) -> Optional[str]:
|
||||
"""调用 Dify 意图识别 API。
|
||||
|
||||
Args:
|
||||
message: 用户消息文本
|
||||
|
||||
Returns:
|
||||
Optional[str]: 识别到的意图ID,失败返回 None
|
||||
"""
|
||||
api_url = settings.approval_dify_base_url
|
||||
api_key = settings.approval_dify_api_key
|
||||
timeout = settings.approval_dify_timeout
|
||||
|
||||
if not api_url or not api_key:
|
||||
logger.warning("审批意图识别 Dify 未配置,跳过意图匹配")
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
f"{api_url}/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "intent-recognition",
|
||||
"messages": [
|
||||
{"role": "system", "content": _INTENT_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 50,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
resp_data = resp.json()
|
||||
content = resp_data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
# 清理可能的 markdown 包裹
|
||||
if content.startswith("```"):
|
||||
content = content.strip("`").strip()
|
||||
|
||||
logger.info("意图识别结果: message=%s, intent=%s", message[:50], content)
|
||||
return content
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("意图识别 Dify 请求超时(%s秒)", timeout)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("意图识别 Dify 调用异常: %s", e)
|
||||
return None
|
||||
|
||||
async def match(
|
||||
self,
|
||||
message: str,
|
||||
condition: str,
|
||||
context: Optional[dict] = None,
|
||||
) -> MatchResult:
|
||||
"""检查消息意图是否在排除列表中。
|
||||
|
||||
Args:
|
||||
message: 用户消息文本
|
||||
condition: 逗号分隔的意图ID列表
|
||||
context: 上下文(本匹配器不需要)
|
||||
|
||||
Returns:
|
||||
MatchResult: 命中时 matched=True, matched_detail="意图: xxx"
|
||||
"""
|
||||
if not message or not condition:
|
||||
return MatchResult(matched=False)
|
||||
|
||||
excluded_intents = [s.strip() for s in condition.split(",") if s.strip()]
|
||||
if not excluded_intents:
|
||||
return MatchResult(matched=False)
|
||||
|
||||
# 调用 Dify 意图识别
|
||||
intent = await self._recognize_intent(message)
|
||||
if intent is None:
|
||||
# Dify 不可用,降级返回未命中
|
||||
return MatchResult(matched=False)
|
||||
|
||||
# 检查意图是否在排除列表中(不区分大小写)
|
||||
intent_lower = intent.lower()
|
||||
for excluded in excluded_intents:
|
||||
if excluded.lower() == intent_lower:
|
||||
return MatchResult(
|
||||
matched=True,
|
||||
matched_detail=f"意图: {intent}",
|
||||
match_position="意图识别匹配",
|
||||
)
|
||||
|
||||
return MatchResult(matched=False)
|
||||
@@ -0,0 +1,67 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 关键词匹配器
|
||||
# =============================================================================
|
||||
# 说明:逗号分隔关键词列表,消息包含任一关键词即命中。
|
||||
# 匹配不区分大小写,支持中英文混合。
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.services.matchers.base import BaseMatcher, MatchResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KeywordMatcher(BaseMatcher):
|
||||
"""关键词匹配器。
|
||||
|
||||
匹配逻辑:
|
||||
1. 将 condition 按逗号分隔为关键词列表
|
||||
2. 对消息文本做小写化处理
|
||||
3. 消息包含任一关键词(小写化后)即命中
|
||||
|
||||
Example:
|
||||
condition = "密码过期,账号锁定,密码错误"
|
||||
message = "我的密码过期了怎么办"
|
||||
→ 命中关键词 "密码过期"
|
||||
"""
|
||||
|
||||
async def match(
|
||||
self,
|
||||
message: str,
|
||||
condition: str,
|
||||
context: Optional[dict] = None,
|
||||
) -> MatchResult:
|
||||
"""检查消息是否包含任一关键词。
|
||||
|
||||
Args:
|
||||
message: 用户消息文本
|
||||
condition: 逗号分隔的关键词列表
|
||||
context: 上下文(本匹配器不需要)
|
||||
|
||||
Returns:
|
||||
MatchResult: 命中时 matched=True, matched_detail=命中的关键词
|
||||
"""
|
||||
if not message or not condition:
|
||||
return MatchResult(matched=False)
|
||||
|
||||
# 按逗号分隔关键词,去除空白
|
||||
keywords = [kw.strip() for kw in condition.split(",") if kw.strip()]
|
||||
if not keywords:
|
||||
return MatchResult(matched=False)
|
||||
|
||||
# 消息小写化用于不区分大小写匹配
|
||||
message_lower = message.lower()
|
||||
|
||||
for kw in keywords:
|
||||
kw_lower = kw.lower()
|
||||
pos = message_lower.find(kw_lower)
|
||||
if pos != -1:
|
||||
return MatchResult(
|
||||
matched=True,
|
||||
matched_detail=f"关键词: {kw}",
|
||||
match_position=f"位置 {pos}-{pos + len(kw)}",
|
||||
)
|
||||
|
||||
return MatchResult(matched=False)
|
||||
@@ -0,0 +1,125 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 正则匹配器
|
||||
# =============================================================================
|
||||
# 说明:使用 Python re.search 进行正则匹配,带编译缓存和 ReDoS 超时保护。
|
||||
# 编译缓存:同一 pattern 只编译一次,缓存在类变量 _compile_cache 中。
|
||||
# ReDoS 保护:使用 signal.alarm 超时机制,防止恶意正则导致 CPU 打满。
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import re
|
||||
import signal
|
||||
from typing import Optional
|
||||
|
||||
from app.services.matchers.base import BaseMatcher, MatchResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 正则匹配超时时间(秒),防止 ReDoS
|
||||
_REGEX_TIMEOUT_SEC: float = 2.0
|
||||
|
||||
# 正则编译缓存最大条目数
|
||||
_MAX_CACHE_SIZE: int = 200
|
||||
|
||||
|
||||
class RegexMatcher(BaseMatcher):
|
||||
"""正则匹配器。
|
||||
|
||||
匹配逻辑:
|
||||
1. 编译 condition 为正则 Pattern(带缓存)
|
||||
2. 在消息文本中搜索匹配
|
||||
3. 命中则返回匹配详情和位置
|
||||
|
||||
安全措施:
|
||||
- 正则编译缓存:同一 pattern 只编译一次
|
||||
- ReDoS 超时保护:匹配超过 2 秒自动中断,返回未命中
|
||||
|
||||
Example:
|
||||
condition = "密码.*过期"
|
||||
message = "我的密码好像过期了"
|
||||
→ 命中,matched_detail="密码好像过期"
|
||||
"""
|
||||
|
||||
# 类级正则编译缓存:pattern_str → compiled Pattern
|
||||
_compile_cache: dict[str, re.Pattern] = {}
|
||||
|
||||
def _get_compiled(self, pattern_str: str) -> Optional[re.Pattern]:
|
||||
"""获取编译后的正则 Pattern(带缓存)。
|
||||
|
||||
Args:
|
||||
pattern_str: 正则表达式字符串
|
||||
|
||||
Returns:
|
||||
Optional[re.Pattern]: 编译后的 Pattern,编译失败返回 None
|
||||
"""
|
||||
# 缓存命中
|
||||
if pattern_str in self._compile_cache:
|
||||
return self._compile_cache[pattern_str]
|
||||
|
||||
# 缓存清理:超过上限时清空(简单 LRU 策略)
|
||||
if len(self._compile_cache) >= _MAX_CACHE_SIZE:
|
||||
self._compile_cache.clear()
|
||||
|
||||
# 编译正则
|
||||
try:
|
||||
compiled = re.compile(pattern_str, re.IGNORECASE | re.MULTILINE)
|
||||
self._compile_cache[pattern_str] = compiled
|
||||
return compiled
|
||||
except re.error as e:
|
||||
logger.warning("正则编译失败: pattern=%s, error=%s", pattern_str, e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _timeout_handler(signum, frame):
|
||||
"""正则匹配超时信号处理器。"""
|
||||
raise TimeoutError("Regex matching timed out (possible ReDoS)")
|
||||
|
||||
async def match(
|
||||
self,
|
||||
message: str,
|
||||
condition: str,
|
||||
context: Optional[dict] = None,
|
||||
) -> MatchResult:
|
||||
"""检查消息是否匹配正则表达式。
|
||||
|
||||
Args:
|
||||
message: 用户消息文本
|
||||
condition: 正则表达式字符串
|
||||
context: 上下文(本匹配器不需要)
|
||||
|
||||
Returns:
|
||||
MatchResult: 命中时 matched=True, matched_detail=匹配到的文本
|
||||
"""
|
||||
if not message or not condition:
|
||||
return MatchResult(matched=False)
|
||||
|
||||
compiled = self._get_compiled(condition)
|
||||
if compiled is None:
|
||||
return MatchResult(matched=False)
|
||||
|
||||
# 使用 signal 超时保护(仅 Unix 平台可用,Windows 降级为无超时)
|
||||
try:
|
||||
# 设置超时信号
|
||||
old_handler = signal.signal(signal.SIGALRM, self._timeout_handler)
|
||||
signal.setitimer(signal.ITIMER_REAL, _REGEX_TIMEOUT_SEC)
|
||||
try:
|
||||
m = compiled.search(message)
|
||||
finally:
|
||||
signal.setitimer(signal.ITIMER_REAL, 0)
|
||||
signal.signal(signal.SIGALRM, old_handler)
|
||||
except (TimeoutError, OSError):
|
||||
logger.warning("正则匹配超时(ReDoS 保护): pattern=%s", condition)
|
||||
return MatchResult(matched=False)
|
||||
except Exception as e:
|
||||
logger.error("正则匹配异常: pattern=%s, error=%s", condition, e)
|
||||
return MatchResult(matched=False)
|
||||
|
||||
if m:
|
||||
matched_text = m.group(0)
|
||||
return MatchResult(
|
||||
matched=True,
|
||||
matched_detail=f"正则匹配: {matched_text}",
|
||||
match_position=f"位置 {m.start()}-{m.end()}",
|
||||
)
|
||||
|
||||
return MatchResult(matched=False)
|
||||
@@ -510,3 +510,50 @@ class MeetingroomService:
|
||||
if dt:
|
||||
result[field] = dt.isoformat()
|
||||
return result
|
||||
|
||||
# ==========================================================================
|
||||
# 操作指南查询
|
||||
# ==========================================================================
|
||||
|
||||
@staticmethod
|
||||
async def get_guides(
|
||||
db,
|
||||
category: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取操作指南列表。
|
||||
|
||||
从数据库查询启用的操作指南,按 sort_order 排序。
|
||||
可按设备类型过滤。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
category: 设备类型过滤(可选)
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 指南列表
|
||||
"""
|
||||
from sqlalchemy import select as sa_select
|
||||
from app.models.meetingroom_guide import MeetingroomGuide
|
||||
|
||||
stmt = (
|
||||
sa_select(MeetingroomGuide)
|
||||
.where(MeetingroomGuide.is_active == True) # noqa: E712
|
||||
.order_by(MeetingroomGuide.sort_order, MeetingroomGuide.id)
|
||||
)
|
||||
if category:
|
||||
stmt = stmt.where(MeetingroomGuide.category == category)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
guides = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": g.id,
|
||||
"category": g.category,
|
||||
"title": g.title,
|
||||
"brief": g.brief,
|
||||
"detail_url": g.detail_url,
|
||||
"icon": g.icon,
|
||||
}
|
||||
for g in guides
|
||||
]
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
# =============================================================================
|
||||
# 企微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
|
||||
@@ -0,0 +1,787 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 测验题目 AI 生成服务
|
||||
# =============================================================================
|
||||
# 说明:复用 Dify Wingman API(OpenAI-compatible 格式),自动生成:
|
||||
# 1. IT 知识题(7 类别,排队等待期间向员工推送)
|
||||
# 2. 诊断题(基于近期工单模式,帮助员工自检问题)
|
||||
#
|
||||
# 生成策略:
|
||||
# - AI 生成的所有题目 is_active=False,需管理员审批后激活
|
||||
# - 种子数据(seed_quiz.py 调用)is_active=True,bootstrap 例外
|
||||
# - 定时任务每日 3:00 生成新题 + 淘汰陈旧题
|
||||
#
|
||||
# 降级策略:
|
||||
# - Dify 不可用时返回空结果(不抛异常),调用方决定是否重试
|
||||
# - JSON 解析三层降级:直接 parse → ```json 代码块 → [..] 提取
|
||||
# - 单题校验失败跳过,不影响其他题
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.quiz import QuizQuestion, QuizAnswer
|
||||
from app.models.conversation import Conversation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 常量
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# 合法的题目类别
|
||||
VALID_CATEGORIES = {"network", "vpn", "email", "system", "printer", "security", "office"}
|
||||
|
||||
# 合法的难度值
|
||||
VALID_DIFFICULTIES = {"easy", "medium", "hard"}
|
||||
|
||||
# 类别中英文映射(用于 Dify prompt)
|
||||
CATEGORY_MAP: Dict[str, Tuple[str, str]] = {
|
||||
"network": ("网络", "局域网/WiFi/网络配置/连通性/IP分配问题"),
|
||||
"vpn": ("VPN", "VPN连接/零信任aTrust/远程接入/认证失败问题"),
|
||||
"email": ("邮箱", "企业邮箱/Outlook/邮件配置/收发失败问题"),
|
||||
"system": ("系统", "Windows/Mac系统/蓝屏/性能优化/系统更新问题"),
|
||||
"printer": ("打印机", "打印机连接/共享/驱动/扫描/卡纸问题"),
|
||||
"security": ("安全", "火绒杀毒/防火墙/密码策略/钓鱼邮件/数据安全"),
|
||||
"office": ("办公软件", "WPS/Office/Excel/Word/PPT/企微文档协同"),
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Dify Prompt 模板
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_KNOWLEDGE_SYSTEM_PROMPT = (
|
||||
"你是一个企业IT支持测验题目生成器。"
|
||||
"你的任务是生成高质量的多选题,帮助员工在排队等待期间学习IT知识。"
|
||||
"题目应贴近企业办公场景(含VPN/火绒杀毒/企微/打印机等),实用且准确。"
|
||||
"必须以JSON数组格式输出,不要包含任何其他文字。"
|
||||
)
|
||||
|
||||
_KNOWLEDGE_USER_TEMPLATE = (
|
||||
"请生成 {count} 道关于「{category_cn}」类别的IT知识选择题。\n\n"
|
||||
"要求:\n"
|
||||
"1. 每题4个选项(A/B/C/D),只有1个正确答案\n"
|
||||
"2. 难度分布:约40%简单、40%中等、20%困难\n"
|
||||
"3. 解析要简明扼要,说明正确答案的原因\n"
|
||||
"4. 题目不要重复,覆盖该类别的不同知识点\n\n"
|
||||
"类别说明:{category_cn} —— {category_desc}\n\n"
|
||||
"输出格式(严格JSON数组,不要markdown代码块):\n"
|
||||
'[{{"question": "题目文本", '
|
||||
'"options": ["选项A", "选项B", "选项C", "选项D"], '
|
||||
'"correct_index": 0, '
|
||||
'"explanation": "解析说明", '
|
||||
'"difficulty": "medium"}}]\n\n'
|
||||
"注意:correct_index 是正确选项的索引(0-3),difficulty 只能是 easy/medium/hard。"
|
||||
)
|
||||
|
||||
_DIAGNOSTIC_SYSTEM_PROMPT = (
|
||||
"你是一个IT故障诊断题目生成器。"
|
||||
"你的任务是基于近期工单模式,生成诊断性选择题,"
|
||||
"帮助员工在排队期间自检问题,答案将提供给坐席参考。"
|
||||
"必须以JSON数组格式输出,不要包含任何其他文字。"
|
||||
)
|
||||
|
||||
_DIAGNOSTIC_USER_TEMPLATE = (
|
||||
"请基于以下近期工单摘要,生成 {count} 道诊断性选择题。\n\n"
|
||||
"问题类别:{problem_category}\n\n"
|
||||
"近期工单摘要:\n{ticket_context}\n\n"
|
||||
"要求:\n"
|
||||
"1. 题目应帮助员工自检当前问题,如\"你的VPN客户端显示什么错误码?\"\n"
|
||||
"2. 选项应覆盖常见情况,便于坐席快速定位问题\n"
|
||||
"3. 每题4个选项,correct_index 指向最可能的选项\n"
|
||||
"4. difficulty 统一为 medium\n\n"
|
||||
"输出格式(严格JSON数组):\n"
|
||||
'[{{"question": "诊断题目", '
|
||||
'"options": ["选项A", "选项B", "选项C", "选项D"], '
|
||||
'"correct_index": 0, '
|
||||
'"explanation": "此选项通常表示...", '
|
||||
'"difficulty": "medium"}}]'
|
||||
)
|
||||
|
||||
|
||||
class QuizGenerationService:
|
||||
"""测验题目 AI 生成服务。
|
||||
|
||||
复用 Dify Wingman API(OpenAI-compatible 格式),
|
||||
生成知识题、诊断题,并管理陈旧题目的自动淘汰。
|
||||
|
||||
所有 AI 生成的题目默认 is_active=False,需管理员审批。
|
||||
种子数据调用时可通过参数设为 is_active=True。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化服务,读取 Dify API 配置。
|
||||
|
||||
优先使用 Wingman 专用配置;若未配置则 fallback 到主 Dify API。
|
||||
"""
|
||||
self.api_url = settings.dify_wingman_api_url or settings.dify_api_url
|
||||
self.api_key = settings.dify_wingman_api_key or settings.dify_api_key
|
||||
self.timeout = settings.dify_wingman_timeout or settings.dify_timeout
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
# ==================================================================
|
||||
# httpx 客户端管理
|
||||
# ==================================================================
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""获取 httpx 异步客户端(懒加载,复用连接池)。"""
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""关闭 httpx 客户端,释放连接池资源。"""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
# ==================================================================
|
||||
# 公开方法
|
||||
# ==================================================================
|
||||
|
||||
async def generate_knowledge_questions_batch(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
category: str,
|
||||
count: int = 5,
|
||||
is_active: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""批量生成知识题。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
category: 题目类别(network/vpn/email/system/printer/security/office)
|
||||
count: 生成数量(默认 5)
|
||||
is_active: 是否直接激活(种子数据 True,定时任务 False)
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"success_count": int,
|
||||
"failed_count": int,
|
||||
"errors": List[str],
|
||||
"questions": List[Dict], # 生成的题目摘要
|
||||
}
|
||||
"""
|
||||
errors: List[str] = []
|
||||
questions_created: List[Dict[str, Any]] = []
|
||||
|
||||
# 校验类别
|
||||
if category not in VALID_CATEGORIES:
|
||||
return {
|
||||
"success_count": 0,
|
||||
"failed_count": count,
|
||||
"errors": [f"无效类别: {category}"],
|
||||
"questions": [],
|
||||
}
|
||||
|
||||
# 构建并调用 Dify
|
||||
category_cn, category_desc = CATEGORY_MAP[category]
|
||||
user_prompt = _KNOWLEDGE_USER_TEMPLATE.format(
|
||||
count=count,
|
||||
category_cn=category_cn,
|
||||
category_desc=category_desc,
|
||||
)
|
||||
|
||||
raw_response = await self._call_dify(
|
||||
system_prompt=_KNOWLEDGE_SYSTEM_PROMPT,
|
||||
user_prompt=user_prompt,
|
||||
temperature=0.7, # 较高温度保证多样性
|
||||
)
|
||||
|
||||
if raw_response is None:
|
||||
return {
|
||||
"success_count": 0,
|
||||
"failed_count": count,
|
||||
"errors": ["Dify API 调用失败(超时或HTTP错误)"],
|
||||
"questions": [],
|
||||
}
|
||||
|
||||
# 解析 JSON 数组
|
||||
items = self._parse_json_array(raw_response)
|
||||
if items is None:
|
||||
logger.warning(f"知识题 JSON 解析失败 [{category}]: {raw_response[:200]}")
|
||||
return {
|
||||
"success_count": 0,
|
||||
"failed_count": count,
|
||||
"errors": ["AI 返回内容无法解析为 JSON 数组"],
|
||||
"questions": [],
|
||||
}
|
||||
|
||||
# 逐条校验并插入
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for i, item in enumerate(items):
|
||||
is_valid, err_msg, normalized = self._validate_question(
|
||||
item, category, q_type="knowledge"
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
errors.append(f"题[{i}]: {err_msg}")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# 去重检查
|
||||
is_dup = await self._check_duplicate(
|
||||
db, normalized["question"], category
|
||||
)
|
||||
if is_dup:
|
||||
errors.append(f"题[{i}]: 与已有题目重复,跳过")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# 插入数据库
|
||||
question = QuizQuestion(
|
||||
type="knowledge",
|
||||
category=category,
|
||||
difficulty=normalized["difficulty"],
|
||||
question=normalized["question"],
|
||||
options=normalized["options"],
|
||||
correct_index=normalized["correct_index"],
|
||||
explanation=normalized["explanation"],
|
||||
is_active=is_active,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
db.add(question)
|
||||
await db.flush() # 获取 id
|
||||
|
||||
questions_created.append({
|
||||
"id": question.id,
|
||||
"question": question.question[:80],
|
||||
"difficulty": question.difficulty,
|
||||
"is_active": is_active,
|
||||
})
|
||||
success_count += 1
|
||||
|
||||
logger.info(
|
||||
f"知识题生成 [{category}]: 成功 {success_count}, 失败 {failed_count}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success_count": success_count,
|
||||
"failed_count": failed_count,
|
||||
"errors": errors,
|
||||
"questions": questions_created,
|
||||
}
|
||||
|
||||
async def generate_diagnostic_questions_batch(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
problem_category: str,
|
||||
count: int = 3,
|
||||
ticket_summaries: Optional[List[str]] = None,
|
||||
is_active: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""批量生成诊断题(基于近期工单模式)。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
problem_category: 问题类别(如 "vpn_disconnect")
|
||||
count: 生成数量(默认 3)
|
||||
ticket_summaries: 近期工单摘要列表(作为 Dify 上下文)
|
||||
is_active: 是否直接激活
|
||||
|
||||
Returns:
|
||||
Dict: 同 generate_knowledge_questions_batch
|
||||
"""
|
||||
errors: List[str] = []
|
||||
questions_created: List[Dict[str, Any]] = []
|
||||
|
||||
# 构建工单上下文
|
||||
if ticket_summaries:
|
||||
ticket_context = "\n".join(
|
||||
f"- {s}" for s in ticket_summaries[:20]
|
||||
)
|
||||
else:
|
||||
ticket_context = "(暂无近期工单数据,请基于常见问题生成)"
|
||||
|
||||
# 构建并调用 Dify
|
||||
user_prompt = _DIAGNOSTIC_USER_TEMPLATE.format(
|
||||
count=count,
|
||||
problem_category=problem_category,
|
||||
ticket_context=ticket_context,
|
||||
)
|
||||
|
||||
raw_response = await self._call_dify(
|
||||
system_prompt=_DIAGNOSTIC_SYSTEM_PROMPT,
|
||||
user_prompt=user_prompt,
|
||||
temperature=0.5, # 较低温度,诊断题需要准确
|
||||
)
|
||||
|
||||
if raw_response is None:
|
||||
return {
|
||||
"success_count": 0,
|
||||
"failed_count": count,
|
||||
"errors": ["Dify API 调用失败"],
|
||||
"questions": [],
|
||||
}
|
||||
|
||||
# 解析 JSON
|
||||
items = self._parse_json_array(raw_response)
|
||||
if items is None:
|
||||
logger.warning(f"诊断题 JSON 解析失败 [{problem_category}]")
|
||||
return {
|
||||
"success_count": 0,
|
||||
"failed_count": count,
|
||||
"errors": ["AI 返回内容无法解析为 JSON 数组"],
|
||||
"questions": [],
|
||||
}
|
||||
|
||||
# 逐条校验并插入
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
# 推断诊断题的 category(从 problem_category 提取)
|
||||
# problem_category 格式如 "vpn_disconnect" → category="vpn"
|
||||
inferred_category = problem_category.split("_")[0] if problem_category else "system"
|
||||
if inferred_category not in VALID_CATEGORIES:
|
||||
inferred_category = "system"
|
||||
|
||||
for i, item in enumerate(items):
|
||||
is_valid, err_msg, normalized = self._validate_question(
|
||||
item, inferred_category, q_type="diagnostic"
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
errors.append(f"诊断题[{i}]: {err_msg}")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# 去重
|
||||
is_dup = await self._check_duplicate(
|
||||
db, normalized["question"], inferred_category
|
||||
)
|
||||
if is_dup:
|
||||
errors.append(f"诊断题[{i}]: 重复,跳过")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# 插入
|
||||
question = QuizQuestion(
|
||||
type="diagnostic",
|
||||
category=inferred_category,
|
||||
problem_category=problem_category,
|
||||
difficulty=normalized["difficulty"],
|
||||
question=normalized["question"],
|
||||
options=normalized["options"],
|
||||
correct_index=normalized["correct_index"],
|
||||
explanation=normalized["explanation"],
|
||||
is_active=is_active,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
db.add(question)
|
||||
await db.flush()
|
||||
|
||||
questions_created.append({
|
||||
"id": question.id,
|
||||
"question": question.question[:80],
|
||||
"problem_category": problem_category,
|
||||
"is_active": is_active,
|
||||
})
|
||||
success_count += 1
|
||||
|
||||
logger.info(
|
||||
f"诊断题生成 [{problem_category}]: 成功 {success_count}, 失败 {failed_count}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success_count": success_count,
|
||||
"failed_count": failed_count,
|
||||
"errors": errors,
|
||||
"questions": questions_created,
|
||||
}
|
||||
|
||||
async def deactivate_stale_questions(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
threshold: float = 0.8,
|
||||
) -> Dict[str, Any]:
|
||||
"""停用被过多员工答过的陈旧题目。
|
||||
|
||||
当一道题被 >threshold 比例的活跃员工(近30天有答题记录)答过时,
|
||||
自动停用(is_active=True → False)。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
threshold: 答题覆盖率阈值(0-1,默认 0.8)
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"deactivated_count": int,
|
||||
"total_active_employees": int,
|
||||
"deactivated_questions": List[Dict],
|
||||
}
|
||||
"""
|
||||
# 1. 统计近30天活跃员工总数
|
||||
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||
total_result = await db.execute(
|
||||
select(func.count(func.distinct(QuizAnswer.employee_id))).where(
|
||||
QuizAnswer.created_at > thirty_days_ago
|
||||
)
|
||||
)
|
||||
total_employees = total_result.scalar() or 0
|
||||
|
||||
if total_employees == 0:
|
||||
logger.debug("无活跃员工答题记录,跳过陈旧题淘汰")
|
||||
return {
|
||||
"deactivated_count": 0,
|
||||
"total_active_employees": 0,
|
||||
"deactivated_questions": [],
|
||||
}
|
||||
|
||||
# 2. 统计每道题的答题人数
|
||||
answer_stats = await db.execute(
|
||||
select(
|
||||
QuizAnswer.question_id,
|
||||
func.count(func.distinct(QuizAnswer.employee_id)).label("answered_count"),
|
||||
)
|
||||
.where(QuizAnswer.created_at > thirty_days_ago)
|
||||
.group_by(QuizAnswer.question_id)
|
||||
)
|
||||
|
||||
deactivated: List[Dict[str, Any]] = []
|
||||
threshold_count = total_employees * threshold
|
||||
|
||||
for row in answer_stats:
|
||||
if row.answered_count >= threshold_count:
|
||||
# 查询并停用该题
|
||||
result = await db.execute(
|
||||
select(QuizQuestion).where(
|
||||
QuizQuestion.id == row.question_id,
|
||||
QuizQuestion.is_active == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
question = result.scalar_one_or_none()
|
||||
if question:
|
||||
question.is_active = False
|
||||
deactivated.append({
|
||||
"question_id": question.id,
|
||||
"question_text": question.question[:80],
|
||||
"answered_count": row.answered_count,
|
||||
"coverage": round(row.answered_count / total_employees, 2),
|
||||
})
|
||||
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"陈旧题停用: {len(deactivated)} 道 "
|
||||
f"(活跃员工 {total_employees} 人, 阈值 {threshold})"
|
||||
)
|
||||
|
||||
return {
|
||||
"deactivated_count": len(deactivated),
|
||||
"total_active_employees": total_employees,
|
||||
"deactivated_questions": deactivated,
|
||||
}
|
||||
|
||||
# ==================================================================
|
||||
# 内部方法 — Dify 调用
|
||||
# ==================================================================
|
||||
|
||||
async def _call_dify(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
temperature: float = 0.7,
|
||||
) -> Optional[str]:
|
||||
"""调用 Dify API(OpenAI-compatible 格式)。
|
||||
|
||||
Args:
|
||||
system_prompt: 系统提示词
|
||||
user_prompt: 用户提示词
|
||||
temperature: 温度(0-1,越高越有创意)
|
||||
|
||||
Returns:
|
||||
Optional[str]: AI 返回文本,失败返回 None
|
||||
"""
|
||||
payload = {
|
||||
"model": "Chat",
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
logger.info(f"调用 Dify 生成题目: prompt_length={len(user_prompt)}")
|
||||
response = await client.post(self.api_url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# 解析 OpenAI 兼容格式返回
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
logger.warning("Dify API 返回空 choices")
|
||||
return None
|
||||
|
||||
content = choices[0]["message"]["content"]
|
||||
logger.info(f"Dify API 返回: content_length={len(content)}")
|
||||
return content
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Dify API 超时(题目生成)")
|
||||
return None
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Dify API HTTP 错误: status={e.response.status_code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Dify API 调用失败: {e}")
|
||||
return None
|
||||
|
||||
# ==================================================================
|
||||
# 内部方法 — JSON 解析
|
||||
# ==================================================================
|
||||
|
||||
def _parse_json_array(self, content: str) -> Optional[List[Dict[str, Any]]]:
|
||||
"""解析 AI 返回的 JSON 数组。
|
||||
|
||||
三层降级解析:
|
||||
1. 直接 json.loads
|
||||
2. 提取 ```json ... ``` 代码块
|
||||
3. 查找第一个 [ 到最后一个 ]
|
||||
|
||||
Args:
|
||||
content: AI 返回的原始文本
|
||||
|
||||
Returns:
|
||||
Optional[List[Dict]]: 解析成功返回列表,失败返回 None
|
||||
"""
|
||||
if not content:
|
||||
return None
|
||||
|
||||
# 尝试 1:直接解析
|
||||
try:
|
||||
result = json.loads(content)
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试 2:提取 markdown 代码块中的 JSON
|
||||
json_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', content, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
result = json.loads(json_match.group(1).strip())
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试 3:查找第一个 [ 到最后一个 ]
|
||||
start = content.find('[')
|
||||
end = content.rfind(']')
|
||||
if start != -1 and end != -1 and end > start:
|
||||
try:
|
||||
result = json.loads(content[start:end + 1])
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
logger.warning(f"JSON 数组解析失败: {content[:200]}")
|
||||
return None
|
||||
|
||||
# ==================================================================
|
||||
# 内部方法 — 题目校验
|
||||
# ==================================================================
|
||||
|
||||
def _validate_question(
|
||||
self,
|
||||
item: Dict[str, Any],
|
||||
category: str,
|
||||
q_type: str = "knowledge",
|
||||
) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
|
||||
"""校验单个题目字段。
|
||||
|
||||
校验规则:
|
||||
- question: 非空字符串,≥5 字符
|
||||
- options: 列表,恰好 4 个非空字符串
|
||||
- correct_index: 整数,0-3 范围
|
||||
- explanation: 非空字符串
|
||||
- difficulty: 枚举值 easy/medium/hard
|
||||
|
||||
Args:
|
||||
item: 待校验的题目字典
|
||||
category: 题目类别
|
||||
q_type: 题目类型(knowledge/diagnostic)
|
||||
|
||||
Returns:
|
||||
Tuple[is_valid, error_msg, normalized_data]
|
||||
"""
|
||||
# 1. 检查必需字段
|
||||
required_fields = {"question", "options", "correct_index", "explanation", "difficulty"}
|
||||
missing = required_fields - set(item.keys())
|
||||
if missing:
|
||||
return False, f"缺少字段: {missing}", None
|
||||
|
||||
# 2. question 非空字符串
|
||||
question_text = item.get("question")
|
||||
if not isinstance(question_text, str) or len(question_text.strip()) < 5:
|
||||
return False, "question 必须是非空字符串(≥5字符)", None
|
||||
|
||||
# 3. options 恰好 4 个非空字符串
|
||||
options = item.get("options")
|
||||
if not isinstance(options, list) or len(options) != 4:
|
||||
opt_count = len(options) if isinstance(options, list) else "非列表"
|
||||
return False, f"options 必须是4个选项的列表, 实际: {opt_count}", None
|
||||
|
||||
for i, opt in enumerate(options):
|
||||
if not isinstance(opt, str) or not opt.strip():
|
||||
return False, f"option[{i}] 必须是非空字符串", None
|
||||
|
||||
# 4. correct_index 0-3 整数
|
||||
correct_index = item.get("correct_index")
|
||||
if not isinstance(correct_index, int) or correct_index < 0 or correct_index > 3:
|
||||
return False, f"correct_index 必须是0-3的整数, 实际: {correct_index}", None
|
||||
|
||||
# 5. difficulty 枚举
|
||||
difficulty = item.get("difficulty", "medium")
|
||||
if difficulty not in VALID_DIFFICULTIES:
|
||||
return False, f"difficulty 无效: {difficulty}, 应为 {VALID_DIFFICULTIES}", None
|
||||
|
||||
# 6. explanation 非空
|
||||
explanation = item.get("explanation", "")
|
||||
if not isinstance(explanation, str) or not explanation.strip():
|
||||
return False, "explanation 不能为空", None
|
||||
|
||||
# 标准化数据
|
||||
normalized = {
|
||||
"type": q_type,
|
||||
"category": category,
|
||||
"difficulty": difficulty,
|
||||
"question": question_text.strip(),
|
||||
"options": [opt.strip() for opt in options],
|
||||
"correct_index": correct_index,
|
||||
"explanation": explanation.strip(),
|
||||
}
|
||||
return True, "", normalized
|
||||
|
||||
# ==================================================================
|
||||
# 内部方法 — 去重检查
|
||||
# ==================================================================
|
||||
|
||||
async def _check_duplicate(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
question_text: str,
|
||||
category: str,
|
||||
) -> bool:
|
||||
"""检查题目是否重复(前 50 字符 + category 匹配)。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
question_text: 题目文本
|
||||
category: 题目类别
|
||||
|
||||
Returns:
|
||||
bool: True 表示已存在重复题目
|
||||
"""
|
||||
# 取前 50 个字符做模糊匹配
|
||||
prefix = question_text[:50]
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count(QuizQuestion.id)).where(
|
||||
QuizQuestion.category == category,
|
||||
QuizQuestion.question.like(f"{prefix}%"),
|
||||
)
|
||||
)
|
||||
count = result.scalar() or 0
|
||||
return count > 0
|
||||
|
||||
# ==================================================================
|
||||
# 内部方法 — 近期工单摘要
|
||||
# ==================================================================
|
||||
|
||||
async def _get_recent_ticket_summaries(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
days: int = 7,
|
||||
limit: int = 20,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取近期已解决工单的摘要和标签(用于诊断题生成上下文)。
|
||||
|
||||
查询条件:
|
||||
- Conversation.status == 'resolved'
|
||||
- created_at > now - days
|
||||
- 取 last_message_summary 和 tags
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
days: 查询天数(默认 7)
|
||||
limit: 返回数量上限(默认 20)
|
||||
|
||||
Returns:
|
||||
List[Dict]: [{"summary": "...", "tags": [...], "category_hint": "..."}]
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
Conversation.id,
|
||||
Conversation.last_message_summary,
|
||||
Conversation.tags,
|
||||
)
|
||||
.where(
|
||||
Conversation.status == "resolved",
|
||||
Conversation.created_at > cutoff,
|
||||
)
|
||||
.order_by(Conversation.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
summaries: List[Dict[str, Any]] = []
|
||||
for row in result:
|
||||
summary_text = row.last_message_summary or ""
|
||||
# tags 是 Dict 类型,如 {"hand_raise": true, "emotion": "angry"}
|
||||
tags_dict = row.tags if isinstance(row.tags, dict) else {}
|
||||
tag_keys = list(tags_dict.keys())
|
||||
|
||||
# 从 tags 键名推断 category_hint
|
||||
category_hint = ""
|
||||
for tag_key in tag_keys:
|
||||
tag_lower = tag_key.lower()
|
||||
for cat in VALID_CATEGORIES:
|
||||
if cat in tag_lower:
|
||||
category_hint = cat
|
||||
break
|
||||
if category_hint:
|
||||
break
|
||||
|
||||
summaries.append({
|
||||
"summary": summary_text,
|
||||
"tags": tag_keys, # 返回 tag 键名列表
|
||||
"category_hint": category_hint,
|
||||
})
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 单例管理
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_quiz_gen_service: Optional[QuizGenerationService] = None
|
||||
|
||||
|
||||
def get_quiz_generation_service() -> QuizGenerationService:
|
||||
"""获取 QuizGenerationService 单例实例。"""
|
||||
global _quiz_gen_service
|
||||
if _quiz_gen_service is None:
|
||||
_quiz_gen_service = QuizGenerationService()
|
||||
return _quiz_gen_service
|
||||
@@ -0,0 +1,535 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 答题与积分服务
|
||||
# =============================================================================
|
||||
# 说明:排队等待期间的答题系统,包含双模式题目选择、积分更新、插队计算
|
||||
#
|
||||
# 答题双模式(决策 D1):
|
||||
# 模式A — 诊断题(info_locked=false):与当前问题相关的选择题
|
||||
# 答案附加到会话上下文,供坐席接单时参考
|
||||
# 模式B — IT知识题(info_locked=true):纯教育性质,提升IT素养
|
||||
#
|
||||
# 积分规则(决策 D2):
|
||||
# 答对 +10分,答错不扣分,跨会话累积
|
||||
# 5级等级:0-99 IT小白 → 100-299 IT入门 → 300-599 IT达人 → 600-999 IT专家 → 1000+ IT大师
|
||||
#
|
||||
# 插队规则(决策 C4):
|
||||
# queue_priority = min(答题数 // 3, 2) # 每答3题前移1位,上限2
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.quiz import (
|
||||
EmployeePoints,
|
||||
QuizAnswer,
|
||||
QuizQuestion,
|
||||
)
|
||||
from app.utils.response import AppException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 积分常量
|
||||
POINTS_PER_CORRECT = 10
|
||||
MAX_QUEUE_PRIORITY = 2
|
||||
QUIZ_PER_PRIORITY = 3 # 每答3题前移1位
|
||||
|
||||
|
||||
class QuizService:
|
||||
"""答题与积分服务。"""
|
||||
|
||||
# ======================================================================
|
||||
# 获取下一道题
|
||||
# ======================================================================
|
||||
|
||||
async def get_next_question(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
employee_id: str,
|
||||
conversation: Optional[Conversation] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取下一道题(双模式自动选择)。
|
||||
|
||||
模式选择逻辑:
|
||||
- 如果有活跃会话且 info_locked=false → 诊断题(模式A)
|
||||
- 如果有活跃会话且 info_locked=true → IT知识题(模式B)
|
||||
- 如果无活跃会话 → IT知识题(模式B)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
employee_id: 员工ID
|
||||
conversation: 当前会话(可选,排队时传入)
|
||||
|
||||
Returns:
|
||||
Dict: 题目数据 {question_id, type, category, question, options}
|
||||
"""
|
||||
# 判定模式
|
||||
use_diagnostic = (
|
||||
conversation is not None
|
||||
and conversation.status == "queued"
|
||||
and not conversation.info_locked
|
||||
)
|
||||
|
||||
if use_diagnostic:
|
||||
# 模式A:诊断题 — 根据问题类别匹配
|
||||
question = await self._get_diagnostic_question(db, employee_id, conversation)
|
||||
else:
|
||||
# 模式B:IT知识题
|
||||
question = await self._get_knowledge_question(db, employee_id)
|
||||
|
||||
if not question:
|
||||
return {
|
||||
"has_question": False,
|
||||
"message": "暂无更多题目,请稍后再试",
|
||||
}
|
||||
|
||||
return {
|
||||
"has_question": True,
|
||||
"question_id": question.id,
|
||||
"type": question.type,
|
||||
"category": question.category,
|
||||
"difficulty": question.difficulty,
|
||||
"question": question.question,
|
||||
"options": question.options,
|
||||
}
|
||||
|
||||
async def _get_diagnostic_question(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
employee_id: str,
|
||||
conversation: Conversation,
|
||||
) -> Optional[QuizQuestion]:
|
||||
"""获取诊断题(模式A)。
|
||||
|
||||
诊断题按问题类别匹配,排除已答过的题目。
|
||||
如果没有匹配的诊断题,降级为IT知识题。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
employee_id: 员工ID
|
||||
conversation: 当前会话
|
||||
|
||||
Returns:
|
||||
QuizQuestion 或 None
|
||||
"""
|
||||
# 查找已答过的题目ID(避免重复)
|
||||
answered_ids_subquery = (
|
||||
select(QuizAnswer.question_id)
|
||||
.where(
|
||||
QuizAnswer.employee_id == employee_id,
|
||||
QuizAnswer.conversation_id == conversation.id,
|
||||
)
|
||||
)
|
||||
|
||||
# 查找诊断题(按问题类别匹配)
|
||||
stmt = (
|
||||
select(QuizQuestion)
|
||||
.where(
|
||||
QuizQuestion.type == "diagnostic",
|
||||
QuizQuestion.is_active == True, # noqa: E712
|
||||
QuizQuestion.id.notin_(answered_ids_subquery),
|
||||
)
|
||||
.order_by(func.random())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
question = result.scalar_one_or_none()
|
||||
|
||||
if question:
|
||||
return question
|
||||
|
||||
# 降级:如果没有匹配的诊断题,使用IT知识题
|
||||
logger.info("无诊断题可用,降级为IT知识题: employee=%s", employee_id)
|
||||
return await self._get_knowledge_question(db, employee_id)
|
||||
|
||||
async def _get_knowledge_question(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
employee_id: str,
|
||||
) -> Optional[QuizQuestion]:
|
||||
"""获取IT知识题(模式B)。
|
||||
|
||||
排除已答过的题目,随机选取。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
employee_id: 员工ID
|
||||
|
||||
Returns:
|
||||
QuizQuestion 或 None
|
||||
"""
|
||||
# 查找已答过的题目ID(跨会话排除,避免重复)
|
||||
answered_ids_subquery = (
|
||||
select(QuizAnswer.question_id)
|
||||
.where(QuizAnswer.employee_id == employee_id)
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(QuizQuestion)
|
||||
.where(
|
||||
QuizQuestion.type == "knowledge",
|
||||
QuizQuestion.is_active == True, # noqa: E712
|
||||
QuizQuestion.id.notin_(answered_ids_subquery),
|
||||
)
|
||||
.order_by(func.random())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
question = result.scalar_one_or_none()
|
||||
|
||||
if question:
|
||||
return question
|
||||
|
||||
# 如果所有题都答完了,重置(允许重复)
|
||||
logger.info("所有题目已答完,重置题目池: employee=%s", employee_id)
|
||||
stmt_all = (
|
||||
select(QuizQuestion)
|
||||
.where(
|
||||
QuizQuestion.type == "knowledge",
|
||||
QuizQuestion.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(func.random())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt_all)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
# ======================================================================
|
||||
# 提交答案
|
||||
# ======================================================================
|
||||
|
||||
async def submit_answer(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
employee_id: str,
|
||||
question_id: str,
|
||||
selected_index: int,
|
||||
conversation: Optional[Conversation] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""提交答案,返回正误+积分变化+插队效果+下一题。
|
||||
|
||||
处理流程:
|
||||
1. 查询题目,判定正误
|
||||
2. 记录答题(quiz_answers)
|
||||
3. 更新积分账户(employee_points)
|
||||
4. 如果在排队中,更新 queue_priority
|
||||
5. 获取下一道题
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
employee_id: 员工ID
|
||||
question_id: 题目ID
|
||||
selected_index: 员工选择的答案索引
|
||||
conversation: 当前会话(可选)
|
||||
|
||||
Returns:
|
||||
Dict: {is_correct, correct_index, explanation, points_earned,
|
||||
total_points, level, queue_priority_changed, next_question}
|
||||
"""
|
||||
# 1. 查询题目
|
||||
result = await db.execute(
|
||||
select(QuizQuestion).where(QuizQuestion.id == question_id)
|
||||
)
|
||||
question = result.scalar_one_or_none()
|
||||
if not question:
|
||||
raise AppException(code=1004, message="题目不存在")
|
||||
|
||||
# 2. 判定正误
|
||||
is_correct = selected_index == question.correct_index
|
||||
points_earned = POINTS_PER_CORRECT if is_correct else 0
|
||||
|
||||
# 3. 记录答题
|
||||
answer = QuizAnswer(
|
||||
employee_id=employee_id,
|
||||
conversation_id=conversation.id if conversation else None,
|
||||
question_id=question_id,
|
||||
selected_index=selected_index,
|
||||
is_correct=is_correct,
|
||||
points_earned=points_earned,
|
||||
)
|
||||
db.add(answer)
|
||||
|
||||
# 4. 更新积分账户
|
||||
points_info = await self._update_employee_points(
|
||||
db, employee_id, points_earned, is_correct
|
||||
)
|
||||
|
||||
# 5. 如果在排队中,更新 queue_priority
|
||||
queue_priority_changed = False
|
||||
old_priority = 0
|
||||
new_priority = 0
|
||||
|
||||
if conversation and conversation.status == "queued":
|
||||
old_priority = conversation.queue_priority
|
||||
|
||||
# 计算本次会话的答题总数
|
||||
answered_count = await db.scalar(
|
||||
select(func.count(QuizAnswer.id)).where(
|
||||
QuizAnswer.employee_id == employee_id,
|
||||
QuizAnswer.conversation_id == conversation.id,
|
||||
)
|
||||
)
|
||||
answered_count = answered_count or 0
|
||||
|
||||
new_priority = min(answered_count // QUIZ_PER_PRIORITY, MAX_QUEUE_PRIORITY)
|
||||
conversation.queue_priority = new_priority
|
||||
conversation.updated_at = __import__("datetime").datetime.now()
|
||||
|
||||
queue_priority_changed = new_priority > old_priority
|
||||
|
||||
if queue_priority_changed:
|
||||
logger.info(
|
||||
"答题插队: employee=%s, answered=%d, priority %d→%d",
|
||||
employee_id, answered_count, old_priority, new_priority
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 6. 获取下一道题
|
||||
next_question = await self.get_next_question(db, employee_id, conversation)
|
||||
|
||||
# 7. 如果是诊断题且答对,将答案文本附加到会话上下文
|
||||
if (question.type == "diagnostic" and conversation and is_correct
|
||||
and question.options and 0 <= selected_index < len(question.options)):
|
||||
await self._append_to_conversation_context(
|
||||
db, conversation, question.question, question.options[selected_index]
|
||||
)
|
||||
|
||||
return {
|
||||
"is_correct": is_correct,
|
||||
"correct_index": question.correct_index,
|
||||
"explanation": question.explanation,
|
||||
"points_earned": points_earned,
|
||||
"total_points": points_info["total_points"],
|
||||
"level": points_info["level"],
|
||||
"answered_count": points_info["answered_count"],
|
||||
"correct_count": points_info["correct_count"],
|
||||
"queue_priority_changed": queue_priority_changed,
|
||||
"old_priority": old_priority,
|
||||
"new_priority": new_priority,
|
||||
"next_question": next_question,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# 积分管理
|
||||
# ======================================================================
|
||||
|
||||
async def _update_employee_points(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
employee_id: str,
|
||||
points_earned: int,
|
||||
is_correct: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""更新员工积分账户。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
employee_id: 员工ID
|
||||
points_earned: 本次获得积分
|
||||
is_correct: 是否答对
|
||||
|
||||
Returns:
|
||||
Dict: 更新后的积分信息
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(EmployeePoints).where(EmployeePoints.employee_id == employee_id)
|
||||
)
|
||||
points = result.scalar_one_or_none()
|
||||
|
||||
if points:
|
||||
# 更新现有记录
|
||||
points.total_points += points_earned
|
||||
points.answered_count += 1
|
||||
if is_correct:
|
||||
points.correct_count += 1
|
||||
points.level = EmployeePoints.calculate_level(points.total_points)
|
||||
else:
|
||||
# 首次答题,创建记录
|
||||
points = EmployeePoints(
|
||||
employee_id=employee_id,
|
||||
total_points=points_earned,
|
||||
answered_count=1,
|
||||
correct_count=1 if is_correct else 0,
|
||||
level=EmployeePoints.calculate_level(points_earned),
|
||||
)
|
||||
db.add(points)
|
||||
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"total_points": points.total_points,
|
||||
"level": points.level,
|
||||
"answered_count": points.answered_count,
|
||||
"correct_count": points.correct_count,
|
||||
}
|
||||
|
||||
async def get_employee_points(
|
||||
self, db: AsyncSession, employee_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""获取员工积分信息。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
employee_id: 员工ID
|
||||
|
||||
Returns:
|
||||
Dict: 积分信息
|
||||
"""
|
||||
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,
|
||||
"accuracy": round(points.correct_count / max(points.answered_count, 1) * 100, 1),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"total_points": 0,
|
||||
"level": "IT小白",
|
||||
"answered_count": 0,
|
||||
"correct_count": 0,
|
||||
"accuracy": 0,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# 答题历史
|
||||
# ======================================================================
|
||||
|
||||
async def get_quiz_history(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
employee_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取答题历史记录。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
employee_id: 员工ID
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
|
||||
Returns:
|
||||
Dict: {total, items, points}
|
||||
"""
|
||||
# 统计总数
|
||||
total = await db.scalar(
|
||||
select(func.count(QuizAnswer.id)).where(
|
||||
QuizAnswer.employee_id == employee_id
|
||||
)
|
||||
)
|
||||
total = total or 0
|
||||
|
||||
# 分页查询
|
||||
offset = (page - 1) * page_size
|
||||
stmt = (
|
||||
select(QuizAnswer, QuizQuestion)
|
||||
.join(QuizQuestion, QuizAnswer.question_id == QuizQuestion.id)
|
||||
.where(QuizAnswer.employee_id == employee_id)
|
||||
.order_by(QuizAnswer.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for answer, question in rows:
|
||||
items.append({
|
||||
"answer_id": answer.id,
|
||||
"question_text": question.question,
|
||||
"options": question.options,
|
||||
"selected_index": answer.selected_index,
|
||||
"correct_index": question.correct_index,
|
||||
"is_correct": answer.is_correct,
|
||||
"points_earned": answer.points_earned,
|
||||
"category": question.category,
|
||||
"type": question.type,
|
||||
"created_at": answer.created_at.isoformat() if answer.created_at else None,
|
||||
})
|
||||
|
||||
# 积分信息
|
||||
points_info = await self.get_employee_points(db, employee_id)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": items,
|
||||
"points": points_info,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# 诊断题答案附加到会话上下文
|
||||
# ======================================================================
|
||||
|
||||
async def _append_to_conversation_context(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
conversation: Conversation,
|
||||
question_text: str,
|
||||
answer_text: str,
|
||||
) -> None:
|
||||
"""将诊断题答案附加到会话上下文(供坐席接单时参考)。
|
||||
|
||||
这相当于员工在排队期间做了自助信息补充。
|
||||
答案通过 WS 推送给坐席端,坐席接单时能看到。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
conversation: 当前会话
|
||||
question_text: 题目文本
|
||||
answer_text: 员工选择的答案文本
|
||||
"""
|
||||
try:
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
|
||||
# 通过WS推送给坐席端
|
||||
ws_data = {
|
||||
"type": "quiz_context_collected",
|
||||
"data": {
|
||||
"conversation_id": conversation.id,
|
||||
"employee_id": conversation.employee_id,
|
||||
"question": question_text,
|
||||
"answer": answer_text,
|
||||
"timestamp": __import__("datetime").datetime.now().isoformat(),
|
||||
},
|
||||
}
|
||||
|
||||
# 推送给坐席端(如果有分配的坐席)
|
||||
if conversation.assigned_agent_id:
|
||||
await ws_manager.send_to_agent(conversation.assigned_agent_id, ws_data)
|
||||
|
||||
logger.info(
|
||||
"诊断题答案附加到上下文: conv=%s, Q=%s, A=%s",
|
||||
conversation.id, question_text[:50], answer_text[:50]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("WS推送诊断题答案失败: %s", e)
|
||||
|
||||
|
||||
# 单例
|
||||
_quiz_service: Optional[QuizService] = None
|
||||
|
||||
|
||||
def get_quiz_service() -> QuizService:
|
||||
"""获取 QuizService 单例。"""
|
||||
global _quiz_service
|
||||
if _quiz_service is None:
|
||||
_quiz_service = QuizService()
|
||||
return _quiz_service
|
||||
@@ -0,0 +1,220 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 会议室报修业务服务
|
||||
# =============================================================================
|
||||
# 说明:处理终端报修的完整流程:
|
||||
# 1. 创建报修记录到 meetingroom_repair 表
|
||||
# 2. 创建IT工单会话(Conversation),状态为 queued(排队等待坐席)
|
||||
# 3. 创建初始消息(故障描述),sender_type=system
|
||||
# 4. 通过企微消息通知IT管理员
|
||||
# 5. 通过WS推送报修通知到坐席端
|
||||
#
|
||||
# 设计决策:
|
||||
# - 报修自动创建工单会话,复用现有IT服务台流程
|
||||
# - 报修人未登录时使用"匿名"身份,但仍创建工单
|
||||
# - 通知管理员和坐席同步进行,不影响主流程
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.models.meetingroom_repair import MeetingroomRepair
|
||||
from app.services.wecom_service import WecomService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 设备类型中文映射
|
||||
DEVICE_TYPE_LABELS = {
|
||||
"projector": "投影仪",
|
||||
"video_conf": "视频会议设备",
|
||||
"aircon": "空调",
|
||||
"desk_chair": "桌椅",
|
||||
"network": "网络",
|
||||
"other": "其他设备",
|
||||
}
|
||||
|
||||
|
||||
class RepairService:
|
||||
"""会议室报修业务服务。
|
||||
|
||||
处理终端报修的完整流程:记录 + 创建工单 + 通知。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
wecom_service: Optional[WecomService] = None,
|
||||
) -> None:
|
||||
"""初始化报修服务。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
wecom_service: 企微服务实例(用于发送通知消息)
|
||||
"""
|
||||
self.db = db
|
||||
self.wecom = wecom_service
|
||||
|
||||
async def submit_repair(
|
||||
self,
|
||||
terminal_sn: str,
|
||||
meetingroom_id: int,
|
||||
meetingroom_name: str,
|
||||
device_type: str,
|
||||
fault_description: str,
|
||||
reporter_name: Optional[str] = None,
|
||||
reporter_userid: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""提交报修,创建工单会话并通知。
|
||||
|
||||
Args:
|
||||
terminal_sn: 终端序列号
|
||||
meetingroom_id: 企微会议室ID
|
||||
meetingroom_name: 会议室名称
|
||||
device_type: 故障设备类型
|
||||
fault_description: 故障描述
|
||||
reporter_name: 报修人姓名(为空则"匿名")
|
||||
reporter_userid: 报修人企微userid(为空则空字符串)
|
||||
|
||||
Returns:
|
||||
dict: {"repair_id": int, "conversation_id": str}
|
||||
"""
|
||||
# 报修人信息处理
|
||||
name = reporter_name or "匿名(终端报修)"
|
||||
userid = reporter_userid or ""
|
||||
|
||||
# 设备类型中文名
|
||||
device_label = DEVICE_TYPE_LABELS.get(device_type, device_type)
|
||||
|
||||
# 构造工单主题
|
||||
subject = f"会议室报修:{meetingroom_name} - {device_label}"
|
||||
|
||||
# 1. 创建IT工单会话
|
||||
conversation_id = str(uuid.uuid4())
|
||||
conversation = Conversation(
|
||||
id=conversation_id,
|
||||
corp_id=settings.wecom_corp_id,
|
||||
employee_id=userid or f"terminal:{terminal_sn}",
|
||||
employee_name=name,
|
||||
department="",
|
||||
position="",
|
||||
level="",
|
||||
status="queued",
|
||||
urgency_score=3, # 报修默认中等紧急
|
||||
tags={"source": "terminal_repair", "terminal_sn": terminal_sn, "meetingroom_id": meetingroom_id},
|
||||
last_message_at=datetime.now(),
|
||||
last_message_summary=fault_description[:256],
|
||||
)
|
||||
self.db.add(conversation)
|
||||
|
||||
# 2. 创建初始系统消息(故障描述)
|
||||
message = Message(
|
||||
conversation_id=conversation_id,
|
||||
sender_type="system",
|
||||
sender_id="system",
|
||||
sender_name="系统",
|
||||
content=f"【终端报修】\n会议室: {meetingroom_name}\n设备类型: {device_label}\n故障描述: {fault_description}\n报修人: {name}\n终端SN: {terminal_sn}",
|
||||
msg_type="text",
|
||||
status="sent",
|
||||
)
|
||||
self.db.add(message)
|
||||
|
||||
# 3. 创建报修记录
|
||||
repair = MeetingroomRepair(
|
||||
terminal_sn=terminal_sn,
|
||||
meetingroom_id=meetingroom_id,
|
||||
meetingroom_name=meetingroom_name,
|
||||
device_type=device_type,
|
||||
fault_description=fault_description,
|
||||
reporter_name=name,
|
||||
reporter_userid=userid,
|
||||
conversation_id=conversation_id,
|
||||
status=0,
|
||||
)
|
||||
self.db.add(repair)
|
||||
|
||||
# 提交事务
|
||||
await self.db.commit()
|
||||
await self.db.refresh(repair)
|
||||
|
||||
# 4. 异步通知IT管理员(不阻塞主流程)
|
||||
try:
|
||||
await self._notify_admins(subject, fault_description, meetingroom_name, name)
|
||||
except Exception as e:
|
||||
logger.warning(f"报修通知管理员失败(不影响主流程): {e}")
|
||||
|
||||
# 5. 通过WS推送报修通知到坐席端
|
||||
try:
|
||||
await self._notify_agents(subject, conversation_id, meetingroom_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"报修WS通知坐席失败(不影响主流程): {e}")
|
||||
|
||||
logger.info(
|
||||
f"报修创建成功: repair_id={repair.id}, conversation_id={conversation_id}, "
|
||||
f"room={meetingroom_name}, device={device_type}"
|
||||
)
|
||||
|
||||
return {
|
||||
"repair_id": repair.id,
|
||||
"conversation_id": conversation_id,
|
||||
}
|
||||
|
||||
async def _notify_admins(
|
||||
self,
|
||||
subject: str,
|
||||
description: str,
|
||||
room_name: str,
|
||||
reporter: str,
|
||||
) -> None:
|
||||
"""通过企微消息通知IT管理员。"""
|
||||
if not self.wecom:
|
||||
return
|
||||
|
||||
# 通知内容
|
||||
content = (
|
||||
f"【会议室报修通知】\n"
|
||||
f"会议室: {room_name}\n"
|
||||
f"报修人: {reporter}\n"
|
||||
f"问题: {description}\n"
|
||||
f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n"
|
||||
f"请及时处理。"
|
||||
)
|
||||
|
||||
# 从配置获取管理员userid列表
|
||||
admin_userids = []
|
||||
if settings.wecom_agent_userids:
|
||||
admin_userids = [uid.strip() for uid in settings.wecom_agent_userids.split(",") if uid.strip()]
|
||||
|
||||
if admin_userids:
|
||||
for uid in admin_userids:
|
||||
try:
|
||||
await self.wecom.send_text_message(uid, content)
|
||||
except Exception as e:
|
||||
logger.warning(f"发送报修通知给 {uid} 失败: {e}")
|
||||
|
||||
async def _notify_agents(
|
||||
self,
|
||||
subject: str,
|
||||
conversation_id: str,
|
||||
room_name: str,
|
||||
) -> None:
|
||||
"""通过WS推送报修通知到坐席端。"""
|
||||
from app.services.ws_manager import manager
|
||||
|
||||
# 构造推送消息
|
||||
message_data = {
|
||||
"type": "new_conversation",
|
||||
"conversation_id": conversation_id,
|
||||
"source": "terminal_repair",
|
||||
"subject": subject,
|
||||
"room_name": room_name,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# 广播到所有在线坐席
|
||||
await manager.broadcast(message_data)
|
||||
@@ -230,7 +230,7 @@ class SessionService:
|
||||
try:
|
||||
await self.wecom_service.send_text_message(
|
||||
conversation.employee_id,
|
||||
"人摇来了!IT坐席为您服务",
|
||||
"坐席正在查看您的信息,请等待处理回复!",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"发送接入通知失败(不阻塞流程): {e}")
|
||||
@@ -321,6 +321,119 @@ class SessionService:
|
||||
|
||||
return agent
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 三段排序:从排队队列中选取下一个要分配的会话(P0新增)
|
||||
# --------------------------------------------------------------------------
|
||||
# 决策 C1/C2:VIP → 已梳理(info_locked=true) → 待梳理(info_locked=false)
|
||||
# 段内排序:queue_priority DESC → urgency_score DESC → created_at ASC
|
||||
# --------------------------------------------------------------------------
|
||||
async def pick_next_queued_conversation(self) -> Optional[Conversation]:
|
||||
"""从排队队列中按三段排序选取下一个要分配的会话。
|
||||
|
||||
排序规则(决策 C1/C2):
|
||||
1. VIP 段(is_vip=true)最优先
|
||||
2. 已梳理段(info_locked=true)次之
|
||||
3. 待梳理段(info_locked=false)最后
|
||||
段内排序:queue_priority DESC → urgency_score DESC → created_at ASC
|
||||
|
||||
使用 SQLAlchemy case() 表达式实现段位排序(避免多次查询)。
|
||||
|
||||
Returns:
|
||||
Conversation: 排序最高的排队会话;None表示队列为空
|
||||
"""
|
||||
from sqlalchemy import case, desc
|
||||
|
||||
# 三段排序权重:VIP=0, 已梳理=1, 待梳理=2(值越小越优先)
|
||||
segment_order = case(
|
||||
(Conversation.is_vip == True, 0),
|
||||
(Conversation.info_locked == True, 1),
|
||||
else_=2,
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(Conversation)
|
||||
.where(Conversation.status == "queued")
|
||||
.order_by(
|
||||
segment_order.asc(), # 段位排序:VIP → 已梳理 → 待梳理
|
||||
desc(Conversation.queue_priority), # 段内:答题插队优先级
|
||||
desc(Conversation.urgency_score), # 段内:紧急度
|
||||
Conversation.created_at.asc(), # 段内:先来先服务
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 自动分配队列中的下一个会话(坐席空闲时触发)
|
||||
# --------------------------------------------------------------------------
|
||||
async def auto_assign_from_queue(self) -> Optional[Conversation]:
|
||||
"""从排队队列中按三段排序选取会话并分配给空闲坐席。
|
||||
|
||||
流程:
|
||||
1. 使用 pick_next_queued_conversation 获取排序最高的排队会话
|
||||
2. 查找空闲坐席(在线且未满负荷,按负载升序)
|
||||
3. 分配坐席,更新会话状态为 serving
|
||||
4. WS 广播通知坐席和员工
|
||||
|
||||
Returns:
|
||||
Conversation: 分配成功的会话;None表示无排队会话或无空闲坐席
|
||||
"""
|
||||
# 1. 获取三段排序的下一个排队会话
|
||||
conversation = await self.pick_next_queued_conversation()
|
||||
if not conversation:
|
||||
return None
|
||||
|
||||
# 2. 查找空闲坐席
|
||||
stmt = select(Agent).where(
|
||||
Agent.status == "online",
|
||||
Agent.current_load < Agent.max_load
|
||||
).order_by(Agent.current_load.asc()).limit(1)
|
||||
result = await self.db.execute(stmt)
|
||||
agent = result.scalars().first()
|
||||
|
||||
if not agent:
|
||||
logger.info(f"有排队会话但无空闲坐席: conv_id={conversation.id}")
|
||||
return None
|
||||
|
||||
# 3. 分配
|
||||
conversation.status = "serving"
|
||||
conversation.assigned_agent_id = agent.user_id
|
||||
conversation.updated_at = datetime.now()
|
||||
self.db.add(conversation)
|
||||
|
||||
agent.current_load += 1
|
||||
self.db.add(agent)
|
||||
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"队列自动分配(三段排序): conv_id={conversation.id}, "
|
||||
f"agent={agent.user_id}, "
|
||||
f"vip={conversation.is_vip}, info_locked={conversation.info_locked}, "
|
||||
f"queue_priority={conversation.queue_priority}"
|
||||
)
|
||||
|
||||
# 4. WS 广播
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "conversation_assigned",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_id": agent.user_id,
|
||||
"employee_id": conversation.employee_id,
|
||||
"employee_name": conversation.employee_name,
|
||||
"is_vip": conversation.is_vip,
|
||||
"info_locked": conversation.info_locked,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"WS广播分配事件失败: {e}")
|
||||
|
||||
return conversation
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 结单
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -37,7 +37,7 @@ WECOM_GETAPPROVALINFO_URL = "https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovali
|
||||
APPROVAL_DETAIL_CONCURRENCY = 10
|
||||
|
||||
# 查询审批数据的时间范围(最近 N 天)
|
||||
APPROVAL_QUERY_DAYS = 7
|
||||
APPROVAL_QUERY_DAYS = 30
|
||||
|
||||
# 企微 getapprovaldata 单页查询上限
|
||||
APPROVAL_PAGE_SIZE = 100
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 分诊业务逻辑服务
|
||||
# =============================================================================
|
||||
# 说明:分诊交互的核心业务逻辑,包括:
|
||||
# 1. start_triage — 发起分诊(含5秒超时自动转人工)
|
||||
# 2. submit_step — 提交步骤选择
|
||||
# 3. skip_step — 跳过步骤
|
||||
# 4. complete_triage — 分诊完成生成最终回复
|
||||
# 5. transfer_to_human — 转人工
|
||||
# 6. determine_urgency — 紧急度判断(关键词规则)
|
||||
# 坐席端:list_pending / get_detail / route_session / get_history / export / exclude_options
|
||||
# =============================================================================
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openpyxl import Workbook
|
||||
from sqlalchemy import func, select, and_, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.triage_session import TriageSession
|
||||
from app.services.dify_triage_service import get_dify_triage_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# 紧急度判断关键词规则(决策 #3)
|
||||
# =============================================================================
|
||||
# 扩展:同时用于"人工"按钮紧急直通判定
|
||||
URGENCY_HIGH_KEYWORDS: List[str] = [
|
||||
"紧急", "马上", "宕机", "无法工作", "崩溃", "死机", "蓝屏",
|
||||
# 新增:紧急直通人工关键词(电脑无法启动、网络无法连接等)
|
||||
"电脑无法启动", "网络无法连接", "多人不能上网", "无法上网",
|
||||
"开不了机", "连不上网", "全部断网",
|
||||
]
|
||||
URGENCY_MEDIUM_KEYWORDS: List[str] = [
|
||||
"报错", "失败", "连不上", "打不开", "不能用",
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# 信息锁定判定(决策 B2/B3)
|
||||
# =============================================================================
|
||||
# 有效回答:不在以下集合中的回答。无效回答包括"人工""不知道"等。
|
||||
INVALID_ANSWERS = frozenset({
|
||||
"人工", "不知道", "不确定", "转人工", "跳过", "",
|
||||
})
|
||||
|
||||
# 有效回答占比阈值:≥70% 判定为信息锁定
|
||||
INFO_LOCKED_THRESHOLD = 0.70
|
||||
|
||||
# =============================================================================
|
||||
# 关闭关键词识别(决策 G2:AI解决确认支持关键词识别)
|
||||
# =============================================================================
|
||||
RESOLVE_KEYWORDS: List[str] = [
|
||||
"解决了", "谢谢", "没问题了", "可以了", "好了",
|
||||
"弄好了", "搞定了", "不需要了", "撤销", "关闭",
|
||||
]
|
||||
|
||||
|
||||
class TriageService:
|
||||
"""分诊业务逻辑服务。
|
||||
|
||||
管理 AI 分诊的完整生命周期,从发起分诊到最终路由。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化分诊服务。"""
|
||||
self.dify_service = get_dify_triage_service()
|
||||
|
||||
# ==========================================================================
|
||||
# 紧急度判断(关键词规则)
|
||||
# ==========================================================================
|
||||
|
||||
@staticmethod
|
||||
def determine_urgency(question: str, confidence: Optional[float] = None) -> str:
|
||||
"""根据关键词 + 置信度判断紧急度。
|
||||
|
||||
规则:
|
||||
1. 含高级关键词(紧急/宕机/崩溃等)→ high
|
||||
2. 置信度 < 0.5 → high(低置信也视为紧急)
|
||||
3. 含中级关键词(报错/失败/连不上等)→ medium
|
||||
4. 其余 → low
|
||||
|
||||
Args:
|
||||
question: 员工问题文本
|
||||
confidence: AI 置信度(可选)
|
||||
|
||||
Returns:
|
||||
str: 紧急度(high/medium/low)
|
||||
"""
|
||||
if any(kw in question for kw in URGENCY_HIGH_KEYWORDS):
|
||||
return "high"
|
||||
if confidence is not None and confidence < 0.5:
|
||||
return "high"
|
||||
if any(kw in question for kw in URGENCY_MEDIUM_KEYWORDS):
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
# ==========================================================================
|
||||
# H5 端方法
|
||||
# ==========================================================================
|
||||
|
||||
async def start_triage(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
conversation_id: str,
|
||||
question: str,
|
||||
user_id: str,
|
||||
user_name: str = "",
|
||||
user_dept: str = "",
|
||||
device_info: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""发起分诊(含5秒超时自动转人工)。
|
||||
|
||||
流程:
|
||||
1. 创建 triage_sessions 记录(status=triaging)
|
||||
2. 调用 Dify 分诊应用(5秒超时)
|
||||
3. 超时则自动转人工(status=timeout)
|
||||
4. 成功则更新分诊步骤和AI分析结果
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
conversation_id: 会话ID
|
||||
question: 员工问题文本
|
||||
user_id: 员工ID
|
||||
user_name: 员工姓名
|
||||
user_dept: 员工部门
|
||||
device_info: 设备信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 分诊结果或超时信息
|
||||
"""
|
||||
# 1. 创建分诊会话记录
|
||||
session = TriageSession(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
user_dept=user_dept,
|
||||
device_info=device_info,
|
||||
request_title=question[:200] if question else "",
|
||||
request_content=question,
|
||||
source="wecom_h5",
|
||||
status="triaging",
|
||||
urgency="medium",
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
triage_id = session.id
|
||||
logger.info("分诊会话已创建: triage_id=%s, user=%s", triage_id, user_id)
|
||||
|
||||
# 2. 调用 Dify 分诊(5秒超时)
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
self.dify_service.analyze(question, context=[], step_index=0),
|
||||
timeout=float(settings.dify_triage_timeout),
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# 超时自动转人工
|
||||
logger.warning("分诊超时(>%s秒),自动转人工: triage_id=%s",
|
||||
settings.dify_triage_timeout, triage_id)
|
||||
await self._transfer_to_human_on_timeout(db, triage_id)
|
||||
return {
|
||||
"status": "timeout",
|
||||
"message": "分诊超时,已自动转人工",
|
||||
"triage_id": triage_id,
|
||||
}
|
||||
except RuntimeError as e:
|
||||
# Dify 不可用,降级转人工
|
||||
logger.error("Dify 分诊不可用,降级转人工: triage_id=%s, error=%s",
|
||||
triage_id, e)
|
||||
await self._transfer_to_human_on_timeout(db, triage_id)
|
||||
return {
|
||||
"status": "timeout",
|
||||
"message": "分诊服务暂时不可用,已自动转人工",
|
||||
"triage_id": triage_id,
|
||||
}
|
||||
|
||||
# 3. 更新分诊会话
|
||||
confidence = result.get("confidence")
|
||||
urgency = self.determine_urgency(question, confidence)
|
||||
|
||||
# 覆盖 Dify 返回的紧急度(以关键词规则为准)
|
||||
if result.get("urgency") and not any(
|
||||
kw in question for kw in URGENCY_HIGH_KEYWORDS + URGENCY_MEDIUM_KEYWORDS
|
||||
):
|
||||
urgency = result.get("urgency", "medium")
|
||||
|
||||
session.triage_steps = result.get("triage_steps", [])
|
||||
session.confidence = confidence
|
||||
session.urgency = urgency
|
||||
session.suggested_route = result.get("suggested_route")
|
||||
session.problem_type = result.get("problem_type")
|
||||
session.problem_category = result.get("problem_category")
|
||||
session.matched_knowledge = result.get("matched_knowledge")
|
||||
session.match_score = result.get("match_score")
|
||||
session.context_tags = result.get("context_tags", [])
|
||||
session.status = "triaging"
|
||||
session.updated_at = datetime.now()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
logger.info(
|
||||
"分诊分析完成: triage_id=%s, problem_type=%s, urgency=%s, steps=%d",
|
||||
triage_id,
|
||||
result.get("problem_type"),
|
||||
urgency,
|
||||
len(result.get("triage_steps", [])),
|
||||
)
|
||||
|
||||
return {
|
||||
"triage_id": triage_id,
|
||||
"steps": result.get("triage_steps", []),
|
||||
"total": len(result.get("triage_steps", [])),
|
||||
"confidence": confidence,
|
||||
"urgency": urgency,
|
||||
"suggested_route": result.get("suggested_route"),
|
||||
}
|
||||
|
||||
async def submit_step(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
triage_id: str,
|
||||
step_index: int,
|
||||
selected_label: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""提交步骤选择(含信息锁定判定)。
|
||||
|
||||
记录用户选择的上下文,并根据选择动态调整后续步骤。
|
||||
当所有步骤完成时,判定信息是否锁定:
|
||||
- 有效回答占比 ≥ 70% → info_locked = true → WS推送 queue_segment_changed
|
||||
- 有效回答占比 < 70% → info_locked = false(员工需继续回答诊断题补充)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
triage_id: 信息梳理会话ID
|
||||
step_index: 当前步骤序号
|
||||
selected_label: 选择的选项标签
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 下一步骤数据、已收集上下文、信息锁定状态
|
||||
"""
|
||||
session = await self._get_session(db, triage_id)
|
||||
if not session:
|
||||
return {"error": "信息梳理会话不存在"}
|
||||
|
||||
# 记录已收集的上下文
|
||||
collected = list(session.collected_context or [])
|
||||
if selected_label and selected_label not in collected:
|
||||
collected.append(selected_label)
|
||||
session.collected_context = collected
|
||||
session.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
# 获取下一步骤(从预生成的步骤中取)
|
||||
steps = session.triage_steps or []
|
||||
next_index = step_index + 1
|
||||
all_steps_done = next_index >= len(steps)
|
||||
|
||||
if not all_steps_done:
|
||||
next_step = steps[next_index] if next_index < len(steps) else None
|
||||
else:
|
||||
next_step = None
|
||||
|
||||
# ==================================================================
|
||||
# 信息锁定判定:所有步骤完成时触发
|
||||
# ==================================================================
|
||||
info_locked = False
|
||||
if all_steps_done:
|
||||
info_locked = self._check_info_locked(collected)
|
||||
if info_locked:
|
||||
# 更新关联的 Conversation 表
|
||||
await self._update_conversation_info_locked(
|
||||
db, session.conversation_id, locked=True
|
||||
)
|
||||
logger.info(
|
||||
"信息锁定成功: triage_id=%s, conversation_id=%s, "
|
||||
"有效回答=%d/%d (%.0f%%)",
|
||||
triage_id, session.conversation_id,
|
||||
sum(1 for a in collected if a.strip() not in INVALID_ANSWERS),
|
||||
len(collected),
|
||||
(sum(1 for a in collected if a.strip() not in INVALID_ANSWERS) / max(len(collected), 1)) * 100
|
||||
)
|
||||
|
||||
# 标记信息梳理状态为完成
|
||||
session.status = "routed"
|
||||
session.route_action = "info_locked"
|
||||
session.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
# WS推送:队列段位变更
|
||||
await self._push_queue_segment_changed(
|
||||
session.user_id, session.conversation_id,
|
||||
"incomplete", "completed",
|
||||
"信息梳理完成,已进入优先队列"
|
||||
)
|
||||
|
||||
return {
|
||||
"next_step": next_step,
|
||||
"collected_context": collected,
|
||||
"info_locked": info_locked,
|
||||
"all_steps_done": all_steps_done,
|
||||
}
|
||||
|
||||
async def skip_step(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
triage_id: str,
|
||||
step_index: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""跳过步骤。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
triage_id: 分诊会话ID
|
||||
step_index: 要跳过的步骤序号
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 下一步骤数据
|
||||
"""
|
||||
session = await self._get_session(db, triage_id)
|
||||
if not session:
|
||||
return {"error": "分诊会话不存在"}
|
||||
|
||||
steps = session.triage_steps or []
|
||||
next_index = step_index + 1
|
||||
|
||||
session.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
if next_index < len(steps):
|
||||
next_step = steps[next_index]
|
||||
else:
|
||||
next_step = None
|
||||
|
||||
return {"next_step": next_step}
|
||||
|
||||
async def complete_triage(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
triage_id: str,
|
||||
context: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""分诊完成,生成最终回复。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
triage_id: 分诊会话ID
|
||||
context: 已收集的上下文列表
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: AI 回复和置信度
|
||||
"""
|
||||
session = await self._get_session(db, triage_id)
|
||||
if not session:
|
||||
return {"error": "分诊会话不存在"}
|
||||
|
||||
# 更新收集的上下文
|
||||
session.collected_context = context
|
||||
session.status = "routed"
|
||||
session.route_action = "ai_self"
|
||||
session.updated_at = datetime.now()
|
||||
|
||||
try:
|
||||
# 调用 Dify 生成最终回复
|
||||
result = await self.dify_service.generate_reply(
|
||||
session.request_content, context
|
||||
)
|
||||
reply = result.get("reply", "根据您提供的信息,建议联系IT服务台获取进一步帮助。")
|
||||
confidence = result.get("confidence", 0.0)
|
||||
except RuntimeError as e:
|
||||
logger.warning("Dify 生成回复失败,使用降级回复: %s", e)
|
||||
reply = "根据您提供的信息,建议联系IT服务台获取进一步帮助。"
|
||||
confidence = 0.0
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"reply": reply, "confidence": confidence}
|
||||
|
||||
async def transfer_to_human(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
triage_id: str,
|
||||
context: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""转人工。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
triage_id: 分诊会话ID
|
||||
context: 已收集的上下文列表
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 转人工结果
|
||||
"""
|
||||
session = await self._get_session(db, triage_id)
|
||||
if not session:
|
||||
return {"error": "分诊会话不存在"}
|
||||
|
||||
session.collected_context = context
|
||||
session.status = "routed"
|
||||
session.route_action = "human"
|
||||
session.updated_at = datetime.now()
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"conversation_id": session.conversation_id,
|
||||
"status": "waiting_agent",
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# 坐席端方法
|
||||
# ==========================================================================
|
||||
|
||||
async def list_pending(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
urgency: Optional[str] = None,
|
||||
problem_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取待分诊列表(按紧急度排序)。
|
||||
|
||||
排序规则:high > medium > low,同紧急度按创建时间倒序。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
urgency: 紧急度筛选
|
||||
problem_type: 问题类型筛选
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: {total, items}
|
||||
"""
|
||||
# 构建查询条件
|
||||
conditions = [TriageSession.status.in_(["pending", "triaging"])]
|
||||
if urgency:
|
||||
conditions.append(TriageSession.urgency == urgency)
|
||||
if problem_type:
|
||||
conditions.append(TriageSession.problem_type == problem_type)
|
||||
|
||||
# 紧急度排序:用 CASE 表达式
|
||||
urgency_order = case(
|
||||
(TriageSession.urgency == "high", 0),
|
||||
(TriageSession.urgency == "medium", 1),
|
||||
(TriageSession.urgency == "low", 2),
|
||||
else_=3,
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(TriageSession)
|
||||
.where(and_(*conditions))
|
||||
.order_by(urgency_order, TriageSession.created_at.desc())
|
||||
)
|
||||
|
||||
# 统计总数
|
||||
count_stmt = select(func.count()).select_from(TriageSession).where(and_(*conditions))
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * page_size
|
||||
stmt = stmt.offset(offset).limit(page_size)
|
||||
result = await db.execute(stmt)
|
||||
items = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"items": [self._session_to_dict(s) for s in items],
|
||||
}
|
||||
|
||||
async def get_stats(self, db: AsyncSession) -> Dict[str, Any]:
|
||||
"""获取分诊看板统计概要。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 统计数据
|
||||
"""
|
||||
now = datetime.now()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# 待分诊总数
|
||||
pending_result = await db.execute(
|
||||
select(func.count()).select_from(TriageSession).where(
|
||||
TriageSession.status.in_(["pending", "triaging"])
|
||||
)
|
||||
)
|
||||
pending_total = pending_result.scalar() or 0
|
||||
|
||||
# 今日已分诊数
|
||||
today_result = await db.execute(
|
||||
select(func.count()).select_from(TriageSession).where(
|
||||
and_(
|
||||
TriageSession.status == "routed",
|
||||
TriageSession.operated_at >= today_start,
|
||||
)
|
||||
)
|
||||
)
|
||||
today_triaged = today_result.scalar() or 0
|
||||
|
||||
# AI 自答数
|
||||
ai_self_result = await db.execute(
|
||||
select(func.count()).select_from(TriageSession).where(
|
||||
and_(
|
||||
TriageSession.route_action == "ai_self",
|
||||
TriageSession.operated_at >= today_start,
|
||||
)
|
||||
)
|
||||
)
|
||||
ai_self_count = ai_self_result.scalar() or 0
|
||||
|
||||
# 转人工数
|
||||
human_result = await db.execute(
|
||||
select(func.count()).select_from(TriageSession).where(
|
||||
and_(
|
||||
TriageSession.route_action == "human",
|
||||
TriageSession.operated_at >= today_start,
|
||||
)
|
||||
)
|
||||
)
|
||||
human_count = human_result.scalar() or 0
|
||||
|
||||
# 自动审批数
|
||||
auto_result = await db.execute(
|
||||
select(func.count()).select_from(TriageSession).where(
|
||||
and_(
|
||||
TriageSession.route_action == "auto_approval",
|
||||
TriageSession.operated_at >= today_start,
|
||||
)
|
||||
)
|
||||
)
|
||||
auto_approval_count = auto_result.scalar() or 0
|
||||
|
||||
# 平均耗时(从创建到操作)
|
||||
avg_result = await db.execute(
|
||||
select(
|
||||
func.avg(
|
||||
func.extract("epoch", TriageSession.operated_at - TriageSession.created_at)
|
||||
)
|
||||
).where(
|
||||
and_(
|
||||
TriageSession.status == "routed",
|
||||
TriageSession.operated_at.isnot(None),
|
||||
TriageSession.operated_at >= today_start,
|
||||
)
|
||||
)
|
||||
)
|
||||
avg_duration = avg_result.scalar()
|
||||
avg_duration_sec = float(avg_duration) if avg_duration else 0.0
|
||||
|
||||
return {
|
||||
"pending_total": pending_total,
|
||||
"today_triaged": today_triaged,
|
||||
"ai_self_count": ai_self_count,
|
||||
"human_count": human_count,
|
||||
"auto_approval_count": auto_approval_count,
|
||||
"avg_duration_sec": round(avg_duration_sec, 1),
|
||||
}
|
||||
|
||||
async def get_detail(self, db: AsyncSession, triage_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取分诊详情。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
triage_id: 分诊会话ID
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: 分诊详情字典,不存在返回 None
|
||||
"""
|
||||
session = await self._get_session(db, triage_id)
|
||||
if not session:
|
||||
return None
|
||||
return self._session_to_detail_dict(session)
|
||||
|
||||
async def route_session(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
triage_id: str,
|
||||
route_action: str,
|
||||
route_note: Optional[str],
|
||||
operator_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""坐席路由操作(覆盖 AI 建议)。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
triage_id: 分诊会话ID
|
||||
route_action: 路由动作
|
||||
route_note: 路由备注
|
||||
operator_id: 操作坐席ID
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: 更新后的分诊会话字典
|
||||
"""
|
||||
session = await self._get_session(db, triage_id)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
session.route_action = route_action
|
||||
session.route_note = route_note
|
||||
session.operator_id = operator_id
|
||||
session.operated_at = datetime.now()
|
||||
session.status = "routed" if route_action != "skip" else "skipped"
|
||||
session.updated_at = datetime.now()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
return self._session_to_dict(session)
|
||||
|
||||
async def get_history(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
route_action: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取已分诊历史列表。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
date_from: 开始日期
|
||||
date_to: 结束日期
|
||||
route_action: 路由动作筛选
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: {total, items}
|
||||
"""
|
||||
conditions = [TriageSession.status.in_(["routed", "skipped", "timeout"])]
|
||||
|
||||
if date_from:
|
||||
try:
|
||||
dt_from = datetime.fromisoformat(date_from)
|
||||
conditions.append(TriageSession.created_at >= dt_from)
|
||||
except ValueError:
|
||||
pass
|
||||
if date_to:
|
||||
try:
|
||||
dt_to = datetime.fromisoformat(date_to)
|
||||
conditions.append(TriageSession.created_at <= dt_to)
|
||||
except ValueError:
|
||||
pass
|
||||
if route_action:
|
||||
conditions.append(TriageSession.route_action == route_action)
|
||||
|
||||
stmt = (
|
||||
select(TriageSession)
|
||||
.where(and_(*conditions))
|
||||
.order_by(TriageSession.created_at.desc())
|
||||
)
|
||||
|
||||
count_stmt = select(func.count()).select_from(TriageSession).where(and_(*conditions))
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
stmt = stmt.offset(offset).limit(page_size)
|
||||
result = await db.execute(stmt)
|
||||
items = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"items": [self._session_to_dict(s) for s in items],
|
||||
}
|
||||
|
||||
async def export_sessions(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
) -> bytes:
|
||||
"""导出分诊记录为 xlsx。
|
||||
|
||||
导出基础字段 + 分诊步骤详情。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
date_from: 开始日期
|
||||
date_to: 结束日期
|
||||
|
||||
Returns:
|
||||
bytes: xlsx 文件内容
|
||||
"""
|
||||
conditions = []
|
||||
if date_from:
|
||||
try:
|
||||
dt_from = datetime.fromisoformat(date_from)
|
||||
conditions.append(TriageSession.created_at >= dt_from)
|
||||
except ValueError:
|
||||
pass
|
||||
if date_to:
|
||||
try:
|
||||
dt_to = datetime.fromisoformat(date_to)
|
||||
conditions.append(TriageSession.created_at <= dt_to)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
stmt = select(TriageSession).order_by(TriageSession.created_at.desc())
|
||||
if conditions:
|
||||
stmt = stmt.where(and_(*conditions))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
sessions = result.scalars().all()
|
||||
|
||||
# 构建 Excel
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "分诊记录"
|
||||
|
||||
# 表头
|
||||
headers = [
|
||||
"分诊ID", "会话ID", "员工ID", "员工姓名", "部门",
|
||||
"问题标题", "问题类型", "问题分类", "置信度", "紧急度",
|
||||
"AI建议路由", "最终路由", "路由备注", "操作坐席",
|
||||
"创建时间", "操作时间", "已收集上下文", "分诊步骤详情",
|
||||
]
|
||||
ws.append(headers)
|
||||
|
||||
# 数据行
|
||||
for s in sessions:
|
||||
steps_detail = ""
|
||||
if s.triage_steps:
|
||||
for i, step in enumerate(s.triage_steps, 1):
|
||||
q = step.get("question", "")
|
||||
opts = " | ".join(
|
||||
f"{o.get('label', '')}({o.get('probability', 0):.0%})"
|
||||
for o in step.get("options", [])
|
||||
)
|
||||
steps_detail += f"步骤{i}: {q} [{opts}]; "
|
||||
|
||||
ws.append([
|
||||
s.id,
|
||||
s.conversation_id,
|
||||
s.user_id,
|
||||
s.user_name or "",
|
||||
s.user_dept or "",
|
||||
s.request_title,
|
||||
s.problem_type or "",
|
||||
s.problem_category or "",
|
||||
round(s.confidence, 2) if s.confidence else "",
|
||||
s.urgency,
|
||||
s.suggested_route or "",
|
||||
s.route_action or "",
|
||||
s.route_note or "",
|
||||
s.operator_id or "",
|
||||
s.created_at.strftime("%Y-%m-%d %H:%M:%S") if s.created_at else "",
|
||||
s.operated_at.strftime("%Y-%m-%d %H:%M:%S") if s.operated_at else "",
|
||||
" / ".join(s.collected_context or []),
|
||||
steps_detail,
|
||||
])
|
||||
|
||||
# 调整列宽
|
||||
for col in ws.columns:
|
||||
max_length = max(len(str(cell.value or "")) for cell in col)
|
||||
ws.column_dimensions[col[0].column_letter].width = min(max_length + 2, 50)
|
||||
|
||||
# 输出到内存
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
|
||||
async def exclude_options(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
triage_id: str,
|
||||
excluded_labels: List[str],
|
||||
recommended_label: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""坐席排除/推荐分诊选项(通过 WS 推送到 H5)。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
triage_id: 分诊会话ID
|
||||
excluded_labels: 要排除的选项标签列表
|
||||
recommended_label: 推荐的选项标签
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 排除结果
|
||||
"""
|
||||
session = await self._get_session(db, triage_id)
|
||||
if not session:
|
||||
return {"error": "分诊会话不存在"}
|
||||
|
||||
# 通过 WS 推送到 H5 端
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
|
||||
ws_data = {
|
||||
"type": "triage_exclude",
|
||||
"data": {
|
||||
"triage_id": triage_id,
|
||||
"excluded_labels": excluded_labels,
|
||||
"recommended_label": recommended_label,
|
||||
},
|
||||
}
|
||||
|
||||
await ws_manager.send_to_employee(session.user_id, ws_data)
|
||||
logger.info(
|
||||
"排除选项已推送: triage_id=%s, excluded=%s, recommended=%s",
|
||||
triage_id,
|
||||
excluded_labels,
|
||||
recommended_label,
|
||||
)
|
||||
|
||||
return {"excluded": True}
|
||||
|
||||
# ==========================================================================
|
||||
# 内部辅助方法
|
||||
# ==========================================================================
|
||||
|
||||
@staticmethod
|
||||
def _check_info_locked(collected_context: List[str]) -> bool:
|
||||
"""判定信息是否锁定(决策 B2/B3)。
|
||||
|
||||
条件:有效回答占比 ≥ 70%。
|
||||
有效回答 = 不在 INVALID_ANSWERS 集合中的回答。
|
||||
|
||||
Args:
|
||||
collected_context: 已收集的上下文回答列表
|
||||
|
||||
Returns:
|
||||
bool: True=已锁定,False=未锁定
|
||||
"""
|
||||
if not collected_context:
|
||||
return False
|
||||
total = len(collected_context)
|
||||
valid = sum(1 for ans in collected_context if ans.strip() not in INVALID_ANSWERS)
|
||||
return (valid / total) >= INFO_LOCKED_THRESHOLD
|
||||
|
||||
async def _update_conversation_info_locked(
|
||||
self, db: AsyncSession, conversation_id: str, locked: bool
|
||||
) -> None:
|
||||
"""更新 Conversation 表的 info_locked 字段。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
conversation_id: 会话ID
|
||||
locked: 是否锁定
|
||||
"""
|
||||
from app.models.conversation import Conversation
|
||||
result = await db.execute(
|
||||
select(Conversation).where(Conversation.id == conversation_id)
|
||||
)
|
||||
conv = result.scalar_one_or_none()
|
||||
if conv:
|
||||
conv.info_locked = locked
|
||||
conv.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
logger.info("Conversation info_locked 更新: conv_id=%s, locked=%s",
|
||||
conversation_id, locked)
|
||||
|
||||
async def _push_queue_segment_changed(
|
||||
self,
|
||||
employee_id: str,
|
||||
conversation_id: str,
|
||||
old_segment: str,
|
||||
new_segment: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""推送队列段位变更 WS事件(queue_segment_changed)。
|
||||
|
||||
当 info_locked 变为 true 时,员工从"待梳理"段升级到"已梳理"段。
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
conversation_id: 会话ID
|
||||
old_segment: 原段位(incomplete)
|
||||
new_segment: 新段位(completed)
|
||||
message: 提示消息
|
||||
"""
|
||||
try:
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
ws_data = {
|
||||
"type": "queue_segment_changed",
|
||||
"data": {
|
||||
"conversation_id": conversation_id,
|
||||
"old_segment": old_segment,
|
||||
"new_segment": new_segment,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
await ws_manager.send_to_employee(employee_id, ws_data)
|
||||
except Exception as e:
|
||||
logger.warning("WS推送队列段位变更失败: %s", e)
|
||||
|
||||
async def _get_session(self, db: AsyncSession, triage_id: str) -> Optional[TriageSession]:
|
||||
"""获取分诊会话记录。"""
|
||||
result = await db.execute(
|
||||
select(TriageSession).where(TriageSession.id == triage_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _transfer_to_human_on_timeout(
|
||||
self, db: AsyncSession, triage_id: str
|
||||
) -> None:
|
||||
"""超时自动转人工。"""
|
||||
session = await self._get_session(db, triage_id)
|
||||
if session:
|
||||
session.status = "timeout"
|
||||
session.route_action = "human"
|
||||
session.route_note = "分诊超时,自动转人工"
|
||||
session.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
@staticmethod
|
||||
def _session_to_dict(s: TriageSession) -> Dict[str, Any]:
|
||||
"""将会话对象转为列表项字典。"""
|
||||
return {
|
||||
"id": s.id,
|
||||
"conversation_id": s.conversation_id,
|
||||
"user_id": s.user_id,
|
||||
"user_name": s.user_name,
|
||||
"user_dept": s.user_dept,
|
||||
"request_title": s.request_title,
|
||||
"problem_type": s.problem_type,
|
||||
"problem_category": s.problem_category,
|
||||
"confidence": s.confidence,
|
||||
"urgency": s.urgency,
|
||||
"suggested_route": s.suggested_route,
|
||||
"status": s.status,
|
||||
"route_action": s.route_action,
|
||||
"route_note": s.route_note,
|
||||
"operator_id": s.operator_id,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
"operated_at": s.operated_at.isoformat() if s.operated_at else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _session_to_detail_dict(s: TriageSession) -> Dict[str, Any]:
|
||||
"""将会话对象转为详情字典。"""
|
||||
return {
|
||||
"id": s.id,
|
||||
"conversation_id": s.conversation_id,
|
||||
"user_id": s.user_id,
|
||||
"user_name": s.user_name,
|
||||
"user_dept": s.user_dept,
|
||||
"user_level": s.user_level,
|
||||
"device_info": s.device_info,
|
||||
"request_title": s.request_title,
|
||||
"request_content": s.request_content,
|
||||
"source": s.source,
|
||||
"problem_type": s.problem_type,
|
||||
"problem_category": s.problem_category,
|
||||
"confidence": s.confidence,
|
||||
"urgency": s.urgency,
|
||||
"suggested_route": s.suggested_route,
|
||||
"matched_knowledge": s.matched_knowledge,
|
||||
"match_score": s.match_score,
|
||||
"context_tags": s.context_tags or [],
|
||||
"triage_steps": s.triage_steps or [],
|
||||
"collected_context": s.collected_context or [],
|
||||
"status": s.status,
|
||||
"route_action": s.route_action,
|
||||
"route_note": s.route_note,
|
||||
"operator_id": s.operator_id,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
||||
"operated_at": s.operated_at.isoformat() if s.operated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# 单例
|
||||
_triage_service: Optional[TriageService] = None
|
||||
|
||||
|
||||
def get_triage_service() -> TriageService:
|
||||
"""获取 TriageService 单例。
|
||||
|
||||
Returns:
|
||||
TriageService: 单例实例
|
||||
"""
|
||||
global _triage_service
|
||||
if _triage_service is None:
|
||||
_triage_service = TriageService()
|
||||
return _triage_service
|
||||
@@ -1171,6 +1171,78 @@ class WecomService:
|
||||
logger.error(f"获取 jsapi_ticket 网络错误: {e}")
|
||||
raise Exception(f"企微API网络错误: {e}") from e
|
||||
|
||||
async def get_agent_config_ticket(self) -> str:
|
||||
"""获取企微 agent_config_ticket。
|
||||
|
||||
对应企微API:
|
||||
GET https://qyapi.weixin.qq.com/cgi-bin/ticket/get?type=agent_config&access_token=TOKEN
|
||||
|
||||
agent_config_ticket 用于 wx.agentConfig() 的签名计算。
|
||||
与 jsapi_ticket 是不同的票据,不能混用。
|
||||
有效期 7200 秒,缓存到 Redis(提前 300 秒刷新)。
|
||||
|
||||
Returns:
|
||||
str: agent_config_ticket 字符串
|
||||
|
||||
Raises:
|
||||
Exception: 获取失败
|
||||
"""
|
||||
cache_key = "wecom:agent_config_ticket"
|
||||
|
||||
# 1. Redis 缓存
|
||||
if self.redis:
|
||||
try:
|
||||
cached = await self.redis.get(cache_key)
|
||||
if cached:
|
||||
if isinstance(cached, bytes):
|
||||
cached = cached.decode("utf-8")
|
||||
logger.debug("从缓存获取 agent_config_ticket")
|
||||
return cached
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 读取 agent_config_ticket 失败(降级): {e}")
|
||||
|
||||
# 2. 调用企微 API
|
||||
access_token = await self.get_access_token()
|
||||
url = (
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/ticket/get"
|
||||
f"?type=agent_config&access_token={access_token}"
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.client.get(url)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
logger.error(
|
||||
f"获取 agent_config_ticket 失败: "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
raise Exception(
|
||||
f"获取 agent_config_ticket 失败: {result.get('errmsg')}"
|
||||
)
|
||||
|
||||
ticket = result.get("ticket", "")
|
||||
expires_in = result.get("expires_in", 7200)
|
||||
|
||||
# 3. 缓存到 Redis(TTL = expires_in - 300s)
|
||||
cache_ttl = max(expires_in - 300, 60)
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.setex(cache_key, cache_ttl, ticket)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Redis 写入 agent_config_ticket 失败(降级): {e}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"agent_config_ticket 获取成功,缓存 TTL={cache_ttl}秒"
|
||||
)
|
||||
return ticket
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"获取 agent_config_ticket 网络错误: {e}")
|
||||
raise Exception(f"企微API网络错误: {e}") from e
|
||||
|
||||
@staticmethod
|
||||
def generate_jsapi_signature(
|
||||
ticket: str, nonce_str: str, timestamp: int, url: str
|
||||
@@ -1185,9 +1257,11 @@ class WecomService:
|
||||
- url 不含 # 及其后面部分
|
||||
- url 不含 ?
|
||||
- url 是前端调用 wx.config 的页面 URL
|
||||
- 此方法同时用于 jsapi_ticket 和 agent_config_ticket 的签名计算
|
||||
(签名算法相同,只是 ticket 不同)
|
||||
|
||||
Args:
|
||||
ticket: jsapi_ticket
|
||||
ticket: jsapi_ticket 或 agent_config_ticket
|
||||
nonce_str: 随机字符串(前端生成,16位)
|
||||
timestamp: 当前时间戳(秒)
|
||||
url: 当前页面 URL(不含 # 后面)
|
||||
|
||||
Reference in New Issue
Block a user