Files

290 lines
12 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__)
# --------------------------------------------------------------------------
# 打招呼关键词(匹配后 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 调用异常,降级模板回复
is_guidance: 是否为引导类消息(打招呼或呼叫人工),前端据此决定 UI 展示
should_count: 是否应增加 ai_substantive_reply_count(仅 AI 命中时为 True
should_transfer: 是否应转人工(状态改为 queued)
dify_conversation_id: Dify 会话ID(用于多轮对话上下文,AI 命中/未命中时更新)
"""
content: str
reply_type: str
is_guidance: bool = False
should_count: bool = False
should_transfer: bool = False
dify_conversation_id: Optional[str] = None
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,
) -> AIReplyResult:
"""处理用户消息,返回统一的 AI 回复结果。
按照优先级依次检测:打招呼 → 呼叫人工 → AI 调用。
每种路径返回不同的 reply_type,由调用方根据结果更新会话状态和计数。
Args:
content: 用户消息内容
dify_conversation_id: Dify 会话ID(用于多轮对话上下文)
user_id: 用户标识(用于 Dify 日志追溯)
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. 调用 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:
# ==============================================================
# 4. 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,
)