""" 交互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, ) # 从工具返回中提取截图 URL(get_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