告警-工单解耦:企微交互+Agent全面切换到工单驱动

Part A: 数据层
- 新增 WechatCardState 模型(order_id ↔ alarm_id 映射 + response_code)
- 新建 models_iot.py(IoT 工单只读 ORM:ops_order + security_ext + clean_ext)
- config.py 新增 IOT_DATABASE_URL 配置

Part B: 企微解耦(alarm_id → order_id)
- wechat_service: response_code 存储迁移到 wechat_card_state,集中 helper
- 卡片发送/更新方法改用 order_id,按钮 key: confirm_{order_id}
- wechat_callback: 按钮解析改 order_id,反查 alarm_id(可空)
- wechat_notify_api: send-card/sync-status 以 orderId 为主键
- yudao_aiot_alarm: 卡片操作改用 order_id,删重复 helper

Part C: Agent 工具全面改为工单驱动
- 新建 order_query.py(查 IoT ops_order,支持安保+保洁工单)
- 新建 order_action.py(操作工单状态 + 提交处理结果)
- 更新 prompts.py 为工单助手
- 更新工具注册(__init__.py)

Part D: 日报改为工单驱动
- daily_report_service 从查 alarm_event 改为查 IoT ops_order + 扩展表
- 支持安保+保洁工单统计
This commit is contained in:
2026-03-31 10:49:42 +08:00
parent 93148fe85b
commit 63a8d5a8f2
14 changed files with 1019 additions and 343 deletions

View File

@@ -2,17 +2,22 @@
Agent Prompt 定义
"""
SYSTEM_PROMPT = """你是VSP安防AI助手通过企业微信协助安保人员处理告警和工单。
SYSTEM_PROMPT = """你是VSP物业AI助手通过企业微信协助物业人员处理安保和保洁工单。
## 能力(必须通过工具获取数据)
1. 查询告警统计和明细query_alarm_stats / list_alarms / get_alarm_detail
2. 处理告警update_alarm_status确认接单、忽略、处理完成、误报
3. 提交工单处理结果submit_order_result含文字描述和处理后照片
4. 查询待处理工单list_my_orders
5. 查询摄像头信息query_camera
1. 查询工单统计query_order_stats)— 支持安保和保洁工单
2. 查询工单列表list_orders— 按类型、状态、时间筛选
3. 查看工单详情get_order_detail— 含告警信息、保洁信息
4. 操作工单状态update_order_status— 确认接单、完成、误报、忽略
5. 提交处理结果submit_order_result— 含文字描述和处理后照片
6. 查询摄像头信息query_camera
## 工单类型
- SECURITY安保工单关联告警含摄像头、告警类型等
- CLEAN保洁工单含保洁类型、难度、预计时长等
## 核心原则(严格遵守)
- 所有数据必须来自工具调用结果,绝对不要编造告警ID、告警数量、摄像头名称、时间等任何数据
- 所有数据必须来自工具调用结果,绝对不要编造工单ID、数量、人员等任何数据
- 如果工具返回错误或未找到数据,如实告知用户,不要猜测或补充
- 不知道的事情直接说"我无法确认",不要推测
- 不要编造不存在的功能或操作
@@ -23,8 +28,7 @@ SYSTEM_PROMPT = """你是VSP安防AI助手通过企业微信协助安保人
- 回复简洁,适合手机阅读
- 重要信息用【】标注
- 禁止使用markdown语法如![](url)、**加粗**、# 标题),企微聊天不支持
- 告警截图会自动发送图片消息,文字回复中不要包含图片链接
- 用户问非安防相关问题时,简短回答"我只能协助处理安防告警和工单相关事务"
- 用户问非物业相关问题时,简短回答"我只能协助处理物业工单相关事务"
"""
IMAGE_ANALYZE_PROMPT = """你是物业安防图片分析员。分析这张图片,判断是否存在安全隐患或需要上报的情况。

View File

