Files
iot-device-management-service/app/services/agent_dispatcher.py

486 lines
20 KiB
Python
Raw 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.

"""
交互Agent调度器
基于 LangGraph StateGraph 的企微交互 Agent。
企微入口适配层处理图片上传、VLM分析等企微特有逻辑
核心对话由 LangGraph 图处理。
"""
import json
import time
from typing import Dict, List, Optional
from openai import AsyncOpenAI
from app.config import settings
from app.services.session_manager import get_session_manager
from app.utils.logger import logger
class AgentDispatcher:
"""交互Agent调度器单例"""
def __init__(self):
self._vlm_client: Optional[AsyncOpenAI] = None
self._enabled = False
self._graph = None
self._pending_images: Dict[str, List[str]] = {}
def init(self, config):
"""初始化Agent"""
self._enabled = config.enabled and bool(config.vlm_api_key)
if not self._enabled:
logger.info("交互Agent未启用AGENT_ENABLED=false 或缺少 API Key")
return
# VLM 客户端(图片分析专用)
self._vlm_client = AsyncOpenAI(
api_key=config.vlm_api_key,
base_url=config.vlm_base_url,
)
from app.services.agent.graph import create_default_graph
self._graph = create_default_graph()
logger.info(f"交互Agent已启用(LangGraph): model={config.model}")
@property
def enabled(self) -> bool:
return self._enabled
# ==================== 消息入口 ====================
async def handle_message(self, user_id: str, content: str) -> str:
"""处理文字消息"""
if not self._enabled:
return "AI助手未启用请联系管理员配置。"
self._pending_images[user_id] = []
session = get_session_manager().get(user_id)
normalized_content = content.strip()
try:
reply = self._maybe_start_manual_order_from_text(session, normalized_content)
if reply is None:
reply = await self._handle_manual_order_message(user_id, session, normalized_content)
if reply is None:
reply = await self._langgraph_chat(user_id, content)
except Exception as e:
logger.error(f"Agent对话失败: {e}", exc_info=True)
reply = "抱歉AI助手暂时无法响应请稍后重试。"
# 发送待发图片
pending = self._pending_images.pop(user_id, [])
if pending:
await self._send_images_to_user(user_id, pending)
return reply
async def handle_image(self, user_id: str, media_id: str) -> str:
"""处理图片消息"""
if not self._enabled:
return "AI助手未启用请联系管理员配置。"
session = get_session_manager().get(user_id)
# 1. 下载图片
from app.services.wechat_service import get_wechat_service
wechat = get_wechat_service()
image_data = await wechat.download_media(media_id)
if not image_data:
return "图片下载失败,请重新发送。"
# 2. 上传 COS 持久化
from app.services.oss_storage import get_oss_storage
oss = get_oss_storage()
object_key = f"agent/{user_id}/{int(time.time())}.jpg"
try:
oss.upload_file(image_data, object_key, content_type="image/jpeg")
except Exception as e:
logger.error(f"Agent图片上传COS失败: {e}")
return "图片保存失败,请重新发送。"
permanent_url = oss.get_permanent_url(object_key)
presign_url = oss.get_presigned_url(object_key)
if session.state == "waiting_manual_order_image":
del presign_url
return await self._start_manual_order_flow(session, permanent_url)
# 3. 检查用户是否有待处理工单
handling_alarm_id = self._find_handling_alarm(user_id)
if handling_alarm_id:
session.pending_images.append(permanent_url)
reply = f"收到图片,是否作为【告警 {handling_alarm_id[:20]}...】的处理结果提交?\n回复「是」确认提交,或继续发送更多图片。"
session.pending_alarm_id = handling_alarm_id
return reply
if session.state in {
"waiting_manual_order_area",
"waiting_manual_order_remark",
"waiting_manual_order_confirm",
} and session.pending_manual_order_images:
session.pending_manual_order_images.append(permanent_url)
if session.state == "waiting_manual_order_area":
return (
f"已追加图片,当前共 {len(session.pending_manual_order_images)} 张。\n"
f"请选择区域:\n{self._format_area_options(session.pending_manual_order_area_options)}\n"
"请回复区域编号。"
)
if session.state == "waiting_manual_order_remark":
return (
f"已追加图片,当前共 {len(session.pending_manual_order_images)} 张。\n"
"请继续补充备注信息,可不填,回复“无”即可。"
)
return (
f"已追加图片,当前共 {len(session.pending_manual_order_images)} 张。\n"
"请回复“确认”创建工单,或回复“取消”放弃。"
)
del presign_url
return await self._start_manual_order_flow(session, permanent_url)
# ==================== LangGraph 对话 ====================
async def _langgraph_chat(self, user_id: str, content: str) -> str:
"""LangGraph 图调用"""
config = {
"configurable": {
"thread_id": f"wechat-{user_id}",
"user_id": user_id,
}
}
result = await self._graph.ainvoke(
{
"messages": [{"role": "user", "content": content}],
"user_id": user_id,
"pending_images": [],
"user_uploaded_images": [],
},
config=config,
)
# 从工具返回中提取截图 URLget_alarm_detail 返回的 snapshot_url
self._extract_pending_images(user_id, result)
# 获取最终回复
last_msg = result["messages"][-1]
reply = last_msg.content if hasattr(last_msg, "content") else str(last_msg)
return reply.strip() if reply else "处理完成"
def _extract_pending_images(self, user_id: str, result):
"""从 LangGraph 结果中提取需要发送的截图"""
for msg in result.get("messages", []):
if hasattr(msg, "type") and msg.type == "tool" and msg.name == "get_alarm_detail":
try:
data = json.loads(msg.content) if isinstance(msg.content, str) else msg.content
url = data.get("snapshot_url", "")
if url:
if user_id not in self._pending_images:
self._pending_images[user_id] = []
self._pending_images[user_id].append(url)
except Exception:
pass
# ==================== 共用方法 ====================
async def _start_manual_order_flow(self, session, image_url: str) -> str:
"""启动手动工单创建流程。"""
session.pending_manual_order_images.append(image_url)
session.pending_manual_order_remark = ""
session.pending_manual_order_area_id = ""
session.pending_manual_order_area_name = ""
session.pending_manual_order_area_options = self._list_notify_areas()
if not session.pending_manual_order_area_options:
session.pending_manual_order_area_options = [self._get_demo_area()]
session.state = "waiting_manual_order_area"
return (
"已收到图片,准备创建手动工单。\n"
f"请选择区域:\n{self._format_area_options(session.pending_manual_order_area_options)}\n"
"请回复区域编号。"
)
@staticmethod
def _maybe_start_manual_order_from_text(session, content: str) -> Optional[str]:
"""用户明确提出创建工单时,优先进入手动建单流程。"""
if session.state != "idle":
return None
trigger_phrases = (
"创建工单",
"新建工单",
"手动工单",
"手动上报",
"我要创建工单",
"我要上报",
"上报工单",
)
if not any(phrase in content for phrase in trigger_phrases):
return None
session.reset()
session.state = "waiting_manual_order_image"
return "请先上传现场图片,我会在收到图片后引导您选择区域并补充备注。"
async def _handle_manual_order_message(self, user_id: str, session, content: str) -> Optional[str]:
"""处理手动工单创建状态机。"""
if session.state == "waiting_manual_order_image":
if content in {"取消", "算了", "不用了"}:
session.reset()
return "已取消本次手动工单创建。"
return "请先上传现场图片。若不需要创建工单,回复“取消”即可。"
if session.state == "waiting_manual_order_area":
area = self._match_area_option(content, session.pending_manual_order_area_options)
if not area:
return (
"未识别到有效区域,请回复区域编号。\n"
f"{self._format_area_options(session.pending_manual_order_area_options)}"
)
assignees = self._get_area_assignees(area["area_id"])
if not assignees and area["area_id"] != self._get_demo_area()["area_id"]:
return f"区域【{area['area_name']}】当前未绑定责任人,请重新选择其他区域。"
session.pending_manual_order_area_id = area["area_id"]
session.pending_manual_order_area_name = area["area_name"]
session.state = "waiting_manual_order_remark"
return f"已选择区域:【{area['area_name']}】。\n请补充备注信息,可不填,回复“无”即可。"
if session.state == "waiting_manual_order_remark":
session.pending_manual_order_remark = "" if content in {"", "没有", "none", "None"} else content
session.state = "waiting_manual_order_confirm"
assignees = self._get_area_assignees(session.pending_manual_order_area_id)
if not assignees and session.pending_manual_order_area_id == self._get_demo_area()["area_id"]:
assignees = [{"person_name": "演示用户", "wechat_uid": user_id, "role": "demo"}]
assignee_names = "".join(person["person_name"] for person in assignees)
remark = session.pending_manual_order_remark or ""
return (
"请确认是否创建工单:\n"
f"区域:【{session.pending_manual_order_area_name}\n"
f"备注:{remark}\n"
f"图片:{len(session.pending_manual_order_images)}\n"
f"派发对象:{assignee_names}\n"
"回复“确认”创建,回复“取消”放弃。"
)
if session.state == "waiting_manual_order_confirm":
if content in {"取消", "算了", "不用了"}:
session.reset()
return "已取消本次手动工单创建。"
if content not in {"确认", "", "创建", "提交"}:
return "请回复“确认”创建工单,或回复“取消”放弃。"
result = await self._create_manual_order(user_id, session)
session.reset()
return result
return None
@staticmethod
def _list_notify_areas() -> List[Dict[str, str]]:
from app.models import NotifyArea, get_session
db = get_session()
try:
areas = (
db.query(NotifyArea)
.filter(NotifyArea.enabled == 1)
.order_by(NotifyArea.area_name.asc())
.all()
)
return [
{"index": str(idx), "area_id": area.area_id, "area_name": area.area_name}
for idx, area in enumerate(areas, start=1)
]
finally:
db.close()
@staticmethod
def _get_demo_area() -> Dict[str, str]:
return {"index": "1", "area_id": "demo-area", "area_name": "\u6f14\u793a\u533a\u57df"}
@staticmethod
def _format_area_options(areas: List[Dict[str, str]]) -> str:
return "\n".join(f"{item['index']}. {item['area_name']}" for item in areas)
@staticmethod
def _match_area_option(content: str, areas: List[Dict[str, str]]) -> Optional[Dict[str, str]]:
normalized = content.strip()
for item in areas:
if normalized == item["index"] or normalized == item["area_name"]:
return item
return None
@staticmethod
def _get_area_assignees(area_id: str) -> List[Dict[str, str]]:
from app.models import AreaPersonBinding, get_session
db = get_session()
try:
persons = (
db.query(AreaPersonBinding)
.filter(
AreaPersonBinding.area_id == area_id,
AreaPersonBinding.enabled == 1,
)
.order_by(AreaPersonBinding.notify_level.asc(), AreaPersonBinding.id.asc())
.all()
)
return [
{
"person_name": person.person_name,
"wechat_uid": person.wechat_uid,
"role": person.role,
}
for person in persons
]
finally:
db.close()
async def _create_manual_order(self, user_id: str, session) -> str:
"""创建手动工单并通知区域绑定人员。"""
assignees = self._get_area_assignees(session.pending_manual_order_area_id)
if not assignees and session.pending_manual_order_area_id != self._get_demo_area()["area_id"]:
return f"区域【{session.pending_manual_order_area_name}】当前未绑定责任人,工单未创建。"
if not assignees:
assignees = [{"person_name": "演示用户", "wechat_uid": user_id, "role": "demo"}]
from app.services.wechat_service import get_wechat_service
from app.services.work_order_service import get_work_order_service
area_name = session.pending_manual_order_area_name
remark = session.pending_manual_order_remark or ""
title = f"【手动上报】{area_name}异常情况"
description_lines = [
"来源:企微手动上报",
f"上报人:{user_id}",
f"区域:{area_name}",
f"备注:{remark}",
f"图片数量:{len(session.pending_manual_order_images)}",
]
if session.pending_manual_order_images:
description_lines.append("图片链接:")
description_lines.extend(session.pending_manual_order_images)
attachments = [
{"type": "image", "url": image_url}
for image_url in session.pending_manual_order_images
]
primary_assignee = assignees[0]
work_order = get_work_order_service().create_work_order(
title=title,
description="\n".join(description_lines),
priority="medium",
assignee_uid=primary_assignee["wechat_uid"],
assignee_name=primary_assignee["person_name"],
attachments=attachments,
department=area_name,
)
if not work_order:
return "工单创建失败,请稍后重试。"
notify_message = (
f"收到新的手动工单:\n"
f"工单号:{work_order.order_no}\n"
f"区域:{area_name}\n"
f"备注:{remark}"
)
wechat = get_wechat_service()
for assignee in assignees:
try:
await wechat.send_text_message(assignee["wechat_uid"], notify_message)
except Exception as e:
logger.error(
f"手动工单通知失败: order={work_order.order_no}, "
f"user={assignee['wechat_uid']}, error={e}"
)
return (
f"工单已创建。\n"
f"工单号:{work_order.order_no}\n"
f"区域:【{area_name}\n"
f"已派发给:{''.join(person['person_name'] for person in assignees)}"
)
async def _analyze_image(self, image_url: str, prompt: str) -> Dict:
"""VLM 分析图片内容"""
try:
resp = await self._vlm_client.chat.completions.create(
model=settings.agent.vlm_model,
temperature=0.1,
messages=[
{"role": "system", "content": prompt},
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": image_url}},
{"type": "text", "text": "请分析这张图片"},
]},
],
timeout=settings.agent.vlm_timeout,
)
text = resp.choices[0].message.content.strip()
if "```" in text:
text = text.split("```")[1].strip()
if text.startswith("json"):
text = text[4:].strip()
return json.loads(text)
except Exception as e:
logger.error(f"VLM图片分析失败: {e}")
return {"has_anomaly": False, "description": "", "alarm_type": ""}
async def _send_images_to_user(self, user_id: str, image_urls: List[str]):
"""通过企微发送图片消息给用户"""
from app.services.wechat_service import get_wechat_service
wechat = get_wechat_service()
if not wechat.enabled:
return
for url in image_urls:
try:
media_id = await wechat.upload_media_from_url(url)
if media_id:
access_token = await wechat._get_access_token()
import httpx
msg = {
"touser": user_id,
"msgtype": "image",
"agentid": wechat.agent_id_int,
"image": {"media_id": media_id},
}
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
async with httpx.AsyncClient(timeout=10) as client:
await client.post(api_url, json=msg)
except Exception as e:
logger.error(f"发送告警截图失败: user={user_id}, error={e}")
@staticmethod
def _find_handling_alarm(user_id: str) -> str:
"""查找用户正在处理的告警ID"""
from app.models import get_session, AlarmEvent
db = get_session()
try:
alarm = db.query(AlarmEvent).filter(
AlarmEvent.handler == user_id,
AlarmEvent.handle_status == "HANDLING",
).order_by(AlarmEvent.event_time.desc()).first()
return alarm.alarm_id if alarm else ""
except Exception as e:
logger.error(f"查询待处理告警失败: user={user_id}, error={e}")
return ""
finally:
db.close()
# 全局单例
_agent_dispatcher: Optional[AgentDispatcher] = None
def get_agent_dispatcher() -> AgentDispatcher:
global _agent_dispatcher
if _agent_dispatcher is None:
_agent_dispatcher = AgentDispatcher()
return _agent_dispatcher