Files
wecom_it_smart_desk/backend/app/services/ai_handler.py
Simon 449c6d4875 feat: 2026-07-12~13 全量更新 - AI对话链路改造+H5 v4/v5+坐席端v5+上下文感知诊断+知识库迭代3
## H5 员工端 v4 (2026-07-13 00:48 已部署)
- 人工按钮三态文案统一为"人工坐席"
- 按钮位置移至发送键和语音按钮上方(垂直堆叠)
- 点按钮直接调 store.shakeAgent(),删除 CallAgentModal 弹窗动画
- 截图快捷键提示改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V"
- 移动端隐藏截图提示(CSS 媒体查询)
- AI转人工提示改为"已为您呼叫人工坐席,请稍等!"
- 坐席接入提示改为"坐席正在查看您的信息,请等待处理回复!"
- 删除"摇铃呼叫坐席"入口和文案
- 删除孤儿组件 MessageList.vue + shake 动画 CSS

## H5 员工端 v5 (2026-07-13 02:08 已部署)
- RightPanel v2.1:删除"软件安装"和"资源权限"标签页
- 移除标签栏,智能推荐(DynamicRecommend)直接展示
- 删除 SoftwareDownloads/ApprovalLinks 引用和相关 CSS

## AI 对话链路全栈改造 Phase 1-6 (已部署)
- Phase 1: Dify JSON输出 + 后端blocking解析 + 双WS推送 + 错误降级
- Phase 2: 关键词收窄(~25强意图词) + 两级分类Prompt + 删除前端checkApprovalIntent
- Phase 3: WS扩展(ai_thinking+dynamic_recommend) + ai_structured气泡 + RightPanel v2 + 选项回传
- Phase 4: VisionService接入 + 图片消息融合(5秒窗口) + 降级策略
- Phase 5: 坐席端ai_thinking指示器 + ai_structured/byod_card渲染 + handleNewMessage修复
- Phase 6: diagnosis_stage(6值) + response_time_ms计时 + 慢响应告警(>10s)

## 坐席端 v5 (2026-07-13 01:38 已部署)
- ai_structured/byod_card 只读渲染
- AI思考指示器 UI
- handleNewMessage 透传 msg_type/extra_data 修复
- 布局优化v2.0: QuickReplyBar L1+L2悬浮 + ReplyBox左右分区 + 右栏260/560px切换
- 键盘快捷键v2.3: 纯数字路由 + ESC分层撤销 + Shift+Space用event.code

## 上下文感知智能诊断闭环 (2026-07-12 已部署)
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked)
- 答题插队 + 五场景关闭
- 迁移052(6表+6列) + queue_service + quiz_service + closing_service
- H5前端: QueueWaiting + RightPanel双Tab + InputBar三态 + ResolveConfirmCard
- 坐席前端: pending_close结单流程 + 信息锁定(Dify步骤完成+有效回答率≥70%)

## 知识库迭代3 (2026-07-12 已部署)
- 分诊交互(H5+坐席+Dify独立应用)
- 拓扑预览(ECharts只读)
- 代答排除(4种匹配器: keyword/regex/intent/category)
- 迁移051 + 44文件43测试通过

## 后端变更
- 6个Python文件改造(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等)
- funny_phrase_service.py: shake/connected/keyword 默认文案更新
- session_service.py: 企微消息文案同步
- 新增: queue.py/quiz.py/triage.py/exclusion_rules.py 等API端点
- 新增: diagnostic.py/quiz.py/triage_session.py 等模型
- 新增: closing_service/queue_service/quiz_service/triage_service 等服务

## 文档更新
- CHANGELOG.md: 新增 [未发布] 区全部变更记录
- 项目管理主文档 v2.5: 新增v0.7.3版本 + 已完成看板 + 最近搞定
- 版本记录: 新增v0.7.3条目
- AI对话链路实施计划: Phase 1-6 全部标记已实施
- 新增架构图/时序图/类图(mermaid)

## 部署路径修正
- 服务器项目根路径: /opt/wecom-it-desk/
- 所有前端dist均为ro bind mount,只能在宿主机源路径操作
- 服务器nginx /h5/ 是静态文件服务(非proxy_pass)
- elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传
2026-07-13 02:17:03 +08:00

428 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微IT智能服务台 — 统一 AI 回复处理器
# =============================================================================
# 说明:统一封装 AI 调用逻辑,供 H5 端和企微回调端共用,确保两端行为一致。
# 1. 打招呼检测 → 引导用户描述问题(不计数)
# 2. 呼叫人工拦截 → 引导先描述问题(不计数)
# 3. AI 调用 → 命中则回复并计数,未命中则转人工
# 4. AI 异常降级 → 模板回复(不计数,不转人工)
#
# 为什么需要此模块:
# - 原 h5.py 和 MessageRouter._try_ai_reply() 各自独立实现 AI 调用逻辑
# - 两端行为不一致:打招呼/呼叫人工拦截只在 H5 端有,计数规则不同
# - 统一后确保无论从哪个入口进来,用户获得的 AI 体验完全一致
# =============================================================================
import logging
import random
from dataclasses import dataclass
from typing import Optional
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 引导用户描述问题,不计数)
# --------------------------------------------------------------------------
_GREETING_KEYWORDS = [
"你好", "您好", "hi", "hello", "", "在吗", "在不在",
"哈喽", "", "早上好", "下午好", "晚上好",
]
# --------------------------------------------------------------------------
# 直接呼叫人工关键词(匹配后 AI 引导用户先描述问题,不计数)
# --------------------------------------------------------------------------
_CALL_HUMAN_KEYWORDS = [
"人工", "人工坐席", "转人工", "客服", "我要人工",
"找人工", "人工客服", "转接人工", "人工服务",
"找客服", "联系人工", "我要找人", "不要机器人",
"真人", "摇人", "找人",
]
# AI 引导话术(打招呼)
_GREETING_GUIDE = (
"你好!我是 IT 智能助手 🤖\n"
"请直接描述你遇到的 IT 问题,比如:\n"
"• 打印机连不上\n"
"• 电脑蓝屏了\n"
"• VPN 无法登录\n"
"我先帮你分析,搞不定再帮你转人工坐席~"
)
# AI 引导话术(呼叫人工)
_CALL_HUMAN_GUIDE = (
"别急~先告诉我你遇到了什么问题?\n"
"我先帮你排查一下,大部分问题我都能解决 💪\n"
"如果确实需要人工坐席,我会帮你转接的!"
)
# AI 未命中转人工话术
_AI_MISS_GUIDE = (
"🤖 AI 暂未学习到相关知识,正在为您转接 IT 坐席,请稍候..."
)
# --------------------------------------------------------------------------
# 非文本消息自动回复模板(图片引导,其余类型暂不支持)
# --------------------------------------------------------------------------
_IMAGE_REPLY = (
"收到您的截图 📷\n"
"请补充文字描述您遇到的问题,以便更快为您处理。\n"
"例如:\n"
"• 这是什么软件的报错截图?\n"
"• 您在操作什么时出现的?\n"
"• 错误信息的具体内容是什么?"
)
_NON_TEXT_REPLY_TEMPLATE = (
"暂不支持{type_name}消息 😅\n"
"请用文字描述您的问题,我会尽快为您处理。"
)
# AI 调用失败降级模板(使用 {topic} 占位符,运行时替换为用户消息摘要)
_FALLBACK_TEMPLATES = [
"收到!关于「{topic}」的问题,让我来帮你分析一下…\n\n"
"这类问题通常由以下原因导致:\n"
"1. 网络连接异常\n"
"2. 设备驱动问题\n"
"3. 系统配置错误\n\n"
"你可以先尝试重启设备,如果问题依旧,请告诉我具体的错误提示。",
"明白,关于「{topic}」的问题…\n\n"
"建议按以下步骤排查:\n"
"1. 检查网络是否正常\n"
"2. 确认相关服务是否启动\n"
"3. 查看是否有报错提示\n\n"
"如果以上步骤无法解决,请补充更多细节。",
]
@dataclass
class AIReplyResult:
"""AI 回复结果(统一返回结构)。
无论消息经过哪种处理路径(打招呼/呼叫人工/AI命中/AI未命中/降级),
都返回此结构,由调用方决定如何持久化和发送。
Attributes:
content: 回复内容
reply_type: 回复类型
- "greeting": 打招呼引导
- "call_human": 呼叫人工拦截引导
- "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
is_guidance: bool = False
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:
"""统一 AI 回复处理器。
封装打招呼检测、呼叫人工拦截、AI 调用、命中判断、计数规则、
转人工逻辑,供 H5 端和企微回调端(MessageRouter)共用,
确保两端行为完全一致。
处理流程(按优先级):
1. 检测打招呼 → 返回引导话术(不计数,不转人工)
2. 检测呼叫人工 → 返回拦截引导(不计数,不转人工)
3. 调用 Dify API
- 命中 → 返回 AI 回复(计数+1,不转人工)
- 未命中 → 返回转人工提示(不计数,转人工)
4. AI 调用异常 → 返回降级模板回复(不计数,不转人工)
计数规则(统一):
- 仅 AI 命中知识库时 ai_substantive_reply_count +1
- 打招呼/呼叫人工/未命中/降级 均不计数
"""
def __init__(self, ai_service: AIService):
"""初始化 AI 处理器。
Args:
ai_service: AI 服务实例(Dify API 封装),通常为应用级共享单例
"""
self.ai_service = ai_service
def is_greeting(self, content: str) -> bool:
"""检测是否为打招呼消息。
匹配规则:用户消息(小写化、去前后空格后)包含任意打招呼关键词。
Args:
content: 用户消息内容
Returns:
bool: 是否为打招呼
"""
text = content.strip().lower()
return any(kw in text for kw in _GREETING_KEYWORDS)
def is_call_human(self, content: str) -> bool:
"""检测是否为直接呼叫人工。
匹配规则:用户消息(小写化、去前后空格后)包含任意呼叫人工关键词。
拦截后引导用户先描述问题,避免直接转人工浪费坐席资源。
Args:
content: 用户消息内容
Returns:
bool: 是否为呼叫人工
"""
text = content.strip().lower()
return any(kw in text for kw in _CALL_HUMAN_KEYWORDS)
async def handle_message(
self,
content: str,
dify_conversation_id: Optional[str] = None,
user_id: Optional[str] = None,
conversation_id: Optional[str] = None,
db=None,
) -> AIReplyResult:
"""处理用户消息,返回统一的 AI 回复结果。
按照优先级依次检测:打招呼 → 呼叫人工 → 代答排除 → AI 调用。
每种路径返回不同的 reply_type,由调用方根据结果更新会话状态和计数。
Args:
content: 用户消息内容
dify_conversation_id: Dify 会话ID(用于多轮对话上下文)
user_id: 用户标识(用于 Dify 日志追溯)
conversation_id: 企微会话ID(用于代答排除检查,传入则启用排除检查)
db: 数据库会话(用于代答排除检查,传入则启用排除检查)
Returns:
AIReplyResult: 统一的 AI 回复结果
"""
# ==================================================================
# 1. 检测打招呼 → 引导描述问题,不计数,不转人工
# ==================================================================
if self.is_greeting(content):
logger.info(f"打招呼引导: user_id={user_id}")
return AIReplyResult(
content=_GREETING_GUIDE,
reply_type="greeting",
is_guidance=True,
should_count=False,
should_transfer=False,
dify_conversation_id=dify_conversation_id,
)
# ==================================================================
# 2. 检测呼叫人工 → 拦截引导,不计数,不转人工
# ==================================================================
if self.is_call_human(content):
logger.info(f"人工拦截引导: user_id={user_id}")
return AIReplyResult(
content=_CALL_HUMAN_GUIDE,
reply_type="call_human",
is_guidance=True,
should_count=False,
should_transfer=False,
dify_conversation_id=dify_conversation_id,
)
# ==================================================================
# 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(
message=content,
conversation_id=dify_conversation_id,
user_id=user_id,
)
# 提取 Dify 返回的 conversation_id(用于多轮对话上下文)
new_conv_id = ai_result.get("conversation_id") or dify_conversation_id
if ai_result["hit"]:
# AI 命中:使用 Dify 回复,计数+1
logger.info(
f"AI命中: user_id={user_id}, "
f"content_length={len(ai_result['content'])}"
)
return AIReplyResult(
content=ai_result["content"],
reply_type="ai_hit",
is_guidance=False,
should_count=True,
should_transfer=False,
dify_conversation_id=new_conv_id,
)
else:
# AI 未命中:转人工
logger.info(f"AI未命中转人工: user_id={user_id}")
return AIReplyResult(
content=_AI_MISS_GUIDE,
reply_type="ai_miss",
is_guidance=False,
should_count=False,
should_transfer=True,
dify_conversation_id=new_conv_id,
)
except Exception as e:
# ==============================================================
# 5. AI 调用异常:降级模板回复
# - 不计数(修复原 h5.py 降级误计数的 Bug)
# - 不转人工(降级是临时故障,用户可继续尝试)
# ==============================================================
logger.error(f"AI调用失败(降级模板回复): {e}")
topic = content.strip()[:15]
fallback_content = random.choice(_FALLBACK_TEMPLATES).format(topic=topic)
return AIReplyResult(
content=fallback_content,
reply_type="ai_fallback",
is_guidance=False,
should_count=False,
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,
)