- 新增3张通知路由表模型(notify_area, camera_area_binding, area_person_binding) - 新增VLM复核服务,通过qwen3-vl-flash对告警截图二次确认 - 新增企微通知服务,告警确认后推送文本卡片给责任人 - 新增通知调度服务,编排VLM复核→查表路由→企微推送流水线 - 新增企微回调接口,支持手动结单/确认处理/标记误报 - 新增通知管理API,区域/摄像头绑定/人员绑定CRUD - 告警上报主流程(edge_compat + yudao_aiot_alarm)接入异步通知 - 扩展配置项支持VLM和企微环境变量 - 添加openai==1.68.0依赖(通过DashScope兼容端点调用)
187 lines
6.4 KiB
Python
187 lines
6.4 KiB
Python
"""
|
||
VLM 视觉语言模型复核服务
|
||
|
||
调用 qwen3-vl-flash 对告警截图进行二次确认,
|
||
生成场景描述文本用于企微通知卡片。
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
from typing import Optional, Dict
|
||
|
||
from openai import AsyncOpenAI
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 算法类型 → VLM Prompt 模板
|
||
VLM_PROMPTS = {
|
||
"leave_post": """分析这张岗位监控截图。
|
||
摄像头位置:{camera_name},监控区域:{roi_name}。
|
||
边缘AI检测到该区域无人在岗,请你复核:该区域内是否确实没有工作人员在岗?
|
||
|
||
输出严格的JSON格式(不要输出其他内容):
|
||
{{"confirmed": true, "description": "一句话描述当前画面"}}
|
||
|
||
说明:confirmed=true 表示确实无人在岗(告警成立),confirmed=false 表示有人在岗(误报)。""",
|
||
|
||
"intrusion": """分析这张周界监控截图。
|
||
摄像头位置:{camera_name},监控区域:{roi_name}。
|
||
边缘AI检测到该区域有人员入侵,请你复核:该区域内是否确实有人员出现?
|
||
|
||
输出严格的JSON格式(不要输出其他内容):
|
||
{{"confirmed": true, "description": "一句话描述当前画面"}}
|
||
|
||
说明:confirmed=true 表示确实有人入侵(告警成立),confirmed=false 表示无人(误报)。""",
|
||
}
|
||
|
||
# 通用降级 prompt(未知算法类型时使用)
|
||
DEFAULT_PROMPT = """分析这张监控截图。
|
||
摄像头位置:{camera_name},监控区域:{roi_name}。
|
||
边缘AI触发了 {alarm_type} 告警,请判断告警是否属实。
|
||
|
||
输出严格的JSON格式(不要输出其他内容):
|
||
{{"confirmed": true, "description": "一句话描述当前画面"}}"""
|
||
|
||
|
||
class VLMService:
|
||
"""VLM 复核服务(单例)"""
|
||
|
||
def __init__(self):
|
||
self._client: Optional[AsyncOpenAI] = None
|
||
self._enabled = False
|
||
self._model = ""
|
||
self._timeout = 10
|
||
self._enable_thinking = False
|
||
|
||
def init(self, config):
|
||
"""初始化 VLM 客户端"""
|
||
self._enabled = config.enabled and bool(config.api_key)
|
||
self._model = config.model
|
||
self._timeout = config.timeout
|
||
self._enable_thinking = config.enable_thinking
|
||
|
||
if self._enabled:
|
||
self._client = AsyncOpenAI(
|
||
api_key=config.api_key,
|
||
base_url=config.base_url,
|
||
)
|
||
logger.info(f"VLM 服务已启用: model={self._model}")
|
||
else:
|
||
logger.info("VLM 服务未启用(VLM_ENABLED=false 或缺少 API Key)")
|
||
|
||
@property
|
||
def enabled(self) -> bool:
|
||
return self._enabled
|
||
|
||
async def verify_alarm(
|
||
self,
|
||
snapshot_url: str,
|
||
alarm_type: str,
|
||
camera_name: str = "",
|
||
roi_name: str = "",
|
||
) -> Dict:
|
||
"""
|
||
VLM 复核告警截图
|
||
|
||
Args:
|
||
snapshot_url: COS 截图 URL
|
||
alarm_type: 告警类型 (leave_post/intrusion)
|
||
camera_name: 摄像头名称
|
||
roi_name: ROI 区域名称
|
||
|
||
Returns:
|
||
{"confirmed": bool, "description": str, "skipped": bool}
|
||
- skipped=True 表示 VLM 未调用(降级处理)
|
||
"""
|
||
if not self._enabled or not self._client:
|
||
return {
|
||
"confirmed": True,
|
||
"description": f"{camera_name or '未知位置'} 触发 {alarm_type} 告警",
|
||
"skipped": True,
|
||
}
|
||
|
||
if not snapshot_url:
|
||
logger.warning("告警无截图URL,跳过 VLM 复核")
|
||
return {
|
||
"confirmed": True,
|
||
"description": f"{camera_name or '未知位置'} 触发 {alarm_type} 告警(无截图)",
|
||
"skipped": True,
|
||
}
|
||
|
||
# 选择 prompt 模板
|
||
template = VLM_PROMPTS.get(alarm_type, DEFAULT_PROMPT)
|
||
prompt = template.format(
|
||
camera_name=camera_name or "未知位置",
|
||
roi_name=roi_name or "未知区域",
|
||
alarm_type=alarm_type,
|
||
)
|
||
|
||
try:
|
||
resp = await asyncio.wait_for(
|
||
self._client.chat.completions.create(
|
||
model=self._model,
|
||
messages=[{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "image_url", "image_url": {"url": snapshot_url}},
|
||
{"type": "text", "text": prompt},
|
||
],
|
||
}],
|
||
extra_body={"enable_thinking": self._enable_thinking},
|
||
),
|
||
timeout=self._timeout,
|
||
)
|
||
|
||
content = resp.choices[0].message.content.strip()
|
||
# 尝试提取 JSON(兼容模型可能输出 markdown code block)
|
||
if "```" in content:
|
||
content = content.split("```")[1]
|
||
if content.startswith("json"):
|
||
content = content[4:]
|
||
content = content.strip()
|
||
|
||
result = json.loads(content)
|
||
logger.info(
|
||
f"VLM 复核完成: confirmed={result.get('confirmed')}, "
|
||
f"desc={result.get('description', '')[:50]}"
|
||
)
|
||
return {
|
||
"confirmed": result.get("confirmed", True),
|
||
"description": result.get("description", ""),
|
||
"skipped": False,
|
||
}
|
||
|
||
except asyncio.TimeoutError:
|
||
logger.warning(f"VLM 复核超时 ({self._timeout}s),降级处理")
|
||
return {
|
||
"confirmed": True,
|
||
"description": f"{camera_name or '未知位置'} 触发 {alarm_type} 告警(VLM超时)",
|
||
"skipped": True,
|
||
}
|
||
except json.JSONDecodeError as e:
|
||
logger.warning(f"VLM 返回内容解析失败: {e}, 原始内容: {content[:200]}")
|
||
return {
|
||
"confirmed": True,
|
||
"description": content[:100] if content else "VLM返回异常",
|
||
"skipped": True,
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"VLM 调用异常: {e}")
|
||
return {
|
||
"confirmed": True,
|
||
"description": f"{camera_name or '未知位置'} 触发 {alarm_type} 告警(VLM异常)",
|
||
"skipped": True,
|
||
}
|
||
|
||
|
||
# 全局单例
|
||
_vlm_service: Optional[VLMService] = None
|
||
|
||
|
||
def get_vlm_service() -> VLMService:
|
||
global _vlm_service
|
||
if _vlm_service is None:
|
||
_vlm_service = VLMService()
|
||
return _vlm_service
|