v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化
This commit is contained in:
+450
-46
@@ -37,6 +37,9 @@ from app.services.routing_service import (
|
||||
)
|
||||
from app.services.vision_service import VisionService
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
from app.services.asset_recommend_service import get_asset_recommend_service
|
||||
from app.services.employee_profile_service import get_employee_profile_service
|
||||
from app.api.approval import APPROVAL_TEMPLATES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -213,6 +216,71 @@ async def _enrich_image_content(
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def _persist_and_push_solution(
|
||||
db,
|
||||
conversation,
|
||||
employee_id: str,
|
||||
solution,
|
||||
):
|
||||
"""处理图谱命中的解决方案(图谱查询结果)
|
||||
|
||||
做什么:
|
||||
1. 创建AI回复消息记录(图谱命中的解决方案)
|
||||
2. 通过WS推送给员工端
|
||||
|
||||
为什么:图谱命中的解决方案直接返回,不需要调用Dify
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
conversation: 会话对象
|
||||
employee_id: 员工ID
|
||||
solution: SolutionResult图谱查询结果
|
||||
"""
|
||||
from app.models.message import Message
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
|
||||
# 1. 创建AI回复消息记录
|
||||
message = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="ai",
|
||||
sender_id="graph",
|
||||
content=solution.solution,
|
||||
msg_type="text",
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# 更新会话计数
|
||||
conversation.ai_substantive_reply_count += 1
|
||||
conversation.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
# 2. 构建推送数据
|
||||
msg_data = {
|
||||
"id": str(message.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "graph",
|
||||
"sender_name": "智能助手",
|
||||
"content": solution.solution,
|
||||
"msg_type": "text",
|
||||
"created_at": message.created_at.isoformat(),
|
||||
"reply_source": "graph_hit", # 标记来源为图谱命中
|
||||
}
|
||||
|
||||
# 3. 通过WS推送给员工端
|
||||
try:
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply",
|
||||
"data": msg_data,
|
||||
})
|
||||
logger.info(
|
||||
f"图谱命中推送成功: employee={employee_id}, "
|
||||
f"solution={solution.action_name}"
|
||||
)
|
||||
except Exception as push_err:
|
||||
logger.error(f"图谱命中推送失败: {push_err}")
|
||||
|
||||
|
||||
async def _persist_and_push(
|
||||
db,
|
||||
conversation: Conversation,
|
||||
@@ -328,6 +396,12 @@ async def _persist_and_push_structured(
|
||||
result: get_structured_reply() 返回的结构化结果
|
||||
"""
|
||||
text = result.get("text", "")
|
||||
# ★ 防御性类型保护:确保 content 始终是 String
|
||||
# 如果 Dify 返回的 text 是 dict/list,WS 推送后前端会显示 [object Object]
|
||||
if not isinstance(text, str):
|
||||
import json as _json
|
||||
text = _json.dumps(text, ensure_ascii=False) if text else ""
|
||||
logger.warning(f"_persist_and_push_structured: text 非 String 类型,已转换: {text[:80]}...")
|
||||
action = result.get("action")
|
||||
options = result.get("options")
|
||||
hit = result.get("hit", False)
|
||||
@@ -352,18 +426,47 @@ async def _persist_and_push_structured(
|
||||
should_transfer = not hit
|
||||
|
||||
# 确定消息类型
|
||||
if is_structured and (options or action):
|
||||
# v2.4 修复:有 action(审批卡片)时,强制设置为 ai_structured
|
||||
# 确保前端能渲染审批卡片入口,不依赖 Dify 返回的 is_structured 字段
|
||||
if action or options or is_structured:
|
||||
msg_type = "ai_structured"
|
||||
else:
|
||||
msg_type = "text"
|
||||
|
||||
# v2.5 调试日志
|
||||
logger.info(f"[DEBUG] msg_type = {msg_type}, action = {bool(action)}, options = {bool(options)}, is_structured = {is_structured}")
|
||||
|
||||
# 构建 extra_data(存储 options 和 action 供前端渲染)
|
||||
extra_data = {}
|
||||
if options:
|
||||
extra_data["options"] = options
|
||||
# 为审批卡片注入标准化 card_data(替换原有的分散匹配逻辑)
|
||||
if action:
|
||||
approval_type = action.get("approval_type")
|
||||
title = action.get("title")
|
||||
|
||||
# v3.0 重构:委托 ApprovalMatcher 统一完成模板匹配 + 卡片构建
|
||||
from app.services.approval_matcher import get_approval_matcher
|
||||
matcher = get_approval_matcher()
|
||||
matched_card = matcher.match_and_build_card(approval_type, title)
|
||||
|
||||
if matched_card:
|
||||
# 注入 URL 供 approve-direct-card 兼容路径使用
|
||||
options = matched_card.get("options", [])
|
||||
if options:
|
||||
action["url"] = options[0].get("url", "")
|
||||
action["card_data"] = matched_card
|
||||
logger.info(f"[ApprovalMatcher] 匹配成功: {approval_type} -> card_type={matched_card.get('card_type')}")
|
||||
else:
|
||||
logger.warning(f"[ApprovalMatcher] 匹配失败: approval_type={approval_type}, title={title}")
|
||||
|
||||
extra_data["action"] = action
|
||||
|
||||
# ★ 调试日志:打印推送到前端的 extra_data 内容
|
||||
logger.info(f"[DEBUG] 推送到前端的 extra_data: {extra_data}")
|
||||
if extra_data.get("action"):
|
||||
logger.info(f"[DEBUG] extra_data.action.approval_type = {extra_data['action'].get('approval_type')}")
|
||||
|
||||
# 1. 存 AI 消息
|
||||
ai_message = Message(
|
||||
conversation_id=conversation.id,
|
||||
@@ -397,48 +500,86 @@ async def _persist_and_push_structured(
|
||||
await db.commit()
|
||||
|
||||
# 3. 推 ai_reply 给员工端(聊天气泡:text + options)
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply",
|
||||
"data": {
|
||||
"message_id": str(ai_message.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": text,
|
||||
"msg_type": msg_type,
|
||||
"extra_data": extra_data if extra_data else None,
|
||||
"is_guidance": False,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
# Phase 6A: 诊断阶段(前端可据此调整 UI/提示)
|
||||
"diagnosis_stage": diagnosis_stage,
|
||||
},
|
||||
})
|
||||
# 添加异常处理,避免整体失败
|
||||
try:
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply",
|
||||
"data": {
|
||||
"message_id": str(ai_message.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": text,
|
||||
"msg_type": msg_type,
|
||||
"extra_data": extra_data if extra_data else None,
|
||||
"is_guidance": False,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
# Phase 6A: 诊断阶段(前端可据此调整 UI/提示)
|
||||
"diagnosis_stage": diagnosis_stage,
|
||||
},
|
||||
})
|
||||
except Exception as emp_err:
|
||||
# 员工端推送失败不应该导致整个任务失败
|
||||
logger.warning(f"员工端 AI 回复推送失败(不影响坐席端): {emp_err}")
|
||||
|
||||
# 4. 推 dynamic_recommend 给员工端侧边栏(仅当 action 非空时)
|
||||
# 4. 推 dynamic_recommend 给员工端侧边栏(仅当 action 非空且不是审批类型时)
|
||||
# 与 ai_reply 同一时刻发出 → 零时间差到达
|
||||
if action:
|
||||
# v2.3 修改:审批类型只推送到消息气泡(左边),不推送到侧边栏(右边)
|
||||
if action and not action.get("approval_type"):
|
||||
# 为审批卡片注入运维平台跳转URL(实现免登录跳转)
|
||||
approval_type = action.get("approval_type")
|
||||
action_url = ""
|
||||
location = "运维平台"
|
||||
# 优先精确匹配:直接用 approval_type 查找模板
|
||||
if approval_type and approval_type in APPROVAL_TEMPLATES:
|
||||
template = APPROVAL_TEMPLATES[approval_type]
|
||||
action_url = template.get("url", "")
|
||||
location = template.get("location", "运维平台")
|
||||
# 关键字匹配:当精确匹配失败时,通过关键字查找模板
|
||||
# Dify返回的 approval_type 可能是中文分类名(如"账号权限申请"、"VPN账号申请")
|
||||
elif approval_type:
|
||||
for template_id, template in APPROVAL_TEMPLATES.items():
|
||||
keywords = template.get("keywords", [])
|
||||
# 检查 approval_type 是否包含任意一个关键字
|
||||
if any(kw.lower() in approval_type.lower() for kw in keywords):
|
||||
action_url = template.get("url", "")
|
||||
location = template.get("location", "运维平台")
|
||||
break
|
||||
|
||||
# v2.2 新增:根据 Dify 返回的 approval_type 设置 filtered_options
|
||||
filtered_options = []
|
||||
if approval_type:
|
||||
filtered_options = [approval_type]
|
||||
|
||||
recommend_data = {
|
||||
"recommend_id": f"rec_{ai_message.id}",
|
||||
"card_type": action.get("type", "approval_card"),
|
||||
"title": action.get("title", ""),
|
||||
"description": action.get("description", ""),
|
||||
"approval_type": action.get("approval_type"),
|
||||
"filtered_options": filtered_options, # v2.2: 精确匹配的选项
|
||||
"action_url": action_url, # 运维平台跳转URL
|
||||
"action_label": f"打开{location}" if action_url else "打开审批表单", # 按钮文字
|
||||
"location": location, # 平台名称
|
||||
"confidence": action.get("confidence", 0.85),
|
||||
"message_id": str(ai_message.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
}
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "dynamic_recommend",
|
||||
"data": recommend_data,
|
||||
})
|
||||
logger.info(
|
||||
f"动态推荐已推送: employee={employee_id}, "
|
||||
f"card_type={recommend_data['card_type']}, "
|
||||
f"title={recommend_data['title']}"
|
||||
)
|
||||
try:
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "dynamic_recommend",
|
||||
"data": recommend_data,
|
||||
})
|
||||
logger.info(
|
||||
f"动态推荐已推送: employee={employee_id}, "
|
||||
f"card_type={recommend_data['card_type']}, "
|
||||
f"title={recommend_data['title']}"
|
||||
)
|
||||
except Exception as rec_err:
|
||||
logger.warning(f"员工端动态推荐推送失败: {rec_err}")
|
||||
|
||||
# 5. 广播坐席端(new_message + conversation_updated)
|
||||
try:
|
||||
@@ -711,6 +852,147 @@ async def _handle_routing(
|
||||
return True
|
||||
|
||||
|
||||
async def _enrich_with_last_ai_context(db, conversation_id: str, content: str) -> str:
|
||||
"""为简短回复拼接对话上下文,弥补 Dify 工作流缺少「对话历史」节点。
|
||||
|
||||
v2.3 改进(相对于 v2.2):
|
||||
- 移除 15 字符硬限制 → 50 字符宽松阈值(问句/换行/长消息自动跳过)
|
||||
- 查询最近 10 条消息(用户+AI)→ 构建完整对话摘要,含用户原始问题
|
||||
- 不再依赖 extra_data.options 判断,对所有简短回复尝试拼接
|
||||
- 跳过刚保存的当前消息避免重复(当前内容已作为 query 单独传给 Dify)
|
||||
|
||||
触发条件:消息不含问号/换行、长度 <= 50 字符 → 可能是选项选择/简短回答。
|
||||
后续:Dify 工作流配置对话历史节点后,可将 `MAX_CONTEXT_LENGTH` 设为 0 来禁用此修复。
|
||||
|
||||
返回:拼接后的消息(如果不需要拼接则返回原内容)
|
||||
"""
|
||||
# 宽松的启发式判断:不含问号、不含换行、<= 50 字符 → 可能是简短回答
|
||||
if "?" in content or "?" in content or "\n" in content or len(content) > 50:
|
||||
return content
|
||||
|
||||
try:
|
||||
# 1. 查询最近 10 条消息(按时间倒序索取最新),含用户和 AI
|
||||
stmt = (
|
||||
select(Message.content, Message.sender_type, Message.created_at)
|
||||
.where(Message.conversation_id == conversation_id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = list(result.all())
|
||||
|
||||
if not rows or len(rows) < 2:
|
||||
return content # 消息太少,无法构建有意义上下文
|
||||
|
||||
# 2. 反转顺序:最早 → 最新
|
||||
rows.reverse()
|
||||
|
||||
# 3. 跳过最后一条员工消息 → 即刚刚保存的当前消息(避免在上下文中重复)
|
||||
# content 已作为 query 单独发给 Dify,不应出现在上下文中
|
||||
if rows and rows[-1][1] == "employee":
|
||||
rows = rows[:-1]
|
||||
|
||||
if len(rows) < 2:
|
||||
return content # 去掉当前消息后没剩几条,不拼接
|
||||
|
||||
# 4. 构建对话摘要(最多保留最近 8 条,避免 prompt 过长)
|
||||
context_lines = []
|
||||
for row_text, row_sender, _ in rows[-8:]:
|
||||
if not row_text:
|
||||
continue
|
||||
role = "用户" if row_sender == "employee" else "AI助手"
|
||||
context_lines.append(f"{role}: {row_text}")
|
||||
|
||||
if not context_lines:
|
||||
return content
|
||||
|
||||
context = "\n".join(context_lines)
|
||||
return (
|
||||
f"【对话上下文】\n{context}\n\n"
|
||||
f"请根据以上对话历史回答用户的以下消息:{content}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"上下文拼接失败,使用原消息: {e}")
|
||||
return content
|
||||
|
||||
|
||||
async def _push_asset_recommends(
|
||||
db,
|
||||
employee_id: str,
|
||||
message: str,
|
||||
dify_result: dict,
|
||||
):
|
||||
"""v3.0 资产推荐推送 - 独立于对话的运维触达通道
|
||||
|
||||
功能:
|
||||
1. L1: 从关键词匹配资产(与当前问题相关)
|
||||
2. L2: 从画像触发运维提醒(与问题无关)
|
||||
3. L3: 角色通用资源推荐
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
employee_id: 员工 ID
|
||||
message: 用户消息(用于关键词匹配)
|
||||
dify_result: Dify 返回结果(包含 intent 等信息)
|
||||
"""
|
||||
try:
|
||||
asset_service = get_asset_recommend_service()
|
||||
profile_service = get_employee_profile_service()
|
||||
|
||||
# 1. L1: 关键词匹配(从用户消息中提取关键词)
|
||||
l1_recs = asset_service.match_keywords(message)
|
||||
|
||||
# 2. L2+L3: 画像匹配(需要获取员工画像)
|
||||
# 为避免每次都调用第三方 API,先尝试获取画像
|
||||
# 画像获取失败时只推送 L1
|
||||
profile = None
|
||||
try:
|
||||
profile = await profile_service.get_profile(employee_id)
|
||||
profile_dict = {
|
||||
'huorong_version': profile.huorong_version,
|
||||
'huorong_virusdb_date': profile.huorong_virusdb_date,
|
||||
'huorong_offline_days': profile.huorong_offline_days,
|
||||
'unionsoft_patches_missing': profile.unionsoft_patches_missing,
|
||||
'unionsoft_violations': profile.unionsoft_violations,
|
||||
}
|
||||
l2_recs = asset_service.match_profile_triggers(profile_dict)
|
||||
|
||||
# L3: 角色通用推荐
|
||||
role = profile.position or ''
|
||||
l3_recs = asset_service.get_by_role(role)
|
||||
for rec in l3_recs:
|
||||
rec.layer = 'L3'
|
||||
rec.layer_label = '常用资源'
|
||||
rec.relevance = 'low'
|
||||
except Exception as e:
|
||||
logger.warning(f"[AssetRecommend] 获取画像失败: {e}")
|
||||
l2_recs = []
|
||||
l3_recs = []
|
||||
|
||||
# 3. 合并所有推荐(去重)
|
||||
all_recs = l1_recs + l2_recs + l3_recs
|
||||
|
||||
if not all_recs:
|
||||
logger.debug(f"[AssetRecommend] 无推荐: employee={employee_id}")
|
||||
return
|
||||
|
||||
# 4. 构建 WS 消息并推送(添加异常处理避免影响主流程)
|
||||
try:
|
||||
ws_msg = asset_service.build_ws_message(all_recs)
|
||||
await ws_manager.broadcast_to_employees([employee_id], ws_msg)
|
||||
logger.info(
|
||||
f"[AssetRecommend] 已推送: employee={employee_id}, "
|
||||
f"L1={len(l1_recs)}, L2={len(l2_recs)}, L3={len(l3_recs)}"
|
||||
)
|
||||
except Exception as ws_err:
|
||||
logger.warning(f"[AssetRecommend] WS推送失败(不影响主流程): {ws_err}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[AssetRecommend] 推送失败: {e}", exc_info=True)
|
||||
# 资产推荐失败不影响主对话流程
|
||||
|
||||
|
||||
async def process_h5_ai_reply(
|
||||
conversation_id: str,
|
||||
employee_id: str,
|
||||
@@ -753,9 +1035,19 @@ async def process_h5_ai_reply(
|
||||
factory = _get_session_factory()
|
||||
async with factory() as db:
|
||||
try:
|
||||
conversation = await db.get(Conversation, conversation_id)
|
||||
# 防御:会话刚创建时可能事务未提交,最多重试 3 次(每次 0.5s)
|
||||
conversation = None
|
||||
for attempt in range(3):
|
||||
conversation = await db.get(Conversation, conversation_id)
|
||||
if conversation:
|
||||
break
|
||||
if attempt < 2:
|
||||
await asyncio.sleep(0.5)
|
||||
# 刷新 session 以看到已提交的数据
|
||||
await db.rollback()
|
||||
|
||||
if not conversation:
|
||||
logger.warning(f"后台 AI 任务:会话不存在 {conversation_id}")
|
||||
logger.warning(f"后台 AI 任务:会话不存在(重试3次后) {conversation_id}")
|
||||
return
|
||||
|
||||
# === BYOD 关键词拦截(仅文本消息)===
|
||||
@@ -814,15 +1106,62 @@ async def process_h5_ai_reply(
|
||||
# 降级:使用原始 content,AI 会收到 "[图片] 截图" 这样的占位符
|
||||
# Dify 会回复"我收到了您的截图,请描述一下问题"
|
||||
|
||||
# === ★ v2.3 临时修复:Dify 对话历史缺失,为简短回复拼接上下文 ===
|
||||
# 问题:Dify 工作流未配置「对话历史」节点,conversation_id 传递了但 LLM 看不到历史
|
||||
# 改进:查询最近 10 条消息(用户+AI)构建完整对话摘要,含用户原始问题
|
||||
# 触发:消息短(<=50字符)、无问号、无换行 → 拼接后传给 Dify
|
||||
# 后续:Dify 工作流配置对话历史后可移除此修复
|
||||
enriched_content = await _enrich_with_last_ai_context(
|
||||
db, conversation_id, enriched_content
|
||||
)
|
||||
|
||||
# === ★ Neo4j 知识图谱查询(新增 v3.0)===
|
||||
# 做什么:在调用 Dify 之前先查询知识图谱
|
||||
# 为什么:简单问题可以直接从图谱返回解决方案,响应更快
|
||||
# 效果:简单问题响应从 3-15秒 → 毫秒级
|
||||
logger.info(f"图谱检查: msg_type={msg_type}, content_len={len(enriched_content) if enriched_content else 0}")
|
||||
if msg_type == "text" and enriched_content:
|
||||
try:
|
||||
from app.services.graph_query_service import get_graph_query_service
|
||||
from app.services.neo4j_client import get_neo4j_client
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
if neo4j_client:
|
||||
graph_service = await get_graph_query_service(neo4j_client)
|
||||
if graph_service:
|
||||
solution = await graph_service.find_solution_by_question(
|
||||
enriched_content
|
||||
)
|
||||
else:
|
||||
solution = None
|
||||
if solution:
|
||||
logger.info(
|
||||
f"图谱命中: question={enriched_content[:30]}, "
|
||||
f"solution={solution.action_name}"
|
||||
)
|
||||
# 直接返回图谱解决方案,跳过 Dify 调用
|
||||
await _persist_and_push_solution(
|
||||
db, conversation, employee_id, solution
|
||||
)
|
||||
return
|
||||
except Exception as graph_err:
|
||||
# 图谱查询失败不阻断,继续原有 Dify 流程
|
||||
import traceback
|
||||
logger.warning(f"图谱查询异常(降级继续): {graph_err}\n{traceback.format_exc()}")
|
||||
|
||||
# === ★ v2.0 结构化 AI 回复(替代流式)===
|
||||
# 1. 立即推送 "正在思考..." 指示器
|
||||
# 同时推给员工(气泡动画)和坐席(状态指示)
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_thinking",
|
||||
"data": {
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
})
|
||||
try:
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_thinking",
|
||||
"data": {
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
})
|
||||
except Exception as thinking_err:
|
||||
logger.warning(f"推送 AI 思考指示器失败: {thinking_err}")
|
||||
|
||||
# 坐席端也通知:AI 正在处理此会话的消息
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
@@ -858,15 +1197,40 @@ async def process_h5_ai_reply(
|
||||
result = await asyncio.wait_for(
|
||||
ai_handler.ai_service.get_structured_reply(
|
||||
message=enriched_content,
|
||||
conversation_id=dify_conversation_id,
|
||||
conversation_id=dify_conversation_id or conversation.dify_conversation_id,
|
||||
user_id=employee_id,
|
||||
),
|
||||
timeout=30,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# 30 秒硬超时 → 建议转人工
|
||||
# v3.0: Dify 超时 → 关键词降级匹配
|
||||
thinking_task.cancel()
|
||||
logger.warning(f"Dify 30 秒超时: conversation={conversation_id}")
|
||||
logger.warning(f"Dify 30 秒超时: conversation={conversation_id},尝试关键词降级")
|
||||
|
||||
from app.services.approval_matcher import get_approval_matcher
|
||||
matcher = get_approval_matcher()
|
||||
matched_card = matcher.match_by_keywords(content)
|
||||
|
||||
if matched_card:
|
||||
logger.info(f"[Fallback] 关键词降级成功: {matched_card.get('title')}")
|
||||
fallback_result = {
|
||||
"text": f"我来帮您提交{matched_card.get('title', '审批')},请点击下方卡片。",
|
||||
"action": {"card_data": matched_card, "url": matched_card.get("options", [{}])[0].get("url", "")} if matched_card.get("options") else None,
|
||||
"options": None,
|
||||
"hit": True,
|
||||
"conversation_id": conversation.dify_conversation_id,
|
||||
"is_structured": True,
|
||||
"diagnosis_stage": None,
|
||||
"response_time_ms": 0,
|
||||
"source": "keyword_fallback",
|
||||
}
|
||||
try:
|
||||
await _persist_and_push_structured(db, conversation, employee_id, fallback_result)
|
||||
return
|
||||
except Exception as fallback_err:
|
||||
logger.error(f"[Fallback] 降级推送失败: {fallback_err}")
|
||||
|
||||
# 降级也失败 → 建议转人工
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply_failed",
|
||||
"data": {
|
||||
@@ -883,13 +1247,53 @@ async def process_h5_ai_reply(
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# v3.1: Dify 返回但无 action(如诊断 escalate/回复"AI服务暂时不可用")→ 关键词降级
|
||||
# 这是对 v3.0 的补充:v3.0 只在 asyncio.TimeoutError 触发降级,但 Dify LLM 自身可能误判
|
||||
if not result.get("action"):
|
||||
from app.services.approval_matcher import get_approval_matcher
|
||||
matcher = get_approval_matcher()
|
||||
matched_card = matcher.match_by_keywords(content)
|
||||
if matched_card:
|
||||
logger.info(f"[Fallback-v3.1] Dify 无action但关键词命中: {matched_card.get('title')}")
|
||||
result = {
|
||||
"text": f"我来帮您提交{matched_card.get('title', '审批')},请点击下方卡片。",
|
||||
"action": {
|
||||
"type": "approval_card",
|
||||
"card_data": matched_card,
|
||||
"url": matched_card.get("options", [{}])[0].get("url", "") if matched_card.get("options") else "",
|
||||
},
|
||||
"options": None,
|
||||
"hit": True,
|
||||
"conversation_id": result.get("conversation_id") or conversation.dify_conversation_id,
|
||||
"is_structured": True,
|
||||
"diagnosis_stage": "recommending",
|
||||
"response_time_ms": result.get("response_time_ms", 0),
|
||||
"source": "keyword_fallback_v3",
|
||||
}
|
||||
|
||||
# 5. 持久化 + 双 WS 推送(ai_reply + dynamic_recommend)
|
||||
await _persist_and_push_structured(
|
||||
db, conversation, employee_id, result,
|
||||
)
|
||||
# 添加单独异常处理,避免影响主流程
|
||||
try:
|
||||
await _persist_and_push_structured(
|
||||
db, conversation, employee_id, result,
|
||||
)
|
||||
except Exception as persist_err:
|
||||
logger.error(f"[Persist] AI回复持久化失败: {persist_err}", exc_info=True)
|
||||
|
||||
# 6. v3.0 资产推荐推送(L1/L2/L3 分层)
|
||||
# 独立于对话的运维触达通道
|
||||
try:
|
||||
await _push_asset_recommends(
|
||||
db, employee_id, content, result,
|
||||
)
|
||||
except Exception as asset_err:
|
||||
logger.error(f"[Asset] 资产推荐推送失败: {asset_err}", exc_info=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"后台 AI 任务异常: {e}", exc_info=True)
|
||||
import traceback
|
||||
# 记录完整的堆栈跟踪信息
|
||||
tb_str = traceback.format_exc()
|
||||
logger.error(f"后台 AI 任务异常: {e}\n堆栈跟踪:\n{tb_str}", exc_info=True)
|
||||
try:
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply_failed",
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
资产推荐定时任务
|
||||
|
||||
功能:
|
||||
1. 每日运维提醒推送(L2)
|
||||
2. 画像缓存预热
|
||||
3. 新员工欢迎推送(L3)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 定时任务调度器实例
|
||||
scheduler = AsyncIOSScheduler()
|
||||
|
||||
|
||||
def setup_scheduled_tasks():
|
||||
"""配置定时任务"""
|
||||
|
||||
# 每日 9:00 运维提醒
|
||||
scheduler.add_job(
|
||||
daily_maintenance_push,
|
||||
trigger=CronTrigger(hour=9, minute=0),
|
||||
id='daily_maintenance_push',
|
||||
name='每日运维提醒推送',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# 每小时画像缓存刷新
|
||||
scheduler.add_job(
|
||||
hourly_profile_sync,
|
||||
trigger=CronTrigger(minute=0),
|
||||
id='hourly_profile_sync',
|
||||
name='每小时员工画像同步',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# 每天 8:55 检查新员工
|
||||
scheduler.add_job(
|
||||
check_new_employees,
|
||||
trigger=CronTrigger(hour=8, minute=55),
|
||||
id='check_new_employees',
|
||||
name='新员工欢迎检查',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
logger.info(f"[Scheduler] 已配置 {len(scheduler.get_jobs())} 个定时任务")
|
||||
|
||||
|
||||
async def daily_maintenance_push():
|
||||
"""每日运维提醒推送"""
|
||||
|
||||
logger.info("[Scheduler] 开始执行每日运维提醒推送")
|
||||
|
||||
from app.services.asset_recommend_service import get_asset_recommend_service
|
||||
from app.services.employee_profile_service import get_employee_profile_service
|
||||
|
||||
asset_service = get_asset_recommend_service()
|
||||
profile_service = get_employee_profile_service()
|
||||
|
||||
# 获取全部员工(分页)
|
||||
# TODO: 实现分页获取员工列表
|
||||
# page = 1
|
||||
# while True:
|
||||
# employees = await get_employees_paginated(page, 100)
|
||||
# if not employees:
|
||||
# break
|
||||
#
|
||||
# for emp in employees:
|
||||
# try:
|
||||
# profile = await profile_service.get_profile(emp.id)
|
||||
# profile_dict = {
|
||||
# 'huorong_version': profile.huorong_version,
|
||||
# 'huorong_virusdb_date': profile.huorong_virusdb_date,
|
||||
# 'huorong_offline_days': profile.huorong_offline_days,
|
||||
# 'unionsoft_patches_missing': profile.unionsoft_patches_missing,
|
||||
# 'unionsoft_violations': profile.unionsoft_violations,
|
||||
# }
|
||||
# l2_recs = asset_service.match_profile_triggers(profile_dict)
|
||||
#
|
||||
# if l2_recs:
|
||||
# ws_msg = asset_service.build_ws_message(l2_recs)
|
||||
# from app.services.ws_manager import manager as ws_manager
|
||||
# await ws_manager.broadcast_to_employees([emp.id], ws_msg)
|
||||
#
|
||||
# except Exception as e:
|
||||
# logger.error(f"[Scheduler] 推送失败: {emp.id}, {e}")
|
||||
#
|
||||
# page += 1
|
||||
|
||||
logger.info("[Scheduler] 每日运维提醒推送完成 (TODO: 实现员工列表获取)")
|
||||
|
||||
|
||||
async def hourly_profile_sync():
|
||||
"""每小时同步员工画像缓存"""
|
||||
|
||||
logger.info("[Scheduler] 开始同步员工画像缓存")
|
||||
|
||||
try:
|
||||
profile_service = get_employee_profile_service()
|
||||
deleted = await profile_service.clear_expired_cache()
|
||||
logger.info(f"[Scheduler] 画像缓存同步完成,清理 {deleted} 条")
|
||||
except Exception as e:
|
||||
logger.error(f"[Scheduler] 画像缓存同步失败: {e}")
|
||||
|
||||
|
||||
async def check_new_employees():
|
||||
"""检查新员工并发送欢迎"""
|
||||
|
||||
logger.info("[Scheduler] 检查新员工")
|
||||
|
||||
# TODO: 实现新员工检测逻辑
|
||||
# 获取过去 24 小时入职的员工
|
||||
# new_employees = await get_new_employees(days=1)
|
||||
#
|
||||
# for emp in new_employees:
|
||||
# asset_service = get_asset_recommend_service()
|
||||
# role_recs = asset_service.get_by_role('new_employee')
|
||||
#
|
||||
# if role_recs:
|
||||
# ws_msg = asset_service.build_ws_message(role_recs)
|
||||
# from app.services.ws_manager import manager as ws_manager
|
||||
# await ws_manager.broadcast_to_employees([emp.id], ws_msg)
|
||||
|
||||
logger.info("[Scheduler] 新员工检查完成 (TODO: 实现)")
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""启动定时任务调度器"""
|
||||
if not scheduler.running:
|
||||
setup_scheduled_tasks()
|
||||
scheduler.start()
|
||||
logger.info("定时任务调度器已启动")
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""停止定时任务调度器"""
|
||||
if scheduler.running:
|
||||
scheduler.shutdown()
|
||||
logger.info("定时任务调度器已停止")
|
||||
@@ -0,0 +1,193 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — Token异常检测定时任务
|
||||
# =============================================================================
|
||||
# 说明:定时检测Token异常使用行为:
|
||||
# 1. 同一Token在多个不同IP使用(可能凭证泄露)
|
||||
# 2. 检测时间窗口:1小时内
|
||||
# 3. 告警阈值:>=3个不同IP
|
||||
# 运行频率:每5分钟执行一次
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import settings
|
||||
from app.services.token_service import TokenService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 异常检测配置
|
||||
TOKEN_ANOMALY_IP_THRESHOLD = 3 # 同一Token使用不同IP数量阈值
|
||||
TOKEN_ANOMALY_TIME_WINDOW = 3600 # 检测时间窗口(秒)
|
||||
|
||||
# Redis Key前缀
|
||||
TOKEN_IP_PREFIX = "token_ip:"
|
||||
|
||||
|
||||
async def detect_token_ip_anomaly():
|
||||
"""检测Token异常使用行为。
|
||||
|
||||
检测逻辑:
|
||||
1. 扫描所有 token_ip:* 的Key
|
||||
2. 解析IP列表,统计不同IP数量
|
||||
3. 超过阈值则触发告警
|
||||
"""
|
||||
try:
|
||||
# 创建Redis客户端
|
||||
redis_client = settings.create_redis_client()
|
||||
token_service = TokenService(redis_client)
|
||||
|
||||
# 扫描所有Token IP记录
|
||||
cursor = 0
|
||||
anomaly_count = 0
|
||||
anomalies = []
|
||||
|
||||
while True:
|
||||
cursor, keys = await redis_client.scan(
|
||||
cursor=cursor,
|
||||
match=f"{TOKEN_IP_PREFIX}*",
|
||||
count=100
|
||||
)
|
||||
|
||||
for key in keys:
|
||||
# 解析key获取token_hash
|
||||
key_str = key.decode("utf-8") if isinstance(key, bytes) else key
|
||||
token_hash = key_str.replace(f"{TOKEN_IP_PREFIX}", "")
|
||||
|
||||
# 获取IP记录
|
||||
data = await redis_client.get(key)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
data_str = data.decode("utf-8") if isinstance(data, bytes) else data
|
||||
|
||||
# 解析IP列表
|
||||
ips = set()
|
||||
for entry in data_str.split(","):
|
||||
if "@" in entry:
|
||||
ip, _ = entry.rsplit("@", 1)
|
||||
ips.add(ip)
|
||||
|
||||
# 检测异常
|
||||
if len(ips) >= TOKEN_ANOMALY_IP_THRESHOLD:
|
||||
anomaly_count += 1
|
||||
anomalies.append({
|
||||
"token_hash": token_hash,
|
||||
"ip_count": len(ips),
|
||||
"ips": list(ips),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
logger.warning(
|
||||
f"检测到Token异常使用: token_hash={token_hash}, "
|
||||
f"ip_count={len(ips)}, ips={list(ips)}"
|
||||
)
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
# 发送告警
|
||||
if anomalies:
|
||||
await _send_anomaly_alert(anomalies)
|
||||
|
||||
logger.info(f"Token异常检测完成: 检测到 {anomaly_count} 个异常")
|
||||
|
||||
await redis_client.aclose()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token异常检测任务执行失败: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def _send_anomaly_alert(anomalies: list):
|
||||
"""发送Token异常告警。
|
||||
|
||||
通过企微机器人发送告警消息。
|
||||
|
||||
Args:
|
||||
anomalies: 异常列表
|
||||
"""
|
||||
if not anomalies:
|
||||
return
|
||||
|
||||
# 检查是否配置了webhook
|
||||
webhook = getattr(settings, "content_audit_webhook", None)
|
||||
if not webhook:
|
||||
logger.warning("未配置 content_audit_webhook,跳过告警")
|
||||
return
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
# 构建告警消息
|
||||
lines = ["🚨 **Token异常使用告警**\n"]
|
||||
lines.append(f"**检测时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
lines.append(f"**异常数量**: {len(anomalies)}")
|
||||
lines.append("\n**异常详情:**")
|
||||
|
||||
for i, a in enumerate(anomalies[:5], 1): # 最多显示5条
|
||||
ips_str = ", ".join(a["ips"][:3])
|
||||
if len(a["ips"]) > 3:
|
||||
ips_str += f" ... (+{len(a['ips']) - 3} more)"
|
||||
lines.append(f"{i}. Token: `{a['token_hash'][:8]}...`")
|
||||
lines.append(f" IP数量: {a['ip_count']}, IPs: {ips_str}")
|
||||
|
||||
if len(anomalies) > 5:
|
||||
lines.append(f"\n... 还有 {len(anomalies) - 5} 条异常")
|
||||
|
||||
lines.append("\n⚠️ **建议**: 立即检查是否为凭证泄露,必要时禁用相关账户")
|
||||
|
||||
content = "\n".join(lines)
|
||||
|
||||
# 发送企微机器人消息
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
await client.post(
|
||||
webhook,
|
||||
json={
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"content": content
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Token异常告警已发送: {len(anomalies)} 条")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送Token异常告警失败: {e}")
|
||||
|
||||
|
||||
async def test_token_anomaly_detection():
|
||||
"""测试用:模拟Token多IP使用场景。
|
||||
|
||||
创建测试数据,验证检测逻辑。
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
redis_client = settings.create_redis_client()
|
||||
token_service = TokenService(redis_client)
|
||||
|
||||
# 生成测试token
|
||||
test_token = "test_token_anomaly_12345"
|
||||
token_hash = hashlib.sha256(test_token.encode()).hexdigest()[:16]
|
||||
|
||||
# 模拟同一token使用3个不同IP
|
||||
key = f"{TOKEN_IP_PREFIX}{token_hash}"
|
||||
test_data = (
|
||||
"192.168.1.100@2026-07-14T10:00:00,"
|
||||
"192.168.1.101@2026-07-14T10:05:00,"
|
||||
"192.168.1.102@2026-07-14T10:10:00"
|
||||
)
|
||||
|
||||
await redis_client.setex(key, 3600, test_data)
|
||||
logger.info(f"测试数据已创建: key={key}, data={test_data}")
|
||||
|
||||
await redis_client.aclose()
|
||||
logger.info("测试数据创建完成,请运行检测任务验证")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
# 直接运行时执行测试
|
||||
asyncio.run(test_token_anomaly_detection())
|
||||
Reference in New Issue
Block a user