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:
Simon
2026-07-13 02:17:03 +08:00
parent bea288e414
commit 449c6d4875
176 changed files with 46637 additions and 4805 deletions
+141 -3
View File
@@ -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,
)
+431 -9
View File
@@ -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 原生 APIv2.1 新增,绕过 dify2openai 代理)。
做什么:
1. POST {base_url}/v1/chat-messagesblocking 模式)
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 模式调用 Difystream=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
# --------------------------------------------------------------------------
# 判断是否命中知识库
# --------------------------------------------------------------------------
+922
View File
@@ -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
# ==========================================================================
# 场景524h内重开
# ==========================================================================
async def reopen_conversation(
self,
employee_id: str,
original_conversation_id: str,
) -> Conversation:
"""24小时内重开已关闭的会话。
创建新会话并关联原会话ID,用于上下文继承。
新会话状态为 ai_handling,复用原会话的员工信息。
Args:
employee_id: 员工企微UserID
original_conversation_id: 原会话ID
Returns:
Conversation: 新创建的会话对象
Raises:
AppException: 原会话不存在、未关闭、超过24h窗口
"""
# 查找原会话
original = await self._get_conversation_by_id(original_conversation_id)
# 校验:原会话必须已关闭
if original.status != "resolved":
raise AppException(1006, "只有已关闭的会话可以重开")
# 校验:24小时窗口
# 使用 updated_at 作为关闭时间近似(resolved后没有专门的 resolved_at 字段)
close_time = original.updated_at
if close_time:
elapsed = datetime.now() - close_time
if elapsed > timedelta(hours=REOPEN_WINDOW_HOURS):
raise AppException(
1007,
f"已超过 {REOPEN_WINDOW_HOURS} 小时重开窗口,请发起新会话。",
)
# 创建新会话,关联原会话
new_conversation = Conversation(
corp_id=original.corp_id,
employee_id=original.employee_id,
employee_name=original.employee_name,
department=original.department,
position=original.position,
level=original.level,
status="ai_handling",
is_vip=original.is_vip,
urgency_score=max(original.urgency_score, 2), # 重开提升紧急度
info_locked=original.info_locked, # 继承信息锁定状态
queue_priority=0,
reference_conversation_id=str(original.id), # 关联原会话
tags={"reopened": True, "original_conv_id": str(original.id)},
last_message_summary="问题复发,重新接入",
)
self.db.add(new_conversation)
await self.db.flush()
logger.info(
f"重开会话: new_conv={new_conversation.id}, "
f"original={original_conversation_id}, employee={employee_id}"
)
# 推送 WS 事件给坐席端:有新会话进入
await ws_manager.broadcast({
"type": "conversation_created",
"data": {
"conversation_id": str(new_conversation.id),
"employee_id": employee_id,
"employee_name": new_conversation.employee_name,
"is_reopen": True,
"reference_conversation_id": str(original.id),
"urgency_score": new_conversation.urgency_score,
},
})
return new_conversation
# ==========================================================================
# 关键词识别(决策 G2
# ==========================================================================
@staticmethod
def check_resolve_keywords(message_content: str) -> bool:
"""检查消息内容是否包含关闭关键词。
用于 AI 对话中识别员工表达"已解决"意图。
当 AI 检测到关键词时,推送确认卡片让员工二次确认。
Args:
message_content: 员工发送的消息内容
Returns:
bool: 是否包含关闭关键词
"""
# 延迟导入避免循环依赖
from app.services.triage_service import RESOLVE_KEYWORDS
content_lower = message_content.lower().strip()
for keyword in RESOLVE_KEYWORDS:
if keyword in content_lower:
return True
return False
# ==========================================================================
# Phase 6A: 诊断闭环协调 — 基于 diagnosis_stage 判断
# ==========================================================================
@staticmethod
def get_diagnosis_stage(conversation: Conversation) -> Optional[str]:
"""从会话 tags 中获取当前诊断阶段。
做什么:读取 conversation.tags["diagnosis_stage"] 字段,
该字段由 _persist_and_push_structured() 在每次 AI 回复时更新。
为什么:closing_service 需要知道 AI 的诊断进度,
以决定是否建议关闭会话或触发结单流程。
Args:
conversation: 会话对象
Returns:
Optional[str]: 诊断阶段值(initial/gathering_info/diagnosing/
recommending/resolved/escalating),无则 None
"""
if not conversation.tags:
return None
return conversation.tags.get("diagnosis_stage")
@staticmethod
def should_suggest_resolve(conversation: Conversation) -> bool:
"""判断是否应建议员工确认解决(基于 diagnosis_stage)。
做什么:当 AI 返回 diagnosis_stage == "resolved" 时,
表示 AI 认为问题已解决,系统可推送确认卡片。
为什么:相比纯关键词匹配,diagnosis_stage 是 AI 主动判断的结果,
更准确地反映问题解决状态。
Args:
conversation: 会话对象
Returns:
bool: True 表示应推送解决确认卡片
"""
stage = ClosingService.get_diagnosis_stage(conversation)
return stage == "resolved"
@staticmethod
def should_escalate_to_human(conversation: Conversation) -> bool:
"""判断是否应建议转人工(基于 diagnosis_stage)。
做什么:当 AI 返回 diagnosis_stage == "escalating" 时,
表示 AI 无法解决问题,应转人工坐席。
为什么:AI 主动判断无法解决比超时兜底更及时,
能更快地将员工转给人工坐席。
Args:
conversation: 会话对象
Returns:
bool: True 表示应转人工
"""
stage = ClosingService.get_diagnosis_stage(conversation)
return stage == "escalating"
async def _notify_queue_position_update(self) -> None:
"""通知所有排队员工其队列位置已更新(WS事件 queue_position_update)。
当有会话被关闭/分配/取消时,排在后面的员工位置前移。
此方法查询所有排队中的会话,计算每个员工的新位置并推送。
为了避免大量推送,仅在有人排队时执行。
"""
from app.services.queue_service import get_queue_service
try:
queue_service = get_queue_service() # 无参单例
# 查询所有排队中的会话
stmt = select(Conversation).where(
Conversation.status == "queued"
).order_by(Conversation.created_at.asc())
result = await self.db.execute(stmt)
queued_conversations = result.scalars().all()
if not queued_conversations:
return
# 为每个排队员工计算新位置并推送
for conv in queued_conversations:
try:
status = await queue_service.get_comprehensive_status(self.db, conv)
queue_info = status.get("queue", {})
await ws_manager.send_to_employee(
conv.employee_id,
{
"type": "queue_position_update",
"data": {
"conversation_id": str(conv.id),
"position": queue_info.get("position", 0),
"segment": queue_info.get("segment", ""),
"ahead_count": queue_info.get("ahead_count", 0),
"queue_priority": conv.queue_priority,
"timestamp": datetime.now().isoformat(),
},
}
)
except Exception as e:
logger.debug(f"推送队列位置更新失败(单个): conv_id={conv.id}, {e}")
continue
except Exception as e:
logger.warning(f"队列位置更新推送异常: {e}")
# ==========================================================================
# 超时检查辅助方法(供 reminder_task.py 调用)
# ==========================================================================
async def get_pending_close_timeout_sessions(self) -> list[Conversation]:
"""获取 pending_close 超时需要自动关闭的会话列表。
条件:status=pending_close 且 pending_close_at 超过5分钟
"""
threshold = datetime.now() - timedelta(minutes=PENDING_CLOSE_AUTO_RESOLVE_MINUTES)
stmt = select(Conversation).where(
Conversation.status == "pending_close",
Conversation.pending_close_at.isnot(None),
Conversation.pending_close_at < threshold,
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_ai_handling_timeout_sessions(self) -> list[Conversation]:
"""获取 ai_handling 超时需要自动关闭的会话列表。
条件:status=ai_handling 且 last_message_at 超过 (30+10)=40 分钟
"""
total_timeout = AI_HANDLING_TIMEOUT_MINUTES + AI_HANDLING_REMINDER_TO_CLOSE_MINUTES
threshold = datetime.now() - timedelta(minutes=total_timeout)
stmt = select(Conversation).where(
Conversation.status == "ai_handling",
Conversation.last_message_at.isnot(None),
Conversation.last_message_at < threshold,
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_ai_handling_reminder_sessions(self) -> list[Conversation]:
"""获取 ai_handling 需要发送超时提醒的会话列表。
条件:status=ai_handling 且 last_message_at 超过30分钟 且未发过提醒
"""
threshold = datetime.now() - timedelta(minutes=AI_HANDLING_TIMEOUT_MINUTES)
stmt = select(Conversation).where(
Conversation.status == "ai_handling",
Conversation.last_message_at.isnot(None),
Conversation.last_message_at < threshold,
Conversation.reminder_sent == False,
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
# ==========================================================================
# 内部辅助方法
# ==========================================================================
async def _get_active_conversation(self, employee_id: str) -> Conversation:
"""获取员工的活跃会话(非 resolved 状态)。
Args:
employee_id: 员工企微UserID
Returns:
Conversation: 活跃会话对象
Raises:
AppException: 无活跃会话
"""
stmt = select(Conversation).where(
Conversation.employee_id == employee_id,
Conversation.status.in_(["ai_handling", "queued", "serving", "pending_close"]),
).order_by(Conversation.created_at.desc())
result = await self.db.execute(stmt)
conversation = result.scalars().first()
if not conversation:
raise AppException(1001, "当前没有活跃会话")
return conversation
async def _get_conversation_by_id(self, conversation_id: str) -> Conversation:
"""根据ID获取会话。
Args:
conversation_id: 会话ID
Returns:
Conversation: 会话对象
Raises:
AppException: 会话不存在
"""
stmt = select(Conversation).where(Conversation.id == conversation_id)
result = await self.db.execute(stmt)
conversation = result.scalars().first()
if not conversation:
raise AppException(3001, "会话不存在")
return conversation
async def _decrement_agent_load(self, agent_id: Optional[str]) -> None:
"""减少坐席当前服务数。
Args:
agent_id: 坐席ID
"""
if not agent_id:
return
stmt = select(Agent).where(Agent.user_id == agent_id)
result = await self.db.execute(stmt)
agent = result.scalars().first()
if agent and agent.current_load > 0:
agent.current_load -= 1
self.db.add(agent)
async def _push_resolve_confirm(
self,
conversation: Conversation,
agent_id: str,
resolve_summary: str,
) -> None:
"""推送结单确认请求给员工(WS事件 resolve_confirm)。
前端收到此事件后,在对话流中弹出确认卡片:
- 坐席摘要展示
- "已解决"按钮 → 调用 employee_confirm_resolve
- "未解决"按钮 → 调用 employee_reject_resolve
- 提示:5分钟内不响应将自动关闭
"""
payload = {
"type": "resolve_confirm",
"data": {
"conversation_id": str(conversation.id),
"agent_id": agent_id,
"resolve_summary": resolve_summary,
"auto_close_minutes": PENDING_CLOSE_AUTO_RESOLVE_MINUTES,
"timestamp": datetime.now().isoformat(),
},
}
try:
await ws_manager.send_to_employee(conversation.employee_id, payload)
except Exception as e:
logger.warning(f"推送 resolve_confirm 失败: {e}")
async def _push_conversation_resolved(
self,
conversation: Conversation,
resolved_by: str,
resolved_method: str,
) -> None:
"""推送会话已关闭事件(WS事件 conversation_resolved)。
通知坐席端和员工端会话已关闭。
同时触发:
1. 队列位置更新通知(queue_position_update)— 通知所有排队员工位置变化
2. 自动分配下一个排队会话(三段排序)
"""
payload = {
"type": "conversation_resolved",
"data": {
"conversation_id": str(conversation.id),
"status": "resolved",
"resolved_by": resolved_by,
"resolved_method": resolved_method,
"resolve_summary": conversation.resolve_summary or "",
"timestamp": datetime.now().isoformat(),
},
}
# 推送给坐席端(广播,因为可能多个坐席需要看到状态变更)
try:
await ws_manager.broadcast(payload)
except Exception as e:
logger.warning(f"推送 conversation_resolved 给坐席失败: {e}")
# 推送给员工端
try:
await ws_manager.send_to_employee(conversation.employee_id, payload)
except Exception as e:
logger.warning(f"推送 conversation_resolved 给员工失败: {e}")
# ------------------------------------------------------------------
# 触发1:通知所有排队员工队列位置已更新(queue_position_update
# ------------------------------------------------------------------
# 会话关闭后,排在后面的员工位置前移1位
try:
await self._notify_queue_position_update()
except Exception as e:
logger.warning(f"推送 queue_position_update 失败: {e}")
# ------------------------------------------------------------------
# 触发2:自动分配队列中的下一个会话(三段排序)
# ------------------------------------------------------------------
# 坐席空闲后,从队列中按 VIP → 已梳理 → 待梳理 顺序分配
try:
from app.services.session_service import SessionService
session_service = SessionService(self.db)
assigned = await session_service.auto_assign_from_queue()
if assigned:
logger.info(f"关闭后自动分配下一个会话: conv_id={assigned.id}")
except Exception as e:
logger.warning(f"关闭后自动分配失败(不阻塞): {e}")
async def _push_auto_close_warning(
self,
conversation: Conversation,
minutes_remaining: int,
) -> None:
"""推送超时关闭警告(WS事件 auto_close_warning)。
在 pending_close 后4分钟(1分钟前剩)时推送,提醒员工即将自动关闭。
"""
payload = {
"type": "auto_close_warning",
"data": {
"conversation_id": str(conversation.id),
"minutes_remaining": minutes_remaining,
"message": f"您的会话将在 {minutes_remaining} 分钟后自动关闭,"
f"如需继续服务请点击「未解决」。",
"timestamp": datetime.now().isoformat(),
},
}
try:
await ws_manager.send_to_employee(conversation.employee_id, payload)
except Exception as e:
logger.warning(f"推送 auto_close_warning 失败: {e}")
async def _trigger_knowledge_sedimentation(
self,
conversation: Conversation,
) -> None:
"""触发知识沉淀流程(异步,不阻塞关闭主流程)。
决策 G5:resolved后判断是否有诊断报告+修复记录
→ 生成知识条目草稿 → 管理后台审核入库
当前实现:仅记录日志,后续接入诊断服务后完善。
知识沉淀为 P2 功能,此处预留接口。
"""
# TODO: P2 阶段接入诊断服务后完善
# 1. 检查是否有关联的诊断报告(DiagnosticReport
# 2. 检查是否有修复记录(DiagnosticDispatch.fix_dispatched
# 3. 如果有,调用 Dify 总结会话+诊断报告 → 生成知识条目草稿
# 4. 草稿存入知识库待审核表
logger.info(
f"知识沉淀触发(P2预留): conv_id={conversation.id}, "
f"method={conversation.resolved_method}, "
f"summary={conversation.resolve_summary[:50] if conversation.resolve_summary else 'N/A'}"
)
# =============================================================================
# 模块级单例工厂
# =============================================================================
# 与 queue_service / quiz_service 一致的模式:
# 每次调用时传入 db session,服务本身无状态
_closing_service_instance: Optional[ClosingService] = None
def get_closing_service(db: AsyncSession) -> ClosingService:
"""获取关闭机制服务实例。
Args:
db: 数据库异步会话
Returns:
ClosingService: 关闭机制服务实例
"""
global _closing_service_instance
if _closing_service_instance is None or _closing_service_instance.db is not db:
_closing_service_instance = ClosingService(db)
return _closing_service_instance
+284
View File
@@ -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
+344
View File
@@ -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
+3 -3
View File
@@ -29,10 +29,10 @@ class FunnyPhraseService:
# 默认话术(当数据库未配置时使用,和 PRD 一致)
DEFAULT_PHRASES = {
"shake": "少主,这就为您去摇人,稍等...",
"keyword": "收到!这就帮您摇位大神来",
"shake": "已为您呼叫人工坐席,请稍等!",
"keyword": "已为您呼叫人工坐席,请稍等!",
"waiting": "人还在路上,别急别急~",
"connected": "人摇来了!IT坐席为您服务",
"connected": "坐席正在查看您的信息,请等待处理回复!",
"timeout": "坐席都在忙,不过AI还在呢,要不先聊聊?我再继续摇",
"vip": "这就帮您安排专家,请稍候",
}
+646
View File
@@ -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(),
}
+33
View File
@@ -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",
]
+55
View File
@@ -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
]
+482
View File
@@ -0,0 +1,482 @@
# =============================================================================
# 企微IT智能服务台 — 分层排队服务
# =============================================================================
# 说明:实现三段排序的排队位置计算和平台统计
#
# 排队三段排序(决策 C1/C2):
# 段1VIP):is_vip = true,不受信息梳理影响
# 段2(已梳理):is_vip = false AND info_locked = true
# 段3(待梳理):is_vip = false AND info_locked = false
#
# 段内排序:queue_priority DESC → urgency_score DESC → created_at ASC
# 插队规则(决策 C4):queue_priority = min(答题数//3, 2),上限2
# =============================================================================
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_, select, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.conversation import Conversation
from app.models.quiz import EmployeePoints
logger = logging.getLogger(__name__)
# 预估每个排队者的服务时间(秒),用于计算预估等待时间
ESTIMATED_SERVICE_TIME_SEC = 300 # 5分钟/人
class QueueService:
"""分层排队服务。
提供排队位置计算、平台统计、坐席端看板数据等功能。
"""
# ======================================================================
# 段位判定
# ======================================================================
@staticmethod
def _determine_segment(conversation: Conversation) -> str:
"""判定会话属于哪个排队段位。
Args:
conversation: 会话对象
Returns:
str: "vip" / "completed" / "incomplete"
"""
if conversation.is_vip:
return "vip"
elif conversation.info_locked:
return "completed"
else:
return "incomplete"
# ======================================================================
# 排队位置计算
# ======================================================================
async def calculate_queue_position(
self, db: AsyncSession, conversation: Conversation
) -> Dict[str, Any]:
"""计算指定会话的排队位置(三段排序)。
排序逻辑:
1. VIP段排最前
2. 已梳理(info_locked=true)段排第二
3. 待梳理(info_locked=false)段排最后
4. 同段内:queue_priority DESC → urgency_score DESC → created_at ASC
Args:
db: 数据库会话
conversation: 要计算位置的会话
Returns:
Dict: {position, segment, ahead_count, estimated_wait_sec}
"""
segment = self._determine_segment(conversation)
ahead_count = 0
# ---- 计算更高段的人数 ----
if segment != "vip":
# 当前不是VIP段 → 所有VIP都排前面
vip_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == True, # noqa: E712
)
)
ahead_count += vip_count or 0
if segment == "incomplete":
# 当前是待梳理段 → 已梳理段也排前面
completed_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == False, # noqa: E712
Conversation.info_locked == True, # noqa: E712
)
)
ahead_count += completed_count or 0
# ---- 计算同段内排在前面的人数 ----
same_segment_ahead = await self._count_same_segment_ahead(db, conversation, segment)
ahead_count += same_segment_ahead
position = ahead_count + 1
estimated_wait = position * ESTIMATED_SERVICE_TIME_SEC
# 中文段位名称(前端展示用)
segment_labels = {
"vip": "VIP优先",
"completed": "已梳理",
"incomplete": "待梳理",
}
return {
"position": position,
"segment": segment,
"segment_label": segment_labels.get(segment, segment),
"ahead_count": ahead_count,
"estimated_wait_sec": estimated_wait,
"estimated_wait_text": self._format_wait_time(estimated_wait),
"queue_priority": conversation.queue_priority,
}
async def _count_same_segment_ahead(
self, db: AsyncSession, conversation: Conversation, segment: str
) -> int:
"""计算同段内排在当前会话前面的排队人数。
段内排序规则:queue_priority DESC → urgency_score DESC → created_at ASC
Args:
db: 数据库会话
conversation: 当前会话
segment: 当前段位
Returns:
int: 同段内排在前面的人数
"""
# 构建同段条件
conditions = [
Conversation.status == "queued",
Conversation.id != conversation.id, # 排除自己
]
if segment == "vip":
conditions.append(Conversation.is_vip == True) # noqa: E712
elif segment == "completed":
conditions.append(Conversation.is_vip == False) # noqa: E712
conditions.append(Conversation.info_locked == True) # noqa: E712
else: # incomplete
conditions.append(Conversation.is_vip == False) # noqa: E712
conditions.append(Conversation.info_locked == False) # noqa: E712
# 同段内排在前面的条件:
# 1. queue_priority 更高
# 2. 或 queue_priority 相同且 urgency_score 更高
# 3. 或 queue_priority 和 urgency_score 都相同且 created_at 更早
ahead_conditions = or_(
Conversation.queue_priority > conversation.queue_priority,
and_(
Conversation.queue_priority == conversation.queue_priority,
Conversation.urgency_score > conversation.urgency_score,
),
and_(
Conversation.queue_priority == conversation.queue_priority,
Conversation.urgency_score == conversation.urgency_score,
Conversation.created_at < conversation.created_at,
),
)
count = await db.scalar(
select(func.count(Conversation.id)).where(
*conditions, ahead_conditions
)
)
return count or 0
# ======================================================================
# 平台统计
# ======================================================================
async def get_platform_stats(self, db: AsyncSession) -> Dict[str, int]:
"""获取平台统计数据(决策 C5)。
total_active = ai_handling + queued + serving
queued = 排队中人数
serving = 服务中人数
Args:
db: 数据库会话
Returns:
Dict: {total_active, queued, serving, ai_handling}
"""
# 总活跃 = ai_handling + queued + serving
total_active = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status.in_(["ai_handling", "queued", "serving"])
)
)
queued = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued"
)
)
serving = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "serving"
)
)
ai_handling = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "ai_handling"
)
)
return {
"total_active": total_active or 0,
"queued": queued or 0,
"serving": serving or 0,
"ai_handling": ai_handling or 0,
}
async def get_queue_segment_stats(self, db: AsyncSession) -> Dict[str, int]:
"""获取排队分段统计(坐席端看板用)。
Returns:
Dict: {vip_count, completed_count, incomplete_count, total_queued}
"""
# VIP段
vip_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == True, # noqa: E712
)
)
# 已梳理段
completed_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == False, # noqa: E712
Conversation.info_locked == True, # noqa: E712
)
)
# 待梳理段
incomplete_count = await db.scalar(
select(func.count(Conversation.id)).where(
Conversation.status == "queued",
Conversation.is_vip == False, # noqa: E712
Conversation.info_locked == False, # noqa: E712
)
)
total_queued = (vip_count or 0) + (completed_count or 0) + (incomplete_count or 0)
return {
"vip_count": vip_count or 0,
"completed_count": completed_count or 0,
"incomplete_count": incomplete_count or 0,
"total_queued": total_queued,
}
# ======================================================================
# 综合排队状态(H5端 queue/status API
# ======================================================================
async def get_comprehensive_status(
self, db: AsyncSession, conversation: Conversation
) -> Dict[str, Any]:
"""获取综合排队状态:排队位置+段位+平台统计+答题状态+积分。
供 GET /api/h5/queue/status API调用。
Args:
db: 数据库会话
conversation: 当前会话
Returns:
Dict: 综合状态数据
"""
# 1. 排队位置(仅排队中时计算)
if conversation.status == "queued":
queue_info = await self.calculate_queue_position(db, conversation)
else:
queue_info = {
"position": 0,
"segment": self._determine_segment(conversation),
"segment_label": "非排队中",
"ahead_count": 0,
"estimated_wait_sec": 0,
"estimated_wait_text": "",
"queue_priority": conversation.queue_priority,
}
# 2. 平台统计
platform_stats = await self.get_platform_stats(db)
# 3. 积分信息
points_info = await self._get_employee_points(db, conversation.employee_id)
# 4. 插队信息
quiz_answered = conversation.queue_priority * 3 if conversation.queue_priority > 0 else 0
max_quiz_for_jump = 6 # 2位×3题=6题
remaining_for_next_jump = 3 - (quiz_answered % 3) if quiz_answered < max_quiz_for_jump else 0
return {
"conversation_status": conversation.status,
"queue": queue_info,
"platform": platform_stats,
"points": points_info,
"quiz": {
"answered_in_session": quiz_answered,
"queue_priority": conversation.queue_priority,
"max_priority": 2,
"remaining_for_next_jump": remaining_for_next_jump,
"can_jump_more": conversation.queue_priority < 2,
},
"info_locked": conversation.info_locked,
}
# ======================================================================
# 坐席端排队看板
# ======================================================================
async def get_agent_dashboard(self, db: AsyncSession) -> Dict[str, Any]:
"""获取坐席端排队看板数据。
Returns:
Dict: {segment_stats, platform_stats, queue_list}
"""
segment_stats = await self.get_queue_segment_stats(db)
platform_stats = await self.get_platform_stats(db)
# 获取排队列表(按三段排序)
queue_list = await self._get_sorted_queue_list(db, limit=50)
return {
"segments": segment_stats,
"platform": platform_stats,
"queue_list": queue_list,
}
async def _get_sorted_queue_list(
self, db: AsyncSession, limit: int = 50
) -> List[Dict[str, Any]]:
"""获取按三段排序的排队列表。
Returns:
List[Dict]: 排队会话列表
"""
# 查询所有排队中的会话,按段位+段内排序
# 段位排序:VIP(0) > 已梳理(1) > 待梳理(2)
segment_order = case(
(Conversation.is_vip == True, 0), # noqa: E712
(Conversation.info_locked == True, 1), # noqa: E712
else_=2,
)
stmt = (
select(Conversation)
.where(Conversation.status == "queued")
.order_by(
segment_order,
Conversation.queue_priority.desc(),
Conversation.urgency_score.desc(),
Conversation.created_at.asc(),
)
.limit(limit)
)
result = await db.execute(stmt)
conversations = result.scalars().all()
# 转为前端需要的列表格式
queue_list = []
for conv in conversations:
segment = self._determine_segment(conv)
queue_list.append({
"conversation_id": conv.id,
"employee_name": conv.employee_name,
"department": conv.department,
"employee_id": conv.employee_id,
"segment": segment,
"segment_label": {
"vip": "VIP优先",
"completed": "已梳理",
"incomplete": "待梳理",
}.get(segment, segment),
"urgency_score": conv.urgency_score,
"queue_priority": conv.queue_priority,
"info_locked": conv.info_locked,
"is_vip": conv.is_vip,
"last_message_summary": conv.last_message_summary,
"created_at": conv.created_at.isoformat() if conv.created_at else None,
"waiting_seconds": int(
(datetime.now(timezone.utc) - conv.created_at).total_seconds()
) if conv.created_at else 0,
})
return queue_list
# ======================================================================
# 辅助方法
# ======================================================================
async def _get_employee_points(
self, db: AsyncSession, employee_id: str
) -> Dict[str, Any]:
"""获取员工积分信息。
Args:
db: 数据库会话
employee_id: 员工ID
Returns:
Dict: {total_points, level, answered_count, correct_count}
"""
result = await db.execute(
select(EmployeePoints).where(EmployeePoints.employee_id == employee_id)
)
points = result.scalar_one_or_none()
if points:
return {
"total_points": points.total_points,
"level": points.level,
"answered_count": points.answered_count,
"correct_count": points.correct_count,
}
else:
return {
"total_points": 0,
"level": "IT小白",
"answered_count": 0,
"correct_count": 0,
}
@staticmethod
def _format_wait_time(seconds: int) -> str:
"""将秒数格式化为人类可读的等待时间文本。
Args:
seconds: 秒数
Returns:
str: 如"约5分钟""约1小时30分钟"
"""
if seconds <= 0:
return "即将接通"
minutes = seconds // 60
if minutes < 1:
return f"{seconds}"
elif minutes < 60:
return f"{minutes}分钟"
else:
hours = minutes // 60
remaining_minutes = minutes % 60
if remaining_minutes == 0:
return f"{hours}小时"
return f"{hours}小时{remaining_minutes}分钟"
# 单例
_queue_service: Optional[QueueService] = None
def get_queue_service() -> QueueService:
"""获取 QueueService 单例。"""
global _queue_service
if _queue_service is None:
_queue_service = QueueService()
return _queue_service
@@ -0,0 +1,787 @@
# =============================================================================
# 企微IT智能服务台 — 测验题目 AI 生成服务
# =============================================================================
# 说明:复用 Dify Wingman APIOpenAI-compatible 格式),自动生成:
# 1. IT 知识题(7 类别,排队等待期间向员工推送)
# 2. 诊断题(基于近期工单模式,帮助员工自检问题)
#
# 生成策略:
# - AI 生成的所有题目 is_active=False,需管理员审批后激活
# - 种子数据(seed_quiz.py 调用)is_active=Truebootstrap 例外
# - 定时任务每日 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 APIOpenAI-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 APIOpenAI-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
+535
View File
@@ -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:
# 模式BIT知识题
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
+220
View File
@@ -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)
+114 -1
View File
@@ -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/C2VIP → 已梳理(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
# --------------------------------------------------------------------------
# 结单
# --------------------------------------------------------------------------
+1 -1
View File
@@ -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
+990
View File
@@ -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
+75 -1
View File
@@ -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. 缓存到 RedisTTL = 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(不含 # 后面)