@@ -2,18 +2,16 @@
工具注册表:导出 all_tools 供图构建使用
"""
from .alarm_query import query_alarm_stats, list_alarms, get_alarm_detail
from .alarm_action import update_alarm_status
from .order_tools import list_my_orders, submit_order_result
from .order_query import query_order_stats, list_orders, get_order_detail
from .order_action import update_order_status, submit_order_result
from .camera_tools import query_camera
# 所有工具列表 — 添加新工具只需在这里追加
all_tools = [
query_alarm_stats,
list_alarms,
get_alarm_detail,
update_alarm_status,
list_my_orders,
query_order_stats,
list_orders,
get_order_detail,
update_order_status,
submit_order_result,
query_camera,
]

View File

@@ -5,7 +5,7 @@
import json
from langchain_core.tools import tool
from .alarm_query import _get_camera_display_name
from .order_query import _get_camera_display_name
@tool

View File

@@ -0,0 +1,182 @@
"""
工单操作工具:确认接单、完成、误报、提交处理结果
"""
import json
from typing import Optional, List
from langchain_core.tools import tool
from langchain_core.runnables import RunnableConfig
from app.utils.logger import logger
async def _update_wechat_card(order_id: str, user_id: str, action: str):
"""更新企微卡片状态(以 order_id 为 key"""
try:
from app.services.wechat_service import get_wechat_service
wechat = get_wechat_service()
response_code = wechat.get_response_code(order_id)
if not response_code:
return
if action == "confirm":
await wechat.update_alarm_card_step2(
response_code=response_code, user_ids=[user_id],
order_id=order_id, operator_name=user_id,
)
else:
await wechat.update_alarm_card_terminal(
response_code=response_code, user_ids=[user_id],
order_id=order_id, action=action, operator_name=user_id,
)
except Exception as e:
logger.error(f"更新企微卡片失败: order={order_id}, error={e}")
@tool
async def update_order_status(order_id: str, action: str, config: RunnableConfig) -> str:
"""更新工单状态:确认接单(confirm)、处理完成(complete)、标记误报(false)、忽略(ignore)
Args:
order_id: 工单IDops_order.id
action: 操作类型 confirm=确认接单 complete=处理完成 false=标记误报 ignore=忽略
"""
user_id = config.get("configurable", {}).get("user_id", "")
from app.services.work_order_client import get_work_order_client
wo_client = get_work_order_client()
if not wo_client.enabled:
return json.dumps({"error": "工单系统未启用"}, ensure_ascii=False)
# IoT 工单操作
iot_success = False
if action == "confirm":
iot_success = await wo_client.confirm_order(order_id)
elif action in ("ignore", "false"):
iot_success = await wo_client.false_alarm(order_id)
elif action == "complete":
iot_success = await wo_client.submit_order(order_id, result=f"已处理 by {user_id}")
if not iot_success:
return json.dumps({"error": f"工单操作失败: order={order_id}, action={action}"}, ensure_ascii=False)
# 有关联告警时同步告警状态
from app.services.wechat_service import get_wechat_service
wechat = get_wechat_service()
alarm_id = wechat.get_alarm_id_for_order(order_id)
if alarm_id:
action_map = {
"confirm": ("CONFIRMED", "HANDLING", "Agent确认接单"),
"ignore": ("FALSE", "IGNORED", "Agent忽略"),
"complete": ("CLOSED", "DONE", "Agent已处理"),
"false": ("FALSE", "IGNORED", "Agent标记误报"),
}
if action in action_map:
alarm_status, handle_status, remark = action_map[action]
from app.services.alarm_event_service import get_alarm_event_service
svc = get_alarm_event_service()
svc.handle_alarm(
alarm_id=alarm_id, alarm_status=alarm_status,
handle_status=handle_status, handler=user_id, remark=remark,
)
# 更新企微卡片
await _update_wechat_card(order_id, user_id, action)
action_labels = {
"confirm": "已确认接单",
"ignore": "已忽略",
"complete": "已处理完成",
"false": "已标记误报",
}
return json.dumps(
{"success": True, "message": f"{action_labels.get(action, action)}: 工单{order_id}"},
ensure_ascii=False,
)
@tool
async def submit_order_result(
order_id: str,
result_text: str,
config: RunnableConfig,
image_urls: Optional[List[str]] = None,
) -> str:
"""提交工单处理结果(文字描述+处理后照片URL
Args:
order_id: 工单IDops_order.id
result_text: 处理结果描述
image_urls: 处理后照片URL列表COS永久URL
"""
user_id = config.get("configurable", {}).get("user_id", "")
if image_urls is None:
image_urls = []
# 合并 session 中暂存的图片
from app.services.session_manager import get_session_manager
session = get_session_manager().get(user_id)
if session.pending_images:
image_urls = session.pending_images + image_urls
session.pending_images = []
from app.services.work_order_client import get_work_order_client
wo_client = get_work_order_client()
if wo_client.enabled:
success = await wo_client.submit_order(
order_id,
result=f"{result_text} by {user_id}",
result_img_urls=image_urls or None,
)
if not success:
return json.dumps({"error": f"提交工单结果失败: order={order_id}"}, ensure_ascii=False)
# 有关联告警时同步告警状态
from app.services.wechat_service import get_wechat_service
wechat = get_wechat_service()
alarm_id = wechat.get_alarm_id_for_order(order_id)
if alarm_id:
from app.services.alarm_event_service import get_alarm_event_service
svc = get_alarm_event_service()
remark = f"Agent结单: {result_text}"
if image_urls:
remark += f" (附{len(image_urls)}张图片)"
svc.handle_alarm(alarm_id=alarm_id, alarm_status="CLOSED",
handle_status="DONE", handler=user_id, remark=remark)
# 持久化处理结果图片到 alarm_event_ext
if image_urls:
try:
from app.models import get_session as get_db_session, AlarmEventExt
db = get_db_session()
try:
ext = AlarmEventExt(
alarm_id=alarm_id,
ext_type="HANDLER_RESULT",
ext_data={"result_text": result_text, "image_urls": image_urls, "handler": user_id},
)
db.add(ext)
db.commit()
except Exception as e:
db.rollback()
logger.error(f"持久化处理结果图片失败: {e}")
finally:
db.close()
except Exception as e:
logger.error(f"保存处理结果失败: {e}")
# 更新卡片到终态
await _update_wechat_card(order_id, user_id, "complete")
result = {
"success": True,
"message": f"工单已提交: {order_id}",
"result": result_text,
"images_count": len(image_urls),
}
return json.dumps(result, ensure_ascii=False)

View File

@@ -0,0 +1,334 @@
"""
工单查询工具:统计、列表、详情(查 IoT ops_order + 扩展表)
支持安保工单SECURITY和保洁工单CLEAN
"""
import json
from datetime import timedelta
from typing import Optional
from langchain_core.tools import tool
from langchain_core.runnables import RunnableConfig
from app.utils.logger import logger
from app.utils.timezone import beijing_now
# 告警类型中文映射
ALARM_TYPE_NAMES = {
"leave_post": "人员离岗", "intrusion": "周界入侵",
"illegal_parking": "车辆违停", "vehicle_congestion": "车辆拥堵",
}
# 工单状态映射
ORDER_STATUS_NAMES = {
"PENDING": "待处理", "ASSIGNED": "已派单", "ARRIVED": "已到岗",
"PAUSED": "已暂停", "COMPLETED": "已完成", "CANCELLED": "已取消",
}
# 工单优先级映射
PRIORITY_NAMES = {0: "", 1: "", 2: ""}
# 保洁类型映射
CLEANING_TYPE_NAMES = {
"ROUTINE": "日常保洁", "DEEP": "深度保洁",
"SPOT": "点状保洁", "EMERGENCY": "应急保洁",
}
def _parse_time_range(time_range: str):
"""解析时间范围,返回 (start_time, label)"""
now = beijing_now()
if time_range == "week":
start = now - timedelta(days=now.weekday())
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
return start, "本周"
elif time_range == "month":
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
return start, "本月"
elif time_range == "yesterday":
start = (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
end = now.replace(hour=0, minute=0, second=0, microsecond=0)
return start, "昨日"
else:
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
return start, "今日"
def _get_camera_display_name(device_id: str) -> str:
"""同步获取摄像头显示名称"""
try:
import asyncio
from app.services.camera_name_service import get_camera_name_service
camera_service = get_camera_name_service()
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
cam_info = pool.submit(
asyncio.run, camera_service.get_camera_info(device_id)
).result(timeout=5)
else:
cam_info = asyncio.run(camera_service.get_camera_info(device_id))
return camera_service.format_display_name(device_id, cam_info)
except Exception:
return device_id
def _query_orders(
order_type: Optional[str] = None,
status: Optional[str] = None,
start_time=None,
end_time=None,
limit: int = 100,
assignee_name: Optional[str] = None,
):
"""查询 IoT 工单(跨库只读)"""
from app.models_iot import get_iot_session, IotOpsOrder
db = get_iot_session()
try:
q = db.query(IotOpsOrder).filter(IotOpsOrder.deleted == 0)
if order_type and order_type != "ALL":
q = q.filter(IotOpsOrder.order_type == order_type)
if status:
q = q.filter(IotOpsOrder.status == status)
if start_time:
q = q.filter(IotOpsOrder.create_time >= start_time)
if end_time:
q = q.filter(IotOpsOrder.create_time < end_time)
if assignee_name:
q = q.filter(IotOpsOrder.assignee_name.contains(assignee_name))
total = q.count()
orders = q.order_by(IotOpsOrder.create_time.desc()).limit(limit).all()
# 提取所有 order_id 用于关联查询扩展表
order_ids = [o.id for o in orders]
# 批量查安保扩展
sec_ext_map = {}
if order_ids:
from app.models_iot import IotOpsOrderSecurityExt
sec_exts = db.query(IotOpsOrderSecurityExt).filter(
IotOpsOrderSecurityExt.ops_order_id.in_(order_ids),
IotOpsOrderSecurityExt.deleted == 0,
).all()
sec_ext_map = {e.ops_order_id: e for e in sec_exts}
# 批量查保洁扩展
clean_ext_map = {}
if order_ids:
from app.models_iot import IotOpsOrderCleanExt
clean_exts = db.query(IotOpsOrderCleanExt).filter(
IotOpsOrderCleanExt.ops_order_id.in_(order_ids),
IotOpsOrderCleanExt.deleted == 0,
).all()
clean_ext_map = {e.ops_order_id: e for e in clean_exts}
return orders, total, sec_ext_map, clean_ext_map
except Exception as e:
logger.error(f"查询IoT工单失败: {e}", exc_info=True)
return [], 0, {}, {}
finally:
db.close()
@tool
def query_order_stats(time_range: str = "today", order_type: str = "ALL") -> str:
"""查询工单统计数据(总数、按状态分布、按类型分布)
Args:
time_range: 时间范围 today=今日 week=本周 month=本月 yesterday=昨日
order_type: 工单类型筛选 SECURITY=安保 CLEAN=保洁 ALL=全部
"""
start, range_label = _parse_time_range(time_range)
now = beijing_now()
end = now
if time_range == "yesterday":
end = now.replace(hour=0, minute=0, second=0, microsecond=0)
orders, total, sec_ext_map, clean_ext_map = _query_orders(
order_type=order_type if order_type != "ALL" else None,
start_time=start, end_time=end, limit=10000,
)
# 按状态统计
status_count = {}
for o in orders:
s = o.status or "PENDING"
status_count[s] = status_count.get(s, 0) + 1
# 按类型统计
type_count = {"SECURITY": 0, "CLEAN": 0}
alarm_type_count = {}
for o in orders:
ot = o.order_type or "SECURITY"
type_count[ot] = type_count.get(ot, 0) + 1
# 安保工单细分告警类型
sec_ext = sec_ext_map.get(o.id)
if sec_ext and sec_ext.alarm_type:
alarm_type_count[sec_ext.alarm_type] = alarm_type_count.get(sec_ext.alarm_type, 0) + 1
# 误报统计(安保特有)
false_alarm_count = sum(
1 for e in sec_ext_map.values() if e.false_alarm == 1
)
result = {
"range": range_label,
"total": total,
"by_status": {ORDER_STATUS_NAMES.get(s, s): c for s, c in status_count.items()},
"by_order_type": {k: v for k, v in type_count.items() if v > 0},
"by_alarm_type": {ALARM_TYPE_NAMES.get(t, t): c for t, c in alarm_type_count.items()} if alarm_type_count else {},
"false_alarm_count": false_alarm_count,
}
return json.dumps(result, ensure_ascii=False)
@tool
def list_orders(
time_range: str = "today",
order_type: str = "ALL",
status: str = "",
limit: int = 10,
assigned_to_me: bool = False,
config: RunnableConfig = None,
) -> str:
"""查询工单列表,返回最近的工单记录
Args:
time_range: 时间范围 today/week/month/yesterday
order_type: 工单类型 SECURITY=安保 CLEAN=保洁 ALL=全部
status: 状态筛选 PENDING/ASSIGNED/ARRIVED/PAUSED/COMPLETED/CANCELLED
limit: 返回条数默认10最多20
assigned_to_me: 是否只看我的工单
"""
start, range_label = _parse_time_range(time_range)
now = beijing_now()
end = now
if time_range == "yesterday":
end = now.replace(hour=0, minute=0, second=0, microsecond=0)
limit = min(limit, 20)
# 获取当前用户
user_id = ""
if config and assigned_to_me:
user_id = config.get("configurable", {}).get("user_id", "")
orders, total, sec_ext_map, clean_ext_map = _query_orders(
order_type=order_type if order_type != "ALL" else None,
status=status or None,
start_time=start, end_time=end, limit=limit,
)
items = []
for o in orders:
create_time = ""
if o.create_time:
try:
create_time = o.create_time.strftime("%m-%d %H:%M")
except Exception:
create_time = str(o.create_time)[:16]
item = {
"order_id": str(o.id),
"order_code": o.order_code or "",
"type": o.order_type or "SECURITY",
"title": o.title or "",
"status": ORDER_STATUS_NAMES.get(o.status, o.status or "待处理"),
"priority": PRIORITY_NAMES.get(o.priority, ""),
"assignee": o.assignee_name or "",
"time": create_time,
}
# 安保扩展
sec_ext = sec_ext_map.get(o.id)
if sec_ext:
item["alarm_type"] = ALARM_TYPE_NAMES.get(sec_ext.alarm_type, sec_ext.alarm_type or "")
item["camera"] = sec_ext.camera_name or _get_camera_display_name(sec_ext.camera_id) if sec_ext.camera_id else ""
item["false_alarm"] = bool(sec_ext.false_alarm)
# 保洁扩展
clean_ext = clean_ext_map.get(o.id)
if clean_ext:
item["cleaning_type"] = CLEANING_TYPE_NAMES.get(clean_ext.cleaning_type, clean_ext.cleaning_type or "")
item["difficulty"] = clean_ext.difficulty_level
items.append(item)
result = {"range": range_label, "total": total, "items": items}
return json.dumps(result, ensure_ascii=False)
@tool
def get_order_detail(order_id: str, config: RunnableConfig) -> str:
"""查询单条工单的详细信息
Args:
order_id: 工单IDops_order.id
"""
from app.models_iot import get_iot_session, IotOpsOrder, IotOpsOrderSecurityExt, IotOpsOrderCleanExt
db = get_iot_session()
try:
order = db.query(IotOpsOrder).filter(
IotOpsOrder.id == int(order_id),
IotOpsOrder.deleted == 0,
).first()
if not order:
return json.dumps({"error": f"未找到工单: {order_id}"}, ensure_ascii=False)
result = {
"order_id": str(order.id),
"order_code": order.order_code or "",
"order_type": order.order_type or "",
"title": order.title or "",
"description": order.description or "",
"status": ORDER_STATUS_NAMES.get(order.status, order.status or ""),
"priority": PRIORITY_NAMES.get(order.priority, ""),
"assignee": order.assignee_name or "",
"location": order.location or "",
"create_time": order.create_time.strftime("%Y-%m-%d %H:%M:%S") if order.create_time else "",
}
# 安保扩展
sec_ext = db.query(IotOpsOrderSecurityExt).filter(
IotOpsOrderSecurityExt.ops_order_id == order.id,
IotOpsOrderSecurityExt.deleted == 0,
).first()
if sec_ext:
result["alarm_id"] = sec_ext.alarm_id or ""
result["alarm_type"] = ALARM_TYPE_NAMES.get(sec_ext.alarm_type, sec_ext.alarm_type or "")
result["camera_name"] = sec_ext.camera_name or (
_get_camera_display_name(sec_ext.camera_id) if sec_ext.camera_id else ""
)
result["has_image"] = bool(sec_ext.image_url)
result["false_alarm"] = bool(sec_ext.false_alarm)
result["result"] = sec_ext.result or ""
if sec_ext.dispatched_time:
result["dispatched_time"] = sec_ext.dispatched_time.strftime("%Y-%m-%d %H:%M:%S")
if sec_ext.confirmed_time:
result["confirmed_time"] = sec_ext.confirmed_time.strftime("%Y-%m-%d %H:%M:%S")
if sec_ext.completed_time:
result["completed_time"] = sec_ext.completed_time.strftime("%Y-%m-%d %H:%M:%S")
# 保洁扩展
clean_ext = db.query(IotOpsOrderCleanExt).filter(
IotOpsOrderCleanExt.ops_order_id == order.id,
IotOpsOrderCleanExt.deleted == 0,
).first()
if clean_ext:
result["cleaning_type"] = CLEANING_TYPE_NAMES.get(clean_ext.cleaning_type, clean_ext.cleaning_type or "")
result["difficulty"] = clean_ext.difficulty_level
result["expected_duration"] = clean_ext.expected_duration
result["is_auto"] = bool(clean_ext.is_auto)
if clean_ext.arrived_time:
result["arrived_time"] = clean_ext.arrived_time.strftime("%Y-%m-%d %H:%M:%S")
if clean_ext.completed_time:
result["clean_completed_time"] = clean_ext.completed_time.strftime("%Y-%m-%d %H:%M:%S")
return json.dumps(result, ensure_ascii=False)
except Exception as e:
logger.error(f"查询工单详情失败: {e}", exc_info=True)
return json.dumps({"error": f"查询失败: {e}"}, ensure_ascii=False)
finally:
db.close()

View File

@@ -1,8 +1,8 @@
"""
每日告警日报定时推送服务
每日工单日报定时推送服务
每天定时生成前一天的告警汇总,发送到企微群聊。
支持按边缘节点分组统计
每天定时生成前一天的工单汇总,发送到企微群聊。
数据源IoT ops_order + 安保/保洁扩展表
"""
import asyncio
from collections import Counter, defaultdict
@@ -12,7 +12,7 @@ from typing import Dict, List, Optional
from app.utils.logger import logger
from app.config import settings
# 告警类型中文映射(与 wechat_service 保持一致)
# 告警类型中文映射
ALARM_TYPE_NAMES = {
"leave_post": "人员离岗",
"intrusion": "周界入侵",
@@ -20,23 +20,21 @@ ALARM_TYPE_NAMES = {
"vehicle_congestion": "车辆拥堵",
}
# 保洁类型映射
CLEANING_TYPE_NAMES = {
"ROUTINE": "日常保洁", "DEEP": "深度保洁",
"SPOT": "点状保洁", "EMERGENCY": "应急保洁",
}
# 工单状态映射
ORDER_STATUS_NAMES = {
"PENDING": "待处理", "ASSIGNED": "已派单", "ARRIVED": "已到岗",
"PAUSED": "已暂停", "COMPLETED": "已完成", "CANCELLED": "已取消",
}
WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
def _get_edge_name_map() -> Dict[str, str]:
"""从 EdgeDevice 表获取 edge_node_id → device_name 映射"""
from app.models import get_session, EdgeDevice
db = get_session()
try:
devices = db.query(EdgeDevice.device_id, EdgeDevice.device_name).all()
return {d.device_id: (d.device_name or d.device_id) for d in devices}
except Exception as e:
logger.warning(f"查询边缘设备名称失败: {e}")
return {}
finally:
db.close()
def _format_resp_time(minutes: float) -> str:
"""格式化响应时长"""
if minutes < 60:
@@ -45,74 +43,113 @@ def _format_resp_time(minutes: float) -> str:
async def generate_daily_report() -> Optional[str]:
"""生成昨日告警日报 Markdown 内容(含按边缘节点分组)"""
from app.services.alarm_event_service import get_alarm_event_service
from app.services.camera_name_service import get_camera_name_service
"""生成昨日工单日报 Markdown 内容"""
from app.utils.timezone import beijing_now
svc = get_alarm_event_service()
camera_svc = get_camera_name_service()
now = beijing_now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
yesterday_start = today_start - timedelta(days=1)
day_before_start = today_start - timedelta(days=2)
# 查询昨日和前日全量告警
yesterday_alarms, yesterday_total = svc.get_alarms(
start_time=yesterday_start, end_time=today_start, page=1, page_size=10000
)
_, prev_total = svc.get_alarms(
start_time=day_before_start, end_time=yesterday_start, page=1, page_size=1
)
date_str = yesterday_start.strftime("%m-%d")
weekday = WEEKDAY_NAMES[yesterday_start.weekday()]
# 无告警时发送简短通知
if yesterday_total == 0:
return (
f"**AI安防日报 — {date_str}{weekday}**\n\n"
f">昨日告警总计:**0** 条\n"
f">系统运行正常,无异常事件"
# 查询 IoT 工单
try:
from app.models_iot import (
get_iot_session, IotOpsOrder,
IotOpsOrderSecurityExt, IotOpsOrderCleanExt,
)
except Exception as e:
logger.error(f"IoT数据库不可用日报生成失败: {e}")
return None
# ---- 全局统计 ----
type_counter: Counter = Counter()
device_counter: Counter = Counter()
handle_done = 0
handle_ignored = 0
handle_unhandled = 0
false_alarm = 0
db = get_iot_session()
try:
# 昨日工单
yesterday_orders = db.query(IotOpsOrder).filter(
IotOpsOrder.create_time >= yesterday_start,
IotOpsOrder.create_time < today_start,
IotOpsOrder.deleted == 0,
).all()
yesterday_total = len(yesterday_orders)
# 前日工单(用于环比)
prev_total = db.query(IotOpsOrder).filter(
IotOpsOrder.create_time >= day_before_start,
IotOpsOrder.create_time < yesterday_start,
IotOpsOrder.deleted == 0,
).count()
if yesterday_total == 0:
return (
f"**物业工单日报 — {date_str}{weekday}**\n\n"
f">昨日工单总计:**0** 条\n"
f">系统运行正常,无工单"
)
# 收集 order_ids
order_ids = [o.id for o in yesterday_orders]
# 批量查安保扩展
sec_ext_map = {}
sec_exts = db.query(IotOpsOrderSecurityExt).filter(
IotOpsOrderSecurityExt.ops_order_id.in_(order_ids),
IotOpsOrderSecurityExt.deleted == 0,
).all()
sec_ext_map = {e.ops_order_id: e for e in sec_exts}
# 批量查保洁扩展
clean_ext_map = {}
clean_exts = db.query(IotOpsOrderCleanExt).filter(
IotOpsOrderCleanExt.ops_order_id.in_(order_ids),
IotOpsOrderCleanExt.deleted == 0,
).all()
clean_ext_map = {e.ops_order_id: e for e in clean_exts}
except Exception as e:
logger.error(f"查询IoT工单失败: {e}", exc_info=True)
return None
finally:
db.close()
# ---- 统计 ----
type_count = {"SECURITY": 0, "CLEAN": 0}
status_count = Counter()
alarm_type_count = Counter()
camera_counter = Counter()
false_alarm_count = 0
response_times: List[float] = []
cleaning_type_count = Counter()
# ---- 按边缘节点分组 ----
edge_groups: Dict[str, List] = defaultdict(list)
for o in yesterday_orders:
ot = o.order_type or "SECURITY"
type_count[ot] = type_count.get(ot, 0) + 1
status_count[o.status or "PENDING"] += 1
for a in yesterday_alarms:
type_counter[a.alarm_type] += 1
device_counter[a.device_id] += 1
# 安保统计
sec_ext = sec_ext_map.get(o.id)
if sec_ext:
if sec_ext.alarm_type:
alarm_type_count[sec_ext.alarm_type] += 1
if sec_ext.camera_name:
camera_counter[sec_ext.camera_name] += 1
elif sec_ext.camera_id:
camera_counter[sec_ext.camera_id] += 1
if sec_ext.false_alarm == 1:
false_alarm_count += 1
# 响应时长dispatched → confirmed
if sec_ext.dispatched_time and sec_ext.confirmed_time:
delta = (sec_ext.confirmed_time - sec_ext.dispatched_time).total_seconds() / 60.0
if 0 <= delta <= 360:
response_times.append(delta)
# 按 edge_node_id 分组(无 edge_node_id 归入 "unknown"
edge_key = a.edge_node_id or "unknown"
edge_groups[edge_key].append(a)
# 保洁统计
clean_ext = clean_ext_map.get(o.id)
if clean_ext and clean_ext.cleaning_type:
cleaning_type_count[clean_ext.cleaning_type] += 1
if a.handle_status == "DONE":
handle_done += 1
elif a.handle_status == "IGNORED":
handle_ignored += 1
else:
handle_unhandled += 1
if a.alarm_status == "FALSE":
false_alarm += 1
if a.handled_at and a.event_time:
delta = (a.handled_at - a.event_time).total_seconds() / 60.0
if 0 <= delta <= 360: # 排除 >6h 异常值
response_times.append(delta)
# 环比变化
# 环比
if prev_total > 0:
change_pct = (yesterday_total - prev_total) / prev_total * 100
if change_pct > 0:
@@ -122,95 +159,62 @@ async def generate_daily_report() -> Optional[str]:
else:
change_str = f"前日{prev_total}条,持平"
else:
change_str = "前日无告警"
change_str = "前日无工单"
# 平均响应时长
resp_str = _format_resp_time(sum(response_times) / len(response_times)) if response_times else "暂无数据"
# 设备 Top5 — 批量获取摄像头名称
top5_devices = device_counter.most_common(5)
all_device_ids = list({d[0] for d in top5_devices})
# 也收集各边缘节点的 top 设备 ID
for edge_key, alarms in edge_groups.items():
edge_dev_counter = Counter(a.device_id for a in alarms)
for dev_id, _ in edge_dev_counter.most_common(3):
if dev_id not in all_device_ids:
all_device_ids.append(dev_id)
try:
name_map = await camera_svc.get_display_names_batch(all_device_ids)
except Exception as e:
logger.warning(f"日报获取摄像头名称失败: {e}")
name_map = {}
# 边缘设备名称映射
edge_name_map = _get_edge_name_map()
# 待处理数量
pending_count = sum(
1 for o in yesterday_orders if o.status in ("PENDING", "ASSIGNED")
)
completed_count = status_count.get("COMPLETED", 0)
cancelled_count = status_count.get("CANCELLED", 0)
# ==================== 组装 Markdown ====================
lines = [
f"**AI安防日报 — {date_str}{weekday}**",
f"**物业工单日报 — {date_str}{weekday}**",
"",
f">昨日告警总计:<font color=\"warning\">{yesterday_total}</font> 条({change_str}",
f">待处理:<font color=\"warning\">{handle_unhandled}</font> 条",
f">已处理:{handle_done}条 | 已忽略:{handle_ignored}条 | 误报:{false_alarm}",
f">平均响应:<font color=\"info\">{resp_str}</font>",
f">昨日工单总计:<font color=\"warning\">{yesterday_total}</font> 条({change_str}",
]
# 按类型分布
if type_counter:
# 按工单类型
sec_count = type_count.get("SECURITY", 0)
clean_count = type_count.get("CLEAN", 0)
if sec_count and clean_count:
lines.append(f">安保工单:{sec_count}条 | 保洁工单:{clean_count}")
elif sec_count:
lines.append(f">安保工单:{sec_count}")
elif clean_count:
lines.append(f">保洁工单:{clean_count}")
lines.append(f">待处理:<font color=\"warning\">{pending_count}</font> 条 | "
f"已完成:{completed_count}条 | 已取消:{cancelled_count}条 | 误报:{false_alarm_count}")
lines.append(f">平均响应:<font color=\"info\">{resp_str}</font>")
# 安保告警类型分布
if alarm_type_count:
lines.append("")
lines.append("**类型分布**")
for alarm_type, count in type_counter.most_common():
lines.append("**安保告警类型分布**")
for alarm_type, count in alarm_type_count.most_common():
type_name = ALARM_TYPE_NAMES.get(alarm_type, alarm_type)
lines.append(f">{type_name}{count}")
# 设备 Top5
if top5_devices:
# 保洁类型分布
if cleaning_type_count:
lines.append("")
lines.append("**告警设备 Top5**")
for i, (device_id, count) in enumerate(top5_devices, 1):
display_name = name_map.get(device_id, device_id)
lines.append(f">{i}. {display_name}{count}")
lines.append("**保洁类型分布**")
for ct, count in cleaning_type_count.most_common():
ct_name = CLEANING_TYPE_NAMES.get(ct, ct)
lines.append(f">{ct_name}{count}")
# ---- 按边缘节点分组汇总 ----
if len(edge_groups) > 1 or (len(edge_groups) == 1 and "unknown" not in edge_groups):
# 摄像头 Top5
top5_cameras = camera_counter.most_common(5)
if top5_cameras:
lines.append("")
lines.append("**各边缘节点汇总**")
# 按告警数降序排列
sorted_edges = sorted(edge_groups.items(), key=lambda x: len(x[1]), reverse=True)
for edge_key, alarms in sorted_edges:
edge_name = edge_name_map.get(edge_key, edge_key)
if edge_key == "unknown":
edge_name = "未知节点"
edge_total = len(alarms)
edge_type_counter = Counter(a.alarm_type for a in alarms)
edge_unhandled = sum(1 for a in alarms if a.handle_status not in ("DONE", "IGNORED"))
edge_false = sum(1 for a in alarms if a.alarm_status == "FALSE")
# 类型分布简写
type_parts = []
for at, ac in edge_type_counter.most_common():
type_parts.append(f"{ALARM_TYPE_NAMES.get(at, at)}{ac}")
type_summary = "".join(type_parts)
# 该节点的 Top3 摄像头
edge_dev_counter = Counter(a.device_id for a in alarms)
top3 = edge_dev_counter.most_common(3)
cam_parts = []
for dev_id, dev_count in top3:
cam_name = name_map.get(dev_id, dev_id)
cam_parts.append(f"{cam_name}({dev_count})")
cam_summary = "".join(cam_parts)
lines.append(f"")
lines.append(f">{edge_name}<font color=\"warning\">{edge_total}</font> 条")
lines.append(f"> 类型:{type_summary}")
if edge_unhandled > 0:
lines.append(f"> 待处理:<font color=\"warning\">{edge_unhandled}</font> 条 误报:{edge_false}")
lines.append(f"> Top设备{cam_summary}")
lines.append("**告警摄像头 Top5**")
for i, (cam_name, count) in enumerate(top5_cameras, 1):
lines.append(f">{i}. {cam_name}{count}")
return "\n".join(lines)

View File

@@ -404,13 +404,22 @@ def _get_permanent_url(snapshot_url: str) -> str:
def _save_order_id(alarm_id: str, order_id: str):
"""将工单ID保存到 alarm_event_extext_type=WORK_ORDER"""
"""将工单ID保存到 wechat_card_state + alarm_event_ext"""
db = get_session()
try:
# 写 wechat_card_stateorder_id ↔ alarm_id 映射)
from app.models import WechatCardState
card_state = WechatCardState(
order_id=str(order_id),
alarm_id=alarm_id,
)
db.merge(card_state)
# 写 alarm_event_ext保持兼容
ext = AlarmEventExt(
alarm_id=alarm_id,
ext_type="WORK_ORDER",
ext_data={"order_id": order_id},
ext_data={"order_id": str(order_id)},
)
db.add(ext)
db.commit()

View File

@@ -42,7 +42,7 @@ class WeChatService:
self._token_expire_at = 0
self._service_base_url = ""
# 缓存 response_code用于更新卡片状态
# key: task_id (alarm_id), value: response_code
# key: order_id, value: response_code
self._response_codes: Dict[str, str] = {}
def init(self, config):
@@ -94,53 +94,85 @@ class WeChatService:
logger.info("企微 access_token 已更新")
return self._access_token
def save_response_code(self, task_id: str, response_code: str):
"""保存卡片的 response_code(内存缓存 + 数据库持久化"""
self._response_codes[task_id] = response_code
def save_response_code(self, order_id: str, response_code: str, alarm_id: str = ""):
"""保存卡片的 response_code 到 wechat_card_state以 order_id 为主键"""
self._response_codes[order_id] = response_code
try:
from app.models import get_session, AlarmEventExt
from app.models import get_session, WechatCardState
db = get_session()
try:
ext = db.query(AlarmEventExt).filter(
AlarmEventExt.alarm_id == task_id,
AlarmEventExt.ext_type == "WECHAT_RESPONSE_CODE",
state = db.query(WechatCardState).filter(
WechatCardState.order_id == order_id,
).first()
if ext:
ext.ext_data = {"response_code": response_code}
if state:
state.response_code = response_code
if alarm_id:
state.alarm_id = alarm_id
else:
ext = AlarmEventExt(
alarm_id=task_id,
ext_type="WECHAT_RESPONSE_CODE",
ext_data={"response_code": response_code},
state = WechatCardState(
order_id=order_id,
response_code=response_code,
alarm_id=alarm_id or None,
)
db.add(ext)
db.add(state)
db.commit()
finally:
db.close()
except Exception as e:
logger.warning(f"持久化 response_code 失败: {e}")
def get_response_code(self, task_id: str) -> Optional[str]:
"""获取 response_code优先内存缓存回退数据库查询"""
code = self._response_codes.pop(task_id, None)
def get_response_code(self, order_id: str) -> Optional[str]:
"""获取 response_code优先内存缓存回退数据库查询 wechat_card_state"""
code = self._response_codes.pop(order_id, None)
if code:
return code
try:
from app.models import get_session, AlarmEventExt
from app.models import get_session, WechatCardState
db = get_session()
try:
ext = db.query(AlarmEventExt).filter(
AlarmEventExt.alarm_id == task_id,
AlarmEventExt.ext_type == "WECHAT_RESPONSE_CODE",
state = db.query(WechatCardState).filter(
WechatCardState.order_id == order_id,
).first()
if ext and ext.ext_data:
return ext.ext_data.get("response_code", "")
if state and state.response_code:
return state.response_code
finally:
db.close()
except Exception as e:
logger.warning(f"查询 response_code 失败: {e}")
return None
def get_alarm_id_for_order(self, order_id: str) -> str:
"""从 wechat_card_state 查 order_id → alarm_id"""
try:
from app.models import get_session, WechatCardState
db = get_session()
try:
state = db.query(WechatCardState).filter(
WechatCardState.order_id == order_id,
).first()
return state.alarm_id or "" if state else ""
finally:
db.close()
except Exception as e:
logger.warning(f"查询 alarm_id 失败: order={order_id}, error={e}")
return ""
def get_order_id_for_alarm(self, alarm_id: str) -> str:
"""从 wechat_card_state 查 alarm_id → order_id"""
try:
from app.models import get_session, WechatCardState
db = get_session()
try:
state = db.query(WechatCardState).filter(
WechatCardState.alarm_id == alarm_id,
).first()
return state.order_id or "" if state else ""
finally:
db.close()
except Exception as e:
logger.warning(f"查询 order_id 失败: alarm={alarm_id}, error={e}")
return ""
# ==================== 媒体上传 ====================
async def upload_media(self, image_data: bytes, filename: str = "alarm.jpg") -> Optional[str]:
@@ -231,13 +263,14 @@ class WeChatService:
async def send_alarm_card(
self,
user_ids: List[str],
alarm_id: str,
order_id: str,
alarm_type: str,
area_name: str,
camera_name: str,
description: str,
event_time: str,
alarm_level: int = 2,
alarm_id: str = "",
) -> bool:
"""
发送按钮交互型模板卡片(个人消息)
@@ -261,7 +294,7 @@ class WeChatService:
"agentid": self.agent_id_int,
"template_card": {
"card_type": "button_interaction",
"task_id": alarm_id,
"task_id": order_id,
"source": {
"desc": "AI安防告警",
"desc_color": 3 if alarm_level <= 1 else 0,
@@ -279,18 +312,18 @@ class WeChatService:
],
"card_action": {
"type": 1,
"url": self._alarm_detail_url(alarm_id) or "https://work.weixin.qq.com",
"url": self._alarm_detail_url(alarm_id or order_id) or "https://work.weixin.qq.com",
},
"button_list": [
{
"text": "确认接单",
"style": 1,
"key": f"confirm_{alarm_id}",
"key": f"confirm_{order_id}",
},
{
"text": "误报忽略",
"style": 2,
"key": f"ignore_{alarm_id}",
"key": f"ignore_{order_id}",
},
],
},
@@ -307,9 +340,9 @@ class WeChatService:
response_code = data.get("response_code", "")
if response_code:
self.save_response_code(alarm_id, response_code)
self.save_response_code(order_id, response_code, alarm_id=alarm_id)
logger.info(f"企微卡片已发送: alarm={alarm_id}, users={user_ids}")
logger.info(f"企微卡片已发送: order={order_id}, alarm={alarm_id}, users={user_ids}")
return True
except Exception as e:
@@ -320,7 +353,7 @@ class WeChatService:
self,
response_code: str,
user_ids: List[str],
alarm_id: str,
order_id: str,
operator_name: str = "",
) -> bool:
"""
@@ -341,7 +374,7 @@ class WeChatService:
"response_code": response_code,
"template_card": {
"card_type": "button_interaction",
"task_id": alarm_id,
"task_id": order_id,
"source": {
"desc": "AI安防工单 - 处理中",
"desc_color": 1,
@@ -352,18 +385,18 @@ class WeChatService:
"sub_title_text": "请在对话框中回复处理结果,或点击按钮快速操作",
"card_action": {
"type": 1,
"url": self._alarm_detail_url(alarm_id) or "https://work.weixin.qq.com",
"url": self._alarm_detail_url(order_id) or "https://work.weixin.qq.com",
},
"button_list": [
{
"text": "已处理完成",
"style": 1,
"key": f"complete_{alarm_id}",
"key": f"complete_{order_id}",
},
{
"text": "标记误报",
"style": 2,
"key": f"false_{alarm_id}",
"key": f"false_{order_id}",
},
],
},
@@ -378,7 +411,7 @@ class WeChatService:
logger.error(f"更新卡片到步骤2失败: {data}")
return False
logger.info(f"卡片已更新到步骤2: alarm={alarm_id}, operator={operator_name}")
logger.info(f"卡片已更新到步骤2: order={order_id}, operator={operator_name}")
return True
except Exception as e:
@@ -389,7 +422,7 @@ class WeChatService:
self,
response_code: str,
user_ids: List[str],
alarm_id: str,
order_id: str,
action: str,
operator_name: str = "",
) -> bool:
@@ -435,7 +468,7 @@ class WeChatService:
logger.error(f"更新卡片终态失败: {data}")
return False
logger.info(f"卡片已更新到终态: alarm={alarm_id}, action={action}")
logger.info(f"卡片已更新到终态: order={order_id}, action={action}")
return True
except Exception as e: