1. wechat_service.py: - send_alarm_card 从 textcard 升级为 button_interaction 模板卡片 - 卡片直接在对话框展示告警信息 + 操作按钮(前往处理/误报忽略) - 新增 update_alarm_card 方法:点击按钮后更新卡片状态(按钮变灰) - 保留群聊消息能力(create_group_chat/send_group_text) 2. wechat_callback.py: - 回调支持 template_card_event 按钮点击事件 - 按钮点击自动更新告警状态(handle→HANDLING, ignore→IGNORED) - 通过 response_code 更新卡片按钮为已处理状态 - H5 详情页 snapshot_url 增加 COS key→预签名URL 转换 - 新增群聊创建和群聊发卡片的测试接口 3. vlm_service.py: - VLM 降级策略统一放行(所有类型 confirmed=True) - 避免 VLM 不可用时离岗告警无法推送 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
184 lines
6.4 KiB
Python
184 lines
6.4 KiB
Python
"""
|
||
VLM 视觉语言模型复核服务
|
||
|
||
调用 qwen3-vl-flash 对告警截图进行二次确认,
|
||
生成场景描述文本用于企微通知卡片。
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
from typing import Optional, Dict
|
||
|
||
from openai import AsyncOpenAI
|
||
|
||
from app.utils.logger import logger
|
||
|
||
# 算法类型中文映射
|
||
ALARM_TYPE_NAMES = {
|
||
"leave_post": "离岗",
|
||
"intrusion": "周界入侵",
|
||
}
|
||
|
||
# 算法类型 → VLM Prompt 模板
|
||
VLM_PROMPTS = {
|
||
"leave_post": """你是安防监控AI复核员。算法类型:离岗检测,监控区域:{roi_name}。
|
||
判断该区域是否有人在岗。
|
||
- confirmed=true:无人在岗(告警成立)
|
||
- confirmed=false:有人在岗(误报)
|
||
description要求:≤15字,直接说结论。
|
||
告警成立示例:"该区域无人在岗"
|
||
误报示例:"画面中无人员离岗情况"
|
||
仅输出JSON:{{"confirmed":true,"description":"..."}}""",
|
||
|
||
"intrusion": """你是安防监控AI复核员。算法类型:周界入侵检测,监控区域:{roi_name}。
|
||
判断该区域是否有人员入侵。
|
||
- confirmed=true:有人入侵(告警成立)
|
||
- confirmed=false:无人入侵(误报)
|
||
description要求:≤15字,直接说结论。
|
||
告警成立示例:"有人员进入周界区域"
|
||
误报示例:"画面中无周界入侵情况"
|
||
仅输出JSON:{{"confirmed":true,"description":"..."}}""",
|
||
}
|
||
|
||
# 通用降级 prompt(未知算法类型时使用)
|
||
DEFAULT_PROMPT = """你是安防监控AI复核员。算法类型:{alarm_type_name},监控区域:{roi_name}。
|
||
判断告警是否属实。
|
||
- confirmed=true:告警成立
|
||
- confirmed=false:误报
|
||
description要求:≤15字,直接说结论。
|
||
仅输出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
|
||
|
||
@staticmethod
|
||
def _fallback_result(alarm_type: str, camera_name: str, reason: str) -> Dict:
|
||
"""降级结果:VLM 不可用时统一放行推送(宁可多报,不可漏报)"""
|
||
return {
|
||
"confirmed": True,
|
||
"description": f"{camera_name or '未知位置'} 触发 {alarm_type} 告警({reason})",
|
||
"skipped": True,
|
||
}
|
||
|
||
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 self._fallback_result(alarm_type, camera_name, "VLM未启用")
|
||
|
||
if not snapshot_url:
|
||
logger.warning("告警无截图URL,跳过 VLM 复核")
|
||
return self._fallback_result(alarm_type, camera_name, "无截图")
|
||
|
||
# 选择 prompt 模板
|
||
template = VLM_PROMPTS.get(alarm_type, DEFAULT_PROMPT)
|
||
alarm_type_name = ALARM_TYPE_NAMES.get(alarm_type, alarm_type)
|
||
prompt = template.format(
|
||
camera_name=camera_name or "未知位置",
|
||
roi_name=roi_name or "监控区域",
|
||
alarm_type=alarm_type,
|
||
alarm_type_name=alarm_type_name,
|
||
)
|
||
|
||
try:
|
||
logger.info(f"VLM 复核开始: type={alarm_type}, url={snapshot_url[:80]}...")
|
||
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', '')[:30]}"
|
||
)
|
||
return {
|
||
"confirmed": result.get("confirmed", True),
|
||
"description": result.get("description", ""),
|
||
"skipped": False,
|
||
}
|
||
|
||
except asyncio.TimeoutError:
|
||
logger.warning(f"VLM 复核超时 ({self._timeout}s),降级处理")
|
||
return self._fallback_result(alarm_type, camera_name, "VLM超时")
|
||
except json.JSONDecodeError as e:
|
||
logger.warning(f"VLM 返回内容解析失败: {e}, 原始内容: {content[:200]}")
|
||
return self._fallback_result(alarm_type, camera_name, "解析失败")
|
||
except Exception as e:
|
||
logger.error(f"VLM 调用异常: {e}")
|
||
return self._fallback_result(alarm_type, camera_name, "VLM异常")
|
||
|
||
|
||
# 全局单例
|
||
_vlm_service: Optional[VLMService] = None
|
||
|
||
|
||
def get_vlm_service() -> VLMService:
|
||
global _vlm_service
|
||
if _vlm_service is None:
|
||
_vlm_service = VLMService()
|
||
return _vlm_service
|